@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
|
-
//
|
|
336
|
-
const
|
|
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
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
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
|
-
|
|
370
|
-
const
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
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
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
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
|
-
|
|
402
|
-
|
|
403
|
-
|
|
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
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
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
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
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
|
-
|
|
422
|
-
|
|
423
|
-
|
|
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
|
-
|
|
427
|
-
|
|
428
|
-
|
|
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
|
-
|
|
432
|
-
setCameras(saveData.cameras || []);
|
|
505
|
+
const responseText = await response.text();
|
|
433
506
|
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
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
|
-
|
|
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
|
-
|
|
574
|
+
// Load a specific project
|
|
575
|
+
const loadProject = useCallback(async (lightweightProject) => {
|
|
459
576
|
try {
|
|
460
|
-
|
|
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: '
|
|
463
|
-
title: 'No
|
|
464
|
-
text: '
|
|
465
|
-
confirmButtonColor: '#
|
|
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
|
-
|
|
471
|
-
|
|
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
|
|
477
|
-
text: 'Please
|
|
478
|
-
confirmButtonColor: '#
|
|
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
|
-
//
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
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
|
-
|
|
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
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
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
|
-
//
|
|
797
|
-
|
|
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
|
-
//
|
|
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}.
|
|
806
|
-
link.href =
|
|
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
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
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
|
-
|
|
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
|
-
|
|
825
|
-
|
|
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
|
-
<
|
|
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={
|
|
1199
|
+
onClick={saveToDatabase}
|
|
1127
1200
|
disabled={!cameras.length && !uploadedImage}
|
|
1128
|
-
tooltip="Save camera placement
|
|
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={
|
|
1149
|
-
disabled={
|
|
1150
|
-
tooltip=
|
|
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
|
-
} ${
|
|
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
|
-
|
|
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
|
-
<
|
|
1705
|
-
|
|
1706
|
-
|
|
1707
|
-
|
|
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
|
-
|
|
1722
|
-
|
|
1723
|
-
|
|
1724
|
-
|
|
1725
|
-
|
|
1726
|
-
|
|
1727
|
-
|
|
1728
|
-
|
|
1729
|
-
|
|
1730
|
-
|
|
1731
|
-
|
|
1732
|
-
|
|
1733
|
-
|
|
1734
|
-
|
|
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
|