feat: Integrationen sichtbar und bedienbar machen

Eine Seite zeigt je Integration, ob sie vorgesehen, eingerichtet oder
gestoert ist, wann sie zuletzt geglueckt ist und woran sie klemmt. Der
Wartungsplaner steht sichtbar als offene Luecke darin, nicht als
Leerstelle.

Telegram-Chats werden hier gekoppelt und wieder getrennt, Meldungen je
Chat einzeln geschaltet. Aufgaben bekommen einen Schalter zum Spiegeln —
einzeln und je Aufgabe, denn wer versehentlich einen ganzen Bereich
spiegelt, schickt Kundennamen nach Hause.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Florian Wilde
2026-09-11 23:04:33 +02:00
co-authored by Claude Opus 5
parent bc60e5f864
commit 1281c3ea96
8 changed files with 624 additions and 3 deletions
@@ -34,6 +34,7 @@ export type EinstellungAnzeige = {
anlaesse: Anlass[]
perPush: boolean
perMail: boolean
perTelegram: boolean
ueberblickUm: string
ruheVon: string
ruheBis: string
@@ -46,6 +47,9 @@ type Eigenschaften = {
geraete: GeraetAnzeige[]
pushMoeglich: boolean
mailMoeglich: boolean
telegramMoeglich: boolean
/** Wie viele Chats Meldungen empfangen. Null heisst: noch keiner. */
telegramChats: number
}
/**
@@ -68,6 +72,8 @@ export function Benachrichtigungen({
geraete,
pushMoeglich,
mailMoeglich,
telegramMoeglich,
telegramChats,
}: Eigenschaften) {
const router = useRouter()
const [laeuft, starte] = useTransition()
@@ -311,6 +317,35 @@ export function Benachrichtigungen({
)}
</span>
</label>
<label className="flex items-center gap-3">
<input
type="checkbox"
name="perTelegram"
defaultChecked={einstellung.perTelegram}
className="accent-betont size-4"
/>
<span className="text-sm">
Telegram
{!telegramMoeglich ? (
<span className="text-gedaempft-vordergrund">
{' '}
— auf diesem Server nicht eingerichtet
</span>
) : telegramChats === 0 ? (
<span className="text-gedaempft-vordergrund">
{' '}
— noch kein Chat eingetragen
</span>
) : (
<span className="text-gedaempft-vordergrund">
{' '}
— an {telegramChats} Chat{telegramChats === 1 ? '' : 's'}, mit Knopf zum
Abhaken
</span>
)}
</span>
</label>
</fieldset>
<div className="grid gap-4 sm:grid-cols-2">
+6 -1
View File
@@ -23,13 +23,14 @@ export default async function EinstellungenSeite() {
const einst = umgebung()
const [benachrichtigung, pushGeraete] = await Promise.all([
const [benachrichtigung, telegramChats, pushGeraete] = await Promise.all([
db.benachrichtigungseinstellung.findUnique({
where: { benutzerId: benutzer.id },
select: {
anlaesse: true,
perPush: true,
perMail: true,
perTelegram: true,
ueberblickUm: true,
ruheVon: true,
ruheBis: true,
@@ -37,6 +38,7 @@ export default async function EinstellungenSeite() {
zeitblockVorlaufMinuten: true,
},
}),
db.telegramChat.count({ where: { benutzerId: benutzer.id, meldungen: true } }),
db.pushGeraet.findMany({
where: { benutzerId: benutzer.id },
orderBy: { erstelltAm: 'asc' },
@@ -119,10 +121,13 @@ export default async function EinstellungenSeite() {
<Benachrichtigungen
pushMoeglich={einst.pushAktiv}
mailMoeglich={einst.mailAktiv}
telegramMoeglich={einst.telegramAktiv}
telegramChats={telegramChats}
einstellung={{
anlaesse: [...einstellung.anlaesse],
perPush: einstellung.perPush,
perMail: einstellung.perMail,
perTelegram: einstellung.perTelegram,
ueberblickUm: alsUhrzeit(einstellung.ueberblickUm),
ruheVon: alsUhrzeit(einstellung.ruheVon),
ruheBis: alsUhrzeit(einstellung.ruheBis),
+180
View File
@@ -0,0 +1,180 @@
import type { Metadata } from 'next'
import { Plug } from 'lucide-react'
import { TelegramChats } from '@/app/(app)/integrationen/telegram-chats'
import {
Card,
CardBeschreibung,
CardInhalt,
CardKopf,
CardTitel,
} from '@/components/ui/card'
import { Kennzeichen } from '@/components/ui/kennzeichen'
import type { Quelle } from '@/generated/prisma/enums'
import { INTEGRATION_FEHLER_AB_VERSUCH } from '@/lib/integration'
import { alsDatumUhrzeit } from '@/lib/zeit'
import { verlangeAnmeldung } from '@/server/berechtigung'
import { db } from '@/server/db'
import { umgebung } from '@/server/umgebung'
export const metadata: Metadata = { title: 'Integrationen' }
type Zeile = {
quelle: Quelle
name: string
beschreibung: string
/** In dieser Instanz überhaupt vorgesehen? */
vorgesehen: boolean
/** Und auch eingerichtet? */
aktiv: boolean
hinweis: string
}
export default async function IntegrationenSeite() {
await verlangeAnmeldung('/integrationen')
const einst = umgebung()
const [zustaende, chats] = await Promise.all([
db.integrationszustand.findMany(),
db.telegramChat.findMany({
orderBy: { erstelltAm: 'asc' },
select: {
id: true,
chatId: true,
bezeichnung: true,
meldungen: true,
erstelltAm: true,
zuletztBenutzt: true,
},
}),
])
const nachQuelle = new Map(zustaende.map((z) => [z.quelle, z]))
const zeilen: Zeile[] = [
{
quelle: 'ZAMMAD',
name: 'Zammad',
beschreibung:
'Tickets, auf die der Suchausdruck passt, werden zu Aufgaben. Geschrieben wird nach Zammad nichts — ein Ticket schließt man im Ticketsystem.',
vorgesehen: einst.instanz === 'firma',
aktiv: einst.zammadAktiv,
hinweis: 'ZAMMAD_URL und ZAMMAD_TOKEN fehlen.',
},
{
quelle: 'WARTUNGSPLANER',
name: 'Wartungsplaner',
beschreibung:
'Noch nicht angebunden: Die Schnittstelle ist offen. Die Aufnahmestelle steht bereit, es fehlt allein das Abholen.',
vorgesehen: einst.instanz === 'firma',
aktiv: false,
hinweis: 'Kommt, sobald die Schnittstelle feststeht.',
},
{
quelle: 'TELEGRAM',
name: 'Telegram',
beschreibung:
'Eine Nachricht an den Bot wird zur Aufgabe. Meldungen gehen denselben Weg zurück — mit einem Knopf zum Abhaken.',
vorgesehen: einst.instanz === 'daheim',
aktiv: einst.telegramAktiv,
hinweis: 'TELEGRAM_TOKEN fehlt.',
},
{
quelle: 'SPIEGEL',
name: 'Spiegel',
beschreibung:
einst.instanz === 'firma'
? 'Schickt Belegungen ohne Titel nach Hause, dazu die Aufgaben, die ausdrücklich zum Spiegeln markiert sind.'
: 'Empfängt dienstliche Belegungen. Sie erscheinen in der Wochenansicht als „Dienstlich belegt", ohne Betreff.',
vorgesehen: true,
aktiv: einst.spiegelSendenAktiv || einst.spiegelEmpfangenAktiv,
hinweis: 'SPIEGEL_GEHEIMNIS fehlt.',
},
]
return (
<div className="flex flex-col gap-6">
<div className="flex items-center gap-3">
<Plug className="text-gedaempft-vordergrund size-6" aria-hidden />
<div>
<h1 className="text-2xl font-semibold tracking-tight">Integrationen</h1>
<p className="text-gedaempft-vordergrund text-sm">
Was von außen hereinkommt — und was diese Instanz hinausgibt.
</p>
</div>
</div>
<div className="flex flex-col gap-4">
{zeilen.map((zeile) => {
const zustand = nachQuelle.get(zeile.quelle)
const gestoert = (zustand?.fehlversuche ?? 0) >= INTEGRATION_FEHLER_AB_VERSUCH
return (
<Card key={zeile.quelle} className={zeile.vorgesehen ? '' : 'opacity-60'}>
<CardKopf>
<div className="flex flex-wrap items-center gap-2">
<CardTitel className="text-base">{zeile.name}</CardTitel>
{!zeile.vorgesehen ? (
<Kennzeichen art="neutral">
In dieser Instanz nicht vorgesehen
</Kennzeichen>
) : gestoert ? (
<Kennzeichen art="gefahr">Gestört</Kennzeichen>
) : zeile.aktiv ? (
<Kennzeichen art="erfolg">Läuft</Kennzeichen>
) : (
<Kennzeichen art="warnung">Nicht eingerichtet</Kennzeichen>
)}
</div>
<CardBeschreibung>{zeile.beschreibung}</CardBeschreibung>
</CardKopf>
<CardInhalt className="text-gedaempft-vordergrund flex flex-col gap-1 text-sm">
{zeile.vorgesehen && !zeile.aktiv && <p>{zeile.hinweis}</p>}
{zustand?.letzterErfolg && (
<p>Zuletzt geglückt: {alsDatumUhrzeit(zustand.letzterErfolg)}</p>
)}
{zustand?.letzterFehler && (
<p className="text-gefahr">
Letzter Fehler: {zustand.letzterFehler}
{zustand.fehlversuche > 1 && ` (${zustand.fehlversuche}× in Folge)`}
</p>
)}
{!zustand && zeile.aktiv && <p>Noch kein Lauf.</p>}
</CardInhalt>
</Card>
)
})}
</div>
{einst.telegramAktiv && (
<Card>
<CardKopf>
<CardTitel className="text-base">Telegram-Chats</CardTitel>
<CardBeschreibung>
Nur wer hier steht, darf mit dem Bot reden — und nur diese Chats bekommen
Meldungen. Alles andere wird verworfen, ohne zu antworten. Die Kennung
deines Chats verrät dir der Bot, wenn du ihm{' '}
<code className="text-xs">/start</code> schickst und danach hier nachsiehst;
steht sie noch nicht drin, findest du sie im Protokoll.
</CardBeschreibung>
</CardKopf>
<CardInhalt>
<TelegramChats
chats={chats.map((c) => ({
id: c.id,
chatId: c.chatId.toString(),
bezeichnung: c.bezeichnung,
meldungen: c.meldungen,
erstelltAm: alsDatumUhrzeit(c.erstelltAm),
zuletztBenutzt: c.zuletztBenutzt
? alsDatumUhrzeit(c.zuletztBenutzt)
: null,
}))}
/>
</CardInhalt>
</Card>
)}
</div>
)
}
@@ -0,0 +1,139 @@
'use client'
import { useState, useTransition } from 'react'
import { useRouter } from 'next/navigation'
import { Bell, BellOff, Link2, Loader2, Trash2 } from 'lucide-react'
import { toast } from 'sonner'
import { Button } from '@/components/ui/button'
import {
chatEntfernen,
chatMeldungenUmschalten,
kopplungAnfordern,
} from '@/server/aktionen/integrationen'
export type ChatAnzeige = {
id: string
chatId: string
bezeichnung: string | null
meldungen: boolean
erstelltAm: string
zuletztBenutzt: string | null
}
export function TelegramChats({ chats }: { chats: ChatAnzeige[] }) {
const router = useRouter()
const [laeuft, starte] = useTransition()
const [code, setCode] = useState<{ code: string; minuten: number } | null>(null)
function anfordern() {
starte(async () => {
const antwort = await kopplungAnfordern()
if (antwort.ok) {
setCode({ code: antwort.code, minuten: antwort.minuten })
} else {
toast.error(antwort.fehler)
}
})
}
function mitRueckmeldung(
tun: () => Promise<{ ok: boolean; meldung?: string; fehler?: string }>,
) {
starte(async () => {
const antwort = await tun()
if (antwort.ok) {
toast.success(antwort.meldung ?? 'Erledigt.')
router.refresh()
} else {
toast.error(antwort.fehler ?? 'Das hat nicht geklappt.')
}
})
}
return (
<div className="flex flex-col gap-4">
{chats.length === 0 ? (
<p className="text-gedaempft-vordergrund text-sm">
Noch kein Chat verbunden. Ohne Verbindung nimmt der Bot nichts an.
</p>
) : (
<ul className="divide-rand divide-y">
{chats.map((chat) => (
<li key={chat.id} className="flex items-center gap-3 py-2">
<div className="min-w-0 flex-1">
<p className="text-sm">{chat.bezeichnung ?? `Chat ${chat.chatId}`}</p>
<p className="text-gedaempft-vordergrund text-xs">
Verbunden {chat.erstelltAm}
{chat.zuletztBenutzt
? ` · zuletzt benutzt ${chat.zuletztBenutzt}`
: ' · noch nichts geschickt'}
</p>
</div>
<Button
art="leise"
groesse="quadrat"
title={
chat.meldungen ? 'Keine Meldungen mehr hierher' : 'Meldungen hierher'
}
disabled={laeuft}
onClick={() => {
const daten = new FormData()
daten.set('id', chat.id)
mitRueckmeldung(() => chatMeldungenUmschalten(daten))
}}
>
{chat.meldungen ? (
<Bell className="text-erfolg" aria-hidden />
) : (
<BellOff aria-hidden />
)}
<span className="sr-only">Meldungen umschalten</span>
</Button>
<Button
art="leise"
groesse="quadrat"
title="Chat trennen"
disabled={laeuft}
onClick={() => {
const daten = new FormData()
daten.set('id', chat.id)
mitRueckmeldung(() => chatEntfernen(daten))
}}
>
<Trash2 aria-hidden />
<span className="sr-only">Trennen</span>
</Button>
</li>
))}
</ul>
)}
{code ? (
<div className="border-rand rounded-md border p-3">
<p className="text-sm">
Schick dem Bot jetzt diesen Code — als Nachricht oder als{' '}
<code className="text-xs">/start {code.code}</code>:
</p>
<p className="my-2 font-mono text-2xl tracking-widest">{code.code}</p>
<p className="text-gedaempft-vordergrund text-xs">
Gültig für {code.minuten} Minuten und nur ein einziges Mal. Danach erscheint
der Chat hier — spätestens nach dem nächsten Abholen.
</p>
</div>
) : (
<div>
<Button art="umriss" disabled={laeuft} onClick={anfordern}>
{laeuft ? (
<Loader2 className="animate-spin" aria-hidden />
) : (
<Link2 aria-hidden />
)}
Chat verbinden
</Button>
</div>
)}
</div>
)
}