@visns-studio/visns-components 5.15.7 → 5.15.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -3,14 +3,15 @@ import mapboxgl from 'mapbox-gl';
3
3
  import MapboxGeocoder from '@mapbox/mapbox-gl-geocoder';
4
4
  import html2canvas from 'html2canvas';
5
5
  import Swal from 'sweetalert2';
6
+ import { pdf } from '@react-pdf/renderer';
7
+ import CameraReportPDF from './utils/CameraReportPDF';
8
+ import SimpleCameraReportPDF from './utils/SimpleCameraReportPDF';
6
9
  import {
7
10
  Edit3,
8
11
  Trash2,
9
12
  Upload,
10
13
  Save,
11
14
  FolderOpen,
12
- Eye,
13
- EyeOff,
14
15
  Download,
15
16
  FileImage,
16
17
  Lightbulb,
@@ -30,14 +31,18 @@ import {
30
31
  } from './data/verkadaCameras';
31
32
  import CameraIcon from './utils/CameraIcon';
32
33
 
33
- const CameraPlacement = () => {
34
+ const CameraPlacement = ({ userProfile }) => {
34
35
  const [cameras, setCameras] = useState([]);
35
36
  const [selectedCamera, setSelectedCamera] = useState(null);
36
37
  const [showModal, setShowModal] = useState(false);
37
38
  const [map, setMap] = useState(null);
38
- const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
39
39
  const [mode, setMode] = useState('image'); // 'image' or 'map' - default to image
40
40
  const [uploadedImage, setUploadedImage] = useState(null);
41
+ const [projectName, setProjectName] = useState('');
42
+ const [sidebarTab, setSidebarTab] = useState('cameras'); // 'cameras' or 'projects'
43
+ const [savedProjects, setSavedProjects] = useState([]);
44
+ const [selectedProject, setSelectedProject] = useState(null);
45
+ const [loadingProjects, setLoadingProjects] = useState(false);
41
46
  const mapContainer = useRef(null);
42
47
  const mapRef = useRef(null);
43
48
  const geocoderRef = useRef(null);
@@ -46,6 +51,7 @@ const CameraPlacement = () => {
46
51
  const [isDragOver, setIsDragOver] = useState(false);
47
52
  const [isRotating, setIsRotating] = useState(false);
48
53
 
54
+
49
55
  // Mapbox configuration
50
56
  const MAPBOX_ACCESS_TOKEN =
51
57
  'pk.eyJ1IjoidmlzbnMtc3R1ZGlvIiwiYSI6ImNrbGlraW5vZDBhMDYycHBsdmk5b3ljbGQifQ.ZGUBk0PWac5GahUeVHdTpg';
@@ -66,7 +72,6 @@ const CameraPlacement = () => {
66
72
  try {
67
73
  map.remove();
68
74
  } catch (error) {
69
- console.warn('Error removing map:', error);
70
75
  }
71
76
  setMap(null);
72
77
  }
@@ -163,7 +168,6 @@ const CameraPlacement = () => {
163
168
  try {
164
169
  map.remove();
165
170
  } catch (error) {
166
- console.warn('Error removing map:', error);
167
171
  }
168
172
  setMap(null);
169
173
  }
@@ -332,8 +336,43 @@ const CameraPlacement = () => {
332
336
  [map, cameras]
333
337
  );
334
338
 
335
- // Save placement as JSON file
336
- const saveAsFile = useCallback(() => {
339
+ // Fetch saved projects (lightweight - no heavy JSON fields)
340
+ const fetchSavedProjects = useCallback(async () => {
341
+ if (!userProfile?.id) return;
342
+
343
+ setLoadingProjects(true);
344
+ try {
345
+ const response = await fetch('/ajax/cameraPlacement/table', {
346
+ method: 'POST',
347
+ headers: {
348
+ 'Content-Type': 'application/json',
349
+ 'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]')?.content || '',
350
+ },
351
+ body: JSON.stringify({
352
+ page: 1,
353
+ per_page: 100,
354
+ order_by: 'created_at',
355
+ order: 'desc'
356
+ }),
357
+ });
358
+
359
+ const responseText = await response.text();
360
+ const result = JSON.parse(responseText);
361
+
362
+ if (result.data && Array.isArray(result.data)) {
363
+ setSavedProjects(result.data);
364
+ } else if (result.data && Array.isArray(result.data.data)) {
365
+ setSavedProjects(result.data.data);
366
+ }
367
+ } catch (error) {
368
+ } finally {
369
+ setLoadingProjects(false);
370
+ }
371
+ }, [userProfile]);
372
+
373
+ // Save placement to database
374
+ const saveToDatabase = useCallback(async () => {
375
+
337
376
  if (!cameras.length && !uploadedImage) {
338
377
  Swal.fire({
339
378
  icon: 'warning',
@@ -344,488 +383,520 @@ const CameraPlacement = () => {
344
383
  return;
345
384
  }
346
385
 
347
- const saveData = {
348
- version: '1.0',
349
- timestamp: new Date().toISOString(),
350
- image: uploadedImage
351
- ? {
352
- src: uploadedImage,
353
- name: 'uploaded-image',
354
- }
355
- : null,
356
- mode: mode,
357
- cameras: cameras.map((camera) => ({
358
- id: camera.id,
359
- name: camera.name,
360
- customName: camera.customName,
361
- x: camera.x,
362
- y: camera.y,
363
- direction: camera.direction,
364
- verkadaModel: camera.verkadaModel,
365
- timestamp: camera.timestamp,
366
- })),
367
- };
386
+ // Enhanced camera data validation
387
+ if (cameras.length === 0) {
388
+ Swal.fire({
389
+ icon: 'warning',
390
+ title: 'No Cameras to Save',
391
+ text: 'Please place at least one camera before saving. Click on the image to add cameras.',
392
+ confirmButtonColor: '#f59e0b',
393
+ });
394
+ return;
395
+ }
368
396
 
369
- const dataStr = JSON.stringify(saveData, null, 2);
370
- const dataUri =
371
- 'data:application/json;charset=utf-8,' +
372
- encodeURIComponent(dataStr);
373
-
374
- const exportFileDefaultName = `camera-placement-${
375
- new Date().toISOString().split('T')[0]
376
- }.json`;
377
-
378
- const linkElement = document.createElement('a');
379
- linkElement.setAttribute('href', dataUri);
380
- linkElement.setAttribute('download', exportFileDefaultName);
381
- linkElement.click();
382
-
383
- // Show success message
384
- Swal.fire({
385
- icon: 'success',
386
- title: 'File Saved!',
387
- text: `Camera placement saved as ${exportFileDefaultName}`,
388
- timer: 3000,
389
- showConfirmButton: false,
390
- toast: true,
391
- position: 'top-end',
397
+ // Validate each camera has required data
398
+ const invalidCameras = cameras.filter(camera => {
399
+ const issues = [];
400
+
401
+ if (!camera.id) issues.push('missing ID');
402
+ if (typeof camera.x !== 'number' || isNaN(camera.x)) issues.push('invalid X coordinate');
403
+ if (typeof camera.y !== 'number' || isNaN(camera.y)) issues.push('invalid Y coordinate');
404
+ if (!camera.name || camera.name.trim() === '') issues.push('missing name');
405
+ if (typeof camera.direction !== 'number' || isNaN(camera.direction)) issues.push('invalid direction');
406
+
407
+ return issues.length > 0;
392
408
  });
393
- }, [cameras, uploadedImage, mode]);
394
409
 
395
- // Load placement from JSON file
396
- const loadFromFile = useCallback(() => {
397
- const input = document.createElement('input');
398
- input.type = 'file';
399
- input.accept = '.json';
410
+ if (invalidCameras.length > 0) {
411
+ Swal.fire({
412
+ icon: 'error',
413
+ title: 'Invalid Camera Data',
414
+ text: `${invalidCameras.length} camera(s) have invalid data. Please check all cameras have proper positioning, names, and direction values.`,
415
+ confirmButtonColor: '#ef4444',
416
+ });
417
+ return;
418
+ }
400
419
 
401
- input.onchange = (event) => {
402
- const file = event.target.files[0];
403
- if (!file) return;
420
+ // Validate cameras_data array will not be empty
421
+ const validCamerasData = cameras.map((camera) => ({
422
+ id: camera.id,
423
+ name: camera.name,
424
+ customName: camera.customName,
425
+ x: camera.x,
426
+ y: camera.y,
427
+ direction: camera.direction,
428
+ verkadaModel: camera.verkadaModel,
429
+ timestamp: camera.timestamp,
430
+ })).filter(camera => camera.id && camera.name);
431
+
432
+ if (validCamerasData.length === 0) {
433
+ Swal.fire({
434
+ icon: 'error',
435
+ title: 'No Valid Camera Data',
436
+ text: 'All cameras seem to have missing or invalid data. Please recreate your cameras.',
437
+ confirmButtonColor: '#ef4444',
438
+ });
439
+ return;
440
+ }
441
+
442
+ if (!projectName.trim()) {
443
+ Swal.fire({
444
+ icon: 'warning',
445
+ title: 'Project Name Required',
446
+ text: 'Please enter a project name before saving.',
447
+ confirmButtonColor: '#8b5cf6',
448
+ });
449
+ return;
450
+ }
404
451
 
405
- const reader = new FileReader();
406
- reader.onload = (e) => {
407
- try {
408
- const saveData = JSON.parse(e.target.result);
452
+ // Validate user profile
453
+ if (!userProfile || !userProfile.id) {
454
+ Swal.fire({
455
+ icon: 'error',
456
+ title: 'Authentication Error',
457
+ text: 'User profile is required to save camera placements.',
458
+ confirmButtonColor: '#ef4444',
459
+ });
460
+ return;
461
+ }
409
462
 
410
- // Validate data structure
411
- if (!saveData.version || !Array.isArray(saveData.cameras)) {
412
- Swal.fire({
413
- icon: 'error',
414
- title: 'Invalid File',
415
- text: 'Please select a valid camera placement file.',
416
- confirmButtonColor: '#f59e0b',
417
- });
418
- return;
419
- }
463
+ // Validate mode
464
+ if (!mode || !['image', 'map'].includes(mode)) {
465
+ Swal.fire({
466
+ icon: 'error',
467
+ title: 'Invalid Mode',
468
+ text: 'Invalid placement mode. Please refresh the page and try again.',
469
+ confirmButtonColor: '#ef4444',
470
+ });
471
+ return;
472
+ }
420
473
 
421
- // Load image if present
422
- if (saveData.image && saveData.image.src) {
423
- setUploadedImage(saveData.image.src);
424
- }
474
+ try {
475
+ // Check if we're updating an existing project or creating a new one
476
+ const isUpdating = selectedProject && selectedProject.id;
477
+ const method = isUpdating ? 'PUT' : 'POST';
478
+ const url = isUpdating ? `/ajax/cameraPlacement/${selectedProject.id}` : '/ajax/cameraPlacement';
479
+
480
+ const saveData = {
481
+ name: projectName.trim(),
482
+ project_name: projectName.trim(),
483
+ version: '1.0',
484
+ mode: mode,
485
+ user_id: userProfile.id,
486
+ image_data: uploadedImage
487
+ ? {
488
+ src: uploadedImage,
489
+ name: 'uploaded-image',
490
+ }
491
+ : null,
492
+ image_name: uploadedImage ? 'uploaded-image' : null,
493
+ cameras_data: validCamerasData,
494
+ };
425
495
 
426
- // Set mode
427
- if (saveData.mode) {
428
- setMode(saveData.mode);
429
- }
496
+ const response = await fetch(url, {
497
+ method: method,
498
+ headers: {
499
+ 'Content-Type': 'application/json',
500
+ 'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]')?.content || '',
501
+ },
502
+ body: JSON.stringify(saveData),
503
+ });
430
504
 
431
- // Load cameras
432
- setCameras(saveData.cameras || []);
505
+ const responseText = await response.text();
433
506
 
434
- Swal.fire({
435
- icon: 'success',
436
- title: 'File Loaded!',
437
- text: `Successfully loaded ${
438
- saveData.cameras?.length || 0
439
- } cameras from ${file.name}`,
440
- confirmButtonColor: '#f59e0b',
441
- });
442
- } catch (error) {
443
- console.error('Error loading file:', error);
444
- Swal.fire({
445
- icon: 'error',
446
- title: 'File Error',
447
- text: "Error reading file. Please ensure it's a valid JSON file.",
448
- confirmButtonColor: '#f59e0b',
449
- });
507
+ let result;
508
+ try {
509
+ result = JSON.parse(responseText);
510
+ } catch (parseError) {
511
+ throw new Error(`Server returned invalid JSON. Status: ${response.status}, Response: ${responseText.substring(0, 200)}`);
512
+ }
513
+
514
+ // DynamicController returns { data: {...}, error: "" } format
515
+ if (result.data && !result.error) {
516
+ // Update the selectedProject with the latest data if we were updating
517
+ if (isUpdating) {
518
+ setSelectedProject(result.data);
450
519
  }
451
- };
452
- reader.readAsText(file);
453
- };
454
520
 
455
- input.click();
521
+ Swal.fire({
522
+ icon: 'success',
523
+ title: isUpdating ? 'Updated!' : 'Saved!',
524
+ text: `Camera placement "${projectName}" ${isUpdating ? 'updated' : 'saved'} successfully`,
525
+ timer: 3000,
526
+ showConfirmButton: false,
527
+ toast: true,
528
+ position: 'top-end',
529
+ });
530
+
531
+ // Refresh the projects list to show updated data
532
+ fetchSavedProjects();
533
+ } else {
534
+ const errorMessage = result.error || result.message || 'Save failed';
535
+ const errorDetails = result.errors ? JSON.stringify(result.errors) : '';
536
+ throw new Error(`${errorMessage}${errorDetails ? ` - Details: ${errorDetails}` : ''}`);
537
+ }
538
+ } catch (error) {
539
+ Swal.fire({
540
+ icon: 'error',
541
+ title: 'Save Failed',
542
+ text: error.message || 'Failed to save camera placement. Please try again.',
543
+ confirmButtonColor: '#ef4444',
544
+ });
545
+ }
546
+ }, [cameras, uploadedImage, mode, projectName, userProfile, selectedProject, fetchSavedProjects]);
547
+
548
+ // Fetch full project data with heavy JSON fields
549
+ const fetchFullProject = useCallback(async (projectId) => {
550
+ try {
551
+ const response = await fetch(`/ajax/cameraPlacement/${projectId}`, {
552
+ headers: {
553
+ 'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]')?.content || '',
554
+ },
555
+ });
556
+
557
+ const responseText = await response.text();
558
+ const result = JSON.parse(responseText);
559
+
560
+ if (result.id) {
561
+ // Direct object response (not wrapped in data)
562
+ return result;
563
+ } else if (result.data) {
564
+ // Wrapped in data object
565
+ return result.data;
566
+ } else {
567
+ throw new Error('Failed to fetch full project data');
568
+ }
569
+ } catch (error) {
570
+ throw error;
571
+ }
456
572
  }, []);
457
573
 
458
- const exportMap = useCallback(async () => {
574
+ // Load a specific project
575
+ const loadProject = useCallback(async (lightweightProject) => {
459
576
  try {
460
- if (!cameras.length) {
577
+ // Show loading indicator
578
+ const loadingToast = Swal.fire({
579
+ title: 'Loading Project...',
580
+ text: 'Fetching project data',
581
+ allowOutsideClick: false,
582
+ showConfirmButton: false,
583
+ didOpen: () => {
584
+ Swal.showLoading();
585
+ }
586
+ });
587
+
588
+ // Fetch full project data including heavy JSON fields
589
+ const fullProject = await fetchFullProject(lightweightProject.id);
590
+
591
+ // Close loading indicator
592
+ Swal.close();
593
+
594
+ setSelectedProject(fullProject);
595
+ setProjectName(fullProject.project_name);
596
+ setMode(fullProject.mode);
597
+
598
+ if (fullProject.image_data && fullProject.image_data.src) {
599
+ setUploadedImage(fullProject.image_data.src);
600
+ } else {
601
+ setUploadedImage(null);
602
+ }
603
+
604
+ setCameras(fullProject.cameras_data || []);
605
+ setSidebarTab('cameras'); // Switch to cameras tab
606
+
607
+ Swal.fire({
608
+ icon: 'success',
609
+ title: 'Project Loaded!',
610
+ text: `Loaded "${fullProject.project_name}" with ${fullProject.cameras_data?.length || 0} cameras`,
611
+ timer: 2000,
612
+ showConfirmButton: false,
613
+ toast: true,
614
+ position: 'top-end',
615
+ });
616
+ } catch (error) {
617
+ Swal.fire({
618
+ icon: 'error',
619
+ title: 'Load Failed',
620
+ text: 'Failed to load project. Please try again.',
621
+ confirmButtonColor: '#ef4444',
622
+ });
623
+ }
624
+ }, [fetchFullProject]);
625
+
626
+ // Fetch projects when user profile changes or sidebar switches to projects
627
+ useEffect(() => {
628
+ if (sidebarTab === 'projects' && userProfile?.id) {
629
+ fetchSavedProjects();
630
+ }
631
+ }, [sidebarTab, userProfile, fetchSavedProjects]);
632
+
633
+ // Initial fetch of projects when component mounts to get correct count
634
+ useEffect(() => {
635
+ if (userProfile?.id) {
636
+ fetchSavedProjects();
637
+ }
638
+ }, [userProfile, fetchSavedProjects]);
639
+
640
+ // Load placement from database (legacy - keeping for compatibility)
641
+ const loadFromDatabase = useCallback(async () => {
642
+ try {
643
+ const response = await fetch('/ajax/cameraPlacement/table', {
644
+ method: 'POST',
645
+ headers: {
646
+ 'Content-Type': 'application/json',
647
+ 'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]')?.content || '',
648
+ },
649
+ body: JSON.stringify({
650
+ page: 1,
651
+ per_page: 100,
652
+ order_by: 'created_at',
653
+ order: 'desc'
654
+ }),
655
+ });
656
+
657
+ const result = await response.json();
658
+
659
+ if (result.data && result.data.length > 0) {
660
+ const placements = result.data;
661
+
662
+ // Show placement selection modal
663
+ const { value: selectedPlacement } = await Swal.fire({
664
+ title: 'Load Camera Placement',
665
+ html: `
666
+ <div style="text-align: left; max-height: 400px; overflow-y: auto;">
667
+ ${placements.map(placement => `
668
+ <div style="padding: 12px; margin: 8px 0; border: 1px solid #e5e7eb; border-radius: 8px; cursor: pointer; transition: background-color 0.2s;"
669
+ onmouseover="this.style.backgroundColor='#f9fafb'"
670
+ onmouseout="this.style.backgroundColor='white'"
671
+ onclick="this.classList.toggle('selected'); document.querySelectorAll('.placement-item').forEach(el => el !== this && el.classList.remove('selected'));"
672
+ class="placement-item"
673
+ data-placement-id="${placement.id}">
674
+ <div style="font-weight: 600; color: #1f2937;">${placement.project_name}</div>
675
+ <div style="font-size: 14px; color: #6b7280; margin-top: 4px;">
676
+ ${placement.cameras_count} cameras • ${placement.mode} mode
677
+ </div>
678
+ <div style="font-size: 12px; color: #9ca3af; margin-top: 4px;">
679
+ Created: ${new Date(placement.created_at).toLocaleDateString()}
680
+ </div>
681
+ </div>
682
+ `).join('')}
683
+ </div>
684
+ <style>
685
+ .placement-item.selected {
686
+ background-color: #dbeafe !important;
687
+ border-color: #3b82f6 !important;
688
+ }
689
+ </style>
690
+ `,
691
+ showCancelButton: true,
692
+ confirmButtonText: 'Load Selected',
693
+ cancelButtonText: 'Cancel',
694
+ confirmButtonColor: '#3b82f6',
695
+ preConfirm: () => {
696
+ const selectedElement = document.querySelector('.placement-item.selected');
697
+ if (!selectedElement) {
698
+ Swal.showValidationMessage('Please select a placement to load');
699
+ return false;
700
+ }
701
+ return selectedElement.getAttribute('data-placement-id');
702
+ }
703
+ });
704
+
705
+ if (selectedPlacement) {
706
+ const placement = placements.find(p => p.id == selectedPlacement);
707
+ if (placement) {
708
+ // Load the placement data
709
+ setProjectName(placement.project_name);
710
+ setMode(placement.mode);
711
+
712
+ if (placement.image_data && placement.image_data.src) {
713
+ setUploadedImage(placement.image_data.src);
714
+ } else {
715
+ setUploadedImage(null);
716
+ }
717
+
718
+ setCameras(placement.cameras_data || []);
719
+
720
+ Swal.fire({
721
+ icon: 'success',
722
+ title: 'Loaded!',
723
+ text: `Successfully loaded "${placement.project_name}" with ${placement.cameras_count} cameras`,
724
+ timer: 3000,
725
+ showConfirmButton: false,
726
+ toast: true,
727
+ position: 'top-end',
728
+ });
729
+ }
730
+ }
731
+ } else {
461
732
  Swal.fire({
462
- icon: 'warning',
463
- title: 'No Cameras',
464
- text: 'Please add cameras first before exporting.',
465
- confirmButtonColor: '#10b981',
733
+ icon: 'info',
734
+ title: 'No Saved Placements',
735
+ text: 'You have no saved camera placements yet.',
736
+ confirmButtonColor: '#3b82f6',
466
737
  });
467
- return;
468
738
  }
739
+ } catch (error) {
740
+ Swal.fire({
741
+ icon: 'error',
742
+ title: 'Load Failed',
743
+ text: 'Failed to load camera placements. Please try again.',
744
+ confirmButtonColor: '#ef4444',
745
+ });
746
+ }
747
+ }, []);
469
748
 
470
- const imageContainer = document.querySelector(
471
- `.${styles.imageContainer}`
472
- );
473
- if (!imageContainer) {
749
+ const exportToPDF = useCallback(async () => {
750
+ try {
751
+ if (!cameras.length) {
474
752
  Swal.fire({
475
753
  icon: 'warning',
476
- title: 'No Image',
477
- text: 'Please upload an image first before exporting.',
478
- confirmButtonColor: '#10b981',
754
+ title: 'No Cameras',
755
+ text: 'Please add cameras first before exporting.',
756
+ confirmButtonColor: '#1e40af',
479
757
  });
480
758
  return;
481
759
  }
482
760
 
483
- // First, take a screenshot of just the image container to get its natural size
484
- const imageCanvas = await html2canvas(imageContainer, {
485
- useCORS: true,
486
- allowTaint: true,
487
- scale: 1.2,
488
- backgroundColor: '#f8fafc',
489
- logging: false,
761
+ // Show loading message
762
+ Swal.fire({
763
+ icon: 'info',
764
+ title: 'Generating PDF...',
765
+ text: 'Please wait while we create your professional camera installation report.',
766
+ allowOutsideClick: false,
767
+ allowEscapeKey: false,
768
+ showConfirmButton: false,
769
+ willOpen: () => {
770
+ Swal.showLoading();
771
+ },
490
772
  });
491
773
 
492
- // Calculate dimensions for professional report layout
493
- const cameraListWidth = 550; // Fixed width for professional report
494
- const headerHeight = 140; // Space for report header
495
- const summaryHeight = 120; // Space for project summary
496
- const cameraHeight = 180; // More space per camera card
497
- const footerHeight = 300; // Space for legend and notes
498
- const totalCameraListHeight =
499
- headerHeight +
500
- summaryHeight +
501
- cameras.length * cameraHeight +
502
- footerHeight;
503
-
504
- // Use the larger of image height or camera list height
505
- const finalHeight = Math.max(
506
- imageCanvas.height,
507
- totalCameraListHeight
508
- );
509
- const finalWidth = imageCanvas.width + cameraListWidth + 20; // small gap
510
-
511
- // Create the composite container with calculated dimensions
512
- const exportContainer = document.createElement('div');
513
- exportContainer.style.position = 'absolute';
514
- exportContainer.style.top = '-10000px';
515
- exportContainer.style.left = '-10000px';
516
- exportContainer.style.width = finalWidth + 'px';
517
- exportContainer.style.height = finalHeight + 'px';
518
- exportContainer.style.backgroundColor = '#ffffff';
519
- exportContainer.style.display = 'flex';
520
- exportContainer.style.gap = '10px';
521
- exportContainer.style.fontFamily =
522
- '-apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Oxygen", "Ubuntu", "Cantarell", sans-serif';
523
- document.body.appendChild(exportContainer);
524
-
525
- // Left side: Image
526
- const imageDiv = document.createElement('div');
527
- imageDiv.style.width = imageCanvas.width + 'px';
528
- imageDiv.style.height = finalHeight + 'px';
529
- imageDiv.style.background = '#f8fafc';
530
- imageDiv.style.display = 'flex';
531
- imageDiv.style.alignItems = 'center';
532
- imageDiv.style.justifyContent = 'center';
533
- imageDiv.style.borderRight = '2px solid #e5e7eb';
534
- imageDiv.style.flexShrink = '0';
535
-
536
- // Scale the canvas to fit if needed
537
- const scaledCanvas = document.createElement('canvas');
538
- const ctx = scaledCanvas.getContext('2d');
539
- scaledCanvas.width = imageCanvas.width;
540
- scaledCanvas.height = imageCanvas.height;
541
- ctx.drawImage(imageCanvas, 0, 0);
542
-
543
- imageDiv.appendChild(scaledCanvas);
544
- exportContainer.appendChild(imageDiv);
545
-
546
- // Right side: Camera list
547
- const cameraListDiv = document.createElement('div');
548
- cameraListDiv.style.width = cameraListWidth + 'px';
549
- cameraListDiv.style.height = finalHeight + 'px';
550
- cameraListDiv.style.padding = '20px';
551
- cameraListDiv.style.background = '#ffffff';
552
- cameraListDiv.style.overflow = 'visible';
553
- cameraListDiv.style.boxSizing = 'border-box';
554
-
555
- // Generate professional report-style HTML
556
- const cameraListHTML = `
557
- <!-- Report Header -->
558
- <div style="background: linear-gradient(135deg, #374151 0%, #1f2937 100%); color: white; padding: 32px; margin: -20px -20px 0 -20px; border-radius: 16px 16px 0 0; box-shadow: 0 6px 20px rgba(0,0,0,0.15);">
559
- <h1 style="margin: 0 0 16px 0; font-size: 36px; font-weight: 800; text-align: center; color: #ffffff; text-shadow: 0 2px 8px rgba(0,0,0,0.5); letter-spacing: -0.6px; line-height: 1.1;">Security Camera Installation Report</h1>
560
- <div style="text-align: center; margin-bottom: 24px;">
561
- <div style="display: inline-block; background: rgba(255,255,255,0.1); border: 1px solid rgba(255,255,255,0.2); padding: 12px 24px; border-radius: 25px; font-size: 14px; font-weight: 600; letter-spacing: 1px; color: #e5e7eb;">
562
- PROJECT LAYOUT & SPECIFICATIONS
563
- </div>
564
- </div>
565
- <div style="display: flex; justify-content: space-between; align-items: center; font-size: 14px; border-top: 1px solid rgba(255,255,255,0.15); padding-top: 20px; letter-spacing: 0.3px;">
566
- <div style="color: #d1d5db; font-weight: 500;"><strong style="color: #f3f4f6; font-weight: 600;">Generated:</strong> ${new Date().toLocaleDateString(
567
- 'en-US',
568
- {
569
- weekday: 'long',
570
- year: 'numeric',
571
- month: 'long',
572
- day: 'numeric',
573
- }
574
- )}</div>
575
- <div style="color: #d1d5db; font-weight: 500;"><strong style="color: #f3f4f6; font-weight: 600;">Time:</strong> ${new Date().toLocaleTimeString()}</div>
576
- </div>
577
- </div>
578
-
579
-
580
- ${cameras
581
- .map((camera, index) => {
582
- const verkadaCamera = camera.verkadaModel
583
- ? getCameraById(camera.verkadaModel)
584
- : null;
585
- // Generate sequential camera number
586
- const cameraNumber = index + 1;
587
-
588
- return `
589
- <div style="margin-bottom: 20px; background: white; border: 1px solid #d1d5db; border-radius: 16px; overflow: hidden; box-shadow: 0 4px 12px rgba(0,0,0,0.08); page-break-inside: avoid;">
590
- <!-- Camera Header -->
591
- <div style="background: ${
592
- verkadaCamera?.environment === 'indoor'
593
- ? 'linear-gradient(135deg, #0d9488 0%, #0f766e 100%)'
594
- : 'linear-gradient(135deg, #ea580c 0%, #dc2626 100%)'
595
- }; color: white; padding: 16px 20px; position: relative;">
596
- <div style="display: flex; align-items: center; gap: 16px;">
597
- <div style="width: 40px; height: 40px; background: rgba(255,255,255,0.15); border: 2px solid rgba(255,255,255,0.25); border-radius: 50%; display: flex; align-items: center; justify-content: center; font-weight: 800; font-size: 16px; flex-shrink: 0; text-shadow: 0 1px 3px rgba(0,0,0,0.3);">${cameraNumber}</div>
598
- <div style="flex: 1;">
599
- <h4 style="margin: 0; font-size: 18px; font-weight: 700; letter-spacing: -0.3px; text-shadow: 0 1px 3px rgba(0,0,0,0.2);">Camera ${cameraNumber}</h4>
600
- <div style="font-size: 13px; opacity: 0.95; margin-top: 4px; letter-spacing: 0.2px;">${
601
- verkadaCamera
602
- ? verkadaCamera.name
603
- : 'Model Not Selected'
604
- }</div>
605
- </div>
606
- ${
607
- verkadaCamera?.ptz
608
- ? '<div style="background: rgba(255,255,255,0.2); border: 1px solid rgba(255,255,255,0.3); padding: 6px 12px; border-radius: 16px; font-size: 11px; font-weight: 600; letter-spacing: 0.5px;">PTZ</div>'
609
- : ''
610
- }
611
- </div>
612
- </div>
613
-
614
- <!-- Camera Details -->
615
- <div style="padding: 20px;">
616
- ${
617
- verkadaCamera
618
- ? `
619
- <div style="display: grid; grid-template-columns: 1fr 1fr; gap: 16px; margin-bottom: 16px;">
620
- <div style="background: #f8fafc; padding: 12px; border-radius: 10px; border-left: 3px solid #3b82f6;">
621
- <div style="font-size: 12px; color: #475569; font-weight: 600; text-transform: uppercase; letter-spacing: 0.8px; margin-bottom: 4px;">Resolution</div>
622
- <div style="font-size: 15px; font-weight: 700; color: #0f172a; line-height: 1.3;">${
623
- verkadaCamera.resolution
624
- }</div>
625
- </div>
626
- <div style="background: #f8fafc; padding: 12px; border-radius: 10px; border-left: 3px solid #8b5cf6;">
627
- <div style="font-size: 12px; color: #475569; font-weight: 600; text-transform: uppercase; letter-spacing: 0.8px; margin-bottom: 4px;">Field of View</div>
628
- <div style="font-size: 15px; font-weight: 700; color: #0f172a; line-height: 1.3;">${
629
- verkadaCamera.fov
630
- }°</div>
631
- </div>
632
- <div style="background: #f8fafc; padding: 12px; border-radius: 10px; border-left: 3px solid ${
633
- verkadaCamera.environment ===
634
- 'indoor'
635
- ? '#10b981'
636
- : '#f59e0b'
637
- };">
638
- <div style="font-size: 12px; color: #475569; font-weight: 600; text-transform: uppercase; letter-spacing: 0.8px; margin-bottom: 4px;">Environment</div>
639
- <div style="font-size: 15px; font-weight: 700; color: #0f172a; text-transform: capitalize; line-height: 1.3;">${
640
- verkadaCamera.environment
641
- }</div>
642
- </div>
643
- <div style="background: #f8fafc; padding: 12px; border-radius: 10px; border-left: 3px solid #ef4444;">
644
- <div style="font-size: 12px; color: #475569; font-weight: 600; text-transform: uppercase; letter-spacing: 0.8px; margin-bottom: 4px;">IR Range</div>
645
- <div style="font-size: 15px; font-weight: 700; color: #0f172a; line-height: 1.3;">${
646
- verkadaCamera.irRange
647
- }m</div>
648
- </div>
649
- </div>
650
-
651
- <!-- Position & Direction -->
652
- <div style="background: linear-gradient(135deg, #fefefe 0%, #f1f5f9 100%); border: 1px solid #e2e8f0; padding: 16px; border-radius: 12px; margin-bottom: 16px;">
653
- <div style="font-size: 13px; color: #334155; font-weight: 700; text-transform: uppercase; letter-spacing: 0.8px; margin-bottom: 12px; text-align: center; border-bottom: 1px solid #e2e8f0; padding-bottom: 8px;">Installation Details</div>
654
- <div style="display: grid; grid-template-columns: 1fr 1fr; gap: 12px;">
655
- <div style="background: white; padding: 10px; border-radius: 8px; border: 1px solid #e2e8f0;">
656
- <div style="font-size: 11px; color: #64748b; font-weight: 600; text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 4px;">Direction</div>
657
- <div style="font-size: 14px; font-weight: 600; color: #0f172a; line-height: 1.4;">${
658
- camera.direction
659
- ? `${camera.direction}° from North`
660
- : 'Not set'
661
- }</div>
662
- </div>
663
- <div style="background: white; padding: 10px; border-radius: 8px; border: 1px solid #e2e8f0;">
664
- <div style="font-size: 11px; color: #64748b; font-weight: 600; text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 4px;">Coordinates</div>
665
- <div style="font-size: 14px; font-weight: 600; color: #0f172a; line-height: 1.4; font-family: monospace;">${
666
- mode === 'map'
667
- ? `${camera.lat?.toFixed(
668
- 6
669
- )}, ${camera.lng?.toFixed(
670
- 6
671
- )}`
672
- : `X: ${Math.round(
673
- camera.x
674
- )}, Y: ${Math.round(
675
- camera.y
676
- )}`
677
- }</div>
678
- </div>
679
- </div>
680
- </div>
681
-
682
- <!-- Features -->
683
- ${
684
- verkadaCamera.features &&
685
- verkadaCamera.features.length > 0
686
- ? `
687
- <div style="background: white; padding: 14px; border-radius: 12px; border: 1px solid #e2e8f0;">
688
- <div style="font-size: 13px; color: #334155; font-weight: 700; text-transform: uppercase; letter-spacing: 0.8px; margin-bottom: 10px; text-align: center;">Key Features</div>
689
- <div style="display: flex; flex-wrap: wrap; gap: 6px; justify-content: center;">
690
- ${verkadaCamera.features
691
- .slice(0, 6)
692
- .map(
693
- (feature) =>
694
- `<span style="background: linear-gradient(135deg, #dbeafe 0%, #bfdbfe 100%); color: #1e40af; border: 1px solid #93c5fd; padding: 6px 12px; border-radius: 18px; font-size: 11px; font-weight: 600; letter-spacing: 0.3px; white-space: nowrap;">${feature}</span>`
695
- )
696
- .join('')}
697
- ${
698
- verkadaCamera.features
699
- .length > 6
700
- ? `<span style="color: #64748b; font-size: 11px; align-self: center; padding: 4px 8px; border-radius: 12px; background: #f1f5f9; font-weight: 500;">+${
701
- verkadaCamera
702
- .features
703
- .length - 6
704
- } more</span>`
705
- : ''
706
- }
707
- </div>
708
- </div>
709
- `
710
- : ''
711
- }
712
- `
713
- : `
714
- <div style="text-align: center; padding: 30px; background: linear-gradient(135deg, #fef2f2 0%, #fee2e2 100%); border: 1px solid #fca5a5; border-radius: 12px; margin: 10px 0;">
715
- <div style="font-size: 16px; font-weight: 700; margin-bottom: 8px; color: #dc2626; letter-spacing: 0.2px;">⚠ No Camera Model Selected</div>
716
- <div style="font-size: 13px; color: #7f1d1d; line-height: 1.5; letter-spacing: 0.2px;">Please configure this camera before installation</div>
717
- </div>
718
- `
719
- }
720
- </div>
721
- </div>
722
- `;
723
- })
724
- .join('')}
725
-
726
- <!-- Report Footer -->
727
- <div style="margin-top: 32px; padding: 24px; background: linear-gradient(135deg, #f1f5f9 0%, #e2e8f0 100%); border-radius: 16px; border: 1px solid #cbd5e1; box-shadow: 0 4px 12px rgba(0,0,0,0.08);">
728
- <!-- Legend Section -->
729
- <div style="margin-bottom: 24px;">
730
- <h3 style="margin: 0 0 16px 0; font-size: 20px; font-weight: 800; color: #0f172a; text-align: center; letter-spacing: -0.4px;">Camera Types & Legend</h3>
731
- <div style="display: flex; justify-content: center; gap: 20px; flex-wrap: wrap; margin-bottom: 20px;">
732
- <div style="display: flex; align-items: center; gap: 12px; background: white; padding: 12px 16px; border-radius: 12px; border: 1px solid #cbd5e1; box-shadow: 0 2px 8px rgba(0,0,0,0.05);">
733
- <div style="width: 24px; height: 24px; background: linear-gradient(135deg, #0d9488 0%, #0f766e 100%); border-radius: 50%; border: 2px solid white; box-shadow: 0 2px 6px rgba(13,148,136,0.3);"></div>
734
- <span style="font-size: 13px; font-weight: 600; color: #1e293b; letter-spacing: 0.2px;">Indoor Camera</span>
735
- </div>
736
- <div style="display: flex; align-items: center; gap: 12px; background: white; padding: 12px 16px; border-radius: 12px; border: 1px solid #cbd5e1; box-shadow: 0 2px 8px rgba(0,0,0,0.05);">
737
- <div style="width: 24px; height: 24px; background: linear-gradient(135deg, #ea580c 0%, #dc2626 100%); border-radius: 50%; border: 2px solid white; box-shadow: 0 2px 6px rgba(234,88,12,0.3);"></div>
738
- <span style="font-size: 13px; font-weight: 600; color: #1e293b; letter-spacing: 0.2px;">Outdoor Camera</span>
739
- </div>
740
- ${
741
- cameras.some(
742
- (c) => getCameraById(c.verkadaModel)?.ptz
743
- )
744
- ? `
745
- <div style="display: flex; align-items: center; gap: 12px; background: white; padding: 12px 16px; border-radius: 12px; border: 1px solid #cbd5e1; box-shadow: 0 2px 8px rgba(0,0,0,0.05);">
746
- <div style="width: 24px; height: 24px; background: #6b7280; border-radius: 50%; border: 3px solid #ef4444; box-shadow: 0 2px 6px rgba(239,68,68,0.3);"></div>
747
- <span style="font-size: 13px; font-weight: 600; color: #1e293b; letter-spacing: 0.2px;">PTZ Camera</span>
748
- </div>`
749
- : ''
750
- }
751
- </div>
752
- </div>
753
-
754
- <!-- Technical Notes -->
755
- <div style="background: white; padding: 20px; border-radius: 12px; border: 1px solid #cbd5e1; box-shadow: 0 2px 8px rgba(0,0,0,0.04);">
756
- <h4 style="margin: 0 0 16px 0; font-size: 16px; font-weight: 700; color: #0f172a; text-align: center; letter-spacing: -0.2px;">Installation Notes</h4>
757
- <div style="font-size: 13px; line-height: 1.8; color: #374151; letter-spacing: 0.2px;">
758
- <div style="margin-bottom: 12px; padding: 10px; background: #f8fafc; border-radius: 8px; border-left: 3px solid #3b82f6;"><strong style="color: #1e40af;">Direction Reference:</strong> All camera directions are measured clockwise from North (0° = North, 90° = East, 180° = South, 270° = West)</div>
759
- <div style="margin-bottom: 12px; padding: 10px; background: #f8fafc; border-radius: 8px; border-left: 3px solid #8b5cf6;"><strong style="color: #7c3aed;">Field of View:</strong> FOV angles represent the horizontal viewing angle. Actual coverage may vary based on mounting height and obstacles</div>
760
- <div style="margin-bottom: 12px; padding: 10px; background: #f8fafc; border-radius: 8px; border-left: 3px solid #10b981;"><strong style="color: #059669;">Coordinates:</strong> ${
761
- mode === 'map'
762
- ? 'Geographic coordinates (Latitude, Longitude) in decimal degrees'
763
- : 'Pixel coordinates relative to uploaded image (X, Y from top-left corner)'
764
- }</div>
765
- <div style="padding: 12px; background: linear-gradient(135deg, #fef2f2 0%, #fee2e2 100%); border-radius: 8px; border-left: 3px solid #ef4444;"><strong style="color: #dc2626;">Important:</strong> This layout should be verified on-site during installation. Actual camera placement may need adjustment based on physical constraints, lighting conditions, and site requirements</div>
766
- </div>
767
- </div>
768
-
769
- <!-- Report ID & Disclaimer -->
770
- <div style="text-align: center; margin-top: 20px; padding-top: 16px; border-top: 2px solid #cbd5e1;">
771
- <div style="font-size: 12px; color: #475569; margin-bottom: 8px; letter-spacing: 0.5px; font-weight: 600;">Report ID: <span style="font-family: monospace; background: #f1f5f9; padding: 2px 6px; border-radius: 4px;">${Date.now()
772
- .toString(36)
773
- .toUpperCase()}</span></div>
774
- <div style="font-size: 11px; color: #64748b; line-height: 1.6; letter-spacing: 0.3px; max-width: 400px; margin: 0 auto;">This report was generated automatically and should be reviewed by qualified security installation professionals</div>
775
- </div>
776
- </div>
777
- `;
774
+ let mapImageData = null;
778
775
 
779
- cameraListDiv.innerHTML = cameraListHTML;
780
- exportContainer.appendChild(cameraListDiv);
781
-
782
- // Wait for rendering
783
- await new Promise((resolve) => setTimeout(resolve, 100));
784
-
785
- // Capture the composite with exact dimensions
786
- const finalCanvas = await html2canvas(exportContainer, {
787
- useCORS: true,
788
- allowTaint: true,
789
- scale: 1,
790
- backgroundColor: '#ffffff',
791
- logging: false,
792
- width: finalWidth,
793
- height: finalHeight,
794
- });
776
+ // Capture the site map image if available
777
+ if (uploadedImage || mode === 'image') {
778
+ const imageContainer = document.querySelector(
779
+ `.${styles.imageContainer}`
780
+ );
781
+ if (imageContainer && uploadedImage) {
782
+ try {
783
+ const canvas = await html2canvas(imageContainer, {
784
+ useCORS: true,
785
+ allowTaint: true,
786
+ scale: 1.2,
787
+ backgroundColor: '#f8fafc',
788
+ logging: false,
789
+ });
790
+ mapImageData = canvas.toDataURL('image/png', 0.9);
791
+ } catch (error) {
792
+ // Continue without image if capture fails
793
+ }
794
+ }
795
+ }
795
796
 
796
- // Clean up
797
- document.body.removeChild(exportContainer);
797
+ // Generate PDF using React-PDF with fallback
798
+ let pdfBlob;
799
+ try {
800
+ // Try the enhanced PDF first
801
+ pdfBlob = await pdf(
802
+ React.createElement(CameraReportPDF, {
803
+ cameras: cameras,
804
+ mapImageData: mapImageData,
805
+ mode: mode,
806
+ customColors: {} // Can be extended to read from props or CSS variables
807
+ })
808
+ ).toBlob();
809
+ } catch (pdfError) {
810
+
811
+ // Fallback to simple PDF
812
+ pdfBlob = await pdf(
813
+ React.createElement(SimpleCameraReportPDF, {
814
+ cameras: cameras,
815
+ mapImageData: mapImageData,
816
+ mode: mode,
817
+ customColors: {} // Can be extended to read from props or CSS variables
818
+ })
819
+ ).toBlob();
820
+ }
798
821
 
799
- // Download
822
+ // Create download link
823
+ const url = URL.createObjectURL(pdfBlob);
800
824
  const link = document.createElement('a');
801
825
  const timestamp = new Date()
802
826
  .toISOString()
803
827
  .slice(0, 19)
804
828
  .replace(/[:.]/g, '-');
805
- link.download = `Security-Camera-Installation-Report-${timestamp}.png`;
806
- link.href = finalCanvas.toDataURL('image/png', 1.0); // Maximum quality
829
+ link.download = `Security-Camera-Installation-Report-${timestamp}.pdf`;
830
+ link.href = url;
807
831
  link.click();
808
832
 
833
+ // Clean up
834
+ URL.revokeObjectURL(url);
835
+
809
836
  // Show success message
810
837
  Swal.fire({
811
838
  icon: 'success',
812
- title: 'Export Complete!',
813
- text: `Professional camera installation report exported as ${link.download}`,
814
- timer: 3000,
815
- showConfirmButton: false,
816
- toast: true,
817
- position: 'top-end',
839
+ title: 'PDF Export Complete!',
840
+ html: `
841
+ <div style="text-align: left; font-size: 14px; line-height: 1.6;">
842
+ <p><strong>Your professional camera installation report has been generated!</strong></p>
843
+ <div style="margin: 15px 0;">
844
+ <div style="display: flex; align-items: center; margin-bottom: 8px;">
845
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#3b82f6" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="margin-right: 8px; flex-shrink: 0;">
846
+ <path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"></path>
847
+ <polyline points="14,2 14,8 20,8"></polyline>
848
+ </svg>
849
+ <span><strong>Page 1:</strong> Site layout with camera positions</span>
850
+ </div>
851
+ <div style="display: flex; align-items: center; margin-bottom: 8px;">
852
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#3b82f6" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="margin-right: 8px; flex-shrink: 0;">
853
+ <rect x="3" y="3" width="18" height="18" rx="2" ry="2"></rect>
854
+ <circle cx="8.5" cy="8.5" r="1.5"></circle>
855
+ <polyline points="21,15 16,10 5,21"></polyline>
856
+ </svg>
857
+ <span><strong>Page 2:</strong> Detailed camera specifications</span>
858
+ </div>
859
+ <div style="display: flex; align-items: center;">
860
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#22c55e" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="margin-right: 8px; flex-shrink: 0;">
861
+ <polyline points="20,6 9,17 4,12"></polyline>
862
+ </svg>
863
+ <span><strong>Includes:</strong> Camera legend and specifications</span>
864
+ </div>
865
+ </div>
866
+ <p style="color: #6b7280; font-size: 12px; margin-top: 15px;">
867
+ File saved as: <code>${link.download}</code>
868
+ </p>
869
+ </div>
870
+ `,
871
+ confirmButtonColor: '#1e40af',
872
+ confirmButtonText: 'Great!',
818
873
  });
819
874
  } catch (error) {
820
- console.error('Error exporting:', error);
875
+
876
+ // More specific error handling
877
+ let errorMessage = 'Unable to generate PDF report. Please try again.';
878
+ if (error.message.includes('Buffer') || error.message.includes('DataView')) {
879
+ errorMessage = 'PDF generation failed due to font loading issue. This is a known issue with React-PDF in development mode.';
880
+ } else if (error.message.includes('font') || error.message.includes('glyph')) {
881
+ errorMessage = 'Font-related error occurred during PDF generation. Trying alternative approach...';
882
+ }
883
+
821
884
  Swal.fire({
822
885
  icon: 'error',
823
- title: 'Export Failed',
824
- text: 'Export failed. Please try again or take a manual screenshot.',
825
- confirmButtonColor: '#10b981',
886
+ title: 'PDF Export Failed',
887
+ html: `
888
+ <div style="text-align: left;">
889
+ <p>${errorMessage}</p>
890
+ <details style="margin-top: 10px;">
891
+ <summary style="cursor: pointer; color: #6b7280;">Technical Details</summary>
892
+ <pre style="font-size: 11px; background: #f3f4f6; padding: 8px; margin: 8px 0; border-radius: 4px; max-height: 100px; overflow-y: auto;">${error.message}</pre>
893
+ </details>
894
+ </div>
895
+ `,
896
+ confirmButtonColor: '#dc2626',
826
897
  });
827
898
  }
828
- }, [cameras]);
899
+ }, [cameras, uploadedImage, mode]);
829
900
 
830
901
  // Update cameras with markers on map
831
902
  useEffect(() => {
@@ -845,10 +916,6 @@ const CameraPlacement = () => {
845
916
  );
846
917
  }
847
918
  } catch (error) {
848
- console.warn(
849
- 'Could not access map style, skipping FOV layer cleanup:',
850
- error
851
- );
852
919
  }
853
920
  existingSources.forEach((sourceId) => {
854
921
  try {
@@ -860,10 +927,6 @@ const CameraPlacement = () => {
860
927
  map.removeSource(sourceId);
861
928
  }
862
929
  } catch (error) {
863
- console.warn(
864
- `Could not remove layer/source ${sourceId}:`,
865
- error
866
- );
867
930
  }
868
931
  });
869
932
 
@@ -1113,8 +1176,18 @@ const CameraPlacement = () => {
1113
1176
  <div className={styles.cameraPlacement}>
1114
1177
  <div className={styles.header}>
1115
1178
  <div className={styles.headerContent}>
1116
- <h1 className={styles.title}>Site Layout Planner</h1>
1179
+ <div className={styles.titleSection}>
1180
+ <h1 className={styles.title}>Site Map Planner</h1>
1181
+ </div>
1117
1182
  <div className={styles.controls}>
1183
+ <input
1184
+ type="text"
1185
+ placeholder="Project name..."
1186
+ value={projectName}
1187
+ onChange={(e) => setProjectName(e.target.value)}
1188
+ className={styles.projectInput}
1189
+ maxLength={100}
1190
+ />
1118
1191
  <IconButton
1119
1192
  icon={Upload}
1120
1193
  onClick={() => fileInputRef.current?.click()}
@@ -1123,33 +1196,16 @@ const CameraPlacement = () => {
1123
1196
  />
1124
1197
  <IconButton
1125
1198
  icon={Save}
1126
- onClick={saveAsFile}
1199
+ onClick={saveToDatabase}
1127
1200
  disabled={!cameras.length && !uploadedImage}
1128
- tooltip="Save camera placement as JSON file"
1201
+ tooltip="Save camera placement to database"
1129
1202
  className={styles.saveFileBtn}
1130
1203
  />
1131
- <IconButton
1132
- icon={FolderOpen}
1133
- onClick={loadFromFile}
1134
- tooltip="Load camera placement from JSON file"
1135
- className={styles.loadFileBtn}
1136
- />
1137
- <IconButton
1138
- icon={sidebarCollapsed ? Eye : EyeOff}
1139
- onClick={() =>
1140
- setSidebarCollapsed(!sidebarCollapsed)
1141
- }
1142
- tooltip={`${
1143
- sidebarCollapsed ? 'Show' : 'Hide'
1144
- } Cameras`}
1145
- />
1146
1204
  <IconButton
1147
1205
  icon={Download}
1148
- onClick={exportMap}
1149
- disabled={mode === 'image' && !uploadedImage}
1150
- tooltip={`Export ${
1151
- mode === 'map' ? 'Map' : 'Image'
1152
- }`}
1206
+ onClick={exportToPDF}
1207
+ disabled={!cameras.length}
1208
+ tooltip="Export to PDF Report"
1153
1209
  className={styles.exportBtn}
1154
1210
  />
1155
1211
  </div>
@@ -1160,9 +1216,7 @@ const CameraPlacement = () => {
1160
1216
  <div
1161
1217
  className={`${styles.imageContainer} ${
1162
1218
  uploadedImage ? styles.addMode : ''
1163
- } ${sidebarCollapsed ? styles.sidebarCollapsed : ''} ${
1164
- isDragOver ? styles.dragOver : ''
1165
- }`}
1219
+ } ${isDragOver ? styles.dragOver : ''}`}
1166
1220
  ref={imageContainer}
1167
1221
  onClick={handleImageClick}
1168
1222
  onDragOver={handleDragOver}
@@ -1602,10 +1656,6 @@ const CameraPlacement = () => {
1602
1656
  rotationHandle.style
1603
1657
  ) {
1604
1658
  rotationHandle.style.transform = `rotate(${newRotation}deg)`;
1605
- } else {
1606
- console.error(
1607
- 'Rotation handle is null or has no style property'
1608
- );
1609
1659
  }
1610
1660
 
1611
1661
  // Update camera direction
@@ -1684,56 +1734,51 @@ const CameraPlacement = () => {
1684
1734
  </div>
1685
1735
  )}
1686
1736
  </div>
1687
- </div>
1688
1737
 
1689
- {/* Hidden file input */}
1690
- <input
1691
- type="file"
1692
- ref={fileInputRef}
1693
- onChange={handleImageUpload}
1694
- accept="image/*"
1695
- style={{ display: 'none' }}
1696
- />
1697
-
1698
- <div
1699
- className={`${styles.sidebar} ${
1700
- sidebarCollapsed ? styles.collapsed : ''
1701
- }`}
1702
- >
1738
+ <div className={styles.sidebar}>
1703
1739
  <div className={styles.sidebarHeader}>
1704
- <h3 className={styles.sidebarTitle}>Camera List</h3>
1705
- <p className={styles.cameraCount}>
1706
- {cameras.length} camera
1707
- {cameras.length !== 1 ? 's' : ''} placed
1708
- </p>
1709
- </div>
1710
-
1711
- <div className={styles.camerasList}>
1712
- {cameras.length === 0 ? (
1713
- <div
1714
- style={{
1715
- textAlign: 'center',
1716
- color: '#6c757d',
1717
- padding: '40px 20px',
1718
- fontStyle: 'italic',
1719
- }}
1740
+ <div className={styles.sidebarTabs}>
1741
+ <button
1742
+ className={`${styles.tabButton} ${sidebarTab === 'cameras' ? styles.active : ''}`}
1743
+ onClick={() => setSidebarTab('cameras')}
1720
1744
  >
1721
- <div>
1722
- Upload an image first, then click anywhere on it
1723
- to place cameras
1724
- </div>
1725
- <div
1726
- style={{
1727
- fontSize: '12px',
1728
- marginTop: '8px',
1729
- }}
1730
- >
1731
- Once placed, you can drag cameras to reposition
1732
- them and use the rotation handle to adjust
1733
- direction
1734
- </div>
1745
+ Cameras
1746
+ <span className={styles.tabCount}>({cameras.length})</span>
1747
+ </button>
1748
+ <button
1749
+ className={`${styles.tabButton} ${sidebarTab === 'projects' ? styles.active : ''}`}
1750
+ onClick={() => setSidebarTab('projects')}
1751
+ >
1752
+ Projects
1753
+ <span className={styles.tabCount}>({savedProjects.length})</span>
1754
+ </button>
1755
+ </div>
1756
+ {selectedProject && sidebarTab === 'cameras' && (
1757
+ <div className={styles.currentProject}>
1758
+ <span className={styles.projectLabel}>Current:</span>
1759
+ <span className={styles.projectName}>{selectedProject.project_name}</span>
1735
1760
  </div>
1736
- ) : (
1761
+ )}
1762
+ </div>
1763
+
1764
+ <div className={styles.sidebarContent}>
1765
+ {sidebarTab === 'cameras' ? (
1766
+ <div className={styles.camerasList}>
1767
+ {cameras.length === 0 ? (
1768
+ <div className={styles.emptyState}>
1769
+ <div>
1770
+ {selectedProject
1771
+ ? `No cameras in "${selectedProject.project_name}"`
1772
+ : "Upload an image first, then click anywhere on it to place cameras"
1773
+ }
1774
+ </div>
1775
+ <div className={styles.emptyStateSubtext}>
1776
+ Once placed, you can drag cameras to reposition
1777
+ them and use the rotation handle to adjust
1778
+ direction
1779
+ </div>
1780
+ </div>
1781
+ ) : (
1737
1782
  cameras.map((camera) => {
1738
1783
  const verkadaCamera = camera.verkadaModel
1739
1784
  ? getCameraById(camera.verkadaModel)
@@ -2185,9 +2230,73 @@ const CameraPlacement = () => {
2185
2230
  </div>
2186
2231
  );
2187
2232
  })
2233
+ )}
2234
+ </div>
2235
+ ) : (
2236
+ <div className={styles.projectsList}>
2237
+ {loadingProjects ? (
2238
+ <div className={styles.loadingState}>
2239
+ <div>Loading projects...</div>
2240
+ </div>
2241
+ ) : savedProjects.length === 0 ? (
2242
+ <div className={styles.emptyState}>
2243
+ <div>No saved projects found</div>
2244
+ <div className={styles.emptyStateSubtext}>
2245
+ Create and save your first camera placement project
2246
+ </div>
2247
+ </div>
2248
+ ) : (
2249
+ savedProjects.map((project) => (
2250
+ <div
2251
+ key={project.id}
2252
+ className={`${styles.projectItem} ${selectedProject?.id === project.id ? styles.selected : ''}`}
2253
+ onClick={() => loadProject(project)}
2254
+ >
2255
+ <div className={styles.projectHeader}>
2256
+ <h4 className={styles.projectTitle}>
2257
+ {project.project_name}
2258
+ </h4>
2259
+ <div className={styles.projectMeta}>
2260
+ <span className={styles.projectMode}>
2261
+ {project.mode}
2262
+ </span>
2263
+ <span className={styles.projectDate}>
2264
+ {new Date(project.created_at).toLocaleDateString()}
2265
+ </span>
2266
+ </div>
2267
+ </div>
2268
+ <div className={styles.projectInfo}>
2269
+ <span className={styles.cameraCount}>
2270
+ {project.cameras_count || 0} cameras
2271
+ </span>
2272
+ {project.image_data && (
2273
+ <span className={styles.hasImage}>
2274
+ 📷 Image
2275
+ </span>
2276
+ )}
2277
+ </div>
2278
+ {project.description && (
2279
+ <div className={styles.projectDescription}>
2280
+ {project.description}
2281
+ </div>
2282
+ )}
2283
+ </div>
2284
+ ))
2285
+ )}
2286
+ </div>
2188
2287
  )}
2189
2288
  </div>
2190
2289
  </div>
2290
+ </div>
2291
+
2292
+ {/* Hidden file input */}
2293
+ <input
2294
+ type="file"
2295
+ ref={fileInputRef}
2296
+ onChange={handleImageUpload}
2297
+ accept="image/*"
2298
+ style={{ display: 'none' }}
2299
+ />
2191
2300
 
2192
2301
  {showModal && selectedCamera && (
2193
2302
  <CameraSelectionModal