ritesh19180 commited on
Commit
2b6d06f
·
verified ·
1 Parent(s): fa9f0c5

Upload folder using huggingface_hub

Browse files
.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 { Send, User, ShieldCheck, Bot, MessageSquare, Circle, Loader2 } from 'lucide-react';
 
 
 
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
- {msg.message}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- <form onSubmit={handleSend} className="flex gap-3">
362
- <input
363
- ref={inputRef}
364
- type="text"
365
- value={inputValue}
366
- onChange={handleInputChange}
367
- placeholder="Type your message..."
368
- style={{ flex: 1, background: '#f9fafb', border: '1.5px solid #e5e7eb', borderRadius: '12px', padding: '10px 16px', fontSize: '13px', outline: 'none' }}
369
- className="focus:border-emerald-500 transition-colors"
370
- />
371
- <button
372
- type="submit"
373
- disabled={!inputValue.trim() || !user}
374
- style={{ padding: '10px 20px', background: '#16a34a', color: '#ffffff', borderRadius: '10px', fontWeight: 600, fontSize: '12px', border: 'none', cursor: 'pointer', display: 'flex', alignItems: 'center', gap: '8px' }}
375
- className="active:scale-95 transition-transform disabled:opacity-50"
376
- >
377
- {isInternal ? <ShieldCheck size={14} /> : <Send size={14} />}
378
- <span className="hidden sm:inline">{isInternal ? 'Note' : 'Send'}</span>
379
- </button>
380
- </form>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- // We keep track of processed payload timestamps to avoid duplicate real-time notifications
7
- const processedPayloads = new Set();
8
-
9
- const useRealtimeNotifications = () => {
10
  const { user, profile } = useAuthStore();
11
- const { addNotification } = useTicketStore();
12
 
13
  useEffect(() => {
14
  if (!user || !profile) return;
15
 
16
- const handleTicketChange = (payload) => {
17
- const { eventType, new: newRecord, old: oldRecord } = payload;
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
- // Use same deduplication logic
84
- const commitTs = payload.commit_timestamp;
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
- { event: '*', schema: 'public', table: 'tickets' },
126
- handleTicketChange
127
- )
128
- .subscribe();
129
-
130
- const messageChannel = supabase
131
- .channel('message-notifications')
132
- .on(
133
- 'postgres_changes',
134
- { event: 'INSERT', schema: 'public', table: 'ticket_messages' },
135
- handleMessageChange
 
 
 
 
 
 
 
 
 
 
136
  )
137
  .subscribe();
138
 
139
  return () => {
140
- supabase.removeChannel(ticketChannel);
141
- supabase.removeChannel(messageChannel);
142
  };
143
- }, [user, profile, addNotification]);
144
  };
145
 
146
- export default useRealtimeNotifications;
 
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
- Bar,
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, AlertCircle } from "lucide-react";
 
 
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
- setTickets(data);
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">Global helpdesk status and AI performance across all channels</p>
 
 
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">No ticket data available yet. Submit your first ticket to see analytics.</p>
 
 
104
  </Card>
105
  ) : (
106
  <>
107
- {/* Summary Statistics Cards */}
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
- data={statusData}
193
- cx="50%"
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
- {/* Additional Insights */}
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 flex items-center gap-2">
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 flex items-center gap-2">
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 text-center">High Confidence</div>
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 text-center">Potential Dupes</div>
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
- // Identify user in LogRocket
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
- setUserStatus(data?.status || 'active');
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
105
  }
106
-
107
- const onboardingDone = await AsyncStorage.getItem('@onboarding_complete');
108
- setShowOnboarding(onboardingDone === null);
109
  } catch (e) {
110
- console.log('Init error', e);
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
- // Identify user in LogRocket on auth change
122
- LogRocket.identify(session.user.id, {
123
- email: session.user.email,
124
- name: session.user.user_metadata?.full_name || 'User',
125
- });
126
-
127
- // Fetch profile status for routing
128
- const { data } = await supabase
129
- .from('profiles')
130
- .select('status')
131
- .eq('id', session.user.id)
132
- .single();
133
- setUserStatus(data?.status || 'active');
 
 
 
 
 
 
 
 
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
- <Stack.Screen name="MainTabs" component={TabNavigator} />
 
 
 
 
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
- <Text style={styles.footerText}>Don't have an account? </Text>
238
- <TouchableOpacity onPress={() => navigation.navigate('Signup')}>
239
- <Text style={styles.footerLink}>Create Account</Text>
 
 
 
 
 
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: "Neural Classification", icon: "⚡" },
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
- : (err.message || 'AI engine is currently busy. Please try again.'));
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}>Neural Processing</Text>
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: '#0f172a',
472
  borderWidth: 1,
473
- borderColor: 'rgba(99,102,241,0.45)',
474
  borderRadius: 18,
475
  paddingVertical: 12,
476
  paddingHorizontal: 16,
