import { BarChart3, Calendar, Clock, RefreshCw, ChevronDown, Download, FileSpreadsheet } from 'lucide-react';
import { useEffect, useState } from 'react';
import ApiService from '../../services/api';
import { useAuth } from '../../contexts/AuthContext';
import toast from 'react-hot-toast';
/**
* Renders the dashboard and associated memo table for a specific hospital.
* It allows users to view dashboard statistics based on selected periods (today, week, month)
* and date, and also displays a table of memos for the chosen period.
* Users can filter and search memos and export them to Excel.
*
* @param {object} props - The component props.
* @param {string} [props.baseUrl="https://app.memotrack.net/media/dashboard/"] - The base URL for the dashboard JSON files.
* @param {string} [props.iframeHtmlUrl="/dashboard.html"] - The URL of the HTML file that loads the dashboard visualization.
* @param {string} [props.apiBaseUrl='https://app.memotrack.net' ||'http://localhost:8000'] - The base URL for the API.
* */
const DashboardIframe = ({
baseUrl = "https://app.memotrack.net/media/dashboard/",
iframeHtmlUrl = "/dashboard.html",
apiBaseUrl = 'https://app.memotrack.net' ||'http://localhost:8000'
}) => {
const {hospital,apiService} = useAuth();
const hospitalId = hospital.id;
const [selectedPeriod, setSelectedPeriod] = useState('month');
const [selectedDate, setSelectedDate] = useState(new Date());
const [showDatePicker, setShowDatePicker] = useState(false);
const [jsonUrl, setJsonUrl] = useState('');
const [iframeUrl, setIframeUrl] = useState('');
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
const [memos, setMemos] = useState([]);
const [memosLoading, setMemosLoading] = useState(false);
const [memosError, setMemosError] = useState(null);
const [currentPage, setCurrentPage] = useState(1);
const memosPerPage = 10;
const [searchTerm, setSearchTerm] = useState("");
const [filteredMemos, setFilteredMemos] = useState(memos);
const [selectedResponder, setSelectedResponder] = useState('');
const [selectedBlock, setSelectedBlock] = useState('');
useEffect(() => {
const lowerSearch = searchTerm.toLowerCase();
const filtered = memos.filter((memo) => {
const complaintMatch = memo.latest_snapshot.complaint
.toLowerCase()
.includes(lowerSearch);
const responderMatch =
selectedResponder === '' ||
memo.latest_snapshot.tagged_role_name === selectedResponder;
const blockMatch =
selectedBlock === '' ||
memo.latest_snapshot.current_block_name === selectedBlock;
return complaintMatch && responderMatch && blockMatch;
});
setFilteredMemos(filtered);
}, [searchTerm, selectedResponder, selectedBlock, memos]);
// Calculate the memos for the current page
const paginatedMemos = filteredMemos.slice(
(currentPage - 1) * memosPerPage,
currentPage * memosPerPage
);
const totalPages = Math.ceil(memos.length / memosPerPage);
// Function to format date based on period and selected date
const formatDateForPeriod = (period, date) => {
const targetDate = new Date(date);
switch (period) {
case 'today': {
const year = targetDate.getFullYear();
const month = String(targetDate.getMonth() + 1).padStart(2, '0');
const day = String(targetDate.getDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
}
case 'week': {
const year = targetDate.getFullYear();
const month = String(targetDate.getMonth() + 1).padStart(2, '0');
// Calculate ISO week number
const startOfYear = new Date(year, 0, 1);
const pastDaysOfYear = (targetDate - startOfYear) / 86400000;
const weekNumber = Math.ceil((pastDaysOfYear + startOfYear.getDay() + 1) / 7);
const weekStr = String(weekNumber).padStart(2, '0');
return `${year}-W${weekStr}`;
}
case 'month': {
const year = targetDate.getFullYear();
const month = String(targetDate.getMonth() + 1).padStart(2, '0');
return `${year}-${month}`;
}
default:
return '';
}
};
// Function to format date for API call
const formatDateForAPI = (period, date) => {
const targetDate = new Date(date);
switch (period) {
case 'today':
return {
type: 'date',
value: targetDate.toISOString().split('T')[0]
};
case 'week':
const year = targetDate.getFullYear();
const startOfYear = new Date(year, 0, 1);
const pastDaysOfYear = (targetDate - startOfYear) / 86400000;
const weekNumber = Math.ceil((pastDaysOfYear + startOfYear.getDay() + 1) / 7);
return {
type: 'week',
year: year,
week: weekNumber
};
case 'month':
return {
type: 'month',
year: targetDate.getFullYear(),
month: targetDate.getMonth() + 1
};
default:
return {};
}
};
// Generate the JSON URL and iframe URL based on hospital ID and selected period
useEffect(() => {
const dateString = formatDateForPeriod(selectedPeriod, selectedDate);
const jsonDataUrl = `${baseUrl}${hospital.id}_${dateString}.json`;
const fullIframeUrl = `${iframeHtmlUrl}?source=${encodeURIComponent(jsonDataUrl)}`;
setJsonUrl(jsonDataUrl);
setIframeUrl(fullIframeUrl);
}, [hospitalId, selectedPeriod, selectedDate, baseUrl, iframeHtmlUrl]);
// function to get query param
function getQueryString(){
try {
const dateParams = formatDateForAPI(selectedPeriod, selectedDate);
const queryParams = new URLSearchParams({
hospital_id: hospital.id,
period_type: dateParams.type,
...dateParams
});
return queryParams;
}catch(err){
toast.error("Failed to Download");
}
}
// Function to fetch memos from backend
const fetchMemos = async () => {
setMemosLoading(true);
setMemosError(null);
try {
const dateParams = formatDateForAPI(selectedPeriod, selectedDate);
const queryParams = new URLSearchParams({
hospital_id: hospital.id,
period_type: dateParams.type,
...dateParams
});
const response = await apiService.getDashboardMemos(hospitalId,queryParams);
console.log(response);
if (response.count <= 0 ) {
throw new Error(`Failed to fetch memos (${response.status})`);
}
setMemos(response.memos);
} catch (err) {
setMemosError(err.message);
setMemos([]);
} finally {
setMemosLoading(false);
}
};
// Function to check if JSON URL is accessible for dashboard
const loadDashboard = async () => {
setLoading(true);
setError(null);
try {
const response = await fetch(jsonUrl);
if (!response.ok) {
throw new Error(`Dashboard data not available (${response.status})`);
}
const data = await response.json();
console.log('Dashboard data loaded:', data);
// Simulate dashboard loading time
setTimeout(() => {
setLoading(false);
}, 500);
} catch (err) {
setError(err.message);
setLoading(false);
}
};
// Auto-load when JSON URL changes
useEffect(() => {
if (jsonUrl) {
loadDashboard();
fetchMemos(); // Also fetch memos when date changes
}
}, [jsonUrl]);
// Handle date input change
const handleDateChange = (e) => {
const newDate = new Date(e.target.value);
setSelectedDate(newDate);
setShowDatePicker(false);
};
// Handle week input change
const handleWeekChange = (e) => {
const [year, week] = e.target.value.split('-W');
const newDate = new Date(year, 0, 1 + (week - 1) * 7);
setSelectedDate(newDate);
setShowDatePicker(false);
};
// Handle month input change
const handleMonthChange = (e) => {
const [year, month] = e.target.value.split('-');
const newDate = new Date(year, month - 1, 1);
setSelectedDate(newDate);
setShowDatePicker(false);
};
// Get formatted date string for display
const getDisplayDate = () => {
const date = new Date(selectedDate);
switch (selectedPeriod) {
case 'today':
return date.toLocaleDateString();
case 'week':
const year = date.getFullYear();
const startOfYear = new Date(year, 0, 1);
const pastDaysOfYear = (date - startOfYear) / 86400000;
const weekNumber = Math.ceil((pastDaysOfYear + startOfYear.getDay() + 1) / 7);
return `Week ${weekNumber}, ${year}`;
case 'month':
return date.toLocaleDateString('en-US', { month: 'long', year: 'numeric' });
default:
return '';
}
};
// Get the appropriate input type and value for the date picker
const getDateInputProps = () => {
const date = new Date(selectedDate);
switch (selectedPeriod) {
case 'today':
return {
type: 'date',
value: date.toISOString().split('T')[0],
onChange: handleDateChange
};
case 'week':
const year = date.getFullYear();
const startOfYear = new Date(year, 0, 1);
const pastDaysOfYear = (date - startOfYear) / 86400000;
const weekNumber = Math.ceil((pastDaysOfYear + startOfYear.getDay() + 1) / 7);
return {
type: 'week',
value: `${year}-W${String(weekNumber).padStart(2, '0')}`,
onChange: handleWeekChange
};
case 'month':
return {
type: 'month',
value: `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}`,
onChange: handleMonthChange
};
default:
return {};
}
};
// Navigate to previous period
const goToPrevious = () => {
const newDate = new Date(selectedDate);
switch (selectedPeriod) {
case 'today':
newDate.setDate(newDate.getDate() - 1);
break;
case 'week':
newDate.setDate(newDate.getDate() - 7);
break;
case 'month':
newDate.setMonth(newDate.getMonth() - 1);
break;
}
setSelectedDate(newDate);
};
// Navigate to next period
const goToNext = () => {
const newDate = new Date(selectedDate);
switch (selectedPeriod) {
case 'today':
newDate.setDate(newDate.getDate() + 1);
break;
case 'week':
newDate.setDate(newDate.getDate() + 7);
break;
case 'month':
newDate.setMonth(newDate.getMonth() + 1);
break;
}
setSelectedDate(newDate);
};
// Reset to current date
const goToCurrent = () => {
setSelectedDate(new Date());
};
// Export single memo
const exportMemo = (memoId) => {
const exportUrl = `${apiBaseUrl}/api/export/memo/${memoId}`;
window.open(exportUrl, '_blank');
};
// Export all memos
const exportAllMemos = () => {
const dateParams = formatDateForAPI(selectedPeriod, selectedDate);
const queryParams = new URLSearchParams({
hospital_id: hospitalId,
period_type: dateParams.type,
...dateParams
});
const exportUrl = `${apiBaseUrl}/api/export/memos/${hospitalId}/?${queryParams}`;
window.open(exportUrl, '_blank');
};
// Format date for display
const formatDisplayDate = (dateString) => {
return new Date(dateString).toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
});
};
// Get status badge color
const getStatusBadge = (status, displayStatus) => {
const effectiveStatus = displayStatus || status;
const colors = {
ongoing: 'bg-yellow-100 text-yellow-800',
completed: 'bg-green-100 text-green-800',
attended: 'bg-blue-100 text-blue-800',
incomplete: 'bg-red-100 text-red-800'
};
return colors[effectiveStatus] || 'bg-gray-100 text-gray-800';
};
return (
<div className="w-full">
<h1 className='text-2xl font-bold p-3 mb-3'>Dashboard</h1>
{/* Controls */}
<div className="mb-6 flex flex-wrap gap-4 items-center justify-between bg-white p-4 rounded-lg border">
{/* Period Selection Buttons */}
<div className="flex gap-2">
<button
onClick={() => setSelectedPeriod('today')}
className={`px-4 py-2 rounded-lg font-medium transition-all flex items-center gap-2 ${
selectedPeriod === 'today'
? 'bg-blue-600 text-white shadow-md'
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
}`}
>
<Clock size={16} />
Today
</button>
<button
onClick={() => setSelectedPeriod('week')}
className={`px-4 py-2 rounded-lg font-medium transition-all flex items-center gap-2 ${
selectedPeriod === 'week'
? 'bg-blue-600 text-white shadow-md'
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
}`}
>
<Calendar size={16} />
Week
</button>
<button
onClick={() => setSelectedPeriod('month')}
className={`px-4 py-2 rounded-lg font-medium transition-all flex items-center gap-2 ${
selectedPeriod === 'month'
? 'bg-blue-600 text-white shadow-md'
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
}`}
>
<BarChart3 size={16} />
Month
</button>
</div>
{/* Date Navigation and Selection */}
<div className="flex items-center gap-2">
{/* Previous Button */}
<button
onClick={goToPrevious}
className="px-3 py-2 bg-gray-100 text-gray-700 rounded-lg hover:bg-gray-200 transition-all"
>
←
</button>
{/* Current Date Display and Picker */}
<div className="relative">
<button
onClick={() => setShowDatePicker(!showDatePicker)}
className="px-4 py-2 bg-gray-50 text-gray-700 rounded-lg hover:bg-gray-100 transition-all flex items-center gap-2 min-w-[200px] justify-between"
>
<span>{getDisplayDate()}</span>
<ChevronDown size={16} className={`transition-transform ${showDatePicker ? 'rotate-180' : ''}`} />
</button>
{showDatePicker && (
<div className="absolute top-full left-0 mt-2 bg-white border rounded-lg shadow-lg p-4 z-50 min-w-[200px]">
<input
{...getDateInputProps()}
className="w-full px-3 py-2 border rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
<div className="flex gap-2 mt-3">
<button
onClick={goToCurrent}
className="px-3 py-1 text-sm bg-blue-600 text-white rounded hover:bg-blue-700 transition-all"
>
Today
</button>
<button
onClick={() => setShowDatePicker(false)}
className="px-3 py-1 text-sm bg-gray-200 text-gray-700 rounded hover:bg-gray-300 transition-all"
>
Close
</button>
</div>
</div>
)}
</div>
{/* Next Button */}
<button
onClick={goToNext}
className="px-3 py-2 bg-gray-100 text-gray-700 rounded-lg hover:bg-gray-200 transition-all"
>
→
</button>
</div>
{/* Refresh Button */}
<button
onClick={() => {
loadDashboard();
fetchMemos();
}}
disabled={loading || memosLoading}
className="px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 disabled:opacity-50 disabled:cursor-not-allowed transition-all flex items-center gap-2"
>
<RefreshCw size={16} className={(loading || memosLoading) ? 'animate-spin' : ''} />
Refresh
</button>
</div>
{/* Error Display */}
{(error || memosError) && (
<div className="mb-4 p-4 bg-red-50 border border-red-200 rounded-lg">
{error && <p className="text-red-700">Dashboard: {error}</p>}
{memosError && <p className="text-red-700">Memos: {memosError}</p>}
</div>
)}
{/* Loading State */}
{(loading || memosLoading) && (
<div className="mb-4 p-4 bg-blue-50 border border-blue-200 rounded-lg">
<p className="text-blue-700 flex items-center gap-2">
<RefreshCw size={16} className="animate-spin" />
Loading data...
</p>
</div>
)}
{/* Dashboard Iframe */}
<iframe
src={iframeUrl}
className="w-full border-none mb-6"
title="Hospital Dashboard"
key={iframeUrl}
style={{minHeight: '100vh'}}
/>
{/* Memos Table */}
<div className="p-4 border-b flex flex-col md:flex-row md:justify-between md:items-center gap-4">
<h2 className="text-xl font-semibold">Memos ({filteredMemos.length})</h2>
<div className="flex flex-col md:flex-row gap-2 w-full md:w-auto">
{/* Search Field */}
<input
type="text"
placeholder="Search complaints..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="px-3 py-2 border rounded-md text-sm w-full md:w-64"
/>
{/* Responder Dropdown */}
<select
value={selectedResponder}
onChange={(e) => setSelectedResponder(e.target.value)}
className="px-3 py-2 border rounded-md text-sm w-full md:w-48"
>
<option value="">All Responders</option>
{Array.from(new Set(memos.map(m => m.latest_snapshot.tagged_role_name)))
.filter(Boolean)
.map((responder) => (
<option key={responder} value={responder}>
{responder}
</option>
))}
</select>
{/* Block Dropdown */}
<select
value={selectedBlock}
onChange={(e) => setSelectedBlock(e.target.value)}
className="px-3 py-2 border rounded-md text-sm w-full md:w-48"
>
<option value="">All Blocks</option>
{Array.from(new Set(memos.map(m => m.latest_snapshot.current_block_name))).map((block) => (
<option key={block} value={block}>{block}</option>
))}
</select>
</div>
{/* Export Button */}
<button
onClick={exportAllMemos}
disabled={filteredMemos.length === 0}
className="px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 disabled:opacity-50 disabled:cursor-not-allowed transition-all flex items-center gap-2"
>
<Download size={16} />
Export All to Excel
</button>
</div>
{memos.length > 0 ? (
<div className="overflow-x-auto">
<table className="w-full">
<thead className="bg-gray-50">
<tr>
{/* <th className="px-4 py-3 text-left text-sm font-medium text-gray-900">Memo ID</th> */}
<th className="px-4 py-3 text-left text-sm font-medium text-gray-900">Created</th>
<th className="px-4 py-3 text-left text-sm font-medium text-gray-900">Complaint</th>
<th className="px-4 py-3 text-left text-sm font-medium text-gray-900">Department</th>
<th className="px-4 py-3 text-left text-sm font-medium text-gray-900">Status</th>
<th className="px-4 py-3 text-left text-sm font-medium text-gray-900">Location</th>
<th className="px-4 py-3 text-left text-sm font-medium text-gray-900">Tagged Role</th>
<th className="px-4 py-3 text-left text-sm font-medium text-gray-900">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-200">
{paginatedMemos.map((memo, index) => (
<tr key={memo.memoId} className={index % 2 === 0 ? 'bg-white' : 'bg-gray-50'}>
{/* <td className="px-4 py-3 text-sm text-gray-900 font-mono">
{memo.memoId.slice(0, 8)}...
</td> */}
<td className="px-4 py-3 text-sm text-gray-900">
{formatDisplayDate(memo.created_at)}
</td>
<td className="px-4 py-3 text-sm text-gray-900 max-w-xs truncate" title={memo.latest_snapshot.complaint}>
{memo.latest_snapshot.complaint}
</td>
<td className="px-4 py-3 text-sm text-gray-900">
{memo.latest_snapshot.department}
</td>
<td className="px-4 py-3 text-sm">
<span className={`px-2 py-1 rounded-full text-xs font-medium ${getStatusBadge(memo.latest_snapshot.status, memo.latest_snapshot.display_status)}`}>
{memo.latest_snapshot.display_status || memo.latest_snapshot.status}
</span>
</td>
<td className="px-4 py-3 text-sm text-gray-900">
<div className="text-xs">
<div>{memo.latest_snapshot.current_block_name}</div>
<div className="text-gray-500">{memo.latest_snapshot.current_ward_name}</div>
</div>
</td>
<td className="px-4 py-3 text-sm text-gray-900">
{memo.latest_snapshot.tagged_role_name}
</td>
<td className="px-4 py-3 text-sm">
<button
onClick={() => exportMemo(memo.memoId)}
className="px-3 py-1 bg-blue-600 text-white rounded hover:bg-blue-700 transition-all flex items-center gap-1 text-xs"
>
<FileSpreadsheet size={12} />
Export
</button>
</td>
</tr>
))}
</tbody>
</table>
<div className="flex justify-center items-center gap-2 my-4">
<button
onClick={() => setCurrentPage((prev) => Math.max(prev - 1, 1))}
disabled={currentPage === 1}
className="px-3 py-1 text-sm bg-gray-200 rounded disabled:opacity-50"
>
Previous
</button>
{Array.from({ length: totalPages }, (_, i) => i + 1)
.filter((page) => {
if (currentPage <= 4) {
return page <= 7;
}
return (
page >= Math.max(currentPage - 3, 1) &&
page <= Math.min(currentPage + 3, totalPages)
);
})
.map((page) => (
<button
key={page}
onClick={() => setCurrentPage(page)}
className={`px-3 py-1 text-sm rounded ${
page === currentPage ? 'bg-blue-600 text-white' : 'bg-gray-100'
}`}
>
{page}
</button>
))}
<button
onClick={() => setCurrentPage((prev) => Math.min(prev + 1, totalPages))}
disabled={currentPage === totalPages}
className="px-3 py-1 text-sm bg-gray-200 rounded disabled:opacity-50"
>
Next
</button>
</div>
</div>
) : (
<div className="p-8 text-center text-gray-500">
<FileSpreadsheet size={48} className="mx-auto mb-4 text-gray-300" />
<p>No memos found for the selected period</p>
</div>
)}
</div>
);
};
export default DashboardIframe;
Source