Compare commits
4
Commits
fce2f41463
...
813c6a7e18
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
813c6a7e18 | ||
|
|
eb0b889203 | ||
|
|
005875d970 | ||
|
|
717a3323fa |
No files matched your search
@@ -9,6 +9,7 @@ import ProjectsPage from './pages/ProjectsPage'
|
||||
import AdminPage from './pages/AdminPage'
|
||||
import CustomersPage from './pages/CustomersPage'
|
||||
import CustomerDetailPage from './pages/CustomerDetailPage'
|
||||
import CustomerChatPage from './pages/CustomerChatPage'
|
||||
import FinancePage from './pages/FinancePage'
|
||||
import FinanceSettingsPage from './pages/FinanceSettingsPage'
|
||||
import LegalPage from './pages/LegalPage'
|
||||
@@ -49,6 +50,8 @@ function AppRoutes() {
|
||||
<Route path="/" element={<Navigate to="/tickets" replace />} />
|
||||
<Route path="/tickets" element={<ProtectedRoute><TicketsPage /></ProtectedRoute>} />
|
||||
<Route path="/customers" element={<ProtectedRoute><CustomersPage /></ProtectedRoute>} />
|
||||
{/* Messenger (Kunden -> Chat); statische Pfade gehen in react-router vor /customers/:id */}
|
||||
<Route path="/customers/chat" element={<ProtectedRoute><CustomerChatPage /></ProtectedRoute>} />
|
||||
<Route path="/customers/:id" element={<ProtectedRoute><CustomerDetailPage /></ProtectedRoute>} />
|
||||
<Route path="/leads" element={<Navigate to="/tickets" replace />} />
|
||||
{/* Finanzen: nur Admins, alle anderen gehen zu /tickets */}
|
||||
|
||||
+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,131 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { FaRotateRight } from 'react-icons/fa6'
|
||||
import { Button } from '../ui'
|
||||
import { PORTAL_URL, chatUploadUrl, getChatMessages, markChatRead, sendChatMessage, teamAuthHeaders } from '../../lib/teamApi'
|
||||
import { loadChatComposer } from '../../lib/loadChatComposer'
|
||||
import { messageMeta, needsFullRefresh, noticeText } from '../../lib/chatFormat'
|
||||
import '../../styles/chat.css'
|
||||
|
||||
const POLL_MS = 15 * 1000
|
||||
|
||||
/**
|
||||
* Gespraech mit einem Kunden (Messenger und Reiter "Chat"). Sprechblasen und Eingabezeile kommen aus dem
|
||||
* Chat-Baustein des Portals (window.ChatComposer); React verwaltet nur die leeren Container.
|
||||
* Antworten fuer einen inzwischen gewechselten Kunden werden verworfen (alive je Effekt).
|
||||
*/
|
||||
export default function ChatThread({ customerId, customerName }) {
|
||||
const zoneRef = useRef(null)
|
||||
const boxRef = useRef(null)
|
||||
const composerRef = useRef(null)
|
||||
const [error, setError] = useState(null)
|
||||
const [notice, setNotice] = useState('')
|
||||
const [reloadKey, setReloadKey] = useState(0)
|
||||
// Name nur ueber eine Referenz lesen: kommt er spaeter (Liste/Kunden laden noch), startet das Gespraech nicht neu
|
||||
const nameRef = useRef(customerName)
|
||||
nameRef.current = customerName
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true
|
||||
let composer = null
|
||||
let timer = null
|
||||
let chat = null
|
||||
const state = { shown: new Set(), last: '', fullAt: 0 }
|
||||
setError(null)
|
||||
setNotice('')
|
||||
|
||||
const append = (messages) => {
|
||||
const box = boxRef.current
|
||||
if (!alive || !box || !chat) return
|
||||
let added = false
|
||||
for (const msg of messages) {
|
||||
if (state.shown.has(msg.id)) continue
|
||||
state.shown.add(msg.id)
|
||||
box.appendChild(chat.renderMessage(msg, {
|
||||
mine: msg.sender === 'admin',
|
||||
meta: messageMeta(msg, nameRef.current),
|
||||
fileUrl: (att) => att.url,
|
||||
}))
|
||||
if (msg.createdAt > state.last) state.last = msg.createdAt
|
||||
added = true
|
||||
}
|
||||
if (added) box.scrollTop = box.scrollHeight
|
||||
}
|
||||
|
||||
// Ganzer Verlauf mit frischen signierten Links (beim Oeffnen und nach 10 Minuten)
|
||||
const loadAll = async () => {
|
||||
const { messages } = await getChatMessages(customerId)
|
||||
if (!alive) return
|
||||
boxRef.current?.replaceChildren()
|
||||
state.shown = new Set()
|
||||
state.last = ''
|
||||
state.fullAt = Date.now()
|
||||
append(messages || [])
|
||||
markChatRead(customerId).catch(() => {})
|
||||
}
|
||||
|
||||
const poll = async () => {
|
||||
if (!alive || document.visibilityState === 'hidden' || !chat) return
|
||||
try {
|
||||
if (needsFullRefresh(state.fullAt, Date.now())) return await loadAll()
|
||||
const { messages } = await getChatMessages(customerId, state.last)
|
||||
if (!alive) return
|
||||
append(messages || [])
|
||||
if ((messages || []).some((m) => m.sender === 'customer')) markChatRead(customerId).catch(() => {})
|
||||
} catch {
|
||||
/* naechste Abfrage versucht es erneut */
|
||||
}
|
||||
}
|
||||
|
||||
;(async () => {
|
||||
try {
|
||||
chat = await loadChatComposer({ baseUrl: PORTAL_URL })
|
||||
if (!alive) return
|
||||
chat.trackScroll(boxRef.current)
|
||||
composer = chat.create({
|
||||
root: composerRef.current,
|
||||
dropZone: zoneRef.current,
|
||||
uploadUrl: chatUploadUrl(customerId),
|
||||
requestHeaders: teamAuthHeaders,
|
||||
placeholder: `Antwort an ${nameRef.current || 'Kunde'}…`,
|
||||
placeholderShort: 'Antwort…',
|
||||
send: async ({ text, attachmentIds }) => {
|
||||
const { message, notice: result } = await sendChatMessage(customerId, { text, attachmentIds })
|
||||
const time = new Date().toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' })
|
||||
if (alive) setNotice(noticeText(result, time))
|
||||
return message
|
||||
},
|
||||
onSent: (message) => append([message]),
|
||||
})
|
||||
await loadAll()
|
||||
// waehrend des Ladens verlassen: keinen Timer mehr anlegen, den das Aufraeumen nicht mehr sieht
|
||||
if (!alive) return
|
||||
timer = setInterval(poll, POLL_MS)
|
||||
} catch (err) {
|
||||
if (alive) setError(err.message || 'Die Unterhaltung konnte nicht geladen werden.')
|
||||
}
|
||||
})()
|
||||
|
||||
const onVisible = () => { if (document.visibilityState === 'visible') poll() }
|
||||
document.addEventListener('visibilitychange', onVisible)
|
||||
return () => {
|
||||
alive = false
|
||||
clearInterval(timer)
|
||||
document.removeEventListener('visibilitychange', onVisible)
|
||||
composer?.destroy()
|
||||
}
|
||||
}, [customerId, reloadKey])
|
||||
|
||||
return (
|
||||
<div className="woms-chat" ref={zoneRef}>
|
||||
{error && (
|
||||
<div className="flex items-center gap-2 wrap">
|
||||
<span className="muted">{error}</span>
|
||||
<Button variant="ghost" onClick={() => setReloadKey((k) => k + 1)}><FaRotateRight /> Erneut versuchen</Button>
|
||||
</div>
|
||||
)}
|
||||
<div className="chat-messages" ref={boxRef} aria-live="polite" />
|
||||
{notice && <div className="chat-notice muted">{notice}</div>}
|
||||
<div ref={composerRef} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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,41 @@
|
||||
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
|
||||
|
||||
/** Eindeutige Beschriftung fuer "Neue Nachricht an Kunde…" wie im Admin-Panel: Name · E-Mail · Kd.-Nr. */
|
||||
export function customerPickLabel(c) {
|
||||
return [c.name, c.email, c.code ? `Kd.-Nr. ${c.code}` : ''].filter(Boolean).join(' · ') || c.$id
|
||||
}
|
||||
|
||||
/** Nur die volle Beschriftung oeffnet einen Kunden; Teil- oder reine Namens-Treffer liefern null. */
|
||||
export function findCustomerByLabel(customers, value) {
|
||||
return customers.find((c) => customerPickLabel(c) === value) || null
|
||||
}
|
||||
@@ -6,6 +6,8 @@ const BASE_TABS = [
|
||||
{ id: 'tickets', label: 'Tickets' },
|
||||
{ id: 'invoices', label: 'Rechnungen', adminOnly: true },
|
||||
{ id: 'projects', label: 'Projekte' },
|
||||
// Chat mit dem Kunden (derselbe Gespraechsbaustein wie im Messenger unter Kunden -> Chat)
|
||||
{ id: 'chat', label: 'Chat' },
|
||||
// Protokoll der Kundenansicht (wer wann das Portal des Kunden geöffnet oder darin etwas geändert hat): alle Mitarbeiter
|
||||
{ id: 'protokoll', label: 'Protokoll' },
|
||||
]
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
// Chat-Baustein des Kundenportals (public/chat-composer.js + .css) zur Laufzeit laden: Eingabezeile mit Anhaengen,
|
||||
// Kamera und Sprachnachricht sowie die Sprechblasen kommen aus einer Quelle fuer Portal und Ticketsystem.
|
||||
// Rein (document/window werden injiziert), unter Node testbar.
|
||||
|
||||
const pending = new WeakMap()
|
||||
|
||||
/** -> Promise<window.ChatComposer>; je document nur ein Ladevorgang, nach einem Fehler neuer Versuch. */
|
||||
export function loadChatComposer({ baseUrl, doc = globalThis.document, win = globalThis.window }) {
|
||||
if (win.ChatComposer) return Promise.resolve(win.ChatComposer)
|
||||
if (pending.has(doc)) return pending.get(doc)
|
||||
const promise = new Promise((resolve, reject) => {
|
||||
const link = doc.createElement('link')
|
||||
link.rel = 'stylesheet'
|
||||
link.href = `${baseUrl}/chat-composer.css`
|
||||
doc.head.appendChild(link)
|
||||
const script = doc.createElement('script')
|
||||
script.src = `${baseUrl}/chat-composer.js`
|
||||
script.async = true
|
||||
script.onload = () => (win.ChatComposer ? resolve(win.ChatComposer) : script.onerror())
|
||||
script.onerror = () => {
|
||||
pending.delete(doc)
|
||||
reject(new Error('Der Chat-Baustein konnte nicht geladen werden.'))
|
||||
}
|
||||
doc.head.appendChild(script)
|
||||
})
|
||||
pending.set(doc, promise)
|
||||
return promise
|
||||
}
|
||||
@@ -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' }] : []),
|
||||
]
|
||||
}
|
||||
+32
-2
@@ -1,14 +1,18 @@
|
||||
import { account } from './appwrite'
|
||||
import { createFinanceRequest } from './financeRequest'
|
||||
import { createJwtCache } from './serverpulsAuth'
|
||||
|
||||
/**
|
||||
* Client fuer den Team-Eingang des Kundenportals (/api/team): alle Mitarbeiter, Appwrite-JWT (Bearer).
|
||||
* Anfrage-Kern (JWT-Cache, Wiederholung bei 401, deutsche Fehlertexte) wie bei den Finanzen: financeRequest.js.
|
||||
*/
|
||||
const PROJECT_ADMIN_URL =
|
||||
export const PORTAL_URL =
|
||||
import.meta.env.VITE_PROJECT_ADMIN_URL || 'https://project.webklar.com'
|
||||
|
||||
const request = createFinanceRequest({ baseUrl: `${PROJECT_ADMIN_URL}/api/team`, createJWT: () => account.createJWT() })
|
||||
const request = createFinanceRequest({ baseUrl: `${PORTAL_URL}/api/team`, createJWT: () => account.createJWT() })
|
||||
|
||||
// Eigenes JWT fuer Anfragen, die nicht ueber request laufen (Hochladen im Chat-Baustein), 10 Minuten gecacht
|
||||
const jwtCache = createJwtCache({ createJWT: () => account.createJWT() })
|
||||
|
||||
const enc = encodeURIComponent
|
||||
|
||||
@@ -29,3 +33,29 @@ export async function createPreviewLink({ subdomain, previewUrl }) {
|
||||
const { openUrl } = await request('/preview-link', { method: 'POST', body: { subdomain, previewUrl } })
|
||||
return openUrl
|
||||
}
|
||||
|
||||
// --- Chat (Messenger, Reiter "Chat", Zahl in der Sidebar) ---
|
||||
|
||||
/** { chats: [{ customerId, customerName, lastText, lastSender, lastAt, unreadFromCustomer }] } */
|
||||
export const listChats = () => request('/chats')
|
||||
|
||||
/** { unread } */
|
||||
export const getChatUnreadCount = () => request('/chats/unread-count')
|
||||
|
||||
/** { messages }; mit since nur neuere (Abfrage im offenen Gespraech) */
|
||||
export const getChatMessages = (customerId, since = '') =>
|
||||
request(`/chats/${enc(customerId)}/messages${since ? `?since=${enc(since)}` : ''}`)
|
||||
|
||||
/** { message, notice } */
|
||||
export const sendChatMessage = (customerId, { text, attachmentIds }) =>
|
||||
request(`/chats/${enc(customerId)}/messages`, { method: 'POST', body: { text, attachmentIds } })
|
||||
|
||||
export const markChatRead = (customerId) => request(`/chats/${enc(customerId)}/read`, { method: 'POST', body: {} })
|
||||
|
||||
/** Upload-Adresse fuer den Chat-Baustein (POST roh, DELETE <url>/<id>) */
|
||||
export const chatUploadUrl = (customerId) => `${PORTAL_URL}/api/team/chats/${enc(customerId)}/attachments`
|
||||
|
||||
/** Header fuer den Chat-Baustein (Option requestHeaders) */
|
||||
export async function teamAuthHeaders() {
|
||||
return { Authorization: `Bearer ${await jwtCache.get()}` }
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { useSearchParams } from 'react-router-dom'
|
||||
import { FaArrowLeft, FaRotateRight } from 'react-icons/fa6'
|
||||
import { PageHeader, Card, Button, EmptyState, AutocompleteInput } from '../components/ui'
|
||||
import ChatThread from '../components/chat/ChatThread'
|
||||
import { useCustomers } from '../hooks/useCustomers'
|
||||
import { listChats } from '../lib/teamApi'
|
||||
import { formatViewLogTime } from '../lib/viewLog'
|
||||
import { customerPickLabel, findCustomerByLabel, lastLine, sortChats } from '../lib/chatFormat'
|
||||
import '../styles/chat.css'
|
||||
|
||||
const LIST_POLL_MS = 30 * 1000
|
||||
|
||||
// Messenger unter Kunden -> Chat: links alle Unterhaltungen, rechts das gewaehlte Gespraech (?kunde=<id>)
|
||||
export default function CustomerChatPage() {
|
||||
const [params, setParams] = useSearchParams()
|
||||
const kunde = params.get('kunde') || ''
|
||||
const { customers } = useCustomers()
|
||||
const [chats, setChats] = useState(null)
|
||||
const [error, setError] = useState(null)
|
||||
const [query, setQuery] = useState('')
|
||||
const [reload, setReload] = useState(0)
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true
|
||||
const load = () => {
|
||||
if (document.visibilityState === 'hidden') return
|
||||
listChats()
|
||||
.then(({ chats: list }) => { if (alive) { setChats(list || []); setError(null) } })
|
||||
.catch((err) => { if (alive) setError(err.message || 'Unterhaltungen konnten nicht geladen werden.') })
|
||||
}
|
||||
load()
|
||||
const iv = setInterval(load, LIST_POLL_MS)
|
||||
return () => { alive = false; clearInterval(iv) }
|
||||
}, [reload, kunde])
|
||||
|
||||
const sorted = useMemo(() => sortChats(chats || []), [chats])
|
||||
const nameOf = (id) =>
|
||||
sorted.find((c) => c.customerId === id)?.customerName || customers.find((c) => c.$id === id)?.name || 'Kunde'
|
||||
|
||||
const open = (id) => setParams({ kunde: id })
|
||||
const suggestions = (text) => {
|
||||
const q = String(text || '').trim().toLowerCase()
|
||||
if (!q) return []
|
||||
return customers
|
||||
.map((c) => ({ c, label: customerPickLabel(c) }))
|
||||
.filter(({ label }) => label.toLowerCase().includes(q))
|
||||
.slice(0, 8)
|
||||
.map(({ label }) => ({ value: label }))
|
||||
}
|
||||
// Erst die eindeutige Beschriftung aus der Vorschlagsliste oeffnet das Gespraech (nicht schon ein Namens-Treffer)
|
||||
const onPick = (value) => {
|
||||
setQuery(value)
|
||||
const hit = findCustomerByLabel(customers, value)
|
||||
if (hit) {
|
||||
setQuery('')
|
||||
open(hit.$id)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="page">
|
||||
<PageHeader title="Chat" subtitle="Unterhaltungen mit Kunden aus dem Kundenportal" />
|
||||
<div className={`chat-page ${kunde ? 'has-thread' : ''}`}>
|
||||
<div className="chat-side">
|
||||
<AutocompleteInput value={query} onChange={onPick} getSuggestions={suggestions} placeholder="Neue Nachricht an Kunde…" />
|
||||
<Card pad={false}>
|
||||
{error && (
|
||||
<div className="flex items-center gap-2 wrap" style={{ padding: 12 }}>
|
||||
<span className="muted">{error}</span>
|
||||
<Button variant="ghost" onClick={() => setReload((k) => k + 1)}><FaRotateRight /> Erneut versuchen</Button>
|
||||
</div>
|
||||
)}
|
||||
{!error && chats && sorted.length === 0 && <EmptyState title="Noch keine Unterhaltungen" />}
|
||||
<div className="chat-list">
|
||||
{sorted.map((c) => (
|
||||
<button
|
||||
key={c.customerId}
|
||||
type="button"
|
||||
className={`chat-list-item ${c.customerId === kunde ? 'active' : ''}`}
|
||||
onClick={() => open(c.customerId)}
|
||||
>
|
||||
<span className="chat-list-top">
|
||||
<strong>{c.customerName}</strong>
|
||||
{c.unreadFromCustomer > 0 && <span className="side-count">{c.unreadFromCustomer}</span>}
|
||||
</span>
|
||||
<span className="chat-list-last">{lastLine(c)}</span>
|
||||
<span className="chat-list-time">{formatViewLogTime(c.lastAt)}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
<div className="chat-main">
|
||||
{kunde ? (
|
||||
<Card title={
|
||||
<span className="flex items-center gap-2">
|
||||
<Button variant="ghost" size="sm" className="chat-back" onClick={() => setParams({})}><FaArrowLeft /> Alle Unterhaltungen</Button>
|
||||
{nameOf(kunde)}
|
||||
</span>
|
||||
}>
|
||||
<ChatThread key={kunde} customerId={kunde} customerName={nameOf(kunde)} />
|
||||
</Card>
|
||||
) : (
|
||||
<Card><EmptyState title="Unterhaltung links auswählen" hint="Oder oben eine neue Nachricht an einen Kunden beginnen." /></Card>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -13,6 +13,7 @@ import { useWebsiteProjects } from '../hooks/useWebsiteProjects'
|
||||
import { customerTabs, resolveCustomerTab } from '../lib/customerTabs'
|
||||
import { customerStage, isArchived, isInvoiceReady, setCustomerArchived, upgradeToCustomer } from '../lib/leads'
|
||||
import CustomerViewLog from '../components/customer/CustomerViewLog'
|
||||
import ChatThread from '../components/chat/ChatThread'
|
||||
import KundenansichtButton from '../components/customer/KundenansichtButton'
|
||||
import { getCustomerPortalStatus } from '../lib/teamApi'
|
||||
import { formatLastLogin } from '../lib/lastLogin'
|
||||
@@ -104,6 +105,7 @@ export default function CustomerDetailPage() {
|
||||
{activeTab === 'tickets' && <CustomerTickets customerId={customer.$id} />}
|
||||
{activeTab === 'invoices' && <CustomerInvoices customer={customer} onShowOverview={() => setTab('overview')} />}
|
||||
{activeTab === 'projects' && <CustomerProjects customerId={customer.$id} />}
|
||||
{activeTab === 'chat' && <Card><ChatThread customerId={customer.$id} customerName={customer.name} /></Card>}
|
||||
{activeTab === 'protokoll' && <CustomerViewLog customerId={customer.$id} />}
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
/* Chat im Ticketsystem (Messenger und Reiter "Chat"). Eingabezeile, Anhaenge und Sprachnachrichten kommen aus
|
||||
chat-composer.css des Kundenportals; hier Farben (--chat-*), Verlauf, Sprechblasen und die Liste. */
|
||||
.woms-chat {
|
||||
--chat-accent: var(--accent);
|
||||
--chat-accent-hover: var(--accent-2);
|
||||
--chat-accent-soft: var(--accent-soft);
|
||||
--chat-accent-line: rgba(16, 185, 129, 0.5);
|
||||
--chat-accent-text: var(--accent);
|
||||
--chat-on-accent: #04130d;
|
||||
--chat-surface: var(--surface-2);
|
||||
display: flex; flex-direction: column; gap: var(--sp-2); min-width: 0;
|
||||
}
|
||||
.woms-chat .chat-messages {
|
||||
display: flex; flex-direction: column; gap: 8px; min-height: 280px; max-height: 60vh; overflow-y: auto;
|
||||
padding: 12px; border: 1px solid var(--border); border-radius: var(--r-sm); background: var(--surface);
|
||||
}
|
||||
.woms-chat .chat-bubble {
|
||||
align-self: flex-start; max-width: 78%; padding: 8px 12px; border-radius: 14px;
|
||||
background: var(--surface-2); color: var(--text); overflow-wrap: anywhere;
|
||||
}
|
||||
.woms-chat .chat-bubble.mine { align-self: flex-end; background: var(--accent-soft); border: 1px solid rgba(16, 185, 129, 0.35); }
|
||||
.woms-chat .chat-meta { display: block; margin-top: 4px; font-size: 11.5px; color: var(--text-muted); }
|
||||
.woms-chat .chat-notice { font-size: 12px; }
|
||||
/* Eingabefeld: Grundstil wie im Admin-Panel (chat-composer.css setzt nur das Layout) */
|
||||
.woms-chat .chat-input-row textarea {
|
||||
resize: none; min-height: 46px; max-height: 130px; padding: 11px 14px; border-radius: 14px;
|
||||
border: 1px solid var(--border); background: var(--surface-2); color: var(--text); font: inherit; line-height: 1.4;
|
||||
}
|
||||
.woms-chat .chat-input-row textarea:focus { outline: none; border-color: var(--accent); }
|
||||
|
||||
.chat-page { display: grid; grid-template-columns: 320px minmax(0, 1fr); gap: var(--sp-4); align-items: start; }
|
||||
.chat-side { display: flex; flex-direction: column; gap: var(--sp-3); min-width: 0; }
|
||||
.chat-list { display: flex; flex-direction: column; max-height: 70vh; overflow-y: auto; }
|
||||
.chat-list-item {
|
||||
display: grid; gap: 2px; padding: 10px 14px; border: none; border-bottom: 1px solid var(--border);
|
||||
background: transparent; color: var(--text); font: inherit; text-align: left; cursor: pointer;
|
||||
}
|
||||
.chat-list-item:hover { background: var(--surface-2); }
|
||||
.chat-list-item.active { background: var(--accent-soft); }
|
||||
.chat-list-top { display: flex; align-items: center; gap: 8px; justify-content: space-between; }
|
||||
.chat-list-last { color: var(--text-muted); font-size: 13px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.chat-list-time { color: var(--text-faint); font-size: 11.5px; }
|
||||
.chat-main { min-width: 0; }
|
||||
.chat-back { display: none; }
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.chat-page { grid-template-columns: minmax(0, 1fr); }
|
||||
.chat-page.has-thread .chat-side { display: none; }
|
||||
.chat-page:not(.has-thread) .chat-main { display: none; }
|
||||
.chat-back { display: inline-flex; }
|
||||
.woms-chat .chat-bubble { max-width: 90%; }
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import test from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { loadModule } from './load.mjs'
|
||||
|
||||
// Chat im WOMS: Sortierung, Hinweis zur Kunden-Mail (K17, Texte wie im Admin-Panel), Zeilen und Neuladen
|
||||
const { sortChats, noticeText, lastLine, messageMeta, needsFullRefresh } = await loadModule('src/lib/chatFormat.js')
|
||||
|
||||
test('sortChats: ungelesene zuerst, dann neueste', () => {
|
||||
const chats = [
|
||||
{ customerId: 'a', lastAt: '2026-10-01T10:00:00.000Z', unreadFromCustomer: 0 },
|
||||
{ customerId: 'b', lastAt: '2026-10-01T08:00:00.000Z', unreadFromCustomer: 2 },
|
||||
{ customerId: 'c', lastAt: '2026-10-01T12:00:00.000Z', unreadFromCustomer: 0 },
|
||||
{ customerId: 'd', lastAt: '2026-10-01T09:00:00.000Z', unreadFromCustomer: 1 },
|
||||
]
|
||||
assert.deepEqual(sortChats(chats).map((c) => c.customerId), ['d', 'b', 'c', 'a'])
|
||||
assert.equal(chats[0].customerId, 'a', 'Eingabe bleibt unveraendert')
|
||||
})
|
||||
|
||||
test('noticeText: gesendet, gedrosselt, Grund, leer', () => {
|
||||
assert.equal(noticeText({ sent: true, to: 'k@x.de' }, '14:05'), 'E-Mail-Hinweis an k@x.de gesendet (14:05)')
|
||||
assert.equal(noticeText({ sent: false, reason: 'throttled' }, '14:05'), 'Kein neuer E-Mail-Hinweis: Der Kunde wurde in den letzten 15 Minuten schon benachrichtigt.')
|
||||
assert.equal(noticeText({ sent: false, reason: 'SMTP weg' }, '14:05'), 'Kein E-Mail-Hinweis: SMTP weg')
|
||||
assert.equal(noticeText(null, '14:05'), '')
|
||||
})
|
||||
|
||||
test('messageMeta und lastLine', () => {
|
||||
assert.equal(messageMeta({ sender: 'admin', senderName: 'Andrej', createdAt: '2026-09-30T10:05:00.000Z' }, 'Berta Berg'), 'Andrej · 30.09.2026 12:05')
|
||||
assert.equal(messageMeta({ sender: 'admin', senderName: '', createdAt: '2026-09-30T10:05:00.000Z' }, 'Berta Berg'), 'Team · 30.09.2026 12:05')
|
||||
assert.equal(messageMeta({ sender: 'customer', senderName: 'x', createdAt: '2026-09-30T10:05:00.000Z' }, 'Berta Berg'), 'Berta Berg · 30.09.2026 12:05')
|
||||
assert.equal(lastLine({ lastSender: 'admin', lastText: 'Erledigt' }), 'Du: Erledigt')
|
||||
assert.equal(lastLine({ lastSender: 'customer', lastText: 'Danke' }), 'Danke')
|
||||
})
|
||||
|
||||
test('needsFullRefresh ab 10 Minuten', () => {
|
||||
assert.equal(needsFullRefresh(0, 599999), false)
|
||||
assert.equal(needsFullRefresh(0, 600000), true)
|
||||
})
|
||||
|
||||
// Abschlussreview: die Auswahl "Neue Nachricht an Kunde…" darf nur eindeutig gewaehlte Kunden oeffnen
|
||||
const { customerPickLabel, findCustomerByLabel } = await loadModule('src/lib/chatFormat.js')
|
||||
|
||||
test('customerPickLabel: Name · E-Mail · Kd.-Nr. wie im Admin-Panel, ohne Name die E-Mail', () => {
|
||||
assert.equal(customerPickLabel({ $id: 'c1', name: 'Amin', email: 'a@x.de', code: '5' }), 'Amin · a@x.de · Kd.-Nr. 5')
|
||||
assert.equal(customerPickLabel({ $id: 'c3', name: '', email: 'leer@x.de' }), 'leer@x.de')
|
||||
})
|
||||
|
||||
test('findCustomerByLabel: Teil- oder Namens-Treffer oeffnen nichts, nur die volle Beschriftung', () => {
|
||||
const customers = [
|
||||
{ $id: 'c1', name: 'Amin', email: 'a@x.de', code: '5' },
|
||||
{ $id: 'c2', name: 'Amin Friseur', email: 'f@x.de', code: '6' },
|
||||
]
|
||||
assert.equal(findCustomerByLabel(customers, 'Amin'), null)
|
||||
assert.equal(findCustomerByLabel(customers, 'Amin Friseur'), null)
|
||||
assert.equal(findCustomerByLabel(customers, 'Amin Friseur · f@x.de · Kd.-Nr. 6')?.$id, 'c2')
|
||||
assert.equal(findCustomerByLabel(customers, 'Amin · a@x.de · Kd.-Nr. 5')?.$id, 'c1')
|
||||
})
|
||||
@@ -4,19 +4,20 @@ import { loadModule } from './load.mjs'
|
||||
|
||||
const { customerTabs, resolveCustomerTab } = await loadModule('src/lib/customerTabs.js')
|
||||
|
||||
test('customerTabs: Rechnungen nur für Admins, Reihenfolge mit Protokoll, Übersicht mit Umlaut', () => {
|
||||
test('customerTabs: Rechnungen nur für Admins, Reihenfolge mit Chat und Protokoll, Übersicht mit Umlaut', () => {
|
||||
assert.deepEqual(customerTabs(true), [
|
||||
{ id: 'overview', label: 'Übersicht' },
|
||||
{ id: 'tickets', label: 'Tickets' },
|
||||
{ id: 'invoices', label: 'Rechnungen' },
|
||||
{ id: 'projects', label: 'Projekte' },
|
||||
{ id: 'chat', label: 'Chat' },
|
||||
{ id: 'protokoll', label: 'Protokoll' },
|
||||
])
|
||||
const staff = customerTabs(false)
|
||||
assert.deepEqual(staff.map((t) => t.id), ['overview', 'tickets', 'projects', 'protokoll'])
|
||||
assert.deepEqual(staff.map((t) => t.id), ['overview', 'tickets', 'projects', 'chat', 'protokoll'])
|
||||
// unbekannt oder nicht eindeutig true gilt als kein Admin
|
||||
for (const v of [undefined, null, 0, '', 'true']) {
|
||||
assert.deepEqual(customerTabs(v).map((t) => t.id), ['overview', 'tickets', 'projects', 'protokoll'], String(v))
|
||||
assert.deepEqual(customerTabs(v).map((t) => t.id), ['overview', 'tickets', 'projects', 'chat', 'protokoll'], String(v))
|
||||
}
|
||||
// die Einträge tragen nur id und label (kein internes Feld)
|
||||
assert.deepEqual(Object.keys(customerTabs(true)[2]), ['id', 'label'])
|
||||
@@ -30,6 +31,7 @@ test('resolveCustomerTab: erlaubter Reiter bleibt, sonst Übersicht', () => {
|
||||
assert.equal(resolveCustomerTab('projects', false), 'projects')
|
||||
assert.equal(resolveCustomerTab('overview', false), 'overview')
|
||||
assert.equal(resolveCustomerTab('protokoll', false), 'protokoll')
|
||||
assert.equal(resolveCustomerTab('chat', false), 'chat')
|
||||
assert.equal(resolveCustomerTab('gibtsnicht', true), 'overview')
|
||||
assert.equal(resolveCustomerTab(undefined, true), 'overview')
|
||||
})
|
||||
@@ -0,0 +1,49 @@
|
||||
import test from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { loadModule } from './load.mjs'
|
||||
|
||||
// Chat-Baustein (chat-composer.js/.css) wird zur Laufzeit vom Kundenportal geladen: eine Quelle fuer Portal und WOMS
|
||||
const { loadChatComposer } = await loadModule('src/lib/loadChatComposer.js')
|
||||
|
||||
function fakeDoc() {
|
||||
const appended = []
|
||||
return {
|
||||
appended,
|
||||
head: { appendChild: (node) => { appended.push(node); return node } },
|
||||
createElement: (tag) => ({ tag, attrs: {}, setAttribute(k, v) { this.attrs[k] = v } }),
|
||||
}
|
||||
}
|
||||
|
||||
test('haengt CSS und Skript einmal an und liefert window.ChatComposer', async () => {
|
||||
const doc = fakeDoc()
|
||||
const win = {}
|
||||
const first = loadChatComposer({ baseUrl: 'https://project.webklar.com', doc, win })
|
||||
const second = loadChatComposer({ baseUrl: 'https://project.webklar.com', doc, win })
|
||||
assert.equal(doc.appended.length, 2)
|
||||
const link = doc.appended.find((n) => n.tag === 'link')
|
||||
const script = doc.appended.find((n) => n.tag === 'script')
|
||||
assert.equal(link.href || link.attrs.href, 'https://project.webklar.com/chat-composer.css')
|
||||
assert.equal(link.rel || link.attrs.rel, 'stylesheet')
|
||||
assert.equal(script.src || script.attrs.src, 'https://project.webklar.com/chat-composer.js')
|
||||
win.ChatComposer = { create() {} }
|
||||
script.onload()
|
||||
assert.equal(await first, win.ChatComposer)
|
||||
assert.equal(await second, win.ChatComposer)
|
||||
})
|
||||
|
||||
test('Skript laedt nicht -> deutsche Meldung, naechster Aufruf versucht es neu', async () => {
|
||||
const doc = fakeDoc()
|
||||
const win = {}
|
||||
const p = loadChatComposer({ baseUrl: 'https://p', doc, win })
|
||||
doc.appended.find((n) => n.tag === 'script').onerror()
|
||||
await assert.rejects(p, { message: 'Der Chat-Baustein konnte nicht geladen werden.' })
|
||||
loadChatComposer({ baseUrl: 'https://p', doc, win })
|
||||
assert.equal(doc.appended.filter((n) => n.tag === 'script').length, 2)
|
||||
})
|
||||
|
||||
test('schon geladen (win.ChatComposer vorhanden) -> nichts anhaengen', async () => {
|
||||
const doc = fakeDoc()
|
||||
const win = { ChatComposer: { create() {} } }
|
||||
assert.equal(await loadChatComposer({ baseUrl: 'https://p', doc, win }), win.ChatComposer)
|
||||
assert.equal(doc.appended.length, 0)
|
||||
})
|
||||
@@ -0,0 +1,19 @@
|
||||
import test from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { loadModule } from './load.mjs'
|
||||
|
||||
// Sidebar: Eintraege als reine Daten (Icons ordnet Navbar.jsx ueber key zu)
|
||||
const { buildNavLinks } = await loadModule('src/lib/navLinks.js')
|
||||
|
||||
test('buildNavLinks: Chat unter Kunden mit Zahl, Admin-Eintraege nur fuer Admins', () => {
|
||||
const links = buildNavLinks({ isAdmin: false, pulseLevel: 'ok', chatUnread: 3 })
|
||||
const kunden = links.find((l) => l.key === 'customers')
|
||||
assert.equal(kunden.href, '/customers')
|
||||
assert.deepEqual(kunden.children, [{ key: 'chat', label: 'Chat', href: '/customers/chat', count: 3 }])
|
||||
assert.deepEqual(links.map((l) => l.key), ['tickets', 'planboard', 'projects', 'services', 'customers', 'leads', 'legal'])
|
||||
assert.equal(links.find((l) => l.key === 'leads').paused, true)
|
||||
const admin = buildNavLinks({ isAdmin: true, pulseLevel: 'warn', chatUnread: 0 })
|
||||
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')
|
||||
assert.equal(admin.find((l) => l.key === 'finance').also, '/finanzen')
|
||||
})
|
||||
Reference in new issue
Block a user