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:
1 parent
717a3323fa
commit
005875d970
7 files changed
+187
-17
No files matched your search
+37
-17
@@ -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"
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
@@ -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' }] : []),
|
||||
]
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
Reference in new issue
Block a user