diff --git a/kimi.md b/kimi.md index 51de1d5..7c77f06 100644 --- a/kimi.md +++ b/kimi.md @@ -189,6 +189,23 @@ tar -czf mp-dist.tar.gz -C dist . ## 8. Verlauf / offene Punkte +**Erledigt 04.10. (v1.19):** Schadensmeldungen mit Kommunikation verknüpft. +threads-Tabelle um `ticket_id TEXT REFERENCES tickets(id)` erweitert (ALTER + +Index idx_threads_ticket). POST /api/tickets legt den Auto-Thread bereits mit +ticket_id an (Verknüpfung kam zur ursprünglichen Thread-Anlage nachträglich +dazu – bestehende Threads ohne ticket_id bleiben unverknüpft, nur NEUE +Meldungen). mapThread liefert ticketId mit. PATCH /api/tickets/:id schreibt +bei Statuswechsel eine Systemnachricht in den verknüpften Thread +(„Status … geändert: alt → neu", from_role 'admin', unread_by_tenant=TRUE) +und sendet eine HTML-Mail an den Mieter (Titel + alt/neu). Frontend: +Ticket-Karten zeigen einen „Kommunikation"-Bereich (Nachrichtenzahl, letzte +Nachricht, Link „Unterhaltung öffnen →" nach `/nachrichten?thread=`); +Messages.tsx wertet den ?thread=-Parameter aus und selektiert die Unterhaltung. +E2E verifiziert: Ticket als a.berger → Thread mit ticketId + Erstnachricht + +KI-Antwort (N8N) im selben Thread; Statuswechsel als Admin → Systemnachricht ++ Mail-Versuch (Bounce nur wegen Demo-Adresse example.de, erwartet); +Testdaten bereinigt. + **Erledigt 04.10. (v1.17 + v1.18):** Auto-Zuordnung der Belege + eigener OCR-Container. ZUORDNUNGSREGEL (Vorgabe des Nutzers): PRIMÄR Stromzählernummer, SEKUNDÄR Adresse. belege-Tabelle um unit_id/gebaude/zuordnung/rohtext erweitert diff --git a/server/src/db.js b/server/src/db.js index a737610..b04f433 100644 --- a/server/src/db.js +++ b/server/src/db.js @@ -72,6 +72,9 @@ CREATE TABLE IF NOT EXISTS threads ( unread_by_tenant BOOLEAN NOT NULL DEFAULT FALSE ); CREATE INDEX IF NOT EXISTS idx_threads_unit ON threads(unit_id); +-- Verknüpfung Unterhaltung ↔ Schadensmeldung (v1.19) +ALTER TABLE threads ADD COLUMN IF NOT EXISTS ticket_id TEXT REFERENCES tickets(id); +CREATE INDEX IF NOT EXISTS idx_threads_ticket ON threads(ticket_id); CREATE TABLE IF NOT EXISTS messages ( id TEXT PRIMARY KEY, diff --git a/server/src/index.js b/server/src/index.js index b316fa7..5ffaee8 100644 --- a/server/src/index.js +++ b/server/src/index.js @@ -169,6 +169,7 @@ const mapMessage = (m) => ({ id: m.id, from: m.from_role, author: m.author, text const mapThread = (t) => ({ id: t.id, unitId: t.unit_id, tenant: t.tenant, subject: t.subject, unreadByAdmin: t.unread_by_admin, unreadByTenant: t.unread_by_tenant, + ticketId: t.ticket_id ?? null, }) const mapCode = (c) => ({ code: c.code, einheitId: c.einheit_id, erstelltAm: c.erstellt_am, verwendetAm: c.verwendet_am ?? undefined }) const mapAnnouncement = (a) => ({ id: a.id, title: a.title, text: a.text, date: a.date, tag: a.tag }) @@ -390,10 +391,10 @@ app.post('/api/tickets', auth, async (req, res, next) => { `Priorität: ${['niedrig', 'mittel', 'hoch'].includes(priority) ? priority : 'mittel'}.\n\n` + `${String(title).trim()}\n${String(description || '–').trim()}` await pool.query( - `INSERT INTO threads (id, unit_id, tenant, subject, unread_by_admin, unread_by_tenant) - VALUES ($1,$2,$3,$4,$5,$6)`, + `INSERT INTO threads (id, unit_id, tenant, subject, unread_by_admin, unread_by_tenant, ticket_id) + VALUES ($1,$2,$3,$4,$5,$6,$7)`, [threadId, unitId, mieterName, threadBetreff, - vonRolle === 'tenant', vonRolle === 'admin'], + vonRolle === 'tenant', vonRolle === 'admin', id], ) await pool.query( `INSERT INTO messages (id, thread_id, from_role, author, text, at) VALUES ($1,$2,$3,$4,$5,$6)`, @@ -466,10 +467,44 @@ app.patch('/api/tickets/:id', auth, adminOnly, async (req, res, next) => { const status = String((req.body || {}).status || '') if (!['offen', 'in Bearbeitung', 'erledigt'].includes(status)) return res.status(400).json({ fehler: 'Ungültiger Status.' }) + const { rows: altRows } = await pool.query('SELECT status FROM tickets WHERE id = $1', [req.params.id]) + if (!altRows.length) return res.status(404).json({ fehler: 'Ticket nicht gefunden.' }) + const alterStatus = altRows[0].status const { rows } = await pool.query( 'UPDATE tickets SET status = $1 WHERE id = $2 RETURNING *', [status, req.params.id], ) - if (!rows.length) return res.status(404).json({ fehler: 'Ticket nicht gefunden.' }) + // Statuswechsel als Systemnachricht in die verknüpfte Unterhaltung schreiben, + // damit der gesamte Verlauf der Schadensmeldung an einem Ort bleibt (v1.19). + const { rows: threadRows } = await pool.query( + 'SELECT * FROM threads WHERE ticket_id = $1', [req.params.id], + ) + if (threadRows.length && alterStatus !== status) { + const th = threadRows[0] + const systemText = `Status der Schadensmeldung geändert: ${alterStatus} → ${status}` + await pool.query( + `INSERT INTO messages (id, thread_id, from_role, author, text, at) VALUES ($1,$2,'admin',$3,$4,$5)`, + [`${th.id}-${Date.now()}`, th.id, req.user.name, systemText, deutschesDatumZeit()], + ) + await pool.query('UPDATE threads SET unread_by_tenant = TRUE WHERE id = $1', [th.id]) + const { rows: einheitRows } = await pool.query('SELECT * FROM units WHERE id = $1', [th.unit_id]) + const label = mail.einheitLabel(mapUnit(einheitRows[0])) + const textVersion = + `Der Status der Schadensmeldung „${rows[0].title}“ wurde geändert:\n\n` + + `${alterStatus} → ${status}\n\nWohnung: ${label}` + const html = mail.baueHtmlMail({ + titel: 'Status aktualisiert', + untertitel: th.subject, + einleitung: `Der Status Ihrer Schadensmeldung „${rows[0].title}“ wurde von der Verwaltung geändert.`, + zeilen: [ + ['Wohnung', label], + ['Vorher', alterStatus], + ['Neu', status], + ], + statusBox: { hintergrund: '#f0fdfa', farbe: '#0f766e', text: `Status: ${status}` }, + aktionenHtml: mail.buttonHtml('Zum Mieterportal', `${process.env.PORTAL_URL || 'https://mieterportal.hocks.eu'}/nachrichten`), + }) + mail.benachrichtigeMieter(th.unit_id, `Status: ${status} – ${rows[0].title}`, textVersion, html) + } res.json({ ticket: mapTicket(rows[0]) }) } catch (e) { next(e) } }) diff --git a/src/pages/Messages.tsx b/src/pages/Messages.tsx index 46d505c..7f5ffcb 100644 --- a/src/pages/Messages.tsx +++ b/src/pages/Messages.tsx @@ -1,4 +1,5 @@ import { useEffect, useRef, useState } from 'react' +import { useSearchParams } from 'react-router' import { MailPlus, SendHorizonal, UserRound } from 'lucide-react' import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' @@ -68,6 +69,7 @@ function NewMessageDialog({ onCreated }: { onCreated: (id: string) => void }) { export default function Messages() { const { threads, role, units, meineEinheit, markThreadRead, addMessage } = useStore() + const [searchParams, setSearchParams] = useSearchParams() const visible = role === 'tenant' ? threads.filter((t) => t.unitId === meineEinheit.id) : threads @@ -76,6 +78,19 @@ export default function Messages() { const [draft, setDraft] = useState('') const bottomRef = useRef(null) + // Deep-Link aus anderen Modulen (z. B. Schadensmeldung): ?thread= + useEffect(() => { + const gewuenscht = searchParams.get('thread') + if (!gewuenscht) return + const gefunden = visible.find((t) => t.id === gewuenscht) + if (gefunden) { + setActiveId(gefunden.id) + searchParams.delete('thread') + setSearchParams(searchParams, { replace: true }) + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [threads, role]) + useEffect(() => { if (!active) return markThreadRead(active.id) diff --git a/src/pages/Tickets.tsx b/src/pages/Tickets.tsx index b968544..893cd6c 100644 --- a/src/pages/Tickets.tsx +++ b/src/pages/Tickets.tsx @@ -1,5 +1,6 @@ import { useMemo, useState } from 'react' -import { Plus, Wrench, CalendarDays, MapPin } from 'lucide-react' +import { Link } from 'react-router' +import { Plus, Wrench, CalendarDays, MapPin, MessagesSquare } from 'lucide-react' import { Button } from '@/components/ui/button' import { Badge } from '@/components/ui/badge' import { @@ -141,9 +142,11 @@ function NewTicketDialog() { } export default function Tickets() { - const { tickets, units, role, setTicketStatus, meineEinheit } = useStore() + const { tickets, units, role, setTicketStatus, meineEinheit, threads } = useStore() const [filter, setFilter] = useState<'alle' | TicketStatus>('alle') + const threadFuerTicket = (ticketId: string) => threads.find((th) => th.ticketId === ticketId) + const visible = useMemo( () => (role === 'tenant' @@ -197,6 +200,8 @@ export default function Tickets() {
{visible.map((t) => { const unit = units.find((u) => u.id === t.unitId) + const thread = threadFuerTicket(t.id) + const letzte = thread?.messages[thread.messages.length - 1] return (
@@ -238,6 +243,33 @@ export default function Tickets() { )}
+ + {thread && ( +
+
+
+ + Kommunikation + + ({thread.messages.length} {thread.messages.length === 1 ? 'Nachricht' : 'Nachrichten'}) + +
+ + Unterhaltung öffnen → + +
+ {letzte && ( +
+ {letzte.author}:{' '} + {letzte.text} + {letzte.at} +
+ )} +
+ )}
) })} diff --git a/src/types/index.ts b/src/types/index.ts index 686d356..a535daa 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -87,6 +87,8 @@ export interface Thread { subject: string unreadByTenant: boolean unreadByAdmin: boolean + /** Verknüpfte Schadensmeldung (v1.19), null bei normalen Unterhaltungen */ + ticketId?: string | null messages: Message[] }