Files
Mieterportal/src/pages/Tickets.tsx
T

299 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useMemo, useState } from 'react'
import { Link } from 'react-router'
import { Plus, Wrench, CalendarDays, MapPin, MessagesSquare, Trash2 } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogTrigger,
DialogFooter,
} from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Textarea } from '@/components/ui/textarea'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { useStore } from '../store/store'
import { fmtDatum, fmtDatumZeit } from '../lib/format'
import type { TicketPriority, TicketStatus } from '../types'
const statusStyle: Record<TicketStatus, string> = {
offen: 'bg-amber-100 text-amber-800',
'in Bearbeitung': 'bg-blue-100 text-blue-800',
erledigt: 'bg-emerald-100 text-emerald-800',
}
const prioStyle: Record<TicketPriority, string> = {
hoch: 'bg-red-100 text-red-700',
mittel: 'bg-amber-100 text-amber-700',
niedrig: 'bg-slate-100 text-slate-600',
}
const categories = [
'Sanitär',
'Elektrik',
'Heizung',
'Fenster / Rollladen',
'Balkon / Außenanlage',
'Schlüssel',
'Sonstiges',
]
function NewTicketDialog() {
const { addTicket, role, meineEinheit } = useStore()
const [open, setOpen] = useState(false)
const [title, setTitle] = useState('')
const [category, setCategory] = useState('Sanitär')
const [priority, setPriority] = useState<TicketPriority>('mittel')
const [description, setDescription] = useState('')
const [sende, setSende] = useState(false)
const submit = async () => {
if (!title.trim() || sende) return
setSende(true)
const err = await addTicket({
title: title.trim(),
description: description.trim() || '–',
category,
priority,
unitId: role === 'tenant' ? meineEinheit.id : 'hs-1',
})
setSende(false)
if (err) return
setTitle('')
setDescription('')
setOpen(false)
}
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button className="gap-2">
<Plus className="h-4 w-4" /> Neue Schadensmeldung
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Schaden oder Störung melden</DialogTitle>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-1.5">
<Label htmlFor="t-title">Kurzbeschreibung</Label>
<Input
id="t-title"
placeholder="z. B. Wasserhahn tropft"
value={title}
onChange={(e) => setTitle(e.target.value)}
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-1.5">
<Label>Kategorie</Label>
<Select value={category} onValueChange={setCategory}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{categories.map((c) => (
<SelectItem key={c} value={c}>{c}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label>Dringlichkeit</Label>
<Select value={priority} onValueChange={(v) => setPriority(v as TicketPriority)}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="niedrig">Niedrig</SelectItem>
<SelectItem value="mittel">Mittel</SelectItem>
<SelectItem value="hoch">Hoch</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div className="space-y-1.5">
<Label htmlFor="t-desc">Beschreibung</Label>
<Textarea
id="t-desc"
rows={4}
placeholder="Was ist passiert? Seit wann? ..."
value={description}
onChange={(e) => setDescription(e.target.value)}
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setOpen(false)}>Abbrechen</Button>
<Button onClick={submit} disabled={!title.trim() || sende}>{sende ? 'Wird gesendet …' : 'Meldung absenden'}</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
export default function Tickets() {
const { tickets, units, role, setTicketStatus, meineEinheit, threads, deleteTicket } = useStore()
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 visible = useMemo(
() =>
(role === 'tenant'
? tickets.filter((t) => t.unitId === meineEinheit.id)
: tickets
).filter((t) => filter === 'alle' || t.status === filter),
[tickets, role, filter, meineEinheit],
)
const counts = {
offen: tickets.filter((t) => t.status === 'offen').length,
'in Bearbeitung': tickets.filter((t) => t.status === 'in Bearbeitung').length,
erledigt: tickets.filter((t) => t.status === 'erledigt').length,
}
return (
<div className="space-y-6">
<div className="flex flex-wrap items-end justify-between gap-3">
<div>
<h1 className="text-2xl font-bold tracking-tight">Schadensmeldungen</h1>
<p className="text-sm text-slate-500">
{role === 'tenant'
? 'Meldungen für Ihre Wohnung – vom Eingang bis zur Erledigung'
: 'Alle Meldungen des Bestands – Priorisierung und Statuspflege'}
</p>
</div>
<NewTicketDialog />
</div>
<div className="grid gap-4 sm:grid-cols-3">
{(['offen', 'in Bearbeitung', 'erledigt'] as const).map((s) => (
<button
key={s}
onClick={() => setFilter(filter === s ? 'alle' : s)}
className={`rounded-2xl border p-5 text-left shadow-sm transition ${
filter === s ? 'border-teal-500 ring-2 ring-teal-200' : 'bg-white hover:border-slate-300'
}`}
>
<div className="text-sm text-slate-500">{s}</div>
<div className="mt-1 text-2xl font-bold">{counts[s]}</div>
</button>
))}
</div>
{visible.length === 0 ? (
<div className="rounded-2xl border border-dashed bg-white p-10 text-center text-sm text-slate-500">
<Wrench className="mx-auto mb-3 h-8 w-8 text-slate-300" />
Keine Meldungen für diesen Filter.
</div>
) : (
<div className="space-y-3">
{visible.map((t) => {
const unit = units.find((u) => u.id === t.unitId)
const thread = threadFuerTicket(t.id)
const letzte = thread?.messages[thread.messages.length - 1]
return (
<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="min-w-0">
<div className="font-semibold">{t.title}</div>
<div className="mt-1 flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-slate-500">
<span className="inline-flex items-center gap-1">
<MapPin className="h-3.5 w-3.5" />
{unit ? `${unit.building}, Whg. ${unit.number}` : t.unitId}
{role === 'admin' && ` · ${t.tenant}`}
</span>
<span className="inline-flex items-center gap-1">
<CalendarDays className="h-3.5 w-3.5" /> {fmtDatum(t.createdAt)}
</span>
<span>Kategorie: {t.category}</span>
</div>
<p className="mt-2 text-sm text-slate-600">{t.description}</p>
</div>
<div className="flex shrink-0 flex-col items-end gap-2">
<Badge className={prioStyle[t.priority]} variant="secondary">
{t.priority}
</Badge>
{role === 'admin' ? (
<Select
value={t.status}
onValueChange={(v) => setTicketStatus(t.id, v as TicketStatus)}
>
<SelectTrigger className="h-8 w-40">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="offen">offen</SelectItem>
<SelectItem value="in Bearbeitung">in Bearbeitung</SelectItem>
<SelectItem value="erledigt">erledigt</SelectItem>
</SelectContent>
</Select>
) : (
<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>
{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">{fmtDatumZeit(letzte.at)}</span>
</div>
)}
</div>
)}
</div>
)
})}
</div>
)}
</div>
)
}