diff --git a/src/App.jsx b/src/App.jsx index f6986d5..54f4579 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -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() { } /> } /> } /> + {/* Messenger (Kunden -> Chat); statische Pfade gehen in react-router vor /customers/:id */} + } /> } /> } /> {/* Finanzen: nur Admins, alle anderen gehen zu /tickets */} diff --git a/src/components/chat/ChatThread.jsx b/src/components/chat/ChatThread.jsx new file mode 100644 index 0000000..d5eee50 --- /dev/null +++ b/src/components/chat/ChatThread.jsx @@ -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 ( +
+ {error && ( +
+ {error} + +
+ )} +
+ {notice &&
{notice}
} +
+
+ ) +} diff --git a/src/lib/customerTabs.js b/src/lib/customerTabs.js index a3b058c..8d3dda8 100644 --- a/src/lib/customerTabs.js +++ b/src/lib/customerTabs.js @@ -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' }, ] diff --git a/src/pages/CustomerChatPage.jsx b/src/pages/CustomerChatPage.jsx new file mode 100644 index 0000000..80bc8a0 --- /dev/null +++ b/src/pages/CustomerChatPage.jsx @@ -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=) +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 ( +
+ +
+
+ + + {error && ( +
+ {error} + +
+ )} + {!error && chats && sorted.length === 0 && } +
+ {sorted.map((c) => ( + + ))} +
+
+
+
+ {kunde ? ( + + + {nameOf(kunde)} + + }> + + + ) : ( + + )} +
+
+
+ ) +} diff --git a/src/pages/CustomerDetailPage.jsx b/src/pages/CustomerDetailPage.jsx index 6402bb9..3473f58 100644 --- a/src/pages/CustomerDetailPage.jsx +++ b/src/pages/CustomerDetailPage.jsx @@ -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' && } {activeTab === 'invoices' && setTab('overview')} />} {activeTab === 'projects' && } + {activeTab === 'chat' && } {activeTab === 'protokoll' && }
) diff --git a/src/styles/chat.css b/src/styles/chat.css new file mode 100644 index 0000000..8c70d06 --- /dev/null +++ b/src/styles/chat.css @@ -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%; } +} diff --git a/tests/unit/customerTabs.test.mjs b/tests/unit/customerTabs.test.mjs index e71c443..41e14a8 100644 --- a/tests/unit/customerTabs.test.mjs +++ b/tests/unit/customerTabs.test.mjs @@ -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') })