app given for client
733
android/app/src/main/assets/dicom-viewer.html
Normal file
@ -0,0 +1,733 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
|
||||||
|
<title>DICOM Viewer - Fullscreen Mobile Friendly</title>
|
||||||
|
<style>
|
||||||
|
html, body {
|
||||||
|
margin: 0; padding: 0;
|
||||||
|
width: 100%; height: 100%;
|
||||||
|
background: #111; color: white; font-family: sans-serif;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
#dicomContainer {
|
||||||
|
position: relative;
|
||||||
|
width: 100%; height: 100%;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
border-radius: 0;
|
||||||
|
overflow: visible;
|
||||||
|
}
|
||||||
|
#dicomImage {
|
||||||
|
width: 100%;
|
||||||
|
height: calc(100% - 100px);
|
||||||
|
background: black;
|
||||||
|
touch-action: none;
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
border-radius: 0;
|
||||||
|
overflow: visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
#dicomImage canvas {
|
||||||
|
max-width: 100%;
|
||||||
|
max-height: 100%;
|
||||||
|
position: relative;
|
||||||
|
top: 0;
|
||||||
|
border-radius: 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Ensure cornerstone canvas shows original rectangular view */
|
||||||
|
.cornerstone-element {
|
||||||
|
position: relative !important;
|
||||||
|
top: 0 !important;
|
||||||
|
width: 100% !important;
|
||||||
|
height: 100% !important;
|
||||||
|
border-radius: 0 !important;
|
||||||
|
overflow: visible !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cornerstone-canvas {
|
||||||
|
position: relative !important;
|
||||||
|
top: 0 !important;
|
||||||
|
border-radius: 0 !important;
|
||||||
|
max-width: 100% !important;
|
||||||
|
max-height: 100% !important;
|
||||||
|
clip-path: none !important;
|
||||||
|
mask: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Remove any circular clipping */
|
||||||
|
.cornerstone-element canvas,
|
||||||
|
#dicomImage canvas,
|
||||||
|
canvas {
|
||||||
|
border-radius: 0 !important;
|
||||||
|
clip-path: none !important;
|
||||||
|
mask: none !important;
|
||||||
|
shape-outside: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Ensure full rectangular display */
|
||||||
|
div[class*="cornerstone"] {
|
||||||
|
border-radius: 0 !important;
|
||||||
|
clip-path: none !important;
|
||||||
|
overflow: visible !important;
|
||||||
|
}
|
||||||
|
#controls {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 15px;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
background: rgba(0,0,0,0.8);
|
||||||
|
padding: 12px 16px;
|
||||||
|
border-radius: 12px;
|
||||||
|
z-index: 9999;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
box-shadow: 0 4px 12px rgba(0,0,0,0.3);
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
border: 1px solid rgba(255,255,255,0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
#controls {
|
||||||
|
bottom: 20px;
|
||||||
|
padding: 14px 18px;
|
||||||
|
border-radius: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#dicomImage {
|
||||||
|
height: calc(100% - 110px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
#controls {
|
||||||
|
bottom: 25px;
|
||||||
|
padding: 16px 20px;
|
||||||
|
border-radius: 20px;
|
||||||
|
left: 10px;
|
||||||
|
right: 10px;
|
||||||
|
transform: none;
|
||||||
|
max-width: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
#dicomImage {
|
||||||
|
height: calc(100% - 130px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#frameControls, #actionControls {
|
||||||
|
margin-top: 8px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: center;
|
||||||
|
width: 90%;
|
||||||
|
}
|
||||||
|
|
||||||
|
#frameControls {
|
||||||
|
flex-wrap: nowrap;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 240px;
|
||||||
|
gap: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
#frameControls, #actionControls {
|
||||||
|
gap: 10px;
|
||||||
|
margin-top: 10px;
|
||||||
|
width: 90%;
|
||||||
|
}
|
||||||
|
|
||||||
|
#frameControls {
|
||||||
|
flex-wrap: nowrap;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 255px;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
#frameControls, #actionControls {
|
||||||
|
gap: 12px;
|
||||||
|
margin-top: 12px;
|
||||||
|
width: 90%;
|
||||||
|
}
|
||||||
|
|
||||||
|
#frameControls {
|
||||||
|
flex-wrap: nowrap;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 270px;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
button {
|
||||||
|
background: #222;
|
||||||
|
color: white;
|
||||||
|
border: 1px solid #555;
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 6px 10px;
|
||||||
|
font-size: 14px;
|
||||||
|
min-width: 34px;
|
||||||
|
min-height: 34px;
|
||||||
|
cursor: pointer;
|
||||||
|
touch-action: manipulation;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
button {
|
||||||
|
padding: 8px 12px;
|
||||||
|
font-size: 16px;
|
||||||
|
min-width: 35px;
|
||||||
|
min-height: 35px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
button {
|
||||||
|
padding: 10px 14px;
|
||||||
|
font-size: 18px;
|
||||||
|
min-width: 37px;
|
||||||
|
min-height: 37px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
input[type=range] {
|
||||||
|
width: 135px;
|
||||||
|
height: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 100px;
|
||||||
|
-webkit-appearance: none;
|
||||||
|
appearance: none;
|
||||||
|
background: rgba(255,255,255,0.3);
|
||||||
|
border-radius: 3px;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type=range]::-webkit-slider-thumb {
|
||||||
|
-webkit-appearance: none;
|
||||||
|
appearance: none;
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
background: #2196F3;
|
||||||
|
border-radius: 50%;
|
||||||
|
cursor: pointer;
|
||||||
|
box-shadow: 0 1px 3px rgba(0,0,0,0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type=range]::-moz-range-thumb {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
background: #2196F3;
|
||||||
|
border-radius: 50%;
|
||||||
|
cursor: pointer;
|
||||||
|
border: none;
|
||||||
|
box-shadow: 0 1px 3px rgba(0,0,0,0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type=range]:disabled {
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type=range]:disabled::-webkit-slider-thumb {
|
||||||
|
background: #666;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type=range]:disabled::-moz-range-thumb {
|
||||||
|
background: #666;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
input[type=range] {
|
||||||
|
width: 155px;
|
||||||
|
height: 6px;
|
||||||
|
min-width: 120px;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type=range]::-webkit-slider-thumb {
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type=range]::-moz-range-thumb {
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
input[type=range] {
|
||||||
|
width: 170px;
|
||||||
|
height: 6px;
|
||||||
|
min-width: 140px;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type=range]::-webkit-slider-thumb {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type=range]::-moz-range-thumb {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
input[type=file] {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
#frameInfo {
|
||||||
|
margin-top: 6px;
|
||||||
|
color: rgba(255,255,255,0.9);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
#frameInfo {
|
||||||
|
font-size: 14px;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
#frameInfo {
|
||||||
|
font-size: 16px;
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Loading Overlay Styles */
|
||||||
|
#loadingOverlay {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background: rgba(0, 0, 0, 0.9);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
z-index: 10000;
|
||||||
|
backdrop-filter: blur(5px);
|
||||||
|
}
|
||||||
|
|
||||||
|
#loadingOverlay.hidden {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loader {
|
||||||
|
width: 50px;
|
||||||
|
height: 50px;
|
||||||
|
border: 4px solid rgba(255, 255, 255, 0.3);
|
||||||
|
border-top: 4px solid #2196F3;
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: spin 1s linear infinite;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
0% { transform: rotate(0deg); }
|
||||||
|
100% { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-text {
|
||||||
|
color: rgba(255, 255, 255, 0.9);
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 500;
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-subtext {
|
||||||
|
color: rgba(255, 255, 255, 0.7);
|
||||||
|
font-size: 14px;
|
||||||
|
text-align: center;
|
||||||
|
max-width: 300px;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.loader {
|
||||||
|
width: 60px;
|
||||||
|
height: 60px;
|
||||||
|
border-width: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-text {
|
||||||
|
font-size: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-subtext {
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
.loader {
|
||||||
|
width: 70px;
|
||||||
|
height: 70px;
|
||||||
|
border-width: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-text {
|
||||||
|
font-size: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-subtext {
|
||||||
|
font-size: 18px;
|
||||||
|
max-width: 280px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<div id="dicomContainer">
|
||||||
|
<div id="dicomImage"></div>
|
||||||
|
|
||||||
|
<!-- Loading Overlay -->
|
||||||
|
<div id="loadingOverlay" class="hidden">
|
||||||
|
<div class="loader"></div>
|
||||||
|
<div class="loading-text">Loading DICOM Image...</div>
|
||||||
|
<div class="loading-subtext">Please wait while we process your medical image</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="controls">
|
||||||
|
<div id="actionControls">
|
||||||
|
<button onclick="resetView()">⟳</button>
|
||||||
|
<button onclick="zoomIn()">+</button>
|
||||||
|
<button onclick="zoomOut()">-</button>
|
||||||
|
<button onclick="document.getElementById('fileInput').click()">📂</button>
|
||||||
|
</div>
|
||||||
|
<div id="frameControls">
|
||||||
|
<button onclick="prevFrame()">◀</button>
|
||||||
|
<input type="range" id="frameSlider" min="1" max="2" value="1" disabled>
|
||||||
|
<button onclick="nextFrame()">▶</button>
|
||||||
|
</div>
|
||||||
|
<div id="frameInfo"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Hidden file input -->
|
||||||
|
<input type="file" id="fileInput" accept=".dcm" multiple>
|
||||||
|
|
||||||
|
<!-- Load from URL (Hidden for WebView usage) -->
|
||||||
|
<input type="text" id="dicomUrl" placeholder="Enter DICOM URL" size="50" style="display: none;">
|
||||||
|
<button id="loadUrlBtn" style="display: none;">Load URL</button>
|
||||||
|
|
||||||
|
<!-- Libraries -->
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/cornerstone-core@2.6.1/dist/cornerstone.min.js"></script>
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/dicom-parser/dist/dicomParser.min.js"></script>
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/cornerstone-wado-image-loader@4.13.2/dist/cornerstoneWADOImageLoader.bundle.min.js"></script>
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/cornerstone-tools@4.22.1/dist/cornerstoneTools.min.js"></script>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const element = document.getElementById('dicomImage');
|
||||||
|
const frameInfo = document.getElementById('frameInfo');
|
||||||
|
const frameSlider = document.getElementById('frameSlider');
|
||||||
|
const loadingOverlay = document.getElementById('loadingOverlay');
|
||||||
|
|
||||||
|
cornerstoneWADOImageLoader.external.cornerstone = cornerstone;
|
||||||
|
cornerstoneWADOImageLoader.external.dicomParser = dicomParser;
|
||||||
|
|
||||||
|
cornerstone.enable(element);
|
||||||
|
cornerstoneTools.init({ showSVGCursors: true });
|
||||||
|
|
||||||
|
// Touch tools
|
||||||
|
cornerstoneTools.addTool(cornerstoneTools.ZoomTouchPinchTool);
|
||||||
|
cornerstoneTools.addTool(cornerstoneTools.StackScrollMultiTouchTool);
|
||||||
|
cornerstoneTools.addTool(cornerstoneTools.WwwcRegionTool);
|
||||||
|
|
||||||
|
cornerstoneTools.setToolActive('ZoomTouchPinch', {});
|
||||||
|
cornerstoneTools.setToolActive('StackScrollMultiTouch', {});
|
||||||
|
cornerstoneTools.setToolActive('WwwcRegion', {});
|
||||||
|
|
||||||
|
// 🔑 Listen for messages from React Native WebView
|
||||||
|
window.addEventListener('message', (event) => {
|
||||||
|
try {
|
||||||
|
const message = event.data;
|
||||||
|
|
||||||
|
// Handle direct URL string
|
||||||
|
if (typeof message === 'string' && message.startsWith('http')) {
|
||||||
|
loadDicom("wadouri:" + message);
|
||||||
|
document.getElementById('dicomUrl').value = message;
|
||||||
|
|
||||||
|
// Send success message back to React Native
|
||||||
|
if (window.ReactNativeWebView) {
|
||||||
|
window.ReactNativeWebView.postMessage(JSON.stringify({
|
||||||
|
type: 'success',
|
||||||
|
message: 'DICOM URL received and loading started'
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle structured JSON message
|
||||||
|
try {
|
||||||
|
const parsedMessage = JSON.parse(message);
|
||||||
|
if (parsedMessage.type === 'loadDicom' && parsedMessage.data) {
|
||||||
|
loadDicom("wadouri:" + parsedMessage.data);
|
||||||
|
document.getElementById('dicomUrl').value = parsedMessage.data;
|
||||||
|
|
||||||
|
// Send success message back to React Native
|
||||||
|
if (window.ReactNativeWebView) {
|
||||||
|
window.ReactNativeWebView.postMessage(JSON.stringify({
|
||||||
|
type: 'success',
|
||||||
|
message: 'Structured DICOM message received and loading started'
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (parseError) {
|
||||||
|
// Message is not JSON, treating as string
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
|
||||||
|
// Send error message back to React Native
|
||||||
|
if (window.ReactNativeWebView) {
|
||||||
|
window.ReactNativeWebView.postMessage(JSON.stringify({
|
||||||
|
type: 'error',
|
||||||
|
message: 'Failed to process message: ' + error.message
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// For Android WebView compatibility
|
||||||
|
document.addEventListener('message', (event) => {
|
||||||
|
window.dispatchEvent(new MessageEvent('message', { data: event.data }));
|
||||||
|
});
|
||||||
|
|
||||||
|
// Notify React Native that WebView is ready
|
||||||
|
window.addEventListener('load', () => {
|
||||||
|
// Initialize slider appearance
|
||||||
|
frameSlider.style.opacity = '0.6';
|
||||||
|
|
||||||
|
if (window.ReactNativeWebView) {
|
||||||
|
window.ReactNativeWebView.postMessage(JSON.stringify({
|
||||||
|
type: 'ready',
|
||||||
|
message: 'DICOM Viewer is ready to receive URLs'
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 🔑 Resize observer for responsiveness
|
||||||
|
window.addEventListener('resize', () => {
|
||||||
|
cornerstone.resize(element, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Loading overlay control functions
|
||||||
|
function showLoading(message = 'Loading DICOM Image...', subtext = 'Please wait while we process your medical image') {
|
||||||
|
if (loadingOverlay) {
|
||||||
|
loadingOverlay.querySelector('.loading-text').textContent = message;
|
||||||
|
loadingOverlay.querySelector('.loading-subtext').textContent = subtext;
|
||||||
|
loadingOverlay.classList.remove('hidden');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function hideLoading() {
|
||||||
|
if (loadingOverlay) {
|
||||||
|
loadingOverlay.classList.add('hidden');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let stack = null;
|
||||||
|
|
||||||
|
// File input
|
||||||
|
document.getElementById('fileInput').addEventListener('change', e => {
|
||||||
|
const files = Array.from(e.target.files);
|
||||||
|
if (!files.length) return;
|
||||||
|
|
||||||
|
// Show loading while processing files
|
||||||
|
if (files.length === 1) {
|
||||||
|
showLoading('Processing DICOM File...', 'Loading selected medical image...');
|
||||||
|
setTimeout(() => {
|
||||||
|
loadDicom(cornerstoneWADOImageLoader.wadouri.fileManager.add(files[0]));
|
||||||
|
}, 100);
|
||||||
|
} else {
|
||||||
|
showLoading('Processing DICOM Files...', `Loading ${files.length} selected medical images...`);
|
||||||
|
setTimeout(() => {
|
||||||
|
loadSeries(files.map(file => cornerstoneWADOImageLoader.wadouri.fileManager.add(file)));
|
||||||
|
}, 100);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// URL load
|
||||||
|
document.getElementById('loadUrlBtn').addEventListener('click', () => {
|
||||||
|
const url = document.getElementById('dicomUrl').value.trim();
|
||||||
|
if (!url) return;
|
||||||
|
showLoading('Loading from URL...', 'Fetching DICOM image from provided URL...');
|
||||||
|
setTimeout(() => {
|
||||||
|
loadDicom("wadouri:" + url);
|
||||||
|
}, 100);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Load dicom
|
||||||
|
function loadDicom(imageId) {
|
||||||
|
// Show loading overlay
|
||||||
|
showLoading('Loading DICOM Image...', 'Processing medical image data, please wait...');
|
||||||
|
|
||||||
|
cornerstone.loadImage(imageId).then(image => {
|
||||||
|
// Hide loading overlay on successful load
|
||||||
|
hideLoading();
|
||||||
|
|
||||||
|
cornerstone.displayImage(element, image);
|
||||||
|
cornerstone.resize(element, true); // ensure fit on load
|
||||||
|
|
||||||
|
const numFrames = parseInt(image.data.string('x00280008') || '1', 10);
|
||||||
|
|
||||||
|
// Always setup frame controls, even for single frames
|
||||||
|
if (numFrames > 1) {
|
||||||
|
stack = { currentImageIdIndex: 0, imageIds: [] };
|
||||||
|
for (let i = 0; i < numFrames; i++) {
|
||||||
|
stack.imageIds.push(imageId + `&frame=${i}`);
|
||||||
|
}
|
||||||
|
setupStack(stack);
|
||||||
|
} else {
|
||||||
|
// Single frame - setup basic stack for consistency
|
||||||
|
stack = { currentImageIdIndex: 0, imageIds: [imageId] };
|
||||||
|
setupStack(stack, true); // Pass true to indicate single frame
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
// Notify React Native of successful load
|
||||||
|
if (window.ReactNativeWebView) {
|
||||||
|
window.ReactNativeWebView.postMessage(JSON.stringify({
|
||||||
|
type: 'success',
|
||||||
|
message: 'DICOM image loaded and displayed successfully',
|
||||||
|
data: {
|
||||||
|
frames: numFrames,
|
||||||
|
imageId: imageId
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
}).catch(err => {
|
||||||
|
// Hide loading overlay on error
|
||||||
|
hideLoading();
|
||||||
|
|
||||||
|
// Notify React Native of error
|
||||||
|
if (window.ReactNativeWebView) {
|
||||||
|
window.ReactNativeWebView.postMessage(JSON.stringify({
|
||||||
|
type: 'error',
|
||||||
|
message: 'Failed to load DICOM image: ' + err.message || err,
|
||||||
|
data: {
|
||||||
|
imageId: imageId,
|
||||||
|
error: err.message || err.toString()
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadSeries(imageIds) {
|
||||||
|
// Show loading overlay for series
|
||||||
|
showLoading('Loading DICOM Series...', `Processing ${imageIds.length} images, please wait...`);
|
||||||
|
|
||||||
|
stack = { currentImageIdIndex: 0, imageIds };
|
||||||
|
cornerstone.loadImage(imageIds[0]).then(image => {
|
||||||
|
// Hide loading overlay on successful load
|
||||||
|
hideLoading();
|
||||||
|
|
||||||
|
cornerstone.displayImage(element, image);
|
||||||
|
cornerstone.resize(element, true);
|
||||||
|
setupStack(stack, imageIds.length === 1);
|
||||||
|
}).catch(err => {
|
||||||
|
// Hide loading overlay on error
|
||||||
|
hideLoading();
|
||||||
|
|
||||||
|
// Handle error silently or send to React Native
|
||||||
|
if (window.ReactNativeWebView) {
|
||||||
|
window.ReactNativeWebView.postMessage(JSON.stringify({
|
||||||
|
type: 'error',
|
||||||
|
message: 'Failed to load DICOM series: ' + err.message || err
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function setupStack(s, isSingleFrame = false) {
|
||||||
|
cornerstoneTools.addStackStateManager(element, ['stack']);
|
||||||
|
cornerstoneTools.addToolState(element, 'stack', s);
|
||||||
|
|
||||||
|
// Handle single frame slider display
|
||||||
|
if (isSingleFrame || s.imageIds.length === 1) {
|
||||||
|
frameSlider.min = 1;
|
||||||
|
frameSlider.max = 2; // Set max to 2 to show proper indicator
|
||||||
|
frameSlider.value = 1;
|
||||||
|
frameSlider.disabled = true; // Disable interaction for single frame
|
||||||
|
frameSlider.style.opacity = '0.6'; // Visual indicator it's disabled
|
||||||
|
} else {
|
||||||
|
frameSlider.min = 1;
|
||||||
|
frameSlider.max = s.imageIds.length;
|
||||||
|
frameSlider.value = 1;
|
||||||
|
frameSlider.disabled = false; // Enable interaction for multiple frames
|
||||||
|
frameSlider.style.opacity = '1'; // Full opacity for enabled state
|
||||||
|
}
|
||||||
|
|
||||||
|
updateFrameInfo(s);
|
||||||
|
|
||||||
|
// Remove existing event listeners to avoid duplicates
|
||||||
|
element.removeEventListener('cornerstonetoolsstackscroll', updateFrameInfo);
|
||||||
|
frameSlider.removeEventListener('input', handleSliderInput);
|
||||||
|
|
||||||
|
// Add event listeners only if not single frame
|
||||||
|
if (!isSingleFrame && s.imageIds.length > 1) {
|
||||||
|
element.addEventListener('cornerstonetoolsstackscroll', () => updateFrameInfo(s));
|
||||||
|
frameSlider.addEventListener('input', handleSliderInput);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSliderInput() {
|
||||||
|
const index = parseInt(frameSlider.value) - 1;
|
||||||
|
changeFrame(index);
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateFrameInfo(s) {
|
||||||
|
frameInfo.textContent = `Image ${s.currentImageIdIndex + 1} of ${s.imageIds.length}`;
|
||||||
|
frameSlider.value = s.currentImageIdIndex + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
function changeFrame(index) {
|
||||||
|
if (!stack) return;
|
||||||
|
if (index < 0 || index >= stack.imageIds.length) return;
|
||||||
|
stack.currentImageIdIndex = index;
|
||||||
|
cornerstone.loadImage(stack.imageIds[index]).then(img => {
|
||||||
|
cornerstone.displayImage(element, img);
|
||||||
|
cornerstone.resize(element, true);
|
||||||
|
updateFrameInfo(stack);
|
||||||
|
}).catch(err => {
|
||||||
|
// Handle frame change error silently
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function prevFrame() { if (stack) changeFrame(stack.currentImageIdIndex - 1); }
|
||||||
|
function nextFrame() { if (stack) changeFrame(stack.currentImageIdIndex + 1); }
|
||||||
|
|
||||||
|
function resetView() { cornerstone.reset(element); }
|
||||||
|
function zoomIn() {
|
||||||
|
const viewport = cornerstone.getViewport(element);
|
||||||
|
viewport.scale *= 1.2;
|
||||||
|
cornerstone.setViewport(element, viewport);
|
||||||
|
}
|
||||||
|
function zoomOut() {
|
||||||
|
const viewport = cornerstone.getViewport(element);
|
||||||
|
viewport.scale /= 1.2;
|
||||||
|
cornerstone.setViewport(element, viewport);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
Before Width: | Height: | Size: 7.1 KiB After Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 7.1 KiB After Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 4.3 KiB After Width: | Height: | Size: 5.7 KiB |
|
Before Width: | Height: | Size: 4.3 KiB After Width: | Height: | Size: 5.7 KiB |
|
Before Width: | Height: | Size: 9.9 KiB After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 9.9 KiB After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 30 KiB |
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 30 KiB |
|
Before Width: | Height: | Size: 24 KiB After Width: | Height: | Size: 47 KiB |
|
Before Width: | Height: | Size: 24 KiB After Width: | Height: | Size: 47 KiB |
|
Before Width: | Height: | Size: 121 KiB After Width: | Height: | Size: 241 KiB |
@ -1,3 +1,3 @@
|
|||||||
<resources>
|
<resources>
|
||||||
<string name="app_name">Radiologist</string>
|
<string name="app_name">Physician</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
@ -1,443 +1,96 @@
|
|||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html>
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>DICOM Viewer Test</title>
|
<title>Dummy Page</title>
|
||||||
<style>
|
<style>
|
||||||
body {
|
body {
|
||||||
margin: 0;
|
|
||||||
padding: 20px;
|
|
||||||
background: #f5f5f5;
|
|
||||||
font-family: Arial, sans-serif;
|
font-family: Arial, sans-serif;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
background: #f5f5f5;
|
||||||
|
color: #333;
|
||||||
}
|
}
|
||||||
.container {
|
header {
|
||||||
max-width: 800px;
|
background: #0073e6;
|
||||||
margin: 0 auto;
|
|
||||||
background: white;
|
|
||||||
padding: 20px;
|
|
||||||
border-radius: 8px;
|
|
||||||
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
|
|
||||||
}
|
|
||||||
.test-section {
|
|
||||||
margin-bottom: 20px;
|
|
||||||
padding: 15px;
|
|
||||||
border: 1px solid #ddd;
|
|
||||||
border-radius: 5px;
|
|
||||||
}
|
|
||||||
.url-input {
|
|
||||||
width: 100%;
|
|
||||||
padding: 10px;
|
|
||||||
border: 1px solid #ccc;
|
|
||||||
border-radius: 4px;
|
|
||||||
margin-bottom: 10px;
|
|
||||||
}
|
|
||||||
.load-button {
|
|
||||||
background: #2196F3;
|
|
||||||
color: white;
|
color: white;
|
||||||
border: none;
|
padding: 1rem;
|
||||||
padding: 10px 20px;
|
text-align: center;
|
||||||
border-radius: 4px;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
}
|
||||||
.load-button:hover {
|
nav {
|
||||||
background: #1976D2;
|
background: #005bb5;
|
||||||
|
padding: 0.5rem;
|
||||||
|
text-align: center;
|
||||||
}
|
}
|
||||||
.viewer-container {
|
nav a {
|
||||||
margin-top: 20px;
|
color: white;
|
||||||
border: 2px solid #ddd;
|
margin: 0 10px;
|
||||||
border-radius: 8px;
|
text-decoration: none;
|
||||||
overflow: hidden;
|
|
||||||
min-height: 400px;
|
|
||||||
}
|
|
||||||
.status {
|
|
||||||
padding: 10px;
|
|
||||||
background: #f0f0f0;
|
|
||||||
border-bottom: 1px solid #ddd;
|
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
}
|
}
|
||||||
#dicomImage {
|
main {
|
||||||
width: 100%;
|
padding: 2rem;
|
||||||
height: 400px;
|
}
|
||||||
background: #000;
|
section {
|
||||||
display: flex;
|
margin-bottom: 2rem;
|
||||||
justify-content: center;
|
}
|
||||||
align-items: center;
|
footer {
|
||||||
|
background: #333;
|
||||||
color: white;
|
color: white;
|
||||||
}
|
text-align: center;
|
||||||
.error {
|
padding: 1rem;
|
||||||
color: #F44336;
|
margin-top: 2rem;
|
||||||
background: #FFEBEE;
|
|
||||||
padding: 10px;
|
|
||||||
border-radius: 4px;
|
|
||||||
margin: 10px 0;
|
|
||||||
}
|
|
||||||
.success {
|
|
||||||
color: #4CAF50;
|
|
||||||
background: #E8F5E8;
|
|
||||||
padding: 10px;
|
|
||||||
border-radius: 4px;
|
|
||||||
margin: 10px 0;
|
|
||||||
}
|
|
||||||
.warning {
|
|
||||||
color: #FF9800;
|
|
||||||
background: #FFF3E0;
|
|
||||||
padding: 10px;
|
|
||||||
border-radius: 4px;
|
|
||||||
margin: 10px 0;
|
|
||||||
}
|
|
||||||
.sample-urls {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
|
|
||||||
gap: 10px;
|
|
||||||
margin-bottom: 20px;
|
|
||||||
}
|
|
||||||
.sample-url {
|
|
||||||
background: #E3F2FD;
|
|
||||||
padding: 10px;
|
|
||||||
border-radius: 4px;
|
|
||||||
cursor: pointer;
|
|
||||||
border: 1px solid #BBDEFB;
|
|
||||||
}
|
|
||||||
.sample-url:hover {
|
|
||||||
background: #BBDEFB;
|
|
||||||
}
|
|
||||||
.dicom-info {
|
|
||||||
background: #F5F5F5;
|
|
||||||
padding: 15px;
|
|
||||||
border-radius: 4px;
|
|
||||||
margin: 15px 0;
|
|
||||||
font-family: monospace;
|
|
||||||
font-size: 12px;
|
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div class="container">
|
<header>
|
||||||
<h1>DICOM Viewer Test</h1>
|
<h1>Welcome to the Dummy Page</h1>
|
||||||
<p>Test the DICOM viewer functionality in your browser before using it in React Native.</p>
|
<p>This is just a placeholder website.</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
<div class="test-section">
|
<nav>
|
||||||
<h3>Sample DICOM URLs</h3>
|
<a href="#">Home</a>
|
||||||
<div class="sample-urls">
|
<a href="#">About</a>
|
||||||
<div class="sample-url" onclick="loadSampleUrl('https://ohif-dicom-json-example.s3.amazonaws.com/LIDC-IDRI-0001/01-01-2000-30178/3000566.000000-03192/1-001.dcm')">
|
<a href="#">Services</a>
|
||||||
<strong>Sample 1:</strong><br>
|
<a href="#">Contact</a>
|
||||||
LIDC-IDRI-0001
|
</nav>
|
||||||
</div>
|
|
||||||
<div class="sample-url" onclick="loadSampleUrl('https://ohif-dicom-json-example.s3.amazonaws.com/LIDC-IDRI-0001/01-01-2000-30178/3000566.000000-03192/1-002.dcm')">
|
|
||||||
<strong>Sample 2:</strong><br>
|
|
||||||
LIDC-IDRI-0001
|
|
||||||
</div>
|
|
||||||
<div class="sample-url" onclick="loadSampleUrl('https://ohif-dicom-json-example.s3.amazonaws.com/LIDC-IDRI-0001/01-01-2000-30178/3000566.000000-03192/1-003.dcm')">
|
|
||||||
<strong>Sample 3:</strong><br>
|
|
||||||
LIDC-IDRI-0001
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="test-section">
|
<main>
|
||||||
<h3>Custom DICOM URL</h3>
|
<section>
|
||||||
<input type="text" id="customUrl" class="url-input" placeholder="Enter DICOM URL here..." />
|
<h2>About Us</h2>
|
||||||
<button onclick="loadCustomUrl()" class="load-button">Load DICOM Image</button>
|
<p>Lorem ipsum dolor sit amet, consectetur adipiscing elit. Donec ac orci vel nisi gravida feugiat.</p>
|
||||||
</div>
|
</section>
|
||||||
|
|
||||||
<div class="viewer-container">
|
<section>
|
||||||
<div class="status" id="status">Ready to load DICOM image</div>
|
<h2>Our Services</h2>
|
||||||
<div id="dicomImage">
|
<ul>
|
||||||
<div>Click a sample URL above or enter a custom URL to load a DICOM image</div>
|
<li>Service One</li>
|
||||||
</div>
|
<li>Service Two</li>
|
||||||
</div>
|
<li>Service Three</li>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
|
||||||
<div id="dicomInfo" class="dicom-info" style="display: none;">
|
<section>
|
||||||
<strong>DICOM Information:</strong><br>
|
<h2>Contact</h2>
|
||||||
<div id="dicomInfoContent"></div>
|
<form>
|
||||||
</div>
|
<label for="name">Name:</label><br>
|
||||||
|
<input type="text" id="name" name="name"><br><br>
|
||||||
|
|
||||||
<div id="messages"></div>
|
<label for="email">Email:</label><br>
|
||||||
</div>
|
<input type="email" id="email" name="email"><br><br>
|
||||||
|
|
||||||
<script>
|
<label for="msg">Message:</label><br>
|
||||||
let cornerstone = null;
|
<textarea id="msg" name="msg" rows="4"></textarea><br><br>
|
||||||
let cornerstoneWADOImageLoader = null;
|
|
||||||
let dicomParser = null;
|
|
||||||
let isLoaded = false;
|
|
||||||
|
|
||||||
// Load sample URL
|
<button type="submit">Send</button>
|
||||||
function loadSampleUrl(url) {
|
</form>
|
||||||
document.getElementById('customUrl').value = url;
|
</section>
|
||||||
loadDicomImage(url);
|
</main>
|
||||||
}
|
|
||||||
|
|
||||||
// Load custom URL
|
<footer>
|
||||||
function loadCustomUrl() {
|
<p>© 2025 Dummy Company. All rights reserved.</p>
|
||||||
const url = document.getElementById('customUrl').value.trim();
|
</footer>
|
||||||
if (url) {
|
|
||||||
loadDicomImage(url);
|
|
||||||
} else {
|
|
||||||
showMessage('Please enter a valid URL', 'error');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Show message
|
|
||||||
function showMessage(message, type = 'info') {
|
|
||||||
const messagesDiv = document.getElementById('messages');
|
|
||||||
const messageDiv = document.createElement('div');
|
|
||||||
messageDiv.className = type;
|
|
||||||
messageDiv.textContent = message;
|
|
||||||
messagesDiv.appendChild(messageDiv);
|
|
||||||
|
|
||||||
// Remove message after 5 seconds
|
|
||||||
setTimeout(() => {
|
|
||||||
messageDiv.remove();
|
|
||||||
}, 5000);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update status
|
|
||||||
function updateStatus(message) {
|
|
||||||
document.getElementById('status').textContent = message;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Show DICOM info
|
|
||||||
function showDicomInfo(info) {
|
|
||||||
const infoDiv = document.getElementById('dicomInfo');
|
|
||||||
const contentDiv = document.getElementById('dicomInfoContent');
|
|
||||||
|
|
||||||
if (info) {
|
|
||||||
contentDiv.innerHTML = info;
|
|
||||||
infoDiv.style.display = 'block';
|
|
||||||
} else {
|
|
||||||
infoDiv.style.display = 'none';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Load libraries
|
|
||||||
async function loadLibraries() {
|
|
||||||
try {
|
|
||||||
updateStatus('Loading DICOM viewer libraries...');
|
|
||||||
|
|
||||||
// Load DICOM Parser first
|
|
||||||
await loadScript('https://unpkg.com/dicom-parser@1.8.6/dist/dicomParser.min.js');
|
|
||||||
dicomParser = window.dicomParser;
|
|
||||||
showMessage('DICOM Parser loaded successfully', 'success');
|
|
||||||
|
|
||||||
// Load Cornerstone Core
|
|
||||||
await loadScript('https://unpkg.com/cornerstone-core@2.3.0/dist/cornerstone.js');
|
|
||||||
cornerstone = window.cornerstone;
|
|
||||||
|
|
||||||
// Load Cornerstone WADO Image Loader with fallback
|
|
||||||
await loadCornerstoneWADO();
|
|
||||||
|
|
||||||
isLoaded = true;
|
|
||||||
updateStatus('Libraries loaded successfully');
|
|
||||||
showMessage('All DICOM viewer libraries loaded successfully', 'success');
|
|
||||||
|
|
||||||
// Initialize viewer
|
|
||||||
const element = document.getElementById('dicomImage');
|
|
||||||
cornerstone.enable(element);
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
updateStatus('Failed to load libraries');
|
|
||||||
showMessage(`Failed to load libraries: ${error.message}`, 'error');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Load script
|
|
||||||
function loadScript(src) {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
const script = document.createElement('script');
|
|
||||||
script.src = src;
|
|
||||||
script.onload = resolve;
|
|
||||||
script.onerror = () => reject(new Error(`Failed to load script: ${src}`));
|
|
||||||
document.head.appendChild(script);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Load Cornerstone WADO Image Loader with fallback
|
|
||||||
function loadCornerstoneWADO() {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
updateStatus('Loading Cornerstone WADO Image Loader...');
|
|
||||||
|
|
||||||
// Try multiple sources for WADO loader
|
|
||||||
const wadoSources = [
|
|
||||||
'https://unpkg.com/cornerstone-wado-image-loader@4.17.1/dist/cornerstoneWADOImageLoader.js',
|
|
||||||
'https://unpkg.com/cornerstone-wado-image-loader@4.16.0/dist/cornerstoneWADOImageLoader.js',
|
|
||||||
'https://unpkg.com/cornerstone-wado-image-loader@4.15.0/dist/cornerstoneWADOImageLoader.js',
|
|
||||||
'https://cdn.jsdelivr.net/npm/cornerstone-wado-image-loader@4.17.1/dist/cornerstoneWADOImageLoader.js'
|
|
||||||
];
|
|
||||||
|
|
||||||
let currentSourceIndex = 0;
|
|
||||||
|
|
||||||
function tryNextSource() {
|
|
||||||
if (currentSourceIndex >= wadoSources.length) {
|
|
||||||
reject(new Error('All WADO Image Loader sources failed'));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const currentSource = wadoSources[currentSourceIndex];
|
|
||||||
updateStatus(`Trying WADO source ${currentSourceIndex + 1}: ${currentSource.split('/').pop()}`);
|
|
||||||
|
|
||||||
const wadoScript = document.createElement('script');
|
|
||||||
wadoScript.src = currentSource;
|
|
||||||
|
|
||||||
wadoScript.onload = () => {
|
|
||||||
try {
|
|
||||||
cornerstoneWADOImageLoader = window.cornerstoneWADOImageLoader;
|
|
||||||
if (cornerstoneWADOImageLoader && cornerstone) {
|
|
||||||
cornerstoneWADOImageLoader.external.cornerstone = cornerstone;
|
|
||||||
showMessage(`WADO Image Loader loaded successfully from: ${currentSource.split('/').pop()}`, 'success');
|
|
||||||
resolve();
|
|
||||||
} else {
|
|
||||||
throw new Error('WADO loader not properly initialized');
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
showMessage(`WADO loader initialization failed: ${error.message}`, 'warning');
|
|
||||||
currentSourceIndex++;
|
|
||||||
tryNextSource();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
wadoScript.onerror = (error) => {
|
|
||||||
showMessage(`Failed to load WADO from ${currentSource.split('/').pop()}`, 'warning');
|
|
||||||
currentSourceIndex++;
|
|
||||||
tryNextSource();
|
|
||||||
};
|
|
||||||
|
|
||||||
// Set timeout for loading
|
|
||||||
const timeout = setTimeout(() => {
|
|
||||||
showMessage(`WADO loader timeout from ${currentSource.split('/').pop()}`, 'warning');
|
|
||||||
currentSourceIndex++;
|
|
||||||
tryNextSource();
|
|
||||||
}, 8000);
|
|
||||||
|
|
||||||
wadoScript.onload = () => {
|
|
||||||
clearTimeout(timeout);
|
|
||||||
try {
|
|
||||||
cornerstoneWADOImageLoader = window.cornerstoneWADOImageLoader;
|
|
||||||
if (cornerstoneWADOImageLoader && cornerstone) {
|
|
||||||
cornerstoneWADOImageLoader.external.cornerstone = cornerstone;
|
|
||||||
showMessage(`WADO Image Loader loaded successfully from: ${currentSource.split('/').pop()}`, 'success');
|
|
||||||
resolve();
|
|
||||||
} else {
|
|
||||||
throw new Error('WADO loader not properly initialized');
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
showMessage(`WADO loader initialization failed: ${error.message}`, 'warning');
|
|
||||||
currentSourceIndex++;
|
|
||||||
tryNextSource();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
document.head.appendChild(wadoScript);
|
|
||||||
}
|
|
||||||
|
|
||||||
tryNextSource();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Load DICOM image
|
|
||||||
async function loadDicomImage(url) {
|
|
||||||
if (!isLoaded) {
|
|
||||||
showMessage('Libraries not loaded yet, please wait...', 'error');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
updateStatus('Loading DICOM image...');
|
|
||||||
showMessage(`Loading DICOM image from: ${url}`, 'info');
|
|
||||||
|
|
||||||
// Test URL accessibility
|
|
||||||
await testUrl(url);
|
|
||||||
|
|
||||||
// Validate DICOM with parser
|
|
||||||
let dicomInfo = null;
|
|
||||||
try {
|
|
||||||
dicomInfo = await validateDicomWithParser(url);
|
|
||||||
showMessage('DICOM file validated successfully', 'success');
|
|
||||||
} catch (parserError) {
|
|
||||||
showMessage(`DICOM validation warning: ${parserError.message}`, 'warning');
|
|
||||||
}
|
|
||||||
|
|
||||||
const element = document.getElementById('dicomImage');
|
|
||||||
const image = await cornerstone.loadImage(`wadouri:${url}`);
|
|
||||||
cornerstone.displayImage(element, image);
|
|
||||||
|
|
||||||
updateStatus('DICOM image loaded successfully');
|
|
||||||
showMessage('DICOM image loaded successfully!', 'success');
|
|
||||||
|
|
||||||
// Display DICOM information if available
|
|
||||||
if (dicomInfo) {
|
|
||||||
displayDicomInfo(dicomInfo);
|
|
||||||
}
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
updateStatus('Failed to load DICOM image');
|
|
||||||
showMessage(`Failed to load DICOM image: ${error.message}`, 'error');
|
|
||||||
showDicomInfo(null);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate DICOM with parser
|
|
||||||
async function validateDicomWithParser(url) {
|
|
||||||
try {
|
|
||||||
const response = await fetch(url);
|
|
||||||
const arrayBuffer = await response.arrayBuffer();
|
|
||||||
|
|
||||||
if (!dicomParser) {
|
|
||||||
throw new Error('DICOM Parser not available');
|
|
||||||
}
|
|
||||||
|
|
||||||
const dataSet = dicomParser.parseDicom(arrayBuffer);
|
|
||||||
return dataSet;
|
|
||||||
} catch (error) {
|
|
||||||
throw new Error(`DICOM validation failed: ${error.message}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Display DICOM information
|
|
||||||
function displayDicomInfo(dataSet) {
|
|
||||||
try {
|
|
||||||
const info = [
|
|
||||||
`Patient Name: ${dataSet.string('x00100010') || 'Unknown'}`,
|
|
||||||
`Patient ID: ${dataSet.string('x00100020') || 'Unknown'}`,
|
|
||||||
`Modality: ${dataSet.string('x00080060') || 'Unknown'}`,
|
|
||||||
`Study Date: ${dataSet.string('x00080020') || 'Unknown'}`,
|
|
||||||
`Study Description: ${dataSet.string('x00081030') || 'Unknown'}`,
|
|
||||||
`Manufacturer: ${dataSet.string('x00080070') || 'Unknown'}`,
|
|
||||||
`Image Size: ${dataSet.uint16('x00280010') || 'Unknown'} x ${dataSet.uint16('x00280011') || 'Unknown'}`,
|
|
||||||
`Bits Allocated: ${dataSet.uint16('x00280100') || 'Unknown'}`,
|
|
||||||
`Samples per Pixel: ${dataSet.uint16('x00280002') || 'Unknown'}`
|
|
||||||
].join('<br>');
|
|
||||||
|
|
||||||
showDicomInfo(info);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error displaying DICOM info:', error);
|
|
||||||
showDicomInfo('Error displaying DICOM information');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Test URL accessibility
|
|
||||||
async function testUrl(url) {
|
|
||||||
try {
|
|
||||||
const response = await fetch(url, { method: 'HEAD' });
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const contentType = response.headers.get('content-type');
|
|
||||||
console.log('Content-Type:', contentType);
|
|
||||||
|
|
||||||
if (contentType && !contentType.includes('application/dicom') && !contentType.includes('image/')) {
|
|
||||||
console.warn('Warning: Unexpected content type:', contentType);
|
|
||||||
}
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
throw new Error(`URL not accessible: ${error.message}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Initialize when page loads
|
|
||||||
window.addEventListener('load', loadLibraries);
|
|
||||||
</script>
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@ -18,7 +18,6 @@ export const login = createAsyncThunk(
|
|||||||
async (credentials: { email: string; password: string }, { rejectWithValue }) => {
|
async (credentials: { email: string; password: string }, { rejectWithValue }) => {
|
||||||
try {
|
try {
|
||||||
const response:any = await authAPI.login(credentials.email, credentials.password,'web');
|
const response:any = await authAPI.login(credentials.email, credentials.password,'web');
|
||||||
console.log('user response',response)
|
|
||||||
|
|
||||||
if(response.data.message && !response.data.success){
|
if(response.data.message && !response.data.success){
|
||||||
showError(response.data.message)
|
showError(response.data.message)
|
||||||
@ -31,7 +30,7 @@ export const login = createAsyncThunk(
|
|||||||
|
|
||||||
if (response.ok && response.data && response.data.data) {
|
if (response.ok && response.data && response.data.data) {
|
||||||
// Return the user data for the fulfilled case
|
// Return the user data for the fulfilled case
|
||||||
if(response.data.data.user.dashboard_role !=='radiologist'){
|
if(response.data.data.user.dashboard_role !=='er_physician'){
|
||||||
showWarning('You are not authorized to access this application')
|
showWarning('You are not authorized to access this application')
|
||||||
return rejectWithValue('Not Authorized');
|
return rejectWithValue('Not Authorized');
|
||||||
}
|
}
|
||||||
|
|||||||
@ -138,7 +138,7 @@ const LoginScreen: React.FC<LoginScreenProps> = ({ navigation }) => {
|
|||||||
* HEADER SECTION - App branding and title
|
* HEADER SECTION - App branding and title
|
||||||
* ======================================================================== */}
|
* ======================================================================== */}
|
||||||
<View style={styles.header}>
|
<View style={styles.header}>
|
||||||
<Text style={styles.title}>Radiologist</Text>
|
<Text style={styles.title}>Physician</Text>
|
||||||
{/* <Text style={styles.subtitle}>Emergency Department Access</Text> */}
|
{/* <Text style={styles.subtitle}>Emergency Department Access</Text> */}
|
||||||
</View>
|
</View>
|
||||||
<View style={styles.imageContainer}>
|
<View style={styles.imageContainer}>
|
||||||
|
|||||||
@ -226,7 +226,7 @@ const SignUpScreen: React.FC<SignUpScreenProps> = ({ navigation }) => {
|
|||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
let role = 'radiologist';
|
let role = 'er_physician';
|
||||||
|
|
||||||
// Prepare form data with proper file handling
|
// Prepare form data with proper file handling
|
||||||
const formFields = {
|
const formFields = {
|
||||||
|
|||||||
455
app/modules/Dashboard/components/PredictionCard.tsx
Normal file
@ -0,0 +1,455 @@
|
|||||||
|
/*
|
||||||
|
* File: PredictionCard.tsx
|
||||||
|
* Description: Prediction card component for displaying AI prediction data and patient information
|
||||||
|
* Design & Developed by Tech4Biz Solutions
|
||||||
|
* Copyright (c) Spurrin Innovations. All rights reserved.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import {
|
||||||
|
View,
|
||||||
|
Text,
|
||||||
|
TouchableOpacity,
|
||||||
|
StyleSheet,
|
||||||
|
Image,
|
||||||
|
} from 'react-native';
|
||||||
|
import Icon from 'react-native-vector-icons/Feather';
|
||||||
|
import { theme } from '../../../theme/theme';
|
||||||
|
import type { PredictionData } from '../types/predictions';
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// INTERFACES
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
interface PredictionCardProps {
|
||||||
|
prediction: PredictionData;
|
||||||
|
onPress: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// PREDICTION CARD COMPONENT
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PredictionCard Component
|
||||||
|
*
|
||||||
|
* Purpose: Display AI prediction data with patient information in a card format
|
||||||
|
*
|
||||||
|
* Features:
|
||||||
|
* - Patient basic information
|
||||||
|
* - AI prediction results
|
||||||
|
* - Confidence scores
|
||||||
|
* - Clinical urgency
|
||||||
|
* - Feedback information
|
||||||
|
* - Modern card design
|
||||||
|
*/
|
||||||
|
export const PredictionCard: React.FC<PredictionCardProps> = ({
|
||||||
|
prediction,
|
||||||
|
onPress,
|
||||||
|
}) => {
|
||||||
|
// ============================================================================
|
||||||
|
// UTILITY FUNCTIONS
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get Urgency Color Configuration
|
||||||
|
*
|
||||||
|
* Purpose: Get color and icon based on clinical urgency
|
||||||
|
*/
|
||||||
|
const getUrgencyConfig = (urgency: string) => {
|
||||||
|
switch (urgency.toLowerCase()) {
|
||||||
|
case 'critical':
|
||||||
|
return {
|
||||||
|
color: theme.colors.error,
|
||||||
|
icon: 'alert-triangle',
|
||||||
|
bgColor: '#FFEBEE'
|
||||||
|
};
|
||||||
|
case 'urgent':
|
||||||
|
return {
|
||||||
|
color: theme.colors.warning,
|
||||||
|
icon: 'clock',
|
||||||
|
bgColor: '#FFF3E0'
|
||||||
|
};
|
||||||
|
case 'non-urgent':
|
||||||
|
return {
|
||||||
|
color: theme.colors.success,
|
||||||
|
icon: 'check-circle',
|
||||||
|
bgColor: '#E8F5E8'
|
||||||
|
};
|
||||||
|
default:
|
||||||
|
return {
|
||||||
|
color: theme.colors.primary,
|
||||||
|
icon: 'info',
|
||||||
|
bgColor: '#E3F2FD'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get Confidence Color
|
||||||
|
*
|
||||||
|
* Purpose: Get color based on confidence score
|
||||||
|
*/
|
||||||
|
const getConfidenceColor = (confidence: number) => {
|
||||||
|
if (confidence >= 0.8) return theme.colors.success;
|
||||||
|
if (confidence >= 0.6) return theme.colors.warning;
|
||||||
|
return theme.colors.error;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format Date
|
||||||
|
*
|
||||||
|
* Purpose: Format processed date for display
|
||||||
|
*/
|
||||||
|
const formatDate = (dateString: string) => {
|
||||||
|
const date = new Date(dateString);
|
||||||
|
return date.toLocaleDateString('en-US', {
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
year: 'numeric',
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// RENDER
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
const urgencyConfig = getUrgencyConfig(prediction.prediction.clinical_urgency);
|
||||||
|
const confidenceColor = getConfidenceColor(prediction.prediction.confidence_score);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TouchableOpacity
|
||||||
|
style={styles.container}
|
||||||
|
onPress={onPress}
|
||||||
|
activeOpacity={0.7}
|
||||||
|
>
|
||||||
|
{/* Header Section */}
|
||||||
|
<View style={styles.header}>
|
||||||
|
<View style={styles.patientInfo}>
|
||||||
|
<Text style={styles.patientName}>
|
||||||
|
{prediction.patientdetails.Name || 'Unknown Patient'}
|
||||||
|
</Text>
|
||||||
|
<Text style={styles.patientDetails}>
|
||||||
|
{prediction.patientdetails.PatID} • {prediction.patientdetails.PatAge} • {prediction.patientdetails.PatSex}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Urgency Badge */}
|
||||||
|
<View style={[styles.urgencyBadge, { backgroundColor: urgencyConfig.bgColor }]}>
|
||||||
|
<Icon name={urgencyConfig.icon} size={16} color={urgencyConfig.color} />
|
||||||
|
<Text style={[styles.urgencyText, { color: urgencyConfig.color }]}>
|
||||||
|
{prediction.prediction.clinical_urgency}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Prediction Results Section */}
|
||||||
|
<View style={styles.predictionSection}>
|
||||||
|
<View style={styles.predictionHeader}>
|
||||||
|
<Icon name="activity" size={20} color={theme.colors.primary} />
|
||||||
|
<Text style={styles.predictionTitle}>AI Prediction</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.predictionDetails}>
|
||||||
|
<View style={styles.predictionRow}>
|
||||||
|
<Text style={styles.predictionLabel}>Finding:</Text>
|
||||||
|
<Text style={styles.predictionValue}>
|
||||||
|
{prediction.prediction.label}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.predictionRow}>
|
||||||
|
<Text style={styles.predictionLabel}>Type:</Text>
|
||||||
|
<Text style={styles.predictionValue}>
|
||||||
|
{prediction.prediction.finding_type}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.predictionRow}>
|
||||||
|
<Text style={styles.predictionLabel}>Confidence:</Text>
|
||||||
|
<View style={styles.confidenceContainer}>
|
||||||
|
<Text style={[styles.confidenceValue, { color: confidenceColor }]}>
|
||||||
|
{(prediction.prediction.confidence_score * 100).toFixed(1)}%
|
||||||
|
</Text>
|
||||||
|
<View style={[styles.confidenceBar, { backgroundColor: confidenceColor }]} />
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Medical Information Section */}
|
||||||
|
<View style={styles.medicalSection}>
|
||||||
|
<View style={styles.medicalRow}>
|
||||||
|
<View style={styles.medicalItem}>
|
||||||
|
<Icon name="home" size={14} color={theme.colors.textSecondary} />
|
||||||
|
<Text style={styles.medicalText} numberOfLines={1} ellipsizeMode="tail">
|
||||||
|
{prediction.patientdetails.InstName || 'Unknown Institution'}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.medicalItem}>
|
||||||
|
<Icon name="activity" size={14} color={theme.colors.textSecondary} />
|
||||||
|
<Text style={styles.medicalText} numberOfLines={1} ellipsizeMode="tail">
|
||||||
|
{prediction.patientdetails.Modality || 'Unknown Modality'}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.medicalRow}>
|
||||||
|
<View style={styles.medicalItem}>
|
||||||
|
<Icon name="calendar" size={14} color={theme.colors.textSecondary} />
|
||||||
|
<Text style={styles.medicalText} numberOfLines={1} ellipsizeMode="tail">
|
||||||
|
{formatDate(prediction.processed_at)}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.medicalItem}>
|
||||||
|
<Icon name="layers" size={14} color={theme.colors.textSecondary} />
|
||||||
|
<Text style={styles.medicalText} numberOfLines={1} ellipsizeMode="tail">
|
||||||
|
{prediction.prediction.processing_info.frame_count} Frames
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Feedback Section */}
|
||||||
|
{prediction.has_provided_feedback && (
|
||||||
|
<View style={styles.feedbackSection}>
|
||||||
|
<View style={styles.feedbackHeader}>
|
||||||
|
<Icon name="message-circle" size={16} color={theme.colors.success} />
|
||||||
|
<Text style={styles.feedbackTitle}>Feedback Available</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.feedbackDetails}>
|
||||||
|
<Text style={styles.feedbackCount}>
|
||||||
|
{prediction.user_feedback_count} feedback(s)
|
||||||
|
</Text>
|
||||||
|
<Text style={styles.feedbackType}>
|
||||||
|
Latest: {prediction.latest_feedback_type}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<View style={styles.footer}>
|
||||||
|
<Text style={styles.processedText}>
|
||||||
|
Processed: {formatDate(prediction.processed_at)}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<View style={styles.actionButton}>
|
||||||
|
<Icon name="chevron-right" size={16} color={theme.colors.primary} />
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</TouchableOpacity>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// STYLES
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
backgroundColor: theme.colors.background,
|
||||||
|
borderRadius: theme.borderRadius.large,
|
||||||
|
padding: theme.spacing.md,
|
||||||
|
marginBottom: theme.spacing.md,
|
||||||
|
...theme.shadows.primary,
|
||||||
|
},
|
||||||
|
|
||||||
|
header: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'flex-start',
|
||||||
|
marginBottom: theme.spacing.md,
|
||||||
|
},
|
||||||
|
|
||||||
|
patientInfo: {
|
||||||
|
flex: 1,
|
||||||
|
marginRight: theme.spacing.sm,
|
||||||
|
},
|
||||||
|
|
||||||
|
patientName: {
|
||||||
|
fontSize: theme.typography.fontSize.displaySmall,
|
||||||
|
fontFamily: theme.typography.fontFamily.bold,
|
||||||
|
color: theme.colors.textPrimary,
|
||||||
|
marginBottom: theme.spacing.xs,
|
||||||
|
},
|
||||||
|
|
||||||
|
patientDetails: {
|
||||||
|
fontSize: theme.typography.fontSize.bodySmall,
|
||||||
|
fontFamily: theme.typography.fontFamily.regular,
|
||||||
|
color: theme.colors.textSecondary,
|
||||||
|
},
|
||||||
|
|
||||||
|
urgencyBadge: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
paddingHorizontal: theme.spacing.sm,
|
||||||
|
paddingVertical: theme.spacing.xs,
|
||||||
|
borderRadius: theme.borderRadius.small,
|
||||||
|
gap: theme.spacing.xs,
|
||||||
|
},
|
||||||
|
|
||||||
|
urgencyText: {
|
||||||
|
fontSize: theme.typography.fontSize.bodySmall,
|
||||||
|
fontFamily: theme.typography.fontFamily.medium,
|
||||||
|
textTransform: 'capitalize',
|
||||||
|
},
|
||||||
|
|
||||||
|
predictionSection: {
|
||||||
|
marginBottom: theme.spacing.md,
|
||||||
|
},
|
||||||
|
|
||||||
|
predictionHeader: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
marginBottom: theme.spacing.sm,
|
||||||
|
gap: theme.spacing.sm,
|
||||||
|
},
|
||||||
|
|
||||||
|
predictionTitle: {
|
||||||
|
fontSize: theme.typography.fontSize.bodyMedium,
|
||||||
|
fontFamily: theme.typography.fontFamily.bold,
|
||||||
|
color: theme.colors.textPrimary,
|
||||||
|
},
|
||||||
|
|
||||||
|
predictionDetails: {
|
||||||
|
gap: theme.spacing.xs,
|
||||||
|
},
|
||||||
|
|
||||||
|
predictionRow: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
|
||||||
|
predictionLabel: {
|
||||||
|
fontSize: theme.typography.fontSize.bodySmall,
|
||||||
|
fontFamily: theme.typography.fontFamily.medium,
|
||||||
|
color: theme.colors.textSecondary,
|
||||||
|
},
|
||||||
|
|
||||||
|
predictionValue: {
|
||||||
|
fontSize: theme.typography.fontSize.bodySmall,
|
||||||
|
fontFamily: theme.typography.fontFamily.bold,
|
||||||
|
color: theme.colors.textPrimary,
|
||||||
|
textTransform: 'capitalize',
|
||||||
|
},
|
||||||
|
|
||||||
|
confidenceContainer: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: theme.spacing.xs,
|
||||||
|
},
|
||||||
|
|
||||||
|
confidenceValue: {
|
||||||
|
fontSize: theme.typography.fontSize.bodySmall,
|
||||||
|
fontFamily: theme.typography.fontFamily.bold,
|
||||||
|
},
|
||||||
|
|
||||||
|
confidenceBar: {
|
||||||
|
width: 20,
|
||||||
|
height: 4,
|
||||||
|
borderRadius: 2,
|
||||||
|
},
|
||||||
|
|
||||||
|
medicalSection: {
|
||||||
|
marginBottom: theme.spacing.md,
|
||||||
|
paddingTop: theme.spacing.md,
|
||||||
|
borderTopWidth: 1,
|
||||||
|
borderTopColor: theme.colors.border,
|
||||||
|
},
|
||||||
|
|
||||||
|
medicalRow: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
marginBottom: theme.spacing.sm,
|
||||||
|
},
|
||||||
|
|
||||||
|
medicalItem: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: theme.spacing.md,
|
||||||
|
flex: 1,
|
||||||
|
maxWidth: '48%', // Prevent items from taking too much space
|
||||||
|
},
|
||||||
|
|
||||||
|
medicalText: {
|
||||||
|
fontSize: theme.typography.fontSize.bodySmall,
|
||||||
|
fontFamily: theme.typography.fontFamily.regular,
|
||||||
|
color: theme.colors.textSecondary,
|
||||||
|
flex: 1, // Allow text to take remaining space
|
||||||
|
height: 20, // Uniform height for all medical text
|
||||||
|
lineHeight: 20, // Ensure text is vertically centered
|
||||||
|
},
|
||||||
|
|
||||||
|
feedbackSection: {
|
||||||
|
marginBottom: theme.spacing.md,
|
||||||
|
paddingTop: theme.spacing.md,
|
||||||
|
borderTopWidth: 1,
|
||||||
|
borderTopColor: theme.colors.border,
|
||||||
|
},
|
||||||
|
|
||||||
|
feedbackHeader: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
marginBottom: theme.spacing.xs,
|
||||||
|
gap: theme.spacing.xs,
|
||||||
|
},
|
||||||
|
|
||||||
|
feedbackTitle: {
|
||||||
|
fontSize: theme.typography.fontSize.bodySmall,
|
||||||
|
fontFamily: theme.typography.fontFamily.medium,
|
||||||
|
color: theme.colors.success,
|
||||||
|
},
|
||||||
|
|
||||||
|
feedbackDetails: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
},
|
||||||
|
|
||||||
|
feedbackCount: {
|
||||||
|
fontSize: theme.typography.fontSize.bodySmall,
|
||||||
|
fontFamily: theme.typography.fontFamily.regular,
|
||||||
|
color: theme.colors.textSecondary,
|
||||||
|
},
|
||||||
|
|
||||||
|
feedbackType: {
|
||||||
|
fontSize: theme.typography.fontSize.bodySmall,
|
||||||
|
fontFamily: theme.typography.fontFamily.medium,
|
||||||
|
color: theme.colors.textPrimary,
|
||||||
|
textTransform: 'capitalize',
|
||||||
|
},
|
||||||
|
|
||||||
|
footer: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
paddingTop: theme.spacing.md,
|
||||||
|
borderTopWidth: 1,
|
||||||
|
borderTopColor: theme.colors.border,
|
||||||
|
},
|
||||||
|
|
||||||
|
processedText: {
|
||||||
|
fontSize: theme.typography.fontSize.bodySmall,
|
||||||
|
fontFamily: theme.typography.fontFamily.regular,
|
||||||
|
color: theme.colors.textMuted,
|
||||||
|
},
|
||||||
|
|
||||||
|
actionButton: {
|
||||||
|
width: 32,
|
||||||
|
height: 32,
|
||||||
|
borderRadius: 16,
|
||||||
|
backgroundColor: theme.colors.backgroundAlt,
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
/*
|
||||||
|
* End of File: PredictionCard.tsx
|
||||||
|
* Design & Developed by Tech4Biz Solutions
|
||||||
|
* Copyright (c) Spurrin Innovations. All rights reserved.
|
||||||
|
*/
|
||||||
377
app/modules/Dashboard/components/PredictionsList.tsx
Normal file
@ -0,0 +1,377 @@
|
|||||||
|
/*
|
||||||
|
* File: PredictionsList.tsx
|
||||||
|
* Description: Predictions list component with tabbed interface for feedback status
|
||||||
|
* Design & Developed by Tech4Biz Solutions
|
||||||
|
* Copyright (c) Spurrin Innovations. All rights reserved.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import {
|
||||||
|
View,
|
||||||
|
Text,
|
||||||
|
StyleSheet,
|
||||||
|
TouchableOpacity,
|
||||||
|
FlatList,
|
||||||
|
RefreshControl,
|
||||||
|
} from 'react-native';
|
||||||
|
import Icon from 'react-native-vector-icons/Feather';
|
||||||
|
import { theme } from '../../../theme/theme';
|
||||||
|
import { PredictionCard } from './PredictionCard';
|
||||||
|
import { usePredictions } from '../hooks/usePredictions';
|
||||||
|
import type { PredictionData } from '../types/predictions';
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// INTERFACES
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
interface PredictionsListProps {
|
||||||
|
onPredictionPress: (prediction: PredictionData) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// PREDICTIONS LIST COMPONENT
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PredictionsList Component
|
||||||
|
*
|
||||||
|
* Purpose: Display AI predictions organized by radiologist feedback status
|
||||||
|
*
|
||||||
|
* Features:
|
||||||
|
* - Two tabs: Radiologist Reviewed and Pending Review
|
||||||
|
* - Search functionality
|
||||||
|
* - Pull-to-refresh
|
||||||
|
* - Loading states
|
||||||
|
* - Error handling
|
||||||
|
* - Empty states
|
||||||
|
*/
|
||||||
|
export const PredictionsList: React.FC<PredictionsListProps> = ({
|
||||||
|
onPredictionPress,
|
||||||
|
}) => {
|
||||||
|
const {
|
||||||
|
activeTab,
|
||||||
|
currentPredictions,
|
||||||
|
currentLoadingState,
|
||||||
|
currentError,
|
||||||
|
switchTab,
|
||||||
|
refreshPredictions,
|
||||||
|
} = usePredictions();
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// UTILITY FUNCTIONS
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle Tab Switch
|
||||||
|
*
|
||||||
|
* Purpose: Switch between radiologist feedback status tabs
|
||||||
|
*/
|
||||||
|
const handleTabSwitch = (tab: 'with-feedback' | 'without-feedback') => {
|
||||||
|
switchTab(tab);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle Prediction Press
|
||||||
|
*
|
||||||
|
* Purpose: Handle when a prediction card is pressed
|
||||||
|
*/
|
||||||
|
const handlePredictionPress = (prediction: PredictionData) => {
|
||||||
|
onPredictionPress(prediction);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render Tab Button
|
||||||
|
*
|
||||||
|
* Purpose: Render individual radiologist feedback status tab button
|
||||||
|
*/
|
||||||
|
const renderTabButton = (tab: 'with-feedback' | 'without-feedback', label: string, icon: string) => {
|
||||||
|
const isActive = activeTab === tab;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[styles.tabButton, isActive && styles.activeTabButton]}
|
||||||
|
onPress={() => handleTabSwitch(tab)}
|
||||||
|
activeOpacity={0.7}
|
||||||
|
>
|
||||||
|
<Icon
|
||||||
|
name={icon}
|
||||||
|
size={20}
|
||||||
|
color={isActive ? theme.colors.primary : theme.colors.textSecondary}
|
||||||
|
/>
|
||||||
|
<Text style={[styles.tabButtonText, isActive && styles.activeTabButtonText]}>
|
||||||
|
{label}
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render Empty State
|
||||||
|
*
|
||||||
|
* Purpose: Render empty state when no predictions available
|
||||||
|
*/
|
||||||
|
const renderEmptyState = () => (
|
||||||
|
<View style={styles.emptyState}>
|
||||||
|
<Icon name="inbox" size={48} color={theme.colors.textMuted} />
|
||||||
|
<Text style={styles.emptyStateTitle}>No Predictions Found</Text>
|
||||||
|
<Text style={styles.emptyStateSubtitle}>
|
||||||
|
{activeTab === 'with-feedback'
|
||||||
|
? 'No predictions have been reviewed by radiologists yet.'
|
||||||
|
: 'No predictions are waiting for radiologist review at the moment.'
|
||||||
|
}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render Error State
|
||||||
|
*
|
||||||
|
* Purpose: Render error state when API call fails
|
||||||
|
*/
|
||||||
|
const renderErrorState = () => (
|
||||||
|
<View style={styles.errorState}>
|
||||||
|
<Icon name="alert-circle" size={48} color={theme.colors.error} />
|
||||||
|
<Text style={styles.errorStateTitle}>Something went wrong</Text>
|
||||||
|
<Text style={styles.errorStateSubtitle}>
|
||||||
|
{currentError || 'Failed to load predictions. Please try again.'}
|
||||||
|
</Text>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={styles.retryButton}
|
||||||
|
onPress={refreshPredictions}
|
||||||
|
activeOpacity={0.7}
|
||||||
|
>
|
||||||
|
<Icon name="refresh-cw" size={16} color={'white'} />
|
||||||
|
<Text style={styles.retryButtonText}>Retry</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// RENDER
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
// Debug logging to see current state
|
||||||
|
console.log('🔍 PredictionsList render debug:');
|
||||||
|
console.log('Active tab:', activeTab);
|
||||||
|
console.log('Current predictions count:', currentPredictions.length);
|
||||||
|
console.log('Current predictions sample:', currentPredictions.slice(0, 2).map(p => ({
|
||||||
|
id: p.id,
|
||||||
|
has_feedback: p.has_provided_feedback,
|
||||||
|
feedbacks_count: p.feedbacks?.length || 0
|
||||||
|
})));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={styles.container}>
|
||||||
|
{/* Tab Navigation */}
|
||||||
|
<View style={styles.tabContainer}>
|
||||||
|
{renderTabButton('with-feedback', 'Radiologist Reviewed', 'message-circle')}
|
||||||
|
{renderTabButton('without-feedback', 'Pending Review', 'message-square')}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Content Area */}
|
||||||
|
<View style={styles.contentContainer}>
|
||||||
|
{currentError ? (
|
||||||
|
renderErrorState()
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{/* Fixed Header - Not part of scrolling */}
|
||||||
|
{currentPredictions.length > 0 && (
|
||||||
|
<View style={styles.listHeader}>
|
||||||
|
<Text style={styles.listHeaderTitle}>
|
||||||
|
{activeTab === 'with-feedback' ? 'Radiologist Reviewed Predictions' : 'Predictions Awaiting Review'}
|
||||||
|
</Text>
|
||||||
|
<Text style={styles.listHeaderSubtitle}>
|
||||||
|
{currentPredictions.length} prediction{currentPredictions.length !== 1 ? 's' : ''} found
|
||||||
|
{activeTab === 'with-feedback' && (
|
||||||
|
<Text style={styles.feedbackCount}>
|
||||||
|
{' • '}{currentPredictions.filter(p => p.feedbacks && p.feedbacks.length > 0).length} with feedback
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Horizontal Scrolling Predictions */}
|
||||||
|
<FlatList
|
||||||
|
data={currentPredictions}
|
||||||
|
renderItem={({ item }) => (
|
||||||
|
<View style={styles.predictionCardWrapper}>
|
||||||
|
<PredictionCard
|
||||||
|
prediction={item}
|
||||||
|
onPress={() => handlePredictionPress(item)}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
keyExtractor={(item) => item.id.toString()}
|
||||||
|
contentContainerStyle={styles.listContainer}
|
||||||
|
showsHorizontalScrollIndicator={true}
|
||||||
|
showsVerticalScrollIndicator={false}
|
||||||
|
horizontal={true}
|
||||||
|
scrollEnabled={true}
|
||||||
|
refreshControl={
|
||||||
|
<RefreshControl
|
||||||
|
refreshing={currentLoadingState}
|
||||||
|
onRefresh={refreshPredictions}
|
||||||
|
colors={[theme.colors.primary]}
|
||||||
|
tintColor={theme.colors.primary}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
ListEmptyComponent={renderEmptyState}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// STYLES
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
backgroundColor: theme.colors.background,
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
|
||||||
|
tabContainer: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
backgroundColor: theme.colors.background,
|
||||||
|
paddingHorizontal: theme.spacing.md,
|
||||||
|
paddingVertical: theme.spacing.sm,
|
||||||
|
borderBottomWidth: 1,
|
||||||
|
borderBottomColor: theme.colors.border,
|
||||||
|
},
|
||||||
|
|
||||||
|
tabButton: {
|
||||||
|
flex: 1,
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
paddingVertical: theme.spacing.md,
|
||||||
|
paddingHorizontal: theme.spacing.sm,
|
||||||
|
borderRadius: theme.borderRadius.medium,
|
||||||
|
gap: theme.spacing.xs,
|
||||||
|
},
|
||||||
|
|
||||||
|
activeTabButton: {
|
||||||
|
backgroundColor: theme.colors.tertiary,
|
||||||
|
},
|
||||||
|
|
||||||
|
tabButtonText: {
|
||||||
|
fontSize: theme.typography.fontSize.bodyMedium,
|
||||||
|
fontFamily: theme.typography.fontFamily.medium,
|
||||||
|
color: theme.colors.textSecondary,
|
||||||
|
},
|
||||||
|
|
||||||
|
activeTabButtonText: {
|
||||||
|
color: theme.colors.primary,
|
||||||
|
},
|
||||||
|
|
||||||
|
contentContainer: {
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
|
||||||
|
listContainer: {
|
||||||
|
padding: theme.spacing.md,
|
||||||
|
alignItems: 'flex-start',
|
||||||
|
},
|
||||||
|
|
||||||
|
listHeader: {
|
||||||
|
marginBottom: theme.spacing.md,
|
||||||
|
paddingBottom: theme.spacing.md,
|
||||||
|
borderBottomWidth: 1,
|
||||||
|
borderBottomColor: theme.colors.border,
|
||||||
|
width: '100%',
|
||||||
|
},
|
||||||
|
|
||||||
|
listHeaderTitle: {
|
||||||
|
fontSize: theme.typography.fontSize.displaySmall,
|
||||||
|
fontFamily: theme.typography.fontFamily.bold,
|
||||||
|
color: theme.colors.textPrimary,
|
||||||
|
marginBottom: theme.spacing.xs,
|
||||||
|
},
|
||||||
|
|
||||||
|
listHeaderSubtitle: {
|
||||||
|
fontSize: theme.typography.fontSize.bodySmall,
|
||||||
|
fontFamily: theme.typography.fontFamily.regular,
|
||||||
|
color: theme.colors.textSecondary,
|
||||||
|
},
|
||||||
|
feedbackCount: {
|
||||||
|
color: theme.colors.primary,
|
||||||
|
fontFamily: theme.typography.fontFamily.medium,
|
||||||
|
},
|
||||||
|
|
||||||
|
emptyState: {
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
padding: theme.spacing.xxl,
|
||||||
|
},
|
||||||
|
|
||||||
|
emptyStateTitle: {
|
||||||
|
fontSize: theme.typography.fontSize.displayMedium,
|
||||||
|
fontFamily: theme.typography.fontFamily.bold,
|
||||||
|
color: theme.colors.textPrimary,
|
||||||
|
marginTop: theme.spacing.md,
|
||||||
|
marginBottom: theme.spacing.sm,
|
||||||
|
textAlign: 'center',
|
||||||
|
},
|
||||||
|
|
||||||
|
emptyStateSubtitle: {
|
||||||
|
fontSize: theme.typography.fontSize.bodyMedium,
|
||||||
|
fontFamily: theme.typography.fontFamily.regular,
|
||||||
|
color: theme.colors.textSecondary,
|
||||||
|
textAlign: 'center',
|
||||||
|
},
|
||||||
|
|
||||||
|
errorState: {
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
padding: theme.spacing.xxl,
|
||||||
|
},
|
||||||
|
|
||||||
|
errorStateTitle: {
|
||||||
|
fontSize: theme.typography.fontSize.displayMedium,
|
||||||
|
fontFamily: theme.typography.fontFamily.bold,
|
||||||
|
color: theme.colors.textPrimary,
|
||||||
|
marginTop: theme.spacing.md,
|
||||||
|
marginBottom: theme.spacing.sm,
|
||||||
|
textAlign: 'center',
|
||||||
|
},
|
||||||
|
|
||||||
|
errorStateSubtitle: {
|
||||||
|
fontSize: theme.typography.fontSize.bodyMedium,
|
||||||
|
fontFamily: theme.typography.fontFamily.regular,
|
||||||
|
color: theme.colors.textSecondary,
|
||||||
|
textAlign: 'center',
|
||||||
|
marginBottom: theme.spacing.lg,
|
||||||
|
},
|
||||||
|
|
||||||
|
retryButton: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
backgroundColor: theme.colors.primary,
|
||||||
|
paddingHorizontal: theme.spacing.lg,
|
||||||
|
paddingVertical: theme.spacing.md,
|
||||||
|
borderRadius: theme.borderRadius.medium,
|
||||||
|
gap: theme.spacing.sm,
|
||||||
|
},
|
||||||
|
|
||||||
|
retryButtonText: {
|
||||||
|
fontSize: theme.typography.fontSize.bodyMedium,
|
||||||
|
fontFamily: theme.typography.fontFamily.medium,
|
||||||
|
color: theme.colors.background,
|
||||||
|
},
|
||||||
|
|
||||||
|
predictionCardWrapper: {
|
||||||
|
marginRight: theme.spacing.md,
|
||||||
|
width: 280, // Uniform width for all cards
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
/*
|
||||||
|
* End of File: PredictionsList.tsx
|
||||||
|
* Design & Developed by Tech4Biz Solutions
|
||||||
|
* Copyright (c) Spurrin Innovations. All rights reserved.
|
||||||
|
*/
|
||||||
@ -5,3 +5,5 @@ export { QuickActions } from './QuickActions';
|
|||||||
export { DepartmentStats } from './DepartmentStats';
|
export { DepartmentStats } from './DepartmentStats';
|
||||||
export { BrainPredictionsOverview } from './BrainPredictionsOverview';
|
export { BrainPredictionsOverview } from './BrainPredictionsOverview';
|
||||||
export { FeedbackAnalysisPieChart } from './FeedbackAnalysisPieChart';
|
export { FeedbackAnalysisPieChart } from './FeedbackAnalysisPieChart';
|
||||||
|
export { PredictionCard } from './PredictionCard';
|
||||||
|
export { PredictionsList } from './PredictionsList';
|
||||||
182
app/modules/Dashboard/hooks/usePredictions.ts
Normal file
@ -0,0 +1,182 @@
|
|||||||
|
/*
|
||||||
|
* File: usePredictions.ts
|
||||||
|
* Description: Custom hook for managing AI predictions state and actions
|
||||||
|
* Design & Developed by Tech4Biz Solutions
|
||||||
|
* Copyright (c) Spurrin Innovations. All rights reserved.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useEffect, useCallback } from 'react';
|
||||||
|
import { useAppDispatch, useAppSelector } from '../../../store/hooks';
|
||||||
|
import {
|
||||||
|
fetchAllPredictions,
|
||||||
|
setActiveTab,
|
||||||
|
setSearchQuery,
|
||||||
|
clearErrors,
|
||||||
|
clearSearch,
|
||||||
|
selectActiveTab,
|
||||||
|
selectSearchQuery,
|
||||||
|
selectPredictionsWithFeedback,
|
||||||
|
selectPredictionsWithoutFeedback,
|
||||||
|
selectIsLoading,
|
||||||
|
selectError,
|
||||||
|
selectCurrentPredictions,
|
||||||
|
selectCurrentLoadingState,
|
||||||
|
selectCurrentError,
|
||||||
|
} from '../redux/predictionsSlice';
|
||||||
|
import { selectUser } from '../../Auth/redux';
|
||||||
|
import type { PredictionTabType } from '../types/predictions';
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// USE PREDICTIONS HOOK
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* usePredictions Hook
|
||||||
|
*
|
||||||
|
* Purpose: Manage AI predictions state and provide actions
|
||||||
|
*
|
||||||
|
* Features:
|
||||||
|
* - Fetch all predictions from single API call
|
||||||
|
* - Frontend filtering for feedback status
|
||||||
|
* - Manage active tab state
|
||||||
|
* - Handle search functionality
|
||||||
|
* - Provide loading and error states
|
||||||
|
* - Auto-fetch data when needed
|
||||||
|
*/
|
||||||
|
export const usePredictions = () => {
|
||||||
|
const dispatch = useAppDispatch();
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// SELECTORS
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
const activeTab = useAppSelector(selectActiveTab);
|
||||||
|
const searchQuery = useAppSelector(selectSearchQuery);
|
||||||
|
|
||||||
|
const predictionsWithFeedback = useAppSelector(selectPredictionsWithFeedback);
|
||||||
|
const predictionsWithoutFeedback = useAppSelector(selectPredictionsWithoutFeedback);
|
||||||
|
|
||||||
|
const isLoading = useAppSelector(selectIsLoading);
|
||||||
|
const error = useAppSelector(selectError);
|
||||||
|
|
||||||
|
const currentPredictions = useAppSelector(selectCurrentPredictions);
|
||||||
|
const currentLoadingState = useAppSelector(selectCurrentLoadingState);
|
||||||
|
const currentError = useAppSelector(selectCurrentError);
|
||||||
|
|
||||||
|
// Get authentication token from auth store
|
||||||
|
const authToken = useAppSelector(selectUser)?.access_token;
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// ACTIONS
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Switch Active Tab
|
||||||
|
*
|
||||||
|
* Purpose: Change between feedback tabs
|
||||||
|
*/
|
||||||
|
const switchTab = useCallback((tab: PredictionTabType) => {
|
||||||
|
dispatch(setActiveTab(tab));
|
||||||
|
}, [dispatch]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update Search Query
|
||||||
|
*
|
||||||
|
* Purpose: Update search query for filtering predictions
|
||||||
|
*/
|
||||||
|
const updateSearchQuery = useCallback((query: string) => {
|
||||||
|
dispatch(setSearchQuery(query));
|
||||||
|
}, [dispatch]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear Search
|
||||||
|
*
|
||||||
|
* Purpose: Clear search query
|
||||||
|
*/
|
||||||
|
const clearSearchQuery = useCallback(() => {
|
||||||
|
dispatch(clearSearch());
|
||||||
|
}, [dispatch]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear Errors
|
||||||
|
*
|
||||||
|
* Purpose: Clear error states
|
||||||
|
*/
|
||||||
|
const clearErrorStates = useCallback(() => {
|
||||||
|
dispatch(clearErrors());
|
||||||
|
}, [dispatch]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Refresh Predictions
|
||||||
|
*
|
||||||
|
* Purpose: Refresh all predictions data
|
||||||
|
*/
|
||||||
|
const refreshPredictions = useCallback(() => {
|
||||||
|
if (!authToken) return;
|
||||||
|
dispatch(fetchAllPredictions(authToken));
|
||||||
|
}, [dispatch, authToken]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch All Predictions
|
||||||
|
*
|
||||||
|
* Purpose: Fetch all predictions from API
|
||||||
|
*/
|
||||||
|
const fetchPredictions = useCallback(() => {
|
||||||
|
if (!authToken) return;
|
||||||
|
dispatch(fetchAllPredictions(authToken));
|
||||||
|
}, [dispatch, authToken]);
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// EFFECTS
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Auto-fetch data when component mounts or token changes
|
||||||
|
*/
|
||||||
|
useEffect(() => {
|
||||||
|
if (!authToken) return;
|
||||||
|
|
||||||
|
// Only fetch if we don't have any predictions yet
|
||||||
|
if (predictionsWithFeedback.length === 0 && predictionsWithoutFeedback.length === 0) {
|
||||||
|
dispatch(fetchAllPredictions(authToken));
|
||||||
|
}
|
||||||
|
}, [authToken, predictionsWithFeedback.length, predictionsWithoutFeedback.length, dispatch]);
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// RETURN VALUES
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
return {
|
||||||
|
// State
|
||||||
|
activeTab,
|
||||||
|
searchQuery,
|
||||||
|
predictionsWithFeedback,
|
||||||
|
predictionsWithoutFeedback,
|
||||||
|
currentPredictions,
|
||||||
|
|
||||||
|
// Loading states
|
||||||
|
isLoading,
|
||||||
|
currentLoadingState,
|
||||||
|
|
||||||
|
// Error states
|
||||||
|
error,
|
||||||
|
currentError,
|
||||||
|
|
||||||
|
// Actions
|
||||||
|
switchTab,
|
||||||
|
updateSearchQuery,
|
||||||
|
clearSearchQuery,
|
||||||
|
clearErrorStates,
|
||||||
|
refreshPredictions,
|
||||||
|
fetchPredictions,
|
||||||
|
|
||||||
|
// Constants
|
||||||
|
authToken
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
/*
|
||||||
|
* End of File: usePredictions.ts
|
||||||
|
* Design & Developed by Tech4Biz Solutions
|
||||||
|
* Copyright (c) Spurrin Innovations. All rights reserved.
|
||||||
|
*/
|
||||||
@ -38,6 +38,8 @@ export { default as CriticalAlerts } from './components/CriticalAlerts';
|
|||||||
export { default as DashboardHeader } from './components/DashboardHeader';
|
export { default as DashboardHeader } from './components/DashboardHeader';
|
||||||
export { default as QuickActions } from './components/QuickActions';
|
export { default as QuickActions } from './components/QuickActions';
|
||||||
export { default as DepartmentStats } from './components/DepartmentStats';
|
export { default as DepartmentStats } from './components/DepartmentStats';
|
||||||
|
export { PredictionCard } from './components/PredictionCard';
|
||||||
|
export { PredictionsList } from './components/PredictionsList';
|
||||||
|
|
||||||
// Export hooks
|
// Export hooks
|
||||||
export * from './hooks';
|
export * from './hooks';
|
||||||
@ -84,6 +86,30 @@ export {
|
|||||||
selectTimeAnalysis,
|
selectTimeAnalysis,
|
||||||
} from './redux/aiDashboardSelectors';
|
} from './redux/aiDashboardSelectors';
|
||||||
|
|
||||||
|
// Export Predictions Redux
|
||||||
|
export {
|
||||||
|
fetchAllPredictions,
|
||||||
|
setActiveTab,
|
||||||
|
setSearchQuery,
|
||||||
|
clearErrors,
|
||||||
|
clearSearch,
|
||||||
|
filterPredictions,
|
||||||
|
} from './redux/predictionsSlice';
|
||||||
|
|
||||||
|
// Export Predictions Selectors
|
||||||
|
export {
|
||||||
|
selectActiveTab,
|
||||||
|
selectSearchQuery,
|
||||||
|
selectAllPredictions,
|
||||||
|
selectPredictionsWithFeedback,
|
||||||
|
selectPredictionsWithoutFeedback,
|
||||||
|
selectIsLoading,
|
||||||
|
selectError,
|
||||||
|
selectCurrentPredictions,
|
||||||
|
selectCurrentLoadingState,
|
||||||
|
selectCurrentError,
|
||||||
|
} from './redux/predictionsSlice';
|
||||||
|
|
||||||
export {
|
export {
|
||||||
fetchAlerts,
|
fetchAlerts,
|
||||||
acknowledgeAlert,
|
acknowledgeAlert,
|
||||||
|
|||||||
@ -11,9 +11,15 @@ import { createStackNavigator } from '@react-navigation/stack';
|
|||||||
// Import dashboard screens
|
// Import dashboard screens
|
||||||
import { DashboardScreen } from '../screens/DashboardScreen';
|
import { DashboardScreen } from '../screens/DashboardScreen';
|
||||||
|
|
||||||
|
// Import PatientCare screens for dashboard integration
|
||||||
|
// import { PatientDetailsScreen } from '../../PatientCare/screens/PatientDetailsScreen';
|
||||||
|
// import { FeedbackDetailScreen } from '../../PatientCare/screens/FeedbackDetailScreen';
|
||||||
|
|
||||||
// Import navigation types
|
// Import navigation types
|
||||||
import { DashboardStackParamList } from './navigationTypes';
|
import { DashboardStackParamList } from './navigationTypes';
|
||||||
import { theme } from '../../../theme';
|
import { theme } from '../../../theme';
|
||||||
|
import { PatientDetailsScreen } from '../../PatientCare';
|
||||||
|
import FeedbackDetailScreen from '../../PatientCare/screens/FeedbackDetailScreen';
|
||||||
|
|
||||||
// Create stack navigator for Dashboard module
|
// Create stack navigator for Dashboard module
|
||||||
const Stack = createStackNavigator<DashboardStackParamList>();
|
const Stack = createStackNavigator<DashboardStackParamList>();
|
||||||
@ -78,6 +84,50 @@ const DashboardStackNavigator: React.FC = () => {
|
|||||||
headerShown: false, // Hide header for main dashboard
|
headerShown: false, // Hide header for main dashboard
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* Patient Details Screen - Accessible from dashboard */}
|
||||||
|
<Stack.Screen
|
||||||
|
name="PatientDetails"
|
||||||
|
component={PatientDetailsScreen}
|
||||||
|
options={{
|
||||||
|
title: 'Patient Details',
|
||||||
|
headerShown: true,
|
||||||
|
headerStyle: {
|
||||||
|
backgroundColor: '#FFFFFF',
|
||||||
|
elevation: 0,
|
||||||
|
shadowOpacity: 0,
|
||||||
|
},
|
||||||
|
headerTitleStyle: {
|
||||||
|
fontFamily: theme.typography.fontFamily.medium,
|
||||||
|
fontSize: 18,
|
||||||
|
color: '#212121',
|
||||||
|
},
|
||||||
|
headerTintColor: '#2196F3',
|
||||||
|
headerBackTitleVisible: false,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Feedback Detail Screen - Accessible from dashboard */}
|
||||||
|
<Stack.Screen
|
||||||
|
name="FeedbackDetail"
|
||||||
|
component={FeedbackDetailScreen}
|
||||||
|
options={{
|
||||||
|
title: 'Feedback Details',
|
||||||
|
headerShown: true,
|
||||||
|
headerStyle: {
|
||||||
|
backgroundColor: '#FFFFFF',
|
||||||
|
elevation: 0,
|
||||||
|
shadowOpacity: 0,
|
||||||
|
},
|
||||||
|
headerTitleStyle: {
|
||||||
|
fontFamily: theme.typography.fontFamily.medium,
|
||||||
|
fontSize: 18,
|
||||||
|
color: '#212121',
|
||||||
|
},
|
||||||
|
headerTintColor: '#2196F3',
|
||||||
|
headerBackTitleVisible: false,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</Stack.Navigator>
|
</Stack.Navigator>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -21,6 +21,9 @@ export type DashboardStackParamList = {
|
|||||||
// Patient Details screen - Detailed patient information
|
// Patient Details screen - Detailed patient information
|
||||||
PatientDetails: PatientDetailsScreenParams;
|
PatientDetails: PatientDetailsScreenParams;
|
||||||
|
|
||||||
|
// Feedback Detail screen - Series feedback information
|
||||||
|
FeedbackDetail: FeedbackDetailScreenParams;
|
||||||
|
|
||||||
// Alert Details screen - Detailed alert information
|
// Alert Details screen - Detailed alert information
|
||||||
AlertDetails: AlertDetailsScreenParams;
|
AlertDetails: AlertDetailsScreenParams;
|
||||||
|
|
||||||
@ -88,6 +91,30 @@ export interface PatientDetailsScreenParams {
|
|||||||
fromScreen?: keyof DashboardStackParamList;
|
fromScreen?: keyof DashboardStackParamList;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* FeedbackDetailScreenParams
|
||||||
|
*
|
||||||
|
* Purpose: Parameters for the feedback detail screen
|
||||||
|
*
|
||||||
|
* Parameters:
|
||||||
|
* - patientId: Required patient ID
|
||||||
|
* - patientName: Required patient name
|
||||||
|
* - seriesNumber: Required series number
|
||||||
|
* - seriesData: Optional series data
|
||||||
|
* - patientData: Optional patient data
|
||||||
|
* - feedbackData: Optional feedback data
|
||||||
|
* - onFeedbackSubmitted: Optional callback for feedback submission
|
||||||
|
*/
|
||||||
|
export interface FeedbackDetailScreenParams {
|
||||||
|
patientId: string;
|
||||||
|
patientName: string;
|
||||||
|
seriesNumber: string;
|
||||||
|
seriesData?: any;
|
||||||
|
patientData?: any;
|
||||||
|
feedbackData?: any[];
|
||||||
|
onFeedbackSubmitted?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* AlertDetailsScreenParams
|
* AlertDetailsScreenParams
|
||||||
*
|
*
|
||||||
@ -149,6 +176,11 @@ export type DashboardScreenProps = DashboardScreenProps<'ERDashboard'>;
|
|||||||
*/
|
*/
|
||||||
export type PatientDetailsScreenProps = DashboardScreenProps<'PatientDetails'>;
|
export type PatientDetailsScreenProps = DashboardScreenProps<'PatientDetails'>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* FeedbackDetailScreenProps - Props for FeedbackDetailScreen component
|
||||||
|
*/
|
||||||
|
export type FeedbackDetailScreenProps = DashboardScreenProps<'FeedbackDetail'>;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* AlertDetailsScreenProps - Props for AlertDetailsScreen component
|
* AlertDetailsScreenProps - Props for AlertDetailsScreen component
|
||||||
*/
|
*/
|
||||||
|
|||||||
242
app/modules/Dashboard/redux/predictionsSlice.ts
Normal file
@ -0,0 +1,242 @@
|
|||||||
|
/*
|
||||||
|
* File: predictionsSlice.ts
|
||||||
|
* Description: Redux slice for managing AI predictions state
|
||||||
|
* Design & Developed by Tech4Biz Solutions
|
||||||
|
* Copyright (c) Spurrin Innovations. All rights reserved.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { createSlice, createAsyncThunk, PayloadAction } from '@reduxjs/toolkit';
|
||||||
|
import { predictionsAPI } from '../services/predictionsAPI';
|
||||||
|
import type { PredictionsResponse, PredictionData, PredictionTabType } from '../types/predictions';
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// ASYNC THUNKS
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch All Predictions Async Thunk
|
||||||
|
*
|
||||||
|
* Purpose: Fetch all predictions and handle filtering on frontend
|
||||||
|
*/
|
||||||
|
export const fetchAllPredictions = createAsyncThunk(
|
||||||
|
'predictions/fetchAll',
|
||||||
|
async (token: string, { rejectWithValue }) => {
|
||||||
|
try {
|
||||||
|
const response :any = await predictionsAPI.fetchAllPredictions(token);
|
||||||
|
console.log('dashboard predction data response', response);
|
||||||
|
if (response.ok && response.data && response.data.data) {
|
||||||
|
return response.data.data as PredictionData[];
|
||||||
|
} else {
|
||||||
|
return rejectWithValue(response.problem || 'Failed to fetch predictions');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
return rejectWithValue('Network error occurred while fetching predictions');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// STATE INTERFACE
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
interface PredictionsState {
|
||||||
|
// Data
|
||||||
|
allPredictions: PredictionData[];
|
||||||
|
predictionsWithFeedback: PredictionData[];
|
||||||
|
predictionsWithoutFeedback: PredictionData[];
|
||||||
|
|
||||||
|
// Loading states
|
||||||
|
isLoading: boolean;
|
||||||
|
|
||||||
|
// Error states
|
||||||
|
error: string | null;
|
||||||
|
|
||||||
|
// UI state
|
||||||
|
activeTab: PredictionTabType;
|
||||||
|
searchQuery: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// INITIAL STATE
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
const initialState: PredictionsState = {
|
||||||
|
// Data
|
||||||
|
allPredictions: [],
|
||||||
|
predictionsWithFeedback: [],
|
||||||
|
predictionsWithoutFeedback: [],
|
||||||
|
|
||||||
|
// Loading states
|
||||||
|
isLoading: false,
|
||||||
|
|
||||||
|
// Error states
|
||||||
|
error: null,
|
||||||
|
|
||||||
|
// UI state
|
||||||
|
activeTab: 'with-feedback',
|
||||||
|
searchQuery: '',
|
||||||
|
};
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// PREDICTIONS SLICE
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
const predictionsSlice = createSlice({
|
||||||
|
name: 'predictions',
|
||||||
|
initialState,
|
||||||
|
reducers: {
|
||||||
|
/**
|
||||||
|
* Set Active Tab
|
||||||
|
*
|
||||||
|
* Purpose: Switch between feedback tabs
|
||||||
|
*/
|
||||||
|
setActiveTab: (state, action: PayloadAction<PredictionTabType>) => {
|
||||||
|
state.activeTab = action.payload;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set Search Query
|
||||||
|
*
|
||||||
|
* Purpose: Update search query for filtering
|
||||||
|
*/
|
||||||
|
setSearchQuery: (state, action: PayloadAction<string>) => {
|
||||||
|
state.searchQuery = action.payload;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear Errors
|
||||||
|
*
|
||||||
|
* Purpose: Clear error states
|
||||||
|
*/
|
||||||
|
clearErrors: (state) => {
|
||||||
|
state.error = null;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear Search
|
||||||
|
*
|
||||||
|
* Purpose: Clear search query
|
||||||
|
*/
|
||||||
|
clearSearch: (state) => {
|
||||||
|
state.searchQuery = '';
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Filter Predictions
|
||||||
|
*
|
||||||
|
* Purpose: Filter predictions based on feedback status
|
||||||
|
*/
|
||||||
|
filterPredictions: (state) => {
|
||||||
|
// Filter predictions with feedback
|
||||||
|
state.predictionsWithFeedback = state.allPredictions.filter(
|
||||||
|
prediction => prediction.has_provided_feedback
|
||||||
|
);
|
||||||
|
|
||||||
|
// Filter predictions without feedback
|
||||||
|
state.predictionsWithoutFeedback = state.allPredictions.filter(
|
||||||
|
prediction => !prediction.has_provided_feedback
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
extraReducers: (builder) => {
|
||||||
|
// ============================================================================
|
||||||
|
// FETCH ALL PREDICTIONS
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
// Pending
|
||||||
|
builder.addCase(fetchAllPredictions.pending, (state) => {
|
||||||
|
state.isLoading = true;
|
||||||
|
state.error = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Fulfilled
|
||||||
|
builder.addCase(fetchAllPredictions.fulfilled, (state, action: PayloadAction<PredictionData[]>) => {
|
||||||
|
state.isLoading = false;
|
||||||
|
state.allPredictions = action.payload;
|
||||||
|
state.error = null;
|
||||||
|
|
||||||
|
// Debug logging to see what's happening with feedback filtering
|
||||||
|
console.log('🔍 Predictions filtering debug:');
|
||||||
|
console.log('Total predictions:', action.payload.length);
|
||||||
|
console.log('Predictions with feedback field:', action.payload.filter(p => p.has_provided_feedback).length);
|
||||||
|
console.log('Predictions with feedbacks array:', action.payload.filter(p => p.feedbacks && p.feedbacks.length > 0).length);
|
||||||
|
console.log('Sample prediction feedback data:', action.payload.slice(0, 2).map(p => ({
|
||||||
|
id: p.id,
|
||||||
|
has_provided_feedback: p.has_provided_feedback,
|
||||||
|
feedbacks_count: p.feedbacks?.length || 0,
|
||||||
|
user_feedback_count: p.user_feedback_count
|
||||||
|
})));
|
||||||
|
|
||||||
|
// Automatically filter predictions after fetching
|
||||||
|
// Primary filter: use has_provided_feedback field
|
||||||
|
// Fallback filter: check if feedbacks array has items
|
||||||
|
state.predictionsWithFeedback = action.payload.filter(
|
||||||
|
prediction => prediction.has_provided_feedback || (prediction.feedbacks && prediction.feedbacks.length > 0)
|
||||||
|
);
|
||||||
|
state.predictionsWithoutFeedback = action.payload.filter(
|
||||||
|
prediction => !prediction.has_provided_feedback && (!prediction.feedbacks || prediction.feedbacks.length === 0)
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log('Filtered results:');
|
||||||
|
console.log('With feedback tab:', state.predictionsWithFeedback.length);
|
||||||
|
console.log('Without feedback tab:', state.predictionsWithoutFeedback.length);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Rejected
|
||||||
|
builder.addCase(fetchAllPredictions.rejected, (state, action) => {
|
||||||
|
state.isLoading = false;
|
||||||
|
state.error = action.error.message || 'Failed to fetch predictions';
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// ACTIONS
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export const {
|
||||||
|
setActiveTab,
|
||||||
|
setSearchQuery,
|
||||||
|
clearErrors,
|
||||||
|
clearSearch,
|
||||||
|
filterPredictions,
|
||||||
|
} = predictionsSlice.actions;
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// SELECTORS
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export const selectActiveTab = (state: { predictions: PredictionsState }) => state.predictions.activeTab;
|
||||||
|
export const selectSearchQuery = (state: { predictions: PredictionsState }) => state.predictions.searchQuery;
|
||||||
|
|
||||||
|
export const selectAllPredictions = (state: { predictions: PredictionsState }) => state.predictions.allPredictions;
|
||||||
|
export const selectPredictionsWithFeedback = (state: { predictions: PredictionsState }) => state.predictions.predictionsWithFeedback;
|
||||||
|
export const selectPredictionsWithoutFeedback = (state: { predictions: PredictionsState }) => state.predictions.predictionsWithoutFeedback;
|
||||||
|
|
||||||
|
export const selectIsLoading = (state: { predictions: PredictionsState }) => state.predictions.isLoading;
|
||||||
|
export const selectError = (state: { predictions: PredictionsState }) => state.predictions.error;
|
||||||
|
|
||||||
|
export const selectCurrentPredictions = (state: { predictions: PredictionsState }) => {
|
||||||
|
const { activeTab, predictionsWithFeedback, predictionsWithoutFeedback } = state.predictions;
|
||||||
|
return activeTab === 'with-feedback' ? predictionsWithFeedback : predictionsWithoutFeedback;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const selectCurrentLoadingState = (state: { predictions: PredictionsState }) => {
|
||||||
|
return state.predictions.isLoading;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const selectCurrentError = (state: { predictions: PredictionsState }) => {
|
||||||
|
return state.predictions.error;
|
||||||
|
};
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// EXPORT
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export default predictionsSlice.reducer;
|
||||||
|
|
||||||
|
/*
|
||||||
|
* End of File: predictionsSlice.ts
|
||||||
|
* Design & Developed by Tech4Biz Solutions
|
||||||
|
* Copyright (c) Spurrin Innovations. All rights reserved.
|
||||||
|
*/
|
||||||
@ -5,7 +5,7 @@
|
|||||||
* Copyright (c) Spurrin Innovations. All rights reserved.
|
* Copyright (c) Spurrin Innovations. All rights reserved.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState } from 'react';
|
||||||
import {
|
import {
|
||||||
View,
|
View,
|
||||||
Text,
|
Text,
|
||||||
@ -15,12 +15,22 @@ import {
|
|||||||
RefreshControl,
|
RefreshControl,
|
||||||
FlatList,
|
FlatList,
|
||||||
Dimensions,
|
Dimensions,
|
||||||
|
Alert,
|
||||||
} from 'react-native';
|
} from 'react-native';
|
||||||
|
import Icon from 'react-native-vector-icons/Feather';
|
||||||
import { theme } from '../../../theme/theme';
|
import { theme } from '../../../theme/theme';
|
||||||
import { DashboardHeader } from '../components/DashboardHeader';
|
import { DashboardHeader } from '../components/DashboardHeader';
|
||||||
import { BrainPredictionsOverview } from '../components/BrainPredictionsOverview';
|
import { BrainPredictionsOverview } from '../components/BrainPredictionsOverview';
|
||||||
import { FeedbackAnalysisPieChart } from '../components/FeedbackAnalysisPieChart';
|
import { FeedbackAnalysisPieChart } from '../components/FeedbackAnalysisPieChart';
|
||||||
|
import { PredictionsList } from '../components/PredictionsList';
|
||||||
import { useAIDashboard } from '../hooks/useAIDashboard';
|
import { useAIDashboard } from '../hooks/useAIDashboard';
|
||||||
|
import { selectUserDisplayName, selectUserFirstName } from '../../Auth/redux/authSelectors';
|
||||||
|
import { useAppSelector } from '../../../store/hooks';
|
||||||
|
import { CompositeNavigationProp } from '@react-navigation/native';
|
||||||
|
import { BottomTabNavigationProp } from '@react-navigation/bottom-tabs';
|
||||||
|
import { StackNavigationProp } from '@react-navigation/stack';
|
||||||
|
import { MainTabParamList } from '../../../navigation/navigationTypes';
|
||||||
|
import { PatientCareStackParamList } from '../../PatientCare/navigation/navigationTypes';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* DashboardScreenProps Interface
|
* DashboardScreenProps Interface
|
||||||
@ -28,10 +38,15 @@ import { useAIDashboard } from '../hooks/useAIDashboard';
|
|||||||
* Purpose: Defines the props required by the DashboardScreen component
|
* Purpose: Defines the props required by the DashboardScreen component
|
||||||
*
|
*
|
||||||
* Props:
|
* Props:
|
||||||
* - navigation: React Navigation object for screen navigation
|
* - navigation: Composite navigation object for tab and stack navigation
|
||||||
*/
|
*/
|
||||||
|
type DashboardScreenNavigationProp = CompositeNavigationProp<
|
||||||
|
BottomTabNavigationProp<MainTabParamList, 'Dashboard'>,
|
||||||
|
StackNavigationProp<PatientCareStackParamList>
|
||||||
|
>;
|
||||||
|
|
||||||
interface DashboardScreenProps {
|
interface DashboardScreenProps {
|
||||||
navigation: any;
|
navigation: DashboardScreenNavigationProp;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -123,7 +138,7 @@ export const DashboardScreen: React.FC<DashboardScreenProps> = ({
|
|||||||
navigation,
|
navigation,
|
||||||
}) => {
|
}) => {
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// CUSTOM HOOKS
|
// CUSTOM HOOKS & SELECTORS
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
// Use custom hook for AI dashboard functionality
|
// Use custom hook for AI dashboard functionality
|
||||||
@ -136,6 +151,40 @@ export const DashboardScreen: React.FC<DashboardScreenProps> = ({
|
|||||||
refreshDashboardStatistics
|
refreshDashboardStatistics
|
||||||
} = useAIDashboard();
|
} = useAIDashboard();
|
||||||
|
|
||||||
|
// Get user display name from auth state
|
||||||
|
const userDisplayName = useAppSelector(selectUserFirstName);
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// HELPER FUNCTIONS
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* getPersonalizedGreeting Function
|
||||||
|
*
|
||||||
|
* Purpose: Generate a personalized greeting based on time of day and user's display name
|
||||||
|
*
|
||||||
|
* @returns Personalized greeting string
|
||||||
|
*/
|
||||||
|
const getPersonalizedGreeting = (): string => {
|
||||||
|
const currentHour = new Date().getHours();
|
||||||
|
let timeGreeting = '';
|
||||||
|
|
||||||
|
// Determine time-based greeting
|
||||||
|
if (currentHour >= 5 && currentHour < 12) {
|
||||||
|
timeGreeting = 'Good Morning';
|
||||||
|
} else if (currentHour >= 12 && currentHour < 17) {
|
||||||
|
timeGreeting = 'Good Afternoon';
|
||||||
|
} else if (currentHour >= 17 && currentHour < 21) {
|
||||||
|
timeGreeting = 'Good Evening';
|
||||||
|
} else {
|
||||||
|
timeGreeting = 'Good Evening';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create personalized greeting with fallback
|
||||||
|
const displayName = userDisplayName || 'Doctor';
|
||||||
|
return `${timeGreeting}, Dr. ${displayName}`;
|
||||||
|
};
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// EVENT HANDLERS
|
// EVENT HANDLERS
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@ -235,11 +284,20 @@ export const DashboardScreen: React.FC<DashboardScreenProps> = ({
|
|||||||
* @param value - Main value to display
|
* @param value - Main value to display
|
||||||
* @param subtitle - Optional subtitle
|
* @param subtitle - Optional subtitle
|
||||||
* @param color - Optional color theme
|
* @param color - Optional color theme
|
||||||
|
* @param iconName - Icon name for the stats card
|
||||||
* @returns Statistics card component
|
* @returns Statistics card component
|
||||||
*/
|
*/
|
||||||
const renderStatsCard = (title: string, value: string | number, subtitle?: string, color?: string) => (
|
const renderStatsCard = (title: string, value: string | number, subtitle?: string, color?: string, iconName?: string) => (
|
||||||
<View style={[styles.statsCard, color && { borderLeftColor: color, borderLeftWidth: 4 }]}>
|
<View style={[styles.statsCard, color && { borderLeftColor: color, borderLeftWidth: 4 }]}>
|
||||||
|
{/* Icon and Title Row */}
|
||||||
|
<View style={styles.statsCardHeader}>
|
||||||
|
{iconName && (
|
||||||
|
<View style={[styles.statsCardIcon, { backgroundColor: color ? color + '20' : theme.colors.backgroundAccent }]}>
|
||||||
|
<Icon name={iconName} size={20} color={color || theme.colors.primary} />
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
<Text style={styles.statsCardTitle}>{title}</Text>
|
<Text style={styles.statsCardTitle}>{title}</Text>
|
||||||
|
</View>
|
||||||
<Text style={[styles.statsCardValue, color && { color }]}>{value}</Text>
|
<Text style={[styles.statsCardValue, color && { color }]}>{value}</Text>
|
||||||
{subtitle && <Text style={styles.statsCardSubtitle}>{subtitle}</Text>}
|
{subtitle && <Text style={styles.statsCardSubtitle}>{subtitle}</Text>}
|
||||||
</View>
|
</View>
|
||||||
@ -250,180 +308,12 @@ export const DashboardScreen: React.FC<DashboardScreenProps> = ({
|
|||||||
*
|
*
|
||||||
* Purpose: Render confidence score breakdown section
|
* Purpose: Render confidence score breakdown section
|
||||||
*/
|
*/
|
||||||
const renderConfidenceBreakdown = () => {
|
|
||||||
// Check if dashboard data exists
|
|
||||||
if (!dashboardData) {
|
|
||||||
return (
|
|
||||||
<View style={styles.section}>
|
|
||||||
<Text style={styles.sectionTitle}>Confidence Score Distribution</Text>
|
|
||||||
<View style={styles.emptyStateContainer}>
|
|
||||||
<Text style={styles.emptyStateText}>Dashboard data not available</Text>
|
|
||||||
<TouchableOpacity style={styles.retryButton} onPress={handleRefresh}>
|
|
||||||
<Text style={styles.retryButtonText}>Retry</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if confidence scores data exists
|
|
||||||
if (!dashboardData.data?.confidence_scores) {
|
|
||||||
return (
|
|
||||||
<View style={styles.section}>
|
|
||||||
<Text style={styles.sectionTitle}>Confidence Score Distribution</Text>
|
|
||||||
<View style={styles.emptyStateContainer}>
|
|
||||||
<Text style={styles.emptyStateText}>Confidence data not available</Text>
|
|
||||||
<Text style={styles.emptyStateSubtext}>AI confidence scores are not currently accessible</Text>
|
|
||||||
<View style={styles.emptyStateInfo}>
|
|
||||||
<Text style={styles.emptyStateInfoText}>• AI system may be initializing</Text>
|
|
||||||
<Text style={styles.emptyStateInfoText}>• Check system status</Text>
|
|
||||||
<Text style={styles.emptyStateInfoText}>• Refresh in a few minutes</Text>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const { high, medium, low } = dashboardData.data.confidence_scores;
|
|
||||||
|
|
||||||
// Check if the object is empty or if all values are undefined/null/zero
|
|
||||||
if (!high && !medium && !low) {
|
|
||||||
return (
|
|
||||||
<View style={styles.section}>
|
|
||||||
<Text style={styles.sectionTitle}>Confidence Score Distribution</Text>
|
|
||||||
<View style={styles.emptyStateContainer}>
|
|
||||||
<Text style={styles.emptyStateText}>No data found</Text>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if all required fields exist and are numbers
|
|
||||||
if (typeof high !== 'number' || typeof medium !== 'number' || typeof low !== 'number') {
|
|
||||||
return (
|
|
||||||
<View style={styles.section}>
|
|
||||||
<Text style={styles.sectionTitle}>Confidence Score Distribution</Text>
|
|
||||||
<View style={styles.emptyStateContainer}>
|
|
||||||
<Text style={styles.emptyStateText}>No confidence data available</Text>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const total = high + medium + low;
|
|
||||||
|
|
||||||
// If no predictions, show empty state
|
|
||||||
if (total === 0) {
|
|
||||||
return (
|
|
||||||
<View style={styles.section}>
|
|
||||||
<Text style={styles.sectionTitle}>Confidence Score Distribution</Text>
|
|
||||||
<View style={styles.emptyStateContainer}>
|
|
||||||
<Text style={styles.emptyStateText}>No predictions available yet</Text>
|
|
||||||
<Text style={styles.emptyStateSubtext}>AI predictions will appear here once the system processes medical scans</Text>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Calculate percentages for better visualization
|
|
||||||
const highPercent = Math.round((high / total) * 100);
|
|
||||||
const mediumPercent = Math.round((medium / total) * 100);
|
|
||||||
const lowPercent = Math.round((low / total) * 100);
|
|
||||||
|
|
||||||
// Helper function to get bar opacity
|
|
||||||
const getBarOpacity = (count: number) => {
|
|
||||||
if (count === 0) return 0.3; // Dimmed for zero values
|
|
||||||
return 0.9; // Full opacity for non-zero values
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<View style={styles.section}>
|
|
||||||
<Text style={styles.sectionTitle}>Confidence Score Distribution</Text>
|
|
||||||
<View style={styles.confidenceContainer}>
|
|
||||||
{/* High Confidence */}
|
|
||||||
<View style={styles.confidenceItem}>
|
|
||||||
<View style={styles.confidenceHeader}>
|
|
||||||
<View style={[styles.confidenceIndicator, { backgroundColor: theme.colors.success }]} />
|
|
||||||
<Text style={styles.confidenceLabel}>High Confidence</Text>
|
|
||||||
<Text style={styles.confidencePercentage}>{highPercent}%</Text>
|
|
||||||
</View>
|
|
||||||
<View style={styles.confidenceBarContainer}>
|
|
||||||
<View
|
|
||||||
style={[
|
|
||||||
styles.confidenceBar,
|
|
||||||
{
|
|
||||||
backgroundColor: theme.colors.success,
|
|
||||||
width: high === 0 ? 4 : `${Math.max(highPercent, 5)}%`,
|
|
||||||
opacity: getBarOpacity(high)
|
|
||||||
}
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
<Text style={styles.confidenceValue}>{high} predictions</Text>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{/* Medium Confidence */}
|
|
||||||
<View style={styles.confidenceItem}>
|
|
||||||
<View style={styles.confidenceHeader}>
|
|
||||||
<View style={[styles.confidenceIndicator, { backgroundColor: theme.colors.warning }]} />
|
|
||||||
<Text style={styles.confidenceLabel}>Medium Confidence</Text>
|
|
||||||
<Text style={styles.confidencePercentage}>{mediumPercent}%</Text>
|
|
||||||
</View>
|
|
||||||
<View style={styles.confidenceBarContainer}>
|
|
||||||
<View
|
|
||||||
style={[
|
|
||||||
styles.confidenceBar,
|
|
||||||
{
|
|
||||||
backgroundColor: theme.colors.warning,
|
|
||||||
width: medium === 0 ? 4 : `${Math.max(mediumPercent, 5)}%`,
|
|
||||||
opacity: getBarOpacity(medium)
|
|
||||||
}
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
<Text style={styles.confidenceValue}>{medium} predictions</Text>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{/* Low Confidence */}
|
|
||||||
<View style={styles.confidenceItem}>
|
|
||||||
<View style={styles.confidenceHeader}>
|
|
||||||
<View style={[styles.confidenceIndicator, { backgroundColor: theme.colors.error }]} />
|
|
||||||
<Text style={styles.confidenceLabel}>Low Confidence</Text>
|
|
||||||
<Text style={styles.confidencePercentage}>{lowPercent}%</Text>
|
|
||||||
</View>
|
|
||||||
<View style={styles.confidenceBarContainer}>
|
|
||||||
<View
|
|
||||||
style={[
|
|
||||||
styles.confidenceBar,
|
|
||||||
{
|
|
||||||
backgroundColor: theme.colors.error,
|
|
||||||
width: low === 0 ? 4 : `${Math.max(lowPercent, 5)}%`,
|
|
||||||
opacity: getBarOpacity(low)
|
|
||||||
}
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
<Text style={styles.confidenceValue}>{low} predictions</Text>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{/* Summary Stats */}
|
|
||||||
<View style={styles.confidenceSummary}>
|
|
||||||
<Text style={styles.summaryText}>
|
|
||||||
Total Predictions: <Text style={styles.summaryValue}>{total}</Text>
|
|
||||||
</Text>
|
|
||||||
<Text style={styles.summaryText}>
|
|
||||||
High Confidence Rate: <Text style={[styles.summaryValue, { color: theme.colors.success }]}>{highPercent}%</Text>
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* renderUrgencyBreakdown Function
|
* renderUrgencyBreakdown Function
|
||||||
*
|
*
|
||||||
* Purpose: Render urgency level breakdown section
|
* Purpose: Render urgency level breakdown section with animated colored circles
|
||||||
*/
|
*/
|
||||||
const renderUrgencyBreakdown = () => {
|
const renderUrgencyBreakdown = () => {
|
||||||
if (!dashboardData?.data.urgency_levels) return null;
|
if (!dashboardData?.data.urgency_levels) return null;
|
||||||
@ -457,21 +347,45 @@ export const DashboardScreen: React.FC<DashboardScreenProps> = ({
|
|||||||
return (
|
return (
|
||||||
<View style={styles.section}>
|
<View style={styles.section}>
|
||||||
<Text style={styles.sectionTitle}>Case Urgency Distribution</Text>
|
<Text style={styles.sectionTitle}>Case Urgency Distribution</Text>
|
||||||
|
|
||||||
<View style={styles.urgencyContainer}>
|
<View style={styles.urgencyContainer}>
|
||||||
<View style={styles.urgencyItem}>
|
{/* Critical Cases Circle */}
|
||||||
<View style={[styles.urgencyIndicator, { backgroundColor: theme.colors.error }]} />
|
<View style={styles.urgencyCircleItem}>
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
styles.circleContainer,
|
||||||
|
{ borderColor: theme.colors.error }
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Text style={styles.circleValue}>{critical}</Text>
|
||||||
|
</View>
|
||||||
<Text style={styles.urgencyLabel}>Critical</Text>
|
<Text style={styles.urgencyLabel}>Critical</Text>
|
||||||
<Text style={styles.urgencyValue}>{critical}</Text>
|
|
||||||
</View>
|
</View>
|
||||||
<View style={styles.urgencyItem}>
|
|
||||||
<View style={[styles.urgencyIndicator, { backgroundColor: theme.colors.warning }]} />
|
{/* Urgent Cases Circle */}
|
||||||
|
<View style={styles.urgencyCircleItem}>
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
styles.circleContainer,
|
||||||
|
{ borderColor: theme.colors.warning }
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Text style={styles.circleValue}>{urgent}</Text>
|
||||||
|
</View>
|
||||||
<Text style={styles.urgencyLabel}>Urgent</Text>
|
<Text style={styles.urgencyLabel}>Urgent</Text>
|
||||||
<Text style={styles.urgencyValue}>{urgent}</Text>
|
|
||||||
</View>
|
</View>
|
||||||
<View style={styles.urgencyItem}>
|
|
||||||
<View style={[styles.urgencyIndicator, { backgroundColor: theme.colors.success }]} />
|
{/* Routine Cases Circle */}
|
||||||
|
<View style={styles.urgencyCircleItem}>
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
styles.circleContainer,
|
||||||
|
{ borderColor: theme.colors.success }
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Text style={styles.circleValue}>{routine}</Text>
|
||||||
|
</View>
|
||||||
<Text style={styles.urgencyLabel}>Routine</Text>
|
<Text style={styles.urgencyLabel}>Routine</Text>
|
||||||
<Text style={styles.urgencyValue}>{routine}</Text>
|
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
@ -611,7 +525,7 @@ export const DashboardScreen: React.FC<DashboardScreenProps> = ({
|
|||||||
<View style={styles.header}>
|
<View style={styles.header}>
|
||||||
{/* Dashboard header with title and refresh button */}
|
{/* Dashboard header with title and refresh button */}
|
||||||
<View style={styles.headerTop}>
|
<View style={styles.headerTop}>
|
||||||
<Text style={styles.dashboardTitle}>AI Analysis Dashboard</Text>
|
<Text style={styles.dashboardTitle}>{getPersonalizedGreeting()}</Text>
|
||||||
<Text style={styles.dashboardSubtitle}>
|
<Text style={styles.dashboardSubtitle}>
|
||||||
{dashboardMessage}
|
{dashboardMessage}
|
||||||
</Text>
|
</Text>
|
||||||
@ -623,25 +537,29 @@ export const DashboardScreen: React.FC<DashboardScreenProps> = ({
|
|||||||
'Total Predictions',
|
'Total Predictions',
|
||||||
dashboardData?.data.total_predictions || 0,
|
dashboardData?.data.total_predictions || 0,
|
||||||
'AI analyses performed',
|
'AI analyses performed',
|
||||||
theme.colors.primary
|
theme.colors.primary,
|
||||||
|
'activity'
|
||||||
)}
|
)}
|
||||||
{renderStatsCard(
|
{renderStatsCard(
|
||||||
'Total Patients',
|
'Total Patients',
|
||||||
dashboardData?.data.total_patients || 0,
|
dashboardData?.data.total_patients || 0,
|
||||||
'Unique patients',
|
'Unique patients',
|
||||||
theme.colors.info
|
theme.colors.info,
|
||||||
|
'users'
|
||||||
)}
|
)}
|
||||||
{renderStatsCard(
|
{renderStatsCard(
|
||||||
'Feedback Rate',
|
'Feedback Rate',
|
||||||
`${dashboardData?.data.feedback_rate_percentage || 0}%`,
|
`${dashboardData?.data.feedback_rate_percentage || 0}%`,
|
||||||
'User feedback coverage',
|
'User feedback coverage',
|
||||||
theme.colors.success
|
theme.colors.success,
|
||||||
|
'message-circle'
|
||||||
)}
|
)}
|
||||||
{renderStatsCard(
|
{renderStatsCard(
|
||||||
'Avg Confidence',
|
'Avg Confidence',
|
||||||
(dashboardData?.data.average_confidence_score || 0).toFixed(2),
|
(dashboardData?.data.average_confidence_score || 0).toFixed(2),
|
||||||
'AI prediction confidence',
|
'AI prediction confidence',
|
||||||
theme.colors.warning
|
theme.colors.warning,
|
||||||
|
'trending-up'
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
@ -693,8 +611,7 @@ export const DashboardScreen: React.FC<DashboardScreenProps> = ({
|
|||||||
{/* Dashboard header with key metrics */}
|
{/* Dashboard header with key metrics */}
|
||||||
{renderHeader()}
|
{renderHeader()}
|
||||||
|
|
||||||
{/* Confidence score breakdown */}
|
|
||||||
{renderConfidenceBreakdown()}
|
|
||||||
|
|
||||||
{/* Urgency level breakdown */}
|
{/* Urgency level breakdown */}
|
||||||
{renderUrgencyBreakdown()}
|
{renderUrgencyBreakdown()}
|
||||||
@ -705,6 +622,85 @@ export const DashboardScreen: React.FC<DashboardScreenProps> = ({
|
|||||||
{/* Time-based analysis */}
|
{/* Time-based analysis */}
|
||||||
{renderTimeAnalysis()}
|
{renderTimeAnalysis()}
|
||||||
|
|
||||||
|
{/* AI Predictions List - Moved to main ScrollView */}
|
||||||
|
<View style={styles.section}>
|
||||||
|
<Text style={styles.sectionTitle}>AI Predictions</Text>
|
||||||
|
<Text style={styles.sectionSubtitle}>
|
||||||
|
Review AI predictions with and without user feedback
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* PredictionsList rendered directly */}
|
||||||
|
<View style={styles.predictionsContainer}>
|
||||||
|
<PredictionsList
|
||||||
|
onPredictionPress={(prediction) => {
|
||||||
|
try {
|
||||||
|
// Navigate to FeedbackDetailScreen with required parameters
|
||||||
|
|
||||||
|
|
||||||
|
navigation.navigate('Patients', {
|
||||||
|
screen: 'FeedbackDetail',
|
||||||
|
params: {
|
||||||
|
patientId: prediction.patid,
|
||||||
|
patientName: prediction.patientdetails.Name || 'Unknown Patient',
|
||||||
|
seriesNumber: prediction.prediction.processing_info.filename || 'Unknown Series',
|
||||||
|
seriesData: {
|
||||||
|
series_num: prediction.prediction.processing_info.filename || 'Unknown Series',
|
||||||
|
series_description: prediction.prediction.finding_type || 'AI Analysis',
|
||||||
|
total_images: prediction.prediction.processing_info.frame_count || 0,
|
||||||
|
png_preview: prediction.preview || '',
|
||||||
|
modality: prediction.patientdetails.Modality || 'Unknown'
|
||||||
|
},
|
||||||
|
patientData: {
|
||||||
|
patid: prediction.patid,
|
||||||
|
hospital_id: prediction.hospital_id,
|
||||||
|
patient_info: {
|
||||||
|
name: prediction.patientdetails.Name || 'Unknown Patient',
|
||||||
|
age: prediction.patientdetails.PatAge || 'Unknown',
|
||||||
|
sex: prediction.patientdetails.PatSex || 'Unknown',
|
||||||
|
date: prediction.patientdetails.Date || 'Unknown',
|
||||||
|
institution: prediction.patientdetails.InstName || 'Unknown Institution',
|
||||||
|
modality: prediction.patientdetails.Modality || 'Unknown Modality',
|
||||||
|
status: prediction.patientdetails.Status || 'Unknown',
|
||||||
|
report_status: prediction.patientdetails.ReportStatus || 'Unknown',
|
||||||
|
file_name: prediction.prediction.processing_info.filename || 'Unknown',
|
||||||
|
file_type: prediction.prediction.processing_info.file_type || 'Unknown',
|
||||||
|
frame_count: prediction.prediction.processing_info.frame_count || 0
|
||||||
|
},
|
||||||
|
series_summary: [{
|
||||||
|
series_num: prediction.prediction.processing_info.filename || 'Unknown Series',
|
||||||
|
series_description: prediction.prediction.finding_type || 'AI Analysis',
|
||||||
|
total_images: prediction.prediction.processing_info.frame_count || 0,
|
||||||
|
png_preview: prediction.preview || '',
|
||||||
|
modality: prediction.patientdetails.Modality || 'Unknown'
|
||||||
|
}],
|
||||||
|
processing_metadata: prediction.processing_metadata,
|
||||||
|
total_predictions: 1,
|
||||||
|
first_processed_at: prediction.processed_at,
|
||||||
|
last_processed_at: prediction.processed_at
|
||||||
|
},
|
||||||
|
feedbackData: prediction.feedbacks || [],
|
||||||
|
onFeedbackSubmitted: () => {
|
||||||
|
// Refresh dashboard data when feedback is submitted
|
||||||
|
console.log('Feedback submitted, refreshing dashboard...');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('Navigation successful to FeedbackDetailScreen');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Navigation error:', error);
|
||||||
|
// Fallback: show alert or handle error gracefully
|
||||||
|
Alert.alert(
|
||||||
|
'Navigation Error',
|
||||||
|
'Unable to open feedback details. Please try again.',
|
||||||
|
[{ text: 'OK' }]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
{/* Bottom spacing for tab bar */}
|
{/* Bottom spacing for tab bar */}
|
||||||
<View style={styles.bottomSpacing} />
|
<View style={styles.bottomSpacing} />
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
@ -761,10 +757,11 @@ const styles = StyleSheet.create({
|
|||||||
|
|
||||||
// Dashboard title styling
|
// Dashboard title styling
|
||||||
dashboardTitle: {
|
dashboardTitle: {
|
||||||
fontSize: theme.typography.fontSize.displayLarge,
|
fontSize: theme.typography.fontSize.displayMedium,
|
||||||
fontFamily: theme.typography.fontFamily.bold,
|
fontFamily: theme.typography.fontFamily.bold,
|
||||||
color: theme.colors.textPrimary,
|
color: theme.colors.textPrimary,
|
||||||
marginBottom: theme.spacing.xs,
|
marginBottom: theme.spacing.xs,
|
||||||
|
marginTop: theme.spacing.sm,
|
||||||
},
|
},
|
||||||
|
|
||||||
// Dashboard subtitle styling
|
// Dashboard subtitle styling
|
||||||
@ -794,12 +791,29 @@ const styles = StyleSheet.create({
|
|||||||
...theme.shadows.primary,
|
...theme.shadows.primary,
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Stats card header styling (icon + title row)
|
||||||
|
statsCardHeader: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
marginBottom: theme.spacing.xs,
|
||||||
|
gap: theme.spacing.sm,
|
||||||
|
},
|
||||||
|
|
||||||
|
// Stats card icon styling
|
||||||
|
statsCardIcon: {
|
||||||
|
width: 32,
|
||||||
|
height: 32,
|
||||||
|
borderRadius: theme.borderRadius.small,
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
|
||||||
// Stats card title styling
|
// Stats card title styling
|
||||||
statsCardTitle: {
|
statsCardTitle: {
|
||||||
fontSize: theme.typography.fontSize.bodySmall,
|
fontSize: theme.typography.fontSize.bodySmall,
|
||||||
fontFamily: theme.typography.fontFamily.medium,
|
fontFamily: theme.typography.fontFamily.medium,
|
||||||
color: theme.colors.textSecondary,
|
color: theme.colors.textSecondary,
|
||||||
marginBottom: theme.spacing.xs,
|
flex: 1,
|
||||||
},
|
},
|
||||||
|
|
||||||
// Stats card value styling
|
// Stats card value styling
|
||||||
@ -835,6 +849,23 @@ const styles = StyleSheet.create({
|
|||||||
marginBottom: theme.spacing.lg,
|
marginBottom: theme.spacing.lg,
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Section subtitle styling
|
||||||
|
sectionSubtitle: {
|
||||||
|
fontSize: theme.typography.fontSize.bodyMedium,
|
||||||
|
fontFamily: theme.typography.fontFamily.regular,
|
||||||
|
color: theme.colors.textSecondary,
|
||||||
|
marginBottom: theme.spacing.md,
|
||||||
|
},
|
||||||
|
|
||||||
|
// Predictions container styling
|
||||||
|
predictionsContainer: {
|
||||||
|
height: 500, // Increased height for better FlatList performance
|
||||||
|
borderRadius: theme.borderRadius.medium,
|
||||||
|
overflow: 'hidden',
|
||||||
|
marginHorizontal: theme.spacing.md,
|
||||||
|
marginBottom: theme.spacing.md,
|
||||||
|
},
|
||||||
|
|
||||||
// Confidence breakdown container
|
// Confidence breakdown container
|
||||||
confidenceContainer: {
|
confidenceContainer: {
|
||||||
gap: theme.spacing.md,
|
gap: theme.spacing.md,
|
||||||
@ -910,35 +941,46 @@ const styles = StyleSheet.create({
|
|||||||
urgencyContainer: {
|
urgencyContainer: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
justifyContent: 'space-around',
|
justifyContent: 'space-around',
|
||||||
|
alignItems: 'flex-start',
|
||||||
|
paddingHorizontal: theme.spacing.sm,
|
||||||
},
|
},
|
||||||
|
|
||||||
// Urgency item styling
|
// Circle Container styling
|
||||||
urgencyItem: {
|
circleContainer: {
|
||||||
|
width: 80,
|
||||||
|
height: 80,
|
||||||
|
borderRadius: 40,
|
||||||
|
justifyContent: 'center',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
flex: 1,
|
borderWidth: 8,
|
||||||
|
backgroundColor: theme.colors.background,
|
||||||
|
shadowColor: '#000',
|
||||||
|
shadowOffset: { width: 0, height: 4 },
|
||||||
|
shadowOpacity: 0.3,
|
||||||
|
shadowRadius: 8,
|
||||||
|
elevation: 8,
|
||||||
},
|
},
|
||||||
|
|
||||||
// Urgency indicator styling
|
// Circle Value styling
|
||||||
urgencyIndicator: {
|
circleValue: {
|
||||||
width: 16,
|
fontSize: theme.typography.fontSize.bodyLarge,
|
||||||
height: 16,
|
fontFamily: theme.typography.fontFamily.bold,
|
||||||
borderRadius: 8,
|
color: theme.colors.textPrimary,
|
||||||
marginBottom: theme.spacing.xs,
|
|
||||||
},
|
},
|
||||||
|
|
||||||
// Urgency label styling
|
// Urgency Label styling
|
||||||
urgencyLabel: {
|
urgencyLabel: {
|
||||||
fontSize: theme.typography.fontSize.bodySmall,
|
fontSize: theme.typography.fontSize.bodySmall,
|
||||||
fontFamily: theme.typography.fontFamily.medium,
|
fontFamily: theme.typography.fontFamily.medium,
|
||||||
color: theme.colors.textSecondary,
|
color: theme.colors.textSecondary,
|
||||||
marginBottom: theme.spacing.xs,
|
marginTop: theme.spacing.sm,
|
||||||
|
textAlign: 'center',
|
||||||
},
|
},
|
||||||
|
|
||||||
// Urgency value styling
|
// Urgency Circle Item styling
|
||||||
urgencyValue: {
|
urgencyCircleItem: {
|
||||||
fontSize: theme.typography.fontSize.bodyMedium,
|
alignItems: 'center',
|
||||||
fontFamily: theme.typography.fontFamily.bold,
|
flex: 1,
|
||||||
color: theme.colors.textPrimary,
|
|
||||||
},
|
},
|
||||||
|
|
||||||
// Feedback container styling
|
// Feedback container styling
|
||||||
|
|||||||
43
app/modules/Dashboard/services/predictionsAPI.ts
Normal file
@ -0,0 +1,43 @@
|
|||||||
|
/*
|
||||||
|
* File: predictionsAPI.ts
|
||||||
|
* Description: API service for fetching AI prediction data and patient information
|
||||||
|
* Design & Developed by Tech4Biz Solutions
|
||||||
|
* Copyright (c) Spurrin Innovations. All rights reserved.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { create } from 'apisauce';
|
||||||
|
import { API_CONFIG, buildHeaders } from '../../../shared/utils';
|
||||||
|
|
||||||
|
const api = create({
|
||||||
|
baseURL: API_CONFIG.BASE_URL
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PredictionsAPI Service
|
||||||
|
*
|
||||||
|
* Purpose: Handle API calls related to AI predictions and patient data
|
||||||
|
*
|
||||||
|
* Features:
|
||||||
|
* - Fetch all predictions from single endpoint
|
||||||
|
* - Frontend filtering for feedback status
|
||||||
|
* - Simple API calls without complex parameters
|
||||||
|
*/
|
||||||
|
export const predictionsAPI = {
|
||||||
|
/**
|
||||||
|
* Fetch All Predictions
|
||||||
|
*
|
||||||
|
* Purpose: Fetch all processed patient predictions
|
||||||
|
*
|
||||||
|
* @param token - Authentication token
|
||||||
|
* @returns Promise with all predictions response
|
||||||
|
*/
|
||||||
|
fetchAllPredictions: (token: string) => {
|
||||||
|
return api.get('/api/ai-cases/feedbacks/processed-patients', {}, buildHeaders({ token }));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/*
|
||||||
|
* End of File: predictionsAPI.ts
|
||||||
|
* Design & Developed by Tech4Biz Solutions
|
||||||
|
* Copyright (c) Spurrin Innovations. All rights reserved.
|
||||||
|
*/
|
||||||
191
app/modules/Dashboard/types/predictions.ts
Normal file
@ -0,0 +1,191 @@
|
|||||||
|
/*
|
||||||
|
* File: predictions.ts
|
||||||
|
* Description: Type definitions for AI prediction data and patient information
|
||||||
|
* Design & Developed by Tech4Biz Solutions
|
||||||
|
* Copyright (c) Spurrin Innovations. All rights reserved.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// PREDICTION DATA TYPES
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export interface PredictionProcessingInfo {
|
||||||
|
filename: string;
|
||||||
|
file_type: string;
|
||||||
|
frame_count: number;
|
||||||
|
is_multiframe: boolean;
|
||||||
|
averaging_applied: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StrokeDetection {
|
||||||
|
Normal: number;
|
||||||
|
Stroke: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BinaryHemorrhage {
|
||||||
|
Normal: number;
|
||||||
|
Hemorrhage: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HemorrhageDetection {
|
||||||
|
Epidural: number;
|
||||||
|
Subdural: number;
|
||||||
|
Subarachnoid: number;
|
||||||
|
"Midline shift": number;
|
||||||
|
Intraparenchymal: number;
|
||||||
|
Intraventricular: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DetailedResults {
|
||||||
|
stroke_detection: StrokeDetection;
|
||||||
|
binary_hemorrhage: BinaryHemorrhage;
|
||||||
|
hemorrhage_detection: HemorrhageDetection;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Prediction {
|
||||||
|
label: string;
|
||||||
|
finding_type: string;
|
||||||
|
processing_info: PredictionProcessingInfo;
|
||||||
|
clinical_urgency: string;
|
||||||
|
confidence_score: number;
|
||||||
|
detailed_results: DetailedResults;
|
||||||
|
finding_category: string;
|
||||||
|
primary_severity: string;
|
||||||
|
anatomical_location: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PatientDetails {
|
||||||
|
Date: string;
|
||||||
|
Name: string;
|
||||||
|
PatID: string;
|
||||||
|
PatAge: string;
|
||||||
|
PatSex: string;
|
||||||
|
Status: string;
|
||||||
|
InstName: string;
|
||||||
|
Modality: string;
|
||||||
|
ReportStatus: string | null;
|
||||||
|
medpacks_data: {
|
||||||
|
series: Array<{
|
||||||
|
Path: string[];
|
||||||
|
SerDes: string;
|
||||||
|
ViePos: string | null;
|
||||||
|
pngpath: string;
|
||||||
|
SeriesNum: string;
|
||||||
|
ImgTotalinSeries: string;
|
||||||
|
}>;
|
||||||
|
file_path: string;
|
||||||
|
basic_info: Record<string, any>;
|
||||||
|
study_info: {
|
||||||
|
modality: string;
|
||||||
|
};
|
||||||
|
hospital_id: string;
|
||||||
|
parsed_data: {
|
||||||
|
series: Array<{
|
||||||
|
Path: string[];
|
||||||
|
SerDes: string;
|
||||||
|
ViePos: string | null;
|
||||||
|
pngpath: string;
|
||||||
|
SeriesNum: string;
|
||||||
|
ImgTotalinSeries: string;
|
||||||
|
}>;
|
||||||
|
patientdetails: {
|
||||||
|
Date: string;
|
||||||
|
Name: string;
|
||||||
|
PatID: string;
|
||||||
|
PatAge: string;
|
||||||
|
PatSex: string;
|
||||||
|
Status: string;
|
||||||
|
InstName: string;
|
||||||
|
Modality: string;
|
||||||
|
ReportStatus: string | null;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
dicom_images: any[];
|
||||||
|
dicom_series: any[];
|
||||||
|
dicom_studies: any[];
|
||||||
|
image_details: Record<string, any>;
|
||||||
|
all_dicom_data: any;
|
||||||
|
technical_details: Record<string, any>;
|
||||||
|
patient_demographics: Record<string, any>;
|
||||||
|
complete_patient_data: {
|
||||||
|
series: string;
|
||||||
|
file_id: string;
|
||||||
|
file_path: string;
|
||||||
|
series_id: string | null;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
hospital_id: string;
|
||||||
|
patientdetails: string;
|
||||||
|
};
|
||||||
|
complete_dicom_details: any;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProcessingMetadata {
|
||||||
|
hospital_id: string;
|
||||||
|
processed_at: string;
|
||||||
|
ai_model_used: string;
|
||||||
|
original_patid: string;
|
||||||
|
sync_timestamp: string;
|
||||||
|
processed_file_path: string;
|
||||||
|
complete_dicom_fetched: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Feedback {
|
||||||
|
feedback_id: string;
|
||||||
|
patid: string;
|
||||||
|
prediction_id: number;
|
||||||
|
user_id: string;
|
||||||
|
feedback_text: string;
|
||||||
|
is_positive: boolean;
|
||||||
|
email: string;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PredictionData {
|
||||||
|
id: number;
|
||||||
|
patid: string;
|
||||||
|
hospital_id: string;
|
||||||
|
prediction: Prediction;
|
||||||
|
patientdetails: PatientDetails;
|
||||||
|
processing_metadata: ProcessingMetadata;
|
||||||
|
file_path: string;
|
||||||
|
processed_at: string;
|
||||||
|
preview: string;
|
||||||
|
feedbacks: Feedback[];
|
||||||
|
user_feedback_count: number;
|
||||||
|
latest_feedback_date: string;
|
||||||
|
latest_feedback_type: string;
|
||||||
|
has_provided_feedback: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PredictionsResponse {
|
||||||
|
success: boolean;
|
||||||
|
data: PredictionData[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// TAB TYPES
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export type PredictionTabType = 'with-feedback' | 'without-feedback';
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// FILTER TYPES
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export interface PredictionFilters {
|
||||||
|
urgency?: string;
|
||||||
|
finding_type?: string;
|
||||||
|
confidence_min?: number;
|
||||||
|
confidence_max?: number;
|
||||||
|
date_from?: string;
|
||||||
|
date_to?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* End of File: predictions.ts
|
||||||
|
* Design & Developed by Tech4Biz Solutions
|
||||||
|
* Copyright (c) Spurrin Innovations. All rights reserved.
|
||||||
|
*/
|
||||||
@ -21,18 +21,17 @@ import Icon from 'react-native-vector-icons/Feather';
|
|||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
interface FilterTabsProps {
|
interface FilterTabsProps {
|
||||||
selectedFilter: 'all' | 'processed' | 'pending' | 'error';
|
selectedFilter: 'all' | 'processed' | 'pending';
|
||||||
onFilterChange: (filter: 'all' | 'processed' | 'pending' | 'error') => void;
|
onFilterChange: (filter: 'all' | 'processed' | 'pending') => void;
|
||||||
patientCounts: {
|
patientCounts: {
|
||||||
all: number;
|
all: number;
|
||||||
processed: number;
|
processed: number;
|
||||||
pending: number;
|
pending: number;
|
||||||
error: number;
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
interface FilterTab {
|
interface FilterTab {
|
||||||
id: 'all' | 'processed' | 'pending' | 'error';
|
id: 'all' | 'processed' | 'pending';
|
||||||
label: string;
|
label: string;
|
||||||
icon: string;
|
icon: string;
|
||||||
color: string;
|
color: string;
|
||||||
@ -49,7 +48,7 @@ interface FilterTab {
|
|||||||
* Purpose: Provide filtering options for patient list
|
* Purpose: Provide filtering options for patient list
|
||||||
*
|
*
|
||||||
* Features:
|
* Features:
|
||||||
* - Multiple filter options (All, Processed, Pending, Error)
|
* - Multiple filter options (All, Processed, Pending)
|
||||||
* - Patient count display for each filter
|
* - Patient count display for each filter
|
||||||
* - Visual indicators with icons and colors
|
* - Visual indicators with icons and colors
|
||||||
* - Horizontal scrollable layout
|
* - Horizontal scrollable layout
|
||||||
@ -87,13 +86,6 @@ const FilterTabs: React.FC<FilterTabsProps> = ({
|
|||||||
color: theme.colors.warning,
|
color: theme.colors.warning,
|
||||||
activeColor: theme.colors.warning,
|
activeColor: theme.colors.warning,
|
||||||
},
|
},
|
||||||
{
|
|
||||||
id: 'error',
|
|
||||||
label: 'Error',
|
|
||||||
icon: 'alert-triangle',
|
|
||||||
color: theme.colors.error,
|
|
||||||
activeColor: theme.colors.error,
|
|
||||||
},
|
|
||||||
];
|
];
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@ -116,8 +108,6 @@ const FilterTabs: React.FC<FilterTabsProps> = ({
|
|||||||
return patientCounts.processed;
|
return patientCounts.processed;
|
||||||
case 'pending':
|
case 'pending':
|
||||||
return patientCounts.pending;
|
return patientCounts.pending;
|
||||||
case 'error':
|
|
||||||
return patientCounts.error;
|
|
||||||
default:
|
default:
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
@ -189,13 +179,6 @@ const FilterTabs: React.FC<FilterTabsProps> = ({
|
|||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{/* Error Indicator */}
|
|
||||||
{tab.id === 'error' && patientCount > 0 && (
|
|
||||||
<View style={styles.errorIndicator}>
|
|
||||||
<View style={styles.pulseDot} />
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@ -299,22 +282,6 @@ const styles = StyleSheet.create({
|
|||||||
color: theme.colors.background,
|
color: theme.colors.background,
|
||||||
},
|
},
|
||||||
|
|
||||||
// Error Indicator
|
|
||||||
errorIndicator: {
|
|
||||||
position: 'absolute',
|
|
||||||
top: 8,
|
|
||||||
right: 8,
|
|
||||||
width: 8,
|
|
||||||
height: 8,
|
|
||||||
},
|
|
||||||
pulseDot: {
|
|
||||||
width: 8,
|
|
||||||
height: 8,
|
|
||||||
borderRadius: 4,
|
|
||||||
backgroundColor: theme.colors.error,
|
|
||||||
// Note: In a real app, you'd add animation here
|
|
||||||
},
|
|
||||||
|
|
||||||
// Active Filter Indicator
|
// Active Filter Indicator
|
||||||
activeIndicator: {
|
activeIndicator: {
|
||||||
marginTop: theme.spacing.xs,
|
marginTop: theme.spacing.xs,
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
/*
|
/*
|
||||||
* File: PatientCard.tsx
|
* File: PatientCard.tsx
|
||||||
* Description: Patient card component for displaying DICOM medical case information
|
* Description: Enhanced patient card component for displaying DICOM medical case information
|
||||||
* Design & Developed by Tech4Biz Solutions
|
* Design & Developed by Tech4Biz Solutions
|
||||||
* Copyright (c) Spurrin Innovations. All rights reserved.
|
* Copyright (c) Spurrin Innovations. All rights reserved.
|
||||||
*/
|
*/
|
||||||
@ -33,16 +33,16 @@ interface PatientCardProps {
|
|||||||
/**
|
/**
|
||||||
* PatientCard Component
|
* PatientCard Component
|
||||||
*
|
*
|
||||||
* Purpose: Display DICOM medical case information in a card format
|
* Purpose: Display DICOM medical case information in a modern, enhanced card format
|
||||||
*
|
*
|
||||||
* Features:
|
* Features:
|
||||||
* - Patient basic information from DICOM data
|
* - Enhanced visual hierarchy with modern design
|
||||||
* - Modality and institution information
|
* - Improved status indicators and color coding
|
||||||
* - Processing status with color coding
|
* - Better spacing and typography
|
||||||
* - Series information
|
* - Enhanced shadows and elevation
|
||||||
* - Time since processed
|
* - More intuitive information layout
|
||||||
* - Emergency alert for critical cases
|
* - Emergency alert for critical cases
|
||||||
* - Modern ER-focused design
|
* - Modern ER-focused design with better UX
|
||||||
*/
|
*/
|
||||||
const PatientCard: React.FC<PatientCardProps> = ({
|
const PatientCard: React.FC<PatientCardProps> = ({
|
||||||
patient,
|
patient,
|
||||||
@ -56,36 +56,40 @@ const PatientCard: React.FC<PatientCardProps> = ({
|
|||||||
/**
|
/**
|
||||||
* Get Status Color Configuration
|
* Get Status Color Configuration
|
||||||
*
|
*
|
||||||
* Purpose: Get color and icon based on processing status
|
* Purpose: Get enhanced color and icon based on processing status
|
||||||
*
|
*
|
||||||
* @param status - Processing status
|
* @param status - Processing status
|
||||||
* @returns Color configuration object
|
* @returns Enhanced color configuration object
|
||||||
*/
|
*/
|
||||||
const getStatusConfig = (status: string) => {
|
const getStatusConfig = (status: string) => {
|
||||||
switch (status.toLowerCase()) {
|
switch (status.toLowerCase()) {
|
||||||
case 'processed':
|
case 'processed':
|
||||||
return {
|
return {
|
||||||
color: theme.colors.success,
|
color: '#10B981',
|
||||||
icon: 'check-circle',
|
icon: 'check-circle',
|
||||||
bgColor: '#F0FFF4'
|
bgColor: '#ECFDF5',
|
||||||
|
borderColor: '#D1FAE5'
|
||||||
};
|
};
|
||||||
case 'pending':
|
case 'pending':
|
||||||
return {
|
return {
|
||||||
color: theme.colors.warning,
|
color: '#F59E0B',
|
||||||
icon: 'clock',
|
icon: 'clock',
|
||||||
bgColor: '#FFF8E1'
|
bgColor: '#FFFBEB',
|
||||||
|
borderColor: '#FEF3C7'
|
||||||
};
|
};
|
||||||
case 'error':
|
case 'error':
|
||||||
return {
|
return {
|
||||||
color: theme.colors.error,
|
color: '#EF4444',
|
||||||
icon: 'alert-triangle',
|
icon: 'alert-triangle',
|
||||||
bgColor: '#FFF5F5'
|
bgColor: '#FEF2F2',
|
||||||
|
borderColor: '#FECACA'
|
||||||
};
|
};
|
||||||
default:
|
default:
|
||||||
return {
|
return {
|
||||||
color: theme.colors.primary,
|
color: '#3B82F6',
|
||||||
icon: 'info',
|
icon: 'info',
|
||||||
bgColor: theme.colors.background
|
bgColor: '#EFF6FF',
|
||||||
|
borderColor: '#DBEAFE'
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@ -93,21 +97,21 @@ const PatientCard: React.FC<PatientCardProps> = ({
|
|||||||
/**
|
/**
|
||||||
* Get Modality Color
|
* Get Modality Color
|
||||||
*
|
*
|
||||||
* Purpose: Get color based on imaging modality
|
* Purpose: Get enhanced color based on imaging modality
|
||||||
*
|
*
|
||||||
* @param modality - Imaging modality
|
* @param modality - Imaging modality
|
||||||
* @returns Color code
|
* @returns Enhanced color code
|
||||||
*/
|
*/
|
||||||
const getModalityColor = (modality: string) => {
|
const getModalityColor = (modality: string) => {
|
||||||
switch (modality.toUpperCase()) {
|
switch (modality.toUpperCase()) {
|
||||||
case 'CT':
|
case 'CT':
|
||||||
return '#4A90E2';
|
return '#3B82F6';
|
||||||
case 'MR':
|
case 'MR':
|
||||||
return '#7B68EE';
|
return '#8B5CF6';
|
||||||
case 'DX':
|
case 'DX':
|
||||||
return '#50C878';
|
return '#10B981';
|
||||||
case 'DICOM':
|
case 'DICOM':
|
||||||
return '#FF6B6B';
|
return '#EF4444';
|
||||||
default:
|
default:
|
||||||
return theme.colors.textSecondary;
|
return theme.colors.textSecondary;
|
||||||
}
|
}
|
||||||
@ -164,21 +168,26 @@ const PatientCard: React.FC<PatientCardProps> = ({
|
|||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Render Status Badge
|
* Render Enhanced Status Badge
|
||||||
*
|
*
|
||||||
* Purpose: Render processing status indicator badge
|
* Purpose: Render improved processing status indicator badge
|
||||||
*/
|
*/
|
||||||
const renderStatusBadge = () => (
|
const renderStatusBadge = () => (
|
||||||
<View style={[styles.statusBadge, { backgroundColor: statusConfig.bgColor, borderColor: statusConfig.color }]}>
|
<View style={[styles.statusBadge, {
|
||||||
<Icon name={statusConfig.icon} size={12} color={statusConfig.color} />
|
backgroundColor: statusConfig.bgColor,
|
||||||
<Text style={[styles.statusText, { color: statusConfig.color }]}>{patientInfo.status}</Text>
|
borderColor: statusConfig.borderColor
|
||||||
|
}]}>
|
||||||
|
<Icon name={statusConfig.icon} size={14} color={statusConfig.color} />
|
||||||
|
<Text style={[styles.statusText, { color: statusConfig.color }]}>
|
||||||
|
{patientInfo.status}
|
||||||
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Render Emergency Button
|
* Render Enhanced Emergency Button
|
||||||
*
|
*
|
||||||
* Purpose: Render emergency alert button for critical cases
|
* Purpose: Render improved emergency alert button for critical cases
|
||||||
*/
|
*/
|
||||||
const renderEmergencyButton = () => {
|
const renderEmergencyButton = () => {
|
||||||
if (!isCritical) {
|
if (!isCritical) {
|
||||||
@ -189,18 +198,35 @@ const PatientCard: React.FC<PatientCardProps> = ({
|
|||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
style={styles.emergencyButton}
|
style={styles.emergencyButton}
|
||||||
onPress={onEmergencyPress}
|
onPress={onEmergencyPress}
|
||||||
activeOpacity={0.7}
|
activeOpacity={0.8}
|
||||||
>
|
>
|
||||||
<Icon name="alert-triangle" size={14} color={theme.colors.background} />
|
<Icon name="alert-triangle" size={16} color={theme.colors.background} />
|
||||||
<Text style={styles.emergencyButtonText}>ALERT</Text>
|
<Text style={styles.emergencyButtonText}>ALERT</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render Enhanced Modality Badge
|
||||||
|
*
|
||||||
|
* Purpose: Render improved modality indicator
|
||||||
|
*/
|
||||||
|
const renderModalityBadge = () => (
|
||||||
|
<View style={[styles.modalityBadge, {
|
||||||
|
backgroundColor: getModalityColor(patientInfo.modality) + '20',
|
||||||
|
borderColor: getModalityColor(patientInfo.modality)
|
||||||
|
}]}>
|
||||||
|
<Text style={[styles.modalityText, {
|
||||||
|
color: getModalityColor(patientInfo.modality)
|
||||||
|
}]}>
|
||||||
|
{patientInfo.modality || 'N/A'}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// MAIN RENDER
|
// MAIN RENDER
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
style={[
|
style={[
|
||||||
@ -209,14 +235,17 @@ const PatientCard: React.FC<PatientCardProps> = ({
|
|||||||
{ borderLeftColor: statusConfig.color }
|
{ borderLeftColor: statusConfig.color }
|
||||||
]}
|
]}
|
||||||
onPress={onPress}
|
onPress={onPress}
|
||||||
activeOpacity={0.7}
|
activeOpacity={0.8}
|
||||||
>
|
>
|
||||||
{/* Header Section */}
|
{/* Enhanced Header Section */}
|
||||||
<View style={styles.header}>
|
<View style={styles.header}>
|
||||||
<View style={styles.headerLeft}>
|
<View style={styles.headerLeft}>
|
||||||
|
<View style={styles.patientNameRow}>
|
||||||
<Text style={styles.patientName}>
|
<Text style={styles.patientName}>
|
||||||
{patientInfo.name || 'Unknown Patient'}
|
{patientInfo.name || 'Unknown Patient'}
|
||||||
</Text>
|
</Text>
|
||||||
|
{renderModalityBadge()}
|
||||||
|
</View>
|
||||||
<Text style={styles.patientInfo}>
|
<Text style={styles.patientInfo}>
|
||||||
ID: {patient.patid} • {patientInfo.age || 'N/A'}y • {patientInfo.sex || 'N/A'}
|
ID: {patient.patid} • {patientInfo.age || 'N/A'}y • {patientInfo.sex || 'N/A'}
|
||||||
</Text>
|
</Text>
|
||||||
@ -227,72 +256,80 @@ const PatientCard: React.FC<PatientCardProps> = ({
|
|||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{/* Medical Information Section */}
|
{/* Enhanced Medical Information Section */}
|
||||||
<View style={styles.medicalSection}>
|
<View style={styles.medicalSection}>
|
||||||
<View style={styles.infoRow}>
|
<View style={styles.infoGrid}>
|
||||||
<View style={styles.infoItem}>
|
<View style={styles.infoCard}>
|
||||||
<Text style={styles.infoLabel}>Modality</Text>
|
<Icon name="file-text" size={16} color={theme.colors.primary} />
|
||||||
<Text style={[
|
<Text style={styles.infoValue}>
|
||||||
styles.infoValue,
|
{patient.total_files_processed || 0}
|
||||||
styles.modalityText,
|
|
||||||
{ color: getModalityColor(patientInfo.modality) }
|
|
||||||
]}>
|
|
||||||
{patientInfo.modality || 'N/A'}
|
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
|
||||||
<View style={styles.infoItem}>
|
|
||||||
<Text style={styles.infoLabel}>Files</Text>
|
<Text style={styles.infoLabel}>Files</Text>
|
||||||
<Text style={[
|
|
||||||
styles.infoValue,
|
|
||||||
{ color: theme.colors.primary }
|
|
||||||
]}>
|
|
||||||
{patient.total_files_processed}
|
|
||||||
</Text>
|
|
||||||
</View>
|
</View>
|
||||||
<View style={styles.infoItem}>
|
|
||||||
<Text style={styles.infoLabel}>Report</Text>
|
<View style={styles.infoCard}>
|
||||||
<Text style={[
|
<Icon name="layers" size={16} color={theme.colors.warning} />
|
||||||
styles.infoValue,
|
<Text style={styles.infoValue}>
|
||||||
{ color: patientInfo.report_status === 'Available' ? theme.colors.success : theme.colors.warning }
|
{seriesCount}
|
||||||
]}>
|
</Text>
|
||||||
|
<Text style={styles.infoLabel}>Series</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.infoCard}>
|
||||||
|
<Icon name="clipboard" size={16} color={
|
||||||
|
patientInfo.report_status === 'Available' ? theme.colors.success : theme.colors.warning
|
||||||
|
} />
|
||||||
|
<Text style={[styles.infoValue, {
|
||||||
|
color: patientInfo.report_status === 'Available' ? theme.colors.success : theme.colors.warning
|
||||||
|
}]}>
|
||||||
{patientInfo.report_status || 'Pending'}
|
{patientInfo.report_status || 'Pending'}
|
||||||
</Text>
|
</Text>
|
||||||
|
<Text style={styles.infoLabel}>Report</Text>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{/* Institution */}
|
{/* Enhanced Institution Row */}
|
||||||
<View style={styles.institutionRow}>
|
<View style={styles.institutionRow}>
|
||||||
<Icon name="home" size={14} color={theme.colors.textSecondary} />
|
<View style={styles.institutionIcon}>
|
||||||
|
<Icon name="home" size={16} color={theme.colors.primary} />
|
||||||
|
</View>
|
||||||
<Text style={styles.institutionText}>
|
<Text style={styles.institutionText}>
|
||||||
{patientInfo.institution || 'Unknown Institution'}
|
{patientInfo.institution || 'Unknown Institution'}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{/* Series Information */}
|
{/* Enhanced Series Information */}
|
||||||
<View style={styles.seriesSection}>
|
<View style={styles.seriesSection}>
|
||||||
<View style={styles.seriesHeader}>
|
<View style={styles.seriesHeader}>
|
||||||
<Icon name="layers" size={14} color={theme.colors.textSecondary} />
|
<Icon name="layers" size={16} color={theme.colors.textSecondary} />
|
||||||
<Text style={styles.seriesLabel}>Series Information</Text>
|
<Text style={styles.seriesLabel}>Series Details</Text>
|
||||||
</View>
|
</View>
|
||||||
|
<View style={styles.seriesInfo}>
|
||||||
<Text style={styles.seriesText}>
|
<Text style={styles.seriesText}>
|
||||||
{seriesCount} Series Available • {patientInfo.frame_count} Total Frames
|
{seriesCount} Series Available
|
||||||
|
</Text>
|
||||||
|
<Text style={styles.frameText}>
|
||||||
|
{patientInfo.frame_count || 0} Total Frames
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
{/* Footer */}
|
{/* Enhanced Footer */}
|
||||||
<View style={styles.footer}>
|
<View style={styles.footer}>
|
||||||
<View style={styles.footerLeft}>
|
<View style={styles.footerLeft}>
|
||||||
<Text style={styles.dateText}>
|
<Text style={styles.dateText}>
|
||||||
{formatDate(patientInfo.date)}
|
{formatDate(patientInfo.date)}
|
||||||
</Text>
|
</Text>
|
||||||
<Text style={styles.processedText}>
|
<Text style={styles.processedText}>
|
||||||
{getTimeSinceProcessed(patient.last_processed_at)}
|
Processed {getTimeSinceProcessed(patient.last_processed_at)}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
<View style={styles.footerRight}>
|
<View style={styles.footerRight}>
|
||||||
<Text style={styles.caseId}>Case #{patient.patid}</Text>
|
<Text style={styles.caseId}>Case #{patient.patid}</Text>
|
||||||
<Icon name="chevron-right" size={16} color={theme.colors.textMuted} />
|
<View style={styles.chevronContainer}>
|
||||||
|
<Icon name="chevron-right" size={18} color={theme.colors.primary} />
|
||||||
|
</View>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
@ -300,21 +337,21 @@ const PatientCard: React.FC<PatientCardProps> = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// STYLES
|
// ENHANCED STYLES
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
container: {
|
container: {
|
||||||
backgroundColor: theme.colors.background,
|
backgroundColor: theme.colors.background,
|
||||||
borderRadius: 12,
|
borderRadius: 16,
|
||||||
padding: theme.spacing.md,
|
padding: theme.spacing.md,
|
||||||
marginHorizontal: theme.spacing.md,
|
marginHorizontal: theme.spacing.md,
|
||||||
marginVertical: theme.spacing.xs,
|
marginVertical: theme.spacing.xs,
|
||||||
shadowColor: theme.colors.shadow,
|
shadowColor: '#000000',
|
||||||
shadowOffset: { width: 0, height: 2 },
|
shadowOffset: { width: 0, height: 2 },
|
||||||
shadowOpacity: 0.1,
|
shadowOpacity: 0.06,
|
||||||
shadowRadius: 4,
|
shadowRadius: 8,
|
||||||
elevation: 2,
|
elevation: 3,
|
||||||
borderWidth: 1,
|
borderWidth: 1,
|
||||||
borderColor: theme.colors.border,
|
borderColor: theme.colors.border,
|
||||||
borderLeftWidth: 4,
|
borderLeftWidth: 4,
|
||||||
@ -322,10 +359,12 @@ const styles = StyleSheet.create({
|
|||||||
containerCritical: {
|
containerCritical: {
|
||||||
borderColor: theme.colors.error,
|
borderColor: theme.colors.error,
|
||||||
borderWidth: 2,
|
borderWidth: 2,
|
||||||
backgroundColor: '#FFF5F5',
|
backgroundColor: '#FEF2F2',
|
||||||
|
shadowColor: theme.colors.error,
|
||||||
|
shadowOpacity: 0.15,
|
||||||
},
|
},
|
||||||
|
|
||||||
// Header Section
|
// Enhanced Header Section
|
||||||
header: {
|
header: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
justifyContent: 'space-between',
|
justifyContent: 'space-between',
|
||||||
@ -334,156 +373,220 @@ const styles = StyleSheet.create({
|
|||||||
},
|
},
|
||||||
headerLeft: {
|
headerLeft: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
marginRight: theme.spacing.sm,
|
marginRight: theme.spacing.md,
|
||||||
},
|
},
|
||||||
headerRight: {
|
headerRight: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
|
gap: theme.spacing.sm,
|
||||||
|
},
|
||||||
|
patientNameRow: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
marginBottom: theme.spacing.xs,
|
||||||
|
gap: theme.spacing.xs,
|
||||||
},
|
},
|
||||||
patientName: {
|
patientName: {
|
||||||
fontSize: 18,
|
fontSize: 20,
|
||||||
color: theme.colors.textPrimary,
|
color: theme.colors.textPrimary,
|
||||||
fontFamily: theme.typography.fontFamily.bold,
|
fontFamily: theme.typography.fontFamily.bold,
|
||||||
|
flex: 1,
|
||||||
},
|
},
|
||||||
patientInfo: {
|
patientInfo: {
|
||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
color: theme.colors.textSecondary,
|
color: theme.colors.textSecondary,
|
||||||
marginTop: 2,
|
fontFamily: theme.typography.fontFamily.medium,
|
||||||
fontFamily: theme.typography.fontFamily.regular,
|
lineHeight: 20,
|
||||||
},
|
},
|
||||||
|
|
||||||
// Status Badge
|
// Enhanced Status Badge
|
||||||
statusBadge: {
|
statusBadge: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
paddingHorizontal: 8,
|
paddingHorizontal: 12,
|
||||||
paddingVertical: 4,
|
paddingVertical: 6,
|
||||||
borderRadius: 12,
|
borderRadius: 20,
|
||||||
marginRight: theme.spacing.xs,
|
borderWidth: 1.5,
|
||||||
borderWidth: 1,
|
gap: theme.spacing.xs,
|
||||||
},
|
},
|
||||||
statusText: {
|
statusText: {
|
||||||
fontSize: 10,
|
fontSize: 11,
|
||||||
fontFamily: theme.typography.fontFamily.bold,
|
fontFamily: theme.typography.fontFamily.bold,
|
||||||
marginLeft: 4,
|
|
||||||
textTransform: 'uppercase',
|
textTransform: 'uppercase',
|
||||||
|
letterSpacing: 0.5,
|
||||||
},
|
},
|
||||||
|
|
||||||
// Emergency Button
|
// Enhanced Emergency Button
|
||||||
emergencyButton: {
|
emergencyButton: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
backgroundColor: theme.colors.error,
|
backgroundColor: theme.colors.error,
|
||||||
|
paddingHorizontal: 12,
|
||||||
|
paddingVertical: 6,
|
||||||
|
borderRadius: 20,
|
||||||
|
gap: theme.spacing.xs,
|
||||||
|
shadowColor: theme.colors.error,
|
||||||
|
shadowOffset: { width: 0, height: 2 },
|
||||||
|
shadowOpacity: 0.3,
|
||||||
|
shadowRadius: 4,
|
||||||
|
elevation: 3,
|
||||||
|
},
|
||||||
|
emergencyButtonText: {
|
||||||
|
fontSize: 11,
|
||||||
|
fontFamily: theme.typography.fontFamily.bold,
|
||||||
|
color: theme.colors.background,
|
||||||
|
letterSpacing: 0.5,
|
||||||
|
},
|
||||||
|
|
||||||
|
// Enhanced Modality Badge
|
||||||
|
modalityBadge: {
|
||||||
paddingHorizontal: 8,
|
paddingHorizontal: 8,
|
||||||
paddingVertical: 4,
|
paddingVertical: 4,
|
||||||
borderRadius: 12,
|
borderRadius: 12,
|
||||||
|
borderWidth: 1.5,
|
||||||
},
|
},
|
||||||
emergencyButtonText: {
|
modalityText: {
|
||||||
fontSize: 10,
|
fontSize: 10,
|
||||||
fontFamily: theme.typography.fontFamily.bold,
|
fontFamily: theme.typography.fontFamily.bold,
|
||||||
color: theme.colors.background,
|
textTransform: 'uppercase',
|
||||||
marginLeft: 4,
|
letterSpacing: 0.5,
|
||||||
},
|
},
|
||||||
|
|
||||||
// Medical Section
|
// Enhanced Medical Section
|
||||||
medicalSection: {
|
medicalSection: {
|
||||||
marginBottom: theme.spacing.sm,
|
marginBottom: theme.spacing.sm,
|
||||||
paddingBottom: theme.spacing.sm,
|
paddingBottom: theme.spacing.sm,
|
||||||
borderBottomWidth: 1,
|
borderBottomWidth: 1,
|
||||||
borderBottomColor: theme.colors.border,
|
borderBottomColor: theme.colors.border,
|
||||||
},
|
},
|
||||||
infoRow: {
|
infoGrid: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
justifyContent: 'space-between',
|
justifyContent: 'space-between',
|
||||||
marginBottom: theme.spacing.sm,
|
marginBottom: theme.spacing.sm,
|
||||||
|
gap: theme.spacing.sm,
|
||||||
},
|
},
|
||||||
infoItem: {
|
infoCard: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
|
paddingVertical: theme.spacing.xs,
|
||||||
|
backgroundColor: theme.colors.backgroundAlt,
|
||||||
|
borderRadius: 12,
|
||||||
|
paddingHorizontal: theme.spacing.sm,
|
||||||
|
gap: theme.spacing.xs,
|
||||||
},
|
},
|
||||||
infoLabel: {
|
infoLabel: {
|
||||||
fontSize: 10,
|
fontSize: 10,
|
||||||
color: theme.colors.textMuted,
|
color: theme.colors.textMuted,
|
||||||
marginBottom: 2,
|
|
||||||
textTransform: 'uppercase',
|
textTransform: 'uppercase',
|
||||||
fontFamily: theme.typography.fontFamily.regular,
|
fontFamily: theme.typography.fontFamily.medium,
|
||||||
|
letterSpacing: 0.5,
|
||||||
|
textAlign: 'center',
|
||||||
},
|
},
|
||||||
infoValue: {
|
infoValue: {
|
||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
fontFamily: theme.typography.fontFamily.bold,
|
fontFamily: theme.typography.fontFamily.medium,
|
||||||
color: theme.colors.textPrimary,
|
color: theme.colors.textPrimary,
|
||||||
textAlign: 'center',
|
textAlign: 'center',
|
||||||
},
|
},
|
||||||
modalityText: {
|
|
||||||
fontFamily: theme.typography.fontFamily.bold,
|
|
||||||
},
|
|
||||||
|
|
||||||
// Institution Row
|
// Enhanced Institution Row
|
||||||
institutionRow: {
|
institutionRow: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
|
backgroundColor: theme.colors.backgroundAlt,
|
||||||
|
padding: theme.spacing.sm,
|
||||||
|
borderRadius: 12,
|
||||||
|
gap: theme.spacing.sm,
|
||||||
|
},
|
||||||
|
institutionIcon: {
|
||||||
|
width: 32,
|
||||||
|
height: 32,
|
||||||
|
borderRadius: 16,
|
||||||
|
backgroundColor: theme.colors.primary + '20',
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
},
|
},
|
||||||
institutionText: {
|
institutionText: {
|
||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
color: theme.colors.textSecondary,
|
color: theme.colors.textSecondary,
|
||||||
marginLeft: 6,
|
|
||||||
flex: 1,
|
flex: 1,
|
||||||
fontFamily: theme.typography.fontFamily.regular,
|
fontFamily: theme.typography.fontFamily.medium,
|
||||||
},
|
},
|
||||||
|
|
||||||
// Series Section
|
// Enhanced Series Section
|
||||||
seriesSection: {
|
seriesSection: {
|
||||||
backgroundColor: theme.colors.backgroundAlt,
|
backgroundColor: theme.colors.backgroundAlt,
|
||||||
borderRadius: 8,
|
borderRadius: 12,
|
||||||
padding: theme.spacing.sm,
|
padding: theme.spacing.sm,
|
||||||
marginBottom: theme.spacing.sm,
|
marginBottom: theme.spacing.sm,
|
||||||
},
|
},
|
||||||
seriesHeader: {
|
seriesHeader: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
marginBottom: 4,
|
marginBottom: theme.spacing.sm,
|
||||||
|
gap: theme.spacing.xs,
|
||||||
},
|
},
|
||||||
seriesLabel: {
|
seriesLabel: {
|
||||||
fontSize: 12,
|
fontSize: 13,
|
||||||
fontFamily: theme.typography.fontFamily.regular,
|
fontFamily: theme.typography.fontFamily.medium,
|
||||||
color: theme.colors.textSecondary,
|
color: theme.colors.textSecondary,
|
||||||
marginLeft: 4,
|
textTransform: 'uppercase',
|
||||||
|
letterSpacing: 0.5,
|
||||||
|
},
|
||||||
|
seriesInfo: {
|
||||||
|
gap: theme.spacing.xs,
|
||||||
},
|
},
|
||||||
seriesText: {
|
seriesText: {
|
||||||
fontSize: 14,
|
fontSize: 15,
|
||||||
color: theme.colors.textPrimary,
|
color: theme.colors.textPrimary,
|
||||||
|
fontFamily: theme.typography.fontFamily.medium,
|
||||||
|
},
|
||||||
|
frameText: {
|
||||||
|
fontSize: 13,
|
||||||
|
color: theme.colors.textSecondary,
|
||||||
fontFamily: theme.typography.fontFamily.regular,
|
fontFamily: theme.typography.fontFamily.regular,
|
||||||
},
|
},
|
||||||
|
|
||||||
// Footer Section
|
// Enhanced Footer Section
|
||||||
footer: {
|
footer: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
justifyContent: 'space-between',
|
justifyContent: 'space-between',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
|
paddingTop: theme.spacing.sm,
|
||||||
|
borderTopWidth: 1,
|
||||||
|
borderTopColor: theme.colors.border,
|
||||||
},
|
},
|
||||||
footerLeft: {
|
footerLeft: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
},
|
},
|
||||||
dateText: {
|
dateText: {
|
||||||
fontSize: 12,
|
fontSize: 13,
|
||||||
color: theme.colors.textMuted,
|
color: theme.colors.textMuted,
|
||||||
fontFamily: theme.typography.fontFamily.regular,
|
fontFamily: theme.typography.fontFamily.medium,
|
||||||
|
marginBottom: 2,
|
||||||
},
|
},
|
||||||
processedText: {
|
processedText: {
|
||||||
fontSize: 11,
|
fontSize: 12,
|
||||||
color: theme.colors.textSecondary,
|
color: theme.colors.textSecondary,
|
||||||
marginTop: 2,
|
|
||||||
fontFamily: theme.typography.fontFamily.regular,
|
fontFamily: theme.typography.fontFamily.regular,
|
||||||
},
|
},
|
||||||
footerRight: {
|
footerRight: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
|
gap: theme.spacing.sm,
|
||||||
},
|
},
|
||||||
caseId: {
|
caseId: {
|
||||||
fontSize: 12,
|
fontSize: 12,
|
||||||
color: theme.colors.textSecondary,
|
color: theme.colors.textSecondary,
|
||||||
marginRight: theme.spacing.xs,
|
fontFamily: theme.typography.fontFamily.medium,
|
||||||
fontFamily: theme.typography.fontFamily.regular,
|
},
|
||||||
|
chevronContainer: {
|
||||||
|
width: 32,
|
||||||
|
height: 32,
|
||||||
|
borderRadius: 16,
|
||||||
|
backgroundColor: theme.colors.primary + '20',
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -13,6 +13,7 @@ import { PatientsScreen, PatientDetailsScreen, SeriesDetailScreen } from '../scr
|
|||||||
|
|
||||||
// Import types
|
// Import types
|
||||||
import { PatientCareStackParamList } from './navigationTypes';
|
import { PatientCareStackParamList } from './navigationTypes';
|
||||||
|
import FeedbackDetailScreen from '../screens/FeedbackDetailScreen';
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// STACK NAVIGATOR
|
// STACK NAVIGATOR
|
||||||
@ -75,6 +76,18 @@ const PatientCareStackNavigator: React.FC = () => {
|
|||||||
gestureDirection: 'horizontal',
|
gestureDirection: 'horizontal',
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
<Stack.Screen
|
||||||
|
name="FeedbackDetail"
|
||||||
|
component={FeedbackDetailScreen}
|
||||||
|
options={{
|
||||||
|
title: 'Feedback Details',
|
||||||
|
gestureEnabled: true,
|
||||||
|
gestureDirection: 'horizontal',
|
||||||
|
headerShown: false
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
|
||||||
</Stack.Navigator>
|
</Stack.Navigator>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -27,6 +27,9 @@ export type PatientCareStackParamList = {
|
|||||||
|
|
||||||
// Series Detail Screen - Detailed series information with predictions and feedback
|
// Series Detail Screen - Detailed series information with predictions and feedback
|
||||||
SeriesDetail: SeriesDetailScreenParams;
|
SeriesDetail: SeriesDetailScreenParams;
|
||||||
|
|
||||||
|
// Feedback Detail Screen - Series feedback history and submission
|
||||||
|
FeedbackDetail: FeedbackDetailScreenParams;
|
||||||
};
|
};
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@ -83,6 +86,32 @@ export interface SeriesDetailScreenParams {
|
|||||||
onFeedbackSubmitted?: () => void;
|
onFeedbackSubmitted?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* FeedbackDetailScreenParams
|
||||||
|
*
|
||||||
|
* Purpose: Parameters for the feedback detail screen
|
||||||
|
*
|
||||||
|
* Parameters:
|
||||||
|
* - patientId: Required patient ID for the series
|
||||||
|
* - patientName: Required patient name for display
|
||||||
|
* - seriesNumber: Required series number to display
|
||||||
|
* - seriesData: Required series data object
|
||||||
|
* - patientData: Required patient data object for context
|
||||||
|
* - feedbackData: Required feedback data array for the series
|
||||||
|
* - onFeedbackSubmitted: Optional callback to refresh parent screen data
|
||||||
|
*/
|
||||||
|
export interface FeedbackDetailScreenParams {
|
||||||
|
patientId: string;
|
||||||
|
patientName: string;
|
||||||
|
seriesNumber: string;
|
||||||
|
seriesData: any;
|
||||||
|
patientData: any;
|
||||||
|
feedbackData: any[]; // Feedback data array for the series
|
||||||
|
// Callback function to refresh parent screen data when feedback is submitted
|
||||||
|
// This ensures PatientDetailsScreen shows updated information when user navigates back
|
||||||
|
onFeedbackSubmitted?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// NAVIGATION PROP TYPES
|
// NAVIGATION PROP TYPES
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@ -128,6 +157,16 @@ export interface SeriesDetailScreenProps {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* FeedbackDetailScreenProps - Props for FeedbackDetailScreen component
|
||||||
|
*/
|
||||||
|
export interface FeedbackDetailScreenProps {
|
||||||
|
navigation: PatientCareNavigationProp;
|
||||||
|
route: {
|
||||||
|
params: FeedbackDetailScreenParams;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// NAVIGATION UTILITY TYPES
|
// NAVIGATION UTILITY TYPES
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|||||||
@ -271,20 +271,17 @@ export const selectPatientStats = createSelector(
|
|||||||
total: 0,
|
total: 0,
|
||||||
processed: 0,
|
processed: 0,
|
||||||
pending: 0,
|
pending: 0,
|
||||||
error: 0,
|
|
||||||
averageAge: 0,
|
averageAge: 0,
|
||||||
modalities: {},
|
modalities: {},
|
||||||
totalFiles: 0,
|
totalFiles: 0,
|
||||||
processedPercentage: 0,
|
processedPercentage: 0,
|
||||||
pendingPercentage: 0,
|
pendingPercentage: 0,
|
||||||
errorPercentage: 0,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const total = patients.length;
|
const total = patients.length;
|
||||||
const processed = patients.filter((p: PatientData) => p.patient_info.status.toLowerCase() === 'processed').length;
|
const processed = patients.filter((p: PatientData) => p.patient_info.status.toLowerCase() === 'processed').length;
|
||||||
const pending = patients.filter((p: PatientData) => p.patient_info.status.toLowerCase() === 'pending').length;
|
const pending = patients.filter((p: PatientData) => p.patient_info.status.toLowerCase() === 'pending').length;
|
||||||
const error = patients.filter((p: PatientData) => p.patient_info.status.toLowerCase() === 'error').length;
|
|
||||||
|
|
||||||
// Calculate average age
|
// Calculate average age
|
||||||
const totalAge = patients.reduce((sum: number, patient: PatientData) => {
|
const totalAge = patients.reduce((sum: number, patient: PatientData) => {
|
||||||
@ -307,13 +304,11 @@ export const selectPatientStats = createSelector(
|
|||||||
total,
|
total,
|
||||||
processed,
|
processed,
|
||||||
pending,
|
pending,
|
||||||
error,
|
|
||||||
averageAge,
|
averageAge,
|
||||||
modalities,
|
modalities,
|
||||||
totalFiles,
|
totalFiles,
|
||||||
processedPercentage: total > 0 ? Math.round((processed / total) * 100) : 0,
|
processedPercentage: total > 0 ? Math.round((processed / total) * 100) : 0,
|
||||||
pendingPercentage: total > 0 ? Math.round((pending / total) * 100) : 0,
|
pendingPercentage: total > 0 ? Math.round((pending / total) * 100) : 0,
|
||||||
errorPercentage: total > 0 ? Math.round((error / total) * 100) : 0,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
@ -392,14 +387,13 @@ export const selectPatientCounts = createSelector(
|
|||||||
[selectPatients],
|
[selectPatients],
|
||||||
(patients) => {
|
(patients) => {
|
||||||
if (!patients || !Array.isArray(patients)) {
|
if (!patients || !Array.isArray(patients)) {
|
||||||
return { all: 0, processed: 0, pending: 0, error: 0 };
|
return { all: 0, processed: 0, pending: 0 };
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
all: patients.length,
|
all: patients.length,
|
||||||
processed: patients.filter((p: PatientData) => p.patient_info.status.toLowerCase() === 'processed').length,
|
processed: patients.filter((p: PatientData) => p.patient_info.status.toLowerCase() === 'processed').length,
|
||||||
pending: patients.filter((p: PatientData) => p.patient_info.status.toLowerCase() === 'pending').length,
|
pending: patients.filter((p: PatientData) => p.patient_info.status.toLowerCase() === 'pending').length,
|
||||||
error: patients.filter((p: PatientData) => p.patient_info.status.toLowerCase() === 'error').length,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|||||||
@ -63,7 +63,7 @@ export interface PatientCareState {
|
|||||||
|
|
||||||
// Search and filtering
|
// Search and filtering
|
||||||
searchQuery: string;
|
searchQuery: string;
|
||||||
selectedFilter: 'all' | 'processed' | 'pending' | 'error';
|
selectedFilter: 'all' | 'processed' | 'pending';
|
||||||
sortBy: 'date' | 'name' | 'processed';
|
sortBy: 'date' | 'name' | 'processed';
|
||||||
sortOrder: 'asc' | 'desc';
|
sortOrder: 'asc' | 'desc';
|
||||||
|
|
||||||
@ -93,7 +93,6 @@ export const fetchPatients = createAsyncThunk(
|
|||||||
async (token: string, { rejectWithValue }) => {
|
async (token: string, { rejectWithValue }) => {
|
||||||
try {
|
try {
|
||||||
const response: any = await patientAPI.getPatients(token);
|
const response: any = await patientAPI.getPatients(token);
|
||||||
console.log('response', response);
|
|
||||||
|
|
||||||
if (response.ok && response.data&& response.data.data) {
|
if (response.ok && response.data&& response.data.data) {
|
||||||
// Return the patients data directly from the new API structure
|
// Return the patients data directly from the new API structure
|
||||||
@ -338,7 +337,7 @@ const patientCareSlice = createSlice({
|
|||||||
*
|
*
|
||||||
* Purpose: Set patient filter
|
* Purpose: Set patient filter
|
||||||
*/
|
*/
|
||||||
setFilter: (state, action: PayloadAction<'all' | 'processed' | 'pending' | 'error'>) => {
|
setFilter: (state, action: PayloadAction<'all' | 'processed' | 'pending'>) => {
|
||||||
state.selectedFilter = action.payload;
|
state.selectedFilter = action.payload;
|
||||||
state.currentPage = 1; // Reset to first page when filtering
|
state.currentPage = 1; // Reset to first page when filtering
|
||||||
},
|
},
|
||||||
|
|||||||
634
app/modules/PatientCare/screens/FeedbackDetailScreen.tsx
Normal file
@ -0,0 +1,634 @@
|
|||||||
|
/*
|
||||||
|
* File: FeedbackDetailScreen.tsx
|
||||||
|
* Description: Feedback detail screen for a specific series showing feedback history (read-only)
|
||||||
|
* Design & Developed by Tech4Biz Solutions
|
||||||
|
* Copyright (c) Spurrin Innovations. All rights reserved.
|
||||||
|
*
|
||||||
|
* Features:
|
||||||
|
* - Display feedback history for the series (read-only)
|
||||||
|
* - Feedback data received from navigation parameters
|
||||||
|
* - Clinical insights and feedback analytics
|
||||||
|
* - Modern healthcare-focused UI design
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { useEffect, useState, useCallback } from 'react';
|
||||||
|
import {
|
||||||
|
View,
|
||||||
|
Text,
|
||||||
|
StyleSheet,
|
||||||
|
ScrollView,
|
||||||
|
TouchableOpacity,
|
||||||
|
StatusBar,
|
||||||
|
Alert,
|
||||||
|
TextInput,
|
||||||
|
RefreshControl,
|
||||||
|
} from 'react-native';
|
||||||
|
import { theme } from '../../../theme/theme';
|
||||||
|
import { useAppDispatch, useAppSelector } from '../../../store/hooks';
|
||||||
|
import Icon from 'react-native-vector-icons/Feather';
|
||||||
|
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||||
|
|
||||||
|
// Import types and API
|
||||||
|
import { selectUser } from '../../Auth/redux/authSelectors';
|
||||||
|
import { FeedbackDetailScreenProps } from '../../Dashboard/navigation/navigationTypes';
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// INTERFACES
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
interface SeriesSummary {
|
||||||
|
series_num: string;
|
||||||
|
series_description: string;
|
||||||
|
total_images: number;
|
||||||
|
png_preview: string;
|
||||||
|
modality: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Feedback {
|
||||||
|
feedback_id: string;
|
||||||
|
user_id: string;
|
||||||
|
feedback_text: string;
|
||||||
|
is_positive: boolean;
|
||||||
|
email: string;
|
||||||
|
created_at: string;
|
||||||
|
prediction_id: number;
|
||||||
|
prediction_file_path: string;
|
||||||
|
series_number: string;
|
||||||
|
feedback_type: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PatientData {
|
||||||
|
patid: string;
|
||||||
|
hospital_id: string;
|
||||||
|
patient_info: {
|
||||||
|
name: string;
|
||||||
|
age: string;
|
||||||
|
sex: string;
|
||||||
|
date: string;
|
||||||
|
institution: string;
|
||||||
|
modality: string;
|
||||||
|
status: string;
|
||||||
|
report_status: string;
|
||||||
|
file_name: string;
|
||||||
|
file_type: string;
|
||||||
|
frame_count: number;
|
||||||
|
};
|
||||||
|
series_summary: SeriesSummary[];
|
||||||
|
processing_metadata: any;
|
||||||
|
total_predictions: number;
|
||||||
|
first_processed_at: string;
|
||||||
|
last_processed_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// FEEDBACK DETAIL SCREEN COMPONENT
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* FeedbackDetailScreen Component
|
||||||
|
*
|
||||||
|
* Purpose: Display feedback details and history for a specific series (read-only)
|
||||||
|
*
|
||||||
|
* Features:
|
||||||
|
* - Feedback history display (read-only)
|
||||||
|
* - Clinical insights and analytics
|
||||||
|
* - Modern healthcare-focused UI design
|
||||||
|
*/
|
||||||
|
const FeedbackDetailScreen: React.FC<FeedbackDetailScreenProps> = ({ navigation, route }) => {
|
||||||
|
// ============================================================================
|
||||||
|
// STATE MANAGEMENT
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
const dispatch = useAppDispatch();
|
||||||
|
|
||||||
|
// Route parameters
|
||||||
|
const { patientId, patientName, seriesNumber, seriesData, patientData, feedbackData, onFeedbackSubmitted } = route.params;
|
||||||
|
|
||||||
|
// Redux state
|
||||||
|
const user = useAppSelector(selectUser);
|
||||||
|
|
||||||
|
// Local state
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// EFFECTS
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Component Mount Effect
|
||||||
|
*
|
||||||
|
* Purpose: Initialize screen and set navigation title
|
||||||
|
*/
|
||||||
|
useEffect(() => {
|
||||||
|
navigation.setOptions({
|
||||||
|
title: `Feedback - Series ${seriesNumber}`,
|
||||||
|
headerShown: true,
|
||||||
|
headerLeft: () => (
|
||||||
|
<TouchableOpacity
|
||||||
|
style={styles.headerBackButton}
|
||||||
|
onPress={handleBackPress}
|
||||||
|
activeOpacity={0.7}
|
||||||
|
>
|
||||||
|
<Icon name="arrow-left" size={24} color={theme.colors.primary} />
|
||||||
|
</TouchableOpacity>
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}, [navigation, seriesNumber, patientId]);
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// EVENT HANDLERS
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle Back Navigation
|
||||||
|
*
|
||||||
|
* Purpose: Navigate back to previous screen
|
||||||
|
*/
|
||||||
|
const handleBackPress = useCallback(() => {
|
||||||
|
navigation.goBack();
|
||||||
|
}, [navigation]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle Refresh
|
||||||
|
*
|
||||||
|
* Purpose: Pull-to-refresh functionality
|
||||||
|
*/
|
||||||
|
const handleRefresh = useCallback(async () => {
|
||||||
|
setIsRefreshing(true);
|
||||||
|
// TODO: Implement actual refresh logic
|
||||||
|
setTimeout(() => {
|
||||||
|
setIsRefreshing(false);
|
||||||
|
}, 1000);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle Back to Patient Details
|
||||||
|
*
|
||||||
|
* Purpose: Navigate to PatientDetails screen within the Dashboard stack
|
||||||
|
*
|
||||||
|
* Note: Now that both screens are in the same Dashboard stack,
|
||||||
|
* navigation should work smoothly without loops.
|
||||||
|
*/
|
||||||
|
const handleBackToPatientDetails = useCallback(() => {
|
||||||
|
try {
|
||||||
|
// Navigate to PatientDetails screen in the same stack
|
||||||
|
navigation.navigate('PatientDetails', {
|
||||||
|
patientId: patientId,
|
||||||
|
patient: patientData || { name: patientName || 'Unknown Patient' },
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Navigation to PatientDetails failed:', error);
|
||||||
|
// Fallback: go back to previous screen
|
||||||
|
navigation.goBack();
|
||||||
|
}
|
||||||
|
}, [navigation, patientId, patientName, patientData]);
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// UTILITY FUNCTIONS
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get Feedback Type Color
|
||||||
|
*
|
||||||
|
* Purpose: Get appropriate color for feedback type
|
||||||
|
*
|
||||||
|
* @param feedbackType - Type of feedback
|
||||||
|
*/
|
||||||
|
const getFeedbackTypeColor = (feedbackType: string) => {
|
||||||
|
switch (feedbackType.toLowerCase()) {
|
||||||
|
case 'clinical_accuracy':
|
||||||
|
return theme.colors.success;
|
||||||
|
case 'confidence_assessment':
|
||||||
|
return theme.colors.warning;
|
||||||
|
case 'technical_issue':
|
||||||
|
return theme.colors.error;
|
||||||
|
default:
|
||||||
|
return theme.colors.info;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get Series Feedback
|
||||||
|
*
|
||||||
|
* Purpose: Get feedback for the current series
|
||||||
|
*/
|
||||||
|
const getSeriesFeedback = () => {
|
||||||
|
return feedbackData?.filter((feedback: Feedback) => feedback.series_number === seriesNumber) || [];
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Is Feedback New
|
||||||
|
*
|
||||||
|
* Purpose: Check if feedback is recent (within 24 hours)
|
||||||
|
*
|
||||||
|
* @param feedbackId - Feedback ID to check
|
||||||
|
*/
|
||||||
|
const isFeedbackNew = (feedbackId: string) => {
|
||||||
|
const feedback = feedbackData?.find((f: Feedback) => f.feedback_id === feedbackId);
|
||||||
|
if (!feedback) return false;
|
||||||
|
|
||||||
|
const feedbackDate = new Date(feedback.created_at);
|
||||||
|
const now = new Date();
|
||||||
|
const diffHours = (now.getTime() - feedbackDate.getTime()) / (1000 * 60 * 60);
|
||||||
|
|
||||||
|
return diffHours < 24;
|
||||||
|
};
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// RENDER HELPERS
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render Series Header
|
||||||
|
*
|
||||||
|
* Purpose: Render series information header
|
||||||
|
*/
|
||||||
|
const renderSeriesHeader = () => {
|
||||||
|
if (!seriesData) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={styles.seriesHeader}>
|
||||||
|
<View style={styles.seriesHeaderTop}>
|
||||||
|
<View style={styles.seriesHeaderLeft}>
|
||||||
|
<Text style={styles.seriesTitle}>
|
||||||
|
Series {seriesData.series_description}
|
||||||
|
</Text>
|
||||||
|
<Text style={styles.seriesMeta}>
|
||||||
|
{seriesData.total_images} images • {seriesData.modality} modality
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<View style={styles.seriesHeaderRight}>
|
||||||
|
<View style={styles.seriesStatusBadge}>
|
||||||
|
<Icon name="check-circle" size={16} color={'white'} />
|
||||||
|
<Text style={styles.seriesStatusText}>Processed</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{patientData && (
|
||||||
|
<View style={styles.patientInfoRow}>
|
||||||
|
<Icon name="user" size={16} color={theme.colors.textSecondary} />
|
||||||
|
<Text style={styles.patientInfoText}>
|
||||||
|
{patientData.patient_info.name} • ID: {patientData.patid}
|
||||||
|
</Text>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={styles.patientDetailButton}
|
||||||
|
onPress={handleBackToPatientDetails}
|
||||||
|
activeOpacity={0.7}
|
||||||
|
>
|
||||||
|
<View style={styles.patientDetailButtonContent}>
|
||||||
|
<Icon name="user" size={14} color={theme.colors.primary} />
|
||||||
|
<Text style={styles.patientDetailButtonText}>View Details</Text>
|
||||||
|
</View>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render Feedback History
|
||||||
|
*
|
||||||
|
* Purpose: Render feedback history display only
|
||||||
|
*/
|
||||||
|
const renderFeedbackHistory = () => {
|
||||||
|
const seriesFeedbacks = getSeriesFeedback();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={styles.feedbackHistory}>
|
||||||
|
{/* Feedback History */}
|
||||||
|
<View style={styles.feedbackList}>
|
||||||
|
<Text style={styles.feedbackListTitle}>
|
||||||
|
Feedback History ({seriesFeedbacks.length})
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
{seriesFeedbacks.length === 0 ? (
|
||||||
|
<View style={styles.emptyFeedbackState}>
|
||||||
|
<Icon name="message-circle" size={48} color={theme.colors.textMuted} />
|
||||||
|
<Text style={styles.emptyFeedbackTitle}>No Feedback Yet</Text>
|
||||||
|
<Text style={styles.emptyFeedbackSubtitle}>
|
||||||
|
No feedback has been provided for this series yet
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
) : (
|
||||||
|
seriesFeedbacks.map((feedback: Feedback) => (
|
||||||
|
<View key={feedback.feedback_id} style={styles.feedbackCard}>
|
||||||
|
<View style={styles.feedbackCardHeader}>
|
||||||
|
<View style={styles.feedbackCardHeaderLeft}>
|
||||||
|
<View style={[
|
||||||
|
styles.feedbackTypeIndicator,
|
||||||
|
{ backgroundColor: feedback.is_positive ? theme.colors.success : theme.colors.error }
|
||||||
|
]}>
|
||||||
|
<Icon
|
||||||
|
name={feedback.is_positive ? "thumbs-up" : "thumbs-down"}
|
||||||
|
size={12}
|
||||||
|
color={theme.colors.background}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
<Text style={styles.feedbackEmail}>{feedback.email}</Text>
|
||||||
|
{isFeedbackNew(feedback.feedback_id) && (
|
||||||
|
<View style={styles.newFeedbackBadge}>
|
||||||
|
<Text style={styles.newFeedbackBadgeText}>NEW</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
<Text style={styles.feedbackTimestamp}>
|
||||||
|
{new Date(feedback.created_at).toLocaleDateString()}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<Text style={styles.feedbackText}>{feedback.feedback_text}</Text>
|
||||||
|
|
||||||
|
<View style={styles.feedbackCardFooter}>
|
||||||
|
<View style={[
|
||||||
|
styles.feedbackTypeBadge,
|
||||||
|
{ backgroundColor: getFeedbackTypeColor(feedback.feedback_type) }
|
||||||
|
]}>
|
||||||
|
<Text style={styles.feedbackTypeBadgeText}>
|
||||||
|
{feedback.feedback_type.replace('_', ' ').toUpperCase()}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// MAIN RENDER
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SafeAreaView style={styles.container}>
|
||||||
|
<StatusBar barStyle="dark-content" backgroundColor={theme.colors.background} />
|
||||||
|
|
||||||
|
{/* Fixed Series Header */}
|
||||||
|
{renderSeriesHeader()}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
{/* Scrollable Feedback Content */}
|
||||||
|
<View style={styles.scrollableContent}>
|
||||||
|
<ScrollView
|
||||||
|
style={styles.content}
|
||||||
|
showsVerticalScrollIndicator={false}
|
||||||
|
refreshControl={
|
||||||
|
<RefreshControl
|
||||||
|
refreshing={isRefreshing}
|
||||||
|
onRefresh={handleRefresh}
|
||||||
|
colors={[theme.colors.primary]}
|
||||||
|
tintColor={theme.colors.primary}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{/* Feedback History from Navigation Parameters */}
|
||||||
|
{renderFeedbackHistory()}
|
||||||
|
</ScrollView>
|
||||||
|
</View>
|
||||||
|
</SafeAreaView>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// STYLES
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: theme.colors.background,
|
||||||
|
},
|
||||||
|
|
||||||
|
// Series Header Styles
|
||||||
|
seriesHeader: {
|
||||||
|
backgroundColor: theme.colors.background,
|
||||||
|
padding: theme.spacing.md,
|
||||||
|
borderBottomWidth: 1,
|
||||||
|
borderBottomColor: theme.colors.border,
|
||||||
|
},
|
||||||
|
// Fixed Feedback Title Styles
|
||||||
|
fixedFeedbackTitle: {
|
||||||
|
backgroundColor: theme.colors.background,
|
||||||
|
padding: theme.spacing.md,
|
||||||
|
borderBottomWidth: 1,
|
||||||
|
borderBottomColor: theme.colors.border,
|
||||||
|
},
|
||||||
|
seriesHeaderTop: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'flex-start',
|
||||||
|
marginBottom: theme.spacing.sm,
|
||||||
|
},
|
||||||
|
seriesHeaderLeft: {
|
||||||
|
flex: 1,
|
||||||
|
marginRight: theme.spacing.md,
|
||||||
|
},
|
||||||
|
seriesHeaderRight: {
|
||||||
|
alignItems: 'flex-end',
|
||||||
|
},
|
||||||
|
seriesTitle: {
|
||||||
|
fontSize: 18,
|
||||||
|
color: theme.colors.textPrimary,
|
||||||
|
fontFamily: theme.typography.fontFamily.bold,
|
||||||
|
marginBottom: 4,
|
||||||
|
},
|
||||||
|
seriesMeta: {
|
||||||
|
fontSize: 14,
|
||||||
|
color: theme.colors.textSecondary,
|
||||||
|
fontFamily: theme.typography.fontFamily.regular,
|
||||||
|
},
|
||||||
|
seriesStatusBadge: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
backgroundColor: theme.colors.success,
|
||||||
|
paddingHorizontal: 8,
|
||||||
|
paddingVertical: 4,
|
||||||
|
borderRadius: 12,
|
||||||
|
},
|
||||||
|
seriesStatusText: {
|
||||||
|
fontSize: 10,
|
||||||
|
color: theme.colors.background,
|
||||||
|
fontFamily: theme.typography.fontFamily.bold,
|
||||||
|
marginLeft: 4,
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
},
|
||||||
|
patientInfoRow: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
patientInfoText: {
|
||||||
|
fontSize: 14,
|
||||||
|
color: theme.colors.textSecondary,
|
||||||
|
fontFamily: theme.typography.fontFamily.regular,
|
||||||
|
marginLeft: theme.spacing.xs,
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
patientDetailButton: {
|
||||||
|
padding: theme.spacing.xs,
|
||||||
|
marginLeft: theme.spacing.sm,
|
||||||
|
backgroundColor: theme.colors.tertiary,
|
||||||
|
borderRadius: 16,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: theme.colors.primary,
|
||||||
|
},
|
||||||
|
patientDetailButtonContent: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
paddingHorizontal: 8,
|
||||||
|
paddingVertical: 4,
|
||||||
|
},
|
||||||
|
patientDetailButtonText: {
|
||||||
|
fontSize: 11,
|
||||||
|
color: theme.colors.primary,
|
||||||
|
fontFamily: theme.typography.fontFamily.medium,
|
||||||
|
marginLeft: 4,
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
},
|
||||||
|
|
||||||
|
// Content Styles
|
||||||
|
scrollableContent: {
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
content: {
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
|
||||||
|
// Section Styles
|
||||||
|
sectionTitle: {
|
||||||
|
fontSize: 18,
|
||||||
|
color: theme.colors.textPrimary,
|
||||||
|
fontFamily: theme.typography.fontFamily.bold,
|
||||||
|
marginBottom: theme.spacing.md,
|
||||||
|
},
|
||||||
|
|
||||||
|
// Feedback Styles
|
||||||
|
feedbackHistory: {
|
||||||
|
padding: theme.spacing.md,
|
||||||
|
},
|
||||||
|
|
||||||
|
// Feedback List Styles
|
||||||
|
feedbackList: {
|
||||||
|
marginTop: theme.spacing.sm,
|
||||||
|
},
|
||||||
|
feedbackListTitle: {
|
||||||
|
fontSize: 16,
|
||||||
|
color: theme.colors.textPrimary,
|
||||||
|
fontFamily: theme.typography.fontFamily.bold,
|
||||||
|
marginBottom: theme.spacing.md,
|
||||||
|
},
|
||||||
|
feedbackCard: {
|
||||||
|
backgroundColor: theme.colors.backgroundAlt,
|
||||||
|
borderRadius: 8,
|
||||||
|
padding: theme.spacing.md,
|
||||||
|
marginBottom: theme.spacing.sm,
|
||||||
|
shadowColor: '#000',
|
||||||
|
shadowOffset: { width: 0, height: 2 },
|
||||||
|
shadowOpacity: 0.1,
|
||||||
|
shadowRadius: 4,
|
||||||
|
elevation: 2,
|
||||||
|
},
|
||||||
|
feedbackCardHeader: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
marginBottom: theme.spacing.sm,
|
||||||
|
},
|
||||||
|
feedbackCardHeaderLeft: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
feedbackTypeIndicator: {
|
||||||
|
width: 20,
|
||||||
|
height: 20,
|
||||||
|
borderRadius: 10,
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
marginRight: theme.spacing.sm,
|
||||||
|
},
|
||||||
|
feedbackEmail: {
|
||||||
|
fontSize: 12,
|
||||||
|
color: theme.colors.textSecondary,
|
||||||
|
fontFamily: theme.typography.fontFamily.medium,
|
||||||
|
marginRight: theme.spacing.sm,
|
||||||
|
},
|
||||||
|
newFeedbackBadge: {
|
||||||
|
backgroundColor: theme.colors.error,
|
||||||
|
paddingHorizontal: 6,
|
||||||
|
paddingVertical: 2,
|
||||||
|
borderRadius: 8,
|
||||||
|
},
|
||||||
|
newFeedbackBadgeText: {
|
||||||
|
fontSize: 8,
|
||||||
|
color: theme.colors.background,
|
||||||
|
fontFamily: theme.typography.fontFamily.bold,
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
},
|
||||||
|
feedbackTimestamp: {
|
||||||
|
fontSize: 10,
|
||||||
|
color: theme.colors.textMuted,
|
||||||
|
fontFamily: theme.typography.fontFamily.regular,
|
||||||
|
},
|
||||||
|
feedbackText: {
|
||||||
|
fontSize: 14,
|
||||||
|
color: theme.colors.textPrimary,
|
||||||
|
fontFamily: theme.typography.fontFamily.regular,
|
||||||
|
lineHeight: 20,
|
||||||
|
marginBottom: theme.spacing.sm,
|
||||||
|
},
|
||||||
|
feedbackCardFooter: {
|
||||||
|
alignItems: 'flex-end',
|
||||||
|
},
|
||||||
|
feedbackTypeBadge: {
|
||||||
|
paddingHorizontal: 8,
|
||||||
|
paddingVertical: 4,
|
||||||
|
borderRadius: 12,
|
||||||
|
},
|
||||||
|
feedbackTypeBadgeText: {
|
||||||
|
fontSize: 10,
|
||||||
|
color: theme.colors.background,
|
||||||
|
fontFamily: theme.typography.fontFamily.bold,
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
},
|
||||||
|
|
||||||
|
// Empty State Styles
|
||||||
|
emptyFeedbackState: {
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
paddingVertical: theme.spacing.xl,
|
||||||
|
},
|
||||||
|
emptyFeedbackTitle: {
|
||||||
|
fontSize: 18,
|
||||||
|
color: theme.colors.textPrimary,
|
||||||
|
fontFamily: theme.typography.fontFamily.bold,
|
||||||
|
marginTop: theme.spacing.md,
|
||||||
|
marginBottom: theme.spacing.sm,
|
||||||
|
},
|
||||||
|
emptyFeedbackSubtitle: {
|
||||||
|
fontSize: 14,
|
||||||
|
color: theme.colors.textSecondary,
|
||||||
|
fontFamily: theme.typography.fontFamily.regular,
|
||||||
|
textAlign: 'center',
|
||||||
|
lineHeight: 20,
|
||||||
|
},
|
||||||
|
|
||||||
|
// Header Back Button Style
|
||||||
|
headerBackButton: {
|
||||||
|
padding: theme.spacing.sm,
|
||||||
|
marginLeft: theme.spacing.xs,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export default FeedbackDetailScreen;
|
||||||
|
|
||||||
|
/*
|
||||||
|
* End of File: FeedbackDetailScreen.tsx
|
||||||
|
* Design & Developed by Tech4Biz Solutions
|
||||||
|
* Copyright (c) Spurrin Innovations. All rights reserved.
|
||||||
|
*/
|
||||||
@ -65,7 +65,7 @@ import { selectUser } from '../../Auth/redux/authSelectors';
|
|||||||
* Features:
|
* Features:
|
||||||
* - Real-time patient data fetching
|
* - Real-time patient data fetching
|
||||||
* - Search functionality with real-time filtering
|
* - Search functionality with real-time filtering
|
||||||
* - Filter tabs (All, Processed, Pending, Error)
|
* - Filter tabs (All, Processed, Pending)
|
||||||
* - Sort options (Date, Name, Processed)
|
* - Sort options (Date, Name, Processed)
|
||||||
* - Pull-to-refresh functionality
|
* - Pull-to-refresh functionality
|
||||||
* - Patient cards with vital information
|
* - Patient cards with vital information
|
||||||
@ -152,7 +152,7 @@ const PatientsScreen: React.FC = () => {
|
|||||||
*
|
*
|
||||||
* Purpose: Update the selected filter and refresh the list
|
* Purpose: Update the selected filter and refresh the list
|
||||||
*/
|
*/
|
||||||
const handleFilterChange = useCallback((filter: 'all' | 'processed' | 'pending' | 'error') => {
|
const handleFilterChange = useCallback((filter: 'all' | 'processed' | 'pending') => {
|
||||||
dispatch(setFilter(filter));
|
dispatch(setFilter(filter));
|
||||||
}, [dispatch]);
|
}, [dispatch]);
|
||||||
|
|
||||||
|
|||||||
@ -33,6 +33,7 @@ import { theme } from '../../../theme/theme';
|
|||||||
import { useAppDispatch, useAppSelector } from '../../../store/hooks';
|
import { useAppDispatch, useAppSelector } from '../../../store/hooks';
|
||||||
import Icon from 'react-native-vector-icons/Feather';
|
import Icon from 'react-native-vector-icons/Feather';
|
||||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||||
|
import { DicomViewerModal } from '../../../shared/components';
|
||||||
|
|
||||||
// Import types and API
|
// Import types and API
|
||||||
import { patientAPI } from '../services/patientAPI';
|
import { patientAPI } from '../services/patientAPI';
|
||||||
@ -152,6 +153,14 @@ const SeriesDetailScreen: React.FC<SeriesDetailScreenProps> = ({ navigation, rou
|
|||||||
// Track newly added feedback for visual indication
|
// Track newly added feedback for visual indication
|
||||||
const [newFeedbackIds, setNewFeedbackIds] = useState<Set<string>>(new Set());
|
const [newFeedbackIds, setNewFeedbackIds] = useState<Set<string>>(new Set());
|
||||||
|
|
||||||
|
// DICOM Modal state
|
||||||
|
const [dicomModalVisible, setDicomModalVisible] = useState(false);
|
||||||
|
const [selectedDicomData, setSelectedDicomData] = useState<{
|
||||||
|
dicomUrl: string;
|
||||||
|
prediction: Prediction;
|
||||||
|
imageIndex: number;
|
||||||
|
} | null>(null);
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// EFFECTS
|
// EFFECTS
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@ -218,6 +227,44 @@ const SeriesDetailScreen: React.FC<SeriesDetailScreenProps> = ({ navigation, rou
|
|||||||
navigation.goBack();
|
navigation.goBack();
|
||||||
}, [navigation]);
|
}, [navigation]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle DICOM Image Press
|
||||||
|
*
|
||||||
|
* Purpose: Open DICOM viewer modal for selected prediction image
|
||||||
|
*
|
||||||
|
* @param prediction - Prediction data containing DICOM file path
|
||||||
|
* @param imageIndex - Index of the image in the series
|
||||||
|
*/
|
||||||
|
const handleDicomImagePress = useCallback((prediction: Prediction, imageIndex: number) => {
|
||||||
|
if (prediction?.file_path) {
|
||||||
|
const dicomUrl = API_CONFIG.BASE_URL + '/api/dicom' + prediction.file_path;
|
||||||
|
console.log('DICOM URL:', dicomUrl);
|
||||||
|
|
||||||
|
setSelectedDicomData({
|
||||||
|
dicomUrl,
|
||||||
|
prediction,
|
||||||
|
imageIndex,
|
||||||
|
});
|
||||||
|
setDicomModalVisible(true);
|
||||||
|
} else {
|
||||||
|
Alert.alert(
|
||||||
|
'No DICOM Available',
|
||||||
|
'No DICOM file path is available for this image.',
|
||||||
|
[{ text: 'OK' }]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle Close DICOM Modal
|
||||||
|
*
|
||||||
|
* Purpose: Close DICOM viewer modal and reset state
|
||||||
|
*/
|
||||||
|
const handleCloseDicomModal = useCallback(() => {
|
||||||
|
setDicomModalVisible(false);
|
||||||
|
setSelectedDicomData(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handle Refresh
|
* Handle Refresh
|
||||||
*
|
*
|
||||||
@ -564,25 +611,6 @@ const SeriesDetailScreen: React.FC<SeriesDetailScreenProps> = ({ navigation, rou
|
|||||||
return 0;
|
return 0;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
|
||||||
* Get Adjusted Percentage Value
|
|
||||||
*
|
|
||||||
* Purpose: Show 100% when there's only 1 image in the series to indicate full analysis
|
|
||||||
*
|
|
||||||
* @param prediction - Prediction object
|
|
||||||
* @param type - Hemorrhage type (epidural, subdural, etc.)
|
|
||||||
*/
|
|
||||||
const getAdjustedPercentageValue = (prediction: any, type: string): number => {
|
|
||||||
const basePercentage = getPercentageValue(prediction, type);
|
|
||||||
|
|
||||||
// If there's only 1 image in the series, show 100% to indicate full analysis
|
|
||||||
if (seriesData && seriesData.total_images === 1) {
|
|
||||||
return 100;
|
|
||||||
}
|
|
||||||
|
|
||||||
return basePercentage;
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get Series Predictions
|
* Get Series Predictions
|
||||||
*
|
*
|
||||||
@ -1067,14 +1095,14 @@ const SeriesDetailScreen: React.FC<SeriesDetailScreenProps> = ({ navigation, rou
|
|||||||
<Icon
|
<Icon
|
||||||
name="activity"
|
name="activity"
|
||||||
size={16}
|
size={16}
|
||||||
color={getPercentageColor(getAdjustedPercentageValue(prediction.prediction, 'epidural'))}
|
color={getPercentageColor(getPercentageValue(prediction.prediction, 'epidural'))}
|
||||||
/>
|
/>
|
||||||
<Text style={styles.compactIndicatorTitle}>Epidural</Text>
|
<Text style={styles.compactIndicatorTitle}>Epidural</Text>
|
||||||
<Text style={[
|
<Text style={[
|
||||||
styles.compactIndicatorPercentage,
|
styles.compactIndicatorPercentage,
|
||||||
{ color: getPercentageColor(getAdjustedPercentageValue(prediction.prediction, 'epidural')) }
|
{ color: getPercentageColor(getPercentageValue(prediction.prediction, 'epidural')) }
|
||||||
]}>
|
]}>
|
||||||
{getAdjustedPercentageValue(prediction.prediction, 'epidural').toFixed(1)}%
|
{getPercentageValue(prediction.prediction, 'epidural').toFixed(1)}%
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
<View style={styles.progressBarContainer}>
|
<View style={styles.progressBarContainer}>
|
||||||
@ -1082,8 +1110,8 @@ const SeriesDetailScreen: React.FC<SeriesDetailScreenProps> = ({ navigation, rou
|
|||||||
style={[
|
style={[
|
||||||
styles.progressBar,
|
styles.progressBar,
|
||||||
{
|
{
|
||||||
width: `${getAdjustedPercentageValue(prediction.prediction, 'epidural')}%`,
|
width: `${getPercentageValue(prediction.prediction, 'epidural')}%`,
|
||||||
backgroundColor: getPercentageColor(getAdjustedPercentageValue(prediction.prediction, 'epidural'))
|
backgroundColor: getPercentageColor(getPercentageValue(prediction.prediction, 'epidural'))
|
||||||
}
|
}
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
@ -1096,14 +1124,14 @@ const SeriesDetailScreen: React.FC<SeriesDetailScreenProps> = ({ navigation, rou
|
|||||||
<Icon
|
<Icon
|
||||||
name="activity"
|
name="activity"
|
||||||
size={16}
|
size={16}
|
||||||
color={getPercentageColor(getAdjustedPercentageValue(prediction.prediction, 'subdural'))}
|
color={getPercentageColor(getPercentageValue(prediction.prediction, 'subdural'))}
|
||||||
/>
|
/>
|
||||||
<Text style={styles.compactIndicatorTitle}>Subdural</Text>
|
<Text style={styles.compactIndicatorTitle}>Subdural</Text>
|
||||||
<Text style={[
|
<Text style={[
|
||||||
styles.compactIndicatorPercentage,
|
styles.compactIndicatorPercentage,
|
||||||
{ color: getPercentageColor(getAdjustedPercentageValue(prediction.prediction, 'subdural')) }
|
{ color: getPercentageColor(getPercentageValue(prediction.prediction, 'subdural')) }
|
||||||
]}>
|
]}>
|
||||||
{getAdjustedPercentageValue(prediction.prediction, 'subdural').toFixed(1)}%
|
{getPercentageValue(prediction.prediction, 'subdural').toFixed(1)}%
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
<View style={styles.progressBarContainer}>
|
<View style={styles.progressBarContainer}>
|
||||||
@ -1111,8 +1139,8 @@ const SeriesDetailScreen: React.FC<SeriesDetailScreenProps> = ({ navigation, rou
|
|||||||
style={[
|
style={[
|
||||||
styles.progressBar,
|
styles.progressBar,
|
||||||
{
|
{
|
||||||
width: `${getAdjustedPercentageValue(prediction.prediction, 'subdural')}%`,
|
width: `${getPercentageValue(prediction.prediction, 'subdural')}%`,
|
||||||
backgroundColor: getPercentageColor(getAdjustedPercentageValue(prediction.prediction, 'subdural'))
|
backgroundColor: getPercentageColor(getPercentageValue(prediction.prediction, 'subdural'))
|
||||||
}
|
}
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
@ -1125,14 +1153,14 @@ const SeriesDetailScreen: React.FC<SeriesDetailScreenProps> = ({ navigation, rou
|
|||||||
<Icon
|
<Icon
|
||||||
name="activity"
|
name="activity"
|
||||||
size={16}
|
size={16}
|
||||||
color={getPercentageColor(getAdjustedPercentageValue(prediction.prediction, 'intraparenchymal'))}
|
color={getPercentageColor(getPercentageValue(prediction.prediction, 'intraparenchymal'))}
|
||||||
/>
|
/>
|
||||||
<Text style={styles.compactIndicatorTitle}>Intraparenchymal</Text>
|
<Text style={styles.compactIndicatorTitle}>Intraparenchymal</Text>
|
||||||
<Text style={[
|
<Text style={[
|
||||||
styles.compactIndicatorPercentage,
|
styles.compactIndicatorPercentage,
|
||||||
{ color: getPercentageColor(getAdjustedPercentageValue(prediction.prediction, 'intraparenchymal')) }
|
{ color: getPercentageColor(getPercentageValue(prediction.prediction, 'intraparenchymal')) }
|
||||||
]}>
|
]}>
|
||||||
{getAdjustedPercentageValue(prediction.prediction, 'intraparenchymal').toFixed(1)}%
|
{getPercentageValue(prediction.prediction, 'intraparenchymal').toFixed(1)}%
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
<View style={styles.progressBarContainer}>
|
<View style={styles.progressBarContainer}>
|
||||||
@ -1140,8 +1168,8 @@ const SeriesDetailScreen: React.FC<SeriesDetailScreenProps> = ({ navigation, rou
|
|||||||
style={[
|
style={[
|
||||||
styles.progressBar,
|
styles.progressBar,
|
||||||
{
|
{
|
||||||
width: `${getAdjustedPercentageValue(prediction.prediction, 'intraparenchymal')}%`,
|
width: `${getPercentageValue(prediction.prediction, 'intraparenchymal')}%`,
|
||||||
backgroundColor: getPercentageColor(getAdjustedPercentageValue(prediction.prediction, 'intraparenchymal'))
|
backgroundColor: getPercentageColor(getPercentageValue(prediction.prediction, 'intraparenchymal'))
|
||||||
}
|
}
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
@ -1154,14 +1182,14 @@ const SeriesDetailScreen: React.FC<SeriesDetailScreenProps> = ({ navigation, rou
|
|||||||
<Icon
|
<Icon
|
||||||
name="activity"
|
name="activity"
|
||||||
size={16}
|
size={16}
|
||||||
color={getPercentageColor(getAdjustedPercentageValue(prediction.prediction, 'subarachnoid'))}
|
color={getPercentageColor(getPercentageValue(prediction.prediction, 'subarachnoid'))}
|
||||||
/>
|
/>
|
||||||
<Text style={styles.compactIndicatorTitle}>Subarachnoid</Text>
|
<Text style={styles.compactIndicatorTitle}>Subarachnoid</Text>
|
||||||
<Text style={[
|
<Text style={[
|
||||||
styles.compactIndicatorPercentage,
|
styles.compactIndicatorPercentage,
|
||||||
{ color: getPercentageColor(getAdjustedPercentageValue(prediction.prediction, 'subarachnoid')) }
|
{ color: getPercentageColor(getPercentageValue(prediction.prediction, 'subarachnoid')) }
|
||||||
]}>
|
]}>
|
||||||
{getAdjustedPercentageValue(prediction.prediction, 'subarachnoid').toFixed(1)}%
|
{getPercentageValue(prediction.prediction, 'subarachnoid').toFixed(1)}%
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
<View style={styles.progressBarContainer}>
|
<View style={styles.progressBarContainer}>
|
||||||
@ -1169,8 +1197,8 @@ const SeriesDetailScreen: React.FC<SeriesDetailScreenProps> = ({ navigation, rou
|
|||||||
style={[
|
style={[
|
||||||
styles.progressBar,
|
styles.progressBar,
|
||||||
{
|
{
|
||||||
width: `${getAdjustedPercentageValue(prediction.prediction, 'subarachnoid')}%`,
|
width: `${getPercentageValue(prediction.prediction, 'subarachnoid')}%`,
|
||||||
backgroundColor: getPercentageColor(getAdjustedPercentageValue(prediction.prediction, 'subarachnoid'))
|
backgroundColor: getPercentageColor(getPercentageValue(prediction.prediction, 'subarachnoid'))
|
||||||
}
|
}
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
@ -1183,14 +1211,14 @@ const SeriesDetailScreen: React.FC<SeriesDetailScreenProps> = ({ navigation, rou
|
|||||||
<Icon
|
<Icon
|
||||||
name="activity"
|
name="activity"
|
||||||
size={16}
|
size={16}
|
||||||
color={getPercentageColor(getAdjustedPercentageValue(prediction.prediction, 'intraventricular'))}
|
color={getPercentageColor(getPercentageValue(prediction.prediction, 'intraventricular'))}
|
||||||
/>
|
/>
|
||||||
<Text style={styles.compactIndicatorTitle}>Intraventricular</Text>
|
<Text style={styles.compactIndicatorTitle}>Intraventricular</Text>
|
||||||
<Text style={[
|
<Text style={[
|
||||||
styles.compactIndicatorPercentage,
|
styles.compactIndicatorPercentage,
|
||||||
{ color: getPercentageColor(getAdjustedPercentageValue(prediction.prediction, 'intraventricular')) }
|
{ color: getPercentageColor(getPercentageValue(prediction.prediction, 'intraventricular')) }
|
||||||
]}>
|
]}>
|
||||||
{getAdjustedPercentageValue(prediction.prediction, 'intraventricular').toFixed(1)}%
|
{getPercentageValue(prediction.prediction, 'intraventricular').toFixed(1)}%
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
<View style={styles.progressBarContainer}>
|
<View style={styles.progressBarContainer}>
|
||||||
@ -1198,8 +1226,8 @@ const SeriesDetailScreen: React.FC<SeriesDetailScreenProps> = ({ navigation, rou
|
|||||||
style={[
|
style={[
|
||||||
styles.progressBar,
|
styles.progressBar,
|
||||||
{
|
{
|
||||||
width: `${getAdjustedPercentageValue(prediction.prediction, 'intraventricular')}%`,
|
width: `${getPercentageValue(prediction.prediction, 'intraventricular')}%`,
|
||||||
backgroundColor: getPercentageColor(getAdjustedPercentageValue(prediction.prediction, 'intraventricular'))
|
backgroundColor: getPercentageColor(getPercentageValue(prediction.prediction, 'intraventricular'))
|
||||||
}
|
}
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
@ -1212,14 +1240,14 @@ const SeriesDetailScreen: React.FC<SeriesDetailScreenProps> = ({ navigation, rou
|
|||||||
<Icon
|
<Icon
|
||||||
name="activity"
|
name="activity"
|
||||||
size={16}
|
size={16}
|
||||||
color={getPercentageColor(getAdjustedPercentageValue(prediction.prediction, 'midline_shift'))}
|
color={getPercentageColor(getPercentageValue(prediction.prediction, 'midline_shift'))}
|
||||||
/>
|
/>
|
||||||
<Text style={styles.compactIndicatorTitle}>Midline Shift</Text>
|
<Text style={styles.compactIndicatorTitle}>Midline Shift</Text>
|
||||||
<Text style={[
|
<Text style={[
|
||||||
styles.compactIndicatorPercentage,
|
styles.compactIndicatorPercentage,
|
||||||
{ color: getPercentageColor(getAdjustedPercentageValue(prediction.prediction, 'midline_shift')) }
|
{ color: getPercentageColor(getPercentageValue(prediction.prediction, 'midline_shift')) }
|
||||||
]}>
|
]}>
|
||||||
{getAdjustedPercentageValue(prediction.prediction, 'midline_shift').toFixed(1)}%
|
{getPercentageValue(prediction.prediction, 'midline_shift').toFixed(1)}%
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
<View style={styles.progressBarContainer}>
|
<View style={styles.progressBarContainer}>
|
||||||
@ -1227,8 +1255,8 @@ const SeriesDetailScreen: React.FC<SeriesDetailScreenProps> = ({ navigation, rou
|
|||||||
style={[
|
style={[
|
||||||
styles.progressBar,
|
styles.progressBar,
|
||||||
{
|
{
|
||||||
width: `${getAdjustedPercentageValue(prediction.prediction, 'midline_shift')}%`,
|
width: `${getPercentageValue(prediction.prediction, 'midline_shift')}%`,
|
||||||
backgroundColor: getAdjustedPercentageValue(prediction.prediction, 'midline_shift') > 50 ? theme.colors.error : theme.colors.warning
|
backgroundColor: getPercentageValue(prediction.prediction, 'midline_shift') > 50 ? theme.colors.error : theme.colors.warning
|
||||||
}
|
}
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
@ -1243,12 +1271,12 @@ const SeriesDetailScreen: React.FC<SeriesDetailScreenProps> = ({ navigation, rou
|
|||||||
{/* Overall Percentage Calculation */}
|
{/* Overall Percentage Calculation */}
|
||||||
{(() => {
|
{(() => {
|
||||||
const percentages = [
|
const percentages = [
|
||||||
getAdjustedPercentageValue(prediction.prediction, 'epidural'),
|
getPercentageValue(prediction.prediction, 'epidural'),
|
||||||
getAdjustedPercentageValue(prediction.prediction, 'subdural'),
|
getPercentageValue(prediction.prediction, 'subdural'),
|
||||||
getAdjustedPercentageValue(prediction.prediction, 'intraparenchymal'),
|
getPercentageValue(prediction.prediction, 'intraparenchymal'),
|
||||||
getAdjustedPercentageValue(prediction.prediction, 'subarachnoid'),
|
getPercentageValue(prediction.prediction, 'subarachnoid'),
|
||||||
getAdjustedPercentageValue(prediction.prediction, 'intraventricular'),
|
getPercentageValue(prediction.prediction, 'intraventricular'),
|
||||||
getAdjustedPercentageValue(prediction.prediction, 'midline_shift')
|
getPercentageValue(prediction.prediction, 'midline_shift')
|
||||||
];
|
];
|
||||||
const maxPercentage = Math.max(...percentages);
|
const maxPercentage = Math.max(...percentages);
|
||||||
const hasHemorrhage = maxPercentage > 10; // Consider >10% as detected
|
const hasHemorrhage = maxPercentage > 10; // Consider >10% as detected
|
||||||
@ -1356,11 +1384,22 @@ const SeriesDetailScreen: React.FC<SeriesDetailScreenProps> = ({ navigation, rou
|
|||||||
{predictions.map((prediction: Prediction, index: number) => (
|
{predictions.map((prediction: Prediction, index: number) => (
|
||||||
<View key={prediction.id} style={styles.imageContainer}>
|
<View key={prediction.id} style={styles.imageContainer}>
|
||||||
{prediction.preview ? (
|
{prediction.preview ? (
|
||||||
|
<TouchableOpacity
|
||||||
|
style={styles.imageClickable}
|
||||||
|
onPress={() => handleDicomImagePress(prediction, index)}
|
||||||
|
activeOpacity={0.7}
|
||||||
|
>
|
||||||
<Image
|
<Image
|
||||||
source={{ uri: API_CONFIG.DICOM_BASE_URL + prediction.preview }}
|
source={{ uri: API_CONFIG.DICOM_BASE_URL + prediction.preview }}
|
||||||
style={styles.seriesImage}
|
style={styles.seriesImage}
|
||||||
resizeMode="cover"
|
resizeMode="cover"
|
||||||
/>
|
/>
|
||||||
|
{/* Overlay to indicate clickable */}
|
||||||
|
<View style={styles.imageOverlay}>
|
||||||
|
<Icon name="maximize-2" size={20} color={theme.colors.background} />
|
||||||
|
<Text style={styles.imageOverlayText}>View DICOM</Text>
|
||||||
|
</View>
|
||||||
|
</TouchableOpacity>
|
||||||
) : (
|
) : (
|
||||||
<View style={styles.noImagePlaceholder}>
|
<View style={styles.noImagePlaceholder}>
|
||||||
<Icon name="image" size={32} color={theme.colors.textMuted} />
|
<Icon name="image" size={32} color={theme.colors.textMuted} />
|
||||||
@ -1723,6 +1762,19 @@ const SeriesDetailScreen: React.FC<SeriesDetailScreenProps> = ({ navigation, rou
|
|||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* DICOM Viewer Modal */}
|
||||||
|
{selectedDicomData && (
|
||||||
|
<DicomViewerModal
|
||||||
|
visible={dicomModalVisible}
|
||||||
|
dicomUrl={selectedDicomData.dicomUrl}
|
||||||
|
onClose={handleCloseDicomModal}
|
||||||
|
title={`${selectedDicomData.prediction.prediction.label}`}
|
||||||
|
patientName={patientName}
|
||||||
|
studyDescription={`Series ${seriesNumber} - Image ${selectedDicomData.imageIndex + 1} - ${selectedDicomData.prediction.prediction.finding_type}`}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
</SafeAreaView>
|
</SafeAreaView>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@ -1953,11 +2005,34 @@ const styles = StyleSheet.create({
|
|||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
marginRight: theme.spacing.md,
|
marginRight: theme.spacing.md,
|
||||||
},
|
},
|
||||||
|
imageClickable: {
|
||||||
|
position: 'relative',
|
||||||
|
marginBottom: theme.spacing.xs,
|
||||||
|
borderRadius: 8,
|
||||||
|
overflow: 'hidden',
|
||||||
|
},
|
||||||
seriesImage: {
|
seriesImage: {
|
||||||
width: 120,
|
width: 120,
|
||||||
height: 120,
|
height: 120,
|
||||||
borderRadius: 8,
|
borderRadius: 8,
|
||||||
marginBottom: theme.spacing.xs,
|
},
|
||||||
|
imageOverlay: {
|
||||||
|
position: 'absolute',
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
bottom: 0,
|
||||||
|
backgroundColor: 'rgba(0, 0, 0, 0.8)',
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
opacity: 1,
|
||||||
|
},
|
||||||
|
imageOverlayText: {
|
||||||
|
color: theme.colors.background,
|
||||||
|
fontSize: 10,
|
||||||
|
fontFamily: theme.typography.fontFamily.bold,
|
||||||
|
marginTop: 4,
|
||||||
|
textAlign: 'center',
|
||||||
},
|
},
|
||||||
noImagePlaceholder: {
|
noImagePlaceholder: {
|
||||||
width: 120,
|
width: 120,
|
||||||
|
|||||||
3
app/modules/PatientDetailsScreen.tsx
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
@ -54,7 +54,7 @@ export const AppInfoScreen: React.FC<AppInfoScreenProps> = ({
|
|||||||
|
|
||||||
// App version and build information
|
// App version and build information
|
||||||
const appInfo = {
|
const appInfo = {
|
||||||
name: 'NeoScan Radiologist',
|
name: 'NeoScan Physician',
|
||||||
version: '1.0.0',
|
version: '1.0.0',
|
||||||
buildNumber: '2025.08.001',
|
buildNumber: '2025.08.001',
|
||||||
releaseDate: 'August 2025',
|
releaseDate: 'August 2025',
|
||||||
|
|||||||
@ -18,6 +18,7 @@ import {
|
|||||||
ActivityIndicator,
|
ActivityIndicator,
|
||||||
ActionSheetIOS,
|
ActionSheetIOS,
|
||||||
Platform,
|
Platform,
|
||||||
|
PermissionsAndroid,
|
||||||
} from 'react-native';
|
} from 'react-native';
|
||||||
import { theme } from '../../../theme/theme';
|
import { theme } from '../../../theme/theme';
|
||||||
import {
|
import {
|
||||||
@ -31,6 +32,7 @@ import { ProfileCard } from '../components/ProfileCard';
|
|||||||
import { CustomModal } from '../../../shared/components';
|
import { CustomModal } from '../../../shared/components';
|
||||||
import { useAppDispatch, useAppSelector } from '../../../store/hooks';
|
import { useAppDispatch, useAppSelector } from '../../../store/hooks';
|
||||||
import { logoutUser } from '../../Auth/redux/authActions';
|
import { logoutUser } from '../../Auth/redux/authActions';
|
||||||
|
import { updateUserProfile } from '../../Auth/redux/authSlice';
|
||||||
import {
|
import {
|
||||||
selectUser,
|
selectUser,
|
||||||
selectUserDisplayName,
|
selectUserDisplayName,
|
||||||
@ -100,6 +102,15 @@ export const SettingsScreen: React.FC<SettingsScreenProps> = ({
|
|||||||
const [uploadingPhoto, setUploadingPhoto] = useState(false);
|
const [uploadingPhoto, setUploadingPhoto] = useState(false);
|
||||||
const [tempProfilePhoto, setTempProfilePhoto] = useState<string | null>(null);
|
const [tempProfilePhoto, setTempProfilePhoto] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Upload response interface
|
||||||
|
interface UploadPhotoResponse {
|
||||||
|
success: boolean;
|
||||||
|
message?: string;
|
||||||
|
data?: {
|
||||||
|
profile_photo_url: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
// Modal state
|
// Modal state
|
||||||
const [modalVisible, setModalVisible] = useState(false);
|
const [modalVisible, setModalVisible] = useState(false);
|
||||||
const [modalConfig, setModalConfig] = useState({
|
const [modalConfig, setModalConfig] = useState({
|
||||||
@ -213,6 +224,39 @@ export const SettingsScreen: React.FC<SettingsScreenProps> = ({
|
|||||||
setSettingsSections(generateSettingsSections());
|
setSettingsSections(generateSettingsSections());
|
||||||
}, [user, dashboardSettings]);
|
}, [user, dashboardSettings]);
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// PERMISSION HANDLERS
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* requestCameraPermission Function
|
||||||
|
*
|
||||||
|
* Purpose: Request camera permission for Android devices
|
||||||
|
*
|
||||||
|
* @returns Promise<boolean> - Whether permission was granted
|
||||||
|
*/
|
||||||
|
const requestCameraPermission = async (): Promise<boolean> => {
|
||||||
|
if (Platform.OS === 'android') {
|
||||||
|
try {
|
||||||
|
const granted = await PermissionsAndroid.request(
|
||||||
|
PermissionsAndroid.PERMISSIONS.CAMERA,
|
||||||
|
{
|
||||||
|
title: 'Camera Permission',
|
||||||
|
message: 'This app needs camera permission to capture profile photos.',
|
||||||
|
buttonNeutral: 'Ask Me Later',
|
||||||
|
buttonNegative: 'Cancel',
|
||||||
|
buttonPositive: 'OK',
|
||||||
|
}
|
||||||
|
);
|
||||||
|
return granted === PermissionsAndroid.RESULTS.GRANTED;
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('Camera permission error:', err);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true; // iOS permissions are handled via Info.plist
|
||||||
|
};
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// EVENT HANDLERS
|
// EVENT HANDLERS
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@ -263,32 +307,62 @@ export const SettingsScreen: React.FC<SettingsScreenProps> = ({
|
|||||||
* Purpose: Launch camera to capture new profile photo
|
* Purpose: Launch camera to capture new profile photo
|
||||||
*
|
*
|
||||||
* Flow:
|
* Flow:
|
||||||
* 1. Launch camera
|
* 1. Check camera permissions
|
||||||
* 2. Validate captured image
|
* 2. Launch camera with callback
|
||||||
* 3. Upload to server
|
* 3. Validate captured image
|
||||||
* 4. Update local state
|
* 4. Upload to server
|
||||||
|
* 5. Update local state
|
||||||
*/
|
*/
|
||||||
const handleCameraCapture = async () => {
|
const handleCameraCapture = async () => {
|
||||||
try {
|
try {
|
||||||
// Launch camera
|
// Check camera permission first
|
||||||
const result: ImagePickerResponse = await launchCamera({
|
const hasPermission = await requestCameraPermission();
|
||||||
|
|
||||||
|
if (!hasPermission) {
|
||||||
|
setModalConfig({
|
||||||
|
title: 'Permission Required',
|
||||||
|
message: 'Camera permission is required to capture profile photos.',
|
||||||
|
type: 'error',
|
||||||
|
onConfirm: () => {},
|
||||||
|
showCancel: false,
|
||||||
|
icon: 'camera',
|
||||||
|
});
|
||||||
|
setModalVisible(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Launch camera with callback
|
||||||
|
const options = {
|
||||||
mediaType: 'photo' as MediaType,
|
mediaType: 'photo' as MediaType,
|
||||||
quality: 0.8,
|
quality: 0.8 as const,
|
||||||
maxWidth: 800,
|
maxWidth: 800,
|
||||||
maxHeight: 800,
|
maxHeight: 800,
|
||||||
saveToPhotos: false,
|
saveToPhotos: false,
|
||||||
includeBase64: false,
|
includeBase64: false,
|
||||||
});
|
};
|
||||||
|
|
||||||
if (result.didCancel || !result.assets || result.assets.length === 0) {
|
launchCamera(options, (response: ImagePickerResponse) => {
|
||||||
|
try {
|
||||||
|
// Handle user cancellation
|
||||||
|
if (response.didCancel) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const asset = result.assets[0];
|
// Handle errors
|
||||||
if (!asset.uri) {
|
if (response.errorMessage) {
|
||||||
|
throw new Error(response.errorMessage);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate response and assets
|
||||||
|
if (!response.assets || response.assets.length === 0) {
|
||||||
throw new Error('No image captured');
|
throw new Error('No image captured');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const asset = response.assets[0];
|
||||||
|
if (!asset.uri) {
|
||||||
|
throw new Error('Invalid image data');
|
||||||
|
}
|
||||||
|
|
||||||
// Validate file size (max 5MB)
|
// Validate file size (max 5MB)
|
||||||
if (asset.fileSize && asset.fileSize > 5 * 1024 * 1024) {
|
if (asset.fileSize && asset.fileSize > 5 * 1024 * 1024) {
|
||||||
throw new Error('Image size must be less than 5MB');
|
throw new Error('Image size must be less than 5MB');
|
||||||
@ -298,13 +372,27 @@ export const SettingsScreen: React.FC<SettingsScreenProps> = ({
|
|||||||
setTempProfilePhoto(asset.uri);
|
setTempProfilePhoto(asset.uri);
|
||||||
|
|
||||||
// Upload the captured photo
|
// Upload the captured photo
|
||||||
await uploadProfilePhoto(asset.uri);
|
uploadProfilePhoto(asset.uri);
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Camera capture error:', error);
|
console.error('Camera capture processing error:', error);
|
||||||
|
setModalConfig({
|
||||||
|
title: 'Camera Error',
|
||||||
|
message: error instanceof Error ? error.message : 'Failed to capture photo',
|
||||||
|
type: 'error',
|
||||||
|
onConfirm: () => {},
|
||||||
|
showCancel: false,
|
||||||
|
icon: 'alert-circle',
|
||||||
|
});
|
||||||
|
setModalVisible(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Camera launch error:', error);
|
||||||
setModalConfig({
|
setModalConfig({
|
||||||
title: 'Error',
|
title: 'Error',
|
||||||
message: error instanceof Error ? error.message : 'Failed to capture photo',
|
message: 'Failed to launch camera. Please try again.',
|
||||||
type: 'error',
|
type: 'error',
|
||||||
onConfirm: () => {},
|
onConfirm: () => {},
|
||||||
showCancel: false,
|
showCancel: false,
|
||||||
@ -320,31 +408,44 @@ export const SettingsScreen: React.FC<SettingsScreenProps> = ({
|
|||||||
* Purpose: Launch gallery to select existing profile photo
|
* Purpose: Launch gallery to select existing profile photo
|
||||||
*
|
*
|
||||||
* Flow:
|
* Flow:
|
||||||
* 1. Launch image picker
|
* 1. Launch image picker with callback
|
||||||
* 2. Validate selected image
|
* 2. Validate selected image
|
||||||
* 3. Upload to server
|
* 3. Upload to server
|
||||||
* 4. Update local state
|
* 4. Update local state
|
||||||
*/
|
*/
|
||||||
const handleGallerySelection = async () => {
|
const handleGallerySelection = () => {
|
||||||
try {
|
try {
|
||||||
// Launch image picker
|
// Launch image picker with callback
|
||||||
const result: ImagePickerResponse = await launchImageLibrary({
|
const options = {
|
||||||
mediaType: 'photo' as MediaType,
|
mediaType: 'photo' as MediaType,
|
||||||
quality: 0.8,
|
quality: 0.8 as const,
|
||||||
maxWidth: 800,
|
maxWidth: 800,
|
||||||
maxHeight: 800,
|
maxHeight: 800,
|
||||||
includeBase64: false,
|
includeBase64: false,
|
||||||
});
|
};
|
||||||
|
|
||||||
if (result.didCancel || !result.assets || result.assets.length === 0) {
|
launchImageLibrary(options, (response: ImagePickerResponse) => {
|
||||||
|
try {
|
||||||
|
// Handle user cancellation
|
||||||
|
if (response.didCancel) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const asset = result.assets[0];
|
// Handle errors
|
||||||
if (!asset.uri) {
|
if (response.errorMessage) {
|
||||||
|
throw new Error(response.errorMessage);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate response and assets
|
||||||
|
if (!response.assets || response.assets.length === 0) {
|
||||||
throw new Error('No image selected');
|
throw new Error('No image selected');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const asset = response.assets[0];
|
||||||
|
if (!asset.uri) {
|
||||||
|
throw new Error('Invalid image data');
|
||||||
|
}
|
||||||
|
|
||||||
// Validate file size (max 5MB)
|
// Validate file size (max 5MB)
|
||||||
if (asset.fileSize && asset.fileSize > 5 * 1024 * 1024) {
|
if (asset.fileSize && asset.fileSize > 5 * 1024 * 1024) {
|
||||||
throw new Error('Image size must be less than 5MB');
|
throw new Error('Image size must be less than 5MB');
|
||||||
@ -354,13 +455,27 @@ export const SettingsScreen: React.FC<SettingsScreenProps> = ({
|
|||||||
setTempProfilePhoto(asset.uri);
|
setTempProfilePhoto(asset.uri);
|
||||||
|
|
||||||
// Upload the selected photo
|
// Upload the selected photo
|
||||||
await uploadProfilePhoto(asset.uri);
|
uploadProfilePhoto(asset.uri);
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Gallery selection error:', error);
|
console.error('Gallery selection processing error:', error);
|
||||||
|
setModalConfig({
|
||||||
|
title: 'Gallery Error',
|
||||||
|
message: error instanceof Error ? error.message : 'Failed to select photo',
|
||||||
|
type: 'error',
|
||||||
|
onConfirm: () => {},
|
||||||
|
showCancel: false,
|
||||||
|
icon: 'alert-circle',
|
||||||
|
});
|
||||||
|
setModalVisible(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Gallery launch error:', error);
|
||||||
setModalConfig({
|
setModalConfig({
|
||||||
title: 'Error',
|
title: 'Error',
|
||||||
message: error instanceof Error ? error.message : 'Failed to select photo',
|
message: 'Failed to open gallery. Please try again.',
|
||||||
type: 'error',
|
type: 'error',
|
||||||
onConfirm: () => {},
|
onConfirm: () => {},
|
||||||
showCancel: false,
|
showCancel: false,
|
||||||
@ -400,21 +515,30 @@ export const SettingsScreen: React.FC<SettingsScreenProps> = ({
|
|||||||
const response = await authAPI.uploadProfilePhoto(formData, token);
|
const response = await authAPI.uploadProfilePhoto(formData, token);
|
||||||
|
|
||||||
// Type the response properly
|
// Type the response properly
|
||||||
const responseData = response.data as { success: boolean; message?: string; data?: any };
|
const responseData = response.data as UploadPhotoResponse;
|
||||||
|
|
||||||
if (responseData.success) {
|
if (responseData.success) {
|
||||||
// Update local state with new photo
|
// Update local state with new photo
|
||||||
setTempProfilePhoto(null);
|
setTempProfilePhoto(null);
|
||||||
|
|
||||||
|
// Update Redux state with new profile photo URL
|
||||||
|
if (responseData.data?.profile_photo_url) {
|
||||||
|
console.log('Updating user profile with new photo URL:', responseData.data.profile_photo_url);
|
||||||
|
dispatch(updateUserProfile({
|
||||||
|
self_url: responseData.data.profile_photo_url
|
||||||
|
}));
|
||||||
|
console.log('Redux state updated successfully');
|
||||||
|
}
|
||||||
|
|
||||||
// Show success message
|
// Show success message
|
||||||
setModalConfig({
|
setModalConfig({
|
||||||
title: 'Success',
|
title: 'Success',
|
||||||
message: 'Profile photo updated successfully!',
|
message: responseData.message || 'Profile photo updated successfully!',
|
||||||
type: 'success',
|
type: 'success',
|
||||||
icon: 'check-circle',
|
icon: 'check-circle',
|
||||||
onConfirm: () => {
|
onConfirm: () => {
|
||||||
// Refresh user data or update Redux state
|
// Optional: Refresh if needed, but Redux update should be enough
|
||||||
handleRefresh();
|
// handleRefresh();
|
||||||
},
|
},
|
||||||
showCancel: false,
|
showCancel: false,
|
||||||
});
|
});
|
||||||
@ -571,7 +695,7 @@ export const SettingsScreen: React.FC<SettingsScreenProps> = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
console.log('user', user)
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// MAIN RENDER
|
// MAIN RENDER
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@ -610,9 +734,9 @@ console.log('user', user)
|
|||||||
style={styles.profileImage}
|
style={styles.profileImage}
|
||||||
resizeMode="cover"
|
resizeMode="cover"
|
||||||
/>
|
/>
|
||||||
) : user.profile_photo_url ? (
|
) : user.self_url ? (
|
||||||
<Image
|
<Image
|
||||||
source={{ uri: API_CONFIG.BASE_URL + '/api/auth' + user.profile_photo_url }}
|
source={{ uri: API_CONFIG.BASE_URL + '/api/auth' + user.self_url }}
|
||||||
style={styles.profileImage}
|
style={styles.profileImage}
|
||||||
resizeMode="cover"
|
resizeMode="cover"
|
||||||
/>
|
/>
|
||||||
@ -645,19 +769,19 @@ console.log('user', user)
|
|||||||
{user.display_name || `${user.first_name} ${user.last_name}`}
|
{user.display_name || `${user.first_name} ${user.last_name}`}
|
||||||
</Text>
|
</Text>
|
||||||
<Text style={styles.profileEmail}>{user.email}</Text>
|
<Text style={styles.profileEmail}>{user.email}</Text>
|
||||||
<Text style={styles.profileRole}>Radiologist</Text>
|
<Text style={styles.profileRole}>Physician</Text>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Settings sections */}
|
{/* Settings sections */}
|
||||||
{settingsSections.map((section) => (
|
{settingsSections.map((section, index) =>
|
||||||
<SettingsSectionComponent
|
React.createElement(SettingsSectionComponent, {
|
||||||
key={section.id}
|
key: `${section.id}-${index}`,
|
||||||
section={section}
|
section: section
|
||||||
/>
|
})
|
||||||
))}
|
)}
|
||||||
|
|
||||||
{/* Bottom spacing for tab bar */}
|
{/* Bottom spacing for tab bar */}
|
||||||
<View style={styles.bottomSpacing} />
|
<View style={styles.bottomSpacing} />
|
||||||
|
|||||||
@ -7,6 +7,7 @@
|
|||||||
|
|
||||||
import { NavigatorScreenParams } from '@react-navigation/native';
|
import { NavigatorScreenParams } from '@react-navigation/native';
|
||||||
import { ERDashboard, Patient, Alert as AlertType } from '../shared/types';
|
import { ERDashboard, Patient, Alert as AlertType } from '../shared/types';
|
||||||
|
import { PatientCareStackParamList } from '../modules/PatientCare/navigation/navigationTypes';
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// ROOT NAVIGATION TYPES
|
// ROOT NAVIGATION TYPES
|
||||||
@ -44,7 +45,7 @@ export type RootStackParamList = {
|
|||||||
*/
|
*/
|
||||||
export type MainTabParamList = {
|
export type MainTabParamList = {
|
||||||
Dashboard: DashboardScreenParams; // Dashboard with initial data
|
Dashboard: DashboardScreenParams; // Dashboard with initial data
|
||||||
Patients: PatientsScreenParams; // Patient list screen
|
Patients: NavigatorScreenParams<PatientCareStackParamList>; // Patient care stack navigator
|
||||||
AIPredictions: AIPredictionScreenParams; // AI predictions screen
|
AIPredictions: AIPredictionScreenParams; // AI predictions screen
|
||||||
Settings: SettingsScreenParams; // Settings screen
|
Settings: SettingsScreenParams; // Settings screen
|
||||||
};
|
};
|
||||||
|
|||||||
@ -14,7 +14,6 @@ interface DicomViewerProps {
|
|||||||
dicomUrl: string;
|
dicomUrl: string;
|
||||||
onError?: (error: string) => void;
|
onError?: (error: string) => void;
|
||||||
onLoad?: () => void;
|
onLoad?: () => void;
|
||||||
debugMode?: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Interface for WebView reference
|
// Interface for WebView reference
|
||||||
@ -23,40 +22,24 @@ interface WebViewRef {
|
|||||||
reload: () => void;
|
reload: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function DicomViewer({ dicomUrl, onError, onLoad, debugMode = false }: DicomViewerProps): React.ReactElement {
|
export default function DicomViewer({ dicomUrl, onError, onLoad }: DicomViewerProps): React.ReactElement {
|
||||||
const webViewRef = useRef<WebViewRef>(null);
|
const webViewRef = useRef<WebViewRef>(null);
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
|
||||||
const [hasError, setHasError] = useState(false);
|
const [hasError, setHasError] = useState(false);
|
||||||
const [debugInfo, setDebugInfo] = useState<string[]>([]);
|
|
||||||
const [webViewReady, setWebViewReady] = useState(false);
|
const [webViewReady, setWebViewReady] = useState(false);
|
||||||
|
|
||||||
// Debug logging function
|
|
||||||
const debugLog = (message: string) => {
|
|
||||||
if (debugMode) {
|
|
||||||
const timestamp = new Date().toLocaleTimeString();
|
|
||||||
const logMessage = `[${timestamp}] ${message}`;
|
|
||||||
console.log(logMessage);
|
|
||||||
setDebugInfo(prev => [...prev.slice(-9), logMessage]); // Keep last 10 messages
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Handle WebView load events
|
// Handle WebView load events
|
||||||
const handleLoadStart = () => {
|
const handleLoadStart = () => {
|
||||||
debugLog('WebView load started');
|
|
||||||
setIsLoading(true);
|
|
||||||
setHasError(false);
|
setHasError(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleLoadEnd = () => {
|
const handleLoadEnd = () => {
|
||||||
debugLog('WebView load ended');
|
|
||||||
setIsLoading(false);
|
|
||||||
setWebViewReady(true);
|
setWebViewReady(true);
|
||||||
onLoad?.();
|
onLoad?.();
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleError = (error: any) => {
|
const handleError = (error: any) => {
|
||||||
debugLog(`WebView error: ${JSON.stringify(error)}`);
|
|
||||||
setIsLoading(false);
|
|
||||||
setHasError(true);
|
setHasError(true);
|
||||||
onError?.(error?.nativeEvent?.description || 'Failed to load DICOM viewer');
|
onError?.(error?.nativeEvent?.description || 'Failed to load DICOM viewer');
|
||||||
};
|
};
|
||||||
@ -64,13 +47,11 @@ export default function DicomViewer({ dicomUrl, onError, onLoad, debugMode = fal
|
|||||||
const handleMessage = (event: WebViewMessageEvent) => {
|
const handleMessage = (event: WebViewMessageEvent) => {
|
||||||
try {
|
try {
|
||||||
const message = event.nativeEvent.data;
|
const message = event.nativeEvent.data;
|
||||||
debugLog(`Message from WebView: ${message}`);
|
|
||||||
|
|
||||||
// Try to parse JSON message
|
// Try to parse JSON message
|
||||||
if (typeof message === 'string') {
|
if (typeof message === 'string') {
|
||||||
try {
|
try {
|
||||||
const parsedMessage = JSON.parse(message);
|
const parsedMessage = JSON.parse(message);
|
||||||
debugLog(`Parsed message: ${JSON.stringify(parsedMessage)}`);
|
|
||||||
|
|
||||||
if (parsedMessage.type === 'error') {
|
if (parsedMessage.type === 'error') {
|
||||||
setHasError(true);
|
setHasError(true);
|
||||||
@ -79,26 +60,23 @@ export default function DicomViewer({ dicomUrl, onError, onLoad, debugMode = fal
|
|||||||
setHasError(false);
|
setHasError(false);
|
||||||
}
|
}
|
||||||
} catch (parseError) {
|
} catch (parseError) {
|
||||||
debugLog(`Failed to parse message as JSON: ${parseError}`);
|
// Failed to parse message as JSON
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
debugLog(`Error handling WebView message: ${error}`);
|
// Error handling WebView message
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Send DICOM URL to WebView when component mounts or URL changes
|
// Send DICOM URL to WebView when component mounts or URL changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (webViewRef.current && dicomUrl && webViewReady) {
|
if (webViewRef.current && dicomUrl && webViewReady) {
|
||||||
debugLog(`Sending DICOM URL to WebView: ${dicomUrl}`);
|
|
||||||
|
|
||||||
// Wait a bit for WebView to be ready
|
// Wait a bit for WebView to be ready
|
||||||
const timer = setTimeout(() => {
|
const timer = setTimeout(() => {
|
||||||
if (webViewRef.current) {
|
if (webViewRef.current) {
|
||||||
try {
|
try {
|
||||||
// Send the URL directly as a string message
|
// Send the URL directly as a string message
|
||||||
webViewRef.current.postMessage(dicomUrl);
|
webViewRef.current.postMessage(dicomUrl);
|
||||||
debugLog('DICOM URL sent successfully');
|
|
||||||
|
|
||||||
// Also try sending as a structured message
|
// Also try sending as a structured message
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
@ -108,12 +86,11 @@ export default function DicomViewer({ dicomUrl, onError, onLoad, debugMode = fal
|
|||||||
data: dicomUrl
|
data: dicomUrl
|
||||||
});
|
});
|
||||||
webViewRef.current.postMessage(structuredMessage);
|
webViewRef.current.postMessage(structuredMessage);
|
||||||
debugLog('Structured DICOM message sent');
|
|
||||||
}
|
}
|
||||||
}, 500);
|
}, 500);
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
debugLog(`Failed to send DICOM URL: ${error}`);
|
// Failed to send DICOM URL
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, 1000);
|
}, 1000);
|
||||||
@ -124,25 +101,20 @@ export default function DicomViewer({ dicomUrl, onError, onLoad, debugMode = fal
|
|||||||
|
|
||||||
// Reload WebView if there's an error
|
// Reload WebView if there's an error
|
||||||
const handleRetry = () => {
|
const handleRetry = () => {
|
||||||
debugLog('Retrying WebView load');
|
|
||||||
if (webViewRef.current) {
|
if (webViewRef.current) {
|
||||||
setHasError(false);
|
setHasError(false);
|
||||||
setIsLoading(true);
|
|
||||||
setWebViewReady(false);
|
setWebViewReady(false);
|
||||||
webViewRef.current.reload();
|
webViewRef.current.reload();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Clear debug info
|
|
||||||
const clearDebugInfo = () => {
|
|
||||||
setDebugInfo([]);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={styles.container}>
|
<View style={styles.container}>
|
||||||
<WebView
|
<WebView
|
||||||
ref={webViewRef as any}
|
ref={webViewRef as any}
|
||||||
source={require('../../assets/dicom/dicom-viewer.html')}
|
source={{ uri: 'file:///android_asset/dicom-viewer.html' }}
|
||||||
originWhitelist={['*']}
|
originWhitelist={['*']}
|
||||||
javaScriptEnabled
|
javaScriptEnabled
|
||||||
domStorageEnabled
|
domStorageEnabled
|
||||||
@ -154,13 +126,7 @@ export default function DicomViewer({ dicomUrl, onError, onLoad, debugMode = fal
|
|||||||
onError={handleError}
|
onError={handleError}
|
||||||
onMessage={handleMessage}
|
onMessage={handleMessage}
|
||||||
style={styles.webview}
|
style={styles.webview}
|
||||||
startInLoadingState
|
mixedContentMode="always"
|
||||||
renderLoading={() => (
|
|
||||||
<View style={styles.loadingContainer}>
|
|
||||||
<ActivityIndicator size="large" color="#2196F3" />
|
|
||||||
<Text style={styles.loadingText}>Loading DICOM Viewer...</Text>
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{hasError && (
|
{hasError && (
|
||||||
@ -175,32 +141,7 @@ export default function DicomViewer({ dicomUrl, onError, onLoad, debugMode = fal
|
|||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{debugMode && (
|
|
||||||
<View style={styles.debugContainer}>
|
|
||||||
<View style={styles.debugHeader}>
|
|
||||||
<Text style={styles.debugTitle}>Debug Info</Text>
|
|
||||||
<TouchableOpacity onPress={clearDebugInfo}>
|
|
||||||
<Text style={styles.clearButton}>Clear</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
</View>
|
|
||||||
<View style={styles.debugContent}>
|
|
||||||
{debugInfo.map((info, index) => (
|
|
||||||
<Text key={index} style={styles.debugText}>{info}</Text>
|
|
||||||
))}
|
|
||||||
</View>
|
|
||||||
<View style={styles.debugStatus}>
|
|
||||||
<Text style={styles.debugStatusText}>
|
|
||||||
WebView Ready: {webViewReady ? 'Yes' : 'No'}
|
|
||||||
</Text>
|
|
||||||
<Text style={styles.debugStatusText}>
|
|
||||||
Loading: {isLoading ? 'Yes' : 'No'}
|
|
||||||
</Text>
|
|
||||||
<Text style={styles.debugStatusText}>
|
|
||||||
Error: {hasError ? 'Yes' : 'No'}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -214,21 +155,7 @@ const styles = StyleSheet.create({
|
|||||||
flex: 1,
|
flex: 1,
|
||||||
backgroundColor: '#000',
|
backgroundColor: '#000',
|
||||||
},
|
},
|
||||||
loadingContainer: {
|
|
||||||
position: 'absolute',
|
|
||||||
top: 0,
|
|
||||||
left: 0,
|
|
||||||
right: 0,
|
|
||||||
bottom: 0,
|
|
||||||
justifyContent: 'center',
|
|
||||||
alignItems: 'center',
|
|
||||||
backgroundColor: '#000',
|
|
||||||
},
|
|
||||||
loadingText: {
|
|
||||||
color: '#FFF',
|
|
||||||
marginTop: 16,
|
|
||||||
fontSize: 16,
|
|
||||||
},
|
|
||||||
errorContainer: {
|
errorContainer: {
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
top: 0,
|
top: 0,
|
||||||
@ -265,52 +192,6 @@ const styles = StyleSheet.create({
|
|||||||
fontSize: 16,
|
fontSize: 16,
|
||||||
fontWeight: '600',
|
fontWeight: '600',
|
||||||
},
|
},
|
||||||
debugContainer: {
|
|
||||||
position: 'absolute',
|
|
||||||
top: 10,
|
|
||||||
right: 10,
|
|
||||||
backgroundColor: 'rgba(0,0,0,0.9)',
|
|
||||||
borderRadius: 8,
|
|
||||||
padding: 10,
|
|
||||||
maxWidth: 300,
|
|
||||||
maxHeight: 400,
|
|
||||||
},
|
|
||||||
debugHeader: {
|
|
||||||
flexDirection: 'row',
|
|
||||||
justifyContent: 'space-between',
|
|
||||||
alignItems: 'center',
|
|
||||||
marginBottom: 8,
|
|
||||||
},
|
|
||||||
debugTitle: {
|
|
||||||
color: '#FFFFFF',
|
|
||||||
fontSize: 14,
|
|
||||||
fontWeight: '600',
|
|
||||||
},
|
|
||||||
clearButton: {
|
|
||||||
color: '#2196F3',
|
|
||||||
fontSize: 12,
|
|
||||||
textDecorationLine: 'underline',
|
|
||||||
},
|
|
||||||
debugContent: {
|
|
||||||
maxHeight: 200,
|
|
||||||
},
|
|
||||||
debugText: {
|
|
||||||
color: '#FFFFFF',
|
|
||||||
fontSize: 10,
|
|
||||||
fontFamily: 'monospace',
|
|
||||||
marginBottom: 2,
|
|
||||||
},
|
|
||||||
debugStatus: {
|
|
||||||
marginTop: 8,
|
|
||||||
paddingTop: 8,
|
|
||||||
borderTopColor: '#333',
|
|
||||||
borderTopWidth: 1,
|
|
||||||
},
|
|
||||||
debugStatusText: {
|
|
||||||
color: '#CCC',
|
|
||||||
fontSize: 10,
|
|
||||||
marginBottom: 2,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
/*
|
/*
|
||||||
|
|||||||
241
app/shared/components/DicomViewerModal.example.tsx
Normal file
@ -0,0 +1,241 @@
|
|||||||
|
/*
|
||||||
|
* File: DicomViewerModal.example.tsx
|
||||||
|
* Description: Example usage of DicomViewerModal component
|
||||||
|
* Design & Developed by Tech4Biz Solutions
|
||||||
|
* Copyright (c) Spurrin Innovations. All rights reserved.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { useState } from 'react';
|
||||||
|
import { View, TouchableOpacity, Text, StyleSheet } from 'react-native';
|
||||||
|
import { DicomViewerModal } from './index';
|
||||||
|
import { theme } from '../../theme/theme';
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// EXAMPLE COMPONENT
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DicomViewerModalExample Component
|
||||||
|
*
|
||||||
|
* Purpose: Demonstrates how to use the DicomViewerModal component
|
||||||
|
*
|
||||||
|
* Features:
|
||||||
|
* - Shows how to pass dicomUrl to modal
|
||||||
|
* - Demonstrates modal state management
|
||||||
|
* - Example with patient information
|
||||||
|
* - Error handling examples
|
||||||
|
*/
|
||||||
|
export const DicomViewerModalExample: React.FC = () => {
|
||||||
|
// ============================================================================
|
||||||
|
// STATE MANAGEMENT
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
const [isModalVisible, setIsModalVisible] = useState(false);
|
||||||
|
|
||||||
|
// Example DICOM URLs (replace with your actual URLs)
|
||||||
|
const exampleDicomUrl = 'https://example-dicom-server.com/studies/123/series/456/instances/789';
|
||||||
|
|
||||||
|
// Example patient data
|
||||||
|
const patientData = {
|
||||||
|
name: 'John Doe',
|
||||||
|
studyDescription: 'CT Brain with Contrast',
|
||||||
|
};
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// EVENT HANDLERS
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Open DICOM viewer modal
|
||||||
|
*/
|
||||||
|
const openDicomViewer = () => {
|
||||||
|
setIsModalVisible(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Close DICOM viewer modal
|
||||||
|
*/
|
||||||
|
const closeDicomViewer = () => {
|
||||||
|
setIsModalVisible(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// RENDER
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={styles.container}>
|
||||||
|
{/* Trigger Button */}
|
||||||
|
<TouchableOpacity
|
||||||
|
style={styles.button}
|
||||||
|
onPress={openDicomViewer}
|
||||||
|
activeOpacity={0.7}
|
||||||
|
>
|
||||||
|
<Text style={styles.buttonText}>View DICOM Image</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
|
||||||
|
{/* DICOM Viewer Modal */}
|
||||||
|
<DicomViewerModal
|
||||||
|
visible={isModalVisible}
|
||||||
|
dicomUrl={exampleDicomUrl}
|
||||||
|
onClose={closeDicomViewer}
|
||||||
|
title="CT Brain Scan"
|
||||||
|
patientName={patientData.name}
|
||||||
|
studyDescription={patientData.studyDescription}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// STYLES
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
flex: 1,
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
backgroundColor: theme.colors.background,
|
||||||
|
padding: theme.spacing.lg,
|
||||||
|
},
|
||||||
|
|
||||||
|
button: {
|
||||||
|
backgroundColor: theme.colors.primary,
|
||||||
|
paddingHorizontal: theme.spacing.xl,
|
||||||
|
paddingVertical: theme.spacing.md,
|
||||||
|
borderRadius: theme.borderRadius.medium,
|
||||||
|
...theme.shadows.primary,
|
||||||
|
},
|
||||||
|
|
||||||
|
buttonText: {
|
||||||
|
fontSize: theme.typography.fontSize.bodyLarge,
|
||||||
|
fontFamily: theme.typography.fontFamily.bold,
|
||||||
|
color: theme.colors.background,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// USAGE EXAMPLES IN OTHER COMPONENTS
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/*
|
||||||
|
|
||||||
|
// Example 1: Basic Usage in Patient Details Screen
|
||||||
|
import { DicomViewerModal } from '../../../shared/components';
|
||||||
|
|
||||||
|
const PatientDetailsExample = () => {
|
||||||
|
const [showDicom, setShowDicom] = useState(false);
|
||||||
|
const dicomUrl = patient.scanResults?.dicomUrl;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<TouchableOpacity onPress={() => setShowDicom(true)}>
|
||||||
|
<Text>View Scan Results</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
|
||||||
|
<DicomViewerModal
|
||||||
|
visible={showDicom}
|
||||||
|
dicomUrl={dicomUrl}
|
||||||
|
onClose={() => setShowDicom(false)}
|
||||||
|
patientName={patient.name}
|
||||||
|
studyDescription={patient.scanResults?.description}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Example 2: Usage with Series Selection
|
||||||
|
import { DicomViewerModal } from '../../../shared/components';
|
||||||
|
|
||||||
|
const SeriesListExample = () => {
|
||||||
|
const [selectedDicom, setSelectedDicom] = useState<string | null>(null);
|
||||||
|
const [modalVisible, setModalVisible] = useState(false);
|
||||||
|
|
||||||
|
const openDicom = (dicomUrl: string) => {
|
||||||
|
setSelectedDicom(dicomUrl);
|
||||||
|
setModalVisible(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeDicom = () => {
|
||||||
|
setModalVisible(false);
|
||||||
|
setSelectedDicom(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{seriesList.map((series) => (
|
||||||
|
<TouchableOpacity
|
||||||
|
key={series.id}
|
||||||
|
onPress={() => openDicom(series.dicomUrl)}
|
||||||
|
>
|
||||||
|
<Text>{series.description}</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<DicomViewerModal
|
||||||
|
visible={modalVisible}
|
||||||
|
dicomUrl={selectedDicom || ''}
|
||||||
|
onClose={closeDicom}
|
||||||
|
title="Series Viewer"
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Example 3: Usage with Error Handling
|
||||||
|
import { DicomViewerModal } from '../../../shared/components';
|
||||||
|
|
||||||
|
const ErrorHandlingExample = () => {
|
||||||
|
const [dicomModalState, setDicomModalState] = useState({
|
||||||
|
visible: false,
|
||||||
|
url: '',
|
||||||
|
title: '',
|
||||||
|
});
|
||||||
|
|
||||||
|
const openDicomWithValidation = (url: string, title: string) => {
|
||||||
|
if (!url) {
|
||||||
|
Alert.alert('Error', 'No DICOM URL available');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setDicomModalState({
|
||||||
|
visible: true,
|
||||||
|
url,
|
||||||
|
title,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeDicomModal = () => {
|
||||||
|
setDicomModalState({
|
||||||
|
visible: false,
|
||||||
|
url: '',
|
||||||
|
title: '',
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={() => openDicomWithValidation(scan.url, scan.title)}
|
||||||
|
>
|
||||||
|
<Text>View Scan</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
|
||||||
|
<DicomViewerModal
|
||||||
|
visible={dicomModalState.visible}
|
||||||
|
dicomUrl={dicomModalState.url}
|
||||||
|
onClose={closeDicomModal}
|
||||||
|
title={dicomModalState.title}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
/*
|
||||||
|
* End of File: DicomViewerModal.example.tsx
|
||||||
|
* Design & Developed by Tech4Biz Solutions
|
||||||
|
* Copyright (c) Spurrin Innovations. All rights reserved.
|
||||||
|
*/
|
||||||
344
app/shared/components/DicomViewerModal.tsx
Normal file
@ -0,0 +1,344 @@
|
|||||||
|
/*
|
||||||
|
* File: DicomViewerModal.tsx
|
||||||
|
* Description: Reusable modal component for DICOM image viewing
|
||||||
|
* Design & Developed by Tech4Biz Solutions
|
||||||
|
* Copyright (c) Spurrin Innovations. All rights reserved.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { useState } from 'react';
|
||||||
|
import {
|
||||||
|
View,
|
||||||
|
Text,
|
||||||
|
StyleSheet,
|
||||||
|
TouchableOpacity,
|
||||||
|
Modal,
|
||||||
|
SafeAreaView,
|
||||||
|
StatusBar,
|
||||||
|
Dimensions,
|
||||||
|
Alert,
|
||||||
|
} from 'react-native';
|
||||||
|
import { theme } from '../../theme/theme';
|
||||||
|
import Icon from 'react-native-vector-icons/Feather';
|
||||||
|
import DicomViewer from './DicomViewer';
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// INTERFACES
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DicomViewerModalProps Interface
|
||||||
|
*
|
||||||
|
* Purpose: Defines the props required by the DicomViewerModal component
|
||||||
|
*
|
||||||
|
* Props:
|
||||||
|
* - visible: Whether the modal is visible
|
||||||
|
* - dicomUrl: URL of the DICOM file to display
|
||||||
|
* - onClose: Callback function when modal is closed
|
||||||
|
* - title: Optional title for the modal header
|
||||||
|
* - patientName: Optional patient name for context
|
||||||
|
* - studyDescription: Optional study description
|
||||||
|
*/
|
||||||
|
interface DicomViewerModalProps {
|
||||||
|
visible: boolean;
|
||||||
|
dicomUrl: string;
|
||||||
|
onClose: () => void;
|
||||||
|
title?: string;
|
||||||
|
patientName?: string;
|
||||||
|
studyDescription?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// DICOM VIEWER MODAL COMPONENT
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DicomViewerModal Component
|
||||||
|
*
|
||||||
|
* Purpose: Provides a full-screen modal for viewing DICOM medical images
|
||||||
|
*
|
||||||
|
* Features:
|
||||||
|
* - Full-screen DICOM image viewing
|
||||||
|
* - Modal overlay with close functionality
|
||||||
|
* - Error handling and display
|
||||||
|
* - Loading states
|
||||||
|
* - Header with patient/study information
|
||||||
|
* - Responsive design for different screen sizes
|
||||||
|
* - Proper medical image viewing environment (dark background)
|
||||||
|
*/
|
||||||
|
export const DicomViewerModal: React.FC<DicomViewerModalProps> = ({
|
||||||
|
visible,
|
||||||
|
dicomUrl,
|
||||||
|
onClose,
|
||||||
|
title = 'DICOM Viewer',
|
||||||
|
patientName,
|
||||||
|
studyDescription,
|
||||||
|
}) => {
|
||||||
|
// ============================================================================
|
||||||
|
// STATE MANAGEMENT
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [hasError, setHasError] = useState(false);
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// EVENT HANDLERS
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle DICOM viewer load completion
|
||||||
|
*/
|
||||||
|
const handleDicomLoad = () => {
|
||||||
|
setIsLoading(false);
|
||||||
|
setHasError(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle DICOM viewer errors
|
||||||
|
* @param error - Error message from DICOM viewer
|
||||||
|
*/
|
||||||
|
const handleDicomError = (error: string) => {
|
||||||
|
setIsLoading(false);
|
||||||
|
setHasError(true);
|
||||||
|
|
||||||
|
// Show error alert to user
|
||||||
|
Alert.alert(
|
||||||
|
'DICOM Loading Error',
|
||||||
|
`Failed to load DICOM file: ${error}`,
|
||||||
|
[
|
||||||
|
{
|
||||||
|
text: 'Retry',
|
||||||
|
onPress: () => {
|
||||||
|
setHasError(false);
|
||||||
|
setIsLoading(true);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: 'Close',
|
||||||
|
onPress: onClose,
|
||||||
|
style: 'cancel',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle modal close request
|
||||||
|
*/
|
||||||
|
const handleClose = () => {
|
||||||
|
// Reset states when closing
|
||||||
|
setIsLoading(false);
|
||||||
|
setHasError(false);
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle back button press (Android)
|
||||||
|
*/
|
||||||
|
const handleRequestClose = () => {
|
||||||
|
handleClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// RENDER HEADER
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render modal header with title and close button
|
||||||
|
*/
|
||||||
|
const renderHeader = () => (
|
||||||
|
<View style={styles.header}>
|
||||||
|
{/* Title and Patient Info */}
|
||||||
|
<View style={styles.headerContent}>
|
||||||
|
<Text style={styles.headerTitle}>{title}</Text>
|
||||||
|
{patientName && (
|
||||||
|
<Text style={styles.headerSubtitle}>Patient: {patientName}</Text>
|
||||||
|
)}
|
||||||
|
{studyDescription && (
|
||||||
|
<Text style={styles.headerSubtitle}>Study: {studyDescription}</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Close Button */}
|
||||||
|
<TouchableOpacity
|
||||||
|
style={styles.closeButton}
|
||||||
|
onPress={handleClose}
|
||||||
|
activeOpacity={0.7}
|
||||||
|
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
|
||||||
|
>
|
||||||
|
<Icon name="x" size={24} color={theme.colors.background} />
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// RENDER CONTENT
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render DICOM viewer content
|
||||||
|
*/
|
||||||
|
const renderContent = () => {
|
||||||
|
if (!dicomUrl) {
|
||||||
|
return (
|
||||||
|
<View style={styles.emptyContainer}>
|
||||||
|
<Icon name="file-minus" size={48} color={theme.colors.textMuted} />
|
||||||
|
<Text style={styles.emptyText}>No DICOM URL provided</Text>
|
||||||
|
<TouchableOpacity style={styles.closeButtonSecondary} onPress={handleClose}>
|
||||||
|
<Text style={styles.closeButtonText}>Close</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={styles.viewerContainer}>
|
||||||
|
<DicomViewer
|
||||||
|
dicomUrl={dicomUrl}
|
||||||
|
onLoad={handleDicomLoad}
|
||||||
|
onError={handleDicomError}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// MAIN RENDER
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
visible={visible}
|
||||||
|
animationType="slide"
|
||||||
|
presentationStyle="fullScreen"
|
||||||
|
onRequestClose={handleRequestClose}
|
||||||
|
>
|
||||||
|
<SafeAreaView style={styles.container}>
|
||||||
|
{/* Set status bar to light content for dark background */}
|
||||||
|
<StatusBar
|
||||||
|
barStyle="light-content"
|
||||||
|
backgroundColor="#000000"
|
||||||
|
translucent={false}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Header */}
|
||||||
|
{renderHeader()}
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
{renderContent()}
|
||||||
|
</SafeAreaView>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// STYLES
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
const { width: screenWidth, height: screenHeight } = Dimensions.get('window');
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
// Main container
|
||||||
|
container: {
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: '#000000',
|
||||||
|
},
|
||||||
|
|
||||||
|
// Header section
|
||||||
|
header: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
paddingHorizontal: theme.spacing.lg,
|
||||||
|
paddingVertical: theme.spacing.md,
|
||||||
|
backgroundColor: 'rgba(0, 0, 0, 0.9)',
|
||||||
|
borderBottomWidth: 1,
|
||||||
|
borderBottomColor: 'rgba(255, 255, 255, 0.1)',
|
||||||
|
},
|
||||||
|
|
||||||
|
// Header content
|
||||||
|
headerContent: {
|
||||||
|
flex: 1,
|
||||||
|
marginRight: theme.spacing.md,
|
||||||
|
},
|
||||||
|
|
||||||
|
// Header title
|
||||||
|
headerTitle: {
|
||||||
|
fontSize: theme.typography.fontSize.displaySmall,
|
||||||
|
fontFamily: theme.typography.fontFamily.bold,
|
||||||
|
color: theme.colors.background,
|
||||||
|
marginBottom: 2,
|
||||||
|
},
|
||||||
|
|
||||||
|
// Header subtitle
|
||||||
|
headerSubtitle: {
|
||||||
|
fontSize: theme.typography.fontSize.bodyMedium,
|
||||||
|
fontFamily: theme.typography.fontFamily.regular,
|
||||||
|
color: 'rgba(255, 255, 255, 0.8)',
|
||||||
|
lineHeight: theme.typography.fontSize.bodyMedium * 1.2,
|
||||||
|
},
|
||||||
|
|
||||||
|
// Close button
|
||||||
|
closeButton: {
|
||||||
|
width: 40,
|
||||||
|
height: 40,
|
||||||
|
borderRadius: 20,
|
||||||
|
backgroundColor: 'rgba(255, 255, 255, 0.1)',
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: 'rgba(255, 255, 255, 0.2)',
|
||||||
|
},
|
||||||
|
|
||||||
|
// DICOM viewer container
|
||||||
|
viewerContainer: {
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: '#000000',
|
||||||
|
},
|
||||||
|
|
||||||
|
// Empty state container
|
||||||
|
emptyContainer: {
|
||||||
|
flex: 1,
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
backgroundColor: '#000000',
|
||||||
|
paddingHorizontal: theme.spacing.xl,
|
||||||
|
},
|
||||||
|
|
||||||
|
// Empty state text
|
||||||
|
emptyText: {
|
||||||
|
fontSize: theme.typography.fontSize.bodyLarge,
|
||||||
|
fontFamily: theme.typography.fontFamily.medium,
|
||||||
|
color: theme.colors.textMuted,
|
||||||
|
textAlign: 'center',
|
||||||
|
marginTop: theme.spacing.md,
|
||||||
|
marginBottom: theme.spacing.xl,
|
||||||
|
},
|
||||||
|
|
||||||
|
// Secondary close button
|
||||||
|
closeButtonSecondary: {
|
||||||
|
backgroundColor: theme.colors.primary,
|
||||||
|
paddingHorizontal: theme.spacing.xl,
|
||||||
|
paddingVertical: theme.spacing.md,
|
||||||
|
borderRadius: theme.borderRadius.medium,
|
||||||
|
...theme.shadows.primary,
|
||||||
|
},
|
||||||
|
|
||||||
|
// Close button text
|
||||||
|
closeButtonText: {
|
||||||
|
fontSize: theme.typography.fontSize.bodyLarge,
|
||||||
|
fontFamily: theme.typography.fontFamily.bold,
|
||||||
|
color: theme.colors.background,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// EXPORT
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export default DicomViewerModal;
|
||||||
|
|
||||||
|
/*
|
||||||
|
* End of File: DicomViewerModal.tsx
|
||||||
|
* Design & Developed by Tech4Biz Solutions
|
||||||
|
* Copyright (c) Spurrin Innovations. All rights reserved.
|
||||||
|
*/
|
||||||
@ -18,6 +18,9 @@ export { ComingSoonScreen } from './ComingSoonScreen';
|
|||||||
// DICOM Viewer Component
|
// DICOM Viewer Component
|
||||||
export { default as DicomViewer } from './DicomViewer';
|
export { default as DicomViewer } from './DicomViewer';
|
||||||
|
|
||||||
|
// DICOM Viewer Modal Component
|
||||||
|
export { default as DicomViewerModal } from './DicomViewerModal';
|
||||||
|
|
||||||
// DICOM Viewer Test Component
|
// DICOM Viewer Test Component
|
||||||
export { default as DicomViewerTest } from './DicomViewerTest';
|
export { default as DicomViewerTest } from './DicomViewerTest';
|
||||||
|
|
||||||
|
|||||||
@ -44,6 +44,7 @@ export interface User {
|
|||||||
onboarding_message: string;
|
onboarding_message: string;
|
||||||
access_token: string;
|
access_token: string;
|
||||||
platform: 'app'|'web';
|
platform: 'app'|'web';
|
||||||
|
self_url:string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type UserRole =
|
export type UserRole =
|
||||||
|
|||||||
@ -19,6 +19,7 @@ import settingsReducer from '../modules/Settings/redux/settingsSlice';
|
|||||||
import uiReducer from '../modules/Dashboard/redux/uiSlice';
|
import uiReducer from '../modules/Dashboard/redux/uiSlice';
|
||||||
import hospitalReducer from '../modules/Auth/redux/hospitalSlice';
|
import hospitalReducer from '../modules/Auth/redux/hospitalSlice';
|
||||||
import aiPredictionReducer from '../modules/AIPrediction/redux/aiPredictionSlice';
|
import aiPredictionReducer from '../modules/AIPrediction/redux/aiPredictionSlice';
|
||||||
|
import predictionsReducer from '../modules/Dashboard/redux/predictionsSlice';
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// REDUX PERSIST CONFIGURATION
|
// REDUX PERSIST CONFIGURATION
|
||||||
@ -59,6 +60,7 @@ const persistConfig = {
|
|||||||
'alerts', // Temporary alerts and notifications
|
'alerts', // Temporary alerts and notifications
|
||||||
'dashboard', // Real-time dashboard data
|
'dashboard', // Real-time dashboard data
|
||||||
'aiDashboard', // AI dashboard statistics (fetched fresh each time)
|
'aiDashboard', // AI dashboard statistics (fetched fresh each time)
|
||||||
|
'predictions', // AI predictions data (fetched fresh each time)
|
||||||
'hospital', // Hospital data (fetched fresh each time)
|
'hospital', // Hospital data (fetched fresh each time)
|
||||||
],
|
],
|
||||||
|
|
||||||
@ -90,6 +92,7 @@ const persistConfig = {
|
|||||||
* - aiDashboard: AI analysis dashboard statistics
|
* - aiDashboard: AI analysis dashboard statistics
|
||||||
* - patientCare: Patient information and medical records
|
* - patientCare: Patient information and medical records
|
||||||
* - aiPrediction: AI prediction cases and analysis
|
* - aiPrediction: AI prediction cases and analysis
|
||||||
|
* - predictions: AI predictions with/without feedback
|
||||||
* - alerts: Critical alerts and notifications
|
* - alerts: Critical alerts and notifications
|
||||||
* - settings: User preferences and app settings
|
* - settings: User preferences and app settings
|
||||||
* - ui: User interface state (loading, modals, etc.)
|
* - ui: User interface state (loading, modals, etc.)
|
||||||
@ -100,6 +103,7 @@ const rootReducer = combineReducers({
|
|||||||
aiDashboard: aiDashboardReducer,
|
aiDashboard: aiDashboardReducer,
|
||||||
patientCare: patientCareReducer,
|
patientCare: patientCareReducer,
|
||||||
aiPrediction: aiPredictionReducer,
|
aiPrediction: aiPredictionReducer,
|
||||||
|
predictions: predictionsReducer,
|
||||||
alerts: alertsReducer,
|
alerts: alertsReducer,
|
||||||
settings: settingsReducer,
|
settings: settingsReducer,
|
||||||
ui: uiReducer,
|
ui: uiReducer,
|
||||||
|
|||||||