Chat im Ticketsystem: Messenger unter Kunden, Reiter Chat auf der Kundenseite

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Opus 5.5 committed 2026-10-01 23:35:59 +00:00
1 parent 005875d970
commit eb0b889203
7 files changed
+299 -3

No files matched your search

+3
View File
@@ -9,6 +9,7 @@ import ProjectsPage from './pages/ProjectsPage'
import AdminPage from './pages/AdminPage' import AdminPage from './pages/AdminPage'
import CustomersPage from './pages/CustomersPage' import CustomersPage from './pages/CustomersPage'
import CustomerDetailPage from './pages/CustomerDetailPage' import CustomerDetailPage from './pages/CustomerDetailPage'
import CustomerChatPage from './pages/CustomerChatPage'
import FinancePage from './pages/FinancePage' import FinancePage from './pages/FinancePage'
import FinanceSettingsPage from './pages/FinanceSettingsPage' import FinanceSettingsPage from './pages/FinanceSettingsPage'
import LegalPage from './pages/LegalPage' import LegalPage from './pages/LegalPage'
@@ -49,6 +50,8 @@ function AppRoutes() {
<Route path="/" element={<Navigate to="/tickets" replace />} /> <Route path="/" element={<Navigate to="/tickets" replace />} />
<Route path="/tickets" element={<ProtectedRoute><TicketsPage /></ProtectedRoute>} /> <Route path="/tickets" element={<ProtectedRoute><TicketsPage /></ProtectedRoute>} />
<Route path="/customers" element={<ProtectedRoute><CustomersPage /></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="/customers/:id" element={<ProtectedRoute><CustomerDetailPage /></ProtectedRoute>} />
<Route path="/leads" element={<Navigate to="/tickets" replace />} /> <Route path="/leads" element={<Navigate to="/tickets" replace />} />
{/* Finanzen: nur Admins, alle anderen gehen zu /tickets */} {/* Finanzen: nur Admins, alle anderen gehen zu /tickets */}
+126
View File
@@ -0,0 +1,126 @@
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)
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, customerName),
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 ${customerName || '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()
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, customerName, 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>
)
}
+2
View File
@@ -6,6 +6,8 @@ const BASE_TABS = [
{ id: 'tickets', label: 'Tickets' }, { id: 'tickets', label: 'Tickets' },
{ id: 'invoices', label: 'Rechnungen', adminOnly: true }, { id: 'invoices', label: 'Rechnungen', adminOnly: true },
{ id: 'projects', label: 'Projekte' }, { 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 // Protokoll der Kundenansicht (wer wann das Portal des Kunden geöffnet oder darin etwas geändert hat): alle Mitarbeiter
{ id: 'protokoll', label: 'Protokoll' }, { id: 'protokoll', label: 'Protokoll' },
] ]
+109
View File
@@ -0,0 +1,109 @@
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 { 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
.filter((c) => (c.name || '').toLowerCase().includes(q))
.slice(0, 8)
.map((c) => ({ value: c.name, hint: c.code || c.email || '' }))
}
const onPick = (value) => {
setQuery(value)
const hit = customers.find((c) => c.name === 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>
)
}
+2
View File
@@ -13,6 +13,7 @@ import { useWebsiteProjects } from '../hooks/useWebsiteProjects'
import { customerTabs, resolveCustomerTab } from '../lib/customerTabs' import { customerTabs, resolveCustomerTab } from '../lib/customerTabs'
import { customerStage, isArchived, isInvoiceReady, setCustomerArchived, upgradeToCustomer } from '../lib/leads' import { customerStage, isArchived, isInvoiceReady, setCustomerArchived, upgradeToCustomer } from '../lib/leads'
import CustomerViewLog from '../components/customer/CustomerViewLog' import CustomerViewLog from '../components/customer/CustomerViewLog'
import ChatThread from '../components/chat/ChatThread'
import KundenansichtButton from '../components/customer/KundenansichtButton' import KundenansichtButton from '../components/customer/KundenansichtButton'
import { getCustomerPortalStatus } from '../lib/teamApi' import { getCustomerPortalStatus } from '../lib/teamApi'
import { formatLastLogin } from '../lib/lastLogin' import { formatLastLogin } from '../lib/lastLogin'
@@ -104,6 +105,7 @@ export default function CustomerDetailPage() {
{activeTab === 'tickets' && <CustomerTickets customerId={customer.$id} />} {activeTab === 'tickets' && <CustomerTickets customerId={customer.$id} />}
{activeTab === 'invoices' && <CustomerInvoices customer={customer} onShowOverview={() => setTab('overview')} />} {activeTab === 'invoices' && <CustomerInvoices customer={customer} onShowOverview={() => setTab('overview')} />}
{activeTab === 'projects' && <CustomerProjects customerId={customer.$id} />} {activeTab === 'projects' && <CustomerProjects customerId={customer.$id} />}
{activeTab === 'chat' && <Card><ChatThread customerId={customer.$id} customerName={customer.name} /></Card>}
{activeTab === 'protokoll' && <CustomerViewLog customerId={customer.$id} />} {activeTab === 'protokoll' && <CustomerViewLog customerId={customer.$id} />}
</div> </div>
) )
+52
View File
@@ -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%; }
}
+5 -3
View File
@@ -4,19 +4,20 @@ import { loadModule } from './load.mjs'
const { customerTabs, resolveCustomerTab } = await loadModule('src/lib/customerTabs.js') 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), [ assert.deepEqual(customerTabs(true), [
{ id: 'overview', label: 'Übersicht' }, { id: 'overview', label: 'Übersicht' },
{ id: 'tickets', label: 'Tickets' }, { id: 'tickets', label: 'Tickets' },
{ id: 'invoices', label: 'Rechnungen' }, { id: 'invoices', label: 'Rechnungen' },
{ id: 'projects', label: 'Projekte' }, { id: 'projects', label: 'Projekte' },
{ id: 'chat', label: 'Chat' },
{ id: 'protokoll', label: 'Protokoll' }, { id: 'protokoll', label: 'Protokoll' },
]) ])
const staff = customerTabs(false) 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 // unbekannt oder nicht eindeutig true gilt als kein Admin
for (const v of [undefined, null, 0, '', 'true']) { 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) // die Einträge tragen nur id und label (kein internes Feld)
assert.deepEqual(Object.keys(customerTabs(true)[2]), ['id', 'label']) 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('projects', false), 'projects')
assert.equal(resolveCustomerTab('overview', false), 'overview') assert.equal(resolveCustomerTab('overview', false), 'overview')
assert.equal(resolveCustomerTab('protokoll', false), 'protokoll') assert.equal(resolveCustomerTab('protokoll', false), 'protokoll')
assert.equal(resolveCustomerTab('chat', false), 'chat')
assert.equal(resolveCustomerTab('gibtsnicht', true), 'overview') assert.equal(resolveCustomerTab('gibtsnicht', true), 'overview')
assert.equal(resolveCustomerTab(undefined, true), 'overview') assert.equal(resolveCustomerTab(undefined, true), 'overview')
}) })