diff --git a/src/components/Metrics/Metrics.jsx b/src/components/Metrics/Metrics.jsx
index 9a5c8ff5..d1720c54 100644
--- a/src/components/Metrics/Metrics.jsx
+++ b/src/components/Metrics/Metrics.jsx
@@ -1,7 +1,5 @@
"use client";
-import { useContext } from 'react';
-
import {
Carousel,
CarouselContent,
diff --git a/src/components/SlackShare/SlackShareModal.jsx b/src/components/SlackShare/SlackShareModal.jsx
new file mode 100644
index 00000000..0d8ee430
--- /dev/null
+++ b/src/components/SlackShare/SlackShareModal.jsx
@@ -0,0 +1,194 @@
+"use client";
+
+import { useState, useEffect } from 'react';
+import { X, MessageSquare, Copy, Share2 } from 'lucide-react';
+import Image from 'next/image';
+
+export const SlackShareModal = ({
+ isOpen,
+ onClose,
+ dashboardInfo,
+ onSend,
+ shareableUrl
+}) => {
+ const [slackMessage, setSlackMessage] = useState('');
+ const [selectedUser, setSelectedUser] = useState('');
+
+ // Available users for Slack sharing
+ const slackUsers = [
+ { id: 'mike', name: 'Mike Chen', email: 'mchen@demo.com', role: 'Procurement Team' },
+ { id: 'sarah', name: 'Sarah Johnson', email: 'sjohnson@demo.com', role: 'Safety Team' },
+ { id: 'lisa', name: 'Lisa Rodriguez', email: 'lrodriguez@demo.com', role: 'Management' },
+ { id: 'david', name: 'David Kim', email: 'dkim@demo.com', role: 'Safety Team' },
+ { id: 'jennifer', name: 'Jennifer Martinez', email: 'jmartinez@demo.com', role: 'Procurement Team' },
+ { id: 'robert', name: 'Robert Wilson', email: 'rwilson@demo.com', role: 'Safety Team' }
+ ];
+
+ // Generate default message when dashboard info changes
+ useEffect(() => {
+ if (dashboardInfo && isOpen) {
+ const urlToUse = shareableUrl || window.location.href;
+ const defaultMessage = `📊 **${dashboardInfo.title}**\n\n${dashboardInfo.description}\n\n🔗 Dashboard URL: ${urlToUse}\n\nView the full dashboard for detailed insights and analytics.`;
+ setSlackMessage(defaultMessage);
+ setSelectedUser('');
+ }
+ }, [dashboardInfo, isOpen, shareableUrl]);
+
+ const handleSend = () => {
+ if (slackMessage.trim() && selectedUser) {
+ const selectedUserData = slackUsers.find(user => user.id === selectedUser);
+ onSend({
+ message: slackMessage,
+ user: selectedUserData,
+ dashboard: dashboardInfo
+ });
+ onClose();
+ setSlackMessage('');
+ setSelectedUser('');
+ } else if (!selectedUser) {
+ // Just return without showing popup
+ return;
+ }
+ };
+
+ const handleCopyUrl = () => {
+ const urlToUse = shareableUrl || window.location.href;
+ navigator.clipboard.writeText(urlToUse).then(() => {
+ // URL copied silently
+ }).catch(() => {
+ // Fallback for older browsers
+ const textArea = document.createElement('textarea');
+ textArea.value = urlToUse;
+ document.body.appendChild(textArea);
+ textArea.select();
+ document.execCommand('copy');
+ document.body.removeChild(textArea);
+ // URL copied silently
+ });
+ };
+
+ if (!isOpen) return null;
+
+ return (
+
+
e.stopPropagation()}>
+
+
+
+ Share Dashboard via Slack
+
+
+
+
+
+
+
+ {/* Dashboard Info */}
+
+
+
+
{dashboardInfo?.title}
+
{dashboardInfo?.description}
+
+
+
+ Copy URL
+
+
+
+ {shareableUrl || (typeof window !== 'undefined' ? window.location.href : '')}
+
+
+
+ {/* User Selection */}
+
+
+ Send to:
+
+
setSelectedUser(e.target.value)}
+ className="w-full p-3 bg-slate-700 border border-slate-600 rounded-lg text-white focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
+ >
+ Select a user...
+ {slackUsers.map((user) => (
+
+ {user.name} - {user.role} ({user.email})
+
+ ))}
+
+ {selectedUser && (
+
+
+
+
+ Selected: {slackUsers.find(user => user.id === selectedUser)?.name}
+
+
+
+ )}
+
+
+ {/* Message Input */}
+
+
+ Message (Editable):
+
+
+
+ {/* Action Buttons */}
+
+
+ Cancel
+
+
+
+ Send to Slack
+
+
+
+
+
+ );
+};
+
+export const SlackShareButton = ({ dashboardInfo, onShare }) => {
+ return (
+
onShare(dashboardInfo)}
+ className="flex items-center gap-2 px-4 py-2 bg-slate-700 hover:bg-slate-600 text-white border border-slate-600 rounded-lg transition-colors"
+ >
+
+ Share
+
+ );
+};
diff --git a/src/components/SlackShare/index.js b/src/components/SlackShare/index.js
new file mode 100644
index 00000000..2c25e91c
--- /dev/null
+++ b/src/components/SlackShare/index.js
@@ -0,0 +1 @@
+export { SlackShareModal, SlackShareButton } from './SlackShareModal';
diff --git a/src/components/TableauEmbed/TableauAuth.jsx b/src/components/TableauEmbed/TableauAuth.jsx
index 321ef068..431157c1 100644
--- a/src/components/TableauEmbed/TableauAuth.jsx
+++ b/src/components/TableauEmbed/TableauAuth.jsx
@@ -17,11 +17,14 @@ export const TableauAuth = forwardRef(function AuthLayer(props, ref) {
isPublic,
WebEdit,
customToolbar,
- layouts
+ id
} = props;
let embed_token;
+ // Check if this is a public.tableau.com URL that doesn't need authentication
+ const isPublicTableauUrl = src && src.includes('public.tableau.com');
+
// tanstack query hook to safely represent users on the client
const {
status: sessionStatus,
@@ -39,9 +42,30 @@ export const TableauAuth = forwardRef(function AuthLayer(props, ref) {
if (isSessionSuccess) {
embed_token = user.embed_token;
}
- console.log("token", embed_token)
- console.log('Link to decode JWT: https://jwt.io/#debugger-io?token=' + embed_token);
+ // Check if Mike is logged in
+ const isMikeLoggedIn = isSessionSuccess && user?.email === 'mchen@demo.com';
+
+ // For public URLs, render immediately without authentication
+ if (isPublicTableauUrl) {
+ return (
+
+ );
+ }
return (
@@ -56,8 +80,10 @@ export const TableauAuth = forwardRef(function AuthLayer(props, ref) {
hide-tabs={hideTabs ? true : false}
toolbar={toolbar}
isPublic={isPublic}
- customToolbar={customToolbar}
- layouts={layouts}
+ customToolbar={customToolbar && !isMikeLoggedIn}
+ height={height}
+ width={width}
+ id={id}
/> :
)
diff --git a/src/components/TableauEmbed/TableauToolbar/TableauToolbar.jsx b/src/components/TableauEmbed/TableauToolbar/TableauToolbar.jsx
index 29e1568a..ce7e2039 100644
--- a/src/components/TableauEmbed/TableauToolbar/TableauToolbar.jsx
+++ b/src/components/TableauEmbed/TableauToolbar/TableauToolbar.jsx
@@ -83,21 +83,21 @@ export const TableauToolbar = forwardRef(function TableauToolbar(props, ref) {
}, [ref]);
return (
-
+
-
- File
+
+ File
-
- window.open(src, '_blank')} >
- Open ⌘O
+
+ window.open(src, '_blank')} >
+ Open ⌘O
-
+
- Edit ⌘E
+ Edit ⌘E
-
+
- New ⌘N
+ New ⌘N
-
- View
+
+ View
-
- await undo(viz) }>
- Undo ⌘Z
+
+ await undo(viz) }>
+ Undo ⌘Z
- await redo(viz) }>
- Redo ⇧⌘Z
+ await redo(viz) }>
+ Redo ⇧⌘Z
- await revert(viz) }>
- Reset ⌘R
+ await revert(viz) }>
+ Reset ⌘R
- await refreshData(viz) }>
- Refresh ⇧⌘R
+ await refreshData(viz) }>
+ Refresh ⇧⌘R
-
- Export
+
+ Export
-
- await exportImage(viz) }>
- Image ⇧⌘I
+
+ await exportImage(viz) }>
+ Image ⇧⌘I
- await exportData(viz) }>
- Data ⇧⌘D
+ await exportData(viz) }>
+ Data ⇧⌘D
- await exportCrossTab(viz) }>
- Crosstab ⇧⌘C
+ await exportCrossTab(viz) }>
+ Crosstab ⇧⌘C
- await exportPDF(viz) }>
- PDF ⇧⌘P
+ await exportPDF(viz) }>
+ PDF ⇧⌘P
- await exportPPT(viz) }>
- Powerpoint ⇧⌘X
+ await exportPPT(viz) }>
+ Powerpoint ⇧⌘X
- await exportTWBX(viz) }>
- Workbook ⇧⌘W
+ await exportTWBX(viz) }>
+ Workbook ⇧⌘W
- await shareViz(viz) }>
- Share ⇧⌘U
+ await shareViz(viz) }>
+ Share ⇧⌘U
diff --git a/src/components/TableauEmbed/TableauViz.jsx b/src/components/TableauEmbed/TableauViz.jsx
index df32b419..d2c087cf 100644
--- a/src/components/TableauEmbed/TableauViz.jsx
+++ b/src/components/TableauEmbed/TableauViz.jsx
@@ -4,8 +4,7 @@ import { useEffect, useState, useRef, forwardRef, useId } from 'react';
// eslint-disable-next-line no-unused-vars
import { tab_embed } from 'libs';
-import { TableauToolbar, XSLayout, SMLayout, MDLayout, LGLayout, XLLayout, XL2Layout } from 'components';
-import { getLayoutProps, parseClassNameForLayouts } from './vizUtils';
+import { TableauToolbar } from 'components';
// handles post authentication logic requiring an initialized object to operate
@@ -18,10 +17,14 @@ export const TableauViz = forwardRef(function Viz(props, ref) {
toolbar,
isPublic,
customToolbar,
- layouts
+ layouts,
+ height,
+ width,
+ id: customId
} = props;
// creates a unique identifier for the embed
- const id = `id-${useId()}`;
+ const generatedId = `id-${useId()}`;
+ const id = customId || generatedId;
// to be used if parent did not forward a ref
const localRef = useRef(null);
// Use the forwarded ref if provided, otherwise use the local ref
@@ -31,6 +34,16 @@ export const TableauViz = forwardRef(function Viz(props, ref) {
// the target of most viz interactions
const [activeSheet, setActiveSheet] = useState(null);
+ useEffect(() => {
+ // Load the Tableau embedding library if not already loaded
+ if (typeof window !== 'undefined' && !window.tableau) {
+ const script = document.createElement('script');
+ script.type = 'module';
+ script.src = 'https://public.tableau.com/javascripts/api/tableau.embedding.3.latest.min.js';
+ document.head.appendChild(script);
+ }
+ }, []);
+
useEffect(() => {
if (innerRef.current) {
const viz = innerRef.current;
@@ -53,111 +66,22 @@ export const TableauViz = forwardRef(function Viz(props, ref) {
}
}, [interactive, innerRef, setActiveSheet])
- const layoutSpec = parseClassNameForLayouts(className, layouts);
-
return (
-
- {customToolbar ? : null}
-
-
-
-
- {customToolbar ? : null}
-
-
-
-
- {customToolbar ? : null}
-
-
-
-
- {customToolbar ? : null}
-
-
-
-
- {customToolbar ? : null}
-
-
-
-
- {customToolbar ? : null}
-
-
+ {customToolbar ?
: null}
+
)
})
diff --git a/src/components/TableauNavigation/DynamicDashboardViewer.jsx b/src/components/TableauNavigation/DynamicDashboardViewer.jsx
new file mode 100644
index 00000000..b3676a51
--- /dev/null
+++ b/src/components/TableauNavigation/DynamicDashboardViewer.jsx
@@ -0,0 +1,229 @@
+"use client";
+import { useState, useEffect } from 'react';
+import { TableauEmbed } from '../TableauEmbed';
+import { SlackShareModal, SlackShareButton } from '../SlackShare';
+import {
+ AlertCircle,
+ Loader2,
+ ExternalLink,
+ Maximize2,
+ Download,
+ Share2,
+ Copy,
+ Check
+} from 'lucide-react';
+import {
+ Dialog,
+ DialogContent,
+ DialogHeader,
+ DialogTitle,
+ DialogDescription,
+ DialogFooter
+} from '../ui/Dialog';
+import { Button } from '../ui/Button';
+import { Input } from '../ui/Input';
+
+export const DynamicDashboardViewer = ({ selectedDashboard, embedToken, siteId }) => {
+ const [isLoading, setIsLoading] = useState(false);
+ const [error, setError] = useState(null);
+ const [shareModalOpen, setShareModalOpen] = useState(false);
+ const [copied, setCopied] = useState(false);
+ const [showSlackModal, setShowSlackModal] = useState(false);
+ const [currentDashboard, setCurrentDashboard] = useState(null);
+
+ // Create a shareable URL with dashboard information
+ const getShareableUrl = () => {
+ if (!selectedDashboard) return window.location.href;
+
+ // Create a URL with dashboard information as query parameters
+ const url = new URL(window.location.href);
+ url.searchParams.set('dashboardId', selectedDashboard.id);
+ url.searchParams.set('dashboardName', selectedDashboard.name);
+ url.searchParams.set('workbookName', selectedDashboard.workbookName);
+ url.searchParams.set('contentUrl', encodeURIComponent(selectedDashboard.contentUrl));
+
+ return url.toString();
+ };
+
+ const copyToClipboard = (text) => {
+ navigator.clipboard.writeText(text).then(() => {
+ setCopied(true);
+ setTimeout(() => setCopied(false), 2000);
+ });
+ };
+
+ const handleSlackShare = (dashboardInfo) => {
+ setCurrentDashboard(dashboardInfo);
+ setShowSlackModal(true);
+ };
+
+ const handleSlackSend = ({ message, user, dashboard }) => {
+ // In a real implementation, this would send to Slack API
+ // Slack message sent silently
+ };
+
+ useEffect(() => {
+ if (selectedDashboard) {
+ setIsLoading(true);
+ setError(null);
+ // Simulate loading time
+ const timer = setTimeout(() => {
+ setIsLoading(false);
+ }, 1000);
+ return () => clearTimeout(timer);
+ }
+ }, [selectedDashboard]);
+
+ if (!selectedDashboard) {
+ return (
+
+
+
+
+
+
Select a Dashboard
+
+ Choose a dashboard from the navigation panel to view it here
+
+
+
+ );
+ }
+
+ if (isLoading) {
+ return (
+
+
+
+
Loading Dashboard
+
{selectedDashboard.name}
+
+
+ );
+ }
+
+ if (error) {
+ return (
+
+
+
+
Error Loading Dashboard
+
{error}
+
+
+ );
+ }
+ // Handle the contentUrl properly
+ // If it's already a full URL, use it directly, otherwise construct it
+ const tableauUrl = selectedDashboard.contentUrl?.startsWith('http')
+ ? selectedDashboard.contentUrl
+ : `https://prod-useast-b.online.tableau.com/t/embeddingplaybook/views/${selectedDashboard.contentUrl?.replace('/sheets/', '/') || ''}`;
+
+ return (
+
+ {/* Dashboard Header */}
+
+
+
+
{selectedDashboard.name}
+
+ {selectedDashboard.workbookName} • {selectedDashboard.projectName || 'Demo'}
+
+
+
+
+
+
+ {/* Dashboard Content */}
+
+
+ {/* Share Modal */}
+
+
+
+ Share Dashboard
+
+ Share a direct link to this dashboard with others.
+
+
+
+
+
+ copyToClipboard(getShareableUrl())}
+ variant="secondary"
+ className="bg-blue-600 hover:bg-blue-700 text-white"
+ >
+ {copied ? (
+
+ ) : (
+
+ )}
+ {copied ? 'Copied' : 'Copy'}
+
+
+
+
+ setShareModalOpen(false)}
+ variant="outline"
+ className="border-slate-600 text-slate-300 hover:bg-slate-700"
+ >
+ Close
+
+
+
+
+
+ {/* Slack Share Modal */}
+
setShowSlackModal(false)}
+ dashboardInfo={currentDashboard}
+ onSend={handleSlackSend}
+ shareableUrl={getShareableUrl()}
+ />
+
+ );
+};
diff --git a/src/components/TableauNavigation/TableauNavigation.jsx b/src/components/TableauNavigation/TableauNavigation.jsx
new file mode 100644
index 00000000..63b74f4a
--- /dev/null
+++ b/src/components/TableauNavigation/TableauNavigation.jsx
@@ -0,0 +1,331 @@
+"use client";
+import { useState, useRef, useEffect, useCallback, memo } from 'react';
+import {
+ Folder,
+ FileText,
+ ChevronRight,
+ ChevronDown,
+ Search,
+ Grid,
+ List,
+} from 'lucide-react';
+import { useTableauSession } from '../../hooks';
+import { useTableauDashboards } from '../../hooks/tableauHooks';
+
+const TableauNavigationComponent = ({ onDashboardSelect, selectedDashboard }) => {
+ const {
+ status: sessionStatus,
+ data: user,
+ error: sessionError,
+ isSuccess: isSessionSuccess,
+ isError: isSessionError,
+ isLoading: isSessionLoading
+ } = useTableauSession();
+
+ // Get Tableau session data
+ const restToken = user?.rest_key;
+ const embedToken = user?.embed_token;
+ const siteId = user?.site_id;
+ const isAuthenticated = isSessionSuccess && embedToken && siteId;
+
+ const {
+ dashboardsByFolder,
+ allViews,
+ isLoading,
+ restApiData,
+ restApiLoading,
+ restApiError
+ } = useTableauDashboards();
+
+ const [searchTerm, setSearchTerm] = useState('');
+ const [expandedFolders, setExpandedFolders] = useState({});
+ const [viewMode, setViewMode] = useState('grid'); // 'grid' or 'list'
+ const [isSearchFocused, setIsSearchFocused] = useState(false);
+ const searchInputRef = useRef(null);
+ const preventBlurRef = useRef(false);
+
+ // Memoized event handlers to prevent re-renders
+ const handleInputFocus = useCallback((e) => {
+ console.log('INPUT FOCUSED');
+ e.stopPropagation();
+ setIsSearchFocused(true);
+ }, []);
+
+ const handleInputBlur = useCallback((e) => {
+ console.log('INPUT BLURRED');
+ if (preventBlurRef.current) {
+ e.preventDefault();
+ e.stopPropagation();
+ return;
+ }
+ setIsSearchFocused(false);
+ }, []);
+
+ const handleInputClick = useCallback((e) => {
+ console.log('INPUT CLICKED');
+ e.stopPropagation();
+ preventBlurRef.current = true;
+ setIsSearchFocused(true);
+ }, []);
+
+ const handleInputMouseDown = useCallback((e) => {
+ e.stopPropagation();
+ // Don't prevent default - allow focus
+ }, []);
+
+ // Maintain focus when search is focused
+ useEffect(() => {
+ if (isSearchFocused && searchInputRef.current) {
+ // Use a timeout to ensure focus happens after any re-renders
+ const timeoutId = setTimeout(() => {
+ if (searchInputRef.current) {
+ searchInputRef.current.focus();
+ }
+ }, 0);
+ return () => clearTimeout(timeoutId);
+ }
+ }, [isSearchFocused]);
+
+ // Use real Tableau data
+ const dashboards = dashboardsByFolder;
+ const views = allViews;
+
+ // Debug logging removed to prevent re-renders
+
+ const toggleFolder = (folderName) => {
+ setExpandedFolders(prev => ({
+ ...prev,
+ [folderName]: !prev[folderName]
+ }));
+ };
+
+ const filteredDashboards = Object.entries(dashboards).filter(([folderName, workbooks]) => {
+ if (!searchTerm) return true;
+ return workbooks.some(workbook =>
+ workbook.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
+ workbook.views.some(view =>
+ view.name.toLowerCase().includes(searchTerm.toLowerCase())
+ )
+ );
+ });
+
+ const filteredViews = views.filter(view =>
+ view.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
+ view.workbookName.toLowerCase().includes(searchTerm.toLowerCase())
+ );
+
+ // Show loading state while session is being established or tokens are loading
+ if (isSessionLoading || (isSessionSuccess && !restToken)) {
+ return (
+
+
+
+
Loading Dashboards...
+
Preparing your Tableau content
+
+
+ );
+ }
+
+ // Show error state if there's a session error
+ if (isSessionError) {
+ return (
+
+
+
Unable to Load Dashboards
+
There was an error connecting to Tableau Cloud
+
+ Please refresh the page or contact support if the issue persists
+
+
+
+ );
+ }
+
+ // If not authenticated, show a simple message (user should be authenticated via SSO)
+ if (!isAuthenticated) {
+ return (
+
+
+
No Dashboards Available
+
No Tableau dashboards found for your account
+
+ Contact your administrator if you believe this is an error
+
+
+
+ );
+ }
+
+ return (
+
+ {/* Header */}
+
+
+
+
My Dashboards
+
{user?.name || user?.email}
+
+
+
+ {/* Search */}
+
e.preventDefault()} onClick={(e) => e.stopPropagation()}>
+
+ setSearchTerm(e.target.value)}
+ onFocus={handleInputFocus}
+ onBlur={handleInputBlur}
+ onClick={handleInputClick}
+ onMouseDown={handleInputMouseDown}
+ className="w-full pl-10 pr-4 py-2 bg-slate-700 border border-slate-600 rounded-lg text-white placeholder-slate-400 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
+ />
+
+
+ {/* View Mode Toggle */}
+
+ setViewMode('grid')}
+ className={`p-1 rounded ${viewMode === 'grid' ? 'bg-blue-600 text-white' : 'text-slate-400 hover:text-white'}`}
+ >
+
+
+ setViewMode('list')}
+ className={`p-1 rounded ${viewMode === 'list' ? 'bg-blue-600 text-white' : 'text-slate-400 hover:text-white'}`}
+ >
+
+
+
+
+
+ {/* Content */}
+
+ {isLoading ? (
+
+
Loading dashboards...
+
+ ) : searchTerm ? (
+ // Search Results from REST API data
+
+ {restApiData?.workbooks ? (
+ <>
+
+ {restApiData.workbooks.filter(dashboard =>
+ dashboard.dashboard_name.toLowerCase().includes(searchTerm.toLowerCase()) ||
+ dashboard.workbook_name.toLowerCase().includes(searchTerm.toLowerCase())
+ ).length} result{restApiData.workbooks.filter(dashboard =>
+ dashboard.dashboard_name.toLowerCase().includes(searchTerm.toLowerCase()) ||
+ dashboard.workbook_name.toLowerCase().includes(searchTerm.toLowerCase())
+ ).length !== 1 ? 's' : ''}
+
+
+ {restApiData.workbooks
+ .filter(dashboard =>
+ dashboard.dashboard_name.toLowerCase().includes(searchTerm.toLowerCase()) ||
+ dashboard.workbook_name.toLowerCase().includes(searchTerm.toLowerCase())
+ )
+ .map((dashboard) => (
+
onDashboardSelect({
+ id: dashboard.dashboard_id,
+ name: dashboard.dashboard_name,
+ workbookName: dashboard.workbook_name,
+ contentUrl: dashboard.content_url
+ })}
+ className={`p-3 rounded-lg cursor-pointer transition-colors mb-1 ${
+ selectedDashboard?.id === dashboard.dashboard_id
+ ? 'bg-blue-600 text-white'
+ : 'hover:bg-slate-700 text-slate-300'
+ }`}
+ >
+
+
+
+
{dashboard.dashboard_name}
+
{dashboard.workbook_name}
+
+
+
+ ))}
+
+ >
+ ) : (
+
+
No search results available
+
+ )}
+
+ ) : (
+ // Folder View
+
+ {/* Show REST API dashboards as the primary content */}
+ {restApiData?.workbooks && restApiData.workbooks.length > 0 ? (
+
+
+ Available Dashboards ({restApiData.workbooks.filter(dashboard =>
+ dashboard.dashboard_name.toLowerCase().includes(searchTerm.toLowerCase()) ||
+ dashboard.workbook_name.toLowerCase().includes(searchTerm.toLowerCase())
+ ).length})
+
+
+ {restApiData.workbooks
+ .filter(dashboard =>
+ dashboard.dashboard_name.toLowerCase().includes(searchTerm.toLowerCase()) ||
+ dashboard.workbook_name.toLowerCase().includes(searchTerm.toLowerCase())
+ )
+ .map((dashboard) => (
+
onDashboardSelect({
+ id: dashboard.dashboard_id,
+ name: dashboard.dashboard_name,
+ workbookName: dashboard.workbook_name,
+ contentUrl: dashboard.content_url
+ })}
+ className={`p-3 rounded-lg transition-colors mb-1 cursor-pointer ${
+ selectedDashboard?.id === dashboard.dashboard_id
+ ? 'bg-blue-600 text-white'
+ : 'hover:bg-slate-700 text-slate-300'
+ }`}
+ >
+
+
+
+
{dashboard.dashboard_name}
+
{dashboard.workbook_name}
+
+
+
+ ))}
+
+
+ ) : (
+
+ {restApiLoading ? (
+
+
Loading dashboards...
+
+ ) : restApiError ? (
+
+
Error loading dashboards:
+
{restApiError}
+
+ ) : (
+
No dashboards available
+ )}
+
+ )}
+
+ )}
+
+
+ );
+};
+
+export const TableauNavigation = memo(TableauNavigationComponent);
diff --git a/src/components/TableauNavigation/index.js b/src/components/TableauNavigation/index.js
new file mode 100644
index 00000000..3f14d707
--- /dev/null
+++ b/src/components/TableauNavigation/index.js
@@ -0,0 +1 @@
+export { TableauNavigation } from './TableauNavigation';
diff --git a/src/components/index.js b/src/components/index.js
index 23132107..670f1f16 100644
--- a/src/components/index.js
+++ b/src/components/index.js
@@ -51,3 +51,10 @@ export { SessionProvider, VercelAgentRuntimeProvider, LanggraphAgentRuntimeProvi
export { FloatingAssistant, Thread, Agent } from './Agent';
export { AuthGuard } from './AuthGuard';
+
+export { TableauNavigation } from './TableauNavigation';
+export { DynamicDashboardViewer } from './TableauNavigation/DynamicDashboardViewer';
+
+export { LanguageSelector } from './LanguageSelector';
+
+export { SlackShareModal, SlackShareButton } from './SlackShare';
diff --git a/src/components/ui/Breadcrumb.jsx b/src/components/ui/Breadcrumb.jsx
index 8cf0c74d..c441ba25 100644
--- a/src/components/ui/Breadcrumb.jsx
+++ b/src/components/ui/Breadcrumb.jsx
@@ -36,7 +36,7 @@ const BreadcrumbLink = React.forwardRef(({ asChild, className, ...props }, ref)
( )
diff --git a/src/contexts/LanguageContext.jsx b/src/contexts/LanguageContext.jsx
new file mode 100644
index 00000000..d0dbff0a
--- /dev/null
+++ b/src/contexts/LanguageContext.jsx
@@ -0,0 +1,297 @@
+"use client";
+
+import { createContext, useContext, useState, useEffect } from 'react';
+
+const LanguageContext = createContext();
+
+// Default translations for use outside LanguageProvider
+const defaultTranslations = {
+ title: "Demo",
+ subtitle: "",
+ metrics: {}
+};
+
+export const useLanguage = () => {
+ const context = useContext(LanguageContext);
+ // Return default values if used outside LanguageProvider (graceful fallback)
+ if (!context) {
+ return {
+ language: 'en',
+ setLanguage: () => {},
+ showLanguageDropdown: false,
+ setShowLanguageDropdown: () => {},
+ t: defaultTranslations,
+ translations: { en: defaultTranslations }
+ };
+ }
+ return context;
+};
+
+export const LanguageProvider = ({ children }) => {
+ const [language, setLanguage] = useState('en');
+ const [showLanguageDropdown, setShowLanguageDropdown] = useState(false);
+
+ // Language translations
+ const translations = {
+ en: {
+ title: "Demo Contractor Risk Management",
+ subtitle: "Comprehensive safety and compliance tracking dashboard with real-time alerts and self-service analytics",
+ executiveSummary: "Executive Summary",
+ executiveSummaryDesc: "Real-time compliance tracking across all contractors and safety metrics",
+ complianceCenter: "Compliance Center",
+ complianceCenterDesc: "Contractor Compliance and Certification Status Overview",
+ insuranceStatus: "Insurance Status",
+ all: "All",
+ active: "Active",
+ expired: "Expired",
+ pending: "Pending",
+ sendExpirationNotice: "Send 'Insurance Expired' Email ",
+ filterByInsuranceStatus: "Filter by Insurance Status",
+ showAllInsuranceStatuses: "Show all insurance statuses",
+ currentlyActiveInsurance: "Currently active insurance",
+ expiredInsurancePolicies: "Expired insurance policies",
+ pendingInsuranceVerification: "Pending insurance verification",
+ insuranceExpirationNotices: "Insurance Expiration Notices",
+ previous: "Previous",
+ next: "Next",
+ email: "Email",
+ of: "of",
+ to: "To",
+ subject: "Subject",
+ message: "Message",
+ close: "Close",
+ sendThisEmail: "Send This Email",
+ sendAll: "Send All",
+ demoMode: "Demo Mode",
+ noActualEmails: "No actual emails will be sent. You have",
+ notificationsReady: "notification(s) ready to send.",
+ urgentInsuranceStatusExpired: "URGENT: Insurance Status Expired - Action Required",
+ dearContractor: "Dear",
+ urgentNotification: "This is an urgent notification regarding your insurance status.",
+ recordsIndicate: "Our records indicate that your insurance coverage has EXPIRED. This requires immediate attention to maintain compliance with our contractor requirements.",
+ selectedRecordDetails: "Selected Record Details",
+ pleaseTakeActions: "Please take the following actions immediately:",
+ reviewPolicy: "1. Review your current insurance policy",
+ renewCoverage: "2. Renew or update your insurance coverage",
+ submitDocumentation: "3. Submit updated documentation to our compliance team",
+ failureWarning: "Failure to address this issue may result in suspension of contractor privileges.",
+ questionsContact: "If you have any questions or need assistance, please contact our compliance department.",
+ bestRegards: "Best regards,",
+ complianceTeam: "Demo Compliance Team",
+ demoEmailGenerated: "This is a demo email generated from Tableau mark selection.",
+ // Metrics translations
+ metrics: {
+ // Common metric names that might come from Tableau
+ "Total Contractors": "Total Contractors",
+ "Active Projects": "Active Projects",
+ "Safety Incidents": "Safety Incidents",
+ "Compliance Rate": "Compliance Rate",
+ "Insurance Expired": "Insurance Expired",
+ "Training Completed": "Training Completed",
+ "Risk Score": "Risk Score",
+ "Certifications": "Certifications",
+ "Violations": "Violations",
+ "Inspections": "Inspections",
+ "Renewals": "Renewals",
+ "Approvals": "Approvals",
+ "Rejections": "Rejections",
+ "Pending": "Pending",
+ "Overdue": "Overdue",
+ "Completed": "Completed",
+ "In Progress": "In Progress",
+ "Not Started": "Not Started",
+ "High Risk": "High Risk",
+ "Medium Risk": "Medium Risk",
+ "Low Risk": "Low Risk",
+ "Critical": "Critical",
+ "Warning": "Warning",
+ "Info": "Info",
+ "Success": "Success",
+ "Error": "Error"
+ }
+ },
+ es: {
+ title: "Gestión de Riesgos de Contratistas Demo",
+ subtitle: "Panel de seguimiento integral de seguridad y cumplimiento con alertas en tiempo real y análisis de autoservicio",
+ executiveSummary: "Resumen Ejecutivo",
+ executiveSummaryDesc: "Seguimiento de cumplimiento en tiempo real en todos los contratistas y métricas de seguridad",
+ complianceCenter: "Centro de Cumplimiento",
+ complianceCenterDesc: "Resumen del Estado de Cumplimiento y Certificación de Contratistas",
+ insuranceStatus: "Estado del Seguro",
+ all: "Todos",
+ active: "Activo",
+ expired: "Vencido",
+ pending: "Pendiente",
+ sendExpirationNotice: "Enviar Email 'Seguro Vencido'",
+ filterByInsuranceStatus: "Filtrar por Estado del Seguro",
+ showAllInsuranceStatuses: "Mostrar todos los estados del seguro",
+ currentlyActiveInsurance: "Seguro actualmente activo",
+ expiredInsurancePolicies: "Pólizas de seguro vencidas",
+ pendingInsuranceVerification: "Verificación de seguro pendiente",
+ insuranceExpirationNotices: "Avisos de Vencimiento de Seguro",
+ previous: "Anterior",
+ next: "Siguiente",
+ email: "Correo",
+ of: "de",
+ to: "Para",
+ subject: "Asunto",
+ message: "Mensaje",
+ close: "Cerrar",
+ sendThisEmail: "Enviar Este Correo",
+ sendAll: "Enviar Todos",
+ demoMode: "Modo Demo",
+ noActualEmails: "No se enviarán correos reales. Tienes",
+ notificationsReady: "notificación(es) lista(s) para enviar.",
+ urgentInsuranceStatusExpired: "URGENTE: Estado del Seguro Vencido - Acción Requerida",
+ dearContractor: "Estimado",
+ urgentNotification: "Esta es una notificación urgente sobre el estado de su seguro.",
+ recordsIndicate: "Nuestros registros indican que su cobertura de seguro ha VENCIDO. Esto requiere atención inmediata para mantener el cumplimiento con nuestros requisitos de contratistas.",
+ selectedRecordDetails: "Detalles del Registro Seleccionado",
+ pleaseTakeActions: "Por favor tome las siguientes acciones inmediatamente:",
+ reviewPolicy: "1. Revise su póliza de seguro actual",
+ renewCoverage: "2. Renueve o actualice su cobertura de seguro",
+ submitDocumentation: "3. Envíe documentación actualizada a nuestro equipo de cumplimiento",
+ failureWarning: "La falta de atención a este problema puede resultar en la suspensión de los privilegios de contratista.",
+ questionsContact: "Si tiene alguna pregunta o necesita asistencia, por favor contacte a nuestro departamento de cumplimiento.",
+ bestRegards: "Saludos cordiales,",
+ complianceTeam: "Equipo de Cumplimiento Demo",
+ demoEmailGenerated: "Este es un correo de demostración generado desde la selección de marcas de Tableau.",
+ // Metrics translations
+ metrics: {
+ "Total Contractors": "Total de Contratistas",
+ "Active Projects": "Proyectos Activos",
+ "Safety Incidents": "Incidentes de Seguridad",
+ "Compliance Rate": "Tasa de Cumplimiento",
+ "Insurance Expired": "Seguro Vencido",
+ "Training Completed": "Capacitación Completada",
+ "Risk Score": "Puntuación de Riesgo",
+ "Certifications": "Certificaciones",
+ "Violations": "Violaciones",
+ "Inspections": "Inspecciones",
+ "Renewals": "Renovaciones",
+ "Approvals": "Aprobaciones",
+ "Rejections": "Rechazos",
+ "Pending": "Pendiente",
+ "Overdue": "Vencido",
+ "Completed": "Completado",
+ "In Progress": "En Progreso",
+ "Not Started": "No Iniciado",
+ "High Risk": "Alto Riesgo",
+ "Medium Risk": "Riesgo Medio",
+ "Low Risk": "Bajo Riesgo",
+ "Critical": "Crítico",
+ "Warning": "Advertencia",
+ "Info": "Información",
+ "Success": "Éxito",
+ "Error": "Error"
+ }
+ },
+ fr: {
+ title: "Gestion des Risques Contractuels Demo",
+ subtitle: "Tableau de bord de suivi complet de la sécurité et de la conformité avec alertes en temps réel et analyses en libre-service",
+ executiveSummary: "Résumé Exécutif",
+ executiveSummaryDesc: "Suivi de conformité en temps réel sur tous les entrepreneurs et métriques de sécurité",
+ complianceCenter: "Centre de Conformité",
+ complianceCenterDesc: "Aperçu du Statut de Conformité et de Certification des Entrepreneurs",
+ insuranceStatus: "Statut d'Assurance",
+ all: "Tous",
+ active: "Actif",
+ expired: "Expiré",
+ pending: "En Attente",
+ sendExpirationNotice: "Envoyer Email 'Assurance Expirée'",
+ filterByInsuranceStatus: "Filtrer par Statut d'Assurance",
+ showAllInsuranceStatuses: "Afficher tous les statuts d'assurance",
+ currentlyActiveInsurance: "Assurance actuellement active",
+ expiredInsurancePolicies: "Polices d'assurance expirées",
+ pendingInsuranceVerification: "Vérification d'assurance en attente",
+ insuranceExpirationNotices: "Avis d'Expiration d'Assurance",
+ previous: "Précédent",
+ next: "Suivant",
+ email: "Email",
+ of: "de",
+ to: "À",
+ subject: "Sujet",
+ message: "Message",
+ close: "Fermer",
+ sendThisEmail: "Envoyer Cet Email",
+ sendAll: "Tout Envoyer",
+ demoMode: "Mode Démo",
+ noActualEmails: "Aucun email réel ne sera envoyé. Vous avez",
+ notificationsReady: "notification(s) prête(s) à envoyer.",
+ urgentInsuranceStatusExpired: "URGENT: Statut d'Assurance Expiré - Action Requise",
+ dearContractor: "Cher",
+ urgentNotification: "Ceci est une notification urgente concernant votre statut d'assurance.",
+ recordsIndicate: "Nos dossiers indiquent que votre couverture d'assurance a EXPIRÉ. Cela nécessite une attention immédiate pour maintenir la conformité avec nos exigences contractuelles.",
+ selectedRecordDetails: "Détails du Registre Sélectionné",
+ pleaseTakeActions: "Veuillez prendre les mesures suivantes immédiatement:",
+ reviewPolicy: "1. Examinez votre police d'assurance actuelle",
+ renewCoverage: "2. Renouvelez ou mettez à jour votre couverture d'assurance",
+ submitDocumentation: "3. Soumettez la documentation mise à jour à notre équipe de conformité",
+ failureWarning: "Le fait de ne pas résoudre ce problème peut entraîner la suspension des privilèges contractuels.",
+ questionsContact: "Si vous avez des questions ou avez besoin d'assistance, veuillez contacter notre département de conformité.",
+ bestRegards: "Cordialement,",
+ complianceTeam: "Équipe de Conformité Demo",
+ demoEmailGenerated: "Ceci est un email de démonstration généré à partir de la sélection de marques Tableau.",
+ // Metrics translations
+ metrics: {
+ "Total Contractors": "Total des Entrepreneurs",
+ "Active Projects": "Projets Actifs",
+ "Safety Incidents": "Incidents de Sécurité",
+ "Compliance Rate": "Taux de Conformité",
+ "Insurance Expired": "Assurance Expirée",
+ "Training Completed": "Formation Terminée",
+ "Risk Score": "Score de Risque",
+ "Certifications": "Certifications",
+ "Violations": "Violations",
+ "Inspections": "Inspections",
+ "Renewals": "Renouvellements",
+ "Approvals": "Approbations",
+ "Rejections": "Rejets",
+ "Pending": "En Attente",
+ "Overdue": "En Retard",
+ "Completed": "Terminé",
+ "In Progress": "En Cours",
+ "Not Started": "Non Commencé",
+ "High Risk": "Risque Élevé",
+ "Medium Risk": "Risque Moyen",
+ "Low Risk": "Faible Risque",
+ "Critical": "Critique",
+ "Warning": "Avertissement",
+ "Info": "Information",
+ "Success": "Succès",
+ "Error": "Erreur"
+ }
+ }
+ };
+
+ const t = translations[language];
+
+ // Close language dropdown when clicking outside
+ useEffect(() => {
+ const handleClickOutside = (event) => {
+ if (showLanguageDropdown && !event.target.closest('.language-selector')) {
+ setShowLanguageDropdown(false);
+ }
+ };
+
+ document.addEventListener('mousedown', handleClickOutside);
+ return () => {
+ document.removeEventListener('mousedown', handleClickOutside);
+ };
+ }, [showLanguageDropdown]);
+
+ const value = {
+ language,
+ setLanguage,
+ showLanguageDropdown,
+ setShowLanguageDropdown,
+ t,
+ translations
+ };
+
+ return (
+
+ {children}
+
+ );
+};
diff --git a/src/global.css b/src/global.css
index 4825f36b..0aac7f3d 100644
--- a/src/global.css
+++ b/src/global.css
@@ -193,6 +193,41 @@
--metrics-neutral: 280 70% 75%;
--metrics-negative: 280 60% 33% ;
}
+
+ /* Define variables for the 'contractor' theme (Blue) */
+ [data-theme="contractor"] {
+ --background: 222 47% 11%; /* Dark Blue */
+ --foreground: 210 40% 98%; /* Light Blue */
+ --demo-background: 222 47% 11%; /* Dark background for demos */
+ --nav-background: 222 47% 11%; /* Dark navigation */
+ --logo-background: 210 40% 98%; /* sometimes logo files are transparent */
+ --icon-background: 210 40% 95%; /* same with icon having transparent backgrounds */
+ --login-card-background: 0 0% 100%; /* for the Auth component on login screens */
+ --nav-icons: 222 47% 30%; /* Darker icons on dark nav */
+ --ai-icons: 210 40% 95%;
+ --breadcrumbs: 210 40% 98%; /* Light breadcrumbs on dark nav */
+ --card: 0 0% 100%; /* White */
+ --card-foreground: 222 47% 11%;
+ --popover: 0 0% 100%;
+ --popover-foreground: 222 47% 11%;
+ --primary: 217 91% 60%; /* Demo Blue */
+ --primary-foreground: 0 0% 100%; /* White */
+ --secondary: 210 40% 96%; /* Light Blue */
+ --secondary-foreground: 222 47% 11%; /* Dark Blue */
+ --muted: 210 40% 96%; /* Very Light Blue */
+ --muted-foreground: 215 16% 47%; /* Mid Blue-Gray */
+ --accent: 217 91% 60%; /* Demo Blue */
+ --accent-foreground: 0 0% 100%; /* White */
+ --destructive: 0 84% 60%; /* Red */
+ --destructive-foreground: 0 0% 100%;
+ --border: 214 32% 91%; /* Soft Blue Border */
+ --input: 214 32% 91%; /* Very Soft Blue Input */
+ --ring: 217 91% 60%; /* Demo Blue Ring */
+
+ --metrics-positive: 142 76% 36%; /* Green */
+ --metrics-neutral: 39 7% 54%; /* Yellow */
+ --metrics-negative: 0 84% 60%; /* Red */
+ }
} /* End of @layer base containing themes */
@layer base {
diff --git a/src/hooks/index.js b/src/hooks/index.js
index 53b8bb89..3e7865bf 100644
--- a/src/hooks/index.js
+++ b/src/hooks/index.js
@@ -4,3 +4,4 @@ export { useInsights } from './useInsights';
export { useTimeout } from './useTimeout';
export { useMetadata } from './useMetadata';
export { useXSQuery, useSMQuery, useMDQuery, useLGQuery, useXLQuery, use2XLQuery } from './useMediaQueries';
+export { useTableauTranslation } from './useTableauTranslation';
diff --git a/src/hooks/tableauHooks.js b/src/hooks/tableauHooks.js
new file mode 100644
index 00000000..ddee623b
--- /dev/null
+++ b/src/hooks/tableauHooks.js
@@ -0,0 +1,2 @@
+"use client";
+export { useTableauDashboards } from './useTableauDashboards';
diff --git a/src/hooks/useTableauDashboards.js b/src/hooks/useTableauDashboards.js
new file mode 100644
index 00000000..96f8b53c
--- /dev/null
+++ b/src/hooks/useTableauDashboards.js
@@ -0,0 +1,148 @@
+"use client";
+
+import { useState, useEffect, useCallback } from 'react';
+import { useTableauSession } from '@/hooks';
+import { useMetadata } from '@/hooks';
+
+// Custom hook for managing Tableau dashboards
+export const useTableauDashboards = () => {
+
+ const {
+ status: sessionStatus,
+ data: user,
+ isSuccess: isSessionSuccess,
+ isError: isSessionError,
+ isLoading: isSessionLoading
+ } = useTableauSession();
+
+ // REST API state
+ const [restApiData, setRestApiData] = useState(null);
+ const [restApiLoading, setRestApiLoading] = useState(false);
+ const [restApiError, setRestApiError] = useState(null);
+
+ // Use the existing metadata hook to get workbooks and dashboards
+ const {
+ data: metadata,
+ isLoading,
+ isError,
+ error
+ } = useMetadata(user);
+
+ // Function to fetch workbooks using our session-based backend API
+ const fetchWorkbooksViaREST = useCallback(async () => {
+ if (!user?.user_id || !user?.site_id || !user?.rest_key) {
+ return;
+ }
+
+ setRestApiLoading(true);
+ setRestApiError(null);
+
+ try {
+ const response = await fetch('/api/tableau/workbooks?pageSize=100&page=1');
+
+ if (!response.ok) {
+ throw new Error(`API Error: ${response.status}`);
+ }
+
+ const data = await response.json();
+
+ // Transform workbooks into dashboard format
+ const allDashboards = [];
+
+ if (data.workbooks && data.workbooks.length > 0) {
+ for (const workbook of data.workbooks) {
+ try {
+ const viewsResponse = await fetch(`/api/tableau/views?workbookId=${workbook.id}`);
+ if (viewsResponse.ok) {
+ const viewsData = await viewsResponse.json();
+ if (viewsData.views) {
+ for (const view of viewsData.views) {
+ allDashboards.push({
+ dashboard_name: view.name,
+ dashboard_id: view.id,
+ workbook_name: workbook.name,
+ content_url: view.contentUrl,
+ workbook_id: workbook.id
+ });
+ }
+ }
+ }
+ } catch (viewError) {
+ console.error(`Error getting views for workbook ${workbook.name}:`, viewError);
+ }
+ }
+ }
+
+ setRestApiData({
+ workbooks: allDashboards,
+ totalCount: allDashboards.length
+ });
+
+ } catch (error) {
+ console.error('❌ Error:', error);
+ setRestApiError(error.message);
+ } finally {
+ setRestApiLoading(false);
+ }
+ }, [user?.user_id, user?.site_id, user?.rest_key]);
+
+ // Fetch workbooks when user is authenticated
+ useEffect(() => {
+ if (user?.user_id && user?.site_id && user?.rest_key) {
+ fetchWorkbooksViaREST();
+ }
+ }, [fetchWorkbooksViaREST, user?.user_id, user?.site_id, user?.rest_key]);
+
+ // Process
+ let dashboardsByFolder = {};
+ let allViews = [];
+
+ if (metadata && Array.isArray(metadata)) {
+
+ metadata.forEach(item => {
+ if (item.workbooks && Array.isArray(item.workbooks)) {
+ item.workbooks.forEach(workbook => {
+ if (workbook.views && Array.isArray(workbook.views)) {
+ const folderName = item.name || 'Default';
+
+ if (!dashboardsByFolder[folderName]) {
+ dashboardsByFolder[folderName] = [];
+ }
+
+ dashboardsByFolder[folderName].push({
+ ...workbook,
+ views: workbook.views
+ });
+
+ // Add all views to the flat array
+ allViews.push(...workbook.views);
+ }
+ });
+ }
+ });
+ } else {
+ }
+
+
+ return {
+ // Session data
+ user,
+ sessionStatus,
+ isSessionSuccess,
+ isSessionError,
+ isSessionLoading,
+
+ // Metadata-based data (GraphQL)
+ dashboardsByFolder,
+ allViews,
+ metadata,
+ isLoading,
+ isError,
+ error,
+
+ // REST API data
+ restApiData,
+ restApiLoading,
+ restApiError,
+ };
+};
diff --git a/src/hooks/useTableauSession.ts b/src/hooks/useTableauSession.ts
index ccfc0583..3d5eb3cb 100644
--- a/src/hooks/useTableauSession.ts
+++ b/src/hooks/useTableauSession.ts
@@ -26,9 +26,10 @@ export const useTableauSession = () => {
return useQuery({
// eslint-disable-next-line @tanstack/query/exhaustive-deps
queryKey: queryKey,
- queryFn: () => {
+ queryFn: async () => {
if (session_data?.user?.email) {
- return getClientSession(session_data.user.email);
+ const result = await getClientSession(session_data.user.email);
+ return result;
} else {
throw new Error("useTableauSession Error: Session data not available");
}
diff --git a/src/hooks/useTableauTranslation.js b/src/hooks/useTableauTranslation.js
new file mode 100644
index 00000000..b453d7ce
--- /dev/null
+++ b/src/hooks/useTableauTranslation.js
@@ -0,0 +1,123 @@
+"use client";
+
+import { useLanguage } from '@/contexts/LanguageContext';
+import {
+ translateTableauData,
+ translateTableauMetric,
+ translateTableauDashboard,
+ translateTableauWorksheet,
+ translateTableauFilter,
+ translateTableauMarks
+} from '@/services/tableauTranslationService';
+
+/**
+ * Custom hook for translating Tableau data
+ * @returns {object} - Translation functions and utilities
+ */
+export const useTableauTranslation = () => {
+ const { t, language } = useLanguage();
+
+ /**
+ * Translate any Tableau data object
+ * @param {object} data - The data to translate
+ * @param {string} dataType - Type of data ('metric', 'dashboard', 'worksheet', 'filter', 'marks')
+ * @returns {object} - Translated data
+ */
+ const translateData = (data, dataType = 'metric') => {
+ return translateTableauData(data, t, dataType);
+ };
+
+ /**
+ * Translate Tableau metric data
+ * @param {object} metricData - The metric data to translate
+ * @returns {object} - Translated metric data
+ */
+ const translateMetric = (metricData) => {
+ return translateTableauMetric(metricData, t);
+ };
+
+ /**
+ * Translate Tableau dashboard data
+ * @param {object} dashboardData - The dashboard data to translate
+ * @returns {object} - Translated dashboard data
+ */
+ const translateDashboard = (dashboardData) => {
+ return translateTableauDashboard(dashboardData, t);
+ };
+
+ /**
+ * Translate Tableau worksheet data
+ * @param {object} worksheetData - The worksheet data to translate
+ * @returns {object} - Translated worksheet data
+ */
+ const translateWorksheet = (worksheetData) => {
+ return translateTableauWorksheet(worksheetData, t);
+ };
+
+ /**
+ * Translate Tableau filter data
+ * @param {object} filterData - The filter data to translate
+ * @returns {object} - Translated filter data
+ */
+ const translateFilter = (filterData) => {
+ return translateTableauFilter(filterData, t);
+ };
+
+ /**
+ * Translate Tableau mark selection data
+ * @param {object} markData - The mark data to translate
+ * @returns {object} - Translated mark data
+ */
+ const translateMarks = (markData) => {
+ return translateTableauMarks(markData, t);
+ };
+
+ /**
+ * Translate an array of Tableau data objects
+ * @param {Array} dataArray - Array of data objects to translate
+ * @param {string} dataType - Type of data for all objects
+ * @returns {Array} - Array of translated data objects
+ */
+ const translateDataArray = (dataArray, dataType = 'metric') => {
+ if (!Array.isArray(dataArray)) {
+ return dataArray;
+ }
+ return dataArray.map(data => translateData(data, dataType));
+ };
+
+ /**
+ * Get the current language code
+ * @returns {string} - Current language code ('en', 'es', 'fr')
+ */
+ const getCurrentLanguage = () => {
+ return language;
+ };
+
+ /**
+ * Check if a specific text has a translation
+ * @param {string} text - The text to check
+ * @returns {boolean} - True if translation exists
+ */
+ const hasTranslation = (text) => {
+ return !!(t?.metrics && t.metrics[text]);
+ };
+
+ return {
+ // Translation functions
+ translateData,
+ translateMetric,
+ translateDashboard,
+ translateWorksheet,
+ translateFilter,
+ translateMarks,
+ translateDataArray,
+
+ // Utility functions
+ getCurrentLanguage,
+ hasTranslation,
+
+ // Direct access to translations
+ translations: t,
+ language
+ };
+};
diff --git a/src/libs/crypto.js b/src/libs/crypto.js
index 596873e8..edb700da 100644
--- a/src/libs/crypto.js
+++ b/src/libs/crypto.js
@@ -19,7 +19,7 @@ export const jwtSign = (sub, jwt_options, scopes, claims) => {
};
// sign the JWT with provided header, payload and secret
- const token = jwt.sign(payload, jwt_options.jwt_secret, {
+ const token = jwt.sign(payload, jwt_options.jwt_secret, {
header: header,
expiresIn: '9m', // https://github.com/auth0/node-jsonwebtoken?tab=readme-ov-file#token-expiration-exp-claim
});
diff --git a/src/models/Users/userStore.ts b/src/models/Users/userStore.ts
index ee421882..288781cd 100644
--- a/src/models/Users/userStore.ts
+++ b/src/models/Users/userStore.ts
@@ -148,5 +148,33 @@ export const Users = [
uaf: {}
},
]
+ },
+ {
+ demo: 'contractor',
+ roles: {
+ 0: { title: 'Safety & Compliance Manager', description: 'Oversees contractor compliance, training, and incident reporting'},
+ 1: { title: 'Director of Operations', description: 'Monitors overall safety performance and drives vendor accountability'},
+ 2: { title: 'Management', description: 'Executive oversight and strategic insights'},
+ },
+ users: [
+ {
+ id: 'a',
+ name: "Sarah Johnson",
+ email: "sjohnson@demo.com",
+ picture: "/img/users/sofia_lopez.png",
+ role: 0,
+ vector_store: 'demo_sjohnson',
+ uaf: {"Department": ["Safety"], "Region": ["Central"]}
+ },
+ {
+ id: 'b',
+ name: "Mike Chen",
+ email: "mchen@demo.com",
+ picture: "/img/users/justin_chen.png",
+ role: 1,
+ vector_store: 'demo_mchen',
+ uaf: {"Department": ["Procurement"], "Region": ["West"]}
+ },
+ ]
}
]
diff --git a/src/services/tableauApi.js b/src/services/tableauApi.js
new file mode 100644
index 00000000..28f255fa
--- /dev/null
+++ b/src/services/tableauApi.js
@@ -0,0 +1,270 @@
+/**
+ * Tableau REST API Service
+ * Handles authentication and data fetching from Tableau Server/Online
+ */
+
+const TABLEAU_BASE_URL = process.env.NEXT_PUBLIC_TABLEAU_BASE_URL || 'https://prod-useast-b.online.tableau.com';
+
+class TableauApiService {
+ constructor() {
+ this.baseUrl = TABLEAU_BASE_URL;
+ this.siteId = process.env.NEXT_PUBLIC_TABLEAU_SITE_ID || 'embeddingplaybook';
+ }
+
+ /**
+ * Authenticate with Tableau Server using JWT token
+ * This method is used when you already have a JWT token from your existing auth system
+ */
+ async authenticateWithJWT(jwtToken) {
+ try {
+ const response = await fetch(`${this.baseUrl}/api/3.19/auth/signin`, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify({
+ credentials: {
+ jwt: jwtToken,
+ site: {
+ contentUrl: this.siteId
+ }
+ }
+ })
+ });
+
+ if (!response.ok) {
+ throw new Error(`JWT authentication failed: ${response.statusText}`);
+ }
+
+ const data = await response.json();
+ return {
+ token: data.credentials.token,
+ siteId: data.credentials.site.id,
+ userId: data.credentials.user.id
+ };
+ } catch (error) {
+ console.error('Tableau JWT authentication error:', error);
+ throw error;
+ }
+ }
+
+ /**
+ * Get user's accessible workbooks and dashboards
+ */
+ async getUserDashboards(token, siteId) {
+ try {
+ console.log('🔍 Fetching workbooks from Tableau API:', {
+ url: `${this.baseUrl}/api/3.19/sites/${siteId}/workbooks`,
+ siteId,
+ hasToken: !!token
+ });
+
+ const response = await fetch(`${this.baseUrl}/api/3.19/sites/${siteId}/workbooks`, {
+ headers: {
+ 'X-Tableau-Auth': token,
+ 'Content-Type': 'application/json'
+ }
+ });
+
+ if (!response.ok) {
+ console.error('❌ Tableau API Error:', {
+ status: response.status,
+ statusText: response.statusText,
+ url: response.url
+ });
+ throw new Error(`Failed to fetch workbooks: ${response.statusText}`);
+ }
+
+ const data = await response.json();
+ const parsedWorkbooks = this.parseWorkbooks(data.workbooks);
+
+ console.log('✅ Workbooks fetched successfully:', {
+ rawCount: data.workbooks?.length || 0,
+ parsedCount: parsedWorkbooks.length,
+ workbooks: parsedWorkbooks.map(wb => ({
+ name: wb.name,
+ project: wb.projectName,
+ id: wb.id
+ }))
+ });
+
+ return parsedWorkbooks;
+ } catch (error) {
+ console.error('❌ Error fetching user dashboards:', error);
+ throw error;
+ }
+ }
+
+ /**
+ * Get folders for organizing dashboards
+ */
+ async getFolders(token, siteId) {
+ try {
+ console.log('📁 Fetching folders from Tableau API:', {
+ url: `${this.baseUrl}/api/3.19/sites/${siteId}/projects`,
+ siteId,
+ hasToken: !!token
+ });
+
+ const response = await fetch(`${this.baseUrl}/api/3.19/sites/${siteId}/projects`, {
+ headers: {
+ 'X-Tableau-Auth': token,
+ 'Content-Type': 'application/json'
+ }
+ });
+
+ if (!response.ok) {
+ console.error('❌ Tableau Folders API Error:', {
+ status: response.status,
+ statusText: response.statusText,
+ url: response.url
+ });
+ throw new Error(`Failed to fetch folders: ${response.statusText}`);
+ }
+
+ const data = await response.json();
+ const parsedProjects = this.parseProjects(data.projects);
+
+ console.log('✅ Folders fetched successfully:', {
+ rawCount: data.projects?.length || 0,
+ parsedCount: parsedProjects.length,
+ folders: parsedProjects.map(project => ({
+ name: project.name,
+ id: project.id,
+ description: project.description
+ }))
+ });
+
+ return parsedProjects;
+ } catch (error) {
+ console.error('❌ Error fetching folders:', error);
+ throw error;
+ }
+ }
+
+ /**
+ * Get specific workbook details including views
+ */
+ async getWorkbookViews(token, siteId, workbookId) {
+ try {
+ console.log('👁️ Fetching views for workbook:', {
+ workbookId,
+ url: `${this.baseUrl}/api/3.19/sites/${siteId}/workbooks/${workbookId}`,
+ hasToken: !!token
+ });
+
+ const response = await fetch(`${this.baseUrl}/api/3.19/sites/${siteId}/workbooks/${workbookId}`, {
+ headers: {
+ 'X-Tableau-Auth': token,
+ 'Content-Type': 'application/json'
+ }
+ });
+
+ if (!response.ok) {
+ console.error('❌ Tableau Views API Error:', {
+ status: response.status,
+ statusText: response.statusText,
+ workbookId,
+ url: response.url
+ });
+ throw new Error(`Failed to fetch workbook views: ${response.statusText}`);
+ }
+
+ const data = await response.json();
+ const parsedViews = this.parseViews(data.workbook.views);
+
+ console.log('✅ Views fetched successfully for workbook:', {
+ workbookId,
+ rawCount: data.workbook.views?.length || 0,
+ parsedCount: parsedViews.length,
+ views: parsedViews.map(view => ({
+ name: view.name,
+ id: view.id,
+ viewUrlName: view.viewUrlName
+ }))
+ });
+
+ return parsedViews;
+ } catch (error) {
+ console.error('❌ Error fetching workbook views:', error);
+ throw error;
+ }
+ }
+
+ /**
+ * Parse workbooks data into a more usable format
+ */
+ parseWorkbooks(workbooks) {
+ return workbooks.map(workbook => ({
+ id: workbook.id,
+ name: workbook.name,
+ description: workbook.description || '',
+ projectId: workbook.project.id,
+ projectName: workbook.project.name,
+ owner: workbook.owner?.name || 'Unknown',
+ createdAt: workbook.createdAt,
+ updatedAt: workbook.updatedAt,
+ webPageUrl: workbook.webPageUrl,
+ size: workbook.size
+ }));
+ }
+
+ /**
+ * Parse projects (folders) data
+ */
+ parseProjects(projects) {
+ return projects.map(project => ({
+ id: project.id,
+ name: project.name,
+ description: project.description || '',
+ parentProjectId: project.parentProjectId,
+ contentPermissions: project.contentPermissions
+ }));
+ }
+
+ /**
+ * Parse views (dashboards) data
+ */
+ parseViews(views) {
+ return views.map(view => ({
+ id: view.id,
+ name: view.name,
+ contentUrl: view.contentUrl,
+ workbookId: view.workbook?.id,
+ workbookName: view.workbook?.name,
+ projectId: view.project?.id,
+ projectName: view.project?.name,
+ createdAt: view.createdAt,
+ updatedAt: view.updatedAt,
+ webPageUrl: view.webPageUrl,
+ viewUrlName: view.viewUrlName
+ }));
+ }
+
+ /**
+ * Get embed URL for a specific view
+ */
+ getEmbedUrl(siteId, workbookId, viewId) {
+ return `${this.baseUrl}/t/${this.siteId}/views/${workbookId}/${viewId}`;
+ }
+
+ /**
+ * Sign out from Tableau
+ */
+ async signOut(token) {
+ try {
+ await fetch(`${this.baseUrl}/api/3.19/auth/signout`, {
+ method: 'POST',
+ headers: {
+ 'X-Tableau-Auth': token,
+ 'Content-Type': 'application/json'
+ }
+ });
+ } catch (error) {
+ console.error('Error signing out:', error);
+ }
+ }
+}
+
+const tableauApiService = new TableauApiService();
+export default tableauApiService;
diff --git a/src/services/tableauTranslationService.js b/src/services/tableauTranslationService.js
new file mode 100644
index 00000000..0c011ab8
--- /dev/null
+++ b/src/services/tableauTranslationService.js
@@ -0,0 +1,167 @@
+/**
+ * Tableau Data Translation Service
+ * Provides comprehensive translation capabilities for Tableau data including:
+ * - Metric names and values
+ * - Dashboard titles and descriptions
+ * - Filter options and labels
+ * - Chart labels and legends
+ * - Tooltip text and messages
+ */
+
+import { translateMetricName, translateMetricValue } from '../utils/metricTranslations';
+
+/**
+ * Translate Tableau metric data
+ * @param {object} metricData - The metric data from Tableau
+ * @param {object} translations - The translations object from language context
+ * @returns {object} - Translated metric data
+ */
+export const translateTableauMetric = (metricData, translations) => {
+ if (!metricData || !translations?.metrics) {
+ return metricData;
+ }
+
+ const translated = {
+ ...metricData,
+ name: translateMetricName(metricData.name, translations),
+ displayName: translateMetricName(metricData.displayName || metricData.name, translations),
+ description: translateMetricName(metricData.description, translations),
+ // Translate any nested values that might contain text
+ values: metricData.values ? metricData.values.map(value =>
+ typeof value === 'string' ? translateMetricValue(value, translations) : value
+ ) : metricData.values
+ };
+
+ return translated;
+};
+
+/**
+ * Translate Tableau dashboard data
+ * @param {object} dashboardData - The dashboard data from Tableau
+ * @param {object} translations - The translations object from language context
+ * @returns {object} - Translated dashboard data
+ */
+export const translateTableauDashboard = (dashboardData, translations) => {
+ if (!dashboardData || !translations) {
+ return dashboardData;
+ }
+
+ return {
+ ...dashboardData,
+ title: translateMetricName(dashboardData.title, translations),
+ description: translateMetricName(dashboardData.description, translations),
+ // Translate any worksheets or sheets
+ worksheets: dashboardData.worksheets ? dashboardData.worksheets.map(worksheet =>
+ translateTableauWorksheet(worksheet, translations)
+ ) : dashboardData.worksheets
+ };
+};
+
+/**
+ * Translate Tableau worksheet data
+ * @param {object} worksheetData - The worksheet data from Tableau
+ * @param {object} translations - The translations object from language context
+ * @returns {object} - Translated worksheet data
+ */
+export const translateTableauWorksheet = (worksheetData, translations) => {
+ if (!worksheetData || !translations?.metrics) {
+ return worksheetData;
+ }
+
+ return {
+ ...worksheetData,
+ name: translateMetricName(worksheetData.name, translations),
+ title: translateMetricName(worksheetData.title, translations),
+ // Translate any columns or fields
+ columns: worksheetData.columns ? worksheetData.columns.map(column => ({
+ ...column,
+ fieldName: translateMetricName(column.fieldName, translations),
+ displayName: translateMetricName(column.displayName || column.fieldName, translations)
+ })) : worksheetData.columns
+ };
+};
+
+/**
+ * Translate Tableau filter data
+ * @param {object} filterData - The filter data from Tableau
+ * @param {object} translations - The translations object from language context
+ * @returns {object} - Translated filter data
+ */
+export const translateTableauFilter = (filterData, translations) => {
+ if (!filterData || !translations?.metrics) {
+ return filterData;
+ }
+
+ return {
+ ...filterData,
+ fieldName: translateMetricName(filterData.fieldName, translations),
+ displayName: translateMetricName(filterData.displayName || filterData.fieldName, translations),
+ // Translate filter values
+ values: filterData.values ? filterData.values.map(value =>
+ typeof value === 'string' ? translateMetricValue(value, translations) : value
+ ) : filterData.values,
+ // Translate any options
+ options: filterData.options ? filterData.options.map(option =>
+ typeof option === 'string' ? translateMetricValue(option, translations) : option
+ ) : filterData.options
+ };
+};
+
+/**
+ * Translate Tableau mark selection data
+ * @param {object} markData - The mark selection data from Tableau
+ * @param {object} translations - The translations object from language context
+ * @returns {object} - Translated mark data
+ */
+export const translateTableauMarks = (markData, translations) => {
+ if (!markData || !translations?.metrics) {
+ return markData;
+ }
+
+ return {
+ ...markData,
+ // Translate columns
+ columns: markData.columns ? markData.columns.map(column => ({
+ ...column,
+ fieldName: translateMetricName(column.fieldName, translations),
+ displayName: translateMetricName(column.displayName || column.fieldName, translations)
+ })) : markData.columns,
+ // Translate data values
+ data: markData.data ? markData.data.map(row =>
+ row.map(cell =>
+ typeof cell === 'string' ? translateMetricValue(cell, translations) : cell
+ )
+ ) : markData.data
+ };
+};
+
+/**
+ * Translate any Tableau data object
+ * @param {object} data - Any Tableau data object
+ * @param {object} translations - The translations object from language context
+ * @param {string} dataType - Type of data ('metric', 'dashboard', 'worksheet', 'filter', 'marks')
+ * @returns {object} - Translated data
+ */
+export const translateTableauData = (data, translations, dataType = 'metric') => {
+ if (!data || !translations) {
+ return data;
+ }
+
+ switch (dataType) {
+ case 'metric':
+ return translateTableauMetric(data, translations);
+ case 'dashboard':
+ return translateTableauDashboard(data, translations);
+ case 'worksheet':
+ return translateTableauWorksheet(data, translations);
+ case 'filter':
+ return translateTableauFilter(data, translations);
+ case 'marks':
+ return translateTableauMarks(data, translations);
+ default:
+ return data;
+ }
+};
+
+// Re-export the utility functions
+export { translateMetricName, translateMetricValue } from '../utils/metricTranslations';
diff --git a/src/utils/metricTranslations.js b/src/utils/metricTranslations.js
new file mode 100644
index 00000000..bbd4f5a6
--- /dev/null
+++ b/src/utils/metricTranslations.js
@@ -0,0 +1,69 @@
+/**
+ * Utility function to translate metric names from Tableau data
+ * @param {string} metricName - The original metric name from Tableau
+ * @param {object} translations - The translations object from language context
+ * @returns {string} - The translated metric name or original if no translation found
+ */
+export const translateMetricName = (metricName, translations) => {
+ if (!metricName || !translations?.metrics) {
+ return metricName;
+ }
+
+ // Direct lookup in metrics translations
+ if (translations.metrics[metricName]) {
+ return translations.metrics[metricName];
+ }
+
+ // Try to find partial matches for dynamic metric names
+ const metricKeys = Object.keys(translations.metrics);
+
+ // Look for partial matches (useful for dynamic metric names)
+ for (const key of metricKeys) {
+ if (metricName.toLowerCase().includes(key.toLowerCase()) ||
+ key.toLowerCase().includes(metricName.toLowerCase())) {
+ return translations.metrics[key];
+ }
+ }
+
+ // If no translation found, return original name
+ return metricName;
+};
+
+/**
+ * Utility function to translate metric values/units
+ * @param {string} value - The value to translate
+ * @param {object} translations - The translations object from language context
+ * @returns {string} - The translated value or original if no translation found
+ */
+export const translateMetricValue = (value, translations) => {
+ if (!value || !translations?.metrics) {
+ return value;
+ }
+
+ // Direct lookup in metrics translations
+ if (translations.metrics[value]) {
+ return translations.metrics[value];
+ }
+
+ return value;
+};
+
+/**
+ * Utility function to translate all metric-related text
+ * @param {object} metric - The metric object from Tableau
+ * @param {object} translations - The translations object from language context
+ * @returns {object} - The metric object with translated text
+ */
+export const translateMetric = (metric, translations) => {
+ if (!metric || !translations?.metrics) {
+ return metric;
+ }
+
+ return {
+ ...metric,
+ name: translateMetricName(metric.name, translations),
+ // Add other fields that might need translation
+ displayName: translateMetricName(metric.displayName || metric.name, translations),
+ description: translateMetricName(metric.description, translations)
+ };
+};
diff --git a/test_system.sh b/test_system.sh
new file mode 100755
index 00000000..5b719ee0
--- /dev/null
+++ b/test_system.sh
@@ -0,0 +1,72 @@
+#!/bin/bash
+
+echo "🧪 Testing Demo Dashboard System"
+echo "=================================="
+echo ""
+
+# Colors for output
+RED='\033[0;31m'
+GREEN='\033[0;32m'
+YELLOW='\033[1;33m'
+BLUE='\033[0;34m'
+NC='\033[0m' # No Color
+
+# Test 1: Check if server is running
+echo -e "${BLUE}Test 1: Server Status${NC}"
+if curl -s "http://localhost:3001" > /dev/null; then
+ echo -e "${GREEN}✅ Server is running on http://localhost:3001${NC}"
+else
+ echo -e "${RED}❌ Server is not running${NC}"
+ exit 1
+fi
+echo ""
+
+# Test 2: Check auth page
+echo -e "${BLUE}Test 2: Authentication Page${NC}"
+if curl -s "http://localhost:3001/demo/contractor/auth" | grep -q "Sarah Johnson"; then
+ echo -e "${GREEN}✅ Auth page is working - Sarah Johnson found${NC}"
+else
+ echo -e "${RED}❌ Auth page issue - Sarah Johnson not found${NC}"
+fi
+echo ""
+
+# Test 3: Check current session status
+echo -e "${BLUE}Test 3: Current Session Status${NC}"
+SESSION_RESPONSE=$(curl -s "http://localhost:3001/api/test/session")
+if echo "$SESSION_RESPONSE" | grep -q '"authenticated":false'; then
+ echo -e "${YELLOW}⚠️ No user authenticated (expected before login)${NC}"
+ echo " To authenticate: Go to http://localhost:3001/demo/contractor/auth and click 'Sarah Johnson'"
+else
+ echo -e "${GREEN}✅ User is authenticated${NC}"
+ echo "Session: $SESSION_RESPONSE"
+fi
+echo ""
+
+# Test 4: Check backend API with dummy data
+echo -e "${BLUE}Test 4: Backend API Endpoint${NC}"
+API_RESPONSE=$(curl -s "http://localhost:3001/api/tableau/workbooks?siteId=test&userId=test&restToken=test&page=1&pageSize=5")
+if echo "$API_RESPONSE" | grep -q "401"; then
+ echo -e "${GREEN}✅ Backend API is working (401 expected with dummy token)${NC}"
+else
+ echo -e "${RED}❌ Backend API issue${NC}"
+ echo "Response: $API_RESPONSE"
+fi
+echo ""
+
+# Test 5: Check safety dashboard
+echo -e "${BLUE}Test 5: Safety Dashboard Page${NC}"
+if curl -s "http://localhost:3001/demo/contractor/safety" | grep -q "No Dashboards Available"; then
+ echo -e "${YELLOW}⚠️ Safety dashboard shows 'No Dashboards Available' (expected before authentication)${NC}"
+else
+ echo -e "${GREEN}✅ Safety dashboard is loading${NC}"
+fi
+echo ""
+
+echo "🎯 NEXT STEPS:"
+echo "=============="
+echo "1. 🔑 Go to: http://localhost:3001/demo/contractor/auth"
+echo "2. 👤 Click on 'Sarah Johnson' to authenticate"
+echo "3. 📊 Navigate to: http://localhost:3001/demo/contractor/safety"
+echo "4. ✨ You should see workbooks in the navigation sidebar"
+echo ""
+echo "If you see workbooks after authentication, the system is working! 🎉"