v1.33: Eintraege in allen Listen loeschbar

This commit is contained in:
2026-10-04 19:13:36 +02:00
parent 20b5dab556
commit 2f873737c2
8 changed files with 325 additions and 10 deletions
+17
View File
@@ -189,6 +189,23 @@ tar -czf mp-dist.tar.gz -C dist .
## 8. Verlauf / offene Punkte ## 8. Verlauf / offene Punkte
**Erledigt 04.10. (v1.33):** Einträge in allen Listen löschbar. Neue
adminOnly-DELETE-Endpunkte in server/src/index.js: /api/tickets/:id
(kaskadiert threads + messages), /api/meters/:id (kaskadiert meter_readings),
/api/belege/:id, /api/buildings/:name und /api/units/:id – letztere zwei
verweigern mit 400 + deutscher Blocker-Liste (Haus: Wohnungen/Zähler/Kosten;
Wohnung: Zähler/Dokumente/Kosten/Mieter), /api/users/:id (eigenes Konto und
letzten aktiven Verwalter geschützt, sessions vorher gelöscht). Store:
deleteTicket, deleteZaehler, deleteHaus, deleteWohnung, deleteBeleg,
loescheBenutzer (apiDelete + lokaler State-Filter, threads bei Ticket-Delete
mitgefiltert). Frontend: rote Trash2-Löschbuttons mit window.confirm +
alert(err) in Bestand (Haus Karte+Liste, Wohnungszeile), Zaehler (Karte+Liste),
Kosten BelegeListe, Tickets (admin, warnt vor Mitlöschung der Kommunikation),
BenutzerVerwaltung (eigenes Konto deaktiviert); Buttons nur bei role=admin,
in Mieter-sichtbaren Seiten (Zaehler, Tickets) zusätzlich gegated. E2E: 400-
Blocker beim Haus mit Wohnung/Zähler, Kaskaden-Löschung bis Haus weg,
Ticket anlegen+löschen, fremden Testbenutzer löschen, eigenes Konto → 400.
**Erledigt 04.10. (v1.32):** Deutsche Datumsformate in allen Modulen. **Erledigt 04.10. (v1.32):** Deutsche Datumsformate in allen Modulen.
NEU src/lib/format.ts: fmtDatum (ISO YYYY-MM-DD → TT.MM.JJJJ, deutsche Werte NEU src/lib/format.ts: fmtDatum (ISO YYYY-MM-DD → TT.MM.JJJJ, deutsche Werte
durchreichen) + fmtDatumZeit (mit Uhrzeit) – eingesetzt in Zaehler (Karte, durchreichen) + fmtDatumZeit (mit Uhrzeit) – eingesetzt in Zaehler (Karte,
+97
View File
@@ -553,6 +553,19 @@ app.patch('/api/tickets/:id', auth, adminOnly, async (req, res, next) => {
} catch (e) { next(e) } } catch (e) { next(e) }
}) })
// Schadensmeldung löschen inkl. verknüpfter Unterhaltung und Nachrichten (v1.33)
app.delete('/api/tickets/:id', auth, adminOnly, async (req, res, next) => {
try {
const { rows } = await pool.query('DELETE FROM tickets WHERE id = $1 RETURNING id', [req.params.id])
if (!rows.length) return res.status(404).json({ fehler: 'Schadensmeldung nicht gefunden.' })
const { rows: threads } = await pool.query('DELETE FROM threads WHERE ticket_id = $1 RETURNING id', [req.params.id])
for (const th of threads) {
await pool.query('DELETE FROM messages WHERE thread_id = $1', [th.id])
}
res.json({ ok: true })
} catch (e) { next(e) }
})
// ── Zählerstände (Strom / Wasser / Gas) ───────────────────── // ── Zählerstände (Strom / Wasser / Gas) ─────────────────────
const ZAEHLER_ARTEN = ['Strom', 'Wasser', 'Gas', 'Wärme', 'Sonstiges'] const ZAEHLER_ARTEN = ['Strom', 'Wasser', 'Gas', 'Wärme', 'Sonstiges']
// Gängige Heizungsformen (v1.29) – Dropdown in Haus-/Wohnungsformular // Gängige Heizungsformen (v1.29) – Dropdown in Haus-/Wohnungsformular
@@ -707,6 +720,16 @@ app.delete('/api/meters/:id/readings/:rid', auth, adminOnly, async (req, res, ne
res.json({ ok: true }) res.json({ ok: true })
} catch (e) { next(e) } } catch (e) { next(e) }
}) })
// Zähler komplett löschen inkl. Verlauf (v1.33)
app.delete('/api/meters/:id', auth, adminOnly, async (req, res, next) => {
try {
const { rows } = await pool.query('DELETE FROM meters WHERE id = $1 RETURNING id', [req.params.id])
if (!rows.length) return res.status(404).json({ fehler: 'Zähler nicht gefunden.' })
await pool.query('DELETE FROM meter_readings WHERE meter_id = $1', [req.params.id])
res.json({ ok: true })
} catch (e) { next(e) }
})
// ── Dokumente (Verwaltung) ─────────────────────────────────── // ── Dokumente (Verwaltung) ───────────────────────────────────
const DOKUMENT_KATEGORIEN = ['Mietvertrag', 'Nebenkostenabrechnung', 'Hausordnung', 'Protokoll', 'Sonstiges'] const DOKUMENT_KATEGORIEN = ['Mietvertrag', 'Nebenkostenabrechnung', 'Hausordnung', 'Protokoll', 'Sonstiges']
@@ -1135,6 +1158,15 @@ app.post('/api/belege/:id/analysieren', auth, adminOnly, async (req, res, next)
} catch (e) { next(e) } } catch (e) { next(e) }
}) })
// Beleg löschen (v1.33)
app.delete('/api/belege/:id', auth, adminOnly, async (req, res, next) => {
try {
const { rows } = await pool.query('DELETE FROM belege WHERE id = $1 RETURNING id', [req.params.id])
if (!rows.length) return res.status(404).json({ fehler: 'Beleg nicht gefunden.' })
res.json({ ok: true })
} catch (e) { next(e) }
})
// ── Bestand: Häuser & Wohnungen (Verwaltung) ──────────────── // ── Bestand: Häuser & Wohnungen (Verwaltung) ────────────────
/** Name/Anzeigeadresse werden aus den Einzelfeldern zusammengesetzt (v1.26). */ /** Name/Anzeigeadresse werden aus den Einzelfeldern zusammengesetzt (v1.26). */
function hausNameAus(b) { function hausNameAus(b) {
@@ -1237,6 +1269,28 @@ app.patch('/api/buildings/:name', auth, adminOnly, async (req, res, next) => {
} catch (e) { next(e) } } catch (e) { next(e) }
}) })
// Haus löschen – nur wenn keine Wohnungen, Zähler oder Kosten mehr darauf verweisen (v1.33)
app.delete('/api/buildings/:name', auth, adminOnly, async (req, res, next) => {
try {
const { rows } = await pool.query('SELECT 1 FROM buildings WHERE name = $1', [req.params.name])
if (!rows.length) return res.status(404).json({ fehler: 'Haus nicht gefunden.' })
const blocker = []
for (const [tabelle, spalte, label] of [
['units', 'building', 'Wohnungen'],
['meters', 'building', 'Zähler'],
['costs', 'building', 'Kostenpositionen'],
]) {
const { rows: r } = await pool.query(`SELECT COUNT(*)::int AS n FROM ${tabelle} WHERE ${spalte} = $1`, [req.params.name])
if (r[0].n > 0) blocker.push(`${r[0].n} ${label}`)
}
if (blocker.length) {
return res.status(400).json({ fehler: `Haus kann nicht gelöscht werden – bitte zuerst entfernen: ${blocker.join(', ')}.` })
}
await pool.query('DELETE FROM buildings WHERE name = $1', [req.params.name])
res.json({ ok: true })
} catch (e) { next(e) }
})
app.post('/api/units', auth, adminOnly, async (req, res, next) => { app.post('/api/units', auth, adminOnly, async (req, res, next) => {
try { try {
const u = req.body || {} const u = req.body || {}
@@ -1297,6 +1351,29 @@ app.patch('/api/units/:id', auth, adminOnly, async (req, res, next) => {
} catch (e) { next(e) } } catch (e) { next(e) }
}) })
// Wohnung löschen – nur wenn keine Zähler, Dokumente, Kosten oder Mieter sie referenzieren (v1.33)
app.delete('/api/units/:id', auth, adminOnly, async (req, res, next) => {
try {
const { rows } = await pool.query('SELECT 1 FROM units WHERE id = $1', [req.params.id])
if (!rows.length) return res.status(404).json({ fehler: 'Wohnung nicht gefunden.' })
const blocker = []
for (const [tabelle, spalte, label] of [
['meters', 'unit_id', 'Zähler'],
['documents', 'unit_id', 'Dokumente'],
['costs', 'unit_id', 'Kostenpositionen'],
['users', 'einheit_id', 'zugeordnete Benutzer'],
]) {
const { rows: r } = await pool.query(`SELECT COUNT(*)::int AS n FROM ${tabelle} WHERE ${spalte} = $1`, [req.params.id])
if (r[0].n > 0) blocker.push(`${r[0].n} ${label}`)
}
if (blocker.length) {
return res.status(400).json({ fehler: `Wohnung kann nicht gelöscht werden – bitte zuerst entfernen/zuordnen: ${blocker.join(', ')}.` })
}
await pool.query('DELETE FROM units WHERE id = $1', [req.params.id])
res.json({ ok: true })
} catch (e) { next(e) }
})
// ── Nachrichten (Threads) ──────────────────────────────────── // ── Nachrichten (Threads) ────────────────────────────────────
app.post('/api/threads', auth, async (req, res, next) => { app.post('/api/threads', auth, async (req, res, next) => {
try { try {
@@ -1458,6 +1535,26 @@ app.patch('/api/users/:id', auth, adminOnly, async (req, res, next) => {
} catch (e) { next(e) } } catch (e) { next(e) }
}) })
// Benutzer löschen: eigenes Konto und den letzten aktiven Verwalter schützen (v1.33)
app.delete('/api/users/:id', auth, adminOnly, async (req, res, next) => {
try {
if (req.params.id === req.user.id)
return res.status(400).json({ fehler: 'Das eigene Konto kann nicht gelöscht werden.' })
const { rows } = await pool.query('SELECT * FROM users WHERE id = $1', [req.params.id])
if (!rows.length) return res.status(404).json({ fehler: 'Benutzer nicht gefunden.' })
if (rows[0].rolle === 'verwaltung' && rows[0].aktiv) {
const { rows: rest } = await pool.query(
`SELECT COUNT(*)::int AS n FROM users WHERE rolle = 'verwaltung' AND aktiv AND id <> $1`, [req.params.id],
)
if (rest[0].n === 0)
return res.status(400).json({ fehler: 'Der letzte aktive Verwaltungsbenutzer kann nicht gelöscht werden.' })
}
await pool.query('DELETE FROM sessions WHERE user_id = $1', [req.params.id])
await pool.query('DELETE FROM users WHERE id = $1', [req.params.id])
res.json({ ok: true })
} catch (e) { next(e) }
})
// Direkte E-Mail der Verwaltung an einen Mieter (immer mit CC an die // Direkte E-Mail der Verwaltung an einen Mieter (immer mit CC an die
// Verwaltung, damit die Kommunikation dort vollständig nachvollziehbar ist) // Verwaltung, damit die Kommunikation dort vollständig nachvollziehbar ist)
app.post('/api/users/:id/email', auth, adminOnly, async (req, res, next) => { app.post('/api/users/:id/email', auth, adminOnly, async (req, res, next) => {
+16 -1
View File
@@ -229,7 +229,7 @@ function EmailDialog({ benutzer: b, einheitLabel }: { benutzer: Benutzer; einhei
} }
export default function BenutzerVerwaltung() { export default function BenutzerVerwaltung() {
const { benutzer, codes, units, setAktiv, setPasswort, weiseEinheitZu, setzeBenutzerEmail, generiereCodes } = useStore() const { benutzer, codes, units, setAktiv, setPasswort, weiseEinheitZu, setzeBenutzerEmail, generiereCodes, loescheBenutzer, me } = useStore()
const [suche, setSuche] = useState('') const [suche, setSuche] = useState('')
const [gruppe, setGruppe] = useState<'alle' | BenutzerRolle>('alle') const [gruppe, setGruppe] = useState<'alle' | BenutzerRolle>('alle')
const [anzahl, setAnzahl] = useState(5) const [anzahl, setAnzahl] = useState(5)
@@ -256,6 +256,12 @@ export default function BenutzerVerwaltung() {
await setPasswort(id, '0815') await setPasswort(id, '0815')
} }
const benutzerLoeschen = async (id: string, name: string) => {
if (!window.confirm(`Benutzer „${name}" wirklich löschen? Sitzungen werden entwertet, die Aktion kann nicht rückgängig gemacht werden.`)) return
const fehler = await loescheBenutzer(id)
if (fehler) window.alert(fehler)
}
const kopieren = (text: string) => { const kopieren = (text: string) => {
navigator.clipboard?.writeText(text).catch(() => {}) navigator.clipboard?.writeText(text).catch(() => {})
setKopiert(text) setKopiert(text)
@@ -474,6 +480,15 @@ export default function BenutzerVerwaltung() {
> >
{b.aktiv ? 'Deaktivieren' : 'Aktivieren'} {b.aktiv ? 'Deaktivieren' : 'Aktivieren'}
</button> </button>
<button
onClick={() => benutzerLoeschen(b.id, b.name)}
disabled={me?.id === b.id}
title={me?.id === b.id ? 'Das eigene Konto kann nicht gelöscht werden' : 'Benutzer löschen'}
aria-label={`Benutzer ${b.name} löschen`}
className="rounded-lg bg-slate-100 px-2.5 py-1.5 text-xs font-medium text-slate-500 transition hover:bg-red-100 hover:text-red-600 disabled:cursor-not-allowed disabled:opacity-40 dark:bg-slate-800 dark:text-slate-400 dark:hover:bg-red-950 dark:hover:text-red-300"
>
Löschen
</button>
</div> </div>
</div> </div>
) )
+56 -4
View File
@@ -1,7 +1,7 @@
import { Fragment, useMemo, useState } from 'react' import { Fragment, useMemo, useState } from 'react'
import { import {
Building2, Home, MapPin, CalendarDays, ArrowUpDown, LayoutGrid, Ruler, Pencil, Building2, Home, MapPin, CalendarDays, ArrowUpDown, LayoutGrid, Ruler, Pencil,
Plus, Users, DoorOpen, Search, List, ChevronDown, ChevronRight, Flame, Plus, Users, DoorOpen, Search, List, ChevronDown, ChevronRight, Flame, Trash2,
} from 'lucide-react' } 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'
@@ -321,6 +321,12 @@ function sortiereWohnungen(liste: Unit[]): Unit[] {
/** Wohnungszeile, in Karten- und Listenansicht gleich. */ /** Wohnungszeile, in Karten- und Listenansicht gleich. */
function WohnungsZeile({ u, gebaeude, festesHaus }: { u: Unit; gebaeude: string[]; festesHaus: string }) { function WohnungsZeile({ u, gebaeude, festesHaus }: { u: Unit; gebaeude: string[]; festesHaus: string }) {
const { deleteWohnung, role } = useStore()
const loesche = async () => {
if (!window.confirm(`Wohnung ${u.building}, Whg. ${u.number} wirklich löschen?`)) return
const fehler = await deleteWohnung(u.id)
if (fehler) window.alert(fehler)
}
return ( return (
<li className="rounded-xl border bg-slate-50/60 px-3 py-2 dark:border-slate-700 dark:bg-slate-800/60"> <li className="rounded-xl border bg-slate-50/60 px-3 py-2 dark:border-slate-700 dark:bg-slate-800/60">
<div className="flex items-center justify-between gap-2"> <div className="flex items-center justify-between gap-2">
@@ -344,6 +350,17 @@ function WohnungsZeile({ u, gebaeude, festesHaus }: { u: Unit; gebaeude: string[
<span className="flex shrink-0 items-center gap-2"> <span className="flex shrink-0 items-center gap-2">
<span className="font-medium text-slate-700 dark:text-slate-200">{fmtEUR.format(u.warmRent)}</span> <span className="font-medium text-slate-700 dark:text-slate-200">{fmtEUR.format(u.warmRent)}</span>
<WohnungDialog wohnung={u} gebaeude={gebaeude} festesHaus={festesHaus} /> <WohnungDialog wohnung={u} gebaeude={gebaeude} festesHaus={festesHaus} />
{role === 'admin' && (
<button
type="button"
className="text-slate-400 transition-colors hover:text-red-600 dark:text-slate-500 dark:hover:text-red-400"
onClick={loesche}
aria-label={`Wohnung ${u.number} löschen`}
title="Wohnung löschen"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
)}
</span> </span>
</div> </div>
</li> </li>
@@ -378,11 +395,18 @@ function BelegungsFortschritt({ angelegt, soll }: { angelegt: number; soll: numb
} }
export default function Bestand() { export default function Bestand() {
const { buildings, units } = useStore() const { buildings, units, deleteHaus, role } = useStore()
const [suche, setSuche] = useState('') const [suche, setSuche] = useState('')
const [ansicht, setAnsicht] = useState<'karten' | 'liste'>('liste') const [ansicht, setAnsicht] = useState<'karten' | 'liste'>('liste')
const [offen, setOffen] = useState<string | null>(null) const [offen, setOffen] = useState<string | null>(null)
const loescheHaus = async (name: string) => {
if (!window.confirm(`Haus „${name}" wirklich löschen?`)) return
const fehler = await deleteHaus(name)
if (fehler) window.alert(fehler)
if (offen === name) setOffen(null)
}
const gebaeudeNamen = useMemo( const gebaeudeNamen = useMemo(
() => buildings.map((b) => b.name).sort(), () => buildings.map((b) => b.name).sort(),
[buildings], [buildings],
@@ -509,7 +533,21 @@ export default function Bestand() {
</div> </div>
</div> </div>
</div> </div>
<HausDialog haus={h} /> <span className="flex shrink-0 items-center gap-1">
<HausDialog haus={h} />
{role === 'admin' && (
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-slate-400 hover:text-red-600 dark:text-slate-500 dark:hover:text-red-400"
onClick={() => loescheHaus(h.name)}
aria-label={`Haus ${h.adresse} löschen`}
title="Haus löschen"
>
<Trash2 className="h-4 w-4" />
</Button>
)}
</span>
</div> </div>
<dl className="mt-4 grid grid-cols-2 gap-x-4 gap-y-2 text-xs"> <dl className="mt-4 grid grid-cols-2 gap-x-4 gap-y-2 text-xs">
<div className="flex items-center gap-1.5 text-slate-500 dark:text-slate-400"> <div className="flex items-center gap-1.5 text-slate-500 dark:text-slate-400">
@@ -618,7 +656,21 @@ export default function Bestand() {
</span> </span>
</td> </td>
<td className="px-3 py-2.5 text-right" onClick={(e) => e.stopPropagation()}> <td className="px-3 py-2.5 text-right" onClick={(e) => e.stopPropagation()}>
<HausDialog haus={h} /> <span className="inline-flex items-center gap-1">
<HausDialog haus={h} />
{role === 'admin' && (
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-slate-400 hover:text-red-600 dark:text-slate-500 dark:hover:text-red-400"
onClick={() => loescheHaus(h.name)}
aria-label={`Haus ${h.adresse} löschen`}
title="Haus löschen"
>
<Trash2 className="h-4 w-4" />
</Button>
)}
</span>
</td> </td>
</tr> </tr>
{aufgeklappt && ( {aufgeklappt && (
+16 -1
View File
@@ -316,10 +316,16 @@ function AbrechnungDialog() {
/** Eingegangene SparkReceipt-Belege (nur Verwaltung). */ /** Eingegangene SparkReceipt-Belege (nur Verwaltung). */
function BelegeListe() { function BelegeListe() {
const { belege, setBelegZugeordnet, analysiereBeleg, units } = useStore() const { belege, setBelegZugeordnet, analysiereBeleg, deleteBeleg, units } = useStore()
const [nurOffene, setNurOffene] = useState(false) const [nurOffene, setNurOffene] = useState(false)
const [analysiere, setAnalysiere] = useState<string | null>(null) const [analysiere, setAnalysiere] = useState<string | null>(null)
const loesche = async (b: Beleg) => {
if (!window.confirm(`Beleg „${b.haendler || b.externeId}" wirklich löschen?`)) return
const fehler = await deleteBeleg(b.id)
if (fehler) window.alert(fehler)
}
const zeilen = useMemo( const zeilen = useMemo(
() => (nurOffene ? belege.filter((b) => !b.zugeordnet) : belege), () => (nurOffene ? belege.filter((b) => !b.zugeordnet) : belege),
[belege, nurOffene], [belege, nurOffene],
@@ -477,6 +483,15 @@ function BelegeListe() {
<ExternalLink className="h-3.5 w-3.5" /> Beleg <ExternalLink className="h-3.5 w-3.5" /> Beleg
</a> </a>
)} )}
<button
type="button"
onClick={() => loesche(b)}
title="Beleg löschen"
aria-label={`Beleg ${b.haendler || b.externeId} löschen`}
className="text-slate-400 transition-colors hover:text-red-600 dark:text-slate-500 dark:hover:text-red-400"
>
<Trash2 className="h-4 w-4" />
</button>
</div> </div>
</TableCell> </TableCell>
</TableRow> </TableRow>
+19 -2
View File
@@ -1,6 +1,6 @@
import { useMemo, useState } from 'react' import { useMemo, useState } from 'react'
import { Link } from 'react-router' import { Link } from 'react-router'
import { Plus, Wrench, CalendarDays, MapPin, MessagesSquare } from 'lucide-react' import { Plus, Wrench, CalendarDays, MapPin, MessagesSquare, Trash2 } 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 {
@@ -143,9 +143,15 @@ function NewTicketDialog() {
} }
export default function Tickets() { export default function Tickets() {
const { tickets, units, role, setTicketStatus, meineEinheit, threads } = useStore() const { tickets, units, role, setTicketStatus, meineEinheit, threads, deleteTicket } = useStore()
const [filter, setFilter] = useState<'alle' | TicketStatus>('alle') const [filter, setFilter] = useState<'alle' | TicketStatus>('alle')
const loescheTicket = async (id: string, titel: string) => {
if (!window.confirm(`Schadensmeldung „${titel}" wirklich löschen? Die zugehörige Kommunikation wird mitgelöscht.`)) return
const fehler = await deleteTicket(id)
if (fehler) window.alert(fehler)
}
const threadFuerTicket = (ticketId: string) => threads.find((th) => th.ticketId === ticketId) const threadFuerTicket = (ticketId: string) => threads.find((th) => th.ticketId === ticketId)
const visible = useMemo( const visible = useMemo(
@@ -242,6 +248,17 @@ export default function Tickets() {
) : ( ) : (
<Badge className={statusStyle[t.status]} variant="secondary">{t.status}</Badge> <Badge className={statusStyle[t.status]} variant="secondary">{t.status}</Badge>
)} )}
{role === 'admin' && (
<button
type="button"
onClick={() => loescheTicket(t.id, t.title)}
title="Schadensmeldung löschen"
aria-label={`Schadensmeldung ${t.title} löschen`}
className="text-slate-400 transition-colors hover:text-red-600 dark:text-slate-500 dark:hover:text-red-400"
>
<Trash2 className="h-4 w-4" />
</button>
)}
</div> </div>
</div> </div>
+34 -2
View File
@@ -388,7 +388,7 @@ function StandEintragenDialog({ meter }: { meter: Meter }) {
} }
export default function Zaehler() { export default function Zaehler() {
const { meters, units, role } = useStore() const { meters, units, role, deleteZaehler } = useStore()
const [filterHaus, setFilterHaus] = useState('alle') const [filterHaus, setFilterHaus] = useState('alle')
const [filterArt, setFilterArt] = useState<'alle' | ZaehlerArt>('alle') const [filterArt, setFilterArt] = useState<'alle' | ZaehlerArt>('alle')
const [suche, setSuche] = useState('') const [suche, setSuche] = useState('')
@@ -405,6 +405,12 @@ export default function Zaehler() {
return u ? `Whg. ${u.number}` : m.unitId return u ? `Whg. ${u.number}` : m.unitId
} }
const loescheZaehler = async (m: Meter) => {
if (!window.confirm(`Zähler ${m.nummer} (${m.art}) wirklich löschen? Der gesamte Verlauf wird mitgelöscht.`)) return
const fehler = await deleteZaehler(m.id)
if (fehler) window.alert(fehler)
}
const visible = useMemo(() => { const visible = useMemo(() => {
const q = suche.trim().toLowerCase() const q = suche.trim().toLowerCase()
return meters.filter( return meters.filter(
@@ -514,7 +520,21 @@ export default function Zaehler() {
</Badge> </Badge>
)} )}
</div> </div>
{role === 'admin' && <ZaehlerDialog meter={m} />} {role === 'admin' && (
<span className="flex shrink-0 items-center gap-1">
<ZaehlerDialog meter={m} />
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-slate-400 hover:text-red-600 dark:text-slate-500 dark:hover:text-red-400"
onClick={() => loescheZaehler(m)}
aria-label={`Zähler ${m.nummer} löschen`}
title="Zähler löschen"
>
<Trash2 className="h-4 w-4" />
</Button>
</span>
)}
</div> </div>
<div className="mt-4 text-3xl font-bold tracking-tight tabular-nums"> <div className="mt-4 text-3xl font-bold tracking-tight tabular-nums">
{fmtStand(m.stand)} {fmtStand(m.stand)}
@@ -587,6 +607,18 @@ export default function Zaehler() {
<VerlaufDialog meter={m} /> <VerlaufDialog meter={m} />
<StandEintragenDialog meter={m} /> <StandEintragenDialog meter={m} />
{role === 'admin' && <ZaehlerDialog meter={m} />} {role === 'admin' && <ZaehlerDialog meter={m} />}
{role === 'admin' && (
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-slate-400 hover:text-red-600 dark:text-slate-500 dark:hover:text-red-400"
onClick={() => loescheZaehler(m)}
aria-label={`Zähler ${m.nummer} löschen`}
title="Zähler löschen"
>
<Trash2 className="h-4 w-4" />
</Button>
)}
</div> </div>
</td> </td>
</tr> </tr>
+70
View File
@@ -99,6 +99,8 @@ interface Store extends Bootstrap {
) => Promise<string | null> ) => Promise<string | null>
addTicket: (t: { title: string; description: string; category: string; priority: string; unitId?: string }) => Promise<string | null> addTicket: (t: { title: string; description: string; category: string; priority: string; unitId?: string }) => Promise<string | null>
setTicketStatus: (id: string, status: TicketStatus) => Promise<void> setTicketStatus: (id: string, status: TicketStatus) => Promise<void>
/** Schadensmeldung inkl. verknüpfter Kommunikation löschen (Verwaltung, v1.33) */
deleteTicket: (id: string) => Promise<string | null>
newThread: (subject: string, text: string, unitId?: string) => Promise<string | null> newThread: (subject: string, text: string, unitId?: string) => Promise<string | null>
addMessage: (threadId: string, text: string) => Promise<void> addMessage: (threadId: string, text: string) => Promise<void>
markThreadRead: (threadId: string) => void markThreadRead: (threadId: string) => void
@@ -111,6 +113,8 @@ interface Store extends Bootstrap {
einheitId: string | null, einheitId: string | null,
) => Promise<string | null> ) => Promise<string | null>
setAktiv: (id: string, aktiv: boolean) => Promise<void> setAktiv: (id: string, aktiv: boolean) => Promise<void>
/** Benutzer endgültig löschen (Verwaltung, v1.33; eigenes Konto/geschützter Letzter ausgenommen) */
loescheBenutzer: (id: string) => Promise<string | null>
setPasswort: (id: string, passwort: string) => Promise<void> setPasswort: (id: string, passwort: string) => Promise<void>
weiseEinheitZu: (id: string, einheitId: string | null) => Promise<void> weiseEinheitZu: (id: string, einheitId: string | null) => Promise<void>
/** Direkt-E-Mail der Verwaltung an einen Mieter (immer mit CC an die Verwaltung, v1.22) */ /** Direkt-E-Mail der Verwaltung an einen Mieter (immer mit CC an die Verwaltung, v1.22) */
@@ -131,6 +135,8 @@ interface Store extends Bootstrap {
id: string, id: string,
z: Partial<{ nummer: string; art: ZaehlerArt; stand: number; unitId: string | null; building: string }>, z: Partial<{ nummer: string; art: ZaehlerArt; stand: number; unitId: string | null; building: string }>,
) => Promise<string | null> ) => Promise<string | null>
/** Zähler inkl. Verlauf löschen (Verwaltung, v1.33) */
deleteZaehler: (id: string) => Promise<string | null>
// ── Zählerverlauf (v1.25) ───────────────────────────────── // ── Zählerverlauf (v1.25) ─────────────────────────────────
/** Verlauf eines Zählers laden (älteste zuerst) */ /** Verlauf eines Zählers laden (älteste zuerst) */
zaehlerVerlauf: (meterId: string) => Promise<MeterReading[]> zaehlerVerlauf: (meterId: string) => Promise<MeterReading[]>
@@ -153,6 +159,10 @@ interface Store extends Bootstrap {
heizungsart: string heizungsart: string
}) => Promise<string | null> }) => Promise<string | null>
updateWohnung: (id: string, u: Partial<Unit>) => Promise<string | null> updateWohnung: (id: string, u: Partial<Unit>) => Promise<string | null>
/** Haus löschen (nur ohne Wohnungen/Zähler/Kosten; v1.33) */
deleteHaus: (name: string) => Promise<string | null>
/** Wohnung löschen (nur ohne Referenzen; v1.33) */
deleteWohnung: (id: string) => Promise<string | null>
// ── Dokumente (Verwaltung pflegt, Mieter lesen) ───────── // ── Dokumente (Verwaltung pflegt, Mieter lesen) ─────────
addDokument: (d: { addDokument: (d: {
title: string title: string
@@ -185,6 +195,8 @@ interface Store extends Bootstrap {
deleteKosten: (id: string) => Promise<string | null> deleteKosten: (id: string) => Promise<string | null>
// ── Belege (SparkReceipt-Import; Verwaltung ordnet zu) ── // ── Belege (SparkReceipt-Import; Verwaltung ordnet zu) ──
setBelegZugeordnet: (id: string, zugeordnet: boolean) => Promise<string | null> setBelegZugeordnet: (id: string, zugeordnet: boolean) => Promise<string | null>
/** Beleg endgültig löschen (Verwaltung, v1.33) */
deleteBeleg: (id: string) => Promise<string | null>
// ── Nebenkostenabrechnung als PDF (v1.23) ───────────────── // ── Nebenkostenabrechnung als PDF (v1.23) ─────────────────
erzeugeAbrechnung: (unitId: string, jahr: number) => Promise<{ erzeugeAbrechnung: (unitId: string, jahr: number) => Promise<{
fehler: string | null fehler: string | null
@@ -310,6 +322,19 @@ export function StoreProvider({ children }: { children: ReactNode }) {
const { ticket } = await apiPatch<{ ticket: Ticket }>(`/api/tickets/${id}`, { status }) const { ticket } = await apiPatch<{ ticket: Ticket }>(`/api/tickets/${id}`, { status })
setDaten((d) => ({ ...d, tickets: d.tickets.map((t) => (t.id === id ? ticket : t)) })) setDaten((d) => ({ ...d, tickets: d.tickets.map((t) => (t.id === id ? ticket : t)) }))
}, },
async deleteTicket(id) {
try {
await apiDelete(`/api/tickets/${id}`)
setDaten((d) => ({
...d,
tickets: d.tickets.filter((t) => t.id !== id),
threads: d.threads.filter((t) => t.ticketId !== id),
}))
return null
} catch (e) {
return fehlerNachricht(e)
}
},
// ── Nachrichten ───────────────────────────────────────── // ── Nachrichten ─────────────────────────────────────────
async newThread(subject, text, unitId) { async newThread(subject, text, unitId) {
try { try {
@@ -360,6 +385,15 @@ export function StoreProvider({ children }: { children: ReactNode }) {
const { benutzer } = await apiPatch<{ benutzer: Benutzer }>(`/api/users/${id}`, { aktiv }) const { benutzer } = await apiPatch<{ benutzer: Benutzer }>(`/api/users/${id}`, { aktiv })
setDaten((d) => ({ ...d, benutzer: d.benutzer.map((b) => (b.id === id ? benutzer : b)) })) setDaten((d) => ({ ...d, benutzer: d.benutzer.map((b) => (b.id === id ? benutzer : b)) }))
}, },
async loescheBenutzer(id) {
try {
await apiDelete(`/api/users/${id}`)
setDaten((d) => ({ ...d, benutzer: d.benutzer.filter((b) => b.id !== id) }))
return null
} catch (e) {
return fehlerNachricht(e)
}
},
async setPasswort(id, passwort) { async setPasswort(id, passwort) {
const { benutzer } = await apiPatch<{ benutzer: Benutzer }>(`/api/users/${id}`, { const { benutzer } = await apiPatch<{ benutzer: Benutzer }>(`/api/users/${id}`, {
passwortHash: await sha256(passwort), passwortHash: await sha256(passwort),
@@ -411,6 +445,15 @@ export function StoreProvider({ children }: { children: ReactNode }) {
return fehlerNachricht(e) return fehlerNachricht(e)
} }
}, },
async deleteZaehler(id) {
try {
await apiDelete(`/api/meters/${id}`)
setDaten((d) => ({ ...d, meters: d.meters.filter((m) => m.id !== id) }))
return null
} catch (e) {
return fehlerNachricht(e)
}
},
// ── Zählerverlauf (v1.25) ─────────────────────────────── // ── Zählerverlauf (v1.25) ───────────────────────────────
async zaehlerVerlauf(meterId) { async zaehlerVerlauf(meterId) {
const { readings } = await apiGet<{ readings: MeterReading[] }>(`/api/meters/${meterId}/readings`) const { readings } = await apiGet<{ readings: MeterReading[] }>(`/api/meters/${meterId}/readings`)
@@ -454,6 +497,15 @@ export function StoreProvider({ children }: { children: ReactNode }) {
return fehlerNachricht(e) return fehlerNachricht(e)
} }
}, },
async deleteHaus(name) {
try {
await apiDelete(`/api/buildings/${encodeURIComponent(name)}`)
setDaten((d) => ({ ...d, buildings: d.buildings.filter((b) => b.name !== name) }))
return null
} catch (e) {
return fehlerNachricht(e)
}
},
async addWohnung(u) { async addWohnung(u) {
try { try {
const { unit } = await apiPost<{ unit: Unit }>('/api/units', u) const { unit } = await apiPost<{ unit: Unit }>('/api/units', u)
@@ -472,6 +524,15 @@ export function StoreProvider({ children }: { children: ReactNode }) {
return fehlerNachricht(e) return fehlerNachricht(e)
} }
}, },
async deleteWohnung(id) {
try {
await apiDelete(`/api/units/${id}`)
setDaten((d) => ({ ...d, units: d.units.filter((x) => x.id !== id) }))
return null
} catch (e) {
return fehlerNachricht(e)
}
},
// ── Dokumente ─────────────────────────────────────────── // ── Dokumente ───────────────────────────────────────────
async addDokument(d) { async addDokument(d) {
try { try {
@@ -555,6 +616,15 @@ export function StoreProvider({ children }: { children: ReactNode }) {
return fehlerNachricht(e) return fehlerNachricht(e)
} }
}, },
async deleteBeleg(id) {
try {
await apiDelete(`/api/belege/${id}`)
setDaten((x) => ({ ...x, belege: x.belege.filter((b) => b.id !== id) }))
return null
} catch (e) {
return fehlerNachricht(e)
}
},
// ── Nebenkostenabrechnung (v1.23) ───────────────────────── // ── Nebenkostenabrechnung (v1.23) ─────────────────────────
async erzeugeAbrechnung(unitId, jahr) { async erzeugeAbrechnung(unitId, jahr) {
try { try {