diff --git a/package-lock.json b/package-lock.json index 4f37710..296aba7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,13 +12,16 @@ "@astrojs/tailwind": "^6.0.0", "@radix-ui/react-dialog": "^1.1.6", "@radix-ui/react-select": "^2.1.6", + "@radix-ui/react-tabs": "^1.1.3", "@radix-ui/react-toast": "^1.2.6", "@shadcn/ui": "^0.0.4", + "@types/date-fns": "^2.5.3", "@types/react": "^19.0.12", "astro": "^5.5.2", "autoprefixer": "^10.4.21", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "date-fns": "^4.1.0", "lucide-react": "^0.484.0", "pocketbase": "^0.25.2", "postcss": "^8.5.3", @@ -1736,6 +1739,37 @@ } } }, + "node_modules/@radix-ui/react-roving-focus": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.2.tgz", + "integrity": "sha512-zgMQWkNO169GtGqRvYrzb0Zf8NhMHS2DuEB/TiEmVnpr5OqPU3i8lfbxaAmC2J/KYuIQxyoQQ6DxepyXp61/xw==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.1", + "@radix-ui/react-collection": "1.1.2", + "@radix-ui/react-compose-refs": "1.1.1", + "@radix-ui/react-context": "1.1.1", + "@radix-ui/react-direction": "1.1.0", + "@radix-ui/react-id": "1.1.0", + "@radix-ui/react-primitive": "2.0.2", + "@radix-ui/react-use-callback-ref": "1.1.0", + "@radix-ui/react-use-controllable-state": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-select": { "version": "2.1.6", "resolved": "https://registry.npmjs.org/@radix-ui/react-select/-/react-select-2.1.6.tgz", @@ -1797,6 +1831,36 @@ } } }, + "node_modules/@radix-ui/react-tabs": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-tabs/-/react-tabs-1.1.3.tgz", + "integrity": "sha512-9mFyI30cuRDImbmFF6O2KUJdgEOsGh9Vmx9x/Dh9tOhL7BngmQPQfwW4aejKm5OHpfWIdmeV6ySyuxoOGjtNng==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.1", + "@radix-ui/react-context": "1.1.1", + "@radix-ui/react-direction": "1.1.0", + "@radix-ui/react-id": "1.1.0", + "@radix-ui/react-presence": "1.1.2", + "@radix-ui/react-primitive": "2.0.2", + "@radix-ui/react-roving-focus": "1.1.2", + "@radix-ui/react-use-controllable-state": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-toast": { "version": "1.2.6", "resolved": "https://registry.npmjs.org/@radix-ui/react-toast/-/react-toast-1.2.6.tgz", @@ -2399,6 +2463,12 @@ "integrity": "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==", "license": "MIT" }, + "node_modules/@types/date-fns": { + "version": "2.5.3", + "resolved": "https://registry.npmjs.org/@types/date-fns/-/date-fns-2.5.3.tgz", + "integrity": "sha512-4KVPD3g5RjSgZtdOjvI/TDFkLNUHhdoWxmierdQbDeEg17Rov0hbBYtIzNaQA67ORpteOhvR9YEMTb6xeDCang==", + "license": "MIT" + }, "node_modules/@types/debug": { "version": "4.1.12", "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.12.tgz", @@ -3279,6 +3349,16 @@ "node": ">= 12" } }, + "node_modules/date-fns": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz", + "integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/kossnocorp" + } + }, "node_modules/debug": { "version": "4.4.0", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz", diff --git a/package.json b/package.json index 303d8c6..6c1da60 100644 --- a/package.json +++ b/package.json @@ -14,13 +14,16 @@ "@astrojs/tailwind": "^6.0.0", "@radix-ui/react-dialog": "^1.1.6", "@radix-ui/react-select": "^2.1.6", + "@radix-ui/react-tabs": "^1.1.3", "@radix-ui/react-toast": "^1.2.6", "@shadcn/ui": "^0.0.4", + "@types/date-fns": "^2.5.3", "@types/react": "^19.0.12", "astro": "^5.5.2", "autoprefixer": "^10.4.21", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "date-fns": "^4.1.0", "lucide-react": "^0.484.0", "pocketbase": "^0.25.2", "postcss": "^8.5.3", diff --git a/public/assets/clock.svg b/public/assets/clock.svg new file mode 100644 index 0000000..e9ed636 --- /dev/null +++ b/public/assets/clock.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/assets/send.svg b/public/assets/send.svg new file mode 100644 index 0000000..eb98d52 --- /dev/null +++ b/public/assets/send.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/components/HireAIAgent.tsx b/src/components/HireAIAgent.tsx index 5126279..f6dd1cc 100644 --- a/src/components/HireAIAgent.tsx +++ b/src/components/HireAIAgent.tsx @@ -279,42 +279,42 @@ export default function HireAIAgent() {

AI Agents vs. Human Developers

- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
FeatureAI AgentsHuman Developers
Availability24/7, instant accessLimited by working hours and availability
CostLow monthly subscriptionHigher hourly or project-based rates
Task ComplexityBest for repetitive, well-defined tasksBetter for complex, novel problems
CreativityLimited to patterns in training dataHigher level of creativity and innovation
ScalabilityHighly scalable at no extra costRequires additional hiring and onboarding
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
FeatureAI AgentsHuman Developers
Availability24/7, instant accessLimited by working hours and availability
CostLow monthly subscriptionHigher hourly or project-based rates
Task ComplexityBest for repetitive, well-defined tasksBetter for complex, novel problems
CreativityLimited to patterns in training dataHigher level of creativity and innovation
ScalabilityHighly scalable at no extra costRequires additional hiring and onboarding
diff --git a/src/components/Ticket.tsx b/src/components/Ticket.tsx new file mode 100644 index 0000000..ea2975e --- /dev/null +++ b/src/components/Ticket.tsx @@ -0,0 +1,365 @@ +import React, { useState, useEffect } from "react"; +import { Button } from "./ui/button"; +import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "./ui/card"; +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 API_URL = 'http://localhost:2058/host-api/v1/ticket/index.php'; + +function Ticketing() { + const [tickets, setTickets] = useState([]); + const [messages, setMessages] = useState([]); + 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(`${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(`${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) => { + const { name, value } = e.target; + setFormData(prev => ({ ...prev, [name]: value })); + }; + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + try { + const response = await fetch(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(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(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(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: ( +
+ + +
+ ) + })); + const statusColors = {open: 'bg-green-500', 'in-progress': 'bg-yellow-500', resolved: 'bg-blue-500', closed: 'bg-red-500', }; + + return ( +
+
+

Ticket / Report

+ +
+ + setActiveTab(value as any)} + /> + + + + + { + tableHeaders.map((thead) => ( + + )) + } + + + { + tableData.length > 0 ? ( + + { + tableData.map((data, index) => ( + + + + + + + + + + )) + } + + ) : ( + + + + + + ) + } +
{thead}
{data.id}{data.title}{data.description} + {data.status} + {data.created_at}{data.actions}
No {activeTab.charAt(0).toUpperCase() + activeTab.slice(1)} Ticket Found
+ + {/* */} + + {/* Create Ticket Dialog */} + + + + Create New Ticket + +
+
+ + +
+
+ + +
+
+ + +
+ +
+
+ + {/* Ticket Messages Dialog */} + + + + Ticket #{selectedTicket?.id}: {selectedTicket?.title} + + {selectedTicket && ( +
+
+

Ticket Details

+

{selectedTicket.description}

+

+ Status: {selectedTicket.status} |  + Created: {new Date(selectedTicket.created_at).toLocaleString()} +

+
+
+

Conversation

+
+ { + messages.length > 0 ? ( + messages.map(message => ( +
+

{message.message}

+

+ + {localizeTime(message.created_at)}

+
+ )) + ) : ( +

No messages yet

+ ) + } +
+
+ +
+ +
+ + +
+ +
+
+ {selectedTicket.status === 'closed' ? ( + + ) : ( + <> + {selectedTicket.status !== 'in-progress' && ( + + )} + {selectedTicket.status !== 'resolved' && ( + + )} + + + )} +
+
+
+
+ )} +
+
+ + ); +} + +export default Ticketing; \ No newline at end of file diff --git a/src/components/Tickiting copy.tsx b/src/components/Tickiting copy.tsx new file mode 100644 index 0000000..6d3620f --- /dev/null +++ b/src/components/Tickiting copy.tsx @@ -0,0 +1,590 @@ +import React, { useState, useEffect } from "react"; +import { Button } from "./ui/button"; +import { Input } from "./ui/input"; +import { Label } from "./ui/label"; +import { Textarea } from "./ui/textarea"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select"; +import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "./ui/dialog"; +import { CustomTabs } from "./ui/CustomTabs"; +import Table from "./ui/table"; +import { Avatar, AvatarFallback, AvatarImage } from "./ui/avatar"; +import { Card, CardHeader, CardContent, CardFooter } from "./ui/card"; +import { Badge } from "./ui/badge"; +import { formatDistanceToNow } from "./ui/date-format"; + +interface Message { + id?: number; + content: string; + sender: 'user' | 'admin'; + createdAt: string; +} + +interface Ticket { + id: number; + title: string; + description: string; + messages: Message[]; + status: 'open' | 'in-progress' | 'resolved' | 'closed'; + priority: 'low' | 'medium' | 'high'; + category?: string; + assignedTo?: string; + createdAt: string; + updatedAt: string; +} + +export default function Ticketing() { + const [tickets, setTickets] = useState([]); + const [filteredTickets, setFilteredTickets] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [isDialogOpen, setIsDialogOpen] = useState(false); + const [isDetailOpen, setIsDetailOpen] = useState(false); + const [currentTicket, setCurrentTicket] = useState>({ + title: '', + description: '', + status: 'open', + priority: 'medium', + category: 'general' + }); + const [selectedTicket, setSelectedTicket] = useState(null); + const [newMessage, setNewMessage] = useState(''); + const [isEditing, setIsEditing] = useState(false); + const [activeTab, setActiveTab] = useState('open'); + const [searchTerm, setSearchTerm] = useState(''); + + const API_URL = 'http://localhost:2058/host-api/v1/ticketing/index.php'; + + const fetchTickets = async () => { + try { + setLoading(true); + const response = await fetch(API_URL); + if (!response.ok) { + throw new Error('Failed to fetch tickets'); + } + const data = await response.json(); + // Convert admin_message to messages array for backward compatibility + const processedData = data.map((ticket: any) => ({ + ...ticket, + messages: [ + { + content: ticket.description, + sender: 'user', + createdAt: ticket.createdAt + }, + ...(ticket.admin_message ? [{ + content: ticket.admin_message, + sender: 'admin', + createdAt: ticket.updatedAt + }] : []) + ] + })); + setTickets(processedData); + filterTickets(processedData, activeTab, searchTerm); + } catch (err) { + setError(err instanceof Error ? err.message : 'An unknown error occurred'); + } finally { + setLoading(false); + } + }; + + const filterTickets = (ticketsToFilter: Ticket[], status: string, search: string) => { + let filtered = ticketsToFilter; + + if (status !== 'all') { + filtered = filtered.filter(ticket => ticket.status === status); + } + + if (search) { + const searchLower = search.toLowerCase(); + filtered = filtered.filter(ticket => + ticket.title.toLowerCase().includes(searchLower) || + ticket.description.toLowerCase().includes(searchLower) || + ticket.messages.some(msg => msg.content.toLowerCase().includes(searchLower)) + ); + } + + setFilteredTickets(filtered); + }; + + const saveTicket = async () => { + try { + const method = isEditing ? 'PUT' : 'POST'; + const url = isEditing ? `${API_URL}?id=${currentTicket.id}` : API_URL; + + const response = await fetch(url, { + method, + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + title: currentTicket.title, + description: currentTicket.description, + status: currentTicket.status, + priority: currentTicket.priority, + category: currentTicket.category + }), + }); + + if (!response.ok) { + const errorData = await response.json(); + throw new Error(errorData.error || `Failed to ${isEditing ? 'update' : 'create'} ticket`); + } + + await fetchTickets(); + setIsDialogOpen(false); + resetForm(); + } catch (err) { + setError(err instanceof Error ? err.message : 'An unknown error occurred'); + } + }; + + const addMessage = async (ticketId: number) => { + try { + if (!newMessage.trim()) return; + + const response = await fetch(`${API_URL}?id=${ticketId}`, { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + message: newMessage + }), + }); + + if (!response.ok) { + const errorData = await response.json(); + throw new Error(errorData.error || 'Failed to add message'); + } + + setNewMessage(''); + await fetchTickets(); + } catch (err) { + setError(err instanceof Error ? err.message : 'An unknown error occurred'); + } + }; + + const reopenTicket = async (ticketId: number) => { + try { + const response = await fetch(`${API_URL}?id=${ticketId}&action=reopen`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + }); + + if (!response.ok) { + throw new Error('Failed to reopen ticket'); + } + + await fetchTickets(); + } catch (err) { + setError(err instanceof Error ? err.message : 'An unknown error occurred'); + } + }; + + const deleteTicket = async (id: number) => { + try { + const response = await fetch(`${API_URL}?id=${id}`, { + method: 'DELETE', + }); + + if (!response.ok) { + throw new Error('Failed to delete ticket'); + } + + await fetchTickets(); + } catch (err) { + setError(err instanceof Error ? err.message : 'An unknown error occurred'); + } + }; + + const viewTicketDetails = (ticket: Ticket) => { + setSelectedTicket(ticket); + setIsDetailOpen(true); + }; + + const initNewTicket = () => { + setCurrentTicket({ + title: '', + description: '', + status: 'open', + priority: 'medium', + category: 'general' + }); + setIsEditing(false); + setIsDialogOpen(true); + }; + + const initEditTicket = (ticket: Ticket) => { + setCurrentTicket({ ...ticket }); + setIsEditing(true); + setIsDialogOpen(true); + }; + + const resetForm = () => { + setCurrentTicket({ + title: '', + description: '', + status: 'open', + priority: 'medium', + category: 'general' + }); + }; + + const handleInputChange = (e: React.ChangeEvent) => { + const { name, value } = e.target; + setCurrentTicket(prev => ({ ...prev, [name]: value })); + }; + + const handleSelectChange = (name: string, value: string) => { + setCurrentTicket(prev => ({ ...prev, [name]: value })); + }; + + useEffect(() => { + fetchTickets(); + }, []); + + useEffect(() => { + filterTickets(tickets, activeTab, searchTerm); + }, [activeTab, searchTerm, tickets]); + + const renderTicketTable = () => { + if (loading) { + return
Loading tickets...
; + } + + if (filteredTickets.length === 0) { + return
No tickets found
; + } + + const tableHeaders = ['ID', 'Title', 'Category', 'Status', 'Priority', 'Created', 'Actions']; + + const tableData = filteredTickets.map(ticket => ({ + 'ID': ticket.id, + 'Title': ( + + ), + 'Category': ticket.category || 'General', + 'Status': ( + + {ticket.status} + + ), + 'Priority': ( + + {ticket.priority} + + ), + 'Created': formatDistanceToNow(new Date(ticket.createdAt), { addSuffix: true }), + 'Actions': ( +
+ + + {ticket.status === 'closed' ? ( + + ) : ( + + )} +
+ ) + })); + + return ( +
+ ); + }; + + const renderMessages = () => { + if (!selectedTicket) return null; + + return ( +
+ {selectedTicket.messages.map((message, index) => ( +
+
+
+ + {message.sender === 'admin' ? 'A' : 'U'} + + {message.sender === 'admin' ? 'Admin' : 'You'} + + {formatDistanceToNow(new Date(message.createdAt), { addSuffix: true })} + +
+

{message.content}

+
+
+ ))} +
+ ); + }; + + const tabs = [ + { + label: "Open Tickets", + value: "open", + content: renderTicketTable() + }, + { + label: "In Progress", + value: "in-progress", + content: renderTicketTable() + }, + { + label: "Resolved", + value: "resolved", + content: renderTicketTable() + }, + { + label: "Closed Tickets", + value: "closed", + content: renderTicketTable() + }, + { + label: "All Tickets", + value: "all", + content: renderTicketTable() + }, + ]; + + return ( +
+
+

Ticketing System

+ +
+ + {error && ( +
+ {error} +
+ )} + +
+
+ setSearchTerm(e.target.value)} + /> +
+
+ + setActiveTab(value)} + /> + + {/* Ticket Dialog */} + + + + + {isEditing ? 'Edit Ticket' : 'Create New Ticket'} + + + {isEditing ? 'Update the ticket details' : 'Fill in the details for the new ticket'} + + + +
+
+ + +
+ +
+ +