477
  marginHorizontal: 20,
478
  marginBottom: 16,
479
- shadowColor: '#6366f1',
480
- shadowOpacity: 0.35,
481
- shadowRadius: 14,
482
- elevation: 10,
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: '#6366f1',
489
  justifyContent: 'center', alignItems: 'center',
490
  },
491
- updateTitle: { fontSize: 13, fontWeight: '800', color: '#e0e7ff' },
492
- updateSubtitle: { fontSize: 11, color: 'rgba(224,231,255,0.55)', fontWeight: '500' },
493
  updateActions: { flexDirection: 'row', alignItems: 'center', gap: 10, flexShrink: 0 },
494
  updateBtn: {
495
- backgroundColor: '#6366f1',
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
- Linking.openURL(video.url).catch(err => {
124
- Alert.alert("Error", "Could not open YouTube link on your device.");
125
- });
126
  };
127
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
128
  // Filter video guides locally in real-time
129
  const getFilteredVideos = () => {
130
- let filtered = YOUTUBE_RESOURCES;
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
- Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium);
227
- await supabase.auth.signOut();
 
 
 
 
 
 
 
 
 
 
 
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
- View,
5
- Text,
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 { ArrowLeft, Send, User, Bot } from 'lucide-react-native';
17
- import { useNavigation } from '@react-navigation/native';
 
 
 
 
 
 
 
 
 
 
 
 
 
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 navigation = useNavigation();
26
- const flatListRef = useRef(null);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
27
 
 
 
 
28
  useEffect(() => {
29
- fetchTicketDetails();
30
- fetchMessages();
31
-
32
- // Set up real-time subscription for messages
33
- const channel = supabase
34
- .channel(`ticket_messages:${ticketId}`)
35
- .on('postgres_changes', {
36
- event: 'INSERT',
37
- schema: 'public',
38
- table: 'ticket_messages',
39
- filter: `ticket_id=eq.${ticketId}`
40
- }, (payload) => {
41
- setMessages(prev => [...prev, payload.new]);
42
- })
43
- .subscribe();
44
-
45
- return () => {
46
- supabase.removeChannel(channel);
47
- };
48
- }, [ticketId]);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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) setTicket(data);
 
 
 
 
 
 
 
 
 
 
58
  };
59
 
60
  const fetchMessages = async () => {
@@ -74,11 +173,12 @@ const TicketDetailScreen = ({ route }) => {
74
  }
75
  };
76
 
77
- const sendMessage = async () => {
78
- if (!newMessage.trim()) return;
 
79
 
80
- const userMessage = newMessage;
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: userMessage,
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
- styles.messageBubble,
110
- isUser ? styles.userBubble : styles.adminBubble,
111
- isAI && styles.aiBubble
112
- ]}>
113
- {!isUser && (
114
- <View style={styles.senderHeader}>
115
- {isAI ? <Bot size={14} color={COLORS.primary} /> : <User size={14} color={COLORS.textLight} />}
116
- <Text style={styles.senderName}>{isAI ? 'AI Assistant' : item.sender_name || 'Support'}</Text>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- <View style={styles.headerContent}>
136
- <Text style={styles.headerTitle} numberOfLines={1}>
137
- {ticket?.subject || 'Ticket Details'}
138
- </Text>
139
- <Text style={styles.headerSubtitle}>#{ticketId?.slice(0, 8).toUpperCase()}</Text>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
140
  </View>
141
  </View>
142
 
143
- <KeyboardAvoidingView
144
- behavior={Platform.OS === 'ios' ? 'padding' : undefined}
145
- style={{ flex: 1 }}
146
- keyboardVerticalOffset={Platform.OS === 'ios' ? 0 : 0}
147
- >
148
- {loading ? (
149
- <View style={styles.loadingContainer}>
150
- <ActivityIndicator size="large" color={COLORS.primary} />
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
151
  </View>
152
- ) : (
153
- <FlatList
154
- ref={flatListRef}
155
- data={messages}
156
- keyExtractor={(item) => item.id}
157
- renderItem={renderMessage}
158
- contentContainerStyle={styles.messagesList}
159
- onContentSizeChange={() => flatListRef.current?.scrollToEnd({ animated: true })}
160
- ListEmptyComponent={
161
- <View style={styles.emptyContainer}>
162
- <Text style={styles.emptyText}>No messages yet. Send a message to start the conversation.</Text>
 
 
 
 
 
 
 
 
 
 
163
  </View>
164
- }
165
- />
166
- )}
 
167
 
168
- <View style={styles.inputContainer}>
169
- <TextInput
170
- style={styles.input}
171
- placeholder="Type a message..."
172
- value={newMessage}
173
- onChangeText={setNewMessage}
174
- multiline
175
- />
176
- <TouchableOpacity
177
- style={[styles.sendBtn, !newMessage.trim() && styles.sendBtnDisabled]}
178
- onPress={sendMessage}
179
- disabled={!newMessage.trim()}
180
- >
181
- <Send size={20} color={COLORS.white} />
182
- </TouchableOpacity>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
183
  </View>
184
- </KeyboardAvoidingView>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
185
  </SafeAreaView>
186
  );
187
  };
