UI-Redesign: AegisSight Design, Filter-Popover, Header-Umbau
- Session-Timeout auf 60 Minuten erhöht (ACCESS_TOKEN_EXPIRY + SESSION_TIMEOUT) - AegisSight Light Theme: Gold-Akzent (#C8A851) statt Indigo - Navigation-Tabs in eigene Zeile unter Header verschoben (HTML-Struktur) - Filter-Bar durch kompaktes Popover mit Checkboxen ersetzt (Mehrfachauswahl) - Archiv-Funktion repariert (lädt jetzt per API statt leerem Store) - Filter-Bugs behoben: Reset-Button ID, Default-Werte, Ohne-Datum-Filter - Mehrspalten-Layout Feature entfernt - Online-Status vom Header an User-Avatar verschoben (grüner Punkt) - Lupen-Icon entfernt - CLAUDE.md: Docker-Deploy und CSS-Tricks Regeln aktualisiert Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Dieser Commit ist enthalten in:
503
CHANGELOG.txt
503
CHANGELOG.txt
@@ -1,6 +1,509 @@
|
|||||||
TASKMATE - CHANGELOG
|
TASKMATE - CHANGELOG
|
||||||
====================
|
====================
|
||||||
|
|
||||||
|
================================================================================
|
||||||
|
19.03.2026 - v390 - Filter-Buttons in die View-Tabs-Zeile verschoben
|
||||||
|
================================================================================
|
||||||
|
- Filter- und Archiv-Buttons aus separater filter-bar in die view-tabs-bar verschoben (rechts)
|
||||||
|
- Neue CSS-Klasse filter-bar-actions statt filter-bar/filter-bar-left
|
||||||
|
- Separate filter-bar Zeile entfernt (eine Zeile weniger im Layout)
|
||||||
|
- filter-popover bleibt in der view-tabs-bar (absolut positioniert)
|
||||||
|
- Responsive: filter-bar-actions wird bei 768px ausgeblendet
|
||||||
|
- board.css: view-tabs-bar auf space-between geaendert
|
||||||
|
- Cache-Version: 390
|
||||||
|
|
||||||
|
================================================================================
|
||||||
|
19.03.2026 - v389 - View-Tabs in eigene Zeile unter dem Header verschoben
|
||||||
|
================================================================================
|
||||||
|
- HTML: div.header-center entfernt, nav.view-tabs-bar nach dem Header eingefuegt
|
||||||
|
- Tabs sind jetzt ein eigenstaendiges Element unter dem Header (sticky)
|
||||||
|
- board.css: .header-center durch .view-tabs-bar ersetzt, flex-wrap entfernt
|
||||||
|
- board.css: overflow-x/scrollbar-hide von .view-tabs entfernt
|
||||||
|
- responsive.css: .header-center Regeln durch .view-tabs-bar ersetzt
|
||||||
|
- responsive.css: view-tabs-bar wird auf Mobile versteckt (Mobile Menu uebernimmt)
|
||||||
|
- Cache-Version auf 389 erhoeht
|
||||||
|
|
||||||
|
================================================================================
|
||||||
|
19.03.2026 - v387 - Connection-Status durch Avatar-Online-Punkt ersetzt
|
||||||
|
================================================================================
|
||||||
|
- Connection-Status Indikator (div#connection-status) aus dem Header entfernt
|
||||||
|
- Gruener Online-Punkt als ::after Pseudo-Element am User-Avatar hinzugefuegt
|
||||||
|
- Punkt wird rot bei Verbindungsverlust (CSS-Klasse .offline auf body)
|
||||||
|
- sync.js: Store-Subscriber fuer syncStatus setzt .offline Klasse auf body
|
||||||
|
- Cache-Version: 386 -> 387
|
||||||
|
|
||||||
|
================================================================================
|
||||||
|
19.03.2026 - v386 - Lupen-Icon aus Suchfeld entfernt
|
||||||
|
================================================================================
|
||||||
|
- SVG-Element mit class "search-icon" aus dem Header entfernt
|
||||||
|
- Suchfeld (search-input) und search-container bleiben erhalten
|
||||||
|
- Cache-Version: 385 -> 386
|
||||||
|
|
||||||
|
================================================================================
|
||||||
|
19.03.2026 - v385 - Filter-Checkboxen statt Dropdowns
|
||||||
|
- Filter-Popover oeffnet sich jetzt rechts statt links
|
||||||
|
- Filter-Bar Buttons rechtsbuendig ausgerichtet (justify-content: flex-end)
|
||||||
|
- Alle Select-Dropdowns durch Checkbox-Listen ersetzt (Mehrfachauswahl moeglich)
|
||||||
|
- Prioritaet: Hoch/Mittel/Niedrig als Checkboxen
|
||||||
|
- Faelligkeit: Ueberfaellig/Heute/Diese Woche/Ohne Datum als Checkboxen
|
||||||
|
- Bearbeiter und Labels werden dynamisch als Checkboxen befuellt
|
||||||
|
- filterTasks in utils.js unterstuetzt jetzt Array-basierte Filter
|
||||||
|
- Checkbox-Design im AegisSight-Stil mit Custom-Checkmark
|
||||||
|
- Geaenderte Dateien: index.html, board.css, app.js, utils.js, sw.js
|
||||||
|
|
||||||
|
================================================================================
|
||||||
|
19.03.2026 - v384 - Filter-Bar kompakt mit Popover
|
||||||
|
- Filter-Bar umgebaut: statt 4 Dropdowns in einer Zeile jetzt kompakter Filter-Button + Archiv-Button
|
||||||
|
- Filter oeffnet sich als Popover-Dropdown mit allen 4 Filter-Optionen
|
||||||
|
- Filter-Button zeigt Anzahl aktiver Filter an ("Filter (2)")
|
||||||
|
- Aktive Filter werden visuell hervorgehoben (Primary-Farbe)
|
||||||
|
- Klick ausserhalb des Popovers schliesst es automatisch
|
||||||
|
- Geaenderte Dateien: index.html, app.js, board.css, sw.js
|
||||||
|
|
||||||
|
================================================================================
|
||||||
|
19.03.2026 - v383 - Filter-System Bugfixes (3 Bugs)
|
||||||
|
- Bug 1: Reset-Button funktionierte nicht (ID-Mismatch btn-reset-filters vs btn-clear-filters)
|
||||||
|
- Bug 2: Default-Werte konsistent auf leeren String umgestellt (HTML, Store, Filter-Logik)
|
||||||
|
- Bug 3: "Ohne Datum" Filter (value=none) zeigt jetzt nur Tasks ohne Faelligkeitsdatum
|
||||||
|
- Geaenderte Dateien: app.js, store.js, utils.js, sw.js
|
||||||
|
|
||||||
|
================================================================================
|
||||||
|
19.03.2026 - v382 - AegisSight Light Theme implementiert
|
||||||
|
- CSS-Variablen in variables.css auf AegisSight-Farbpalette umgestellt
|
||||||
|
- Primary/Accent: Indigo (#4F46E5) ersetzt durch Gold (#C8A851)
|
||||||
|
- Hintergruende angepasst (bg-main, bg-tertiary, bg-hover, bg-active)
|
||||||
|
- Info-Farben auf gedaempftes Blaugrau (#7C8DB5) geaendert
|
||||||
|
- Scrollbar-Farben und Focus-Schatten aktualisiert
|
||||||
|
- Hardcodierte alte Farbreferenzen in 5 CSS-Dateien ersetzt:
|
||||||
|
gitea.css, knowledge.css, board.css, coding.css, reminders.css
|
||||||
|
- Status-Farben (success, warning, error) und Prioritaetsfarben unveraendert
|
||||||
|
================================================================================
|
||||||
|
19.03.2026 - v381 - Mehrspalten-Layout Feature komplett entfernt
|
||||||
|
- Button #btn-toggle-layout aus index.html entfernt
|
||||||
|
- Alle Layout-Methoden aus board.js entfernt (loadLayoutPreference, saveLayoutPreference, toggleLayout, applyLayoutClass, checkAndApplyDynamicLayout)
|
||||||
|
- Multi-Column CSS-Regeln aus board.css entfernt
|
||||||
|
- Resize-Event-Listener und setTimeout-Aufrufe fuer Layout entfernt
|
||||||
|
================================================================================
|
||||||
|
19.03.2026 - v380 - Archiv-Modal laedt archivierte Tasks jetzt per API
|
||||||
|
- Problem: Archiv-Modal war immer leer, weil archivierte Tasks nie vom Backend geladen wurden
|
||||||
|
- Ursache: openArchiveModal nutzte nur store.get("tasks").filter(t => t.archived), aber der Store enthielt nur aktive Tasks (Backend filtert mit archived=0)
|
||||||
|
- Loesung: openArchiveModal ruft jetzt api.getTasks(projectId, { archived: true }) auf und laedt archivierte Tasks per API
|
||||||
|
- renderArchiveList bekommt Tasks als Parameter statt nochmal den Store zu filtern
|
||||||
|
- restoreTask und deleteArchivedTask laden die Archivliste nach Aktion neu vom Server
|
||||||
|
- Ladeindikator waehrend API-Anfrage
|
||||||
|
- Geaenderte Dateien: frontend/js/app.js, frontend/sw.js (v380)
|
||||||
|
|
||||||
|
================================================================================
|
||||||
|
19.03.2026 - v379 - Header-Tabs immer in eigener zweiter Zeile
|
||||||
|
- Problem: .header-center (Tabs) rutschte bei breitem Fenster neben Avatar in Zeile 1
|
||||||
|
- Ursache: width:100% + flex-basis:100% reicht nicht, Flex-Container bricht nicht um wenn genug Platz
|
||||||
|
- Loesung: flex: 0 0 100% (nicht wachsen, nicht schrumpfen, Basis 100%) erzwingt Umbruch zuverlaessig
|
||||||
|
|
||||||
|
================================================================================
|
||||||
|
19.03.2026 - v378 - Search-Container Layout-Fix im Header
|
||||||
|
- Problem: width:100% im .search-container drueckte die Lupe aus header-right heraus
|
||||||
|
- Loesung: width:100% ersetzt durch flex:1 1 auto + min-width:150px
|
||||||
|
- Container nimmt jetzt nur verfuegbaren Platz ein, dehnt sich nicht ueber header-right hinaus
|
||||||
|
|
||||||
|
================================================================================
|
||||||
|
19.03.2026 - v376 - Navigation-Tabs in eigene Header-Zeile verschoben
|
||||||
|
================================================================================
|
||||||
|
- Navigation-Tabs werden jetzt in einer separaten zweiten Zeile im Header angezeigt
|
||||||
|
- Zeile 1: Logo + Projekt + Suche + Online + Timer + Avatar
|
||||||
|
- Zeile 2: Tabs (volle Breite, zentriert)
|
||||||
|
- Zeile 3: Filter-Bar (unveraendert)
|
||||||
|
- Nur CSS-Aenderungen in board.css (kein HTML geaendert)
|
||||||
|
- Geaenderte Dateien: frontend/css/board.css, frontend/sw.js
|
||||||
|
|
||||||
|
================================================================================
|
||||||
|
31.01.2026 - v374 - Mobile Board komplett ueberarbeitet
|
||||||
|
================================================================================
|
||||||
|
|
||||||
|
## VERBESSERUNG
|
||||||
|
|
||||||
|
### Board auf Smartphone deutlich groesser und besser lesbar
|
||||||
|
- Task-Titel: 14px -> 16px (bzw. 18px auf kleinen Screens)
|
||||||
|
- Task-Cards: mehr Padding, groessere Labels
|
||||||
|
- Task-Meta-Infos: 12px -> 14px, groessere Icons
|
||||||
|
- Priority-Sterne: 12px -> 16px
|
||||||
|
- Assignee-Avatare: 24px -> 32px
|
||||||
|
|
||||||
|
### Filter & Stats-Bar optimiert
|
||||||
|
- Filter-Selects: groesser (48px Hoehe), volle Breite auf kleinen Screens
|
||||||
|
- Stats-Bar: groessere Icons (40px), groessere Zahlen
|
||||||
|
- Horizontales Scrollen fuer Stats wenn noetig
|
||||||
|
|
||||||
|
### Week-Strip groesser
|
||||||
|
- Navigation-Buttons: 28px -> 40px
|
||||||
|
- Tages-Nummern: 14px -> 18px
|
||||||
|
|
||||||
|
### Spalten-Header groesser
|
||||||
|
- Titel: 14px -> 16px (18px auf kleinen Screens)
|
||||||
|
- Task-Count Badge: 24px -> 28px
|
||||||
|
- Mehr Padding
|
||||||
|
|
||||||
|
### Add-Task-Button groesser
|
||||||
|
- Min-Hoehe: 52px (56px auf kleinen Screens)
|
||||||
|
- Schrift: 14px -> 16px (18px auf kleinen Screens)
|
||||||
|
|
||||||
|
### Modal-Optimierungen
|
||||||
|
- Fullscreen auf sehr kleinen Screens (< 480px)
|
||||||
|
- Groessere Form-Labels
|
||||||
|
- Mehr Padding
|
||||||
|
|
||||||
|
### Geaenderte Dateien
|
||||||
|
- frontend/css/mobile.css - Umfangreiche Board-Optimierungen
|
||||||
|
|
||||||
|
================================================================================
|
||||||
|
31.01.2026 - v373 - Mobile UX Optimierung
|
||||||
|
================================================================================
|
||||||
|
|
||||||
|
## VERBESSERUNG
|
||||||
|
|
||||||
|
### Mobile Touch & iOS Kompatibilitaet
|
||||||
|
- Input-Schriftgroesse auf 16px erhoeht (verhindert iOS Zoom bei Focus)
|
||||||
|
- Modal Close Button auf 44px Touch Target vergroessert
|
||||||
|
- Dropdown Items mit min-height 44px fuer bessere Touch-Bedienung
|
||||||
|
- Safe Area Support fuer Mobile Column Indicator (iOS Notch/Home Bar)
|
||||||
|
- Safe Area Support fuer Toast Container auf Mobile
|
||||||
|
- Safe Area Support fuer Lightbox Close Button
|
||||||
|
|
||||||
|
### Geaenderte Dateien
|
||||||
|
- frontend/css/components.css - Input font-size, Dropdown min-height
|
||||||
|
- frontend/css/modal.css - Modal close 44px, Lightbox safe-area
|
||||||
|
- frontend/css/mobile.css - Column indicator safe-area
|
||||||
|
- frontend/css/responsive.css - Toast container safe-area
|
||||||
|
|
||||||
|
================================================================================
|
||||||
|
24.01.2026 - v372 - UI: Navigation-Tabs vertikal und zentriert
|
||||||
|
================================================================================
|
||||||
|
|
||||||
|
## VERBESSERUNG
|
||||||
|
|
||||||
|
### Vertikale Navigation-Tabs
|
||||||
|
- Tabs sind jetzt immer vertikal: Icon oben, Text darunter
|
||||||
|
- Kompakteres Layout, alle 7 Tabs passen besser nebeneinander
|
||||||
|
- Tabs bleiben bei allen Bildschirmbreiten zentriert (nicht nach rechts)
|
||||||
|
|
||||||
|
### Geaenderte Dateien
|
||||||
|
- frontend/css/board.css - Basis-Styles fuer .view-tab
|
||||||
|
- frontend/css/responsive.css - justify-content: center hinzugefuegt
|
||||||
|
|
||||||
|
================================================================================
|
||||||
|
24.01.2026 - v369 - Bugfix: Spalte loeschen mit archivierten Aufgaben
|
||||||
|
================================================================================
|
||||||
|
|
||||||
|
## BUG BEHOBEN
|
||||||
|
|
||||||
|
### Spalte loeschen funktioniert jetzt korrekt
|
||||||
|
- Problem: Spalten mit archivierten Aufgaben konnten nicht geloescht werden
|
||||||
|
- Ursache: Frontend pruefte nur aktive Tasks, Backend zaehlte alle (inkl. archivierte)
|
||||||
|
- Spalte wanderte nach rechts statt geloescht zu werden
|
||||||
|
- Loesung: Backend prueft jetzt nur aktive (nicht-archivierte) Tasks
|
||||||
|
|
||||||
|
### Archivierte Aufgaben bleiben erhalten
|
||||||
|
- Archivierte Aufgaben werden NICHT geloescht wenn Spalte geloescht wird
|
||||||
|
- Stattdessen: column_id wird auf NULL gesetzt
|
||||||
|
- Bei Wiederherstellung: Spaltenauswahl-Dialog erscheint
|
||||||
|
- Benutzer waehlt neue Spalte fuer die wiederhergestellte Aufgabe
|
||||||
|
|
||||||
|
### Neues Modal: Spaltenauswahl
|
||||||
|
- Neues Modal "column-select-modal" in index.html
|
||||||
|
- Event-Handler "column-select:show" in app.js
|
||||||
|
- Wird verwendet bei Wiederherstellung von Aufgaben ohne gueltige Spalte
|
||||||
|
|
||||||
|
================================================================================
|
||||||
|
23.01.2026 - v368 - Kontakte & Board: UI-Verbesserungen
|
||||||
|
================================================================================
|
||||||
|
|
||||||
|
## KONTAKTE-MODUL
|
||||||
|
|
||||||
|
### Filter-Buttons
|
||||||
|
- Text durch Icons ersetzt (Personen-Icon / Gebaeude-Icon)
|
||||||
|
- Inline-SVGs fuer bessere Kompatibilitaet
|
||||||
|
|
||||||
|
### Icons repariert
|
||||||
|
- Alle xlink:href Referenzen durch Inline-SVGs ersetzt
|
||||||
|
- Betroffene Icons: edit, trash, arrow-left, plus, x, mail, phone, users
|
||||||
|
- Kontaktdetails und Interaktionen nutzen jetzt Inline-SVGs
|
||||||
|
|
||||||
|
### Avatar-Darstellung
|
||||||
|
- Typ-Badges (Person/Institution) komplett entfernt
|
||||||
|
- Stattdessen Icons im Avatar-Kreis (Personen-Icon / Gebaeude-Icon)
|
||||||
|
- Weisse Icons auf farbigem Hintergrund
|
||||||
|
|
||||||
|
### Caching-Problem behoben
|
||||||
|
- Cache-Control Headers im Backend hinzugefuegt
|
||||||
|
- Kontakte werden jetzt immer frisch geladen (kein 304 Not Modified)
|
||||||
|
|
||||||
|
### Filter-Zaehlung korrigiert
|
||||||
|
- Von Server-seitiger zu Client-seitiger Filterung gewechselt
|
||||||
|
- Alle Kontakte werden einmal geladen, dann lokal gefiltert
|
||||||
|
- Zaehlung zeigt jetzt korrekte Werte (z.B. 7+5 statt 5+4)
|
||||||
|
|
||||||
|
### Neue Kontakte hinzugefuegt
|
||||||
|
- Barbara Banczyk (LfM)
|
||||||
|
- Torsten Giebel (LfM)
|
||||||
|
- Anna Heinzmann (LKA BW)
|
||||||
|
- Stephanie Kaschka (LKA HH)
|
||||||
|
- Kilian Bieker (ohne Institution)
|
||||||
|
- Marc Restemeyer (LAFP NRW)
|
||||||
|
|
||||||
|
## BOARD-MODUL
|
||||||
|
|
||||||
|
### Spalten-Buttons verbessert
|
||||||
|
- Edit/Delete-Buttons von absoluter zu Flexbox-Positionierung
|
||||||
|
- Automatische vertikale Zentrierung
|
||||||
|
- Keine Ueberlappung mehr mit Spaltentitel
|
||||||
|
- Lange Titel werden mit "..." abgeschnitten
|
||||||
|
|
||||||
|
## TECHNISCH
|
||||||
|
- Service Worker Cache-Version erhoeht auf v368
|
||||||
|
|
||||||
|
================================================================================
|
||||||
|
23.01.2026 - v362 - Institution: Felder entfernt
|
||||||
|
================================================================================
|
||||||
|
|
||||||
|
## ENTFERNT
|
||||||
|
- Mitarbeiteranzahl (employee_count) aus Institution entfernt
|
||||||
|
- Gruendungsjahr (founded_year) aus Institution entfernt
|
||||||
|
- Steuernummer (tax_number) aus Institution entfernt
|
||||||
|
- Spalten aus Datenbank entfernt
|
||||||
|
- Service Worker Cache-Version erhoeht auf v362
|
||||||
|
|
||||||
|
================================================================================
|
||||||
|
23.01.2026 - v361 - Kontakte: Umbenennung Organisation zu Institution
|
||||||
|
================================================================================
|
||||||
|
|
||||||
|
## GEAENDERT
|
||||||
|
- "Organisation" ueberall zu "Institution" umbenannt
|
||||||
|
- Service Worker Cache-Version erhoeht auf v361
|
||||||
|
|
||||||
|
================================================================================
|
||||||
|
23.01.2026 - v360 - Kontakte: Zwei Buttons und Umbenennung zu Organisation
|
||||||
|
================================================================================
|
||||||
|
|
||||||
|
## GEAENDERT
|
||||||
|
- Zwei separate Buttons: "Neuer Kontakt" (Person) und "Neue Organisation"
|
||||||
|
- "Unternehmen" ueberall zu "Organisation" umbenannt
|
||||||
|
- Filter-Tab "Unternehmen" -> "Organisationen"
|
||||||
|
- Formular-Titel passt sich dem Typ an
|
||||||
|
- Service Worker Cache-Version erhoeht auf v360
|
||||||
|
|
||||||
|
================================================================================
|
||||||
|
22.01.2026 - v359 - Geburtstag-Feld aus Kontakten entfernt
|
||||||
|
================================================================================
|
||||||
|
|
||||||
|
## ENTFERNT
|
||||||
|
- Geburtstag-Feld aus dem Kontakt-Formular (Person) entfernt
|
||||||
|
- Geburtstag-Anzeige aus der Kontakt-Detailansicht entfernt
|
||||||
|
- birth_date Spalte aus der Datenbank entfernt
|
||||||
|
- birth_date aus Backend-Routes (contacts.js, contacts-extended.js) entfernt
|
||||||
|
- birth_date aus Migrations-Dateien entfernt
|
||||||
|
- Service Worker Cache-Version erhoeht auf v359
|
||||||
|
|
||||||
|
================================================================================
|
||||||
|
21.01.2026 - v346 - BUGFIX: Kontakte Event-Binding repariert
|
||||||
|
================================================================================
|
||||||
|
|
||||||
|
## FEHLER BEHOBEN
|
||||||
|
- "Neuer Kontakt" Button: Event-Binding nach Modal-Schließen repariert
|
||||||
|
- Modal-Schließen optimiert: Kein komplettes Re-Rendering des Layouts mehr
|
||||||
|
- Robusteres Event-Binding mit Console-Logging für Debugging
|
||||||
|
- Service Worker Cache-Version erhöht auf v346
|
||||||
|
|
||||||
|
================================================================================
|
||||||
|
21.01.2026 - BUGFIX: Kontakte Modal-Overlay Fehler behoben
|
||||||
|
================================================================================
|
||||||
|
|
||||||
|
## FEHLER BEHOBEN
|
||||||
|
- Kontakte-Formular zeigte "undefined" beim Anlegen neuer Kontakte
|
||||||
|
- Modal-Overlay fehlte beim Kontakt-Formular
|
||||||
|
- Form-Container hatte keine korrekte Modal-Struktur
|
||||||
|
- "Neuer Kontakt" Button reagierte nicht - Event-Handling verbessert
|
||||||
|
- SVG-Icon durch Text-Symbol ersetzt für bessere Kompatibilität
|
||||||
|
- Modal-CSS-Klasse korrigiert: "visible" statt "show" für korrekte Anzeige
|
||||||
|
- Z-Index erhöht, um sicherzustellen dass Modal über allem liegt
|
||||||
|
- Modal-Struktur korrigiert: Overlay und Modal sind jetzt separate Elemente
|
||||||
|
- Form-Submit Problem behoben: Formular wurde als GET statt POST abgeschickt
|
||||||
|
- Event-Binding direkt auf Submit-Button statt Form-Submit für bessere Kontrolle
|
||||||
|
- Validierung für Pflichtfelder (Nachname/Firmenname) hinzugefügt
|
||||||
|
- Display-Name wird jetzt korrekt im Frontend generiert
|
||||||
|
- Nach Kontakt-Erstellung werden vollständige Details geladen
|
||||||
|
- Event-Delegation für Form-Buttons implementiert
|
||||||
|
- API-Request Syntax korrigiert (object statt separate Parameter)
|
||||||
|
- Kontakt-Löschung funktioniert jetzt korrekt
|
||||||
|
- Interaktionen-API ebenfalls korrigiert
|
||||||
|
- Filter-Sidebar entfernt und in Header-Leiste integriert
|
||||||
|
- Archiv/Aktiv Filter entfernt - nur noch Alle/Personen/Unternehmen
|
||||||
|
- Modernes Filter-Button-Design im Header
|
||||||
|
- Responsive Layout für mobile Geräte
|
||||||
|
- Service Worker Cache-Version erhöht auf v358
|
||||||
|
|
||||||
|
================================================================================
|
||||||
|
20.01.2026 - UPDATE: Kategoriesystem aus Kontaktmanagement entfernt
|
||||||
|
================================================================================
|
||||||
|
|
||||||
|
## ÄNDERUNGEN
|
||||||
|
- Kategoriesystem komplett entfernt (Backend und Frontend)
|
||||||
|
- Vereinfachtes Kontaktmanagement ohne Kategorie-Verknüpfungen
|
||||||
|
- Saubere UI ohne Kategorie-Filter und -Zuweisungen
|
||||||
|
|
||||||
|
================================================================================
|
||||||
|
20.01.2026 - FEATURE: Kontaktmanagement komplett neu implementiert
|
||||||
|
================================================================================
|
||||||
|
|
||||||
|
## NEUE FUNKTIONEN
|
||||||
|
- Modernes Kontaktmanagement-Modul (neu entwickelt)
|
||||||
|
- Unterstützung für Personen und Unternehmen
|
||||||
|
- Mehrere Kontaktdetails pro Person (E-Mail, Telefon, Adresse)
|
||||||
|
- Verknüpfung von Personen mit Unternehmen
|
||||||
|
- Interaktions-Historie (Anrufe, E-Mails, Meetings, Notizen)
|
||||||
|
- Avatar-Upload für Kontakte
|
||||||
|
|
||||||
|
## UI/UX
|
||||||
|
- Moderne Card-basierte Listenansicht mit alphabetischer Gruppierung
|
||||||
|
- Sidebar mit Filtern (Alle, Personen, Unternehmen, Aktiv, Archiviert)
|
||||||
|
- Detailansicht mit allen Kontaktinformationen
|
||||||
|
- Formular mit Type-Toggle (Person/Unternehmen)
|
||||||
|
- Dynamische Formularfelder je nach Kontakttyp
|
||||||
|
- Integration in die globale Suche
|
||||||
|
|
||||||
|
## DATENBANK
|
||||||
|
- Neue optimierte Tabellenstruktur:
|
||||||
|
- contacts: Haupttabelle für Personen und Unternehmen
|
||||||
|
- contact_details: Mehrere E-Mails, Telefone etc. pro Kontakt
|
||||||
|
- contact_categories: Kategorien zur Organisation
|
||||||
|
- contact_interactions: Interaktions-Historie
|
||||||
|
- Trigger für automatisches updated_at
|
||||||
|
- Performance-Indizes für schnelle Abfragen
|
||||||
|
|
||||||
|
## TECHNISCHE DETAILS
|
||||||
|
- Backend: Vollständige REST API in /api/contacts
|
||||||
|
- Frontend: ES6 Modul contacts.js (ersetzt contacts-basic.js)
|
||||||
|
- CSS: Komplett neues contacts.css mit responsivem Design
|
||||||
|
- Cache-Version auf 326 erhöht
|
||||||
|
|
||||||
|
================================================================================
|
||||||
|
19.01.2025 - BUGFIX: User-Dropdown Z-Index korrigiert
|
||||||
|
================================================================================
|
||||||
|
|
||||||
|
## FEHLERBEHEBUNG
|
||||||
|
- User-Dropdown erscheint jetzt korrekt über anderen Elementen
|
||||||
|
- Z-Index von 100 auf 300 erhöht (über sticky header mit z-index 200)
|
||||||
|
- Cache-Version auf 315 erhöht
|
||||||
|
|
||||||
|
## TECHNISCHE DETAILS
|
||||||
|
- Datei: frontend/css/variables.css
|
||||||
|
- Geändert: --z-dropdown: 300 (vorher 100)
|
||||||
|
|
||||||
|
================================================================================
|
||||||
|
19.01.2025 - FEATURE: Wissen-Modul elegant überarbeitet
|
||||||
|
================================================================================
|
||||||
|
|
||||||
|
## NEUE FUNKTIONEN
|
||||||
|
- Eleganter Ansichtstyp-Umschalter im Header (Liste/Karten/Kompakt)
|
||||||
|
- Template-Auswahl direkt im Entry-Modal als Tabs:
|
||||||
|
- Allgemein: Flexibler Eintrag mit allen Feldern
|
||||||
|
- Link: Optimiert für Weblinks (URL-Feld erforderlich)
|
||||||
|
- Dokument: Fokus auf Datei-Anhänge (URL ausgeblendet)
|
||||||
|
- Notiz: Erweiterte Notizen (URL ausgeblendet, größeres Textfeld)
|
||||||
|
- Verbessertes Karten-Design mit sauberen SVG-Icons statt Emojis
|
||||||
|
|
||||||
|
## UI/UX VERBESSERUNGEN
|
||||||
|
- View-Switcher nahtlos in Header integriert (TaskMate-Stil)
|
||||||
|
- Template-Tabs ändern dynamisch die Formular-Felder
|
||||||
|
- Karten zeigen URL-Domain statt voller URL
|
||||||
|
- Notizen-Vorschau in Karten auf 150 Zeichen
|
||||||
|
- View-Präferenz wird gespeichert
|
||||||
|
- Mobile: View-Switcher ausgeblendet, automatisch Karten
|
||||||
|
|
||||||
|
## TECHNISCHE DETAILS
|
||||||
|
- Template-System ohne separates Modal implementiert
|
||||||
|
- Dynamische Feld-Sichtbarkeit basierend auf Template-Typ
|
||||||
|
- Kompakte View-Switcher Komponente
|
||||||
|
- SVG-Icons für bessere Performance und Konsistenz
|
||||||
|
|
||||||
|
## BUGFIXES
|
||||||
|
✅ Sperrige UI-Elemente entfernt und durch elegante Lösungen ersetzt
|
||||||
|
✅ Template-Flow intuitiver gestaltet
|
||||||
|
✅ Visuelles Design konsistent mit TaskMate-Stil
|
||||||
|
|
||||||
|
================================================================================
|
||||||
|
17.01.2026 - FEATURE: Responsive Design-Verbesserungen
|
||||||
|
================================================================================
|
||||||
|
|
||||||
|
## NEUE FUNKTIONEN
|
||||||
|
- Anwendung passt sich automatisch an Browsergröße an
|
||||||
|
- Kein horizontales Scrollen mehr nötig
|
||||||
|
- Navigation zeigt bei mittleren Bildschirmen (1200px) nur Icons
|
||||||
|
- Tooltips beim Hover über Navigations-Icons
|
||||||
|
|
||||||
|
## IMPLEMENTIERUNG
|
||||||
|
✅ Globale responsive CSS-Regeln hinzugefügt (overflow-x: hidden)
|
||||||
|
✅ Navigation-Tabs zeigen ab 1200px nur noch Icons
|
||||||
|
✅ data-tooltip Attribute für alle View-Tabs hinzugefügt
|
||||||
|
✅ Header-Elemente optimiert für verschiedene Bildschirmgrößen
|
||||||
|
✅ Project Selector und Search Container werden bei kleineren Bildschirmen schmaler
|
||||||
|
✅ View-Tabs sind bei Bedarf horizontal scrollbar (ohne sichtbare Scrollbar)
|
||||||
|
|
||||||
|
## TECHNISCHE DETAILS
|
||||||
|
- Neue Media Query für max-width: 1200px
|
||||||
|
- Touch-optimiertes horizontales Scrollen für View-Tabs
|
||||||
|
- Minimale Touch-Target-Größe von 44px beibehalten
|
||||||
|
- Box-sizing: border-box global angewendet
|
||||||
|
|
||||||
|
## BUGFIXES
|
||||||
|
✅ Elemente verschwinden nicht mehr bei kleinen Bildschirmen
|
||||||
|
✅ Horizontales Scrollen komplett eliminiert
|
||||||
|
✅ Search Input hat keine feste Breite mehr (max-width statt width)
|
||||||
|
✅ Project Select nutzt responsive Breiten
|
||||||
|
✅ Board und Columns nutzen flexible Breiten
|
||||||
|
✅ Header-Layout für kleine Bildschirme optimiert (keine Wrapping-Probleme mehr)
|
||||||
|
✅ Mobile Board-Columns nutzen 100% Breite statt calc(100vw - 32px)
|
||||||
|
|
||||||
|
================================================================================
|
||||||
|
11.01.2025 - BUGFIX: Mobile Ansicht für Liste und Kalender repariert
|
||||||
|
================================================================================
|
||||||
|
|
||||||
|
## PROBLEM
|
||||||
|
Liste und Kalender wurden in der mobilen Ansicht nicht angezeigt
|
||||||
|
|
||||||
|
## LÖSUNG
|
||||||
|
✅ CSS-Konflikt zwischen hidden-Klasse und active-Display behoben
|
||||||
|
✅ Mobile-spezifische Responsive-Anpassungen für beide Views
|
||||||
|
✅ Verbesserte Touch-Scrolling für Tabellen in der Listenansicht
|
||||||
|
✅ Optimierte Kalender-Darstellung auf kleinen Bildschirmen
|
||||||
|
|
||||||
|
================================================================================
|
||||||
|
11.01.2025 - FEATURE: Verbesserte mobile Navigation mit Swipe-Gesten
|
||||||
|
================================================================================
|
||||||
|
|
||||||
|
## NEUE FUNKTIONEN
|
||||||
|
- Board-View zeigt jetzt nur eine Statuskarte auf einmal
|
||||||
|
- Swipe links/rechts wechselt zwischen den Statuskarten
|
||||||
|
- Swipe in der Header-Leiste wechselt zwischen den Views (Board, Liste, etc.)
|
||||||
|
- Visuelle Indikatoren zeigen aktuelle Spalte und verfügbare Navigationsoptionen
|
||||||
|
|
||||||
|
## IMPLEMENTIERUNG
|
||||||
|
✅ Neues mobile-swipe.js Modul für erweiterte Touch-Gesten
|
||||||
|
✅ Eine Spalte pro Bildschirm in mobiler Ansicht
|
||||||
|
✅ Spalten-Indikator mit Punkten und Spaltenname
|
||||||
|
✅ View-Hints beim Swipen in der Header-Leiste
|
||||||
|
✅ Responsive Anpassungen für optimale mobile Experience
|
||||||
|
|
||||||
|
================================================================================
|
||||||
|
11.01.2025 - BUGFIX: Wissenseinträge - Benachrichtigungen aktiviert
|
||||||
|
================================================================================
|
||||||
|
|
||||||
|
## PROBLEM
|
||||||
|
Beim Erstellen neuer Wissenseinträge erhielten andere Nutzer keine Benachrichtigung
|
||||||
|
|
||||||
|
## LÖSUNG
|
||||||
|
✅ Benachrichtigungen werden jetzt als persistent gespeichert
|
||||||
|
✅ Alle Nutzer erhalten eine Inbox-Benachrichtigung bei neuen Einträgen
|
||||||
|
✅ Klick auf Benachrichtigung navigiert direkt zum Wissenseintrag
|
||||||
|
|
||||||
================================================================================
|
================================================================================
|
||||||
11.01.2025 - FEATURE: Coding-Modul - Freie Pfad-Eingabe
|
11.01.2025 - FEATURE: Coding-Modul - Freie Pfad-Eingabe
|
||||||
================================================================================
|
================================================================================
|
||||||
|
|||||||
948
CLAUDE.md
948
CLAUDE.md
@@ -1,777 +1,227 @@
|
|||||||
# TaskMate - Entwicklerdokumentation
|
# TaskMate - Projekt-Konfiguration
|
||||||
|
# YAML-Format fuer KI-Assistenten
|
||||||
|
|
||||||
## ⚠️ WICHTIGER HINWEIS FÜR KI-ASSISTENTEN
|
PROJECT:
|
||||||
Der Anwender hat **KEINE Programmierkenntnisse**. Das bedeutet:
|
name: TaskMate
|
||||||
- **DU übernimmst ALLE technischen Aufgaben vollständig**
|
type: PRODUCTION
|
||||||
- **Erkläre in einfachen Worten**, was du tust und warum
|
url: https://taskmate.aegis-sight.de
|
||||||
- **Frage NIEMALS nach technischen Details** oder Code-Schnipseln
|
container: taskmate
|
||||||
- **Führe ALLE Schritte selbstständig aus**
|
port_external: 3001
|
||||||
- Der Anwender kann nur bestätigen/ablehnen, nicht selbst coden
|
port_internal: 3000
|
||||||
|
gitea: https://gitea-undso.aegis-sight.de/AegisSight/TaskMate
|
||||||
|
|
||||||
### Kommunikations-Regeln
|
USER_PROFILE:
|
||||||
✅ **RICHTIG**: "Ich werde jetzt die Benutzeroberfläche anpassen, damit..."
|
programming_skills: NONE
|
||||||
❌ **FALSCH**: "Kannst du mir den Code aus Zeile 42 zeigen?"
|
support_level: FULL_SERVICE
|
||||||
|
communication: SIMPLE_LANGUAGE
|
||||||
|
|
||||||
✅ **RICHTIG**: "Ich starte jetzt den Server neu. Das dauert etwa 30 Sekunden."
|
COMMUNICATION_RULES:
|
||||||
❌ **FALSCH**: "Führe bitte folgenden Befehl aus: docker restart..."
|
do:
|
||||||
|
- "Alle technischen Aufgaben vollstaendig uebernehmen"
|
||||||
|
- "In einfachen Worten erklaeren was passiert"
|
||||||
|
- "Alle Schritte selbststaendig ausfuehren"
|
||||||
|
- "Status-Updates geben: Aenderung abgeschlossen, teste jetzt..."
|
||||||
|
dont:
|
||||||
|
- "NIEMALS nach technischen Details fragen"
|
||||||
|
- "NIEMALS nach Code-Schnipseln fragen"
|
||||||
|
- "NIEMALS Befehle zum Ausfuehren geben"
|
||||||
|
- "NIEMALS nach Logs oder Console fragen"
|
||||||
|
|
||||||
## 🚀 Quick Start
|
CRITICAL_RULES:
|
||||||
|
1_cache_version: "Nach Frontend-Aenderungen: frontend/sw.js CACHE_VERSION erhoehen"
|
||||||
|
2_changelog_file: "Bei JEDER Aenderung: CHANGELOG.txt aktualisieren"
|
||||||
|
3_changelog_db: "Bei JEDER Aenderung: Wissensdatenbank Changelog aktualisieren (siehe WORKFLOWS)"
|
||||||
|
4_no_toISOString: "NIEMALS toISOString() fuer Datums-Operationen (UTC-Problem!)"
|
||||||
|
5_realtime_updates: "User darf NIE F5 druecken muessen"
|
||||||
|
6_docker_restart: "Nach Backend-Aenderungen: docker compose up -d --build (NICHT docker restart - das nutzt das alte Image!)"
|
||||||
|
7_no_css_tricks: "Layout-Aenderungen immer ueber HTML-Struktur loesen, NIEMALS mit CSS-Tricks wie flex-wrap+order, negative margins oder aehnlichem. Wenn ein Element in eine eigene Zeile soll, muss es ein eigenes HTML-Element sein."
|
||||||
|
|
||||||
### Wichtigste Befehle
|
PROTECTED_DATA:
|
||||||
```bash
|
project: "AegisSight"
|
||||||
# Docker Container neu starten (nach Backend-Änderungen)
|
description: "Produktiv im Einsatz - NIEMALS loeschen oder aendern"
|
||||||
docker restart taskmate
|
protected_users:
|
||||||
|
|
||||||
# Container neu bauen (bei Dependency-Änderungen)
|
|
||||||
docker build -t taskmate . && docker restart taskmate
|
|
||||||
|
|
||||||
# Logs anzeigen
|
|
||||||
docker logs taskmate -f
|
|
||||||
|
|
||||||
# Health Check
|
|
||||||
curl http://localhost:3000/api/health
|
|
||||||
```
|
|
||||||
|
|
||||||
### Kritische Regeln - NIEMALS VERGESSEN! ⚠️
|
|
||||||
1. **Cache-Version erhöhen** nach Frontend-Änderungen: `frontend/sw.js` → `CACHE_VERSION`
|
|
||||||
2. **CHANGELOG.txt** bei JEDER Änderung aktualisieren
|
|
||||||
3. **Keine `toISOString()`** für Datums-Operationen (UTC-Problem!)
|
|
||||||
4. **Echtzeit-Updates** - User darf NIE F5 drücken müssen
|
|
||||||
5. **Docker restart** nach Backend-Änderungen
|
|
||||||
|
|
||||||
### Datenschutz & Projektsicherheit 🔐
|
|
||||||
**ABSOLUT KRITISCH**: Das Projekt "AegisSight" ist produktiv im Einsatz!
|
|
||||||
|
|
||||||
- **Projekt "AegisSight" NIEMALS löschen, ändern oder beeinträchtigen**
|
|
||||||
- **Bestehende Benutzer NIEMALS zurücksetzen, löschen oder verändern**
|
|
||||||
- **Produktivdaten sind TABU** - keine Testdaten in echte Projekte
|
|
||||||
- **Keine Datenbank-Resets** ohne explizite Anweisung
|
|
||||||
- **JEDE Änderung MUSS umkehrbar sein** - Live-System!
|
|
||||||
- **Backup vor kritischen Änderungen** ist Pflicht
|
|
||||||
|
|
||||||
**⚠️ NUTZERDATEN-SCHUTZ - ABSOLUTES VERBOT**:
|
|
||||||
- **KEINE Änderungen an Nutzerdaten oder Kennwörtern**
|
|
||||||
- **Geschützte Benutzer (NICHT modifizieren)**:
|
|
||||||
- admin
|
- admin
|
||||||
- Hendrik (hendrik_gebhardt@gmx.de)
|
- "Hendrik (hendrik_gebhardt@gmx.de)"
|
||||||
- Monami (momohomma@googlemail.com)
|
- "Monami (momohomma@googlemail.com)"
|
||||||
- **Diese Benutzer sind produktiv im Einsatz**
|
forbidden_actions:
|
||||||
- **Keine Passwort-Resets oder Änderungen an diesen Accounts**
|
- "Nutzerdaten aendern"
|
||||||
- **Bei Anmeldeproblemen: Nur Debugging, keine Datenänderung**
|
- "Passwoerter aendern"
|
||||||
|
- "Datenbank-Resets ohne Anweisung"
|
||||||
|
- "Testdaten in Produktivdaten"
|
||||||
|
|
||||||
### Rollback-Strategie für Live-Betrieb
|
COMMANDS:
|
||||||
Bei JEDER Änderung sicherstellen:
|
restart: "docker restart taskmate"
|
||||||
|
rebuild: "cd /home/claude-dev/TaskMate && docker compose build --no-cache && docker compose up -d"
|
||||||
|
logs: "docker logs taskmate -f --tail 100"
|
||||||
|
health: "curl http://localhost:3000/api/health"
|
||||||
|
copy_frontend: "docker cp frontend/js/ taskmate:/app/public/js/"
|
||||||
|
copy_backend: "docker cp backend/routes/ taskmate:/app/routes/"
|
||||||
|
|
||||||
```bash
|
FILE_STRUCTURE:
|
||||||
# 1. Vor Änderungen - Backup erstellen
|
frontend:
|
||||||
cp data/taskmate.db data/taskmate.db.backup-$(date +%Y%m%d-%H%M%S)
|
core:
|
||||||
docker commit taskmate taskmate-backup-$(date +%Y%m%d-%H%M%S)
|
- "frontend/js/app.js - Hauptanwendung & View-Controller"
|
||||||
|
- "frontend/js/store.js - State Management (Pub-Sub)"
|
||||||
|
- "frontend/js/api.js - API Client mit Auth/CSRF"
|
||||||
|
- "frontend/js/auth.js - Login/Token-Verwaltung"
|
||||||
|
- "frontend/js/sync.js - Socket.io Echtzeit"
|
||||||
|
views:
|
||||||
|
- "frontend/js/board.js - Kanban-Board mit Drag&Drop"
|
||||||
|
- "frontend/js/calendar.js - Kalender (Monat/Woche)"
|
||||||
|
- "frontend/js/list.js - Listenansicht"
|
||||||
|
- "frontend/js/dashboard.js - Statistik-Dashboard"
|
||||||
|
- "frontend/js/proposals.js - Vorschlagssystem"
|
||||||
|
- "frontend/js/knowledge.js - Wissensdatenbank"
|
||||||
|
- "frontend/js/admin.js - Benutzerverwaltung"
|
||||||
|
important:
|
||||||
|
- "frontend/sw.js - Service Worker mit CACHE_VERSION"
|
||||||
|
- "frontend/index.html - Haupt-HTML"
|
||||||
|
backend:
|
||||||
|
core:
|
||||||
|
- "backend/server.js - Express Server mit Socket.io"
|
||||||
|
- "backend/database.js - Datenbankschema (20+ Tabellen)"
|
||||||
|
routes:
|
||||||
|
- "/api/auth - Login/Logout"
|
||||||
|
- "/api/tasks - Aufgaben CRUD"
|
||||||
|
- "/api/projects - Projekte"
|
||||||
|
- "/api/columns - Kanban-Spalten"
|
||||||
|
- "/api/comments - Kommentare"
|
||||||
|
- "/api/files - Datei-Upload"
|
||||||
|
- "/api/proposals - Vorschlaege"
|
||||||
|
- "/api/knowledge - Wissensdatenbank"
|
||||||
|
other:
|
||||||
|
- "CHANGELOG.txt - Aenderungsprotokoll"
|
||||||
|
|
||||||
# 2. Bei Problemen - Rollback durchführen
|
DATABASE:
|
||||||
docker stop taskmate
|
type: SQLite
|
||||||
docker run -d --name taskmate-temp taskmate-backup-TIMESTAMP
|
path: "data/taskmate.db"
|
||||||
# Nach Test: docker rm -f taskmate && docker rename taskmate-temp taskmate
|
tables:
|
||||||
|
- users
|
||||||
|
- projects
|
||||||
|
- columns
|
||||||
|
- tasks
|
||||||
|
- task_labels
|
||||||
|
- task_assignees
|
||||||
|
- comments
|
||||||
|
- attachments
|
||||||
|
- proposals
|
||||||
|
- notifications
|
||||||
|
- knowledge_categories
|
||||||
|
- knowledge_entries
|
||||||
|
|
||||||
# 3. Code-Rollback via Git
|
CODE_PATTERNS:
|
||||||
git stash # Aktuelle Änderungen sichern
|
date_formatting:
|
||||||
git checkout HEAD~1 # Zum vorherigen Commit
|
correct: |
|
||||||
docker build -t taskmate . && docker restart taskmate
|
const year = date.getFullYear();
|
||||||
```
|
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||||
|
const day = String(date.getDate()).padStart(2, '0');
|
||||||
|
const dateStr = `${year}-${month}-${day}`;
|
||||||
|
wrong: "date.toISOString().split('T')[0] // NIEMALS - UTC Problem!"
|
||||||
|
|
||||||
**Änderungs-Workflow für Live-System:**
|
toast_messages:
|
||||||
1. Backup von Datenbank UND Docker-Image
|
correct: |
|
||||||
2. Kleine, inkrementelle Änderungen
|
|
||||||
3. Sofortiger Test nach jeder Änderung
|
|
||||||
4. Rollback-Plan dokumentieren
|
|
||||||
5. Bei kritischen Änderungen: Wartungsfenster planen
|
|
||||||
|
|
||||||
### Arbeitsweise mit nicht-technischem Anwender
|
|
||||||
**Der Anwender versteht KEIN Coding!** Daher:
|
|
||||||
|
|
||||||
1. **Vollständige Übernahme**: Du führst ALLE technischen Schritte durch
|
|
||||||
2. **Einfache Erklärungen**: "Ich passe jetzt X an, damit Y funktioniert"
|
|
||||||
3. **Status-Updates**: "Änderung abgeschlossen, teste jetzt..."
|
|
||||||
4. **Keine technischen Fragen**: Niemals nach Code, Logs oder Befehlen fragen
|
|
||||||
5. **Proaktives Handeln**: Selbstständig debuggen und lösen
|
|
||||||
|
|
||||||
**Beispiel-Kommunikation:**
|
|
||||||
- ✅ "Ich habe ein Problem gefunden und behebe es jetzt..."
|
|
||||||
- ❌ "Welche Version von Node.js ist installiert?"
|
|
||||||
- ✅ "Die Änderung ist fertig. Bitte die Seite neu laden und testen."
|
|
||||||
- ❌ "Kannst du mal in die Console schauen?"
|
|
||||||
|
|
||||||
### Zugriff & Domains
|
|
||||||
- **Frontend**: https://taskmate.aegis-sight.de
|
|
||||||
- **Lokaler Port**: 3001 → Container Port 3000
|
|
||||||
- **Gitea**: https://gitea-undso.aegis-sight.de/AegisSight/TaskMate
|
|
||||||
- **Gitea-Token**: Siehe `.env` Datei (NIEMALS in Git einchecken!)
|
|
||||||
|
|
||||||
## 📁 Projektstruktur
|
|
||||||
|
|
||||||
### Wichtige Dateien - Hier starten!
|
|
||||||
```
|
|
||||||
frontend/js/app.js # Hauptanwendung & View-Controller
|
|
||||||
backend/server.js # Express Server mit Socket.io
|
|
||||||
backend/database.js # Datenbankschema (20+ Tabellen)
|
|
||||||
frontend/js/store.js # State Management (Pub-Sub)
|
|
||||||
frontend/js/api.js # API Client mit Auth/CSRF
|
|
||||||
frontend/sw.js # Service Worker → CACHE_VERSION!
|
|
||||||
CHANGELOG.txt # Änderungsprotokoll
|
|
||||||
```
|
|
||||||
|
|
||||||
### Frontend-Module (22 Dateien)
|
|
||||||
```
|
|
||||||
# Core
|
|
||||||
app.js # Hauptanwendung
|
|
||||||
store.js # State Management
|
|
||||||
api.js # Backend-Kommunikation
|
|
||||||
auth.js # Login/Token-Verwaltung
|
|
||||||
utils.js # Hilfsfunktionen
|
|
||||||
|
|
||||||
# Views
|
|
||||||
board.js # Kanban-Board mit Drag&Drop
|
|
||||||
calendar.js # Kalender (Monat/Woche)
|
|
||||||
list.js # Listenansicht
|
|
||||||
dashboard.js # Statistik-Dashboard
|
|
||||||
proposals.js # Vorschlagssystem
|
|
||||||
knowledge.js # Wissensdatenbank
|
|
||||||
admin.js # Benutzerverwaltung
|
|
||||||
|
|
||||||
# Features
|
|
||||||
task-modal.js # Aufgaben-Details
|
|
||||||
notifications.js # Benachrichtigungen
|
|
||||||
sync.js # Socket.io Echtzeit
|
|
||||||
mobile.js # Mobile Features
|
|
||||||
```
|
|
||||||
|
|
||||||
### Backend-Routes (22 Module)
|
|
||||||
```
|
|
||||||
/api/auth # Login/Logout
|
|
||||||
/api/tasks # Aufgaben CRUD
|
|
||||||
/api/projects # Projekte
|
|
||||||
/api/columns # Kanban-Spalten
|
|
||||||
/api/comments # Kommentare
|
|
||||||
/api/files # Datei-Upload
|
|
||||||
/api/proposals # Vorschläge
|
|
||||||
/api/gitea # Git-Integration
|
|
||||||
/api/knowledge # Wissensdatenbank
|
|
||||||
```
|
|
||||||
|
|
||||||
## 🔧 Entwicklung
|
|
||||||
|
|
||||||
### Neue View/Ansicht hinzufügen
|
|
||||||
```javascript
|
|
||||||
// 1. Datei erstellen: frontend/js/myview.js
|
|
||||||
export function initMyView() {
|
|
||||||
// KRITISCH: Echtzeit-Updates registrieren!
|
|
||||||
store.subscribe('tasks', updateView);
|
|
||||||
window.addEventListener('app:refresh', updateView);
|
|
||||||
|
|
||||||
function updateView() {
|
|
||||||
// UI aktualisieren
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. In index.html einbinden
|
|
||||||
<script type="module" src="js/myview.js"></script>
|
|
||||||
|
|
||||||
// 3. Navigation erweitern in navigation.js
|
|
||||||
```
|
|
||||||
|
|
||||||
### Neue API-Route erstellen
|
|
||||||
```javascript
|
|
||||||
// 1. Route-Datei: backend/routes/myroute.js
|
|
||||||
const router = require('express').Router();
|
|
||||||
const auth = require('../middleware/auth');
|
|
||||||
|
|
||||||
router.get('/', auth, (req, res) => {
|
|
||||||
// Implementation
|
|
||||||
});
|
|
||||||
|
|
||||||
module.exports = router;
|
|
||||||
|
|
||||||
// 2. In server.js registrieren
|
|
||||||
app.use('/api/myroute', require('./routes/myroute'));
|
|
||||||
|
|
||||||
// 3. Frontend API-Call in api.js
|
|
||||||
async myRouteCall() {
|
|
||||||
return this.request('/api/myroute');
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Datums-Formatierung (RICHTIG!)
|
|
||||||
```javascript
|
|
||||||
// ✅ RICHTIG - Lokale Zeit
|
|
||||||
const year = date.getFullYear();
|
|
||||||
const month = String(date.getMonth() + 1).padStart(2, '0');
|
|
||||||
const day = String(date.getDate()).padStart(2, '0');
|
|
||||||
const dateStr = `${year}-${month}-${day}`;
|
|
||||||
|
|
||||||
// ❌ FALSCH - UTC-Konvertierung
|
|
||||||
const dateStr = date.toISOString().split('T')[0]; // NIEMALS!
|
|
||||||
```
|
|
||||||
|
|
||||||
### Echtzeit-Updates implementieren
|
|
||||||
```javascript
|
|
||||||
// PFLICHT für ALLE Komponenten!
|
|
||||||
// 1. Store-Subscriptions
|
|
||||||
store.subscribe('tasks', () => renderTasks());
|
|
||||||
store.subscribe('columns', () => updateColumns());
|
|
||||||
store.subscribe('labels', () => refreshLabels());
|
|
||||||
|
|
||||||
// 2. Event-Listener
|
|
||||||
window.addEventListener('app:refresh', () => {
|
|
||||||
// Komplette UI aktualisieren
|
|
||||||
});
|
|
||||||
|
|
||||||
window.addEventListener('modal:close', () => {
|
|
||||||
// Nach Modal-Schließung
|
|
||||||
});
|
|
||||||
|
|
||||||
// 3. Socket.io Events in sync.js
|
|
||||||
socket.on('task:update', (data) => {
|
|
||||||
store.updateTask(data);
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
## 💾 Datenbank
|
|
||||||
|
|
||||||
### Wichtige Tabellen
|
|
||||||
```sql
|
|
||||||
users # Benutzer mit Rollen
|
|
||||||
projects # Projekte
|
|
||||||
columns # Kanban-Spalten
|
|
||||||
tasks # Aufgaben
|
|
||||||
task_labels # M:N Labels
|
|
||||||
task_assignees # M:N Zuweisungen
|
|
||||||
comments # Kommentare
|
|
||||||
attachments # Dateianhänge
|
|
||||||
proposals # Vorschläge
|
|
||||||
notifications # Benachrichtigungen
|
|
||||||
knowledge_* # Wissensdatenbank
|
|
||||||
```
|
|
||||||
|
|
||||||
### Schema ändern
|
|
||||||
```javascript
|
|
||||||
// 1. In backend/database.js anpassen
|
|
||||||
CREATE TABLE new_table (
|
|
||||||
id INTEGER PRIMARY KEY,
|
|
||||||
...
|
|
||||||
);
|
|
||||||
|
|
||||||
// 2. Datenbank neu initialisieren
|
|
||||||
rm data/taskmate.db*
|
|
||||||
docker restart taskmate
|
|
||||||
|
|
||||||
// 3. API & Frontend anpassen
|
|
||||||
```
|
|
||||||
|
|
||||||
## 🚢 Deployment
|
|
||||||
|
|
||||||
### Deployment-Checkliste
|
|
||||||
```bash
|
|
||||||
# 1. Vor Deployment
|
|
||||||
- [ ] Keine console.log() im Code
|
|
||||||
- [ ] Alle Features getestet
|
|
||||||
- [ ] Keine Testdaten in DB
|
|
||||||
|
|
||||||
# 2. Deployment durchführen
|
|
||||||
- [ ] Cache-Version erhöhen: frontend/sw.js
|
|
||||||
- [ ] CHANGELOG.txt aktualisieren
|
|
||||||
- [ ] Git commit & push
|
|
||||||
- [ ] docker build -t taskmate .
|
|
||||||
- [ ] docker restart taskmate
|
|
||||||
|
|
||||||
# 3. Nach Deployment
|
|
||||||
- [ ] https://taskmate.aegis-sight.de testen
|
|
||||||
- [ ] Browser-Cache leeren (Strg+F5)
|
|
||||||
- [ ] Login, Aufgabe erstellen, etc. testen
|
|
||||||
```
|
|
||||||
|
|
||||||
### Docker-Befehle
|
|
||||||
```bash
|
|
||||||
# Container Status
|
|
||||||
docker ps -a | grep taskmate
|
|
||||||
|
|
||||||
# Container stoppen/starten
|
|
||||||
docker stop taskmate
|
|
||||||
docker start taskmate
|
|
||||||
|
|
||||||
# Container neu erstellen
|
|
||||||
docker rm -f taskmate
|
|
||||||
docker-compose up -d
|
|
||||||
|
|
||||||
# In Container Shell
|
|
||||||
docker exec -it taskmate sh
|
|
||||||
|
|
||||||
# Logs live verfolgen
|
|
||||||
docker logs taskmate -f --tail 100
|
|
||||||
```
|
|
||||||
|
|
||||||
### KRITISCHES PROBLEM: Frontend-Änderungen werden nicht sichtbar
|
|
||||||
|
|
||||||
**Problem**: Frontend-Dateien (HTML, CSS, JS) werden beim Docker Build nach `/app/public/` kopiert und sind NICHT live gemountet. Änderungen in `/home/claude-dev/TaskMate/frontend/` werden daher nicht automatisch übernommen.
|
|
||||||
|
|
||||||
**Symptome**:
|
|
||||||
- CSS/JS-Änderungen funktionieren nicht trotz Browser-Cache-Löschung
|
|
||||||
- Service Worker Cache-Version Erhöhung hilft nicht
|
|
||||||
- Änderungen werden sporadisch nach längerer Zeit sichtbar
|
|
||||||
|
|
||||||
**Ursache**:
|
|
||||||
1. **Dockerfile kopiert Frontend-Dateien**: `COPY frontend/ ./public/`
|
|
||||||
2. **Express.js cached statische Dateien** mit ETags und Last-Modified Headers
|
|
||||||
3. **Mehrschichtiges Caching**: Service Worker + Browser + Express.js
|
|
||||||
|
|
||||||
**LÖSUNG A - Sofortige Änderungen (Development)**:
|
|
||||||
```bash
|
|
||||||
# Einzelne Datei kopieren
|
|
||||||
docker cp frontend/css/style.css taskmate:/app/public/css/style.css
|
|
||||||
|
|
||||||
# Mehrere Dateien kopieren
|
|
||||||
docker cp frontend/js/app.js taskmate:/app/public/js/app.js
|
|
||||||
docker cp frontend/index.html taskmate:/app/public/index.html
|
|
||||||
|
|
||||||
# CSS + JS zusammen kopieren
|
|
||||||
docker cp frontend/css/ taskmate:/app/public/css/
|
|
||||||
docker cp frontend/js/ taskmate:/app/public/js/
|
|
||||||
```
|
|
||||||
|
|
||||||
**LÖSUNG B - Vollständige Aktualisierung (Production)**:
|
|
||||||
```bash
|
|
||||||
# Docker Image neu bauen und Container ersetzen
|
|
||||||
docker build -t taskmate . && docker restart taskmate
|
|
||||||
```
|
|
||||||
|
|
||||||
**Express.js Caching deaktiviert**:
|
|
||||||
```javascript
|
|
||||||
// In server.js - statische Dateien ohne Caching
|
|
||||||
app.use(express.static(path.join(__dirname, 'public'), {
|
|
||||||
etag: false,
|
|
||||||
lastModified: false,
|
|
||||||
cacheControl: false,
|
|
||||||
setHeaders: (res, path) => {
|
|
||||||
res.setHeader('Cache-Control', 'no-store, no-cache, must-revalidate');
|
|
||||||
}
|
|
||||||
}));
|
|
||||||
```
|
|
||||||
|
|
||||||
**Debugging-Workflow**:
|
|
||||||
1. Prüfe Datei-Timestamps im Container: `docker exec taskmate ls -la /app/public/css/`
|
|
||||||
2. Vergleiche mit lokalen Dateien: `ls -la frontend/css/`
|
|
||||||
3. Bei Diskrepanz: Files mit `docker cp` aktualisieren
|
|
||||||
4. Bei JavaScript-Problemen: Browser-Console auf Fehler prüfen
|
|
||||||
|
|
||||||
**Warum passiert das**:
|
|
||||||
- Container-Pfad `/app/public/` = Static Files (nicht live)
|
|
||||||
- Container-Pfad `/app/taskmate-source/` = Git-Operationen (live gemountet)
|
|
||||||
- Frontend wird NUR beim Build-Time kopiert, nicht zur Laufzeit
|
|
||||||
|
|
||||||
## 🚨 KRITISCHE LEKTIONEN AUS PROBLEMEN
|
|
||||||
|
|
||||||
### ⚠️ Kontakte-Modul Implementation Probleme (07.01.2025)
|
|
||||||
|
|
||||||
**FEHLER 1: Backend API-Route nicht gefunden (404)**
|
|
||||||
- **Problem**: GET /api/contacts gibt 404 - Endpoint nicht gefunden
|
|
||||||
- **Ursache**: Backend-Dateien nicht im Docker-Container, Container nicht neu gestartet
|
|
||||||
- **Lösung**: Backend-Dateien kopieren und Container neu starten
|
|
||||||
- **Prävention**:
|
|
||||||
```bash
|
|
||||||
# Backend-Änderungen: Alle Dateien kopieren
|
|
||||||
docker cp backend/routes/contacts.js taskmate:/app/routes/
|
|
||||||
docker cp backend/middleware/validation.js taskmate:/app/middleware/
|
|
||||||
docker cp backend/server.js taskmate:/app/server.js
|
|
||||||
docker restart taskmate # IMMER nach Backend-Änderungen
|
|
||||||
```
|
|
||||||
|
|
||||||
**FEHLER 2: Database Table existiert nicht (500 Internal Server Error)**
|
|
||||||
- **Problem**: "no such table: contacts" - Tabelle wurde nicht erstellt
|
|
||||||
- **Ursache**: database.js Änderungen nicht übernommen, bestehende DB erweitert sich nicht automatisch
|
|
||||||
- **Lösung**: database.js kopieren + Tabelle manuell erstellen
|
|
||||||
- **Pattern für neue Tabellen**:
|
|
||||||
```bash
|
|
||||||
docker cp backend/database.js taskmate:/app/database.js
|
|
||||||
docker exec taskmate node -e "
|
|
||||||
const Database = require('better-sqlite3');
|
|
||||||
const db = new Database('/app/data/taskmate.db');
|
|
||||||
db.exec('CREATE TABLE IF NOT EXISTS new_table (...);');
|
|
||||||
console.log('Table created successfully');
|
|
||||||
"
|
|
||||||
```
|
|
||||||
|
|
||||||
**FEHLER 3: store.showMessage ist undefined**
|
|
||||||
- **Problem**: `store.showMessage()` Funktion existiert nicht
|
|
||||||
- **Ursache**: Falsche API für Toast-Nachrichten
|
|
||||||
- **Lösung**: Verwende `window.dispatchEvent` mit `toast:show`
|
|
||||||
- **Pattern für Toast-Messages**:
|
|
||||||
```javascript
|
|
||||||
// FALSCH: store.showMessage('Text', 'success')
|
|
||||||
// RICHTIG:
|
|
||||||
window.dispatchEvent(new CustomEvent('toast:show', {
|
window.dispatchEvent(new CustomEvent('toast:show', {
|
||||||
detail: { message: 'Text', type: 'success' }
|
detail: { message: 'Text', type: 'success' }
|
||||||
}));
|
}));
|
||||||
```
|
wrong: "store.showMessage('Text', 'success') // existiert nicht"
|
||||||
|
|
||||||
**FEHLER 4: Event-Handler nicht gebunden**
|
realtime_updates:
|
||||||
- **Problem**: Button-Clicks funktionieren nicht trotz Event-Listener
|
required: |
|
||||||
- **Ursache**: Timing-Problem - DOM noch nicht bereit, Modal-Overlay fehlt
|
store.subscribe('tasks', () => renderTasks());
|
||||||
- **Lösung**: Korrekte Modal-Struktur + Overlay-Management
|
window.addEventListener('app:refresh', () => updateView());
|
||||||
- **Debugging-Pattern**:
|
|
||||||
```javascript
|
|
||||||
console.log('[Module] Element check:', this.buttonElement);
|
|
||||||
if (this.buttonElement) {
|
|
||||||
console.log('[Module] Binding event');
|
|
||||||
this.buttonElement.addEventListener('click', () => {
|
|
||||||
console.log('[Module] Button clicked!');
|
|
||||||
});
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**FEHLER 5: Modal Design inkonsistent**
|
TROUBLESHOOTING:
|
||||||
- **Problem**: Custom Modal-Styles passen nicht zum App-Design
|
frontend_changes_not_visible:
|
||||||
- **Ursache**: Eigene CSS-Klassen statt Standard-Modal-Pattern
|
problem: "Frontend-Dateien werden beim Build kopiert, nicht live gemountet"
|
||||||
- **Lösung**: Standard Modal-Struktur verwenden
|
solution_dev: "docker cp frontend/js/ taskmate:/app/public/js/"
|
||||||
- **Standard Modal-Pattern**:
|
solution_prod: "cd /home/claude-dev/TaskMate && docker compose build --no-cache && docker compose up -d"
|
||||||
```html
|
|
||||||
<div class="modal modal-medium hidden">
|
|
||||||
<div class="modal-header">
|
|
||||||
<h2>Title</h2>
|
|
||||||
<button class="modal-close" data-close-modal>×</button>
|
|
||||||
</div>
|
|
||||||
<div class="modal-body"><!-- Content --></div>
|
|
||||||
<div class="modal-footer"><!-- Buttons --></div>
|
|
||||||
</div>
|
|
||||||
```
|
|
||||||
|
|
||||||
### ⚠️ SVG Icon-Rendering Probleme (07.01.2025)
|
401_unauthorized:
|
||||||
|
problem: "Token abgelaufen"
|
||||||
|
solution: "Neu einloggen"
|
||||||
|
|
||||||
**FEHLER: SVG Icons werden nicht angezeigt**
|
csrf_error:
|
||||||
- **Problem**: SVG-Icons verschwinden oder zeigen nicht korrekt an
|
problem: "CSRF Token ungueltig"
|
||||||
- **Ursache**: `createElement()` unterstützt kein SVG-Namespace
|
solution: "Browser-Cache/Cookies loeschen, neu einloggen"
|
||||||
- **Lösung**: SVG mit `innerHTML` oder als String-Template einfügen
|
|
||||||
- **Pattern**:
|
|
||||||
```javascript
|
|
||||||
// FALSCH - SVG wird nicht gerendert
|
|
||||||
const icon = createElement('svg', { viewBox: '0 0 24 24' });
|
|
||||||
|
|
||||||
// RICHTIG - SVG als HTML-String
|
missing_data:
|
||||||
element.innerHTML = `<svg viewBox="0 0 24 24">...</svg>`;
|
problem: "Daten scheinen verschwunden"
|
||||||
```
|
first_check: "docker logs taskmate - auf 401/403 Fehler pruefen"
|
||||||
- **Prävention**: Bei dynamischen SVGs immer innerHTML oder DOMParser nutzen
|
note: "NIEMALS sofort Backup/Restore - erst Auth pruefen"
|
||||||
|
|
||||||
### ⚠️ API Field Name Mismatches (07.01.2025)
|
WORKFLOWS:
|
||||||
|
changelog_entry:
|
||||||
|
description: "PFLICHT bei jeder Code-Aenderung"
|
||||||
|
step_1:
|
||||||
|
name: "CHANGELOG.txt aktualisieren"
|
||||||
|
file: "/home/claude-dev/TaskMate/CHANGELOG.txt"
|
||||||
|
format: "DD.MM.YYYY - vXXX - Kurzbeschreibung"
|
||||||
|
step_2:
|
||||||
|
name: "Wissensdatenbank Changelog aktualisieren"
|
||||||
|
command: |
|
||||||
|
docker exec taskmate node -e "
|
||||||
|
const Database = require('better-sqlite3');
|
||||||
|
const db = new Database('/app/data/taskmate.db');
|
||||||
|
db.prepare(\`
|
||||||
|
INSERT INTO knowledge_entries (category_id, title, notes, position, created_by)
|
||||||
|
VALUES (15, 'DD.MM.YYYY - Beschreibung', 'Markdown-Inhalt', 0, 1)
|
||||||
|
\`).run();
|
||||||
|
console.log('Changelog erstellt');
|
||||||
|
"
|
||||||
|
category_id: 15
|
||||||
|
title_format: "DD.MM.YYYY - Kurze Beschreibung"
|
||||||
|
notes_format: "Markdown mit Details (Problem, Loesung, geaenderte Dateien)"
|
||||||
|
|
||||||
**FEHLER: Frontend/Backend Feldnamen-Diskrepanz**
|
deployment:
|
||||||
- **Problem**: Daten werden mit 0 Bytes oder leer angezeigt
|
steps:
|
||||||
- **Ursache**: Backend sendet camelCase, Frontend erwartet snake_case
|
- "Cache-Version erhoehen: frontend/sw.js"
|
||||||
- **Beispiel**: `originalName` vs `original_name`, `sizeBytes` vs `size_bytes`
|
- "CHANGELOG.txt aktualisieren"
|
||||||
- **Lösung**: Fallback-Pattern für beide Schreibweisen
|
- "Wissensdatenbank Changelog aktualisieren"
|
||||||
- **Pattern**:
|
- "docker cp oder docker build"
|
||||||
```javascript
|
- "docker compose up -d (Container mit neuem Image starten)"
|
||||||
// Robuste Feldabfrage mit Fallback
|
- "Testen: https://taskmate.aegis-sight.de"
|
||||||
const fileName = data.originalName || data.original_name || '';
|
- "Browser-Cache leeren (Strg+F5)"
|
||||||
const fileSize = data.sizeBytes || data.size_bytes || 0;
|
|
||||||
```
|
|
||||||
- **Prävention**: API-Dokumentation prüfen, einheitliche Naming-Convention
|
|
||||||
|
|
||||||
### ⚠️ File Upload Field Names (07.01.2025)
|
CONVENTIONS:
|
||||||
|
language_ui: Deutsch
|
||||||
|
language_code: Englisch
|
||||||
|
umlauts: "ae oe ue verwenden (ä ö ü)"
|
||||||
|
emojis: "Nur in Dokumentation, nicht in Code/UI"
|
||||||
|
auto_save: true
|
||||||
|
|
||||||
**FEHLER: Multer "Unexpected field" Error**
|
SECURITY:
|
||||||
- **Problem**: 500 Error bei File-Upload
|
auth:
|
||||||
- **Ursache**: Frontend sendet 'files' (plural), Backend erwartet 'file' (singular)
|
type: JWT
|
||||||
- **Lösung**: Backend-Konsistenz herstellen
|
validity: 24h
|
||||||
- **Pattern**:
|
storage: localStorage
|
||||||
```javascript
|
csrf:
|
||||||
// Backend - Konsistent 'files' verwenden
|
header: "X-CSRF-Token"
|
||||||
upload.single('files') // NICHT 'file'
|
generated_at: login
|
||||||
|
roles:
|
||||||
|
admin: "Nur Benutzerverwaltung"
|
||||||
|
user: "Alles ausser Admin-Bereich"
|
||||||
|
|
||||||
// Frontend - FormData immer mit 'files'
|
KNOWN_ISSUES:
|
||||||
formData.append('files', file);
|
docker_rebuild:
|
||||||
```
|
problem: "docker restart nutzt das ALTE Image - Code-Aenderungen werden NICHT uebernommen"
|
||||||
- **Prävention**: Einheitliche Field-Names über alle Upload-Endpoints
|
wichtig: "docker build allein reicht auch nicht, da docker-compose den alten Container weiter nutzt"
|
||||||
|
solution: "cd /home/claude-dev/TaskMate && docker compose build --no-cache && docker compose up -d"
|
||||||
|
hinweis: "Immer mit --no-cache bauen, da Docker COPY-Layer cached auch wenn Dateien sich geaendert haben"
|
||||||
|
svg_rendering:
|
||||||
|
problem: "createElement() unterstuetzt kein SVG-Namespace"
|
||||||
|
solution: "SVG mit innerHTML oder String-Template einfuegen"
|
||||||
|
|
||||||
### ⚠️ Erinnerung-Implementation Probleme (06.01.2026)
|
field_name_mismatch:
|
||||||
|
problem: "Backend camelCase vs Frontend snake_case"
|
||||||
|
solution: "Fallback-Pattern: data.originalName || data.original_name"
|
||||||
|
|
||||||
**FEHLER 1: Syntax-Fehler in JavaScript blockierte Login**
|
file_upload:
|
||||||
- **Problem**: Missing closing brace in calendar.js verhinderte Login komplett
|
problem: "Multer Unexpected field Error"
|
||||||
- **Ursache**: Unvollständige Code-Blöcke beim Multi-Edit
|
solution: "Konsistent 'files' (plural) verwenden"
|
||||||
- **Lösung**: IMMER Syntax-Check nach JavaScript-Änderungen
|
|
||||||
- **Prävention**:
|
|
||||||
```bash
|
|
||||||
# Nach JS-Änderungen prüfen:
|
|
||||||
node -c frontend/js/calendar.js
|
|
||||||
docker logs taskmate --tail 20 # Auf Syntax-Fehler prüfen
|
|
||||||
```
|
|
||||||
|
|
||||||
**FEHLER 2: "Verschwundene" Projekte durch 401-Fehler**
|
ROLLBACK:
|
||||||
- **Problem**: User dachte AegisSight-Projekt sei gelöscht
|
backup_db: "cp data/taskmate.db data/taskmate.db.backup-$(date +%Y%m%d-%H%M%S)"
|
||||||
- **Ursache**: Authentifizierungs-Token abgelaufen, API gibt 401 zurück
|
backup_docker: "docker commit taskmate taskmate-backup-$(date +%Y%m%d-%H%M%S)"
|
||||||
- **Diagnose**: `docker logs taskmate` zeigt 401-Fehler
|
restore_code: "git stash && git checkout HEAD~1"
|
||||||
- **Lösung**: Einfach neu anmelden, Daten sind intakt
|
|
||||||
- **Prävention**: Bei "verschwundenen" Daten IMMER zuerst Auth prüfen
|
|
||||||
|
|
||||||
**FEHLER 3: Checkbox-Styling funktioniert nicht**
|
Last-Updated: 2026-03-19
|
||||||
- **Problem**: CSS-Selektoren greifen nicht, komplexe Pseudo-Element-Struktur
|
|
||||||
- **Ursache**: Browser-CSS-Konflikte, CSS-Variable-Probleme
|
|
||||||
- **Lösung**: Direktes Styling nativer Checkboxes mit `appearance: none`
|
|
||||||
- **Lesson**: Bei CSS-Problemen: **Einfachster Ansatz zuerst**
|
|
||||||
```css
|
|
||||||
/* FALSCH: Komplexe Pseudo-Struktur */
|
|
||||||
input:checked + span::after { content: '✓'; }
|
|
||||||
|
|
||||||
/* RICHTIG: Direktes Styling */
|
|
||||||
input[type="checkbox"] {
|
|
||||||
appearance: none;
|
|
||||||
background: #3B82F6 when :checked;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**FEHLER 4: Event-Handler Konflikte bei Modal-Updates**
|
|
||||||
- **Problem**: Dropdown-Handler werden überschrieben
|
|
||||||
- **Ursache**: Mehrfache Event-Binding ohne Cleanup
|
|
||||||
- **Lösung**: Element-Kloning für saubere Event-Handler
|
|
||||||
- **Pattern**:
|
|
||||||
```javascript
|
|
||||||
// Event-Handler cleanup durch Klonen
|
|
||||||
const newElement = element.cloneNode(true);
|
|
||||||
element.parentNode.replaceChild(newElement, element);
|
|
||||||
// Dann neue Handler binden
|
|
||||||
```
|
|
||||||
|
|
||||||
**FEHLER 5: Visuelle Darstellung unterbricht Funktionalität**
|
|
||||||
- **Problem**: Erinnerungen unterbrachen Aufgaben-Balken
|
|
||||||
- **Ursache**: Falsche Render-Reihenfolge (Erinnerungen vor Aufgaben)
|
|
||||||
- **Lösung**: Aufgaben zuerst, dann Erinnerungen
|
|
||||||
- **Lesson**: UI-Reihenfolge muss Funktionalität folgen, nicht umgekehrt
|
|
||||||
|
|
||||||
### ⚠️ Dropdown-Transparenz-Probleme (09.01.2026)
|
|
||||||
|
|
||||||
**FEHLER: Dropdown-Menüs haben unerwünschte Transparenz**
|
|
||||||
- **Problem**: Dropdown-Menüs zeigen durchscheinenden Hintergrund, besonders bei dunklen Themes
|
|
||||||
- **Symptome**:
|
|
||||||
- Text schwer lesbar durch transparenten Hintergrund
|
|
||||||
- Inhalte dahinter scheinen durch
|
|
||||||
- Besonders auffällig bei Kontakte-Modul und anderen Dropdown-Menüs
|
|
||||||
- **Ursache**: CSS-Variablen für Hintergründe verwenden `rgba()` mit Transparenz
|
|
||||||
- **Lösung**: Explizite, nicht-transparente Hintergrundfarben für Dropdowns setzen
|
|
||||||
- **Pattern**:
|
|
||||||
```css
|
|
||||||
/* FALSCH - Transparente Hintergründe */
|
|
||||||
.dropdown {
|
|
||||||
background: var(--bg-secondary); /* rgba mit 0.95 opacity */
|
|
||||||
}
|
|
||||||
|
|
||||||
/* RICHTIG - Solide Hintergründe für Dropdowns */
|
|
||||||
.dropdown {
|
|
||||||
background: #ffffff; /* Hell-Theme */
|
|
||||||
background: #1a1a1a; /* Dunkel-Theme */
|
|
||||||
}
|
|
||||||
```
|
|
||||||
- **Prävention**:
|
|
||||||
- Dropdown-Komponenten immer mit soliden Hintergründen
|
|
||||||
- Keine CSS-Variablen mit Transparenz für interaktive Elemente
|
|
||||||
- Bei Theme-Support: Explizite Farben ohne Alpha-Kanal
|
|
||||||
|
|
||||||
### 🔧 TROUBLESHOOTING-WORKFLOW
|
|
||||||
|
|
||||||
**Bei JavaScript-Fehlern:**
|
|
||||||
1. `docker logs taskmate --tail 50` prüfen
|
|
||||||
2. Browser-Console auf Syntax-Fehler prüfen
|
|
||||||
3. Node.js Syntax-Check: `node -c datei.js`
|
|
||||||
|
|
||||||
**Bei "verschwundenen" Daten:**
|
|
||||||
1. **NIEMALS** sofort Backup/Restore - erst debuggen!
|
|
||||||
2. API-Logs prüfen auf 401/403 Fehler
|
|
||||||
3. Auth-Status prüfen: `localStorage.getItem('token')`
|
|
||||||
4. Datenbank direkt prüfen: `sqlite3 data/taskmate.db "SELECT COUNT(*) FROM projects"`
|
|
||||||
|
|
||||||
**Bei CSS-Problemen:**
|
|
||||||
1. Simplest approach first - keine komplexen Selektoren
|
|
||||||
2. `!important` nur als letzter Ausweg
|
|
||||||
3. Browser-DevTools: Computed Styles prüfen
|
|
||||||
4. Cache leeren: `CACHE_VERSION++` in sw.js
|
|
||||||
|
|
||||||
**Bei neuen Modulen mit globaler Suche:**
|
|
||||||
1. Module in app.js setupSearch() registrieren:
|
|
||||||
```javascript
|
|
||||||
} else if (currentView === 'mymodule') {
|
|
||||||
import('./mymodule.js').then(module => {
|
|
||||||
if (module.myManager) {
|
|
||||||
module.myManager.searchQuery = value;
|
|
||||||
module.myManager.filterData();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
```
|
|
||||||
2. Manager-Instanz exportieren: `export { myManager }`
|
|
||||||
3. clearSearch() Funktion ebenfalls erweitern
|
|
||||||
4. Lokale Suchfelder entfernen - nur Header-Suche nutzen
|
|
||||||
|
|
||||||
**Bei File-Upload Problemen:**
|
|
||||||
1. Prüfe ob Entry/Task bereits gespeichert ist (ID vorhanden)
|
|
||||||
2. Bei neuen Einträgen: Erst speichern, dann Upload
|
|
||||||
3. Field-Name Konsistenz prüfen: 'files' (plural) überall
|
|
||||||
4. `docker logs taskmate` für Multer-Errors checken
|
|
||||||
|
|
||||||
## 🐛 Troubleshooting
|
|
||||||
|
|
||||||
### Häufige Probleme
|
|
||||||
|
|
||||||
**401 Unauthorized**
|
|
||||||
- Token abgelaufen → Neu einloggen
|
|
||||||
- Prüfen: localStorage.getItem('token')
|
|
||||||
|
|
||||||
**CSRF Token ungültig**
|
|
||||||
- Browser-Cache/Cookies löschen
|
|
||||||
- Neu einloggen
|
|
||||||
|
|
||||||
**Änderungen nicht sichtbar**
|
|
||||||
- Service Worker Cache → sw.js Version erhöhen!
|
|
||||||
- Browser: Strg+F5
|
|
||||||
- Prüfen: Echtzeit-Updates implementiert?
|
|
||||||
|
|
||||||
**Docker startet nicht**
|
|
||||||
```bash
|
|
||||||
docker logs taskmate
|
|
||||||
docker ps -a | grep taskmate
|
|
||||||
netstat -tulpn | grep 3001
|
|
||||||
```
|
|
||||||
|
|
||||||
**Datenbank-Fehler**
|
|
||||||
```bash
|
|
||||||
# Backup erstellen
|
|
||||||
cp data/taskmate.db data/taskmate.db.backup
|
|
||||||
|
|
||||||
# Integrität prüfen
|
|
||||||
sqlite3 data/taskmate.db "PRAGMA integrity_check;"
|
|
||||||
|
|
||||||
# Schema anzeigen
|
|
||||||
sqlite3 data/taskmate.db ".schema"
|
|
||||||
```
|
|
||||||
|
|
||||||
### Debug-Tipps
|
|
||||||
|
|
||||||
**Frontend Debugging**
|
|
||||||
```javascript
|
|
||||||
// Store-Status prüfen
|
|
||||||
console.log(store.getState());
|
|
||||||
|
|
||||||
// API-Calls tracken
|
|
||||||
window.api.debug = true;
|
|
||||||
|
|
||||||
// Socket-Events loggen
|
|
||||||
window.socket.on('*', console.log);
|
|
||||||
```
|
|
||||||
|
|
||||||
**Backend Debugging**
|
|
||||||
```javascript
|
|
||||||
// In server.js
|
|
||||||
app.use((req, res, next) => {
|
|
||||||
console.log(`${req.method} ${req.path}`);
|
|
||||||
next();
|
|
||||||
});
|
|
||||||
|
|
||||||
// SQL Queries loggen
|
|
||||||
db.prepare(sql).run(); // Vorher console.log(sql)
|
|
||||||
```
|
|
||||||
|
|
||||||
## 📋 Code-Patterns
|
|
||||||
|
|
||||||
### API Response Format
|
|
||||||
```javascript
|
|
||||||
// Erfolg
|
|
||||||
res.json({
|
|
||||||
success: true,
|
|
||||||
data: result
|
|
||||||
});
|
|
||||||
|
|
||||||
// Fehler
|
|
||||||
res.status(400).json({
|
|
||||||
success: false,
|
|
||||||
error: 'Fehlermeldung'
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
### Store Update Pattern
|
|
||||||
```javascript
|
|
||||||
// Daten aktualisieren
|
|
||||||
store.updateTasks(tasks);
|
|
||||||
|
|
||||||
// Wird automatisch ausgelöst:
|
|
||||||
// - Alle task-Subscriber
|
|
||||||
// - Socket.io Broadcast
|
|
||||||
// - UI-Updates
|
|
||||||
```
|
|
||||||
|
|
||||||
### Modal Pattern
|
|
||||||
```javascript
|
|
||||||
// Modal öffnen
|
|
||||||
modal.show();
|
|
||||||
|
|
||||||
// WICHTIG: Bei Schließung
|
|
||||||
modal.addEventListener('close', () => {
|
|
||||||
window.dispatchEvent(new CustomEvent('modal:close'));
|
|
||||||
// Triggert UI-Updates!
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
## 🔒 Sicherheit
|
|
||||||
|
|
||||||
### Authentifizierung
|
|
||||||
- JWT Token mit 24h Gültigkeit
|
|
||||||
- Refresh bei jeder Aktivität
|
|
||||||
- Token in localStorage
|
|
||||||
|
|
||||||
### CSRF-Schutz
|
|
||||||
- Token bei Login generiert
|
|
||||||
- Bei jeder Mutation mitgesendet
|
|
||||||
- Header: `X-CSRF-Token`
|
|
||||||
|
|
||||||
### Berechtigungen
|
|
||||||
- Admin: Nur Benutzerverwaltung
|
|
||||||
- User: Alles außer Admin-Bereich
|
|
||||||
- Projekt-basierte Rechte
|
|
||||||
|
|
||||||
## 📝 Wichtige Konventionen
|
|
||||||
|
|
||||||
- **Sprache**: Deutsch für UI, Englisch für Code
|
|
||||||
- **Umlaute**: ä, ö, ü verwenden (keine ae, oe, ue)
|
|
||||||
- **CSS**: Variablen in `frontend/css/variables.css`
|
|
||||||
- **Keine Emojis** in Code/UI (nur Doku)
|
|
||||||
- **Auto-Save**: Änderungen werden automatisch gespeichert
|
|
||||||
|
|
||||||
## 🎯 Performance
|
|
||||||
|
|
||||||
### Frontend
|
|
||||||
- Lazy Loading für Views
|
|
||||||
- Debouncing bei Suche/Filter
|
|
||||||
- Virtual Scrolling bei langen Listen
|
|
||||||
- Service Worker Caching
|
|
||||||
|
|
||||||
### Backend
|
|
||||||
- SQLite mit WAL Mode
|
|
||||||
- Prepared Statements
|
|
||||||
- Index auf häufig gefilterte Spalten
|
|
||||||
- Pagination bei großen Datenmengen
|
|
||||||
|
|
||||||
## 🔄 Git Workflow
|
|
||||||
|
|
||||||
### Lokales Repository
|
|
||||||
```bash
|
|
||||||
# Status prüfen
|
|
||||||
git status
|
|
||||||
|
|
||||||
# Commit erstellen
|
|
||||||
git add .
|
|
||||||
git commit -m "Beschreibung der Änderung"
|
|
||||||
|
|
||||||
# Zu Gitea pushen
|
|
||||||
git push origin main
|
|
||||||
```
|
|
||||||
|
|
||||||
### Gitea Integration
|
|
||||||
- Automatischer Push bei Commits
|
|
||||||
- Repository-Projekt Verknüpfung
|
|
||||||
- Branch-Verwaltung in UI
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
**Hinweis**: Diese Dokumentation ist für die KI-gestützte Entwicklung optimiert. Bei Fragen die `ANWENDUNGSBESCHREIBUNG.txt` für Endnutzer-Dokumentation konsultieren.
|
|
||||||
|
|||||||
@@ -113,7 +113,7 @@ function createTables() {
|
|||||||
CREATE TABLE IF NOT EXISTS tasks (
|
CREATE TABLE IF NOT EXISTS tasks (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
project_id INTEGER NOT NULL,
|
project_id INTEGER NOT NULL,
|
||||||
column_id INTEGER NOT NULL,
|
column_id INTEGER,
|
||||||
title TEXT NOT NULL,
|
title TEXT NOT NULL,
|
||||||
description TEXT,
|
description TEXT,
|
||||||
priority TEXT DEFAULT 'medium',
|
priority TEXT DEFAULT 'medium',
|
||||||
@@ -128,7 +128,7 @@ function createTables() {
|
|||||||
created_by INTEGER,
|
created_by INTEGER,
|
||||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE,
|
FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE,
|
||||||
FOREIGN KEY (column_id) REFERENCES columns(id) ON DELETE CASCADE,
|
FOREIGN KEY (column_id) REFERENCES columns(id) ON DELETE SET NULL,
|
||||||
FOREIGN KEY (assigned_to) REFERENCES users(id),
|
FOREIGN KEY (assigned_to) REFERENCES users(id),
|
||||||
FOREIGN KEY (depends_on) REFERENCES tasks(id) ON DELETE SET NULL,
|
FOREIGN KEY (depends_on) REFERENCES tasks(id) ON DELETE SET NULL,
|
||||||
FOREIGN KEY (created_by) REFERENCES users(id)
|
FOREIGN KEY (created_by) REFERENCES users(id)
|
||||||
@@ -613,31 +613,109 @@ function createTables() {
|
|||||||
)
|
)
|
||||||
`);
|
`);
|
||||||
|
|
||||||
// Kontakte
|
// Neues optimiertes Kontakt-Schema
|
||||||
db.exec(`
|
db.exec(`
|
||||||
CREATE TABLE IF NOT EXISTS contacts (
|
CREATE TABLE IF NOT EXISTS contacts (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
type TEXT NOT NULL CHECK(type IN ('person', 'company')),
|
||||||
|
|
||||||
|
-- Gemeinsame Felder
|
||||||
|
display_name TEXT NOT NULL,
|
||||||
|
status TEXT DEFAULT 'active' CHECK(status IN ('active', 'inactive', 'archived')),
|
||||||
|
tags TEXT,
|
||||||
|
notes TEXT,
|
||||||
|
avatar_url TEXT,
|
||||||
|
|
||||||
|
-- Person-spezifische Felder
|
||||||
|
salutation TEXT,
|
||||||
first_name TEXT,
|
first_name TEXT,
|
||||||
last_name TEXT,
|
last_name TEXT,
|
||||||
company TEXT,
|
|
||||||
position TEXT,
|
position TEXT,
|
||||||
email TEXT,
|
department TEXT,
|
||||||
phone TEXT,
|
parent_company_id INTEGER,
|
||||||
mobile TEXT,
|
|
||||||
address TEXT,
|
-- Firma-spezifische Felder
|
||||||
postal_code TEXT,
|
company_name TEXT,
|
||||||
city TEXT,
|
company_type TEXT,
|
||||||
country TEXT,
|
industry TEXT,
|
||||||
website TEXT,
|
website TEXT,
|
||||||
notes TEXT,
|
|
||||||
tags TEXT,
|
-- Meta
|
||||||
|
project_id INTEGER NOT NULL,
|
||||||
|
created_by INTEGER NOT NULL,
|
||||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
created_by INTEGER,
|
|
||||||
|
FOREIGN KEY (parent_company_id) REFERENCES contacts(id) ON DELETE SET NULL,
|
||||||
|
FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE,
|
||||||
FOREIGN KEY (created_by) REFERENCES users(id)
|
FOREIGN KEY (created_by) REFERENCES users(id)
|
||||||
)
|
)
|
||||||
`);
|
`);
|
||||||
|
|
||||||
|
// Kontaktdetails (E-Mails, Telefone, Adressen)
|
||||||
|
db.exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS contact_details (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
contact_id INTEGER NOT NULL,
|
||||||
|
type TEXT NOT NULL CHECK(type IN ('email', 'phone', 'mobile', 'fax', 'address', 'social')),
|
||||||
|
label TEXT DEFAULT 'Arbeit',
|
||||||
|
value TEXT NOT NULL,
|
||||||
|
is_primary BOOLEAN DEFAULT 0,
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY (contact_id) REFERENCES contacts(id) ON DELETE CASCADE
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
|
||||||
|
// Kontakt-Kategorien
|
||||||
|
db.exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS contact_categories (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
color TEXT DEFAULT '#6B7280',
|
||||||
|
icon TEXT,
|
||||||
|
project_id INTEGER,
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
|
||||||
|
// Kontakt-Kategorie Zuordnungen
|
||||||
|
db.exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS contact_to_categories (
|
||||||
|
contact_id INTEGER NOT NULL,
|
||||||
|
category_id INTEGER NOT NULL,
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
PRIMARY KEY (contact_id, category_id),
|
||||||
|
FOREIGN KEY (contact_id) REFERENCES contacts(id) ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY (category_id) REFERENCES contact_categories(id) ON DELETE CASCADE
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
|
||||||
|
// Interaktionen/Historie
|
||||||
|
db.exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS contact_interactions (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
contact_id INTEGER NOT NULL,
|
||||||
|
type TEXT NOT NULL CHECK(type IN ('call', 'email', 'meeting', 'note', 'task')),
|
||||||
|
subject TEXT,
|
||||||
|
content TEXT,
|
||||||
|
interaction_date DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
created_by INTEGER NOT NULL,
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY (contact_id) REFERENCES contacts(id) ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY (created_by) REFERENCES users(id)
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
|
||||||
|
// Trigger für updated_at auf Kontakte-Tabelle
|
||||||
|
db.exec(`
|
||||||
|
CREATE TRIGGER IF NOT EXISTS update_contacts_timestamp
|
||||||
|
AFTER UPDATE ON contacts
|
||||||
|
BEGIN
|
||||||
|
UPDATE contacts SET updated_at = CURRENT_TIMESTAMP WHERE id = NEW.id;
|
||||||
|
END
|
||||||
|
`);
|
||||||
|
|
||||||
// Indizes für Performance
|
// Indizes für Performance
|
||||||
db.exec(`
|
db.exec(`
|
||||||
CREATE INDEX IF NOT EXISTS idx_tasks_project ON tasks(project_id);
|
CREATE INDEX IF NOT EXISTS idx_tasks_project ON tasks(project_id);
|
||||||
@@ -660,8 +738,16 @@ function createTables() {
|
|||||||
CREATE INDEX IF NOT EXISTS idx_coding_directories_position ON coding_directories(position);
|
CREATE INDEX IF NOT EXISTS idx_coding_directories_position ON coding_directories(position);
|
||||||
CREATE INDEX IF NOT EXISTS idx_coding_usage_directory ON coding_usage(directory_id);
|
CREATE INDEX IF NOT EXISTS idx_coding_usage_directory ON coding_usage(directory_id);
|
||||||
CREATE INDEX IF NOT EXISTS idx_coding_usage_timestamp ON coding_usage(timestamp);
|
CREATE INDEX IF NOT EXISTS idx_coding_usage_timestamp ON coding_usage(timestamp);
|
||||||
CREATE INDEX IF NOT EXISTS idx_contacts_company ON contacts(company);
|
-- Kontakt-Indizes für Performance
|
||||||
CREATE INDEX IF NOT EXISTS idx_contacts_tags ON contacts(tags);
|
CREATE INDEX IF NOT EXISTS idx_contacts_type ON contacts(type);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_contacts_status ON contacts(status);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_contacts_project ON contacts(project_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_contacts_parent ON contacts(parent_company_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_contacts_display_name ON contacts(display_name);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_details_contact ON contact_details(contact_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_details_type ON contact_details(type);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_interactions_contact ON contact_interactions(contact_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_interactions_date ON contact_interactions(interaction_date);
|
||||||
`);
|
`);
|
||||||
|
|
||||||
logger.info('Datenbank-Tabellen erstellt');
|
logger.info('Datenbank-Tabellen erstellt');
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ const JWT_SECRET = process.env.JWT_SECRET;
|
|||||||
if (!JWT_SECRET || JWT_SECRET.length < 32) {
|
if (!JWT_SECRET || JWT_SECRET.length < 32) {
|
||||||
throw new Error('JWT_SECRET muss in .env gesetzt und mindestens 32 Zeichen lang sein!');
|
throw new Error('JWT_SECRET muss in .env gesetzt und mindestens 32 Zeichen lang sein!');
|
||||||
}
|
}
|
||||||
const ACCESS_TOKEN_EXPIRY = 15; // Minuten (kürzer für mehr Sicherheit)
|
const ACCESS_TOKEN_EXPIRY = 60; // Minuten (kürzer für mehr Sicherheit)
|
||||||
const REFRESH_TOKEN_EXPIRY = 7 * 24 * 60; // 7 Tage in Minuten
|
const REFRESH_TOKEN_EXPIRY = 7 * 24 * 60; // 7 Tage in Minuten
|
||||||
const SESSION_TIMEOUT = parseInt(process.env.SESSION_TIMEOUT) || 30; // Minuten
|
const SESSION_TIMEOUT = parseInt(process.env.SESSION_TIMEOUT) || 30; // Minuten
|
||||||
|
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ const backup = require('../utils/backup');
|
|||||||
*/
|
*/
|
||||||
const DEFAULT_UPLOAD_SETTINGS = {
|
const DEFAULT_UPLOAD_SETTINGS = {
|
||||||
maxFileSizeMB: 15,
|
maxFileSizeMB: 15,
|
||||||
allowedExtensions: ['pdf', 'docx', 'txt']
|
allowedExtensions: ['pdf', 'docx', 'doc', 'txt', 'jpg', 'jpeg', 'png', 'gif', 'webp', 'bmp', 'svg', 'xls', 'xlsx', 'ppt', 'pptx', 'rtf', 'csv', 'json', 'html']
|
||||||
};
|
};
|
||||||
|
|
||||||
// Alle Admin-Routes erfordern Authentifizierung und Admin-Rolle
|
// Alle Admin-Routes erfordern Authentifizierung und Admin-Rolle
|
||||||
|
|||||||
@@ -252,17 +252,20 @@ router.delete('/:id', (req, res) => {
|
|||||||
return res.status(404).json({ error: 'Spalte nicht gefunden' });
|
return res.status(404).json({ error: 'Spalte nicht gefunden' });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Prüfen ob Aufgaben in der Spalte sind
|
// Prüfen ob AKTIVE (nicht-archivierte) Aufgaben in der Spalte sind
|
||||||
const taskCount = db.prepare(
|
const activeTaskCount = db.prepare(
|
||||||
'SELECT COUNT(*) as count FROM tasks WHERE column_id = ?'
|
'SELECT COUNT(*) as count FROM tasks WHERE column_id = ? AND (archived IS NULL OR archived = 0)'
|
||||||
).get(columnId).count;
|
).get(columnId).count;
|
||||||
|
|
||||||
if (taskCount > 0) {
|
if (activeTaskCount > 0) {
|
||||||
return res.status(400).json({
|
return res.status(400).json({
|
||||||
error: 'Spalte enthält noch Aufgaben. Verschiebe oder lösche diese zuerst.'
|
error: 'Spalte enthält noch Aufgaben. Verschiebe oder lösche diese zuerst.'
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Archivierte Aufgaben: column_id auf NULL setzen (bleiben im Archiv erhalten)
|
||||||
|
db.prepare('UPDATE tasks SET column_id = NULL WHERE column_id = ? AND archived = 1').run(columnId);
|
||||||
|
|
||||||
// Mindestens eine Spalte muss bleiben
|
// Mindestens eine Spalte muss bleiben
|
||||||
const columnCount = db.prepare(
|
const columnCount = db.prepare(
|
||||||
'SELECT COUNT(*) as count FROM columns WHERE project_id = ?'
|
'SELECT COUNT(*) as count FROM columns WHERE project_id = ?'
|
||||||
|
|||||||
@@ -1,438 +1,445 @@
|
|||||||
/**
|
/**
|
||||||
* TASKMATE - Contact Routes
|
* TASKMATE - Kontakte API
|
||||||
* =========================
|
* ========================
|
||||||
* CRUD für Kontakte
|
* REST API für Kontaktmanagement
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const express = require('express');
|
const router = require('express').Router();
|
||||||
const router = express.Router();
|
|
||||||
const { getDb } = require('../database');
|
const { getDb } = require('../database');
|
||||||
const logger = require('../utils/logger');
|
const logger = require('../utils/logger');
|
||||||
const { validators } = require('../middleware/validation');
|
const { authenticateToken } = require('../middleware/auth');
|
||||||
|
const { upload } = require('../middleware/upload');
|
||||||
|
|
||||||
|
// =====================
|
||||||
|
// KONTAKTE CRUD
|
||||||
|
// =====================
|
||||||
|
|
||||||
|
// GET /api/contacts - Alle Kontakte abrufen
|
||||||
|
router.get('/', authenticateToken, (req, res) => {
|
||||||
|
// Kein Caching für Kontakte
|
||||||
|
res.set('Cache-Control', 'no-store, no-cache, must-revalidate');
|
||||||
|
res.set('Pragma', 'no-cache');
|
||||||
|
|
||||||
/**
|
|
||||||
* GET /api/contacts
|
|
||||||
* Alle Kontakte abrufen mit optionalem Filter
|
|
||||||
*/
|
|
||||||
router.get('/', (req, res) => {
|
|
||||||
try {
|
try {
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
const { search, tag, sortBy = 'created_at', sortOrder = 'desc' } = req.query;
|
const currentUser = req.user;
|
||||||
|
const { project_id, type, status, search } = req.query;
|
||||||
|
|
||||||
|
logger.info('Kontakte abrufen', { user_id: currentUser.id, filters: req.query });
|
||||||
|
|
||||||
let query = `
|
let query = `
|
||||||
SELECT c.*, u.display_name as creator_name
|
SELECT
|
||||||
|
c.*,
|
||||||
|
u.display_name as created_by_name,
|
||||||
|
pc.display_name as parent_company_name
|
||||||
FROM contacts c
|
FROM contacts c
|
||||||
LEFT JOIN users u ON c.created_by = u.id
|
LEFT JOIN users u ON c.created_by = u.id
|
||||||
|
LEFT JOIN contacts pc ON c.parent_company_id = pc.id
|
||||||
WHERE 1=1
|
WHERE 1=1
|
||||||
`;
|
`;
|
||||||
const params = [];
|
const params = [];
|
||||||
|
|
||||||
// Suchfilter
|
// Filter nach Projekt
|
||||||
|
if (project_id) {
|
||||||
|
query += ' AND c.project_id = ?';
|
||||||
|
params.push(project_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter nach Typ
|
||||||
|
if (type && ['person', 'company'].includes(type)) {
|
||||||
|
query += ' AND c.type = ?';
|
||||||
|
params.push(type);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter nach Status
|
||||||
|
if (status && ['active', 'inactive', 'archived'].includes(status)) {
|
||||||
|
query += ' AND c.status = ?';
|
||||||
|
params.push(status);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Suche
|
||||||
if (search) {
|
if (search) {
|
||||||
query += ` AND (
|
query += ` AND (
|
||||||
|
c.display_name LIKE ? OR
|
||||||
c.first_name LIKE ? OR
|
c.first_name LIKE ? OR
|
||||||
c.last_name LIKE ? OR
|
c.last_name LIKE ? OR
|
||||||
c.company LIKE ? OR
|
c.company_name LIKE ? OR
|
||||||
c.email LIKE ? OR
|
c.tags LIKE ? OR
|
||||||
c.phone LIKE ? OR
|
c.notes LIKE ?
|
||||||
c.mobile LIKE ?
|
|
||||||
)`;
|
)`;
|
||||||
const searchParam = `%${search}%`;
|
const searchPattern = `%${search}%`;
|
||||||
params.push(searchParam, searchParam, searchParam, searchParam, searchParam, searchParam);
|
params.push(searchPattern, searchPattern, searchPattern, searchPattern, searchPattern, searchPattern);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Tag-Filter
|
query += ' ORDER BY c.display_name ASC';
|
||||||
if (tag) {
|
|
||||||
query += ` AND c.tags LIKE ?`;
|
|
||||||
params.push(`%${tag}%`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sortierung
|
const contacts = db.prepare(query).all(...params);
|
||||||
const validSortFields = ['first_name', 'last_name', 'company', 'created_at', 'updated_at'];
|
|
||||||
const sortField = validSortFields.includes(sortBy) ? sortBy : 'created_at';
|
|
||||||
const order = sortOrder.toLowerCase() === 'asc' ? 'ASC' : 'DESC';
|
|
||||||
query += ` ORDER BY c.${sortField} ${order}`;
|
|
||||||
|
|
||||||
const contacts = db.prepare(query).all(params);
|
// Details für jeden Kontakt abrufen
|
||||||
|
const contactsWithDetails = contacts.map(contact => {
|
||||||
|
// Kontaktdetails abrufen
|
||||||
|
const details = db.prepare(`
|
||||||
|
SELECT * FROM contact_details
|
||||||
|
WHERE contact_id = ?
|
||||||
|
ORDER BY is_primary DESC, type, label
|
||||||
|
`).all(contact.id);
|
||||||
|
|
||||||
res.json(contacts.map(c => ({
|
return {
|
||||||
id: c.id,
|
...contact,
|
||||||
firstName: c.first_name,
|
details
|
||||||
lastName: c.last_name,
|
};
|
||||||
company: c.company,
|
});
|
||||||
position: c.position,
|
|
||||||
email: c.email,
|
res.json({ success: true, data: contactsWithDetails });
|
||||||
phone: c.phone,
|
|
||||||
mobile: c.mobile,
|
|
||||||
address: c.address,
|
|
||||||
postalCode: c.postal_code,
|
|
||||||
city: c.city,
|
|
||||||
country: c.country,
|
|
||||||
website: c.website,
|
|
||||||
notes: c.notes,
|
|
||||||
tags: c.tags ? c.tags.split(',').map(t => t.trim()) : [],
|
|
||||||
createdAt: c.created_at,
|
|
||||||
updatedAt: c.updated_at,
|
|
||||||
createdBy: c.created_by,
|
|
||||||
creatorName: c.creator_name
|
|
||||||
})));
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Fehler beim Abrufen der Kontakte:', { error: error.message });
|
logger.error('Fehler beim Abrufen der Kontakte:', error);
|
||||||
res.status(500).json({ error: 'Interner Serverfehler' });
|
res.status(500).json({ success: false, error: error.message });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
// GET /api/contacts/:id - Einzelnen Kontakt abrufen
|
||||||
* GET /api/contacts/:id
|
router.get('/:id', authenticateToken, (req, res) => {
|
||||||
* Einzelnen Kontakt abrufen
|
|
||||||
*/
|
|
||||||
router.get('/:id', (req, res) => {
|
|
||||||
try {
|
try {
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
const contactId = req.params.id;
|
const { id } = req.params;
|
||||||
|
|
||||||
const contact = db.prepare(`
|
const contact = db.prepare(`
|
||||||
SELECT c.*, u.display_name as creator_name
|
SELECT
|
||||||
|
c.*,
|
||||||
|
u.display_name as created_by_name,
|
||||||
|
pc.display_name as parent_company_name
|
||||||
FROM contacts c
|
FROM contacts c
|
||||||
LEFT JOIN users u ON c.created_by = u.id
|
LEFT JOIN users u ON c.created_by = u.id
|
||||||
|
LEFT JOIN contacts pc ON c.parent_company_id = pc.id
|
||||||
WHERE c.id = ?
|
WHERE c.id = ?
|
||||||
`).get(contactId);
|
`).get(id);
|
||||||
|
|
||||||
if (!contact) {
|
if (!contact) {
|
||||||
return res.status(404).json({ error: 'Kontakt nicht gefunden' });
|
return res.status(404).json({ success: false, error: 'Kontakt nicht gefunden' });
|
||||||
}
|
}
|
||||||
|
|
||||||
res.json({
|
// Details abrufen
|
||||||
id: contact.id,
|
contact.details = db.prepare(`
|
||||||
firstName: contact.first_name,
|
SELECT * FROM contact_details
|
||||||
lastName: contact.last_name,
|
WHERE contact_id = ?
|
||||||
company: contact.company,
|
ORDER BY is_primary DESC, type, label
|
||||||
position: contact.position,
|
`).all(id);
|
||||||
email: contact.email,
|
|
||||||
phone: contact.phone,
|
|
||||||
mobile: contact.mobile,
|
// Interaktionen abrufen
|
||||||
address: contact.address,
|
contact.interactions = db.prepare(`
|
||||||
postalCode: contact.postal_code,
|
SELECT ci.*, u.display_name as created_by_name
|
||||||
city: contact.city,
|
FROM contact_interactions ci
|
||||||
country: contact.country,
|
LEFT JOIN users u ON ci.created_by = u.id
|
||||||
website: contact.website,
|
WHERE ci.contact_id = ?
|
||||||
notes: contact.notes,
|
ORDER BY ci.interaction_date DESC
|
||||||
tags: contact.tags ? contact.tags.split(',').map(t => t.trim()) : [],
|
LIMIT 10
|
||||||
createdAt: contact.created_at,
|
`).all(id);
|
||||||
updatedAt: contact.updated_at,
|
|
||||||
createdBy: contact.created_by,
|
res.json({ success: true, data: contact });
|
||||||
creatorName: contact.creator_name
|
|
||||||
});
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Fehler beim Abrufen des Kontakts:', { error: error.message, contactId: req.params.id });
|
logger.error('Fehler beim Abrufen des Kontakts:', error);
|
||||||
res.status(500).json({ error: 'Interner Serverfehler' });
|
res.status(500).json({ success: false, error: error.message });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
// POST /api/contacts - Neuen Kontakt erstellen
|
||||||
* POST /api/contacts
|
router.post('/', authenticateToken, (req, res) => {
|
||||||
* Neuen Kontakt erstellen
|
|
||||||
*/
|
|
||||||
router.post('/', validators.contact, (req, res) => {
|
|
||||||
try {
|
try {
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
const userId = req.user.id;
|
const currentUser = req.user;
|
||||||
const {
|
const { type, project_id, details = [], ...contactData } = req.body;
|
||||||
firstName,
|
|
||||||
lastName,
|
// Validierung
|
||||||
company,
|
if (!type || !['person', 'company'].includes(type)) {
|
||||||
position,
|
return res.status(400).json({
|
||||||
email,
|
success: false,
|
||||||
phone,
|
error: 'Kontakttyp muss "person" oder "company" sein'
|
||||||
mobile,
|
});
|
||||||
address,
|
}
|
||||||
postalCode,
|
|
||||||
city,
|
if (!project_id) {
|
||||||
country,
|
return res.status(400).json({
|
||||||
website,
|
success: false,
|
||||||
notes,
|
error: 'Projekt-ID ist erforderlich'
|
||||||
tags
|
});
|
||||||
} = req.body;
|
}
|
||||||
|
|
||||||
|
// Display Name generieren falls nicht vorhanden
|
||||||
|
if (!contactData.display_name) {
|
||||||
|
if (type === 'person') {
|
||||||
|
contactData.display_name = `${contactData.first_name || ''} ${contactData.last_name || ''}`.trim();
|
||||||
|
} else {
|
||||||
|
contactData.display_name = contactData.company_name || '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Transaktion starten
|
||||||
|
const insertContact = db.prepare(`
|
||||||
|
INSERT INTO contacts (
|
||||||
|
type, project_id, created_by,
|
||||||
|
display_name, status, tags, notes, avatar_url,
|
||||||
|
salutation, first_name, last_name, position, department,
|
||||||
|
parent_company_id,
|
||||||
|
company_name, company_type, industry, website
|
||||||
|
) VALUES (
|
||||||
|
?, ?, ?,
|
||||||
|
?, ?, ?, ?, ?,
|
||||||
|
?, ?, ?, ?, ?,
|
||||||
|
?,
|
||||||
|
?, ?, ?, ?
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
|
||||||
|
const insertDetail = db.prepare(`
|
||||||
|
INSERT INTO contact_details (contact_id, type, label, value, is_primary)
|
||||||
|
VALUES (?, ?, ?, ?, ?)
|
||||||
|
`);
|
||||||
|
|
||||||
|
|
||||||
|
const result = db.transaction(() => {
|
||||||
|
// Kontakt einfügen
|
||||||
|
const info = insertContact.run(
|
||||||
|
type, project_id, currentUser.id,
|
||||||
|
contactData.display_name,
|
||||||
|
contactData.status || 'active',
|
||||||
|
contactData.tags || null,
|
||||||
|
contactData.notes || null,
|
||||||
|
contactData.avatar_url || null,
|
||||||
|
// Person-Felder
|
||||||
|
type === 'person' ? contactData.salutation : null,
|
||||||
|
type === 'person' ? contactData.first_name : null,
|
||||||
|
type === 'person' ? contactData.last_name : null,
|
||||||
|
type === 'person' ? contactData.position : null,
|
||||||
|
type === 'person' ? contactData.department : null,
|
||||||
|
type === 'person' ? contactData.parent_company_id : null,
|
||||||
|
// Company-Felder
|
||||||
|
type === 'company' ? contactData.company_name : null,
|
||||||
|
type === 'company' ? contactData.company_type : null,
|
||||||
|
type === 'company' ? contactData.industry : null,
|
||||||
|
type === 'company' ? contactData.website : null
|
||||||
|
);
|
||||||
|
|
||||||
|
const contactId = info.lastInsertRowid;
|
||||||
|
|
||||||
|
// Details einfügen
|
||||||
|
for (const detail of details) {
|
||||||
|
if (detail.value) {
|
||||||
|
insertDetail.run(
|
||||||
|
contactId,
|
||||||
|
detail.type,
|
||||||
|
detail.label || 'Arbeit',
|
||||||
|
detail.value,
|
||||||
|
detail.is_primary ? 1 : 0
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return contactId;
|
||||||
|
})();
|
||||||
|
|
||||||
|
// Neu erstellten Kontakt mit Details abrufen
|
||||||
|
const newContact = db.prepare(`
|
||||||
|
SELECT * FROM contacts WHERE id = ?
|
||||||
|
`).get(result);
|
||||||
|
|
||||||
|
newContact.details = db.prepare(`
|
||||||
|
SELECT * FROM contact_details WHERE contact_id = ?
|
||||||
|
`).all(result);
|
||||||
|
|
||||||
|
|
||||||
|
logger.info('Kontakt erstellt', { contact_id: result, user_id: currentUser.id });
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: newContact,
|
||||||
|
message: 'Kontakt erfolgreich erstellt'
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Fehler beim Erstellen des Kontakts:', error);
|
||||||
|
res.status(500).json({ success: false, error: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// PUT /api/contacts/:id - Kontakt aktualisieren
|
||||||
|
router.put('/:id', authenticateToken, (req, res) => {
|
||||||
|
try {
|
||||||
|
const db = getDb();
|
||||||
|
const { id } = req.params;
|
||||||
|
const { details = [], ...contactData } = req.body;
|
||||||
|
|
||||||
|
// Prüfen ob Kontakt existiert
|
||||||
|
const existing = db.prepare('SELECT * FROM contacts WHERE id = ?').get(id);
|
||||||
|
if (!existing) {
|
||||||
|
return res.status(404).json({ success: false, error: 'Kontakt nicht gefunden' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update-Query dynamisch aufbauen
|
||||||
|
const fields = [];
|
||||||
|
const values = [];
|
||||||
|
|
||||||
|
// Erlaubte Felder für Update
|
||||||
|
const allowedFields = [
|
||||||
|
'display_name', 'status', 'tags', 'notes', 'avatar_url',
|
||||||
|
'salutation', 'first_name', 'last_name', 'position', 'department',
|
||||||
|
'parent_company_id',
|
||||||
|
'company_name', 'company_type', 'industry', 'website'
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const field of allowedFields) {
|
||||||
|
if (contactData.hasOwnProperty(field)) {
|
||||||
|
fields.push(`${field} = ?`);
|
||||||
|
values.push(contactData[field]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
values.push(id);
|
||||||
|
|
||||||
|
// Transaktion für Updates
|
||||||
|
db.transaction(() => {
|
||||||
|
// Kontakt aktualisieren
|
||||||
|
if (fields.length > 0) {
|
||||||
|
db.prepare(`
|
||||||
|
UPDATE contacts
|
||||||
|
SET ${fields.join(', ')}
|
||||||
|
WHERE id = ?
|
||||||
|
`).run(...values);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Details aktualisieren - alte löschen und neue einfügen
|
||||||
|
db.prepare('DELETE FROM contact_details WHERE contact_id = ?').run(id);
|
||||||
|
|
||||||
|
const insertDetail = db.prepare(`
|
||||||
|
INSERT INTO contact_details (contact_id, type, label, value, is_primary)
|
||||||
|
VALUES (?, ?, ?, ?, ?)
|
||||||
|
`);
|
||||||
|
|
||||||
|
for (const detail of details) {
|
||||||
|
if (detail.value) {
|
||||||
|
insertDetail.run(
|
||||||
|
id,
|
||||||
|
detail.type,
|
||||||
|
detail.label || 'Arbeit',
|
||||||
|
detail.value,
|
||||||
|
detail.is_primary ? 1 : 0
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
})();
|
||||||
|
|
||||||
|
// Aktualisierten Kontakt abrufen
|
||||||
|
const updatedContact = db.prepare(`
|
||||||
|
SELECT * FROM contacts WHERE id = ?
|
||||||
|
`).get(id);
|
||||||
|
|
||||||
|
updatedContact.details = db.prepare(`
|
||||||
|
SELECT * FROM contact_details WHERE contact_id = ?
|
||||||
|
`).all(id);
|
||||||
|
logger.info('Kontakt aktualisiert', { contact_id: id });
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: updatedContact,
|
||||||
|
message: 'Kontakt erfolgreich aktualisiert'
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Fehler beim Aktualisieren des Kontakts:', error);
|
||||||
|
res.status(500).json({ success: false, error: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// DELETE /api/contacts/:id - Kontakt löschen
|
||||||
|
router.delete('/:id', authenticateToken, (req, res) => {
|
||||||
|
try {
|
||||||
|
const db = getDb();
|
||||||
|
const { id } = req.params;
|
||||||
|
|
||||||
|
const result = db.prepare('DELETE FROM contacts WHERE id = ?').run(id);
|
||||||
|
|
||||||
|
if (result.changes === 0) {
|
||||||
|
return res.status(404).json({ success: false, error: 'Kontakt nicht gefunden' });
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info('Kontakt gelöscht', { contact_id: id });
|
||||||
|
|
||||||
|
res.json({ success: true, message: 'Kontakt erfolgreich gelöscht' });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Fehler beim Löschen des Kontakts:', error);
|
||||||
|
res.status(500).json({ success: false, error: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// =====================
|
||||||
|
// INTERAKTIONEN (nach /:id Routen)
|
||||||
|
// =====================
|
||||||
|
|
||||||
|
// POST /api/contacts/:id/interactions - Neue Interaktion hinzufügen
|
||||||
|
router.post('/:id/interactions', authenticateToken, (req, res) => {
|
||||||
|
try {
|
||||||
|
const db = getDb();
|
||||||
|
const currentUser = req.user;
|
||||||
|
const { id } = req.params;
|
||||||
|
const { type, subject, content } = req.body;
|
||||||
|
|
||||||
|
if (!type || !['call', 'email', 'meeting', 'note', 'task'].includes(type)) {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
error: 'Ungültiger Interaktionstyp'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const result = db.prepare(`
|
const result = db.prepare(`
|
||||||
INSERT INTO contacts (
|
INSERT INTO contact_interactions (
|
||||||
first_name, last_name, company, position,
|
contact_id, type, subject, content, created_by
|
||||||
email, phone, mobile, address, postal_code,
|
) VALUES (?, ?, ?, ?, ?)
|
||||||
city, country, website, notes, tags,
|
`).run(id, type, subject, content, currentUser.id);
|
||||||
created_by
|
|
||||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
||||||
`).run(
|
|
||||||
firstName || null,
|
|
||||||
lastName || null,
|
|
||||||
company || null,
|
|
||||||
position || null,
|
|
||||||
email || null,
|
|
||||||
phone || null,
|
|
||||||
mobile || null,
|
|
||||||
address || null,
|
|
||||||
postalCode || null,
|
|
||||||
city || null,
|
|
||||||
country || null,
|
|
||||||
website || null,
|
|
||||||
notes || null,
|
|
||||||
Array.isArray(tags) ? tags.join(', ') : null,
|
|
||||||
userId
|
|
||||||
);
|
|
||||||
|
|
||||||
const newContact = db.prepare(`
|
const newInteraction = db.prepare(`
|
||||||
SELECT c.*, u.display_name as creator_name
|
SELECT ci.*, u.display_name as created_by_name
|
||||||
FROM contacts c
|
FROM contact_interactions ci
|
||||||
LEFT JOIN users u ON c.created_by = u.id
|
LEFT JOIN users u ON ci.created_by = u.id
|
||||||
WHERE c.id = ?
|
WHERE ci.id = ?
|
||||||
`).get(result.lastInsertRowid);
|
`).get(result.lastInsertRowid);
|
||||||
|
|
||||||
// Socket.io Event
|
res.json({ success: true, data: newInteraction });
|
||||||
const io = req.app.get('io');
|
|
||||||
io.emit('contact:created', {
|
|
||||||
contact: {
|
|
||||||
id: newContact.id,
|
|
||||||
firstName: newContact.first_name,
|
|
||||||
lastName: newContact.last_name,
|
|
||||||
company: newContact.company,
|
|
||||||
position: newContact.position,
|
|
||||||
email: newContact.email,
|
|
||||||
phone: newContact.phone,
|
|
||||||
mobile: newContact.mobile,
|
|
||||||
address: newContact.address,
|
|
||||||
postalCode: newContact.postal_code,
|
|
||||||
city: newContact.city,
|
|
||||||
country: newContact.country,
|
|
||||||
website: newContact.website,
|
|
||||||
notes: newContact.notes,
|
|
||||||
tags: newContact.tags ? newContact.tags.split(',').map(t => t.trim()) : [],
|
|
||||||
createdAt: newContact.created_at,
|
|
||||||
updatedAt: newContact.updated_at,
|
|
||||||
createdBy: newContact.created_by,
|
|
||||||
creatorName: newContact.creator_name
|
|
||||||
},
|
|
||||||
userId
|
|
||||||
});
|
|
||||||
|
|
||||||
res.status(201).json({
|
|
||||||
id: newContact.id,
|
|
||||||
firstName: newContact.first_name,
|
|
||||||
lastName: newContact.last_name,
|
|
||||||
company: newContact.company,
|
|
||||||
position: newContact.position,
|
|
||||||
email: newContact.email,
|
|
||||||
phone: newContact.phone,
|
|
||||||
mobile: newContact.mobile,
|
|
||||||
address: newContact.address,
|
|
||||||
postalCode: newContact.postal_code,
|
|
||||||
city: newContact.city,
|
|
||||||
country: newContact.country,
|
|
||||||
website: newContact.website,
|
|
||||||
notes: newContact.notes,
|
|
||||||
tags: newContact.tags ? newContact.tags.split(',').map(t => t.trim()) : [],
|
|
||||||
createdAt: newContact.created_at,
|
|
||||||
updatedAt: newContact.updated_at,
|
|
||||||
createdBy: newContact.created_by,
|
|
||||||
creatorName: newContact.creator_name
|
|
||||||
});
|
|
||||||
|
|
||||||
logger.info('Kontakt erstellt', { contactId: newContact.id, userId });
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Fehler beim Erstellen des Kontakts:', { error: error.message, body: req.body });
|
logger.error('Fehler beim Hinzufügen der Interaktion:', error);
|
||||||
res.status(500).json({ error: 'Interner Serverfehler' });
|
res.status(500).json({ success: false, error: error.message });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
// =====================
|
||||||
* PUT /api/contacts/:id
|
// DATEI-UPLOAD (nach /:id Routen)
|
||||||
* Kontakt aktualisieren
|
// =====================
|
||||||
*/
|
|
||||||
router.put('/:id', validators.contact, (req, res) => {
|
// POST /api/contacts/:id/avatar - Avatar hochladen
|
||||||
|
router.post('/:id/avatar', authenticateToken, upload.single('files'), async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
const contactId = req.params.id;
|
const { id } = req.params;
|
||||||
const userId = req.user.id;
|
|
||||||
const {
|
|
||||||
firstName,
|
|
||||||
lastName,
|
|
||||||
company,
|
|
||||||
position,
|
|
||||||
email,
|
|
||||||
phone,
|
|
||||||
mobile,
|
|
||||||
address,
|
|
||||||
postalCode,
|
|
||||||
city,
|
|
||||||
country,
|
|
||||||
website,
|
|
||||||
notes,
|
|
||||||
tags
|
|
||||||
} = req.body;
|
|
||||||
|
|
||||||
// Prüfen ob Kontakt existiert
|
if (!req.file) {
|
||||||
const existing = db.prepare('SELECT id FROM contacts WHERE id = ?').get(contactId);
|
return res.status(400).json({
|
||||||
if (!existing) {
|
success: false,
|
||||||
return res.status(404).json({ error: 'Kontakt nicht gefunden' });
|
error: 'Keine Datei hochgeladen'
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update
|
// Avatar-URL speichern
|
||||||
db.prepare(`
|
const avatarUrl = `/uploads/${req.file.filename}`;
|
||||||
UPDATE contacts SET
|
|
||||||
first_name = ?,
|
|
||||||
last_name = ?,
|
|
||||||
company = ?,
|
|
||||||
position = ?,
|
|
||||||
email = ?,
|
|
||||||
phone = ?,
|
|
||||||
mobile = ?,
|
|
||||||
address = ?,
|
|
||||||
postal_code = ?,
|
|
||||||
city = ?,
|
|
||||||
country = ?,
|
|
||||||
website = ?,
|
|
||||||
notes = ?,
|
|
||||||
tags = ?,
|
|
||||||
updated_at = CURRENT_TIMESTAMP
|
|
||||||
WHERE id = ?
|
|
||||||
`).run(
|
|
||||||
firstName || null,
|
|
||||||
lastName || null,
|
|
||||||
company || null,
|
|
||||||
position || null,
|
|
||||||
email || null,
|
|
||||||
phone || null,
|
|
||||||
mobile || null,
|
|
||||||
address || null,
|
|
||||||
postalCode || null,
|
|
||||||
city || null,
|
|
||||||
country || null,
|
|
||||||
website || null,
|
|
||||||
notes || null,
|
|
||||||
Array.isArray(tags) ? tags.join(', ') : null,
|
|
||||||
contactId
|
|
||||||
);
|
|
||||||
|
|
||||||
const updatedContact = db.prepare(`
|
db.prepare('UPDATE contacts SET avatar_url = ? WHERE id = ?')
|
||||||
SELECT c.*, u.display_name as creator_name
|
.run(avatarUrl, id);
|
||||||
FROM contacts c
|
|
||||||
LEFT JOIN users u ON c.created_by = u.id
|
|
||||||
WHERE c.id = ?
|
|
||||||
`).get(contactId);
|
|
||||||
|
|
||||||
// Socket.io Event
|
|
||||||
const io = req.app.get('io');
|
|
||||||
io.emit('contact:updated', {
|
|
||||||
contact: {
|
|
||||||
id: updatedContact.id,
|
|
||||||
firstName: updatedContact.first_name,
|
|
||||||
lastName: updatedContact.last_name,
|
|
||||||
company: updatedContact.company,
|
|
||||||
position: updatedContact.position,
|
|
||||||
email: updatedContact.email,
|
|
||||||
phone: updatedContact.phone,
|
|
||||||
mobile: updatedContact.mobile,
|
|
||||||
address: updatedContact.address,
|
|
||||||
postalCode: updatedContact.postal_code,
|
|
||||||
city: updatedContact.city,
|
|
||||||
country: updatedContact.country,
|
|
||||||
website: updatedContact.website,
|
|
||||||
notes: updatedContact.notes,
|
|
||||||
tags: updatedContact.tags ? updatedContact.tags.split(',').map(t => t.trim()) : [],
|
|
||||||
createdAt: updatedContact.created_at,
|
|
||||||
updatedAt: updatedContact.updated_at,
|
|
||||||
createdBy: updatedContact.created_by,
|
|
||||||
creatorName: updatedContact.creator_name
|
|
||||||
},
|
|
||||||
userId
|
|
||||||
});
|
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
id: updatedContact.id,
|
success: true,
|
||||||
firstName: updatedContact.first_name,
|
data: { avatar_url: avatarUrl },
|
||||||
lastName: updatedContact.last_name,
|
message: 'Avatar erfolgreich hochgeladen'
|
||||||
company: updatedContact.company,
|
|
||||||
position: updatedContact.position,
|
|
||||||
email: updatedContact.email,
|
|
||||||
phone: updatedContact.phone,
|
|
||||||
mobile: updatedContact.mobile,
|
|
||||||
address: updatedContact.address,
|
|
||||||
postalCode: updatedContact.postal_code,
|
|
||||||
city: updatedContact.city,
|
|
||||||
country: updatedContact.country,
|
|
||||||
website: updatedContact.website,
|
|
||||||
notes: updatedContact.notes,
|
|
||||||
tags: updatedContact.tags ? updatedContact.tags.split(',').map(t => t.trim()) : [],
|
|
||||||
createdAt: updatedContact.created_at,
|
|
||||||
updatedAt: updatedContact.updated_at,
|
|
||||||
createdBy: updatedContact.created_by,
|
|
||||||
creatorName: updatedContact.creator_name
|
|
||||||
});
|
});
|
||||||
|
|
||||||
logger.info('Kontakt aktualisiert', { contactId, userId });
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Fehler beim Aktualisieren des Kontakts:', { error: error.message, contactId: req.params.id });
|
logger.error('Fehler beim Avatar-Upload:', error);
|
||||||
res.status(500).json({ error: 'Interner Serverfehler' });
|
res.status(500).json({ success: false, error: error.message });
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* DELETE /api/contacts/:id
|
|
||||||
* Kontakt löschen
|
|
||||||
*/
|
|
||||||
router.delete('/:id', (req, res) => {
|
|
||||||
try {
|
|
||||||
const db = getDb();
|
|
||||||
const contactId = req.params.id;
|
|
||||||
const userId = req.user.id;
|
|
||||||
|
|
||||||
// Prüfen ob Kontakt existiert
|
|
||||||
const existing = db.prepare('SELECT id FROM contacts WHERE id = ?').get(contactId);
|
|
||||||
if (!existing) {
|
|
||||||
return res.status(404).json({ error: 'Kontakt nicht gefunden' });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Löschen
|
|
||||||
db.prepare('DELETE FROM contacts WHERE id = ?').run(contactId);
|
|
||||||
|
|
||||||
// Socket.io Event
|
|
||||||
const io = req.app.get('io');
|
|
||||||
io.emit('contact:deleted', { contactId, userId });
|
|
||||||
|
|
||||||
res.json({ success: true });
|
|
||||||
|
|
||||||
logger.info('Kontakt gelöscht', { contactId, userId });
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('Fehler beim Löschen des Kontakts:', { error: error.message, contactId: req.params.id });
|
|
||||||
res.status(500).json({ error: 'Interner Serverfehler' });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* GET /api/contacts/tags
|
|
||||||
* Alle verwendeten Tags abrufen
|
|
||||||
*/
|
|
||||||
router.get('/tags/all', (req, res) => {
|
|
||||||
try {
|
|
||||||
const db = getDb();
|
|
||||||
|
|
||||||
const contacts = db.prepare('SELECT DISTINCT tags FROM contacts WHERE tags IS NOT NULL').all();
|
|
||||||
|
|
||||||
// Alle Tags sammeln und deduplizieren
|
|
||||||
const allTags = new Set();
|
|
||||||
contacts.forEach(contact => {
|
|
||||||
if (contact.tags) {
|
|
||||||
contact.tags.split(',').forEach(tag => {
|
|
||||||
const trimmedTag = tag.trim();
|
|
||||||
if (trimmedTag) {
|
|
||||||
allTags.add(trimmedTag);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
res.json(Array.from(allTags).sort());
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('Fehler beim Abrufen der Tags:', { error: error.message });
|
|
||||||
res.status(500).json({ error: 'Interner Serverfehler' });
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -522,7 +522,7 @@ router.post('/entries', (req, res) => {
|
|||||||
actorId: req.user.id
|
actorId: req.user.id
|
||||||
},
|
},
|
||||||
io,
|
io,
|
||||||
false // nicht persistent
|
true // persistent, damit die Benachrichtigung in der Inbox bleibt
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -828,7 +828,7 @@ router.post('/:id/duplicate', (req, res) => {
|
|||||||
router.put('/:id/archive', (req, res) => {
|
router.put('/:id/archive', (req, res) => {
|
||||||
try {
|
try {
|
||||||
const taskId = req.params.id;
|
const taskId = req.params.id;
|
||||||
const { archived } = req.body;
|
const { archived, columnId } = req.body;
|
||||||
|
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
|
|
||||||
@@ -837,16 +837,42 @@ router.put('/:id/archive', (req, res) => {
|
|||||||
return res.status(404).json({ error: 'Aufgabe nicht gefunden' });
|
return res.status(404).json({ error: 'Aufgabe nicht gefunden' });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Bei Wiederherstellung: Prüfen ob Spalte vorhanden
|
||||||
|
if (!archived && !task.column_id) {
|
||||||
|
// Task hat keine Spalte (wurde mit gelöschter Spalte archiviert)
|
||||||
|
if (!columnId) {
|
||||||
|
return res.status(400).json({
|
||||||
|
error: 'Spalte erforderlich',
|
||||||
|
requiresColumn: true
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// Prüfen ob Spalte existiert und zum Projekt gehört
|
||||||
|
const column = db.prepare('SELECT * FROM columns WHERE id = ? AND project_id = ?')
|
||||||
|
.get(columnId, task.project_id);
|
||||||
|
if (!column) {
|
||||||
|
return res.status(400).json({ error: 'Ungültige Spalte' });
|
||||||
|
}
|
||||||
|
// Wiederherstellen mit neuer Spalte
|
||||||
|
db.prepare('UPDATE tasks SET archived = 0, column_id = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?')
|
||||||
|
.run(columnId, taskId);
|
||||||
|
} else {
|
||||||
|
// Normales Archivieren/Wiederherstellen
|
||||||
db.prepare('UPDATE tasks SET archived = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?')
|
db.prepare('UPDATE tasks SET archived = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?')
|
||||||
.run(archived ? 1 : 0, taskId);
|
.run(archived ? 1 : 0, taskId);
|
||||||
|
}
|
||||||
|
|
||||||
addHistory(db, taskId, req.user.id, archived ? 'archived' : 'restored');
|
addHistory(db, taskId, req.user.id, archived ? 'archived' : 'restored');
|
||||||
|
|
||||||
logger.info(`Aufgabe ${archived ? 'archiviert' : 'wiederhergestellt'}: ${task.title}`);
|
logger.info(`Aufgabe ${archived ? 'archiviert' : 'wiederhergestellt'}: ${task.title}`);
|
||||||
|
|
||||||
// WebSocket
|
// WebSocket - vollständige Task-Daten senden
|
||||||
const io = req.app.get('io');
|
const io = req.app.get('io');
|
||||||
io.to(`project:${task.project_id}`).emit('task:archived', { id: taskId, archived: !!archived });
|
const updatedTask = getFullTask(db, taskId);
|
||||||
|
io.to(`project:${task.project_id}`).emit('task:archived', {
|
||||||
|
id: taskId,
|
||||||
|
archived: !!archived,
|
||||||
|
columnId: updatedTask?.columnId
|
||||||
|
});
|
||||||
|
|
||||||
res.json({ message: archived ? 'Aufgabe archiviert' : 'Aufgabe wiederhergestellt' });
|
res.json({ message: archived ? 'Aufgabe archiviert' : 'Aufgabe wiederhergestellt' });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
"namespace": "android_app",
|
"namespace": "android_app",
|
||||||
"package_name": "de.aegissight.taskmate",
|
"package_name": "de.aegissight.taskmate",
|
||||||
"sha256_cert_fingerprints": [
|
"sha256_cert_fingerprints": [
|
||||||
"TO_BE_REPLACED_WITH_YOUR_APP_SIGNING_KEY_FINGERPRINT"
|
"75:8D:45:A0:FF:33:7E:39:69:A7:E2:13:CE:6A:3C:A4:C8:67:2F:6A:21:91:42:4C:74:B1:BC:B9:C5:B8:74:F2"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}]
|
}]
|
||||||
@@ -112,13 +112,15 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
height: var(--header-height);
|
min-height: var(--header-height);
|
||||||
padding: 0 var(--spacing-4);
|
padding: 0 var(--spacing-4);
|
||||||
background: var(--bg-card);
|
background: var(--bg-card);
|
||||||
border-bottom: 1px solid var(--border-light);
|
border-bottom: 1px solid var(--border-light);
|
||||||
position: sticky;
|
position: sticky;
|
||||||
top: 0;
|
top: 0;
|
||||||
z-index: var(--z-sticky);
|
z-index: var(--z-sticky);
|
||||||
|
width: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
.header-left,
|
.header-left,
|
||||||
@@ -126,11 +128,20 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: var(--spacing-3);
|
gap: var(--spacing-3);
|
||||||
|
flex-shrink: 0;
|
||||||
|
flex-wrap: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.header-center {
|
.view-tabs-bar {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
padding: var(--spacing-1) var(--spacing-4);
|
||||||
|
background: var(--bg-card);
|
||||||
|
border-bottom: 1px solid var(--border-light);
|
||||||
|
position: sticky;
|
||||||
|
top: var(--header-height);
|
||||||
|
z-index: calc(var(--z-sticky) - 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
.logo {
|
.logo {
|
||||||
@@ -150,7 +161,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.project-select {
|
.project-select {
|
||||||
min-width: 160px;
|
width: 100%;
|
||||||
|
min-width: 120px;
|
||||||
max-width: 200px;
|
max-width: 200px;
|
||||||
font-weight: var(--font-medium);
|
font-weight: var(--font-medium);
|
||||||
font-size: var(--text-sm);
|
font-size: var(--text-sm);
|
||||||
@@ -166,15 +178,17 @@
|
|||||||
position: relative;
|
position: relative;
|
||||||
backdrop-filter: blur(10px);
|
backdrop-filter: blur(10px);
|
||||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.1);
|
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.1);
|
||||||
|
justify-content: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.view-tab {
|
.view-tab {
|
||||||
position: relative;
|
position: relative;
|
||||||
display: flex;
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: var(--spacing-2);
|
gap: 2px;
|
||||||
padding: 12px 20px;
|
padding: 8px 12px;
|
||||||
font-size: var(--text-sm);
|
font-size: 11px;
|
||||||
font-weight: var(--font-medium);
|
font-weight: var(--font-medium);
|
||||||
color: var(--text-tertiary);
|
color: var(--text-tertiary);
|
||||||
background: none;
|
background: none;
|
||||||
@@ -183,6 +197,7 @@
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all var(--transition-default);
|
transition: all var(--transition-default);
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Tab Icon */
|
/* Tab Icon */
|
||||||
@@ -207,7 +222,7 @@
|
|||||||
/* Active State with Underline */
|
/* Active State with Underline */
|
||||||
.view-tab.active {
|
.view-tab.active {
|
||||||
color: var(--primary);
|
color: var(--primary);
|
||||||
background: rgba(59, 130, 246, 0.1);
|
background: rgba(200, 168, 81, 0.1);
|
||||||
border-radius: var(--radius-md);
|
border-radius: var(--radius-md);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -256,6 +271,9 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: var(--spacing-2);
|
gap: var(--spacing-2);
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-width: 150px;
|
||||||
|
max-width: 450px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.search-icon {
|
.search-icon {
|
||||||
@@ -266,8 +284,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.search-input {
|
.search-input {
|
||||||
width: 450px;
|
width: 100%;
|
||||||
min-width: 350px;
|
max-width: 450px;
|
||||||
padding: 10px 16px;
|
padding: 10px 16px;
|
||||||
background: var(--bg-tertiary);
|
background: var(--bg-tertiary);
|
||||||
border: 1px solid transparent;
|
border: 1px solid transparent;
|
||||||
@@ -439,6 +457,23 @@
|
|||||||
border-radius: var(--radius-full);
|
border-radius: var(--radius-full);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all var(--transition-fast);
|
transition: all var(--transition-fast);
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-avatar::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
bottom: 0;
|
||||||
|
right: 0;
|
||||||
|
width: 10px;
|
||||||
|
height: 10px;
|
||||||
|
background: var(--success);
|
||||||
|
border-radius: 50%;
|
||||||
|
border: 2px solid var(--bg-card);
|
||||||
|
}
|
||||||
|
|
||||||
|
.offline .user-avatar::after {
|
||||||
|
background: var(--error);
|
||||||
}
|
}
|
||||||
|
|
||||||
.user-avatar:hover {
|
.user-avatar:hover {
|
||||||
@@ -447,16 +482,16 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.user-dropdown {
|
.user-dropdown {
|
||||||
position: absolute;
|
position: fixed;
|
||||||
top: calc(100% + 8px);
|
top: 60px;
|
||||||
right: 0;
|
right: 20px;
|
||||||
min-width: 220px;
|
min-width: 220px;
|
||||||
padding: var(--spacing-2);
|
padding: var(--spacing-2);
|
||||||
background: var(--bg-card);
|
background: var(--bg-card);
|
||||||
border: 1px solid var(--border-default);
|
border: 1px solid var(--border-default);
|
||||||
border-radius: var(--radius-xl);
|
border-radius: var(--radius-xl);
|
||||||
box-shadow: var(--shadow-lg);
|
box-shadow: var(--shadow-lg);
|
||||||
z-index: var(--z-dropdown);
|
z-index: 9999;
|
||||||
}
|
}
|
||||||
|
|
||||||
.user-info {
|
.user-info {
|
||||||
@@ -524,34 +559,125 @@
|
|||||||
FILTER BAR
|
FILTER BAR
|
||||||
======================================== */
|
======================================== */
|
||||||
|
|
||||||
.filter-bar {
|
.filter-bar-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
gap: var(--spacing-2);
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: flex-start;
|
|
||||||
gap: var(--spacing-4);
|
|
||||||
padding: var(--spacing-3) var(--spacing-4);
|
|
||||||
background: var(--bg-card);
|
|
||||||
border-bottom: 1px solid var(--border-light);
|
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
width: 100%;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.filter-group {
|
.filter-toggle-btn {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
gap: var(--spacing-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-toggle-btn.has-filters {
|
||||||
|
color: var(--primary);
|
||||||
|
border-color: var(--primary);
|
||||||
|
background: var(--primary-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-popover {
|
||||||
|
position: absolute;
|
||||||
|
top: 100%;
|
||||||
|
right: var(--spacing-4);
|
||||||
|
z-index: var(--z-dropdown);
|
||||||
|
min-width: 280px;
|
||||||
|
background: var(--bg-card);
|
||||||
|
border: 1px solid var(--border-light);
|
||||||
|
border-radius: var(--radius-xl);
|
||||||
|
box-shadow: var(--shadow-lg);
|
||||||
|
padding: var(--spacing-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-popover.hidden {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-popover-content {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
gap: var(--spacing-3);
|
gap: var(--spacing-3);
|
||||||
}
|
}
|
||||||
|
|
||||||
.filter-group label {
|
.filter-popover-row {
|
||||||
font-size: var(--text-sm);
|
display: flex;
|
||||||
font-weight: var(--font-medium);
|
flex-direction: column;
|
||||||
color: var(--text-tertiary);
|
gap: var(--spacing-1);
|
||||||
}
|
}
|
||||||
|
|
||||||
.filter-actions {
|
.filter-popover-row label {
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
font-weight: var(--font-semibold);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-checkbox-list {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
max-height: 200px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-checkbox-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
gap: var(--spacing-2);
|
gap: var(--spacing-2);
|
||||||
margin-left: auto;
|
padding: 6px 8px;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-checkbox-item:hover {
|
||||||
|
background: var(--bg-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-checkbox-item input[type="checkbox"] {
|
||||||
|
appearance: none;
|
||||||
|
-webkit-appearance: none;
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
min-width: 20px;
|
||||||
|
border: 1px solid var(--border-default);
|
||||||
|
border-radius: 4px;
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
cursor: pointer;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-checkbox-item input[type="checkbox"]:hover {
|
||||||
|
border-color: var(--primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-checkbox-item input[type="checkbox"]:checked {
|
||||||
|
background: var(--primary);
|
||||||
|
border-color: var(--primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-checkbox-item input[type="checkbox"]:checked::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
left: 6px;
|
||||||
|
top: 2px;
|
||||||
|
width: 5px;
|
||||||
|
height: 10px;
|
||||||
|
border: solid white;
|
||||||
|
border-width: 0 2px 2px 0;
|
||||||
|
transform: rotate(45deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-popover-footer {
|
||||||
|
padding-top: var(--spacing-2);
|
||||||
|
border-top: 1px solid var(--border-light);
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
margin-top: var(--spacing-2);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ========================================
|
/* ========================================
|
||||||
@@ -637,26 +763,26 @@
|
|||||||
background: var(--bg-main);
|
background: var(--bg-main);
|
||||||
}
|
}
|
||||||
|
|
||||||
.view-board .filter-bar {
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.board {
|
.board {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: var(--spacing-3);
|
gap: var(--spacing-3);
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
|
overflow-y: hidden;
|
||||||
padding: var(--spacing-4);
|
padding: var(--spacing-4);
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
|
width: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Column */
|
/* Column */
|
||||||
.column {
|
.column {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
width: 280px;
|
width: var(--column-width, 280px);
|
||||||
min-width: 260px;
|
min-width: var(--column-min-width, 260px);
|
||||||
|
max-width: 100%;
|
||||||
max-height: calc(100vh - var(--header-height) - 160px);
|
max-height: calc(100vh - var(--header-height) - 160px);
|
||||||
background: var(--bg-card);
|
background: var(--bg-card);
|
||||||
border: 1px solid var(--border-light);
|
border: 1px solid var(--border-light);
|
||||||
@@ -697,7 +823,10 @@
|
|||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 0.05em;
|
letter-spacing: 0.05em;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
padding-right: var(--spacing-8);
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.column-count {
|
.column-count {
|
||||||
@@ -716,12 +845,12 @@
|
|||||||
|
|
||||||
.column-actions {
|
.column-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
gap: var(--spacing-1);
|
gap: var(--spacing-1);
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
transition: opacity var(--transition-fast);
|
transition: opacity var(--transition-fast);
|
||||||
position: absolute;
|
flex-shrink: 0;
|
||||||
top: var(--spacing-3);
|
margin-left: var(--spacing-2);
|
||||||
right: var(--spacing-3);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.column-actions .btn-icon {
|
.column-actions .btn-icon {
|
||||||
@@ -1276,75 +1405,3 @@
|
|||||||
gap: var(--spacing-2);
|
gap: var(--spacing-2);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Base Multi-Column Layout - aktiviert das Feature, aber zeigt noch einspaltig */
|
|
||||||
.board.multi-column-layout .column-body {
|
|
||||||
/* Bleibt erstmal bei flex layout bis Inhalt zu lang wird */
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: var(--spacing-2);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Dynamisch aktivierte 2-spaltige Ansicht (wenn Scrollen nötig wäre) */
|
|
||||||
.board.multi-column-layout .column-body.dynamic-2-columns {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: 1fr 1fr;
|
|
||||||
grid-auto-flow: row;
|
|
||||||
gap: var(--spacing-2);
|
|
||||||
align-content: start;
|
|
||||||
overflow-x: hidden;
|
|
||||||
overflow-y: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Dynamisch aktivierte 3-spaltige Ansicht (wenn viel Inhalt) */
|
|
||||||
.board.multi-column-layout .column-body.dynamic-3-columns {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: 1fr 1fr 1fr;
|
|
||||||
grid-auto-flow: row;
|
|
||||||
gap: var(--spacing-2);
|
|
||||||
align-content: start;
|
|
||||||
overflow-x: hidden;
|
|
||||||
overflow-y: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Spalten-Breite wenn erweitert */
|
|
||||||
.board.multi-column-layout .column {
|
|
||||||
transition: width var(--transition-default), min-width var(--transition-default);
|
|
||||||
}
|
|
||||||
|
|
||||||
.board.multi-column-layout .column.expanded-2x {
|
|
||||||
width: auto;
|
|
||||||
min-width: 560px;
|
|
||||||
max-width: 640px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.board.multi-column-layout .column.expanded-3x {
|
|
||||||
width: auto;
|
|
||||||
min-width: 840px;
|
|
||||||
max-width: 960px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Task Cards im Multi-Column Layout */
|
|
||||||
.board.multi-column-layout .task-card {
|
|
||||||
width: 100%;
|
|
||||||
box-sizing: border-box;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Hover-Effekt für Layout-Toggle Button */
|
|
||||||
#btn-toggle-layout {
|
|
||||||
transition: all var(--transition-fast);
|
|
||||||
}
|
|
||||||
|
|
||||||
#btn-toggle-layout:hover {
|
|
||||||
transform: rotate(90deg);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Active state indicator - korrigiert für richtige Selektion */
|
|
||||||
.view-board.active .board.multi-column-layout ~ * {
|
|
||||||
/* Dummy rule to ensure the layout class is applied */
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Layout toggle button active state */
|
|
||||||
#btn-toggle-layout.active {
|
|
||||||
color: var(--primary);
|
|
||||||
background: var(--primary-light);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
======================================== */
|
======================================== */
|
||||||
|
|
||||||
.view-calendar {
|
.view-calendar {
|
||||||
display: flex;
|
display: none;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
padding: var(--spacing-6);
|
padding: var(--spacing-6);
|
||||||
gap: var(--spacing-4);
|
gap: var(--spacing-4);
|
||||||
@@ -17,6 +17,10 @@
|
|||||||
height: 100%;
|
height: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.view-calendar.active {
|
||||||
|
display: flex !important;
|
||||||
|
}
|
||||||
|
|
||||||
/* Calendar Header */
|
/* Calendar Header */
|
||||||
.calendar-header {
|
.calendar-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@@ -175,8 +175,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.git-status-badge.ahead {
|
.git-status-badge.ahead {
|
||||||
background: rgba(59, 130, 246, 0.15);
|
background: rgba(124, 141, 181, 0.15);
|
||||||
color: #3B82F6;
|
color: var(--info);
|
||||||
}
|
}
|
||||||
|
|
||||||
.git-status-badge.behind {
|
.git-status-badge.behind {
|
||||||
|
|||||||
@@ -170,7 +170,7 @@ select {
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 10px 14px;
|
padding: 10px 14px;
|
||||||
font-family: var(--font-primary);
|
font-family: var(--font-primary);
|
||||||
font-size: var(--text-sm);
|
font-size: var(--text-base); /* 16px - verhindert iOS Zoom bei Focus */
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
background: var(--bg-input);
|
background: var(--bg-input);
|
||||||
border: 1px solid var(--border-default);
|
border: 1px solid var(--border-default);
|
||||||
@@ -604,7 +604,8 @@ input[type="color"] {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
gap: var(--spacing-3);
|
gap: var(--spacing-3);
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: var(--spacing-2) var(--spacing-3);
|
padding: var(--spacing-3) var(--spacing-4);
|
||||||
|
min-height: 44px;
|
||||||
font-size: var(--text-sm);
|
font-size: var(--text-sm);
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
background: none;
|
background: none;
|
||||||
|
|||||||
Datei-Diff unterdrückt, da er zu groß ist
Diff laden
@@ -796,7 +796,7 @@
|
|||||||
|
|
||||||
.drop-zone.drag-over {
|
.drop-zone.drag-over {
|
||||||
border-color: var(--primary);
|
border-color: var(--primary);
|
||||||
background: rgba(59, 130, 246, 0.05);
|
background: rgba(200, 168, 81, 0.08);
|
||||||
color: var(--primary);
|
color: var(--primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -287,10 +287,10 @@
|
|||||||
background-color: transparent;
|
background-color: transparent;
|
||||||
}
|
}
|
||||||
25%, 75% {
|
25%, 75% {
|
||||||
background-color: var(--primary-100, rgba(59, 130, 246, 0.1));
|
background-color: var(--primary-100, rgba(200, 168, 81, 0.1));
|
||||||
}
|
}
|
||||||
50% {
|
50% {
|
||||||
background-color: var(--primary-200, rgba(59, 130, 246, 0.2));
|
background-color: var(--primary-200, rgba(200, 168, 81, 0.2));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -13,7 +13,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.view-list.active {
|
.view-list.active {
|
||||||
display: flex;
|
display: flex !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* List Header */
|
/* List Header */
|
||||||
|
|||||||
@@ -71,6 +71,82 @@
|
|||||||
transform: translateY(-8px) rotate(-45deg);
|
transform: translateY(-8px) rotate(-45deg);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ========================================
|
||||||
|
MOBILE COLUMN INDICATOR
|
||||||
|
======================================== */
|
||||||
|
|
||||||
|
.mobile-column-indicator {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 20px;
|
||||||
|
bottom: calc(20px + env(safe-area-inset-bottom, 0px));
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
z-index: 100;
|
||||||
|
text-align: center;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.column-dots {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
justify-content: center;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.column-dots .dot {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--text-tertiary);
|
||||||
|
opacity: 0.3;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.column-dots .dot.active {
|
||||||
|
background: var(--primary);
|
||||||
|
opacity: 1;
|
||||||
|
transform: scale(1.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.column-name {
|
||||||
|
background: rgba(0, 0, 0, 0.8);
|
||||||
|
color: white;
|
||||||
|
padding: 8px 16px;
|
||||||
|
border-radius: 20px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Mobile view hint */
|
||||||
|
.mobile-view-hint {
|
||||||
|
position: fixed;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
z-index: 100;
|
||||||
|
background: rgba(0, 0, 0, 0.8);
|
||||||
|
color: white;
|
||||||
|
padding: 12px 20px;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 500;
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-view-hint.visible {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-view-hint.left {
|
||||||
|
left: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-view-hint.right {
|
||||||
|
right: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
/* ========================================
|
/* ========================================
|
||||||
MOBILE SLIDE-IN MENU
|
MOBILE SLIDE-IN MENU
|
||||||
======================================== */
|
======================================== */
|
||||||
@@ -436,6 +512,221 @@
|
|||||||
min-height: 44px;
|
min-height: 44px;
|
||||||
min-width: 44px;
|
min-width: 44px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ========================================
|
||||||
|
MOBILE BOARD OPTIMIERUNG
|
||||||
|
======================================== */
|
||||||
|
|
||||||
|
/* Task Cards - Groesser und besser lesbar */
|
||||||
|
.task-card {
|
||||||
|
padding: var(--spacing-4);
|
||||||
|
border-radius: var(--radius-xl);
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-title {
|
||||||
|
font-size: 1rem;
|
||||||
|
line-height: 1.4;
|
||||||
|
font-weight: var(--font-semibold);
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-card-meta {
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
gap: var(--spacing-4);
|
||||||
|
margin-top: var(--spacing-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-meta-item {
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-meta-item .icon {
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-card-labels {
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: var(--spacing-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-label {
|
||||||
|
padding: 6px 12px;
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-card-footer {
|
||||||
|
margin-top: var(--spacing-4);
|
||||||
|
padding-top: var(--spacing-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-assignee-avatar {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-counts {
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
gap: var(--spacing-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Priority Stars groesser */
|
||||||
|
.priority-stars {
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Column Header */
|
||||||
|
.column-header {
|
||||||
|
padding: var(--spacing-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.column-title {
|
||||||
|
font-size: var(--text-base);
|
||||||
|
}
|
||||||
|
|
||||||
|
.column-count {
|
||||||
|
min-width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Column Body */
|
||||||
|
.column-body {
|
||||||
|
padding: var(--spacing-3);
|
||||||
|
gap: var(--spacing-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Add Task Button */
|
||||||
|
.btn-add-task {
|
||||||
|
padding: var(--spacing-4);
|
||||||
|
font-size: var(--text-base);
|
||||||
|
min-height: 52px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Filter Bar */
|
||||||
|
.filter-bar {
|
||||||
|
padding: var(--spacing-4);
|
||||||
|
gap: var(--spacing-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-select {
|
||||||
|
min-height: 44px;
|
||||||
|
font-size: var(--text-base);
|
||||||
|
padding: var(--spacing-3) var(--spacing-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Stats Bar */
|
||||||
|
.board-stats-bar {
|
||||||
|
padding: var(--spacing-3) var(--spacing-4);
|
||||||
|
gap: var(--spacing-4);
|
||||||
|
overflow-x: auto;
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.board-stat {
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.board-stat-icon {
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.board-stat-icon svg {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.board-stat-value {
|
||||||
|
font-size: var(--text-lg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.board-stat-label {
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Week Strip */
|
||||||
|
.week-strip {
|
||||||
|
padding: var(--spacing-3) var(--spacing-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.week-strip-nav {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.week-strip-nav svg {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.week-strip-day {
|
||||||
|
padding: var(--spacing-2) var(--spacing-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.week-strip-day-name {
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.week-strip-day-number {
|
||||||
|
font-size: var(--text-lg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.week-strip-today {
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
padding: var(--spacing-2) var(--spacing-3);
|
||||||
|
min-height: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Board Padding */
|
||||||
|
.board {
|
||||||
|
padding: var(--spacing-3);
|
||||||
|
gap: var(--spacing-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Modal auf Mobile */
|
||||||
|
.modal-body {
|
||||||
|
padding: var(--spacing-5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group label {
|
||||||
|
font-size: var(--text-base);
|
||||||
|
margin-bottom: var(--spacing-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Subtasks groesser */
|
||||||
|
.subtask-item {
|
||||||
|
padding: var(--spacing-3) var(--spacing-4);
|
||||||
|
min-height: 48px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subtask-title {
|
||||||
|
font-size: var(--text-base);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Comments groesser */
|
||||||
|
.comment-content {
|
||||||
|
padding: var(--spacing-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-text {
|
||||||
|
font-size: var(--text-base);
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Attachments */
|
||||||
|
.attachment-name {
|
||||||
|
font-size: var(--text-base);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Links */
|
||||||
|
.link-item {
|
||||||
|
padding: var(--spacing-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.link-title {
|
||||||
|
font-size: var(--text-base);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ========================================
|
/* ========================================
|
||||||
@@ -469,4 +760,150 @@
|
|||||||
.hamburger-btn.active .hamburger-line:nth-child(3) {
|
.hamburger-btn.active .hamburger-line:nth-child(3) {
|
||||||
transform: translateY(-7px) rotate(-45deg);
|
transform: translateY(-7px) rotate(-45deg);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ========================================
|
||||||
|
EXTRA SMALL: NOCH GROESSERE ELEMENTE
|
||||||
|
======================================== */
|
||||||
|
|
||||||
|
/* Task Cards noch groesser */
|
||||||
|
.task-card {
|
||||||
|
padding: var(--spacing-5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-title {
|
||||||
|
font-size: 1.125rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-card-meta {
|
||||||
|
font-size: var(--text-base);
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-label {
|
||||||
|
padding: 8px 14px;
|
||||||
|
font-size: var(--text-base);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Column */
|
||||||
|
.column-header {
|
||||||
|
padding: var(--spacing-4) var(--spacing-5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.column-title {
|
||||||
|
font-size: var(--text-lg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.column-body {
|
||||||
|
padding: var(--spacing-4);
|
||||||
|
gap: var(--spacing-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Filter - vertikal auf sehr kleinen Screens */
|
||||||
|
.filter-bar {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-group {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
gap: var(--spacing-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-group label {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-select {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 48px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-actions {
|
||||||
|
margin-left: 0;
|
||||||
|
justify-content: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Stats Bar - scrollbar horizontal */
|
||||||
|
.board-stats-bar {
|
||||||
|
justify-content: flex-start;
|
||||||
|
gap: var(--spacing-5);
|
||||||
|
padding: var(--spacing-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.board-stat-icon {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.board-stat-value {
|
||||||
|
font-size: var(--text-xl);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Week Strip kompakter */
|
||||||
|
.week-strip {
|
||||||
|
padding: var(--spacing-2) var(--spacing-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.week-strip-day {
|
||||||
|
padding: var(--spacing-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.week-strip-day-name {
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.week-strip-day-number {
|
||||||
|
font-size: var(--text-base);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Login Screen */
|
||||||
|
.login-container {
|
||||||
|
padding: var(--spacing-6);
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-header h1 {
|
||||||
|
font-size: var(--text-xl);
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-form .form-group label {
|
||||||
|
font-size: var(--text-base);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Modals */
|
||||||
|
.modal {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
max-height: 100%;
|
||||||
|
border-radius: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-header {
|
||||||
|
padding: var(--spacing-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-header h2 {
|
||||||
|
font-size: var(--text-lg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-body {
|
||||||
|
padding: var(--spacing-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-footer {
|
||||||
|
padding: var(--spacing-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Form Groups */
|
||||||
|
.form-row {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Add Task Button */
|
||||||
|
.btn-add-task {
|
||||||
|
min-height: 56px;
|
||||||
|
font-size: var(--text-lg);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -77,8 +77,8 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
width: 32px;
|
width: 44px;
|
||||||
height: 32px;
|
height: 44px;
|
||||||
font-size: 20px;
|
font-size: 20px;
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
background: none;
|
background: none;
|
||||||
@@ -770,7 +770,9 @@
|
|||||||
.lightbox-close {
|
.lightbox-close {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: var(--spacing-6);
|
top: var(--spacing-6);
|
||||||
|
top: calc(var(--spacing-6) + env(safe-area-inset-top, 0px));
|
||||||
right: var(--spacing-6);
|
right: var(--spacing-6);
|
||||||
|
right: calc(var(--spacing-6) + env(safe-area-inset-right, 0px));
|
||||||
width: 48px;
|
width: 48px;
|
||||||
height: 48px;
|
height: 48px;
|
||||||
font-size: 32px;
|
font-size: 32px;
|
||||||
|
|||||||
@@ -59,16 +59,16 @@
|
|||||||
============================================================================= */
|
============================================================================= */
|
||||||
|
|
||||||
.notification-dropdown {
|
.notification-dropdown {
|
||||||
position: absolute;
|
position: fixed;
|
||||||
top: calc(100% + 8px);
|
top: 60px;
|
||||||
right: 0;
|
right: 20px;
|
||||||
width: 380px;
|
width: 380px;
|
||||||
max-height: 520px;
|
max-height: 520px;
|
||||||
background: var(--bg-card);
|
background: var(--bg-card);
|
||||||
border: 1px solid var(--border-default);
|
border: 1px solid var(--border-default);
|
||||||
border-radius: var(--radius-xl);
|
border-radius: var(--radius-xl);
|
||||||
box-shadow: var(--shadow-xl);
|
box-shadow: var(--shadow-xl);
|
||||||
z-index: var(--z-dropdown);
|
z-index: 10001;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|||||||
@@ -175,7 +175,7 @@
|
|||||||
.reminder-number-input:focus {
|
.reminder-number-input:focus {
|
||||||
border-color: var(--primary);
|
border-color: var(--primary);
|
||||||
outline: none;
|
outline: none;
|
||||||
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
|
box-shadow: var(--shadow-focus);
|
||||||
}
|
}
|
||||||
|
|
||||||
.reminder-unit-select {
|
.reminder-unit-select {
|
||||||
@@ -200,7 +200,7 @@
|
|||||||
.reminder-unit-select:focus {
|
.reminder-unit-select:focus {
|
||||||
border-color: var(--primary);
|
border-color: var(--primary);
|
||||||
outline: none;
|
outline: none;
|
||||||
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
|
box-shadow: var(--shadow-focus);
|
||||||
}
|
}
|
||||||
|
|
||||||
.reminder-advance-suffix {
|
.reminder-advance-suffix {
|
||||||
@@ -293,7 +293,7 @@
|
|||||||
|
|
||||||
.custom-select-trigger.active {
|
.custom-select-trigger.active {
|
||||||
border-color: var(--primary);
|
border-color: var(--primary);
|
||||||
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
|
box-shadow: var(--shadow-focus);
|
||||||
}
|
}
|
||||||
|
|
||||||
.custom-select-value {
|
.custom-select-value {
|
||||||
@@ -477,7 +477,7 @@
|
|||||||
border-color: #1d4ed8;
|
border-color: #1d4ed8;
|
||||||
color: white !important;
|
color: white !important;
|
||||||
transform: translateY(-1px);
|
transform: translateY(-1px);
|
||||||
box-shadow: 0 3px 8px rgba(59, 130, 246, 0.4);
|
box-shadow: 0 3px 8px rgba(200, 168, 81, 0.4);
|
||||||
}
|
}
|
||||||
|
|
||||||
#reminder-modal .btn-secondary {
|
#reminder-modal .btn-secondary {
|
||||||
|
|||||||
@@ -4,6 +4,57 @@
|
|||||||
* Mobile und Tablet Anpassungen
|
* Mobile und Tablet Anpassungen
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
/* ========================================
|
||||||
|
GLOBALE RESPONSIVE ANPASSUNGEN
|
||||||
|
======================================== */
|
||||||
|
|
||||||
|
/* Verhindere horizontales Scrollen */
|
||||||
|
html {
|
||||||
|
overflow-x: hidden;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
overflow-x: hidden;
|
||||||
|
width: 100%;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* App Container */
|
||||||
|
.app {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 100%;
|
||||||
|
overflow-x: hidden;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Hauptcontainer */
|
||||||
|
.main {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 100%;
|
||||||
|
overflow-x: hidden;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Alle Views */
|
||||||
|
.view {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 100%;
|
||||||
|
overflow-x: hidden;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Verhindere Überlauf bei allen Elementen */
|
||||||
|
* {
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Responsive Box-Sizing für alle Elemente */
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
/* ========================================
|
/* ========================================
|
||||||
TABLET (max 1024px)
|
TABLET (max 1024px)
|
||||||
======================================== */
|
======================================== */
|
||||||
@@ -31,33 +82,91 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ========================================
|
||||||
|
MEDIUM SCREENS (max 1200px)
|
||||||
|
======================================== */
|
||||||
|
|
||||||
|
@media (max-width: 1200px) {
|
||||||
|
/* Header-Anpassungen für mittlere Bildschirme */
|
||||||
|
.header {
|
||||||
|
padding: var(--spacing-sm) var(--spacing-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-left {
|
||||||
|
gap: var(--spacing-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-right {
|
||||||
|
gap: var(--spacing-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Project Selector schmaler */
|
||||||
|
.project-select {
|
||||||
|
max-width: 200px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Search Container schmaler */
|
||||||
|
.search-container {
|
||||||
|
max-width: 200px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Navigation tabs - kompakter und zentriert bei mittleren Bildschirmen */
|
||||||
|
.view-tabs {
|
||||||
|
gap: var(--spacing-xs);
|
||||||
|
flex-wrap: nowrap;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.view-tab {
|
||||||
|
padding: 6px 8px;
|
||||||
|
font-size: 11px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Modals anpassen */
|
||||||
|
.modal {
|
||||||
|
margin: var(--spacing-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-large {
|
||||||
|
max-width: calc(100vw - 32px);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Tabellen responsiv */
|
||||||
|
table {
|
||||||
|
display: block;
|
||||||
|
overflow-x: auto;
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/* ========================================
|
/* ========================================
|
||||||
SMALL TABLET (max 768px)
|
SMALL TABLET (max 768px)
|
||||||
======================================== */
|
======================================== */
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.header {
|
.header {
|
||||||
flex-wrap: wrap;
|
display: flex;
|
||||||
height: auto;
|
align-items: center;
|
||||||
padding: var(--spacing-sm) var(--spacing-md);
|
height: 60px;
|
||||||
gap: var(--spacing-sm);
|
padding: 0 var(--spacing-sm);
|
||||||
|
gap: var(--spacing-xs);
|
||||||
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.header-left {
|
.header-left {
|
||||||
flex: 1;
|
flex: 0 0 auto;
|
||||||
order: 1;
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--spacing-xs);
|
||||||
}
|
}
|
||||||
|
|
||||||
.header-center {
|
.view-tabs-bar {
|
||||||
order: 3;
|
display: none; /* Navigation wird ins Mobile Menu verschoben */
|
||||||
width: 100%;
|
|
||||||
justify-content: center;
|
|
||||||
padding-top: var(--spacing-sm);
|
|
||||||
border-top: 1px solid var(--border-default);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.header-right {
|
.header-right {
|
||||||
order: 2;
|
flex: 0 0 auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.logo {
|
.logo {
|
||||||
@@ -72,28 +181,18 @@
|
|||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.view-tabs {
|
/* Mobile Navigation wird über Hamburger Menu gesteuert */
|
||||||
width: 100%;
|
.mobile-menu {
|
||||||
justify-content: center;
|
display: block;
|
||||||
}
|
}
|
||||||
|
|
||||||
.view-tab {
|
/* Logo kleiner auf Mobile */
|
||||||
flex: 1;
|
.logo {
|
||||||
text-align: center;
|
font-size: var(--text-lg);
|
||||||
}
|
}
|
||||||
|
|
||||||
.filter-bar {
|
.filter-bar-actions {
|
||||||
flex-direction: column;
|
display: none;
|
||||||
gap: var(--spacing-md);
|
|
||||||
}
|
|
||||||
|
|
||||||
.filter-group {
|
|
||||||
flex-wrap: wrap;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.filter-actions {
|
|
||||||
justify-content: center;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.view-board {
|
.view-board {
|
||||||
@@ -101,16 +200,119 @@
|
|||||||
gap: var(--spacing-md);
|
gap: var(--spacing-md);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Mobile Board - One column at a time */
|
||||||
|
.board-container {
|
||||||
|
overflow-x: hidden !important;
|
||||||
|
scroll-snap-type: none !important;
|
||||||
|
width: 100%;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.column {
|
.column {
|
||||||
width: calc(100vw - 32px);
|
width: 100%;
|
||||||
min-width: calc(100vw - 32px);
|
min-width: unset;
|
||||||
max-height: none;
|
max-width: 100%;
|
||||||
|
max-height: calc(100vh - 200px);
|
||||||
|
margin: 0;
|
||||||
|
padding: var(--spacing-sm);
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.column.mobile-active {
|
||||||
|
display: flex !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-add-column {
|
.btn-add-column {
|
||||||
width: calc(100vw - 32px);
|
width: 100%;
|
||||||
min-width: calc(100vw - 32px);
|
min-width: unset;
|
||||||
min-height: 100px;
|
min-height: 100px;
|
||||||
|
margin: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Hide scrollbar in mobile board */
|
||||||
|
.board-container::-webkit-scrollbar {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Fix for views in mobile */
|
||||||
|
.view.active {
|
||||||
|
display: flex !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* List view mobile adjustments */
|
||||||
|
.view-list.active {
|
||||||
|
padding: var(--spacing-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-header {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
gap: var(--spacing-md);
|
||||||
|
padding: var(--spacing-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-controls {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-view-toggle {
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-sort {
|
||||||
|
margin-left: 0;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Table responsive */
|
||||||
|
.list-table-container {
|
||||||
|
overflow-x: auto;
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-table {
|
||||||
|
min-width: 600px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Calendar view mobile adjustments */
|
||||||
|
.view-calendar {
|
||||||
|
padding: var(--spacing-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-header {
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--spacing-md);
|
||||||
|
padding: var(--spacing-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-nav {
|
||||||
|
width: 100%;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-actions {
|
||||||
|
width: 100%;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-grid {
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-day-header {
|
||||||
|
padding: var(--spacing-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-day {
|
||||||
|
min-height: 60px;
|
||||||
|
padding: var(--spacing-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-task {
|
||||||
|
font-size: 9px;
|
||||||
|
padding: 1px 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.modal {
|
.modal {
|
||||||
@@ -264,6 +466,7 @@
|
|||||||
left: var(--spacing-md);
|
left: var(--spacing-md);
|
||||||
right: var(--spacing-md);
|
right: var(--spacing-md);
|
||||||
bottom: var(--spacing-md);
|
bottom: var(--spacing-md);
|
||||||
|
bottom: calc(var(--spacing-md) + env(safe-area-inset-bottom, 0px));
|
||||||
}
|
}
|
||||||
|
|
||||||
.toast {
|
.toast {
|
||||||
@@ -291,7 +494,7 @@
|
|||||||
|
|
||||||
@media print {
|
@media print {
|
||||||
.header,
|
.header,
|
||||||
.filter-bar,
|
.view-tabs-bar,
|
||||||
.btn-add-column,
|
.btn-add-column,
|
||||||
.column-actions,
|
.column-actions,
|
||||||
.btn-add-task,
|
.btn-add-task,
|
||||||
|
|||||||
@@ -9,26 +9,26 @@
|
|||||||
FARBEN - Modernes Light Theme
|
FARBEN - Modernes Light Theme
|
||||||
======================================== */
|
======================================== */
|
||||||
|
|
||||||
/* Primärfarben */
|
/* Primärfarben - AegisSight Light Theme */
|
||||||
--primary: #4F46E5;
|
--primary: #C8A851;
|
||||||
--primary-hover: #4338CA;
|
--primary-hover: #B5923E;
|
||||||
--primary-light: #EEF2FF;
|
--primary-light: rgba(200, 168, 81, 0.12);
|
||||||
--accent: #06B6D4;
|
--accent: #C8A851;
|
||||||
--accent-hover: #0891B2;
|
--accent-hover: #B5923E;
|
||||||
|
|
||||||
/* Hintergründe */
|
/* Hintergründe */
|
||||||
--bg-main: #F8FAFC;
|
--bg-main: #E8EDF6;
|
||||||
--bg-secondary: #FFFFFF;
|
--bg-secondary: #FFFFFF;
|
||||||
--bg-tertiary: #F1F5F9;
|
--bg-tertiary: #F1F3F9;
|
||||||
--bg-hover: #E2E8F0;
|
--bg-hover: #F8F9FC;
|
||||||
--bg-active: #CBD5E1;
|
--bg-active: #D0D6DE;
|
||||||
--bg-sidebar: #FFFFFF;
|
--bg-sidebar: #FFFFFF;
|
||||||
--bg-card: #FFFFFF;
|
--bg-card: #FFFFFF;
|
||||||
--bg-input: #FFFFFF;
|
--bg-input: #FFFFFF;
|
||||||
|
|
||||||
/* Textfarben */
|
/* Textfarben */
|
||||||
--text-primary: #0F172A;
|
--text-primary: #0A1832;
|
||||||
--text-secondary: #475569;
|
--text-secondary: #64748B;
|
||||||
--text-tertiary: #64748B;
|
--text-tertiary: #64748B;
|
||||||
--text-muted: #94A3B8;
|
--text-muted: #94A3B8;
|
||||||
--text-placeholder: #94A3B8;
|
--text-placeholder: #94A3B8;
|
||||||
@@ -44,9 +44,9 @@
|
|||||||
--error: #EF4444;
|
--error: #EF4444;
|
||||||
--error-bg: #FEE2E2;
|
--error-bg: #FEE2E2;
|
||||||
--error-text: #991B1B;
|
--error-text: #991B1B;
|
||||||
--info: #3B82F6;
|
--info: #7C8DB5;
|
||||||
--info-bg: #DBEAFE;
|
--info-bg: rgba(124, 141, 181, 0.12);
|
||||||
--info-text: #1E40AF;
|
--info-text: #4A5568;
|
||||||
|
|
||||||
/* Prioritätsfarben */
|
/* Prioritätsfarben */
|
||||||
--priority-high: #EF4444;
|
--priority-high: #EF4444;
|
||||||
@@ -60,7 +60,7 @@
|
|||||||
--border-light: #F1F5F9;
|
--border-light: #F1F5F9;
|
||||||
--border-default: #E2E8F0;
|
--border-default: #E2E8F0;
|
||||||
--border-dark: #CBD5E1;
|
--border-dark: #CBD5E1;
|
||||||
--border-focus: #4F46E5;
|
--border-focus: #C8A851;
|
||||||
|
|
||||||
/* Schatten */
|
/* Schatten */
|
||||||
--shadow-xs: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
|
--shadow-xs: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
|
||||||
@@ -68,12 +68,12 @@
|
|||||||
--shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -2px rgba(0, 0, 0, 0.1);
|
--shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -2px rgba(0, 0, 0, 0.1);
|
||||||
--shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -4px rgba(0, 0, 0, 0.1);
|
--shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -4px rgba(0, 0, 0, 0.1);
|
||||||
--shadow-xl: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 8px 10px -6px rgba(0, 0, 0, 0.1);
|
--shadow-xl: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 8px 10px -6px rgba(0, 0, 0, 0.1);
|
||||||
--shadow-focus: 0 0 0 3px rgba(79, 70, 229, 0.2);
|
--shadow-focus: 0 0 0 3px rgba(200, 168, 81, 0.25);
|
||||||
|
|
||||||
/* Scrollbar */
|
/* Scrollbar */
|
||||||
--scrollbar-bg: #F1F5F9;
|
--scrollbar-bg: #E8EDF6;
|
||||||
--scrollbar-thumb: #CBD5E1;
|
--scrollbar-thumb: #94A3B8;
|
||||||
--scrollbar-thumb-hover: #94A3B8;
|
--scrollbar-thumb-hover: #64748B;
|
||||||
|
|
||||||
/* Overlay */
|
/* Overlay */
|
||||||
--overlay-bg: rgba(15, 23, 42, 0.5);
|
--overlay-bg: rgba(15, 23, 42, 0.5);
|
||||||
@@ -158,7 +158,7 @@
|
|||||||
Z-INDEX
|
Z-INDEX
|
||||||
======================================== */
|
======================================== */
|
||||||
|
|
||||||
--z-dropdown: 100;
|
--z-dropdown: 300;
|
||||||
--z-sticky: 200;
|
--z-sticky: 200;
|
||||||
--z-modal-overlay: 900;
|
--z-modal-overlay: 900;
|
||||||
--z-modal: 1000;
|
--z-modal: 1000;
|
||||||
|
|||||||
@@ -38,6 +38,7 @@
|
|||||||
<link rel="stylesheet" href="css/knowledge.css">
|
<link rel="stylesheet" href="css/knowledge.css">
|
||||||
<link rel="stylesheet" href="css/reminders.css">
|
<link rel="stylesheet" href="css/reminders.css">
|
||||||
<link rel="stylesheet" href="css/contacts.css">
|
<link rel="stylesheet" href="css/contacts.css">
|
||||||
|
<link rel="stylesheet" href="css/contacts-modern.css">
|
||||||
<link rel="stylesheet" href="css/responsive.css">
|
<link rel="stylesheet" href="css/responsive.css">
|
||||||
<link rel="stylesheet" href="css/mobile.css">
|
<link rel="stylesheet" href="css/mobile.css">
|
||||||
<link rel="stylesheet" href="css/pwa.css">
|
<link rel="stylesheet" href="css/pwa.css">
|
||||||
@@ -182,8 +183,72 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="header-center">
|
<div class="header-right">
|
||||||
<!-- View Tabs -->
|
<!-- Search -->
|
||||||
|
<div class="search-container">
|
||||||
|
<input type="text" id="search-input" class="search-input" placeholder="Suchen...">
|
||||||
|
<button type="button" id="search-clear" class="search-clear hidden" title="Suche zurücksetzen (Esc)">
|
||||||
|
<svg viewBox="0 0 24 24" width="16" height="16"><path d="M18 6L6 18M6 6l12 12" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg>
|
||||||
|
</button>
|
||||||
|
<div id="search-spinner" class="search-spinner hidden"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Notification Bell -->
|
||||||
|
<div class="notification-bell" id="notification-bell">
|
||||||
|
<button id="notification-btn" class="btn btn-icon" title="Benachrichtigungen">
|
||||||
|
<svg class="notification-icon" viewBox="0 0 24 24">
|
||||||
|
<path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9" stroke="currentColor" stroke-width="2" fill="none" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M13.73 21a2 2 0 0 1-3.46 0" stroke="currentColor" stroke-width="2" fill="none" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
<span id="notification-badge" class="notification-badge hidden">0</span>
|
||||||
|
</button>
|
||||||
|
<div id="notification-dropdown" class="notification-dropdown hidden">
|
||||||
|
<div class="notification-header">
|
||||||
|
<h3>Benachrichtigungen</h3>
|
||||||
|
<button id="btn-mark-all-read" class="btn btn-text">Alle gelesen</button>
|
||||||
|
</div>
|
||||||
|
<div id="notification-list" class="notification-list">
|
||||||
|
<!-- Notifications rendered here -->
|
||||||
|
</div>
|
||||||
|
<div id="notification-empty" class="notification-empty hidden">
|
||||||
|
<svg class="notification-empty-icon" viewBox="0 0 24 24">
|
||||||
|
<path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9" stroke="currentColor" stroke-width="2" fill="none"/>
|
||||||
|
<path d="M13.73 21a2 2 0 0 1-3.46 0" stroke="currentColor" stroke-width="2" fill="none"/>
|
||||||
|
</svg>
|
||||||
|
<p>Keine Benachrichtigungen</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Session Timer -->
|
||||||
|
<div id="session-timer" class="session-timer" title="Verbleibende Sitzungszeit">
|
||||||
|
<svg class="session-timer-icon" viewBox="0 0 24 24" width="16" height="16">
|
||||||
|
<circle cx="12" cy="12" r="10" stroke="currentColor" stroke-width="2" fill="none"/>
|
||||||
|
<polyline points="12 6 12 12 16 14" stroke="currentColor" stroke-width="2" fill="none" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
<span id="session-countdown">--:--</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- User Menu -->
|
||||||
|
<div class="user-menu">
|
||||||
|
<button id="user-menu-btn" class="user-avatar">
|
||||||
|
<span id="user-initial">U</span>
|
||||||
|
</button>
|
||||||
|
<div id="user-dropdown" class="user-dropdown hidden">
|
||||||
|
<div class="user-info">
|
||||||
|
<span id="user-name">Benutzer</span>
|
||||||
|
<span id="user-role">Angemeldet</span>
|
||||||
|
</div>
|
||||||
|
<div class="dropdown-divider"></div>
|
||||||
|
<button id="btn-settings" class="dropdown-item">Einstellungen</button>
|
||||||
|
<div class="dropdown-divider"></div>
|
||||||
|
<button id="btn-logout" class="dropdown-item text-danger">Abmelden</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<nav class="view-tabs-bar">
|
||||||
<nav class="view-tabs">
|
<nav class="view-tabs">
|
||||||
<button class="view-tab active" data-view="board">
|
<button class="view-tab active" data-view="board">
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
@@ -247,79 +312,70 @@
|
|||||||
Kontakte
|
Kontakte
|
||||||
</button>
|
</button>
|
||||||
</nav>
|
</nav>
|
||||||
</div>
|
<div class="filter-bar-actions">
|
||||||
|
<button id="btn-filter-toggle" class="btn btn-outline filter-toggle-btn">
|
||||||
<div class="header-right">
|
<svg class="icon" viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="22 3 2 3 10 12.46 10 19 14 21 14 12.46 22 3"></polygon></svg>
|
||||||
<!-- Search -->
|
<span>Filter</span>
|
||||||
<div class="search-container">
|
|
||||||
<svg class="search-icon" viewBox="0 0 24 24"><circle cx="11" cy="11" r="8" stroke="currentColor" stroke-width="2" fill="none"/><path d="m21 21-4.35-4.35" stroke="currentColor" stroke-width="2"/></svg>
|
|
||||||
<input type="text" id="search-input" class="search-input" placeholder="Suchen...">
|
|
||||||
<button type="button" id="search-clear" class="search-clear hidden" title="Suche zurücksetzen (Esc)">
|
|
||||||
<svg viewBox="0 0 24 24" width="16" height="16"><path d="M18 6L6 18M6 6l12 12" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg>
|
|
||||||
</button>
|
</button>
|
||||||
<div id="search-spinner" class="search-spinner hidden"></div>
|
<button id="btn-show-archived" class="btn btn-outline filter-toggle-btn">
|
||||||
</div>
|
<svg class="icon" viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="3" width="20" height="5" rx="1"></rect><path d="M4 8v11a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8"></path><path d="M10 12h4"></path></svg>
|
||||||
|
<span>Archiv</span>
|
||||||
<!-- Connection Status -->
|
|
||||||
<div id="connection-status" class="connection-status online" title="Verbunden">
|
|
||||||
<span class="status-dot"></span>
|
|
||||||
<span class="status-text">Online</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Notification Bell -->
|
|
||||||
<div class="notification-bell" id="notification-bell">
|
|
||||||
<button id="notification-btn" class="btn btn-icon" title="Benachrichtigungen">
|
|
||||||
<svg class="notification-icon" viewBox="0 0 24 24">
|
|
||||||
<path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9" stroke="currentColor" stroke-width="2" fill="none" stroke-linecap="round" stroke-linejoin="round"/>
|
|
||||||
<path d="M13.73 21a2 2 0 0 1-3.46 0" stroke="currentColor" stroke-width="2" fill="none" stroke-linecap="round" stroke-linejoin="round"/>
|
|
||||||
</svg>
|
|
||||||
<span id="notification-badge" class="notification-badge hidden">0</span>
|
|
||||||
</button>
|
</button>
|
||||||
<div id="notification-dropdown" class="notification-dropdown hidden">
|
|
||||||
<div class="notification-header">
|
|
||||||
<h3>Benachrichtigungen</h3>
|
|
||||||
<button id="btn-mark-all-read" class="btn btn-text">Alle gelesen</button>
|
|
||||||
</div>
|
</div>
|
||||||
<div id="notification-list" class="notification-list">
|
<div id="filter-popover" class="filter-popover hidden">
|
||||||
<!-- Notifications rendered here -->
|
<div class="filter-popover-content">
|
||||||
|
<div class="filter-popover-row">
|
||||||
|
<label>Bearbeiter</label>
|
||||||
|
<div id="filter-assignee-list" class="filter-checkbox-list"></div>
|
||||||
</div>
|
</div>
|
||||||
<div id="notification-empty" class="notification-empty hidden">
|
<div class="filter-popover-row">
|
||||||
<svg class="notification-empty-icon" viewBox="0 0 24 24">
|
<label>Prioritaet</label>
|
||||||
<path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9" stroke="currentColor" stroke-width="2" fill="none"/>
|
<div id="filter-priority-list" class="filter-checkbox-list">
|
||||||
<path d="M13.73 21a2 2 0 0 1-3.46 0" stroke="currentColor" stroke-width="2" fill="none"/>
|
<label class="filter-checkbox-item">
|
||||||
</svg>
|
<input type="checkbox" name="filter-priority" value="high">
|
||||||
<p>Keine Benachrichtigungen</p>
|
<span>Hoch</span>
|
||||||
|
</label>
|
||||||
|
<label class="filter-checkbox-item">
|
||||||
|
<input type="checkbox" name="filter-priority" value="medium">
|
||||||
|
<span>Mittel</span>
|
||||||
|
</label>
|
||||||
|
<label class="filter-checkbox-item">
|
||||||
|
<input type="checkbox" name="filter-priority" value="low">
|
||||||
|
<span>Niedrig</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="filter-popover-row">
|
||||||
|
<label>Labels</label>
|
||||||
|
<div id="filter-labels-list" class="filter-checkbox-list"></div>
|
||||||
|
</div>
|
||||||
|
<div class="filter-popover-row">
|
||||||
|
<label>Faelligkeit</label>
|
||||||
|
<div id="filter-due-list" class="filter-checkbox-list">
|
||||||
|
<label class="filter-checkbox-item">
|
||||||
|
<input type="checkbox" name="filter-due" value="overdue">
|
||||||
|
<span>Ueberfaellig</span>
|
||||||
|
</label>
|
||||||
|
<label class="filter-checkbox-item">
|
||||||
|
<input type="checkbox" name="filter-due" value="today">
|
||||||
|
<span>Heute</span>
|
||||||
|
</label>
|
||||||
|
<label class="filter-checkbox-item">
|
||||||
|
<input type="checkbox" name="filter-due" value="week">
|
||||||
|
<span>Diese Woche</span>
|
||||||
|
</label>
|
||||||
|
<label class="filter-checkbox-item">
|
||||||
|
<input type="checkbox" name="filter-due" value="none">
|
||||||
|
<span>Ohne Datum</span>
|
||||||
|
</label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="filter-popover-footer">
|
||||||
<!-- Session Timer -->
|
<button id="btn-clear-filters" class="btn btn-text">Filter zurücksetzen</button>
|
||||||
<div id="session-timer" class="session-timer" title="Verbleibende Sitzungszeit">
|
|
||||||
<svg class="session-timer-icon" viewBox="0 0 24 24" width="16" height="16">
|
|
||||||
<circle cx="12" cy="12" r="10" stroke="currentColor" stroke-width="2" fill="none"/>
|
|
||||||
<polyline points="12 6 12 12 16 14" stroke="currentColor" stroke-width="2" fill="none" stroke-linecap="round" stroke-linejoin="round"/>
|
|
||||||
</svg>
|
|
||||||
<span id="session-countdown">--:--</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- User Menu -->
|
|
||||||
<div class="user-menu">
|
|
||||||
<button id="user-menu-btn" class="user-avatar">
|
|
||||||
<span id="user-initial">U</span>
|
|
||||||
</button>
|
|
||||||
<div id="user-dropdown" class="user-dropdown hidden">
|
|
||||||
<div class="user-info">
|
|
||||||
<span id="user-name">Benutzer</span>
|
|
||||||
<span id="user-role">Angemeldet</span>
|
|
||||||
</div>
|
|
||||||
<div class="dropdown-divider"></div>
|
|
||||||
<button id="btn-settings" class="dropdown-item">Einstellungen</button>
|
|
||||||
<div class="dropdown-divider"></div>
|
|
||||||
<button id="btn-logout" class="dropdown-item text-danger">Abmelden</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</nav>
|
||||||
</header>
|
|
||||||
|
|
||||||
<!-- Offline Banner -->
|
<!-- Offline Banner -->
|
||||||
<div id="offline-banner" class="offline-banner hidden">
|
<div id="offline-banner" class="offline-banner hidden">
|
||||||
@@ -332,43 +388,8 @@
|
|||||||
|
|
||||||
<!-- Board View -->
|
<!-- Board View -->
|
||||||
<div id="view-board" class="view view-board active">
|
<div id="view-board" class="view view-board active">
|
||||||
<!-- Filter Bar -->
|
|
||||||
<div class="filter-bar">
|
|
||||||
<div class="filter-group">
|
|
||||||
<label>Filter:</label>
|
|
||||||
<select id="filter-assignee" class="filter-select">
|
|
||||||
<option value="">Alle Benutzer</option>
|
|
||||||
</select>
|
|
||||||
<select id="filter-priority" class="filter-select">
|
|
||||||
<option value="">Alle Prioritäten</option>
|
|
||||||
<option value="high">Hoch</option>
|
|
||||||
<option value="medium">Mittel</option>
|
|
||||||
<option value="low">Niedrig</option>
|
|
||||||
</select>
|
|
||||||
<select id="filter-labels" class="filter-select">
|
|
||||||
<option value="">Alle Labels</option>
|
|
||||||
</select>
|
|
||||||
<select id="filter-due" class="filter-select">
|
|
||||||
<option value="">Alle Fälligkeiten</option>
|
|
||||||
<option value="overdue">Überfällig</option>
|
|
||||||
<option value="today">Heute</option>
|
|
||||||
<option value="week">Diese Woche</option>
|
|
||||||
<option value="none">Ohne Datum</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div class="filter-actions">
|
|
||||||
<button id="btn-toggle-layout" class="btn btn-icon" title="Layout umschalten">
|
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
||||||
<rect x="3" y="3" width="7" height="7"/>
|
|
||||||
<rect x="14" y="3" width="7" height="7"/>
|
|
||||||
<rect x="3" y="14" width="7" height="7"/>
|
|
||||||
<rect x="14" y="14" width="7" height="7"/>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
<button id="btn-clear-filters" class="btn btn-text">Filter zurücksetzen</button>
|
|
||||||
<button id="btn-show-archived" class="btn btn-text">Archiv anzeigen</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Week Strip Calendar -->
|
<!-- Week Strip Calendar -->
|
||||||
<div id="week-strip" class="week-strip">
|
<div id="week-strip" class="week-strip">
|
||||||
@@ -1051,6 +1072,26 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Column Select Modal -->
|
||||||
|
<div id="column-select-modal" class="modal modal-small hidden">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h2>Spalte auswählen</h2>
|
||||||
|
<button class="modal-close" data-close-modal>×</button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<p id="column-select-message"></p>
|
||||||
|
<div class="form-group" style="margin-top: 1rem;">
|
||||||
|
<select id="column-select-dropdown" class="form-control">
|
||||||
|
<!-- Spalten werden dynamisch eingefügt -->
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" id="column-select-cancel" class="btn btn-secondary">Abbrechen</button>
|
||||||
|
<button type="button" id="column-select-ok" class="btn btn-primary">Wiederherstellen</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Archive Modal -->
|
<!-- Archive Modal -->
|
||||||
<div id="archive-modal" class="modal modal-large hidden">
|
<div id="archive-modal" class="modal modal-large hidden">
|
||||||
<div class="modal-header">
|
<div class="modal-header">
|
||||||
@@ -2094,6 +2135,9 @@
|
|||||||
<!-- Socket.io Client -->
|
<!-- Socket.io Client -->
|
||||||
<script src="/socket.io/socket.io.js"></script>
|
<script src="/socket.io/socket.io.js"></script>
|
||||||
|
|
||||||
|
<!-- Auth Fix -->
|
||||||
|
<script src="js/auth-fix.js"></script>
|
||||||
|
|
||||||
<!-- Main App (ES Module) -->
|
<!-- Main App (ES Module) -->
|
||||||
<script type="module" src="js/app.js"></script>
|
<script type="module" src="js/app.js"></script>
|
||||||
<script type="module" src="js/reminders.js"></script>
|
<script type="module" src="js/reminders.js"></script>
|
||||||
|
|||||||
@@ -570,8 +570,10 @@ class ApiClient {
|
|||||||
return this.put(`/tasks/${taskId}/archive`, { archived: true });
|
return this.put(`/tasks/${taskId}/archive`, { archived: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
async restoreTask(projectId, taskId) {
|
async restoreTask(projectId, taskId, columnId = null) {
|
||||||
return this.put(`/tasks/${taskId}/archive`, { archived: false });
|
const data = { archived: false };
|
||||||
|
if (columnId) data.columnId = columnId;
|
||||||
|
return this.put(`/tasks/${taskId}/archive`, data);
|
||||||
}
|
}
|
||||||
|
|
||||||
async getTaskHistory(projectId, taskId) {
|
async getTaskHistory(projectId, taskId) {
|
||||||
|
|||||||
@@ -263,27 +263,33 @@ class App {
|
|||||||
// Search - Hybrid search with client-side filtering and server search
|
// Search - Hybrid search with client-side filtering and server search
|
||||||
this.setupSearch();
|
this.setupSearch();
|
||||||
|
|
||||||
// Filter changes
|
// Filter toggle popover
|
||||||
$('#filter-priority')?.addEventListener('change', (e) => {
|
$('#btn-filter-toggle')?.addEventListener('click', (e) => {
|
||||||
store.setFilter('priority', e.target.value);
|
e.stopPropagation();
|
||||||
|
$('#filter-popover')?.classList.toggle('hidden');
|
||||||
});
|
});
|
||||||
|
|
||||||
$('#filter-assignee')?.addEventListener('change', (e) => {
|
// Close popover on click outside
|
||||||
store.setFilter('assignee', e.target.value);
|
document.addEventListener('click', (e) => {
|
||||||
|
const popover = $('#filter-popover');
|
||||||
|
const toggleBtn = $('#btn-filter-toggle');
|
||||||
|
if (popover && !popover.contains(e.target) && !toggleBtn?.contains(e.target)) {
|
||||||
|
popover.classList.add('hidden');
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
$('#filter-labels')?.addEventListener('change', (e) => {
|
// Filter changes - checkbox listeners for static filters
|
||||||
store.setFilter('label', e.target.value);
|
$('#filter-priority-list')?.addEventListener('change', () => this.applyCheckboxFilters());
|
||||||
});
|
$('#filter-due-list')?.addEventListener('change', () => this.applyCheckboxFilters());
|
||||||
|
$('#filter-assignee-list')?.addEventListener('change', () => this.applyCheckboxFilters());
|
||||||
$('#filter-due')?.addEventListener('change', (e) => {
|
$('#filter-labels-list')?.addEventListener('change', () => this.applyCheckboxFilters());
|
||||||
store.setFilter('dueDate', e.target.value);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Reset filters
|
// Reset filters
|
||||||
$('#btn-reset-filters')?.addEventListener('click', () => {
|
$('#btn-clear-filters')?.addEventListener('click', () => {
|
||||||
store.resetFilters();
|
store.resetFilters();
|
||||||
this.resetFilterInputs();
|
this.resetFilterInputs();
|
||||||
|
this.updateFilterButtonState();
|
||||||
|
$('#filter-popover')?.classList.add('hidden');
|
||||||
});
|
});
|
||||||
|
|
||||||
// Open archive modal
|
// Open archive modal
|
||||||
@@ -301,6 +307,9 @@ class App {
|
|||||||
// Confirm dialog events
|
// Confirm dialog events
|
||||||
window.addEventListener('confirm:show', (e) => this.showConfirmDialog(e.detail));
|
window.addEventListener('confirm:show', (e) => this.showConfirmDialog(e.detail));
|
||||||
|
|
||||||
|
// Column select dialog events
|
||||||
|
window.addEventListener('column-select:show', (e) => this.showColumnSelectDialog(e.detail));
|
||||||
|
|
||||||
// Lightbox events
|
// Lightbox events
|
||||||
window.addEventListener('lightbox:open', (e) => this.openLightbox(e.detail));
|
window.addEventListener('lightbox:open', (e) => this.openLightbox(e.detail));
|
||||||
|
|
||||||
@@ -702,11 +711,13 @@ class App {
|
|||||||
// Initialize contacts view when switching to it
|
// Initialize contacts view when switching to it
|
||||||
if (view === 'contacts') {
|
if (view === 'contacts') {
|
||||||
window.initContactsPromise = window.initContactsPromise || import('./contacts.js').then(module => {
|
window.initContactsPromise = window.initContactsPromise || import('./contacts.js').then(module => {
|
||||||
if (module.initContacts) {
|
if (module.contactsManager && !module.contactsManager.isInitialized) {
|
||||||
return module.initContacts();
|
return module.contactsManager.init();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
window.initContactsPromise.catch(console.error);
|
window.initContactsPromise.catch(error => {
|
||||||
|
console.error('[App] Contacts module error:', error);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Render list view when switching to it (delayed to ensure store is updated)
|
// Render list view when switching to it (delayed to ensure store is updated)
|
||||||
@@ -752,65 +763,114 @@ class App {
|
|||||||
}
|
}
|
||||||
|
|
||||||
populateAssigneeFilter() {
|
populateAssigneeFilter() {
|
||||||
const select = $('#filter-assignee');
|
const list = $('#filter-assignee-list');
|
||||||
if (!select) return;
|
if (!list) return;
|
||||||
|
|
||||||
const users = store.get('users');
|
const users = store.get('users');
|
||||||
select.innerHTML = '<option value="all">Alle Bearbeiter</option>';
|
list.innerHTML = '';
|
||||||
|
|
||||||
users.forEach(user => {
|
users.forEach(user => {
|
||||||
const option = document.createElement('option');
|
const label = document.createElement('label');
|
||||||
option.value = user.id;
|
label.className = 'filter-checkbox-item';
|
||||||
option.textContent = user.displayName || user.email || 'Unbekannt';
|
label.innerHTML = `<input type="checkbox" name="filter-assignee" value="${user.id}"><span>${user.displayName || user.email || 'Unbekannt'}</span>`;
|
||||||
select.appendChild(option);
|
list.appendChild(label);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
populateLabelFilter() {
|
populateLabelFilter() {
|
||||||
const select = $('#filter-labels');
|
const list = $('#filter-labels-list');
|
||||||
if (!select) return;
|
if (!list) return;
|
||||||
|
|
||||||
const labels = store.get('labels');
|
const labels = store.get('labels');
|
||||||
select.innerHTML = '<option value="all">Alle Labels</option>';
|
list.innerHTML = '';
|
||||||
|
|
||||||
labels.forEach(label => {
|
labels.forEach(label => {
|
||||||
const option = document.createElement('option');
|
const item = document.createElement('label');
|
||||||
option.value = label.id;
|
item.className = 'filter-checkbox-item';
|
||||||
option.textContent = label.name;
|
item.innerHTML = `<input type="checkbox" name="filter-labels" value="${label.id}"><span>${label.name}</span>`;
|
||||||
select.appendChild(option);
|
list.appendChild(item);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
applyCheckboxFilters() {
|
||||||
|
const getCheckedValues = (name) => {
|
||||||
|
const checkboxes = document.querySelectorAll(`input[name="${name}"]:checked`);
|
||||||
|
return Array.from(checkboxes).map(cb => cb.value);
|
||||||
|
};
|
||||||
|
|
||||||
|
const priority = getCheckedValues('filter-priority');
|
||||||
|
const assignee = getCheckedValues('filter-assignee');
|
||||||
|
const label = getCheckedValues('filter-labels');
|
||||||
|
const dueDate = getCheckedValues('filter-due');
|
||||||
|
|
||||||
|
store.setFilter('priority', priority.length > 0 ? priority : '');
|
||||||
|
store.setFilter('assignee', assignee.length > 0 ? assignee : '');
|
||||||
|
store.setFilter('label', label.length > 0 ? label : '');
|
||||||
|
store.setFilter('dueDate', dueDate.length > 0 ? dueDate : '');
|
||||||
|
|
||||||
|
this.updateFilterButtonState();
|
||||||
|
}
|
||||||
|
|
||||||
resetFilterInputs() {
|
resetFilterInputs() {
|
||||||
const priority = $('#filter-priority');
|
const checkboxes = document.querySelectorAll('#filter-popover input[type="checkbox"]');
|
||||||
const assignee = $('#filter-assignee');
|
checkboxes.forEach(cb => cb.checked = false);
|
||||||
const labels = $('#filter-labels');
|
|
||||||
const dueDate = $('#filter-due');
|
|
||||||
const search = $('#search-input');
|
const search = $('#search-input');
|
||||||
const searchClear = $('#search-clear');
|
const searchClear = $('#search-clear');
|
||||||
const searchContainer = $('.search-container');
|
const searchContainer = $('.search-container');
|
||||||
|
|
||||||
if (priority) priority.value = 'all';
|
|
||||||
if (assignee) assignee.value = 'all';
|
|
||||||
if (labels) labels.value = 'all';
|
|
||||||
if (dueDate) dueDate.value = '';
|
|
||||||
if (search) search.value = '';
|
if (search) search.value = '';
|
||||||
if (searchClear) searchClear.classList.add('hidden');
|
if (searchClear) searchClear.classList.add('hidden');
|
||||||
if (searchContainer) searchContainer.classList.remove('has-search');
|
if (searchContainer) searchContainer.classList.remove('has-search');
|
||||||
}
|
}
|
||||||
|
|
||||||
openArchiveModal() {
|
updateFilterButtonState() {
|
||||||
this.handleModalOpen({ modalId: 'archive-modal' });
|
const btn = $('#btn-filter-toggle');
|
||||||
this.renderArchiveList();
|
if (!btn) return;
|
||||||
|
|
||||||
|
const groups = ['filter-priority', 'filter-assignee', 'filter-labels', 'filter-due'];
|
||||||
|
const activeCount = groups.filter(name => {
|
||||||
|
return document.querySelectorAll(`input[name="${name}"]:checked`).length > 0;
|
||||||
|
}).length;
|
||||||
|
|
||||||
|
const label = btn.querySelector('span');
|
||||||
|
if (label) {
|
||||||
|
label.textContent = activeCount > 0 ? `Filter (${activeCount})` : 'Filter';
|
||||||
|
}
|
||||||
|
btn.classList.toggle('has-filters', activeCount > 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
renderArchiveList() {
|
async openArchiveModal() {
|
||||||
|
this.handleModalOpen({ modalId: 'archive-modal' });
|
||||||
|
|
||||||
|
const archiveList = $('#archive-list');
|
||||||
|
const archiveEmpty = $('#archive-empty');
|
||||||
|
|
||||||
|
// Ladeindikator anzeigen
|
||||||
|
if (archiveList) {
|
||||||
|
archiveList.classList.remove('hidden');
|
||||||
|
archiveList.innerHTML = '<div style="text-align:center;padding:2rem;color:var(--text-secondary)">Lade archivierte Aufgaben...</div>';
|
||||||
|
}
|
||||||
|
if (archiveEmpty) archiveEmpty.classList.add('hidden');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const projectId = store.get('currentProjectId');
|
||||||
|
const allTasks = await api.getTasks(projectId, { archived: true });
|
||||||
|
const archivedTasks = allTasks.filter(t => t.archived);
|
||||||
|
this.renderArchiveList(archivedTasks);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load archived tasks:', error);
|
||||||
|
if (archiveList) {
|
||||||
|
archiveList.innerHTML = '<div style="text-align:center;padding:2rem;color:var(--danger)">Fehler beim Laden der archivierten Aufgaben</div>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
renderArchiveList(tasks) {
|
||||||
const archiveList = $('#archive-list');
|
const archiveList = $('#archive-list');
|
||||||
const archiveEmpty = $('#archive-empty');
|
const archiveEmpty = $('#archive-empty');
|
||||||
if (!archiveList) return;
|
if (!archiveList) return;
|
||||||
|
|
||||||
// Get all archived tasks
|
|
||||||
const tasks = store.get('tasks').filter(t => t.archived);
|
|
||||||
const columns = store.get('columns');
|
const columns = store.get('columns');
|
||||||
|
|
||||||
if (tasks.length === 0) {
|
if (tasks.length === 0) {
|
||||||
@@ -855,13 +915,16 @@ class App {
|
|||||||
const projectId = store.get('currentProjectId');
|
const projectId = store.get('currentProjectId');
|
||||||
await api.restoreTask(projectId, taskId);
|
await api.restoreTask(projectId, taskId);
|
||||||
store.updateTask(taskId, { archived: false });
|
store.updateTask(taskId, { archived: false });
|
||||||
this.renderArchiveList();
|
|
||||||
this.showSuccess('Aufgabe wiederhergestellt');
|
this.showSuccess('Aufgabe wiederhergestellt');
|
||||||
|
|
||||||
// Re-render board
|
// Re-render board
|
||||||
if (this.board) {
|
if (this.board) {
|
||||||
this.board.render();
|
this.board.render();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Archivliste neu laden
|
||||||
|
const allTasks = await api.getTasks(projectId, { archived: true });
|
||||||
|
this.renderArchiveList(allTasks.filter(t => t.archived));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Restore error:', error);
|
console.error('Restore error:', error);
|
||||||
this.showError('Fehler beim Wiederherstellen');
|
this.showError('Fehler beim Wiederherstellen');
|
||||||
@@ -889,8 +952,11 @@ class App {
|
|||||||
const projectId = store.get('currentProjectId');
|
const projectId = store.get('currentProjectId');
|
||||||
await api.deleteTask(projectId, taskId);
|
await api.deleteTask(projectId, taskId);
|
||||||
store.removeTask(taskId);
|
store.removeTask(taskId);
|
||||||
this.renderArchiveList();
|
|
||||||
this.showSuccess('Aufgabe gelöscht');
|
this.showSuccess('Aufgabe gelöscht');
|
||||||
|
|
||||||
|
// Archivliste neu laden
|
||||||
|
const allTasks = await api.getTasks(projectId, { archived: true });
|
||||||
|
this.renderArchiveList(allTasks.filter(t => t.archived));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.showError('Fehler beim Löschen');
|
this.showError('Fehler beim Löschen');
|
||||||
}
|
}
|
||||||
@@ -944,9 +1010,9 @@ class App {
|
|||||||
import('./contacts.js').then(module => {
|
import('./contacts.js').then(module => {
|
||||||
if (module.contactsManager) {
|
if (module.contactsManager) {
|
||||||
module.contactsManager.searchQuery = '';
|
module.contactsManager.searchQuery = '';
|
||||||
module.contactsManager.filterContacts();
|
module.contactsManager.loadContacts();
|
||||||
}
|
}
|
||||||
});
|
}).catch(() => {});
|
||||||
|
|
||||||
// Cancel any pending server search
|
// Cancel any pending server search
|
||||||
if (searchAbortController) {
|
if (searchAbortController) {
|
||||||
@@ -1027,9 +1093,9 @@ class App {
|
|||||||
import('./contacts.js').then(module => {
|
import('./contacts.js').then(module => {
|
||||||
if (module.contactsManager) {
|
if (module.contactsManager) {
|
||||||
module.contactsManager.searchQuery = value;
|
module.contactsManager.searchQuery = value;
|
||||||
module.contactsManager.filterContacts();
|
module.contactsManager.loadContacts();
|
||||||
}
|
}
|
||||||
});
|
}).catch(() => {});
|
||||||
} else {
|
} else {
|
||||||
// Immediate client-side filtering for tasks
|
// Immediate client-side filtering for tasks
|
||||||
store.setFilter('search', value);
|
store.setFilter('search', value);
|
||||||
@@ -1283,6 +1349,55 @@ class App {
|
|||||||
cancelBtn?.addEventListener('click', handleCancel);
|
cancelBtn?.addEventListener('click', handleCancel);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// =====================
|
||||||
|
// COLUMN SELECT DIALOG
|
||||||
|
// =====================
|
||||||
|
|
||||||
|
showColumnSelectDialog({ message, columns, onSelect }) {
|
||||||
|
const modal = $('#column-select-modal');
|
||||||
|
const messageEl = $('#column-select-message');
|
||||||
|
const dropdown = $('#column-select-dropdown');
|
||||||
|
const confirmBtn = $('#column-select-ok');
|
||||||
|
const cancelBtn = $('#column-select-cancel');
|
||||||
|
|
||||||
|
if (messageEl) messageEl.textContent = message;
|
||||||
|
|
||||||
|
// Spalten in Dropdown einfügen
|
||||||
|
if (dropdown) {
|
||||||
|
dropdown.innerHTML = '';
|
||||||
|
columns.forEach(column => {
|
||||||
|
const option = document.createElement('option');
|
||||||
|
option.value = column.id;
|
||||||
|
option.textContent = column.name;
|
||||||
|
dropdown.appendChild(option);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show modal
|
||||||
|
this.handleModalOpen({ modalId: 'column-select-modal' });
|
||||||
|
|
||||||
|
// One-time event handlers
|
||||||
|
const handleConfirm = () => {
|
||||||
|
const selectedColumnId = parseInt(dropdown?.value);
|
||||||
|
this.handleModalClose({ modalId: 'column-select-modal' });
|
||||||
|
if (onSelect && selectedColumnId) onSelect(selectedColumnId);
|
||||||
|
cleanup();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCancel = () => {
|
||||||
|
this.handleModalClose({ modalId: 'column-select-modal' });
|
||||||
|
cleanup();
|
||||||
|
};
|
||||||
|
|
||||||
|
const cleanup = () => {
|
||||||
|
confirmBtn?.removeEventListener('click', handleConfirm);
|
||||||
|
cancelBtn?.removeEventListener('click', handleCancel);
|
||||||
|
};
|
||||||
|
|
||||||
|
confirmBtn?.addEventListener('click', handleConfirm);
|
||||||
|
cancelBtn?.addEventListener('click', handleCancel);
|
||||||
|
}
|
||||||
|
|
||||||
// =====================
|
// =====================
|
||||||
// LIGHTBOX
|
// LIGHTBOX
|
||||||
// =====================
|
// =====================
|
||||||
|
|||||||
@@ -26,9 +26,6 @@ class BoardManager {
|
|||||||
this.weekStripDate = this.getMonday(new Date());
|
this.weekStripDate = this.getMonday(new Date());
|
||||||
this.tooltip = null;
|
this.tooltip = null;
|
||||||
|
|
||||||
// Layout preferences
|
|
||||||
this.multiColumnLayout = this.loadLayoutPreference();
|
|
||||||
|
|
||||||
this.init();
|
this.init();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -56,20 +53,6 @@ class BoardManager {
|
|||||||
this.renderWeekStrip();
|
this.renderWeekStrip();
|
||||||
});
|
});
|
||||||
|
|
||||||
// Apply initial layout preference
|
|
||||||
setTimeout(() => {
|
|
||||||
this.applyLayoutClass();
|
|
||||||
if (this.multiColumnLayout) {
|
|
||||||
this.checkAndApplyDynamicLayout();
|
|
||||||
}
|
|
||||||
}, 100);
|
|
||||||
|
|
||||||
// Re-check layout on window resize
|
|
||||||
window.addEventListener('resize', debounce(() => {
|
|
||||||
if (this.multiColumnLayout) {
|
|
||||||
this.checkAndApplyDynamicLayout();
|
|
||||||
}
|
|
||||||
}, 250));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// =====================
|
// =====================
|
||||||
@@ -163,12 +146,6 @@ class BoardManager {
|
|||||||
// Keyboard navigation
|
// Keyboard navigation
|
||||||
document.addEventListener('keydown', (e) => this.handleKeyboard(e));
|
document.addEventListener('keydown', (e) => this.handleKeyboard(e));
|
||||||
|
|
||||||
// Layout toggle button - use delegated event handling
|
|
||||||
document.addEventListener('click', (e) => {
|
|
||||||
if (e.target.closest('#btn-toggle-layout')) {
|
|
||||||
this.toggleLayout();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// =====================
|
// =====================
|
||||||
@@ -181,9 +158,6 @@ class BoardManager {
|
|||||||
const columns = store.get('columns');
|
const columns = store.get('columns');
|
||||||
clearElement(this.boardElement);
|
clearElement(this.boardElement);
|
||||||
|
|
||||||
// Apply layout class
|
|
||||||
this.applyLayoutClass();
|
|
||||||
|
|
||||||
columns.forEach(column => {
|
columns.forEach(column => {
|
||||||
const columnElement = this.createColumnElement(column);
|
const columnElement = this.createColumnElement(column);
|
||||||
this.boardElement.appendChild(columnElement);
|
this.boardElement.appendChild(columnElement);
|
||||||
@@ -199,8 +173,8 @@ class BoardManager {
|
|||||||
]);
|
]);
|
||||||
this.boardElement.appendChild(addColumnBtn);
|
this.boardElement.appendChild(addColumnBtn);
|
||||||
|
|
||||||
// Check dynamic layout after render
|
// Emit event for mobile column navigation
|
||||||
setTimeout(() => this.checkAndApplyDynamicLayout(), 100);
|
document.dispatchEvent(new CustomEvent('columns:updated'));
|
||||||
}
|
}
|
||||||
|
|
||||||
createColumnElement(column) {
|
createColumnElement(column) {
|
||||||
@@ -506,9 +480,6 @@ class BoardManager {
|
|||||||
countElement.textContent = filteredTasks.length.toString();
|
countElement.textContent = filteredTasks.length.toString();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Check if dynamic layout adjustment is needed
|
|
||||||
setTimeout(() => this.checkAndApplyDynamicLayout(), 100);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// =====================
|
// =====================
|
||||||
@@ -1496,102 +1467,6 @@ class BoardManager {
|
|||||||
return div.innerHTML;
|
return div.innerHTML;
|
||||||
}
|
}
|
||||||
|
|
||||||
// =====================
|
|
||||||
// LAYOUT PREFERENCES
|
|
||||||
// =====================
|
|
||||||
|
|
||||||
loadLayoutPreference() {
|
|
||||||
const stored = localStorage.getItem('taskmate:boardLayout');
|
|
||||||
return stored === 'multiColumn';
|
|
||||||
}
|
|
||||||
|
|
||||||
saveLayoutPreference(multiColumn) {
|
|
||||||
localStorage.setItem('taskmate:boardLayout', multiColumn ? 'multiColumn' : 'single');
|
|
||||||
}
|
|
||||||
|
|
||||||
toggleLayout() {
|
|
||||||
this.multiColumnLayout = !this.multiColumnLayout;
|
|
||||||
this.saveLayoutPreference(this.multiColumnLayout);
|
|
||||||
this.applyLayoutClass();
|
|
||||||
this.checkAndApplyDynamicLayout();
|
|
||||||
|
|
||||||
this.showSuccess(this.multiColumnLayout
|
|
||||||
? 'Mehrspalten-Layout aktiviert'
|
|
||||||
: 'Einspalten-Layout aktiviert'
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
applyLayoutClass() {
|
|
||||||
const toggleBtn = $('#btn-toggle-layout');
|
|
||||||
|
|
||||||
if (this.multiColumnLayout) {
|
|
||||||
this.boardElement?.classList.add('multi-column-layout');
|
|
||||||
toggleBtn?.classList.add('active');
|
|
||||||
} else {
|
|
||||||
this.boardElement?.classList.remove('multi-column-layout');
|
|
||||||
toggleBtn?.classList.remove('active');
|
|
||||||
|
|
||||||
// Remove all dynamic classes when disabled
|
|
||||||
const columns = this.boardElement?.querySelectorAll('.column');
|
|
||||||
columns?.forEach(column => {
|
|
||||||
const columnBody = column.querySelector('.column-body');
|
|
||||||
columnBody?.classList.remove('dynamic-2-columns', 'dynamic-3-columns');
|
|
||||||
column.classList.remove('expanded-2x', 'expanded-3x');
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
checkAndApplyDynamicLayout() {
|
|
||||||
if (!this.multiColumnLayout || !this.boardElement) return;
|
|
||||||
|
|
||||||
// Debug logging
|
|
||||||
console.log('[Layout Check] Checking dynamic layout...');
|
|
||||||
|
|
||||||
// Check each column to see if scrolling is needed
|
|
||||||
const columns = this.boardElement.querySelectorAll('.column');
|
|
||||||
|
|
||||||
columns.forEach(column => {
|
|
||||||
const columnBody = column.querySelector('.column-body');
|
|
||||||
if (!columnBody) return;
|
|
||||||
|
|
||||||
// Remove dynamic classes first
|
|
||||||
columnBody.classList.remove('dynamic-2-columns', 'dynamic-3-columns');
|
|
||||||
column.classList.remove('expanded-2x', 'expanded-3x');
|
|
||||||
|
|
||||||
// Force reflow to get accurate measurements
|
|
||||||
columnBody.offsetHeight;
|
|
||||||
|
|
||||||
const scrollHeight = columnBody.scrollHeight;
|
|
||||||
const clientHeight = columnBody.clientHeight;
|
|
||||||
const hasOverflow = scrollHeight > clientHeight;
|
|
||||||
|
|
||||||
console.log('[Layout Check]', {
|
|
||||||
column: column.dataset.columnId,
|
|
||||||
scrollHeight,
|
|
||||||
clientHeight,
|
|
||||||
hasOverflow,
|
|
||||||
ratio: scrollHeight / clientHeight
|
|
||||||
});
|
|
||||||
|
|
||||||
// Check if content overflows
|
|
||||||
if (hasOverflow && clientHeight > 0) {
|
|
||||||
// Calculate how many columns we need based on content height
|
|
||||||
const ratio = scrollHeight / clientHeight;
|
|
||||||
|
|
||||||
if (ratio > 2.5 && window.innerWidth >= 1800) {
|
|
||||||
// Need 3 columns
|
|
||||||
console.log('[Layout] Applying 3 columns');
|
|
||||||
columnBody.classList.add('dynamic-3-columns');
|
|
||||||
column.classList.add('expanded-3x');
|
|
||||||
} else if (ratio > 1.1 && window.innerWidth >= 1400) {
|
|
||||||
// Need 2 columns (reduced threshold to 1.1)
|
|
||||||
console.log('[Layout] Applying 2 columns');
|
|
||||||
columnBody.classList.add('dynamic-2-columns');
|
|
||||||
column.classList.add('expanded-2x');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create and export singleton
|
// Create and export singleton
|
||||||
|
|||||||
@@ -712,7 +712,11 @@ class CalendarViewManager {
|
|||||||
|
|
||||||
filteredTasks = filteredTasks.filter(task => {
|
filteredTasks = filteredTasks.filter(task => {
|
||||||
const filterCategory = columnFilterMap[task.columnId];
|
const filterCategory = columnFilterMap[task.columnId];
|
||||||
// If no filter state exists for this category, default to showing "in_progress"
|
// If column has 'in_progress' filter category, check if 'in_progress' filter is enabled
|
||||||
|
if (filterCategory === 'in_progress') {
|
||||||
|
return this.enabledFilters['in_progress'] !== false;
|
||||||
|
}
|
||||||
|
// For other categories, check their specific filter state
|
||||||
if (this.enabledFilters[filterCategory] === undefined) {
|
if (this.enabledFilters[filterCategory] === undefined) {
|
||||||
// Default: show in_progress, hide open and completed
|
// Default: show in_progress, hide open and completed
|
||||||
return filterCategory === 'in_progress';
|
return filterCategory === 'in_progress';
|
||||||
|
|||||||
Datei-Diff unterdrückt, da er zu groß ist
Diff laden
@@ -5,6 +5,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { $, $$ } from './utils.js';
|
import { $, $$ } from './utils.js';
|
||||||
|
import { enhanceMobileSwipe } from './mobile-swipe.js';
|
||||||
|
|
||||||
class MobileManager {
|
class MobileManager {
|
||||||
constructor() {
|
constructor() {
|
||||||
@@ -21,6 +22,8 @@ class MobileManager {
|
|||||||
this.touchStartTime = 0;
|
this.touchStartTime = 0;
|
||||||
this.isSwiping = false;
|
this.isSwiping = false;
|
||||||
this.swipeDirection = null;
|
this.swipeDirection = null;
|
||||||
|
this.currentColumnIndex = 0;
|
||||||
|
this.swipeTarget = null; // 'board' or 'header'
|
||||||
|
|
||||||
// Touch drag & drop state
|
// Touch drag & drop state
|
||||||
this.touchDraggedElement = null;
|
this.touchDraggedElement = null;
|
||||||
@@ -87,6 +90,9 @@ class MobileManager {
|
|||||||
this.updateUserInfo();
|
this.updateUserInfo();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Enhance with new swipe functionality
|
||||||
|
enhanceMobileSwipe(this);
|
||||||
|
|
||||||
console.log('[Mobile] Initialized');
|
console.log('[Mobile] Initialized');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -220,8 +226,18 @@ class MobileManager {
|
|||||||
$$('.view').forEach(v => {
|
$$('.view').forEach(v => {
|
||||||
const viewName = v.id.replace('view-', '');
|
const viewName = v.id.replace('view-', '');
|
||||||
const isActive = viewName === view;
|
const isActive = viewName === view;
|
||||||
v.classList.toggle('active', isActive);
|
|
||||||
v.classList.toggle('hidden', !isActive);
|
if (isActive) {
|
||||||
|
v.classList.add('active');
|
||||||
|
v.classList.remove('hidden');
|
||||||
|
// Force display for mobile
|
||||||
|
if (this.isMobile) {
|
||||||
|
v.style.display = '';
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
v.classList.remove('active');
|
||||||
|
v.classList.add('hidden');
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Update mobile nav
|
// Update mobile nav
|
||||||
@@ -300,41 +316,42 @@ class MobileManager {
|
|||||||
bindSwipeEvents() {
|
bindSwipeEvents() {
|
||||||
if (!this.mainContent) return;
|
if (!this.mainContent) return;
|
||||||
|
|
||||||
this.mainContent.addEventListener('touchstart', (e) => this.handleSwipeStart(e), { passive: true });
|
// Swipe für Board-Container (Spalten-Navigation)
|
||||||
this.mainContent.addEventListener('touchmove', (e) => this.handleSwipeMove(e), { passive: false });
|
const boardContainer = document.querySelector('.board-container');
|
||||||
this.mainContent.addEventListener('touchend', (e) => this.handleSwipeEnd(e), { passive: true });
|
if (boardContainer) {
|
||||||
this.mainContent.addEventListener('touchcancel', () => this.resetSwipe(), { passive: true });
|
boardContainer.addEventListener('touchstart', (e) => this.handleBoardSwipeStart(e), { passive: true });
|
||||||
|
boardContainer.addEventListener('touchmove', (e) => this.handleBoardSwipeMove(e), { passive: false });
|
||||||
|
boardContainer.addEventListener('touchend', (e) => this.handleBoardSwipeEnd(e), { passive: true });
|
||||||
|
boardContainer.addEventListener('touchcancel', () => this.resetSwipe(), { passive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Swipe für Header (View-Navigation)
|
||||||
|
const header = document.querySelector('.header');
|
||||||
|
if (header) {
|
||||||
|
header.addEventListener('touchstart', (e) => this.handleHeaderSwipeStart(e), { passive: true });
|
||||||
|
header.addEventListener('touchmove', (e) => this.handleHeaderSwipeMove(e), { passive: false });
|
||||||
|
header.addEventListener('touchend', (e) => this.handleHeaderSwipeEnd(e), { passive: true });
|
||||||
|
header.addEventListener('touchcancel', () => this.resetSwipe(), { passive: true });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handle swipe start
|
* Handle board swipe start (für Spalten-Navigation)
|
||||||
*/
|
*/
|
||||||
handleSwipeStart(e) {
|
handleBoardSwipeStart(e) {
|
||||||
if (!this.isMobile) return;
|
if (!this.isMobile || this.currentView !== 'board') return;
|
||||||
|
if (this.isMenuOpen || $('.modal-overlay:not(.hidden)')) return;
|
||||||
|
|
||||||
// Don't swipe if menu is open
|
// Don't swipe on interactive elements
|
||||||
if (this.isMenuOpen) return;
|
|
||||||
|
|
||||||
// Don't swipe if modal is open
|
|
||||||
if ($('.modal-overlay:not(.hidden)')) return;
|
|
||||||
|
|
||||||
// Don't swipe on specific interactive elements, but allow swipe in column-body
|
|
||||||
const target = e.target;
|
const target = e.target;
|
||||||
if (target.closest('.modal') ||
|
if (target.closest('button') ||
|
||||||
target.closest('.calendar-grid') ||
|
|
||||||
target.closest('.knowledge-entry-list') ||
|
|
||||||
target.closest('.list-table') ||
|
|
||||||
target.closest('input') ||
|
target.closest('input') ||
|
||||||
target.closest('textarea') ||
|
target.closest('textarea') ||
|
||||||
target.closest('select') ||
|
target.closest('select') ||
|
||||||
target.closest('button') ||
|
target.closest('.task-card')) {
|
||||||
target.closest('a[href]') ||
|
|
||||||
target.closest('.task-card .priority-stars') ||
|
|
||||||
target.closest('.task-card .task-counts')) {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Only single touch
|
|
||||||
if (e.touches.length !== 1) return;
|
if (e.touches.length !== 1) return;
|
||||||
|
|
||||||
this.touchStartX = e.touches[0].clientX;
|
this.touchStartX = e.touches[0].clientX;
|
||||||
@@ -342,12 +359,79 @@ class MobileManager {
|
|||||||
this.touchStartTime = Date.now();
|
this.touchStartTime = Date.now();
|
||||||
this.isSwiping = false;
|
this.isSwiping = false;
|
||||||
this.swipeDirection = null;
|
this.swipeDirection = null;
|
||||||
|
this.swipeTarget = 'board';
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handle swipe move
|
* Handle header swipe start (für View-Navigation)
|
||||||
*/
|
*/
|
||||||
handleSwipeMove(e) {
|
handleHeaderSwipeStart(e) {
|
||||||
|
if (!this.isMobile) return;
|
||||||
|
if (this.isMenuOpen || $('.modal-overlay:not(.hidden)')) return;
|
||||||
|
|
||||||
|
// Nur in der Header-Region swipen
|
||||||
|
if (!e.target.closest('.header')) return;
|
||||||
|
|
||||||
|
// Nicht auf Buttons swipen
|
||||||
|
if (e.target.closest('button') || e.target.closest('.view-tab')) return;
|
||||||
|
|
||||||
|
if (e.touches.length !== 1) return;
|
||||||
|
|
||||||
|
this.touchStartX = e.touches[0].clientX;
|
||||||
|
this.touchStartY = e.touches[0].clientY;
|
||||||
|
this.touchStartTime = Date.now();
|
||||||
|
this.isSwiping = false;
|
||||||
|
this.swipeDirection = null;
|
||||||
|
this.swipeTarget = 'header';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle board swipe move
|
||||||
|
*/
|
||||||
|
handleBoardSwipeMove(e) {
|
||||||
|
if (!this.isMobile || this.touchStartX === 0 || this.swipeTarget !== 'board') return;
|
||||||
|
|
||||||
|
const touch = e.touches[0];
|
||||||
|
this.touchCurrentX = touch.clientX;
|
||||||
|
this.touchCurrentY = touch.clientY;
|
||||||
|
|
||||||
|
const deltaX = this.touchCurrentX - this.touchStartX;
|
||||||
|
const deltaY = this.touchCurrentY - this.touchStartY;
|
||||||
|
|
||||||
|
// Determine direction
|
||||||
|
if (!this.swipeDirection && (Math.abs(deltaX) > 10 || Math.abs(deltaY) > 10)) {
|
||||||
|
if (Math.abs(deltaX) > Math.abs(deltaY) * 1.5) {
|
||||||
|
this.swipeDirection = 'horizontal';
|
||||||
|
this.isSwiping = true;
|
||||||
|
document.body.classList.add('is-swiping');
|
||||||
|
} else {
|
||||||
|
this.swipeDirection = 'vertical';
|
||||||
|
this.resetSwipe();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.swipeDirection !== 'horizontal') return;
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
// Visual feedback für Spalten-Navigation
|
||||||
|
const columns = $$('.column');
|
||||||
|
if (deltaX > this.SWIPE_THRESHOLD && this.currentColumnIndex > 0) {
|
||||||
|
this.swipeIndicatorLeft?.classList.add('visible');
|
||||||
|
this.swipeIndicatorRight?.classList.remove('visible');
|
||||||
|
} else if (deltaX < -this.SWIPE_THRESHOLD && this.currentColumnIndex < columns.length - 1) {
|
||||||
|
this.swipeIndicatorRight?.classList.add('visible');
|
||||||
|
this.swipeIndicatorLeft?.classList.remove('visible');
|
||||||
|
} else {
|
||||||
|
this.swipeIndicatorLeft?.classList.remove('visible');
|
||||||
|
this.swipeIndicatorRight?.classList.remove('visible');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle header swipe move
|
||||||
|
*/
|
||||||
|
handleHeaderSwipeMove(e) {
|
||||||
if (!this.isMobile || this.touchStartX === 0) return;
|
if (!this.isMobile || this.touchStartX === 0) return;
|
||||||
|
|
||||||
const touch = e.touches[0];
|
const touch = e.touches[0];
|
||||||
@@ -412,10 +496,11 @@ class MobileManager {
|
|||||||
* Handle swipe end
|
* Handle swipe end
|
||||||
*/
|
*/
|
||||||
handleSwipeEnd() {
|
handleSwipeEnd() {
|
||||||
if (!this.isSwiping || this.swipeDirection !== 'horizontal') {
|
if (!this.isMobile || this.touchStartX === 0 || this.swipeTarget !== 'header') return;
|
||||||
this.resetSwipe();
|
|
||||||
return;
|
const touch = e.touches[0];
|
||||||
}
|
this.touchCurrentX = touch.clientX;
|
||||||
|
this.touchCurrentY = touch.clientY;
|
||||||
|
|
||||||
const deltaX = this.touchCurrentX - this.touchStartX;
|
const deltaX = this.touchCurrentX - this.touchStartX;
|
||||||
const deltaTime = Date.now() - this.touchStartTime;
|
const deltaTime = Date.now() - this.touchStartTime;
|
||||||
|
|||||||
@@ -32,10 +32,10 @@ class Store {
|
|||||||
// Filters
|
// Filters
|
||||||
filters: {
|
filters: {
|
||||||
search: '',
|
search: '',
|
||||||
priority: 'all',
|
priority: '',
|
||||||
assignee: 'all',
|
assignee: '',
|
||||||
label: 'all',
|
label: '',
|
||||||
dueDate: 'all',
|
dueDate: '',
|
||||||
archived: false
|
archived: false
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -155,10 +155,10 @@ class Store {
|
|||||||
labels: [],
|
labels: [],
|
||||||
filters: {
|
filters: {
|
||||||
search: '',
|
search: '',
|
||||||
priority: 'all',
|
priority: '',
|
||||||
assignee: 'all',
|
assignee: '',
|
||||||
label: 'all',
|
label: '',
|
||||||
dueDate: 'all',
|
dueDate: '',
|
||||||
archived: false
|
archived: false
|
||||||
},
|
},
|
||||||
searchResultIds: [],
|
searchResultIds: [],
|
||||||
@@ -412,10 +412,10 @@ class Store {
|
|||||||
this.setState({
|
this.setState({
|
||||||
filters: {
|
filters: {
|
||||||
search: '',
|
search: '',
|
||||||
priority: 'all',
|
priority: '',
|
||||||
assignee: 'all',
|
assignee: '',
|
||||||
label: 'all',
|
label: '',
|
||||||
dueDate: 'all',
|
dueDate: '',
|
||||||
archived: false
|
archived: false
|
||||||
}
|
}
|
||||||
}, 'RESET_FILTERS');
|
}, 'RESET_FILTERS');
|
||||||
|
|||||||
@@ -49,6 +49,16 @@ class SyncManager {
|
|||||||
});
|
});
|
||||||
|
|
||||||
this.setupEventListeners();
|
this.setupEventListeners();
|
||||||
|
|
||||||
|
// Update body offline class based on sync status
|
||||||
|
store.subscribe('syncStatus', () => {
|
||||||
|
const status = store.get('syncStatus');
|
||||||
|
if (status === 'offline' || status === 'error') {
|
||||||
|
document.body.classList.add('offline');
|
||||||
|
} else {
|
||||||
|
document.body.classList.remove('offline');
|
||||||
|
}
|
||||||
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[Sync] Failed to connect:', error);
|
console.error('[Sync] Failed to connect:', error);
|
||||||
store.setSyncStatus('error');
|
store.setSyncStatus('error');
|
||||||
|
|||||||
@@ -587,9 +587,37 @@ class TaskModalManager {
|
|||||||
this.close();
|
this.close();
|
||||||
this.showSuccess('Aufgabe wiederhergestellt');
|
this.showSuccess('Aufgabe wiederhergestellt');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
// Prüfen ob Spaltenauswahl erforderlich ist
|
||||||
|
if (error.data?.requiresColumn) {
|
||||||
|
this.showColumnSelectDialog();
|
||||||
|
} else {
|
||||||
this.showError('Fehler beim Wiederherstellen');
|
this.showError('Fehler beim Wiederherstellen');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
showColumnSelectDialog() {
|
||||||
|
const columns = store.get('columns');
|
||||||
|
|
||||||
|
// Modal für Spaltenauswahl erstellen
|
||||||
|
window.dispatchEvent(new CustomEvent('column-select:show', {
|
||||||
|
detail: {
|
||||||
|
message: 'Die ursprüngliche Spalte existiert nicht mehr. Bitte wählen Sie eine Spalte:',
|
||||||
|
columns: columns,
|
||||||
|
onSelect: async (columnId) => {
|
||||||
|
try {
|
||||||
|
const projectId = store.get('currentProjectId');
|
||||||
|
await api.restoreTask(projectId, this.taskId, columnId);
|
||||||
|
store.updateTask(this.taskId, { archived: false, columnId: columnId });
|
||||||
|
this.close();
|
||||||
|
this.showSuccess('Aufgabe wiederhergestellt');
|
||||||
|
} catch (error) {
|
||||||
|
this.showError('Fehler beim Wiederherstellen');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
async autoSaveDescription() {
|
async autoSaveDescription() {
|
||||||
// Deprecated - use autoSaveTask instead
|
// Deprecated - use autoSaveTask instead
|
||||||
|
|||||||
@@ -492,38 +492,56 @@ export function filterTasks(tasks, filters, searchResultIds = [], columns = [])
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Priority filter
|
// Priority filter (string or array)
|
||||||
if (filters.priority && filters.priority !== 'all') {
|
if (filters.priority && filters.priority.length > 0) {
|
||||||
if (task.priority !== filters.priority) return false;
|
const priorityFilter = Array.isArray(filters.priority) ? filters.priority : [filters.priority];
|
||||||
|
if (!priorityFilter.includes(task.priority)) return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Assignee filter
|
// Assignee filter (string or array)
|
||||||
if (filters.assignee && filters.assignee !== 'all') {
|
if (filters.assignee && filters.assignee.length > 0) {
|
||||||
if (task.assignedTo !== parseInt(filters.assignee)) return false;
|
const assigneeFilter = Array.isArray(filters.assignee) ? filters.assignee : [filters.assignee];
|
||||||
|
const assigneeIds = assigneeFilter.map(id => parseInt(id));
|
||||||
|
const taskAssigneeId = task.assignedTo;
|
||||||
|
const taskAssignees = task.assignees?.map(a => a.id || a) || [];
|
||||||
|
const matches = assigneeIds.some(id => id === taskAssigneeId || taskAssignees.includes(id));
|
||||||
|
if (!matches) return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Label filter
|
// Label filter (string or array)
|
||||||
if (filters.label && filters.label !== 'all') {
|
if (filters.label && filters.label.length > 0) {
|
||||||
const hasLabel = task.labels?.some(l => l.id === parseInt(filters.label));
|
const labelFilter = Array.isArray(filters.label) ? filters.label : [filters.label];
|
||||||
|
const labelIds = labelFilter.map(id => parseInt(id));
|
||||||
|
const hasLabel = task.labels?.some(l => labelIds.includes(l.id));
|
||||||
if (!hasLabel) return false;
|
if (!hasLabel) return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Due date filter
|
// Due date filter (string or array)
|
||||||
if (filters.dueDate && filters.dueDate !== 'all' && filters.dueDate !== '') {
|
if (filters.dueDate && filters.dueDate.length > 0) {
|
||||||
const status = getDueDateStatus(task.dueDate);
|
const dueDateFilter = Array.isArray(filters.dueDate) ? filters.dueDate : [filters.dueDate];
|
||||||
|
let matches = false;
|
||||||
|
|
||||||
// Bei "überfällig" erledigte Aufgaben ausschließen
|
for (const filterVal of dueDateFilter) {
|
||||||
if (filters.dueDate === 'overdue') {
|
if (filterVal === 'none') {
|
||||||
if (status !== 'overdue' || isTaskCompleted(task)) return false;
|
if (!task.dueDate) { matches = true; break; }
|
||||||
}
|
} else if (filterVal === 'overdue') {
|
||||||
if (filters.dueDate === 'today' && status !== 'today') return false;
|
const status = getDueDateStatus(task.dueDate);
|
||||||
if (filters.dueDate === 'week') {
|
if (status === 'overdue' && !isTaskCompleted(task)) { matches = true; break; }
|
||||||
|
} else if (filterVal === 'today') {
|
||||||
|
const status = getDueDateStatus(task.dueDate);
|
||||||
|
if (status === 'today') { matches = true; break; }
|
||||||
|
} else if (filterVal === 'week') {
|
||||||
|
if (task.dueDate) {
|
||||||
const due = new Date(task.dueDate);
|
const due = new Date(task.dueDate);
|
||||||
const weekFromNow = new Date();
|
const weekFromNow = new Date();
|
||||||
weekFromNow.setDate(weekFromNow.getDate() + 7);
|
weekFromNow.setDate(weekFromNow.getDate() + 7);
|
||||||
if (!task.dueDate || due > weekFromNow) return false;
|
if (due <= weekFromNow) { matches = true; break; }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!matches) return false;
|
||||||
|
}
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
* Offline support and caching
|
* Offline support and caching
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const CACHE_VERSION = '306';
|
const CACHE_VERSION = '390';
|
||||||
const CACHE_NAME = 'taskmate-v' + CACHE_VERSION;
|
const CACHE_NAME = 'taskmate-v' + CACHE_VERSION;
|
||||||
const STATIC_CACHE_NAME = 'taskmate-static-v' + CACHE_VERSION;
|
const STATIC_CACHE_NAME = 'taskmate-static-v' + CACHE_VERSION;
|
||||||
const DYNAMIC_CACHE_NAME = 'taskmate-dynamic-v' + CACHE_VERSION;
|
const DYNAMIC_CACHE_NAME = 'taskmate-dynamic-v' + CACHE_VERSION;
|
||||||
|
|||||||
In neuem Issue referenzieren
Einen Benutzer sperren