215 lines
8.6 KiB
TypeScript
215 lines
8.6 KiB
TypeScript
"use client"
|
|
|
|
import { useState, useMemo } from "react"
|
|
import type { Ticket, TicketStatus } from "@/lib/types"
|
|
import { StatusBadge } from "./status-badge"
|
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
|
import {
|
|
Select,
|
|
SelectContent,
|
|
SelectItem,
|
|
SelectTrigger,
|
|
SelectValue,
|
|
} from "@/components/ui/select"
|
|
import { ArrowUpDown, ArrowDown, ArrowUp, Calendar, MapPin, FileText } from "lucide-react"
|
|
import { Input } from "@/components/ui/input"
|
|
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog"
|
|
import { TicketComments } from "./ticket-comments"
|
|
|
|
interface TicketTableProps {
|
|
tickets: Ticket[]
|
|
showStatusUpdate?: boolean
|
|
onStatusUpdate?: (ticketId: number, status: TicketStatus) => void
|
|
}
|
|
|
|
type SortDirection = "asc" | "desc"
|
|
|
|
export function TicketTable({ tickets, showStatusUpdate = false, onStatusUpdate }: TicketTableProps) {
|
|
const [sortConfig, setSortConfig] = useState<{ key: keyof Ticket | "room" | "owner"; direction: SortDirection }>({ key: "createdAt", direction: "desc" })
|
|
const [search, setSearch] = useState("")
|
|
const [statusFilter, setStatusFilter] = useState<TicketStatus | "ALL">("ALL")
|
|
const [selectedTicket, setSelectedTicket] = useState<Ticket | null>(null)
|
|
|
|
// Advanced Filtering & Sorting
|
|
const processedTickets = useMemo(() => {
|
|
let filtered = [...tickets]
|
|
|
|
// Search
|
|
if (search.trim()) {
|
|
const q = search.toLowerCase()
|
|
filtered = filtered.filter(t =>
|
|
t.title.toLowerCase().includes(q) ||
|
|
t.description.toLowerCase().includes(q) ||
|
|
t.room.name.toLowerCase().includes(q)
|
|
)
|
|
}
|
|
|
|
// Filter
|
|
if (statusFilter !== "ALL") {
|
|
filtered = filtered.filter(t => t.status === statusFilter)
|
|
}
|
|
|
|
// Sort
|
|
return filtered.sort((a, b) => {
|
|
let aValue: any = a[sortConfig.key as keyof Ticket]
|
|
let bValue: any = b[sortConfig.key as keyof Ticket]
|
|
|
|
// Nested keys
|
|
if (sortConfig.key === "room") {
|
|
aValue = a.room.name
|
|
bValue = b.room.name
|
|
} else if (sortConfig.key === "owner") {
|
|
aValue = a.owner.name
|
|
bValue = b.owner.name
|
|
}
|
|
|
|
if (aValue < bValue) return sortConfig.direction === "asc" ? -1 : 1
|
|
if (aValue > bValue) return sortConfig.direction === "asc" ? 1 : -1
|
|
return 0
|
|
})
|
|
}, [tickets, search, statusFilter, sortConfig])
|
|
|
|
const handleSort = (key: keyof Ticket | "room" | "owner") => {
|
|
setSortConfig(current => ({
|
|
key,
|
|
direction: current.key === key && current.direction === "asc" ? "desc" : "asc"
|
|
}))
|
|
}
|
|
|
|
const formatDate = (dateString: string) => {
|
|
return new Date(dateString).toLocaleDateString("de-DE", {
|
|
year: "numeric",
|
|
month: "2-digit",
|
|
day: "2-digit",
|
|
hour: "2-digit",
|
|
minute: "2-digit"
|
|
})
|
|
}
|
|
|
|
return (
|
|
<div className="space-y-4">
|
|
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
|
|
<Input
|
|
placeholder="Search tickets..."
|
|
value={search}
|
|
onChange={(e) => setSearch(e.target.value)}
|
|
className="max-w-sm"
|
|
/>
|
|
<div className="flex items-center gap-2">
|
|
<Select value={statusFilter} onValueChange={(v) => setStatusFilter(v as TicketStatus | "ALL")}>
|
|
<SelectTrigger className="w-[180px]">
|
|
<SelectValue placeholder="Filter Status" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="ALL">All Statuses</SelectItem>
|
|
<SelectItem value="OPEN">Open</SelectItem>
|
|
<SelectItem value="IN_PROGRESS">In Progress</SelectItem>
|
|
<SelectItem value="CLOSED">Closed</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="rounded-lg border border-border bg-card overflow-hidden">
|
|
<Table>
|
|
<TableHeader>
|
|
<TableRow className="bg-muted/50 hover:bg-muted/50">
|
|
<TableHead className="font-semibold cursor-pointer" onClick={() => handleSort("room")}>
|
|
<div className="flex items-center gap-2">
|
|
<MapPin className="h-4 w-4" />
|
|
Room {sortConfig.key === "room" && (sortConfig.direction === "asc" ? <ArrowUp className="h-3 w-3" /> : <ArrowDown className="h-3 w-3" />)}
|
|
</div>
|
|
</TableHead>
|
|
<TableHead className="font-semibold cursor-pointer" onClick={() => handleSort("title")}>
|
|
Title {sortConfig.key === "title" && (sortConfig.direction === "asc" ? <ArrowUp className="h-3 w-3" /> : <ArrowDown className="h-3 w-3" />)}
|
|
</TableHead>
|
|
<TableHead className="font-semibold hidden md:table-cell">Description</TableHead>
|
|
<TableHead className="font-semibold cursor-pointer" onClick={() => handleSort("createdAt")}>
|
|
<div className="flex items-center gap-1">
|
|
<Calendar className="h-4 w-4" />
|
|
Date {sortConfig.key === "createdAt" && (sortConfig.direction === "asc" ? <ArrowUp className="h-3 w-3" /> : <ArrowDown className="h-3 w-3" />)}
|
|
</div>
|
|
</TableHead>
|
|
<TableHead className="font-semibold cursor-pointer" onClick={() => handleSort("status")}>
|
|
Status {sortConfig.key === "status" && (sortConfig.direction === "asc" ? <ArrowUp className="h-3 w-3" /> : <ArrowDown className="h-3 w-3" />)}
|
|
</TableHead>
|
|
</TableRow>
|
|
</TableHeader>
|
|
<TableBody>
|
|
{processedTickets.length === 0 ? (
|
|
<TableRow>
|
|
<TableCell colSpan={5} className="h-24 text-center">
|
|
No results found.
|
|
</TableCell>
|
|
</TableRow>
|
|
) : (
|
|
processedTickets.map((ticket) => (
|
|
<TableRow
|
|
key={ticket.id}
|
|
className="group cursor-pointer hover:bg-muted/50"
|
|
onClick={() => setSelectedTicket(ticket)}
|
|
>
|
|
<TableCell className="font-medium">{ticket.room.name}</TableCell>
|
|
<TableCell className="font-medium">{ticket.title}</TableCell>
|
|
<TableCell className="hidden md:table-cell max-w-xs">
|
|
<p className="truncate text-muted-foreground">{ticket.description}</p>
|
|
</TableCell>
|
|
<TableCell className="text-muted-foreground">{formatDate(ticket.createdAt)}</TableCell>
|
|
<TableCell onClick={(e) => e.stopPropagation()}>
|
|
{showStatusUpdate && onStatusUpdate ? (
|
|
<Select
|
|
value={ticket.status}
|
|
onValueChange={(value: TicketStatus) => onStatusUpdate(ticket.id, value)}
|
|
>
|
|
<SelectTrigger className="w-32 h-8">
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="OPEN"><StatusBadge status="OPEN" /></SelectItem>
|
|
<SelectItem value="IN_PROGRESS"><StatusBadge status="IN_PROGRESS" /></SelectItem>
|
|
<SelectItem value="CLOSED"><StatusBadge status="CLOSED" /></SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
) : (
|
|
<StatusBadge status={ticket.status} />
|
|
)}
|
|
</TableCell>
|
|
</TableRow>
|
|
))
|
|
)}
|
|
</TableBody>
|
|
</Table>
|
|
</div>
|
|
|
|
{selectedTicket && (
|
|
<Dialog open={!!selectedTicket} onOpenChange={(open) => !open && setSelectedTicket(null)}>
|
|
<DialogContent className="max-w-2xl">
|
|
<DialogHeader>
|
|
<DialogTitle>{selectedTicket.title}</DialogTitle>
|
|
<DialogDescription>
|
|
{selectedTicket.room.name} • {formatDate(selectedTicket.createdAt)} • {selectedTicket.owner.name}
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
|
|
<div className="grid gap-6">
|
|
<div>
|
|
<h3 className="font-semibold mb-2">Description</h3>
|
|
<div className="p-3 bg-muted rounded-md text-sm">
|
|
{selectedTicket.description}
|
|
</div>
|
|
</div>
|
|
|
|
<div>
|
|
<StatusBadge status={selectedTicket.status} />
|
|
</div>
|
|
|
|
<div className="border-t pt-4">
|
|
<TicketComments ticketId={selectedTicket.id} />
|
|
</div>
|
|
</div>
|
|
</DialogContent>
|
|
</Dialog>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|