diff --git a/index.html b/index.html index cab2aea..6e9f3c2 100644 --- a/index.html +++ b/index.html @@ -3,6 +3,7 @@ + Webklar WOMS 2.0 diff --git a/public/favicon.svg b/public/favicon.svg new file mode 100644 index 0000000..ef2669e --- /dev/null +++ b/public/favicon.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/src/App.jsx b/src/App.jsx index e0505ce..74f0ad0 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -1,7 +1,8 @@ -import { BrowserRouter, Routes, Route, Navigate, useLocation } from 'react-router-dom' +import { useCallback, useEffect, useState } from 'react' +import { BrowserRouter, Routes, Route, Navigate, Link, useLocation } from 'react-router-dom' import { AuthProvider, useAuth } from './context/AuthContext' import Sidebar from './components/Navbar' -import Footer from './components/Footer' +import Logo from './components/Logo' import LoginPage from './pages/LoginPage' import TicketsPage from './pages/TicketsPage' import PlanboardPage from './pages/PlanboardPage' @@ -28,12 +29,7 @@ function ProtectedRoute({ children }) { const location = useLocation() if (loading) { - return ( -
-
-

Loading...

-
- ) + return
Wird geladen …
} if (!user) { @@ -79,19 +75,40 @@ function AppRoutes() { function AppContent() { const { user } = useAuth() + const location = useLocation() + // Schublade des Menues auf dem Handy; auf dem Desktop steht das Menue fest und menuOpen bleibt ohne Wirkung + const [menuOpen, setMenuOpen] = useState(false) + const open = useCallback(() => setMenuOpen(true), []) + const close = useCallback(() => setMenuOpen(false), []) + + // Seitenwechsel (nur der Pfad, nicht ?woid= oder ?tab=): Schublade zu und wieder nach oben + useEffect(() => { + setMenuOpen(false) + window.scrollTo(0, 0) + }, [location.pathname]) + + // Esc schliesst die Schublade, nur solange sie offen ist + useEffect(() => { + if (!menuOpen) return undefined + const onKey = (e) => { if (e.key === 'Escape') setMenuOpen(false) } + document.addEventListener('keydown', onKey) + return () => document.removeEventListener('keydown', onKey) + }, [menuOpen]) if (!user) { return } return ( -
- -
-
- +
+ +
+
+
+ WEBklar +
-
+
) diff --git a/src/components/Footer.jsx b/src/components/Footer.jsx deleted file mode 100644 index f3925d3..0000000 --- a/src/components/Footer.jsx +++ /dev/null @@ -1,9 +0,0 @@ -export default function Footer() { - return ( -
-

- Webklar Ticket-Plattform · © {new Date().getFullYear()} -

-
- ) -} diff --git a/src/components/Logo.jsx b/src/components/Logo.jsx new file mode 100644 index 0000000..4b6b13a --- /dev/null +++ b/src/components/Logo.jsx @@ -0,0 +1,17 @@ +// WEBklar-Zeichen (Ellipse mit Stern); Pfade aus dem Prototyp. Die Groesse setzen im Menue und auf der Anmeldung die Stile +// (.brand svg, .login-box svg), size gilt, wo keine Regel greift. +export default function Logo({ size = 24 }) { + return ( + + ) +} diff --git a/src/components/Navbar.jsx b/src/components/Navbar.jsx index 7ff77e8..774454a 100644 --- a/src/components/Navbar.jsx +++ b/src/components/Navbar.jsx @@ -1,52 +1,16 @@ import { useEffect, useState } from 'react' import { Link, useLocation, useNavigate } from 'react-router-dom' import { useAuth } from '../context/AuthContext' -import { - IconTicket, - IconUsers, - IconTargetArrow, - IconReportMoney, - IconScale, - IconActivityHeartbeat, - IconFolders, - IconLayoutGrid, - IconCalendar, - IconSettings, - IconLogout, - IconLayoutSidebarLeftCollapse, - IconLayoutSidebarLeftExpand, - IconMessages, - IconReceiptRefund, - IconDiscount, - IconMailCog, -} from '@tabler/icons-react' +import Logo from './Logo' import { serverpulsApi } from '../lib/serverpulsApi' -import { startsCollapsed } from '../lib/sidebar' import { buildNavLinks } from '../lib/navLinks' import { useSidebarCounts } from '../hooks/useSidebarCounts' -const ICONS = { - tickets: , - planboard: , - projects: , - services: , - customers: , - chat: , - gutschriften: , - angebote: , - speicher: , - leads: , - finance: , - legal: , - serverpuls: , - admin: , -} - -export default function Sidebar() { +// Seitenmenue als reine Textliste. onNavigate schliesst die Schublade auf dem Handy (App.jsx). +export default function Sidebar({ onNavigate }) { const { user, logout, isAdmin } = useAuth() const navigate = useNavigate() const location = useLocation() - const [collapsed, setCollapsed] = useState(() => startsCollapsed()) const [pulseLevel, setPulseLevel] = useState('ok') const counts = useSidebarCounts() @@ -69,7 +33,7 @@ export default function Sidebar() { navigate('/login') } - // Eintraege und Unterpunkte (Kunden -> Chat) aus lib/navLinks.js, Icons ueber key + // Eintraege und Unterpunkte (Kunden -> Chat) aus lib/navLinks.js const links = buildNavLinks({ isAdmin, pulseLevel, counts }) const isActive = (href) => @@ -78,79 +42,51 @@ export default function Sidebar() { const isLinkActive = (link) => isActive(link.href) || (link.also ? isActive(link.also) : false) return ( -
) } @@ -121,7 +121,7 @@ export default function AdminPage() { } return ( -
+
Systemverwaltung @@ -222,6 +222,6 @@ export default function AdminPage() {
)} -
+
) } diff --git a/src/pages/ProjectsPage.jsx b/src/pages/ProjectsPage.jsx index 053f787..57c1b94 100644 --- a/src/pages/ProjectsPage.jsx +++ b/src/pages/ProjectsPage.jsx @@ -369,7 +369,7 @@ export default function ProjectsPage() { const typeOptions = Object.entries(TYPES).filter(([, meta]) => isAdmin || !meta.adminOnly) return ( -
+

Website-Projekte

diff --git a/src/styles/global.css b/src/styles/global.css index f1bd0ef..cb53fbb 100644 --- a/src/styles/global.css +++ b/src/styles/global.css @@ -131,6 +131,7 @@ fieldset:disabled { opacity: 1; } .nav-item.paused:hover { background: none; color: var(--text-3); } .nav-count { font-size: 12px; color: var(--text-3); font-variant-numeric: tabular-nums; } .nav-count.hot { color: var(--accent-ink); font-weight: 600; } +.nav-item.active .nav-count:not(.hot) { color: var(--text-2); } /* auf der grauen Markierung reicht --text-3 nicht (2,7:1) */ .nav-dot { width: 6px; height: 6px; border-radius: 50%; background: var(--nav-dot); } .nav-dot.critical { background: var(--danger); } @@ -143,6 +144,7 @@ fieldset:disabled { opacity: 1; } gap: 2px; } .sidebar-foot .who { font-weight: 500; } +.sidebar-foot .link { align-self: flex-start; } /* Knopf statt Link: sonst steht "Abmelden" mittig in der vollen Breite */ .main { flex: 1; margin-left: var(--sidebar-w); min-width: 0; } @@ -482,14 +484,6 @@ a.list-row:hover .cell-title { color: var(--accent-ink); } } /* NEU-ENDE */ -/* Layout */ -.main-content { - padding: var(--sp-5); - max-width: 1320px; - margin: 0 auto; - width: 100%; -} - /* Utilities */ .text-center { text-align: center; } @@ -515,11 +509,6 @@ a.list-row:hover .cell-title { color: var(--accent-ink); } /* =================== REDESIGN (Designsystem) =============== */ /* ============================================================= */ -/* --- App-Shell --- */ -.app-shell { display: flex; height: 100vh; overflow: hidden; } -.app-main { flex: 1; min-width: 0; display: flex; flex-direction: column; overflow: hidden; } -.app-scroll { flex: 1; overflow-y: auto; } - /* --- Page header + container --- */ .page-header { display: flex; align-items: flex-start; justify-content: space-between; @@ -1110,53 +1099,6 @@ select.form-control { border-radius: var(--radius); background: var(--grey-bg); color: var(--grey-fg); font-weight: 600; } -/* Altes Menue (Navbar.jsx) unveraendert aus dem Altbestand: Task 5 ersetzt das Menue und loescht diesen Teil. Ohne ihn faellt das */ -/* Alt-Geruest (.app-shell) zusammen; .side.collapsed gilt auf dem Handy (lib/sidebar.js). */ -.side { - width: var(--sidebar-w); - flex-shrink: 0; - background: linear-gradient(180deg, var(--surface-1), var(--bg)); - border-right: 1px solid var(--border); - display: flex; - flex-direction: column; - transition: width 0.2s ease; -} -.side.collapsed { width: 72px; } -.side-head { - display: flex; align-items: center; gap: var(--sp-3); - padding: var(--sp-4); border-bottom: 1px solid var(--border); - min-height: 64px; -} -.side-brand { font-weight: 800; font-size: 16px; letter-spacing: 0.2px; white-space: nowrap; } -.side-brand small { display: block; font-weight: 500; font-size: 11px; color: var(--text-muted); } -.side-nav { flex: 1; overflow-y: auto; padding: var(--sp-3) var(--sp-2); } -.side-group { margin-bottom: var(--sp-4); } -.side.collapsed .side-link-label, -.side.collapsed .side-brand { display: none; } -.side-link { - display: flex; align-items: center; gap: var(--sp-3); - padding: 10px var(--sp-3); margin: 2px var(--sp-1); - border-radius: var(--r-sm); color: var(--text-muted); - text-decoration: none; transition: all 0.15s ease; white-space: nowrap; -} -.side-link svg { width: 20px; height: 20px; flex-shrink: 0; } -.side-link:hover { background: var(--surface-2); color: var(--text); } -.side-link-paused, .side-link-paused:hover { opacity: 0.45; cursor: not-allowed; background: transparent; color: var(--text-muted); } -.side-link.active { background: var(--accent-soft); color: var(--accent); font-weight: 600; } -.side-sublink { padding-left: calc(var(--sp-3) + 18px); font-size: 13.5px; } -.side-sublink svg { width: 17px; height: 17px; } -.side.collapsed .side-sublink { padding-left: var(--sp-3); } -.side.collapsed .side-count { position: absolute; margin: -18px 0 0 14px; min-width: 16px; padding: 0 4px; font-size: 10px; line-height: 16px; } -.side.collapsed .side-sublink { position: relative; } -.side-link.active svg { color: var(--accent); } -.side-foot { border-top: 1px solid var(--border); padding: var(--sp-3); } -.side-toggle { - background: transparent; border: none; color: var(--text-muted); - cursor: pointer; padding: var(--sp-2); border-radius: var(--r-sm); - display: flex; align-items: center; gap: var(--sp-2); width: 100%; -} -.side-toggle:hover { background: var(--surface-2); color: var(--text); } - /* Neutral: Karte, Raster, Aufklappmenue */ .card { margin-bottom: 16px; background: none; border: 0; border-radius: 0; color: var(--text); } .card-header { padding: 0 0 8px; background: none; border-bottom: 1px solid var(--line); color: var(--text); font-weight: 600; } @@ -1179,7 +1121,7 @@ div.row { display: flex; flex-wrap: wrap; margin: 0 -8px; } .spinner { width: 40px; height: 40px; margin: 0 auto; border: 4px solid var(--line); border-top-color: var(--text-2); border-radius: 50%; animation: spin 1s linear infinite; } /* Verschachtelte Seiten: Abstand und Breite des inneren Rahmens entfallen */ -.page .page, .page .main-content { margin: 0; padding: 0; max-width: none; } +.page .page { margin: 0; padding: 0; max-width: none; } /* Links in Bestandsklassen, die bisher --accent als Farbe nutzten: feiner Unterstrich */ .fin-link, .legal-files a, .credit-cols a, .area-rows a { diff --git a/src/styles/serverpuls.css b/src/styles/serverpuls.css index b4f8359..15e4756 100644 --- a/src/styles/serverpuls.css +++ b/src/styles/serverpuls.css @@ -66,9 +66,3 @@ .sp-cleanup-grid strong { font-size: 20px; } .sp-cleanup-list { list-style: none; margin: var(--sp-3) 0; padding: 0; } .sp-cleanup-list li { display: flex; justify-content: space-between; padding: 4px 0; border-bottom: 1px solid var(--border); font-size: 14px; } - -.side-dot { width: 8px; height: 8px; border-radius: 50%; margin-left: auto; flex-shrink: 0; } -.side-dot-medium { background: #f59e0b; } -.side-dot-critical { background: #ef4444; } -.side.collapsed .side-dot { position: absolute; margin: 0; transform: translate(12px, -10px); } -.side-link { position: relative; } diff --git a/tests/unit/navLinks.test.mjs b/tests/unit/navLinks.test.mjs index 6f03916..b9a7d62 100644 --- a/tests/unit/navLinks.test.mjs +++ b/tests/unit/navLinks.test.mjs @@ -17,6 +17,7 @@ test('buildNavLinks: vier Unterpunkte unter Kunden mit Zahlen, Admin-Eintraege n ]) assert.deepEqual(links.map((l) => l.key), ['tickets', 'planboard', 'projects', 'services', 'customers', 'leads', 'legal']) assert.equal(links.find((l) => l.key === 'leads').paused, true) + assert.equal(kunden.children.find((c) => c.key === 'chat').hot, true) const admin = buildNavLinks({ isAdmin: true, pulseLevel: 'warn', counts: {} }) assert.deepEqual(admin.map((l) => l.key), ['tickets', 'planboard', 'projects', 'services', 'customers', 'leads', 'finance', 'legal', 'serverpuls', 'admin']) assert.equal(admin.find((l) => l.key === 'serverpuls').dot, 'warn') diff --git a/tests/unit/sidebar.test.mjs b/tests/unit/sidebar.test.mjs deleted file mode 100644 index 7f832ce..0000000 --- a/tests/unit/sidebar.test.mjs +++ /dev/null @@ -1,27 +0,0 @@ -import test from 'node:test' -import assert from 'node:assert/strict' -import { loadModule } from './load.mjs' - -const { startsCollapsed, AUTO_COLLAPSE_MAX_WIDTH } = await loadModule('src/lib/sidebar.js') - -// Fenster mit matchMedia, das "(max-width: Npx)" gegen eine feste Breite prueft -const windowOf = (width) => ({ - matchMedia: (query) => ({ matches: width <= Number(/max-width:\s*(\d+)px/.exec(query)?.[1]) }), -}) - -test('startsCollapsed: Handy (390 px) und Grenze (720 px) starten eingeklappt', () => { - assert.equal(AUTO_COLLAPSE_MAX_WIDTH, 720) - assert.equal(startsCollapsed(windowOf(390)), true) - assert.equal(startsCollapsed(windowOf(720)), true) -}) - -test('startsCollapsed: breitere Fenster starten ausgeklappt', () => { - assert.equal(startsCollapsed(windowOf(721)), false) - assert.equal(startsCollapsed(windowOf(1280)), false) -}) - -test('startsCollapsed: ohne window oder matchMedia (oder bei Fehler) ausgeklappt', () => { - assert.equal(startsCollapsed(null), false) - assert.equal(startsCollapsed({}), false) - assert.equal(startsCollapsed({ matchMedia: () => { throw new Error('kaputt') } }), false) -})