v1.33: Eintraege in allen Listen loeschbar
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.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,
|
||||||
|
|||||||
@@ -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) => {
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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>
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user