l
|
|
@ -0,0 +1,63 @@
|
||||||
|
package de.itsolutions.ticketsystem.controller;
|
||||||
|
|
||||||
|
import de.itsolutions.ticketsystem.entity.Comment;
|
||||||
|
import de.itsolutions.ticketsystem.entity.Ticket;
|
||||||
|
import de.itsolutions.ticketsystem.entity.User;
|
||||||
|
import de.itsolutions.ticketsystem.repository.CommentRepository;
|
||||||
|
import de.itsolutions.ticketsystem.repository.TicketRepository;
|
||||||
|
import de.itsolutions.ticketsystem.repository.UserRepository;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.http.HttpStatus;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.security.core.Authentication;
|
||||||
|
import org.springframework.security.core.context.SecurityContextHolder;
|
||||||
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
import org.springframework.web.server.ResponseStatusException;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/tickets/{ticketId}/comments")
|
||||||
|
public class CommentController {
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private CommentRepository commentRepository;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private TicketRepository ticketRepository;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private UserRepository userRepository;
|
||||||
|
|
||||||
|
@GetMapping
|
||||||
|
public List<Comment> getComments(@PathVariable Long ticketId) {
|
||||||
|
if (!ticketRepository.existsById(ticketId)) {
|
||||||
|
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Ticket not found");
|
||||||
|
}
|
||||||
|
return commentRepository.findByTicketIdOrderByCreatedAtAsc(ticketId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping
|
||||||
|
public Comment addComment(@PathVariable Long ticketId, @RequestBody Map<String, String> payload) {
|
||||||
|
String text = payload.get("text");
|
||||||
|
if (text == null || text.trim().isEmpty()) {
|
||||||
|
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Text is required");
|
||||||
|
}
|
||||||
|
|
||||||
|
Ticket ticket = ticketRepository.findById(ticketId)
|
||||||
|
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Ticket not found"));
|
||||||
|
|
||||||
|
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
|
||||||
|
String email = auth.getName();
|
||||||
|
User user = userRepository.findByEmail(email)
|
||||||
|
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "User not found"));
|
||||||
|
|
||||||
|
Comment comment = new Comment();
|
||||||
|
comment.setText(text);
|
||||||
|
comment.setTicket(ticket);
|
||||||
|
comment.setAuthor(user);
|
||||||
|
|
||||||
|
return commentRepository.save(comment);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,42 @@
|
||||||
|
package de.itsolutions.ticketsystem.entity;
|
||||||
|
|
||||||
|
import jakarta.persistence.*;
|
||||||
|
import lombok.Data;
|
||||||
|
import lombok.NoArgsConstructor;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
@Entity
|
||||||
|
@Table(name = "comments")
|
||||||
|
@Data
|
||||||
|
@NoArgsConstructor
|
||||||
|
public class Comment {
|
||||||
|
|
||||||
|
@Id
|
||||||
|
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||||
|
private Long id;
|
||||||
|
|
||||||
|
@Column(nullable = false, columnDefinition = "TEXT")
|
||||||
|
private String text;
|
||||||
|
|
||||||
|
@Column(nullable = false)
|
||||||
|
private LocalDateTime createdAt = LocalDateTime.now();
|
||||||
|
|
||||||
|
@ManyToOne(optional = false)
|
||||||
|
@JoinColumn(name = "user_id", nullable = false)
|
||||||
|
private User author;
|
||||||
|
|
||||||
|
@ManyToOne(optional = false)
|
||||||
|
@JoinColumn(name = "ticket_id", nullable = false)
|
||||||
|
private Ticket ticket;
|
||||||
|
|
||||||
|
public void setId(Long id) { this.id = id; }
|
||||||
|
public Long getId() { return id; }
|
||||||
|
public void setText(String text) { this.text = text; }
|
||||||
|
public String getText() { return text; }
|
||||||
|
public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; }
|
||||||
|
public LocalDateTime getCreatedAt() { return createdAt; }
|
||||||
|
public void setAuthor(User author) { this.author = author; }
|
||||||
|
public User getAuthor() { return author; }
|
||||||
|
public void setTicket(Ticket ticket) { this.ticket = ticket; }
|
||||||
|
public Ticket getTicket() { return ticket; }
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,9 @@
|
||||||
|
package de.itsolutions.ticketsystem.repository;
|
||||||
|
|
||||||
|
import de.itsolutions.ticketsystem.entity.Comment;
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
public interface CommentRepository extends JpaRepository<Comment, Long> {
|
||||||
|
List<Comment> findByTicketIdOrderByCreatedAtAsc(Long ticketId);
|
||||||
|
}
|
||||||
|
|
@ -35,7 +35,7 @@ export function AppShell({ children }: AppShellProps) {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-background">
|
<div className="min-h-screen bg-background">
|
||||||
<header className="sticky top-0 z-50 w-full border-b border-border/60 bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
|
<header className="sticky top-0 z-50 w-full border-b border-border/60 bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
|
||||||
<div className="container flex h-16 items-center justify-between px-4">
|
<div className="w-full max-w-[95%] mx-auto flex h-16 items-center justify-between px-4">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div className="flex h-9 w-9 items-center justify-center rounded-lg bg-primary">
|
<div className="flex h-9 w-9 items-center justify-center rounded-lg bg-primary">
|
||||||
<Cpu className="h-5 w-5 text-primary-foreground" />
|
<Cpu className="h-5 w-5 text-primary-foreground" />
|
||||||
|
|
@ -124,7 +124,7 @@ export function AppShell({ children }: AppShellProps) {
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<main className="container px-4 py-8">{children}</main>
|
<main className="w-full max-w-[95%] mx-auto px-4 py-8">{children}</main>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
103
Frontend/components/tickets/ticket-comments.tsx
Normal file
|
|
@ -0,0 +1,103 @@
|
||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useState, useEffect, useCallback } from "react"
|
||||||
|
import { useAuth } from "@/lib/auth-context"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { Textarea } from "@/components/ui/textarea"
|
||||||
|
import { Avatar, AvatarFallback } from "@/components/ui/avatar"
|
||||||
|
import type { Comment } from "@/lib/types"
|
||||||
|
|
||||||
|
interface TicketCommentsProps {
|
||||||
|
ticketId: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TicketComments({ ticketId }: TicketCommentsProps) {
|
||||||
|
const [comments, setComments] = useState<Comment[]>([])
|
||||||
|
const [newComment, setNewComment] = useState("")
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const { user, authHeader } = useAuth()
|
||||||
|
const API_URL = process.env.NEXT_PUBLIC_API_URL + "/api"
|
||||||
|
|
||||||
|
const fetchComments = useCallback(async () => {
|
||||||
|
if (!authHeader) return
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API_URL}/tickets/${ticketId}/comments`, {
|
||||||
|
headers: { "Authorization": authHeader }
|
||||||
|
})
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json()
|
||||||
|
setComments(data)
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e)
|
||||||
|
}
|
||||||
|
}, [ticketId, authHeader])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (authHeader) {
|
||||||
|
fetchComments()
|
||||||
|
}
|
||||||
|
}, [fetchComments, authHeader])
|
||||||
|
|
||||||
|
const handlePost = async () => {
|
||||||
|
if (!authHeader || !newComment.trim()) return
|
||||||
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API_URL}/tickets/${ticketId}/comments`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Authorization": authHeader,
|
||||||
|
"Content-Type": "application/json"
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ text: newComment })
|
||||||
|
})
|
||||||
|
if (res.ok) {
|
||||||
|
setNewComment("")
|
||||||
|
fetchComments()
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e)
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<h3 className="font-semibold">Comments</h3>
|
||||||
|
<div className="space-y-4 max-h-[300px] overflow-y-auto pr-2">
|
||||||
|
{comments.length === 0 && <p className="text-sm text-muted-foreground">No comments yet.</p>}
|
||||||
|
{comments.map((comment) => (
|
||||||
|
<div key={comment.id} className="flex gap-3">
|
||||||
|
<Avatar className="h-8 w-8">
|
||||||
|
<AvatarFallback>{comment.author.name ? comment.author.name.charAt(0) : '?'}</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
<div className="flex-1 space-y-1">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-sm font-medium">{comment.author.name}</span>
|
||||||
|
<span className="text-xs text-muted-foreground">{new Date(comment.createdAt).toLocaleString()}</span>
|
||||||
|
</div>
|
||||||
|
<div className="p-2 bg-muted/50 rounded-md">
|
||||||
|
<p className="text-sm text-foreground/90 whitespace-pre-wrap">{comment.text}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2 pt-2">
|
||||||
|
<Textarea
|
||||||
|
placeholder="Write a comment..."
|
||||||
|
value={newComment}
|
||||||
|
onChange={(e) => setNewComment(e.target.value)}
|
||||||
|
className="min-h-[80px]"
|
||||||
|
/>
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<Button onClick={handlePost} disabled={!newComment.trim() || loading}>
|
||||||
|
Post Comment
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -12,7 +12,9 @@ import {
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from "@/components/ui/select"
|
} from "@/components/ui/select"
|
||||||
import { ArrowUpDown, ArrowDown, ArrowUp, Calendar, MapPin, FileText } from "lucide-react"
|
import { ArrowUpDown, ArrowDown, ArrowUp, Calendar, MapPin, FileText } from "lucide-react"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Input } from "@/components/ui/input"
|
||||||
|
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog"
|
||||||
|
import { TicketComments } from "./ticket-comments"
|
||||||
|
|
||||||
interface TicketTableProps {
|
interface TicketTableProps {
|
||||||
tickets: Ticket[]
|
tickets: Ticket[]
|
||||||
|
|
@ -23,18 +25,55 @@ interface TicketTableProps {
|
||||||
type SortDirection = "asc" | "desc"
|
type SortDirection = "asc" | "desc"
|
||||||
|
|
||||||
export function TicketTable({ tickets, showStatusUpdate = false, onStatusUpdate }: TicketTableProps) {
|
export function TicketTable({ tickets, showStatusUpdate = false, onStatusUpdate }: TicketTableProps) {
|
||||||
const [sortDirection, setSortDirection] = useState<SortDirection>("desc")
|
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)
|
||||||
|
|
||||||
const sortedTickets = useMemo(() => {
|
// Advanced Filtering & Sorting
|
||||||
return [...tickets].sort((a, b) => {
|
const processedTickets = useMemo(() => {
|
||||||
const dateA = new Date(a.createdAt).getTime()
|
let filtered = [...tickets]
|
||||||
const dateB = new Date(b.createdAt).getTime()
|
|
||||||
return sortDirection === "desc" ? dateB - dateA : dateA - dateB
|
// 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, sortDirection])
|
}, [tickets, search, statusFilter, sortConfig])
|
||||||
|
|
||||||
const toggleSort = () => {
|
const handleSort = (key: keyof Ticket | "room" | "owner") => {
|
||||||
setSortDirection((prev) => (prev === "desc" ? "asc" : "desc"))
|
setSortConfig(current => ({
|
||||||
|
key,
|
||||||
|
direction: current.key === key && current.direction === "asc" ? "desc" : "asc"
|
||||||
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
const formatDate = (dateString: string) => {
|
const formatDate = (dateString: string) => {
|
||||||
|
|
@ -47,97 +86,130 @@ export function TicketTable({ tickets, showStatusUpdate = false, onStatusUpdate
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
if (tickets.length === 0) {
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col items-center justify-center rounded-lg border border-dashed border-border p-12 text-center">
|
|
||||||
<FileText className="h-12 w-12 text-muted-foreground/50" />
|
|
||||||
<h3 className="mt-4 text-lg font-medium">No tickets found</h3>
|
|
||||||
<p className="mt-2 text-sm text-muted-foreground">
|
|
||||||
There are no tickets to display at this time.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="rounded-lg border border-border bg-card overflow-hidden">
|
<div className="space-y-4">
|
||||||
<Table>
|
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
|
||||||
<TableHeader>
|
<Input
|
||||||
<TableRow className="bg-muted/50 hover:bg-muted/50">
|
placeholder="Search tickets..."
|
||||||
<TableHead className="font-semibold">
|
value={search}
|
||||||
<div className="flex items-center gap-2">
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
<MapPin className="h-4 w-4" />
|
className="max-w-sm"
|
||||||
Room
|
/>
|
||||||
</div>
|
<div className="flex items-center gap-2">
|
||||||
</TableHead>
|
<Select value={statusFilter} onValueChange={(v) => setStatusFilter(v as TicketStatus | "ALL")}>
|
||||||
<TableHead className="font-semibold">Title</TableHead>
|
<SelectTrigger className="w-[180px]">
|
||||||
<TableHead className="font-semibold hidden md:table-cell">Description</TableHead>
|
<SelectValue placeholder="Filter Status" />
|
||||||
<TableHead className="font-semibold">
|
</SelectTrigger>
|
||||||
<Button
|
<SelectContent>
|
||||||
variant="ghost"
|
<SelectItem value="ALL">All Statuses</SelectItem>
|
||||||
size="sm"
|
<SelectItem value="OPEN">Open</SelectItem>
|
||||||
onClick={toggleSort}
|
<SelectItem value="IN_PROGRESS">In Progress</SelectItem>
|
||||||
className="flex items-center gap-1 -ml-2 h-auto p-2 hover:bg-transparent"
|
<SelectItem value="CLOSED">Closed</SelectItem>
|
||||||
>
|
</SelectContent>
|
||||||
<Calendar className="h-4 w-4" />
|
</Select>
|
||||||
Date
|
</div>
|
||||||
{sortDirection === "desc" ? (
|
</div>
|
||||||
<ArrowDown className="h-3 w-3" />
|
|
||||||
) : (
|
<div className="rounded-lg border border-border bg-card overflow-hidden">
|
||||||
<ArrowUp className="h-3 w-3" />
|
<Table>
|
||||||
)}
|
<TableHeader>
|
||||||
</Button>
|
<TableRow className="bg-muted/50 hover:bg-muted/50">
|
||||||
</TableHead>
|
<TableHead className="font-semibold cursor-pointer" onClick={() => handleSort("room")}>
|
||||||
<TableHead className="font-semibold">Status</TableHead>
|
<div className="flex items-center gap-2">
|
||||||
</TableRow>
|
<MapPin className="h-4 w-4" />
|
||||||
</TableHeader>
|
Room {sortConfig.key === "room" && (sortConfig.direction === "asc" ? <ArrowUp className="h-3 w-3" /> : <ArrowDown className="h-3 w-3" />)}
|
||||||
<TableBody>
|
</div>
|
||||||
{sortedTickets.map((ticket) => (
|
</TableHead>
|
||||||
<TableRow key={ticket.id} className="group">
|
<TableHead className="font-semibold cursor-pointer" onClick={() => handleSort("title")}>
|
||||||
<TableCell className="font-medium">{ticket.room.name}</TableCell>
|
Title {sortConfig.key === "title" && (sortConfig.direction === "asc" ? <ArrowUp className="h-3 w-3" /> : <ArrowDown className="h-3 w-3" />)}
|
||||||
<TableCell className="font-medium">{ticket.title}</TableCell>
|
</TableHead>
|
||||||
<TableCell className="hidden md:table-cell max-w-xs">
|
<TableHead className="font-semibold hidden md:table-cell">Description</TableHead>
|
||||||
<p className="truncate text-muted-foreground">{ticket.description}</p>
|
<TableHead className="font-semibold cursor-pointer" onClick={() => handleSort("createdAt")}>
|
||||||
</TableCell>
|
<div className="flex items-center gap-1">
|
||||||
<TableCell className="text-muted-foreground">{formatDate(ticket.createdAt)}</TableCell>
|
<Calendar className="h-4 w-4" />
|
||||||
<TableCell>
|
Date {sortConfig.key === "createdAt" && (sortConfig.direction === "asc" ? <ArrowUp className="h-3 w-3" /> : <ArrowDown className="h-3 w-3" />)}
|
||||||
{showStatusUpdate && onStatusUpdate ? (
|
</div>
|
||||||
<Select
|
</TableHead>
|
||||||
value={ticket.status}
|
<TableHead className="font-semibold cursor-pointer" onClick={() => handleSort("status")}>
|
||||||
onValueChange={(value: TicketStatus) => onStatusUpdate(ticket.id, value)}
|
Status {sortConfig.key === "status" && (sortConfig.direction === "asc" ? <ArrowUp className="h-3 w-3" /> : <ArrowDown className="h-3 w-3" />)}
|
||||||
>
|
</TableHead>
|
||||||
<SelectTrigger className="w-32 h-8">
|
|
||||||
<SelectValue />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="OPEN">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<span className="h-2 w-2 rounded-full bg-status-open" />
|
|
||||||
Offen
|
|
||||||
</div>
|
|
||||||
</SelectItem>
|
|
||||||
<SelectItem value="IN_PROGRESS">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<span className="h-2 w-2 rounded-full bg-status-progress" />
|
|
||||||
In Bearbeitung
|
|
||||||
</div>
|
|
||||||
</SelectItem>
|
|
||||||
<SelectItem value="CLOSED">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<span className="h-2 w-2 rounded-full bg-status-done" />
|
|
||||||
Erledigt
|
|
||||||
</div>
|
|
||||||
</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
) : (
|
|
||||||
<StatusBadge status={ticket.status} />
|
|
||||||
)}
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
</TableRow>
|
||||||
))}
|
</TableHeader>
|
||||||
</TableBody>
|
<TableBody>
|
||||||
</Table>
|
{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>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ interface AuthContextType {
|
||||||
user: User | null
|
user: User | null
|
||||||
tickets: Ticket[]
|
tickets: Ticket[]
|
||||||
rooms: Room[]
|
rooms: Room[]
|
||||||
|
authHeader: string | null
|
||||||
login: (email: string, password: string) => Promise<boolean>
|
login: (email: string, password: string) => Promise<boolean>
|
||||||
register: (user: Omit<User, "id" | "name"> & { firstname: string; lastname: string; password: string; roomIds?: number[] }) => Promise<boolean>
|
register: (user: Omit<User, "id" | "name"> & { firstname: string; lastname: string; password: string; roomIds?: number[] }) => Promise<boolean>
|
||||||
logout: () => void
|
logout: () => void
|
||||||
|
|
@ -184,6 +185,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||||
user,
|
user,
|
||||||
tickets,
|
tickets,
|
||||||
rooms,
|
rooms,
|
||||||
|
authHeader,
|
||||||
login,
|
login,
|
||||||
register,
|
register,
|
||||||
logout,
|
logout,
|
||||||
|
|
|
||||||
|
|
@ -25,3 +25,11 @@ export interface Ticket {
|
||||||
owner: User
|
owner: User
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface Comment {
|
||||||
|
id: number
|
||||||
|
text: string
|
||||||
|
createdAt: string
|
||||||
|
author: User
|
||||||
|
ticket: Ticket
|
||||||
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
Before Width: | Height: | Size: 2.6 KiB After Width: | Height: | Size: 568 B |
|
Before Width: | Height: | Size: 585 B After Width: | Height: | Size: 568 B |
|
Before Width: | Height: | Size: 566 B After Width: | Height: | Size: 568 B |
|
|
@ -1,26 +1 @@
|
||||||
<svg width="180" height="180" viewBox="0 0 180 180" fill="none" xmlns="http://www.w3.org/2000/svg">
|
<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="1200" fill="none"><rect width="1200" height="1200" fill="#EAEAEA" rx="3"/><g opacity=".5"><g opacity=".5"><path fill="#FAFAFA" d="M600.709 736.5c-75.454 0-136.621-61.167-136.621-136.62 0-75.454 61.167-136.621 136.621-136.621 75.453 0 136.62 61.167 136.62 136.621 0 75.453-61.167 136.62-136.62 136.62Z"/><path stroke="#C9C9C9" stroke-width="2.418" d="M600.709 736.5c-75.454 0-136.621-61.167-136.621-136.62 0-75.454 61.167-136.621 136.621-136.621 75.453 0 136.62 61.167 136.62 136.621 0 75.453-61.167 136.62-136.62 136.62Z"/></g><path stroke="url(#a)" stroke-width="2.418" d="M0-1.209h553.581" transform="scale(1 -1) rotate(45 1163.11 91.165)"/><path stroke="url(#b)" stroke-width="2.418" d="M404.846 598.671h391.726"/><path stroke="url(#c)" stroke-width="2.418" d="M599.5 795.742V404.017"/><path stroke="url(#d)" stroke-width="2.418" d="m795.717 796.597-391.441-391.44"/><path fill="#fff" d="M600.709 656.704c-31.384 0-56.825-25.441-56.825-56.824 0-31.384 25.441-56.825 56.825-56.825 31.383 0 56.824 25.441 56.824 56.825 0 31.383-25.441 56.824-56.824 56.824Z"/><g clip-path="url(#e)"><path fill="#666" fill-rule="evenodd" d="M616.426 586.58h-31.434v16.176l3.553-3.554.531-.531h9.068l.074-.074 8.463-8.463h2.565l7.18 7.181V586.58Zm-15.715 14.654 3.698 3.699 1.283 1.282-2.565 2.565-1.282-1.283-5.2-5.199h-6.066l-5.514 5.514-.073.073v2.876a2.418 2.418 0 0 0 2.418 2.418h26.598a2.418 2.418 0 0 0 2.418-2.418v-8.317l-8.463-8.463-7.181 7.181-.071.072Zm-19.347 5.442v4.085a6.045 6.045 0 0 0 6.046 6.045h26.598a6.044 6.044 0 0 0 6.045-6.045v-7.108l1.356-1.355-1.282-1.283-.074-.073v-17.989h-38.689v23.43l-.146.146.146.147Z" clip-rule="evenodd"/></g><path stroke="#C9C9C9" stroke-width="2.418" d="M600.709 656.704c-31.384 0-56.825-25.441-56.825-56.824 0-31.384 25.441-56.825 56.825-56.825 31.383 0 56.824 25.441 56.824 56.825 0 31.383-25.441 56.824-56.824 56.824Z"/></g><defs><linearGradient id="a" x1="554.061" x2="-.48" y1=".083" y2=".087" gradientUnits="userSpaceOnUse"><stop stop-color="#C9C9C9" stop-opacity="0"/><stop offset=".208" stop-color="#C9C9C9"/><stop offset=".792" stop-color="#C9C9C9"/><stop offset="1" stop-color="#C9C9C9" stop-opacity="0"/></linearGradient><linearGradient id="b" x1="796.912" x2="404.507" y1="599.963" y2="599.965" gradientUnits="userSpaceOnUse"><stop stop-color="#C9C9C9" stop-opacity="0"/><stop offset=".208" stop-color="#C9C9C9"/><stop offset=".792" stop-color="#C9C9C9"/><stop offset="1" stop-color="#C9C9C9" stop-opacity="0"/></linearGradient><linearGradient id="c" x1="600.792" x2="600.794" y1="403.677" y2="796.082" gradientUnits="userSpaceOnUse"><stop stop-color="#C9C9C9" stop-opacity="0"/><stop offset=".208" stop-color="#C9C9C9"/><stop offset=".792" stop-color="#C9C9C9"/><stop offset="1" stop-color="#C9C9C9" stop-opacity="0"/></linearGradient><linearGradient id="d" x1="404.85" x2="796.972" y1="403.903" y2="796.02" gradientUnits="userSpaceOnUse"><stop stop-color="#C9C9C9" stop-opacity="0"/><stop offset=".208" stop-color="#C9C9C9"/><stop offset=".792" stop-color="#C9C9C9"/><stop offset="1" stop-color="#C9C9C9" stop-opacity="0"/></linearGradient><clipPath id="e"><path fill="#fff" d="M581.364 580.535h38.689v38.689h-38.689z"/></clipPath></defs></svg>
|
||||||
<style>
|
|
||||||
@media (prefers-color-scheme: light) {
|
|
||||||
.background { fill: black; }
|
|
||||||
.foreground { fill: white; }
|
|
||||||
}
|
|
||||||
@media (prefers-color-scheme: dark) {
|
|
||||||
.background { fill: white; }
|
|
||||||
.foreground { fill: black; }
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
<g clip-path="url(#clip0_7960_43945)">
|
|
||||||
<rect class="background" width="180" height="180" rx="37" />
|
|
||||||
<g style="transform: scale(95%); transform-origin: center">
|
|
||||||
<path class="foreground"
|
|
||||||
d="M101.141 53H136.632C151.023 53 162.689 64.6662 162.689 79.0573V112.904H148.112V79.0573C148.112 78.7105 148.098 78.3662 148.072 78.0251L112.581 112.898C112.701 112.902 112.821 112.904 112.941 112.904H148.112V126.672H112.941C98.5504 126.672 86.5638 114.891 86.5638 100.5V66.7434H101.141V100.5C101.141 101.15 101.191 101.792 101.289 102.422L137.56 66.7816C137.255 66.7563 136.945 66.7434 136.632 66.7434H101.141V53Z" />
|
|
||||||
<path class="foreground"
|
|
||||||
d="M65.2926 124.136L14 66.7372H34.6355L64.7495 100.436V66.7372H80.1365V118.47C80.1365 126.278 70.4953 129.958 65.2926 124.136Z" />
|
|
||||||
</g>
|
|
||||||
</g>
|
|
||||||
<defs>
|
|
||||||
<clipPath id="clip0_7960_43945">
|
|
||||||
<rect width="180" height="180" fill="white" />
|
|
||||||
</clipPath>
|
|
||||||
</defs>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 1.3 KiB After Width: | Height: | Size: 3.2 KiB |
|
|
@ -1 +1 @@
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="215" height="48" fill="none"><path fill="#000" d="M57.588 9.6h6L73.828 38h-5.2l-2.36-6.88h-11.36L52.548 38h-5.2l10.24-28.4Zm7.16 17.16-4.16-12.16-4.16 12.16h8.32Zm23.694-2.24c-.186-1.307-.706-2.32-1.56-3.04-.853-.72-1.866-1.08-3.04-1.08-1.68 0-2.986.613-3.92 1.84-.906 1.227-1.36 2.947-1.36 5.16s.454 3.933 1.36 5.16c.934 1.227 2.24 1.84 3.92 1.84 1.254 0 2.307-.373 3.16-1.12.854-.773 1.387-1.867 1.6-3.28l5.12.24c-.186 1.68-.733 3.147-1.64 4.4-.906 1.227-2.08 2.173-3.52 2.84-1.413.667-2.986 1-4.72 1-2.08 0-3.906-.453-5.48-1.36-1.546-.907-2.76-2.2-3.64-3.88-.853-1.68-1.28-3.627-1.28-5.84 0-2.24.427-4.187 1.28-5.84.88-1.68 2.094-2.973 3.64-3.88 1.574-.907 3.4-1.36 5.48-1.36 1.68 0 3.227.32 4.64.96 1.414.64 2.56 1.56 3.44 2.76.907 1.2 1.454 2.6 1.64 4.2l-5.12.28Zm11.486-7.72.12 3.4c.534-1.227 1.307-2.173 2.32-2.84 1.04-.693 2.267-1.04 3.68-1.04 1.494 0 2.76.387 3.8 1.16 1.067.747 1.827 1.813 2.28 3.2.507-1.44 1.294-2.52 2.36-3.24 1.094-.747 2.414-1.12 3.96-1.12 1.414 0 2.64.307 3.68.92s1.84 1.52 2.4 2.72c.56 1.2.84 2.667.84 4.4V38h-4.96V25.92c0-1.813-.293-3.187-.88-4.12-.56-.96-1.413-1.44-2.56-1.44-.906 0-1.68.213-2.32.64-.64.427-1.133 1.053-1.48 1.88-.32.827-.48 1.84-.48 3.04V38h-4.56V25.92c0-1.2-.133-2.213-.4-3.04-.24-.827-.626-1.453-1.16-1.88-.506-.427-1.133-.64-1.88-.64-.906 0-1.68.227-2.32.68-.64.427-1.133 1.053-1.48 1.88-.32.827-.48 1.827-.48 3V38h-4.96V16.8h4.48Zm26.723 10.6c0-2.24.427-4.187 1.28-5.84.854-1.68 2.067-2.973 3.64-3.88 1.574-.907 3.4-1.36 5.48-1.36 1.84 0 3.494.413 4.96 1.24 1.467.827 2.64 2.08 3.52 3.76.88 1.653 1.347 3.693 1.4 6.12v1.32h-15.08c.107 1.813.614 3.227 1.52 4.24.907.987 2.134 1.48 3.68 1.48.987 0 1.88-.253 2.68-.76a4.803 4.803 0 0 0 1.84-2.2l5.08.36c-.64 2.027-1.84 3.64-3.6 4.84-1.733 1.173-3.733 1.76-6 1.76-2.08 0-3.906-.453-5.48-1.36-1.573-.907-2.786-2.2-3.64-3.88-.853-1.68-1.28-3.627-1.28-5.84Zm15.16-2.04c-.213-1.733-.76-3.013-1.64-3.84-.853-.827-1.893-1.24-3.12-1.24-1.44 0-2.6.453-3.48 1.36-.88.88-1.44 2.12-1.68 3.72h9.92ZM163.139 9.6V38h-5.04V9.6h5.04Zm8.322 7.2.24 5.88-.64-.36c.32-2.053 1.094-3.56 2.32-4.52 1.254-.987 2.787-1.48 4.6-1.48 2.32 0 4.107.733 5.36 2.2 1.254 1.44 1.88 3.387 1.88 5.84V38h-4.96V25.92c0-1.253-.12-2.28-.36-3.08-.24-.8-.64-1.413-1.2-1.84-.533-.427-1.253-.64-2.16-.64-1.44 0-2.573.48-3.4 1.44-.8.933-1.2 2.307-1.2 4.12V38h-4.96V16.8h4.48Zm30.003 7.72c-.186-1.307-.706-2.32-1.56-3.04-.853-.72-1.866-1.08-3.04-1.08-1.68 0-2.986.613-3.92 1.84-.906 1.227-1.36 2.947-1.36 5.16s.454 3.933 1.36 5.16c.934 1.227 2.24 1.84 3.92 1.84 1.254 0 2.307-.373 3.16-1.12.854-.773 1.387-1.867 1.6-3.28l5.12.24c-.186 1.68-.733 3.147-1.64 4.4-.906 1.227-2.08 2.173-3.52 2.84-1.413.667-2.986 1-4.72 1-2.08 0-3.906-.453-5.48-1.36-1.546-.907-2.76-2.2-3.64-3.88-.853-1.68-1.28-3.627-1.28-5.84 0-2.24.427-4.187 1.28-5.84.88-1.68 2.094-2.973 3.64-3.88 1.574-.907 3.4-1.36 5.48-1.36 1.68 0 3.227.32 4.64.96 1.414.64 2.56 1.56 3.44 2.76.907 1.2 1.454 2.6 1.64 4.2l-5.12.28Zm11.443 8.16V38h-5.6v-5.32h5.6Z"/><path fill="#171717" fill-rule="evenodd" d="m7.839 40.783 16.03-28.054L20 6 0 40.783h7.839Zm8.214 0H40L27.99 19.894l-4.02 7.032 3.976 6.914H20.02l-3.967 6.943Z" clip-rule="evenodd"/></svg>
|
<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="1200" fill="none"><rect width="1200" height="1200" fill="#EAEAEA" rx="3"/><g opacity=".5"><g opacity=".5"><path fill="#FAFAFA" d="M600.709 736.5c-75.454 0-136.621-61.167-136.621-136.62 0-75.454 61.167-136.621 136.621-136.621 75.453 0 136.62 61.167 136.62 136.621 0 75.453-61.167 136.62-136.62 136.62Z"/><path stroke="#C9C9C9" stroke-width="2.418" d="M600.709 736.5c-75.454 0-136.621-61.167-136.621-136.62 0-75.454 61.167-136.621 136.621-136.621 75.453 0 136.62 61.167 136.62 136.621 0 75.453-61.167 136.62-136.62 136.62Z"/></g><path stroke="url(#a)" stroke-width="2.418" d="M0-1.209h553.581" transform="scale(1 -1) rotate(45 1163.11 91.165)"/><path stroke="url(#b)" stroke-width="2.418" d="M404.846 598.671h391.726"/><path stroke="url(#c)" stroke-width="2.418" d="M599.5 795.742V404.017"/><path stroke="url(#d)" stroke-width="2.418" d="m795.717 796.597-391.441-391.44"/><path fill="#fff" d="M600.709 656.704c-31.384 0-56.825-25.441-56.825-56.824 0-31.384 25.441-56.825 56.825-56.825 31.383 0 56.824 25.441 56.824 56.825 0 31.383-25.441 56.824-56.824 56.824Z"/><g clip-path="url(#e)"><path fill="#666" fill-rule="evenodd" d="M616.426 586.58h-31.434v16.176l3.553-3.554.531-.531h9.068l.074-.074 8.463-8.463h2.565l7.18 7.181V586.58Zm-15.715 14.654 3.698 3.699 1.283 1.282-2.565 2.565-1.282-1.283-5.2-5.199h-6.066l-5.514 5.514-.073.073v2.876a2.418 2.418 0 0 0 2.418 2.418h26.598a2.418 2.418 0 0 0 2.418-2.418v-8.317l-8.463-8.463-7.181 7.181-.071.072Zm-19.347 5.442v4.085a6.045 6.045 0 0 0 6.046 6.045h26.598a6.044 6.044 0 0 0 6.045-6.045v-7.108l1.356-1.355-1.282-1.283-.074-.073v-17.989h-38.689v23.43l-.146.146.146.147Z" clip-rule="evenodd"/></g><path stroke="#C9C9C9" stroke-width="2.418" d="M600.709 656.704c-31.384 0-56.825-25.441-56.825-56.824 0-31.384 25.441-56.825 56.825-56.825 31.383 0 56.824 25.441 56.824 56.825 0 31.383-25.441 56.824-56.824 56.824Z"/></g><defs><linearGradient id="a" x1="554.061" x2="-.48" y1=".083" y2=".087" gradientUnits="userSpaceOnUse"><stop stop-color="#C9C9C9" stop-opacity="0"/><stop offset=".208" stop-color="#C9C9C9"/><stop offset=".792" stop-color="#C9C9C9"/><stop offset="1" stop-color="#C9C9C9" stop-opacity="0"/></linearGradient><linearGradient id="b" x1="796.912" x2="404.507" y1="599.963" y2="599.965" gradientUnits="userSpaceOnUse"><stop stop-color="#C9C9C9" stop-opacity="0"/><stop offset=".208" stop-color="#C9C9C9"/><stop offset=".792" stop-color="#C9C9C9"/><stop offset="1" stop-color="#C9C9C9" stop-opacity="0"/></linearGradient><linearGradient id="c" x1="600.792" x2="600.794" y1="403.677" y2="796.082" gradientUnits="userSpaceOnUse"><stop stop-color="#C9C9C9" stop-opacity="0"/><stop offset=".208" stop-color="#C9C9C9"/><stop offset=".792" stop-color="#C9C9C9"/><stop offset="1" stop-color="#C9C9C9" stop-opacity="0"/></linearGradient><linearGradient id="d" x1="404.85" x2="796.972" y1="403.903" y2="796.02" gradientUnits="userSpaceOnUse"><stop stop-color="#C9C9C9" stop-opacity="0"/><stop offset=".208" stop-color="#C9C9C9"/><stop offset=".792" stop-color="#C9C9C9"/><stop offset="1" stop-color="#C9C9C9" stop-opacity="0"/></linearGradient><clipPath id="e"><path fill="#fff" d="M581.364 580.535h38.689v38.689h-38.689z"/></clipPath></defs></svg>
|
||||||
|
Before Width: | Height: | Size: 3.1 KiB After Width: | Height: | Size: 3.2 KiB |