Sidebar: Chat als Unterpunkt von Kunden mit Zahl der ungelesenen Nachrichten

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Opus 5.5 committed 2026-10-01 23:32:24 +00:00
1 parent 717a3323fa
commit 005875d970
7 files changed
+187 -17

No files matched your search

+37 -17
View File
@@ -16,10 +16,27 @@ import {
IconLayoutSidebarLeftCollapse,
IconLayoutSidebarLeftExpand,
IconExternalLink,
IconMessages,
} from '@tabler/icons-react'
import { openProjectAdmin } from '../lib/employeeAdminApi'
import { serverpulsApi } from '../lib/serverpulsApi'
import { startsCollapsed } from '../lib/sidebar'
import { buildNavLinks } from '../lib/navLinks'
import { useChatUnread } from '../hooks/useChatUnread'
const ICONS = {
tickets: <IconTicket />,
planboard: <IconCalendar />,
projects: <IconFolders />,
services: <IconLayoutGrid />,
customers: <IconUsers />,
chat: <IconMessages />,
leads: <IconTargetArrow />,
finance: <IconReportMoney />,
legal: <IconScale />,
serverpuls: <IconActivityHeartbeat />,
admin: <IconSettings />,
}
export default function Sidebar() {
const { user, logout, isAdmin } = useAuth()
@@ -28,6 +45,7 @@ export default function Sidebar() {
const [collapsed, setCollapsed] = useState(() => startsCollapsed())
const [switching, setSwitching] = useState(false)
const [pulseLevel, setPulseLevel] = useState('ok')
const chatUnread = useChatUnread()
// Serverpuls-Punkt: nur Admins, beim Laden und alle 5 Minuten; Fehler bleiben still
useEffect(() => {
@@ -53,20 +71,8 @@ export default function Sidebar() {
navigate('/login')
}
// Eine flache Liste: fuer ein kleines Team reichen acht Eintraege ohne Gruppen
const links = [
{ label: 'Tickets', href: '/tickets', icon: <IconTicket /> },
{ label: 'Planboard', href: '/planboard', icon: <IconCalendar /> },
{ label: 'Projekte', href: '/projects', icon: <IconFolders /> },
{ label: 'Services', href: '/services', icon: <IconLayoutGrid /> },
{ label: 'Kunden', href: '/customers', icon: <IconUsers /> },
// Leads liegen auf Eis (Lead-Automatik gestoppt 2026-09-30): grau, nicht klickbar
{ label: 'Leads', href: '/leads', icon: <IconTargetArrow />, paused: true },
...(isAdmin ? [{ label: 'Finanzen', href: '/finance', also: '/finanzen', icon: <IconReportMoney /> }] : []),
{ label: 'Rechtliches', href: '/rechtliches', icon: <IconScale /> },
...(isAdmin ? [{ label: 'Serverpuls', href: '/serverpuls', icon: <IconActivityHeartbeat />, dot: pulseLevel }] : []),
...(isAdmin ? [{ label: 'Admin', href: '/admin', icon: <IconSettings /> }] : []),
]
// Eintraege und Unterpunkte (Kunden -> Chat) aus lib/navLinks.js, Icons ueber key
const links = buildNavLinks({ isAdmin, pulseLevel, chatUnread })
const isActive = (href) =>
location.pathname === href || location.pathname.startsWith(href + '/')
@@ -92,7 +98,7 @@ export default function Sidebar() {
title={`${link.label} (pausiert)`}
aria-disabled="true"
>
{link.icon}
{ICONS[link.key]}
<span className="side-link-label">{link.label}</span>
</span>
) : (
@@ -102,7 +108,7 @@ export default function Sidebar() {
className={`side-link ${isLinkActive(link) ? 'active' : ''}`}
title={link.label}
>
{link.icon}
{ICONS[link.key]}
<span className="side-link-label">{link.label}</span>
{link.dot && link.dot !== 'ok' && (
<span
@@ -112,7 +118,21 @@ export default function Sidebar() {
/>
)}
</Link>
))}
)).flatMap((item, i) => [
item,
...(links[i].children || []).map((child) => (
<Link
key={child.href}
to={child.href}
className={`side-link side-sublink ${isActive(child.href) ? 'active' : ''}`}
title={child.count > 0 ? `${child.label} (${child.count} ungelesen)` : child.label}
>
{ICONS[child.key]}
<span className="side-link-label">{child.label}</span>
{child.count > 0 && <span className="side-count">{child.count}</span>}
</Link>
)),
])}
<button
type="button"
className="side-link side-link-button"
+31
View File
@@ -0,0 +1,31 @@
import { useEffect, useState } from 'react'
import { getChatUnreadCount } from '../lib/teamApi'
// Zahl ungelesener Kundennachrichten fuer die Sidebar: jede Minute bei sichtbarem Tab und sofort beim
// Zurueckkehren; Fehler behalten den letzten Wert.
const POLL_MS = 60 * 1000
export function useChatUnread() {
const [unread, setUnread] = useState(0)
useEffect(() => {
let dead = false
const load = () => {
if (document.visibilityState === 'hidden') return
getChatUnreadCount()
.then((r) => { if (!dead) setUnread(Number(r?.unread) || 0) })
.catch(() => {})
}
load()
const iv = setInterval(load, POLL_MS)
const onVisible = () => { if (document.visibilityState === 'visible') load() }
document.addEventListener('visibilitychange', onVisible)
return () => {
dead = true
clearInterval(iv)
document.removeEventListener('visibilitychange', onVisible)
}
}, [])
return unread
}
+31
View File
@@ -0,0 +1,31 @@
import { formatViewLogTime } from './viewLog'
// Chat im Ticketsystem: reine Hilfen fuer Liste, Hinweiszeile und Sprechblasen (unter Node testbar).
/** Ungelesene zuerst, sonst die neueste Unterhaltung oben; die Eingabe bleibt unveraendert. */
export function sortChats(chats) {
return [...chats].sort((a, b) => {
const unread = Number((b.unreadFromCustomer || 0) > 0) - Number((a.unreadFromCustomer || 0) > 0)
return unread || String(b.lastAt || '').localeCompare(String(a.lastAt || ''))
})
}
/** Ergebnis des Mail-Hinweises an den Kunden (K17), Texte wie im Admin-Panel. */
export function noticeText(notice, time) {
if (!notice) return ''
if (notice.sent) return `E-Mail-Hinweis an ${notice.to} gesendet (${time})`
if (notice.reason === 'throttled') return 'Kein neuer E-Mail-Hinweis: Der Kunde wurde in den letzten 15 Minuten schon benachrichtigt.'
return `Kein E-Mail-Hinweis: ${notice.reason}`
}
export const lastLine = (chat) => `${chat.lastSender === 'admin' ? 'Du: ' : ''}${chat.lastText || ''}`
/** Zeile unter der Sprechblase: Absender · dd.mm.yyyy hh:mm */
export function messageMeta(msg, customerName) {
const who = msg.sender === 'admin' ? msg.senderName || 'Team' : customerName
return `${who} · ${formatViewLogTime(msg.createdAt)}`
}
// Signierte Anhang-Links gelten 15 Minuten: den Verlauf nach 10 Minuten komplett neu laden
const FULL_REFRESH_MS = 10 * 60 * 1000
export const needsFullRefresh = (lastFullAt, now) => now - lastFullAt >= FULL_REFRESH_MS
+23
View File
@@ -0,0 +1,23 @@
// Eintraege der Sidebar als reine Daten (unter Node testbar); Navbar.jsx ordnet die Icons ueber key zu.
// Unterpunkte (children) erscheinen eingerueckt unter ihrem Eintrag, count zeigt eine Zahl (nur > 0).
export function buildNavLinks({ isAdmin, pulseLevel, chatUnread }) {
return [
{ key: 'tickets', label: 'Tickets', href: '/tickets' },
{ key: 'planboard', label: 'Planboard', href: '/planboard' },
{ key: 'projects', label: 'Projekte', href: '/projects' },
{ key: 'services', label: 'Services', href: '/services' },
{
key: 'customers',
label: 'Kunden',
href: '/customers',
children: [{ key: 'chat', label: 'Chat', href: '/customers/chat', count: chatUnread }],
},
// Leads liegen auf Eis (Lead-Automatik gestoppt 2026-09-30): grau, nicht klickbar
{ key: 'leads', label: 'Leads', href: '/leads', paused: true },
...(isAdmin ? [{ key: 'finance', label: 'Finanzen', href: '/finance', also: '/finanzen' }] : []),
{ key: 'legal', label: 'Rechtliches', href: '/rechtliches' },
...(isAdmin ? [{ key: 'serverpuls', label: 'Serverpuls', href: '/serverpuls', dot: pulseLevel }] : []),
...(isAdmin ? [{ key: 'admin', label: 'Admin', href: '/admin' }] : []),
]
}
+9
View File
@@ -850,6 +850,15 @@ textarea.form-control {
.side-link-button:disabled { opacity: 0.6; cursor: progress; }
.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-count {
margin-left: auto; min-width: 20px; padding: 1px 6px; border-radius: 999px; text-align: center;
background: var(--accent); color: #04130d; font-size: 11.5px; font-weight: 700; line-height: 18px;
}
.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 {