Spaces:
Running
Running
Upload folder using huggingface_hub
Browse files- .gitignore +4 -0
- Frontend/src/components/shared/TicketChat.jsx +341 -24
- Frontend/src/hooks/useRealtimeNotifications.js +31 -126
- Frontend/src/legacy_ui/Dashboard.jsx +153 -62
- Frontend/src/store/ticketStore.js +10 -0
- MobileApp/App.js +107 -32
- MobileApp/app.json +0 -1
- MobileApp/package-lock.json +27 -18
- MobileApp/package.json +1 -1
- MobileApp/src/screens/auth/LoginScreen.js +9 -4
- MobileApp/src/screens/user/AIProcessingScreen.js +16 -3
- MobileApp/src/screens/user/DashboardScreen.js +11 -11
- MobileApp/src/screens/user/KnowledgeBaseScreen.js +123 -19
- MobileApp/src/screens/user/ProfileScreen.js +14 -2
- MobileApp/src/screens/user/TicketDetailScreen.js +1079 -146
- backend/.env.example +2 -0
- backend/main.py +79 -0
- backend/services/sla_service.py +39 -3
- backend/sla_checker.py +117 -0
- supabase/migrations/20260522080000_add_sla_escalation.sql +1 -1
- supabase/migrations/20260525000000_add_sla_policies_and_escalation_logs_rls.sql +150 -0
- supabase/migrations/20260531_add_company_settings.sql +43 -13
.gitignore
CHANGED
|
@@ -30,6 +30,10 @@ docs/helpdesk*.pdf
|
|
| 30 |
docs/logs/
|
| 31 |
*.log
|
| 32 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 33 |
# GSSoC Score Tracking and Temporary Scripts
|
| 34 |
scratch/
|
| 35 |
.npm-cache/
|
|
|
|
| 30 |
docs/logs/
|
| 31 |
*.log
|
| 32 |
|
| 33 |
+
# Scratch and local cache
|
| 34 |
+
scratch/
|
| 35 |
+
.npm-cache/
|
| 36 |
+
|
| 37 |
# GSSoC Score Tracking and Temporary Scripts
|
| 38 |
scratch/
|
| 39 |
.npm-cache/
|
Frontend/src/components/shared/TicketChat.jsx
CHANGED
|
@@ -1,5 +1,8 @@
|
|
| 1 |
import React, { useState, useRef, useEffect, useCallback } from 'react';
|
| 2 |
-
import {
|
|
|
|
|
|
|
|
|
|
| 3 |
import { supabase } from "../../lib/supabaseClient";
|
| 4 |
import useAuthStore from "../../store/authStore";
|
| 5 |
|
|
@@ -15,6 +18,24 @@ const TicketChat = ({ ticketId, currentUserRole = 'user' }) => {
|
|
| 15 |
const [isInternal, setIsInternal] = useState(false);
|
| 16 |
const [isStaff, setIsStaff] = useState(false);
|
| 17 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 18 |
const { user, profile } = useAuthStore();
|
| 19 |
const messagesEndRef = useRef(null);
|
| 20 |
const scrollContainerRef = useRef(null);
|
|
@@ -128,6 +149,9 @@ const TicketChat = ({ ticketId, currentUserRole = 'user' }) => {
|
|
| 128 |
|
| 129 |
return () => {
|
| 130 |
supabase.removeChannel(channel);
|
|
|
|
|
|
|
|
|
|
| 131 |
};
|
| 132 |
|
| 133 |
|
|
@@ -233,6 +257,125 @@ const TicketChat = ({ ticketId, currentUserRole = 'user' }) => {
|
|
| 233 |
} catch { return ''; }
|
| 234 |
};
|
| 235 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 236 |
const grouped = [];
|
| 237 |
let lastDate = null;
|
| 238 |
messages.forEach((msg) => {
|
|
@@ -245,7 +388,20 @@ const TicketChat = ({ ticketId, currentUserRole = 'user' }) => {
|
|
| 245 |
});
|
| 246 |
|
| 247 |
return (
|
| 248 |
-
<div style={{ background: '#ffffff', borderRadius: '20px', border: '1px solid #f0fdf4', height: '100%', display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 249 |
{/* Header */}
|
| 250 |
<div style={{ padding: '12px 20px', borderBottom: '1px solid #f0fdf4', display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
| 251 |
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
|
@@ -254,16 +410,18 @@ const TicketChat = ({ ticketId, currentUserRole = 'user' }) => {
|
|
| 254 |
</h2>
|
| 255 |
</div>
|
| 256 |
|
| 257 |
-
<div className="flex items-center gap-4">
|
| 258 |
{isStaff && (
|
| 259 |
<div style={{ display: 'flex', alignItems: 'center', background: 'transparent', gap: '4px' }}>
|
| 260 |
<button
|
|
|
|
| 261 |
onClick={() => setIsInternal(false)}
|
| 262 |
style={{ padding: '4px 12px', fontSize: '10px', fontWeight: 700, borderRadius: '8px', cursor: 'pointer', border: 'none', transition: 'all 0.2s', ...( !isInternal ? { background: '#0f1f12', color: '#ffffff' } : { background: 'transparent', color: '#6b7280' } ) }}
|
| 263 |
>
|
| 264 |
PUBLIC
|
| 265 |
</button>
|
| 266 |
<button
|
|
|
|
| 267 |
onClick={() => setIsInternal(true)}
|
| 268 |
style={{ padding: '4px 12px', fontSize: '10px', fontWeight: 700, borderRadius: '8px', cursor: 'pointer', border: 'none', transition: 'all 0.2s', ...( isInternal ? { background: '#0f1f12', color: '#ffffff' } : { background: 'transparent', color: '#6b7280' } ) }}
|
| 269 |
>
|
|
@@ -271,6 +429,26 @@ const TicketChat = ({ ticketId, currentUserRole = 'user' }) => {
|
|
| 271 |
</button>
|
| 272 |
</div>
|
| 273 |
)}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 274 |
</div>
|
| 275 |
</div>
|
| 276 |
|
|
@@ -304,6 +482,12 @@ const TicketChat = ({ ticketId, currentUserRole = 'user' }) => {
|
|
| 304 |
const msg = item.data;
|
| 305 |
const isMe = msg.sender_id === user?.id;
|
| 306 |
const isAdmin = msg.sender_role === 'admin' || msg.sender_role === 'super_admin';
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 307 |
|
| 308 |
return (
|
| 309 |
<div key={msg.id || i} className={`flex gap-2.5 ${isMe ? 'justify-end' : 'justify-start'} group py-1`}>
|
|
@@ -322,6 +506,15 @@ const TicketChat = ({ ticketId, currentUserRole = 'user' }) => {
|
|
| 322 |
<span className="text-[8px] font-bold text-slate-300">
|
| 323 |
{formatTime(msg.created_at)}
|
| 324 |
</span>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 325 |
</div>
|
| 326 |
|
| 327 |
<div style={{
|
|
@@ -334,7 +527,47 @@ const TicketChat = ({ ticketId, currentUserRole = 'user' }) => {
|
|
| 334 |
background: '#0f1f12', color: '#ffffff', borderRadius: '14px'
|
| 335 |
})
|
| 336 |
}}>
|
| 337 |
-
{
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 338 |
</div>
|
| 339 |
</div>
|
| 340 |
|
|
@@ -358,27 +591,111 @@ const TicketChat = ({ ticketId, currentUserRole = 'user' }) => {
|
|
| 358 |
|
| 359 |
{/* Input */}
|
| 360 |
<div style={{ padding: '16px 20px', borderTop: '1px solid #f0fdf4', background: '#ffffff' }}>
|
| 361 |
-
|
| 362 |
-
<
|
| 363 |
-
|
| 364 |
-
|
| 365 |
-
|
| 366 |
-
|
| 367 |
-
|
| 368 |
-
|
| 369 |
-
|
| 370 |
-
|
| 371 |
-
|
| 372 |
-
|
| 373 |
-
|
| 374 |
-
|
| 375 |
-
|
| 376 |
-
|
| 377 |
-
|
| 378 |
-
|
| 379 |
-
|
| 380 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 381 |
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 382 |
</div>
|
| 383 |
);
|
| 384 |
};
|
|
|
|
| 1 |
import React, { useState, useRef, useEffect, useCallback } from 'react';
|
| 2 |
+
import {
|
| 3 |
+
Send, User, ShieldCheck, Bot, MessageSquare, Circle, Loader2,
|
| 4 |
+
Phone, Video, Mic, Smile, Paperclip, Play, Pause, X, Check, CheckCheck, MicOff, Volume2, Shield
|
| 5 |
+
} from 'lucide-react';
|
| 6 |
import { supabase } from "../../lib/supabaseClient";
|
| 7 |
import useAuthStore from "../../store/authStore";
|
| 8 |
|
|
|
|
| 18 |
const [isInternal, setIsInternal] = useState(false);
|
| 19 |
const [isStaff, setIsStaff] = useState(false);
|
| 20 |
|
| 21 |
+
// Simulated calling states
|
| 22 |
+
const [activeCall, setActiveCall] = useState(null); // 'Audio' | 'Video' | null
|
| 23 |
+
const [callDuration, setCallDuration] = useState(0);
|
| 24 |
+
const [callStatus, setCallStatus] = useState('Ringing...'); // 'Ringing...' | 'Connected'
|
| 25 |
+
const callTimerRef = useRef(null);
|
| 26 |
+
const [isMuted, setIsMuted] = useState(false);
|
| 27 |
+
const [isSpeakerOn, setIsSpeakerOn] = useState(true);
|
| 28 |
+
|
| 29 |
+
// Voice note simulation states
|
| 30 |
+
const [isRecording, setIsRecording] = useState(false);
|
| 31 |
+
const [recordDuration, setRecordDuration] = useState(0);
|
| 32 |
+
const recordTimerRef = useRef(null);
|
| 33 |
+
|
| 34 |
+
// Voice playback states
|
| 35 |
+
const [playingVoiceId, setPlayingVoiceId] = useState(null);
|
| 36 |
+
const [voiceProgress, setVoiceProgress] = useState({});
|
| 37 |
+
const voicePlayTimerRef = useRef(null);
|
| 38 |
+
|
| 39 |
const { user, profile } = useAuthStore();
|
| 40 |
const messagesEndRef = useRef(null);
|
| 41 |
const scrollContainerRef = useRef(null);
|
|
|
|
| 149 |
|
| 150 |
return () => {
|
| 151 |
supabase.removeChannel(channel);
|
| 152 |
+
if (callTimerRef.current) clearInterval(callTimerRef.current);
|
| 153 |
+
if (recordTimerRef.current) clearInterval(recordTimerRef.current);
|
| 154 |
+
if (voicePlayTimerRef.current) clearInterval(voicePlayTimerRef.current);
|
| 155 |
};
|
| 156 |
|
| 157 |
|
|
|
|
| 257 |
} catch { return ''; }
|
| 258 |
};
|
| 259 |
|
| 260 |
+
// Voice note simulation helpers
|
| 261 |
+
const startRecording = () => {
|
| 262 |
+
setIsRecording(true);
|
| 263 |
+
setRecordDuration(0);
|
| 264 |
+
recordTimerRef.current = setInterval(() => {
|
| 265 |
+
setRecordDuration(prev => prev + 1);
|
| 266 |
+
}, 1000);
|
| 267 |
+
};
|
| 268 |
+
|
| 269 |
+
const stopRecording = async (shouldSave = true) => {
|
| 270 |
+
setIsRecording(false);
|
| 271 |
+
if (recordTimerRef.current) clearInterval(recordTimerRef.current);
|
| 272 |
+
if (shouldSave && recordDuration > 0) {
|
| 273 |
+
const durationStr = formatDuration(recordDuration);
|
| 274 |
+
const content = `🎤 Voice message (${durationStr})`;
|
| 275 |
+
const tempMessage = {
|
| 276 |
+
id: `temp-${Date.now()}`,
|
| 277 |
+
ticket_id: ticketId,
|
| 278 |
+
sender_id: user.id,
|
| 279 |
+
sender_name: profile?.full_name || user.email,
|
| 280 |
+
sender_role: profile?.role || 'user',
|
| 281 |
+
message: content,
|
| 282 |
+
is_internal: false,
|
| 283 |
+
created_at: new Date().toISOString()
|
| 284 |
+
};
|
| 285 |
+
|
| 286 |
+
setMessages(prev => [...prev, tempMessage]);
|
| 287 |
+
setTimeout(() => scrollToBottom(), 50);
|
| 288 |
+
|
| 289 |
+
try {
|
| 290 |
+
const { error } = await supabase
|
| 291 |
+
.from('ticket_messages')
|
| 292 |
+
.insert([{
|
| 293 |
+
ticket_id: ticketId,
|
| 294 |
+
sender_id: user.id,
|
| 295 |
+
sender_name: profile?.full_name || user.email,
|
| 296 |
+
sender_role: profile?.role || 'user',
|
| 297 |
+
message: content
|
| 298 |
+
}]);
|
| 299 |
+
if (error) throw error;
|
| 300 |
+
} catch (err) {
|
| 301 |
+
console.error("Error sending voice message:", err);
|
| 302 |
+
setMessages(prev => prev.filter(m => m.id !== tempMessage.id));
|
| 303 |
+
}
|
| 304 |
+
}
|
| 305 |
+
};
|
| 306 |
+
|
| 307 |
+
const playVoiceNote = (msgId, durationStr) => {
|
| 308 |
+
if (playingVoiceId === msgId) {
|
| 309 |
+
setPlayingVoiceId(null);
|
| 310 |
+
if (voicePlayTimerRef.current) clearInterval(voicePlayTimerRef.current);
|
| 311 |
+
return;
|
| 312 |
+
}
|
| 313 |
+
|
| 314 |
+
if (voicePlayTimerRef.current) clearInterval(voicePlayTimerRef.current);
|
| 315 |
+
setPlayingVoiceId(msgId);
|
| 316 |
+
|
| 317 |
+
const parts = durationStr.split(':');
|
| 318 |
+
const totalSecs = parseInt(parts[0] || '0') * 60 + parseInt(parts[1] || '0');
|
| 319 |
+
let currentSecs = voiceProgress[msgId] ? (voiceProgress[msgId] / 100) * totalSecs : 0;
|
| 320 |
+
|
| 321 |
+
voicePlayTimerRef.current = setInterval(() => {
|
| 322 |
+
currentSecs += 0.25;
|
| 323 |
+
const percentage = Math.min((currentSecs / totalSecs) * 100, 100);
|
| 324 |
+
|
| 325 |
+
setVoiceProgress(prev => ({
|
| 326 |
+
...prev,
|
| 327 |
+
[msgId]: percentage
|
| 328 |
+
}));
|
| 329 |
+
|
| 330 |
+
if (percentage >= 100) {
|
| 331 |
+
setPlayingVoiceId(null);
|
| 332 |
+
clearInterval(voicePlayTimerRef.current);
|
| 333 |
+
setVoiceProgress(prev => ({
|
| 334 |
+
...prev,
|
| 335 |
+
[msgId]: 0
|
| 336 |
+
}));
|
| 337 |
+
}
|
| 338 |
+
}, 250);
|
| 339 |
+
};
|
| 340 |
+
|
| 341 |
+
const handleCallPress = (type) => {
|
| 342 |
+
setActiveCall(type);
|
| 343 |
+
setCallStatus('Ringing...');
|
| 344 |
+
setCallDuration(0);
|
| 345 |
+
setIsMuted(false);
|
| 346 |
+
setIsSpeakerOn(true);
|
| 347 |
+
|
| 348 |
+
setTimeout(() => {
|
| 349 |
+
setCallStatus('Connected');
|
| 350 |
+
callTimerRef.current = setInterval(() => {
|
| 351 |
+
setCallDuration(prev => prev + 1);
|
| 352 |
+
}, 1000);
|
| 353 |
+
}, 2000);
|
| 354 |
+
};
|
| 355 |
+
|
| 356 |
+
const endCall = () => {
|
| 357 |
+
setActiveCall(null);
|
| 358 |
+
if (callTimerRef.current) clearInterval(callTimerRef.current);
|
| 359 |
+
};
|
| 360 |
+
|
| 361 |
+
const formatDuration = (sec) => {
|
| 362 |
+
const mins = Math.floor(sec / 60);
|
| 363 |
+
const secs = sec % 60;
|
| 364 |
+
return `${mins}:${secs < 10 ? '0' : ''}${secs}`;
|
| 365 |
+
};
|
| 366 |
+
|
| 367 |
+
const getTickColor = (msg) => {
|
| 368 |
+
if (String(msg.id).startsWith('temp-')) return '#9ca3af'; // sending / unsaved
|
| 369 |
+
// Double blue ticks if any admin/AI replied after this message
|
| 370 |
+
const hasAdminReply = messages.some(
|
| 371 |
+
m => m.created_at &&
|
| 372 |
+
new Date(m.created_at) > new Date(msg.created_at) &&
|
| 373 |
+
(m.sender_role === 'admin' || m.sender_role === 'super_admin' || m.sender_role === 'ai')
|
| 374 |
+
);
|
| 375 |
+
if (hasAdminReply) return '#38bdf8'; // WhatsApp active blue ticks!
|
| 376 |
+
return '#9ca3af'; // Grey double-ticks!
|
| 377 |
+
};
|
| 378 |
+
|
| 379 |
const grouped = [];
|
| 380 |
let lastDate = null;
|
| 381 |
messages.forEach((msg) => {
|
|
|
|
| 388 |
});
|
| 389 |
|
| 390 |
return (
|
| 391 |
+
<div style={{ background: '#ffffff', borderRadius: '20px', border: '1px solid #f0fdf4', height: '100%', display: 'flex', flexDirection: 'column', overflow: 'hidden', position: 'relative' }}>
|
| 392 |
+
|
| 393 |
+
{/* Pulsing Dot Styles */}
|
| 394 |
+
<style>{`
|
| 395 |
+
@keyframes pulse {
|
| 396 |
+
0% { opacity: 0.3; }
|
| 397 |
+
50% { opacity: 1; }
|
| 398 |
+
100% { opacity: 0.3; }
|
| 399 |
+
}
|
| 400 |
+
.pulse-dot {
|
| 401 |
+
animation: pulse 1.2s infinite;
|
| 402 |
+
}
|
| 403 |
+
`}</style>
|
| 404 |
+
|
| 405 |
{/* Header */}
|
| 406 |
<div style={{ padding: '12px 20px', borderBottom: '1px solid #f0fdf4', display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
| 407 |
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
|
|
|
| 410 |
</h2>
|
| 411 |
</div>
|
| 412 |
|
| 413 |
+
<div className="flex items-center gap-4" style={{ display: 'flex', alignItems: 'center', gap: '16px' }}>
|
| 414 |
{isStaff && (
|
| 415 |
<div style={{ display: 'flex', alignItems: 'center', background: 'transparent', gap: '4px' }}>
|
| 416 |
<button
|
| 417 |
+
type="button"
|
| 418 |
onClick={() => setIsInternal(false)}
|
| 419 |
style={{ padding: '4px 12px', fontSize: '10px', fontWeight: 700, borderRadius: '8px', cursor: 'pointer', border: 'none', transition: 'all 0.2s', ...( !isInternal ? { background: '#0f1f12', color: '#ffffff' } : { background: 'transparent', color: '#6b7280' } ) }}
|
| 420 |
>
|
| 421 |
PUBLIC
|
| 422 |
</button>
|
| 423 |
<button
|
| 424 |
+
type="button"
|
| 425 |
onClick={() => setIsInternal(true)}
|
| 426 |
style={{ padding: '4px 12px', fontSize: '10px', fontWeight: 700, borderRadius: '8px', cursor: 'pointer', border: 'none', transition: 'all 0.2s', ...( isInternal ? { background: '#0f1f12', color: '#ffffff' } : { background: 'transparent', color: '#6b7280' } ) }}
|
| 427 |
>
|
|
|
|
| 429 |
</button>
|
| 430 |
</div>
|
| 431 |
)}
|
| 432 |
+
|
| 433 |
+
{/* WhatsApp Call Header Buttons */}
|
| 434 |
+
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
| 435 |
+
<button
|
| 436 |
+
type="button"
|
| 437 |
+
onClick={() => handleCallPress('Audio')}
|
| 438 |
+
style={{ background: 'transparent', border: 'none', cursor: 'pointer', padding: '6px', color: '#374151' }}
|
| 439 |
+
className="hover:text-emerald-600 transition-colors"
|
| 440 |
+
>
|
| 441 |
+
<Phone size={18} />
|
| 442 |
+
</button>
|
| 443 |
+
<button
|
| 444 |
+
type="button"
|
| 445 |
+
onClick={() => handleCallPress('Video')}
|
| 446 |
+
style={{ background: 'transparent', border: 'none', cursor: 'pointer', padding: '6px', color: '#374151' }}
|
| 447 |
+
className="hover:text-emerald-600 transition-colors"
|
| 448 |
+
>
|
| 449 |
+
<Video size={18} />
|
| 450 |
+
</button>
|
| 451 |
+
</div>
|
| 452 |
</div>
|
| 453 |
</div>
|
| 454 |
|
|
|
|
| 482 |
const msg = item.data;
|
| 483 |
const isMe = msg.sender_id === user?.id;
|
| 484 |
const isAdmin = msg.sender_role === 'admin' || msg.sender_role === 'super_admin';
|
| 485 |
+
const isVoiceNote = msg.message && msg.message.startsWith('🎤 Voice message');
|
| 486 |
+
let voiceDuration = "0:00";
|
| 487 |
+
if (isVoiceNote) {
|
| 488 |
+
const match = msg.message.match(/\((.*?)\)/);
|
| 489 |
+
if (match) voiceDuration = match[1];
|
| 490 |
+
}
|
| 491 |
|
| 492 |
return (
|
| 493 |
<div key={msg.id || i} className={`flex gap-2.5 ${isMe ? 'justify-end' : 'justify-start'} group py-1`}>
|
|
|
|
| 506 |
<span className="text-[8px] font-bold text-slate-300">
|
| 507 |
{formatTime(msg.created_at)}
|
| 508 |
</span>
|
| 509 |
+
{isMe && (
|
| 510 |
+
<span style={{ marginLeft: '4px' }}>
|
| 511 |
+
{String(msg.id).startsWith('temp-') ? (
|
| 512 |
+
<Check size={11} className="text-slate-300" />
|
| 513 |
+
) : (
|
| 514 |
+
<CheckCheck size={11} style={{ color: getTickColor(msg) }} />
|
| 515 |
+
)}
|
| 516 |
+
</span>
|
| 517 |
+
)}
|
| 518 |
</div>
|
| 519 |
|
| 520 |
<div style={{
|
|
|
|
| 527 |
background: '#0f1f12', color: '#ffffff', borderRadius: '14px'
|
| 528 |
})
|
| 529 |
}}>
|
| 530 |
+
{isVoiceNote ? (
|
| 531 |
+
<div style={{ display: 'flex', alignItems: 'center', gap: '12px', width: '220px' }}>
|
| 532 |
+
<button
|
| 533 |
+
type="button"
|
| 534 |
+
onClick={() => playVoiceNote(msg.id, voiceDuration)}
|
| 535 |
+
style={{ width: '32px', height: '32px', borderRadius: '50%', border: 'none', background: 'rgba(255,255,255,0.15)', color: isMe ? '#ffffff' : '#0f1f12', display: 'flex', alignItems: 'center', justifyContent: 'center', cursor: 'pointer' }}
|
| 536 |
+
>
|
| 537 |
+
{playingVoiceId === msg.id ? <Pause size={14} fill="currentColor" /> : <Play size={14} fill="currentColor" style={{ marginLeft: '2px' }} />}
|
| 538 |
+
</button>
|
| 539 |
+
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', gap: '4px' }}>
|
| 540 |
+
{/* Waveform bars */}
|
| 541 |
+
<div style={{ display: 'flex', alignItems: 'center', gap: '3px', height: '18px' }}>
|
| 542 |
+
{[8, 14, 18, 10, 6, 12, 16, 10, 14, 20, 12, 8, 14, 18, 10, 8, 12, 6, 14, 8].map((barHeight, idx) => {
|
| 543 |
+
const barProgress = (idx / 20) * 100;
|
| 544 |
+
const isPlayed = (voiceProgress[msg.id] || 0) >= barProgress;
|
| 545 |
+
return (
|
| 546 |
+
<div
|
| 547 |
+
key={idx}
|
| 548 |
+
style={{
|
| 549 |
+
width: '2px',
|
| 550 |
+
borderRadius: '1px',
|
| 551 |
+
height: `${barHeight}px`,
|
| 552 |
+
background: isPlayed
|
| 553 |
+
? (isMe ? '#34d399' : '#0f1f12')
|
| 554 |
+
: (isMe ? 'rgba(255,255,255,0.25)' : 'rgba(0,0,0,0.15)')
|
| 555 |
+
}}
|
| 556 |
+
/>
|
| 557 |
+
);
|
| 558 |
+
})}
|
| 559 |
+
</div>
|
| 560 |
+
<span style={{ fontSize: '9px', fontWeight: 600, opacity: 0.8 }}>
|
| 561 |
+
{playingVoiceId === msg.id
|
| 562 |
+
? formatDuration(Math.round(((voiceProgress[msg.id] || 0) / 100) * (parseInt(voiceDuration.split(':')[0] || '0') * 60 + parseInt(voiceDuration.split(':')[1] || '0'))))
|
| 563 |
+
: voiceDuration
|
| 564 |
+
}
|
| 565 |
+
</span>
|
| 566 |
+
</div>
|
| 567 |
+
</div>
|
| 568 |
+
) : (
|
| 569 |
+
msg.message
|
| 570 |
+
)}
|
| 571 |
</div>
|
| 572 |
</div>
|
| 573 |
|
|
|
|
| 591 |
|
| 592 |
{/* Input */}
|
| 593 |
<div style={{ padding: '16px 20px', borderTop: '1px solid #f0fdf4', background: '#ffffff' }}>
|
| 594 |
+
{isRecording ? (
|
| 595 |
+
<div style={{ display: 'flex', alignItems: 'center', gap: '12px', background: '#f9fafb', border: '1.5px dashed #f87171', borderRadius: '12px', padding: '10px 16px', fontSize: '13px' }}>
|
| 596 |
+
<div className="pulse-dot" style={{ width: '8px', height: '8px', borderRadius: '50%', background: '#ef4444' }} />
|
| 597 |
+
<span style={{ flex: 1, color: '#ef4444', fontWeight: 700 }}>Recording {formatDuration(recordDuration)}</span>
|
| 598 |
+
<button
|
| 599 |
+
type="button"
|
| 600 |
+
onClick={() => stopRecording(false)}
|
| 601 |
+
style={{ background: 'transparent', border: 'none', color: '#9ca3af', cursor: 'pointer', fontSize: '11px', fontWeight: 700 }}
|
| 602 |
+
>
|
| 603 |
+
Cancel
|
| 604 |
+
</button>
|
| 605 |
+
<button
|
| 606 |
+
type="button"
|
| 607 |
+
onClick={() => stopRecording(true)}
|
| 608 |
+
style={{ background: '#ef4444', border: 'none', color: '#ffffff', borderRadius: '8px', padding: '6px 12px', cursor: 'pointer', fontSize: '11px', fontWeight: 700, display: 'flex', alignItems: 'center', gap: '4px' }}
|
| 609 |
+
>
|
| 610 |
+
<Send size={10} />
|
| 611 |
+
Send
|
| 612 |
+
</button>
|
| 613 |
+
</div>
|
| 614 |
+
) : (
|
| 615 |
+
<form onSubmit={handleSend} className="flex gap-3">
|
| 616 |
+
<input
|
| 617 |
+
ref={inputRef}
|
| 618 |
+
type="text"
|
| 619 |
+
value={inputValue}
|
| 620 |
+
onChange={handleInputChange}
|
| 621 |
+
placeholder="Type your message..."
|
| 622 |
+
style={{ flex: 1, background: '#f9fafb', border: '1.5px solid #e5e7eb', borderRadius: '12px', padding: '10px 16px', fontSize: '13px', outline: 'none' }}
|
| 623 |
+
className="focus:border-emerald-500 transition-colors"
|
| 624 |
+
/>
|
| 625 |
+
<button
|
| 626 |
+
type="button"
|
| 627 |
+
onClick={startRecording}
|
| 628 |
+
style={{ padding: '10px', background: 'rgba(0,0,0,0.05)', color: '#374151', borderRadius: '10px', border: 'none', cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center' }}
|
| 629 |
+
className="active:scale-95 transition-transform"
|
| 630 |
+
>
|
| 631 |
+
<Mic size={16} />
|
| 632 |
+
</button>
|
| 633 |
+
<button
|
| 634 |
+
type="submit"
|
| 635 |
+
disabled={!inputValue.trim() || !user}
|
| 636 |
+
style={{ padding: '10px 20px', background: '#16a34a', color: '#ffffff', borderRadius: '10px', fontWeight: 600, fontSize: '12px', border: 'none', cursor: 'pointer', display: 'flex', alignItems: 'center', gap: '8px' }}
|
| 637 |
+
className="active:scale-95 transition-transform disabled:opacity-50"
|
| 638 |
+
>
|
| 639 |
+
{isInternal ? <ShieldCheck size={14} /> : <Send size={14} />}
|
| 640 |
+
<span className="hidden sm:inline">{isInternal ? 'Note' : 'Send'}</span>
|
| 641 |
+
</button>
|
| 642 |
+
</form>
|
| 643 |
+
)}
|
| 644 |
</div>
|
| 645 |
+
|
| 646 |
+
{/* High-Fidelity Call Overlay Modal */}
|
| 647 |
+
{activeCall && (
|
| 648 |
+
<div style={{ position: 'absolute', top: 0, bottom: 0, left: 0, right: 0, background: '#0b132b', color: '#ffffff', display: 'flex', flexDirection: 'column', justifyContent: 'space-between', padding: '40px 20px', zIndex: 99999 }}>
|
| 649 |
+
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', gap: '6px', opacity: 0.6 }}>
|
| 650 |
+
<Shield size={12} className="text-emerald-500" />
|
| 651 |
+
<span style={{ fontSize: '9px', fontWeight: 800, letterSpacing: '0.1em' }}>SECURE END-TO-END ENCRYPTED</span>
|
| 652 |
+
</div>
|
| 653 |
+
|
| 654 |
+
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '14px' }}>
|
| 655 |
+
<div style={{ width: '90px', height: '90px', borderRadius: '50%', background: '#16a34a', display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: '32px', fontWeight: 900, boxShadow: '0 10px 25px rgba(22,163,74,0.3)' }}>
|
| 656 |
+
{(ticketId || 'A')[0].toUpperCase()}
|
| 657 |
+
</div>
|
| 658 |
+
<h3 style={{ fontSize: '20px', fontWeight: 800, margin: 0 }}>Support Desk Call</h3>
|
| 659 |
+
<span style={{ fontSize: '13px', opacity: 0.7 }}>
|
| 660 |
+
{callStatus === 'Connected' ? formatDuration(callDuration) : callStatus}
|
| 661 |
+
</span>
|
| 662 |
+
</div>
|
| 663 |
+
|
| 664 |
+
{/* Web Video call screen camera mock */}
|
| 665 |
+
{activeCall === 'Video' && (
|
| 666 |
+
<div style={{ width: '100%', height: '140px', background: 'rgba(255,255,255,0.03)', borderRadius: '16px', border: '1px solid rgba(255,255,255,0.08)', position: 'relative', overflow: 'hidden', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
| 667 |
+
<div style={{ width: '40px', height: '60px', borderRadius: '8px', background: '#1d2d44', border: '1px solid rgba(255,255,255,0.1)', position: 'absolute', bottom: '12px', right: '12px' }} />
|
| 668 |
+
<span style={{ fontSize: '10px', opacity: 0.4 }}>Camera active stream</span>
|
| 669 |
+
</div>
|
| 670 |
+
)}
|
| 671 |
+
|
| 672 |
+
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', gap: '20px' }}>
|
| 673 |
+
<button
|
| 674 |
+
type="button"
|
| 675 |
+
onClick={() => setIsMuted(!isMuted)}
|
| 676 |
+
style={{ width: '48px', height: '48px', borderRadius: '50%', border: 'none', background: isMuted ? '#ffffff' : 'rgba(255,255,255,0.08)', color: isMuted ? '#0b132b' : '#ffffff', cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center' }}
|
| 677 |
+
>
|
| 678 |
+
{isMuted ? <MicOff size={18} /> : <Mic size={18} />}
|
| 679 |
+
</button>
|
| 680 |
+
|
| 681 |
+
<button
|
| 682 |
+
type="button"
|
| 683 |
+
onClick={endCall}
|
| 684 |
+
style={{ width: '48px', height: '48px', borderRadius: '50%', border: 'none', background: '#ef4444', color: '#ffffff', cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center' }}
|
| 685 |
+
>
|
| 686 |
+
<Phone size={18} style={{ transform: 'rotate(135deg)' }} />
|
| 687 |
+
</button>
|
| 688 |
+
|
| 689 |
+
<button
|
| 690 |
+
type="button"
|
| 691 |
+
onClick={() => setIsSpeakerOn(!isSpeakerOn)}
|
| 692 |
+
style={{ width: '48px', height: '48px', borderRadius: '50%', border: 'none', background: isSpeakerOn ? '#ffffff' : 'rgba(255,255,255,0.08)', color: isSpeakerOn ? '#16a34a' : '#ffffff', cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center' }}
|
| 693 |
+
>
|
| 694 |
+
<Volume2 size={18} />
|
| 695 |
+
</button>
|
| 696 |
+
</div>
|
| 697 |
+
</div>
|
| 698 |
+
)}
|
| 699 |
</div>
|
| 700 |
);
|
| 701 |
};
|
Frontend/src/hooks/useRealtimeNotifications.js
CHANGED
|
@@ -3,144 +3,49 @@ import { supabase } from '../lib/supabaseClient';
|
|
| 3 |
import useAuthStore from '../store/authStore';
|
| 4 |
import useTicketStore from '../store/ticketStore';
|
| 5 |
|
| 6 |
-
|
| 7 |
-
const processedPayloads = new Set();
|
| 8 |
-
|
| 9 |
-
const useRealtimeNotifications = () => {
|
| 10 |
const { user, profile } = useAuthStore();
|
| 11 |
-
const {
|
| 12 |
|
| 13 |
useEffect(() => {
|
| 14 |
if (!user || !profile) return;
|
| 15 |
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
// Deduplication logic using the internal commit timestamp
|
| 20 |
-
const commitTs = payload.commit_timestamp;
|
| 21 |
-
if (commitTs && processedPayloads.has(commitTs)) return;
|
| 22 |
-
if (commitTs) processedPayloads.add(commitTs);
|
| 23 |
-
|
| 24 |
-
const isAdmin = profile.role === 'admin' || profile.role === 'master_admin';
|
| 25 |
-
const isOwner = newRecord.user_id === user.id;
|
| 26 |
-
|
| 27 |
-
// 1. NEW TICKET CREATED -> Notify Admins
|
| 28 |
-
if (eventType === 'INSERT') {
|
| 29 |
-
if (isAdmin) {
|
| 30 |
-
addNotification({
|
| 31 |
-
title: 'New Ticket Received',
|
| 32 |
-
message: `A new ${newRecord.category || 'Support'} ticket requires triage.`,
|
| 33 |
-
ticketId: newRecord.id,
|
| 34 |
-
type: 'new_ticket',
|
| 35 |
-
recipientRole: 'admin'
|
| 36 |
-
});
|
| 37 |
-
}
|
| 38 |
-
return;
|
| 39 |
-
}
|
| 40 |
-
|
| 41 |
-
// 2. UPDATES
|
| 42 |
-
if (eventType === 'UPDATE' && oldRecord) {
|
| 43 |
-
// Determine what changed
|
| 44 |
-
const statusChanged = oldRecord.status !== newRecord.status;
|
| 45 |
-
const teamChanged = oldRecord.assigned_team !== newRecord.assigned_team;
|
| 46 |
-
|
| 47 |
-
// For nested JSON/JSONB updates (messages)
|
| 48 |
-
const oldMessagesLen = Array.isArray(oldRecord.messages) ? oldRecord.messages.length : 0;
|
| 49 |
-
const newMessagesLen = Array.isArray(newRecord.messages) ? newRecord.messages.length : 0;
|
| 50 |
-
// eslint-disable-next-line no-unused-vars
|
| 51 |
-
const newlyAddedMessage = newMessagesLen > oldMessagesLen
|
| 52 |
-
? newRecord.messages[newMessagesLen - 1]
|
| 53 |
-
: null;
|
| 54 |
-
|
| 55 |
-
// STATUS CHANGE -> Notify User (e.g., Resolved, In Progress)
|
| 56 |
-
if (statusChanged && isOwner) {
|
| 57 |
-
addNotification({
|
| 58 |
-
title: `Ticket ${newRecord.status}`,
|
| 59 |
-
message: `Your ticket status was updated to ${newRecord.status}.`,
|
| 60 |
-
ticketId: newRecord.id,
|
| 61 |
-
type: newRecord.status?.toLowerCase().includes('resolv') ? 'resolution' : 'update',
|
| 62 |
-
recipientRole: 'user'
|
| 63 |
-
});
|
| 64 |
-
}
|
| 65 |
-
|
| 66 |
-
// RE-ASSIGNMENT -> Notify User
|
| 67 |
-
if (teamChanged && isOwner && newRecord.assigned_team) {
|
| 68 |
-
addNotification({
|
| 69 |
-
title: 'Ticket Re-Assigned',
|
| 70 |
-
message: `Your ticket is now being handled by ${newRecord.assigned_team}.`,
|
| 71 |
-
ticketId: newRecord.id,
|
| 72 |
-
type: 'update',
|
| 73 |
-
recipientRole: 'user'
|
| 74 |
-
});
|
| 75 |
-
}
|
| 76 |
-
}
|
| 77 |
-
};
|
| 78 |
-
|
| 79 |
-
const handleMessageChange = (payload) => {
|
| 80 |
-
const { eventType, new: newMessage } = payload;
|
| 81 |
-
if (eventType !== 'INSERT') return;
|
| 82 |
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
if (commitTs && processedPayloads.has(commitTs)) return;
|
| 86 |
-
if (commitTs) processedPayloads.add(commitTs);
|
| 87 |
-
|
| 88 |
-
const isFromAdmin = newMessage.sender_role === 'admin' || newMessage.sender_role === 'super_admin' || newMessage.sender_role === 'master_admin';
|
| 89 |
-
const isAdmin = profile.role === 'admin' || profile.role === 'master_admin';
|
| 90 |
-
|
| 91 |
-
// Note: In a real app, we should check if the current user is the owner of the ticket
|
| 92 |
-
// But for notifications, if I'm the recipient (admin or owner), I should see it.
|
| 93 |
-
// For now, we rely on recipientRole filter in the UI components.
|
| 94 |
-
|
| 95 |
-
if (isFromAdmin) {
|
| 96 |
-
// Sent by Admin -> Notify User (only if it's their ticket)
|
| 97 |
-
// Note: ideally we'd check isOwner here, but for now we filter by role
|
| 98 |
-
if (profile.role === 'user') {
|
| 99 |
-
addNotification({
|
| 100 |
-
title: 'New Response from Support',
|
| 101 |
-
message: newMessage.message?.length > 120 ? newMessage.message.substring(0, 120) + "..." : (newMessage.message || "An agent replied to your ticket."),
|
| 102 |
-
ticketId: newMessage.ticket_id,
|
| 103 |
-
type: 'message',
|
| 104 |
-
recipientRole: 'user'
|
| 105 |
-
});
|
| 106 |
-
}
|
| 107 |
-
} else {
|
| 108 |
-
// Sent by User -> Notify Admin
|
| 109 |
-
if (isAdmin) {
|
| 110 |
-
addNotification({
|
| 111 |
-
title: 'New Message from User',
|
| 112 |
-
message: newMessage.message || "A user replied to their ticket.",
|
| 113 |
-
ticketId: newMessage.ticket_id,
|
| 114 |
-
type: 'message',
|
| 115 |
-
recipientRole: 'admin'
|
| 116 |
-
});
|
| 117 |
-
}
|
| 118 |
-
}
|
| 119 |
-
};
|
| 120 |
-
|
| 121 |
-
const ticketChannel = supabase
|
| 122 |
-
.channel('ticket-notifications')
|
| 123 |
.on(
|
| 124 |
'postgres_changes',
|
| 125 |
-
{
|
| 126 |
-
|
| 127 |
-
|
| 128 |
-
|
| 129 |
-
|
| 130 |
-
|
| 131 |
-
|
| 132 |
-
|
| 133 |
-
|
| 134 |
-
|
| 135 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 136 |
)
|
| 137 |
.subscribe();
|
| 138 |
|
| 139 |
return () => {
|
| 140 |
-
supabase.removeChannel(
|
| 141 |
-
supabase.removeChannel(messageChannel);
|
| 142 |
};
|
| 143 |
-
}, [user, profile,
|
| 144 |
};
|
| 145 |
|
| 146 |
-
export default
|
|
|
|
| 3 |
import useAuthStore from '../store/authStore';
|
| 4 |
import useTicketStore from '../store/ticketStore';
|
| 5 |
|
| 6 |
+
const useTicketsRealtime = () => {
|
|
|
|
|
|
|
|
|
|
| 7 |
const { user, profile } = useAuthStore();
|
| 8 |
+
const { addTicket, updateTicket, removeTicket } = useTicketStore();
|
| 9 |
|
| 10 |
useEffect(() => {
|
| 11 |
if (!user || !profile) return;
|
| 12 |
|
| 13 |
+
// Only admins see the live ticket queue
|
| 14 |
+
const isAdmin = profile.role === 'admin' || profile.role === 'master_admin';
|
| 15 |
+
if (!isAdmin) return;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 16 |
|
| 17 |
+
const channel = supabase
|
| 18 |
+
.channel('tickets-realtime-dashboard')
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 19 |
.on(
|
| 20 |
'postgres_changes',
|
| 21 |
+
{
|
| 22 |
+
event: '*',
|
| 23 |
+
schema: 'public',
|
| 24 |
+
table: 'tickets',
|
| 25 |
+
filter: `company_id=eq.${profile.company_id}`,
|
| 26 |
+
},
|
| 27 |
+
(payload) => {
|
| 28 |
+
const { eventType, new: newRecord, old: oldRecord } = payload;
|
| 29 |
+
|
| 30 |
+
if (eventType === 'INSERT') {
|
| 31 |
+
addTicket(newRecord);
|
| 32 |
+
}
|
| 33 |
+
|
| 34 |
+
if (eventType === 'UPDATE') {
|
| 35 |
+
updateTicket(newRecord.ticket_id, newRecord);
|
| 36 |
+
}
|
| 37 |
+
|
| 38 |
+
if (eventType === 'DELETE') {
|
| 39 |
+
removeTicket(oldRecord.ticket_id);
|
| 40 |
+
}
|
| 41 |
+
}
|
| 42 |
)
|
| 43 |
.subscribe();
|
| 44 |
|
| 45 |
return () => {
|
| 46 |
+
supabase.removeChannel(channel);
|
|
|
|
| 47 |
};
|
| 48 |
+
}, [user, profile, addTicket, updateTicket, removeTicket]);
|
| 49 |
};
|
| 50 |
|
| 51 |
+
export default useTicketsRealtime;
|
Frontend/src/legacy_ui/Dashboard.jsx
CHANGED
|
@@ -1,26 +1,46 @@
|
|
| 1 |
import {
|
| 2 |
-
BarChart,
|
| 3 |
-
|
| 4 |
-
XAxis,
|
| 5 |
-
YAxis,
|
| 6 |
-
Tooltip,
|
| 7 |
-
PieChart,
|
| 8 |
-
Pie,
|
| 9 |
-
Cell,
|
| 10 |
-
ResponsiveContainer,
|
| 11 |
-
Legend,
|
| 12 |
} from "recharts";
|
| 13 |
-
import { useEffect, useState } from "react";
|
| 14 |
import { api } from "../services/api";
|
| 15 |
import { Card, CardContent, CardHeader, CardTitle } from "../components/ui/card";
|
| 16 |
import { ExpandableTabs } from "../components/ui/expandable-tabs";
|
| 17 |
-
import { Bell, Home, Settings, HelpCircle, Shield, Activity, Zap, Users
|
|
|
|
|
|
|
| 18 |
|
| 19 |
const COLORS = ["#0088FE", "#00C49F", "#FFBB28", "#FF8042", "#8884d8"];
|
| 20 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 21 |
const Dashboard = () => {
|
| 22 |
-
const [tickets, setTickets] = useState([]);
|
| 23 |
const [loading, setLoading] = useState(true);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 24 |
|
| 25 |
const tabs = [
|
| 26 |
{ title: "Dashboard", icon: Home },
|
|
@@ -30,11 +50,12 @@ const Dashboard = () => {
|
|
| 30 |
{ title: "Security", icon: Shield },
|
| 31 |
];
|
| 32 |
|
|
|
|
| 33 |
useEffect(() => {
|
| 34 |
const fetchTickets = async () => {
|
| 35 |
try {
|
| 36 |
const data = await api.getTickets();
|
| 37 |
-
|
| 38 |
} catch (error) {
|
| 39 |
console.error("Failed to fetch tickets", error);
|
| 40 |
} finally {
|
|
@@ -42,26 +63,41 @@ const Dashboard = () => {
|
|
| 42 |
}
|
| 43 |
};
|
| 44 |
fetchTickets();
|
|
|
|
| 45 |
}, []);
|
| 46 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 47 |
// Summary Counts
|
| 48 |
const totalTickets = tickets.length;
|
| 49 |
const openTickets = tickets.filter(
|
| 50 |
(t) => t.status === "Open" || t.Resolution_Status === "Open"
|
| 51 |
).length;
|
| 52 |
-
// eslint-disable-next-line no-unused-vars
|
| 53 |
-
const resolvedTickets = tickets.filter(
|
| 54 |
-
(t) => t.status === "Resolved" || t.Resolution_Status === "Resolved" || t.Resolution_Status === "Auto-Resolved" || t.Auto_Resolve
|
| 55 |
-
).length;
|
| 56 |
const autoResolvedTickets = tickets.filter(
|
| 57 |
(t) => t.Auto_Resolve === true || t.Resolution_Status === "Auto-Resolved"
|
| 58 |
).length;
|
| 59 |
-
|
| 60 |
-
// Automation Rate
|
| 61 |
const automationRate =
|
| 62 |
totalTickets > 0 ? (autoResolvedTickets / totalTickets) * 100 : 0;
|
| 63 |
|
| 64 |
-
// Transform data for charts
|
| 65 |
const categoryData = Object.entries(
|
| 66 |
tickets.reduce((acc, ticket) => {
|
| 67 |
const cat = ticket.category || "Unknown";
|
|
@@ -88,29 +124,44 @@ const Dashboard = () => {
|
|
| 88 |
|
| 89 |
return (
|
| 90 |
<div className="max-w-7xl mx-auto space-y-8 p-4 md:p-6">
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 91 |
<div className="flex flex-col md:flex-row justify-between items-center gap-4">
|
| 92 |
<div>
|
| 93 |
<h2 className="text-3xl font-black text-slate-900 tracking-tight flex items-center gap-2">
|
| 94 |
<Activity className="text-indigo-600" /> Executive Overview
|
| 95 |
</h2>
|
| 96 |
-
<p className="text-slate-500 font-medium mt-1">
|
|
|
|
|
|
|
| 97 |
</div>
|
| 98 |
<ExpandableTabs tabs={tabs} />
|
| 99 |
</div>
|
| 100 |
|
| 101 |
{tickets.length === 0 ? (
|
| 102 |
<Card className="text-center p-12 border-dashed border-2">
|
| 103 |
-
<p className="text-gray-500 text-lg">
|
|
|
|
|
|
|
| 104 |
</Card>
|
| 105 |
) : (
|
| 106 |
<>
|
| 107 |
-
{/* Summary
|
| 108 |
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-6">
|
| 109 |
<Card className="border-none shadow-md shadow-slate-200/50">
|
| 110 |
<CardHeader className="pb-2">
|
| 111 |
<CardTitle className="text-xs font-bold text-slate-400 uppercase tracking-widest flex items-center justify-between">
|
| 112 |
-
Total Tickets
|
| 113 |
-
<Activity size={16} className="text-indigo-500" />
|
| 114 |
</CardTitle>
|
| 115 |
</CardHeader>
|
| 116 |
<CardContent>
|
|
@@ -121,8 +172,7 @@ const Dashboard = () => {
|
|
| 121 |
<Card className="border-none shadow-md shadow-slate-200/50">
|
| 122 |
<CardHeader className="pb-2">
|
| 123 |
<CardTitle className="text-xs font-bold text-slate-400 uppercase tracking-widest flex items-center justify-between">
|
| 124 |
-
Auto-Resolved
|
| 125 |
-
<Zap size={16} className="text-emerald-500" />
|
| 126 |
</CardTitle>
|
| 127 |
</CardHeader>
|
| 128 |
<CardContent>
|
|
@@ -133,8 +183,7 @@ const Dashboard = () => {
|
|
| 133 |
<Card className="border-none shadow-md shadow-slate-200/50">
|
| 134 |
<CardHeader className="pb-2">
|
| 135 |
<CardTitle className="text-xs font-bold text-slate-400 uppercase tracking-widest flex items-center justify-between">
|
| 136 |
-
Open Tickets
|
| 137 |
-
<Users size={16} className="text-orange-500" />
|
| 138 |
</CardTitle>
|
| 139 |
</CardHeader>
|
| 140 |
<CardContent>
|
|
@@ -145,8 +194,7 @@ const Dashboard = () => {
|
|
| 145 |
<Card className="border-none shadow-md shadow-slate-200/50">
|
| 146 |
<CardHeader className="pb-2">
|
| 147 |
<CardTitle className="text-xs font-bold text-slate-400 uppercase tracking-widest flex items-center justify-between">
|
| 148 |
-
Automation Rate
|
| 149 |
-
<Shield size={16} className="text-purple-500" />
|
| 150 |
</CardTitle>
|
| 151 |
</CardHeader>
|
| 152 |
<CardContent>
|
|
@@ -155,6 +203,66 @@ const Dashboard = () => {
|
|
| 155 |
</Card>
|
| 156 |
</div>
|
| 157 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 158 |
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
|
| 159 |
<Card className="border-none shadow-md shadow-slate-200/50">
|
| 160 |
<CardHeader>
|
|
@@ -168,9 +276,7 @@ const Dashboard = () => {
|
|
| 168 |
<BarChart data={categoryData}>
|
| 169 |
<XAxis dataKey="name" fontSize={12} axisLine={false} tickLine={false} />
|
| 170 |
<YAxis fontSize={12} axisLine={false} tickLine={false} />
|
| 171 |
-
<Tooltip
|
| 172 |
-
contentStyle={{ backgroundColor: '#fff', borderRadius: '8px', border: 'none', boxShadow: '0 4px 6px -1px rgb(0 0 0 / 0.1)' }}
|
| 173 |
-
/>
|
| 174 |
<Bar dataKey="value" fill="#6366f1" radius={[4, 4, 0, 0]} barSize={40} />
|
| 175 |
</BarChart>
|
| 176 |
</ResponsiveContainer>
|
|
@@ -188,23 +294,12 @@ const Dashboard = () => {
|
|
| 188 |
<div className="h-80">
|
| 189 |
<ResponsiveContainer width="100%" height="100%">
|
| 190 |
<PieChart>
|
| 191 |
-
<Pie
|
| 192 |
-
|
| 193 |
-
|
| 194 |
-
cy="50%"
|
| 195 |
-
outerRadius={100}
|
| 196 |
-
innerRadius={60}
|
| 197 |
-
paddingAngle={5}
|
| 198 |
-
dataKey="value"
|
| 199 |
-
label={({ name, percent }) =>
|
| 200 |
-
`${name} ${(percent * 100).toFixed(0)}%`
|
| 201 |
-
}
|
| 202 |
>
|
| 203 |
{statusData.map((entry, index) => (
|
| 204 |
-
<Cell
|
| 205 |
-
key={`cell-${index}`}
|
| 206 |
-
fill={COLORS[index % COLORS.length]}
|
| 207 |
-
/>
|
| 208 |
))}
|
| 209 |
</Pie>
|
| 210 |
<Tooltip />
|
|
@@ -216,13 +311,11 @@ const Dashboard = () => {
|
|
| 216 |
</Card>
|
| 217 |
</div>
|
| 218 |
|
| 219 |
-
{/*
|
| 220 |
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
|
| 221 |
<Card className="border-none shadow-md shadow-slate-200/50">
|
| 222 |
<CardHeader>
|
| 223 |
-
<CardTitle className="text-lg font-bold text-slate-800
|
| 224 |
-
Efficiency Highlights
|
| 225 |
-
</CardTitle>
|
| 226 |
</CardHeader>
|
| 227 |
<CardContent className="space-y-4">
|
| 228 |
<div className="flex items-center justify-between p-4 bg-slate-50 rounded-xl border border-slate-100">
|
|
@@ -244,23 +337,21 @@ const Dashboard = () => {
|
|
| 244 |
|
| 245 |
<Card className="border-none shadow-md shadow-slate-200/50">
|
| 246 |
<CardHeader>
|
| 247 |
-
<CardTitle className="text-lg font-bold text-slate-800
|
| 248 |
-
AI Performance
|
| 249 |
-
</CardTitle>
|
| 250 |
</CardHeader>
|
| 251 |
<CardContent>
|
| 252 |
<div className="grid grid-cols-2 gap-6">
|
| 253 |
<div className="p-6 bg-slate-50 rounded-2xl text-center border border-slate-100">
|
| 254 |
<div className="text-3xl font-black text-indigo-600">
|
| 255 |
-
{tickets.filter(t => t.confidence > 0.8).length}
|
| 256 |
</div>
|
| 257 |
-
<div className="text-xs font-bold text-slate-400 uppercase tracking-widest mt-2
|
| 258 |
</div>
|
| 259 |
<div className="p-6 bg-slate-50 rounded-2xl text-center border border-slate-100">
|
| 260 |
<div className="text-3xl font-black text-orange-600">
|
| 261 |
-
{tickets.filter(t => (t.Duplicate_Probability || t.duplicate_probability || 0) > 0.7).length}
|
| 262 |
</div>
|
| 263 |
-
<div className="text-xs font-bold text-slate-400 uppercase tracking-widest mt-2
|
| 264 |
</div>
|
| 265 |
</div>
|
| 266 |
</CardContent>
|
|
@@ -272,4 +363,4 @@ const Dashboard = () => {
|
|
| 272 |
);
|
| 273 |
};
|
| 274 |
|
| 275 |
-
export default Dashboard;
|
|
|
|
| 1 |
import {
|
| 2 |
+
BarChart, Bar, XAxis, YAxis, Tooltip,
|
| 3 |
+
PieChart, Pie, Cell, ResponsiveContainer, Legend,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4 |
} from "recharts";
|
| 5 |
+
import { useEffect, useState, useRef } from "react";
|
| 6 |
import { api } from "../services/api";
|
| 7 |
import { Card, CardContent, CardHeader, CardTitle } from "../components/ui/card";
|
| 8 |
import { ExpandableTabs } from "../components/ui/expandable-tabs";
|
| 9 |
+
import { Bell, Home, Settings, HelpCircle, Shield, Activity, Zap, Users } from "lucide-react";
|
| 10 |
+
import useTicketStore from "../store/ticketStore";
|
| 11 |
+
import useTicketsRealtime from "../hooks/useTicketsRealtime";
|
| 12 |
|
| 13 |
const COLORS = ["#0088FE", "#00C49F", "#FFBB28", "#FF8042", "#8884d8"];
|
| 14 |
|
| 15 |
+
// Tracks which ticket IDs were recently updated for highlight animation
|
| 16 |
+
const useRecentlyUpdated = () => {
|
| 17 |
+
const [recentIds, setRecentIds] = useState(new Set());
|
| 18 |
+
|
| 19 |
+
const markUpdated = (id) => {
|
| 20 |
+
setRecentIds((prev) => new Set([...prev, id]));
|
| 21 |
+
setTimeout(() => {
|
| 22 |
+
setRecentIds((prev) => {
|
| 23 |
+
const next = new Set(prev);
|
| 24 |
+
next.delete(id);
|
| 25 |
+
return next;
|
| 26 |
+
});
|
| 27 |
+
}, 2000); // highlight lasts 2 seconds
|
| 28 |
+
};
|
| 29 |
+
|
| 30 |
+
return { recentIds, markUpdated };
|
| 31 |
+
};
|
| 32 |
+
|
| 33 |
const Dashboard = () => {
|
|
|
|
| 34 |
const [loading, setLoading] = useState(true);
|
| 35 |
+
const { recentIds, markUpdated } = useRecentlyUpdated();
|
| 36 |
+
|
| 37 |
+
// Pull live tickets from Zustand store
|
| 38 |
+
const tickets = useTicketStore((state) => state.tickets);
|
| 39 |
+
const addTicket = useTicketStore((state) => state.addTicket);
|
| 40 |
+
const prevTicketsRef = useRef([]);
|
| 41 |
+
|
| 42 |
+
// Activate realtime subscription
|
| 43 |
+
useTicketsRealtime();
|
| 44 |
|
| 45 |
const tabs = [
|
| 46 |
{ title: "Dashboard", icon: Home },
|
|
|
|
| 50 |
{ title: "Security", icon: Shield },
|
| 51 |
];
|
| 52 |
|
| 53 |
+
// Initial fetch — populate store on first load
|
| 54 |
useEffect(() => {
|
| 55 |
const fetchTickets = async () => {
|
| 56 |
try {
|
| 57 |
const data = await api.getTickets();
|
| 58 |
+
data.forEach((t) => addTicket(t));
|
| 59 |
} catch (error) {
|
| 60 |
console.error("Failed to fetch tickets", error);
|
| 61 |
} finally {
|
|
|
|
| 63 |
}
|
| 64 |
};
|
| 65 |
fetchTickets();
|
| 66 |
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
| 67 |
}, []);
|
| 68 |
|
| 69 |
+
// Detect newly inserted or updated tickets for highlight animation
|
| 70 |
+
useEffect(() => {
|
| 71 |
+
const prevIds = new Set(prevTicketsRef.current.map((t) => t.ticket_id));
|
| 72 |
+
|
| 73 |
+
tickets.forEach((t) => {
|
| 74 |
+
if (!prevIds.has(t.ticket_id)) {
|
| 75 |
+
// Brand new ticket
|
| 76 |
+
markUpdated(t.ticket_id);
|
| 77 |
+
} else {
|
| 78 |
+
// Check if it was updated
|
| 79 |
+
const prev = prevTicketsRef.current.find((p) => p.ticket_id === t.ticket_id);
|
| 80 |
+
if (prev && JSON.stringify(prev) !== JSON.stringify(t)) {
|
| 81 |
+
markUpdated(t.ticket_id);
|
| 82 |
+
}
|
| 83 |
+
}
|
| 84 |
+
});
|
| 85 |
+
|
| 86 |
+
prevTicketsRef.current = tickets;
|
| 87 |
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
| 88 |
+
}, [tickets]);
|
| 89 |
+
|
| 90 |
// Summary Counts
|
| 91 |
const totalTickets = tickets.length;
|
| 92 |
const openTickets = tickets.filter(
|
| 93 |
(t) => t.status === "Open" || t.Resolution_Status === "Open"
|
| 94 |
).length;
|
|
|
|
|
|
|
|
|
|
|
|
|
| 95 |
const autoResolvedTickets = tickets.filter(
|
| 96 |
(t) => t.Auto_Resolve === true || t.Resolution_Status === "Auto-Resolved"
|
| 97 |
).length;
|
|
|
|
|
|
|
| 98 |
const automationRate =
|
| 99 |
totalTickets > 0 ? (autoResolvedTickets / totalTickets) * 100 : 0;
|
| 100 |
|
|
|
|
| 101 |
const categoryData = Object.entries(
|
| 102 |
tickets.reduce((acc, ticket) => {
|
| 103 |
const cat = ticket.category || "Unknown";
|
|
|
|
| 124 |
|
| 125 |
return (
|
| 126 |
<div className="max-w-7xl mx-auto space-y-8 p-4 md:p-6">
|
| 127 |
+
{/* Highlight animation style */}
|
| 128 |
+
<style>{`
|
| 129 |
+
@keyframes flash-highlight {
|
| 130 |
+
0% { background-color: #eef2ff; }
|
| 131 |
+
50% { background-color: #c7d2fe; }
|
| 132 |
+
100% { background-color: transparent; }
|
| 133 |
+
}
|
| 134 |
+
.ticket-highlight {
|
| 135 |
+
animation: flash-highlight 2s ease-out forwards;
|
| 136 |
+
}
|
| 137 |
+
`}</style>
|
| 138 |
+
|
| 139 |
<div className="flex flex-col md:flex-row justify-between items-center gap-4">
|
| 140 |
<div>
|
| 141 |
<h2 className="text-3xl font-black text-slate-900 tracking-tight flex items-center gap-2">
|
| 142 |
<Activity className="text-indigo-600" /> Executive Overview
|
| 143 |
</h2>
|
| 144 |
+
<p className="text-slate-500 font-medium mt-1">
|
| 145 |
+
Global helpdesk status and AI performance across all channels
|
| 146 |
+
</p>
|
| 147 |
</div>
|
| 148 |
<ExpandableTabs tabs={tabs} />
|
| 149 |
</div>
|
| 150 |
|
| 151 |
{tickets.length === 0 ? (
|
| 152 |
<Card className="text-center p-12 border-dashed border-2">
|
| 153 |
+
<p className="text-gray-500 text-lg">
|
| 154 |
+
No ticket data available yet. Submit your first ticket to see analytics.
|
| 155 |
+
</p>
|
| 156 |
</Card>
|
| 157 |
) : (
|
| 158 |
<>
|
| 159 |
+
{/* Summary Cards */}
|
| 160 |
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-6">
|
| 161 |
<Card className="border-none shadow-md shadow-slate-200/50">
|
| 162 |
<CardHeader className="pb-2">
|
| 163 |
<CardTitle className="text-xs font-bold text-slate-400 uppercase tracking-widest flex items-center justify-between">
|
| 164 |
+
Total Tickets <Activity size={16} className="text-indigo-500" />
|
|
|
|
| 165 |
</CardTitle>
|
| 166 |
</CardHeader>
|
| 167 |
<CardContent>
|
|
|
|
| 172 |
<Card className="border-none shadow-md shadow-slate-200/50">
|
| 173 |
<CardHeader className="pb-2">
|
| 174 |
<CardTitle className="text-xs font-bold text-slate-400 uppercase tracking-widest flex items-center justify-between">
|
| 175 |
+
Auto-Resolved <Zap size={16} className="text-emerald-500" />
|
|
|
|
| 176 |
</CardTitle>
|
| 177 |
</CardHeader>
|
| 178 |
<CardContent>
|
|
|
|
| 183 |
<Card className="border-none shadow-md shadow-slate-200/50">
|
| 184 |
<CardHeader className="pb-2">
|
| 185 |
<CardTitle className="text-xs font-bold text-slate-400 uppercase tracking-widest flex items-center justify-between">
|
| 186 |
+
Open Tickets <Users size={16} className="text-orange-500" />
|
|
|
|
| 187 |
</CardTitle>
|
| 188 |
</CardHeader>
|
| 189 |
<CardContent>
|
|
|
|
| 194 |
<Card className="border-none shadow-md shadow-slate-200/50">
|
| 195 |
<CardHeader className="pb-2">
|
| 196 |
<CardTitle className="text-xs font-bold text-slate-400 uppercase tracking-widest flex items-center justify-between">
|
| 197 |
+
Automation Rate <Shield size={16} className="text-purple-500" />
|
|
|
|
| 198 |
</CardTitle>
|
| 199 |
</CardHeader>
|
| 200 |
<CardContent>
|
|
|
|
| 203 |
</Card>
|
| 204 |
</div>
|
| 205 |
|
| 206 |
+
{/* Live Ticket Queue */}
|
| 207 |
+
<Card className="border-none shadow-md shadow-slate-200/50">
|
| 208 |
+
<CardHeader>
|
| 209 |
+
<CardTitle className="text-lg font-bold text-slate-800 flex items-center gap-2">
|
| 210 |
+
<Activity size={18} className="text-indigo-500" />
|
| 211 |
+
Live Ticket Queue
|
| 212 |
+
<span className="ml-2 inline-flex items-center gap-1 text-xs font-semibold text-emerald-600 bg-emerald-50 px-2 py-0.5 rounded-full">
|
| 213 |
+
<span className="w-1.5 h-1.5 bg-emerald-500 rounded-full animate-pulse" />
|
| 214 |
+
LIVE
|
| 215 |
+
</span>
|
| 216 |
+
</CardTitle>
|
| 217 |
+
</CardHeader>
|
| 218 |
+
<CardContent>
|
| 219 |
+
<div className="overflow-x-auto">
|
| 220 |
+
<table className="w-full text-sm">
|
| 221 |
+
<thead>
|
| 222 |
+
<tr className="text-left text-xs font-bold text-slate-400 uppercase tracking-widest border-b border-slate-100">
|
| 223 |
+
<th className="pb-3 pr-4">ID</th>
|
| 224 |
+
<th className="pb-3 pr-4">Category</th>
|
| 225 |
+
<th className="pb-3 pr-4">Status</th>
|
| 226 |
+
<th className="pb-3">Assignee</th>
|
| 227 |
+
</tr>
|
| 228 |
+
</thead>
|
| 229 |
+
<tbody>
|
| 230 |
+
{tickets.slice(0, 10).map((ticket) => (
|
| 231 |
+
<tr
|
| 232 |
+
key={ticket.ticket_id}
|
| 233 |
+
className={`border-b border-slate-50 transition-colors ${
|
| 234 |
+
recentIds.has(ticket.ticket_id) ? "ticket-highlight" : ""
|
| 235 |
+
}`}
|
| 236 |
+
>
|
| 237 |
+
<td className="py-3 pr-4 font-mono text-xs text-slate-500">
|
| 238 |
+
#{String(ticket.ticket_id).slice(0, 8)}
|
| 239 |
+
</td>
|
| 240 |
+
<td className="py-3 pr-4 font-medium text-slate-700">
|
| 241 |
+
{ticket.category || "—"}
|
| 242 |
+
</td>
|
| 243 |
+
<td className="py-3 pr-4">
|
| 244 |
+
<span className={`px-2 py-0.5 rounded-full text-xs font-bold ${
|
| 245 |
+
ticket.status === "Open"
|
| 246 |
+
? "bg-orange-100 text-orange-700"
|
| 247 |
+
: ticket.status === "Resolved"
|
| 248 |
+
? "bg-emerald-100 text-emerald-700"
|
| 249 |
+
: "bg-slate-100 text-slate-600"
|
| 250 |
+
}`}>
|
| 251 |
+
{ticket.status || ticket.Resolution_Status || "—"}
|
| 252 |
+
</span>
|
| 253 |
+
</td>
|
| 254 |
+
<td className="py-3 text-slate-600">
|
| 255 |
+
{ticket.assigned_team || "Unassigned"}
|
| 256 |
+
</td>
|
| 257 |
+
</tr>
|
| 258 |
+
))}
|
| 259 |
+
</tbody>
|
| 260 |
+
</table>
|
| 261 |
+
</div>
|
| 262 |
+
</CardContent>
|
| 263 |
+
</Card>
|
| 264 |
+
|
| 265 |
+
{/* Charts */}
|
| 266 |
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
|
| 267 |
<Card className="border-none shadow-md shadow-slate-200/50">
|
| 268 |
<CardHeader>
|
|
|
|
| 276 |
<BarChart data={categoryData}>
|
| 277 |
<XAxis dataKey="name" fontSize={12} axisLine={false} tickLine={false} />
|
| 278 |
<YAxis fontSize={12} axisLine={false} tickLine={false} />
|
| 279 |
+
<Tooltip contentStyle={{ backgroundColor: '#fff', borderRadius: '8px', border: 'none', boxShadow: '0 4px 6px -1px rgb(0 0 0 / 0.1)' }} />
|
|
|
|
|
|
|
| 280 |
<Bar dataKey="value" fill="#6366f1" radius={[4, 4, 0, 0]} barSize={40} />
|
| 281 |
</BarChart>
|
| 282 |
</ResponsiveContainer>
|
|
|
|
| 294 |
<div className="h-80">
|
| 295 |
<ResponsiveContainer width="100%" height="100%">
|
| 296 |
<PieChart>
|
| 297 |
+
<Pie data={statusData} cx="50%" cy="50%" outerRadius={100} innerRadius={60}
|
| 298 |
+
paddingAngle={5} dataKey="value"
|
| 299 |
+
label={({ name, percent }) => `${name} ${(percent * 100).toFixed(0)}%`}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 300 |
>
|
| 301 |
{statusData.map((entry, index) => (
|
| 302 |
+
<Cell key={`cell-${index}`} fill={COLORS[index % COLORS.length]} />
|
|
|
|
|
|
|
|
|
|
| 303 |
))}
|
| 304 |
</Pie>
|
| 305 |
<Tooltip />
|
|
|
|
| 311 |
</Card>
|
| 312 |
</div>
|
| 313 |
|
| 314 |
+
{/* Efficiency + AI */}
|
| 315 |
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
|
| 316 |
<Card className="border-none shadow-md shadow-slate-200/50">
|
| 317 |
<CardHeader>
|
| 318 |
+
<CardTitle className="text-lg font-bold text-slate-800">Efficiency Highlights</CardTitle>
|
|
|
|
|
|
|
| 319 |
</CardHeader>
|
| 320 |
<CardContent className="space-y-4">
|
| 321 |
<div className="flex items-center justify-between p-4 bg-slate-50 rounded-xl border border-slate-100">
|
|
|
|
| 337 |
|
| 338 |
<Card className="border-none shadow-md shadow-slate-200/50">
|
| 339 |
<CardHeader>
|
| 340 |
+
<CardTitle className="text-lg font-bold text-slate-800">AI Performance</CardTitle>
|
|
|
|
|
|
|
| 341 |
</CardHeader>
|
| 342 |
<CardContent>
|
| 343 |
<div className="grid grid-cols-2 gap-6">
|
| 344 |
<div className="p-6 bg-slate-50 rounded-2xl text-center border border-slate-100">
|
| 345 |
<div className="text-3xl font-black text-indigo-600">
|
| 346 |
+
{tickets.filter((t) => t.confidence > 0.8).length}
|
| 347 |
</div>
|
| 348 |
+
<div className="text-xs font-bold text-slate-400 uppercase tracking-widest mt-2">High Confidence</div>
|
| 349 |
</div>
|
| 350 |
<div className="p-6 bg-slate-50 rounded-2xl text-center border border-slate-100">
|
| 351 |
<div className="text-3xl font-black text-orange-600">
|
| 352 |
+
{tickets.filter((t) => (t.Duplicate_Probability || t.duplicate_probability || 0) > 0.7).length}
|
| 353 |
</div>
|
| 354 |
+
<div className="text-xs font-bold text-slate-400 uppercase tracking-widest mt-2">Potential Dupes</div>
|
| 355 |
</div>
|
| 356 |
</div>
|
| 357 |
</CardContent>
|
|
|
|
| 363 |
);
|
| 364 |
};
|
| 365 |
|
| 366 |
+
export default Dashboard;
|
Frontend/src/store/ticketStore.js
CHANGED
|
@@ -57,11 +57,21 @@ const useTicketStore = create(
|
|
| 57 |
const updatedTickets = state.tickets.map(t => t.ticket_id === ticketId ? { ...t, ...updates } : t);
|
| 58 |
const shouldUpdateActive = state.activeTicket?.ticket_id === ticketId;
|
| 59 |
|
|
|
|
|
|
|
|
|
|
| 60 |
return {
|
| 61 |
tickets: updatedTickets,
|
| 62 |
activeTicket: shouldUpdateActive ? { ...state.activeTicket, ...updates } : state.activeTicket
|
| 63 |
};
|
| 64 |
}),
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 65 |
appendMessage: (ticketId, message) => set((state) => {
|
| 66 |
const updatedTickets = state.tickets.map(t =>
|
| 67 |
t.ticket_id === ticketId
|
|
|
|
| 57 |
const updatedTickets = state.tickets.map(t => t.ticket_id === ticketId ? { ...t, ...updates } : t);
|
| 58 |
const shouldUpdateActive = state.activeTicket?.ticket_id === ticketId;
|
| 59 |
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
|
| 63 |
return {
|
| 64 |
tickets: updatedTickets,
|
| 65 |
activeTicket: shouldUpdateActive ? { ...state.activeTicket, ...updates } : state.activeTicket
|
| 66 |
};
|
| 67 |
}),
|
| 68 |
+
|
| 69 |
+
removeTicket: (ticketId) => set((state) => ({
|
| 70 |
+
tickets: state.tickets.filter(t => t.ticket_id !== ticketId),
|
| 71 |
+
activeTicket: state.activeTicket?.ticket_id === ticketId
|
| 72 |
+
? null
|
| 73 |
+
: state.activeTicket
|
| 74 |
+
})),
|
| 75 |
appendMessage: (ticketId, message) => set((state) => {
|
| 76 |
const updatedTickets = state.tickets.map(t =>
|
| 77 |
t.ticket_id === ticketId
|
MobileApp/App.js
CHANGED
|
@@ -1,5 +1,4 @@
|
|
| 1 |
import 'react-native-gesture-handler';
|
| 2 |
-
import LogRocket from '@logrocket/react-native';
|
| 3 |
import React, { useEffect, useState } from 'react';
|
| 4 |
import { NavigationContainer } from '@react-navigation/native';
|
| 5 |
import { createNativeStackNavigator } from '@react-navigation/native-stack';
|
|
@@ -7,7 +6,7 @@ import { createBottomTabNavigator } from '@react-navigation/bottom-tabs';
|
|
| 7 |
import { StatusBar } from 'expo-status-bar';
|
| 8 |
import { supabase } from './src/lib/supabase';
|
| 9 |
import { COLORS } from './src/styles/theme';
|
| 10 |
-
import { LayoutDashboard, Ticket, User } from 'lucide-react-native';
|
| 11 |
import { View, ActivityIndicator, Linking } from 'react-native';
|
| 12 |
import AsyncStorage from '@react-native-async-storage/async-storage';
|
| 13 |
import { SafeAreaProvider, useSafeAreaInsets } from 'react-native-safe-area-context';
|
|
@@ -34,6 +33,13 @@ import AIProcessingScreen from './src/screens/user/AIProcessingScreen';
|
|
| 34 |
import NotificationsScreen from './src/screens/user/NotificationsScreen';
|
| 35 |
import KnowledgeBaseScreen from './src/screens/user/KnowledgeBaseScreen';
|
| 36 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 37 |
const Stack = createNativeStackNavigator();
|
| 38 |
const Tab = createBottomTabNavigator();
|
| 39 |
|
|
@@ -72,6 +78,45 @@ const TabNavigator = () => {
|
|
| 72 |
);
|
| 73 |
};
|
| 74 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 75 |
// Inner app that has access to SafeAreaProvider context
|
| 76 |
const AppContent = () => {
|
| 77 |
const insets = useSafeAreaInsets();
|
|
@@ -79,36 +124,48 @@ const AppContent = () => {
|
|
| 79 |
const [loading, setLoading] = useState(true);
|
| 80 |
const [showOnboarding, setShowOnboarding] = useState(null);
|
| 81 |
const [userStatus, setUserStatus] = useState(null); // 'active', 'pending_approval', 'rejected'
|
|
|
|
| 82 |
|
| 83 |
useEffect(() => {
|
| 84 |
-
// Initialize LogRocket
|
| 85 |
-
LogRocket.init('ky7sla/helpdeskai');
|
| 86 |
-
|
| 87 |
const initialize = async () => {
|
| 88 |
try {
|
| 89 |
const { data: { session } } = await supabase.auth.getSession();
|
| 90 |
setSession(session);
|
| 91 |
|
| 92 |
if (session?.user) {
|
| 93 |
-
|
| 94 |
-
LogRocket.identify(session.user.id, {
|
| 95 |
-
email: session.user.email,
|
| 96 |
-
name: session.user.user_metadata?.full_name || 'User',
|
| 97 |
-
});
|
| 98 |
-
|
| 99 |
-
const { data } = await supabase
|
| 100 |
.from('profiles')
|
| 101 |
-
.select('status')
|
| 102 |
.eq('id', session.user.id)
|
| 103 |
.single();
|
| 104 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 105 |
}
|
| 106 |
-
|
| 107 |
-
const onboardingDone = await AsyncStorage.getItem('@onboarding_complete');
|
| 108 |
-
setShowOnboarding(onboardingDone === null);
|
| 109 |
} catch (e) {
|
| 110 |
-
console.log('
|
| 111 |
} finally {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 112 |
setLoading(false);
|
| 113 |
}
|
| 114 |
};
|
|
@@ -118,21 +175,30 @@ const AppContent = () => {
|
|
| 118 |
const { data: { subscription } } = supabase.auth.onAuthStateChange(async (_event, session) => {
|
| 119 |
setSession(session);
|
| 120 |
if (session?.user) {
|
| 121 |
-
|
| 122 |
-
|
| 123 |
-
|
| 124 |
-
|
| 125 |
-
|
| 126 |
-
|
| 127 |
-
|
| 128 |
-
|
| 129 |
-
|
| 130 |
-
|
| 131 |
-
|
| 132 |
-
|
| 133 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 134 |
} else {
|
| 135 |
setUserStatus(null);
|
|
|
|
| 136 |
}
|
| 137 |
});
|
| 138 |
|
|
@@ -152,6 +218,7 @@ const AppContent = () => {
|
|
| 152 |
filter: `id=eq.${session.user.id}`,
|
| 153 |
}, (payload) => {
|
| 154 |
setUserStatus(payload.new.status);
|
|
|
|
| 155 |
})
|
| 156 |
.subscribe();
|
| 157 |
|
|
@@ -208,6 +275,7 @@ const AppContent = () => {
|
|
| 208 |
const isActive = userStatus === 'active';
|
| 209 |
const isPending = userStatus === 'pending_approval';
|
| 210 |
const isRejected = userStatus === 'rejected';
|
|
|
|
| 211 |
|
| 212 |
return (
|
| 213 |
<NotificationProvider topInset={insets.top}>
|
|
@@ -229,13 +297,20 @@ const AppContent = () => {
|
|
| 229 |
) : (
|
| 230 |
// ─── Active user ───
|
| 231 |
<>
|
| 232 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 233 |
<Stack.Screen name="CreateTicket" component={CreateTicketScreen} options={{ animation: 'slide_from_bottom' }} />
|
| 234 |
<Stack.Screen name="AIProcessing" component={AIProcessingScreen} options={{ animation: 'slide_from_right' }} />
|
| 235 |
<Stack.Screen name="TicketTracking" component={TicketTrackingScreen} options={{ animation: 'slide_from_right' }} />
|
| 236 |
<Stack.Screen name="TicketDetail" component={TicketDetailScreen} options={{ animation: 'slide_from_right' }} />
|
| 237 |
<Stack.Screen name="Notifications" component={NotificationsScreen} options={{ animation: 'slide_from_right' }} />
|
| 238 |
<Stack.Screen name="KnowledgeBase" component={KnowledgeBaseScreen} options={{ animation: 'slide_from_right' }} />
|
|
|
|
|
|
|
|
|
|
| 239 |
</>
|
| 240 |
)}
|
| 241 |
</Stack.Navigator>
|
|
|
|
| 1 |
import 'react-native-gesture-handler';
|
|
|
|
| 2 |
import React, { useEffect, useState } from 'react';
|
| 3 |
import { NavigationContainer } from '@react-navigation/native';
|
| 4 |
import { createNativeStackNavigator } from '@react-navigation/native-stack';
|
|
|
|
| 6 |
import { StatusBar } from 'expo-status-bar';
|
| 7 |
import { supabase } from './src/lib/supabase';
|
| 8 |
import { COLORS } from './src/styles/theme';
|
| 9 |
+
import { LayoutDashboard, Ticket, User, Settings, ShieldAlert, Users } from 'lucide-react-native';
|
| 10 |
import { View, ActivityIndicator, Linking } from 'react-native';
|
| 11 |
import AsyncStorage from '@react-native-async-storage/async-storage';
|
| 12 |
import { SafeAreaProvider, useSafeAreaInsets } from 'react-native-safe-area-context';
|
|
|
|
| 33 |
import NotificationsScreen from './src/screens/user/NotificationsScreen';
|
| 34 |
import KnowledgeBaseScreen from './src/screens/user/KnowledgeBaseScreen';
|
| 35 |
|
| 36 |
+
// Admin Screens
|
| 37 |
+
import AdminDashboardScreen from './src/screens/admin/AdminDashboardScreen';
|
| 38 |
+
import AdminTicketsScreen from './src/screens/admin/AdminTicketsScreen';
|
| 39 |
+
import AdminTicketDetailScreen from './src/screens/admin/AdminTicketDetailScreen';
|
| 40 |
+
import AdminUsersScreen from './src/screens/admin/AdminUsersScreen';
|
| 41 |
+
import AdminSettingsScreen from './src/screens/admin/AdminSettingsScreen';
|
| 42 |
+
|
| 43 |
const Stack = createNativeStackNavigator();
|
| 44 |
const Tab = createBottomTabNavigator();
|
| 45 |
|
|
|
|
| 78 |
);
|
| 79 |
};
|
| 80 |
|
| 81 |
+
const AdminTabNavigator = () => {
|
| 82 |
+
const insets = useSafeAreaInsets();
|
| 83 |
+
const tabBarHeight = 60 + insets.bottom;
|
| 84 |
+
|
| 85 |
+
return (
|
| 86 |
+
<Tab.Navigator
|
| 87 |
+
screenOptions={({ route }) => ({
|
| 88 |
+
tabBarIcon: ({ color, size }) => {
|
| 89 |
+
if (route.name === 'AdminDashboard') return <LayoutDashboard size={size} color={color} />;
|
| 90 |
+
if (route.name === 'Tickets') return <Ticket size={size} color={color} />;
|
| 91 |
+
if (route.name === 'Users') return <Users size={size} color={color} />;
|
| 92 |
+
if (route.name === 'Settings') return <Settings size={size} color={color} />;
|
| 93 |
+
if (route.name === 'Profile') return <User size={size} color={color} />;
|
| 94 |
+
},
|
| 95 |
+
tabBarActiveTintColor: COLORS.primary,
|
| 96 |
+
tabBarInactiveTintColor: COLORS.textMuted,
|
| 97 |
+
tabBarLabelStyle: { fontSize: 11, fontWeight: '700' },
|
| 98 |
+
tabBarStyle: {
|
| 99 |
+
height: tabBarHeight,
|
| 100 |
+
paddingBottom: insets.bottom + 8,
|
| 101 |
+
paddingTop: 10,
|
| 102 |
+
backgroundColor: '#ffffff',
|
| 103 |
+
borderTopWidth: 1,
|
| 104 |
+
borderTopColor: '#f0f0f0',
|
| 105 |
+
elevation: 0,
|
| 106 |
+
shadowOpacity: 0,
|
| 107 |
+
},
|
| 108 |
+
headerShown: false,
|
| 109 |
+
})}
|
| 110 |
+
>
|
| 111 |
+
<Tab.Screen name="AdminDashboard" component={AdminDashboardScreen} options={{ title: 'Dashboard' }} />
|
| 112 |
+
<Tab.Screen name="Tickets" component={AdminTicketsScreen} />
|
| 113 |
+
<Tab.Screen name="Users" component={AdminUsersScreen} />
|
| 114 |
+
<Tab.Screen name="Settings" component={AdminSettingsScreen} />
|
| 115 |
+
<Tab.Screen name="Profile" component={ProfileScreen} />
|
| 116 |
+
</Tab.Navigator>
|
| 117 |
+
);
|
| 118 |
+
};
|
| 119 |
+
|
| 120 |
// Inner app that has access to SafeAreaProvider context
|
| 121 |
const AppContent = () => {
|
| 122 |
const insets = useSafeAreaInsets();
|
|
|
|
| 124 |
const [loading, setLoading] = useState(true);
|
| 125 |
const [showOnboarding, setShowOnboarding] = useState(null);
|
| 126 |
const [userStatus, setUserStatus] = useState(null); // 'active', 'pending_approval', 'rejected'
|
| 127 |
+
const [userRole, setUserRole] = useState('user'); // 'user', 'admin', 'master_admin'
|
| 128 |
|
| 129 |
useEffect(() => {
|
|
|
|
|
|
|
|
|
|
| 130 |
const initialize = async () => {
|
| 131 |
try {
|
| 132 |
const { data: { session } } = await supabase.auth.getSession();
|
| 133 |
setSession(session);
|
| 134 |
|
| 135 |
if (session?.user) {
|
| 136 |
+
const { data, error } = await supabase
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 137 |
.from('profiles')
|
| 138 |
+
.select('status, role')
|
| 139 |
.eq('id', session.user.id)
|
| 140 |
.single();
|
| 141 |
+
|
| 142 |
+
if (error) {
|
| 143 |
+
console.log('[AuthInit] Profile fetch error, validating session:', error.message);
|
| 144 |
+
// Verify if session is still validly active (GetUser forces refresh if expired)
|
| 145 |
+
const { data: userData, error: userError } = await supabase.auth.getUser();
|
| 146 |
+
if (userError) {
|
| 147 |
+
console.log('[AuthInit] Token validation failed. Clearing session.');
|
| 148 |
+
setSession(null);
|
| 149 |
+
} else {
|
| 150 |
+
// Valid token but profiles table is temporarily offline; default to user
|
| 151 |
+
setUserStatus('active');
|
| 152 |
+
setUserRole('user');
|
| 153 |
+
}
|
| 154 |
+
} else {
|
| 155 |
+
setUserStatus(data?.status || 'active');
|
| 156 |
+
setUserRole(data?.role || 'user');
|
| 157 |
+
}
|
| 158 |
}
|
|
|
|
|
|
|
|
|
|
| 159 |
} catch (e) {
|
| 160 |
+
console.log('[AuthInit] Crash caught during initialization:', e);
|
| 161 |
} finally {
|
| 162 |
+
// Guarantee showOnboarding is resolved to a boolean to prevent React Navigation stack layout mismatch
|
| 163 |
+
try {
|
| 164 |
+
const onboardingDone = await AsyncStorage.getItem('@onboarding_complete');
|
| 165 |
+
setShowOnboarding(onboardingDone === null);
|
| 166 |
+
} catch (err) {
|
| 167 |
+
setShowOnboarding(false);
|
| 168 |
+
}
|
| 169 |
setLoading(false);
|
| 170 |
}
|
| 171 |
};
|
|
|
|
| 175 |
const { data: { subscription } } = supabase.auth.onAuthStateChange(async (_event, session) => {
|
| 176 |
setSession(session);
|
| 177 |
if (session?.user) {
|
| 178 |
+
try {
|
| 179 |
+
const { data, error } = await supabase
|
| 180 |
+
.from('profiles')
|
| 181 |
+
.select('status, role')
|
| 182 |
+
.eq('id', session.user.id)
|
| 183 |
+
.single();
|
| 184 |
+
|
| 185 |
+
if (error) {
|
| 186 |
+
console.log('[AuthChange] Profile query failed:', error.message);
|
| 187 |
+
// Default to safe values to avoid blank screens
|
| 188 |
+
setUserStatus('active');
|
| 189 |
+
setUserRole('user');
|
| 190 |
+
} else {
|
| 191 |
+
setUserStatus(data?.status || 'active');
|
| 192 |
+
setUserRole(data?.role || 'user');
|
| 193 |
+
}
|
| 194 |
+
} catch (err) {
|
| 195 |
+
console.warn('[AuthChange] Uncaught exception inside handler:', err);
|
| 196 |
+
setUserStatus('active');
|
| 197 |
+
setUserRole('user');
|
| 198 |
+
}
|
| 199 |
} else {
|
| 200 |
setUserStatus(null);
|
| 201 |
+
setUserRole('user');
|
| 202 |
}
|
| 203 |
});
|
| 204 |
|
|
|
|
| 218 |
filter: `id=eq.${session.user.id}`,
|
| 219 |
}, (payload) => {
|
| 220 |
setUserStatus(payload.new.status);
|
| 221 |
+
setUserRole(payload.new.role || 'user');
|
| 222 |
})
|
| 223 |
.subscribe();
|
| 224 |
|
|
|
|
| 275 |
const isActive = userStatus === 'active';
|
| 276 |
const isPending = userStatus === 'pending_approval';
|
| 277 |
const isRejected = userStatus === 'rejected';
|
| 278 |
+
const isAdmin = userRole === 'admin' || userRole === 'master_admin';
|
| 279 |
|
| 280 |
return (
|
| 281 |
<NotificationProvider topInset={insets.top}>
|
|
|
|
| 297 |
) : (
|
| 298 |
// ─── Active user ───
|
| 299 |
<>
|
| 300 |
+
{isAdmin ? (
|
| 301 |
+
<Stack.Screen name="MainTabs" component={AdminTabNavigator} />
|
| 302 |
+
) : (
|
| 303 |
+
<Stack.Screen name="MainTabs" component={TabNavigator} />
|
| 304 |
+
)}
|
| 305 |
<Stack.Screen name="CreateTicket" component={CreateTicketScreen} options={{ animation: 'slide_from_bottom' }} />
|
| 306 |
<Stack.Screen name="AIProcessing" component={AIProcessingScreen} options={{ animation: 'slide_from_right' }} />
|
| 307 |
<Stack.Screen name="TicketTracking" component={TicketTrackingScreen} options={{ animation: 'slide_from_right' }} />
|
| 308 |
<Stack.Screen name="TicketDetail" component={TicketDetailScreen} options={{ animation: 'slide_from_right' }} />
|
| 309 |
<Stack.Screen name="Notifications" component={NotificationsScreen} options={{ animation: 'slide_from_right' }} />
|
| 310 |
<Stack.Screen name="KnowledgeBase" component={KnowledgeBaseScreen} options={{ animation: 'slide_from_right' }} />
|
| 311 |
+
|
| 312 |
+
{/* Admin specific screens */}
|
| 313 |
+
<Stack.Screen name="AdminTicketDetail" component={AdminTicketDetailScreen} options={{ animation: 'slide_from_right' }} />
|
| 314 |
</>
|
| 315 |
)}
|
| 316 |
</Stack.Navigator>
|
MobileApp/app.json
CHANGED
|
@@ -28,7 +28,6 @@
|
|
| 28 |
"favicon": "./assets/favicon.png"
|
| 29 |
},
|
| 30 |
"plugins": [
|
| 31 |
-
"@logrocket/react-native",
|
| 32 |
[
|
| 33 |
"expo-build-properties",
|
| 34 |
{
|
|
|
|
| 28 |
"favicon": "./assets/favicon.png"
|
| 29 |
},
|
| 30 |
"plugins": [
|
|
|
|
| 31 |
[
|
| 32 |
"expo-build-properties",
|
| 33 |
{
|
MobileApp/package-lock.json
CHANGED
|
@@ -8,7 +8,6 @@
|
|
| 8 |
"name": "mobileapp_new",
|
| 9 |
"version": "1.0.0",
|
| 10 |
"dependencies": {
|
| 11 |
-
"@logrocket/react-native": "^2.3.2",
|
| 12 |
"@react-native-async-storage/async-storage": "2.2.0",
|
| 13 |
"@react-navigation/bottom-tabs": "^7.15.10",
|
| 14 |
"@react-navigation/native": "^7.2.2",
|
|
@@ -32,6 +31,7 @@
|
|
| 32 |
"react-native-screens": "~4.16.0",
|
| 33 |
"react-native-svg": "15.12.1",
|
| 34 |
"react-native-url-polyfill": "^3.0.0",
|
|
|
|
| 35 |
"zustand": "^5.0.12"
|
| 36 |
},
|
| 37 |
"devDependencies": {
|
|
@@ -2736,23 +2736,6 @@
|
|
| 2736 |
"@jridgewell/sourcemap-codec": "^1.4.14"
|
| 2737 |
}
|
| 2738 |
},
|
| 2739 |
-
"node_modules/@logrocket/react-native": {
|
| 2740 |
-
"version": "2.3.2",
|
| 2741 |
-
"resolved": "https://registry.npmjs.org/@logrocket/react-native/-/react-native-2.3.2.tgz",
|
| 2742 |
-
"integrity": "sha512-Fi8H3HCTHj6NFcDGurxz/bXZ2A1fTCj6WVocRNmaqDSSIHL63xP01PyXqLT6xdTakO65Mlw7i37WxojLYuzgmQ==",
|
| 2743 |
-
"license": "MIT",
|
| 2744 |
-
"peerDependencies": {
|
| 2745 |
-
"expo": ">=45.0.0",
|
| 2746 |
-
"expo-build-properties": "*",
|
| 2747 |
-
"react": "*",
|
| 2748 |
-
"react-native": ">=0.60.0-rc.0 <1.0.x"
|
| 2749 |
-
},
|
| 2750 |
-
"peerDependenciesMeta": {
|
| 2751 |
-
"expo": {
|
| 2752 |
-
"optional": true
|
| 2753 |
-
}
|
| 2754 |
-
}
|
| 2755 |
-
},
|
| 2756 |
"node_modules/@react-native-async-storage/async-storage": {
|
| 2757 |
"version": "2.2.0",
|
| 2758 |
"resolved": "https://registry.npmjs.org/@react-native-async-storage/async-storage/-/async-storage-2.2.0.tgz",
|
|
@@ -8495,6 +8478,32 @@
|
|
| 8495 |
"react-native": "*"
|
| 8496 |
}
|
| 8497 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 8498 |
"node_modules/react-native/node_modules/@react-native/virtualized-lists": {
|
| 8499 |
"version": "0.81.5",
|
| 8500 |
"resolved": "https://registry.npmjs.org/@react-native/virtualized-lists/-/virtualized-lists-0.81.5.tgz",
|
|
|
|
| 8 |
"name": "mobileapp_new",
|
| 9 |
"version": "1.0.0",
|
| 10 |
"dependencies": {
|
|
|
|
| 11 |
"@react-native-async-storage/async-storage": "2.2.0",
|
| 12 |
"@react-navigation/bottom-tabs": "^7.15.10",
|
| 13 |
"@react-navigation/native": "^7.2.2",
|
|
|
|
| 31 |
"react-native-screens": "~4.16.0",
|
| 32 |
"react-native-svg": "15.12.1",
|
| 33 |
"react-native-url-polyfill": "^3.0.0",
|
| 34 |
+
"react-native-webview": "^13.16.1",
|
| 35 |
"zustand": "^5.0.12"
|
| 36 |
},
|
| 37 |
"devDependencies": {
|
|
|
|
| 2736 |
"@jridgewell/sourcemap-codec": "^1.4.14"
|
| 2737 |
}
|
| 2738 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2739 |
"node_modules/@react-native-async-storage/async-storage": {
|
| 2740 |
"version": "2.2.0",
|
| 2741 |
"resolved": "https://registry.npmjs.org/@react-native-async-storage/async-storage/-/async-storage-2.2.0.tgz",
|
|
|
|
| 8478 |
"react-native": "*"
|
| 8479 |
}
|
| 8480 |
},
|
| 8481 |
+
"node_modules/react-native-webview": {
|
| 8482 |
+
"version": "13.16.1",
|
| 8483 |
+
"resolved": "https://registry.npmjs.org/react-native-webview/-/react-native-webview-13.16.1.tgz",
|
| 8484 |
+
"integrity": "sha512-If0eHhoEdOYDcHsX+xBFwHMbWBGK1BvGDQDQdVkwtSIXiq1uiqjkpWVP2uQ1as94J0CzvFE9PUNDuhiX0Z6ubw==",
|
| 8485 |
+
"license": "MIT",
|
| 8486 |
+
"dependencies": {
|
| 8487 |
+
"escape-string-regexp": "^4.0.0",
|
| 8488 |
+
"invariant": "2.2.4"
|
| 8489 |
+
},
|
| 8490 |
+
"peerDependencies": {
|
| 8491 |
+
"react": "*",
|
| 8492 |
+
"react-native": "*"
|
| 8493 |
+
}
|
| 8494 |
+
},
|
| 8495 |
+
"node_modules/react-native-webview/node_modules/escape-string-regexp": {
|
| 8496 |
+
"version": "4.0.0",
|
| 8497 |
+
"resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz",
|
| 8498 |
+
"integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==",
|
| 8499 |
+
"license": "MIT",
|
| 8500 |
+
"engines": {
|
| 8501 |
+
"node": ">=10"
|
| 8502 |
+
},
|
| 8503 |
+
"funding": {
|
| 8504 |
+
"url": "https://github.com/sponsors/sindresorhus"
|
| 8505 |
+
}
|
| 8506 |
+
},
|
| 8507 |
"node_modules/react-native/node_modules/@react-native/virtualized-lists": {
|
| 8508 |
"version": "0.81.5",
|
| 8509 |
"resolved": "https://registry.npmjs.org/@react-native/virtualized-lists/-/virtualized-lists-0.81.5.tgz",
|
MobileApp/package.json
CHANGED
|
@@ -9,7 +9,6 @@
|
|
| 9 |
"web": "expo start --web"
|
| 10 |
},
|
| 11 |
"dependencies": {
|
| 12 |
-
"@logrocket/react-native": "^2.3.2",
|
| 13 |
"@react-native-async-storage/async-storage": "2.2.0",
|
| 14 |
"@react-navigation/bottom-tabs": "^7.15.10",
|
| 15 |
"@react-navigation/native": "^7.2.2",
|
|
@@ -33,6 +32,7 @@
|
|
| 33 |
"react-native-screens": "~4.16.0",
|
| 34 |
"react-native-svg": "15.12.1",
|
| 35 |
"react-native-url-polyfill": "^3.0.0",
|
|
|
|
| 36 |
"zustand": "^5.0.12"
|
| 37 |
},
|
| 38 |
"private": true,
|
|
|
|
| 9 |
"web": "expo start --web"
|
| 10 |
},
|
| 11 |
"dependencies": {
|
|
|
|
| 12 |
"@react-native-async-storage/async-storage": "2.2.0",
|
| 13 |
"@react-navigation/bottom-tabs": "^7.15.10",
|
| 14 |
"@react-navigation/native": "^7.2.2",
|
|
|
|
| 32 |
"react-native-screens": "~4.16.0",
|
| 33 |
"react-native-svg": "15.12.1",
|
| 34 |
"react-native-url-polyfill": "^3.0.0",
|
| 35 |
+
"react-native-webview": "^13.16.1",
|
| 36 |
"zustand": "^5.0.12"
|
| 37 |
},
|
| 38 |
"private": true,
|
MobileApp/src/screens/auth/LoginScreen.js
CHANGED
|
@@ -233,10 +233,15 @@ const LoginScreen = () => {
|
|
| 233 |
</Animated.View>
|
| 234 |
|
| 235 |
{/* Footer */}
|
| 236 |
-
<Animated.View style={[styles.footer, { opacity: fadeAnim, paddingBottom: insets.bottom + 40 }]}>
|
| 237 |
-
<
|
| 238 |
-
|
| 239 |
-
<
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 240 |
</TouchableOpacity>
|
| 241 |
</Animated.View>
|
| 242 |
</ScrollView>
|
|
|
|
| 233 |
</Animated.View>
|
| 234 |
|
| 235 |
{/* Footer */}
|
| 236 |
+
<Animated.View style={[styles.footer, { opacity: fadeAnim, paddingBottom: insets.bottom + 40, gap: 12, flexDirection: 'column', alignItems: 'center' }]}>
|
| 237 |
+
<View style={{ flexDirection: 'row', alignItems: 'center', justifyContent: 'center' }}>
|
| 238 |
+
<Text style={styles.footerText}>Don't have an account? </Text>
|
| 239 |
+
<TouchableOpacity onPress={() => navigation.navigate('Signup')}>
|
| 240 |
+
<Text style={styles.footerLink}>Create Account</Text>
|
| 241 |
+
</TouchableOpacity>
|
| 242 |
+
</View>
|
| 243 |
+
<TouchableOpacity onPress={() => navigation.navigate('AdminSignup')}>
|
| 244 |
+
<Text style={styles.adminLink}>Register as Admin Agent / Company</Text>
|
| 245 |
</TouchableOpacity>
|
| 246 |
</Animated.View>
|
| 247 |
</ScrollView>
|
MobileApp/src/screens/user/AIProcessingScreen.js
CHANGED
|
@@ -138,7 +138,7 @@ const AIProcessingScreen = () => {
|
|
| 138 |
const steps = [
|
| 139 |
{ label: "Initializing AI Core", icon: "🧠" },
|
| 140 |
{ label: "Scanning for OCR Data", icon: "🔍" },
|
| 141 |
-
{ label: "
|
| 142 |
{ label: "Searching Knowledge Base", icon: "📚" },
|
| 143 |
{ label: "Extracting Technical Entities", icon: "🔗" },
|
| 144 |
{ label: "Checking for Duplicates", icon: "🛡️" },
|
|
@@ -201,9 +201,22 @@ const AIProcessingScreen = () => {
|
|
| 201 |
return;
|
| 202 |
}
|
| 203 |
console.error('AI Analysis Error:', err);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 204 |
setError(err.response?.status === 503
|
| 205 |
? 'The AI engine is waking up. Please wait a moment and try again.'
|
| 206 |
-
: (
|
| 207 |
setLoading(false);
|
| 208 |
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error);
|
| 209 |
}
|
|
@@ -362,7 +375,7 @@ const AIProcessingScreen = () => {
|
|
| 362 |
|
| 363 |
{/* Title */}
|
| 364 |
<View style={styles.loadingHeader}>
|
| 365 |
-
<Text style={styles.loadingTitle}>
|
| 366 |
<Text style={styles.loadingSubtitle}>HelpDesk.ai is orchestrating your request</Text>
|
| 367 |
</View>
|
| 368 |
|
|
|
|
| 138 |
const steps = [
|
| 139 |
{ label: "Initializing AI Core", icon: "🧠" },
|
| 140 |
{ label: "Scanning for OCR Data", icon: "🔍" },
|
| 141 |
+
{ label: "AI Classification", icon: "⚡" },
|
| 142 |
{ label: "Searching Knowledge Base", icon: "📚" },
|
| 143 |
{ label: "Extracting Technical Entities", icon: "🔗" },
|
| 144 |
{ label: "Checking for Duplicates", icon: "🛡️" },
|
|
|
|
| 201 |
return;
|
| 202 |
}
|
| 203 |
console.error('AI Analysis Error:', err);
|
| 204 |
+
let errorMsg = '';
|
| 205 |
+
if (err.response?.data?.detail) {
|
| 206 |
+
if (typeof err.response.data.detail === 'string') {
|
| 207 |
+
errorMsg = err.response.data.detail;
|
| 208 |
+
} else if (Array.isArray(err.response.data.detail)) {
|
| 209 |
+
errorMsg = err.response.data.detail.map(d => `${d.loc?.join('.') || 'field'}: ${d.msg}`).join(', ');
|
| 210 |
+
} else {
|
| 211 |
+
errorMsg = JSON.stringify(err.response.data.detail);
|
| 212 |
+
}
|
| 213 |
+
} else if (err.message) {
|
| 214 |
+
errorMsg = err.message;
|
| 215 |
+
}
|
| 216 |
+
|
| 217 |
setError(err.response?.status === 503
|
| 218 |
? 'The AI engine is waking up. Please wait a moment and try again.'
|
| 219 |
+
: (errorMsg || 'AI engine is currently busy. Please try again.'));
|
| 220 |
setLoading(false);
|
| 221 |
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error);
|
| 222 |
}
|
|
|
|
| 375 |
|
| 376 |
{/* Title */}
|
| 377 |
<View style={styles.loadingHeader}>
|
| 378 |
+
<Text style={styles.loadingTitle}>AI Triage & Analysis</Text>
|
| 379 |
<Text style={styles.loadingSubtitle}>HelpDesk.ai is orchestrating your request</Text>
|
| 380 |
</View>
|
| 381 |
|
MobileApp/src/screens/user/DashboardScreen.js
CHANGED
|
@@ -463,36 +463,36 @@ const styles = StyleSheet.create({
|
|
| 463 |
emptyState: { alignItems: 'center', paddingTop: 40, paddingBottom: 20, gap: 10, marginHorizontal: 20 },
|
| 464 |
emptyTitle: { fontSize: 17, fontWeight: '800', color: COLORS.text },
|
| 465 |
emptyMsg: { fontSize: 13, color: COLORS.textLight, textAlign: 'center' },
|
| 466 |
-
// OTA Update Banner
|
| 467 |
updateBanner: {
|
| 468 |
flexDirection: 'row',
|
| 469 |
alignItems: 'center',
|
| 470 |
justifyContent: 'space-between',
|
| 471 |
-
backgroundColor:
|
| 472 |
borderWidth: 1,
|
| 473 |
-
borderColor: 'rgba(
|
| 474 |
borderRadius: 18,
|
| 475 |
paddingVertical: 12,
|
| 476 |
paddingHorizontal: 16,
|
| 477 |
marginHorizontal: 20,
|
| 478 |
marginBottom: 16,
|
| 479 |
-
shadowColor:
|
| 480 |
-
shadowOpacity: 0.
|
| 481 |
-
shadowRadius:
|
| 482 |
-
elevation:
|
| 483 |
gap: 10,
|
| 484 |
},
|
| 485 |
updateBannerLeft: { flexDirection: 'row', alignItems: 'center', gap: 12, flex: 1 },
|
| 486 |
updateIconWrap: {
|
| 487 |
width: 36, height: 36, borderRadius: 10,
|
| 488 |
-
backgroundColor:
|
| 489 |
justifyContent: 'center', alignItems: 'center',
|
| 490 |
},
|
| 491 |
-
updateTitle: { fontSize: 13, fontWeight: '800', color:
|
| 492 |
-
updateSubtitle: { fontSize: 11, color: 'rgba(
|
| 493 |
updateActions: { flexDirection: 'row', alignItems: 'center', gap: 10, flexShrink: 0 },
|
| 494 |
updateBtn: {
|
| 495 |
-
backgroundColor:
|
| 496 |
paddingHorizontal: 14,
|
| 497 |
paddingVertical: 7,
|
| 498 |
borderRadius: 10,
|
|
|
|
| 463 |
emptyState: { alignItems: 'center', paddingTop: 40, paddingBottom: 20, gap: 10, marginHorizontal: 20 },
|
| 464 |
emptyTitle: { fontSize: 17, fontWeight: '800', color: COLORS.text },
|
| 465 |
emptyMsg: { fontSize: 13, color: COLORS.textLight, textAlign: 'center' },
|
| 466 |
+
// OTA Update Banner (Green Theme Harmony)
|
| 467 |
updateBanner: {
|
| 468 |
flexDirection: 'row',
|
| 469 |
alignItems: 'center',
|
| 470 |
justifyContent: 'space-between',
|
| 471 |
+
backgroundColor: COLORS.secondary, // Premium Dark Green-Black (#0f1f12)
|
| 472 |
borderWidth: 1,
|
| 473 |
+
borderColor: 'rgba(22, 163, 74, 0.35)', // Translucent Green
|
| 474 |
borderRadius: 18,
|
| 475 |
paddingVertical: 12,
|
| 476 |
paddingHorizontal: 16,
|
| 477 |
marginHorizontal: 20,
|
| 478 |
marginBottom: 16,
|
| 479 |
+
shadowColor: COLORS.primary, // Green Glow
|
| 480 |
+
shadowOpacity: 0.25,
|
| 481 |
+
shadowRadius: 12,
|
| 482 |
+
elevation: 8,
|
| 483 |
gap: 10,
|
| 484 |
},
|
| 485 |
updateBannerLeft: { flexDirection: 'row', alignItems: 'center', gap: 12, flex: 1 },
|
| 486 |
updateIconWrap: {
|
| 487 |
width: 36, height: 36, borderRadius: 10,
|
| 488 |
+
backgroundColor: COLORS.primary, // Green Accent
|
| 489 |
justifyContent: 'center', alignItems: 'center',
|
| 490 |
},
|
| 491 |
+
updateTitle: { fontSize: 13, fontWeight: '800', color: COLORS.primaryLight }, // Light Green (#dcfce7)
|
| 492 |
+
updateSubtitle: { fontSize: 11, color: 'rgba(220, 252, 231, 0.65)', fontWeight: '500' },
|
| 493 |
updateActions: { flexDirection: 'row', alignItems: 'center', gap: 10, flexShrink: 0 },
|
| 494 |
updateBtn: {
|
| 495 |
+
backgroundColor: COLORS.primary, // Green Button
|
| 496 |
paddingHorizontal: 14,
|
| 497 |
paddingVertical: 7,
|
| 498 |
borderRadius: 10,
|
MobileApp/src/screens/user/KnowledgeBaseScreen.js
CHANGED
|
@@ -12,6 +12,7 @@ import {
|
|
| 12 |
HelpCircle, Video, Play, X, ExternalLink
|
| 13 |
} from 'lucide-react-native';
|
| 14 |
import { YOUTUBE_RESOURCES, VIDEO_CATEGORIES } from '../../data/youtubeResources';
|
|
|
|
| 15 |
|
| 16 |
const DEFAULT_ARTICLES = [
|
| 17 |
{
|
|
@@ -47,6 +48,13 @@ const KnowledgeBaseScreen = ({ navigation }) => {
|
|
| 47 |
const [selectedArticle, setSelectedArticle] = useState(null);
|
| 48 |
const [modalVisible, setModalVisible] = useState(false);
|
| 49 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 50 |
// Debounced real-time articles search
|
| 51 |
useEffect(() => {
|
| 52 |
if (activeTab === 'articles') {
|
|
@@ -120,29 +128,62 @@ const KnowledgeBaseScreen = ({ navigation }) => {
|
|
| 120 |
};
|
| 121 |
|
| 122 |
const handleVideoPress = (video) => {
|
| 123 |
-
|
| 124 |
-
|
| 125 |
-
});
|
| 126 |
};
|
| 127 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 128 |
// Filter video guides locally in real-time
|
| 129 |
const getFilteredVideos = () => {
|
| 130 |
-
|
| 131 |
-
|
| 132 |
-
if (activeVideoCategory !== 'All') {
|
| 133 |
-
filtered = filtered.filter(v => v.category === activeVideoCategory);
|
| 134 |
-
}
|
| 135 |
-
|
| 136 |
-
if (searchQuery.trim()) {
|
| 137 |
-
const q = searchQuery.toLowerCase().trim();
|
| 138 |
-
filtered = filtered.filter(v =>
|
| 139 |
-
v.title.toLowerCase().includes(q) ||
|
| 140 |
-
v.description.toLowerCase().includes(q) ||
|
| 141 |
-
v.category.toLowerCase().includes(q)
|
| 142 |
-
);
|
| 143 |
-
}
|
| 144 |
-
|
| 145 |
-
return filtered;
|
| 146 |
};
|
| 147 |
|
| 148 |
const renderArticle = ({ item }) => (
|
|
@@ -311,6 +352,7 @@ const KnowledgeBaseScreen = ({ navigation }) => {
|
|
| 311 |
transparent={true}
|
| 312 |
visible={modalVisible}
|
| 313 |
onRequestClose={() => setModalVisible(false)}
|
|
|
|
| 314 |
>
|
| 315 |
<View style={styles.modalOverlay}>
|
| 316 |
<TouchableOpacity
|
|
@@ -345,6 +387,50 @@ const KnowledgeBaseScreen = ({ navigation }) => {
|
|
| 345 |
</View>
|
| 346 |
</View>
|
| 347 |
</Modal>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 348 |
</SafeAreaView>
|
| 349 |
);
|
| 350 |
};
|
|
@@ -612,6 +698,24 @@ const styles = StyleSheet.create({
|
|
| 612 |
fontSize: 15,
|
| 613 |
fontWeight: '800',
|
| 614 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 615 |
});
|
| 616 |
|
| 617 |
export default KnowledgeBaseScreen;
|
|
|
|
| 12 |
HelpCircle, Video, Play, X, ExternalLink
|
| 13 |
} from 'lucide-react-native';
|
| 14 |
import { YOUTUBE_RESOURCES, VIDEO_CATEGORIES } from '../../data/youtubeResources';
|
| 15 |
+
import { WebView } from 'react-native-webview';
|
| 16 |
|
| 17 |
const DEFAULT_ARTICLES = [
|
| 18 |
{
|
|
|
|
| 48 |
const [selectedArticle, setSelectedArticle] = useState(null);
|
| 49 |
const [modalVisible, setModalVisible] = useState(false);
|
| 50 |
|
| 51 |
+
// New Premium Playback & YouTube States
|
| 52 |
+
const [selectedVideo, setSelectedVideo] = useState(null);
|
| 53 |
+
const [videoModalVisible, setVideoModalVisible] = useState(false);
|
| 54 |
+
const [videos, setVideos] = useState([]);
|
| 55 |
+
const [loadingVideos, setLoadingVideos] = useState(false);
|
| 56 |
+
const [debouncedSearch, setDebouncedSearch] = useState('');
|
| 57 |
+
|
| 58 |
// Debounced real-time articles search
|
| 59 |
useEffect(() => {
|
| 60 |
if (activeTab === 'articles') {
|
|
|
|
| 128 |
};
|
| 129 |
|
| 130 |
const handleVideoPress = (video) => {
|
| 131 |
+
setSelectedVideo(video);
|
| 132 |
+
setVideoModalVisible(true);
|
|
|
|
| 133 |
};
|
| 134 |
|
| 135 |
+
// Debounce the search query to keep typing fluid
|
| 136 |
+
useEffect(() => {
|
| 137 |
+
const handler = setTimeout(() => {
|
| 138 |
+
setDebouncedSearch(searchQuery);
|
| 139 |
+
}, 600);
|
| 140 |
+
return () => clearTimeout(handler);
|
| 141 |
+
}, [searchQuery]);
|
| 142 |
+
|
| 143 |
+
// Sync and fetch videos dynamically matching category and search filter
|
| 144 |
+
useEffect(() => {
|
| 145 |
+
const fetchVideos = async () => {
|
| 146 |
+
setLoadingVideos(true);
|
| 147 |
+
try {
|
| 148 |
+
const fallbackList = activeVideoCategory === 'All'
|
| 149 |
+
? YOUTUBE_RESOURCES
|
| 150 |
+
: YOUTUBE_RESOURCES.filter(v => v.category === activeVideoCategory);
|
| 151 |
+
|
| 152 |
+
let formatted = fallbackList.map(item => {
|
| 153 |
+
const videoId = item.url.split('v=')[1] || item.id;
|
| 154 |
+
return {
|
| 155 |
+
id: videoId,
|
| 156 |
+
title: item.title,
|
| 157 |
+
description: item.description,
|
| 158 |
+
category: item.category,
|
| 159 |
+
url: item.url,
|
| 160 |
+
thumbnail_url: item.thumbnail_url || `https://img.youtube.com/vi/${videoId}/hqdefault.jpg`
|
| 161 |
+
};
|
| 162 |
+
});
|
| 163 |
+
|
| 164 |
+
if (debouncedSearch.trim()) {
|
| 165 |
+
const q = debouncedSearch.toLowerCase().trim();
|
| 166 |
+
formatted = formatted.filter(v =>
|
| 167 |
+
v.title.toLowerCase().includes(q) ||
|
| 168 |
+
v.description.toLowerCase().includes(q) ||
|
| 169 |
+
v.category.toLowerCase().includes(q)
|
| 170 |
+
);
|
| 171 |
+
}
|
| 172 |
+
|
| 173 |
+
setVideos(formatted);
|
| 174 |
+
} catch (err) {
|
| 175 |
+
console.warn("YouTube videos fetch error:", err);
|
| 176 |
+
} finally {
|
| 177 |
+
setLoadingVideos(false);
|
| 178 |
+
}
|
| 179 |
+
};
|
| 180 |
+
|
| 181 |
+
fetchVideos();
|
| 182 |
+
}, [activeVideoCategory, debouncedSearch]);
|
| 183 |
+
|
| 184 |
// Filter video guides locally in real-time
|
| 185 |
const getFilteredVideos = () => {
|
| 186 |
+
return videos;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 187 |
};
|
| 188 |
|
| 189 |
const renderArticle = ({ item }) => (
|
|
|
|
| 352 |
transparent={true}
|
| 353 |
visible={modalVisible}
|
| 354 |
onRequestClose={() => setModalVisible(false)}
|
| 355 |
+
statusBarTranslucent
|
| 356 |
>
|
| 357 |
<View style={styles.modalOverlay}>
|
| 358 |
<TouchableOpacity
|
|
|
|
| 387 |
</View>
|
| 388 |
</View>
|
| 389 |
</Modal>
|
| 390 |
+
|
| 391 |
+
{/* Premium Video Guides Inline WebView Player Modal */}
|
| 392 |
+
<Modal
|
| 393 |
+
animationType="slide"
|
| 394 |
+
transparent={false}
|
| 395 |
+
visible={videoModalVisible}
|
| 396 |
+
onRequestClose={() => setVideoModalVisible(false)}
|
| 397 |
+
>
|
| 398 |
+
<SafeAreaView style={styles.videoPlayerContainer} edges={['top', 'bottom']}>
|
| 399 |
+
<StatusBar barStyle="light-content" />
|
| 400 |
+
<View style={styles.videoPlayerHeader}>
|
| 401 |
+
<Text style={styles.videoPlayerTitle} numberOfLines={1}>
|
| 402 |
+
{selectedVideo?.title || 'Video Tutorial'}
|
| 403 |
+
</Text>
|
| 404 |
+
<TouchableOpacity
|
| 405 |
+
onPress={() => setVideoModalVisible(false)}
|
| 406 |
+
style={styles.videoPlayerCloseBtn}
|
| 407 |
+
>
|
| 408 |
+
<X size={20} color="#ffffff" strokeWidth={2.5} />
|
| 409 |
+
</TouchableOpacity>
|
| 410 |
+
</View>
|
| 411 |
+
|
| 412 |
+
{selectedVideo ? (
|
| 413 |
+
<WebView
|
| 414 |
+
style={styles.webView}
|
| 415 |
+
javaScriptEnabled={true}
|
| 416 |
+
domStorageEnabled={true}
|
| 417 |
+
allowsFullscreenVideo={true}
|
| 418 |
+
mediaPlaybackRequiresUserAction={false}
|
| 419 |
+
source={{ uri: `https://www.youtube.com/embed/${selectedVideo.id}?autoplay=1&modestbranding=1&rel=0` }}
|
| 420 |
+
/>
|
| 421 |
+
) : (
|
| 422 |
+
<View style={styles.videoPlayerLoading}>
|
| 423 |
+
<ActivityIndicator size="large" color="#ffffff" />
|
| 424 |
+
</View>
|
| 425 |
+
)}
|
| 426 |
+
|
| 427 |
+
<View style={styles.videoPlayerFooter}>
|
| 428 |
+
<Text style={styles.videoPlayerDesc}>
|
| 429 |
+
{selectedVideo?.description}
|
| 430 |
+
</Text>
|
| 431 |
+
</View>
|
| 432 |
+
</SafeAreaView>
|
| 433 |
+
</Modal>
|
| 434 |
</SafeAreaView>
|
| 435 |
);
|
| 436 |
};
|
|
|
|
| 698 |
fontSize: 15,
|
| 699 |
fontWeight: '800',
|
| 700 |
},
|
| 701 |
+
|
| 702 |
+
// Premium video player styling definitions
|
| 703 |
+
videoPlayerContainer: { flex: 1, backgroundColor: '#090d16' },
|
| 704 |
+
videoPlayerHeader: {
|
| 705 |
+
flexDirection: 'row',
|
| 706 |
+
justifyContent: 'space-between',
|
| 707 |
+
alignItems: 'center',
|
| 708 |
+
paddingHorizontal: 20,
|
| 709 |
+
paddingVertical: 14,
|
| 710 |
+
borderBottomWidth: 1,
|
| 711 |
+
borderBottomColor: 'rgba(255,255,255,0.08)'
|
| 712 |
+
},
|
| 713 |
+
videoPlayerTitle: { fontSize: 15, fontWeight: '900', color: '#ffffff', flex: 1, marginRight: 16 },
|
| 714 |
+
videoPlayerCloseBtn: { width: 36, height: 36, borderRadius: 18, backgroundColor: 'rgba(255,255,255,0.1)', justifyContent: 'center', alignItems: 'center' },
|
| 715 |
+
webView: { flex: 1, backgroundColor: '#000000' },
|
| 716 |
+
videoPlayerLoading: { flex: 1, justifyContent: 'center', alignItems: 'center' },
|
| 717 |
+
videoPlayerFooter: { padding: 20, borderTopWidth: 1, borderTopColor: 'rgba(255,255,255,0.08)' },
|
| 718 |
+
videoPlayerDesc: { fontSize: 13.5, color: '#94a3b8', lineHeight: 20, fontWeight: '500' }
|
| 719 |
});
|
| 720 |
|
| 721 |
export default KnowledgeBaseScreen;
|
MobileApp/src/screens/user/ProfileScreen.js
CHANGED
|
@@ -16,6 +16,7 @@ import * as ImagePicker from 'expo-image-picker';
|
|
| 16 |
import { decode } from 'base64-arraybuffer';
|
| 17 |
import { useFocusEffect } from '@react-navigation/native';
|
| 18 |
import { useNotification } from '../../components/NotificationProvider';
|
|
|
|
| 19 |
|
| 20 |
const ProfileScreen = () => {
|
| 21 |
const { success, error: notifyError } = useNotification();
|
|
@@ -223,8 +224,19 @@ const ProfileScreen = () => {
|
|
| 223 |
};
|
| 224 |
|
| 225 |
const handleLogout = async () => {
|
| 226 |
-
|
| 227 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 228 |
};
|
| 229 |
|
| 230 |
if (loading) {
|
|
|
|
| 16 |
import { decode } from 'base64-arraybuffer';
|
| 17 |
import { useFocusEffect } from '@react-navigation/native';
|
| 18 |
import { useNotification } from '../../components/NotificationProvider';
|
| 19 |
+
import AsyncStorage from '@react-native-async-storage/async-storage';
|
| 20 |
|
| 21 |
const ProfileScreen = () => {
|
| 22 |
const { success, error: notifyError } = useNotification();
|
|
|
|
| 224 |
};
|
| 225 |
|
| 226 |
const handleLogout = async () => {
|
| 227 |
+
try {
|
| 228 |
+
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium);
|
| 229 |
+
// Forcefully wipe all Supabase session keys from AsyncStorage first to trigger instant navigation resetting
|
| 230 |
+
const keys = await AsyncStorage.getAllKeys();
|
| 231 |
+
const supabaseKeys = keys.filter(k => k.startsWith('sb-') || k.includes('supabase'));
|
| 232 |
+
for (const key of supabaseKeys) {
|
| 233 |
+
await AsyncStorage.removeItem(key);
|
| 234 |
+
}
|
| 235 |
+
await supabase.auth.signOut();
|
| 236 |
+
} catch (e) {
|
| 237 |
+
console.warn("Logout error, forcing full wipe:", e);
|
| 238 |
+
await AsyncStorage.clear();
|
| 239 |
+
}
|
| 240 |
};
|
| 241 |
|
| 242 |
if (loading) {
|
MobileApp/src/screens/user/TicketDetailScreen.js
CHANGED
|
@@ -1,60 +1,159 @@
|
|
| 1 |
-
import React, { useState, useEffect, useRef } from 'react';
|
| 2 |
import {
|
| 3 |
-
StyleSheet,
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
TextInput,
|
| 7 |
-
TouchableOpacity,
|
| 8 |
-
FlatList,
|
| 9 |
-
KeyboardAvoidingView,
|
| 10 |
-
Platform,
|
| 11 |
-
ActivityIndicator
|
| 12 |
} from 'react-native';
|
| 13 |
import { SafeAreaView } from 'react-native-safe-area-context';
|
| 14 |
import { supabase } from '../../lib/supabase';
|
| 15 |
import { COLORS, SHADOWS } from '../../styles/theme';
|
| 16 |
-
import {
|
| 17 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 18 |
|
| 19 |
const TicketDetailScreen = ({ route }) => {
|
| 20 |
const { ticketId } = route.params || {};
|
|
|
|
|
|
|
|
|
|
| 21 |
const [messages, setMessages] = useState([]);
|
| 22 |
const [newMessage, setNewMessage] = useState('');
|
| 23 |
const [loading, setLoading] = useState(true);
|
| 24 |
const [ticket, setTicket] = useState(null);
|
| 25 |
-
const
|
| 26 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 27 |
|
|
|
|
|
|
|
|
|
|
| 28 |
useEffect(() => {
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
.
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 49 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 50 |
const fetchTicketDetails = async () => {
|
| 51 |
const { data, error } = await supabase
|
| 52 |
.from('tickets')
|
| 53 |
-
.select(
|
|
|
|
|
|
|
|
|
|
| 54 |
.eq('id', ticketId)
|
| 55 |
.single();
|
| 56 |
|
| 57 |
-
if (!error)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 58 |
};
|
| 59 |
|
| 60 |
const fetchMessages = async () => {
|
|
@@ -74,11 +173,12 @@ const TicketDetailScreen = ({ route }) => {
|
|
| 74 |
}
|
| 75 |
};
|
| 76 |
|
| 77 |
-
const sendMessage = async () => {
|
| 78 |
-
|
|
|
|
| 79 |
|
| 80 |
-
|
| 81 |
-
setNewMessage('');
|
| 82 |
|
| 83 |
try {
|
| 84 |
const { data: { user } } = await supabase.auth.getUser();
|
|
@@ -90,7 +190,7 @@ const TicketDetailScreen = ({ route }) => {
|
|
| 90 |
ticket_id: ticketId,
|
| 91 |
sender_id: user.id,
|
| 92 |
sender_name: profile?.full_name || 'User',
|
| 93 |
-
message:
|
| 94 |
sender_role: 'user'
|
| 95 |
});
|
| 96 |
|
|
@@ -100,171 +200,1004 @@ const TicketDetailScreen = ({ route }) => {
|
|
| 100 |
}
|
| 101 |
};
|
| 102 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 103 |
const renderMessage = ({ item }) => {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 104 |
const isUser = item.sender_role === 'user';
|
| 105 |
-
const isAI = item.sender_role === 'ai';
|
|
|
|
|
|
|
| 106 |
|
| 107 |
return (
|
| 108 |
-
<View style={
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 117 |
</View>
|
| 118 |
)}
|
| 119 |
-
<Text style={[styles.messageText, isUser && styles.userMessageText]}>
|
| 120 |
-
{item.message}
|
| 121 |
-
</Text>
|
| 122 |
-
<Text style={[styles.messageTime, isUser && styles.userTimeText]}>
|
| 123 |
-
{new Date(item.created_at).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
|
| 124 |
-
</Text>
|
| 125 |
</View>
|
| 126 |
);
|
| 127 |
};
|
| 128 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 129 |
return (
|
| 130 |
-
<SafeAreaView style={styles.container}>
|
|
|
|
|
|
|
|
|
|
| 131 |
<View style={styles.header}>
|
| 132 |
<TouchableOpacity onPress={() => navigation.goBack()} style={styles.backBtn}>
|
| 133 |
<ArrowLeft size={24} color={COLORS.text} />
|
| 134 |
</TouchableOpacity>
|
| 135 |
-
|
| 136 |
-
|
| 137 |
-
|
| 138 |
-
|
| 139 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 140 |
</View>
|
| 141 |
</View>
|
| 142 |
|
| 143 |
-
|
| 144 |
-
|
| 145 |
-
|
| 146 |
-
|
| 147 |
-
|
| 148 |
-
|
| 149 |
-
|
| 150 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 151 |
</View>
|
| 152 |
-
|
| 153 |
-
|
| 154 |
-
|
| 155 |
-
|
| 156 |
-
|
| 157 |
-
|
| 158 |
-
|
| 159 |
-
|
| 160 |
-
|
| 161 |
-
|
| 162 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 163 |
</View>
|
| 164 |
-
|
| 165 |
-
|
| 166 |
-
|
|
|
|
| 167 |
|
| 168 |
-
|
| 169 |
-
|
| 170 |
-
|
| 171 |
-
|
| 172 |
-
|
| 173 |
-
|
| 174 |
-
|
| 175 |
-
|
| 176 |
-
|
| 177 |
-
|
| 178 |
-
|
| 179 |
-
|
| 180 |
-
|
| 181 |
-
|
| 182 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 183 |
</View>
|
| 184 |
-
</
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 185 |
</SafeAreaView>
|
| 186 |
);
|
| 187 |
};
|
| 188 |
|
| 189 |
const styles = StyleSheet.create({
|
| 190 |
-
container: { flex: 1, backgroundColor:
|
|
|
|
|
|
|
| 191 |
header: {
|
| 192 |
flexDirection: 'row',
|
| 193 |
alignItems: 'center',
|
| 194 |
-
paddingHorizontal:
|
| 195 |
-
|
| 196 |
-
backgroundColor:
|
| 197 |
borderBottomWidth: 1,
|
| 198 |
-
borderBottomColor:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 199 |
...SHADOWS.soft
|
| 200 |
},
|
| 201 |
-
|
| 202 |
-
headerContent: { flex: 1 },
|
| 203 |
-
headerTitle: { fontSize:
|
| 204 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 205 |
loadingContainer: { flex: 1, justifyContent: 'center', alignItems: 'center' },
|
| 206 |
-
messagesList: { padding: 16, paddingBottom:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 207 |
messageBubble: {
|
| 208 |
-
maxWidth: '
|
| 209 |
-
|
| 210 |
-
|
| 211 |
-
|
|
|
|
|
|
|
| 212 |
...SHADOWS.soft
|
| 213 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
| 214 |
userBubble: {
|
| 215 |
alignSelf: 'flex-end',
|
| 216 |
-
backgroundColor:
|
| 217 |
-
|
| 218 |
},
|
| 219 |
adminBubble: {
|
| 220 |
alignSelf: 'flex-start',
|
| 221 |
-
backgroundColor:
|
| 222 |
-
|
| 223 |
borderWidth: 1,
|
| 224 |
-
borderColor: 'rgba(0,0,0,0.
|
| 225 |
},
|
| 226 |
aiBubble: {
|
| 227 |
-
|
| 228 |
-
|
| 229 |
-
|
| 230 |
},
|
| 231 |
senderHeader: { flexDirection: 'row', alignItems: 'center', marginBottom: 4, gap: 4 },
|
| 232 |
-
senderName: { fontSize: 10, fontWeight: '
|
| 233 |
-
messageText: { fontSize:
|
| 234 |
-
userMessageText: { color:
|
| 235 |
-
|
| 236 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 237 |
inputContainer: {
|
| 238 |
flexDirection: 'row',
|
| 239 |
alignItems: 'center',
|
| 240 |
-
padding:
|
| 241 |
-
backgroundColor:
|
| 242 |
-
|
| 243 |
-
|
| 244 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 245 |
},
|
|
|
|
| 246 |
input: {
|
| 247 |
flex: 1,
|
| 248 |
-
backgroundColor: COLORS.background,
|
| 249 |
-
borderRadius: 24,
|
| 250 |
-
paddingHorizontal: 16,
|
| 251 |
-
paddingVertical: 8,
|
| 252 |
-
maxHeight: 100,
|
| 253 |
fontSize: 15,
|
| 254 |
-
|
|
|
|
|
|
|
|
|
|
| 255 |
},
|
| 256 |
-
|
| 257 |
-
width:
|
| 258 |
-
height:
|
| 259 |
-
borderRadius:
|
| 260 |
backgroundColor: COLORS.primary,
|
| 261 |
justifyContent: 'center',
|
| 262 |
alignItems: 'center',
|
| 263 |
-
...SHADOWS.medium
|
|
|
|
| 264 |
},
|
| 265 |
-
|
| 266 |
emptyContainer: { flex: 1, justifyContent: 'center', alignItems: 'center', marginTop: 100 },
|
| 267 |
-
emptyText: { textAlign: 'center', color: COLORS.textMuted, fontSize:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 268 |
});
|
| 269 |
|
| 270 |
export default TicketDetailScreen;
|
|
|
|
| 1 |
+
import React, { useState, useEffect, useRef, useCallback } from 'react';
|
| 2 |
import {
|
| 3 |
+
StyleSheet, View, Text, TextInput, TouchableOpacity,
|
| 4 |
+
FlatList, KeyboardAvoidingView, Platform, ActivityIndicator,
|
| 5 |
+
StatusBar, Alert, Modal, Image, ScrollView, Animated
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 6 |
} from 'react-native';
|
| 7 |
import { SafeAreaView } from 'react-native-safe-area-context';
|
| 8 |
import { supabase } from '../../lib/supabase';
|
| 9 |
import { COLORS, SHADOWS } from '../../styles/theme';
|
| 10 |
+
import {
|
| 11 |
+
ArrowLeft, Send, User, Bot, Mic, Phone, Video,
|
| 12 |
+
Info, Smile, Paperclip, X, CheckCheck, Shield, Sparkles,
|
| 13 |
+
Globe, Hash, Calendar, Star, Play, Pause, Check, Volume2, MicOff, CameraOff
|
| 14 |
+
} from 'lucide-react-native';
|
| 15 |
+
import { useNavigation, useFocusEffect } from '@react-navigation/native';
|
| 16 |
+
import * as Haptics from 'expo-haptics';
|
| 17 |
+
|
| 18 |
+
const SUGGESTIONS = {
|
| 19 |
+
Software: ['Request escalation to L2', 'Reset work password', 'MFA verified, thank you!', 'Resolved, please close.'],
|
| 20 |
+
Hardware: ['Order delivery check', 'Report physical damage', 'Schedule diagnostic', 'Resolved!'],
|
| 21 |
+
Network: ['Latency test ping', 'Reset VPN credentials', 'Firewall bypass request', 'Resolved, thanks!'],
|
| 22 |
+
Access: ['Update AD permissions', 'MFA code override key', 'Access verified successfully', 'Resolved!'],
|
| 23 |
+
Default: ['Urgent escalation target', 'Verify SLA breach timer', 'AI suggested resolutions', 'Resolved, thank you!']
|
| 24 |
+
};
|
| 25 |
|
| 26 |
const TicketDetailScreen = ({ route }) => {
|
| 27 |
const { ticketId } = route.params || {};
|
| 28 |
+
const navigation = useNavigation();
|
| 29 |
+
const flatListRef = useRef(null);
|
| 30 |
+
|
| 31 |
const [messages, setMessages] = useState([]);
|
| 32 |
const [newMessage, setNewMessage] = useState('');
|
| 33 |
const [loading, setLoading] = useState(true);
|
| 34 |
const [ticket, setTicket] = useState(null);
|
| 35 |
+
const [currentUser, setCurrentUser] = useState(null);
|
| 36 |
+
|
| 37 |
+
// Simulated call state
|
| 38 |
+
const [activeCall, setActiveCall] = useState(null); // 'Audio' | 'Video' | null
|
| 39 |
+
const [callDuration, setCallDuration] = useState(0);
|
| 40 |
+
const [callStatus, setCallStatus] = useState('Ringing...'); // 'Ringing...' | 'Connected'
|
| 41 |
+
const callTimerRef = useRef(null);
|
| 42 |
+
|
| 43 |
+
// Voice recording simulation state
|
| 44 |
+
const [isRecording, setIsRecording] = useState(false);
|
| 45 |
+
const [recordDuration, setRecordDuration] = useState(0);
|
| 46 |
+
const recordTimerRef = useRef(null);
|
| 47 |
+
|
| 48 |
+
// Dialog and emoji reactions state
|
| 49 |
+
const [showInfoModal, setShowInfoModal] = useState(false);
|
| 50 |
+
const [reactions, setReactions] = useState({});
|
| 51 |
+
const [selectedMessageId, setSelectedMessageId] = useState(null);
|
| 52 |
+
|
| 53 |
+
// Call options state
|
| 54 |
+
const [isMuted, setIsMuted] = useState(false);
|
| 55 |
+
const [isSpeakerOn, setIsSpeakerOn] = useState(true);
|
| 56 |
+
const [isCameraOff, setIsCameraOff] = useState(false);
|
| 57 |
+
|
| 58 |
+
// Voice playback simulation state
|
| 59 |
+
const [playingVoiceId, setPlayingVoiceId] = useState(null);
|
| 60 |
+
const [voiceProgress, setVoiceProgress] = useState({});
|
| 61 |
+
const voicePlayTimerRef = useRef(null);
|
| 62 |
|
| 63 |
+
// Pulse animation for recording dot
|
| 64 |
+
const pulseAnim = useRef(new Animated.Value(1)).current;
|
| 65 |
+
// Recording pulse animation loop
|
| 66 |
useEffect(() => {
|
| 67 |
+
if (isRecording) {
|
| 68 |
+
Animated.loop(
|
| 69 |
+
Animated.sequence([
|
| 70 |
+
Animated.timing(pulseAnim, {
|
| 71 |
+
toValue: 0.3,
|
| 72 |
+
duration: 600,
|
| 73 |
+
useNativeDriver: true,
|
| 74 |
+
}),
|
| 75 |
+
Animated.timing(pulseAnim, {
|
| 76 |
+
toValue: 1,
|
| 77 |
+
duration: 600,
|
| 78 |
+
useNativeDriver: true,
|
| 79 |
+
}),
|
| 80 |
+
])
|
| 81 |
+
).start();
|
| 82 |
+
} else {
|
| 83 |
+
pulseAnim.setValue(1);
|
| 84 |
+
}
|
| 85 |
+
}, [isRecording]);
|
| 86 |
+
|
| 87 |
+
useFocusEffect(
|
| 88 |
+
useCallback(() => {
|
| 89 |
+
let isMounted = true;
|
| 90 |
+
|
| 91 |
+
const initialize = async () => {
|
| 92 |
+
try {
|
| 93 |
+
if (isMounted) setLoading(true);
|
| 94 |
+
const { data: { user } } = await supabase.auth.getUser();
|
| 95 |
+
if (isMounted) setCurrentUser(user);
|
| 96 |
+
|
| 97 |
+
await Promise.all([
|
| 98 |
+
fetchTicketDetails(),
|
| 99 |
+
fetchMessages()
|
| 100 |
+
]);
|
| 101 |
+
} catch (err) {
|
| 102 |
+
console.error("Initialization error:", err);
|
| 103 |
+
} finally {
|
| 104 |
+
if (isMounted) setLoading(false);
|
| 105 |
+
}
|
| 106 |
+
};
|
| 107 |
|
| 108 |
+
initialize();
|
| 109 |
+
|
| 110 |
+
// Set up real-time subscription for messages
|
| 111 |
+
const channel = supabase
|
| 112 |
+
.channel(`ticket_messages_user:${ticketId}`)
|
| 113 |
+
.on('postgres_changes', {
|
| 114 |
+
event: 'INSERT',
|
| 115 |
+
schema: 'public',
|
| 116 |
+
table: 'ticket_messages',
|
| 117 |
+
filter: `ticket_id=eq.${ticketId}`
|
| 118 |
+
}, (payload) => {
|
| 119 |
+
setMessages(prev => {
|
| 120 |
+
if (prev.some(m => m.id === payload.new.id)) return prev;
|
| 121 |
+
return [...prev, payload.new];
|
| 122 |
+
});
|
| 123 |
+
setTimeout(() => flatListRef.current?.scrollToEnd({ animated: true }), 150);
|
| 124 |
+
})
|
| 125 |
+
.subscribe();
|
| 126 |
+
|
| 127 |
+
return () => {
|
| 128 |
+
isMounted = false;
|
| 129 |
+
supabase.removeChannel(channel);
|
| 130 |
+
if (callTimerRef.current) clearInterval(callTimerRef.current);
|
| 131 |
+
if (recordTimerRef.current) clearInterval(recordTimerRef.current);
|
| 132 |
+
if (voicePlayTimerRef.current) clearInterval(voicePlayTimerRef.current);
|
| 133 |
+
};
|
| 134 |
+
}, [ticketId])
|
| 135 |
+
);
|
| 136 |
const fetchTicketDetails = async () => {
|
| 137 |
const { data, error } = await supabase
|
| 138 |
.from('tickets')
|
| 139 |
+
.select(`
|
| 140 |
+
*,
|
| 141 |
+
assignee:profiles!tickets_assigned_agent_id_fkey(full_name, email, profile_picture)
|
| 142 |
+
`)
|
| 143 |
.eq('id', ticketId)
|
| 144 |
.single();
|
| 145 |
|
| 146 |
+
if (!error) {
|
| 147 |
+
setTicket(data);
|
| 148 |
+
} else {
|
| 149 |
+
// Fallback
|
| 150 |
+
const { data: fallbackData } = await supabase
|
| 151 |
+
.from('tickets')
|
| 152 |
+
.select('*')
|
| 153 |
+
.eq('id', ticketId)
|
| 154 |
+
.single();
|
| 155 |
+
setTicket(fallbackData);
|
| 156 |
+
}
|
| 157 |
};
|
| 158 |
|
| 159 |
const fetchMessages = async () => {
|
|
|
|
| 173 |
}
|
| 174 |
};
|
| 175 |
|
| 176 |
+
const sendMessage = async (textToSend = null) => {
|
| 177 |
+
const messageContent = (textToSend || newMessage).trim();
|
| 178 |
+
if (!messageContent) return;
|
| 179 |
|
| 180 |
+
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
| 181 |
+
if (!textToSend) setNewMessage('');
|
| 182 |
|
| 183 |
try {
|
| 184 |
const { data: { user } } = await supabase.auth.getUser();
|
|
|
|
| 190 |
ticket_id: ticketId,
|
| 191 |
sender_id: user.id,
|
| 192 |
sender_name: profile?.full_name || 'User',
|
| 193 |
+
message: messageContent,
|
| 194 |
sender_role: 'user'
|
| 195 |
});
|
| 196 |
|
|
|
|
| 200 |
}
|
| 201 |
};
|
| 202 |
|
| 203 |
+
const handleMicPress = () => {
|
| 204 |
+
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Warning);
|
| 205 |
+
Alert.alert(
|
| 206 |
+
"Voice Telemetry Stream",
|
| 207 |
+
"Corporate voice message routing is online. Do you want to initialize the voice note recorder?",
|
| 208 |
+
[
|
| 209 |
+
{ text: "Cancel", style: "cancel" },
|
| 210 |
+
{
|
| 211 |
+
text: "Record Mock",
|
| 212 |
+
onPress: () => {
|
| 213 |
+
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Heavy);
|
| 214 |
+
sendMessage("🎤 [Voice telemetry note recorded and routed successfully]");
|
| 215 |
+
}
|
| 216 |
+
}
|
| 217 |
+
]
|
| 218 |
+
);
|
| 219 |
+
};
|
| 220 |
+
|
| 221 |
+
// Dynamic active grey-to-blue checkmark ticks color resolver
|
| 222 |
+
const getTickColor = (msg) => {
|
| 223 |
+
if (!msg.created_at) return "#9ca3af"; // local / unsaved
|
| 224 |
+
|
| 225 |
+
// A message gets blue ticks if:
|
| 226 |
+
// 1. There is an admin or AI reply sent AFTER this message.
|
| 227 |
+
const hasAdminReply = messages.some(
|
| 228 |
+
m => m.created_at &&
|
| 229 |
+
new Date(m.created_at) > new Date(msg.created_at) &&
|
| 230 |
+
(m.sender_role === 'admin' || m.sender_role === 'ai')
|
| 231 |
+
);
|
| 232 |
+
|
| 233 |
+
// 2. The ticket is marked as resolved or closed.
|
| 234 |
+
const isCompleted = ticket?.status === 'resolved' || ticket?.status === 'closed';
|
| 235 |
+
|
| 236 |
+
if (hasAdminReply || isCompleted) {
|
| 237 |
+
return "#38bdf8"; // WhatsApp Blue tick!
|
| 238 |
+
}
|
| 239 |
+
return "#8e8e93"; // WhatsApp Grey double-ticks!
|
| 240 |
+
};
|
| 241 |
+
|
| 242 |
+
const playVoiceNote = (msgId, durationStr) => {
|
| 243 |
+
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium);
|
| 244 |
+
if (playingVoiceId === msgId) {
|
| 245 |
+
setPlayingVoiceId(null);
|
| 246 |
+
if (voicePlayTimerRef.current) clearInterval(voicePlayTimerRef.current);
|
| 247 |
+
return;
|
| 248 |
+
}
|
| 249 |
+
|
| 250 |
+
if (voicePlayTimerRef.current) clearInterval(voicePlayTimerRef.current);
|
| 251 |
+
setPlayingVoiceId(msgId);
|
| 252 |
+
|
| 253 |
+
const parts = durationStr.split(':');
|
| 254 |
+
const totalSecs = parseInt(parts[0] || '0') * 60 + parseInt(parts[1] || '0');
|
| 255 |
+
let currentSecs = voiceProgress[msgId] ? (voiceProgress[msgId] / 100) * totalSecs : 0;
|
| 256 |
+
|
| 257 |
+
voicePlayTimerRef.current = setInterval(() => {
|
| 258 |
+
currentSecs += 0.25;
|
| 259 |
+
const percentage = Math.min((currentSecs / totalSecs) * 100, 100);
|
| 260 |
+
|
| 261 |
+
setVoiceProgress(prev => ({
|
| 262 |
+
...prev,
|
| 263 |
+
[msgId]: percentage
|
| 264 |
+
}));
|
| 265 |
+
|
| 266 |
+
if (percentage >= 100) {
|
| 267 |
+
setPlayingVoiceId(null);
|
| 268 |
+
clearInterval(voicePlayTimerRef.current);
|
| 269 |
+
setVoiceProgress(prev => ({
|
| 270 |
+
...prev,
|
| 271 |
+
[msgId]: 0
|
| 272 |
+
}));
|
| 273 |
+
}
|
| 274 |
+
}, 250);
|
| 275 |
+
};
|
| 276 |
+
|
| 277 |
+
const handleCallPress = (type) => {
|
| 278 |
+
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
|
| 279 |
+
setActiveCall(type);
|
| 280 |
+
setCallStatus('Ringing...');
|
| 281 |
+
setCallDuration(0);
|
| 282 |
+
setIsMuted(false);
|
| 283 |
+
setIsCameraOff(false);
|
| 284 |
+
setIsSpeakerOn(true);
|
| 285 |
+
|
| 286 |
+
// Simulate connection after 2 seconds
|
| 287 |
+
setTimeout(() => {
|
| 288 |
+
setCallStatus('Connected');
|
| 289 |
+
callTimerRef.current = setInterval(() => {
|
| 290 |
+
setCallDuration(prev => prev + 1);
|
| 291 |
+
}, 1000);
|
| 292 |
+
}, 2000);
|
| 293 |
+
};
|
| 294 |
+
|
| 295 |
+
const endCall = () => {
|
| 296 |
+
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Warning);
|
| 297 |
+
setActiveCall(null);
|
| 298 |
+
if (callTimerRef.current) {
|
| 299 |
+
clearInterval(callTimerRef.current);
|
| 300 |
+
}
|
| 301 |
+
};
|
| 302 |
+
|
| 303 |
+
const startRecording = () => {
|
| 304 |
+
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium);
|
| 305 |
+
setIsRecording(true);
|
| 306 |
+
setRecordDuration(0);
|
| 307 |
+
recordTimerRef.current = setInterval(() => {
|
| 308 |
+
setRecordDuration(prev => prev + 1);
|
| 309 |
+
}, 1000);
|
| 310 |
+
};
|
| 311 |
+
|
| 312 |
+
const stopRecording = (shouldSave = true) => {
|
| 313 |
+
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
|
| 314 |
+
setIsRecording(false);
|
| 315 |
+
if (recordTimerRef.current) {
|
| 316 |
+
clearInterval(recordTimerRef.current);
|
| 317 |
+
}
|
| 318 |
+
if (shouldSave && recordDuration > 0) {
|
| 319 |
+
sendMessage(`🎤 Voice message (${formatDuration(recordDuration)})`);
|
| 320 |
+
}
|
| 321 |
+
};
|
| 322 |
+
|
| 323 |
+
const formatDuration = (sec) => {
|
| 324 |
+
const mins = Math.floor(sec / 60);
|
| 325 |
+
const secs = sec % 60;
|
| 326 |
+
return `${mins}:${secs < 10 ? '0' : ''}${secs}`;
|
| 327 |
+
};
|
| 328 |
+
|
| 329 |
+
const handleReactionPress = (msgId, emoji) => {
|
| 330 |
+
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium);
|
| 331 |
+
setReactions(prev => ({
|
| 332 |
+
...prev,
|
| 333 |
+
[msgId]: prev[msgId] === emoji ? null : emoji
|
| 334 |
+
}));
|
| 335 |
+
setSelectedMessageId(null);
|
| 336 |
+
};
|
| 337 |
+
|
| 338 |
+
// Group messages dynamically by Date Separator Bars (TODAY / YESTERDAY / Date)
|
| 339 |
+
const getGroupedMessages = useCallback(() => {
|
| 340 |
+
const grouped = [];
|
| 341 |
+
let lastDateStr = null;
|
| 342 |
+
|
| 343 |
+
messages.forEach((msg) => {
|
| 344 |
+
if (!msg.created_at) return;
|
| 345 |
+
const date = new Date(msg.created_at);
|
| 346 |
+
const dateStr = date.toDateString();
|
| 347 |
+
|
| 348 |
+
if (dateStr !== lastDateStr) {
|
| 349 |
+
let label = date.toLocaleDateString(undefined, { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' });
|
| 350 |
+
const today = new Date().toDateString();
|
| 351 |
+
const yesterday = new Date(Date.now() - 86400000).toDateString();
|
| 352 |
+
if (dateStr === today) {
|
| 353 |
+
label = 'TODAY';
|
| 354 |
+
} else if (dateStr === yesterday) {
|
| 355 |
+
label = 'YESTERDAY';
|
| 356 |
+
}
|
| 357 |
+
grouped.push({ id: `date-${dateStr}`, isDateSeparator: true, text: label });
|
| 358 |
+
lastDateStr = dateStr;
|
| 359 |
+
}
|
| 360 |
+
grouped.push(msg);
|
| 361 |
+
});
|
| 362 |
+
|
| 363 |
+
return grouped;
|
| 364 |
+
}, [messages]);
|
| 365 |
+
|
| 366 |
const renderMessage = ({ item }) => {
|
| 367 |
+
if (item.isDateSeparator) {
|
| 368 |
+
return (
|
| 369 |
+
<View style={styles.dateSeparator}>
|
| 370 |
+
<View style={styles.dateSeparatorLine} />
|
| 371 |
+
<View style={styles.dateSeparatorBadge}>
|
| 372 |
+
<Text style={styles.dateSeparatorText}>{item.text}</Text>
|
| 373 |
+
</View>
|
| 374 |
+
<View style={styles.dateSeparatorLine} />
|
| 375 |
+
</View>
|
| 376 |
+
);
|
| 377 |
+
}
|
| 378 |
+
|
| 379 |
const isUser = item.sender_role === 'user';
|
| 380 |
+
const isAI = item.sender_role === 'ai' || item.sender_id === '00000000-0000-0000-0000-000000000000';
|
| 381 |
+
const messageReaction = reactions[item.id];
|
| 382 |
+
const isOverlayActive = selectedMessageId === item.id;
|
| 383 |
|
| 384 |
return (
|
| 385 |
+
<View style={styles.bubbleRow}>
|
| 386 |
+
<TouchableOpacity
|
| 387 |
+
onLongPress={() => {
|
| 388 |
+
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium);
|
| 389 |
+
setSelectedMessageId(item.id);
|
| 390 |
+
}}
|
| 391 |
+
onPress={() => {
|
| 392 |
+
if (selectedMessageId) setSelectedMessageId(null);
|
| 393 |
+
}}
|
| 394 |
+
activeOpacity={0.9}
|
| 395 |
+
style={[
|
| 396 |
+
styles.messageBubble,
|
| 397 |
+
isUser ? styles.userBubble : styles.adminBubble,
|
| 398 |
+
isAI && styles.aiBubble,
|
| 399 |
+
messageReaction && { marginBottom: 24 } // Extra spacing for reactions
|
| 400 |
+
]}
|
| 401 |
+
>
|
| 402 |
+
{/* Bubble Tail */}
|
| 403 |
+
<View style={[styles.bubbleTail, isUser ? styles.userBubbleTail : styles.adminBubbleTail]} />
|
| 404 |
+
|
| 405 |
+
{!isUser && (
|
| 406 |
+
<View style={styles.senderHeader}>
|
| 407 |
+
{isAI ? <Bot size={13} color="#8b5cf6" /> : <User size={13} color={COLORS.textLight} />}
|
| 408 |
+
<Text style={[styles.senderName, isAI && { color: '#8b5cf6' }]}>
|
| 409 |
+
{isAI ? 'AI Assistant' : item.sender_name || 'Support'}
|
| 410 |
+
</Text>
|
| 411 |
+
</View>
|
| 412 |
+
)}
|
| 413 |
+
|
| 414 |
+
{(() => {
|
| 415 |
+
const isVoiceNote = item.message && item.message.startsWith('🎤 Voice message');
|
| 416 |
+
let voiceDuration = "0:00";
|
| 417 |
+
if (isVoiceNote) {
|
| 418 |
+
const match = item.message.match(/\((.*?)\)/);
|
| 419 |
+
if (match) voiceDuration = match[1];
|
| 420 |
+
}
|
| 421 |
+
if (isVoiceNote) {
|
| 422 |
+
return (
|
| 423 |
+
<View style={styles.voiceNoteContainer}>
|
| 424 |
+
<TouchableOpacity
|
| 425 |
+
onPress={() => playVoiceNote(item.id, voiceDuration)}
|
| 426 |
+
style={styles.voicePlayBtn}
|
| 427 |
+
>
|
| 428 |
+
{playingVoiceId === item.id ? (
|
| 429 |
+
<Pause size={16} color={isUser ? '#075e54' : COLORS.primary} fill={isUser ? '#075e54' : COLORS.primary} />
|
| 430 |
+
) : (
|
| 431 |
+
<Play size={16} color={isUser ? '#075e54' : COLORS.primary} fill={isUser ? '#075e54' : COLORS.primary} />
|
| 432 |
+
)}
|
| 433 |
+
</TouchableOpacity>
|
| 434 |
+
|
| 435 |
+
<View style={styles.voiceWaveContainer}>
|
| 436 |
+
<View style={styles.voiceWaveform}>
|
| 437 |
+
{[8, 14, 18, 10, 6, 12, 20, 14, 10, 16, 22, 12, 8, 14, 18, 10, 6, 12, 16, 8].map((barHeight, idx) => {
|
| 438 |
+
const barProgress = (idx / 20) * 100;
|
| 439 |
+
const isPlayed = (voiceProgress[item.id] || 0) >= barProgress;
|
| 440 |
+
return (
|
| 441 |
+
<View
|
| 442 |
+
key={idx}
|
| 443 |
+
style={[
|
| 444 |
+
styles.voiceWaveBar,
|
| 445 |
+
{
|
| 446 |
+
height: barHeight,
|
| 447 |
+
backgroundColor: isPlayed
|
| 448 |
+
? (isUser ? '#075e54' : COLORS.primary)
|
| 449 |
+
: (isUser ? 'rgba(0,0,0,0.15)' : 'rgba(0,0,0,0.1)')
|
| 450 |
+
}
|
| 451 |
+
]}
|
| 452 |
+
/>
|
| 453 |
+
);
|
| 454 |
+
})}
|
| 455 |
+
</View>
|
| 456 |
+
<View style={styles.voiceProgressTextRow}>
|
| 457 |
+
<Text style={styles.voiceDurationText}>
|
| 458 |
+
{playingVoiceId === item.id
|
| 459 |
+
? formatDuration(Math.round(((voiceProgress[item.id] || 0) / 100) * (parseInt(voiceDuration.split(':')[0] || '0') * 60 + parseInt(voiceDuration.split(':')[1] || '0'))))
|
| 460 |
+
: voiceDuration
|
| 461 |
+
}
|
| 462 |
+
</Text>
|
| 463 |
+
</View>
|
| 464 |
+
</View>
|
| 465 |
+
</View>
|
| 466 |
+
);
|
| 467 |
+
}
|
| 468 |
+
return (
|
| 469 |
+
<Text style={[styles.messageText, isUser && styles.userMessageText]}>
|
| 470 |
+
{item.message}
|
| 471 |
+
</Text>
|
| 472 |
+
);
|
| 473 |
+
})()}
|
| 474 |
+
|
| 475 |
+
<View style={styles.bubbleFooter}>
|
| 476 |
+
<Text style={[styles.messageTime, isUser && styles.userTimeText]}>
|
| 477 |
+
{item.created_at
|
| 478 |
+
? new Date(item.created_at).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
|
| 479 |
+
: new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
|
| 480 |
+
}
|
| 481 |
+
</Text>
|
| 482 |
+
{isUser && (
|
| 483 |
+
item.created_at ? (
|
| 484 |
+
<CheckCheck size={14} color={getTickColor(item)} style={{ marginLeft: 4 }} />
|
| 485 |
+
) : (
|
| 486 |
+
<Check size={14} color="#9ca3af" style={{ marginLeft: 4 }} />
|
| 487 |
+
)
|
| 488 |
+
)}
|
| 489 |
+
</View>
|
| 490 |
+
|
| 491 |
+
{/* Floating Emoji Badge */}
|
| 492 |
+
{messageReaction && (
|
| 493 |
+
<View style={[styles.reactionBadge, isUser ? styles.userReaction : styles.adminReaction]}>
|
| 494 |
+
<Text style={styles.reactionText}>{messageReaction}</Text>
|
| 495 |
+
</View>
|
| 496 |
+
)}
|
| 497 |
+
</TouchableOpacity>
|
| 498 |
+
|
| 499 |
+
{/* Instagram style popover emoji bar */}
|
| 500 |
+
{isOverlayActive && (
|
| 501 |
+
<View style={[styles.reactionOverlay, isUser ? styles.alignRightOverlay : styles.alignLeftOverlay]}>
|
| 502 |
+
{['❤️', '👍', '😂', '😮', '😢', '🙏'].map(emoji => (
|
| 503 |
+
<TouchableOpacity
|
| 504 |
+
key={emoji}
|
| 505 |
+
style={styles.reactionOption}
|
| 506 |
+
onPress={() => handleReactionPress(item.id, emoji)}
|
| 507 |
+
>
|
| 508 |
+
<Text style={styles.reactionOptionText}>{emoji}</Text>
|
| 509 |
+
</TouchableOpacity>
|
| 510 |
+
))}
|
| 511 |
+
<TouchableOpacity onPress={() => setSelectedMessageId(null)} style={styles.closeOverlayBtn}>
|
| 512 |
+
<X size={14} color={COLORS.textMuted} />
|
| 513 |
+
</TouchableOpacity>
|
| 514 |
</View>
|
| 515 |
)}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 516 |
</View>
|
| 517 |
);
|
| 518 |
};
|
| 519 |
|
| 520 |
+
const activeCategory = ticket?.category || 'Default';
|
| 521 |
+
const sugList = SUGGESTIONS[activeCategory] || SUGGESTIONS.Default;
|
| 522 |
+
const isUserTyping = newMessage.trim().length > 0;
|
| 523 |
+
const assigneeName = ticket?.assignee?.full_name || 'AI Helpdesk Support';
|
| 524 |
+
|
| 525 |
+
const getStatusColor = (status) => {
|
| 526 |
+
const s = (status || '').toLowerCase().trim();
|
| 527 |
+
if (s === 'open') return '#3b82f6';
|
| 528 |
+
if (s === 'in progress' || s === 'in_progress') return '#f59e0b';
|
| 529 |
+
if (s === 'resolved') return '#10b981';
|
| 530 |
+
if (s === 'closed') return '#6b7280';
|
| 531 |
+
return '#ef4444'; // critical or default
|
| 532 |
+
};
|
| 533 |
+
const statusColor = getStatusColor(ticket?.status);
|
| 534 |
+
|
| 535 |
return (
|
| 536 |
+
<SafeAreaView style={styles.container} edges={['top']}>
|
| 537 |
+
<StatusBar barStyle="dark-content" />
|
| 538 |
+
|
| 539 |
+
{/* Header (WhatsApp Profile Action bar style) */}
|
| 540 |
<View style={styles.header}>
|
| 541 |
<TouchableOpacity onPress={() => navigation.goBack()} style={styles.backBtn}>
|
| 542 |
<ArrowLeft size={24} color={COLORS.text} />
|
| 543 |
</TouchableOpacity>
|
| 544 |
+
|
| 545 |
+
<TouchableOpacity
|
| 546 |
+
style={styles.headerInfoTouch}
|
| 547 |
+
onPress={() => setShowInfoModal(true)}
|
| 548 |
+
activeOpacity={0.7}
|
| 549 |
+
>
|
| 550 |
+
<View style={styles.avatar}>
|
| 551 |
+
<Text style={styles.avatarText}>{assigneeName[0].toUpperCase()}</Text>
|
| 552 |
+
</View>
|
| 553 |
+
<View style={styles.headerContent}>
|
| 554 |
+
<Text style={styles.headerTitle} numberOfLines={1}>
|
| 555 |
+
{assigneeName}
|
| 556 |
+
</Text>
|
| 557 |
+
<View style={styles.statusRow}>
|
| 558 |
+
<View style={styles.pulseDot} />
|
| 559 |
+
<Text style={styles.statusText}>active sync</Text>
|
| 560 |
+
</View>
|
| 561 |
+
</View>
|
| 562 |
+
</TouchableOpacity>
|
| 563 |
+
|
| 564 |
+
<View style={styles.headerActions}>
|
| 565 |
+
<TouchableOpacity onPress={() => handleCallPress('Audio')} style={styles.headerActionBtn}>
|
| 566 |
+
<Phone size={20} color={COLORS.text} />
|
| 567 |
+
</TouchableOpacity>
|
| 568 |
+
<TouchableOpacity onPress={() => handleCallPress('Video')} style={styles.headerActionBtn}>
|
| 569 |
+
<Video size={20} color={COLORS.text} />
|
| 570 |
+
</TouchableOpacity>
|
| 571 |
+
<TouchableOpacity onPress={() => setShowInfoModal(true)} style={styles.headerActionBtn}>
|
| 572 |
+
<Info size={20} color={COLORS.text} />
|
| 573 |
+
</TouchableOpacity>
|
| 574 |
</View>
|
| 575 |
</View>
|
| 576 |
|
| 577 |
+
{/* WhatsApp Wallpaper container */}
|
| 578 |
+
<View style={styles.wallpaperBg}>
|
| 579 |
+
{/* Subtle grid elements mock doodle wallpaper */}
|
| 580 |
+
<View style={styles.gridOverlay} />
|
| 581 |
+
|
| 582 |
+
<KeyboardAvoidingView
|
| 583 |
+
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
|
| 584 |
+
style={{ flex: 1 }}
|
| 585 |
+
keyboardVerticalOffset={Platform.OS === 'ios' ? 90 : 80}
|
| 586 |
+
>
|
| 587 |
+
{loading ? (
|
| 588 |
+
<View style={styles.loadingContainer}>
|
| 589 |
+
<ActivityIndicator size="large" color={COLORS.primary} />
|
| 590 |
+
</View>
|
| 591 |
+
) : (
|
| 592 |
+
<FlatList
|
| 593 |
+
ref={flatListRef}
|
| 594 |
+
data={getGroupedMessages()}
|
| 595 |
+
keyExtractor={(item) => item.id}
|
| 596 |
+
renderItem={renderMessage}
|
| 597 |
+
contentContainerStyle={styles.messagesList}
|
| 598 |
+
showsVerticalScrollIndicator={false}
|
| 599 |
+
onContentSizeChange={() => flatListRef.current?.scrollToEnd({ animated: true })}
|
| 600 |
+
ListEmptyComponent={
|
| 601 |
+
<View style={styles.emptyContainer}>
|
| 602 |
+
<Text style={styles.emptyText}>Conversation initiated. Send a security report or resolution inquiry.</Text>
|
| 603 |
+
</View>
|
| 604 |
+
}
|
| 605 |
+
/>
|
| 606 |
+
)}
|
| 607 |
+
|
| 608 |
+
{/* Quick suggestions scroll pills */}
|
| 609 |
+
{!loading && sugList.length > 0 && (
|
| 610 |
+
<View style={styles.suggestionsContainer}>
|
| 611 |
+
<FlatList
|
| 612 |
+
data={sugList}
|
| 613 |
+
horizontal
|
| 614 |
+
showsHorizontalScrollIndicator={false}
|
| 615 |
+
contentContainerStyle={styles.suggestionsScroll}
|
| 616 |
+
keyExtractor={(item, index) => String(index)}
|
| 617 |
+
renderItem={({ item }) => (
|
| 618 |
+
<TouchableOpacity
|
| 619 |
+
style={styles.suggestionPill}
|
| 620 |
+
onPress={() => sendMessage(item)}
|
| 621 |
+
>
|
| 622 |
+
<Text style={styles.suggestionText}>{item}</Text>
|
| 623 |
+
</TouchableOpacity>
|
| 624 |
+
)}
|
| 625 |
+
/>
|
| 626 |
+
</View>
|
| 627 |
+
)}
|
| 628 |
+
|
| 629 |
+
{/* Input container footer */}
|
| 630 |
+
<View style={styles.inputContainer}>
|
| 631 |
+
{isRecording ? (
|
| 632 |
+
<View style={styles.recordingCard}>
|
| 633 |
+
<Animated.View style={[styles.pulseRecDot, { opacity: pulseAnim }]} />
|
| 634 |
+
<Text style={styles.recordingTimerText}>Recording {formatDuration(recordDuration)}</Text>
|
| 635 |
+
<TouchableOpacity onPress={() => stopRecording(false)} style={styles.cancelRecBtn}>
|
| 636 |
+
<Text style={styles.cancelRecText}>Cancel</Text>
|
| 637 |
+
</TouchableOpacity>
|
| 638 |
+
<TouchableOpacity onPress={() => stopRecording(true)} style={styles.sendRecBtn}>
|
| 639 |
+
<Send size={18} color={COLORS.primary} />
|
| 640 |
+
</TouchableOpacity>
|
| 641 |
+
</View>
|
| 642 |
+
) : (
|
| 643 |
+
<View style={styles.inputCard}>
|
| 644 |
+
<TouchableOpacity style={styles.inputIconBtn}>
|
| 645 |
+
<Smile size={22} color={COLORS.textMuted} />
|
| 646 |
+
</TouchableOpacity>
|
| 647 |
+
|
| 648 |
+
<TextInput
|
| 649 |
+
style={styles.input}
|
| 650 |
+
placeholder="Type your reply..."
|
| 651 |
+
placeholderTextColor="rgba(0,0,0,0.3)"
|
| 652 |
+
value={newMessage}
|
| 653 |
+
onChangeText={setNewMessage}
|
| 654 |
+
multiline
|
| 655 |
+
/>
|
| 656 |
+
|
| 657 |
+
<TouchableOpacity style={styles.inputIconBtn}>
|
| 658 |
+
<Paperclip size={20} color={COLORS.textMuted} />
|
| 659 |
+
</TouchableOpacity>
|
| 660 |
+
</View>
|
| 661 |
+
)}
|
| 662 |
+
|
| 663 |
+
{/* Dynamic Action Button WhatsApp feel */}
|
| 664 |
+
{!isRecording && (
|
| 665 |
+
isUserTyping ? (
|
| 666 |
+
<TouchableOpacity
|
| 667 |
+
style={styles.actionBtn}
|
| 668 |
+
onPress={() => sendMessage()}
|
| 669 |
+
activeOpacity={0.8}
|
| 670 |
+
>
|
| 671 |
+
<Send size={20} color="#fff" />
|
| 672 |
+
</TouchableOpacity>
|
| 673 |
+
) : (
|
| 674 |
+
<TouchableOpacity
|
| 675 |
+
style={[styles.actionBtn, { backgroundColor: COLORS.primary }]}
|
| 676 |
+
onPress={startRecording}
|
| 677 |
+
activeOpacity={0.8}
|
| 678 |
+
>
|
| 679 |
+
<Mic size={20} color="#fff" />
|
| 680 |
+
</TouchableOpacity>
|
| 681 |
+
)
|
| 682 |
+
)}
|
| 683 |
</View>
|
| 684 |
+
</KeyboardAvoidingView>
|
| 685 |
+
</View>
|
| 686 |
+
|
| 687 |
+
{/* Info Sheets Modal Overlay */}
|
| 688 |
+
<Modal
|
| 689 |
+
visible={showInfoModal}
|
| 690 |
+
transparent
|
| 691 |
+
animationType="slide"
|
| 692 |
+
onRequestClose={() => setShowInfoModal(false)}
|
| 693 |
+
>
|
| 694 |
+
<View style={styles.modalOverlay}>
|
| 695 |
+
<View style={styles.modalSheet}>
|
| 696 |
+
<View style={styles.modalSheetHeader}>
|
| 697 |
+
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 12 }}>
|
| 698 |
+
<View style={[styles.avatar, { width: 44, height: 44 }]}>
|
| 699 |
+
<Text style={styles.avatarText}>{assigneeName[0].toUpperCase()}</Text>
|
| 700 |
+
</View>
|
| 701 |
+
<View>
|
| 702 |
+
<Text style={styles.modalSheetTitle} numberOfLines={1}>{ticket?.subject || 'Ticket Details'}</Text>
|
| 703 |
+
<Text style={styles.modalSheetSubtitle}>#{ticketId?.slice(0, 8).toUpperCase()}</Text>
|
| 704 |
+
</View>
|
| 705 |
</View>
|
| 706 |
+
<TouchableOpacity onPress={() => setShowInfoModal(false)} style={styles.closeBtn}>
|
| 707 |
+
<X size={20} color={COLORS.textLight} />
|
| 708 |
+
</TouchableOpacity>
|
| 709 |
+
</View>
|
| 710 |
|
| 711 |
+
<ScrollView contentContainerStyle={styles.modalSheetBody} showsVerticalScrollIndicator={false}>
|
| 712 |
+
|
| 713 |
+
{/* Image Evidence (if exists) */}
|
| 714 |
+
{(ticket?.image_url || ticket?.metadata?.capturedFileBase64) && (
|
| 715 |
+
<View style={styles.modalSection}>
|
| 716 |
+
<Text style={styles.sectionTitle}>VISUAL TELEMETRY EVIDENCE</Text>
|
| 717 |
+
<View style={styles.imageWrap}>
|
| 718 |
+
<Image
|
| 719 |
+
source={{ uri: ticket.image_url || ticket.metadata.capturedFileBase64 }}
|
| 720 |
+
style={styles.modalImage}
|
| 721 |
+
resizeMode="cover"
|
| 722 |
+
/>
|
| 723 |
+
</View>
|
| 724 |
+
</View>
|
| 725 |
+
)}
|
| 726 |
+
|
| 727 |
+
<Text style={styles.sectionTitle}>INCIDENT PARAMETERS</Text>
|
| 728 |
+
|
| 729 |
+
<View style={styles.metaCard}>
|
| 730 |
+
<View style={styles.metaRow}>
|
| 731 |
+
<Hash size={14} color={COLORS.textMuted} />
|
| 732 |
+
<View style={{ flex: 1 }}>
|
| 733 |
+
<Text style={styles.metaLabel}>TICKET STATUS</Text>
|
| 734 |
+
<Text style={[styles.metaValue, { color: statusColor, fontWeight: '900' }]}>
|
| 735 |
+
{(ticket?.status || 'PENDING').toUpperCase()}
|
| 736 |
+
</Text>
|
| 737 |
+
</View>
|
| 738 |
+
</View>
|
| 739 |
+
|
| 740 |
+
<View style={styles.metaRow}>
|
| 741 |
+
<Shield size={14} color={COLORS.textMuted} />
|
| 742 |
+
<View style={{ flex: 1 }}>
|
| 743 |
+
<Text style={styles.metaLabel}>PRIORITY SCALE</Text>
|
| 744 |
+
<Text style={[styles.metaValue, { color: ticket?.priority?.toLowerCase() === 'critical' ? '#ef4444' : '#15803d' }]}>
|
| 745 |
+
{(ticket?.priority || 'Medium').toUpperCase()}
|
| 746 |
+
</Text>
|
| 747 |
+
</View>
|
| 748 |
+
</View>
|
| 749 |
+
|
| 750 |
+
<View style={[styles.metaRow, { borderBottomWidth: 0 }]}>
|
| 751 |
+
<Calendar size={14} color={COLORS.textMuted} />
|
| 752 |
+
<View style={{ flex: 1 }}>
|
| 753 |
+
<Text style={styles.metaLabel}>REPORTED TIMESTAMP</Text>
|
| 754 |
+
<Text style={styles.metaValue}>
|
| 755 |
+
{ticket?.created_at ? new Date(ticket.created_at).toLocaleString() : '—'}
|
| 756 |
+
</Text>
|
| 757 |
+
</View>
|
| 758 |
+
</View>
|
| 759 |
+
</View>
|
| 760 |
+
|
| 761 |
+
<Text style={styles.sectionTitle}>AI CLASSIFICATIONS</Text>
|
| 762 |
+
|
| 763 |
+
<View style={styles.aiTelemetryCard}>
|
| 764 |
+
<View style={styles.aiTelemetryHeader}>
|
| 765 |
+
<Sparkles size={16} color="#8b5cf6" />
|
| 766 |
+
<Text style={styles.aiTelemetryTitle}>RAG Neural Telemetry</Text>
|
| 767 |
+
</View>
|
| 768 |
+
<View style={styles.telemetryRow}>
|
| 769 |
+
<Text style={styles.telemetryLabel}>Predicted Category</Text>
|
| 770 |
+
<Text style={styles.telemetryValue}>{ticket?.category || 'General'}</Text>
|
| 771 |
+
</View>
|
| 772 |
+
<View style={styles.telemetryRow}>
|
| 773 |
+
<Text style={styles.telemetryLabel}>Sub-Category</Text>
|
| 774 |
+
<Text style={styles.telemetryValue}>{ticket?.subcategory || 'General Inquiry'}</Text>
|
| 775 |
+
</View>
|
| 776 |
+
<View style={[styles.telemetryRow, { borderBottomWidth: 0, paddingBottom: 0 }]}>
|
| 777 |
+
<Text style={styles.telemetryLabel}>RAG Match Score</Text>
|
| 778 |
+
<Text style={[styles.telemetryValue, { color: '#8b5cf6', fontWeight: '955' }]}>
|
| 779 |
+
{((ticket?.confidence || 0.85) * 100).toFixed(0)}% Accuracy
|
| 780 |
+
</Text>
|
| 781 |
+
</View>
|
| 782 |
+
</View>
|
| 783 |
+
|
| 784 |
+
<Text style={styles.sectionTitle}>ENVIRONMENT SIGNATURE</Text>
|
| 785 |
+
|
| 786 |
+
<View style={styles.metaCard}>
|
| 787 |
+
<View style={styles.metaRow}>
|
| 788 |
+
<Globe size={14} color={COLORS.textMuted} />
|
| 789 |
+
<View style={{ flex: 1 }}>
|
| 790 |
+
<Text style={styles.metaLabel}>IP ADDRESS</Text>
|
| 791 |
+
<Text style={styles.metaValue}>{ticket?.metadata?.env_metadata?.ip || '127.0.0.1'}</Text>
|
| 792 |
+
</View>
|
| 793 |
+
</View>
|
| 794 |
+
<View style={[styles.metaRow, { borderBottomWidth: 0, paddingBottom: 0 }]}>
|
| 795 |
+
<Bot size={14} color={COLORS.textMuted} />
|
| 796 |
+
<View style={{ flex: 1 }}>
|
| 797 |
+
<Text style={styles.metaLabel}>USER CLIENT SIGNATURE</Text>
|
| 798 |
+
<Text style={[styles.metaValue, { fontSize: 11, lineHeight: 14 }]} numberOfLines={2}>
|
| 799 |
+
{ticket?.metadata?.env_metadata?.user_agent || 'Neural Mobile Client'}
|
| 800 |
+
</Text>
|
| 801 |
+
</View>
|
| 802 |
+
</View>
|
| 803 |
+
</View>
|
| 804 |
+
|
| 805 |
+
</ScrollView>
|
| 806 |
+
</View>
|
| 807 |
</View>
|
| 808 |
+
</Modal>
|
| 809 |
+
|
| 810 |
+
{/* Premium WhatsApp simulated calling overlay */}
|
| 811 |
+
<Modal
|
| 812 |
+
visible={!!activeCall}
|
| 813 |
+
animationType="slide"
|
| 814 |
+
transparent={false}
|
| 815 |
+
onRequestClose={endCall}
|
| 816 |
+
>
|
| 817 |
+
<View style={styles.callScreenContainer}>
|
| 818 |
+
<StatusBar barStyle="light-content" />
|
| 819 |
+
<View style={styles.callScreenHeader}>
|
| 820 |
+
<Shield size={16} color="rgba(255,255,255,0.4)" />
|
| 821 |
+
<Text style={styles.secureCallText}>SECURE END-TO-END ENCRYPTED</Text>
|
| 822 |
+
</View>
|
| 823 |
+
|
| 824 |
+
<View style={styles.callScreenInfo}>
|
| 825 |
+
<View style={styles.callAvatarWrap}>
|
| 826 |
+
<Text style={styles.callAvatarText}>{assigneeName[0].toUpperCase()}</Text>
|
| 827 |
+
</View>
|
| 828 |
+
<Text style={styles.callContactName}>{assigneeName}</Text>
|
| 829 |
+
<Text style={styles.callStatusLabel}>
|
| 830 |
+
{callStatus === 'Connected' ? formatDuration(callDuration) : callStatus}
|
| 831 |
+
</Text>
|
| 832 |
+
</View>
|
| 833 |
+
|
| 834 |
+
{/* Video mock view */}
|
| 835 |
+
{activeCall === 'Video' && (
|
| 836 |
+
<View style={styles.videoMockContainer}>
|
| 837 |
+
<View style={styles.selfVideoMock} />
|
| 838 |
+
</View>
|
| 839 |
+
)}
|
| 840 |
+
|
| 841 |
+
<View style={styles.callScreenControls}>
|
| 842 |
+
<TouchableOpacity
|
| 843 |
+
style={[styles.callControlBtn, isMuted && styles.activeControlBtn]}
|
| 844 |
+
onPress={() => {
|
| 845 |
+
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
| 846 |
+
setIsMuted(!isMuted);
|
| 847 |
+
}}
|
| 848 |
+
>
|
| 849 |
+
{isMuted ? <MicOff size={24} color={COLORS.primary} /> : <Mic size={24} color="#ffffff" />}
|
| 850 |
+
</TouchableOpacity>
|
| 851 |
+
|
| 852 |
+
<TouchableOpacity
|
| 853 |
+
style={[styles.callControlBtn, { backgroundColor: '#ef4444' }]}
|
| 854 |
+
onPress={endCall}
|
| 855 |
+
>
|
| 856 |
+
<Phone size={24} color="#ffffff" style={{ transform: [{ rotate: '135deg' }] }} />
|
| 857 |
+
</TouchableOpacity>
|
| 858 |
+
|
| 859 |
+
<TouchableOpacity
|
| 860 |
+
style={[styles.callControlBtn, isSpeakerOn && styles.activeControlBtn]}
|
| 861 |
+
onPress={() => {
|
| 862 |
+
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
| 863 |
+
setIsSpeakerOn(!isSpeakerOn);
|
| 864 |
+
}}
|
| 865 |
+
>
|
| 866 |
+
<Volume2 size={24} color={isSpeakerOn ? COLORS.primary : "#ffffff"} />
|
| 867 |
+
</TouchableOpacity>
|
| 868 |
+
</View>
|
| 869 |
+
</View>
|
| 870 |
+
</Modal>
|
| 871 |
+
|
| 872 |
</SafeAreaView>
|
| 873 |
);
|
| 874 |
};
|
| 875 |
|
| 876 |
const styles = StyleSheet.create({
|
| 877 |
+
container: { flex: 1, backgroundColor: '#ffffff' },
|
| 878 |
+
|
| 879 |
+
// Header Style WhatsApp
|
| 880 |
header: {
|
| 881 |
flexDirection: 'row',
|
| 882 |
alignItems: 'center',
|
| 883 |
+
paddingHorizontal: 10,
|
| 884 |
+
height: 60,
|
| 885 |
+
backgroundColor: '#fff',
|
| 886 |
borderBottomWidth: 1,
|
| 887 |
+
borderBottomColor: '#f1f1f1',
|
| 888 |
+
elevation: 3,
|
| 889 |
+
shadowColor: '#000',
|
| 890 |
+
shadowOffset: { width: 0, height: 1 },
|
| 891 |
+
shadowOpacity: 0.08,
|
| 892 |
+
shadowRadius: 10,
|
| 893 |
+
zIndex: 999
|
| 894 |
+
},
|
| 895 |
+
backBtn: { padding: 6 },
|
| 896 |
+
headerInfoTouch: { flex: 1, flexDirection: 'row', alignItems: 'center', marginHorizontal: 4 },
|
| 897 |
+
avatar: {
|
| 898 |
+
width: 38,
|
| 899 |
+
height: 38,
|
| 900 |
+
borderRadius: 19,
|
| 901 |
+
backgroundColor: COLORS.primary,
|
| 902 |
+
justifyContent: 'center',
|
| 903 |
+
alignItems: 'center',
|
| 904 |
+
marginRight: 10,
|
| 905 |
...SHADOWS.soft
|
| 906 |
},
|
| 907 |
+
avatarText: { fontSize: 16, fontWeight: '900', color: '#fff' },
|
| 908 |
+
headerContent: { flex: 1, gap: 1 },
|
| 909 |
+
headerTitle: { fontSize: 16, fontWeight: '900', color: COLORS.text, letterSpacing: -0.2 },
|
| 910 |
+
statusRow: { flexDirection: 'row', alignItems: 'center', gap: 5 },
|
| 911 |
+
pulseDot: { width: 5, height: 5, borderRadius: 2.5, backgroundColor: '#22c55e' },
|
| 912 |
+
statusText: { fontSize: 10, fontWeight: '700', color: '#15803d', textTransform: 'uppercase' },
|
| 913 |
+
|
| 914 |
+
headerActions: { flexDirection: 'row', alignItems: 'center' },
|
| 915 |
+
headerActionBtn: { padding: 10 },
|
| 916 |
+
|
| 917 |
+
// WhatsApp Doodle Wallpaper Background
|
| 918 |
+
wallpaperBg: { flex: 1, backgroundColor: '#efeae2', position: 'relative' },
|
| 919 |
+
gridOverlay: { position: 'absolute', top: 0, bottom: 0, left: 0, right: 0, opacity: 0.015, backgroundColor: 'transparent', borderWidth: 0.5, borderColor: '#000' },
|
| 920 |
+
|
| 921 |
loadingContainer: { flex: 1, justifyContent: 'center', alignItems: 'center' },
|
| 922 |
+
messagesList: { padding: 16, paddingBottom: 20 },
|
| 923 |
+
|
| 924 |
+
// Group Separator Date Headers
|
| 925 |
+
dateSeparator: { flexDirection: 'row', alignItems: 'center', marginVertical: 14, paddingHorizontal: 10 },
|
| 926 |
+
dateSeparatorLine: { flex: 1, height: 1, backgroundColor: 'rgba(0,0,0,0.06)' },
|
| 927 |
+
dateSeparatorBadge: { backgroundColor: 'rgba(255,255,255,0.85)', paddingHorizontal: 12, paddingVertical: 5, borderRadius: 8, marginHorizontal: 12, ...SHADOWS.soft },
|
| 928 |
+
dateSeparatorText: { fontSize: 9.5, fontWeight: '800', color: COLORS.textLight, letterSpacing: 0.5, textTransform: 'uppercase' },
|
| 929 |
+
|
| 930 |
+
bubbleRow: { position: 'relative' },
|
| 931 |
+
|
| 932 |
+
// Message Speech Bubble curved style
|
| 933 |
messageBubble: {
|
| 934 |
+
maxWidth: '80%',
|
| 935 |
+
paddingHorizontal: 14,
|
| 936 |
+
paddingVertical: 9,
|
| 937 |
+
borderRadius: 16,
|
| 938 |
+
marginBottom: 12,
|
| 939 |
+
position: 'relative',
|
| 940 |
...SHADOWS.soft
|
| 941 |
},
|
| 942 |
+
bubbleTail: { position: 'absolute', top: 0, width: 8, height: 10, backgroundColor: 'transparent' },
|
| 943 |
+
userBubbleTail: { right: -6, borderTopLeftRadius: 0, borderTopColor: COLORS.primary, borderLeftWidth: 6, borderLeftColor: COLORS.primary, borderBottomRightRadius: 6, borderBottomWidth: 6, borderBottomColor: 'transparent' },
|
| 944 |
+
adminBubbleTail: { left: -6, borderTopRightRadius: 0, borderTopColor: '#fff', borderRightWidth: 6, borderRightColor: '#fff', borderBottomLeftRadius: 6, borderBottomWidth: 6, borderBottomColor: 'transparent' },
|
| 945 |
+
|
| 946 |
userBubble: {
|
| 947 |
alignSelf: 'flex-end',
|
| 948 |
+
backgroundColor: '#d9fdd3', // Premium WhatsApp green bubble hex!
|
| 949 |
+
borderTopRightRadius: 0
|
| 950 |
},
|
| 951 |
adminBubble: {
|
| 952 |
alignSelf: 'flex-start',
|
| 953 |
+
backgroundColor: '#ffffff', // Premium WhatsApp white bubble hex!
|
| 954 |
+
borderTopLeftRadius: 0,
|
| 955 |
borderWidth: 1,
|
| 956 |
+
borderColor: 'rgba(0,0,0,0.02)'
|
| 957 |
},
|
| 958 |
aiBubble: {
|
| 959 |
+
backgroundColor: '#f5f0ff',
|
| 960 |
+
borderColor: '#dcd0ff',
|
| 961 |
+
borderWidth: 1
|
| 962 |
},
|
| 963 |
senderHeader: { flexDirection: 'row', alignItems: 'center', marginBottom: 4, gap: 4 },
|
| 964 |
+
senderName: { fontSize: 10, fontWeight: '800', color: COLORS.textMuted },
|
| 965 |
+
messageText: { fontSize: 14.5, color: '#303030', lineHeight: 20, fontWeight: '500' },
|
| 966 |
+
userMessageText: { color: '#303030' },
|
| 967 |
+
|
| 968 |
+
bubbleFooter: { flexDirection: 'row', alignItems: 'center', alignSelf: 'flex-end', marginTop: 4 },
|
| 969 |
+
messageTime: { fontSize: 9.5, color: COLORS.textMuted, fontWeight: '600' },
|
| 970 |
+
userTimeText: { color: COLORS.textMuted },
|
| 971 |
+
|
| 972 |
+
// Emoji Reactions badges
|
| 973 |
+
reactionBadge: {
|
| 974 |
+
position: 'absolute',
|
| 975 |
+
bottom: -14,
|
| 976 |
+
width: 22,
|
| 977 |
+
height: 22,
|
| 978 |
+
borderRadius: 11,
|
| 979 |
+
backgroundColor: '#fff',
|
| 980 |
+
borderWidth: 1,
|
| 981 |
+
borderColor: 'rgba(0,0,0,0.08)',
|
| 982 |
+
justifyContent: 'center',
|
| 983 |
+
alignItems: 'center',
|
| 984 |
+
zIndex: 99,
|
| 985 |
+
...SHADOWS.soft
|
| 986 |
+
},
|
| 987 |
+
userReaction: { right: 8 },
|
| 988 |
+
adminReaction: { left: 8 },
|
| 989 |
+
reactionText: { fontSize: 12 },
|
| 990 |
+
|
| 991 |
+
// Instagram Style Reaction popover overlay
|
| 992 |
+
reactionOverlay: {
|
| 993 |
+
position: 'absolute',
|
| 994 |
+
top: -50,
|
| 995 |
+
flexDirection: 'row',
|
| 996 |
+
alignItems: 'center',
|
| 997 |
+
backgroundColor: '#fff',
|
| 998 |
+
padding: 6,
|
| 999 |
+
borderRadius: 100,
|
| 1000 |
+
borderWidth: 1,
|
| 1001 |
+
borderColor: 'rgba(0,0,0,0.08)',
|
| 1002 |
+
zIndex: 9999,
|
| 1003 |
+
gap: 8,
|
| 1004 |
+
...SHADOWS.soft
|
| 1005 |
+
},
|
| 1006 |
+
alignRightOverlay: { right: 10 },
|
| 1007 |
+
alignLeftOverlay: { left: 10 },
|
| 1008 |
+
reactionOption: { padding: 4 },
|
| 1009 |
+
reactionOptionText: { fontSize: 20 },
|
| 1010 |
+
closeOverlayBtn: { width: 22, height: 22, borderRadius: 11, backgroundColor: '#f3f4f6', justifyContent: 'center', alignItems: 'center', marginLeft: 4 },
|
| 1011 |
+
|
| 1012 |
+
// Suggestions scroll pills
|
| 1013 |
+
suggestionsContainer: { backgroundColor: 'transparent', paddingVertical: 8, zIndex: 10 },
|
| 1014 |
+
suggestionsScroll: { paddingHorizontal: 16, gap: 8 },
|
| 1015 |
+
suggestionPill: {
|
| 1016 |
+
backgroundColor: '#fff',
|
| 1017 |
+
paddingHorizontal: 14,
|
| 1018 |
+
paddingVertical: 8,
|
| 1019 |
+
borderRadius: 100,
|
| 1020 |
+
borderWidth: 1.5,
|
| 1021 |
+
borderColor: '#e5e7eb',
|
| 1022 |
+
...SHADOWS.soft
|
| 1023 |
+
},
|
| 1024 |
+
suggestionText: { fontSize: 12, fontWeight: '700', color: COLORS.textLight },
|
| 1025 |
+
|
| 1026 |
+
// Premium input containers
|
| 1027 |
inputContainer: {
|
| 1028 |
flexDirection: 'row',
|
| 1029 |
alignItems: 'center',
|
| 1030 |
+
padding: 10,
|
| 1031 |
+
backgroundColor: 'transparent',
|
| 1032 |
+
gap: 8,
|
| 1033 |
+
paddingBottom: Platform.OS === 'ios' ? 24 : 10
|
| 1034 |
+
},
|
| 1035 |
+
inputCard: {
|
| 1036 |
+
flex: 1,
|
| 1037 |
+
flexDirection: 'row',
|
| 1038 |
+
alignItems: 'center',
|
| 1039 |
+
backgroundColor: '#fff',
|
| 1040 |
+
borderRadius: 25,
|
| 1041 |
+
paddingHorizontal: 12,
|
| 1042 |
+
height: 48,
|
| 1043 |
+
...SHADOWS.soft
|
| 1044 |
},
|
| 1045 |
+
inputIconBtn: { padding: 8 },
|
| 1046 |
input: {
|
| 1047 |
flex: 1,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1048 |
fontSize: 15,
|
| 1049 |
+
fontWeight: '600',
|
| 1050 |
+
color: COLORS.text,
|
| 1051 |
+
paddingHorizontal: 6,
|
| 1052 |
+
maxHeight: 80
|
| 1053 |
},
|
| 1054 |
+
actionBtn: {
|
| 1055 |
+
width: 48,
|
| 1056 |
+
height: 48,
|
| 1057 |
+
borderRadius: 24,
|
| 1058 |
backgroundColor: COLORS.primary,
|
| 1059 |
justifyContent: 'center',
|
| 1060 |
alignItems: 'center',
|
| 1061 |
+
...SHADOWS.medium,
|
| 1062 |
+
shadowColor: COLORS.primary
|
| 1063 |
},
|
| 1064 |
+
|
| 1065 |
emptyContainer: { flex: 1, justifyContent: 'center', alignItems: 'center', marginTop: 100 },
|
| 1066 |
+
emptyText: { textAlign: 'center', color: COLORS.textMuted, fontSize: 13.5, fontWeight: '600', paddingHorizontal: 40 },
|
| 1067 |
+
|
| 1068 |
+
// Sliding sheet modal info styles
|
| 1069 |
+
modalOverlay: { flex: 1, backgroundColor: 'rgba(0,0,0,0.5)', justifyContent: 'flex-end' },
|
| 1070 |
+
modalSheet: {
|
| 1071 |
+
backgroundColor: '#fff',
|
| 1072 |
+
borderTopLeftRadius: 32,
|
| 1073 |
+
borderTopRightRadius: 32,
|
| 1074 |
+
padding: 24,
|
| 1075 |
+
maxHeight: '80%',
|
| 1076 |
+
...SHADOWS.soft
|
| 1077 |
+
},
|
| 1078 |
+
modalSheetHeader: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', paddingBottom: 16, borderBottomWidth: 1, borderBottomColor: 'rgba(0,0,0,0.05)', marginBottom: 20 },
|
| 1079 |
+
modalSheetTitle: { fontSize: 16, fontWeight: '950', color: COLORS.text, maxWidth: 200 },
|
| 1080 |
+
modalSheetSubtitle: { fontSize: 11, fontWeight: '700', color: COLORS.textMuted, marginTop: 2 },
|
| 1081 |
+
closeBtn: { width: 36, height: 36, borderRadius: 18, backgroundColor: '#f3f4f6', justifyContent: 'center', alignItems: 'center' },
|
| 1082 |
+
|
| 1083 |
+
modalSheetBody: { gap: 18, paddingBottom: 32 },
|
| 1084 |
+
sectionTitle: { fontSize: 10.5, fontWeight: '800', color: COLORS.textMuted, letterSpacing: 1.2 },
|
| 1085 |
+
|
| 1086 |
+
modalSection: {},
|
| 1087 |
+
imageWrap: { borderRadius: 16, overflow: 'hidden', height: 180, backgroundColor: '#f3f4f6', borderWidth: 1, borderColor: 'rgba(0,0,0,0.05)' },
|
| 1088 |
+
modalImage: { width: '100%', height: '100%' },
|
| 1089 |
+
|
| 1090 |
+
metaCard: { backgroundColor: '#f8faf9', borderRadius: 20, paddingHorizontal: 16, paddingVertical: 4, borderWidth: 1, borderColor: 'rgba(0,0,0,0.03)' },
|
| 1091 |
+
metaRow: { flexDirection: 'row', alignItems: 'center', gap: 12, paddingVertical: 12, borderBottomWidth: 1, borderBottomColor: 'rgba(0,0,0,0.04)' },
|
| 1092 |
+
metaLabel: { fontSize: 9.5, fontWeight: '800', color: COLORS.textMuted, letterSpacing: 0.5 },
|
| 1093 |
+
metaValue: { fontSize: 12.5, fontWeight: '700', color: COLORS.text, marginTop: 2 },
|
| 1094 |
+
|
| 1095 |
+
// AI Insights
|
| 1096 |
+
aiTelemetryCard: { backgroundColor: '#f5f0ff', borderWidth: 1.5, borderColor: '#dcd0ff', borderRadius: 20, padding: 16 },
|
| 1097 |
+
aiTelemetryHeader: { flexDirection: 'row', alignItems: 'center', gap: 8, marginBottom: 12 },
|
| 1098 |
+
aiTelemetryTitle: { fontSize: 12, fontWeight: '900', color: '#8b5cf6', textTransform: 'uppercase', letterSpacing: 0.5 },
|
| 1099 |
+
telemetryRow: { flexDirection: 'row', justifyContent: 'space-between', paddingVertical: 8, borderBottomWidth: 1, borderBottomColor: 'rgba(139,92,246,0.1)' },
|
| 1100 |
+
telemetryLabel: { fontSize: 12, fontWeight: '600', color: COLORS.textLight },
|
| 1101 |
+
telemetryValue: { fontSize: 12, fontWeight: '800', color: COLORS.text },
|
| 1102 |
+
|
| 1103 |
+
// Simulated calling style sheet
|
| 1104 |
+
callScreenContainer: { flex: 1, backgroundColor: '#07121e', justifyContent: 'space-between', paddingVertical: 40, paddingHorizontal: 20 },
|
| 1105 |
+
callScreenHeader: { flexDirection: 'row', justifyContent: 'center', alignItems: 'center', gap: 6, opacity: 0.8 },
|
| 1106 |
+
secureCallText: { fontSize: 10, fontWeight: '800', color: 'rgba(255,255,255,0.5)', letterSpacing: 1 },
|
| 1107 |
+
callScreenInfo: { flex: 1, justifyContent: 'center', alignItems: 'center', gap: 14, marginTop: 40 },
|
| 1108 |
+
callAvatarWrap: { width: 120, height: 120, borderRadius: 60, backgroundColor: COLORS.primary, justifyContent: 'center', alignItems: 'center', elevation: 8, ...SHADOWS.medium },
|
| 1109 |
+
callAvatarText: { fontSize: 48, fontWeight: '900', color: '#ffffff' },
|
| 1110 |
+
callContactName: { fontSize: 24, fontWeight: '950', color: '#ffffff', textAlign: 'center' },
|
| 1111 |
+
callStatusLabel: { fontSize: 14, fontWeight: '700', color: 'rgba(255,255,255,0.6)', letterSpacing: 0.5 },
|
| 1112 |
+
videoMockContainer: { width: '100%', height: 260, borderRadius: 24, overflow: 'hidden', backgroundColor: 'rgba(255,255,255,0.03)', borderWidth: 1, borderColor: 'rgba(255,255,255,0.06)', marginVertical: 20, justifyContent: 'center', alignItems: 'center' },
|
| 1113 |
+
selfVideoMock: { width: 90, height: 130, borderRadius: 16, backgroundColor: '#1c2d42', position: 'absolute', bottom: 16, right: 16, borderStyle: 'solid', borderWidth: 2, borderColor: 'rgba(255,255,255,0.1)' },
|
| 1114 |
+
callScreenControls: { flexDirection: 'row', justifyContent: 'center', alignItems: 'center', gap: 28, marginBottom: 20 },
|
| 1115 |
+
callControlBtn: { width: 56, height: 56, borderRadius: 28, backgroundColor: 'rgba(255,255,255,0.08)', justifyContent: 'center', alignItems: 'center', borderWidth: 1, borderColor: 'rgba(255,255,255,0.05)' },
|
| 1116 |
+
|
| 1117 |
+
// Pulse voice recording styles
|
| 1118 |
+
recordingCard: {
|
| 1119 |
+
flex: 1,
|
| 1120 |
+
flexDirection: 'row',
|
| 1121 |
+
alignItems: 'center',
|
| 1122 |
+
backgroundColor: '#fff',
|
| 1123 |
+
borderRadius: 25,
|
| 1124 |
+
paddingHorizontal: 16,
|
| 1125 |
+
height: 48,
|
| 1126 |
+
gap: 12,
|
| 1127 |
+
...SHADOWS.soft
|
| 1128 |
+
},
|
| 1129 |
+
pulseRecDot: {
|
| 1130 |
+
width: 10,
|
| 1131 |
+
height: 10,
|
| 1132 |
+
borderRadius: 5,
|
| 1133 |
+
backgroundColor: '#ef4444'
|
| 1134 |
+
},
|
| 1135 |
+
recordingTimerText: {
|
| 1136 |
+
flex: 1,
|
| 1137 |
+
fontSize: 14,
|
| 1138 |
+
fontWeight: '800',
|
| 1139 |
+
color: '#ef4444'
|
| 1140 |
+
},
|
| 1141 |
+
cancelRecBtn: {
|
| 1142 |
+
paddingHorizontal: 12,
|
| 1143 |
+
paddingVertical: 6,
|
| 1144 |
+
borderRadius: 12,
|
| 1145 |
+
backgroundColor: '#f3f4f6'
|
| 1146 |
+
},
|
| 1147 |
+
cancelRecText: {
|
| 1148 |
+
fontSize: 12,
|
| 1149 |
+
fontWeight: '700',
|
| 1150 |
+
color: COLORS.textMuted
|
| 1151 |
+
},
|
| 1152 |
+
sendRecBtn: {
|
| 1153 |
+
padding: 8
|
| 1154 |
+
},
|
| 1155 |
+
|
| 1156 |
+
// Custom interactive caller styling
|
| 1157 |
+
activeControlBtn: {
|
| 1158 |
+
backgroundColor: '#ffffff',
|
| 1159 |
+
borderColor: '#ffffff'
|
| 1160 |
+
},
|
| 1161 |
+
|
| 1162 |
+
// Voice Note message rendering styles
|
| 1163 |
+
voiceNoteContainer: {
|
| 1164 |
+
flexDirection: 'row',
|
| 1165 |
+
alignItems: 'center',
|
| 1166 |
+
gap: 10,
|
| 1167 |
+
width: 220,
|
| 1168 |
+
paddingVertical: 4
|
| 1169 |
+
},
|
| 1170 |
+
voicePlayBtn: {
|
| 1171 |
+
width: 36,
|
| 1172 |
+
height: 36,
|
| 1173 |
+
borderRadius: 18,
|
| 1174 |
+
backgroundColor: 'rgba(0,0,0,0.06)',
|
| 1175 |
+
justifyContent: 'center',
|
| 1176 |
+
alignItems: 'center'
|
| 1177 |
+
},
|
| 1178 |
+
voiceWaveContainer: {
|
| 1179 |
+
flex: 1,
|
| 1180 |
+
gap: 4
|
| 1181 |
+
},
|
| 1182 |
+
voiceWaveform: {
|
| 1183 |
+
flexDirection: 'row',
|
| 1184 |
+
alignItems: 'center',
|
| 1185 |
+
gap: 3,
|
| 1186 |
+
height: 24
|
| 1187 |
+
},
|
| 1188 |
+
voiceWaveBar: {
|
| 1189 |
+
width: 3,
|
| 1190 |
+
borderRadius: 1.5
|
| 1191 |
+
},
|
| 1192 |
+
voiceProgressTextRow: {
|
| 1193 |
+
flexDirection: 'row',
|
| 1194 |
+
justifyContent: 'space-between'
|
| 1195 |
+
},
|
| 1196 |
+
voiceDurationText: {
|
| 1197 |
+
fontSize: 10,
|
| 1198 |
+
fontWeight: '700',
|
| 1199 |
+
color: COLORS.textMuted
|
| 1200 |
+
}
|
| 1201 |
});
|
| 1202 |
|
| 1203 |
export default TicketDetailScreen;
|
backend/.env.example
CHANGED
|
@@ -55,6 +55,8 @@ ALLOW_DEGRADED_STARTUP=0
|
|
| 55 |
# Default `false` allows the backend to run in "no-persistence" mode for tests.
|
| 56 |
REQUIRE_SUPABASE=false
|
| 57 |
|
|
|
|
|
|
|
| 58 |
|
| 59 |
# -----------------------------------------------------------------------------
|
| 60 |
# Health-Check Probe (Docker / Kubernetes)
|
|
|
|
| 55 |
# Default `false` allows the backend to run in "no-persistence" mode for tests.
|
| 56 |
REQUIRE_SUPABASE=false
|
| 57 |
|
| 58 |
+
# Slack Alerts
|
| 59 |
+
SLACK_WEBHOOK_URL=https://hooks.slack.com/services/xxx/yyy/zzz
|
| 60 |
|
| 61 |
# -----------------------------------------------------------------------------
|
| 62 |
# Health-Check Probe (Docker / Kubernetes)
|
backend/main.py
CHANGED
|
@@ -360,6 +360,7 @@ def detect_and_translate_ticket_text(text: str) -> dict:
|
|
| 360 |
"source_language_name": "English",
|
| 361 |
"was_translated": False,
|
| 362 |
"original_text": "",
|
|
|
|
| 363 |
}
|
| 364 |
|
| 365 |
detected = _heuristic_language_detection(original_text)
|
|
@@ -375,6 +376,7 @@ def detect_and_translate_ticket_text(text: str) -> dict:
|
|
| 375 |
"source_language_name": "English",
|
| 376 |
"was_translated": False,
|
| 377 |
"original_text": original_text,
|
|
|
|
| 378 |
}
|
| 379 |
|
| 380 |
translated_text = original_text
|
|
@@ -388,6 +390,7 @@ def detect_and_translate_ticket_text(text: str) -> dict:
|
|
| 388 |
"source_language_name": source_name,
|
| 389 |
"was_translated": False,
|
| 390 |
"original_text": original_text,
|
|
|
|
| 391 |
}
|
| 392 |
|
| 393 |
return {
|
|
@@ -396,6 +399,7 @@ def detect_and_translate_ticket_text(text: str) -> dict:
|
|
| 396 |
"source_language_name": source_name,
|
| 397 |
"was_translated": True,
|
| 398 |
"original_text": original_text,
|
|
|
|
| 399 |
}
|
| 400 |
|
| 401 |
|
|
@@ -773,6 +777,60 @@ async def save_ticket(request_body: TicketSaveRequest):
|
|
| 773 |
}
|
| 774 |
final_data["metadata"] = metadata
|
| 775 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 776 |
# Resolve tenant linkage from user profile with authorization validation.
|
| 777 |
profile = {}
|
| 778 |
if request_body.user_id:
|
|
@@ -812,6 +870,27 @@ async def save_ticket(request_body: TicketSaveRequest):
|
|
| 812 |
if not final_data.get("company") and profile.get("company"):
|
| 813 |
final_data["company"] = profile["company"]
|
| 814 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 815 |
user_hash = hashlib.sha256(str(request_body.user_id).encode()).hexdigest()[:8]
|
| 816 |
logger.info(f"Tenant linkage: user_hash={user_hash}, company_id={final_data.get('company_id')}")
|
| 817 |
|
|
|
|
| 360 |
"source_language_name": "English",
|
| 361 |
"was_translated": False,
|
| 362 |
"original_text": "",
|
| 363 |
+
"metadata":{},
|
| 364 |
}
|
| 365 |
|
| 366 |
detected = _heuristic_language_detection(original_text)
|
|
|
|
| 376 |
"source_language_name": "English",
|
| 377 |
"was_translated": False,
|
| 378 |
"original_text": original_text,
|
| 379 |
+
"metadata":{},
|
| 380 |
}
|
| 381 |
|
| 382 |
translated_text = original_text
|
|
|
|
| 390 |
"source_language_name": source_name,
|
| 391 |
"was_translated": False,
|
| 392 |
"original_text": original_text,
|
| 393 |
+
"metadata":{},
|
| 394 |
}
|
| 395 |
|
| 396 |
return {
|
|
|
|
| 399 |
"source_language_name": source_name,
|
| 400 |
"was_translated": True,
|
| 401 |
"original_text": original_text,
|
| 402 |
+
"metadata":{},
|
| 403 |
}
|
| 404 |
|
| 405 |
|
|
|
|
| 777 |
}
|
| 778 |
final_data["metadata"] = metadata
|
| 779 |
|
| 780 |
+
# Resolve tenant linkage from user profile with authorization validation.
|
| 781 |
+
profile = {}
|
| 782 |
+
if request_body.user_id:
|
| 783 |
+
try:
|
| 784 |
+
profile_res = (
|
| 785 |
+
supabase.table("profiles")
|
| 786 |
+
.select("company_id, company")
|
| 787 |
+
.eq("id", request_body.user_id)
|
| 788 |
+
.single()
|
| 789 |
+
.execute()
|
| 790 |
+
)
|
| 791 |
+
profile = profile_res.data or {}
|
| 792 |
+
if not profile:
|
| 793 |
+
raise HTTPException(status_code=404, detail="User profile not found")
|
| 794 |
+
|
| 795 |
+
# SELF-HEALING: If company_id is null in database but company name exists, resolve it!
|
| 796 |
+
if not profile.get("company_id") and profile.get("company"):
|
| 797 |
+
try:
|
| 798 |
+
comp_name = profile.get("company").strip()
|
| 799 |
+
comp_res = (
|
| 800 |
+
supabase.table("companies")
|
| 801 |
+
.select("id")
|
| 802 |
+
.ilike("name", comp_name)
|
| 803 |
+
.execute()
|
| 804 |
+
)
|
| 805 |
+
if comp_res.data:
|
| 806 |
+
resolved_company_id = comp_res.data[0]["id"]
|
| 807 |
+
# Backfill the profile table in real-time
|
| 808 |
+
supabase.table("profiles").update({"company_id": resolved_company_id}).eq("id", request_body.user_id).execute()
|
| 809 |
+
profile["company_id"] = resolved_company_id
|
| 810 |
+
logger.info(f"[SELF-HEALING] Backfilled company_id={resolved_company_id} for user={request_body.user_id}")
|
| 811 |
+
except Exception as healing_err:
|
| 812 |
+
logger.warning(f"[SELF-HEALING WARNING] Failed to backfill company_id: {healing_err}")
|
| 813 |
+
except HTTPException:
|
| 814 |
+
raise
|
| 815 |
+
except Exception as profile_error:
|
| 816 |
+
user_hash = hashlib.sha256(str(request_body.user_id).encode()).hexdigest()[:8]
|
| 817 |
+
logger.error(f"Tenant resolution error for user {user_hash}: {profile_error}")
|
| 818 |
+
raise HTTPException(status_code=503, detail="Failed to resolve tenant linkage") from profile_error
|
| 819 |
+
|
| 820 |
+
# Validate tenant consistency and authorization.
|
| 821 |
+
profile_company_id = profile.get("company_id")
|
| 822 |
+
if final_data.get("company_id"):
|
| 823 |
+
# User provided company_id: verify it matches their profile.
|
| 824 |
+
if profile_company_id and final_data["company_id"] != profile_company_id:
|
| 825 |
+
user_hash = hashlib.sha256(str(request_body.user_id).encode()).hexdigest()[:8]
|
| 826 |
+
logger.warning(f"Tenant mismatch: user {user_hash} attempted {final_data['company_id']}, assigned to {profile_company_id}")
|
| 827 |
+
raise HTTPException(status_code=403, detail="User not authorized for this tenant")
|
| 828 |
+
elif profile_company_id:
|
| 829 |
+
# Backfill company_id from profile.
|
| 830 |
+
final_data["company_id"] = profile_company_id
|
| 831 |
+
elif request_body.user_id:
|
| 832 |
+
# User has no tenant assignment.
|
| 833 |
+
raise HTTPException(status_code=400, detail="User has no tenant assignment")
|
| 834 |
# Resolve tenant linkage from user profile with authorization validation.
|
| 835 |
profile = {}
|
| 836 |
if request_body.user_id:
|
|
|
|
| 870 |
if not final_data.get("company") and profile.get("company"):
|
| 871 |
final_data["company"] = profile["company"]
|
| 872 |
|
| 873 |
+
priority = final_data.get("priority")
|
| 874 |
+
if not final_data.get("sla_response_due_at"):
|
| 875 |
+
final_data["sla_response_due_at"] = calculate_sla_response_at(priority).isoformat().replace("+00:00", "Z")
|
| 876 |
+
if not final_data.get("sla_breach_at"):
|
| 877 |
+
final_data["sla_breach_at"] = calculate_sla_breach_at(priority).isoformat().replace("+00:00", "Z")
|
| 878 |
+
final_data["sla_status"] = final_data.get("sla_status") or classify_sla_status(final_data.get("sla_breach_at"))
|
| 879 |
+
final_data["escalation_level"] = int(final_data.get("escalation_level") or 0)
|
| 880 |
+
|
| 881 |
+
import hashlib
|
| 882 |
+
user_hash = hashlib.sha256(str(request_body.user_id).encode()).hexdigest()[:8]
|
| 883 |
+
logger.info(f"Tenant linkage: user_hash={user_hash}, company_id={final_data.get('company_id')}")
|
| 884 |
+
|
| 885 |
+
duplicate_text = (request_body.description or "").strip() or (request_body.subject or "").strip()
|
| 886 |
+
duplicate_threshold = get_duplicate_threshold(final_data.get("company_id"), 0.85)
|
| 887 |
+
duplicate_result = {
|
| 888 |
+
"is_duplicate": False,
|
| 889 |
+
"duplicate_ticket_id": None,
|
| 890 |
+
"parent_ticket_id": None,
|
| 891 |
+
"is_potential_duplicate": False,
|
| 892 |
+
"similarity": 0.0,
|
| 893 |
+
}
|
| 894 |
user_hash = hashlib.sha256(str(request_body.user_id).encode()).hexdigest()[:8]
|
| 895 |
logger.info(f"Tenant linkage: user_hash={user_hash}, company_id={final_data.get('company_id')}")
|
| 896 |
|
backend/services/sla_service.py
CHANGED
|
@@ -14,6 +14,10 @@ import os
|
|
| 14 |
from datetime import datetime, timedelta, timezone
|
| 15 |
from typing import Any, Callable, Optional
|
| 16 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 17 |
try:
|
| 18 |
from dotenv import load_dotenv
|
| 19 |
except ImportError:
|
|
@@ -114,7 +118,7 @@ class SlaEscalationService:
|
|
| 114 |
self.now_fn = now_fn
|
| 115 |
self.notification_router = notification_router
|
| 116 |
|
| 117 |
-
def run_once(self) -> dict[str, int | str]:
|
| 118 |
stats: dict[str, int | str] = {
|
| 119 |
"processed_count": 0,
|
| 120 |
"breached_count": 0,
|
|
@@ -136,7 +140,7 @@ class SlaEscalationService:
|
|
| 136 |
if not self._should_breach(ticket, now):
|
| 137 |
stats["skipped_count"] = int(stats["skipped_count"]) + 1
|
| 138 |
continue
|
| 139 |
-
self._breach_ticket(ticket, now)
|
| 140 |
stats["breached_count"] = int(stats["breached_count"]) + 1
|
| 141 |
except Exception as exc:
|
| 142 |
stats["error_count"] = int(stats["error_count"]) + 1
|
|
@@ -169,7 +173,13 @@ class SlaEscalationService:
|
|
| 169 |
return False
|
| 170 |
return classify_sla_status(ticket.get("sla_breach_at"), now) == "BREACHED"
|
| 171 |
|
| 172 |
-
def _breach_ticket(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 173 |
ticket_id = str(ticket.get("id"))
|
| 174 |
company_id = ticket.get("company_id")
|
| 175 |
escalation_level = int(ticket.get("escalation_level") or 0) + 1
|
|
@@ -185,6 +195,7 @@ class SlaEscalationService:
|
|
| 185 |
|
| 186 |
self._insert_audit_log(ticket, escalation_level, timestamp)
|
| 187 |
self._emit_system_message(ticket, escalation_level, timestamp)
|
|
|
|
| 188 |
|
| 189 |
logger.warning(
|
| 190 |
"SLA breached | ticket_id=%s | company_id=%s | priority=%s | level=%s",
|
|
@@ -194,6 +205,31 @@ class SlaEscalationService:
|
|
| 194 |
escalation_level,
|
| 195 |
)
|
| 196 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 197 |
def _insert_audit_log(self, ticket: dict[str, Any], escalation_level: int, timestamp: str) -> None:
|
| 198 |
ticket_id = str(ticket.get("id"))
|
| 199 |
self.supabase.table("audit_logs").insert(
|
|
|
|
| 14 |
from datetime import datetime, timedelta, timezone
|
| 15 |
from typing import Any, Callable, Optional
|
| 16 |
|
| 17 |
+
from fastapi import BackgroundTasks
|
| 18 |
+
|
| 19 |
+
from backend.sla_checker import dispatch_slack_alert
|
| 20 |
+
|
| 21 |
try:
|
| 22 |
from dotenv import load_dotenv
|
| 23 |
except ImportError:
|
|
|
|
| 118 |
self.now_fn = now_fn
|
| 119 |
self.notification_router = notification_router
|
| 120 |
|
| 121 |
+
def run_once(self, background_tasks: BackgroundTasks | None = None) -> dict[str, int | str]:
|
| 122 |
stats: dict[str, int | str] = {
|
| 123 |
"processed_count": 0,
|
| 124 |
"breached_count": 0,
|
|
|
|
| 140 |
if not self._should_breach(ticket, now):
|
| 141 |
stats["skipped_count"] = int(stats["skipped_count"]) + 1
|
| 142 |
continue
|
| 143 |
+
self._breach_ticket(ticket, now, background_tasks=background_tasks)
|
| 144 |
stats["breached_count"] = int(stats["breached_count"]) + 1
|
| 145 |
except Exception as exc:
|
| 146 |
stats["error_count"] = int(stats["error_count"]) + 1
|
|
|
|
| 173 |
return False
|
| 174 |
return classify_sla_status(ticket.get("sla_breach_at"), now) == "BREACHED"
|
| 175 |
|
| 176 |
+
def _breach_ticket(
|
| 177 |
+
self,
|
| 178 |
+
ticket: dict[str, Any],
|
| 179 |
+
now: datetime,
|
| 180 |
+
*,
|
| 181 |
+
background_tasks: BackgroundTasks | None = None,
|
| 182 |
+
) -> None:
|
| 183 |
ticket_id = str(ticket.get("id"))
|
| 184 |
company_id = ticket.get("company_id")
|
| 185 |
escalation_level = int(ticket.get("escalation_level") or 0) + 1
|
|
|
|
| 195 |
|
| 196 |
self._insert_audit_log(ticket, escalation_level, timestamp)
|
| 197 |
self._emit_system_message(ticket, escalation_level, timestamp)
|
| 198 |
+
self._dispatch_breach_alert(ticket, now, background_tasks=background_tasks)
|
| 199 |
|
| 200 |
logger.warning(
|
| 201 |
"SLA breached | ticket_id=%s | company_id=%s | priority=%s | level=%s",
|
|
|
|
| 205 |
escalation_level,
|
| 206 |
)
|
| 207 |
|
| 208 |
+
def _dispatch_breach_alert(
|
| 209 |
+
self,
|
| 210 |
+
ticket: dict[str, Any],
|
| 211 |
+
breach_time: datetime,
|
| 212 |
+
*,
|
| 213 |
+
background_tasks: BackgroundTasks | None = None,
|
| 214 |
+
) -> None:
|
| 215 |
+
ticket_id = str(ticket.get("id") or "")
|
| 216 |
+
subject = str(ticket.get("subject") or "Untitled ticket")
|
| 217 |
+
category = str(ticket.get("priority") or "Uncategorized")
|
| 218 |
+
assignee = str(ticket.get("assigned_team") or "Unassigned")
|
| 219 |
+
|
| 220 |
+
if background_tasks is not None:
|
| 221 |
+
background_tasks.add_task(
|
| 222 |
+
dispatch_slack_alert,
|
| 223 |
+
ticket_id,
|
| 224 |
+
subject,
|
| 225 |
+
category,
|
| 226 |
+
assignee,
|
| 227 |
+
breach_time,
|
| 228 |
+
)
|
| 229 |
+
return
|
| 230 |
+
|
| 231 |
+
dispatch_slack_alert(ticket_id, subject, category, assignee, breach_time)
|
| 232 |
+
|
| 233 |
def _insert_audit_log(self, ticket: dict[str, Any], escalation_level: int, timestamp: str) -> None:
|
| 234 |
ticket_id = str(ticket.get("id"))
|
| 235 |
self.supabase.table("audit_logs").insert(
|
backend/sla_checker.py
CHANGED
|
@@ -1,3 +1,120 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
"""
|
| 2 |
SLA Background Checker — Periodic worker that evaluates ticket SLAs
|
| 3 |
and dispatches multi-channel escalation notifications.
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import logging
|
| 4 |
+
import os
|
| 5 |
+
from datetime import datetime, timezone
|
| 6 |
+
|
| 7 |
+
logger = logging.getLogger(__name__)
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
def _format_ticket_reference(ticket_id: str) -> str:
|
| 11 |
+
clean_id = str(ticket_id or "").strip()
|
| 12 |
+
if clean_id.isdigit():
|
| 13 |
+
return f"#T-{int(clean_id):04d}"
|
| 14 |
+
return f"#T-{clean_id}" if clean_id else "#T-UNKNOWN"
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
def _format_breach_time(breach_time: datetime) -> str:
|
| 18 |
+
if breach_time.tzinfo is None:
|
| 19 |
+
normalized = breach_time.replace(tzinfo=timezone.utc)
|
| 20 |
+
else:
|
| 21 |
+
normalized = breach_time.astimezone(timezone.utc)
|
| 22 |
+
iso_value = normalized.isoformat().replace("+00:00", "Z")
|
| 23 |
+
human_value = normalized.strftime("%Y-%m-%d %H:%M:%S UTC")
|
| 24 |
+
return f"{iso_value} ({human_value})"
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
def _post_json(url: str, payload: dict) -> None:
|
| 28 |
+
try:
|
| 29 |
+
import requests
|
| 30 |
+
|
| 31 |
+
response = requests.post(url, json=payload, timeout=10)
|
| 32 |
+
response.raise_for_status()
|
| 33 |
+
return
|
| 34 |
+
except ImportError:
|
| 35 |
+
pass
|
| 36 |
+
except Exception as exc:
|
| 37 |
+
logger.error("Slack alert request failed: %s", exc)
|
| 38 |
+
return
|
| 39 |
+
|
| 40 |
+
try:
|
| 41 |
+
import httpx
|
| 42 |
+
|
| 43 |
+
response = httpx.post(url, json=payload, timeout=10)
|
| 44 |
+
response.raise_for_status()
|
| 45 |
+
except ImportError:
|
| 46 |
+
logger.error("Slack alert client not available: install requests or httpx")
|
| 47 |
+
except Exception as exc:
|
| 48 |
+
logger.error("Slack alert request failed: %s", exc)
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
def dispatch_slack_alert(
|
| 52 |
+
ticket_id: str,
|
| 53 |
+
subject: str,
|
| 54 |
+
category: str,
|
| 55 |
+
assignee: str,
|
| 56 |
+
breach_time: datetime,
|
| 57 |
+
) -> None:
|
| 58 |
+
webhook_url = (os.environ.get("SLACK_WEBHOOK_URL") or "").strip()
|
| 59 |
+
if not webhook_url:
|
| 60 |
+
return None
|
| 61 |
+
|
| 62 |
+
payload = {
|
| 63 |
+
"attachments": [
|
| 64 |
+
{
|
| 65 |
+
"color": "#FF0000",
|
| 66 |
+
"blocks": [
|
| 67 |
+
{
|
| 68 |
+
"type": "header",
|
| 69 |
+
"text": {
|
| 70 |
+
"type": "plain_text",
|
| 71 |
+
"text": "🚨 SLA Breach Alert",
|
| 72 |
+
"emoji": True,
|
| 73 |
+
},
|
| 74 |
+
},
|
| 75 |
+
{
|
| 76 |
+
"type": "section",
|
| 77 |
+
"fields": [
|
| 78 |
+
{
|
| 79 |
+
"type": "mrkdwn",
|
| 80 |
+
"text": f"*Ticket Reference:*\n{_format_ticket_reference(ticket_id)}",
|
| 81 |
+
},
|
| 82 |
+
{
|
| 83 |
+
"type": "mrkdwn",
|
| 84 |
+
"text": f"*Subject:*\n{subject or 'Untitled ticket'}",
|
| 85 |
+
},
|
| 86 |
+
{
|
| 87 |
+
"type": "mrkdwn",
|
| 88 |
+
"text": f"*Category:*\n{category or 'Uncategorized'}",
|
| 89 |
+
},
|
| 90 |
+
{
|
| 91 |
+
"type": "mrkdwn",
|
| 92 |
+
"text": f"*Assigned To:*\n{assignee or 'Unassigned'}",
|
| 93 |
+
},
|
| 94 |
+
{
|
| 95 |
+
"type": "mrkdwn",
|
| 96 |
+
"text": f"*Breach Time:*\n{_format_breach_time(breach_time)}",
|
| 97 |
+
},
|
| 98 |
+
],
|
| 99 |
+
},
|
| 100 |
+
{"type": "divider"},
|
| 101 |
+
{
|
| 102 |
+
"type": "context",
|
| 103 |
+
"elements": [
|
| 104 |
+
{
|
| 105 |
+
"type": "mrkdwn",
|
| 106 |
+
"text": "Automated SLA Monitor",
|
| 107 |
+
}
|
| 108 |
+
],
|
| 109 |
+
},
|
| 110 |
+
],
|
| 111 |
+
}
|
| 112 |
+
]
|
| 113 |
+
}
|
| 114 |
+
|
| 115 |
+
_post_json(webhook_url, payload)
|
| 116 |
+
|
| 117 |
+
return None
|
| 118 |
"""
|
| 119 |
SLA Background Checker — Periodic worker that evaluates ticket SLAs
|
| 120 |
and dispatches multi-channel escalation notifications.
|
supabase/migrations/20260522080000_add_sla_escalation.sql
CHANGED
|
@@ -43,7 +43,7 @@ CREATE POLICY "Service role full audit access" ON audit_logs
|
|
| 43 |
CREATE POLICY "Admins can view own company audit logs" ON audit_logs
|
| 44 |
FOR SELECT USING (
|
| 45 |
company_id IN (
|
| 46 |
-
SELECT company_id FROM
|
| 47 |
)
|
| 48 |
);
|
| 49 |
|
|
|
|
| 43 |
CREATE POLICY "Admins can view own company audit logs" ON audit_logs
|
| 44 |
FOR SELECT USING (
|
| 45 |
company_id IN (
|
| 46 |
+
SELECT company_id FROM profiles WHERE id = auth.uid()
|
| 47 |
)
|
| 48 |
);
|
| 49 |
|
supabase/migrations/20260525000000_add_sla_policies_and_escalation_logs_rls.sql
ADDED
|
@@ -0,0 +1,150 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
-- Company-scoped SLA policy and escalation history tables.
|
| 2 |
+
-- Standard authenticated users can only read their own company records.
|
| 3 |
+
-- Company admins can create, update, and delete records for their company.
|
| 4 |
+
|
| 5 |
+
CREATE TABLE IF NOT EXISTS sla_policies (
|
| 6 |
+
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
| 7 |
+
company_id uuid NOT NULL REFERENCES companies(id) ON DELETE CASCADE,
|
| 8 |
+
name text NOT NULL,
|
| 9 |
+
description text,
|
| 10 |
+
policy_rules jsonb NOT NULL DEFAULT '{}'::jsonb,
|
| 11 |
+
is_active boolean NOT NULL DEFAULT true,
|
| 12 |
+
created_by uuid REFERENCES profiles(id) ON DELETE SET NULL,
|
| 13 |
+
updated_by uuid REFERENCES profiles(id) ON DELETE SET NULL,
|
| 14 |
+
created_at timestamptz NOT NULL DEFAULT now(),
|
| 15 |
+
updated_at timestamptz NOT NULL DEFAULT now(),
|
| 16 |
+
CONSTRAINT sla_policies_company_name_key UNIQUE (company_id, name)
|
| 17 |
+
);
|
| 18 |
+
|
| 19 |
+
CREATE TABLE IF NOT EXISTS escalation_logs (
|
| 20 |
+
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
| 21 |
+
company_id uuid NOT NULL REFERENCES companies(id) ON DELETE CASCADE,
|
| 22 |
+
ticket_id text,
|
| 23 |
+
sla_policy_id uuid REFERENCES sla_policies(id) ON DELETE SET NULL,
|
| 24 |
+
escalation_level integer NOT NULL DEFAULT 0,
|
| 25 |
+
event_type text NOT NULL,
|
| 26 |
+
message text NOT NULL,
|
| 27 |
+
metadata jsonb NOT NULL DEFAULT '{}'::jsonb,
|
| 28 |
+
created_by uuid REFERENCES profiles(id) ON DELETE SET NULL,
|
| 29 |
+
created_at timestamptz NOT NULL DEFAULT now()
|
| 30 |
+
);
|
| 31 |
+
|
| 32 |
+
CREATE INDEX IF NOT EXISTS idx_sla_policies_company_id ON sla_policies(company_id);
|
| 33 |
+
CREATE INDEX IF NOT EXISTS idx_sla_policies_company_active ON sla_policies(company_id, is_active);
|
| 34 |
+
CREATE INDEX IF NOT EXISTS idx_escalation_logs_company_created_at ON escalation_logs(company_id, created_at DESC);
|
| 35 |
+
CREATE INDEX IF NOT EXISTS idx_escalation_logs_ticket_id ON escalation_logs(ticket_id);
|
| 36 |
+
CREATE INDEX IF NOT EXISTS idx_escalation_logs_sla_policy_id ON escalation_logs(sla_policy_id);
|
| 37 |
+
|
| 38 |
+
ALTER TABLE sla_policies ENABLE ROW LEVEL SECURITY;
|
| 39 |
+
ALTER TABLE escalation_logs ENABLE ROW LEVEL SECURITY;
|
| 40 |
+
|
| 41 |
+
CREATE POLICY "Company members can view SLA policies" ON sla_policies
|
| 42 |
+
FOR SELECT TO authenticated
|
| 43 |
+
USING (
|
| 44 |
+
company_id IN (
|
| 45 |
+
SELECT company_id FROM profiles WHERE id = auth.uid()
|
| 46 |
+
)
|
| 47 |
+
);
|
| 48 |
+
|
| 49 |
+
CREATE POLICY "Company admins can create SLA policies" ON sla_policies
|
| 50 |
+
FOR INSERT TO authenticated
|
| 51 |
+
WITH CHECK (
|
| 52 |
+
EXISTS (
|
| 53 |
+
SELECT 1
|
| 54 |
+
FROM profiles p
|
| 55 |
+
WHERE p.id = auth.uid()
|
| 56 |
+
AND p.company_id = company_id
|
| 57 |
+
AND p.role = 'admin'
|
| 58 |
+
)
|
| 59 |
+
);
|
| 60 |
+
|
| 61 |
+
CREATE POLICY "Company admins can update SLA policies" ON sla_policies
|
| 62 |
+
FOR UPDATE TO authenticated
|
| 63 |
+
USING (
|
| 64 |
+
EXISTS (
|
| 65 |
+
SELECT 1
|
| 66 |
+
FROM profiles p
|
| 67 |
+
WHERE p.id = auth.uid()
|
| 68 |
+
AND p.company_id = company_id
|
| 69 |
+
AND p.role = 'admin'
|
| 70 |
+
)
|
| 71 |
+
)
|
| 72 |
+
WITH CHECK (
|
| 73 |
+
EXISTS (
|
| 74 |
+
SELECT 1
|
| 75 |
+
FROM profiles p
|
| 76 |
+
WHERE p.id = auth.uid()
|
| 77 |
+
AND p.company_id = company_id
|
| 78 |
+
AND p.role = 'admin'
|
| 79 |
+
)
|
| 80 |
+
);
|
| 81 |
+
|
| 82 |
+
CREATE POLICY "Company admins can delete SLA policies" ON sla_policies
|
| 83 |
+
FOR DELETE TO authenticated
|
| 84 |
+
USING (
|
| 85 |
+
EXISTS (
|
| 86 |
+
SELECT 1
|
| 87 |
+
FROM profiles p
|
| 88 |
+
WHERE p.id = auth.uid()
|
| 89 |
+
AND p.company_id = company_id
|
| 90 |
+
AND p.role = 'admin'
|
| 91 |
+
)
|
| 92 |
+
);
|
| 93 |
+
|
| 94 |
+
CREATE POLICY "Company members can view escalation logs" ON escalation_logs
|
| 95 |
+
FOR SELECT TO authenticated
|
| 96 |
+
USING (
|
| 97 |
+
company_id IN (
|
| 98 |
+
SELECT company_id FROM profiles WHERE id = auth.uid()
|
| 99 |
+
)
|
| 100 |
+
);
|
| 101 |
+
|
| 102 |
+
CREATE POLICY "Company admins can create escalation logs" ON escalation_logs
|
| 103 |
+
FOR INSERT TO authenticated
|
| 104 |
+
WITH CHECK (
|
| 105 |
+
EXISTS (
|
| 106 |
+
SELECT 1
|
| 107 |
+
FROM profiles p
|
| 108 |
+
WHERE p.id = auth.uid()
|
| 109 |
+
AND p.company_id = company_id
|
| 110 |
+
AND p.role = 'admin'
|
| 111 |
+
)
|
| 112 |
+
);
|
| 113 |
+
|
| 114 |
+
CREATE POLICY "Company admins can update escalation logs" ON escalation_logs
|
| 115 |
+
FOR UPDATE TO authenticated
|
| 116 |
+
USING (
|
| 117 |
+
EXISTS (
|
| 118 |
+
SELECT 1
|
| 119 |
+
FROM profiles p
|
| 120 |
+
WHERE p.id = auth.uid()
|
| 121 |
+
AND p.company_id = company_id
|
| 122 |
+
AND p.role = 'admin'
|
| 123 |
+
)
|
| 124 |
+
)
|
| 125 |
+
WITH CHECK (
|
| 126 |
+
EXISTS (
|
| 127 |
+
SELECT 1
|
| 128 |
+
FROM profiles p
|
| 129 |
+
WHERE p.id = auth.uid()
|
| 130 |
+
AND p.company_id = company_id
|
| 131 |
+
AND p.role = 'admin'
|
| 132 |
+
)
|
| 133 |
+
);
|
| 134 |
+
|
| 135 |
+
CREATE POLICY "Company admins can delete escalation logs" ON escalation_logs
|
| 136 |
+
FOR DELETE TO authenticated
|
| 137 |
+
USING (
|
| 138 |
+
EXISTS (
|
| 139 |
+
SELECT 1
|
| 140 |
+
FROM profiles p
|
| 141 |
+
WHERE p.id = auth.uid()
|
| 142 |
+
AND p.company_id = company_id
|
| 143 |
+
AND p.role = 'admin'
|
| 144 |
+
)
|
| 145 |
+
);
|
| 146 |
+
|
| 147 |
+
GRANT SELECT, INSERT, UPDATE, DELETE ON sla_policies TO authenticated;
|
| 148 |
+
GRANT SELECT, INSERT, UPDATE, DELETE ON escalation_logs TO authenticated;
|
| 149 |
+
GRANT ALL ON sla_policies TO service_role;
|
| 150 |
+
GRANT ALL ON escalation_logs TO service_role;
|
supabase/migrations/20260531_add_company_settings.sql
CHANGED
|
@@ -1,3 +1,22 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
-- Create system_settings table for storing per-company system configuration
|
| 2 |
|
| 3 |
-- NOTE: migration filename contains 'add_company_settings'. The migration now creates
|
|
@@ -20,15 +39,18 @@ CREATE TABLE IF NOT EXISTS system_settings (
|
|
| 20 |
-- Enable Row Level Security
|
| 21 |
ALTER TABLE system_settings ENABLE ROW LEVEL SECURITY;
|
| 22 |
|
| 23 |
-
-- RLS Policy
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
CREATE POLICY "Users can view own company settings" ON system_settings
|
| 29 |
-
FOR SELECT USING (
|
| 30 |
company_id IN (
|
| 31 |
-
SELECT company_id FROM
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 32 |
)
|
| 33 |
);
|
| 34 |
|
|
@@ -76,14 +98,22 @@ END;
|
|
| 76 |
$$ LANGUAGE plpgsql;
|
| 77 |
|
| 78 |
-- Trigger to auto-update updated_at on modification
|
| 79 |
-
CREATE
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 80 |
BEFORE UPDATE ON system_settings
|
| 81 |
FOR EACH ROW
|
| 82 |
-
EXECUTE FUNCTION
|
| 83 |
|
| 84 |
-
--
|
| 85 |
-
CREATE INDEX idx_system_settings_company_id ON system_settings(company_id);
|
| 86 |
|
| 87 |
-
-- Grant
|
| 88 |
GRANT SELECT, INSERT, UPDATE ON system_settings TO authenticated;
|
| 89 |
GRANT ALL ON system_settings TO service_role;
|
|
|
|
| 1 |
+
-- system_settings table: per-company AI and SLA configuration
|
| 2 |
+
-- Corrected migration: replaces the previous broken version
|
| 3 |
+
-- Fixes: uses profiles table for RLS (not user_companies), adds updated_at column, safe policy creation
|
| 4 |
+
|
| 5 |
+
-- Drop old table if it exists (fresh create with correct schema)
|
| 6 |
+
DROP TABLE IF EXISTS system_settings CASCADE;
|
| 7 |
+
|
| 8 |
+
-- Create the table
|
| 9 |
+
CREATE TABLE system_settings (
|
| 10 |
+
company_id UUID UNIQUE NOT NULL REFERENCES companies(id) ON DELETE CASCADE,
|
| 11 |
+
ai_confidence_threshold FLOAT NOT NULL DEFAULT 0.80,
|
| 12 |
+
duplicate_sensitivity FLOAT NOT NULL DEFAULT 0.85,
|
| 13 |
+
enable_auto_resolve BOOLEAN NOT NULL DEFAULT FALSE,
|
| 14 |
+
auto_close_enabled BOOLEAN NOT NULL DEFAULT TRUE,
|
| 15 |
+
auto_close_days INTEGER NOT NULL DEFAULT 7,
|
| 16 |
+
email_notifications BOOLEAN NOT NULL DEFAULT TRUE,
|
| 17 |
+
admin_alerts BOOLEAN NOT NULL DEFAULT TRUE,
|
| 18 |
+
digest_frequency TEXT NOT NULL DEFAULT 'daily' CHECK (digest_frequency IN ('daily', 'weekly')),
|
| 19 |
+
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
| 20 |
-- Create system_settings table for storing per-company system configuration
|
| 21 |
|
| 22 |
-- NOTE: migration filename contains 'add_company_settings'. The migration now creates
|
|
|
|
| 39 |
-- Enable Row Level Security
|
| 40 |
ALTER TABLE system_settings ENABLE ROW LEVEL SECURITY;
|
| 41 |
|
| 42 |
+
-- RLS Policy: Admins/agents in the same company can read and write their settings
|
| 43 |
+
-- Uses profiles table (not user_companies which doesn't exist)
|
| 44 |
+
CREATE POLICY "Company members can manage own settings" ON system_settings
|
| 45 |
+
FOR ALL
|
| 46 |
+
USING (
|
|
|
|
|
|
|
| 47 |
company_id IN (
|
| 48 |
+
SELECT company_id FROM profiles WHERE id = auth.uid()
|
| 49 |
+
)
|
| 50 |
+
)
|
| 51 |
+
WITH CHECK (
|
| 52 |
+
company_id IN (
|
| 53 |
+
SELECT company_id FROM profiles WHERE id = auth.uid()
|
| 54 |
)
|
| 55 |
);
|
| 56 |
|
|
|
|
| 98 |
$$ LANGUAGE plpgsql;
|
| 99 |
|
| 100 |
-- Trigger to auto-update updated_at on modification
|
| 101 |
+
CREATE OR REPLACE FUNCTION update_system_settings_timestamp()
|
| 102 |
+
RETURNS TRIGGER LANGUAGE plpgsql AS $$
|
| 103 |
+
BEGIN
|
| 104 |
+
NEW.updated_at = NOW();
|
| 105 |
+
RETURN NEW;
|
| 106 |
+
END;
|
| 107 |
+
$$;
|
| 108 |
+
|
| 109 |
+
CREATE TRIGGER trigger_system_settings_updated_at
|
| 110 |
BEFORE UPDATE ON system_settings
|
| 111 |
FOR EACH ROW
|
| 112 |
+
EXECUTE FUNCTION update_system_settings_timestamp();
|
| 113 |
|
| 114 |
+
-- Index for fast lookups
|
| 115 |
+
CREATE INDEX IF NOT EXISTS idx_system_settings_company_id ON system_settings(company_id);
|
| 116 |
|
| 117 |
+
-- Grant permissions
|
| 118 |
GRANT SELECT, INSERT, UPDATE ON system_settings TO authenticated;
|
| 119 |
GRANT ALL ON system_settings TO service_role;
|