188
 
189
  const styles = StyleSheet.create({
190
- container: { flex: 1, backgroundColor: COLORS.background },
 
 
191
  header: {
192
  flexDirection: 'row',
193
  alignItems: 'center',
194
- paddingHorizontal: 16,
195
- paddingVertical: 12,
196
- backgroundColor: COLORS.white,
197
  borderBottomWidth: 1,
198
- borderBottomColor: COLORS.border,
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
199
  ...SHADOWS.soft
200
  },
201
- backBtn: { padding: 8, marginRight: 8 },
202
- headerContent: { flex: 1 },
203
- headerTitle: { fontSize: 18, fontWeight: '800', color: COLORS.text },
204
- headerSubtitle: { fontSize: 12, color: COLORS.textMuted, fontWeight: '600' },
 
 
 
 
 
 
 
 
 
 
205
  loadingContainer: { flex: 1, justifyContent: 'center', alignItems: 'center' },
206
- messagesList: { padding: 16, paddingBottom: 32 },
 
 
 
 
 
 
 
 
 
 
207
  messageBubble: {
208
- maxWidth: '85%',
209
- padding: 12,
210
- borderRadius: 20,
211
- marginBottom: 16,
 
 
212
  ...SHADOWS.soft
213
  },
 
 
 
 
214
  userBubble: {
215
  alignSelf: 'flex-end',
216
- backgroundColor: COLORS.primary,
217
- borderBottomRightRadius: 4
218
  },
219
  adminBubble: {
220
  alignSelf: 'flex-start',
221
- backgroundColor: COLORS.white,
222
- borderBottomLeftRadius: 4,
223
  borderWidth: 1,
224
- borderColor: 'rgba(0,0,0,0.05)'
225
  },
226
  aiBubble: {
227
- borderColor: COLORS.primaryLight,
228
- borderWidth: 1,
229
- backgroundColor: COLORS.primaryLight + '20'
230
  },
231
  senderHeader: { flexDirection: 'row', alignItems: 'center', marginBottom: 4, gap: 4 },
232
- senderName: { fontSize: 10, fontWeight: '700', color: COLORS.textMuted },
233
- messageText: { fontSize: 15, color: COLORS.text, lineHeight: 20 },
234
- userMessageText: { color: COLORS.white },
235
- messageTime: { fontSize: 10, color: COLORS.textMuted, marginTop: 4, alignSelf: 'flex-end' },
236
- userTimeText: { color: 'rgba(255,255,255,0.7)' },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
237
  inputContainer: {
238
  flexDirection: 'row',
239
  alignItems: 'center',
240
- padding: 12,
241
- backgroundColor: COLORS.white,
242
- borderTopWidth: 1,
243
- borderTopColor: COLORS.border,
244
- paddingBottom: Platform.OS === 'ios' ? 30 : 12
 
 
 
 
 
 
 
 
 
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
- marginRight: 8
 
 
 
255
  },
256
- sendBtn: {
257
- width: 44,
258
- height: 44,
259
- borderRadius: 22,
260
  backgroundColor: COLORS.primary,
261
  justifyContent: 'center',
262
  alignItems: 'center',
263
- ...SHADOWS.medium
 
264
  },
265
- sendBtnDisabled: { backgroundColor: COLORS.textMuted, elevation: 0 },
266
  emptyContainer: { flex: 1, justifyContent: 'center', alignItems: 'center', marginTop: 100 },
267
- emptyText: { textAlign: 'center', color: COLORS.textMuted, fontSize: 14 }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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(self, ticket: dict[str, Any], now: datetime) -> None:
 
 
 
 
 
 
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 user_companies WHERE user_id = auth.uid()
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 1: Service role (backend) has full access
24
- CREATE POLICY "Service role full access" ON system_settings
25
- FOR ALL USING (auth.role() = 'service_role');
26
-
27
- -- RLS Policy 2: Users can view settings for their company
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 user_companies WHERE user_id = auth.uid()
 
 
 
 
 
32
  )
33
  );
34
 
@@ -76,14 +98,22 @@ END;
76
  $$ LANGUAGE plpgsql;
77
 
78
  -- Trigger to auto-update updated_at on modification
79
- CREATE TRIGGER update_system_settings_timestamp
 
 
 
 
 
 
 
 
80
  BEFORE UPDATE ON system_settings
81
  FOR EACH ROW
82
- EXECUTE FUNCTION update_timestamp();
83
 
84
- -- Create index on company_id for fast lookups
85
- CREATE INDEX idx_system_settings_company_id ON system_settings(company_id);
86
 
87
- -- Grant appropriate permissions
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;