Workers P2: Seite /hustler (lazy), Menü „Hustler“ statt Leads, Stil und Schrift Pixelify Sans

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Sonnet 5.5 committed 2026-10-18 04:27:51 +00:00
1 parent a6f1b0c56d
commit 7ddd60e59b
10 files changed
+382 -7

No files matched your search

+13 -2
View File
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useState } from 'react'
import { lazy, Suspense, 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'
@@ -25,6 +25,9 @@ import BankCallbackPage from './pages/BankCallbackPage'
import AdminRoute from './components/AdminRoute'
import { safeReturnPath } from './lib/returnPath'
// Hochhaus der Hustler: erst beim Öffnen laden, damit Engine und Schrift nicht im Haupt-Bundle liegen
const WorkersPage = lazy(() => import('./pages/WorkersPage'))
function ProtectedRoute({ children }) {
const { user, loading } = useAuth()
const location = useLocation()
@@ -40,6 +43,12 @@ function ProtectedRoute({ children }) {
return children
}
// Alte Adressen /leads und /workers führen auf /hustler, die Query bleibt (/workers?tab=botleads&bot=<id> wird /hustler?tab=botleads&bot=<id>)
function HustlerRedirect() {
const { search } = useLocation()
return <Navigate to={`/hustler${search}`} replace />
}
function AppRoutes() {
const { user } = useAuth()
const location = useLocation()
@@ -57,7 +66,9 @@ function AppRoutes() {
<Route path="/customers/gutschriften" element={<ProtectedRoute><CreditsPage /></ProtectedRoute>} />
<Route path="/customers/angebote" element={<ProtectedRoute><OffersPage /></ProtectedRoute>} />
<Route path="/customers/:id" element={<ProtectedRoute><CustomerDetailPage /></ProtectedRoute>} />
<Route path="/leads" element={<Navigate to="/tickets" replace />} />
<Route path="/hustler" element={<ProtectedRoute><Suspense fallback={<div className="loading">Wird geladen …</div>}><WorkersPage /></Suspense></ProtectedRoute>} />
<Route path="/leads" element={<HustlerRedirect />} />
<Route path="/workers" element={<HustlerRedirect />} />
{/* Finanzen: nur Admins, alle anderen gehen zu /tickets */}
<Route path="/finance" element={<ProtectedRoute><AdminRoute><FinancePage /></AdminRoute></ProtectedRoute>} />
<Route path="/finance/einstellungen" element={<ProtectedRoute><AdminRoute><FinanceSettingsPage /></AdminRoute></ProtectedRoute>} />
+1 -2
View File
@@ -19,8 +19,7 @@ export function buildNavLinks({ isAdmin, pulseLevel, counts = {} }) {
{ key: 'speicher', label: 'E-Mail-Speicher', href: '/customers/email-speicher', count: counts.storage ?? 0 },
],
},
// Leads liegen auf Eis (Lead-Automatik gestoppt 2026-09-30): grau, nicht klickbar
{ key: 'leads', label: 'Leads', href: '/leads', paused: true },
{ key: 'hustler', label: 'Hustler', href: '/hustler' },
...(isAdmin ? [{ key: 'finance', label: 'Finanzen', href: '/finance', also: '/finanzen' }] : []),
{ key: 'legal', label: 'Rechtliches', href: '/rechtliches' },
...(isAdmin ? [{ key: 'serverpuls', label: 'Serverpuls', href: '/serverpuls', dot: pulseLevel }] : []),
+40
View File
@@ -0,0 +1,40 @@
import { useEffect } from 'react'
import { useSearchParams } from 'react-router-dom'
import { PageHeader, Tabs } from '../components/ui'
import WorkersBoard from '../components/workers/WorkersBoard'
import { useAuth } from '../context/AuthContext'
import { workersApi } from '../lib/workersClient'
import { pickTab } from '../lib/workersView'
import '../styles/workers.css'
// Reiter der Seite. Später kommen freigabe (P3) und botleads (P6) dazu; die Reiterleiste erscheint erst ab zwei Reitern.
const WORKERS_TABS = [
{ id: 'hochhaus', label: 'Hochhaus' },
]
const api = workersApi
// Seite „Hustler“ (Dateiname bleibt WorkersPage): Titel, Reiterwahl über ?tab= und der Inhalt des gewählten Reiters.
// Auth und Dienst-API kommen von hier; das Board selbst kennt weder Anmeldung noch Appwrite.
export default function WorkersPage() {
const { isAdmin } = useAuth()
const [params, setParams] = useSearchParams()
const active = pickTab(WORKERS_TABS, params.get('tab'))
// Tab-Titel nur solange die Seite offen ist; beim Verlassen kommt der vorige Titel zurück
useEffect(() => {
const previous = document.title
document.title = 'Hustler'
return () => { document.title = previous }
}, [])
return (
<div className="page wk-page">
<PageHeader title="Hustler" subtitle="Die Nachtschicht deiner Claude-Armee" />
{WORKERS_TABS.length > 1 && (
<Tabs tabs={WORKERS_TABS} active={active.id} onChange={(id) => setParams({ tab: id })} />
)}
{active.id === 'hochhaus' && <WorkersBoard api={api} isAdmin={isAdmin} />}
</div>
)
}
+190
View File
@@ -0,0 +1,190 @@
/* Hustler (Workers P2): Hochhaus, Kennzahlen, Worker-Karte, Liste, Einstellungen. Weißes Theme: Haarlinien statt Karten, nur Tokens aus global.css. */
/* Schrift selbst gehostet (SIL OFL 1.1, Lizenztext liegt daneben), nur der Latin-Schnitt mit allen Gewichten */
@font-face {
font-family: "Pixelify Sans";
src: url("/fonts/PixelifySans-Variable.woff2") format("woff2");
font-weight: 400 700;
font-style: normal;
font-display: swap;
}
.wk-page .page-head h1 { font-family: "Pixelify Sans", var(--font); letter-spacing: 0; }
/* ---------- Kopfzeile mit Pausenschalter ---------- */
.wk-toolbar {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px;
padding: 12px 0;
border-top: 1px solid var(--line);
border-bottom: 1px solid var(--line);
margin-bottom: 20px;
}
.wk-status { color: var(--text-2); }
/* ---------- Kennzahlen ---------- */
.wk-kpis.stats {
display: grid;
grid-template-columns: repeat(6, minmax(0, 1fr));
gap: 16px 32px;
margin: 0 0 24px;
}
.wk-kpis .stat { min-width: 0; font-variant-numeric: tabular-nums; }
.wk-kpis .stat .sub { min-height: 16px; }
/* ---------- Hochhaus und Seitenspalte ---------- */
.wk-main {
display: grid;
grid-template-columns: minmax(0, 1fr) 320px;
gap: 32px;
align-items: start;
}
.wk-side {
min-width: 0;
padding-left: 24px;
border-left: 1px solid var(--line);
}
.wk-stage { position: relative; min-width: 0; }
.wk-canvas {
display: block;
width: 100%;
aspect-ratio: 5 / 4;
image-rendering: pixelated;
cursor: pointer;
touch-action: pan-y;
}
.wk-zoom {
position: absolute;
top: 8px;
right: 8px;
display: flex;
gap: 4px;
}
.wk-zoom .btn { min-width: 32px; }
/* ---------- Worker-Karte ---------- */
.wk-card { position: relative; padding-bottom: 20px; }
.wk-card > .btn-quiet { position: absolute; top: 0; right: 0; }
.wk-card h3 { margin: 20px 0 8px; font-size: 13px; line-height: 20px; font-weight: 600; color: var(--text-2); }
.wk-portrait {
display: block;
width: 72px;
height: 96px;
margin-bottom: 8px;
image-rendering: pixelated;
}
.wk-name { font-family: "Pixelify Sans", var(--font); font-size: 20px; line-height: 26px; font-weight: 600; overflow-wrap: anywhere; }
.wk-role { color: var(--text-2); margin-bottom: 8px; }
.wk-hint { color: var(--text-2); font-size: 13px; }
.wk-chip {
display: inline-block;
margin-right: 8px;
padding: 0 6px;
border-radius: var(--radius);
font-size: 12px;
line-height: 20px;
font-weight: 500;
background: var(--grey-bg);
color: var(--grey-fg);
}
.wk-chip-opus { background: var(--orange-bg); color: var(--orange-fg); }
.wk-chip-sonnet { background: var(--blue-bg); color: var(--blue-fg); }
.wk-chip-haiku { background: var(--green-bg); color: var(--green-fg); }
.wk-chip-overseer { background: var(--violet-bg); color: var(--violet-fg); }
.wk-facts, .wk-counters dl { margin: 12px 0 0; }
.wk-facts > div, .wk-counters dl > div {
display: flex;
justify-content: space-between;
gap: 12px;
padding: 6px 0;
border-top: 1px solid var(--line);
font-variant-numeric: tabular-nums;
}
.wk-facts dt, .wk-counters dt { color: var(--text-2); flex: none; }
.wk-facts dd, .wk-counters dd { text-align: right; min-width: 0; overflow-wrap: anywhere; }
.wk-trace { list-style: none; margin: 0; padding: 0; }
.wk-trace li { padding: 6px 0; border-top: 1px solid var(--line); font-size: 13px; line-height: 18px; overflow-wrap: anywhere; }
.wk-trace-time { color: var(--text-3); font-variant-numeric: tabular-nums; }
/* ---------- Liste aller Worker ---------- */
.wk-list { margin-top: 8px; border-top: 1px solid var(--line); }
.wk-list ul { list-style: none; margin: 0; padding: 0; }
.wk-list-floor { margin: 16px 0 4px; font-size: 12px; line-height: 16px; font-weight: 600; color: var(--text-3); }
.wk-list button {
display: grid;
grid-template-columns: 8px minmax(0, 1fr) auto;
align-items: center;
gap: 8px;
width: 100%;
min-height: 32px;
padding: 4px 8px;
border-radius: var(--radius);
text-align: left;
transition: background-color 0.12s;
}
.wk-list button:hover { background: var(--bg-hover); }
.wk-list button[aria-pressed="true"] { background: var(--accent-soft); font-weight: 600; }
.wk-list button > span:last-child { color: var(--text-2); font-size: 12px; font-weight: 400; }
.wk-list button:focus-visible { outline-offset: -2px; }
.wk-dot { width: 8px; height: 8px; border-radius: 50%; background: var(--text-3); }
.wk-dot-arbeitet { background: var(--green-fg); }
.wk-dot-wartet { background: var(--nav-dot); }
.wk-dot-leerlauf { background: var(--text-3); }
.wk-dot-grau { background: transparent; box-shadow: inset 0 0 0 1.5px var(--line-strong); }
.wk-dot-fehler { background: var(--danger); }
/* ---------- Einstellungen (Dialog) ---------- */
.wk-set-group {
display: flex;
flex-direction: column;
gap: 12px;
padding-top: 16px;
border-top: 1px solid var(--line);
}
.wk-set-title { font-size: 13px; line-height: 20px; font-weight: 600; color: var(--text-2); }
.wk-set-group .muted { font-size: 13px; }
.wk-halt {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 8px;
padding: 12px 0;
border-top: 1px solid var(--danger);
border-bottom: 1px solid var(--danger);
}
.wk-halt .wk-set-title { color: var(--red-fg); }
/* ---------- Schmale Bildschirme ---------- */
@media (max-width: 1100px) {
.wk-kpis.stats { grid-template-columns: repeat(3, minmax(0, 1fr)); }
.wk-main { grid-template-columns: minmax(0, 1fr); gap: 24px; }
.wk-side { padding-left: 0; border-left: 0; padding-top: 24px; border-top: 1px solid var(--line); }
}
@media (max-width: 860px) {
.wk-kpis.stats { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 16px 24px; }
.wk-zoom { gap: 6px; }
.wk-zoom .btn { min-width: 36px; min-height: 36px; }
.wk-toolbar .btn { min-height: 36px; }
}
/* ---------- Weniger Bewegung ---------- */
@media (prefers-reduced-motion: reduce) {
.wk-list button, .wk-zoom .btn, .wk-toolbar .btn { transition: none; }
}