new changes
This commit is contained in:
commit
b980f7a052
3
.bolt/config.json
Normal file
3
.bolt/config.json
Normal file
|
@ -0,0 +1,3 @@
|
|||
{
|
||||
"template": "bolt-vite-react-ts"
|
||||
}
|
5
.bolt/prompt
Normal file
5
.bolt/prompt
Normal file
|
@ -0,0 +1,5 @@
|
|||
For all designs I ask you to make, have them be beautiful, not cookie cutter. Make webpages that are fully featured and worthy for production.
|
||||
|
||||
By default, this template supports JSX syntax with Tailwind CSS classes, React hooks, and Lucide React for icons. Do not install other packages for UI themes, icons, etc unless absolutely necessary or I request them.
|
||||
|
||||
Use icons from lucide-react for logos.
|
25
.gitignore
vendored
Normal file
25
.gitignore
vendored
Normal file
|
@ -0,0 +1,25 @@
|
|||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
.env
|
28
eslint.config.js
Normal file
28
eslint.config.js
Normal file
|
@ -0,0 +1,28 @@
|
|||
import js from '@eslint/js';
|
||||
import globals from 'globals';
|
||||
import reactHooks from 'eslint-plugin-react-hooks';
|
||||
import reactRefresh from 'eslint-plugin-react-refresh';
|
||||
import tseslint from 'typescript-eslint';
|
||||
|
||||
export default tseslint.config(
|
||||
{ ignores: ['dist'] },
|
||||
{
|
||||
extends: [js.configs.recommended, ...tseslint.configs.recommended],
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
languageOptions: {
|
||||
ecmaVersion: 2020,
|
||||
globals: globals.browser,
|
||||
},
|
||||
plugins: {
|
||||
'react-hooks': reactHooks,
|
||||
'react-refresh': reactRefresh,
|
||||
},
|
||||
rules: {
|
||||
...reactHooks.configs.recommended.rules,
|
||||
'react-refresh/only-export-components': [
|
||||
'warn',
|
||||
{ allowConstantExport: true },
|
||||
],
|
||||
},
|
||||
}
|
||||
);
|
13
index.html
Normal file
13
index.html
Normal file
|
@ -0,0 +1,13 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>IT Ticket Management System</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
4066
package-lock.json
generated
Normal file
4066
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
36
package.json
Normal file
36
package.json
Normal file
|
@ -0,0 +1,36 @@
|
|||
{
|
||||
"name": "vite-react-typescript-starter",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"lucide-react": "^0.263.1",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^8.57.0",
|
||||
"@types/react": "^18.2.15",
|
||||
"@types/react-dom": "^18.2.7",
|
||||
"@vitejs/plugin-react": "^4.0.3",
|
||||
"autoprefixer": "^10.4.14",
|
||||
"eslint": "^8.57.0",
|
||||
"eslint-plugin-react-hooks": "^4.6.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.3",
|
||||
"globals": "^13.20.0",
|
||||
"postcss": "^8.4.24",
|
||||
"tailwindcss": "^3.3.0",
|
||||
"typescript": "^5.0.2",
|
||||
"typescript-eslint": "^7.13.1",
|
||||
"vite": "^4.4.5"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.18.0"
|
||||
}
|
||||
}
|
6
postcss.config.js
Normal file
6
postcss.config.js
Normal file
|
@ -0,0 +1,6 @@
|
|||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
171
src/App.tsx
Normal file
171
src/App.tsx
Normal file
|
@ -0,0 +1,171 @@
|
|||
import React, { useState, useEffect } from 'react';
|
||||
import { Computer, MessageSquare, History, Plus } from 'lucide-react';
|
||||
import { TicketForm } from './components/TicketForm';
|
||||
import { TicketHistory } from './components/TicketHistory';
|
||||
import { ChatInterface } from './components/ChatInterface';
|
||||
import { TicketConfirmation } from './components/TicketConfirmation';
|
||||
import { apiService } from './services/apiService';
|
||||
import { Ticket } from './types';
|
||||
|
||||
function App() {
|
||||
const [activeTab, setActiveTab] = useState<'new' | 'history' | 'chat'>('new');
|
||||
const [tickets, setTickets] = useState<Ticket[]>([]);
|
||||
const [showConfirmation, setShowConfirmation] = useState<Ticket | null>(null);
|
||||
const [isLoadingTickets, setIsLoadingTickets] = useState(false);
|
||||
|
||||
// Load tickets from backend on component mount
|
||||
useEffect(() => {
|
||||
loadTickets();
|
||||
}, []);
|
||||
|
||||
const loadTickets = async () => {
|
||||
setIsLoadingTickets(true);
|
||||
try {
|
||||
const backendTickets = await apiService.getTickets();
|
||||
// Convert backend format to frontend format
|
||||
const formattedTickets: Ticket[] = backendTickets.map((ticket: any) => ({
|
||||
...ticket,
|
||||
status: 'Open' as const, // Backend doesn't have status field yet
|
||||
timestamp: new Date(ticket.timestamp).toLocaleString()
|
||||
}));
|
||||
setTickets(formattedTickets);
|
||||
} catch (error) {
|
||||
console.error('Error loading tickets:', error);
|
||||
} finally {
|
||||
setIsLoadingTickets(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleTicketSubmit = (ticket: Ticket) => {
|
||||
setTickets(prev => [ticket, ...prev]);
|
||||
setShowConfirmation(ticket);
|
||||
};
|
||||
|
||||
const handleTicketUpdate = (ticketId: string, updates: Partial<Ticket>) => {
|
||||
setTickets(prev =>
|
||||
prev.map(ticket =>
|
||||
ticket.id === ticketId ? { ...ticket, ...updates } : ticket
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
const handleCreateTicketFromChat = (conversation: string) => {
|
||||
// This would typically show a form pre-filled with the conversation
|
||||
// For now, we'll just switch to the new ticket tab
|
||||
setActiveTab('new');
|
||||
};
|
||||
|
||||
const tabs = [
|
||||
{ id: 'new' as const, label: 'New Ticket', icon: Plus },
|
||||
{ id: 'history' as const, label: 'History', icon: History },
|
||||
{ id: 'chat' as const, label: 'Live Chat', icon: MessageSquare }
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-950 text-white">
|
||||
{/* Header */}
|
||||
<div className="bg-gray-900 border-b border-gray-800">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="flex items-center justify-between h-16">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 bg-gradient-to-r from-blue-600 to-purple-600 rounded-lg flex items-center justify-center">
|
||||
<Computer className="w-6 h-6 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-xl font-bold">DIGI TICKET</h1>
|
||||
<p className="text-xs text-gray-400">IT Support System</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 text-sm text-gray-400">
|
||||
<span>Total Tickets:</span>
|
||||
<span className="text-white font-semibold">
|
||||
{isLoadingTickets ? '...' : tickets.length}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Navigation */}
|
||||
<div className="bg-gray-900 border-b border-gray-800">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<nav className="flex space-x-1">
|
||||
{tabs.map((tab) => {
|
||||
const Icon = tab.icon;
|
||||
return (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
className={`flex items-center gap-2 px-6 py-4 text-sm font-medium transition-all duration-200 border-b-2 ${
|
||||
activeTab === tab.id
|
||||
? 'text-blue-400 border-blue-400 bg-blue-400/5'
|
||||
: 'text-gray-400 border-transparent hover:text-gray-300 hover:border-gray-600'
|
||||
}`}
|
||||
>
|
||||
<Icon className="w-4 h-4" />
|
||||
<span>{tab.label}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Main Content */}
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
{activeTab === 'new' && (
|
||||
<div>
|
||||
<div className="mb-8">
|
||||
<h2 className="text-2xl font-bold text-white mb-2">Submit New Ticket</h2>
|
||||
<p className="text-gray-400">Use voice or text to describe your IT issue</p>
|
||||
</div>
|
||||
<TicketForm onTicketSubmit={handleTicketSubmit} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'history' && (
|
||||
<div>
|
||||
<div className="mb-8 flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold text-white mb-2">Ticket History</h2>
|
||||
<p className="text-gray-400">View and manage all your support tickets</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={loadTickets}
|
||||
disabled={isLoadingTickets}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg transition-colors disabled:opacity-50"
|
||||
>
|
||||
{isLoadingTickets ? 'Loading...' : 'Refresh'}
|
||||
</button>
|
||||
</div>
|
||||
<TicketHistory
|
||||
tickets={tickets}
|
||||
onTicketUpdate={handleTicketUpdate}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'chat' && (
|
||||
<div>
|
||||
<div className="mb-8">
|
||||
<h2 className="text-2xl font-bold text-white mb-2">Live Chat Support</h2>
|
||||
<p className="text-gray-400">Get instant help from our AI assistant</p>
|
||||
</div>
|
||||
<ChatInterface onCreateTicket={handleCreateTicketFromChat} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Confirmation Modal */}
|
||||
{showConfirmation && (
|
||||
<TicketConfirmation
|
||||
ticket={showConfirmation}
|
||||
onClose={() => setShowConfirmation(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
274
src/components/ChatInterface.tsx
Normal file
274
src/components/ChatInterface.tsx
Normal file
|
@ -0,0 +1,274 @@
|
|||
import React, { useState, useRef, useEffect } from 'react';
|
||||
import { Send, Bot, User, Loader, MessageSquare, UserPlus } from 'lucide-react';
|
||||
import { ChatMessage } from '../types';
|
||||
import { ChatService } from '../services/chatService';
|
||||
|
||||
interface ChatInterfaceProps {
|
||||
onCreateTicket?: (conversation: string) => void;
|
||||
}
|
||||
|
||||
export const ChatInterface: React.FC<ChatInterfaceProps> = ({ onCreateTicket }) => {
|
||||
const [messages, setMessages] = useState<ChatMessage[]>([
|
||||
{
|
||||
id: '1',
|
||||
role: 'assistant',
|
||||
content: "Hello! I'm your IT support assistant. I'm here to help you with any technical issues you might be experiencing. What can I help you with today?",
|
||||
timestamp: new Date().toLocaleString()
|
||||
}
|
||||
]);
|
||||
const [input, setInput] = useState('');
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [showTicketForm, setShowTicketForm] = useState(false);
|
||||
const [ticketFormData, setTicketFormData] = useState({ requester: '', email: '' });
|
||||
const [isCreatingTicket, setIsCreatingTicket] = useState(false);
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||
const [chatService] = useState(() => new ChatService());
|
||||
|
||||
const scrollToBottom = () => {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
scrollToBottom();
|
||||
}, [messages]);
|
||||
|
||||
const handleSend = async () => {
|
||||
if (!input.trim() || isLoading) return;
|
||||
|
||||
const userMessage: ChatMessage = {
|
||||
id: Date.now().toString(),
|
||||
role: 'user',
|
||||
content: input,
|
||||
timestamp: new Date().toLocaleString()
|
||||
};
|
||||
|
||||
setMessages(prev => [...prev, userMessage]);
|
||||
setInput('');
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
const response = await chatService.sendMessage([...messages, userMessage]);
|
||||
|
||||
const assistantMessage: ChatMessage = {
|
||||
id: (Date.now() + 1).toString(),
|
||||
role: 'assistant',
|
||||
content: response,
|
||||
timestamp: new Date().toLocaleString()
|
||||
};
|
||||
|
||||
setMessages(prev => [...prev, assistantMessage]);
|
||||
} catch (error) {
|
||||
console.error('Chat error:', error);
|
||||
const errorMessage: ChatMessage = {
|
||||
id: (Date.now() + 1).toString(),
|
||||
role: 'assistant',
|
||||
content: "I apologize, but I'm experiencing some technical difficulties. Please try again or create a support ticket if the issue persists.",
|
||||
timestamp: new Date().toLocaleString()
|
||||
};
|
||||
setMessages(prev => [...prev, errorMessage]);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyPress = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleSend();
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreateTicketFromChat = async () => {
|
||||
if (!ticketFormData.requester.trim() || !ticketFormData.email.trim()) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsCreatingTicket(true);
|
||||
try {
|
||||
const ticket = await chatService.createTicketFromConversation(
|
||||
ticketFormData.requester,
|
||||
ticketFormData.email,
|
||||
messages
|
||||
);
|
||||
|
||||
// Show success message
|
||||
const successMessage: ChatMessage = {
|
||||
id: (Date.now() + 2).toString(),
|
||||
role: 'assistant',
|
||||
content: `Great! I've created a support ticket for you. Your ticket ID is ${ticket.id}. Our ${ticket.department} team will review your request and get back to you soon.`,
|
||||
timestamp: new Date().toLocaleString()
|
||||
};
|
||||
|
||||
setMessages(prev => [...prev, successMessage]);
|
||||
setShowTicketForm(false);
|
||||
setTicketFormData({ requester: '', email: '' });
|
||||
} catch (error) {
|
||||
console.error('Error creating ticket from chat:', error);
|
||||
const errorMessage: ChatMessage = {
|
||||
id: (Date.now() + 2).toString(),
|
||||
role: 'assistant',
|
||||
content: "I'm sorry, there was an error creating your ticket. Please try again or use the main ticket form.",
|
||||
timestamp: new Date().toLocaleString()
|
||||
};
|
||||
setMessages(prev => [...prev, errorMessage]);
|
||||
} finally {
|
||||
setIsCreatingTicket(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-gray-900 rounded-xl border border-gray-800 h-[600px] flex flex-col">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-4 border-b border-gray-800">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 bg-gradient-to-r from-blue-600 to-purple-600 rounded-lg flex items-center justify-center">
|
||||
<Bot className="w-5 h-5 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-white">IT Support Chat</h3>
|
||||
<p className="text-sm text-gray-400">Ask me anything about IT issues</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{messages.length > 1 && !showTicketForm && (
|
||||
<button
|
||||
onClick={() => setShowTicketForm(true)}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-green-600 hover:bg-green-700 text-white rounded-lg transition-all duration-200 hover:scale-105 text-sm"
|
||||
>
|
||||
<MessageSquare className="w-4 h-4" />
|
||||
<span>Create Ticket</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Messages */}
|
||||
<div className="flex-1 overflow-y-auto p-4 space-y-4">
|
||||
{messages.map((message) => (
|
||||
<div
|
||||
key={message.id}
|
||||
className={`flex gap-3 ${message.role === 'user' ? 'justify-end' : 'justify-start'}`}
|
||||
>
|
||||
{message.role === 'assistant' && (
|
||||
<div className="w-8 h-8 bg-gradient-to-r from-blue-600 to-purple-600 rounded-full flex items-center justify-center flex-shrink-0">
|
||||
<Bot className="w-4 h-4 text-white" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div
|
||||
className={`max-w-[70%] p-3 rounded-lg ${
|
||||
message.role === 'user'
|
||||
? 'bg-blue-600 text-white'
|
||||
: 'bg-gray-800 text-gray-100 border border-gray-700'
|
||||
}`}
|
||||
>
|
||||
<p className="whitespace-pre-wrap leading-relaxed">{message.content}</p>
|
||||
<p className="text-xs opacity-70 mt-2">{message.timestamp}</p>
|
||||
</div>
|
||||
|
||||
{message.role === 'user' && (
|
||||
<div className="w-8 h-8 bg-gray-700 rounded-full flex items-center justify-center flex-shrink-0">
|
||||
<User className="w-4 h-4 text-gray-300" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{isLoading && (
|
||||
<div className="flex gap-3 justify-start">
|
||||
<div className="w-8 h-8 bg-gradient-to-r from-blue-600 to-purple-600 rounded-full flex items-center justify-center">
|
||||
<Bot className="w-4 h-4 text-white" />
|
||||
</div>
|
||||
<div className="bg-gray-800 border border-gray-700 rounded-lg p-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Loader className="w-4 h-4 animate-spin text-blue-400" />
|
||||
<span className="text-gray-400">Thinking...</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Ticket Creation Form */}
|
||||
{showTicketForm && (
|
||||
<div className="bg-gray-800 border border-gray-700 rounded-lg p-4">
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<UserPlus className="w-5 h-5 text-green-400" />
|
||||
<h4 className="text-white font-medium">Create Support Ticket</h4>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Your full name"
|
||||
value={ticketFormData.requester}
|
||||
onChange={(e) => setTicketFormData(prev => ({ ...prev, requester: e.target.value }))}
|
||||
className="w-full px-3 py-2 bg-gray-700 border border-gray-600 rounded text-white placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
disabled={isCreatingTicket}
|
||||
/>
|
||||
|
||||
<input
|
||||
type="email"
|
||||
placeholder="Your email address"
|
||||
value={ticketFormData.email}
|
||||
onChange={(e) => setTicketFormData(prev => ({ ...prev, email: e.target.value }))}
|
||||
className="w-full px-3 py-2 bg-gray-700 border border-gray-600 rounded text-white placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
disabled={isCreatingTicket}
|
||||
/>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={handleCreateTicketFromChat}
|
||||
disabled={!ticketFormData.requester.trim() || !ticketFormData.email.trim() || isCreatingTicket}
|
||||
className="flex-1 flex items-center justify-center gap-2 px-4 py-2 bg-green-600 hover:bg-green-700 text-white rounded transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{isCreatingTicket ? (
|
||||
<>
|
||||
<Loader className="w-4 h-4 animate-spin" />
|
||||
<span>Creating...</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<MessageSquare className="w-4 h-4" />
|
||||
<span>Create Ticket</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => setShowTicketForm(false)}
|
||||
disabled={isCreatingTicket}
|
||||
className="px-4 py-2 bg-gray-600 hover:bg-gray-700 text-white rounded transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div ref={messagesEndRef} />
|
||||
</div>
|
||||
|
||||
{/* Input */}
|
||||
<div className="p-4 border-t border-gray-800">
|
||||
<div className="flex gap-3">
|
||||
<textarea
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
onKeyPress={handleKeyPress}
|
||||
placeholder="Type your message here..."
|
||||
className="flex-1 px-4 py-3 bg-gray-800 border border-gray-700 rounded-lg text-white placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent resize-none"
|
||||
rows={1}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
<button
|
||||
onClick={handleSend}
|
||||
disabled={!input.trim() || isLoading}
|
||||
className="px-6 py-3 bg-gradient-to-r from-blue-600 to-purple-600 hover:from-blue-700 hover:to-purple-700 text-white rounded-lg transition-all duration-200 hover:scale-105 disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:scale-100"
|
||||
>
|
||||
<Send className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
162
src/components/TicketConfirmation.tsx
Normal file
162
src/components/TicketConfirmation.tsx
Normal file
|
@ -0,0 +1,162 @@
|
|||
import React, { useEffect, useState } from 'react';
|
||||
import { CheckCircle, Volume2, VolumeX, Copy, Check } from 'lucide-react';
|
||||
import { Ticket } from '../types';
|
||||
import { TextToSpeechService } from '../services/speechService';
|
||||
|
||||
interface TicketConfirmationProps {
|
||||
ticket: Ticket;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export const TicketConfirmation: React.FC<TicketConfirmationProps> = ({ ticket, onClose }) => {
|
||||
const [isSpeaking, setIsSpeaking] = useState(false);
|
||||
const [isCopied, setIsCopied] = useState(false);
|
||||
const [ttsService] = useState(() => new TextToSpeechService());
|
||||
|
||||
const speechText = `Ticket submitted successfully. Ticket ID: ${ticket.id}. Department: ${ticket.department}. Team: ${ticket.sub_department}. Issue Type: ${ticket.issue_type}. Priority: ${ticket.urgency}. Summary: ${ticket.summary}.`;
|
||||
|
||||
useEffect(() => {
|
||||
// Auto-play confirmation
|
||||
ttsService.speak(speechText);
|
||||
setIsSpeaking(true);
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
setIsSpeaking(false);
|
||||
}, 5000);
|
||||
|
||||
return () => {
|
||||
clearTimeout(timer);
|
||||
ttsService.stop();
|
||||
};
|
||||
}, []);
|
||||
|
||||
const toggleSpeech = () => {
|
||||
if (isSpeaking) {
|
||||
ttsService.stop();
|
||||
setIsSpeaking(false);
|
||||
} else {
|
||||
ttsService.speak(speechText);
|
||||
setIsSpeaking(true);
|
||||
setTimeout(() => setIsSpeaking(false), 5000);
|
||||
}
|
||||
};
|
||||
|
||||
const copyTicketId = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(ticket.id);
|
||||
setIsCopied(true);
|
||||
setTimeout(() => setIsCopied(false), 2000);
|
||||
} catch (error) {
|
||||
console.error('Failed to copy ticket ID:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const getUrgencyColor = (urgency: string) => {
|
||||
switch (urgency) {
|
||||
case 'Low': return 'text-green-400 bg-green-400/10 border-green-400/20';
|
||||
case 'Medium': return 'text-yellow-400 bg-yellow-400/10 border-yellow-400/20';
|
||||
case 'High': return 'text-orange-400 bg-orange-400/10 border-orange-400/20';
|
||||
case 'Critical': return 'text-red-400 bg-red-400/10 border-red-400/20';
|
||||
default: return 'text-gray-400 bg-gray-400/10 border-gray-400/20';
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center p-4 z-50">
|
||||
<div className="bg-gray-900 rounded-xl p-6 max-w-md w-full border border-gray-800 shadow-2xl">
|
||||
<div className="text-center mb-6">
|
||||
<div className="w-16 h-16 bg-green-600 rounded-full flex items-center justify-center mx-auto mb-4">
|
||||
<CheckCircle className="w-8 h-8 text-white" />
|
||||
</div>
|
||||
<h2 className="text-2xl font-bold text-white mb-2">Ticket Submitted!</h2>
|
||||
<p className="text-gray-400">Your support request has been received</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4 mb-6">
|
||||
<div className="bg-gray-800 rounded-lg p-4 border border-gray-700">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-sm text-gray-400">Ticket ID</span>
|
||||
<button
|
||||
onClick={copyTicketId}
|
||||
className="flex items-center gap-1 text-blue-400 hover:text-blue-300 transition-colors"
|
||||
>
|
||||
{isCopied ? (
|
||||
<>
|
||||
<Check className="w-4 h-4" />
|
||||
<span className="text-xs">Copied</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Copy className="w-4 h-4" />
|
||||
<span className="text-xs">Copy</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-white font-mono text-lg">{ticket.id}</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="bg-gray-800 rounded-lg p-3 border border-gray-700">
|
||||
<p className="text-xs text-gray-400 mb-1">Department</p>
|
||||
<p className="text-white font-medium">{ticket.department}</p>
|
||||
</div>
|
||||
<div className="bg-gray-800 rounded-lg p-3 border border-gray-700">
|
||||
<p className="text-xs text-gray-400 mb-1">Team</p>
|
||||
<p className="text-white font-medium">{ticket.sub_department}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-gray-800 rounded-lg p-3 border border-gray-700">
|
||||
<p className="text-xs text-gray-400 mb-1">Issue Type</p>
|
||||
<p className="text-white font-medium">{ticket.issue_type}</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between bg-gray-800 rounded-lg p-3 border border-gray-700">
|
||||
<div>
|
||||
<p className="text-xs text-gray-400 mb-1">Priority</p>
|
||||
<span className={`px-2 py-1 rounded-full text-xs font-medium border ${getUrgencyColor(ticket.urgency)}`}>
|
||||
{ticket.urgency}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-gray-800 rounded-lg p-3 border border-gray-700">
|
||||
<p className="text-xs text-gray-400 mb-1">Summary</p>
|
||||
<p className="text-white text-sm leading-relaxed">{ticket.summary}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
onClick={toggleSpeech}
|
||||
className={`flex items-center gap-2 px-4 py-2 rounded-lg transition-all duration-200 ${
|
||||
isSpeaking
|
||||
? 'bg-red-600 hover:bg-red-700 text-white'
|
||||
: 'bg-blue-600 hover:bg-blue-700 text-white'
|
||||
}`}
|
||||
>
|
||||
{isSpeaking ? (
|
||||
<>
|
||||
<VolumeX className="w-4 h-4" />
|
||||
<span>Stop</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Volume2 className="w-4 h-4" />
|
||||
<span>Listen</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="flex-1 px-4 py-2 bg-gray-700 hover:bg-gray-600 text-white rounded-lg transition-all duration-200"
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
165
src/components/TicketForm.tsx
Normal file
165
src/components/TicketForm.tsx
Normal file
|
@ -0,0 +1,165 @@
|
|||
import React, { useState } from 'react';
|
||||
import { Send, User, Mail, FileText, Loader } from 'lucide-react';
|
||||
import { VoiceRecorder } from './VoiceRecorder';
|
||||
import { TextToSpeechService } from '../services/speechService';
|
||||
import { apiService } from '../services/apiService';
|
||||
import { Ticket } from '../types';
|
||||
|
||||
interface TicketFormProps {
|
||||
onTicketSubmit: (ticket: Ticket) => void;
|
||||
}
|
||||
|
||||
export const TicketForm: React.FC<TicketFormProps> = ({ onTicketSubmit }) => {
|
||||
const [formData, setFormData] = useState({
|
||||
requester: '',
|
||||
email: '',
|
||||
content: ''
|
||||
});
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [ttsService] = useState(() => new TextToSpeechService());
|
||||
|
||||
const handleVoiceTranscript = (transcript: string) => {
|
||||
setFormData(prev => ({ ...prev, content: transcript }));
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!formData.requester.trim() || !formData.email.trim() || !formData.content.trim()) {
|
||||
setError('Please fill in all required fields');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSubmitting(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const ticket = await apiService.submitTicket({
|
||||
requester: formData.requester,
|
||||
email: formData.email,
|
||||
content: formData.content
|
||||
});
|
||||
|
||||
// Convert backend response to frontend Ticket format
|
||||
const formattedTicket: Ticket = {
|
||||
...ticket,
|
||||
status: 'Open' as const,
|
||||
timestamp: new Date(ticket.timestamp).toLocaleString()
|
||||
};
|
||||
|
||||
onTicketSubmit(formattedTicket);
|
||||
|
||||
// Text-to-speech confirmation
|
||||
const speechText = `Ticket submitted successfully. Ticket ID: ${ticket.id}. Department: ${ticket.department}. Priority: ${ticket.urgency}.`;
|
||||
ttsService.speak(speechText);
|
||||
|
||||
// Reset form
|
||||
setFormData({ requester: '', email: '', content: '' });
|
||||
} catch (error) {
|
||||
console.error('Error submitting ticket:', error);
|
||||
setError(error instanceof Error ? error.message : 'Failed to submit ticket. Please try again.');
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="max-w-2xl mx-auto">
|
||||
<div className="bg-gray-900 rounded-xl p-6 shadow-2xl border border-gray-800">
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
<div className="w-10 h-10 bg-blue-600 rounded-lg flex items-center justify-center">
|
||||
<FileText className="w-5 h-5 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold text-white">Create New Ticket</h2>
|
||||
<p className="text-gray-400 text-sm">Submit your IT support request</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="mb-6 p-4 bg-red-900/50 border border-red-700 rounded-lg">
|
||||
<p className="text-red-300 text-sm">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-300 mb-2">
|
||||
<User className="w-4 h-4 inline mr-2" />
|
||||
Your Name
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.requester}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, requester: e.target.value }))}
|
||||
className="w-full px-4 py-3 bg-gray-800 border border-gray-700 rounded-lg text-white placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-all duration-200"
|
||||
placeholder="Enter your full name"
|
||||
required
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-300 mb-2">
|
||||
<Mail className="w-4 h-4 inline mr-2" />
|
||||
Email Address
|
||||
</label>
|
||||
<input
|
||||
type="email"
|
||||
value={formData.email}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, email: e.target.value }))}
|
||||
className="w-full px-4 py-3 bg-gray-800 border border-gray-700 rounded-lg text-white placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-all duration-200"
|
||||
placeholder="your.email@company.com"
|
||||
required
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-300 mb-3">
|
||||
Describe Your Issue
|
||||
</label>
|
||||
|
||||
<VoiceRecorder
|
||||
onTranscript={handleVoiceTranscript}
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
|
||||
<div className="mt-4">
|
||||
<textarea
|
||||
value={formData.content}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, content: e.target.value }))}
|
||||
rows={6}
|
||||
className="w-full px-4 py-3 bg-gray-800 border border-gray-700 rounded-lg text-white placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-all duration-200 resize-none"
|
||||
placeholder="Type your issue description here or use voice recording above..."
|
||||
required
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isSubmitting || !formData.requester.trim() || !formData.email.trim() || !formData.content.trim()}
|
||||
className="w-full flex items-center justify-center gap-3 px-6 py-4 bg-gradient-to-r from-blue-600 to-purple-600 hover:from-blue-700 hover:to-purple-700 text-white font-medium rounded-lg transition-all duration-200 hover:scale-105 disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:scale-100 shadow-lg shadow-blue-600/25"
|
||||
>
|
||||
{isSubmitting ? (
|
||||
<>
|
||||
<Loader className="w-5 h-5 animate-spin" />
|
||||
<span>Processing Ticket...</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Send className="w-5 h-5" />
|
||||
<span>Submit Ticket</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
174
src/components/TicketHistory.tsx
Normal file
174
src/components/TicketHistory.tsx
Normal file
|
@ -0,0 +1,174 @@
|
|||
import React, { useState } from 'react';
|
||||
import { Clock, CheckCircle, AlertCircle, Filter, Search, User, Mail } from 'lucide-react';
|
||||
import { Ticket } from '../types';
|
||||
|
||||
interface TicketHistoryProps {
|
||||
tickets: Ticket[];
|
||||
onTicketUpdate: (ticketId: string, updates: Partial<Ticket>) => void;
|
||||
}
|
||||
|
||||
export const TicketHistory: React.FC<TicketHistoryProps> = ({ tickets, onTicketUpdate }) => {
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [statusFilter, setStatusFilter] = useState<string>('all');
|
||||
const [urgencyFilter, setUrgencyFilter] = useState<string>('all');
|
||||
|
||||
const filteredTickets = tickets.filter(ticket => {
|
||||
const matchesSearch =
|
||||
ticket.summary.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
ticket.requester.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
ticket.content.toLowerCase().includes(searchTerm.toLowerCase());
|
||||
|
||||
const matchesStatus = statusFilter === 'all' || ticket.status === statusFilter;
|
||||
const matchesUrgency = urgencyFilter === 'all' || ticket.urgency === urgencyFilter;
|
||||
|
||||
return matchesSearch && matchesStatus && matchesUrgency;
|
||||
});
|
||||
|
||||
const getStatusColor = (status: string) => {
|
||||
switch (status) {
|
||||
case 'Open': return 'text-blue-400 bg-blue-400/10';
|
||||
case 'In Progress': return 'text-yellow-400 bg-yellow-400/10';
|
||||
case 'Resolved': return 'text-green-400 bg-green-400/10';
|
||||
case 'Closed': return 'text-gray-400 bg-gray-400/10';
|
||||
default: return 'text-gray-400 bg-gray-400/10';
|
||||
}
|
||||
};
|
||||
|
||||
const getUrgencyColor = (urgency: string) => {
|
||||
switch (urgency) {
|
||||
case 'Low': return 'text-green-400 bg-green-400/10';
|
||||
case 'Medium': return 'text-yellow-400 bg-yellow-400/10';
|
||||
case 'High': return 'text-orange-400 bg-orange-400/10';
|
||||
case 'Critical': return 'text-red-400 bg-red-400/10';
|
||||
default: return 'text-gray-400 bg-gray-400/10';
|
||||
}
|
||||
};
|
||||
|
||||
const markAsResolved = (ticketId: string) => {
|
||||
onTicketUpdate(ticketId, { status: 'Resolved' });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Filters */}
|
||||
<div className="bg-gray-900 rounded-xl p-6 border border-gray-800">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<Filter className="w-5 h-5 text-blue-400" />
|
||||
<h3 className="text-lg font-semibold text-white">Filter Tickets</h3>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div className="relative">
|
||||
<Search className="w-4 h-4 absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-500" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search tickets..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="w-full pl-10 pr-4 py-2 bg-gray-800 border border-gray-700 rounded-lg text-white placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<select
|
||||
value={statusFilter}
|
||||
onChange={(e) => setStatusFilter(e.target.value)}
|
||||
className="px-4 py-2 bg-gray-800 border border-gray-700 rounded-lg text-white focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
>
|
||||
<option value="all">All Status</option>
|
||||
<option value="Open">Open</option>
|
||||
<option value="In Progress">In Progress</option>
|
||||
<option value="Resolved">Resolved</option>
|
||||
<option value="Closed">Closed</option>
|
||||
</select>
|
||||
|
||||
<select
|
||||
value={urgencyFilter}
|
||||
onChange={(e) => setUrgencyFilter(e.target.value)}
|
||||
className="px-4 py-2 bg-gray-800 border border-gray-700 rounded-lg text-white focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
>
|
||||
<option value="all">All Urgency</option>
|
||||
<option value="Low">Low</option>
|
||||
<option value="Medium">Medium</option>
|
||||
<option value="High">High</option>
|
||||
<option value="Critical">Critical</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tickets List */}
|
||||
<div className="space-y-4">
|
||||
{filteredTickets.length === 0 ? (
|
||||
<div className="bg-gray-900 rounded-xl p-8 text-center border border-gray-800">
|
||||
<AlertCircle className="w-12 h-12 text-gray-500 mx-auto mb-4" />
|
||||
<p className="text-gray-400">No tickets found matching your criteria</p>
|
||||
</div>
|
||||
) : (
|
||||
filteredTickets.map((ticket) => (
|
||||
<div key={ticket.id} className="bg-gray-900 rounded-xl p-6 border border-gray-800 hover:border-gray-700 transition-all duration-200">
|
||||
<div className="flex items-start justify-between mb-4">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<h3 className="text-lg font-semibold text-white">#{ticket.id}</h3>
|
||||
<span className={`px-2 py-1 rounded-full text-xs font-medium ${getStatusColor(ticket.status)}`}>
|
||||
{ticket.status}
|
||||
</span>
|
||||
<span className={`px-2 py-1 rounded-full text-xs font-medium ${getUrgencyColor(ticket.urgency)}`}>
|
||||
{ticket.urgency}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-gray-300 font-medium mb-2">{ticket.summary}</p>
|
||||
<div className="flex items-center gap-4 text-sm text-gray-400">
|
||||
<div className="flex items-center gap-1">
|
||||
<User className="w-4 h-4" />
|
||||
<span>{ticket.requester}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Mail className="w-4 h-4" />
|
||||
<span>{ticket.email}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Clock className="w-4 h-4" />
|
||||
<span>{ticket.timestamp}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{ticket.status !== 'Resolved' && ticket.status !== 'Closed' && (
|
||||
<button
|
||||
onClick={() => markAsResolved(ticket.id)}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-green-600 hover:bg-green-700 text-white rounded-lg transition-all duration-200 hover:scale-105"
|
||||
>
|
||||
<CheckCircle className="w-4 h-4" />
|
||||
<span>Resolve</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="border-t border-gray-800 pt-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mb-4">
|
||||
<div>
|
||||
<p className="text-xs text-gray-500 mb-1">Department</p>
|
||||
<p className="text-white font-medium">{ticket.department}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-gray-500 mb-1">Team</p>
|
||||
<p className="text-white font-medium">{ticket.sub_department}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-gray-500 mb-1">Issue Type</p>
|
||||
<p className="text-white font-medium">{ticket.issue_type}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="text-xs text-gray-500 mb-2">Description</p>
|
||||
<p className="text-gray-300 leading-relaxed">{ticket.content}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
109
src/components/VoiceRecorder.tsx
Normal file
109
src/components/VoiceRecorder.tsx
Normal file
|
@ -0,0 +1,109 @@
|
|||
import React, { useState, useEffect } from 'react';
|
||||
import { Mic, MicOff, Volume2 } from 'lucide-react';
|
||||
import { SpeechService } from '../services/speechService';
|
||||
|
||||
interface VoiceRecorderProps {
|
||||
onTranscript: (transcript: string) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export const VoiceRecorder: React.FC<VoiceRecorderProps> = ({ onTranscript, disabled = false }) => {
|
||||
const [isListening, setIsListening] = useState(false);
|
||||
const [transcript, setTranscript] = useState('');
|
||||
const [isSupported, setIsSupported] = useState(false);
|
||||
const [speechService] = useState(() => new SpeechService());
|
||||
|
||||
useEffect(() => {
|
||||
setIsSupported(speechService.isSupported());
|
||||
}, [speechService]);
|
||||
|
||||
const startListening = () => {
|
||||
if (!isSupported || disabled) return;
|
||||
|
||||
speechService.startListening(
|
||||
(result) => {
|
||||
setTranscript(result.transcript);
|
||||
if (result.isFinal) {
|
||||
onTranscript(result.transcript);
|
||||
}
|
||||
},
|
||||
(error) => {
|
||||
console.error('Speech recognition error:', error);
|
||||
setIsListening(false);
|
||||
}
|
||||
);
|
||||
setIsListening(true);
|
||||
};
|
||||
|
||||
const stopListening = () => {
|
||||
speechService.stopListening();
|
||||
setIsListening(false);
|
||||
if (transcript) {
|
||||
onTranscript(transcript);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleListening = () => {
|
||||
if (isListening) {
|
||||
stopListening();
|
||||
} else {
|
||||
startListening();
|
||||
}
|
||||
};
|
||||
|
||||
if (!isSupported) {
|
||||
return (
|
||||
<div className="flex items-center justify-center p-4 bg-gray-800 rounded-lg border border-gray-700">
|
||||
<MicOff className="w-5 h-5 text-gray-500 mr-2" />
|
||||
<span className="text-gray-400 text-sm">Speech recognition not supported in this browser</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<button
|
||||
onClick={toggleListening}
|
||||
disabled={disabled}
|
||||
className={`
|
||||
w-full flex items-center justify-center gap-3 px-6 py-4 rounded-lg font-medium transition-all duration-200
|
||||
${isListening
|
||||
? 'bg-red-600 hover:bg-red-700 text-white shadow-lg shadow-red-600/25'
|
||||
: 'bg-blue-600 hover:bg-blue-700 text-white shadow-lg shadow-blue-600/25'
|
||||
}
|
||||
${disabled ? 'opacity-50 cursor-not-allowed' : 'hover:scale-105'}
|
||||
`}
|
||||
>
|
||||
{isListening ? (
|
||||
<>
|
||||
<div className="w-2 h-2 bg-white rounded-full animate-pulse"></div>
|
||||
<MicOff className="w-5 h-5" />
|
||||
<span>Stop Recording</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Mic className="w-5 h-5" />
|
||||
<span>Start Voice Recording</span>
|
||||
<Volume2 className="w-4 h-4 opacity-70" />
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{isListening && (
|
||||
<div className="bg-gray-800 rounded-lg p-4 border border-gray-700">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<div className="flex gap-1">
|
||||
<div className="w-2 h-2 bg-red-500 rounded-full animate-pulse"></div>
|
||||
<div className="w-2 h-2 bg-red-500 rounded-full animate-pulse" style={{ animationDelay: '0.1s' }}></div>
|
||||
<div className="w-2 h-2 bg-red-500 rounded-full animate-pulse" style={{ animationDelay: '0.2s' }}></div>
|
||||
</div>
|
||||
<span className="text-red-400 text-sm font-medium">Listening...</span>
|
||||
</div>
|
||||
<div className="text-gray-300 text-sm min-h-[1.5rem]">
|
||||
{transcript || 'Speak now...'}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
24
src/hooks/useLocalStorage.ts
Normal file
24
src/hooks/useLocalStorage.ts
Normal file
|
@ -0,0 +1,24 @@
|
|||
import { useState, useEffect } from 'react';
|
||||
|
||||
export function useLocalStorage<T>(key: string, initialValue: T): [T, (value: T) => void] {
|
||||
const [storedValue, setStoredValue] = useState<T>(() => {
|
||||
try {
|
||||
const item = window.localStorage.getItem(key);
|
||||
return item ? JSON.parse(item) : initialValue;
|
||||
} catch (error) {
|
||||
console.error(`Error reading localStorage key "${key}":`, error);
|
||||
return initialValue;
|
||||
}
|
||||
});
|
||||
|
||||
const setValue = (value: T) => {
|
||||
try {
|
||||
setStoredValue(value);
|
||||
window.localStorage.setItem(key, JSON.stringify(value));
|
||||
} catch (error) {
|
||||
console.error(`Error setting localStorage key "${key}":`, error);
|
||||
}
|
||||
};
|
||||
|
||||
return [storedValue, setValue];
|
||||
}
|
3
src/index.css
Normal file
3
src/index.css
Normal file
|
@ -0,0 +1,3 @@
|
|||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
10
src/main.tsx
Normal file
10
src/main.tsx
Normal file
|
@ -0,0 +1,10 @@
|
|||
import { StrictMode } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import App from './App.tsx';
|
||||
import './index.css';
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>
|
||||
);
|
71
src/services/apiService.ts
Normal file
71
src/services/apiService.ts
Normal file
|
@ -0,0 +1,71 @@
|
|||
const API_BASE_URL = 'http://localhost:3001/api';
|
||||
|
||||
export class ApiService {
|
||||
private async makeRequest(endpoint: string, options: RequestInit = {}) {
|
||||
const url = `${API_BASE_URL}${endpoint}`;
|
||||
const config: RequestInit = {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...options.headers,
|
||||
},
|
||||
...options,
|
||||
};
|
||||
|
||||
try {
|
||||
const response = await fetch(url, config);
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
throw new Error(errorData.error || `HTTP error! status: ${response.status}`);
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
console.error(`API request failed for ${endpoint}:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async submitTicket(ticketData: {
|
||||
requester: string;
|
||||
email: string;
|
||||
content: string;
|
||||
}) {
|
||||
return this.makeRequest('/submit-ticket', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(ticketData),
|
||||
});
|
||||
}
|
||||
|
||||
async getTickets() {
|
||||
return this.makeRequest('/tickets');
|
||||
}
|
||||
|
||||
async sendChatMessage(messages: Array<{ role: string; content: string }>) {
|
||||
const response = await this.makeRequest('/chat', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ messages }),
|
||||
});
|
||||
return response.response;
|
||||
}
|
||||
|
||||
async createTicketFromChat(data: {
|
||||
requester: string;
|
||||
email: string;
|
||||
conversation: string;
|
||||
}) {
|
||||
return this.makeRequest('/chat/create-ticket', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
}
|
||||
|
||||
async getQuickResponse(issueType: string) {
|
||||
return this.makeRequest('/chat/quick-response', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ issue_type: issueType }),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export const apiService = new ApiService();
|
43
src/services/chatService.ts
Normal file
43
src/services/chatService.ts
Normal file
|
@ -0,0 +1,43 @@
|
|||
import { ChatMessage } from '../types';
|
||||
import { apiService } from './apiService';
|
||||
|
||||
export class ChatService {
|
||||
async sendMessage(messages: ChatMessage[]): Promise<string> {
|
||||
try {
|
||||
// Convert ChatMessage format to API format
|
||||
const apiMessages = messages.map(msg => ({
|
||||
role: msg.role,
|
||||
content: msg.content
|
||||
}));
|
||||
|
||||
const response = await apiService.sendChatMessage(apiMessages);
|
||||
return response;
|
||||
} catch (error) {
|
||||
console.error('Chat service error:', error);
|
||||
throw new Error('Failed to get response from chat service. Please try again or create a support ticket.');
|
||||
}
|
||||
}
|
||||
|
||||
async createTicketFromConversation(
|
||||
requester: string,
|
||||
email: string,
|
||||
messages: ChatMessage[]
|
||||
) {
|
||||
try {
|
||||
const conversation = messages
|
||||
.map(msg => `${msg.role === 'user' ? 'User' : 'Assistant'}: ${msg.content}`)
|
||||
.join('\n\n');
|
||||
|
||||
const response = await apiService.createTicketFromChat({
|
||||
requester,
|
||||
email,
|
||||
conversation
|
||||
});
|
||||
|
||||
return response.ticket;
|
||||
} catch (error) {
|
||||
console.error('Error creating ticket from chat:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
96
src/services/speechService.ts
Normal file
96
src/services/speechService.ts
Normal file
|
@ -0,0 +1,96 @@
|
|||
import { VoiceRecognitionResult } from '../types';
|
||||
|
||||
export class SpeechService {
|
||||
private recognition: SpeechRecognition | null = null;
|
||||
private isListening = false;
|
||||
|
||||
constructor() {
|
||||
if ('webkitSpeechRecognition' in window || 'SpeechRecognition' in window) {
|
||||
const SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition;
|
||||
this.recognition = new SpeechRecognition();
|
||||
this.recognition.continuous = true;
|
||||
this.recognition.interimResults = true;
|
||||
this.recognition.lang = 'en-US';
|
||||
}
|
||||
}
|
||||
|
||||
isSupported(): boolean {
|
||||
return this.recognition !== null;
|
||||
}
|
||||
|
||||
startListening(
|
||||
onResult: (result: VoiceRecognitionResult) => void,
|
||||
onError: (error: string) => void
|
||||
): void {
|
||||
if (!this.recognition || this.isListening) return;
|
||||
|
||||
this.recognition.onresult = (event) => {
|
||||
let transcript = '';
|
||||
let confidence = 0;
|
||||
let isFinal = false;
|
||||
|
||||
for (let i = event.resultIndex; i < event.results.length; i++) {
|
||||
const result = event.results[i];
|
||||
transcript += result[0].transcript;
|
||||
confidence = result[0].confidence;
|
||||
isFinal = result.isFinal;
|
||||
}
|
||||
|
||||
onResult({ transcript, confidence, isFinal });
|
||||
};
|
||||
|
||||
this.recognition.onerror = (event) => {
|
||||
onError(`Speech recognition error: ${event.error}`);
|
||||
};
|
||||
|
||||
this.recognition.onend = () => {
|
||||
this.isListening = false;
|
||||
};
|
||||
|
||||
try {
|
||||
this.recognition.start();
|
||||
this.isListening = true;
|
||||
} catch (error) {
|
||||
onError('Failed to start speech recognition');
|
||||
}
|
||||
}
|
||||
|
||||
stopListening(): void {
|
||||
if (this.recognition && this.isListening) {
|
||||
this.recognition.stop();
|
||||
this.isListening = false;
|
||||
}
|
||||
}
|
||||
|
||||
getIsListening(): boolean {
|
||||
return this.isListening;
|
||||
}
|
||||
}
|
||||
|
||||
export class TextToSpeechService {
|
||||
private synth: SpeechSynthesis;
|
||||
|
||||
constructor() {
|
||||
this.synth = window.speechSynthesis;
|
||||
}
|
||||
|
||||
speak(text: string, options: { rate?: number; pitch?: number; volume?: number } = {}): void {
|
||||
if (!this.synth) return;
|
||||
|
||||
// Cancel any ongoing speech
|
||||
this.synth.cancel();
|
||||
|
||||
const utterance = new SpeechSynthesisUtterance(text);
|
||||
utterance.rate = options.rate || 1;
|
||||
utterance.pitch = options.pitch || 1;
|
||||
utterance.volume = options.volume || 1;
|
||||
|
||||
this.synth.speak(utterance);
|
||||
}
|
||||
|
||||
stop(): void {
|
||||
if (this.synth) {
|
||||
this.synth.cancel();
|
||||
}
|
||||
}
|
||||
}
|
28
src/services/ticketClassifier.ts
Normal file
28
src/services/ticketClassifier.ts
Normal file
|
@ -0,0 +1,28 @@
|
|||
import { Classification } from '../types';
|
||||
import { apiService } from './apiService';
|
||||
|
||||
export class ITTicketClassifier {
|
||||
async classifyTicket(ticketContent: string): Promise<Classification> {
|
||||
try {
|
||||
// The classification is now handled by the backend
|
||||
// This method is kept for compatibility but won't be used directly
|
||||
// since the backend handles both classification and ticket creation
|
||||
return {
|
||||
department: 'Processing...',
|
||||
sub_department: 'Processing...',
|
||||
issue_type: 'Processing...',
|
||||
urgency: 'Medium',
|
||||
summary: 'Processing ticket classification...'
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Classification error:', error);
|
||||
return {
|
||||
department: 'Helpdesk',
|
||||
sub_department: 'User Support',
|
||||
issue_type: 'General Inquiry',
|
||||
urgency: 'Low',
|
||||
summary: 'General support request'
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
34
src/types/index.ts
Normal file
34
src/types/index.ts
Normal file
|
@ -0,0 +1,34 @@
|
|||
export interface Ticket {
|
||||
id: string;
|
||||
requester: string;
|
||||
email: string;
|
||||
timestamp: string;
|
||||
content: string;
|
||||
status: 'Open' | 'In Progress' | 'Resolved' | 'Closed';
|
||||
department: string;
|
||||
sub_department: string;
|
||||
issue_type: string;
|
||||
urgency: 'Low' | 'Medium' | 'High' | 'Critical';
|
||||
summary: string;
|
||||
}
|
||||
|
||||
export interface ChatMessage {
|
||||
id: string;
|
||||
role: 'user' | 'assistant';
|
||||
content: string;
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
export interface Classification {
|
||||
department: string;
|
||||
sub_department: string;
|
||||
issue_type: string;
|
||||
urgency: 'Low' | 'Medium' | 'High' | 'Critical';
|
||||
summary: string;
|
||||
}
|
||||
|
||||
export interface VoiceRecognitionResult {
|
||||
transcript: string;
|
||||
confidence: number;
|
||||
isFinal: boolean;
|
||||
}
|
43
src/types/speech.d.ts
vendored
Normal file
43
src/types/speech.d.ts
vendored
Normal file
|
@ -0,0 +1,43 @@
|
|||
interface SpeechRecognition extends EventTarget {
|
||||
continuous: boolean;
|
||||
interimResults: boolean;
|
||||
lang: string;
|
||||
onresult: (event: SpeechRecognitionEvent) => void;
|
||||
onerror: (event: SpeechRecognitionErrorEvent) => void;
|
||||
onend: () => void;
|
||||
start(): void;
|
||||
stop(): void;
|
||||
}
|
||||
|
||||
interface SpeechRecognitionEvent {
|
||||
resultIndex: number;
|
||||
results: SpeechRecognitionResultList;
|
||||
}
|
||||
|
||||
interface SpeechRecognitionResultList {
|
||||
length: number;
|
||||
item(index: number): SpeechRecognitionResult;
|
||||
[index: number]: SpeechRecognitionResult;
|
||||
}
|
||||
|
||||
interface SpeechRecognitionResult {
|
||||
length: number;
|
||||
item(index: number): SpeechRecognitionAlternative;
|
||||
[index: number]: SpeechRecognitionAlternative;
|
||||
isFinal: boolean;
|
||||
}
|
||||
|
||||
interface SpeechRecognitionAlternative {
|
||||
transcript: string;
|
||||
confidence: number;
|
||||
}
|
||||
|
||||
interface SpeechRecognitionErrorEvent {
|
||||
error: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
interface Window {
|
||||
SpeechRecognition: typeof SpeechRecognition;
|
||||
webkitSpeechRecognition: typeof SpeechRecognition;
|
||||
}
|
1
src/vite-env.d.ts
vendored
Normal file
1
src/vite-env.d.ts
vendored
Normal file
|
@ -0,0 +1 @@
|
|||
/// <reference types="vite/client" />
|
8
tailwind.config.js
Normal file
8
tailwind.config.js
Normal file
|
@ -0,0 +1,8 @@
|
|||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
content: ['./index.html', './src/**/*.{js,ts,jsx,tsx}'],
|
||||
theme: {
|
||||
extend: {},
|
||||
},
|
||||
plugins: [],
|
||||
};
|
24
tsconfig.app.json
Normal file
24
tsconfig.app.json
Normal file
|
@ -0,0 +1,24 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"isolatedModules": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
7
tsconfig.json
Normal file
7
tsconfig.json
Normal file
|
@ -0,0 +1,7 @@
|
|||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
22
tsconfig.node.json
Normal file
22
tsconfig.node.json
Normal file
|
@ -0,0 +1,22 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"lib": ["ES2023"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"isolatedModules": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
10
vite.config.ts
Normal file
10
vite.config.ts
Normal file
|
@ -0,0 +1,10 @@
|
|||
import { defineConfig } from 'vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
|
||||
// https://vitejs.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
optimizeDeps: {
|
||||
exclude: ['lucide-react'],
|
||||
},
|
||||
});
|
Loading…
Reference in a new issue