v1.19: Schadensmeldungen mit Kommunikation verknuepft
This commit is contained in:
@@ -189,6 +189,23 @@ tar -czf mp-dist.tar.gz -C dist .
|
|||||||
|
|
||||||
## 8. Verlauf / offene Punkte
|
## 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=<id>`);
|
||||||
|
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
|
**Erledigt 04.10. (v1.17 + v1.18):** Auto-Zuordnung der Belege + eigener
|
||||||
OCR-Container. ZUORDNUNGSREGEL (Vorgabe des Nutzers): PRIMÄR Stromzählernummer,
|
OCR-Container. ZUORDNUNGSREGEL (Vorgabe des Nutzers): PRIMÄR Stromzählernummer,
|
||||||
SEKUNDÄR Adresse. belege-Tabelle um unit_id/gebaude/zuordnung/rohtext erweitert
|
SEKUNDÄR Adresse. belege-Tabelle um unit_id/gebaude/zuordnung/rohtext erweitert
|
||||||
|
|||||||
@@ -72,6 +72,9 @@ CREATE TABLE IF NOT EXISTS threads (
|
|||||||
unread_by_tenant BOOLEAN NOT NULL DEFAULT FALSE
|
unread_by_tenant BOOLEAN NOT NULL DEFAULT FALSE
|
||||||
);
|
);
|
||||||
CREATE INDEX IF NOT EXISTS idx_threads_unit ON threads(unit_id);
|
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 (
|
CREATE TABLE IF NOT EXISTS messages (
|
||||||
id TEXT PRIMARY KEY,
|
id TEXT PRIMARY KEY,
|
||||||
|
|||||||
+39
-4
@@ -169,6 +169,7 @@ const mapMessage = (m) => ({ id: m.id, from: m.from_role, author: m.author, text
|
|||||||
const mapThread = (t) => ({
|
const mapThread = (t) => ({
|
||||||
id: t.id, unitId: t.unit_id, tenant: t.tenant, subject: t.subject,
|
id: t.id, unitId: t.unit_id, tenant: t.tenant, subject: t.subject,
|
||||||
unreadByAdmin: t.unread_by_admin, unreadByTenant: t.unread_by_tenant,
|
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 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 })
|
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` +
|
`Priorität: ${['niedrig', 'mittel', 'hoch'].includes(priority) ? priority : 'mittel'}.\n\n` +
|
||||||
`${String(title).trim()}\n${String(description || '–').trim()}`
|
`${String(title).trim()}\n${String(description || '–').trim()}`
|
||||||
await pool.query(
|
await pool.query(
|
||||||
`INSERT INTO threads (id, unit_id, tenant, subject, unread_by_admin, unread_by_tenant)
|
`INSERT INTO threads (id, unit_id, tenant, subject, unread_by_admin, unread_by_tenant, ticket_id)
|
||||||
VALUES ($1,$2,$3,$4,$5,$6)`,
|
VALUES ($1,$2,$3,$4,$5,$6,$7)`,
|
||||||
[threadId, unitId, mieterName, threadBetreff,
|
[threadId, unitId, mieterName, threadBetreff,
|
||||||
vonRolle === 'tenant', vonRolle === 'admin'],
|
vonRolle === 'tenant', vonRolle === 'admin', id],
|
||||||
)
|
)
|
||||||
await pool.query(
|
await pool.query(
|
||||||
`INSERT INTO messages (id, thread_id, from_role, author, text, at) VALUES ($1,$2,$3,$4,$5,$6)`,
|
`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 || '')
|
const status = String((req.body || {}).status || '')
|
||||||
if (!['offen', 'in Bearbeitung', 'erledigt'].includes(status))
|
if (!['offen', 'in Bearbeitung', 'erledigt'].includes(status))
|
||||||
return res.status(400).json({ fehler: 'Ungültiger 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(
|
const { rows } = await pool.query(
|
||||||
'UPDATE tickets SET status = $1 WHERE id = $2 RETURNING *', [status, req.params.id],
|
'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]) })
|
res.json({ ticket: mapTicket(rows[0]) })
|
||||||
} catch (e) { next(e) }
|
} catch (e) { next(e) }
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useEffect, useRef, useState } from 'react'
|
import { useEffect, useRef, useState } from 'react'
|
||||||
|
import { useSearchParams } from 'react-router'
|
||||||
import { MailPlus, SendHorizonal, UserRound } from 'lucide-react'
|
import { MailPlus, SendHorizonal, UserRound } from 'lucide-react'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Input } from '@/components/ui/input'
|
import { Input } from '@/components/ui/input'
|
||||||
@@ -68,6 +69,7 @@ function NewMessageDialog({ onCreated }: { onCreated: (id: string) => void }) {
|
|||||||
|
|
||||||
export default function Messages() {
|
export default function Messages() {
|
||||||
const { threads, role, units, meineEinheit, markThreadRead, addMessage } = useStore()
|
const { threads, role, units, meineEinheit, markThreadRead, addMessage } = useStore()
|
||||||
|
const [searchParams, setSearchParams] = useSearchParams()
|
||||||
const visible = role === 'tenant'
|
const visible = role === 'tenant'
|
||||||
? threads.filter((t) => t.unitId === meineEinheit.id)
|
? threads.filter((t) => t.unitId === meineEinheit.id)
|
||||||
: threads
|
: threads
|
||||||
@@ -76,6 +78,19 @@ export default function Messages() {
|
|||||||
const [draft, setDraft] = useState('')
|
const [draft, setDraft] = useState('')
|
||||||
const bottomRef = useRef<HTMLDivElement>(null)
|
const bottomRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
|
// Deep-Link aus anderen Modulen (z. B. Schadensmeldung): ?thread=<id>
|
||||||
|
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(() => {
|
useEffect(() => {
|
||||||
if (!active) return
|
if (!active) return
|
||||||
markThreadRead(active.id)
|
markThreadRead(active.id)
|
||||||
|
|||||||
+34
-2
@@ -1,5 +1,6 @@
|
|||||||
import { useMemo, useState } from 'react'
|
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 { Button } from '@/components/ui/button'
|
||||||
import { Badge } from '@/components/ui/badge'
|
import { Badge } from '@/components/ui/badge'
|
||||||
import {
|
import {
|
||||||
@@ -141,9 +142,11 @@ function NewTicketDialog() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function Tickets() {
|
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 [filter, setFilter] = useState<'alle' | TicketStatus>('alle')
|
||||||
|
|
||||||
|
const threadFuerTicket = (ticketId: string) => threads.find((th) => th.ticketId === ticketId)
|
||||||
|
|
||||||
const visible = useMemo(
|
const visible = useMemo(
|
||||||
() =>
|
() =>
|
||||||
(role === 'tenant'
|
(role === 'tenant'
|
||||||
@@ -197,6 +200,8 @@ export default function Tickets() {
|
|||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{visible.map((t) => {
|
{visible.map((t) => {
|
||||||
const unit = units.find((u) => u.id === t.unitId)
|
const unit = units.find((u) => u.id === t.unitId)
|
||||||
|
const thread = threadFuerTicket(t.id)
|
||||||
|
const letzte = thread?.messages[thread.messages.length - 1]
|
||||||
return (
|
return (
|
||||||
<div key={t.id} className="rounded-2xl border bg-white p-5 shadow-sm">
|
<div key={t.id} className="rounded-2xl border bg-white p-5 shadow-sm">
|
||||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||||
@@ -238,6 +243,33 @@ export default function Tickets() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{thread && (
|
||||||
|
<div className="mt-4 rounded-xl border border-teal-100 bg-teal-50/60 p-4">
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||||
|
<div className="flex items-center gap-2 text-sm font-semibold text-teal-800">
|
||||||
|
<MessagesSquare className="h-4 w-4" />
|
||||||
|
Kommunikation
|
||||||
|
<span className="text-xs font-normal text-teal-600">
|
||||||
|
({thread.messages.length} {thread.messages.length === 1 ? 'Nachricht' : 'Nachrichten'})
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<Link
|
||||||
|
to={`/nachrichten?thread=${thread.id}`}
|
||||||
|
className="text-xs font-medium text-teal-700 underline-offset-2 hover:underline"
|
||||||
|
>
|
||||||
|
Unterhaltung öffnen →
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
{letzte && (
|
||||||
|
<div className="mt-2 text-xs text-slate-600">
|
||||||
|
<span className="font-semibold">{letzte.author}:</span>{' '}
|
||||||
|
<span className="line-clamp-2">{letzte.text}</span>
|
||||||
|
<span className="mt-0.5 block text-slate-400">{letzte.at}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -87,6 +87,8 @@ export interface Thread {
|
|||||||
subject: string
|
subject: string
|
||||||
unreadByTenant: boolean
|
unreadByTenant: boolean
|
||||||
unreadByAdmin: boolean
|
unreadByAdmin: boolean
|
||||||
|
/** Verknüpfte Schadensmeldung (v1.19), null bei normalen Unterhaltungen */
|
||||||
|
ticketId?: string | null
|
||||||
messages: Message[]
|
messages: Message[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user