diff --git a/client/src/components/Widgets/CommCoorReport.jsx b/client/src/components/Widgets/CommCoorReport.jsx
new file mode 100644
index 0000000..f156d2f
--- /dev/null
+++ b/client/src/components/Widgets/CommCoorReport.jsx
@@ -0,0 +1,140 @@
+import {Flex, Button, Spinner, Text} from "@chakra-ui/react";
+import useBuildUrl from "../../hooks/useBuildUrl.js";
+import VdapApi from '../../hooks/useVdapApi.js';
+import { useRef, useEffect } from 'react';
+import {useDashboardStore} from "../../store/useDashboardStore.jsx";
+
+export default function CommCoorReport({ settings, widgetID }) {
+ const reportRef = useRef(null);
+
+ const updateSettings = useDashboardStore(state => state.updateWidgetSettings);
+
+ // build url, defaults to 7 days past
+ const url = useBuildUrl('/api/v2/comm_coor_report', settings?.startDate, settings?.endDate);
+ console.log(url);
+
+ // VdapApi hook only calls api if the url changes and apiData remains stable since vdapApi uses state to track the data
+ const {data: apiData, isLoading, error} = VdapApi(url);
+ console.log(apiData);
+
+ const handleDownloadReport = () => {
+ if (apiData.length === 0) return;
+ const element = document.createElement('a');
+ element.style.display = 'none';
+ // Appends HTML right inside the element, after its last child
+ element.insertAdjacentHTML('afterbegin', apiData?.report_html);
+
+ // 1. Create a blob object with text content and text/plain mime type
+ const blob = new Blob([element.textContent || ""], { type: 'text/plain' });
+
+ // 2. Generate a temporary URL pointing to that blob
+ const fileUrl = URL.createObjectURL(blob);
+
+ // 3. Create a hidden anchor element
+ const linkElement = document.createElement('a');
+ linkElement.href = fileUrl;
+ linkElement.download = `comm_coor_report_${apiData?.start_date}_${apiData?.end_date}`; // Sets the target file name
+ linkElement.style.display = 'none';
+
+ // 4. Append to body, trigger click, and remove it immediately
+ document.body.appendChild(linkElement);
+ linkElement.click();
+ document.body.removeChild(linkElement);
+ document.body.removeChild(element);
+
+ // 5. Free up memory by revoking the blob URL
+ URL.revokeObjectURL(fileUrl);
+ }
+
+ const handleCopyReport = () => {
+ if (apiData.length === 0) return;
+ const element = document.createElement('a');
+ element.style.display = 'none';
+ // Appends HTML right inside the element, after its last child
+ element.insertAdjacentHTML('afterbegin', apiData?.report_html);
+
+ navigator.clipboard.writeText(element.textContent || "")
+ .then(() => {
+ alert('Copied to clipboard!');
+ })
+ .catch((err => {
+ console.error('Failed to copy: ', err);
+ }));
+
+ document.body.removeChild(element);
+ }
+
+ useEffect(() => {
+ if (reportRef.current) {
+ reportRef.current.innerHTML = apiData?.report_html;
+ const p = reportRef.current.querySelectorAll('p');
+ const b = reportRef.current.querySelectorAll('b');
+ p[0].remove();
+ b[0].remove();
+ }
+ }, [apiData]);
+
+ // copy data to clipboard in formatted way when user clicks on copy btn in widget settings
+ useEffect(() => {
+ if (settings?.copyToClipboard === true && reportRef.current) {
+ navigator.clipboard.writeText(reportRef.current.innerText)
+ .then(() => {
+ alert('Copied to clipboard!');
+ })
+ .catch((err => {
+ console.error('Failed to copy: ', err);
+ }));
+ updateSettings(widgetID, {copyToClipboard: false});
+ }
+ }, [settings, widgetID, updateSettings]);
+
+ // download report
+ useEffect(() => {
+ if (settings?.downloadReport === true && reportRef.current) {
+ // 1. Create a blob object with text content and text/plain mime type
+ const blob = new Blob([reportRef.current.innerText || ""], { type: 'text/plain' });
+
+ // 2. Generate a temporary URL pointing to that blob
+ const fileUrl = URL.createObjectURL(blob);
+
+ // 3. Create a hidden anchor element
+ const linkElement = document.createElement('a');
+ linkElement.href = fileUrl;
+ linkElement.download = `comm_coor_report_${apiData?.start_date}_${apiData?.end_date}`; // Sets the target file name
+ linkElement.style.display = 'none';
+
+ // 4. Append to body, trigger click, and remove it immediately
+ document.body.appendChild(linkElement);
+ linkElement.click();
+ document.body.removeChild(linkElement);
+
+ // 5. Free up memory by revoking the blob URL
+ URL.revokeObjectURL(fileUrl);
+ updateSettings(widgetID, {downloadReport: false});
+ }
+ }, [settings, widgetID, updateSettings, apiData]);
+
+ if (isLoading) return (
+
+
+
+ );
+
+ if (error) return (
+
+ {error}
+
+ );
+
+ if (!apiData) return (
+
+ No data
+
+ );
+
+ return (
+
+
+
+ )
+}
\ No newline at end of file
diff --git a/client/src/components/Widgets/settings/CommCoorReportSettings.jsx b/client/src/components/Widgets/settings/CommCoorReportSettings.jsx
new file mode 100644
index 0000000..3edf232
--- /dev/null
+++ b/client/src/components/Widgets/settings/CommCoorReportSettings.jsx
@@ -0,0 +1,83 @@
+import {Button, Field, Flex, Input, Switch, Text} from "@chakra-ui/react";
+import {useDashboardStore} from "@/store/useDashboardStore.jsx";
+import {useState} from "react";
+
+export default function CommCoorReportSettings({ widgetId, currentSettings }) {
+ const updateSettings = useDashboardStore(state => state.updateWidgetSettings);
+ const [startError, setStartError] = useState(null);
+ const [endError, setEndError] = useState(null);
+
+ const handleCopyToClipboard = () => {
+ updateSettings(widgetId, {copyToClipboard: true});
+ }
+
+ const handleDownloadReport = () => {
+ updateSettings(widgetId, {downloadReport: true});
+ }
+
+ return (
+
+ {/* COPY TO CLIPBOARD */}
+
+
+ {/* DOWNLOAD REPORT */}
+
+
+ {/* SELECTING DATE RANGE */}
+
+
+
+
+ Start Date
+
+
+ {
+ const value = e.target.value;
+ updateSettings(widgetId, {...currentSettings, startDate: value});
+
+ const dateSelected = new Date(value);
+ const today = new Date();
+ today.setHours(0, 0, 0, 0);
+
+ if (value && (dateSelected > today)) setStartError("Can't Choose a Future Date");
+ else setStartError("");
+ }}
+ />
+ {startError && {startError}}
+
+
+
+
+
+
+ End Date
+
+
+ {
+ const value = e.target.value;
+ updateSettings(widgetId, {...currentSettings, endDate: value});
+
+ const dateSelected = new Date(value);
+ const today = new Date();
+ today.setHours(0, 0, 0, 0);
+
+ if (value && (dateSelected > today)) setEndError("Can't Choose a Future Date");
+ else setEndError("");
+ }}
+ />
+ {endError && {endError}}
+
+
+
+
+
+ )
+}
\ No newline at end of file
diff --git a/client/src/constants/widgetRegistry.jsx b/client/src/constants/widgetRegistry.jsx
index dd6fbfe..2118963 100644
--- a/client/src/constants/widgetRegistry.jsx
+++ b/client/src/constants/widgetRegistry.jsx
@@ -159,7 +159,24 @@ export const WIDGET_REGISTRY = {
description: "Percentage of TP, TN, FP, and FN changes in viable alert levels.",
component: lazy(() => import('../components/Widgets/LlmAlChangesQuadPlot.jsx')),
allowedSizes: {
- small: { w: 3, h: 12 }
+ medium: { w: 3, h: 12 }
+ }
+ },
+
+ "comm_coor_report": {
+ name: "Communication Coordinator Report",
+ category: "Daily Volcanic Activity",
+ description: "Generated Report",
+ component: lazy(() => import('../components/Widgets/CommCoorReport.jsx')),
+ settingsComponent: lazy(() => import('../components/Widgets/settings/CommCoorReportSettings.jsx')),
+ defaultSettings: {
+ copyToClipboard: false,
+ downloadReport: false,
+ startDate: getStartDate(),
+ endDate: getEndDate(),
+ },
+ allowedSizes: {
+ medium: { w: 3, h: 12 },
}
}
diff --git a/client/src/hooks/useVdapApi.js b/client/src/hooks/useVdapApi.js
index ed369db..0f780b1 100644
--- a/client/src/hooks/useVdapApi.js
+++ b/client/src/hooks/useVdapApi.js
@@ -26,7 +26,7 @@ export default function useWidgetApi(endpoint) {
}
const json = await response.json();
- const responseData = Array.isArray(json) ? json : json.data;
+ const responseData = Array.isArray(json) ? json : (json?.data ?? json);
setData(responseData || []);
} catch (err) {