364 lines
16 KiB
TypeScript
364 lines
16 KiB
TypeScript
import React, { useState, useEffect } from "react";
|
|
import { Button } from "./ui/button";
|
|
import Table from "./ui/table";
|
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from "./ui/dialog";
|
|
import { Input } from "./ui/input";
|
|
import { Textarea } from "./ui/textarea";
|
|
import { Label } from "./ui/label";
|
|
import { Select, SelectTrigger, SelectValue, SelectContent, SelectItem } from "./ui/select";
|
|
import { CustomTabs } from "./ui/CustomTabs";
|
|
import {localizeTime} from "../lib/localizeTime";
|
|
interface Ticket {
|
|
id: number;
|
|
title: string;
|
|
description: string;
|
|
status: 'open' | 'in-progress' | 'resolved' | 'closed'; // Add 'closed' here
|
|
created_at: string;
|
|
updated_at: string;
|
|
created_by: number;
|
|
}
|
|
|
|
interface Message {
|
|
id: number;
|
|
ticket_id: number;
|
|
user_id: number;
|
|
message: string;
|
|
created_at: string;
|
|
user_type: string;
|
|
}
|
|
|
|
const PUBLIC_TICKET_API_URL = import.meta.env.PUBLIC_TICKET_API_URL;
|
|
|
|
function Ticketing() {
|
|
const [tickets, setTickets] = useState<Ticket[]>([]);
|
|
const [messages, setMessages] = useState<Message[]>([]);
|
|
const [selectedTicket, setSelectedTicket] = useState<(Ticket & { status: 'open' | 'in-progress' | 'resolved' | 'closed' }) | null>(null);
|
|
const [activeTab, setActiveTab] = useState<'open' | 'in-progress' | 'resolved' | 'closed'>('open');
|
|
const [isDialogOpen, setIsDialogOpen] = useState(false);
|
|
const [isMessageDialogOpen, setIsMessageDialogOpen] = useState(false);
|
|
const [newMessage, setNewMessage] = useState('');
|
|
const [formData, setFormData] = useState({title: '', description: '', status: 'open' as const, });
|
|
|
|
// Fetch tickets
|
|
useEffect(() => {fetchTickets(); }, [activeTab]);
|
|
|
|
// Fetch messages when ticket is selected
|
|
useEffect(() => {if (selectedTicket) {fetchMessages(selectedTicket.id);}}, [selectedTicket]);
|
|
|
|
const fetchTickets = async () => {
|
|
try {
|
|
const response = await fetch(`${PUBLIC_TICKET_API_URL}?action=get_tickets&status=${activeTab}`);
|
|
const data = await response.json();
|
|
if (data.success) {
|
|
setTickets(data.tickets);
|
|
}
|
|
} catch (error) {
|
|
console.error('Error fetching tickets:', error);
|
|
}
|
|
};
|
|
|
|
const fetchMessages = async (ticketId: number) => {
|
|
try {
|
|
const response = await fetch(`${PUBLIC_TICKET_API_URL}?action=get_messages&ticket_id=${ticketId}`);
|
|
const data = await response.json();
|
|
console.log('Messages response:', data); // Debug log
|
|
if (data.success) {
|
|
setMessages(data.messages || []);
|
|
}
|
|
} catch (error) {
|
|
console.error('Error fetching messages:', error);
|
|
}
|
|
};
|
|
|
|
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
|
|
const { name, value } = e.target;
|
|
setFormData(prev => ({ ...prev, [name]: value }));
|
|
};
|
|
|
|
const handleSubmit = async (e: React.FormEvent) => {
|
|
e.preventDefault();
|
|
try {
|
|
const response = await fetch(PUBLIC_TICKET_API_URL, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
action: 'create_ticket',
|
|
...formData,
|
|
}),
|
|
});
|
|
const data = await response.json();
|
|
if (data.success) {
|
|
fetchTickets(); // Refresh the list
|
|
setIsDialogOpen(false);
|
|
setFormData({ title: '', description: '', status: 'open' });
|
|
}
|
|
} catch (error) {
|
|
console.error('Error creating ticket:', error);
|
|
}
|
|
};
|
|
|
|
const handleStatusChange = async (ticketId: number, newStatus: 'open' | 'in-progress' | 'resolved' | 'closed') => {
|
|
try {
|
|
const response = await fetch(PUBLIC_TICKET_API_URL, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
action: 'update_ticket_status',
|
|
ticket_id: ticketId,
|
|
status: newStatus,
|
|
}),
|
|
});
|
|
const data = await response.json();
|
|
if (data.success) {
|
|
fetchTickets(); // Refresh the list
|
|
if (selectedTicket) {
|
|
setSelectedTicket({ ...selectedTicket, status: newStatus });
|
|
}
|
|
}
|
|
} catch (error) {
|
|
console.error('Error updating ticket status:', error);
|
|
}
|
|
};
|
|
|
|
const handleSendMessage = async () => {
|
|
if (!selectedTicket || !newMessage.trim()) return;
|
|
|
|
try {
|
|
const response = await fetch(PUBLIC_TICKET_API_URL, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
action: 'add_message',
|
|
ticket_id: selectedTicket.id,
|
|
message: newMessage,
|
|
user_type: 'user'
|
|
}),
|
|
});
|
|
const data = await response.json();
|
|
if (data.success) {
|
|
fetchMessages(selectedTicket.id); // Refresh messages
|
|
setNewMessage('');
|
|
}
|
|
} catch (error) {
|
|
console.error('Error sending message:', error);
|
|
}
|
|
};
|
|
|
|
const handleDeleteTicket = async (ticketId: number) => {
|
|
try {
|
|
const response = await fetch(PUBLIC_TICKET_API_URL, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
action: 'delete_ticket',
|
|
ticket_id: ticketId,
|
|
}),
|
|
});
|
|
const data = await response.json();
|
|
if (data.success) {
|
|
fetchTickets(); // Refresh the list
|
|
if (selectedTicket?.id === ticketId) {
|
|
setIsMessageDialogOpen(false);
|
|
}
|
|
}
|
|
} catch (error) {
|
|
console.error('Error deleting ticket:', error);
|
|
}
|
|
};
|
|
|
|
// Table configuration
|
|
const tableHeaders = ['ID', 'Title', 'Description', 'Status', 'Created At', 'Actions'];
|
|
const tableData = tickets.map(ticket => ({
|
|
id: ticket.id,
|
|
title: ticket.title,
|
|
description: ticket.description,
|
|
status: ticket.status,
|
|
created_at: localizeTime(ticket.created_at),
|
|
actions: (
|
|
<div className="flex space-x-2">
|
|
<Button size="sm" onClick={() => {setSelectedTicket(ticket); setIsMessageDialogOpen(true); }}>View/Reply</Button>
|
|
<Button size="sm" onClick={() => handleDeleteTicket(ticket.id)} className="bg-red-500 hover:bg-red-600">Delete</Button>
|
|
</div>
|
|
)
|
|
}));
|
|
const statusColors = {open: 'bg-green-500', 'in-progress': 'bg-yellow-500', resolved: 'bg-blue-500', closed: 'bg-red-500', };
|
|
|
|
return (
|
|
<div className="container mx-auto p-4">
|
|
<div className="flex justify-between items-center mb-6">
|
|
<h1 className="text-2xl font-bold">Ticket / Report</h1>
|
|
<Button size="sm" onClick={() => setIsDialogOpen(true)}>Create Ticket</Button>
|
|
</div>
|
|
|
|
<CustomTabs
|
|
tabs={[
|
|
{ label: 'Open', value: 'open', content: null },
|
|
{ label: 'In Progress', value: 'in-progress', content: null },
|
|
{ label: 'Resolved', value: 'resolved', content: null },
|
|
{ label: 'Closed', value: 'closed', content: null },
|
|
]}
|
|
defaultValue={activeTab}
|
|
onValueChange={(value) => setActiveTab(value as any)}
|
|
/>
|
|
|
|
<table className="w-full border-collapse">
|
|
<thead>
|
|
<tr className="bg-neutral-800 text-white">
|
|
{
|
|
tableHeaders.map((thead, index) => (
|
|
<th key={index} className={`p-2 text-center border-[1px] border-[#6d9e37]`}>{thead}</th>
|
|
))
|
|
}
|
|
</tr>
|
|
</thead>
|
|
{
|
|
tableData.length > 0 ? (
|
|
<tbody className="[&>tr:nth-child(even)]:bg-[#262626]">
|
|
{
|
|
tableData.map((data, index) => (
|
|
<tr key={index}>
|
|
<td className="px-2 border-[1px] border-[#6d9e37] font-medium">{data.id}</td>
|
|
<td className="px-2 border-[1px] border-[#6d9e37] font-medium">{data.title}</td>
|
|
<td className="px-2 border-[1px] border-[#6d9e37] font-medium">{data.description}</td>
|
|
<td className="px-2 border-[1px] border-[#6d9e37] font-medium text-center">
|
|
<span className={`text-white px-2 py-1 rounded ${statusColors[data.status] || ''}`}>{data.status}</span>
|
|
</td>
|
|
<td className="px-2 border-[1px] border-[#6d9e37] font-medium text-center">{data.created_at}</td>
|
|
<td className="px-2 border-[1px] border-[#6d9e37] font-medium flex items-center justify-center p-1">{data.actions}</td>
|
|
</tr>
|
|
|
|
))
|
|
}
|
|
</tbody>
|
|
) : (
|
|
<tbody>
|
|
<tr>
|
|
<th colSpan={6} className="p-4 border-[1px] border-[#6d9e37] font-medium">No {activeTab.charAt(0).toUpperCase() + activeTab.slice(1)} Ticket Found</th>
|
|
</tr>
|
|
</tbody>
|
|
)
|
|
}
|
|
</table>
|
|
|
|
{/* <Table headers={tableHeaders} data={tableData} striped hover /> */}
|
|
|
|
{/* Create Ticket Dialog */}
|
|
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
|
|
<DialogContent>
|
|
<DialogHeader>
|
|
<DialogTitle>Create New Ticket</DialogTitle>
|
|
</DialogHeader>
|
|
<form onSubmit={handleSubmit} className="space-y-4">
|
|
<div>
|
|
<Label htmlFor="title">Title</Label>
|
|
<Input id="title" name="title" value={formData.title} onChange={handleInputChange} required className="w-full bg-white rounded-md outline-none border border-[#6d9e37] p-2" placeholder="Write ticket title" />
|
|
</div>
|
|
<div>
|
|
<Label htmlFor="description">Description</Label>
|
|
<textarea id="description" name="description" value={formData.description} onChange={handleInputChange} rows={4} className="w-full rounded-md outline-none border border-[#6d9e37] p-2" required placeholder="Write description here..."></textarea>
|
|
</div>
|
|
<div className="flex justify-end space-x-2">
|
|
<Button variant="outline" onClick={() => setIsDialogOpen(false)}>Cancel</Button>
|
|
<Button type="submit">Create</Button>
|
|
</div>
|
|
</form>
|
|
</DialogContent>
|
|
</Dialog>
|
|
|
|
{/* Ticket Messages Dialog */}
|
|
<Dialog open={isMessageDialogOpen} onOpenChange={setIsMessageDialogOpen}>
|
|
<DialogContent className="max-w-2xl">
|
|
<DialogHeader>
|
|
<DialogTitle>Ticket #{selectedTicket?.id}: {selectedTicket?.title}</DialogTitle>
|
|
</DialogHeader>
|
|
{selectedTicket && (
|
|
<div className="space-y-4">
|
|
<div className="p-4 bg-gray-50 rounded">
|
|
<p className="font-semibold text-gray-500">Ticket Details</p>
|
|
<p className="text-gray-500">{selectedTicket.description}</p>
|
|
<p className="text-sm text-gray-500 mt-2 ">
|
|
<strong>Status:</strong> <span className={`font-bold px-1 rounded-md text-white ${statusColors[selectedTicket.status] || ''}`}>{selectedTicket.status}</span> |
|
|
<strong>Created:</strong> {new Date(selectedTicket.created_at).toLocaleString()}
|
|
</p>
|
|
</div>
|
|
<div className="border-t pt-4">
|
|
<p className="font-semibold mb-2 text-[#6d9e37]">Conversation</p>
|
|
<div className="space-y-4 max-h-64 overflow-y-auto">
|
|
{
|
|
messages.length > 0 ? (
|
|
messages.map(message => (
|
|
<div key={message.id} className={`px-3 py-1 border-b rounded flex flex-col text-gray-500 ${message.user_type === 'user' ? 'justify-end items-end border-[#6d9e37]' : 'justify-start items-start border-gray-500'} `}>
|
|
<p>{message.message}</p>
|
|
<p className="text-xs text-gray-500 mt-1 inline-flex items-center gap-1">
|
|
<img src="/assets/clock.svg" alt="" />
|
|
{localizeTime(message.created_at)}</p>
|
|
</div>
|
|
))
|
|
) : (
|
|
<p className="text-gray-500">No messages yet</p>
|
|
)
|
|
}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="border-t pt-4">
|
|
<Label htmlFor="newMessage" className="text-[#6d9e37]">Add Reply</Label>
|
|
<div className="flex flex-row gap-x-2">
|
|
<textarea placeholder="Write your message..." id="newMessage" rows={1} value={newMessage} onChange={(e) => setNewMessage(e.target.value)} className="w-full p-2 mb-2 border border-[#6d9e37] outline-none focus:outline-none rounded text-gray-500 resize-none"></textarea>
|
|
<Button onClick={handleSendMessage} disabled={!newMessage.trim()} className="px-6">
|
|
<img src="/assets/send.svg" alt="" />
|
|
</Button>
|
|
</div>
|
|
|
|
<div className="flex justify-between">
|
|
<div className="space-x-2">
|
|
{selectedTicket.status === 'closed' ? (
|
|
<Button
|
|
size="sm"
|
|
variant="default"
|
|
onClick={() => handleStatusChange(selectedTicket.id, 'open')}
|
|
className="bg-green-600 hover:bg-green-700"
|
|
>
|
|
Reopen
|
|
</Button>
|
|
) : (
|
|
<>
|
|
{selectedTicket.status !== 'in-progress' && (
|
|
<Button
|
|
size="sm"
|
|
variant="outline"
|
|
onClick={() => handleStatusChange(selectedTicket.id, 'in-progress')}
|
|
>
|
|
Mark as In Progress
|
|
</Button>
|
|
)}
|
|
{selectedTicket.status !== 'resolved' && (
|
|
<Button
|
|
size="sm"
|
|
variant="outline"
|
|
onClick={() => handleStatusChange(selectedTicket.id, 'resolved')}
|
|
>
|
|
Mark as Resolved
|
|
</Button>
|
|
)}
|
|
<Button
|
|
size="sm"
|
|
variant="outline"
|
|
onClick={() => handleStatusChange(selectedTicket.id, 'closed')}
|
|
>
|
|
Close
|
|
</Button>
|
|
</>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</DialogContent>
|
|
</Dialog>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export default Ticketing; |