RC1 Asm app latest changes

This commit is contained in:
Duradundi 2026-08-24 07:43:54 +00:00
parent abc533cf86
commit 74243f4d34
106 changed files with 13608 additions and 3366 deletions

View File

@ -2,8 +2,8 @@
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/png" href="/seera-logo.png?v=1765198405" />
<link rel="apple-touch-icon" href="/seera-logo.png?v=1765198405" />
<link rel="icon" type="image/png" href="/seera-logo.png?v=1785757497" />
<link rel="apple-touch-icon" href="/seera-logo.png?v=1785757497" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content="Seera Arabia Asset Management System" />
<title>Seera Arabia - Asset Management System</title>

View File

@ -12,6 +12,7 @@
"@types/react-router-dom": "^5.3.3",
"axios": "^1.12.2",
"frappe-react-sdk": "^1.13.0",
"html5-qrcode": "^2.3.8",
"i18next": "^25.7.2",
"i18next-browser-languagedetector": "^8.2.0",
"leaflet": "^1.9.4",
@ -2437,6 +2438,12 @@
"void-elements": "3.1.0"
}
},
"node_modules/html5-qrcode": {
"version": "2.3.8",
"resolved": "https://registry.npmjs.org/html5-qrcode/-/html5-qrcode-2.3.8.tgz",
"integrity": "sha512-jsr4vafJhwoLVEDW3n1KvPnCCXWaQfRng0/EEYk1vNcQGcG/htAdhJX0be8YyqMoSz7+hZvOZSTAepsabiuhiQ==",
"license": "Apache-2.0"
},
"node_modules/i18next": {
"version": "25.7.2",
"resolved": "https://registry.npmjs.org/i18next/-/i18next-25.7.2.tgz",

View File

@ -16,6 +16,7 @@
"@types/react-router-dom": "^5.3.3",
"axios": "^1.12.2",
"frappe-react-sdk": "^1.13.0",
"html5-qrcode": "^2.3.8",
"i18next": "^25.7.2",
"i18next-browser-languagedetector": "^8.2.0",
"leaflet": "^1.9.4",

View File

@ -18,11 +18,12 @@
// export default App;
import React from 'react';
import { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router-dom';
import React, { useEffect } from 'react';
import { BrowserRouter as Router, Routes, Route, Navigate, useLocation } from 'react-router-dom';
import Login from './pages/Login';
import Dashboard from './pages/Dashboard';
import ModernDashboard from './pages/ModernDashboard';
import WoFeedbackPage from './pages/WoFeedbackPage';
import UsersList from './pages/UsersList';
import EventsList from './pages/EventsList';
import AssetList from './pages/AssetList';
@ -39,28 +40,68 @@ import PPMPlannerDetail from './pages/PPMPlannerDetail';
import MaintenanceCalendarPage from './pages/MaintenanceCalendarPage';
import YearlyPPMPlannerPage from './pages/YearlyPPMPlannerPage';
import ActiveMap from './pages/ActiveMap';
import AppEntry from './components/AppEntry';
import ItemList from './pages/ItemList';
import ItemDetail from './pages/ItemDetail';
import ComingSoon from './pages/ComingSoon';
import Sidebar from './components/Sidebar';
import Header from './components/Header';
import { useLanguage } from './contexts/LanguageContext';
import { SidebarLayoutProvider, useSidebarLayout } from './contexts/SidebarLayoutContext';
import IssueList from './pages/IssueList';
import IssueDetail from './pages/IssueDetail';
import MaintenanceTeamList from './pages/MaintenanceTeamList';
import MaintenanceTeamDetail from './pages/MaintenanceTeamDetail';
import UserProfilePage from './pages/UserProfilePage';
import { getDefaultRoute, getStoredRoleProfile, isEndUserRole } from './utils/roleAccess';
// Layout with Sidebar and Header
const LayoutWithSidebar: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const LayoutWithSidebarInner: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const location = useLocation();
const { isRTL } = useLanguage();
const { mobileOpen, closeMobileSidebar } = useSidebarLayout();
const user = localStorage.getItem('user');
const userEmail = user ? JSON.parse(user).email : '';
useEffect(() => {
const mq = window.matchMedia('(min-width: 1024px)');
const handleChange = () => {
if (mq.matches) closeMobileSidebar();
};
handleChange();
mq.addEventListener('change', handleChange);
return () => mq.removeEventListener('change', handleChange);
}, [closeMobileSidebar]);
return (
<div className="flex h-screen overflow-hidden bg-gray-50 dark:bg-gray-900">
<Sidebar userEmail={userEmail} />
<div className="flex-1 flex flex-col overflow-hidden">
{mobileOpen && (
<button
type="button"
className="fixed inset-0 z-40 bg-black/50 lg:hidden"
onClick={closeMobileSidebar}
aria-label="Close menu"
/>
)}
<div
className={`
fixed top-0 z-50 h-full transition-transform duration-300 ease-in-out lg:static lg:z-auto lg:translate-x-0
${isRTL
? `right-0 ${mobileOpen ? 'translate-x-0' : 'translate-x-full'}`
: `left-0 ${mobileOpen ? 'translate-x-0' : '-translate-x-full'}`
}
`}
>
<Sidebar userEmail={userEmail} />
</div>
<div className="flex min-w-0 flex-1 flex-col overflow-hidden">
<Header userEmail={userEmail} />
<div className="flex-1 overflow-y-auto bg-gray-50 dark:bg-gray-900">
<div
key={location.pathname}
className="flex-1 overflow-y-auto bg-gray-50 dark:bg-gray-900"
>
{children}
</div>
</div>
@ -68,12 +109,25 @@ const LayoutWithSidebar: React.FC<{ children: React.ReactNode }> = ({ children }
);
};
const LayoutWithSidebar: React.FC<{ children: React.ReactNode }> = ({ children }) => (
<SidebarLayoutProvider>
<LayoutWithSidebarInner>{children}</LayoutWithSidebarInner>
</SidebarLayoutProvider>
);
// Protected Route Component
const ProtectedRoute: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const user = localStorage.getItem('user');
return user ? <>{children}</> : <Navigate to="/login" replace />;
};
const EndUserDashboardGuard: React.FC<{ children: React.ReactNode }> = ({ children }) => {
if (isEndUserRole(getStoredRoleProfile())) {
return <Navigate to={getDefaultRoute(getStoredRoleProfile())} replace />;
}
return <>{children}</>;
};
const App: React.FC = () => {
return (
<Router basename="/asm_app">
@ -82,9 +136,14 @@ const App: React.FC = () => {
<Route path="/dashboard" element={
<ProtectedRoute>
<LayoutWithSidebar><ModernDashboard /></LayoutWithSidebar>
<EndUserDashboardGuard>
<LayoutWithSidebar><ModernDashboard /></LayoutWithSidebar>
</EndUserDashboardGuard>
</ProtectedRoute>
} />
<Route path="/wo-feedback" element={<WoFeedbackPage />} />
<Route path="/work-order-feedback" element={<Navigate to="/wo-feedback" replace />} />
<Route path="/assets" element={
<ProtectedRoute>
@ -260,7 +319,8 @@ const App: React.FC = () => {
{/* Default redirect */}
<Route path="/" element={<Navigate to="/login" replace />} />
<Route index element={<AppEntry />} />
<Route path="/" element={<AppEntry />} />
<Route path="*" element={<Navigate to="/login" replace />} />
</Routes>
</Router>

View File

@ -0,0 +1,406 @@
import React, { useState } from 'react';
import {
FaHistory,
FaSync,
FaChevronDown,
FaChevronUp,
FaUser,
FaClock,
FaCheckCircle,
FaSpinner,
} from 'react-icons/fa';
import { useAuditLogs } from '../hooks/useAuditLogs';
import type { AuditLogEntry } from '../hooks/useAuditLogs';
interface ActivityLogProps {
doctype: string;
docname: string | null;
creationDate?: string;
createdBy?: string;
title?: string;
limit?: number;
initialVisible?: number;
collapsible?: boolean;
startCollapsed?: boolean;
compact?: boolean;
className?: string;
onRefresh?: () => void;
}
const formatFieldName = (fieldName: string): string => {
if (!fieldName) return '';
return fieldName
.replace(/^custom_/, '')
.replace(/_/g, ' ')
.replace(/\b\w/g, (char) => char.toUpperCase());
};
const formatValue = (value: any): string => {
if (value === null || value === undefined) return '(empty)';
if (value === '') return '(empty)';
if (value === 0) return '0';
if (value === 1) return '1';
if (typeof value === 'boolean') return value ? 'Yes' : 'No';
if (typeof value === 'object') return JSON.stringify(value);
return String(value);
};
const formatAuditDate = (dateStr: string): string => {
if (!dateStr) return '';
const date = new Date(dateStr);
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffMins = Math.floor(diffMs / 60000);
const diffHours = Math.floor(diffMs / 3600000);
const diffDays = Math.floor(diffMs / 86400000);
if (diffMins < 1) return 'Just now';
if (diffMins < 60) return `${diffMins} min${diffMins > 1 ? 's' : ''} ago`;
if (diffHours < 24) return `${diffHours} hour${diffHours > 1 ? 's' : ''} ago`;
if (diffDays < 7) return `${diffDays} day${diffDays > 1 ? 's' : ''} ago`;
return date.toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: date.getFullYear() !== now.getFullYear() ? 'numeric' : undefined,
hour: '2-digit',
minute: '2-digit',
});
};
const formatUsername = (email: string): string => {
if (!email) return 'Unknown';
const atIndex = email.indexOf('@');
if (atIndex === -1) return email;
return email.substring(0, atIndex);
};
const getChangeColor = (fieldName: string): string => {
const lower = fieldName.toLowerCase();
if (lower.includes('status') || lower.includes('state') || lower.includes('workflow')) {
return 'text-purple-600 dark:text-purple-400';
}
if (lower.includes('date')) {
return 'text-blue-600 dark:text-blue-400';
}
if (
lower.includes('technician') ||
lower.includes('supervisor') ||
lower.includes('assigned') ||
lower.includes('location') ||
lower.includes('department') ||
lower.includes('building') ||
lower.includes('room')
) {
return 'text-green-600 dark:text-green-400';
}
return 'text-gray-600 dark:text-gray-400';
};
const TimelineEntry: React.FC<{
log: AuditLogEntry;
isLatest: boolean;
compact: boolean;
}> = ({ log, isLatest, compact }) => {
const dotSize = compact ? 'w-2.5 h-2.5' : 'w-3 h-3';
const avatarSize = compact ? 'w-5 h-5' : 'w-6 h-6';
const iconSize = compact ? 8 : 10;
const textSize = compact ? 'text-[10px]' : 'text-xs';
const valueSize = compact ? 'text-[9px]' : 'text-[10px]';
return (
<div className={`relative ${compact ? 'pl-6' : 'pl-8'}`}>
<div
className={`absolute ${compact ? 'left-1' : 'left-1.5'} top-1.5 ${dotSize} rounded-full border-2 border-white dark:border-gray-800 ${
isLatest ? 'bg-blue-500' : 'bg-gray-300 dark:bg-gray-600'
}`}
/>
<div
className={`${compact ? 'p-2' : 'p-3'} rounded-lg ${
isLatest
? 'bg-blue-50 dark:bg-blue-900/20 border border-blue-100 dark:border-blue-800/50'
: 'bg-gray-50 dark:bg-gray-700/50'
}`}
>
<div className="flex items-center justify-between mb-1.5">
<div className="flex items-center gap-1.5">
<div
className={`${avatarSize} rounded-full bg-gray-200 dark:bg-gray-600 flex items-center justify-center`}
>
<FaUser className="text-gray-500 dark:text-gray-400" size={iconSize} />
</div>
<span className={`${textSize} font-medium text-gray-700 dark:text-gray-300`}>
{formatUsername(log.owner)}
</span>
</div>
<div className={`flex items-center gap-1 ${textSize} text-gray-500 dark:text-gray-400`}>
<FaClock size={iconSize} />
<span title={new Date(log.creation).toLocaleString()}>
{formatAuditDate(log.creation)}
</span>
</div>
</div>
<div className="space-y-1">
{log.changes.length > 0 ? (
log.changes.map((change, i) => (
<div key={i} className={textSize}>
<span className={`font-medium ${getChangeColor(change.field)}`}>
{formatFieldName(change.field)}
</span>
<span className="text-gray-500 dark:text-gray-400"> changed from </span>
<span
className={`px-1 py-0.5 bg-red-100 dark:bg-red-900/30 text-red-600 dark:text-red-400 rounded ${valueSize} font-mono`}
>
{formatValue(change.oldValue)}
</span>
<span className="text-gray-500 dark:text-gray-400"> </span>
<span
className={`px-1 py-0.5 bg-green-100 dark:bg-green-900/30 text-green-600 dark:text-green-400 rounded ${valueSize} font-mono`}
>
{formatValue(change.newValue)}
</span>
</div>
))
) : (
<p className={`${textSize} text-gray-500 dark:text-gray-400 italic`}>Document updated</p>
)}
{log.added && log.added.length > 0 && (
<div className={`${textSize} text-green-600 dark:text-green-400`}>
<span className="font-medium">Added:</span> {log.added.length} item(s)
</div>
)}
{log.removed && log.removed.length > 0 && (
<div className={`${textSize} text-red-600 dark:text-red-400`}>
<span className="font-medium">Removed:</span> {log.removed.length} item(s)
</div>
)}
{log.rowChanged && log.rowChanged.length > 0 && (
<div className={`${textSize} text-orange-600 dark:text-orange-400`}>
<span className="font-medium">Modified:</span> {log.rowChanged.length} row(s)
</div>
)}
</div>
</div>
</div>
);
};
const CreatedEntry: React.FC<{
creationDate: string;
createdBy: string;
doctype: string;
compact: boolean;
}> = ({ creationDate, createdBy, doctype, compact }) => {
const dotSize = compact ? 'w-2.5 h-2.5' : 'w-3 h-3';
const avatarSize = compact ? 'w-5 h-5' : 'w-6 h-6';
const iconSize = compact ? 8 : 10;
const textSize = compact ? 'text-[10px]' : 'text-xs';
const displayDoctype = doctype.replace(/_/g, ' ');
return (
<div className={`relative ${compact ? 'pl-6' : 'pl-8'}`}>
<div
className={`absolute ${compact ? 'left-1' : 'left-1.5'} top-1.5 ${dotSize} rounded-full border-2 border-white dark:border-gray-800 bg-green-500`}
/>
<div
className={`${compact ? 'p-2' : 'p-3'} rounded-lg bg-green-50 dark:bg-green-900/20 border border-green-100 dark:border-green-800/50`}
>
<div className="flex items-center justify-between mb-1">
<div className="flex items-center gap-1.5">
<div
className={`${avatarSize} rounded-full bg-green-200 dark:bg-green-800 flex items-center justify-center`}
>
<FaUser className="text-green-600 dark:text-green-400" size={iconSize} />
</div>
<span className={`${textSize} font-medium text-gray-700 dark:text-gray-300`}>
{formatUsername(createdBy)}
</span>
</div>
<div className={`flex items-center gap-1 ${textSize} text-gray-500 dark:text-gray-400`}>
<FaClock size={iconSize} />
<span title={new Date(creationDate).toLocaleString()}>
{formatAuditDate(creationDate)}
</span>
</div>
</div>
<span
className={`inline-flex items-center gap-1 px-1.5 py-0.5 bg-green-100 dark:bg-green-800/50 text-green-700 dark:text-green-300 rounded ${textSize} font-medium`}
>
<FaCheckCircle size={iconSize} />
Created this {displayDoctype}
</span>
</div>
</div>
);
};
const ActivityLog: React.FC<ActivityLogProps> = ({
doctype,
docname,
creationDate,
createdBy,
title = 'Activity Log',
limit = 50,
initialVisible = 5,
collapsible = true,
startCollapsed = false,
compact = false,
className = '',
onRefresh,
}) => {
const [isExpanded, setIsExpanded] = useState(!startCollapsed);
const [showAll, setShowAll] = useState(false);
const { auditLogs, loading, refetch } = useAuditLogs({
doctype,
docname,
limit,
enabled: !!docname,
});
const handleRefresh = () => {
refetch();
onRefresh?.();
};
if (!docname) return null;
const headerIconSize = compact ? 14 : 16;
const headerTextClass = compact ? 'text-sm' : 'text-base';
const timelineLineLeft = compact ? 'left-2' : 'left-3';
const showMoreTextSize = compact ? 'text-[10px]' : 'text-xs';
const showMoreIconSize = compact ? 8 : 10;
const visibleLogs = showAll ? auditLogs : auditLogs.slice(0, initialVisible);
return (
<div
className={`bg-white dark:bg-gray-800 rounded-xl shadow-md border border-gray-200 dark:border-gray-700 overflow-hidden ${className}`}
>
<div className="flex items-center justify-between p-3 border-b border-gray-200 dark:border-gray-700">
<div
className={`flex items-center gap-2 flex-1 ${collapsible ? 'cursor-pointer' : ''}`}
onClick={() => collapsible && setIsExpanded(!isExpanded)}
>
<FaHistory className="text-blue-500" size={headerIconSize} />
<h2 className={`${headerTextClass} font-semibold text-gray-800 dark:text-white`}>
{title}
</h2>
{auditLogs.length > 0 && (
<span className="px-1.5 py-0.5 bg-blue-100 dark:bg-blue-900/30 text-blue-600 dark:text-blue-400 rounded-full text-[10px] font-medium">
{auditLogs.length}
</span>
)}
</div>
<div className="flex items-center gap-1">
<button
type="button"
onClick={(e) => {
e.stopPropagation();
handleRefresh();
}}
disabled={loading}
className="p-1 text-gray-400 hover:text-blue-500 hover:bg-blue-50 dark:hover:bg-blue-900/20 rounded transition-colors disabled:opacity-50"
title="Refresh activity log"
>
<FaSync className={loading ? 'animate-spin' : ''} size={compact ? 10 : 12} />
</button>
{collapsible && (
<button
type="button"
onClick={() => setIsExpanded(!isExpanded)}
className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors p-1"
>
{isExpanded ? (
<FaChevronUp size={compact ? 12 : 14} />
) : (
<FaChevronDown size={compact ? 12 : 14} />
)}
</button>
)}
</div>
</div>
{isExpanded && (
<div className="p-3">
{loading && (
<div className="flex items-center justify-center py-6">
<FaSpinner className="animate-spin text-blue-500 mr-2" size={14} />
<span className="text-xs text-gray-500 dark:text-gray-400">Loading...</span>
</div>
)}
{!loading && auditLogs.length === 0 && (
<div className="relative">
<div className={`absolute ${timelineLineLeft} top-0 bottom-0 w-0.5 bg-gray-200 dark:bg-gray-700`} />
<div className={`relative ${compact ? 'pl-6' : 'pl-8'} mb-3`}>
<div
className={`absolute ${compact ? 'left-1' : 'left-1.5'} top-1 ${compact ? 'w-2.5 h-2.5' : 'w-3 h-3'} rounded-full border-2 border-white dark:border-gray-800 bg-gray-300 dark:bg-gray-600`}
/>
<div className={`${compact ? 'p-2' : 'p-3'} rounded-lg bg-gray-50 dark:bg-gray-700/50`}>
<p className={`${compact ? 'text-[10px]' : 'text-xs'} text-gray-500 dark:text-gray-400 italic`}>
No changes recorded yet
</p>
</div>
</div>
{creationDate && createdBy && (
<CreatedEntry
creationDate={creationDate}
createdBy={createdBy}
doctype={doctype}
compact={compact}
/>
)}
</div>
)}
{!loading && auditLogs.length > 0 && (
<div className="relative">
<div className={`absolute ${timelineLineLeft} top-0 bottom-0 w-0.5 bg-gray-200 dark:bg-gray-700`} />
<div className="space-y-3">
{visibleLogs.map((log, index) => (
<TimelineEntry
key={log.name}
log={log}
isLatest={index === 0}
compact={compact}
/>
))}
</div>
{auditLogs.length > initialVisible && (
<div className="mt-3 text-center">
<button
type="button"
onClick={() => setShowAll(!showAll)}
className={`inline-flex items-center gap-1 px-2 py-1 ${showMoreTextSize} font-medium text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300 hover:bg-blue-50 dark:hover:bg-blue-900/20 rounded-md transition-colors`}
>
{showAll ? (
<>
<FaChevronUp size={showMoreIconSize} /> Show Less
</>
) : (
<>
<FaChevronDown size={showMoreIconSize} /> Show All ({auditLogs.length})
</>
)}
</button>
</div>
)}
{creationDate && createdBy && (
<div className="mt-3">
<CreatedEntry
creationDate={creationDate}
createdBy={createdBy}
doctype={doctype}
compact={compact}
/>
</div>
)}
</div>
)}
</div>
)}
</div>
);
};
export default ActivityLog;

View File

@ -0,0 +1,46 @@
import React, { useEffect, useState } from 'react';
import { Navigate, useNavigate } from 'react-router-dom';
import { resolveAuthenticatedRoute } from '../utils/appRouting';
const AppEntry: React.FC = () => {
const navigate = useNavigate();
const [target, setTarget] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
resolveAuthenticatedRoute()
.then((route) => {
if (cancelled) return;
if (route) {
setTarget(route);
} else {
navigate('/login', { replace: true });
}
})
.catch(() => {
if (!cancelled) {
navigate('/login', { replace: true });
}
});
return () => {
cancelled = true;
};
}, [navigate]);
if (target) {
return <Navigate to={target} replace />;
}
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50 dark:bg-gray-900">
<div className="text-center">
<div className="animate-spin rounded-full h-10 w-10 border-b-2 border-indigo-600 mx-auto" />
<p className="mt-4 text-gray-600 dark:text-gray-400 text-sm">Loading...</p>
</div>
</div>
);
};
export default AppEntry;

View File

@ -0,0 +1,288 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { FaBoxes, FaExternalLinkAlt, FaSync, FaTimes } from 'react-icons/fa';
import { useTranslation } from 'react-i18next';
import type { PieChartData } from './PieChart';
import { buildAssetUrl } from '../utils/buildAssetUrl';
import type { DashboardLocationFilters } from '../utils/hospitalUtils';
import type { AssetDeviceStatusSummaryFilters } from '../services/assetDeviceStatusService';
import {
compactFilterFieldWrapClass,
compactFilterInputClass,
compactFilterLabelClass,
compactToolbarBtnPrimary,
} from '../utils/reportModalStyles';
export type AssetStatusReportRow = {
name: string;
asset_name?: string;
custom_device_status?: string;
location?: string;
department?: string;
company?: string;
custom_site?: string;
};
type AssetStatusRefreshResult = {
chart: PieChartData | null;
total_assets: number;
assets: AssetStatusReportRow[];
};
type AssetStatusReportModalProps = {
isOpen: boolean;
onClose: () => void;
chartData: PieChartData | null;
defaultFromDate?: string;
defaultToDate?: string;
locationFilters?: DashboardLocationFilters;
onRefresh?: (filters: AssetDeviceStatusSummaryFilters) => Promise<AssetStatusRefreshResult>;
};
type StatusCardStyle = {
bg: string;
border: string;
text: string;
bar: string;
};
const getStatusCardStyle = (label: string, chartColor?: string): StatusCardStyle => {
const key = (label || '').trim().toLowerCase();
if (key === 'up') {
return { bg: 'bg-emerald-50 dark:bg-emerald-900/20', border: 'border-emerald-200 dark:border-emerald-800', text: 'text-emerald-700 dark:text-emerald-300', bar: '#10B981' };
}
if (key === 'down') {
return { bg: 'bg-red-50 dark:bg-red-900/20', border: 'border-red-200 dark:border-red-800', text: 'text-red-700 dark:text-red-300', bar: '#EF4444' };
}
if (key.includes('maintenance')) {
return { bg: 'bg-amber-50 dark:bg-amber-900/20', border: 'border-amber-200 dark:border-amber-800', text: 'text-amber-700 dark:text-amber-300', bar: '#F59E0B' };
}
if (key.includes('decommission')) {
return { bg: 'bg-gray-50 dark:bg-gray-800', border: 'border-gray-200 dark:border-gray-700', text: 'text-gray-600 dark:text-gray-300', bar: '#6B7280' };
}
return {
bg: 'bg-indigo-50 dark:bg-indigo-900/20',
border: 'border-indigo-200 dark:border-indigo-800',
text: 'text-indigo-700 dark:text-indigo-300',
bar: chartColor || '#6366F1',
};
};
const APP_BASE = '/asm_app';
const AssetStatusReportModal: React.FC<AssetStatusReportModalProps> = ({
isOpen,
onClose,
chartData,
defaultFromDate = '',
defaultToDate = '',
locationFilters,
onRefresh,
}) => {
const { t } = useTranslation();
const [loading, setLoading] = useState(false);
const [fromDate, setFromDate] = useState(defaultFromDate);
const [toDate, setToDate] = useState(defaultToDate);
const [localChartData, setLocalChartData] = useState<PieChartData | null>(chartData);
useEffect(() => {
if (!isOpen) return;
setFromDate(defaultFromDate);
setToDate(defaultToDate);
setLocalChartData(chartData);
}, [isOpen, defaultFromDate, defaultToDate, chartData]);
const buildFilters = useCallback((): AssetDeviceStatusSummaryFilters => {
const filters: AssetDeviceStatusSummaryFilters = {};
if (locationFilters?.company) filters.company = locationFilters.company;
if (locationFilters?.site_name) filters.site_name = locationFilters.site_name;
if (fromDate) filters.from_date = fromDate;
if (toDate) filters.to_date = toDate;
return filters;
}, [fromDate, toDate, locationFilters]);
const loadData = useCallback(async () => {
if (!onRefresh) return;
setLoading(true);
try {
const result = await onRefresh(buildFilters());
setLocalChartData(result.chart);
} catch (err) {
console.error('Failed to load asset status overview:', err);
setLocalChartData(null);
} finally {
setLoading(false);
}
}, [onRefresh, buildFilters]);
useEffect(() => {
if (!isOpen || !onRefresh) return;
loadData();
}, [isOpen, onRefresh]); // eslint-disable-line react-hooks/exhaustive-deps
const totalAssets = localChartData?.total ?? 0;
const statusRows = useMemo(() => {
const labels = localChartData?.labels || [];
const values = localChartData?.datasets?.[0]?.values || [];
const colors = localChartData?.datasets?.[0]?.colors || [];
return labels.map((label, index) => ({
label,
count: Number(values[index]) || 0,
color: colors[index],
percentage: totalAssets > 0 ? ((Number(values[index]) || 0) / totalAssets) * 100 : 0,
}));
}, [localChartData, totalAssets]);
const navigateToAssets = useCallback(
(deviceStatus?: string) => {
const path = buildAssetUrl(
{ device_status: deviceStatus, ...locationFilters },
fromDate,
toDate,
locationFilters
);
window.location.href = `${APP_BASE}${path}`;
},
[fromDate, toDate, locationFilters]
);
if (!isOpen) return null;
return (
<div className="fixed inset-0 z-[80] flex items-center justify-center p-4">
<button type="button" className="absolute inset-0 bg-black/50 backdrop-blur-sm" onClick={onClose} aria-label="Close" />
<div className="relative w-full max-w-2xl max-h-[90vh] overflow-hidden rounded-xl bg-white dark:bg-gray-900 shadow-2xl flex flex-col">
<div className="bg-gradient-to-r from-teal-500 to-emerald-600 text-white px-4 py-3">
<div className="flex items-start justify-between gap-3">
<div className="flex items-center gap-2 min-w-0">
<div className="p-1.5 rounded-lg bg-white/15">
<FaBoxes className="w-4 h-4" />
</div>
<div>
<h2 className="text-base font-semibold">
{t('dashboard.assetStatusOverview', { defaultValue: 'Asset Status Overview' })}
</h2>
<p className="text-xs text-white/85 mt-0.5">
{t('dashboard.clickStatusCardHint', { defaultValue: 'Click a card to view filtered assets' })}
</p>
</div>
</div>
<button type="button" onClick={onClose} className="p-1.5 rounded-lg hover:bg-white/10">
<FaTimes className="text-sm" />
</button>
</div>
</div>
<div className="px-3 py-2 bg-teal-50 dark:bg-teal-900/20 border-b border-teal-100 dark:border-teal-900/40">
<div className="flex flex-wrap items-end gap-2">
<div className={`${compactFilterFieldWrapClass} mb-0 w-[8.5rem]`}>
<label className={compactFilterLabelClass}>
{t('dashboard.fromDate', { defaultValue: 'From Date' })}
</label>
<input
type="date"
value={fromDate}
onChange={e => setFromDate(e.target.value)}
className={compactFilterInputClass}
/>
</div>
<div className={`${compactFilterFieldWrapClass} mb-0 w-[8.5rem]`}>
<label className={compactFilterLabelClass}>
{t('dashboard.toDate', { defaultValue: 'To Date' })}
</label>
<input
type="date"
value={toDate}
onChange={e => setToDate(e.target.value)}
className={compactFilterInputClass}
/>
</div>
<button
type="button"
onClick={loadData}
disabled={loading || !onRefresh}
className={`${compactToolbarBtnPrimary('emerald')} mb-0.5`}
>
<FaSync className={`text-[10px] ${loading ? 'animate-spin' : ''}`} />
{t('dashboard.refreshReport', { defaultValue: 'Refresh' })}
</button>
{(locationFilters?.company || locationFilters?.site_name) && (
<div className="flex flex-wrap gap-1.5 ml-auto pb-0.5">
{locationFilters?.company && (
<span className="px-2 py-0.5 rounded-full bg-white dark:bg-gray-800 border border-teal-200 dark:border-teal-800 text-teal-800 dark:text-teal-200 text-[10px]">
{locationFilters.company}
</span>
)}
{locationFilters?.site_name && (
<span className="px-2 py-0.5 rounded-full bg-white dark:bg-gray-800 border border-teal-200 dark:border-teal-800 text-teal-800 dark:text-teal-200 text-[10px]">
{locationFilters.site_name}
</span>
)}
</div>
)}
</div>
</div>
<div className="p-3 overflow-y-auto flex-1 space-y-3">
<button
type="button"
onClick={() => navigateToAssets()}
className="w-full text-left rounded-lg border border-indigo-200 dark:border-indigo-800 bg-indigo-50 dark:bg-indigo-900/20 p-3 hover:shadow-md transition-all group"
>
<div className="flex items-center justify-between gap-3">
<div>
<div className="text-xl font-bold text-indigo-700 dark:text-indigo-300">{totalAssets.toLocaleString()}</div>
<div className="text-xs font-medium text-indigo-600 dark:text-indigo-300 mt-0.5">
{t('dashboard.totalAssets', { defaultValue: 'Total Assets' })}
</div>
</div>
<span className="inline-flex items-center gap-1 text-[10px] font-semibold text-indigo-600 dark:text-indigo-300 group-hover:underline">
{t('dashboard.viewAll', { defaultValue: 'View all' })}
<FaExternalLinkAlt className="w-2.5 h-2.5" />
</span>
</div>
</button>
{loading ? (
<div className="py-8 flex justify-center">
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-teal-600" />
</div>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
{statusRows.map((row) => {
const style = getStatusCardStyle(row.label, row.color);
return (
<button
key={row.label}
type="button"
onClick={() => navigateToAssets(row.label)}
className={`text-left rounded-lg border p-3 transition-all hover:shadow-md group ${style.bg} ${style.border}`}
>
<div className={`text-lg font-bold ${style.text}`}>{row.count.toLocaleString()}</div>
<div className={`text-[10px] font-semibold uppercase tracking-wide mt-0.5 ${style.text}`}>{row.label}</div>
<div className="mt-2 h-1.5 rounded-full bg-white/70 dark:bg-gray-900/30 overflow-hidden">
<div className="h-full rounded-full" style={{ width: `${row.percentage}%`, backgroundColor: style.bar }} />
</div>
<div className="mt-1.5 flex items-center justify-between text-[10px] text-gray-600 dark:text-gray-400">
<span>{row.percentage.toFixed(1)}% {t('dashboard.ofTotal', { defaultValue: 'of total' })}</span>
<span className="opacity-0 group-hover:opacity-100 transition-opacity font-medium">
{t('dashboard.viewFiltered', { defaultValue: 'View ↗' })}
</span>
</div>
</button>
);
})}
</div>
)}
</div>
</div>
</div>
);
};
export default AssetStatusReportModal;

View File

@ -0,0 +1,60 @@
import React from 'react';
import { FaExternalLinkAlt } from 'react-icons/fa';
import { useTranslation } from 'react-i18next';
import PieChart, { type PieChartData } from './PieChart';
type AssetUpDownCardProps = {
title: string;
data: PieChartData | null;
loading?: boolean;
totalAssets: number;
onOpenReport: () => void;
};
const AssetUpDownCard: React.FC<AssetUpDownCardProps> = ({
title,
data,
loading = false,
totalAssets,
onOpenReport,
}) => {
const { t } = useTranslation();
return (
<div className="bg-white dark:bg-gray-800 rounded-lg shadow hover:shadow-md transition-all px-5 py-5 border border-gray-200 dark:border-gray-700">
<div className="flex items-center justify-between mb-4">
<div>
<h3 className="text-base font-semibold text-gray-900 dark:text-white">{title}</h3>
<p className="text-sm text-gray-600 dark:text-gray-400 mt-1">
{totalAssets.toLocaleString()} {t('dashboard.totalAssetsLabel', { defaultValue: 'Total' })}
</p>
</div>
<button
type="button"
onClick={onOpenReport}
className="p-2 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg text-teal-600 dark:text-teal-300"
title={t('dashboard.viewAssetStatusOverview', { defaultValue: 'View asset status overview' })}
>
<FaExternalLinkAlt className="w-4 h-4" />
</button>
</div>
{loading ? (
<div className="h-64 flex items-center justify-center">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-teal-600" />
</div>
) : !data?.labels?.length ? (
<div className="h-64 flex items-center justify-center text-gray-400 text-sm">
{t('dashboard.noAssetStatusData', { defaultValue: 'No asset status data' })}
</div>
) : (
<PieChart
data={data}
emptyMessage={t('dashboard.noAssetStatusData', { defaultValue: 'No asset status data' })}
/>
)}
</div>
);
};
export default AssetUpDownCard;

View File

@ -0,0 +1,667 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { FaSync, FaTimes } from 'react-icons/fa';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
import apiService from '../services/apiService';
import {
buildAssigneesStatusChartData,
getAssigneeCancelled,
getAssigneeCompleted,
getAssigneeName,
getAssigneeOverdue,
getAssigneePlanned,
getAssigneeTotal,
runAssigneesStatusReport,
} from '../services/assigneesStatusService';
import { buildMaintenanceUrl } from '../utils/buildMaintenanceUrl';
import { buildWoUrl } from '../utils/buildWoUrl';
import { getRepairCompletionRate } from '../utils/chartLabelUtils';
import type { DashboardLocationFilters } from '../utils/hospitalUtils';
type ChartDataset = {
name?: string;
values?: number[];
colors?: string[];
};
type ChartData = {
labels?: string[];
datasets?: ChartDataset[];
hideFooterTotal?: boolean;
rawRows?: Record<string, unknown>[];
};
type ChartDetailModalProps = {
isOpen: boolean;
onClose: () => void;
title: string;
initialData?: ChartData | null;
redirectContext?: 'wo_status' | 'wo_type' | 'assignees_status' | null;
defaultFromDate?: string;
defaultToDate?: string;
locationFilters?: DashboardLocationFilters;
workOrderType?: string;
};
const APP_BASE = '/asm_app';
const ChartDetailModal: React.FC<ChartDetailModalProps> = ({
isOpen,
onClose,
title,
initialData = null,
redirectContext = null,
defaultFromDate = '',
defaultToDate = '',
locationFilters,
workOrderType = '',
}) => {
const { t } = useTranslation();
const navigate = useNavigate();
const [fromDate, setFromDate] = useState(defaultFromDate);
const [toDate, setToDate] = useState(defaultToDate);
const [chartData, setChartData] = useState<ChartData | null>(initialData);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const buildMetricsFilters = useCallback(
(from: string, to: string) => {
const filters: Record<string, string> = {};
if (from) filters.from_date = from;
if (to) filters.to_date = to;
if (locationFilters?.company) filters.company = locationFilters.company;
if (locationFilters?.site_name) filters.site_name = locationFilters.site_name;
if (workOrderType) filters.work_order_type = workOrderType;
return filters;
},
[locationFilters, workOrderType]
);
const loadData = useCallback(
async (from: string, to: string) => {
if (redirectContext === 'wo_status') {
setLoading(true);
setError(null);
try {
const result = await apiService.getDashboardWorkOrderMetrics(buildMetricsFilters(from, to));
setChartData(result.work_order_chart);
} catch (err) {
setChartData(null);
setError(err instanceof Error ? err.message : 'Failed to load report data');
} finally {
setLoading(false);
}
return;
}
if (redirectContext === 'wo_type') {
setLoading(true);
setError(null);
try {
const result = await apiService.getDashboardWorkOrderMetrics(buildMetricsFilters(from, to));
const rates = result.completion_by_type?.rates || [];
setChartData({
labels: rates.map(item => item.type),
datasets: [
{
name: 'Completion Rate (%)',
values: rates.map(item => getRepairCompletionRate(item)),
},
{
name: 'Completed WOs',
values: rates.map(
item => item.completedCombined ?? (item.completed || 0) + (item.closed || 0)
),
},
{
name: 'In Progress',
values: rates.map(item => item.inProgress || 0),
},
{
name: 'Rejected',
values: rates.map(item => item.rejected || 0),
},
{
name: 'Total WOs',
values: rates.map(item => item.total || 0),
},
],
hideFooterTotal: true,
});
} catch (err) {
setChartData(null);
setError(err instanceof Error ? err.message : 'Failed to load report data');
} finally {
setLoading(false);
}
return;
}
if (redirectContext === 'assignees_status') {
setLoading(true);
setError(null);
try {
const rows = await runAssigneesStatusReport(buildMetricsFilters(from, to));
setChartData(buildAssigneesStatusChartData(rows));
} catch (err) {
setChartData(null);
setError(err instanceof Error ? err.message : 'Failed to load report data');
} finally {
setLoading(false);
}
return;
}
setChartData(initialData);
},
[redirectContext, initialData, buildMetricsFilters]
);
useEffect(() => {
if (!isOpen) return;
setFromDate(defaultFromDate);
setToDate(defaultToDate);
loadData(defaultFromDate, defaultToDate);
}, [isOpen, defaultFromDate, defaultToDate, loadData]);
useEffect(() => {
if (!isOpen) return;
const previousOverflow = document.body.style.overflow;
document.body.style.overflow = 'hidden';
return () => {
document.body.style.overflow = previousOverflow;
};
}, [isOpen]);
useEffect(() => {
if (!isOpen) return;
const onKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') onClose();
};
window.addEventListener('keydown', onKeyDown);
return () => window.removeEventListener('keydown', onKeyDown);
}, [isOpen, onClose]);
const statusRows = useMemo(() => {
const labels = chartData?.labels || [];
const values = chartData?.datasets?.[0]?.values || [];
const colors = chartData?.datasets?.[0]?.colors || [];
return labels.map((label, index) => ({
label,
value: Number(values[index]) || 0,
color: colors[index] || '#6366F1',
}));
}, [chartData]);
const typeRows = useMemo(() => {
const labels = chartData?.labels || [];
const datasets = chartData?.datasets || [];
return labels.map((label, rowIndex) => ({
label,
cells: datasets.map(dataset => Number(dataset.values?.[rowIndex]) || 0),
}));
}, [chartData]);
const typeColumns = chartData?.datasets?.map(dataset => dataset.name || '') || [];
const statusTotal = useMemo(() => statusRows.reduce((sum, row) => sum + row.value, 0), [statusRows]);
const assigneeRows = useMemo(() => {
const rawRows = chartData?.rawRows || [];
return rawRows.map(row => ({
assignee: getAssigneeName(row),
planned: getAssigneePlanned(row),
completed: getAssigneeCompleted(row),
cancelled: getAssigneeCancelled(row),
overdue: getAssigneeOverdue(row),
total: getAssigneeTotal(row),
}));
}, [chartData?.rawRows]);
const assigneeTotals = useMemo(
() =>
assigneeRows.reduce(
(acc, row) => {
acc.planned += row.planned;
acc.completed += row.completed;
acc.cancelled += row.cancelled;
acc.overdue += row.overdue;
acc.total += row.total;
return acc;
},
{ planned: 0, completed: 0, cancelled: 0, overdue: 0, total: 0 }
),
[assigneeRows]
);
const handleApply = () => {
loadData(fromDate, toDate);
};
const handleClearDates = () => {
setFromDate('');
setToDate('');
loadData('', '');
};
const navigateToWorkOrders = (params: {
work_order_type?: string;
status?: string;
}) => {
const path = buildWoUrl(
{
work_order_type: params.work_order_type,
status: params.status,
company: locationFilters?.company,
site_name: locationFilters?.site_name,
},
fromDate || undefined,
toDate || undefined,
locationFilters
);
onClose();
window.location.href = `${APP_BASE}${path}`;
};
const navigateToMaintenance = (assignee: string, status: string) => {
const path = buildMaintenanceUrl(
{ assignee, status },
fromDate || undefined,
toDate || undefined,
locationFilters
);
onClose();
window.location.href = `${APP_BASE}${path}`;
};
const handleAssigneeCellClick = (assignee: string, status: string, value: number) => {
if (redirectContext !== 'assignees_status' || value <= 0) return;
navigateToMaintenance(assignee, status);
};
const handleStatusRowClick = (status: string) => {
if (redirectContext !== 'wo_status') return;
onClose();
navigate(
buildWoUrl({ status }, fromDate || undefined, toDate || undefined, locationFilters)
);
};
const handleTypeCellClick = (rowLabel: string, columnName: string) => {
if (redirectContext !== 'wo_type') return;
if (columnName === 'Rejected') {
navigateToWorkOrders({ work_order_type: rowLabel, status: 'Rejected' });
return;
}
if (columnName === 'In Progress') {
navigateToWorkOrders({ work_order_type: rowLabel, status: 'Work In Progress' });
return;
}
if (columnName === 'Completed WOs' || columnName === 'Completion Rate (%)') {
navigateToWorkOrders({ work_order_type: rowLabel, status: 'Completed,Closed' });
return;
}
navigateToWorkOrders({ work_order_type: rowLabel });
};
const dateRangeLabel =
fromDate || toDate
? t('dashboard.activeDateRange', {
defaultValue: 'Date range: {{from}} to {{to}}',
from: fromDate || '—',
to: toDate || '—',
})
: t('dashboard.allDates', { defaultValue: 'Showing all dates' });
if (!isOpen) return null;
const isTypeMode = redirectContext === 'wo_type';
const isAssigneeMode = redirectContext === 'assignees_status' || !!(chartData?.rawRows?.length);
return (
<div className="fixed inset-0 z-[70] flex items-center justify-center p-4 bg-black/50 backdrop-blur-sm">
<div className="w-full max-w-4xl max-h-[90vh] overflow-hidden rounded-xl bg-white dark:bg-gray-900 shadow-2xl border border-gray-200 dark:border-gray-700 flex flex-col">
<div
className={`px-5 py-4 border-b border-gray-200 dark:border-gray-700 ${
isTypeMode || isAssigneeMode
? 'bg-gradient-to-r from-indigo-600 to-purple-600 text-white'
: ''
}`}
>
<div className="flex items-center justify-between gap-3">
<div>
<h3 className={`text-lg font-semibold ${isTypeMode || isAssigneeMode ? 'text-white' : 'text-gray-900 dark:text-white'}`}>
{title}
</h3>
<p
className={`text-xs mt-1 ${
isTypeMode || isAssigneeMode ? 'text-white/85' : 'text-indigo-600 dark:text-indigo-300'
}`}
>
{isTypeMode
? t('dashboard.clickRowToFilter', {
defaultValue: 'Click a row or value to view filtered work orders',
})
: dateRangeLabel}
</p>
</div>
<button
type="button"
onClick={onClose}
className={`p-2 rounded-lg ${
isTypeMode || isAssigneeMode
? 'hover:bg-white/10 text-white'
: 'hover:bg-gray-100 dark:hover:bg-gray-800 text-gray-500'
}`}
aria-label="Close"
>
<FaTimes />
</button>
</div>
</div>
<div className="px-5 py-3 border-b border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800/50">
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-3 items-end">
<div>
<label className="block text-xs font-medium text-gray-600 dark:text-gray-300 mb-1">
{t('dashboard.fromDate', { defaultValue: 'From Date' })}
</label>
<input
type="date"
value={fromDate}
onChange={e => setFromDate(e.target.value)}
className="w-full rounded-md border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-900 px-3 py-2 text-sm"
/>
</div>
<div>
<label className="block text-xs font-medium text-gray-600 dark:text-gray-300 mb-1">
{t('dashboard.toDate', { defaultValue: 'To Date' })}
</label>
<input
type="date"
value={toDate}
onChange={e => setToDate(e.target.value)}
className="w-full rounded-md border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-900 px-3 py-2 text-sm"
/>
</div>
<div className="flex flex-wrap gap-2 lg:col-span-2">
<button
type="button"
onClick={handleApply}
disabled={loading}
className="inline-flex items-center gap-2 px-3 py-2 rounded-md bg-indigo-600 hover:bg-indigo-700 text-white text-sm disabled:opacity-60"
>
<FaSync className={loading ? 'animate-spin' : ''} />
{t('dashboard.applyFilters', { defaultValue: 'Apply' })}
</button>
<button
type="button"
onClick={handleClearDates}
disabled={loading}
className="px-3 py-2 rounded-md border border-gray-300 dark:border-gray-600 text-sm text-gray-700 dark:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-700 disabled:opacity-60"
>
{t('dashboard.clearDates', { defaultValue: 'Clear dates' })}
</button>
</div>
</div>
{isTypeMode && (
<p className="text-xs text-gray-500 dark:text-gray-400 mt-2">{dateRangeLabel}</p>
)}
</div>
<div className="overflow-y-auto flex-1">
{error && (
<div className="mx-5 mt-4 rounded-md border border-red-200 bg-red-50 text-red-700 px-4 py-3 text-sm">
{error}
</div>
)}
{isTypeMode ? (
<table className="min-w-full text-sm">
<thead className="bg-gray-50 dark:bg-gray-800/80 sticky top-0">
<tr>
<th className="px-4 py-3 text-left font-semibold text-gray-700 dark:text-gray-200">
{t('dashboard.woType', { defaultValue: 'WO Type' })}
</th>
{typeColumns.map(column => (
<th
key={column}
className="px-4 py-3 text-right font-semibold text-gray-700 dark:text-gray-200 whitespace-nowrap"
>
{column}
</th>
))}
</tr>
</thead>
<tbody>
{loading ? (
<tr>
<td colSpan={typeColumns.length + 1} className="px-5 py-10 text-center text-gray-500">
{t('common.loading', { defaultValue: 'Loading...' })}
</td>
</tr>
) : typeRows.length === 0 ? (
<tr>
<td colSpan={typeColumns.length + 1} className="px-5 py-10 text-center text-gray-500">
{t('common.noData', { defaultValue: 'No data' })}
</td>
</tr>
) : (
typeRows.map(row => (
<tr key={row.label} className="border-t border-gray-100 dark:border-gray-800">
<td className="px-4 py-3">
<button
type="button"
onClick={() => handleTypeCellClick(row.label, 'Total WOs')}
className="text-left font-medium text-indigo-700 dark:text-indigo-300 hover:underline"
>
{row.label}
</button>
</td>
{row.cells.map((value, index) => (
<td key={`${row.label}-${typeColumns[index]}`} className="px-4 py-3 text-right">
<button
type="button"
onClick={() => handleTypeCellClick(row.label, typeColumns[index])}
className="font-semibold text-gray-900 dark:text-white hover:text-indigo-600 dark:hover:text-indigo-300"
>
{typeColumns[index] === 'Completion Rate (%)'
? `${value.toFixed(2)}%`
: value}
</button>
</td>
))}
</tr>
))
)}
</tbody>
</table>
) : isAssigneeMode ? (
<table className="min-w-full text-sm">
<thead className="bg-gray-50 dark:bg-gray-800/80 sticky top-0">
<tr>
<th className="px-4 py-3 text-left font-semibold text-gray-700 dark:text-gray-200">
{t('dashboard.assignedTo', { defaultValue: 'Assigned To' })}
</th>
<th className="px-4 py-3 text-right font-semibold text-indigo-600 dark:text-indigo-300">
{t('dashboard.plannedTasks', { defaultValue: 'Planned' })}
</th>
<th className="px-4 py-3 text-right font-semibold text-green-600 dark:text-green-400">
{t('dashboard.completedTasks', { defaultValue: 'Completed' })}
</th>
<th className="px-4 py-3 text-right font-semibold text-amber-600 dark:text-amber-400">
{t('dashboard.cancelledTasks', { defaultValue: 'Cancelled' })}
</th>
<th className="px-4 py-3 text-right font-semibold text-red-600 dark:text-red-400">
{t('dashboard.overdueTasks', { defaultValue: 'Overdue' })}
</th>
<th className="px-4 py-3 text-right font-semibold text-gray-700 dark:text-gray-200">
{t('dashboard.totalTasks', { defaultValue: 'Total' })}
</th>
</tr>
</thead>
<tbody>
{loading ? (
<tr>
<td colSpan={6} className="px-5 py-10 text-center text-gray-500">
{t('common.loading', { defaultValue: 'Loading...' })}
</td>
</tr>
) : assigneeRows.length === 0 ? (
<tr>
<td colSpan={6} className="px-5 py-10 text-center text-gray-500">
{t('common.noData', { defaultValue: 'No data' })}
</td>
</tr>
) : (
assigneeRows.map(row => (
<tr key={row.assignee} className="border-t border-gray-100 dark:border-gray-800">
<td className="px-4 py-3 text-gray-800 dark:text-gray-100">{row.assignee}</td>
<td className="px-4 py-3 text-right">
<AssigneeCountButton
value={row.planned}
onClick={() => handleAssigneeCellClick(row.assignee, 'Planned', row.planned)}
/>
</td>
<td className="px-4 py-3 text-right">
<AssigneeCountButton
value={row.completed}
onClick={() => handleAssigneeCellClick(row.assignee, 'Completed', row.completed)}
/>
</td>
<td className="px-4 py-3 text-right">
<AssigneeCountButton
value={row.cancelled}
onClick={() => handleAssigneeCellClick(row.assignee, 'Cancelled', row.cancelled)}
/>
</td>
<td className="px-4 py-3 text-right">
<AssigneeCountButton
value={row.overdue}
onClick={() => handleAssigneeCellClick(row.assignee, 'Overdue', row.overdue)}
/>
</td>
<td className="px-4 py-3 text-right font-semibold text-gray-900 dark:text-white">
{row.total}
</td>
</tr>
))
)}
</tbody>
{!loading && assigneeRows.length > 0 && (
<tfoot>
<tr className="border-t border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800/80">
<td className="px-4 py-3 font-semibold text-gray-800 dark:text-gray-100">
{t('dashboard.totalTasks', { defaultValue: 'Total' })}
</td>
<td className="px-4 py-3 text-right font-semibold text-indigo-600 dark:text-indigo-300">
{assigneeTotals.planned}
</td>
<td className="px-4 py-3 text-right font-semibold text-green-600 dark:text-green-400">
{assigneeTotals.completed}
</td>
<td className="px-4 py-3 text-right font-semibold text-amber-600 dark:text-amber-400">
{assigneeTotals.cancelled}
</td>
<td className="px-4 py-3 text-right font-semibold text-red-600 dark:text-red-400">
{assigneeTotals.overdue}
</td>
<td className="px-4 py-3 text-right font-semibold text-gray-900 dark:text-white">
{assigneeTotals.total}
</td>
</tr>
</tfoot>
)}
</table>
) : (
<table className="min-w-full text-sm">
<thead className="bg-gray-50 dark:bg-gray-800/80 sticky top-0">
<tr>
<th className="px-5 py-3 text-left font-semibold text-gray-700 dark:text-gray-200">
{t('commonFields.status', { defaultValue: 'Status' })}
</th>
<th className="px-5 py-3 text-right font-semibold text-gray-700 dark:text-gray-200">
{t('dashboard.workOrders', { defaultValue: 'Work Orders' })}
</th>
</tr>
</thead>
<tbody>
{loading ? (
<tr>
<td colSpan={2} className="px-5 py-10 text-center text-gray-500">
{t('common.loading', { defaultValue: 'Loading...' })}
</td>
</tr>
) : statusRows.length === 0 ? (
<tr>
<td colSpan={2} className="px-5 py-10 text-center text-gray-500">
{t('common.noData', { defaultValue: 'No data' })}
</td>
</tr>
) : (
statusRows.map(row => (
<tr
key={row.label}
className={`border-t border-gray-100 dark:border-gray-800 ${
redirectContext === 'wo_status'
? 'cursor-pointer hover:bg-indigo-50 dark:hover:bg-indigo-900/20'
: ''
}`}
onClick={() => handleStatusRowClick(row.label)}
>
<td className="px-5 py-3">
<div className="flex items-center gap-2">
<span
className="w-2.5 h-2.5 rounded-full flex-shrink-0"
style={{ backgroundColor: row.color }}
/>
<span className="text-gray-800 dark:text-gray-100">{row.label}</span>
</div>
</td>
<td className="px-5 py-3 text-right font-semibold text-gray-900 dark:text-white">
{row.value}
</td>
</tr>
))
)}
</tbody>
{!loading && statusRows.length > 0 && !chartData?.hideFooterTotal && (
<tfoot>
<tr className="border-t border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800/80">
<td className="px-5 py-3 font-semibold text-gray-800 dark:text-gray-100">
{t('dashboard.totalWorkOrders', { defaultValue: 'Total Work Orders' })}
</td>
<td className="px-5 py-3 text-right font-semibold text-gray-900 dark:text-white">
{statusTotal}
</td>
</tr>
</tfoot>
)}
</table>
)}
</div>
</div>
</div>
);
};
export default ChartDetailModal;
const AssigneeCountButton: React.FC<{ value: number; onClick: () => void }> = ({ value, onClick }) => {
if (value <= 0) {
return <span className="text-gray-400">0</span>;
}
return (
<button
type="button"
onClick={onClick}
className="font-semibold text-gray-900 dark:text-white hover:text-indigo-600 dark:hover:text-indigo-300"
>
{value}
</button>
);
};

View File

@ -0,0 +1,384 @@
import React, { useState, useMemo } from 'react';
import {
FaComments,
FaUser,
FaTrash,
FaClock,
FaSpinner,
FaSync,
FaChevronDown,
FaChevronUp,
FaExclamationTriangle,
FaCheckCircle,
FaTimesCircle,
FaInfoCircle,
FaPaperclip,
FaThumbsUp,
FaEdit,
} from 'react-icons/fa';
import { toast } from 'react-toastify';
import { useComments } from '../hooks/useComments';
import MentionInput from './MentionInput';
interface CommentSectionProps {
referenceDoctype: string;
referenceName: string | null;
title?: string;
pollInterval?: number;
initialLimit?: number;
collapsible?: boolean;
startCollapsed?: boolean;
}
const commentTypeMeta: Record<
string,
{ icon: React.ReactNode; color: string; label: string }
> = {
Comment: {
icon: <FaComments size={10} />,
color: 'text-blue-600 dark:text-blue-400',
label: 'Comment',
},
Info: {
icon: <FaInfoCircle size={10} />,
color: 'text-gray-500 dark:text-gray-400',
label: 'Info',
},
Edit: {
icon: <FaEdit size={10} />,
color: 'text-orange-500 dark:text-orange-400',
label: 'Edit',
},
Attachment: {
icon: <FaPaperclip size={10} />,
color: 'text-purple-500 dark:text-purple-400',
label: 'Attachment',
},
Like: {
icon: <FaThumbsUp size={10} />,
color: 'text-pink-500 dark:text-pink-400',
label: 'Like',
},
};
const timeAgo = (dateStr: string): string => {
if (!dateStr) return '';
const date = new Date(dateStr);
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const mins = Math.floor(diffMs / 60000);
const hrs = Math.floor(diffMs / 3600000);
const days = Math.floor(diffMs / 86400000);
if (mins < 1) return 'Just now';
if (mins < 60) return `${mins}m ago`;
if (hrs < 24) return `${hrs}h ago`;
if (days < 7) return `${days}d ago`;
return date.toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: date.getFullYear() !== now.getFullYear() ? 'numeric' : undefined,
});
};
const emailToName = (email: string): string => {
if (!email) return 'Unknown';
const at = email.indexOf('@');
if (at === -1) return email;
return email
.substring(0, at)
.replace(/[._-]/g, ' ')
.replace(/\b\w/g, (c) => c.toUpperCase());
};
const CommentContent: React.FC<{ html: string }> = ({ html }) => {
const cleaned = html
.replace(/<div class="ql-editor[^"]*">/g, '')
.replace(/<\/div>$/g, '');
return (
<div
className="comment-content text-sm text-gray-800 dark:text-gray-200 leading-relaxed
[&_a]:text-teal-600 [&_a]:dark:text-teal-400 [&_a]:underline [&_a]:font-medium
[&_.mention]:text-teal-700 [&_.mention]:dark:text-teal-300 [&_.mention]:font-semibold
[&_.mention]:bg-teal-50 [&_.mention]:dark:bg-teal-900/30 [&_.mention]:px-1 [&_.mention]:py-0.5
[&_.mention]:rounded [&_.mention]:pointer-events-none [&_.mention]:cursor-default
[&_.mention_a]:no-underline [&_.mention_a]:text-inherit
[&_p]:my-0"
dangerouslySetInnerHTML={{ __html: cleaned }}
/>
);
};
const CommentSection: React.FC<CommentSectionProps> = ({
referenceDoctype,
referenceName,
title = 'Comments & Discussion',
pollInterval = 30000,
initialLimit = 5,
collapsible = true,
startCollapsed = false,
}) => {
const {
comments,
loading,
posting,
error,
currentUser,
refetch,
postComment,
deleteComment,
mentionUsers,
mentionLoading,
searchMentionUsers,
} = useComments({
referenceDoctype,
referenceName,
pollInterval,
});
const [expanded, setExpanded] = useState(!startCollapsed);
const [showAll, setShowAll] = useState(false);
const [draftText, setDraftText] = useState('');
const [deletingId, setDeletingId] = useState<string | null>(null);
const userComments = useMemo(
() => comments.filter((c) => c.comment_type === 'Comment'),
[comments]
);
const visibleComments = useMemo(() => {
if (showAll) return comments;
return comments.slice(-initialLimit);
}, [comments, showAll, initialLimit]);
const handlePost = async (html: string) => {
try {
await postComment(html);
setDraftText('');
toast.success('Comment posted!', {
position: 'top-right',
autoClose: 2000,
icon: <FaCheckCircle />,
});
} catch (err: any) {
toast.error(`Failed to post comment: ${err.message || 'Unknown error'}`, {
position: 'top-right',
autoClose: 5000,
icon: <FaTimesCircle />,
});
}
};
const handleDelete = async (commentName: string) => {
setDeletingId(commentName);
try {
await deleteComment(commentName);
toast.success('Comment deleted', {
position: 'top-right',
autoClose: 2000,
icon: <FaCheckCircle />,
});
} catch (err: any) {
toast.error(`Failed to delete: ${err.message || 'Unknown error'}`, {
position: 'top-right',
autoClose: 5000,
icon: <FaTimesCircle />,
});
} finally {
setDeletingId(null);
}
};
if (!referenceName) {
return (
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-md p-6 border border-gray-200 dark:border-gray-700">
<div className="flex items-center gap-2 text-gray-500 dark:text-gray-400">
<FaComments className="text-gray-400" />
<span className="text-sm">Save the document first to enable comments.</span>
</div>
</div>
);
}
return (
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-md border border-gray-200 dark:border-gray-700 overflow-hidden">
<div
className={`flex items-center justify-between px-5 py-3 border-b border-gray-200 dark:border-gray-700 ${
collapsible ? 'cursor-pointer select-none' : ''
}`}
onClick={() => collapsible && setExpanded((v) => !v)}
>
<div className="flex items-center gap-2">
<FaComments className="text-teal-500" size={16} />
<h2 className="text-base font-semibold text-gray-800 dark:text-white">
{title}
</h2>
{userComments.length > 0 && (
<span className="px-2 py-0.5 bg-teal-100 dark:bg-teal-900/30 text-teal-700 dark:text-teal-300 rounded-full text-xs font-medium">
{userComments.length}
</span>
)}
</div>
<div className="flex items-center gap-2">
<button
type="button"
onClick={(e) => {
e.stopPropagation();
refetch();
}}
disabled={loading}
className="p-1.5 text-gray-400 hover:text-teal-500 hover:bg-teal-50 dark:hover:bg-teal-900/20 rounded transition-colors disabled:opacity-50"
title="Refresh comments"
>
<FaSync className={loading ? 'animate-spin' : ''} size={11} />
</button>
{collapsible && (
<span className="text-gray-400 dark:text-gray-500">
{expanded ? <FaChevronUp size={12} /> : <FaChevronDown size={12} />}
</span>
)}
</div>
</div>
{expanded && (
<div className="p-5 space-y-5">
{error && (
<div className="flex items-center gap-2 text-red-600 dark:text-red-400 text-sm bg-red-50 dark:bg-red-900/20 rounded-lg p-3">
<FaExclamationTriangle size={12} />
{error}
</div>
)}
{loading && comments.length === 0 && (
<div className="flex items-center justify-center py-8">
<FaSpinner className="animate-spin text-teal-500 mr-2" size={16} />
<span className="text-sm text-gray-500 dark:text-gray-400">Loading comments</span>
</div>
)}
{comments.length > 0 && (
<>
{!showAll && comments.length > initialLimit && (
<button
type="button"
onClick={() => setShowAll(true)}
className="w-full text-center py-2 text-xs text-teal-600 dark:text-teal-400
hover:bg-teal-50 dark:hover:bg-teal-900/20 rounded-lg transition-colors font-medium"
>
Show {comments.length - initialLimit} older comment{comments.length - initialLimit !== 1 ? 's' : ''}
</button>
)}
<div className="space-y-3">
{visibleComments.map((comment) => {
const meta = commentTypeMeta[comment.comment_type] || commentTypeMeta.Comment;
const isOwn = comment.comment_email === currentUser || comment.owner === currentUser;
const isDeleting = deletingId === comment.name;
return (
<div
key={comment.name}
className={`group relative rounded-lg border transition-colors ${
comment.comment_type === 'Comment'
? 'bg-white dark:bg-gray-800 border-gray-200 dark:border-gray-700 hover:border-gray-300 dark:hover:border-gray-600'
: 'bg-gray-50 dark:bg-gray-800/50 border-gray-100 dark:border-gray-700/50'
}`}
>
<div className="px-4 py-3">
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<div className="w-7 h-7 rounded-full bg-teal-100 dark:bg-teal-900/40 flex items-center justify-center flex-shrink-0">
<FaUser className="text-teal-600 dark:text-teal-400" size={10} />
</div>
<div className="min-w-0">
<span className="text-sm font-medium text-gray-800 dark:text-gray-200">
{comment.comment_by || emailToName(comment.comment_email || comment.owner)}
</span>
{comment.comment_type !== 'Comment' && (
<span className={`ml-2 inline-flex items-center gap-1 text-[10px] font-medium ${meta.color}`}>
{meta.icon}
{meta.label}
</span>
)}
</div>
</div>
<div className="flex items-center gap-2">
<span
className="text-[11px] text-gray-400 dark:text-gray-500 flex items-center gap-1"
title={new Date(comment.creation).toLocaleString()}
>
<FaClock size={9} />
{timeAgo(comment.creation)}
</span>
{isOwn && comment.comment_type === 'Comment' && (
<button
type="button"
onClick={() => handleDelete(comment.name)}
disabled={isDeleting}
className="opacity-0 group-hover:opacity-100 p-1 text-gray-400 hover:text-red-500
hover:bg-red-50 dark:hover:bg-red-900/20 rounded transition-all disabled:opacity-50"
title="Delete comment"
>
{isDeleting ? (
<FaSpinner className="animate-spin" size={10} />
) : (
<FaTrash size={10} />
)}
</button>
)}
</div>
</div>
<div className="ml-9">
<CommentContent html={comment.content} />
</div>
</div>
</div>
);
})}
</div>
{showAll && comments.length > initialLimit && (
<button
type="button"
onClick={() => setShowAll(false)}
className="w-full text-center py-2 text-xs text-teal-600 dark:text-teal-400
hover:bg-teal-50 dark:hover:bg-teal-900/20 rounded-lg transition-colors font-medium"
>
Show fewer comments
</button>
)}
</>
)}
{!loading && comments.length === 0 && (
<div className="text-center py-8">
<FaComments className="mx-auto text-gray-300 dark:text-gray-600 mb-2" size={28} />
<p className="text-sm text-gray-500 dark:text-gray-400">
No comments yet. Start the discussion!
</p>
</div>
)}
<div className="pt-3 border-t border-gray-200 dark:border-gray-700">
<MentionInput
value={draftText}
onChange={setDraftText}
onSubmit={handlePost}
disabled={!referenceName}
posting={posting}
mentionUsers={mentionUsers}
mentionLoading={mentionLoading}
onMentionSearch={searchMentionUsers}
/>
</div>
</div>
)}
</div>
);
};
export default CommentSection;

View File

@ -3,20 +3,33 @@ import { useNavigate, useLocation } from 'react-router-dom';
import { useTheme } from '../contexts/ThemeContext';
import { useLanguage } from '../contexts/LanguageContext';
import { useTranslation } from 'react-i18next';
import { Moon, Sun, Languages, LogOut, UserCircle } from 'lucide-react';
import { Moon, Sun, Languages, LogOut, UserCircle, Menu } from 'lucide-react';
import NotificationBell from './NotificationBell';
import { useSidebarLayout } from '../contexts/SidebarLayoutContext';
interface HeaderProps {
userEmail?: string;
}
const getStoredUserDisplayName = (): string => {
try {
const user = localStorage.getItem('user');
if (!user) return '';
const parsed = JSON.parse(user);
return parsed.full_name || parsed.email || parsed.user_id || parsed.name || '';
} catch {
return '';
}
};
const Header: React.FC<HeaderProps> = ({ userEmail }) => {
const navigate = useNavigate();
const location = useLocation();
const { theme, toggleTheme } = useTheme();
const { language, changeLanguage } = useLanguage();
const { t } = useTranslation();
const [userDisplayName, setUserDisplayName] = useState<string>('');
const [userDisplayName, setUserDisplayName] = useState<string>(getStoredUserDisplayName);
const { openMobileSidebar } = useSidebarLayout();
useEffect(() => {
const fetchUserDisplayName = async () => {
@ -86,20 +99,34 @@ const Header: React.FC<HeaderProps> = ({ userEmail }) => {
const isProfileActive = location.pathname === '/user-profile';
return (
<header className="h-14 bg-white dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700 px-4 flex items-center justify-end gap-2 flex-shrink-0">
<header className="h-14 bg-white dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700 px-4 flex items-center justify-between gap-2 flex-shrink-0">
<button
type="button"
onClick={openMobileSidebar}
className="lg:hidden p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors text-gray-700 dark:text-gray-300"
aria-label={t('common.menu', { defaultValue: 'Open menu' })}
>
<Menu size={20} />
</button>
<div className="flex items-center justify-end gap-2 flex-1 min-w-0">
<button
type="button"
onClick={() => navigate('/user-profile')}
className={`
p-2 rounded-lg transition-colors text-white
flex items-center gap-2 px-3 py-2 rounded-lg transition-colors text-white
${isProfileActive
? 'bg-indigo-700 ring-2 ring-indigo-300 dark:ring-indigo-500'
: 'bg-indigo-600 hover:bg-indigo-700'}
`}
title={userDisplayName || t('common.userProfile', { defaultValue: 'User Profile' })}
aria-label={userDisplayName || t('common.userProfile', { defaultValue: 'User Profile' })}
>
<UserCircle size={20} />
{userDisplayName && (
<span className="text-sm font-medium max-w-[180px] truncate">
{userDisplayName}
</span>
)}
<UserCircle size={20} className="flex-shrink-0" />
</button>
<div className="relative">
@ -129,6 +156,7 @@ const Header: React.FC<HeaderProps> = ({ userEmail }) => {
>
<LogOut size={20} />
</button>
</div>
</header>
);
};

View File

@ -0,0 +1,68 @@
import React, { useMemo } from 'react';
type HorizontalBarDataset = {
name: string;
values: number[];
colors?: string[];
};
type HorizontalBarChartProps = {
labels: string[];
datasets: HorizontalBarDataset[];
valueLabel?: string;
};
const HorizontalBarChart: React.FC<HorizontalBarChartProps> = ({
labels,
datasets,
valueLabel = '',
}) => {
const primaryDataset = datasets[0];
const values = primaryDataset?.values || [];
const maxValue = useMemo(() => Math.max(...values.map(value => Number(value) || 0), 1), [values]);
if (!labels.length || !primaryDataset) {
return (
<div className="h-48 flex items-center justify-center text-gray-400 text-xs">
No chart data available
</div>
);
}
const formatValue = (value: number) => {
const rounded = Math.round(value * 100) / 100;
return valueLabel ? `${rounded} ${valueLabel}` : String(rounded);
};
return (
<div className="max-h-72 overflow-y-auto pr-1 space-y-2">
{labels.map((label, rowIndex) => {
const value = Number(values[rowIndex]) || 0;
const widthPct = (value / maxValue) * 100;
return (
<div key={`${label}-${rowIndex}`} className="grid grid-cols-[7rem_1fr_auto] gap-2 items-center">
<div className="text-xs text-gray-700 dark:text-gray-300 truncate text-right" title={label}>
{label}
</div>
<div className="h-7 bg-gray-100 dark:bg-gray-700/60 rounded-md overflow-hidden">
<div
className="h-full rounded-md bg-gradient-to-r from-indigo-500 to-purple-500 transition-opacity"
style={{
width: `${widthPct}%`,
minWidth: value > 0 ? '4px' : 0,
}}
/>
</div>
<div className="text-xs font-semibold text-gray-700 dark:text-gray-200 whitespace-nowrap">
{formatValue(value)}
</div>
</div>
);
})}
</div>
);
};
export default HorizontalBarChart;

View File

@ -10,6 +10,7 @@ interface LinkFieldProps {
placeholder?: string;
disabled?: boolean;
filters?: Record<string, any>;
query?: string;
compact?: boolean;
usePortal?: boolean; // New prop to enable portal rendering
}
@ -25,6 +26,7 @@ const LinkField: React.FC<LinkFieldProps> = ({
placeholder,
disabled = false,
filters,
query,
compact = false,
usePortal = false, // Default to false for backward compatibility
}) => {
@ -45,11 +47,12 @@ const LinkField: React.FC<LinkFieldProps> = ({
// Stringify filters for comparison (avoid object reference issues)
const filtersKey = useMemo(() => JSON.stringify(stableFilters), [stableFilters]);
const queryKey = query || '';
// Fetch link options from ERPNext with filters
const searchLink = useCallback(async (text: string = '', force: boolean = false) => {
// Prevent duplicate calls for the same search text
const searchKey = `${text}-${filtersKey}`;
const searchKey = `${text}-${filtersKey}-${queryKey}`;
if (!force && lastSearchRef.current === searchKey) {
return;
}
@ -67,6 +70,10 @@ const LinkField: React.FC<LinkFieldProps> = ({
params.append('filters', JSON.stringify(stableFilters));
}
if (query) {
params.append('query', query);
}
const response = await apiService.apiCall<{ value: string; description?: string }[]>(
`/api/method/frappe.desk.search.search_link?${params.toString()}`
);
@ -77,7 +84,7 @@ const LinkField: React.FC<LinkFieldProps> = ({
} finally {
setIsLoading(false);
}
}, [doctype, filtersKey, stableFilters]);
}, [doctype, filtersKey, queryKey, stableFilters, query]);
// Debounced search for typing
const debouncedSearch = useCallback((text: string) => {

View File

@ -0,0 +1,107 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import type { DateFilterField } from '../utils/listFilterUtils';
interface ListFilterSortControlsProps {
sortBy: string;
dateFilterBy: DateFilterField;
dateStart: string;
dateEnd: string;
onSortByChange: (value: string) => void;
onDateFilterByChange: (value: DateFilterField) => void;
onDateStartChange: (value: string) => void;
onDateEndChange: (value: string) => void;
/** Show asset_name sort options (Asset list only) */
includeAssetNameSort?: boolean;
}
/** Inline sort + date filter fields — place inside the same filter grid as other filters */
const ListFilterSortControls: React.FC<ListFilterSortControlsProps> = ({
sortBy,
dateFilterBy,
dateStart,
dateEnd,
onSortByChange,
onDateFilterByChange,
onDateStartChange,
onDateEndChange,
includeAssetNameSort = false,
}) => {
const { t } = useTranslation();
const fieldClass =
'w-full px-2 py-1 text-xs border border-gray-300 dark:border-gray-600 rounded focus:outline-none focus:ring-2 focus:ring-blue-500 bg-white dark:bg-gray-700 text-gray-900 dark:text-white';
return (
<>
<div className="relative">
<label className="block text-[10px] font-medium text-gray-700 dark:text-gray-300 mb-0.5">
{t('filters.sortBy', { defaultValue: 'Sort By' })}
</label>
<select value={sortBy} onChange={(e) => onSortByChange(e.target.value)} className={fieldClass}>
<option value="creation desc">{t('filters.sortCreationNewest', { defaultValue: 'Created (Newest)' })}</option>
<option value="creation asc">{t('filters.sortCreationOldest', { defaultValue: 'Created (Oldest)' })}</option>
<option value="modified desc">{t('filters.sortModifiedNewest', { defaultValue: 'Modified (Newest)' })}</option>
<option value="modified asc">{t('filters.sortModifiedOldest', { defaultValue: 'Modified (Oldest)' })}</option>
{includeAssetNameSort && (
<>
<option value="asset_name asc">{t('filters.sortAssetNameAsc', { defaultValue: 'Asset Name (A-Z)' })}</option>
<option value="asset_name desc">{t('filters.sortAssetNameDesc', { defaultValue: 'Asset Name (Z-A)' })}</option>
</>
)}
<option value="name asc">{t('filters.sortNameAsc', { defaultValue: 'Name (A-Z)' })}</option>
<option value="name desc">{t('filters.sortNameDesc', { defaultValue: 'Name (Z-A)' })}</option>
</select>
</div>
<div className="relative">
<label className="block text-[10px] font-medium text-gray-700 dark:text-gray-300 mb-0.5">
{t('filters.filterBy', { defaultValue: 'Filter By' })}
</label>
<select
value={dateFilterBy}
onChange={(e) => onDateFilterByChange(e.target.value as DateFilterField)}
className={fieldClass}
>
<option value="">{t('filters.filterBy', { defaultValue: 'Filter By' })}</option>
<option value="creation">{t('filters.createdDate', { defaultValue: 'Created Date' })}</option>
<option value="modified">{t('filters.latestModifiedDate', { defaultValue: 'Latest Modified Date' })}</option>
</select>
</div>
{dateFilterBy && (
<>
<div className="relative">
<label className="block text-[10px] font-medium text-gray-700 dark:text-gray-300 mb-0.5">
{t('filters.startDate', { defaultValue: 'Start Date' })}
</label>
<input
type="date"
value={dateStart}
onChange={(e) => {
const v = e.target.value;
onDateStartChange(v);
if (dateEnd && v > dateEnd) onDateEndChange(v);
}}
className={fieldClass}
/>
</div>
<div className="relative">
<label className="block text-[10px] font-medium text-gray-700 dark:text-gray-300 mb-0.5">
{t('filters.endDate', { defaultValue: 'End Date' })}
</label>
<input
type="date"
value={dateEnd}
onChange={(e) => onDateEndChange(e.target.value)}
min={dateStart || undefined}
className={fieldClass}
/>
</div>
</>
)}
</>
);
};
export default ListFilterSortControls;

View File

@ -0,0 +1,138 @@
import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
export interface ListPaginationProps {
currentPage: number;
totalCount?: number;
pageSize: number;
hasMore?: boolean;
itemLabel?: string;
onPageChange: (page: number) => void;
className?: string;
}
const ListPagination: React.FC<ListPaginationProps> = ({
currentPage,
totalCount = 0,
pageSize,
hasMore = false,
itemLabel,
onPageChange,
className = '',
}) => {
const { t } = useTranslation();
const displayLabel = itemLabel ?? t('listPages.results');
const totalPages = totalCount > 0 ? Math.max(1, Math.ceil(totalCount / pageSize)) : 0;
const hasTotal = totalCount > 0;
const start = (currentPage - 1) * pageSize + 1;
const end = hasTotal
? Math.min(currentPage * pageSize, totalCount)
: currentPage * pageSize;
const [goToInput, setGoToInput] = useState('');
const handleGoToSubmit = (e: React.FormEvent) => {
e.preventDefault();
const num = parseInt(goToInput.trim(), 10);
if (!Number.isNaN(num) && num >= 1) {
const target = hasTotal ? Math.min(num, totalPages) : num;
onPageChange(target);
setGoToInput('');
}
};
const getPageNumbers = (): (number | 'ellipsis')[] => {
if (totalPages <= 7) {
return Array.from({ length: totalPages }, (_, i) => i + 1);
}
const pages: (number | 'ellipsis')[] = [];
pages.push(1);
if (currentPage > 3) pages.push('ellipsis');
for (let p = Math.max(2, currentPage - 1); p <= Math.min(totalPages - 1, currentPage + 1); p++) {
if (!pages.includes(p)) pages.push(p);
}
if (currentPage < totalPages - 2) pages.push('ellipsis');
if (totalPages > 1) pages.push(totalPages);
return pages;
};
const showPagination = hasMore || currentPage > 1 || (hasTotal && totalPages > 1);
if (!showPagination) return null;
return (
<div className={`flex flex-wrap items-center justify-between gap-3 px-6 py-4 border-t border-gray-200 dark:border-gray-700 ${className}`}>
<div className="text-sm text-gray-700 dark:text-gray-300">
{hasTotal
? t('pagination.showingToOf', { start, end, total: totalCount, label: displayLabel })
: t('pagination.showingTo', { start, end, label: displayLabel })}
</div>
<div className="flex flex-wrap items-center gap-2">
<button
type="button"
onClick={() => onPageChange(currentPage - 1)}
disabled={currentPage <= 1}
className="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-600 disabled:opacity-50 disabled:cursor-not-allowed"
>
{t('pagination.previous')}
</button>
{hasTotal && totalPages > 1 && (
<div className="flex items-center gap-1">
{getPageNumbers().map((p, i) =>
p === 'ellipsis' ? (
<span key={`e-${i}`} className="px-2 text-gray-500 dark:text-gray-400">
</span>
) : (
<button
key={p}
type="button"
onClick={() => onPageChange(p)}
className={`min-w-[2rem] px-2 py-1 text-sm font-medium rounded-lg transition-colors ${
p === currentPage
? 'bg-blue-600 text-white border border-blue-600'
: 'text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-600'
}`}
>
{p}
</button>
)
)}
</div>
)}
<button
type="button"
onClick={() => onPageChange(currentPage + 1)}
disabled={hasTotal ? currentPage >= totalPages : !hasMore}
className="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-600 disabled:opacity-50 disabled:cursor-not-allowed"
>
{t('pagination.next')}
</button>
<form onSubmit={handleGoToSubmit} className="flex items-center gap-1 ml-2">
<span className="text-sm text-gray-600 dark:text-gray-400 whitespace-nowrap">{t('pagination.goTo')}</span>
<input
type="number"
min={1}
max={hasTotal ? totalPages : undefined}
value={goToInput}
onChange={(e) => setGoToInput(e.target.value)}
placeholder={hasTotal ? `1-${totalPages}` : t('pagination.page')}
className="w-14 px-2 py-1 text-sm border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-blue-500 focus:border-transparent"
/>
<button
type="submit"
className="px-2 py-1 text-sm font-medium text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300 border border-gray-300 dark:border-gray-600 rounded hover:bg-gray-50 dark:hover:bg-gray-700"
>
{t('pagination.go')}
</button>
</form>
</div>
</div>
);
};
export default ListPagination;

View File

@ -0,0 +1,315 @@
import React, { useState, useRef, useEffect, useCallback, type KeyboardEvent } from 'react';
import { FaSpinner, FaUser } from 'react-icons/fa';
import type { MentionUser } from '../services/commentService';
import API_CONFIG from '../config/api';
interface MentionInputProps {
value: string;
onChange: (text: string) => void;
onSubmit: (html: string) => void;
placeholder?: string;
disabled?: boolean;
mentionUsers: MentionUser[];
mentionLoading: boolean;
onMentionSearch: (query: string) => void;
posting?: boolean;
}
interface InsertedMention {
startIndex: number;
displayText: string;
userId: string;
fullName: string;
}
const MentionInput: React.FC<MentionInputProps> = ({
value,
onChange,
onSubmit,
placeholder = 'Type a comment… Use @ to mention someone',
disabled = false,
mentionUsers,
mentionLoading,
onMentionSearch,
posting = false,
}) => {
const textareaRef = useRef<HTMLTextAreaElement>(null);
const dropdownRef = useRef<HTMLDivElement>(null);
const [showMentionDropdown, setShowMentionDropdown] = useState(false);
const [mentionQuery, setMentionQuery] = useState('');
const [mentionStartPos, setMentionStartPos] = useState<number | null>(null);
const [selectedIndex, setSelectedIndex] = useState(0);
const [insertedMentions, setInsertedMentions] = useState<InsertedMention[]>([]);
const updateDropdownPosition = useCallback(() => {
const ta = textareaRef.current;
if (!ta) return;
ta.getBoundingClientRect();
ta.offsetParent?.getBoundingClientRect();
}, []);
const handleChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
const newValue = e.target.value;
const cursorPos = e.target.selectionStart ?? 0;
onChange(newValue);
const textBeforeCursor = newValue.substring(0, cursorPos);
const lastAtIndex = textBeforeCursor.lastIndexOf('@');
if (lastAtIndex !== -1) {
const charBefore = lastAtIndex > 0 ? newValue[lastAtIndex - 1] : ' ';
if (charBefore === ' ' || charBefore === '\n' || lastAtIndex === 0) {
const query = textBeforeCursor.substring(lastAtIndex + 1);
if (!query.includes(' ') || query.length <= 30) {
setShowMentionDropdown(true);
setMentionQuery(query);
setMentionStartPos(lastAtIndex);
setSelectedIndex(0);
onMentionSearch(query);
updateDropdownPosition();
return;
}
}
}
setShowMentionDropdown(false);
setMentionStartPos(null);
};
const insertMention = useCallback(
(user: MentionUser) => {
if (mentionStartPos === null) return;
const ta = textareaRef.current;
const before = value.substring(0, mentionStartPos);
const cursorPos = ta?.selectionStart ?? mentionStartPos + mentionQuery.length + 1;
const after = value.substring(cursorPos);
const displayText = user.full_name || user.name;
const newText = `${before}@${displayText} ${after}`;
setInsertedMentions((prev) => [
...prev,
{
startIndex: mentionStartPos,
displayText,
userId: user.name,
fullName: user.full_name || user.name,
},
]);
onChange(newText);
setShowMentionDropdown(false);
setMentionStartPos(null);
setMentionQuery('');
setTimeout(() => {
if (ta) {
ta.focus();
const newPos = before.length + displayText.length + 2;
ta.selectionStart = newPos;
ta.selectionEnd = newPos;
}
}, 0);
},
[mentionStartPos, mentionQuery, value, onChange]
);
const buildHtml = useCallback(
(text: string): string => {
let html = text;
const baseUrl = API_CONFIG.BASE_URL || window.location.origin;
const sorted = [...insertedMentions].sort((a, b) => b.startIndex - a.startIndex);
for (const m of sorted) {
const mentionText = `@${m.displayText}`;
const idx = html.indexOf(mentionText);
if (idx === -1) continue;
const profileUrl = `${baseUrl}/app/user-profile/${encodeURIComponent(m.userId)}`;
const mentionHtml =
`<span class="mention" ` +
`data-id="${m.userId}" ` +
`data-value="&lt;a href=&quot;${profileUrl}&quot; target=&quot;_blank&quot;&gt;${m.fullName}" ` +
`data-denotation-char="@" ` +
`data-is-group="false" ` +
`data-link="${profileUrl}">` +
`\uFEFF<span contenteditable="false">` +
`<span class="ql-mention-denotation-char">@</span>` +
`<a href="${profileUrl}" target="_blank">${m.fullName}</a>` +
`</span>\uFEFF</span>`;
html = html.substring(0, idx) + mentionHtml + html.substring(idx + mentionText.length);
}
html = html.replace(/\n/g, '<br>');
return `<div class="ql-editor read-mode"><p>${html}</p></div>`;
},
[insertedMentions]
);
const handleSubmit = () => {
const trimmed = value.trim();
if (!trimmed || posting) return;
const html = buildHtml(trimmed);
onSubmit(html);
setInsertedMentions([]);
};
const handleKeyDown = (e: KeyboardEvent<HTMLTextAreaElement>) => {
if (showMentionDropdown && mentionUsers.length > 0) {
switch (e.key) {
case 'ArrowDown':
e.preventDefault();
setSelectedIndex((prev) => Math.min(prev + 1, mentionUsers.length - 1));
return;
case 'ArrowUp':
e.preventDefault();
setSelectedIndex((prev) => Math.max(prev - 1, 0));
return;
case 'Enter':
e.preventDefault();
insertMention(mentionUsers[selectedIndex]);
return;
case 'Escape':
e.preventDefault();
setShowMentionDropdown(false);
return;
}
}
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) {
e.preventDefault();
handleSubmit();
}
};
useEffect(() => {
const handleClickOutside = (e: MouseEvent) => {
if (
dropdownRef.current &&
!dropdownRef.current.contains(e.target as Node) &&
textareaRef.current &&
!textareaRef.current.contains(e.target as Node)
) {
setShowMentionDropdown(false);
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, []);
useEffect(() => {
if (!dropdownRef.current) return;
const item = dropdownRef.current.querySelector(`[data-idx="${selectedIndex}"]`);
item?.scrollIntoView({ block: 'nearest' });
}, [selectedIndex]);
const baseUrl = API_CONFIG.BASE_URL || '';
return (
<div className="relative">
{showMentionDropdown && (
<div
ref={dropdownRef}
className="absolute z-50 bottom-full mb-1 w-72 max-h-52 overflow-y-auto
bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-600
rounded-lg shadow-lg"
style={{ left: 0 }}
>
{mentionLoading && mentionUsers.length === 0 ? (
<div className="flex items-center gap-2 px-3 py-4 text-sm text-gray-500 dark:text-gray-400">
<FaSpinner className="animate-spin" size={12} />
Searching users
</div>
) : mentionUsers.length === 0 ? (
<div className="px-3 py-4 text-sm text-gray-500 dark:text-gray-400">
No users found
</div>
) : (
mentionUsers.map((user, idx) => (
<button
key={user.name}
data-idx={idx}
type="button"
className={`w-full flex items-center gap-2.5 px-3 py-2 text-left transition-colors
${idx === selectedIndex
? 'bg-teal-50 dark:bg-teal-900/30 text-teal-800 dark:text-teal-200'
: 'text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700'
}`}
onMouseEnter={() => setSelectedIndex(idx)}
onMouseDown={(e) => {
e.preventDefault();
insertMention(user);
}}
>
{user.user_image ? (
<img
src={`${baseUrl}${user.user_image}`}
alt=""
className="w-7 h-7 rounded-full object-cover flex-shrink-0"
/>
) : (
<div className="w-7 h-7 rounded-full bg-gray-200 dark:bg-gray-600 flex items-center justify-center flex-shrink-0">
<FaUser className="text-gray-500 dark:text-gray-400" size={10} />
</div>
)}
<div className="min-w-0 flex-1">
<p className="text-sm font-medium truncate">
{user.full_name || user.name}
</p>
<p className="text-xs text-gray-500 dark:text-gray-400 truncate">
{user.name}
</p>
</div>
</button>
))
)}
</div>
)}
<div className="flex gap-2 items-end">
<textarea
ref={textareaRef}
value={value}
onChange={handleChange}
onKeyDown={handleKeyDown}
placeholder={placeholder}
disabled={disabled || posting}
rows={3}
className="flex-1 px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg
bg-white dark:bg-gray-700 text-gray-900 dark:text-white text-sm
disabled:bg-gray-100 dark:disabled:bg-gray-800
focus:outline-none focus:ring-2 focus:ring-teal-500 resize-none
placeholder:text-gray-400 dark:placeholder:text-gray-500"
/>
<button
type="button"
onClick={handleSubmit}
disabled={disabled || posting || !value.trim()}
className="px-4 py-2 bg-teal-600 hover:bg-teal-700 disabled:bg-teal-600/50
text-white text-sm font-medium rounded-lg transition-colors
disabled:cursor-not-allowed flex items-center gap-1.5 h-10 flex-shrink-0"
>
{posting ? (
<>
<FaSpinner className="animate-spin" size={12} />
<span>Posting</span>
</>
) : (
<span>Comment</span>
)}
</button>
</div>
<p className="mt-1 text-[10px] text-gray-400 dark:text-gray-500">
<kbd className="px-1 py-0.5 bg-gray-100 dark:bg-gray-700 rounded text-[9px]">@</kbd> to mention
&nbsp;·&nbsp;
<kbd className="px-1 py-0.5 bg-gray-100 dark:bg-gray-700 rounded text-[9px]">Ctrl+Enter</kbd> to post
</p>
</div>
);
};
export default MentionInput;

View File

@ -1,23 +1,24 @@
import React, { useState, useRef, useEffect } from 'react';
import React, { useState, useRef, useEffect, useCallback } from 'react';
import { createPortal } from 'react-dom';
import { Bell } from 'lucide-react';
import { useNotifications } from '../hooks/useNotifications';
import { useNavigate } from 'react-router-dom';
import { FaCheck, FaTimes, FaBell } from 'react-icons/fa';
import { FaBell } from 'react-icons/fa';
const NotificationBell: React.FC = () => {
const { notifications, unreadCount, markAsRead, markAllAsRead, loading } = useNotifications();
const { notifications, unreadCount, markAsRead, markAllAsRead } = useNotifications();
const [isOpen, setIsOpen] = useState(false);
const buttonRef = useRef<HTMLDivElement>(null);
const panelRef = useRef<HTMLDivElement>(null);
const navigate = useNavigate();
// If notifications are not available (empty array and not loading),
// the bell will still show but with 0 count - this is fine
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (panelRef.current && !panelRef.current.contains(event.target as Node)) {
setIsOpen(false);
const target = event.target as Node;
if (buttonRef.current?.contains(target) || panelRef.current?.contains(target)) {
return;
}
setIsOpen(false);
};
if (isOpen) {
@ -29,63 +30,49 @@ const NotificationBell: React.FC = () => {
};
}, [isOpen]);
useEffect(() => {
if (!isOpen) return;
const mq = window.matchMedia('(max-width: 1023px)');
if (!mq.matches) return;
const previousOverflow = document.body.style.overflow;
document.body.style.overflow = 'hidden';
return () => {
document.body.style.overflow = previousOverflow;
};
}, [isOpen]);
const handleNotificationClick = async (notification: any) => {
console.log('[NotificationBell] Clicked notification:', notification);
console.log('[NotificationBell] document_type:', notification.document_type);
console.log('[NotificationBell] document_name:', notification.document_name);
// Try to mark as read, but don't block navigation if it fails
if (!notification.read) {
try {
await markAsRead(notification.name);
} catch (error) {
console.warn('[NotificationBell] Could not mark as read (permission issue):', error);
// Continue anyway - navigate to document
}
}
// Navigate based on document type
if (notification.document_type && notification.document_name) {
const docType = notification.document_type;
const docName = notification.document_name;
// Normalize document type (handle both spaces and underscores)
const normalizedType = docType.replace(/_/g, ' ').trim();
console.log('[NotificationBell] Normalized type:', normalizedType);
console.log('[NotificationBell] Document name:', docName);
// Map document types to routes
if (normalizedType === 'Asset Maintenance Log' || normalizedType === 'Asset Maintenance') {
console.log('[NotificationBell] Navigating to maintenance:', `/maintenance/${docName}`);
navigate(`/maintenance/${docName}`);
} else if (normalizedType === 'Work Order' || normalizedType === 'Asset Repair') {
console.log('[NotificationBell] Navigating to work order:', `/work-orders/${docName}`);
navigate(`/work-orders/${docName}`);
} else if (normalizedType === 'Asset') {
console.log('[NotificationBell] Navigating to asset:', `/assets/${docName}`);
navigate(`/assets/${docName}`);
} else if (normalizedType === 'PM Schedule Generator' || normalizedType === 'PM Schedule') {
console.log('[NotificationBell] Navigating to PPM planner:', `/ppm-planner/${docName}`);
navigate(`/ppm-planner/${docName}`);
} else if (normalizedType === 'PPM') {
console.log('[NotificationBell] Navigating to PPM:', `/ppm/${docName}`);
navigate(`/ppm/${docName}`);
} else if (normalizedType === 'Item') {
console.log('[NotificationBell] Navigating to inventory:', `/inventory/${docName}`);
navigate(`/inventory/${docName}`);
} else {
// Fallback: Try to open in Frappe if route not found
console.warn(`[NotificationBell] Unknown document type: ${docType}, opening in Frappe`);
const frappeRoute = docType.toLowerCase().replace(/\s+/g, '-').replace(/_/g, '-');
window.open(`/app/${frappeRoute}/${docName}`, '_blank');
}
} else {
console.warn('[NotificationBell] No document_type or document_name found:', {
document_type: notification.document_type,
document_name: notification.document_name,
notification
});
}
setIsOpen(false);
@ -109,126 +96,152 @@ const NotificationBell: React.FC = () => {
const unreadNotifications = notifications.filter(n => !n.read);
const readNotifications = notifications.filter(n => n.read).slice(0, 10);
const renderPanel = () => (
<div
ref={panelRef}
className="
fixed z-[9999] flex flex-col overflow-hidden
bg-white dark:bg-gray-800 rounded-xl shadow-2xl
border border-gray-200 dark:border-gray-700
left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2
w-[calc(100vw-2rem)] max-w-md max-h-[75vh]
lg:left-auto lg:top-[3.75rem] lg:right-4 lg:translate-x-0 lg:translate-y-0 lg:w-80 lg:max-h-96
"
>
<div className="p-4 border-b border-gray-200 dark:border-gray-700 flex items-center justify-between gap-2">
<h3 className="font-semibold text-gray-800 dark:text-white flex items-center gap-2 min-w-0">
<FaBell />
<span className="truncate">Notifications</span>
{unreadCount > 0 && (
<span className="text-xs bg-red-500 text-white px-2 py-0.5 rounded-full shrink-0">
{unreadCount} new
</span>
)}
</h3>
{unreadCount > 0 && (
<button
type="button"
onClick={markAllAsRead}
className="text-xs text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300 shrink-0"
>
Mark all read
</button>
)}
</div>
<div className="overflow-y-auto flex-1 min-h-0">
{notifications.length === 0 ? (
<div className="p-8 text-center text-gray-500 dark:text-gray-400">
<FaBell className="mx-auto text-3xl mb-2 opacity-50" />
<p>No notifications</p>
</div>
) : (
<>
{unreadNotifications.length > 0 && (
<div className="p-2">
<div className="text-xs font-semibold text-gray-500 dark:text-gray-400 px-2 mb-1">
NEW
</div>
{unreadNotifications.map(notif => (
<div
key={notif.name}
onClick={() => handleNotificationClick(notif)}
className="p-3 hover:bg-gray-50 dark:hover:bg-gray-700 cursor-pointer border-l-4 border-blue-500 bg-blue-50 dark:bg-blue-900/20"
>
<div className="flex items-start justify-between gap-2">
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-gray-900 dark:text-white truncate">
{(notif as any).subject || notif.document_type || 'Notification'}
</p>
{(notif as any).email_content && (
<p className="text-xs text-gray-600 dark:text-gray-400 mt-1 line-clamp-2">
{(notif as any).email_content}
</p>
)}
<p className="text-xs text-gray-500 dark:text-gray-500 mt-1">
{formatDate(notif.creation)}
</p>
</div>
<div className="w-2 h-2 bg-blue-500 rounded-full flex-shrink-0 mt-1" />
</div>
</div>
))}
</div>
)}
{readNotifications.length > 0 && (
<div className="p-2 border-t border-gray-200 dark:border-gray-700">
{unreadNotifications.length > 0 && (
<div className="text-xs font-semibold text-gray-500 dark:text-gray-400 px-2 mb-1">
EARLIER
</div>
)}
{readNotifications.map(notif => (
<div
key={notif.name}
onClick={() => handleNotificationClick(notif)}
className="p-3 hover:bg-gray-50 dark:hover:bg-gray-700 cursor-pointer"
>
<div className="flex items-start justify-between gap-2">
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-gray-700 dark:text-gray-300 truncate">
{(notif as any).subject || notif.document_type || 'Notification'}
</p>
{(notif as any).email_content && (
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1 line-clamp-2">
{(notif as any).email_content}
</p>
)}
<p className="text-xs text-gray-400 dark:text-gray-500 mt-1">
{formatDate(notif.creation)}
</p>
</div>
</div>
</div>
))}
</div>
)}
</>
)}
</div>
</div>
);
const closePanel = useCallback(() => setIsOpen(false), []);
return (
<div className="relative" ref={panelRef}>
<div className="relative" ref={buttonRef}>
<button
onClick={() => setIsOpen(!isOpen)}
className="relative p-2 text-gray-600 dark:text-gray-300 hover:text-gray-900 dark:hover:text-white hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors"
type="button"
onClick={() => setIsOpen(prev => !prev)}
className="relative p-2 min-h-[44px] min-w-[44px] flex items-center justify-center text-gray-600 dark:text-gray-300 hover:text-gray-900 dark:hover:text-white hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors"
title="Notifications"
aria-expanded={isOpen}
aria-label="Notifications"
>
<Bell size={20} />
{unreadCount > 0 && (
<span className="absolute top-0 right-0 flex items-center justify-center w-5 h-5 text-xs font-bold text-white bg-red-500 rounded-full">
<span className="absolute top-1 right-1 flex items-center justify-center w-5 h-5 text-xs font-bold text-white bg-red-500 rounded-full">
{unreadCount > 9 ? '9+' : unreadCount}
</span>
)}
</button>
{isOpen && (
<div className="absolute right-0 mt-2 w-80 bg-white dark:bg-gray-800 rounded-lg shadow-xl border border-gray-200 dark:border-gray-700 z-[9999] max-h-96 overflow-hidden flex flex-col">
{/* Header */}
<div className="p-4 border-b border-gray-200 dark:border-gray-700 flex items-center justify-between">
<h3 className="font-semibold text-gray-800 dark:text-white flex items-center gap-2">
<FaBell />
Notifications
{unreadCount > 0 && (
<span className="text-xs bg-red-500 text-white px-2 py-0.5 rounded-full">
{unreadCount} new
</span>
)}
</h3>
{unreadCount > 0 && (
<button
onClick={markAllAsRead}
className="text-xs text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300"
>
Mark all read
</button>
)}
</div>
{/* Notifications List */}
<div className="overflow-y-auto flex-1">
{notifications.length === 0 ? (
<div className="p-8 text-center text-gray-500 dark:text-gray-400">
<FaBell className="mx-auto text-3xl mb-2 opacity-50" />
<p>No notifications</p>
</div>
) : (
<>
{unreadNotifications.length > 0 && (
<div className="p-2">
<div className="text-xs font-semibold text-gray-500 dark:text-gray-400 px-2 mb-1">
NEW
</div>
{unreadNotifications.map(notif => (
<div
key={notif.name}
onClick={() => handleNotificationClick(notif)}
className="p-3 hover:bg-gray-50 dark:hover:bg-gray-700 cursor-pointer border-l-4 border-blue-500 bg-blue-50 dark:bg-blue-900/20"
>
<div className="flex items-start justify-between gap-2">
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-gray-900 dark:text-white truncate">
{(notif as any).subject || notif.document_type || 'Notification'}
</p>
{(notif as any).email_content && (
<p className="text-xs text-gray-600 dark:text-gray-400 mt-1 line-clamp-2">
{(notif as any).email_content}
</p>
)}
<p className="text-xs text-gray-500 dark:text-gray-500 mt-1">
{formatDate(notif.creation)}
</p>
</div>
<div className="w-2 h-2 bg-blue-500 rounded-full flex-shrink-0 mt-1"></div>
</div>
</div>
))}
</div>
)}
{readNotifications.length > 0 && (
<div className="p-2 border-t border-gray-200 dark:border-gray-700">
{unreadNotifications.length > 0 && (
<div className="text-xs font-semibold text-gray-500 dark:text-gray-400 px-2 mb-1">
EARLIER
</div>
)}
{readNotifications.map(notif => (
<div
key={notif.name}
onClick={() => handleNotificationClick(notif)}
className="p-3 hover:bg-gray-50 dark:hover:bg-gray-700 cursor-pointer"
>
<div className="flex items-start justify-between gap-2">
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-gray-700 dark:text-gray-300 truncate">
{(notif as any).subject || notif.document_type || 'Notification'}
</p>
{(notif as any).email_content && (
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1 line-clamp-2">
{(notif as any).email_content}
</p>
)}
<p className="text-xs text-gray-400 dark:text-gray-500 mt-1">
{formatDate(notif.creation)}
</p>
</div>
</div>
</div>
))}
</div>
)}
</>
)}
</div>
</div>
)}
{isOpen &&
createPortal(
<>
<button
type="button"
className="fixed inset-0 z-[9998] bg-black/40 lg:bg-black/20"
onClick={closePanel}
aria-label="Close notifications"
/>
{renderPanel()}
</>,
document.body
)}
</div>
);
};
export default NotificationBell;

View File

@ -0,0 +1,164 @@
import React, { useCallback, useState } from 'react';
export type PieChartData = {
labels?: string[];
datasets?: Array<{ name?: string; values?: number[]; colors?: string[] }>;
total?: number;
};
type ChartTooltipState = {
x: number;
y: number;
text: string;
};
const FALLBACK_COLORS = [
'#6366F1',
'#8B5CF6',
'#06B6D4',
'#EC4899',
'#F59E0B',
'#10B981',
'#EF4444',
'#3B82F6',
];
const toNumber = (value: unknown): number => {
const n = Number(value);
return Number.isFinite(n) && n >= 0 ? n : 0;
};
const formatTooltip = (label: string, value: number, total?: number): string => {
if (total != null && total > 0) {
return `${label}: ${value} (${((value / total) * 100).toFixed(1)}%)`;
}
return `${label}: ${value}`;
};
const useChartTooltip = () => {
const [tooltip, setTooltip] = useState<ChartTooltipState | null>(null);
const show = useCallback(
(clientX: number, clientY: number, label: string, value: number, total?: number) => {
setTooltip({ x: clientX, y: clientY, text: formatTooltip(label, value, total) });
},
[]
);
const hide = useCallback(() => setTooltip(null), []);
return { tooltip, show, hide };
};
const ChartTooltipOverlay: React.FC<{ tooltip: ChartTooltipState | null }> = ({ tooltip }) => {
if (!tooltip) return null;
return (
<div
className="fixed z-[9999] pointer-events-none px-2.5 py-1.5 rounded text-xs font-medium text-white bg-gray-900 shadow-lg whitespace-nowrap"
style={{ left: tooltip.x, top: tooltip.y - 8, transform: 'translate(-50%, -100%)' }}
role="tooltip"
>
{tooltip.text}
</div>
);
};
type PieChartProps = {
data: PieChartData | null;
emptyMessage?: string;
};
const PieChart: React.FC<PieChartProps> = ({ data, emptyMessage = 'No data available' }) => {
const { tooltip, show, hide } = useChartTooltip();
const labels = data?.labels || [];
const values = (data?.datasets?.[0]?.values || []).map(toNumber);
const datasetColors = data?.datasets?.[0]?.colors;
const colors =
datasetColors && datasetColors.length === values.length
? datasetColors
: values.map((_, i) => FALLBACK_COLORS[i % FALLBACK_COLORS.length]);
const total = data?.total ?? values.reduce((sum, val) => sum + val, 0);
if (total <= 0 || labels.length === 0) {
return (
<div className="h-48 flex items-center justify-center text-gray-400 text-sm">{emptyMessage}</div>
);
}
const radius = 100;
const cx = radius + 10;
const cy = radius + 10;
let cumulative = 0;
const slices = values.map((value, i) => {
const startAngle = (cumulative / total) * 2 * Math.PI - Math.PI / 2;
cumulative += value;
const endAngle = (cumulative / total) * 2 * Math.PI - Math.PI / 2;
const largeArc = endAngle - startAngle > Math.PI ? 1 : 0;
const x1 = cx + radius * Math.cos(startAngle);
const y1 = cy + radius * Math.sin(startAngle);
const x2 = cx + radius * Math.cos(endAngle);
const y2 = cy + radius * Math.sin(endAngle);
return {
path: `M ${cx} ${cy} L ${x1} ${y1} A ${radius} ${radius} 0 ${largeArc} 1 ${x2} ${y2} Z`,
color: colors[i],
label: labels[i],
value,
percentage: ((value / total) * 100).toFixed(1),
};
});
const isSingleSlice = slices.length === 1;
return (
<div className="flex flex-col md:flex-row items-center justify-around gap-4" onMouseLeave={hide}>
<ChartTooltipOverlay tooltip={tooltip} />
<svg width={cx * 2} height={cy * 2} viewBox={`0 0 ${cx * 2} ${cy * 2}`} className="max-w-xs shrink-0">
{isSingleSlice ? (
<circle
cx={cx}
cy={cy}
r={radius}
fill={slices[0].color}
className="hover:opacity-90 transition-opacity drop-shadow-lg"
onMouseEnter={(e) => show(e.clientX, e.clientY, slices[0].label, slices[0].value, total)}
onMouseMove={(e) => show(e.clientX, e.clientY, slices[0].label, slices[0].value, total)}
onMouseLeave={hide}
>
<title>{formatTooltip(slices[0].label, slices[0].value, total)}</title>
</circle>
) : (
slices.map((slice, i) => (
<path
key={i}
d={slice.path}
fill={slice.color}
className="hover:opacity-80 transition-opacity drop-shadow-md"
onMouseEnter={(e) => show(e.clientX, e.clientY, slice.label, slice.value, total)}
onMouseMove={(e) => show(e.clientX, e.clientY, slice.label, slice.value, total)}
onMouseLeave={hide}
>
<title>{formatTooltip(slice.label, slice.value, total)}</title>
</path>
))
)}
</svg>
<div className="flex flex-col gap-2 min-w-[140px]">
{slices.map((slice, i) => (
<div key={i} className="flex items-center gap-2 text-xs text-gray-600 dark:text-gray-300">
<span className="w-3 h-3 rounded-full shrink-0" style={{ backgroundColor: slice.color }} />
<span className="truncate flex-1">{slice.label}</span>
<span className="font-semibold text-gray-800 dark:text-white">{slice.value}</span>
</div>
))}
</div>
</div>
);
};
export default PieChart;

View File

@ -0,0 +1,286 @@
import React, { useEffect, useMemo, useState } from 'react';
import * as XLSX from 'xlsx';
import {
FaCheckSquare,
FaDownload,
FaFileExcel,
FaFileExport,
FaFilePdf,
FaSpinner,
FaSquare,
FaTimes,
} from 'react-icons/fa';
export interface QueryReportColumn {
fieldname: string;
label?: string;
}
export type QueryReportExportFormat = 'excel' | 'pdf';
export interface QueryReportExportModalProps {
isOpen: boolean;
onClose: () => void;
title?: string;
columns: QueryReportColumn[];
rows: Record<string, unknown>[];
fileNamePrefix?: string;
}
function formatCellValue(value: unknown): string {
if (value === null || value === undefined || value === '') return '';
return String(value);
}
function downloadExcel(
rows: Record<string, unknown>[],
columns: QueryReportColumn[],
fileName: string,
) {
const wsData = [
columns.map((c) => c.label || c.fieldname),
...rows.map((row) => columns.map((c) => formatCellValue(row[c.fieldname]))),
];
const ws = XLSX.utils.aoa_to_sheet(wsData);
const wb = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(wb, ws, 'Export');
XLSX.writeFile(wb, fileName);
}
function downloadPdf(
rows: Record<string, unknown>[],
columns: QueryReportColumn[],
reportTitle: string,
) {
const printWindow = window.open('', '_blank');
if (!printWindow) {
window.alert('Please allow popups for this site to export PDF.');
return;
}
const tableHTML = `
<!DOCTYPE html>
<html>
<head>
<title>${reportTitle}</title>
<style>
body { font-family: Arial, sans-serif; margin: 20px; }
h1 { text-align: center; color: #333; margin-bottom: 8px; font-size: 18px; }
.meta { text-align: center; color: #666; margin-bottom: 20px; font-size: 11px; }
table { width: 100%; border-collapse: collapse; font-size: 10px; }
th, td { border: 1px solid #ccc; padding: 6px 8px; text-align: left; vertical-align: top; }
th { background: #047857; color: white; }
tr:nth-child(even) { background: #f9fafb; }
@media print { body { margin: 0; } }
</style>
</head>
<body>
<h1>${reportTitle}</h1>
<div class="meta">
Generated on: ${new Date().toLocaleString()} | Total Records: ${rows.length}
</div>
<table>
<thead>
<tr>
${columns.map((c) => `<th>${c.label || c.fieldname}</th>`).join('')}
</tr>
</thead>
<tbody>
${rows.map((row) => `
<tr>
${columns.map((c) => `<td>${formatCellValue(row[c.fieldname]) || '—'}</td>`).join('')}
</tr>
`).join('')}
</tbody>
</table>
<script>window.onload = function() { window.print(); }</script>
</body>
</html>
`;
printWindow.document.write(tableHTML);
printWindow.document.close();
}
const QueryReportExportModal: React.FC<QueryReportExportModalProps> = ({
isOpen,
onClose,
title = 'Export Report',
columns,
rows,
fileNamePrefix = 'report',
}) => {
const [format, setFormat] = useState<QueryReportExportFormat>('excel');
const [checkedKeys, setCheckedKeys] = useState<Set<string>>(new Set());
const [isExporting, setIsExporting] = useState(false);
const columnOptions = useMemo(
() => columns.filter((c) => c.fieldname),
[columns],
);
useEffect(() => {
if (!isOpen) return;
setFormat('excel');
setCheckedKeys(new Set(columnOptions.map((c) => c.fieldname)));
}, [isOpen, columnOptions]);
if (!isOpen) return null;
const chosenColumns = columnOptions.filter((c) => checkedKeys.has(c.fieldname));
const canExport = rows.length > 0 && chosenColumns.length > 0;
const toggleColumn = (key: string) => {
setCheckedKeys((prev) => {
const next = new Set(prev);
if (next.has(key)) next.delete(key);
else next.add(key);
return next;
});
};
const selectAll = () => setCheckedKeys(new Set(columnOptions.map((c) => c.fieldname)));
const selectNone = () => setCheckedKeys(new Set());
const handleExport = async () => {
if (!canExport) return;
setIsExporting(true);
try {
const datePart = new Date().toISOString().split('T')[0];
if (format === 'excel') {
downloadExcel(rows, chosenColumns, `${fileNamePrefix}_${datePart}.xlsx`);
} else {
downloadPdf(rows, chosenColumns, title);
}
onClose();
} catch (err) {
console.error('Export failed:', err);
window.alert(`Export failed: ${err instanceof Error ? err.message : 'Unknown error'}`);
} finally {
setIsExporting(false);
}
};
return (
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-[80] p-4">
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-2xl w-full max-w-lg max-h-[90vh] flex flex-col overflow-hidden">
<div className="bg-gradient-to-r from-emerald-600 to-teal-600 px-5 py-4 flex items-center justify-between shrink-0">
<div className="flex items-center gap-2">
<FaFileExport className="text-white" />
<h3 className="text-base font-semibold text-white">{title}</h3>
</div>
<button
type="button"
onClick={onClose}
disabled={isExporting}
className="text-white/80 hover:text-white p-1 rounded-lg hover:bg-white/20"
>
<FaTimes />
</button>
</div>
<div className="p-5 space-y-5 overflow-y-auto flex-1">
<div>
<h4 className="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-2">File format</h4>
<div className="flex gap-3">
<label className={`flex-1 flex items-center gap-2 p-3 rounded-lg border cursor-pointer ${
format === 'excel'
? 'border-emerald-500 bg-emerald-50 dark:bg-emerald-900/20'
: 'border-gray-200 dark:border-gray-700'
}`}>
<input
type="radio"
name="wo_feedback_export_format"
checked={format === 'excel'}
onChange={() => setFormat('excel')}
className="text-emerald-600"
/>
<FaFileExcel className="text-green-700" />
<span className="text-sm font-medium text-gray-800 dark:text-gray-200">Excel (.xlsx)</span>
</label>
<label className={`flex-1 flex items-center gap-2 p-3 rounded-lg border cursor-pointer ${
format === 'pdf'
? 'border-emerald-500 bg-emerald-50 dark:bg-emerald-900/20'
: 'border-gray-200 dark:border-gray-700'
}`}>
<input
type="radio"
name="wo_feedback_export_format"
checked={format === 'pdf'}
onChange={() => setFormat('pdf')}
className="text-emerald-600"
/>
<FaFilePdf className="text-red-600" />
<span className="text-sm font-medium text-gray-800 dark:text-gray-200">PDF</span>
</label>
</div>
</div>
<div>
<div className="flex items-center justify-between mb-2">
<h4 className="text-sm font-semibold text-gray-700 dark:text-gray-300">
Columns to export
</h4>
<div className="flex gap-3 text-xs text-emerald-600 dark:text-emerald-400">
<button type="button" onClick={selectAll} className="hover:underline">All</button>
<button type="button" onClick={selectNone} className="hover:underline">None</button>
</div>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-1.5 max-h-52 overflow-y-auto p-2 bg-gray-50 dark:bg-gray-900/50 rounded-lg border border-gray-200 dark:border-gray-700">
{columnOptions.map((col) => {
const checked = checkedKeys.has(col.fieldname);
return (
<button
key={col.fieldname}
type="button"
onClick={() => toggleColumn(col.fieldname)}
className={`flex items-center gap-2 px-2 py-1.5 rounded text-left text-xs transition-colors ${
checked
? 'bg-emerald-100 dark:bg-emerald-900/30 text-emerald-800 dark:text-emerald-200'
: 'hover:bg-gray-100 dark:hover:bg-gray-800 text-gray-600 dark:text-gray-400'
}`}
>
{checked
? <FaCheckSquare size={13} className="text-emerald-600 shrink-0" />
: <FaSquare size={13} className="text-gray-300 shrink-0" />}
<span className="truncate" title={col.label || col.fieldname}>
{col.label || col.fieldname}
</span>
</button>
);
})}
</div>
<p className="text-xs text-gray-400 mt-1.5">
{checkedKeys.size} of {columnOptions.length} columns selected · {rows.length} row{rows.length !== 1 ? 's' : ''}
</p>
</div>
</div>
<div className="px-5 py-4 border-t border-gray-200 dark:border-gray-700 flex justify-end gap-3 shrink-0">
<button
type="button"
onClick={onClose}
disabled={isExporting}
className="px-4 py-2 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg"
>
Cancel
</button>
<button
type="button"
onClick={handleExport}
disabled={!canExport || isExporting}
className="px-4 py-2 text-sm font-medium text-white bg-emerald-600 hover:bg-emerald-700 rounded-lg flex items-center gap-2 disabled:opacity-50"
>
{isExporting ? (
<><FaSpinner className="animate-spin" size={14} /> Exporting</>
) : (
<><FaDownload size={14} /> Export</>
)}
</button>
</div>
</div>
</div>
);
};
export default QueryReportExportModal;

View File

@ -0,0 +1,147 @@
import React, { useCallback, useMemo, useState } from 'react';
import {
CompletionByTypeData,
CompletionByTypeRate,
REPAIR_COMPLETION_BAR_COLORS,
getRepairCompletionRate,
isNullOrUnknownLabel,
} from '../utils/chartLabelUtils';
type TooltipState = {
x: number;
y: number;
rate: CompletionByTypeRate;
color: string;
percentage: number;
};
type RepairStatusCompletionBarChartProps = {
data: CompletionByTypeData | null;
};
const RepairStatusCompletionBarChart: React.FC<RepairStatusCompletionBarChartProps> = ({ data }) => {
const [tooltip, setTooltip] = useState<TooltipState | null>(null);
const rates = useMemo(
() => (data?.rates || []).filter(item => !isNullOrUnknownLabel(item.type)),
[data]
);
const hideTooltip = useCallback(() => setTooltip(null), []);
if (!rates.length) {
return (
<div className="h-44 flex items-center justify-center text-gray-400 text-sm">
No completion data available
</div>
);
}
const chartHeight = 160;
const barWidth = 44;
const barGap = 18;
const paddingLeft = 36;
const paddingRight = 16;
const width = paddingLeft + rates.length * (barWidth + barGap) + paddingRight;
const gridLines = [0, 25, 50, 75, 100];
return (
<div className="relative w-full overflow-x-auto" onMouseLeave={hideTooltip}>
{tooltip && (
<div
className="fixed z-[9999] pointer-events-none rounded-lg bg-gray-900 text-white px-3 py-2 text-xs shadow-lg max-w-[220px]"
style={{ left: tooltip.x, top: tooltip.y - 8, transform: 'translate(-50%, -100%)' }}
>
<div className="flex items-center gap-2 mb-1">
<span className="w-2.5 h-2.5 rounded-sm shrink-0" style={{ backgroundColor: tooltip.color }} />
<span className="font-semibold truncate">{tooltip.rate.type}</span>
</div>
<div className="text-emerald-300 font-semibold">{tooltip.percentage.toFixed(1)}% Completed</div>
<div className="text-gray-300 mt-1">
{(tooltip.rate.completedCombined ?? (tooltip.rate.completed || 0) + (tooltip.rate.closed || 0))} completed ·{' '}
{tooltip.rate.inProgress || 0} prog · {tooltip.rate.rejected || 0} rej
</div>
<div className="text-gray-400">{tooltip.rate.total || 0} total</div>
</div>
)}
<svg width={width} height={chartHeight + 56} className="min-w-full">
{gridLines.map(level => {
const y = chartHeight - (level / 100) * chartHeight + 8;
return (
<g key={level}>
<line x1={paddingLeft} y1={y} x2={width - paddingRight} y2={y} stroke="#E5E7EB" strokeWidth="1" />
<text x={paddingLeft - 8} y={y + 4} textAnchor="end" className="fill-gray-400 text-[10px]">
{level}%
</text>
</g>
);
})}
{rates.map((rate, index) => {
const percentage = getRepairCompletionRate(rate);
const barHeight = Math.max(2, (percentage / 100) * chartHeight);
const x = paddingLeft + index * (barWidth + barGap);
const y = chartHeight - barHeight + 8;
const color = REPAIR_COMPLETION_BAR_COLORS[index % REPAIR_COMPLETION_BAR_COLORS.length];
const label = rate.type.length > 12 ? `${rate.type.slice(0, 10)}` : rate.type;
return (
<g key={rate.type}>
<rect
x={x}
y={y}
width={barWidth}
height={barHeight}
rx={4}
fill={color}
className="hover:opacity-85 transition-opacity cursor-default"
onMouseEnter={(e) =>
setTooltip({
x: e.clientX,
y: e.clientY,
rate,
color,
percentage,
})
}
onMouseMove={(e) =>
setTooltip({
x: e.clientX,
y: e.clientY,
rate,
color,
percentage,
})
}
onMouseLeave={hideTooltip}
/>
<text
x={x + barWidth / 2}
y={chartHeight + 24}
textAnchor="middle"
className="fill-gray-500 text-[10px]"
>
{label}
</text>
</g>
);
})}
</svg>
<div className="flex flex-wrap gap-3 mt-1 px-1">
{rates.slice(0, 6).map((rate, index) => (
<div key={rate.type} className="flex items-center gap-1.5 text-[10px] text-gray-500">
<span
className="w-2.5 h-2.5 rounded-sm shrink-0"
style={{ backgroundColor: REPAIR_COMPLETION_BAR_COLORS[index % REPAIR_COMPLETION_BAR_COLORS.length] }}
/>
<span className="truncate max-w-[100px]">{rate.type}</span>
</div>
))}
</div>
</div>
);
};
export default RepairStatusCompletionBarChart;

View File

@ -0,0 +1,87 @@
import React, { useMemo } from 'react';
import { FaCheckCircle, FaExternalLinkAlt } from 'react-icons/fa';
import { useTranslation } from 'react-i18next';
import RepairStatusCompletionBarChart from './RepairStatusCompletionBarChart';
import type { CompletionByTypeData } from '../utils/chartLabelUtils';
type RepairStatusCompletionCardProps = {
loading?: boolean;
totalWorkOrders: number;
completedWorkOrders: number;
inProgressWorkOrders: number;
rejectedWorkOrders: number;
byTypeChartData: CompletionByTypeData | null;
onOpenReport: () => void;
};
const RepairStatusCompletionCard: React.FC<RepairStatusCompletionCardProps> = ({
loading = false,
totalWorkOrders,
completedWorkOrders,
inProgressWorkOrders,
rejectedWorkOrders,
byTypeChartData,
onOpenReport,
}) => {
const { t } = useTranslation();
const completionRate = useMemo(() => {
if (totalWorkOrders <= 0) return '0.00';
return ((completedWorkOrders / totalWorkOrders) * 100).toFixed(2);
}, [completedWorkOrders, totalWorkOrders]);
return (
<div className="bg-white dark:bg-gray-800 rounded-lg shadow hover:shadow-md transition-all px-5 py-4 border border-gray-200 dark:border-gray-700">
<div className="flex items-start justify-between gap-3 mb-3">
<div className="flex items-start gap-3 min-w-0">
<div className="w-11 h-11 rounded-full bg-emerald-50 dark:bg-emerald-900/30 flex items-center justify-center shrink-0">
<FaCheckCircle className="text-emerald-600 dark:text-emerald-300 text-lg" />
</div>
<div>
<div className="text-2xl font-semibold text-gray-900 dark:text-white">{completionRate}%</div>
<div className="text-sm font-medium text-emerald-700 dark:text-emerald-300">
{t('dashboard.overallCompletionRate', { defaultValue: 'Overall Completion Rate' })}
</div>
</div>
</div>
<button
type="button"
onClick={onOpenReport}
className="p-2 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg text-emerald-600 dark:text-emerald-300 shrink-0"
title={t('dashboard.viewCompletionDetails', { defaultValue: 'View completion details' })}
>
<FaExternalLinkAlt className="w-4 h-4" />
</button>
</div>
{loading ? (
<div className="h-44 flex items-center justify-center">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-emerald-600" />
</div>
) : (
<RepairStatusCompletionBarChart data={byTypeChartData} />
)}
<div className="grid grid-cols-2 sm:grid-cols-4 gap-2 mt-3 pt-3 border-t border-gray-200 dark:border-gray-700">
<div className="rounded-lg bg-indigo-50 dark:bg-indigo-900/20 px-3 py-2 text-center">
<div className="text-lg font-semibold text-indigo-700 dark:text-indigo-300">{totalWorkOrders}</div>
<div className="text-[10px] uppercase tracking-wide text-gray-500 dark:text-gray-400">Total</div>
</div>
<div className="rounded-lg bg-blue-50 dark:bg-blue-900/20 px-3 py-2 text-center">
<div className="text-lg font-semibold text-blue-700 dark:text-blue-300">{inProgressWorkOrders}</div>
<div className="text-[10px] uppercase tracking-wide text-gray-500 dark:text-gray-400">In Progress</div>
</div>
<div className="rounded-lg bg-red-50 dark:bg-red-900/20 px-3 py-2 text-center">
<div className="text-lg font-semibold text-red-700 dark:text-red-300">{rejectedWorkOrders}</div>
<div className="text-[10px] uppercase tracking-wide text-gray-500 dark:text-gray-400">Rejected</div>
</div>
<div className="rounded-lg bg-emerald-50 dark:bg-emerald-900/20 px-3 py-2 text-center">
<div className="text-lg font-semibold text-emerald-700 dark:text-emerald-300">{completedWorkOrders}</div>
<div className="text-[10px] uppercase tracking-wide text-gray-500 dark:text-gray-400">Completed</div>
</div>
</div>
</div>
);
};
export default RepairStatusCompletionCard;

View File

@ -1,7 +1,15 @@
import React, { useState } from 'react';
import React, { useEffect, useState } from 'react';
import { Link, useLocation } from 'react-router-dom';
import { useLanguage } from '../contexts/LanguageContext';
import { useSidebarLayout } from '../contexts/SidebarLayoutContext';
import { useTranslation } from 'react-i18next';
import useRoleProfile from '../hooks/useRoleProfile';
import {
isEndUserRole,
isTechnicianRole,
isSidebarLinkVisible,
type SidebarLinkId,
} from '../utils/roleAccess';
import {
LayoutDashboard,
Package,
@ -29,11 +37,31 @@ interface SidebarProps {
userEmail?: string;
}
function useLgUp(): boolean {
const [isLgUp, setIsLgUp] = useState(() =>
typeof window !== 'undefined' ? window.matchMedia('(min-width: 1024px)').matches : true
);
useEffect(() => {
const mq = window.matchMedia('(min-width: 1024px)');
const handleChange = () => setIsLgUp(mq.matches);
handleChange();
mq.addEventListener('change', handleChange);
return () => mq.removeEventListener('change', handleChange);
}, []);
return isLgUp;
}
const Sidebar: React.FC<SidebarProps> = ({ userEmail }) => {
const [isCollapsed, setIsCollapsed] = useState(false);
const location = useLocation();
const { isRTL } = useLanguage();
const { t } = useTranslation();
const { closeMobileSidebar } = useSidebarLayout();
const isLgUp = useLgUp();
const { roleProfile } = useRoleProfile();
const isRoleRestricted = isEndUserRole(roleProfile) || isTechnicianRole(roleProfile);
// Get base URL for assets (handles both dev and production)
// BASE_URL in Vite already includes trailing slash in production, but not in dev
@ -42,40 +70,27 @@ const Sidebar: React.FC<SidebarProps> = ({ userEmail }) => {
// Version is automatically updated by build script based on file modification time
const imageVersion = import.meta.env.DEV
? `?v=${Date.now()}`
: `?v=1765196376`; // Auto-updated by build script
: `?v=1785757497`; // Auto-updated by build script
const logoVersion = import.meta.env.DEV
? `?v=${Date.now()}`
: `?v=1765198405`; // Auto-updated by build script
: `?v=1785757497`; // Auto-updated by build script
const backgroundImageUrl = baseUrl.endsWith('/')
? `${baseUrl}sidebar-background.jpg${imageVersion}`
: `${baseUrl}/sidebar-background.jpg${imageVersion}`;
// Role-based visibility logic
// const isMaintenanceManagerKASH = userEmail === 'maintenancemanager-kash@gmail.com';
// const isMaintenanceManagerTH = userEmail === 'maintenancemanager-th@gmail.com';
// const isMaintenanceManagerDAJH = userEmail === 'maintenancemanager-dajh@gmail.com';
const isFinanceManager = userEmail === 'financemanager@gmail.com';
const isEndUser = userEmail && (
userEmail.startsWith('enduser1-kash') ||
userEmail.startsWith('enduser1-dajh') ||
userEmail.startsWith('enduser1-th')
);
// const isTechnician = userEmail && (
// userEmail.startsWith('technician1-kash') ||
// userEmail.startsWith('technician1-dajh') ||
// userEmail.startsWith('technician1-th')
// );
const showAsset = !isFinanceManager && !isEndUser;
// const showInventory = !isFinanceManager && !isEndUser;
const showPreventiveMaintenance = !isFinanceManager && !isEndUser;
const showGeneralWO = !isFinanceManager && !isEndUser;
// const showAMTeam = !isFinanceManager && !isEndUser;
// const showProjectDashboard = !isMaintenanceManagerKASH && !isMaintenanceManagerTH && !isMaintenanceManagerDAJH && !isFinanceManager && !isEndUser && !isTechnician;
// const showSiteDashboards = !isFinanceManager && !isEndUser;
// const showSupplierDashboard = !isFinanceManager && !isEndUser;
// const showSLA = !isFinanceManager && !isEndUser && !isTechnician;
// const showSiteInfo = !isFinanceManager && !isEndUser;
const getDefaultLinkVisibility = (linkId: SidebarLinkId): boolean => {
switch (linkId) {
case 'assets':
case 'work-orders':
case 'ppm-planner':
case 'maintenance-calendar':
return !isFinanceManager;
default:
return true;
}
};
const links: SidebarLink[] = [
{
@ -83,28 +98,36 @@ const Sidebar: React.FC<SidebarProps> = ({ userEmail }) => {
title: t('common.dashboard'),
icon: <LayoutDashboard size={20} />,
path: '/dashboard',
visible: true
visible: isRoleRestricted
? isSidebarLinkVisible('dashboard', roleProfile)
: getDefaultLinkVisibility('dashboard')
},
{
id: 'assets',
title: t('common.assets'),
icon: <Package size={20} />,
path: '/assets',
visible: showAsset
visible: isRoleRestricted
? isSidebarLinkVisible('assets', roleProfile)
: getDefaultLinkVisibility('assets')
},
{
id: 'inventory',
title: 'Inventory',
icon: <Package size={20} />,
path: '/inventory',
visible: true
visible: isRoleRestricted
? isSidebarLinkVisible('inventory', roleProfile)
: getDefaultLinkVisibility('inventory')
},
{
id: 'work-orders',
title: t('common.workOrders'),
icon: <ClipboardList size={20} />,
path: '/work-orders',
visible: showGeneralWO
visible: isRoleRestricted
? isSidebarLinkVisible('work-orders', roleProfile)
: getDefaultLinkVisibility('work-orders')
},
// {
// id: 'maintenance',
@ -125,49 +148,63 @@ const Sidebar: React.FC<SidebarProps> = ({ userEmail }) => {
title: 'PPM Planner',
icon: <CalendarCheck size={20} />,
path: '/ppm-planner',
visible: showPreventiveMaintenance
visible: isRoleRestricted
? isSidebarLinkVisible('ppm-planner', roleProfile)
: getDefaultLinkVisibility('ppm-planner')
},
{
id: 'maintenance-calendar',
title: 'Maintenance Calendar',
icon: <Calendar size={20} />,
path: '/maintenance-calendar',
visible: showPreventiveMaintenance
visible: isRoleRestricted
? isSidebarLinkVisible('maintenance-calendar', roleProfile)
: getDefaultLinkVisibility('maintenance-calendar')
},
{
id: 'active-map',
title: 'Active Map',
icon: <Map size={20} />,
path: '/active-map',
visible: true
visible: isRoleRestricted
? isSidebarLinkVisible('active-map', roleProfile)
: getDefaultLinkVisibility('active-map')
},
{
id: 'maintenance-teams',
title: 'Maintenance Team',
icon: <Users size={20} />,
path: '/maintenance-teams',
visible: true
visible: isRoleRestricted
? isSidebarLinkVisible('maintenance-teams', roleProfile)
: getDefaultLinkVisibility('maintenance-teams')
},
{
id: 'procurement',
title: 'Procurement',
icon: <ShoppingCart size={20} />,
path: '/procurement',
visible: true
visible: isRoleRestricted
? isSidebarLinkVisible('procurement', roleProfile)
: getDefaultLinkVisibility('procurement')
},
{
id: 'sla',
title: 'Service Level Agreement (SLA)',
icon: <FileText size={20} />,
path: '/sla',
visible: true
visible: isRoleRestricted
? isSidebarLinkVisible('sla', roleProfile)
: getDefaultLinkVisibility('sla')
},
{
id: 'support',
title: 'Support',
icon: <HelpCircle size={20} />,
path: '/support',
visible: true
visible: isRoleRestricted
? isSidebarLinkVisible('support', roleProfile)
: getDefaultLinkVisibility('support')
},
// {
@ -220,6 +257,12 @@ const Sidebar: React.FC<SidebarProps> = ({ userEmail }) => {
return location.pathname === path;
};
const afterNav = () => {
if (!isLgUp) {
closeMobileSidebar();
}
};
return (
<div
className={`
@ -232,7 +275,8 @@ const Sidebar: React.FC<SidebarProps> = ({ userEmail }) => {
flex-col
shadow-xl
border-r border-gray-200 dark:border-gray-700
${isCollapsed ? 'w-16' : 'w-64'}
w-64
${isCollapsed ? 'lg:w-16' : 'lg:w-64'}
`}
style={{
backgroundImage: `url(${backgroundImageUrl})`,
@ -291,7 +335,7 @@ const Sidebar: React.FC<SidebarProps> = ({ userEmail }) => {
)}
<button
onClick={() => setIsCollapsed(!isCollapsed)}
className="text-white dark:text-white hover:bg-white/20 dark:hover:bg-white/20 p-2 rounded-lg transition-colors"
className="hidden lg:inline-flex text-white dark:text-white hover:bg-white/20 dark:hover:bg-white/20 p-2 rounded-lg transition-colors"
>
{isCollapsed ? <Menu size={20} /> : <X size={20} />}
</button>
@ -303,6 +347,7 @@ const Sidebar: React.FC<SidebarProps> = ({ userEmail }) => {
<Link
key={link.id}
to={link.path}
onClick={afterNav}
className={`
flex
items-center

View File

@ -0,0 +1,114 @@
import React, { useMemo, useState } from 'react';
type StackedDataset = {
name: string;
values: number[];
color: string;
};
type StackedBarChartProps = {
labels: string[];
datasets: StackedDataset[];
};
const StackedBarChart: React.FC<StackedBarChartProps> = ({
labels,
datasets,
}) => {
const [tooltip, setTooltip] = useState<{ x: number; y: number; text: string } | null>(null);
const rowTotals = useMemo(
() =>
labels.map((_, index) =>
datasets.reduce((sum, dataset) => sum + (Number(dataset.values[index]) || 0), 0)
),
[labels, datasets]
);
const maxTotal = useMemo(() => Math.max(...rowTotals, 1), [rowTotals]);
if (!labels.length || !datasets.length) {
return (
<div className="h-48 flex items-center justify-center text-gray-400 text-xs">
No chart data available
</div>
);
}
return (
<div className="relative" onMouseLeave={() => setTooltip(null)}>
{tooltip && (
<div
className="fixed z-[90] pointer-events-none rounded-md bg-gray-900 text-white text-xs px-2 py-1 shadow-lg"
style={{ left: tooltip.x + 12, top: tooltip.y + 12 }}
>
{tooltip.text}
</div>
)}
<div className="max-h-72 overflow-y-auto pr-1 space-y-2">
{labels.map((label, rowIndex) => {
const total = rowTotals[rowIndex] || 0;
return (
<div key={`${label}-${rowIndex}`} className="grid grid-cols-[120px_1fr_36px] gap-2 items-center">
<div
className="text-xs text-gray-700 dark:text-gray-300 truncate"
title={label}
>
{label}
</div>
<div className="h-7 bg-gray-100 dark:bg-gray-700/60 rounded-md overflow-hidden flex">
{datasets.map(dataset => {
const value = Number(dataset.values[rowIndex]) || 0;
if (value <= 0) return null;
const widthPct = (value / maxTotal) * 100;
return (
<div
key={`${dataset.name}-${rowIndex}`}
className="h-full transition-opacity hover:opacity-85 cursor-pointer"
style={{
width: `${widthPct}%`,
backgroundColor: dataset.color,
minWidth: value > 0 ? '4px' : 0,
}}
onMouseEnter={e =>
setTooltip({
x: e.clientX,
y: e.clientY,
text: `${label}: ${dataset.name.replace('_', ' ')} = ${value}`,
})
}
onMouseMove={e =>
setTooltip({
x: e.clientX,
y: e.clientY,
text: `${label}: ${dataset.name.replace('_', ' ')} = ${value}`,
})
}
/>
);
})}
</div>
<div className="text-xs font-semibold text-gray-700 dark:text-gray-200 text-right">
{total}
</div>
</div>
);
})}
</div>
<div className="flex flex-wrap items-center gap-3 mt-3 pt-2 border-t border-gray-200 dark:border-gray-700">
{datasets.map(dataset => (
<div key={dataset.name} className="flex items-center gap-1.5">
<span className="w-2.5 h-2.5 rounded-sm" style={{ backgroundColor: dataset.color }} />
<span className="text-xs text-gray-600 dark:text-gray-400 capitalize">
{dataset.name.replace('_', ' ')}
</span>
</div>
))}
</div>
</div>
);
};
export default StackedBarChart;

View File

@ -0,0 +1,108 @@
import React, { useEffect, useRef, useState } from 'react';
import { FaUserClock } from 'react-icons/fa';
import { useTranslation } from 'react-i18next';
import HorizontalBarChart from './HorizontalBarChart';
import type { TechnicianWorkingHoursChartData } from '../services/technicianWorkingHoursService';
type TechnicianHoursChartCardProps = {
title: string;
data: TechnicianWorkingHoursChartData | null;
loading?: boolean;
onOpenReport: () => void;
};
const TechnicianHoursChartCard: React.FC<TechnicianHoursChartCardProps> = ({
title,
data,
loading = false,
onOpenReport,
}) => {
const { t } = useTranslation();
const [menuOpen, setMenuOpen] = useState(false);
const menuRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (menuRef.current && !menuRef.current.contains(event.target as Node)) {
setMenuOpen(false);
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, []);
const totalHours = data?.totalHours ?? 0;
const subtitle = data
? t('dashboard.technicianHoursSubtitle', {
total: Math.round(totalHours * 100) / 100,
defaultValue: `Total: ${Math.round(totalHours * 100) / 100} hrs`,
})
: t('dashboard.technicianHoursEmpty', { defaultValue: 'No technician hours data' });
return (
<div className="bg-white dark:bg-gray-800 rounded-lg shadow hover:shadow-md transition-all p-3 border border-gray-200 dark:border-gray-700">
<div className="flex items-start justify-between gap-2 mb-2">
<div className="min-w-0">
<div className="flex items-center gap-2">
<button
type="button"
onClick={onOpenReport}
className="p-1.5 rounded-full bg-indigo-50 dark:bg-indigo-900/30 text-indigo-600 dark:text-indigo-300 hover:bg-indigo-100 dark:hover:bg-indigo-900/50 transition-colors shrink-0"
title={t('dashboard.viewFullReport', { defaultValue: 'View Full Report' })}
>
<FaUserClock className="w-3.5 h-3.5" />
</button>
<div className="min-w-0">
<h3 className="text-sm font-semibold text-gray-800 dark:text-white truncate">{title}</h3>
<p className="text-[11px] text-indigo-600 dark:text-indigo-300 mt-0.5">{subtitle}</p>
</div>
</div>
</div>
<div className="relative" ref={menuRef}>
<button
type="button"
onClick={() => setMenuOpen(prev => !prev)}
className="p-1.5 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg"
aria-label="Chart menu"
>
<svg className="w-4 h-4 text-gray-500" fill="currentColor" viewBox="0 0 20 20">
<path d="M10 6a2 2 0 110-4 2 2 0 010 4zM10 12a2 2 0 110-4 2 2 0 010 4zM10 18a2 2 0 110-4 2 2 0 010 4z" />
</svg>
</button>
{menuOpen && (
<div className="absolute right-0 mt-1 w-44 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg shadow-lg z-20 py-1">
<button
type="button"
className="w-full text-left px-3 py-2 text-xs text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-gray-700"
onClick={() => {
setMenuOpen(false);
onOpenReport();
}}
>
{t('dashboard.viewFullReport', { defaultValue: 'View Full Report' })}
</button>
</div>
)}
</div>
</div>
{loading ? (
<div className="h-48 flex flex-col items-center justify-center gap-2">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-indigo-600" />
<span className="text-xs text-gray-500">
{t('dashboard.loadingTechnicianHours', { defaultValue: 'Loading technician hours…' })}
</span>
</div>
) : !data || !data.datasets.length ? (
<div className="h-48 flex items-center justify-center text-gray-400 text-xs">
{t('dashboard.noChartData', { defaultValue: 'No chart data available' })}
</div>
) : (
<HorizontalBarChart labels={data.labels} datasets={data.datasets} valueLabel="hrs" />
)}
</div>
);
};
export default TechnicianHoursChartCard;

View File

@ -0,0 +1,394 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { FaArrowLeft, FaDownload, FaFileExcel, FaPrint, FaSync, FaTimes } from 'react-icons/fa';
import { useTranslation } from 'react-i18next';
import * as XLSX from 'xlsx';
import LinkField from './LinkField';
import {
buildTechnicianWorkSummaryChartData,
getTechnicianLabel,
runTechnicianWorkSummaryReport,
type TechnicianWorkSummaryFilters,
type TechnicianWorkSummaryRow,
} from '../services/technicianWorkSummaryService';
import { buildMobileTeamSiteFilters, isSiteEnabledHospital } from '../utils/hospitalUtils';
import {
compactFilterFieldWrapClass,
compactFilterInputClass,
compactFilterLabelClass,
compactSummaryCardClass,
compactSummaryLabelClass,
compactSummaryValueClass,
compactToolbarBtnPrimary,
compactToolbarBtnSecondary,
} from '../utils/reportModalStyles';
type TechnicianWorkOrderSummaryReportModalProps = {
isOpen: boolean;
onClose: () => void;
permittedIssueTypes?: string[];
isAdmin?: boolean;
defaultWorkOrderType?: string;
defaultFromDate?: string;
defaultToDate?: string;
defaultCompany?: string;
defaultSiteName?: string;
companyLocked?: boolean;
siteLocked?: boolean;
hospitalLinkFilters?: Record<string, unknown>;
};
const TechnicianWorkOrderSummaryReportModal: React.FC<TechnicianWorkOrderSummaryReportModalProps> = ({
isOpen,
onClose,
permittedIssueTypes = [],
isAdmin = false,
defaultWorkOrderType = '',
defaultFromDate = '',
defaultToDate = '',
defaultCompany = '',
defaultSiteName = '',
companyLocked = false,
siteLocked = false,
hospitalLinkFilters,
}) => {
const { t } = useTranslation();
const [fromDate, setFromDate] = useState(defaultFromDate);
const [toDate, setToDate] = useState(defaultToDate);
const [workOrderType, setWorkOrderType] = useState(defaultWorkOrderType);
const [company, setCompany] = useState(defaultCompany);
const [siteName, setSiteName] = useState(defaultSiteName);
const [rows, setRows] = useState<TechnicianWorkSummaryRow[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const singlePermittedType = !isAdmin && permittedIssueTypes.length === 1 ? permittedIssueTypes[0] : '';
const typeLocked = !!singlePermittedType;
const showSiteFilter = isSiteEnabledHospital(company);
const mobileTeamSiteFilters = useMemo(
() => buildMobileTeamSiteFilters(company, siteLocked ? siteName : undefined),
[company, siteName, siteLocked]
);
useEffect(() => {
if (!isOpen) return;
setFromDate(defaultFromDate);
setToDate(defaultToDate);
setWorkOrderType(singlePermittedType || defaultWorkOrderType);
setCompany(defaultCompany);
setSiteName(defaultSiteName);
}, [
isOpen,
defaultFromDate,
defaultToDate,
defaultWorkOrderType,
defaultCompany,
defaultSiteName,
singlePermittedType,
]);
useEffect(() => {
if (!isOpen) return;
const previousOverflow = document.body.style.overflow;
document.body.style.overflow = 'hidden';
return () => {
document.body.style.overflow = previousOverflow;
};
}, [isOpen]);
useEffect(() => {
if (!isOpen) return;
const onKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') onClose();
};
window.addEventListener('keydown', onKeyDown);
return () => window.removeEventListener('keydown', onKeyDown);
}, [isOpen, onClose]);
const buildFilters = useCallback((): TechnicianWorkSummaryFilters => {
const filters: TechnicianWorkSummaryFilters = {};
if (fromDate) filters.from_date = fromDate;
if (toDate) filters.to_date = toDate;
if (workOrderType) filters.work_order_type = workOrderType;
if (company) filters.company = company;
if (siteName && showSiteFilter) filters.site_name = siteName;
return filters;
}, [fromDate, toDate, workOrderType, company, siteName, showSiteFilter]);
const loadReport = useCallback(async () => {
setLoading(true);
setError(null);
try {
let result = await runTechnicianWorkSummaryReport(buildFilters());
setRows(result);
} catch (err) {
setRows([]);
setError(err instanceof Error ? err.message : 'Failed to load report');
} finally {
setLoading(false);
}
}, [buildFilters]);
useEffect(() => {
if (isOpen) {
loadReport();
}
}, [isOpen, loadReport]);
const summary = useMemo(() => {
const chart = buildTechnicianWorkSummaryChartData(rows);
return chart?.totals || { completed: 0, in_progress: 0, open: 0, total: 0 };
}, [rows]);
const exportRows = useMemo(
() =>
rows.map(row => ({
Technician: getTechnicianLabel(row),
Total: Number(row.total) || 0,
Completed: Number(row.completed) || 0,
'In Progress': Number(row.in_progress) || 0,
Open: Number(row.open) || 0,
})),
[rows]
);
const handleExportCsv = () => {
if (!exportRows.length) return;
const headers = Object.keys(exportRows[0]);
const csv = [
headers.join(','),
...exportRows.map(row => headers.map(h => `"${String((row as any)[h]).replace(/"/g, '""')}"`).join(',')),
].join('\n');
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = 'technician-work-order-summary.csv';
link.click();
URL.revokeObjectURL(url);
};
const handleExportExcel = () => {
if (!exportRows.length) return;
const worksheet = XLSX.utils.json_to_sheet(exportRows);
const workbook = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(workbook, worksheet, 'Technicians');
XLSX.writeFile(workbook, 'technician-work-order-summary.xlsx');
};
const handlePrint = () => {
window.print();
};
if (!isOpen) return null;
return (
<div className="fixed inset-0 z-[80] bg-black/50 backdrop-blur-sm">
<div className="fixed inset-0 bg-white dark:bg-gray-900 flex flex-col print:static print:inset-auto">
<div className="bg-gradient-to-r from-purple-700 via-indigo-700 to-purple-800 text-white px-4 sm:px-5 py-3 print:bg-white print:text-black">
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-2 min-w-0">
<button
type="button"
onClick={onClose}
className="p-1.5 rounded-lg hover:bg-white/10 print:hidden"
aria-label="Close"
>
<FaArrowLeft className="text-sm" />
</button>
<div className="min-w-0">
<h2 className="text-base sm:text-lg font-semibold truncate">
{t('dashboard.techniciansWorked', { defaultValue: 'Technicians Work Summary' })}
</h2>
<p className="text-xs sm:text-sm text-purple-100 print:text-gray-600">
{t('dashboard.techniciansWorkedReportHint', {
defaultValue: 'Work orders grouped by assigned contractor',
})}
</p>
</div>
</div>
<button
type="button"
onClick={onClose}
className="p-2 rounded-lg hover:bg-white/10 print:hidden"
aria-label="Close"
>
<FaTimes />
</button>
</div>
</div>
<div className="flex-1 overflow-y-auto p-3 sm:p-4 space-y-3">
<div className="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg p-3 print:hidden">
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-5 gap-2">
<div className={compactFilterFieldWrapClass}>
<label className={compactFilterLabelClass}>
{t('dashboard.fromDate', { defaultValue: 'From Date' })}
</label>
<input
type="date"
value={fromDate}
onChange={e => setFromDate(e.target.value)}
className={compactFilterInputClass}
/>
</div>
<div className={compactFilterFieldWrapClass}>
<label className={compactFilterLabelClass}>
{t('dashboard.toDate', { defaultValue: 'To Date' })}
</label>
<input
type="date"
value={toDate}
onChange={e => setToDate(e.target.value)}
className={compactFilterInputClass}
/>
</div>
<LinkField
label={t('dashboard.technicalDepartment', { defaultValue: 'Technical Department' })}
doctype="Issue Type"
value={workOrderType}
onChange={setWorkOrderType}
placeholder={t('dashboard.allDepartments', { defaultValue: 'All Departments' })}
disabled={typeLocked}
compact
/>
<LinkField
label={t('commonFields.hospital', { defaultValue: 'Hospital' })}
doctype="Company"
value={company}
onChange={value => {
setCompany(value);
if (!isSiteEnabledHospital(value)) setSiteName('');
}}
placeholder={t('dashboard.allHospitals', { defaultValue: 'All Hospitals' })}
disabled={companyLocked}
compact
filters={hospitalLinkFilters}
/>
{showSiteFilter && (
<LinkField
label={t('commonFields.siteName', { defaultValue: 'PHCC Site' })}
doctype="Mobile Team Site"
value={siteName}
onChange={setSiteName}
placeholder={t('dashboard.allSites', { defaultValue: 'All Sites' })}
disabled={siteLocked}
compact
filters={mobileTeamSiteFilters}
/>
)}
</div>
<div className="flex flex-wrap items-center gap-1.5 mt-2">
<button
type="button"
onClick={loadReport}
disabled={loading}
className={compactToolbarBtnPrimary('purple')}
>
<FaSync className={`text-[10px] ${loading ? 'animate-spin' : ''}`} />
{t('dashboard.refreshReport', { defaultValue: 'Refresh' })}
</button>
<button
type="button"
onClick={handleExportCsv}
disabled={!exportRows.length}
className={compactToolbarBtnSecondary}
>
<FaDownload className="text-[10px]" />
CSV
</button>
<button
type="button"
onClick={handleExportExcel}
disabled={!exportRows.length}
className={compactToolbarBtnSecondary}
>
<FaFileExcel className="text-[10px]" />
Excel
</button>
<button
type="button"
onClick={handlePrint}
disabled={!exportRows.length}
className={compactToolbarBtnSecondary}
>
<FaPrint className="text-[10px]" />
{t('listPages.print', { defaultValue: 'Print' })}
</button>
</div>
</div>
<div className="grid grid-cols-2 lg:grid-cols-4 gap-2">
<SummaryStat label={t('dashboard.totalWorkOrders', { defaultValue: 'Total' })} value={summary.total} />
<SummaryStat label={t('dashboard.completedWorkOrders', { defaultValue: 'Completed' })} value={summary.completed} color="text-green-600" />
<SummaryStat label={t('dashboard.workOrdersInProgress', { defaultValue: 'In Progress' })} value={summary.in_progress} color="text-blue-600" />
<SummaryStat label={t('dashboard.openWorkOrders', { defaultValue: 'Open' })} value={summary.open} color="text-amber-600" />
</div>
{error && (
<div className="rounded-md border border-red-200 bg-red-50 text-red-700 px-4 py-3 text-sm">
{error}
</div>
)}
<div className="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden">
<div className="overflow-x-auto">
<table className="min-w-full text-sm">
<thead className="bg-gray-50 dark:bg-gray-900/60">
<tr>
<th className="px-4 py-3 text-left font-semibold text-gray-700 dark:text-gray-200">
{t('commonFields.assignedContractor', { defaultValue: 'Assigned Contractor' })}
</th>
<th className="px-4 py-3 text-right font-semibold text-gray-700 dark:text-gray-200">Total</th>
<th className="px-4 py-3 text-right font-semibold text-green-700 dark:text-green-400">Completed</th>
<th className="px-4 py-3 text-right font-semibold text-blue-700 dark:text-blue-400">In Progress</th>
<th className="px-4 py-3 text-right font-semibold text-amber-700 dark:text-amber-400">Open</th>
</tr>
</thead>
<tbody>
{loading ? (
<tr>
<td colSpan={5} className="px-4 py-10 text-center text-gray-500">
{t('common.loading', { defaultValue: 'Loading...' })}
</td>
</tr>
) : rows.length === 0 ? (
<tr>
<td colSpan={5} className="px-4 py-10 text-center text-gray-500">
{t('common.noData', { defaultValue: 'No data' })}
</td>
</tr>
) : (
rows.map((row, index) => (
<tr key={`${getTechnicianLabel(row)}-${index}`} className="border-t border-gray-100 dark:border-gray-700">
<td className="px-4 py-3 text-gray-800 dark:text-gray-100">{getTechnicianLabel(row)}</td>
<td className="px-4 py-3 text-right font-medium">{Number(row.total) || 0}</td>
<td className="px-4 py-3 text-right text-green-700 dark:text-green-400">{Number(row.completed) || 0}</td>
<td className="px-4 py-3 text-right text-blue-700 dark:text-blue-400">{Number(row.in_progress) || 0}</td>
<td className="px-4 py-3 text-right text-amber-700 dark:text-amber-400">{Number(row.open) || 0}</td>
</tr>
))
)}
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
);
};
const SummaryStat: React.FC<{ label: string; value: number; color?: string }> = ({
label,
value,
color = 'text-gray-900 dark:text-white',
}) => (
<div className={`${compactSummaryCardClass} border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800`}>
<div className={compactSummaryLabelClass}>{label}</div>
<div className={`${compactSummaryValueClass} mt-0.5 ${color}`}>{value}</div>
</div>
);
export default TechnicianWorkOrderSummaryReportModal;

View File

@ -0,0 +1,104 @@
import React, { useEffect, useRef, useState } from 'react';
import { FaHardHat } from 'react-icons/fa';
import { useTranslation } from 'react-i18next';
import StackedBarChart from './StackedBarChart';
import type { TechnicianWorkSummaryChartData } from '../services/technicianWorkSummaryService';
type TechnicianWorkedChartCardProps = {
title: string;
data: TechnicianWorkSummaryChartData | null;
loading?: boolean;
onOpenReport: () => void;
};
const TechnicianWorkedChartCard: React.FC<TechnicianWorkedChartCardProps> = ({
title,
data,
loading = false,
onOpenReport,
}) => {
const { t } = useTranslation();
const [menuOpen, setMenuOpen] = useState(false);
const menuRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (menuRef.current && !menuRef.current.contains(event.target as Node)) {
setMenuOpen(false);
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, []);
const subtitle = data
? t('dashboard.techniciansWorkedSubtitle', {
completed: data.totals.completed,
inProgress: data.totals.in_progress,
open: data.totals.open,
defaultValue: `${data.totals.completed} done • ${data.totals.in_progress} wip • ${data.totals.open} open`,
})
: t('dashboard.techniciansWorkedEmpty', { defaultValue: 'No technician data' });
return (
<div className="bg-white dark:bg-gray-800 rounded-lg shadow hover:shadow-md transition-all p-3 border border-gray-200 dark:border-gray-700">
<div className="flex items-start justify-between gap-2 mb-2">
<div className="min-w-0">
<div className="flex items-center gap-2">
<h3 className="text-sm font-semibold text-gray-800 dark:text-white truncate">{title}</h3>
<button
type="button"
onClick={onOpenReport}
className="p-1.5 rounded-lg bg-purple-50 dark:bg-purple-900/30 text-purple-600 dark:text-purple-300 hover:bg-purple-100 dark:hover:bg-purple-900/50 transition-colors"
title={t('dashboard.viewFullReport', { defaultValue: 'View Full Report' })}
>
<FaHardHat className="w-3.5 h-3.5" />
</button>
</div>
<p className="text-[11px] text-gray-500 dark:text-gray-400 mt-0.5">{subtitle}</p>
</div>
<div className="relative" ref={menuRef}>
<button
type="button"
onClick={() => setMenuOpen(prev => !prev)}
className="p-1.5 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg"
aria-label="Chart menu"
>
<svg className="w-4 h-4 text-gray-500" fill="currentColor" viewBox="0 0 20 20">
<path d="M10 6a2 2 0 110-4 2 2 0 010 4zM10 12a2 2 0 110-4 2 2 0 010 4zM10 18a2 2 0 110-4 2 2 0 010 4z" />
</svg>
</button>
{menuOpen && (
<div className="absolute right-0 mt-1 w-44 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg shadow-lg z-20 py-1">
<button
type="button"
className="w-full text-left px-3 py-2 text-xs text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-gray-700"
onClick={() => {
setMenuOpen(false);
onOpenReport();
}}
>
{t('dashboard.viewFullReport', { defaultValue: 'View Full Report' })}
</button>
</div>
)}
</div>
</div>
{loading ? (
<div className="h-48 flex items-center justify-center">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-purple-600" />
</div>
) : !data || !data.datasets.length ? (
<div className="h-48 flex items-center justify-center text-gray-400 text-xs">
{t('dashboard.noChartData', { defaultValue: 'No chart data available' })}
</div>
) : (
<StackedBarChart labels={data.labels} datasets={data.datasets} />
)}
</div>
);
};
export default TechnicianWorkedChartCard;

View File

@ -0,0 +1,585 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import {
FaArrowLeft,
FaClock,
FaDownload,
FaFileExcel,
FaFilter,
FaPrint,
FaSync,
FaTimes,
FaUserCog,
FaUsers,
} from 'react-icons/fa';
import { useTranslation } from 'react-i18next';
import * as XLSX from 'xlsx';
import LinkField from './LinkField';
import {
getTechnicianHoursLabel,
parseTechnicianHours,
runTechnicianWorkingHoursReport,
type TechnicianWorkingHoursFilters,
type TechnicianWorkingHoursRow,
} from '../services/technicianWorkingHoursService';
import { buildMobileTeamSiteFilters, isSiteEnabledHospital } from '../utils/hospitalUtils';
import {
compactFilterFieldWrapClass,
compactFilterInputClass,
compactFilterLabelClass,
compactSummaryCardClass,
compactSummaryLabelClass,
compactSummaryValueClass,
compactToolbarBtnPrimary,
compactToolbarBtnSecondary,
} from '../utils/reportModalStyles';
type TechnicianWorkingHoursReportModalProps = {
isOpen: boolean;
onClose: () => void;
permittedIssueTypes?: string[];
isAdmin?: boolean;
defaultWorkOrderType?: string;
defaultFromDate?: string;
defaultToDate?: string;
defaultCompany?: string;
defaultSiteName?: string;
companyLocked?: boolean;
siteLocked?: boolean;
hospitalLinkFilters?: Record<string, unknown>;
};
const TechnicianWorkingHoursReportModal: React.FC<TechnicianWorkingHoursReportModalProps> = ({
isOpen,
onClose,
permittedIssueTypes = [],
isAdmin = false,
defaultWorkOrderType = '',
defaultFromDate = '',
defaultToDate = '',
defaultCompany = '',
defaultSiteName = '',
companyLocked = false,
siteLocked = false,
hospitalLinkFilters,
}) => {
const { t } = useTranslation();
const [fromDate, setFromDate] = useState(defaultFromDate);
const [toDate, setToDate] = useState(defaultToDate);
const [workOrderType, setWorkOrderType] = useState(defaultWorkOrderType);
const [company, setCompany] = useState(defaultCompany);
const [siteName, setSiteName] = useState(defaultSiteName);
const [rows, setRows] = useState<TechnicianWorkingHoursRow[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [filtersExpanded, setFiltersExpanded] = useState(true);
const singlePermittedType = !isAdmin && permittedIssueTypes.length === 1 ? permittedIssueTypes[0] : '';
const typeLocked = !!singlePermittedType;
const showSiteFilter = isSiteEnabledHospital(company);
const mobileTeamSiteFilters = useMemo(
() => buildMobileTeamSiteFilters(company, siteLocked ? siteName : undefined),
[company, siteName, siteLocked]
);
useEffect(() => {
if (!isOpen) return;
setFromDate(defaultFromDate);
setToDate(defaultToDate);
setWorkOrderType(singlePermittedType || defaultWorkOrderType);
setCompany(defaultCompany);
setSiteName(defaultSiteName);
setFiltersExpanded(true);
}, [
isOpen,
defaultFromDate,
defaultToDate,
defaultWorkOrderType,
defaultCompany,
defaultSiteName,
singlePermittedType,
]);
useEffect(() => {
if (!isOpen) return;
const previousOverflow = document.body.style.overflow;
document.body.style.overflow = 'hidden';
return () => {
document.body.style.overflow = previousOverflow;
};
}, [isOpen]);
useEffect(() => {
if (!isOpen) return;
const onKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') onClose();
};
window.addEventListener('keydown', onKeyDown);
return () => window.removeEventListener('keydown', onKeyDown);
}, [isOpen, onClose]);
const activeFilterCount = useMemo(() => {
let count = 0;
if (fromDate) count += 1;
if (toDate) count += 1;
if (workOrderType) count += 1;
if (company) count += 1;
if (siteName && showSiteFilter) count += 1;
return count;
}, [fromDate, toDate, workOrderType, company, siteName, showSiteFilter]);
const buildFilters = useCallback((): TechnicianWorkingHoursFilters => {
const filters: TechnicianWorkingHoursFilters = {};
if (fromDate) filters.from_date = fromDate;
if (toDate) filters.to_date = toDate;
if (workOrderType) filters.work_order_type = workOrderType;
if (company) filters.company = company;
if (siteName && showSiteFilter) filters.site_name = siteName;
return filters;
}, [fromDate, toDate, workOrderType, company, siteName, showSiteFilter]);
const loadReport = useCallback(async () => {
setLoading(true);
setError(null);
try {
const result = await runTechnicianWorkingHoursReport(buildFilters());
setRows(result);
} catch (err) {
setRows([]);
setError(err instanceof Error ? err.message : 'Failed to load report');
} finally {
setLoading(false);
}
}, [buildFilters]);
useEffect(() => {
if (isOpen) {
loadReport();
}
}, [isOpen, loadReport]);
const summary = useMemo(() => {
const totalTechnicians = rows.length;
const totalHours = rows.reduce((sum, row) => sum + parseTechnicianHours(row.total_hours), 0);
const avgHours = totalTechnicians > 0 ? totalHours / totalTechnicians : 0;
return {
totalTechnicians,
totalHours: Math.round(totalHours * 100) / 100,
avgHours: Math.round(avgHours * 100) / 100,
};
}, [rows]);
const exportRows = useMemo(
() =>
rows.map(row => ({
Engineer: row.engineer || '',
'Technician Name': getTechnicianHoursLabel(row),
'Total Hours': parseTechnicianHours(row.total_hours),
})),
[rows]
);
const handleClearFilters = () => {
setFromDate(defaultFromDate);
setToDate(defaultToDate);
setWorkOrderType(singlePermittedType || defaultWorkOrderType);
setCompany(defaultCompany);
setSiteName(defaultSiteName);
};
const handleExportCsv = () => {
if (!exportRows.length) return;
const headers = Object.keys(exportRows[0]);
const csv = [
headers.join(','),
...exportRows.map(row => headers.map(h => `"${String((row as any)[h]).replace(/"/g, '""')}"`).join(',')),
].join('\n');
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = 'technicians-working-hours.csv';
link.click();
URL.revokeObjectURL(url);
};
const handleExportExcel = () => {
if (!exportRows.length) return;
const worksheet = XLSX.utils.json_to_sheet(exportRows);
const workbook = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(workbook, worksheet, 'Technicians');
XLSX.writeFile(workbook, 'technicians-working-hours.xlsx');
};
const handlePrint = () => {
if (!exportRows.length) return;
const tableRows = rows
.map(
(row, index) => `
<tr>
<td>${index + 1}</td>
<td>${getTechnicianHoursLabel(row)}</td>
<td>${row.engineer || ''}</td>
<td class="hours">${parseTechnicianHours(row.total_hours)} hrs</td>
</tr>
`
)
.join('');
const printWindow = window.open('', '_blank');
if (!printWindow) return;
printWindow.document.write(`
<!DOCTYPE html>
<html>
<head>
<title>Technicians Working Hours</title>
<style>
body { font-family: Arial, sans-serif; padding: 24px; color: #111827; }
h1 { color: #0891B2; margin-bottom: 8px; }
p { color: #6B7280; margin-top: 0; }
table { width: 100%; border-collapse: collapse; margin-top: 20px; }
th { background: #0891B2; color: white; text-align: left; padding: 10px 12px; }
td { border-bottom: 1px solid #E5E7EB; padding: 10px 12px; }
.hours { color: #0891B2; font-weight: 700; }
</style>
</head>
<body>
<h1>Technicians Working Hours</h1>
<p>${rows.length} technicians ${summary.totalHours} total hours</p>
<table>
<thead>
<tr>
<th>#</th>
<th>Technician Name</th>
<th>Engineer</th>
<th>Total Hours</th>
</tr>
</thead>
<tbody>${tableRows}</tbody>
</table>
</body>
</html>
`);
printWindow.document.close();
printWindow.focus();
printWindow.print();
};
if (!isOpen) return null;
return (
<div className="fixed inset-0 z-[80] bg-black/50 backdrop-blur-sm">
<div className="fixed inset-0 bg-white dark:bg-gray-900 flex flex-col">
<div className="bg-gradient-to-r from-cyan-600 to-blue-600 text-white px-4 sm:px-5 py-3">
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-2 min-w-0">
<button
type="button"
onClick={onClose}
className="p-1.5 rounded-lg hover:bg-white/10"
aria-label="Back"
>
<FaArrowLeft className="text-sm" />
</button>
<div className="w-8 h-8 rounded-full bg-white/15 flex items-center justify-center shrink-0">
<FaClock className="text-sm" />
</div>
<div className="min-w-0">
<h2 className="text-base sm:text-lg font-semibold truncate">
{t('dashboard.technicianWorkingHours', { defaultValue: 'Technicians Working Hours' })}
</h2>
<p className="text-xs sm:text-sm text-cyan-100">
{t('dashboard.technicianHoursFound', {
count: summary.totalTechnicians,
defaultValue: `${summary.totalTechnicians} technicians found`,
})}
</p>
</div>
</div>
<button
type="button"
onClick={onClose}
className="p-2 rounded-lg hover:bg-white/10"
aria-label="Close"
>
<FaTimes />
</button>
</div>
</div>
<div className="flex-1 overflow-y-auto p-3 sm:p-4 space-y-3">
{!loading && rows.length > 0 && (
<div className="grid grid-cols-1 sm:grid-cols-3 gap-2">
<SummaryCard
icon={<FaUsers className="text-blue-500" />}
label={t('dashboard.totalTechnicians', { defaultValue: 'Total Technicians' })}
value={String(summary.totalTechnicians)}
theme="blue"
/>
<SummaryCard
icon={<FaClock className="text-cyan-500" />}
label={t('dashboard.totalHoursWorked', { defaultValue: 'Total Hours Worked' })}
value={`${summary.totalHours} hrs`}
theme="cyan"
/>
<SummaryCard
icon={<FaUserCog className="text-purple-500" />}
label={t('dashboard.avgHoursPerTechnician', { defaultValue: 'Average Hours/Technician' })}
value={`${summary.avgHours} hrs`}
theme="purple"
/>
</div>
)}
<div className="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg">
<div className="flex flex-wrap items-center justify-between gap-2 px-3 py-2 border-b border-gray-200 dark:border-gray-700">
<button
type="button"
onClick={() => setFiltersExpanded(prev => !prev)}
className="inline-flex items-center gap-1.5 text-xs font-medium text-gray-700 dark:text-gray-200"
>
<FaFilter className="text-cyan-600 text-xs" />
{t('dashboard.filters', { defaultValue: 'Filters' })}
{activeFilterCount > 0 && (
<span className="inline-flex items-center justify-center min-w-[1rem] h-4 px-1 rounded-full bg-cyan-100 text-cyan-700 text-[10px] font-semibold">
{activeFilterCount}
</span>
)}
</button>
<div className="flex flex-wrap items-center gap-1.5">
<button
type="button"
onClick={loadReport}
disabled={loading}
className={compactToolbarBtnPrimary('cyan')}
>
<FaSync className={`text-[10px] ${loading ? 'animate-spin' : ''}`} />
{t('dashboard.refreshReport', { defaultValue: 'Refresh' })}
</button>
<button
type="button"
onClick={handleExportCsv}
disabled={!exportRows.length}
className={compactToolbarBtnSecondary}
>
<FaDownload className="text-[10px]" />
CSV
</button>
<button
type="button"
onClick={handleExportExcel}
disabled={!exportRows.length}
className={compactToolbarBtnSecondary}
>
<FaFileExcel className="text-[10px]" />
Excel
</button>
<button
type="button"
onClick={handlePrint}
disabled={!exportRows.length}
className={compactToolbarBtnSecondary}
>
<FaPrint className="text-[10px]" />
PDF/Print
</button>
</div>
</div>
{filtersExpanded && (
<div className="p-3 border-b border-gray-200 dark:border-gray-700">
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-5 gap-2">
<div className={compactFilterFieldWrapClass}>
<label className={compactFilterLabelClass}>
{t('dashboard.fromDate', { defaultValue: 'From Date' })}
</label>
<input
type="date"
value={fromDate}
onChange={e => setFromDate(e.target.value)}
className={compactFilterInputClass}
/>
</div>
<div className={compactFilterFieldWrapClass}>
<label className={compactFilterLabelClass}>
{t('dashboard.toDate', { defaultValue: 'To Date' })}
</label>
<input
type="date"
value={toDate}
onChange={e => setToDate(e.target.value)}
className={compactFilterInputClass}
/>
</div>
<LinkField
label={t('dashboard.technicalDepartment', { defaultValue: 'Technical Department' })}
doctype="Issue Type"
value={workOrderType}
onChange={setWorkOrderType}
placeholder={t('dashboard.allDepartments', { defaultValue: 'All Departments' })}
disabled={typeLocked}
compact
/>
<LinkField
label={t('commonFields.hospital', { defaultValue: 'Hospital' })}
doctype="Company"
value={company}
onChange={value => {
setCompany(value);
if (!isSiteEnabledHospital(value)) setSiteName('');
}}
placeholder={t('dashboard.allHospitals', { defaultValue: 'All Hospitals' })}
disabled={companyLocked}
compact
filters={hospitalLinkFilters}
/>
{showSiteFilter && (
<LinkField
label={t('commonFields.siteName', { defaultValue: 'PHCC Site' })}
doctype="Mobile Team Site"
value={siteName}
onChange={setSiteName}
placeholder={t('dashboard.allSites', { defaultValue: 'All Sites' })}
disabled={siteLocked}
compact
filters={mobileTeamSiteFilters}
/>
)}
</div>
<div className="mt-2">
<button
type="button"
onClick={handleClearFilters}
className="text-[11px] text-cyan-700 dark:text-cyan-300 hover:underline"
>
{t('dashboard.clearFilters', { defaultValue: 'Clear Filters' })}
</button>
</div>
</div>
)}
</div>
{error && (
<div className="rounded-md border border-red-200 bg-red-50 text-red-700 px-4 py-3 text-sm">
{error}
</div>
)}
<div className="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden">
<div className="overflow-x-auto">
<table className="min-w-full text-sm">
<thead className="bg-gray-50 dark:bg-gray-900/60 sticky top-0 z-10">
<tr>
<th className="px-4 py-3 text-left font-semibold text-gray-700 dark:text-gray-200 w-12">#</th>
<th className="px-4 py-3 text-left font-semibold text-gray-700 dark:text-gray-200">
{t('dashboard.technicianName', { defaultValue: 'Technician Name' })}
</th>
<th className="px-4 py-3 text-left font-semibold text-gray-700 dark:text-gray-200">
{t('dashboard.engineer', { defaultValue: 'Engineer' })}
</th>
<th className="px-4 py-3 text-right font-semibold text-gray-700 dark:text-gray-200">
{t('dashboard.totalHoursSpent', { defaultValue: 'Total Hours Spent' })}
</th>
</tr>
</thead>
<tbody>
{loading ? (
<tr>
<td colSpan={4} className="px-4 py-10 text-center text-gray-500">
{t('dashboard.loadingTechnicianHours', { defaultValue: 'Loading technician hours…' })}
</td>
</tr>
) : rows.length === 0 ? (
<tr>
<td colSpan={4} className="px-4 py-10 text-center text-gray-500">
{t('common.noData', { defaultValue: 'No data' })}
</td>
</tr>
) : (
rows.map((row, index) => (
<tr key={`${row.engineer || getTechnicianHoursLabel(row)}-${index}`} className="border-t border-gray-100 dark:border-gray-700">
<td className="px-4 py-3 text-gray-500">{index + 1}</td>
<td className="px-4 py-3">
<div className="flex items-center gap-2 min-w-0">
<div className="w-8 h-8 rounded-full bg-cyan-50 dark:bg-cyan-900/20 flex items-center justify-center shrink-0">
<FaUserCog className="text-cyan-600 dark:text-cyan-300 text-sm" />
</div>
<span className="truncate text-gray-800 dark:text-gray-100">{getTechnicianHoursLabel(row)}</span>
</div>
</td>
<td className="px-4 py-3">
{row.engineer ? (
<a
href={`/app/user/${encodeURIComponent(row.engineer)}`}
target="_blank"
rel="noopener noreferrer"
className="text-cyan-700 dark:text-cyan-300 hover:underline truncate inline-block max-w-[220px]"
>
{row.engineer}
</a>
) : (
<span className="text-gray-400"></span>
)}
</td>
<td className="px-4 py-3 text-right">
<span className="inline-flex items-center gap-1 rounded-full bg-cyan-50 dark:bg-cyan-900/20 px-3 py-1 text-cyan-700 dark:text-cyan-300 font-semibold">
<FaClock className="text-xs" />
{parseTechnicianHours(row.total_hours)} hrs
</span>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</div>
</div>
<div className="border-t border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-900 px-3 sm:px-4 py-2 flex flex-wrap items-center justify-between gap-2">
<div className="text-xs text-gray-600 dark:text-gray-300">
{t('dashboard.technicianHoursFooter', {
count: rows.length,
total: summary.totalHours,
defaultValue: `Showing ${rows.length} technicians (filtered) • ${summary.totalHours} total hours`,
})}
</div>
<button
type="button"
onClick={onClose}
className="px-3 py-1.5 rounded-md border border-gray-300 dark:border-gray-600 text-xs hover:bg-gray-50 dark:hover:bg-gray-800"
>
{t('common.close', { defaultValue: 'Close' })}
</button>
</div>
</div>
</div>
);
};
const SummaryCard: React.FC<{
icon: React.ReactNode;
label: string;
value: string;
theme: 'blue' | 'cyan' | 'purple';
}> = ({ icon, label, value, theme }) => {
const themeClasses = {
blue: 'bg-blue-50 dark:bg-blue-900/20 border-blue-100 dark:border-blue-800',
cyan: 'bg-cyan-50 dark:bg-cyan-900/20 border-cyan-100 dark:border-cyan-800',
purple: 'bg-purple-50 dark:bg-purple-900/20 border-purple-100 dark:border-purple-800',
}[theme];
return (
<div className={`${compactSummaryCardClass} ${themeClasses}`}>
<div className="flex items-center gap-1.5 mb-1">
{icon}
<div className={compactSummaryLabelClass}>{label}</div>
</div>
<div className={compactSummaryValueClass}>{value}</div>
</div>
);
};
export default TechnicianWorkingHoursReportModal;

View File

@ -0,0 +1,259 @@
import React, { useEffect, useState } from 'react';
import { FaCheckCircle, FaSpinner } from 'react-icons/fa';
import woFeedbackWebFormService, {
type WoFeedbackRecord,
type WoFeedbackWebFormConfig,
} from '../services/woFeedbackWebFormService';
export interface WoFeedbackFormProps {
workOrder: string;
defaultRequesterName?: string;
compact?: boolean;
onSuccess?: (record: WoFeedbackRecord) => void;
}
type FormPhase = 'loading' | 'form' | 'existing' | 'success' | 'error';
const inputCls =
'w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-indigo-400';
const WoFeedbackForm: React.FC<WoFeedbackFormProps> = ({
workOrder,
defaultRequesterName = '',
compact = false,
onSuccess,
}) => {
const [phase, setPhase] = useState<FormPhase>('loading');
const [config, setConfig] = useState<WoFeedbackWebFormConfig | null>(null);
const [existing, setExisting] = useState<WoFeedbackRecord | null>(null);
const [requesterName, setRequesterName] = useState(defaultRequesterName);
const [rating, setRating] = useState('');
const [comments, setComments] = useState('');
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState('');
useEffect(() => {
setRequesterName(defaultRequesterName);
}, [defaultRequesterName]);
useEffect(() => {
if (!workOrder) {
setPhase('error');
setError('Work Order is required.');
return;
}
let cancelled = false;
(async () => {
setPhase('loading');
setError('');
try {
const [cfg, fb] = await Promise.all([
woFeedbackWebFormService.getWebFormConfig(),
woFeedbackWebFormService.getFeedbackForWorkOrder(workOrder),
]);
if (cancelled) return;
setConfig(cfg);
if (fb) {
setExisting(fb);
setPhase('existing');
} else {
setPhase('form');
}
} catch (e) {
if (cancelled) return;
setError(e instanceof Error ? e.message : 'Failed to load feedback form');
setPhase('error');
}
})();
return () => { cancelled = true; };
}, [workOrder]);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!requesterName.trim()) {
setError('Requester Name is required.');
return;
}
if (!rating) {
setError('Please select a rating.');
return;
}
setSubmitting(true);
setError('');
try {
const saved = await woFeedbackWebFormService.submitFeedback({
work_order: workOrder,
requester_name: requesterName.trim(),
rating,
comments: comments.trim(),
});
setExisting(saved);
setPhase('success');
onSuccess?.(saved);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to submit feedback');
} finally {
setSubmitting(false);
}
};
if (phase === 'loading') {
return (
<div className={`flex items-center justify-center gap-2 text-gray-500 ${compact ? 'py-4' : 'py-10'}`}>
<FaSpinner className="animate-spin text-indigo-500" />
<span className="text-sm">Loading feedback form</span>
</div>
);
}
if (phase === 'error' && !config) {
return (
<div className="rounded-lg border border-red-200 bg-red-50 dark:bg-red-900/20 dark:border-red-800 px-4 py-3 text-sm text-red-700 dark:text-red-300">
{error || 'Unable to load feedback form.'}
</div>
);
}
const title = config?.title || 'WO Feedback';
if (phase === 'existing' && existing) {
return (
<div className={compact ? 'space-y-3' : 'space-y-4'}>
{!compact && config?.introductionText && (
<p className="text-sm text-gray-600 dark:text-gray-400">{config.introductionText}</p>
)}
<div className="rounded-lg border border-green-200 dark:border-green-800 bg-green-50 dark:bg-green-900/20 p-4 text-center">
<FaCheckCircle className="mx-auto text-green-600 mb-2" />
<p className="text-sm font-semibold text-green-800 dark:text-green-300">Feedback already submitted</p>
<p className="text-xs text-green-700 dark:text-green-400 mt-2">
<span className="font-medium">Requester:</span> {existing.requester_name || '—'}
</p>
<p className="text-sm font-bold text-green-800 dark:text-green-200 mt-1">
{existing.rating || '—'}
</p>
{existing.comments && (
<p className="text-xs text-green-700 dark:text-green-400 mt-2 text-left">
<span className="font-medium">Comments:</span> {existing.comments}
</p>
)}
</div>
</div>
);
}
if (phase === 'success' && existing) {
return (
<div className={`rounded-lg border border-green-200 dark:border-green-800 bg-green-50 dark:bg-green-900/20 p-4 text-center ${compact ? '' : 'py-8'}`}>
<FaCheckCircle className="mx-auto text-green-600 text-2xl mb-3" />
<h3 className="text-base font-bold text-green-800 dark:text-green-200">
{config?.successTitle || 'Feedback Submitted'}
</h3>
<p className="text-sm text-green-700 dark:text-green-300 mt-1">
{config?.successMessage || 'Your feedback was submitted successfully.'}
</p>
<p className="text-xs text-green-600 dark:text-green-400 mt-3">
{existing.rating}
</p>
{existing.comments && (
<p className="text-xs text-green-600 dark:text-green-400 mt-2">
{existing.comments}
</p>
)}
</div>
);
}
return (
<form onSubmit={handleSubmit} className={compact ? 'space-y-3' : 'space-y-4'}>
{!compact && (
<div>
<h3 className="text-base font-semibold text-gray-900 dark:text-white">{title}</h3>
{config?.introductionText && (
<p className="text-sm text-gray-600 dark:text-gray-400 mt-1">{config.introductionText}</p>
)}
</div>
)}
{compact && config?.introductionText && (
<p className="text-xs text-gray-500 dark:text-gray-400">{config.introductionText}</p>
)}
<div>
<label className="block text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase mb-1">
Work Order
</label>
<div className="px-3 py-2 text-sm bg-gray-100 dark:bg-gray-900/40 border border-gray-200 dark:border-gray-700 rounded-lg text-gray-800 dark:text-gray-200">
{workOrder}
</div>
</div>
<div>
<label className="block text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase mb-1">
Requester Name <span className="text-red-500">*</span>
</label>
<input
type="text"
value={requesterName}
onChange={e => setRequesterName(e.target.value)}
className={inputCls}
placeholder="Your name"
required
/>
</div>
<div>
<label className="block text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase mb-1">
Rating <span className="text-red-500">*</span>
</label>
<select
value={rating}
onChange={e => setRating(e.target.value)}
className={inputCls}
required
>
<option value="">Select rating</option>
{(config?.ratingOptions || []).map(opt => (
<option key={opt} value={opt}>{opt}</option>
))}
</select>
</div>
<div>
<label className="block text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase mb-1">
Comments
</label>
<textarea
value={comments}
onChange={e => setComments(e.target.value)}
className={`${inputCls} min-h-[88px] resize-y`}
placeholder="Optional comments…"
rows={3}
/>
</div>
{error && (
<p className="text-sm text-red-600 dark:text-red-400">{error}</p>
)}
<button
type="submit"
disabled={submitting}
className="w-full flex items-center justify-center gap-2 px-4 py-2.5 bg-indigo-600 hover:bg-indigo-700 text-white rounded-lg text-sm font-medium disabled:opacity-50"
>
{submitting ? (
<>
<FaSpinner className="animate-spin" size={14} />
Submitting
</>
) : (
config?.buttonLabel || 'Save'
)}
</button>
</form>
);
};
export default WoFeedbackForm;

View File

@ -0,0 +1,336 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { FaExternalLinkAlt, FaFileExport, FaStar, FaTimes } from 'react-icons/fa';
import { useTranslation } from 'react-i18next';
import LinkField from './LinkField';
import QueryReportExportModal from './QueryReportExportModal';
import woFeedbackReportService, {
type WoFeedbackReportFilters,
} from '../services/woFeedbackReportService';
import { WO_FEEDBACK_RATING_OPTIONS } from '../utils/woFeedbackChartUtils';
import { buildMobileTeamSiteFilters, isSiteEnabledHospital } from '../utils/hospitalUtils';
interface WoFeedbackReportModalProps {
isOpen: boolean;
onClose: () => void;
defaultCompany?: string;
defaultSiteName?: string;
companyLocked?: boolean;
siteLocked?: boolean;
hospitalLinkFilters?: Record<string, unknown>;
}
type AppliedFilters = WoFeedbackReportFilters;
const EMPTY_FILTERS: AppliedFilters = {
work_order: '',
requester_name: '',
rating: '',
company: '',
site_name: '',
};
const compactFilterLabelClass =
'block font-medium text-gray-700 dark:text-gray-300 text-[10px] mb-0.5';
const compactFilterInputClass =
'w-full px-2 py-1 text-xs border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:outline-none focus:ring-1 focus:ring-emerald-500 disabled:bg-gray-100 dark:disabled:bg-gray-700';
const WoFeedbackReportModal: React.FC<WoFeedbackReportModalProps> = ({
isOpen,
onClose,
defaultCompany = '',
defaultSiteName = '',
companyLocked = false,
siteLocked = false,
hospitalLinkFilters,
}) => {
const { t } = useTranslation();
const [reportData, setReportData] = useState<any>(null);
const [loading, setLoading] = useState(false);
const [refreshing, setRefreshing] = useState(false);
const [workOrder, setWorkOrder] = useState('');
const [requesterNameDraft, setRequesterNameDraft] = useState('');
const [rating, setRating] = useState('');
const [company, setCompany] = useState(defaultCompany);
const [siteName, setSiteName] = useState(defaultSiteName);
const [appliedFilters, setAppliedFilters] = useState<AppliedFilters>(EMPTY_FILTERS);
const [showExportModal, setShowExportModal] = useState(false);
const hasLoadedOnce = useRef(false);
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const showSiteFilter = isSiteEnabledHospital(company);
const mobileTeamSiteFilters = useMemo(
() => buildMobileTeamSiteFilters(company, siteLocked ? siteName : undefined),
[company, siteName, siteLocked]
);
useEffect(() => {
if (!isOpen) {
setWorkOrder('');
setRequesterNameDraft('');
setRating('');
setCompany(defaultCompany);
setSiteName(defaultSiteName);
setAppliedFilters(EMPTY_FILTERS);
setReportData(null);
setLoading(false);
setRefreshing(false);
hasLoadedOnce.current = false;
setShowExportModal(false);
if (debounceRef.current) clearTimeout(debounceRef.current);
}
}, [isOpen, defaultCompany, defaultSiteName]);
useEffect(() => {
if (!isOpen) return;
setCompany(defaultCompany);
setSiteName(defaultSiteName);
}, [isOpen, defaultCompany, defaultSiteName]);
useEffect(() => {
if (!isOpen) return;
if (debounceRef.current) clearTimeout(debounceRef.current);
debounceRef.current = setTimeout(() => {
setAppliedFilters({
work_order: workOrder.trim(),
requester_name: requesterNameDraft.trim(),
rating,
company: company.trim(),
site_name: showSiteFilter ? siteName.trim() : '',
});
}, 450);
return () => {
if (debounceRef.current) clearTimeout(debounceRef.current);
};
}, [isOpen, workOrder, requesterNameDraft, rating, company, siteName, showSiteFilter]);
const fetchReport = useCallback(async (filters: AppliedFilters) => {
const isRefresh = hasLoadedOnce.current;
if (isRefresh) setRefreshing(true);
else setLoading(true);
try {
const data = await woFeedbackReportService.runWoFeedbackReport(filters);
setReportData(data || null);
hasLoadedOnce.current = true;
} catch (e) {
console.error('WO feedback report error:', e);
if (!isRefresh) setReportData(null);
} finally {
setLoading(false);
setRefreshing(false);
}
}, []);
useEffect(() => {
if (!isOpen) return;
fetchReport(appliedFilters);
}, [isOpen, appliedFilters, fetchReport]);
if (!isOpen) return null;
const columns: any[] = reportData?.columns || [];
const rows: any[] = (reportData?.result || []).filter(
(r: any) => r && typeof r === 'object' && !r.is_total_row,
);
const hasActiveFilter = Boolean(
workOrder.trim() ||
requesterNameDraft.trim() ||
rating ||
company.trim() ||
(showSiteFilter && siteName.trim())
);
const showInitialLoader = loading && !hasLoadedOnce.current;
const showEmpty = !showInitialLoader && rows.length === 0;
return (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-[70] p-4">
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-2xl max-w-5xl w-full max-h-[90vh] overflow-hidden flex flex-col">
<div className="bg-gradient-to-r from-emerald-600 to-teal-600 px-6 py-4 flex items-center justify-between shrink-0">
<div className="flex items-center gap-3">
<FaStar className="text-white text-xl" />
<h3 className="text-lg font-semibold text-white">
{t('dashboard.woFeedbackReport', { defaultValue: 'WO Feedback Report' })}
</h3>
</div>
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => setShowExportModal(true)}
disabled={showInitialLoader || rows.length === 0}
className="inline-flex items-center gap-1.5 px-3 py-1.5 text-sm font-medium text-emerald-700 bg-white hover:bg-emerald-50 rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
<FaFileExport size={12} /> Export
</button>
<button
type="button"
onClick={onClose}
className="text-white/80 hover:text-white p-1 rounded-lg hover:bg-white/20 transition-colors"
>
<FaTimes className="w-5 h-5" />
</button>
</div>
</div>
<div className="px-6 py-3 border-b border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-900/30 shrink-0">
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-5 gap-3">
<LinkField
label={t('commonFields.hospital', { defaultValue: 'Hospital' })}
doctype="Company"
value={company}
onChange={value => {
setCompany(value);
if (!isSiteEnabledHospital(value)) setSiteName('');
}}
placeholder={t('dashboard.allHospitals', { defaultValue: 'All Hospitals' })}
disabled={companyLocked}
compact
filters={hospitalLinkFilters}
/>
{showSiteFilter && (
<LinkField
label={t('commonFields.siteName', { defaultValue: 'PHCC Site' })}
doctype="Mobile Team Site"
value={siteName}
onChange={setSiteName}
placeholder={t('dashboard.allSites', { defaultValue: 'All Sites' })}
disabled={siteLocked}
compact
filters={mobileTeamSiteFilters}
/>
)}
<LinkField
label={t('commonFields.workOrder', { defaultValue: 'Work Order' })}
doctype="Work_Order"
value={workOrder}
onChange={setWorkOrder}
placeholder="Filter by work order…"
compact
/>
<div className="relative w-full mb-2">
<label className={compactFilterLabelClass}>Requester Name</label>
<input
type="text"
value={requesterNameDraft}
onChange={e => setRequesterNameDraft(e.target.value)}
placeholder="Filter by requester…"
className={compactFilterInputClass}
/>
</div>
<div className="relative w-full mb-2">
<label className={compactFilterLabelClass}>Rating</label>
<select
value={rating}
onChange={e => setRating(e.target.value)}
className={compactFilterInputClass}
>
<option value="">All ratings</option>
{WO_FEEDBACK_RATING_OPTIONS.map(opt => (
<option key={opt} value={opt}>{opt}</option>
))}
</select>
</div>
</div>
{hasActiveFilter && (
<button
type="button"
onClick={() => {
setWorkOrder('');
setRequesterNameDraft('');
setRating('');
setCompany(defaultCompany);
setSiteName(defaultSiteName);
}}
className="mt-3 text-xs text-red-500 hover:text-red-700 flex items-center gap-1 px-2 py-1 rounded-lg hover:bg-red-50 dark:hover:bg-red-900/20 transition-colors"
>
<FaTimes size={10} /> {t('dashboard.clearFilters', { defaultValue: 'Clear filters' })}
</button>
)}
</div>
<div className="p-6 overflow-auto flex-1 relative min-h-[240px]">
{showInitialLoader ? (
<div className="flex items-center justify-center py-16">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-emerald-600" />
</div>
) : showEmpty ? (
<div className="text-center text-gray-400 py-16">No feedback records found</div>
) : (
<div className={`overflow-x-auto transition-opacity duration-200 ${refreshing ? 'opacity-60' : 'opacity-100'}`}>
{refreshing && (
<div className="absolute top-6 right-6 z-10">
<div className="animate-spin rounded-full h-5 w-5 border-2 border-emerald-600 border-t-transparent" />
</div>
)}
<table className="w-full text-sm border-collapse">
<thead>
<tr className="border-b border-gray-200 dark:border-gray-700">
{columns.map((col: any, i: number) => (
<th
key={i}
className="py-3 px-4 font-semibold text-gray-700 dark:text-gray-300 bg-gray-50 dark:bg-gray-900/50 text-left"
>
{col.label || col.fieldname}
</th>
))}
</tr>
</thead>
<tbody>
{rows.map((row: any, ri: number) => (
<tr
key={ri}
className="border-b border-gray-100 dark:border-gray-700/50 hover:bg-emerald-50/40 dark:hover:bg-emerald-900/10 transition-colors"
>
{columns.map((col: any, ci: number) => {
const fieldLower = (col.fieldname || '').toLowerCase();
const isWoCol = fieldLower === 'work_order';
const cellVal = row[col.fieldname] ?? '—';
const woId = isWoCol ? String(cellVal) : '';
const isClickable = isWoCol && woId && woId !== '—';
return (
<td
key={ci}
className={`py-2.5 px-4 align-top ${
isClickable
? 'cursor-pointer text-emerald-600 dark:text-emerald-400 font-semibold hover:underline'
: 'text-gray-800 dark:text-gray-200'
}`}
onClick={isClickable
? () => { window.location.href = `/asm_app/work-orders/${encodeURIComponent(woId)}`; }
: undefined}
title={isClickable ? `Open Work Order ${woId}` : undefined}
>
{isClickable ? (
<span className="inline-flex items-center gap-1.5">
{cellVal}
<FaExternalLinkAlt size={9} className="opacity-50 shrink-0" />
</span>
) : (
cellVal
)}
</td>
);
})}
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
</div>
<QueryReportExportModal
isOpen={showExportModal}
onClose={() => setShowExportModal(false)}
title={t('dashboard.woFeedbackReport', { defaultValue: 'WO Feedback Report' })}
columns={columns}
rows={rows}
fileNamePrefix="wo_feedback_report"
/>
</div>
);
};
export default WoFeedbackReportModal;

View File

@ -0,0 +1,69 @@
import React, { useEffect, useState } from 'react';
import { FaStar } from 'react-icons/fa';
import woFeedbackWebFormService, { type WoFeedbackRecord } from '../services/woFeedbackWebFormService';
export interface WoFeedbackSummaryProps {
workOrder: string;
}
const WoFeedbackSummary: React.FC<WoFeedbackSummaryProps> = ({ workOrder }) => {
const [loading, setLoading] = useState(true);
const [feedback, setFeedback] = useState<WoFeedbackRecord | null>(null);
useEffect(() => {
if (!workOrder) {
setLoading(false);
setFeedback(null);
return;
}
let cancelled = false;
(async () => {
setLoading(true);
try {
const fb = await woFeedbackWebFormService.getFeedbackForWorkOrder(workOrder);
if (!cancelled) setFeedback(fb);
} catch {
if (!cancelled) setFeedback(null);
} finally {
if (!cancelled) setLoading(false);
}
})();
return () => { cancelled = true; };
}, [workOrder]);
if (loading || !feedback?.rating) return null;
return (
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-md p-6 border border-gray-200 dark:border-gray-700">
<h2 className="text-base font-semibold text-gray-800 dark:text-white mb-4 pb-2 border-b border-gray-200 dark:border-gray-700">
WO Feedback
</h2>
<div className="flex items-start gap-3">
<FaStar className="text-amber-500 mt-0.5 shrink-0" />
<div>
<p className="text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase mb-1">
Rating
</p>
<p className="text-sm font-semibold text-gray-900 dark:text-white">
{feedback.rating}
</p>
{feedback.requester_name && (
<p className="text-xs text-gray-500 dark:text-gray-400 mt-2">
<span className="font-medium">Requester:</span> {feedback.requester_name}
</p>
)}
{feedback.comments && (
<p className="text-xs text-gray-500 dark:text-gray-400 mt-2">
<span className="font-medium">Comments:</span> {feedback.comments}
</p>
)}
</div>
</div>
</div>
);
};
export default WoFeedbackSummary;

View File

@ -0,0 +1,396 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { FaClipboardList, FaSync, FaTimes } from 'react-icons/fa';
import { useTranslation } from 'react-i18next';
import { buildWoUrl } from '../utils/buildWoUrl';
import type { DashboardLocationFilters } from '../utils/hospitalUtils';
import type { AssetDeviceStatusSummaryFilters } from '../services/assetDeviceStatusService';
import {
compactFilterFieldWrapClass,
compactFilterInputClass,
compactFilterLabelClass,
compactSummaryCardClass,
compactSummaryLabelClass,
compactSummaryValueClass,
compactToolbarBtnPrimary,
} from '../utils/reportModalStyles';
type ChartDataset = {
name?: string;
values?: number[];
color?: string;
colors?: string[];
};
type TypeStatusChartData = {
labels?: string[];
datasets?: ChartDataset[];
};
type WorkOrderStatusRefreshResult = {
chart: TypeStatusChartData | null;
total: number;
};
type WorkOrderStatusReportModalProps = {
isOpen: boolean;
onClose: () => void;
initialChartData?: TypeStatusChartData | null;
defaultFromDate?: string;
defaultToDate?: string;
locationFilters?: DashboardLocationFilters;
workOrderType?: string;
onRefresh?: (filters: AssetDeviceStatusSummaryFilters & { work_order_type?: string }) => Promise<WorkOrderStatusRefreshResult>;
};
const APP_BASE = '/asm_app';
const STATUS_HEADER_COLORS: Record<string, string> = {
Open: 'text-amber-700 dark:text-amber-300',
'Work In Progress': 'text-blue-700 dark:text-blue-300',
'Pending Review': 'text-rose-700 dark:text-rose-300',
Completed: 'text-green-700 dark:text-green-400',
Closed: 'text-purple-700 dark:text-purple-300',
Rejected: 'text-red-700 dark:text-red-400',
Cancelled: 'text-gray-600 dark:text-gray-300',
};
const WorkOrderStatusReportModal: React.FC<WorkOrderStatusReportModalProps> = ({
isOpen,
onClose,
initialChartData = null,
defaultFromDate = '',
defaultToDate = '',
locationFilters,
workOrderType = '',
onRefresh,
}) => {
const { t } = useTranslation();
const [fromDate, setFromDate] = useState(defaultFromDate);
const [toDate, setToDate] = useState(defaultToDate);
const [chartData, setChartData] = useState<TypeStatusChartData | null>(initialChartData);
const [totalWorkOrders, setTotalWorkOrders] = useState(0);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (!isOpen) return;
setFromDate(defaultFromDate);
setToDate(defaultToDate);
setChartData(initialChartData);
setError(null);
}, [isOpen, defaultFromDate, defaultToDate, initialChartData]);
useEffect(() => {
if (!isOpen) return;
const previousOverflow = document.body.style.overflow;
document.body.style.overflow = 'hidden';
return () => {
document.body.style.overflow = previousOverflow;
};
}, [isOpen]);
useEffect(() => {
if (!isOpen) return;
const onKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') onClose();
};
window.addEventListener('keydown', onKeyDown);
return () => window.removeEventListener('keydown', onKeyDown);
}, [isOpen, onClose]);
const buildFilters = useCallback(() => {
const filters: AssetDeviceStatusSummaryFilters & { work_order_type?: string } = {};
if (locationFilters?.company) filters.company = locationFilters.company;
if (locationFilters?.site_name) filters.site_name = locationFilters.site_name;
if (fromDate) filters.from_date = fromDate;
if (toDate) filters.to_date = toDate;
if (workOrderType) filters.work_order_type = workOrderType;
return filters;
}, [fromDate, toDate, locationFilters, workOrderType]);
const loadData = useCallback(async () => {
if (!onRefresh) return;
setLoading(true);
setError(null);
try {
const result = await onRefresh(buildFilters());
setChartData(result.chart);
setTotalWorkOrders(result.total);
} catch (err) {
setChartData(null);
setTotalWorkOrders(0);
setError(err instanceof Error ? err.message : 'Failed to load report data');
} finally {
setLoading(false);
}
}, [onRefresh, buildFilters]);
useEffect(() => {
if (!isOpen || !onRefresh) return;
loadData();
}, [isOpen, onRefresh]); // eslint-disable-line react-hooks/exhaustive-deps
const typeRows = useMemo(() => {
const labels = chartData?.labels || [];
const datasets = chartData?.datasets || [];
return labels.map((label, rowIndex) => ({
label,
cells: datasets.map(dataset => Number(dataset.values?.[rowIndex]) || 0),
rowTotal: datasets.reduce((sum, dataset) => sum + (Number(dataset.values?.[rowIndex]) || 0), 0),
}));
}, [chartData]);
const typeColumns = chartData?.datasets?.map(dataset => dataset.name || '') || [];
const columnTotals = useMemo(
() =>
typeColumns.map((_column, columnIndex) =>
typeRows.reduce((sum, row) => sum + (row.cells[columnIndex] || 0), 0)
),
[typeColumns, typeRows]
);
const computedTotal = useMemo(
() => typeRows.reduce((sum, row) => sum + row.rowTotal, 0),
[typeRows]
);
const displayTotal = totalWorkOrders || computedTotal;
const navigateToWorkOrders = (params: { work_order_type?: string; status?: string }) => {
const path = buildWoUrl(
{
work_order_type: params.work_order_type || workOrderType || undefined,
status: params.status,
company: locationFilters?.company,
site_name: locationFilters?.site_name,
},
fromDate || undefined,
toDate || undefined,
locationFilters
);
onClose();
window.location.href = `${APP_BASE}${path}`;
};
const handleTypeClick = (typeLabel: string) => {
navigateToWorkOrders({ work_order_type: typeLabel });
};
const handleCellClick = (typeLabel: string, status: string, value: number) => {
if (value <= 0) return;
navigateToWorkOrders({ work_order_type: typeLabel, status });
};
const handleStatusTotalClick = (status: string, value: number) => {
if (value <= 0) return;
navigateToWorkOrders({ status });
};
if (!isOpen) return null;
return (
<div className="fixed inset-0 z-[80] flex items-center justify-center p-4">
<button type="button" className="absolute inset-0 bg-black/50 backdrop-blur-sm" onClick={onClose} aria-label="Close" />
<div className="relative w-full max-w-5xl max-h-[90vh] overflow-hidden rounded-xl bg-white dark:bg-gray-900 shadow-2xl flex flex-col">
<div className="bg-gradient-to-r from-indigo-600 to-purple-600 text-white px-4 py-3">
<div className="flex items-start justify-between gap-3">
<div className="flex items-center gap-2 min-w-0">
<div className="p-1.5 rounded-lg bg-white/15 shrink-0">
<FaClipboardList className="w-4 h-4" />
</div>
<div className="min-w-0">
<h2 className="text-base font-semibold truncate">
{t('dashboard.workOrderStatus', { defaultValue: 'Work Order Status' })}
</h2>
<p className="text-xs text-white/85 mt-0.5">
{t('dashboard.workOrderTypeStatusHint', {
defaultValue: 'Type vs status counts — click a number to view filtered work orders',
})}
</p>
</div>
</div>
<button type="button" onClick={onClose} className="p-1.5 rounded-lg hover:bg-white/10">
<FaTimes className="text-sm" />
</button>
</div>
</div>
<div className="px-3 py-2 border-b border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800/50">
<div className="flex flex-wrap items-end gap-2">
<div className={`${compactFilterFieldWrapClass} mb-0 w-[8.5rem]`}>
<label className={compactFilterLabelClass}>
{t('dashboard.fromDate', { defaultValue: 'From Date' })}
</label>
<input
type="date"
value={fromDate}
onChange={e => setFromDate(e.target.value)}
className={compactFilterInputClass}
/>
</div>
<div className={`${compactFilterFieldWrapClass} mb-0 w-[8.5rem]`}>
<label className={compactFilterLabelClass}>
{t('dashboard.toDate', { defaultValue: 'To Date' })}
</label>
<input
type="date"
value={toDate}
onChange={e => setToDate(e.target.value)}
className={compactFilterInputClass}
/>
</div>
<button
type="button"
onClick={loadData}
disabled={loading || !onRefresh}
className={`${compactToolbarBtnPrimary('purple')} mb-0.5`}
>
<FaSync className={`text-[10px] ${loading ? 'animate-spin' : ''}`} />
{t('dashboard.refreshReport', { defaultValue: 'Refresh' })}
</button>
{(locationFilters?.company || locationFilters?.site_name) && (
<div className="flex flex-wrap gap-1.5 ml-auto pb-0.5">
{locationFilters?.company && (
<span className="px-2 py-0.5 rounded-full bg-white dark:bg-gray-800 border border-indigo-200 dark:border-indigo-800 text-indigo-800 dark:text-indigo-200 text-[10px]">
{locationFilters.company}
</span>
)}
{locationFilters?.site_name && (
<span className="px-2 py-0.5 rounded-full bg-white dark:bg-gray-800 border border-indigo-200 dark:border-indigo-800 text-indigo-800 dark:text-indigo-200 text-[10px]">
{locationFilters.site_name}
</span>
)}
</div>
)}
</div>
</div>
<div className="px-3 py-2 border-b border-gray-200 dark:border-gray-700">
<div className={`${compactSummaryCardClass} border-indigo-200 dark:border-indigo-800 bg-indigo-50 dark:bg-indigo-900/20 inline-block min-w-[8rem]`}>
<div className={compactSummaryLabelClass}>
{t('dashboard.totalWorkOrders', { defaultValue: 'Total Work Orders' })}
</div>
<div className={`${compactSummaryValueClass} text-indigo-700 dark:text-indigo-300`}>
{displayTotal.toLocaleString()}
</div>
</div>
</div>
<div className="overflow-y-auto flex-1">
{error && (
<div className="mx-3 mt-3 rounded-md border border-red-200 bg-red-50 text-red-700 px-3 py-2 text-xs">
{error}
</div>
)}
<table className="min-w-full text-xs">
<thead className="bg-gray-50 dark:bg-gray-800/80 sticky top-0">
<tr>
<th className="px-3 py-2 text-left font-semibold text-gray-700 dark:text-gray-200">
{t('dashboard.woType', { defaultValue: 'WO Type' })}
</th>
{typeColumns.map(column => (
<th
key={column}
className={`px-3 py-2 text-right font-semibold whitespace-nowrap ${STATUS_HEADER_COLORS[column] || 'text-gray-700 dark:text-gray-200'}`}
>
{column}
</th>
))}
<th className="px-3 py-2 text-right font-semibold text-gray-700 dark:text-gray-200">
{t('dashboard.totalWorkOrders', { defaultValue: 'Total' })}
</th>
</tr>
</thead>
<tbody>
{loading ? (
<tr>
<td colSpan={typeColumns.length + 2} className="px-3 py-8 text-center text-gray-500">
{t('common.loading', { defaultValue: 'Loading...' })}
</td>
</tr>
) : typeRows.length === 0 ? (
<tr>
<td colSpan={typeColumns.length + 2} className="px-3 py-8 text-center text-gray-500">
{t('common.noData', { defaultValue: 'No data' })}
</td>
</tr>
) : (
typeRows.map(row => (
<tr key={row.label} className="border-t border-gray-100 dark:border-gray-800">
<td className="px-3 py-2">
<button
type="button"
onClick={() => handleTypeClick(row.label)}
className="text-left font-medium text-indigo-700 dark:text-indigo-300 hover:underline"
>
{row.label}
</button>
</td>
{row.cells.map((value, index) => (
<td key={`${row.label}-${typeColumns[index]}`} className="px-3 py-2 text-right">
<CountButton
value={value}
onClick={() => handleCellClick(row.label, typeColumns[index], value)}
/>
</td>
))}
<td className="px-3 py-2 text-right font-semibold text-gray-900 dark:text-white">
{row.rowTotal}
</td>
</tr>
))
)}
</tbody>
{!loading && typeRows.length > 0 && (
<tfoot>
<tr className="border-t border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800/80">
<td className="px-3 py-2 font-semibold text-gray-800 dark:text-gray-100">
{t('dashboard.totalWorkOrders', { defaultValue: 'Total' })}
</td>
{columnTotals.map((value, index) => (
<td key={`total-${typeColumns[index]}`} className="px-3 py-2 text-right">
<CountButton
value={value}
onClick={() => handleStatusTotalClick(typeColumns[index], value)}
bold
/>
</td>
))}
<td className="px-3 py-2 text-right font-semibold text-gray-900 dark:text-white">
{computedTotal}
</td>
</tr>
</tfoot>
)}
</table>
</div>
</div>
</div>
);
};
const CountButton: React.FC<{
value: number;
onClick: () => void;
bold?: boolean;
}> = ({ value, onClick, bold = false }) => {
if (value <= 0) {
return <span className="text-gray-400">0</span>;
}
return (
<button
type="button"
onClick={onClick}
className={`${bold ? 'font-semibold' : 'font-medium'} text-gray-900 dark:text-white hover:text-indigo-600 dark:hover:text-indigo-300 hover:underline`}
>
{value.toLocaleString()}
</button>
);
};
export default WorkOrderStatusReportModal;

View File

@ -7,10 +7,8 @@ interface ApiConfig {
}
const API_CONFIG: ApiConfig = {
// Backend URL - Use proxy in development, direct URL in production
BASE_URL: import.meta.env.DEV
? '' // Use relative URLs in development (goes through Vite proxy)
: import.meta.env.VITE_FRAPPE_BASE_URL || 'https://imanrdh-seeraasm.seeraarabia.com',
// Use same-origin relative URLs in production so the app works on any domain.
BASE_URL: import.meta.env.VITE_FRAPPE_BASE_URL || '',
// API Endpoints
ENDPOINTS: {
@ -23,6 +21,8 @@ const API_CONFIG: ApiConfig = {
// Dashboard
DASHBOARD_STATS: '/api/method/asset_lite.api.custom_api.get_dashboard_stats',
DASHBOARD_NUMBER_CARDS: '/api/method/asset_lite.api.dashboard_api.get_number_cards',
DASHBOARD_WORK_ORDER_METRICS: '/api/method/asset_lite.api.dashboard_api.get_dashboard_work_order_metrics',
DASHBOARD_ASSET_DEVICE_STATUS: '/api/method/asset_lite.api.dashboard_api.get_asset_device_status_summary',
DASHBOARD_LIST_CHARTS: '/api/method/asset_lite.api.dashboard_api.list_dashboard_charts',
DASHBOARD_CHART_DATA: '/api/method/asset_lite.api.dashboard_api.get_dashboard_chart_data',
DASHBOARD_REPAIR_COST: '/api/method/asset_lite.api.dashboard_api.get_repair_cost_by_item',
@ -74,7 +74,7 @@ const API_CONFIG: ApiConfig = {
// Authentication
LOGIN: '/api/method/login',
LOGOUT: '/api/method/logout',
CSRF_TOKEN: '/api/method/frappe.sessions.get_csrf_token',
CSRF_TOKEN: '/api/method/asset_lite.api.custom_api.get_user_details',
RESET_PASSWORD: '/api/method/frappe.core.doctype.user.user.reset_password',
// File Upload

View File

@ -0,0 +1,33 @@
import React, { createContext, useCallback, useContext, useMemo, useState } from 'react';
type SidebarLayoutContextValue = {
mobileOpen: boolean;
openMobileSidebar: () => void;
closeMobileSidebar: () => void;
};
const SidebarLayoutContext = createContext<SidebarLayoutContextValue | undefined>(undefined);
export const SidebarLayoutProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const [mobileOpen, setMobileOpen] = useState(false);
const openMobileSidebar = useCallback(() => setMobileOpen(true), []);
const closeMobileSidebar = useCallback(() => setMobileOpen(false), []);
const value = useMemo(
() => ({ mobileOpen, openMobileSidebar, closeMobileSidebar }),
[mobileOpen, openMobileSidebar, closeMobileSidebar]
);
return <SidebarLayoutContext.Provider value={value}>{children}</SidebarLayoutContext.Provider>;
};
export function useSidebarLayout(): SidebarLayoutContextValue {
const context = useContext(SidebarLayoutContext);
if (!context) {
throw new Error('useSidebarLayout must be used within SidebarLayoutProvider');
}
return context;
}
export default SidebarLayoutContext;

View File

@ -54,6 +54,7 @@ export interface NumberCards {
work_orders_open: number;
work_orders_in_progress: number;
work_orders_completed: number;
work_orders_closed: number;
}
interface RecentActivity {
@ -129,8 +130,11 @@ export function useDashboardStats() {
return useApi(() => apiService.getDashboardStats());
}
export function useNumberCards() {
return useApi(() => apiService.getNumberCards());
export function useNumberCards(filters?: Record<string, string>) {
return useApi(
() => apiService.getNumberCards(filters),
[JSON.stringify(filters || {})]
);
}
export function useDashboardChart(chartName: string, filters?: Record<string, any>) {

View File

@ -0,0 +1,120 @@
import { useState, useCallback, useEffect } from 'react';
import apiService from '../services/apiService';
export interface VersionChange {
field: string;
oldValue: any;
newValue: any;
}
export interface AuditLogEntry {
name: string;
owner: string;
creation: string;
changes: VersionChange[];
added: any[];
removed: any[];
rowChanged: any[];
}
interface UseAuditLogsOptions {
doctype: string;
docname: string | null;
limit?: number;
enabled?: boolean;
}
interface UseAuditLogsReturn {
auditLogs: AuditLogEntry[];
loading: boolean;
error: string | null;
refetch: () => void;
}
export const useAuditLogs = ({
doctype,
docname,
limit = 50,
enabled = true,
}: UseAuditLogsOptions): UseAuditLogsReturn => {
const [auditLogs, setAuditLogs] = useState<AuditLogEntry[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const fetchAuditLogs = useCallback(async () => {
if (!enabled || !doctype || !docname) return;
setLoading(true);
setError(null);
try {
const response = await apiService.apiCall<any>(
`/api/resource/Version?filters=[["ref_doctype","=","${encodeURIComponent(doctype)}"],["docname","=","${encodeURIComponent(docname)}"]]&fields=["name","owner","creation","data"]&order_by=creation desc&limit=${limit}`
);
if (response?.data && response.data.length > 0) {
const parsedLogs: AuditLogEntry[] = response.data.map((version: any) => {
let parsedData: {
added: any[];
changed: any[];
removed: any[];
row_changed: any[];
} = { added: [], changed: [], removed: [], row_changed: [] };
try {
parsedData = JSON.parse(version.data || '{}');
} catch (e) {
console.error('Error parsing version data:', e);
}
let changes: VersionChange[] = [];
if (Array.isArray(parsedData.changed)) {
changes = parsedData.changed.map((change: any[]) => ({
field: change[0] || '',
oldValue: change[1],
newValue: change[2],
}));
} else if (parsedData.changed && typeof parsedData.changed === 'object') {
changes = Object.entries(parsedData.changed).map(([field, values]) => {
const [oldValue, newValue] = values as [any, any];
return { field, oldValue, newValue };
});
}
return {
name: version.name,
owner: version.owner,
creation: version.creation,
changes,
added: parsedData.added || [],
removed: parsedData.removed || [],
rowChanged: parsedData.row_changed || [],
};
});
setAuditLogs(parsedLogs);
} else {
setAuditLogs([]);
}
} catch (err) {
console.error(`Error fetching audit logs for ${doctype}/${docname}:`, err);
setError(err instanceof Error ? err.message : 'Failed to load activity log');
setAuditLogs([]);
} finally {
setLoading(false);
}
}, [doctype, docname, limit, enabled]);
useEffect(() => {
fetchAuditLogs();
}, [fetchAuditLogs]);
return {
auditLogs,
loading,
error,
refetch: fetchAuditLogs,
};
};
export default useAuditLogs;

View File

@ -0,0 +1,124 @@
import { useState, useEffect, useCallback, useRef } from 'react';
import commentService, { type CommentData, type MentionUser } from '../services/commentService';
interface UseCommentsOptions {
referenceDoctype: string;
referenceName: string | null;
pollInterval?: number;
}
interface UseCommentsReturn {
comments: CommentData[];
loading: boolean;
posting: boolean;
error: string | null;
currentUser: string;
refetch: () => Promise<void>;
postComment: (content: string) => Promise<void>;
deleteComment: (commentName: string) => Promise<void>;
mentionUsers: MentionUser[];
mentionLoading: boolean;
searchMentionUsers: (query: string) => Promise<void>;
}
export function useComments({
referenceDoctype,
referenceName,
pollInterval = 30000,
}: UseCommentsOptions): UseCommentsReturn {
const [comments, setComments] = useState<CommentData[]>([]);
const [loading, setLoading] = useState(true);
const [posting, setPosting] = useState(false);
const [error, setError] = useState<string | null>(null);
const [currentUser, setCurrentUser] = useState('');
const [mentionUsers, setMentionUsers] = useState<MentionUser[]>([]);
const [mentionLoading, setMentionLoading] = useState(false);
const mentionSearchTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
commentService.getCurrentUser().then(setCurrentUser).catch(() => {});
}, []);
const fetchComments = useCallback(async () => {
if (!referenceName) {
setComments([]);
setLoading(false);
return;
}
try {
const data = await commentService.getComments(referenceDoctype, referenceName);
setComments(data);
setError(null);
} catch (err: any) {
console.error('Error fetching comments:', err);
setError(err.message || 'Failed to load comments');
} finally {
setLoading(false);
}
}, [referenceDoctype, referenceName]);
useEffect(() => {
setLoading(true);
fetchComments();
}, [fetchComments]);
useEffect(() => {
if (!pollInterval || !referenceName) return;
const id = setInterval(fetchComments, pollInterval);
return () => clearInterval(id);
}, [pollInterval, fetchComments, referenceName]);
const postComment = useCallback(
async (content: string) => {
if (!referenceName) return;
setPosting(true);
try {
await commentService.postComment(referenceDoctype, referenceName, content);
await fetchComments();
} catch (err: any) {
throw err;
} finally {
setPosting(false);
}
},
[referenceDoctype, referenceName, fetchComments]
);
const deleteComment = useCallback(async (commentName: string) => {
try {
await commentService.deleteComment(commentName);
setComments((prev) => prev.filter((c) => c.name !== commentName));
} catch (err: any) {
throw err;
}
}, []);
const searchMentionUsers = useCallback(async (query: string) => {
if (mentionSearchTimer.current) clearTimeout(mentionSearchTimer.current);
setMentionLoading(true);
mentionSearchTimer.current = setTimeout(async () => {
try {
const users = await commentService.searchUsers(query);
setMentionUsers(users);
} catch {
setMentionUsers([]);
} finally {
setMentionLoading(false);
}
}, 250);
}, []);
return {
comments,
loading,
posting,
error,
currentUser,
refetch: fetchComments,
postComment,
deleteComment,
mentionUsers,
mentionLoading,
searchMentionUsers,
};
}

View File

@ -0,0 +1,56 @@
import { useEffect } from 'react';
import { fetchUserHospitalName } from '../utils/userHospital';
export type HospitalFormField =
| 'company'
| 'custom_hospital_name'
| 'hospital'
| 'asset_owner_company';
interface UseDefaultHospitalOptions {
enabled?: boolean;
fields: HospitalFormField[];
}
export function useDefaultHospital(
setFormData: React.Dispatch<React.SetStateAction<Record<string, any>>>,
{ enabled = true, fields }: UseDefaultHospitalOptions
): void {
const fieldsKey = fields.join('|');
useEffect(() => {
if (!enabled) {
return;
}
let cancelled = false;
fetchUserHospitalName().then((hospitalName) => {
if (cancelled || !hospitalName) {
return;
}
setFormData((prev) => {
const updates: Partial<Record<HospitalFormField, string>> = {};
for (const field of fields) {
if (!prev[field]) {
updates[field] = hospitalName;
}
}
if (Object.keys(updates).length === 0) {
return prev;
}
return { ...prev, ...updates };
});
});
return () => {
cancelled = true;
};
}, [enabled, fieldsKey, setFormData]);
}
export default useDefaultHospital;

View File

@ -2,6 +2,36 @@ import { useState, useEffect, useCallback, useRef } from 'react';
import itemService from '../services/itemService';
import type { Item, CreateItemData } from '../services/itemService';
const mergeFilters = (
userFilters: ItemFilters | undefined,
permissionFilters: Record<string, any>
): ItemFilters => {
const merged: ItemFilters = { ...(userFilters || {}) };
for (const [field, value] of Object.entries(permissionFilters)) {
if (!merged[field]) {
(merged as any)[field] = value;
} else if (Array.isArray(value) && value[0] === 'in') {
const permittedValues = value[1] as string[];
const userValue = merged[field];
if (typeof userValue === 'string') {
if (!permittedValues.includes(userValue)) {
(merged as any)[field] = ['in', []];
}
} else if (Array.isArray(userValue) && userValue[0] === 'in') {
const userValues = userValue[1] as string[];
const intersection = userValues.filter((v) => permittedValues.includes(v));
(merged as any)[field] = ['in', intersection];
} else {
(merged as any)[field] = value;
}
}
}
return merged;
};
export interface ItemFilters {
item_code?: string;
item_name?: string;
@ -19,7 +49,8 @@ export function useItems(
filters?: ItemFilters,
limit: number = 20,
offset: number = 0,
orderBy?: string
orderBy?: string,
permissionFilters: Record<string, any> = {}
) {
const [items, setItems] = useState<Item[]>([]);
const [totalCount, setTotalCount] = useState(0);
@ -30,6 +61,7 @@ export function useItems(
const hasAttemptedRef = useRef(false);
const filtersJson = JSON.stringify(filters);
const permissionFiltersJson = JSON.stringify(permissionFilters);
useEffect(() => {
if (hasAttemptedRef.current && error) {
@ -42,14 +74,15 @@ export function useItems(
const fetchItems = async () => {
try {
setLoading(true);
const mergedFilters = mergeFilters(filters, permissionFilters);
const fields = ['name', 'item_code', 'item_name', 'item_group', 'stock_uom', 'disabled', 'is_stock_item','custom_hospital_name', 'opening_stock', 'valuation_rate', 'standard_rate', 'creation', 'modified', 'owner', 'docstatus'];
const response = await itemService.getItems(filters, fields, limit, offset);
const response = await itemService.getItems(mergedFilters, fields, limit, offset, orderBy);
if (!isCancelled) {
setItems(response.data);
setTotalCount(response.total);
setHasMore(response.data.length === limit);
setHasMore(offset + response.data.length < response.total);
setError(null);
}
} catch (err) {
@ -73,7 +106,7 @@ export function useItems(
isCancelled = true;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [filtersJson, limit, offset, orderBy, refetchTrigger]);
}, [filtersJson, permissionFiltersJson, limit, offset, orderBy, refetchTrigger]);
const refetch = useCallback(() => {
hasAttemptedRef.current = false;

View File

@ -0,0 +1,102 @@
import { useCallback, useMemo } from 'react';
import { useSearchParams } from 'react-router-dom';
import type { DateFilterField, SortOption } from '../utils/listFilterUtils';
import { normalizeSortBy } from '../utils/listFilterUtils';
export interface ListSortFilterState {
sortBy: SortOption;
dateFilterBy: DateFilterField;
dateStart: string;
dateEnd: string;
page: number;
setSortBy: (value: string) => void;
setDateFilterBy: (value: DateFilterField) => void;
setDateStart: (value: string) => void;
setDateEnd: (value: string) => void;
setPage: (value: number) => void;
resetPage: () => void;
}
export function useListSortFilters(defaultSort: SortOption = 'creation desc'): ListSortFilterState {
const [searchParams, setSearchParams] = useSearchParams();
const sortBy = normalizeSortBy(searchParams.get('sort_by') || defaultSort, defaultSort);
const dateFilterBy = (searchParams.get('date_filter_by') || '') as DateFilterField;
const dateStart = searchParams.get('date_start') || '';
const dateEnd = searchParams.get('date_end') || '';
const page = Math.max(0, parseInt(searchParams.get('page') || '1', 10) - 1);
const updateParams = useCallback(
(updates: Record<string, string | null>, resetPageOnChange = false) => {
const next = new URLSearchParams(searchParams);
for (const [key, value] of Object.entries(updates)) {
if (value === null || value === '') next.delete(key);
else next.set(key, value);
}
if (resetPageOnChange) {
next.delete('page');
}
setSearchParams(next, { replace: true });
},
[searchParams, setSearchParams]
);
const setSortBy = useCallback(
(value: string) => updateParams({ sort_by: value }, true),
[updateParams]
);
const setDateFilterBy = useCallback(
(value: DateFilterField) => updateParams({ date_filter_by: value || null }, true),
[updateParams]
);
const setDateStart = useCallback(
(value: string) => updateParams({ date_start: value || null }, true),
[updateParams]
);
const setDateEnd = useCallback(
(value: string) => updateParams({ date_end: value || null }, true),
[updateParams]
);
const setPage = useCallback(
(value: number) => updateParams({ page: value <= 0 ? null : String(value + 1) }),
[updateParams]
);
const resetPage = useCallback(() => updateParams({ page: null }), [updateParams]);
return useMemo(
() => ({
sortBy,
dateFilterBy,
dateStart,
dateEnd,
page,
setSortBy,
setDateFilterBy,
setDateStart,
setDateEnd,
setPage,
resetPage,
}),
[
sortBy,
dateFilterBy,
dateStart,
dateEnd,
page,
setSortBy,
setDateFilterBy,
setDateStart,
setDateEnd,
setPage,
resetPage,
]
);
}

View File

@ -0,0 +1,64 @@
import { useState, useEffect, useCallback } from 'react';
import { fetchCurrentUserProfile } from '../services/userProfileService';
import { getStoredRoleProfile, getStoredUserEmail } from '../utils/roleAccess';
interface UseRoleProfileResult {
roleProfile: string;
userEmail: string;
loading: boolean;
refreshRoleProfile: () => Promise<void>;
}
export function useRoleProfile(): UseRoleProfileResult {
const [roleProfile, setRoleProfile] = useState(getStoredRoleProfile);
const [userEmail, setUserEmail] = useState(getStoredUserEmail);
const [loading, setLoading] = useState(!getStoredRoleProfile());
const refreshRoleProfile = useCallback(async () => {
const storedUser = localStorage.getItem('user');
if (!storedUser) {
setRoleProfile('');
setUserEmail('');
setLoading(false);
return;
}
try {
setLoading(true);
const profile = await fetchCurrentUserProfile();
const parsed = JSON.parse(storedUser);
const updatedUser = {
...parsed,
email: profile.email || profile.name || parsed.email,
role_profile_name: profile.role_profile_name || '',
full_name: profile.full_name || parsed.full_name,
custom_site_name: profile.custom_site_name || '',
custom_phcc_site_name: profile.custom_phcc_site_name || '',
};
localStorage.setItem('user', JSON.stringify(updatedUser));
setRoleProfile(updatedUser.role_profile_name || '');
setUserEmail(updatedUser.email || profile.name || '');
} catch (error) {
console.error('[useRoleProfile] Failed to load role profile:', error);
setRoleProfile(getStoredRoleProfile());
setUserEmail(getStoredUserEmail());
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
if (!getStoredRoleProfile()) {
refreshRoleProfile();
} else {
setRoleProfile(getStoredRoleProfile());
setUserEmail(getStoredUserEmail());
setLoading(false);
}
}, [refreshRoleProfile]);
return { roleProfile, userEmail, loading, refreshRoleProfile };
}
export default useRoleProfile;

View File

@ -0,0 +1,88 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import {
fetchUserLocationFilters,
getStoredHospitalName,
getStoredPhccSiteName,
} from '../utils/userHospital';
import { buildHospitalLinkFilters, isSiteEnabledHospital } from '../utils/hospitalUtils';
export function useUserDashboardFilters() {
const [filterHospital, setFilterHospital] = useState(() => getStoredHospitalName());
const [filterSiteName, setFilterSiteName] = useState(() => getStoredPhccSiteName());
const [hospitalLocked, setHospitalLocked] = useState(false);
const [siteLocked, setSiteLocked] = useState(() => !!getStoredPhccSiteName());
const [allowedHospitals, setAllowedHospitals] = useState<string[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
let cancelled = false;
fetchUserLocationFilters()
.then(({ hospital, siteName, isLocked, allowedHospitals: allowed }) => {
if (cancelled) return;
setAllowedHospitals(allowed);
setHospitalLocked(isLocked);
if (hospital) {
setFilterHospital(hospital);
}
if (siteName) {
setFilterSiteName(siteName);
setSiteLocked(true);
}
})
.finally(() => {
if (!cancelled) {
setLoading(false);
}
});
return () => {
cancelled = true;
};
}, []);
const handleHospitalChange = useCallback(
(value: string) => {
if (hospitalLocked) return;
setFilterHospital(value);
if (!isSiteEnabledHospital(value)) {
setFilterSiteName('');
}
},
[hospitalLocked]
);
const handleSiteChange = useCallback(
(value: string) => {
if (siteLocked) return;
setFilterSiteName(value);
},
[siteLocked]
);
const hospitalLinkFilters = useMemo(
() => buildHospitalLinkFilters(allowedHospitals),
[allowedHospitals]
);
return {
filterHospital,
filterSiteName,
hospitalLocked,
siteLocked,
allowedHospitals,
hospitalLinkFilters,
/** @deprecated use hospitalLocked — hospital filter readonly when user has one permitted hospital */
filtersLocked: hospitalLocked,
loading,
handleHospitalChange,
handleSiteChange,
setFilterSiteName: handleSiteChange,
};
}
export default useUserDashboardFilters;

View File

@ -0,0 +1,62 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import {
fetchUserLocationFilters,
getStoredHospitalName,
} from '../utils/userHospital';
import { buildHospitalLinkFilters } from '../utils/hospitalUtils';
export function useUserHospitalFilter() {
const [filterHospital, setFilterHospital] = useState(() => getStoredHospitalName());
const [filtersLocked, setFiltersLocked] = useState(false);
const [allowedHospitals, setAllowedHospitals] = useState<string[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
let cancelled = false;
fetchUserLocationFilters()
.then(({ hospital, isLocked, allowedHospitals: allowed }) => {
if (cancelled) return;
setAllowedHospitals(allowed);
setFiltersLocked(isLocked);
if (hospital) {
setFilterHospital(hospital);
}
})
.finally(() => {
if (!cancelled) {
setLoading(false);
}
});
return () => {
cancelled = true;
};
}, []);
const handleHospitalChange = useCallback(
(value: string) => {
if (filtersLocked) return;
setFilterHospital(value);
},
[filtersLocked]
);
const hospitalLinkFilters = useMemo(
() => buildHospitalLinkFilters(allowedHospitals),
[allowedHospitals]
);
return {
filterHospital,
filtersLocked,
allowedHospitals,
hospitalLinkFilters,
loading,
handleHospitalChange,
};
}
export default useUserHospitalFilter;

View File

@ -0,0 +1,37 @@
import { useEffect, useState } from 'react';
import {
fetchUserLocationFilters,
getStoredPhccSiteName,
} from '../utils/userHospital';
export function useUserPhccSiteFilter() {
const [userPhccSiteName, setUserPhccSiteName] = useState(() => getStoredPhccSiteName());
const [loading, setLoading] = useState(!getStoredPhccSiteName());
useEffect(() => {
let cancelled = false;
fetchUserLocationFilters()
.then(({ siteName }) => {
if (cancelled || !siteName) return;
setUserPhccSiteName(siteName);
})
.finally(() => {
if (!cancelled) {
setLoading(false);
}
});
return () => {
cancelled = true;
};
}, []);
return {
userPhccSiteName,
siteLocked: !!userPhccSiteName,
loading,
};
}
export default useUserPhccSiteFilter;

View File

@ -1,47 +1,54 @@
import { useState, useEffect, useCallback, useRef } from 'react';
import workOrderService from '../services/workOrderService';
import type { WorkOrder, WorkOrderFilters, CreateWorkOrderData } from '../services/workOrderService';
import type { WorkOrder, WorkOrderFilters, WorkOrderListQuery, CreateWorkOrderData } from '../services/workOrderService';
/**
* Merge user filters with permission filters
* Permission filters take precedence for security
*/
const mergeFilters = (
userFilters: WorkOrderFilters | undefined,
userQuery: WorkOrderFilters | WorkOrderListQuery | any[] | undefined,
permissionFilters: Record<string, any>
): WorkOrderFilters => {
const merged: WorkOrderFilters = { ...(userFilters || {}) };
): WorkOrderFilters | WorkOrderListQuery | any[] => {
if (Array.isArray(userQuery)) {
return userQuery;
}
const baseFilters =
userQuery && 'or_filters' in userQuery
? { ...(userQuery.filters as Record<string, any> | undefined) }
: { ...((userQuery as WorkOrderFilters | undefined) || {}) };
const merged: Record<string, any> = { ...baseFilters };
const orFilters =
userQuery && 'or_filters' in userQuery ? userQuery.or_filters : undefined;
// Apply permission filters (they take precedence for security)
for (const [field, value] of Object.entries(permissionFilters)) {
if (!merged[field as keyof WorkOrderFilters]) {
// No user filter on this field, apply permission filter directly
(merged as any)[field] = value;
if (!merged[field]) {
merged[field] = value;
} else if (Array.isArray(value) && value[0] === 'in') {
// Permission filter is ["in", [...values]]
const permittedValues = value[1] as string[];
const userValue = merged[field as keyof WorkOrderFilters];
const userValue = merged[field];
if (typeof userValue === 'string') {
// User selected a specific value, check if it's permitted
if (!permittedValues.includes(userValue)) {
// User selected a value they don't have permission for
// Set to empty array to return no results
(merged as any)[field] = ['in', []];
merged[field] = ['in', []];
}
// If permitted, keep the user's specific selection
} else if (Array.isArray(userValue) && userValue[0] === 'in') {
// Both are ["in", [...]] format, intersect them
const userValues = userValue[1] as string[];
const intersection = userValues.filter(v => permittedValues.includes(v));
(merged as any)[field] = ['in', intersection];
merged[field] = ['in', intersection];
} else {
// Other filter types, apply permission filter
(merged as any)[field] = value;
merged[field] = value;
}
}
}
if (orFilters?.length) {
return { filters: merged, or_filters: orFilters };
}
return merged;
};
@ -49,11 +56,11 @@ const mergeFilters = (
* Hook to fetch list of work orders with filters, pagination, and permission-based filtering
*/
export function useWorkOrders(
filters?: WorkOrderFilters,
filters?: WorkOrderFilters | WorkOrderListQuery | any[],
limit: number = 20,
offset: number = 0,
orderBy?: string,
permissionFilters: Record<string, any> = {} // ← NEW: Permission filters parameter
permissionFilters: Record<string, any> = {}
) {
const [workOrders, setWorkOrders] = useState<WorkOrder[]>([]);
const [totalCount, setTotalCount] = useState(0);
@ -81,7 +88,9 @@ export function useWorkOrders(
setLoading(true);
// ✅ NEW: Merge user filters with permission filters
const mergedFilters = mergeFilters(filters, permissionFilters);
const mergedFilters = Array.isArray(filters)
? filters
: mergeFilters(filters, permissionFilters);
console.log('[useWorkOrders] User filters:', filters);
console.log('[useWorkOrders] Permission filters:', permissionFilters);

View File

@ -20,6 +20,15 @@
.rotate-y-180 {
transform: rotateY(180deg);
}
.scrollbar-hide {
-ms-overflow-style: none;
scrollbar-width: none;
}
.scrollbar-hide::-webkit-scrollbar {
display: none;
}
}
/* Custom Scrollbar Styles */

View File

@ -64,14 +64,22 @@
"openWorkOrders": "أوامر العمل المفتوحة",
"workOrdersInProgress": "أوامر العمل قيد التنفيذ",
"completedWorkOrders": "أوامر العمل المكتملة",
"closedWorkOrders": "أوامر العمل المغلقة",
"totalWorkOrders": "إجمالي أوامر العمل",
"overdueWorkOrders": "أوامر العمل المتأخرة",
"upTime": "وقت التشغيل",
"downTime": "وقت التوقف",
"workOrderStatus": "حالة أمر العمل",
"workOrderTypeStatusHint": "عدد الحالات حسب النوع — انقر على الرقم لعرض أوامر العمل المفلترة",
"workOrderByType": "أمر العمل حسب النوع",
"maintenanceByAsset": "الصيانة حسب الأصل",
"assigneesStatus": "حالة المكلفين",
"assigneesStatus": "عدد حالة مكلفي صيانة الأصول",
"assignedTo": "المكلف",
"plannedTasks": "مخطط",
"completedTasks": "مكتمل",
"cancelledTasks": "ملغى",
"overdueTasks": "متأخر",
"totalTasks": "الإجمالي",
"maintenanceFrequency": "تكرار الصيانة",
"maintenanceLogs": "سجلات الصيانة",
"assetUptime": "وقت تشغيل الأصل",
@ -79,7 +87,57 @@
"maintenanceEfficiency": "كفاءة الصيانة",
"overdueMaintenance": "صيانة متأخرة",
"upDownTimeChart": "مخطط وقت التشغيل والتوقف",
"ppmStatus": "حالة الصيانة الوقائية"
"assetUpDown": "حالة الأصول (تشغيل / توقف)",
"assetStatusOverview": "نظرة عامة على حالة الأصول",
"clickStatusCardHint": "انقر على بطاقة لعرض الأصول المفلترة",
"totalAssetsLabel": "الإجمالي",
"totalAssets": "إجمالي الأصول",
"noAssetStatusData": "لا توجد بيانات لحالة الأصول",
"viewAssetStatusOverview": "عرض نظرة عامة على حالة الأصول",
"viewAll": "عرض الكل",
"viewFiltered": "عرض ↗",
"ofTotal": "من الإجمالي",
"ppmStatus": "حالة الصيانة الوقائية",
"techniciansWorked": "ملخص عمل الفنيين",
"workOrderStatusDistribution": "توزيع حالة أمر العمل",
"workOrders": "أوامر العمل",
"activeDateRange": "نطاق التاريخ: {{from}} إلى {{to}}",
"allDates": "عرض جميع التواريخ",
"applyFilters": "تطبيق",
"clearDates": "مسح التواريخ",
"techniciansWorkedSubtitle": "{{completed}} منجز • {{inProgress}} قيد التنفيذ • {{open}} مفتوح",
"techniciansWorkedEmpty": "لا توجد بيانات للفنيين",
"techniciansWorkedReportHint": "أوامر العمل مجمعة حسب المقاول المعين",
"technicianWorkingHours": "ساعات عمل الفنيين",
"technicianHoursSubtitle": "الإجمالي: {{total}} ساعة",
"technicianHoursEmpty": "لا توجد بيانات لساعات الفنيين",
"loadingTechnicianHours": "جاري تحميل ساعات الفنيين…",
"technicianHoursFound": "تم العثور على {{count}} فني",
"totalTechnicians": "إجمالي الفنيين",
"totalHoursWorked": "إجمالي ساعات العمل",
"avgHoursPerTechnician": "متوسط الساعات/فني",
"technicianName": "اسم الفني",
"engineer": "المهندس",
"totalHoursSpent": "إجمالي الساعات",
"technicianHoursFooter": "عرض {{count}} فني (مفلتر) • {{total}} إجمالي الساعات",
"filters": "الفلاتر",
"clearFilters": "مسح الفلاتر",
"viewFullReport": "عرض التقرير الكامل",
"noChartData": "لا توجد بيانات للمخطط",
"fromDate": "من تاريخ",
"toDate": "إلى تاريخ",
"technicalDepartment": "القسم الفني",
"allDepartments": "جميع الأقسام",
"refreshReport": "تحديث",
"allHospitals": "جميع المستشفيات",
"allSites": "جميع المواقع",
"overallCompletionRate": "معدل الإنجاز الإجمالي",
"overallCompletionByDepartment": "معدل الإنجاز الإجمالي حسب القسم",
"viewCompletionDetails": "عرض تفاصيل الإنجاز",
"clickRowToFilter": "انقر على صف أو قيمة لعرض أوامر العمل المفلترة",
"woType": "نوع أمر العمل",
"woFeedbackReport": "تقرير ملاحظات أمر العمل",
"woFeedbackTotalResponses": "إجمالي الردود: {{total}}"
},
"commonFields": {
"assetId": "معرف الأصل",
@ -184,6 +242,34 @@
"exportingAll": "جاري تصدير جميع {count} صف(وف)",
"selected": "محدد",
"rows": "صفوف"
},
"filters": {
"filterBy": "تصفية حسب",
"sortBy": "ترتيب حسب",
"createdDate": "تاريخ الإنشاء",
"latestModifiedDate": "تاريخ آخر تعديل",
"startDate": "تاريخ البداية",
"endDate": "تاريخ النهاية",
"sortCreationNewest": "الإنشاء (الأحدث)",
"sortCreationOldest": "الإنشاء (الأقدم)",
"sortModifiedNewest": "التعديل (الأحدث)",
"sortModifiedOldest": "التعديل (الأقدم)",
"sortNameAsc": "الاسم (أ-ي)",
"sortNameDesc": "الاسم (ي-أ)"
},
"pagination": {
"showingToOf": "عرض {{start}} إلى {{end}} من {{total}} {{label}}",
"showingTo": "عرض {{start}} إلى {{end}} {{label}}",
"previous": "السابق",
"next": "التالي",
"goTo": "انتقل إلى",
"go": "انتقل",
"page": "صفحة",
"assets": "أصول",
"workOrders": "أوامر عمل",
"items": "عناصر",
"issues": "مشكلات",
"maintenanceTeams": "فرق صيانة"
}
}

View File

@ -74,14 +74,22 @@
"openWorkOrders": "OPEN WORK ORDERS",
"workOrdersInProgress": "WORK ORDERS IN PROGRESS",
"completedWorkOrders": "COMPLETED WORK ORDERS",
"closedWorkOrders": "CLOSED WORK ORDERS",
"totalWorkOrders": "TOTAL WORK ORDERS",
"overdueWorkOrders": "OVERDUE WORK ORDERS",
"upTime": "Up Time",
"downTime": "Down Time",
"workOrderStatus": "Work Order Status",
"workOrderTypeStatusHint": "Type vs status counts — click a number to view filtered work orders",
"workOrderByType": "Work Order by Type",
"maintenanceByAsset": "Maintenance by Asset",
"assigneesStatus": "Assignees Status",
"assigneesStatus": "Asset Maintenance Assignees Status Count",
"assignedTo": "Assigned To",
"plannedTasks": "Planned",
"completedTasks": "Completed",
"cancelledTasks": "Cancelled",
"overdueTasks": "Overdue",
"totalTasks": "Total",
"maintenanceFrequency": "Maintenance Frequency",
"maintenanceLogs": "MAINTENANCE LOGS",
"assetUptime": "Asset Uptime",
@ -89,7 +97,57 @@
"maintenanceEfficiency": "Maintenance Efficiency",
"overdueMaintenance": "Overdue Maintenance",
"upDownTimeChart": "Up & Down Time Chart",
"ppmStatus": "PPM Status"
"assetUpDown": "Asset UP & Down",
"assetStatusOverview": "Asset Status Overview",
"clickStatusCardHint": "Click a card to view filtered assets",
"totalAssetsLabel": "Total",
"totalAssets": "Total Assets",
"noAssetStatusData": "No asset status data",
"viewAssetStatusOverview": "View asset status overview",
"viewAll": "View all",
"viewFiltered": "View ↗",
"ofTotal": "of total",
"ppmStatus": "PPM Status",
"techniciansWorked": "Technicians Work Summary",
"workOrderStatusDistribution": "Work Order Status Distribution",
"workOrders": "Work Orders",
"activeDateRange": "Date range: {{from}} to {{to}}",
"allDates": "Showing all dates",
"applyFilters": "Apply",
"clearDates": "Clear dates",
"techniciansWorkedSubtitle": "{{completed}} done • {{inProgress}} wip • {{open}} open",
"techniciansWorkedEmpty": "No technician data",
"techniciansWorkedReportHint": "Work orders grouped by assigned contractor",
"technicianWorkingHours": "Technicians Working Hours",
"technicianHoursSubtitle": "Total: {{total}} hrs",
"technicianHoursEmpty": "No technician hours data",
"loadingTechnicianHours": "Loading technician hours…",
"technicianHoursFound": "{{count}} technicians found",
"totalTechnicians": "Total Technicians",
"totalHoursWorked": "Total Hours Worked",
"avgHoursPerTechnician": "Average Hours/Technician",
"technicianName": "Technician Name",
"engineer": "Engineer",
"totalHoursSpent": "Total Hours Spent",
"technicianHoursFooter": "Showing {{count}} technicians (filtered) • {{total}} total hours",
"filters": "Filters",
"clearFilters": "Clear Filters",
"viewFullReport": "View Full Report",
"noChartData": "No chart data available",
"fromDate": "From Date",
"toDate": "To Date",
"technicalDepartment": "Technical Department",
"allDepartments": "All Departments",
"refreshReport": "Refresh",
"allHospitals": "All Hospitals",
"allSites": "All Sites",
"overallCompletionRate": "Overall Completion Rate",
"overallCompletionByDepartment": "Overall Completion Rate by Department",
"viewCompletionDetails": "View completion details",
"clickRowToFilter": "Click a row or value to view filtered work orders",
"woType": "WO Type",
"woFeedbackReport": "WO Feedback Report",
"woFeedbackTotalResponses": "Total responses: {{total}}"
},
"commonFields": {
"assetId": "Asset ID",
@ -459,6 +517,34 @@
"noEventsFound": "No events found",
"noEventsScheduled": "No events are currently scheduled.",
"refreshEvents": "Refresh Events"
},
"filters": {
"filterBy": "Filter By",
"sortBy": "Sort By",
"createdDate": "Created Date",
"latestModifiedDate": "Latest Modified Date",
"startDate": "Start Date",
"endDate": "End Date",
"sortCreationNewest": "Created (Newest)",
"sortCreationOldest": "Created (Oldest)",
"sortModifiedNewest": "Modified (Newest)",
"sortModifiedOldest": "Modified (Oldest)",
"sortNameAsc": "Name (A-Z)",
"sortNameDesc": "Name (Z-A)"
},
"pagination": {
"showingToOf": "Showing {{start}} to {{end}} of {{total}} {{label}}",
"showingTo": "Showing {{start}} to {{end}} {{label}}",
"previous": "Previous",
"next": "Next",
"goTo": "Go to",
"go": "Go",
"page": "Page",
"assets": "assets",
"workOrders": "work orders",
"items": "items",
"issues": "issues",
"maintenanceTeams": "maintenance teams"
}
}

View File

@ -11,13 +11,15 @@
* - PHCC: custom_site for assets, site_name for work orders, asset-based for maintenance
*/
import React, { useState, useEffect, useRef } from 'react';
import React, { useState, useEffect, useRef, useMemo, useCallback } from 'react';
import { useNavigate } from 'react-router-dom';
import { MapContainer, TileLayer, Marker, Popup, Tooltip, useMap } from 'react-leaflet';
import L from 'leaflet';
import 'leaflet/dist/leaflet.css';
import apiService from '../services/apiService';
import LinkField from '../components/LinkField';
import useUserDashboardFilters from '../hooks/useUserDashboardFilters';
import { isSiteEnabledHospital, buildMobileTeamSiteFilters } from '../utils/hospitalUtils';
// Fix for default marker icons in React-Leaflet
import icon from 'leaflet/dist/images/marker-icon.png';
@ -50,6 +52,7 @@ interface LocationData {
wo_progress: number;
wo_review: number;
wo_completed: number;
wo_closed: number;
phcc_asset_names?: string[];
}
@ -76,14 +79,71 @@ const MapBounds: React.FC<{ locations: LocationData[] }> = ({ locations }) => {
const ActiveMap: React.FC = () => {
const navigate = useNavigate();
const [selectedHospital, setSelectedHospital] = useState<string>('');
const [selectedPHCC, setSelectedPHCC] = useState<string>('');
const [locations, setLocations] = useState<LocationData[]>([]);
const [loading, setLoading] = useState(true);
const markersRef = useRef<Record<string, L.Marker>>({});
const {
filterHospital,
filterSiteName,
hospitalLocked,
siteLocked,
allowedHospitals,
loading: filtersLoading,
handleHospitalChange,
handleSiteChange,
} = useUserDashboardFilters();
const showSiteFilter = isSiteEnabledHospital(filterHospital);
const hospitalLocationLinkFilters = useMemo(() => {
const filters: Record<string, unknown> = { custom_is_hospital: 1 };
if (allowedHospitals.length > 0) {
filters.name = ['in', allowedHospitals];
}
return filters;
}, [allowedHospitals]);
const mobileTeamSiteFilters = useMemo(
() => buildMobileTeamSiteFilters(filterHospital, siteLocked ? filterSiteName : undefined),
[filterHospital, filterSiteName, siteLocked]
);
const phccLocationLinkFilters = useMemo(() => {
const filters: Record<string, unknown> = { custom_is_phcc: 1 };
if (siteLocked && filterSiteName) {
filters.name = filterSiteName;
}
return filters;
}, [siteLocked, filterSiteName]);
const applyHospitalSiteFilters = useCallback(
(
filters: Record<string, unknown>,
locationType: 'hospital' | 'phcc',
siteField: 'site_name' | 'custom_site',
hospitalName?: string
) => {
const hospital = hospitalName || filterHospital;
if (
locationType === 'hospital' &&
filterSiteName &&
hospital &&
isSiteEnabledHospital(hospital)
) {
filters[siteField] = filterSiteName;
}
return filters;
},
[filterHospital, filterSiteName]
);
// Fetch location counts based on location type
const fetchLocationCounts = async (location: any, locationType: 'hospital' | 'phcc'): Promise<LocationData> => {
const fetchLocationCounts = async (
location: { name: string; latitude: string | number; longitude: string | number },
locationType: 'hospital' | 'phcc'
): Promise<LocationData> => {
const isPhcc = locationType === 'phcc';
const assetFilterField = isPhcc ? 'custom_site' : 'company';
const woFilterField = isPhcc ? 'site_name' : 'company';
@ -99,13 +159,23 @@ const ActiveMap: React.FC = () => {
wo_progress: 0,
wo_review: 0,
wo_completed: 0,
wo_closed: 0,
phcc_asset_names: []
};
const hospitalName = locationType === 'hospital' ? location.name : undefined;
try {
const assetFilters = applyHospitalSiteFilters(
{ [assetFilterField]: location.name },
locationType,
'custom_site',
hospitalName
);
// Fetch Asset count
const assetsResponse = await apiService.apiCall<any>(
`/api/resource/Asset?filters=${encodeURIComponent(JSON.stringify({ [assetFilterField]: location.name }))}&fields=["name"]&limit_page_length=0`
`/api/resource/Asset?filters=${encodeURIComponent(JSON.stringify(assetFilters))}&fields=["name"]&limit_page_length=0`
);
const assetList = assetsResponse?.data || [];
counts.assets = assetList.length;
@ -115,62 +185,75 @@ const ActiveMap: React.FC = () => {
counts.phcc_asset_names = assetList.map((a: any) => a.name);
}
// Fetch Normal Work Orders
const normalWOResponse = await apiService.apiCall<any>(
`/api/resource/Work_Order?filters=${encodeURIComponent(JSON.stringify({
const normalWOFilters = applyHospitalSiteFilters(
{
[woFilterField]: location.name,
custom_priority_: 'Normal',
repair_status: ['in', ['Open', 'Work In Progress']]
}))}&fields=["name"]`
repair_status: ['in', ['Open', 'Work In Progress']],
},
locationType,
'site_name',
hospitalName
);
// Fetch Normal Work Orders
const normalWOResponse = await apiService.apiCall<any>(
`/api/resource/Work_Order?filters=${encodeURIComponent(JSON.stringify(normalWOFilters))}&fields=["name"]`
);
counts.normal_work_orders = normalWOResponse?.data?.length || 0;
// Fetch Urgent Work Orders
const urgentWOResponse = await apiService.apiCall<any>(
`/api/resource/Work_Order?filters=${encodeURIComponent(JSON.stringify({
const urgentWOFilters = applyHospitalSiteFilters(
{
[woFilterField]: location.name,
custom_priority_: 'Urgent',
repair_status: ['in', ['Open', 'Work In Progress']]
}))}&fields=["name"]`
repair_status: ['in', ['Open', 'Work In Progress']],
},
locationType,
'site_name',
hospitalName
);
// Fetch Urgent Work Orders
const urgentWOResponse = await apiService.apiCall<any>(
`/api/resource/Work_Order?filters=${encodeURIComponent(JSON.stringify(urgentWOFilters))}&fields=["name"]`
);
counts.urgent_work_orders = urgentWOResponse?.data?.length || 0;
const buildWoStatusFilters = (repairStatus: string) =>
applyHospitalSiteFilters(
{
[woFilterField]: location.name,
repair_status: repairStatus,
},
locationType,
'site_name',
hospitalName
);
// Fetch WO Status counts
const [woOpen, woProgress, woReview, woCompleted] = await Promise.all([
// Open
const [woOpen, woProgress, woReview, woCompleted, woClosed] = await Promise.all([
apiService.apiCall<any>(
`/api/resource/Work_Order?filters=${encodeURIComponent(JSON.stringify({
[woFilterField]: location.name,
repair_status: 'Open'
}))}&fields=["name"]`
`/api/resource/Work_Order?filters=${encodeURIComponent(JSON.stringify(buildWoStatusFilters('Open')))}&fields=["name"]`
),
// Work In Progress
apiService.apiCall<any>(
`/api/resource/Work_Order?filters=${encodeURIComponent(JSON.stringify({
[woFilterField]: location.name,
repair_status: 'Work In Progress'
}))}&fields=["name"]`
`/api/resource/Work_Order?filters=${encodeURIComponent(JSON.stringify(buildWoStatusFilters('Work In Progress')))}&fields=["name"]`
),
// Pending Review
apiService.apiCall<any>(
`/api/resource/Work_Order?filters=${encodeURIComponent(JSON.stringify({
[woFilterField]: location.name,
repair_status: 'Pending Review'
}))}&fields=["name"]`
`/api/resource/Work_Order?filters=${encodeURIComponent(JSON.stringify(buildWoStatusFilters('Pending Review')))}&fields=["name"]`
),
// Completed
apiService.apiCall<any>(
`/api/resource/Work_Order?filters=${encodeURIComponent(JSON.stringify({
[woFilterField]: location.name,
repair_status: 'Completed'
}))}&fields=["name"]`
)
`/api/resource/Work_Order?filters=${encodeURIComponent(JSON.stringify(buildWoStatusFilters('Completed')))}&fields=["name"]`
),
apiService.apiCall<any>(
`/api/resource/Work_Order?filters=${encodeURIComponent(JSON.stringify(buildWoStatusFilters('Closed')))}&fields=["name"]`
),
]);
counts.wo_open = woOpen?.data?.length || 0;
counts.wo_progress = woProgress?.data?.length || 0;
counts.wo_review = woReview?.data?.length || 0;
counts.wo_completed = woCompleted?.data?.length || 0;
counts.wo_closed = woClosed?.data?.length || 0;
// Fetch Maintenance counts - different logic for PHCC vs Hospital
if (isPhcc && counts.phcc_asset_names && counts.phcc_asset_names.length > 0) {
@ -199,26 +282,28 @@ const ActiveMap: React.FC = () => {
counts.completed_maintenance = completedPM?.data?.length || 0;
counts.overdue_maintenance = overduePM?.data?.length || 0;
} else if (!isPhcc) {
const buildMaintenanceFilters = (maintenanceStatus: string) =>
applyHospitalSiteFilters(
{
custom_hospital_name: location.name,
maintenance_status: maintenanceStatus,
},
locationType,
'site_name',
hospitalName
);
// For Hospital, filter by custom_hospital_name
const [plannedPM, completedPM, overduePM] = await Promise.all([
apiService.apiCall<any>(
`/api/resource/Asset Maintenance Log?filters=${encodeURIComponent(JSON.stringify({
custom_hospital_name: location.name,
maintenance_status: 'Planned'
}))}&fields=["name"]`
`/api/resource/Asset Maintenance Log?filters=${encodeURIComponent(JSON.stringify(buildMaintenanceFilters('Planned')))}&fields=["name"]`
),
apiService.apiCall<any>(
`/api/resource/Asset Maintenance Log?filters=${encodeURIComponent(JSON.stringify({
custom_hospital_name: location.name,
maintenance_status: 'Completed'
}))}&fields=["name"]`
`/api/resource/Asset Maintenance Log?filters=${encodeURIComponent(JSON.stringify(buildMaintenanceFilters('Completed')))}&fields=["name"]`
),
apiService.apiCall<any>(
`/api/resource/Asset Maintenance Log?filters=${encodeURIComponent(JSON.stringify({
custom_hospital_name: location.name,
maintenance_status: 'Overdue'
}))}&fields=["name"]`
)
`/api/resource/Asset Maintenance Log?filters=${encodeURIComponent(JSON.stringify(buildMaintenanceFilters('Overdue')))}&fields=["name"]`
),
]);
counts.planned_maintenance = plannedPM?.data?.length || 0;
counts.completed_maintenance = completedPM?.data?.length || 0;
@ -243,17 +328,20 @@ const ActiveMap: React.FC = () => {
try {
let allLocations: LocationData[] = [];
const fetchPromises: Promise<LocationData[]>[] = [];
const effectivePhcc = siteLocked && filterSiteName ? filterSiteName : selectedPHCC;
// Fetch Hospital locations (if no PHCC is specifically selected, or if hospital is selected)
if (!selectedPHCC || selectedHospital) {
const hospitalFilters: Record<string, any> = {
if (!effectivePhcc || filterHospital) {
const hospitalFilters: Record<string, unknown> = {
latitude: ['!=', ''],
longitude: ['!=', ''],
custom_is_hospital: 1
custom_is_hospital: 1,
};
if (selectedHospital) {
hospitalFilters.name = selectedHospital;
if (filterHospital) {
hospitalFilters.name = filterHospital;
} else if (allowedHospitals.length > 0) {
hospitalFilters.name = ['in', allowedHospitals];
}
fetchPromises.push(
@ -262,22 +350,24 @@ const ActiveMap: React.FC = () => {
`/api/resource/Location?filters=${encodeURIComponent(JSON.stringify(hospitalFilters))}&fields=["name","latitude","longitude"]&limit_page_length=0`
);
const locationList = locationsResponse?.data || [];
const locationPromises = locationList.map((loc: any) => fetchLocationCounts(loc, 'hospital'));
const locationPromises = locationList.map((loc: { name: string; latitude: string; longitude: string }) =>
fetchLocationCounts(loc, 'hospital')
);
return Promise.all(locationPromises);
})()
);
}
// Fetch PHCC locations (if no hospital is specifically selected, or if PHCC is selected)
if (!selectedHospital || selectedPHCC) {
const phccFilters: Record<string, any> = {
if (!filterHospital || effectivePhcc) {
const phccFilters: Record<string, unknown> = {
latitude: ['!=', ''],
longitude: ['!=', ''],
custom_is_phcc: 1
custom_is_phcc: 1,
};
if (selectedPHCC) {
phccFilters.name = selectedPHCC;
if (effectivePhcc) {
phccFilters.name = effectivePhcc;
}
fetchPromises.push(
@ -286,14 +376,16 @@ const ActiveMap: React.FC = () => {
`/api/resource/Location?filters=${encodeURIComponent(JSON.stringify(phccFilters))}&fields=["name","latitude","longitude"]&limit_page_length=0`
);
const locationList = locationsResponse?.data || [];
const locationPromises = locationList.map((loc: any) => fetchLocationCounts(loc, 'phcc'));
const locationPromises = locationList.map((loc: { name: string; latitude: string; longitude: string }) =>
fetchLocationCounts(loc, 'phcc')
);
return Promise.all(locationPromises);
})()
);
}
const results = await Promise.all(fetchPromises);
allLocations = results.flat().filter(l => !isNaN(l.latitude) && !isNaN(l.longitude));
allLocations = results.flat().filter((l) => !isNaN(l.latitude) && !isNaN(l.longitude));
setLocations(allLocations);
} catch (error) {
console.error('Error fetching map data:', error);
@ -303,14 +395,24 @@ const ActiveMap: React.FC = () => {
};
useEffect(() => {
if (filtersLoading) return;
if (siteLocked && filterSiteName) {
setSelectedPHCC(filterSiteName);
}
fetchAndRenderData();
}, [selectedHospital, selectedPHCC]);
}, [filterHospital, filterSiteName, selectedPHCC, filtersLoading, siteLocked, allowedHospitals]);
// Navigate to list view with filters
const navigateToWorkOrders = (location: LocationData, priority?: string, status?: string) => {
const params = new URLSearchParams();
const filterField = location.location_type === 'phcc' ? 'site_name' : 'company';
params.set(filterField, location.name);
if (location.location_type === 'phcc') {
params.set('site_name', location.name);
} else {
params.set('company', location.name);
if (filterSiteName && isSiteEnabledHospital(location.name)) {
params.set('site_name', filterSiteName);
}
}
if (priority) params.set('priority', priority);
if (status) params.set('status', status);
navigate(`/work-orders?${params.toString()}`);
@ -435,6 +537,17 @@ const ActiveMap: React.FC = () => {
</button>
</td>
</tr>
<tr className="bg-gray-50 dark:bg-gray-700/40 hover:bg-gray-100 dark:hover:bg-gray-700/60 transition-colors">
<td className="px-3 py-2 text-gray-800 dark:text-gray-300 font-medium">Closed</td>
<td className="px-3 py-2">
<button
onClick={() => navigateToWorkOrders(location, undefined, 'Closed')}
className="text-gray-700 dark:text-gray-300 font-bold hover:underline cursor-pointer"
>
{location.wo_closed}
</button>
</td>
</tr>
</tbody>
</table>
</div>
@ -495,27 +608,43 @@ const ActiveMap: React.FC = () => {
{/* Filter Container */}
<div className="flex-shrink-0 bg-white dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700 px-4 py-3 relative z-[1000]">
<div className="flex flex-wrap gap-4 relative z-[1000]">
{/* Hospital Filter */}
{/* Hospital Filter — Location name matches Company/Hospital name */}
<div className="w-64 relative z-[1000]">
<LinkField
label="Hospital"
doctype="Location"
value={selectedHospital}
onChange={setSelectedHospital}
filters={{ custom_is_hospital: 1 }}
placeholder="Select hospital"
value={filterHospital}
onChange={handleHospitalChange}
filters={hospitalLocationLinkFilters}
placeholder="All Hospitals"
disabled={hospitalLocked}
/>
</div>
{showSiteFilter && (
<div className="w-64 relative z-[1000]">
<LinkField
label="Site Name"
doctype="Mobile Team Site"
value={filterSiteName}
onChange={handleSiteChange}
filters={mobileTeamSiteFilters}
placeholder="All Sites"
disabled={siteLocked}
/>
</div>
)}
{/* PHCC Filter */}
<div className="w-64 relative z-[1000]">
<LinkField
label="PHCC"
doctype="Location"
value={selectedPHCC}
value={siteLocked && filterSiteName ? filterSiteName : selectedPHCC}
onChange={setSelectedPHCC}
filters={{ custom_is_phcc: 1 }}
filters={phccLocationLinkFilters}
placeholder="Select PHCC"
disabled={siteLocked}
/>
</div>
</div>
@ -525,7 +654,9 @@ const ActiveMap: React.FC = () => {
<div className="flex-1 relative" style={{ zIndex: 1 }}>
{loading && (
<div className="absolute inset-0 flex items-center justify-center bg-white bg-opacity-75 dark:bg-gray-900 dark:bg-opacity-75 z-[1000]">
<div className="text-gray-600 dark:text-gray-300">Loading map data...</div>
<div className="text-gray-600 dark:text-gray-300">
{filtersLoading ? 'Loading filters...' : 'Loading map data...'}
</div>
</div>
)}
<MapContainer

View File

@ -1,4 +1,5 @@
import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react';
import { createPortal } from 'react-dom';
import { useParams, useNavigate, useSearchParams } from 'react-router-dom';
import { useAssetDetails, useAssetMutations } from '../hooks/useAsset';
import { useDocTypeMeta } from '../hooks/useDocTypeMeta';
@ -8,6 +9,12 @@ import type { CreateAssetData,AssetFinanceBookRow } from '../services/assetServi
import LinkField from '../components/LinkField';
import apiService from '../services/apiService';
import useDefaultHospital from '../hooks/useDefaultHospital';
import CommentSection from '../components/CommentSection';
import ActivityLog from '../components/ActivityLog';
import { isSiteEnabledHospital, buildMobileTeamSiteFilters } from '../utils/hospitalUtils';
import useUserPhccSiteFilter from '../hooks/useUserPhccSiteFilter';
import { openHtmlReport, showReportBlockedMessage, toAbsoluteSiteUrl } from '../utils/openHtmlReport';
const AssetDetail: React.FC = () => {
const { assetName } = useParams<{ assetName: string }>();
@ -26,10 +33,16 @@ const AssetDetail: React.FC = () => {
asset_name: '',
company: '',
custom_serial_number: '',
custom_local_id: '',
location: '',
custom_manufacturer: '',
department: '',
custom_asset_type: '',
asset_category: '',
custom_moh_id: '',
custom_floor: '',
custom_moh_engg: '',
custom_smeh_engg: '',
custom_manufacturing_year: '',
custom_model: '',
custom_class: '',
@ -93,17 +106,35 @@ const AssetDetail: React.FC = () => {
const [isActionDropdownOpen, setIsActionDropdownOpen] = useState(false);
const actionDropdownRef = useRef<HTMLDivElement>(null);
// Close dropdown when clicking outside
// Close dropdown when clicking outside (desktop only)
useEffect(() => {
if (!isActionDropdownOpen) return;
const mq = window.matchMedia('(min-width: 1024px)');
if (!mq.matches) return;
const handleClickOutside = (event: MouseEvent) => {
if (actionDropdownRef.current && !actionDropdownRef.current.contains(event.target as Node)) {
setIsActionDropdownOpen(false);
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, []);
document.addEventListener('click', handleClickOutside);
return () => document.removeEventListener('click', handleClickOutside);
}, [isActionDropdownOpen]);
useEffect(() => {
if (!isActionDropdownOpen) return;
const mq = window.matchMedia('(min-width: 1024px)');
if (mq.matches) return;
const previousOverflow = document.body.style.overflow;
document.body.style.overflow = 'hidden';
return () => {
document.body.style.overflow = previousOverflow;
};
}, [isActionDropdownOpen]);
// Debug logging (development only)
useEffect(() => {
@ -160,6 +191,37 @@ const AssetDetail: React.FC = () => {
const [userSiteName, setUserSiteName] = useState('');
const [departmentFilters, setDepartmentFilters] = useState<Record<string, any>>({});
const { userPhccSiteName, siteLocked } = useUserPhccSiteFilter();
const shouldShowSiteNameField = useMemo(
() => isSiteEnabledHospital(formData.company),
[formData.company]
);
const mobileTeamSiteFilters = useMemo(
() => buildMobileTeamSiteFilters(formData.company, userPhccSiteName),
[formData.company, userPhccSiteName]
);
const mohSupervisorFilters = useMemo(() => {
if (!formData.company) {
return {};
}
return {
custom_site_name: formData.company,
role_profile_name: 'MOH Supervisor',
};
}, [formData.company]);
const smehEnggFilters = useMemo(() => {
if (!formData.company) {
return {};
}
return {
custom_site_name: formData.company,
role_profile_name: 'Technician',
};
}, [formData.company]);
// Set default available_for_use_date for new assets
useEffect(() => {
@ -172,38 +234,33 @@ const AssetDetail: React.FC = () => {
}
}, [isNewAsset]);
// Load user details on mount and set default values (onload logic)
// Load user details on mount (hospital for department filters)
useEffect(() => {
async function loadUserDetails() {
try {
const user = await apiService.getUserDetails();
setUserSiteName(user.custom_site_name || '');
// Onload logic: Set default company and site based on user data
// Only apply when creating new asset or when company is not set
setFormData(prev => {
const updates: Partial<CreateAssetData> = {};
// Set company and asset_owner_company if new asset or company not set
if ((isNewAsset || !prev.company) && user.custom_site_name) {
updates.company = user.custom_site_name;
updates.asset_owner_company = user.custom_site_name;
}
// Set custom_site if not already set
if (!prev.custom_site && user.custom_phcc_site_name) {
updates.custom_site = user.custom_phcc_site_name;
}
return { ...prev, ...updates };
});
} catch (err) {
console.error('Error loading user details', err);
}
}
loadUserDetails();
}, [isNewAsset]);
}, []);
// Auto-set site name from user profile when available
useEffect(() => {
if (!userPhccSiteName) return;
setFormData((prev) => {
if (prev.custom_site) return prev;
return { ...prev, custom_site: userPhccSiteName };
});
}, [userPhccSiteName]);
useDefaultHospital(setFormData, {
fields: ['company', 'asset_owner_company'],
});
const addFinanceRow = () => {
// Get today's date in YYYY-MM-DD format for date input
@ -402,9 +459,8 @@ const AssetDetail: React.FC = () => {
// If site_name starts with "Mobile", exclude Bio department (but script has it commented out)
// Otherwise, exclude Non Bio department
// Also check if company starts with "Mobile"
const isMobileSite =
(userSiteName && userSiteName.startsWith('Mobile')) ||
(formData.company && formData.company.startsWith('Mobile'));
const isMobileSite =
isSiteEnabledHospital(userSiteName) || isSiteEnabledHospital(formData.company);
if (userSiteName || formData.company) {
if (isMobileSite) {
@ -429,12 +485,18 @@ const AssetDetail: React.FC = () => {
asset_name: isDuplicating ? `${asset.asset_name} (Copy)` : (asset.asset_name || ''),
company: asset.company || '',
custom_serial_number: isDuplicating ? '' : (asset.custom_serial_number || ''),
custom_local_id: isDuplicating ? '' : ((asset as any).custom_local_id || ''),
location: asset.location || '',
custom_manufacturer: asset.custom_manufacturer || '',
department: asset.department || '',
custom_moh_supervisor: asset.custom_moh_supervisor || '',
custom_team_leader: asset.custom_team_leader || '',
custom_asset_type: asset.custom_asset_type || '',
asset_category: asset.asset_category || '',
custom_moh_id: (asset as any).custom_moh_id || '',
custom_floor: (asset as any).custom_floor || '',
custom_moh_engg: (asset as any).custom_moh_engg || '',
custom_smeh_engg: (asset as any).custom_smeh_engg || '',
custom_manufacturing_year: asset.custom_manufacturing_year || '',
custom_model: asset.custom_model || '',
custom_class: asset.custom_class || '',
@ -520,7 +582,7 @@ const AssetDetail: React.FC = () => {
}
if (!formData.custom_asset_type) {
alert('Please select a Category');
alert('Please select an Asset Type');
return;
}
@ -647,13 +709,27 @@ const AssetDetail: React.FC = () => {
);
}
const runAssetAction = (action: () => void) => {
const isDesktop = window.matchMedia('(min-width: 1024px)').matches;
if (isDesktop) {
action();
setIsActionDropdownOpen(false);
return;
}
setIsActionDropdownOpen(false);
window.setTimeout(() => action(), 0);
};
const handlePrintQR = () => {
if (!qrCodeUrl || !asset) return;
const printWindow = window.open('', '_blank');
if (!printWindow) return;
printWindow.document.write(`
const reportHandle = openHtmlReport();
if (!reportHandle) {
showReportBlockedMessage();
return;
}
reportHandle.write(`
<!DOCTYPE html>
<html>
<head>
@ -710,20 +786,21 @@ const AssetDetail: React.FC = () => {
<strong>Asset ID:</strong> ${asset.name}<br/>
<strong>Asset Name:</strong> ${asset.asset_name || 'N/A'}
</div>
<img src="${qrCodeUrl}" alt="QR Code" class="qr-image" onload="window.print();" />
<img src="${toAbsoluteSiteUrl(qrCodeUrl)}" alt="QR Code" class="qr-image" onload="window.print();" />
</div>
</body>
</html>
`);
printWindow.document.close();
};
const handlePrintStayPlugged = () => {
const printWindow = window.open('', '_blank');
if (!printWindow) return;
printWindow.document.write(`
const reportHandle = openHtmlReport();
if (!reportHandle) {
showReportBlockedMessage();
return;
}
reportHandle.write(`
<!DOCTYPE html>
<html>
<head>
@ -761,13 +838,11 @@ const AssetDetail: React.FC = () => {
</head>
<body>
<div class="image-container">
<img src="/files/Stay Plugged.jpg" alt="Stay Plugged" class="stay-plugged-image" onload="window.print();" />
<img src="${toAbsoluteSiteUrl('/files/Stay Plugged.jpg')}" alt="Stay Plugged" class="stay-plugged-image" onload="window.print();" />
</div>
</body>
</html>
`);
printWindow.document.close();
};
const handlePPMPlan = async () => {
@ -776,6 +851,16 @@ const handlePPMPlan = async () => {
return;
}
const reportHandle = openHtmlReport(`
<!DOCTYPE html>
<html><head><title>PPM Plan</title></head>
<body style="font-family:Arial,sans-serif;padding:24px;">Loading PPM Plan...</body></html>
`);
if (!reportHandle) {
showReportBlockedMessage();
return;
}
try {
// Fetch maintenance logs with "Planned" status for this asset
const response = await apiService.apiCall<any>(
@ -871,10 +956,6 @@ const handlePPMPlan = async () => {
// Sort by due date
ppmData.sort((a, b) => new Date(a.due_date).getTime() - new Date(b.due_date).getTime());
// Open PPM Plan window
const ppmWindow = window.open('', '_blank');
if (!ppmWindow) return;
const tableRows = ppmData.length > 0
? ppmData.map((row, index) => `
<tr class="${index % 2 === 0 ? 'bg-white' : 'bg-gray-50'}">
@ -892,7 +973,7 @@ const handlePPMPlan = async () => {
`).join('')
: `<tr><td colspan="8" class="border px-3 py-4 text-center text-gray-500">No planned maintenance found for this asset</td></tr>`;
ppmWindow.document.write(`
reportHandle.write(`
<!DOCTYPE html>
<html>
<head>
@ -1041,8 +1122,6 @@ const handlePPMPlan = async () => {
</html>
`);
ppmWindow.document.close();
} catch (err) {
console.error('Error fetching PPM Plan:', err);
alert('Failed to load PPM Plan. Please try again.');
@ -1056,6 +1135,16 @@ const handlePPMPlan = async () => {
}
try {
const reportHandle = openHtmlReport(`
<!DOCTYPE html>
<html><head><title>Asset History</title></head>
<body style="font-family:Arial,sans-serif;padding:24px;">Loading Asset History...</body></html>
`);
if (!reportHandle) {
showReportBlockedMessage();
return;
}
// Fetch Asset Details
const assetResponse = await apiService.apiCall<any>(
`/api/resource/Asset/${assetName}`
@ -1122,10 +1211,6 @@ const handlePPMPlan = async () => {
groupedSpareParts[wo].push(sp);
});
// Open Asset History window
const historyWindow = window.open('', '_blank');
if (!historyWindow) return;
// Generate Work Orders HTML
const workOrdersHTML = workOrders.length > 0
? workOrders.map((wo: any, index: number) => {
@ -1269,7 +1354,7 @@ const handlePPMPlan = async () => {
}).join('')
: `<tr><td colspan="7" class="px-4 py-8 text-center text-gray-500">No Pending/Planned Maintenance Logs</td></tr>`;
historyWindow.document.write(`
reportHandle.write(`
<!DOCTYPE html>
<html>
<head>
@ -1514,8 +1599,6 @@ const handlePPMPlan = async () => {
</html>
`);
historyWindow.document.close();
} catch (err) {
console.error('Error fetching Asset History:', err);
alert('Failed to load Asset History. Please try again.');
@ -1528,8 +1611,11 @@ const handlePPMPlan = async () => {
return;
}
const reportWindow = window.open('', '_blank');
if (!reportWindow) return;
const reportHandle = openHtmlReport();
if (!reportHandle) {
showReportBlockedMessage();
return;
}
// Get current date formatted
const today = new Date();
@ -1586,7 +1672,7 @@ const handlePPMPlan = async () => {
}
}
reportWindow.document.write(`
reportHandle.write(`
<!DOCTYPE html>
<html lang="ar" dir="rtl">
<head>
@ -1940,8 +2026,6 @@ const handlePPMPlan = async () => {
</body>
</html>
`);
reportWindow.document.close();
};
const handleDismantlingReport = () => {
@ -1950,8 +2034,11 @@ const handlePPMPlan = async () => {
return;
}
const reportWindow = window.open('', '_blank');
if (!reportWindow) return;
const reportHandle = openHtmlReport();
if (!reportHandle) {
showReportBlockedMessage();
return;
}
// Get current date formatted
const today = new Date();
@ -1971,7 +2058,7 @@ const handlePPMPlan = async () => {
usagePeriod = `${years} سنة و ${months} شهر`;
}
reportWindow.document.write(`
reportHandle.write(`
<!DOCTYPE html>
<html lang="ar" dir="rtl">
<head>
@ -2306,10 +2393,70 @@ const handlePPMPlan = async () => {
</body>
</html>
`);
reportWindow.document.close();
};
const renderActionMenuItems = () => (
<>
{qrCodeUrl && (
<button
type="button"
onClick={() => runAssetAction(handlePrintQR)}
className="w-full text-left px-4 py-3 min-h-[48px] text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 flex items-center gap-2"
>
<FaQrcode size={14} />
Print QR
</button>
)}
<button
type="button"
onClick={() => runAssetAction(handlePrintStayPlugged)}
className="w-full text-left px-4 py-3 min-h-[48px] text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 flex items-center gap-2"
>
<FaQrcode size={14} />
Stay Plugged
</button>
<button
type="button"
onClick={() => runAssetAction(handlePPMPlan)}
className="w-full text-left px-4 py-3 min-h-[48px] text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 flex items-center gap-2"
>
<FaCalendarAlt size={14} />
PPM Plan
</button>
<button
type="button"
onClick={() => runAssetAction(handleAssetHistory)}
className="w-full text-left px-4 py-3 min-h-[48px] text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 flex items-center gap-2"
>
<FaHistory size={14} />
Asset History
</button>
<button
type="button"
onClick={() => runAssetAction(handleInstallationReport)}
className="w-full text-left px-4 py-3 min-h-[48px] text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 flex items-center gap-2"
>
<FaFileAlt size={14} />
Installation Report
</button>
{asset?.status === 'Scrapped' && (
<button
type="button"
onClick={() => runAssetAction(handleDismantlingReport)}
className="w-full text-left px-4 py-3 min-h-[48px] text-sm hover:bg-red-50 dark:hover:bg-red-900/20 flex items-center gap-2 text-red-600 dark:text-red-400 border-t border-gray-200 dark:border-gray-700"
>
<FaTrashAlt size={14} />
Dismantling Report
</button>
)}
</>
);
return (
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 p-4 sm:p-6 min-w-0 overflow-x-hidden">
{/* Header */}
@ -2336,116 +2483,57 @@ const handlePPMPlan = async () => {
</span>
)}
</div>
<div className="flex items-center gap-3">
<div className="flex flex-wrap items-center gap-2 sm:gap-3">
{!isNewAsset && !isEditing && !isCancelled && (
<>
{/* Actions Dropdown */}
<div className="relative" ref={actionDropdownRef}>
<button
type="button"
onClick={() => setIsActionDropdownOpen(!isActionDropdownOpen)}
className="bg-blue-600 hover:bg-blue-700 text-white px-2 py-1 rounded-lg flex items-center gap-2"
className="bg-blue-600 hover:bg-blue-700 text-white px-3 py-2 min-h-[44px] rounded-lg flex items-center gap-2"
>
Actions
<FaChevronDown size={12} className={`transition-transform ${isActionDropdownOpen ? 'rotate-180' : ''}`} />
</button>
{/* Dropdown Menu */}
{isActionDropdownOpen && (
<div className="absolute right-0 mt-2 w-56 bg-white dark:bg-gray-800 rounded-md shadow-lg z-50 border border-gray-200 dark:border-gray-700">
<div className="py-1">
{/* Print QR - Only show if QR code exists */}
{qrCodeUrl && (
<button
onClick={() => {
handlePrintQR();
setIsActionDropdownOpen(false);
}}
className="w-full text-left px-4 py-2 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 flex items-center gap-2"
>
<FaQrcode size={14} />
Print QR
</button>
)}
{/* Stay Plugged */}
<button
onClick={() => {
handlePrintStayPlugged();
setIsActionDropdownOpen(false);
}}
className="w-full text-left px-4 py-2 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 flex items-center gap-2"
>
<FaQrcode size={14} />
Stay Plugged
</button>
{/* PPM Plan */}
<button
onClick={() => {
handlePPMPlan();
setIsActionDropdownOpen(false);
}}
className="w-full text-left px-4 py-2 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 flex items-center gap-2"
>
<FaCalendarAlt size={14} />
PPM Plan
</button>
{/* Asset History */}
<button
onClick={() => {
handleAssetHistory();
setIsActionDropdownOpen(false);
}}
className="w-full text-left px-4 py-2 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 flex items-center gap-2"
>
<FaHistory size={14} />
Asset History
</button>
{/* Installation Report */}
<button
onClick={() => {
handleInstallationReport();
setIsActionDropdownOpen(false);
}}
className="w-full text-left px-4 py-2 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 flex items-center gap-2"
>
<FaFileAlt size={14} />
Installation Report
</button>
{/* Dismantling Report - Only show when status is Scrapped */}
{asset?.status === 'Scrapped' && (
<button
onClick={() => {
handleDismantlingReport();
setIsActionDropdownOpen(false);
}}
className="w-full text-left px-4 py-2 text-sm text-gray-700 dark:text-gray-300 hover:bg-red-50 dark:hover:bg-red-900/20 flex items-center gap-2 text-red-600 dark:text-red-400 border-t border-gray-200 dark:border-gray-700"
>
<FaTrashAlt size={14} />
Dismantling Report
</button>
)}
</div>
<div className="absolute right-0 mt-2 w-56 bg-white dark:bg-gray-800 rounded-md shadow-lg z-50 border border-gray-200 dark:border-gray-700 hidden lg:block">
<div className="py-1">{renderActionMenuItems()}</div>
</div>
)}
</div>
{isActionDropdownOpen &&
createPortal(
<>
<button
type="button"
className="fixed inset-0 z-[10000] bg-black/50 lg:hidden"
onClick={() => setIsActionDropdownOpen(false)}
aria-label="Close actions"
/>
<div className="fixed inset-x-0 bottom-0 z-[10001] max-h-[85vh] overflow-y-auto rounded-t-2xl bg-white dark:bg-gray-800 border-t border-gray-200 dark:border-gray-700 lg:hidden">
<div className="px-4 py-3 border-b border-gray-200 dark:border-gray-700">
<h3 className="text-sm font-semibold text-gray-900 dark:text-white">Actions</h3>
</div>
<div className="py-1">{renderActionMenuItems()}</div>
</div>
</>,
document.body
)}
<button
onClick={() => setIsEditing(true)}
className="bg-blue-600 hover:bg-blue-700 text-white px-2 py-1 rounded-lg flex items-center gap-2"
className="bg-blue-600 hover:bg-blue-700 text-white px-3 py-2 min-h-[44px] rounded-lg flex items-center gap-2"
>
<FaEdit />
Edit
</button>
{/* Submit button - only show for Draft documents */}
{isDraft && (
<button
onClick={handleSubmitDocument}
disabled={saving}
className="bg-purple-600 hover:bg-purple-700 text-white px-2 py-1 rounded-lg flex items-center gap-2 disabled:opacity-50"
className="bg-purple-600 hover:bg-purple-700 text-white px-3 py-2 min-h-[44px] rounded-lg flex items-center gap-2 disabled:opacity-50"
>
<FaCheck />
{saving ? 'Submitting...' : 'Submit'}
@ -2513,13 +2601,21 @@ const handlePPMPlan = async () => {
</div>
<LinkField
label="Category"
label="Asset Type"
doctype="Asset Type"
value={formData.custom_asset_type || ''}
onChange={(val) => setFormData({ ...formData, custom_asset_type: val })}
disabled={isFieldDisabled('custom_asset_type')}
/>
<LinkField
label="Asset Category"
doctype="Asset Category"
value={formData.asset_category || ''}
onChange={(val) => setFormData({ ...formData, asset_category: val })}
disabled={isFieldDisabled('asset_category')}
/>
<LinkField
label="Modality"
doctype="Modality"
@ -2602,6 +2698,21 @@ const handlePPMPlan = async () => {
/>
</div>
<div>
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">
Local ID
</label>
<input
type="text"
name="custom_local_id"
value={formData.custom_local_id || ''}
onChange={handleChange}
placeholder="Local ID"
disabled={isFieldDisabled('custom_local_id')}
className="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:bg-gray-100 dark:disabled:bg-gray-700 bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
/>
</div>
{/* <div>
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">
System ID
@ -2721,41 +2832,31 @@ const handlePPMPlan = async () => {
doctype="Company"
value={formData.company || ''}
onChange={(val) => {
setFormData({ ...formData, company: val, asset_owner_company: val, department: '' });
setFormData({
...formData,
company: val,
asset_owner_company: val,
department: '',
custom_site: '',
custom_moh_supervisor: '',
custom_smeh_engg: '',
});
}}
disabled={isFieldDisabled('company')}
filters={{ domain: 'Healthcare' }}
/>
{/* Site Name - conditionally visible based on depends_on: company.startsWith('Mobile') */}
{shouldShowField('custom_site') && (
// <div>
// <label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">
// Site Name {isFieldMandatory('custom_site') && <span className="text-red-500">*</span>}
// </label>
// <input
// type="text"
// name="custom_site"
// value={formData.custom_site || ''}
// onChange={handleChange}
// placeholder="Site name"
// disabled={isFieldDisabled('custom_site')}
// required={isFieldMandatory('custom_site')}
// className="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:bg-gray-100 dark:disabled:bg-gray-700 bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
// />
// </div>
<div>
{shouldShowSiteNameField && (
<LinkField
label="Site name"
key={formData.company || 'site-name'}
label="Site Name"
doctype="Mobile Team Site"
value={formData.custom_site || ''}
onChange={(val) => setFormData({ ...formData, custom_site: val })}
disabled={isFieldDisabled('custom_site')}
disabled={isFieldDisabled('custom_site') || !formData.company || siteLocked}
filters={mobileTeamSiteFilters}
placeholder="Select Site"
/>
</div>
)}
<LinkField
@ -2775,6 +2876,36 @@ const handlePPMPlan = async () => {
disabled={isFieldDisabled('location')}
/>
<div>
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">
MOH ID
</label>
<input
type="text"
name="custom_moh_id"
value={formData.custom_moh_id || ''}
onChange={handleChange}
placeholder="MOH ID"
disabled={isFieldDisabled('custom_moh_id')}
className="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:bg-gray-100 dark:disabled:bg-gray-700 bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
/>
</div>
<div>
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">
Floor
</label>
<input
type="text"
name="custom_floor"
value={formData.custom_floor || ''}
onChange={handleChange}
placeholder="Floor"
disabled={isFieldDisabled('custom_floor')}
className="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:bg-gray-100 dark:disabled:bg-gray-700 bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
/>
</div>
<div>
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">
Building
@ -3048,11 +3179,27 @@ const handlePPMPlan = async () => {
/>
</div>
<LinkField
key={`moh-supervisor-${formData.company || 'none'}`}
label="MOH Supervisor"
doctype="User"
query="frappe.core.doctype.user.user.user_query"
value={formData.custom_moh_supervisor || ''}
onChange={(val) => setFormData({ ...formData, custom_moh_supervisor: val })}
disabled={isFieldDisabled('custom_moh_supervisor')}
disabled={isFieldDisabled('custom_moh_supervisor') || !formData.company}
filters={mohSupervisorFilters}
placeholder={formData.company ? 'Select MOH Supervisor' : 'Select Hospital first'}
/>
<LinkField
key={`smeh-engg-${formData.company || 'none'}`}
label="SMEH Engg"
doctype="User"
query="frappe.core.doctype.user.user.user_query"
value={formData.custom_smeh_engg || ''}
onChange={(val) => setFormData({ ...formData, custom_smeh_engg: val })}
disabled={isFieldDisabled('custom_smeh_engg') || !formData.company}
filters={smehEnggFilters}
placeholder={formData.company ? 'Select SMEH Engg' : 'Select Hospital first'}
/>
<LinkField
@ -3649,6 +3796,26 @@ const handlePPMPlan = async () => {
)}
</div>
</form>
{!isNewAsset && !isDuplicating && (
<div className="mt-6 space-y-6 max-w-5xl">
<CommentSection
referenceDoctype="Asset"
referenceName={assetName || null}
pollInterval={30000}
initialLimit={5}
/>
<ActivityLog
doctype="Asset"
docname={assetName || null}
creationDate={asset?.creation}
createdBy={asset?.owner}
initialVisible={5}
collapsible={false}
startCollapsed={false}
/>
</div>
)}
</div>
);
};

View File

@ -1,5 +1,5 @@
import React, { useState, useEffect, useRef, useCallback } from 'react';
import { useNavigate } from 'react-router-dom';
import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react';
import { useNavigate, useSearchParams } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { useAssets, useAssetMutations } from '../hooks/useAsset';
import * as XLSX from 'xlsx';
@ -8,6 +8,13 @@ import { FaPlus, FaSearch, FaEdit, FaEye, FaTrash, FaCopy, FaEllipsisV, FaDownlo
import LinkField from '../components/LinkField';
import { useUserPermissions } from '../hooks/useUserPermissions';
import { ScanQRButton } from '../components/QRScanner';
import ListPagination from '../components/ListPagination';
import ListFilterSortControls from '../components/ListFilterSortControls';
import { useListSortFilters } from '../hooks/useListSortFilters';
import { buildDateRangeFilters } from '../utils/listFilterUtils';
import { buildMobileTeamSiteFilters } from '../utils/hospitalUtils';
import useUserPhccSiteFilter from '../hooks/useUserPhccSiteFilter';
import useUserHospitalFilter from '../hooks/useUserHospitalFilter';
// Export column configuration - will be defined inside component to use translations
@ -316,7 +323,21 @@ const ExportModal: React.FC<ExportModalProps & { exportColumns: Array<{key: stri
const AssetList: React.FC = () => {
const { t } = useTranslation();
const navigate = useNavigate();
const [page, setPage] = useState(0);
const [searchParams, setSearchParams] = useSearchParams();
const filtersChangedOnce = useRef(false);
const {
sortBy,
dateFilterBy,
dateStart,
dateEnd,
page,
setSortBy,
setDateFilterBy,
setDateStart,
setDateEnd,
setPage,
resetPage,
} = useListSortFilters('creation desc');
const [searchTerm, setSearchTerm] = useState('');
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState<string | null>(null);
const [actionMenuOpen, setActionMenuOpen] = useState<string | null>(null);
@ -412,20 +433,22 @@ const AssetList: React.FC = () => {
}, []);
// Filter states
const { filterHospital, filtersLocked: hospitalLocked, handleHospitalChange, hospitalLinkFilters } = useUserHospitalFilter();
const { userPhccSiteName, siteLocked } = useUserPhccSiteFilter();
const [filterAssetId, setFilterAssetId] = useState('');
const [filterCompany, setFilterCompany] = useState('');
const [filterSite, setFilterSite] = useState('');
const [filterCompany, setFilterCompany] = useState(() => searchParams.get('company') || '');
const [filterSite, setFilterSite] = useState(() => searchParams.get('site_name') || searchParams.get('custom_site') || '');
const [filterManufacturer, setFilterManufacturer] = useState('');
const [filterSupplier, setFilterSupplier] = useState('');
const [filterLocation, setFilterLocation] = useState('');
const [filterDepartment, setFilterDepartment] = useState('');
const [filterModality, setFilterModality] = useState('');
const [filterDeviceStatus, setFilterDeviceStatus] = useState('');
const [filterDeviceStatus, setFilterDeviceStatus] = useState(() => searchParams.get('device_status') || '');
const [filterAssetName, setFilterAssetName] = useState('');
const [filterSerialNumber, setFilterSerialNumber] = useState('');
// Advanced filter UI states
const [isFilterExpanded, setIsFilterExpanded] = useState(true);
const [isFilterExpanded, setIsFilterExpanded] = useState(false);
const [activeFilterCount, setActiveFilterCount] = useState(0);
const [savedFilters, setSavedFilters] = useState<any[]>([]);
const [showSaveFilterModal, setShowSaveFilterModal] = useState(false);
@ -439,6 +462,42 @@ const AssetList: React.FC = () => {
const assetNameDebounceRef = useRef<number | null>(null);
const serialNumberDebounceRef = useRef<number | null>(null);
const mobileTeamSiteFilters = useMemo(
() => buildMobileTeamSiteFilters(filterCompany, userPhccSiteName),
[filterCompany, userPhccSiteName]
);
// Auto-apply user's hospital when set on profile
useEffect(() => {
if (hospitalLocked && filterHospital) {
setFilterCompany(filterHospital);
}
}, [hospitalLocked, filterHospital]);
// Auto-apply user's PHCC site when set on profile
useEffect(() => {
if (siteLocked && userPhccSiteName) {
setFilterSite(userPhccSiteName);
}
}, [siteLocked, userPhccSiteName]);
// Sync filters to URL when user changes a filter (skip first mount)
useEffect(() => {
if (!filtersChangedOnce.current) {
filtersChangedOnce.current = true;
return;
}
setSearchParams((prev) => {
const next = new URLSearchParams(prev);
if (filterCompany) next.set('company', filterCompany); else next.delete('company');
if (filterSite) next.set('site_name', filterSite); else next.delete('site_name');
next.delete('custom_site');
if (filterDeviceStatus) next.set('device_status', filterDeviceStatus); else next.delete('device_status');
next.set('page', '1');
return next;
});
}, [filterCompany, filterSite, filterDeviceStatus, setSearchParams]);
// Load saved filters from localStorage on mount
useEffect(() => {
const saved = localStorage.getItem('assetFilterPresets');
@ -479,26 +538,27 @@ const AssetList: React.FC = () => {
searchTerm
]);
// Build filters object
// Build filters object (match reference: buildDateRangeFilters + Frappe operators)
const filters: Record<string, any> = {};
if (filterAssetId) filters['name'] = filterAssetId;
if (filterCompany) filters['company'] = filterCompany;
if (filterSite) filters['custom_site'] = filterSite;
if (filterManufacturer) filters['custom_manufacturer'] = filterManufacturer;
if (filterSupplier) filters['supplier'] = filterSupplier;
if (filterLocation) filters['location'] = filterLocation;
if (filterDepartment) filters['department'] = filterDepartment;
if (filterModality) filters['custom_modality'] = filterModality;
if (filterDeviceStatus) filters['custom_device_status'] = filterDeviceStatus;
if (filterAssetName) filters['asset_name'] = ['like', `%${filterAssetName}%`];
if (filterSerialNumber) filters['custom_serial_number'] = ['like', `%${filterSerialNumber}%`];
if (searchTerm) filters['asset_name'] = ['like', `%${searchTerm}%`];
if (filterAssetId) filters.name = filterAssetId;
if (filterCompany) filters.company = filterCompany;
if (filterSite) filters.custom_site = filterSite;
if (filterManufacturer) filters.custom_manufacturer = filterManufacturer;
if (filterSupplier) filters.supplier = filterSupplier;
if (filterLocation) filters.location = filterLocation;
if (filterDepartment) filters.department = filterDepartment;
if (filterModality) filters.custom_modality = filterModality;
if (filterDeviceStatus) filters.custom_device_status = filterDeviceStatus;
if (filterAssetName) filters.asset_name = ['like', `%${filterAssetName}%`];
if (filterSerialNumber) filters.custom_serial_number = ['like', `%${filterSerialNumber}%`];
if (searchTerm) filters.asset_name = ['like', `%${searchTerm}%`];
Object.assign(filters, buildDateRangeFilters(dateFilterBy, dateStart, dateEnd));
const { assets, totalCount, hasMore, loading, error, refetch } = useAssets(
filters,
limit,
page * limit,
'creation desc',
sortBy,
permissionFilters
);
@ -511,13 +571,12 @@ const AssetList: React.FC = () => {
}
}, [loading, initialLoadComplete, permissionsLoading]);
// ✅ Reset page when filters change - only if not already on page 0
// Reset page when filters change (exclude resetPage from deps — it changes on every URL update and breaks pagination)
useEffect(() => {
if (page !== 0) {
setPage(0);
}
resetPage();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [filterAssetId, filterCompany, filterSite, filterManufacturer, filterSupplier, filterLocation, filterDepartment, filterModality,
filterDeviceStatus, filterAssetName, filterSerialNumber, searchTerm]);
filterDeviceStatus, filterAssetName, filterSerialNumber, searchTerm, sortBy, dateFilterBy, dateStart, dateEnd]);
// ✅ Clear selection when filters/page change
useEffect(() => {
@ -830,6 +889,8 @@ const AssetList: React.FC = () => {
if (asset.custom_asset_type) params.append('asset_type', asset.custom_asset_type);
if (asset.custom_manufacturer) params.append('manufacturer', asset.custom_manufacturer);
if (asset.custom_serial_number) params.append('serial_number', asset.custom_serial_number);
if (asset.custom_local_id) params.append('local_id', asset.custom_local_id);
if (asset.custom_moh_id) params.append('moh_id', asset.custom_moh_id);
if (asset.department) params.append('department', asset.department);
if (asset.custom_site_contractor) params.append('site_contractor', asset.custom_site_contractor);
if (asset.custom_subcontractor) params.append('subcontractor', asset.custom_subcontractor);
@ -837,6 +898,7 @@ const AssetList: React.FC = () => {
if (asset.custom_service_agreement) params.append('custom_service_agreement', asset.custom_service_agreement);
if (asset.custom_service_coverage) params.append('custom_service_coverage', asset.custom_service_coverage);
if (asset.custom_site) params.append('site_name', asset.custom_site);
if (asset.custom_smeh_engg) params.append('smeh_engg', asset.custom_smeh_engg);
navigate(`/work-orders/new?${params.toString()}`);
};
@ -874,8 +936,12 @@ const AssetList: React.FC = () => {
const handleClearFilters = () => {
setFilterAssetId('');
setFilterCompany('');
setFilterSite('');
if (!hospitalLocked) {
setFilterCompany('');
}
if (!siteLocked) {
setFilterSite('');
}
setFilterManufacturer('');
setFilterSupplier('');
setFilterLocation('');
@ -1074,12 +1140,14 @@ const AssetList: React.FC = () => {
{filterSite && (
<span className="inline-flex items-center gap-1.5 px-2.5 py-1 bg-white/90 text-emerald-700 rounded-full text-[10px] font-medium whitespace-nowrap shadow-sm">
<span className="font-semibold">Site:</span> {filterSite}
<button
onClick={(e) => { e.stopPropagation(); setFilterSite(''); }}
className="hover:text-red-500 transition-colors"
>
<FaTimes className="text-[9px]" />
</button>
{!siteLocked && (
<button
onClick={(e) => { e.stopPropagation(); setFilterSite(''); }}
className="hover:text-red-500 transition-colors"
>
<FaTimes className="text-[9px]" />
</button>
)}
</span>
)}
{filterAssetName && (
@ -1239,6 +1307,17 @@ const AssetList: React.FC = () => {
<div className="space-y-5">
<div className="bg-gray-50 dark:bg-gray-900/50 p-3 rounded-lg">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-5 gap-3">
<ListFilterSortControls
sortBy={sortBy}
dateFilterBy={dateFilterBy}
dateStart={dateStart}
dateEnd={dateEnd}
onSortByChange={setSortBy}
onDateFilterByChange={setDateFilterBy}
onDateStartChange={setDateStart}
onDateEndChange={setDateEnd}
includeAssetNameSort
/>
{/* Asset ID */}
<div className="relative z-[60]">
<LinkField
@ -1328,15 +1407,21 @@ const AssetList: React.FC = () => {
label="Hospital"
doctype="Company"
value={filterCompany}
onChange={(val) => setFilterCompany(val)}
onChange={(val) => {
handleHospitalChange(val);
setFilterCompany(val);
}}
placeholder="Select Hospital"
disabled={false}
disabled={hospitalLocked}
compact={true}
filters={{ domain: 'Healthcare' }}
filters={hospitalLinkFilters}
/>
{filterCompany && (
{filterCompany && !hospitalLocked && (
<button
onClick={() => setFilterCompany('')}
onClick={() => {
handleHospitalChange('');
setFilterCompany('');
}}
className="absolute right-2 top-6 text-gray-400 hover:text-red-500 transition-colors z-10"
>
<FaTimes size={10} />
@ -1352,10 +1437,11 @@ const AssetList: React.FC = () => {
value={filterSite}
onChange={(val) => setFilterSite(val)}
placeholder="Select Site"
disabled={false}
disabled={siteLocked}
filters={mobileTeamSiteFilters}
compact={true}
/>
{filterSite && (
{filterSite && !siteLocked && (
<button
onClick={() => setFilterSite('')}
className="absolute right-2 top-6 text-gray-400 hover:text-red-500 transition-colors z-10"
@ -1790,39 +1876,14 @@ const AssetList: React.FC = () => {
</table>
</div>
{/* Pagination */}
{assets.length > 0 && (
<div className="bg-gray-50 dark:bg-gray-700 px-6 py-4 flex items-center justify-between border-t border-gray-200 dark:border-gray-600">
<div className="text-sm text-gray-700 dark:text-gray-300">
Showing <span className="font-medium">{page * limit + 1}</span> to{' '}
<span className="font-medium">
{Math.min((page + 1) * limit, totalCount)}
</span>{' '}
of <span className="font-medium">{totalCount}</span> results
{hasAnyRestrictions && (
<span className="ml-1 text-amber-600 dark:text-amber-400">(filtered)</span>
)}
</div>
<div className="flex gap-2">
<button
disabled={page === 0}
onClick={() => setPage(page - 1)}
className="px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-md text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-800 hover:bg-gray-50 dark:hover:bg-gray-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
Previous
</button>
<button
disabled={!hasMore}
onClick={() => setPage(page + 1)}
className="px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-md text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-800 hover:bg-gray-50 dark:hover:bg-gray-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
Next
</button>
</div>
</div>
)}
<ListPagination
currentPage={page + 1}
totalCount={totalCount}
pageSize={limit}
hasMore={hasMore}
itemLabel={t('pagination.assets')}
onPageChange={(p) => setPage(Math.max(0, p - 1))}
/>
</div>
{/* Delete Confirmation Modal */}

View File

@ -5,6 +5,7 @@ import { useMaintenanceLogDetails, useMaintenanceMutations } from '../hooks/useA
import { FaArrowLeft, FaSave, FaEdit, FaClock, FaList, FaPlus, FaTrash, FaCheck, FaTimes, FaExclamationTriangle } from 'react-icons/fa';
import apiService from '../services/apiService';
import WorkflowActions from '../components/WorkflowActions';
import useDefaultHospital from '../hooks/useDefaultHospital';
// Cast apiService to any to bypass strict typing
const api = apiService as any;
@ -125,6 +126,11 @@ const AssetMaintenanceDetail: React.FC = () => {
description: '',
});
useDefaultHospital(setFormData, {
enabled: isNewLog && !isDuplicating,
fields: ['custom_hospital_name'],
});
// Check early completion logic
const checkEarlyCompletion = useCallback((dueDate: string, completionDate: string, maintenanceStatus: string) => {
if (maintenanceStatus !== 'Completed') {

View File

@ -1,21 +1,55 @@
import React, { useState, useEffect, useRef } from 'react';
import { useNavigate } from 'react-router-dom';
import React, { useState, useEffect, useRef, useMemo } from 'react';
import { useNavigate, useSearchParams } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { useAssetMaintenanceLogs, useMaintenanceMutations } from '../hooks/useAssetMaintenance';
import type { MaintenanceFilters } from '../services/assetMaintenanceService';
import { FaPlus, FaSearch, FaEdit, FaEye, FaTrash, FaCopy, FaEllipsisV, FaDownload, FaPrint, FaFileExport, FaCheckCircle, FaClock, FaExclamationTriangle, FaCalendarCheck } from 'react-icons/fa';
const AssetMaintenanceList: React.FC = () => {
const { t } = useTranslation();
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const [page, setPage] = useState(0);
const [searchTerm, setSearchTerm] = useState('');
const [statusFilter, setStatusFilter] = useState<string>('');
const [statusFilter, setStatusFilter] = useState<string>(() => searchParams.get('status') || '');
const [assigneeFilter, setAssigneeFilter] = useState<string>(() => searchParams.get('assignee') || '');
const [dateStart, setDateStart] = useState(() => searchParams.get('date_start') || '');
const [dateEnd, setDateEnd] = useState(() => searchParams.get('date_end') || '');
const [companyFilter, setCompanyFilter] = useState(() => searchParams.get('company') || '');
const [siteFilter, setSiteFilter] = useState(() => searchParams.get('site_name') || '');
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState<string | null>(null);
const [actionMenuOpen, setActionMenuOpen] = useState<string | null>(null);
const dropdownRef = useRef<HTMLDivElement>(null);
const limit = 20;
const filters = statusFilter ? { maintenance_status: statusFilter } : {};
useEffect(() => {
setStatusFilter(searchParams.get('status') || '');
setAssigneeFilter(searchParams.get('assignee') || '');
setDateStart(searchParams.get('date_start') || '');
setDateEnd(searchParams.get('date_end') || '');
setCompanyFilter(searchParams.get('company') || '');
setSiteFilter(searchParams.get('site_name') || '');
setPage(0);
}, [searchParams]);
const filters = useMemo(() => {
const nextFilters: MaintenanceFilters = {};
if (statusFilter) nextFilters.maintenance_status = statusFilter;
if (assigneeFilter) nextFilters.assign_to_name = assigneeFilter;
if (companyFilter) nextFilters.company = companyFilter;
if (siteFilter) nextFilters.site_name = siteFilter;
if (dateStart && dateEnd) {
nextFilters.due_date = ['between', [dateStart, dateEnd]];
} else if (dateStart) {
nextFilters.due_date = ['>=', dateStart];
} else if (dateEnd) {
nextFilters.due_date = ['<=', dateEnd];
}
return nextFilters;
}, [statusFilter, assigneeFilter, companyFilter, siteFilter, dateStart, dateEnd]);
const { logs, totalCount, hasMore, loading, error, refetch } = useAssetMaintenanceLogs(
filters,

View File

@ -21,6 +21,9 @@ import {
import { toast, ToastContainer, Bounce } from 'react-toastify';
import 'react-toastify/dist/ReactToastify.css';
import LinkField from '../components/LinkField';
import CommentSection from '../components/CommentSection';
import ActivityLog from '../components/ActivityLog';
import useDefaultHospital from '../hooks/useDefaultHospital';
import type { CreateIssueData } from '../services/issueService';
// Helper to get today's date in YYYY-MM-DD format
@ -90,6 +93,11 @@ const IssueDetail: React.FC = () => {
const [isEditing, setIsEditing] = useState(isNewIssue);
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
useDefaultHospital(setFormData, {
enabled: isNewIssue,
fields: ['company'],
});
// Load issue data when fetched
useEffect(() => {
if (issue && !isNewIssue) {
@ -643,25 +651,31 @@ const IssueDetail: React.FC = () => {
{formatDateTime(issue.creation)}
</p>
</div>
<div className="p-4 bg-gray-50 dark:bg-gray-700 rounded-lg">
<p className="text-xs text-gray-500 dark:text-gray-400 mb-1">Last Modified</p>
<p className="text-sm text-gray-900 dark:text-white">
{formatDateTime(issue.modified)}
</p>
</div>
<div className="p-4 bg-gray-50 dark:bg-gray-700 rounded-lg">
<p className="text-xs text-gray-500 dark:text-gray-400 mb-1">Modified By</p>
<p className="text-sm text-gray-900 dark:text-white">
{issue.modified_by || '-'}
</p>
</div>
</>
)}
</div>
</div>
{!isNewIssue && (
<div className="space-y-6">
<CommentSection
referenceDoctype="Issue"
referenceName={issueName || null}
title="Comments & Discussion"
pollInterval={30000}
initialLimit={5}
/>
<ActivityLog
doctype="Issue"
docname={issueName || null}
creationDate={issue?.creation}
createdBy={issue?.owner}
initialVisible={5}
startCollapsed={true}
/>
</div>
)}
{/* Company Info Card */}
{formData.company && !isNewIssue && (
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-md p-6 border border-gray-200 dark:border-gray-700">

View File

@ -2,6 +2,10 @@ import React, { useState, useMemo, useEffect, useCallback } from 'react';
import { useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { useIssueList } from '../hooks/useIssue';
import ListPagination from '../components/ListPagination';
import ListFilterSortControls from '../components/ListFilterSortControls';
import { useListSortFilters } from '../hooks/useListSortFilters';
import { applyDateRangeToFilters } from '../utils/listFilterUtils';
import * as XLSX from 'xlsx';
import {
FaPlus,
@ -234,8 +238,20 @@ const IssueList: React.FC = () => {
{ key: 'owner', label: t('commonFields.createdBy'), default: false },
];
const [currentPage, setCurrentPage] = useState(1);
const [pageSize] = useState(20);
const {
sortBy,
dateFilterBy,
dateStart,
dateEnd,
page,
setSortBy,
setDateFilterBy,
setDateStart,
setDateEnd,
setPage,
resetPage,
} = useListSortFilters('creation desc');
const pageSize = 20;
const [initialLoadComplete, setInitialLoadComplete] = useState(false);
const [selectedRows, setSelectedRows] = useState<Set<string>>(new Set());
const [showExportModal, setShowExportModal] = useState(false);
@ -263,30 +279,37 @@ const IssueList: React.FC = () => {
setActiveFilterCount(count);
}, [statusFilter, priorityFilter, companyFilter, issueIdFilter]);
const apiFilters = useMemo(() => {
const filters: Record<string, any> = {};
if (statusFilter) filters['status'] = statusFilter;
if (priorityFilter) filters['priority'] = priorityFilter;
if (companyFilter) filters['company'] = companyFilter;
if (issueIdFilter) filters['name'] = issueIdFilter;
return filters;
}, [statusFilter, priorityFilter, companyFilter, issueIdFilter]);
const apiFilters = useMemo(
() =>
applyDateRangeToFilters(
{
...(statusFilter ? { status: statusFilter } : {}),
...(priorityFilter ? { priority: priorityFilter } : {}),
...(companyFilter ? { company: companyFilter } : {}),
...(issueIdFilter ? { name: issueIdFilter } : {}),
},
dateFilterBy,
dateStart,
dateEnd
),
[statusFilter, priorityFilter, companyFilter, issueIdFilter, dateFilterBy, dateStart, dateEnd]
);
const { issues, loading, error, totalCount, refetch } = useIssueList({
filters: apiFilters,
limit_start: (currentPage - 1) * pageSize,
limit_start: page * pageSize,
limit_page_length: pageSize,
order_by: 'creation desc',
order_by: sortBy,
});
useEffect(() => { if (!loading && !initialLoadComplete) setInitialLoadComplete(true); }, [loading, initialLoadComplete]);
useEffect(() => { if (currentPage !== 1) setCurrentPage(1); }, [statusFilter, priorityFilter, companyFilter, issueIdFilter]);
useEffect(() => { setSelectedRows(new Set()); }, [statusFilter, priorityFilter, companyFilter, issueIdFilter, currentPage]);
useEffect(() => { resetPage(); /* eslint-disable-next-line react-hooks/exhaustive-deps */ }, [statusFilter, priorityFilter, companyFilter, issueIdFilter, sortBy, dateFilterBy, dateStart, dateEnd]);
useEffect(() => { setSelectedRows(new Set()); }, [statusFilter, priorityFilter, companyFilter, issueIdFilter, page]);
const totalPages = Math.ceil(totalCount / pageSize);
const hasMore = (page + 1) * pageSize < totalCount;
const formatDate = (dateStr: string) => dateStr ? new Date(dateStr).toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' }) : '-';
const clearFilters = () => { setStatusFilter(''); setPriorityFilter(''); setCompanyFilter(''); setIssueIdFilter(''); setCurrentPage(1); };
const clearFilters = () => { setStatusFilter(''); setPriorityFilter(''); setCompanyFilter(''); setIssueIdFilter(''); resetPage(); };
const hasActiveFilters = statusFilter || priorityFilter || companyFilter || issueIdFilter;
const handleSaveFilterPreset = () => {
@ -328,7 +351,7 @@ const IssueList: React.FC = () => {
try {
const response = await fetch('/api/method/frappe.client.get_list', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ doctype: 'Issue', filters: apiFilters, fields: ['*'], limit_start: currentPageNum * pageSizeNum, limit_page_length: pageSizeNum, order_by: 'creation desc' })
body: JSON.stringify({ doctype: 'Issue', filters: apiFilters, fields: ['*'], limit_start: currentPageNum * pageSizeNum, limit_page_length: pageSizeNum, order_by: sortBy })
});
const data = await response.json();
const results = data.message || [];
@ -494,22 +517,32 @@ const IssueList: React.FC = () => {
)}
<div className="bg-gray-50 dark:bg-gray-900/50 p-3 rounded-lg">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-3">
<ListFilterSortControls
sortBy={sortBy}
dateFilterBy={dateFilterBy}
dateStart={dateStart}
dateEnd={dateEnd}
onSortByChange={setSortBy}
onDateFilterByChange={setDateFilterBy}
onDateStartChange={setDateStart}
onDateEndChange={setDateEnd}
/>
<div className="relative z-[60]">
<LinkField label="Issue" doctype="Issue" value={issueIdFilter} onChange={(val) => { setIssueIdFilter(val); setCurrentPage(1); }} placeholder="Select Issue" disabled={false} compact={true} />
<LinkField label="Issue" doctype="Issue" value={issueIdFilter} onChange={(val) => { setIssueIdFilter(val); resetPage(); }} placeholder="Select Issue" disabled={false} compact={true} />
{issueIdFilter && <button onClick={() => setIssueIdFilter('')} className="absolute right-2 top-6 text-gray-400 hover:text-red-500 transition-colors z-10"><FaTimes size={10} /></button>}
</div>
<div className="relative">
<label className="block text-[10px] font-medium text-gray-700 dark:text-gray-300 mb-0.5">Status</label>
<select value={statusFilter} onChange={(e) => { setStatusFilter(e.target.value); setCurrentPage(1); }} className="w-full px-2 py-1 text-xs border border-gray-300 dark:border-gray-600 rounded focus:outline-none focus:ring-2 focus:ring-blue-500 bg-white dark:bg-gray-700 text-gray-900 dark:text-white">
<select value={statusFilter} onChange={(e) => { setStatusFilter(e.target.value); resetPage(); }} className="w-full px-2 py-1 text-xs border border-gray-300 dark:border-gray-600 rounded focus:outline-none focus:ring-2 focus:ring-blue-500 bg-white dark:bg-gray-700 text-gray-900 dark:text-white">
<option value="">All Statuses</option><option value="Open">Open</option><option value="Replied">Replied</option><option value="On Hold">On Hold</option><option value="Resolved">Resolved</option><option value="Closed">Closed</option>
</select>
</div>
<div className="relative z-[59]">
<LinkField label={t('commonFields.priority')} doctype="Issue Priority" value={priorityFilter} onChange={(val) => { setPriorityFilter(val); setCurrentPage(1); }} placeholder={t('issues.allPriorities')} disabled={false} compact={true} />
<LinkField label={t('commonFields.priority')} doctype="Issue Priority" value={priorityFilter} onChange={(val) => { setPriorityFilter(val); resetPage(); }} placeholder={t('issues.allPriorities')} disabled={false} compact={true} />
{priorityFilter && <button onClick={() => setPriorityFilter('')} className="absolute right-2 top-6 text-gray-400 hover:text-red-500 transition-colors z-10"><FaTimes size={10} /></button>}
</div>
<div className="relative z-[58]">
<LinkField label={t('commonFields.company')} doctype="Company" value={companyFilter} onChange={(val) => { setCompanyFilter(val); setCurrentPage(1); }} placeholder={t('issues.allCompanies')} disabled={false} compact={true} />
<LinkField label={t('commonFields.company')} doctype="Company" value={companyFilter} onChange={(val) => { setCompanyFilter(val); resetPage(); }} placeholder={t('issues.allCompanies')} disabled={false} compact={true} />
{companyFilter && <button onClick={() => setCompanyFilter('')} className="absolute right-2 top-6 text-gray-400 hover:text-red-500 transition-colors z-10"><FaTimes size={10} /></button>}
</div>
</div>
@ -593,16 +626,14 @@ const IssueList: React.FC = () => {
</table>
</div>
{totalPages > 1 && (
<div className="px-4 py-3 border-t border-gray-200 dark:border-gray-700 flex items-center justify-between">
<div className="text-sm text-gray-500 dark:text-gray-400">Showing {((currentPage - 1) * pageSize) + 1} to {Math.min(currentPage * pageSize, totalCount)} of {totalCount} issues</div>
<div className="flex items-center gap-2">
<button onClick={() => setCurrentPage(p => Math.max(1, p - 1))} disabled={currentPage === 1} className="p-2 rounded-lg border border-gray-300 dark:border-gray-600 disabled:opacity-50 disabled:cursor-not-allowed hover:bg-gray-50 dark:hover:bg-gray-700"><FaChevronLeft /></button>
<span className="px-3 py-1 text-sm text-gray-700 dark:text-gray-300">Page {currentPage} of {totalPages}</span>
<button onClick={() => setCurrentPage(p => Math.min(totalPages, p + 1))} disabled={currentPage === totalPages} className="p-2 rounded-lg border border-gray-300 dark:border-gray-600 disabled:opacity-50 disabled:cursor-not-allowed hover:bg-gray-50 dark:hover:bg-gray-700"><FaChevronRight /></button>
</div>
</div>
)}
<ListPagination
currentPage={page + 1}
totalCount={totalCount}
pageSize={pageSize}
hasMore={hasMore}
itemLabel={t('pagination.issues')}
onPageChange={(p) => setPage(Math.max(0, p - 1))}
/>
</div>
{/* Delete Confirmation Modal */}

View File

@ -5,6 +5,9 @@ import { FaArrowLeft, FaSave, FaEdit, FaCheck, FaTrashAlt, FaSync } from 'react-
import type { CreateItemData } from '../services/itemService';
import LinkField from '../components/LinkField';
import API_CONFIG from '../config/api';
import useDefaultHospital from '../hooks/useDefaultHospital';
import CommentSection from '../components/CommentSection';
import ActivityLog from '../components/ActivityLog';
const ItemDetail: React.FC = () => {
const { itemName } = useParams<{ itemName: string }>();
@ -53,6 +56,11 @@ const ItemDetail: React.FC = () => {
const [isEditing, setIsEditing] = useState(isNewItem);
useDefaultHospital(setFormData, {
enabled: isNewItem && !isDuplicating,
fields: ['custom_hospital_name'],
});
// Check document status
const docstatus = item?.docstatus ?? 0;
const isSubmitted = docstatus === 1;
@ -673,6 +681,26 @@ const ItemDetail: React.FC = () => {
</div> */}
</div>
</div>
{!isNewItem && !isDuplicating && (
<div className="mt-6 space-y-6">
<CommentSection
referenceDoctype="Item"
referenceName={itemName || null}
title="Comments & Discussion"
pollInterval={30000}
initialLimit={5}
/>
<ActivityLog
doctype="Item"
docname={itemName || null}
creationDate={item?.creation}
createdBy={item?.owner}
initialVisible={5}
startCollapsed={true}
/>
</div>
)}
</div>
);
};

View File

@ -5,6 +5,11 @@ import { useItems, useItemMutations } from '../hooks/useItem';
import * as XLSX from 'xlsx';
import { FaPlus, FaEdit, FaEye, FaTrash, FaCopy, FaFileExport, FaBox, FaTimes, FaFilter, FaChevronDown, FaChevronUp, FaSave, FaStar, FaCheckSquare, FaSquare, FaFileExcel, FaFileCsv, FaDownload } from 'react-icons/fa';
import LinkField from '../components/LinkField';
import { useUserPermissions } from '../hooks/useUserPermissions';
import ListPagination from '../components/ListPagination';
import ListFilterSortControls from '../components/ListFilterSortControls';
import { useListSortFilters } from '../hooks/useListSortFilters';
import { applyDateRangeToFilters } from '../utils/listFilterUtils';
// Export types
type ExportFormat = 'csv' | 'excel';
@ -312,7 +317,19 @@ const ExportModal: React.FC<ExportModalProps> = ({
const ItemList: React.FC = () => {
const { t } = useTranslation();
const navigate = useNavigate();
const [page, setPage] = useState(0);
const {
sortBy,
dateFilterBy,
dateStart,
dateEnd,
page,
setSortBy,
setDateFilterBy,
setDateStart,
setDateEnd,
setPage,
resetPage,
} = useListSortFilters('creation desc');
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState<string | null>(null);
const limit = 20;
@ -347,6 +364,13 @@ const ItemList: React.FC = () => {
const [showExportModal, setShowExportModal] = useState(false);
const [isExporting, setIsExporting] = useState(false);
const {
permissionFilters,
loading: permissionsLoading,
error: permissionsError,
hasAnyRestrictions,
} = useUserPermissions('Item');
// Filter states
const [filterItemId, setFilterItemId] = useState('');
const [filterItemGroup, setFilterItemGroup] = useState('');
@ -354,7 +378,7 @@ const ItemList: React.FC = () => {
const [filterItemName, setFilterItemName] = useState('');
// Advanced filter UI states
const [isFilterExpanded, setIsFilterExpanded] = useState(true);
const [isFilterExpanded, setIsFilterExpanded] = useState(false);
const [activeFilterCount, setActiveFilterCount] = useState(0);
const [savedFilters, setSavedFilters] = useState<any[]>([]);
const [showSaveFilterModal, setShowSaveFilterModal] = useState(false);
@ -385,35 +409,39 @@ const ItemList: React.FC = () => {
setActiveFilterCount(count);
}, [filterItemId, filterItemGroup, filterCompany, filterItemName]);
// Build filters object
const filters: Record<string, any> = {};
if (filterItemId) filters['name'] = filterItemId;
if (filterItemGroup) filters['item_group'] = filterItemGroup;
if (filterCompany) filters['custom_hospital_name'] = filterCompany;
if (filterItemName) filters['item_name'] = ['like', `%${filterItemName}%`];
const filters: Record<string, any> = applyDateRangeToFilters(
{
...(filterItemId ? { name: filterItemId } : {}),
...(filterItemGroup ? { item_group: filterItemGroup } : {}),
...(filterCompany ? { custom_hospital_name: filterCompany } : {}),
...(filterItemName ? { item_name: ['like', `%${filterItemName}%`] } : {}),
},
dateFilterBy,
dateStart,
dateEnd
);
const { items, totalCount, hasMore, loading, error, refetch } = useItems(
Object.keys(filters).length > 0 ? filters : undefined,
limit,
page * limit,
'creation desc'
sortBy,
permissionFilters
);
const { deleteItem, loading: mutationLoading } = useItemMutations();
// Mark initial load complete
useEffect(() => {
if (!loading && !initialLoadComplete) {
if (!loading && !initialLoadComplete && !permissionsLoading) {
setInitialLoadComplete(true);
}
}, [loading, initialLoadComplete]);
}, [loading, initialLoadComplete, permissionsLoading]);
// Reset page when filters change
useEffect(() => {
if (page !== 0) {
setPage(0);
}
}, [filterItemId, filterItemGroup, filterCompany, filterItemName]);
resetPage();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [filterItemId, filterItemGroup, filterCompany, filterItemName, sortBy, dateFilterBy, dateStart, dateEnd]);
// Clear selection when filters/page change
useEffect(() => {
@ -553,7 +581,7 @@ const ItemList: React.FC = () => {
},
body: JSON.stringify({
doctype: 'Item',
filters: filters,
filters: { ...filters, ...permissionFilters },
fields: ['*'],
limit_start: currentPage * pageSize,
limit_page_length: pageSize,
@ -583,7 +611,7 @@ const ItemList: React.FC = () => {
}
return allItems;
}, [filters]);
}, [filters, permissionFilters]);
// Export handler
const handleExport = async (scope: ExportScope, format: ExportFormat, columns: string[]) => {
@ -924,6 +952,16 @@ const ItemList: React.FC = () => {
<div className="space-y-5">
<div className="bg-gray-50 dark:bg-gray-900/50 p-3 rounded-lg">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-3">
<ListFilterSortControls
sortBy={sortBy}
dateFilterBy={dateFilterBy}
dateStart={dateStart}
dateEnd={dateEnd}
onSortByChange={setSortBy}
onDateFilterByChange={setDateFilterBy}
onDateStartChange={setDateStart}
onDateEndChange={setDateEnd}
/>
{/* Item ID */}
<div className="relative z-[60]">
<LinkField
@ -1249,30 +1287,14 @@ const ItemList: React.FC = () => {
</table>
</div>
{/* Pagination */}
{(hasMore || page > 0) && (
<div className="px-6 py-4 border-t border-gray-200 dark:border-gray-700 flex items-center justify-between">
<div className="text-sm text-gray-700 dark:text-gray-300">
Showing {page * limit + 1} to {Math.min((page + 1) * limit, totalCount)} of {totalCount} results
</div>
<div className="flex gap-2">
<button
onClick={() => setPage(Math.max(0, page - 1))}
disabled={page === 0}
className="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-600 disabled:opacity-50 disabled:cursor-not-allowed"
>
Previous
</button>
<button
onClick={() => setPage(page + 1)}
disabled={!hasMore}
className="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-600 disabled:opacity-50 disabled:cursor-not-allowed"
>
Next
</button>
</div>
</div>
)}
<ListPagination
currentPage={page + 1}
totalCount={totalCount}
pageSize={limit}
hasMore={hasMore}
itemLabel={t('pagination.items')}
onPageChange={(p) => setPage(Math.max(0, p - 1))}
/>
</div>
{/* Delete Confirmation Modal */}

View File

@ -3,6 +3,7 @@ import { useNavigate, useLocation } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { loadFrappeTranslations } from '../i18n';
import apiService, { ApiError } from '../services/apiService';
import { persistUserProfileAndGetRoute, resolveAuthenticatedRoute } from '../utils/appRouting';
const SESSION_STORAGE_FLAG_KEY = 'asm_show_after_password_reset';
@ -112,15 +113,35 @@ const Login: React.FC = () => {
const params = new URLSearchParams(location.search);
if (params.get('manual_login') === '1') return;
const user = localStorage.getItem('user');
if (user) {
navigate('/dashboard', { replace: true });
const storedUser = localStorage.getItem('user');
if (!storedUser) {
loadFrappeTranslations().catch(() => {
// static translations only before login
});
return;
}
loadFrappeTranslations().catch(() => {
// static translations only before login
});
let cancelled = false;
const verifySessionAndRedirect = async () => {
const route = await resolveAuthenticatedRoute();
if (cancelled) return;
if (route) {
navigate(route, { replace: true });
return;
}
loadFrappeTranslations().catch(() => {
// static translations only before login
});
};
verifySessionAndRedirect();
return () => {
cancelled = true;
};
}, [location.pathname, location.search, navigate, pwdResetBusy]);
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
@ -157,7 +178,8 @@ const Login: React.FC = () => {
console.warn('Could not load translations after login:', err);
}
navigate('/dashboard');
const route = await persistUserProfileAndGetRoute();
navigate(route);
} else {
setError(t('login.loginFailed'));
}

View File

@ -3,25 +3,28 @@ import { useNavigate, useSearchParams } from 'react-router-dom';
import { FaFilter, FaChevronDown, FaChevronUp, FaTimes, FaCalendarAlt, FaMap } from 'react-icons/fa';
import MaintenanceCalendar from '../components/MaintenanceCalendar';
import LinkField from '../components/LinkField';
import useUserHospitalFilter from '../hooks/useUserHospitalFilter';
const MaintenanceCalendarPage: React.FC = () => {
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const { filterHospital, filtersLocked, handleHospitalChange } = useUserHospitalFilter();
// Filter states
const [filterCompany, setFilterCompany] = useState('');
const [filterDepartment, setFilterDepartment] = useState('');
const [filterStatus, setFilterStatus] = useState('');
const [filterAssignTo, setFilterAssignTo] = useState('');
// Load filters from URL on mount
// Load filters from URL on mount (only when hospital is not locked to user profile)
useEffect(() => {
if (filtersLocked) return;
const hospital = searchParams.get('hospital');
const status = searchParams.get('status');
if (hospital) setFilterCompany(hospital);
if (hospital) handleHospitalChange(hospital);
if (status) setFilterStatus(status);
}, [searchParams]);
}, [searchParams, filtersLocked, handleHospitalChange]);
// View type states
const [viewType, setViewType] = useState<'maintenance-log' | 'ppm-planner'>('maintenance-log');
@ -32,28 +35,30 @@ const MaintenanceCalendarPage: React.FC = () => {
// Update active filter count
useEffect(() => {
const count = [filterCompany, filterDepartment, filterStatus, filterAssignTo].filter(Boolean).length;
const count = [filterHospital, filterDepartment, filterStatus, filterAssignTo].filter(Boolean).length;
setActiveFilterCount(count);
}, [filterCompany, filterDepartment, filterStatus, filterAssignTo]);
}, [filterHospital, filterDepartment, filterStatus, filterAssignTo]);
const handleClearFilters = () => {
setFilterCompany('');
if (!filtersLocked) {
handleHospitalChange('');
}
setFilterDepartment('');
setFilterStatus('');
setFilterAssignTo('');
};
const hasActiveFilters = filterCompany || filterDepartment || filterStatus || filterAssignTo;
const hasActiveFilters = filterHospital || filterDepartment || filterStatus || filterAssignTo;
// Build filters for calendar - memoized to prevent object reference changes
const calendarFilters: Record<string, any> = useMemo(() => {
const filters: Record<string, any> = {};
if (filterCompany) filters['company'] = filterCompany;
if (filterHospital) filters['company'] = filterHospital;
if (filterDepartment) filters['department'] = filterDepartment;
if (filterStatus) filters['maintenance_status'] = filterStatus;
if (filterAssignTo) filters['assign_to_name'] = filterAssignTo;
return filters;
}, [filterCompany, filterDepartment, filterStatus, filterAssignTo]);
}, [filterHospital, filterDepartment, filterStatus, filterAssignTo]);
return (
<div className="flex flex-col h-screen bg-gray-50 dark:bg-gray-900 overflow-hidden">
@ -140,14 +145,15 @@ const MaintenanceCalendarPage: React.FC = () => {
<LinkField
label="Hospital"
doctype="Company"
value={filterCompany}
onChange={(val) => setFilterCompany(val)}
value={filterHospital}
onChange={handleHospitalChange}
placeholder="Select Hospital"
compact={true}
disabled={filtersLocked}
/>
{filterCompany && (
{filterHospital && !filtersLocked && (
<button
onClick={() => setFilterCompany('')}
onClick={() => handleHospitalChange('')}
className="absolute right-2 top-6 text-gray-400 hover:text-red-500 transition-colors z-10"
>
<FaTimes size={10} />

View File

@ -20,7 +20,10 @@ import {
import { toast, ToastContainer, Bounce } from 'react-toastify';
import 'react-toastify/dist/ReactToastify.css';
import LinkField from '../components/LinkField';
import CommentSection from '../components/CommentSection';
import ActivityLog from '../components/ActivityLog';
import type { CreateMaintenanceTeamData, MaintenanceTeamMember } from '../services/maintenanceTeamService';
import useDefaultHospital from '../hooks/useDefaultHospital';
const MaintenanceTeamDetail: React.FC = () => {
const { t } = useTranslation();
@ -47,6 +50,11 @@ const MaintenanceTeamDetail: React.FC = () => {
const [isEditing, setIsEditing] = useState(isNewTeam);
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
useDefaultHospital(setFormData, {
enabled: isNewTeam && !duplicateFrom,
fields: ['company'],
});
const [showDeleteMemberConfirm, setShowDeleteMemberConfirm] = useState<number | null>(null);
const [checkingMember, setCheckingMember] = useState<number | null>(null); // Track which row is being checked
@ -570,29 +578,23 @@ const MaintenanceTeamDetail: React.FC = () => {
</div>
</div>
{/* Timeline Card */}
{!isNewTeam && team && (
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-md p-6 border border-gray-200 dark:border-gray-700">
<h2 className="text-lg font-semibold text-gray-800 dark:text-white mb-4 pb-2 border-b border-gray-200 dark:border-gray-700 flex items-center gap-2">
<FaBuilding className="text-teal-500" />Details
</h2>
<div className="space-y-4">
<div className="p-4 bg-gray-50 dark:bg-gray-700 rounded-lg">
<p className="text-xs text-gray-500 dark:text-gray-400 mb-1">Created</p>
<p className="text-sm text-gray-900 dark:text-white">{formatDateTime(team.creation)}</p>
</div>
<div className="p-4 bg-gray-50 dark:bg-gray-700 rounded-lg">
<p className="text-xs text-gray-500 dark:text-gray-400 mb-1">Last Modified</p>
<p className="text-sm text-gray-900 dark:text-white">{formatDateTime(team.modified)}</p>
</div>
<div className="p-4 bg-gray-50 dark:bg-gray-700 rounded-lg">
<p className="text-xs text-gray-500 dark:text-gray-400 mb-1">Modified By</p>
<p className="text-sm text-gray-900 dark:text-white">{team.modified_by || '-'}</p>
</div>
</div>
{!isNewTeam && team?.name && (
<div className="space-y-6">
<CommentSection
referenceDoctype="Asset Maintenance Team"
referenceName={team.name}
title="Comments & Discussion"
pollInterval={30000}
initialLimit={5}
/>
<ActivityLog
doctype="Asset Maintenance Team"
docname={team.name}
creationDate={team.creation}
createdBy={team.owner}
initialVisible={5}
startCollapsed={true}
/>
</div>
)}
</div>

View File

@ -2,6 +2,10 @@ import React, { useState, useMemo, useEffect, useCallback } from 'react';
import { useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { useMaintenanceTeamList } from '../hooks/useMaintenanceTeam';
import ListPagination from '../components/ListPagination';
import ListFilterSortControls from '../components/ListFilterSortControls';
import { useListSortFilters } from '../hooks/useListSortFilters';
import { applyDateRangeToFilters } from '../utils/listFilterUtils';
import * as XLSX from 'xlsx';
import {
FaPlus,
@ -176,8 +180,20 @@ const MaintenanceTeamList: React.FC = () => {
{ key: 'modified', label: t('commonFields.modifiedOn'), default: false },
];
const [currentPage, setCurrentPage] = useState(1);
const [pageSize] = useState(20);
const {
sortBy,
dateFilterBy,
dateStart,
dateEnd,
page,
setSortBy,
setDateFilterBy,
setDateStart,
setDateEnd,
setPage,
resetPage,
} = useListSortFilters('creation desc');
const pageSize = 20;
const [initialLoadComplete, setInitialLoadComplete] = useState(false);
const [selectedRows, setSelectedRows] = useState<Set<string>>(new Set());
const [showExportModal, setShowExportModal] = useState(false);
@ -203,28 +219,35 @@ const MaintenanceTeamList: React.FC = () => {
setActiveFilterCount(count);
}, [companyFilter, teamNameFilter]);
const apiFilters = useMemo(() => {
const filters: Record<string, any> = {};
if (companyFilter) filters['company'] = companyFilter;
if (teamNameFilter) filters['name'] = teamNameFilter;
return filters;
}, [companyFilter, teamNameFilter]);
const apiFilters = useMemo(
() =>
applyDateRangeToFilters(
{
...(companyFilter ? { company: companyFilter } : {}),
...(teamNameFilter ? { name: teamNameFilter } : {}),
},
dateFilterBy,
dateStart,
dateEnd
),
[companyFilter, teamNameFilter, dateFilterBy, dateStart, dateEnd]
);
const { teams, loading, error, totalCount, refetch } = useMaintenanceTeamList({
filters: apiFilters,
limit_start: (currentPage - 1) * pageSize,
limit_start: page * pageSize,
limit_page_length: pageSize,
order_by: 'creation desc',
order_by: sortBy,
});
useEffect(() => { if (!loading && !initialLoadComplete) setInitialLoadComplete(true); }, [loading, initialLoadComplete]);
useEffect(() => { if (currentPage !== 1) setCurrentPage(1); }, [companyFilter, teamNameFilter]);
useEffect(() => { setSelectedRows(new Set()); }, [companyFilter, teamNameFilter, currentPage]);
useEffect(() => { resetPage(); /* eslint-disable-next-line react-hooks/exhaustive-deps */ }, [companyFilter, teamNameFilter, sortBy, dateFilterBy, dateStart, dateEnd]);
useEffect(() => { setSelectedRows(new Set()); }, [companyFilter, teamNameFilter, page]);
const totalPages = Math.ceil(totalCount / pageSize);
const hasMore = (page + 1) * pageSize < totalCount;
const formatDate = (dateStr?: string) => dateStr ? new Date(dateStr).toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' }) : '-';
const clearFilters = () => { setCompanyFilter(''); setTeamNameFilter(''); setCurrentPage(1); };
const clearFilters = () => { setCompanyFilter(''); setTeamNameFilter(''); resetPage(); };
const hasActiveFilters = companyFilter || teamNameFilter;
const handleSaveFilterPreset = () => {
@ -266,7 +289,7 @@ const MaintenanceTeamList: React.FC = () => {
try {
const response = await fetch('/api/method/frappe.client.get_list', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ doctype: 'Asset Maintenance Team', filters: apiFilters, fields: ['*'], limit_start: currentPageNum * pageSizeNum, limit_page_length: pageSizeNum, order_by: 'creation desc' })
body: JSON.stringify({ doctype: 'Asset Maintenance Team', filters: apiFilters, fields: ['*'], limit_start: currentPageNum * pageSizeNum, limit_page_length: pageSizeNum, order_by: sortBy })
});
const data = await response.json();
const results = data.message || [];
@ -426,13 +449,23 @@ const MaintenanceTeamList: React.FC = () => {
</div>
)}
<div className="bg-gray-50 dark:bg-gray-900/50 p-3 rounded-lg">
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-3">
<ListFilterSortControls
sortBy={sortBy}
dateFilterBy={dateFilterBy}
dateStart={dateStart}
dateEnd={dateEnd}
onSortByChange={setSortBy}
onDateFilterByChange={setDateFilterBy}
onDateStartChange={setDateStart}
onDateEndChange={setDateEnd}
/>
<div className="relative z-[60]">
<LinkField label="Hospital" doctype="Company" value={companyFilter} onChange={(val) => { setCompanyFilter(val); setCurrentPage(1); }} placeholder="Select Hospital" disabled={false} compact={true} />
<LinkField label="Hospital" doctype="Company" value={companyFilter} onChange={(val) => { setCompanyFilter(val); resetPage(); }} placeholder="Select Hospital" disabled={false} compact={true} />
{companyFilter && <button onClick={() => setCompanyFilter('')} className="absolute right-2 top-6 text-gray-400 hover:text-red-500 transition-colors z-10"><FaTimes size={10} /></button>}
</div>
<div className="relative z-[59]">
<LinkField label={t('maintenance.teamName')} doctype="Asset Maintenance Team" value={teamNameFilter} onChange={(val) => { setTeamNameFilter(val); setCurrentPage(1); }} placeholder={t('maintenance.selectTeam')} disabled={false} compact={true} />
<LinkField label={t('maintenance.teamName')} doctype="Asset Maintenance Team" value={teamNameFilter} onChange={(val) => { setTeamNameFilter(val); resetPage(); }} placeholder={t('maintenance.selectTeam')} disabled={false} compact={true} />
{teamNameFilter && <button onClick={() => setTeamNameFilter('')} className="absolute right-2 top-6 text-gray-400 hover:text-red-500 transition-colors z-10"><FaTimes size={10} /></button>}
</div>
</div>
@ -525,16 +558,14 @@ const MaintenanceTeamList: React.FC = () => {
</table>
</div>
{totalPages > 1 && (
<div className="px-4 py-3 border-t border-gray-200 dark:border-gray-700 flex items-center justify-between">
<div className="text-sm text-gray-500 dark:text-gray-400">Showing {((currentPage - 1) * pageSize) + 1} to {Math.min(currentPage * pageSize, totalCount)} of {totalCount} teams</div>
<div className="flex items-center gap-2">
<button onClick={() => setCurrentPage(p => Math.max(1, p - 1))} disabled={currentPage === 1} className="p-2 rounded-lg border border-gray-300 dark:border-gray-600 disabled:opacity-50 disabled:cursor-not-allowed hover:bg-gray-50 dark:hover:bg-gray-700"><FaChevronLeft /></button>
<span className="px-3 py-1 text-sm text-gray-700 dark:text-gray-300">Page {currentPage} of {totalPages}</span>
<button onClick={() => setCurrentPage(p => Math.min(totalPages, p + 1))} disabled={currentPage === totalPages} className="p-2 rounded-lg border border-gray-300 dark:border-gray-600 disabled:opacity-50 disabled:cursor-not-allowed hover:bg-gray-50 dark:hover:bg-gray-700"><FaChevronRight /></button>
</div>
</div>
)}
<ListPagination
currentPage={page + 1}
totalCount={totalCount}
pageSize={pageSize}
hasMore={hasMore}
itemLabel={t('pagination.maintenanceTeams')}
onPageChange={(p) => setPage(Math.max(0, p - 1))}
/>
</div>
{/* Delete Confirmation Modal */}

File diff suppressed because it is too large Load Diff

View File

@ -3,6 +3,7 @@ import { useParams, useNavigate, useSearchParams } from 'react-router-dom';
import { usePPMDetails, usePPMMutations } from '../hooks/usePPM';
import { FaArrowLeft, FaSave, FaEdit, FaTools } from 'react-icons/fa';
import type { CreatePPMData } from '../services/ppmService';
import useDefaultHospital from '../hooks/useDefaultHospital';
const PPMDetail: React.FC = () => {
const { ppmName } = useParams<{ ppmName: string }>();
@ -30,6 +31,11 @@ const PPMDetail: React.FC = () => {
custom_price_per_pm: 0,
});
useDefaultHospital(setFormData, {
enabled: isNewPPM && !isDuplicating,
fields: ['company'],
});
useEffect(() => {
if (ppm) {
setFormData({

View File

@ -5,6 +5,7 @@ import { usePMScheduleDetails, usePMScheduleMutations } from '../hooks/usePMSche
import { useAssetMaintenanceLogs } from '../hooks/useAssetMaintenance';
import { FaArrowLeft, FaSave, FaEdit, FaCalendarAlt, FaTrash, FaSpinner, FaCheckCircle, FaHistory, FaUser, FaClock, FaClipboardCheck, FaThumbsUp } from 'react-icons/fa';
import LinkField from '../components/LinkField';
import useDefaultHospital from '../hooks/useDefaultHospital';
// Activity Log interface
interface ActivityLog {
@ -77,6 +78,11 @@ const PPMPlannerDetail: React.FC = () => {
no_of_pms: ''
});
useDefaultHospital(setFormData, {
enabled: !!pmSchedule && !pmSchedule.hospital,
fields: ['hospital'],
});
// Fetch current user on mount
useEffect(() => {
const fetchCurrentUser = async () => {

View File

@ -8,6 +8,7 @@ import {
} from 'react-icons/fa';
import * as XLSX from 'xlsx';
import { usePMSchedules, usePMScheduleMutations } from '../hooks/usePMSchedule';
import useUserHospitalFilter from '../hooks/useUserHospitalFilter';
import LinkField from '../components/LinkField';
// ==================== TYPES ====================
@ -565,7 +566,7 @@ const PPMPlannerList: React.FC = () => {
// Filters
const [isFilterExpanded, setIsFilterExpanded] = useState(false);
const [filterHospital, setFilterHospital] = useState('');
const { filterHospital, filtersLocked, handleHospitalChange } = useUserHospitalFilter();
const [filterModality, setFilterModality] = useState('');
const [filterManufacturer, setFilterManufacturer] = useState('');
const [filterPeriodicity, setFilterPeriodicity] = useState('');
@ -882,7 +883,9 @@ const PPMPlannerList: React.FC = () => {
};
const handleClearFilters = () => {
setFilterHospital('');
if (!filtersLocked) {
handleHospitalChange('');
}
setFilterModality('');
setFilterManufacturer('');
setFilterPeriodicity('');
@ -1004,9 +1007,10 @@ const PPMPlannerList: React.FC = () => {
label={t('commonFields.hospital')}
doctype="Company"
value={filterHospital}
onChange={setFilterHospital}
onChange={handleHospitalChange}
placeholder={t('filters.allHospitals')}
filters={{ domain: "Healthcare" }}
disabled={filtersLocked}
/>
</div>
<div>

View File

@ -14,11 +14,13 @@ import {
FaExclamationTriangle,
FaUserTag,
FaIdBadge,
FaShieldAlt
FaShieldAlt,
FaBuilding
} from 'react-icons/fa';
import { toast, ToastContainer, Bounce } from 'react-toastify';
import 'react-toastify/dist/ReactToastify.css';
import LinkField from '../components/LinkField';
import apiService from '../services/apiService';
import {
fetchCurrentUserProfile,
updateUserProfile,
@ -64,16 +66,23 @@ const UserProfilePage: React.FC = () => {
try {
setLoading(true);
// NEW: Check if user is System Manager
// Check if user is System Manager
const csrfToken = await apiService.getCSRFToken();
const roleHeaders: Record<string, string> = {
'Content-Type': 'application/json',
Accept: 'application/json',
};
if (csrfToken) {
roleHeaders['X-Frappe-CSRF-Token'] = csrfToken;
}
const roleResponse = await fetch('/api/method/asset_lite.api.user_roles.check_has_role', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
headers: roleHeaders,
credentials: 'include',
body: JSON.stringify({
roles: 'System Manager'
})
roles: 'System Manager',
}),
});
const roleData = await roleResponse.json();
const isSysManager = roleData.message?.has_role || false;
@ -527,6 +536,42 @@ const UserProfilePage: React.FC = () => {
</>
)}
</div>
{/* Site Name (Read-only) */}
<div>
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">
Site Name
<span className="ml-1 text-gray-400">(Read-only)</span>
</label>
<div className="relative">
<FaBuilding className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400" size={14} />
<input
type="text"
value={profile?.custom_site_name || '-'}
readOnly
disabled
className="w-full pl-10 pr-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg bg-gray-100 dark:bg-gray-700 text-gray-500 dark:text-gray-400 cursor-not-allowed"
/>
</div>
</div>
{/* PHCC Site Name (Read-only) */}
<div>
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">
PHCC Site Name
<span className="ml-1 text-gray-400">(Read-only)</span>
</label>
<div className="relative">
<FaBuilding className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400" size={14} />
<input
type="text"
value={profile?.custom_phcc_site_name || '-'}
readOnly
disabled
className="w-full pl-10 pr-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg bg-gray-100 dark:bg-gray-700 text-gray-500 dark:text-gray-400 cursor-not-allowed"
/>
</div>
</div>
</div>
</div>

View File

@ -0,0 +1,46 @@
import React from 'react';
import { useSearchParams } from 'react-router-dom';
import WoFeedbackForm from '../components/WoFeedbackForm';
const WoFeedbackPage: React.FC = () => {
const [searchParams] = useSearchParams();
const workOrder = (searchParams.get('work_order') || '').trim();
const baseUrl = import.meta.env.BASE_URL || '/assets/asm_ui_app/';
const logoUrl = `${baseUrl}${baseUrl.endsWith('/') ? '' : '/'}seera-logo.png`;
return (
<div className="min-h-screen bg-gradient-to-br from-slate-50 via-white to-indigo-50 dark:from-gray-900 dark:via-gray-900 dark:to-indigo-950 flex flex-col items-center justify-center p-4">
<div className="w-full max-w-md">
<div className="flex justify-center mb-6">
<img src={logoUrl} alt="Logo" className="h-10 object-contain" onError={e => { (e.target as HTMLImageElement).style.display = 'none'; }} />
</div>
<div className="bg-white dark:bg-gray-800 rounded-2xl shadow-xl border border-gray-200 dark:border-gray-700 p-6 md:p-8">
{!workOrder ? (
<div className="text-center py-6">
<h1 className="text-lg font-bold text-gray-900 dark:text-white mb-2">Work Order Feedback</h1>
<p className="text-sm text-gray-600 dark:text-gray-400">
A work order link is required. Please use the link shared with you, for example:
</p>
<p className="mt-3 text-xs font-mono text-indigo-600 dark:text-indigo-400 break-all">
/asm_app/wo-feedback?work_order=WO-00001
</p>
</div>
) : (
<>
<h1 className="text-lg font-bold text-gray-900 dark:text-white mb-1 text-center">
Work Order Feedback
</h1>
{/* <p className="text-xs text-gray-500 dark:text-gray-400 text-center mb-6">
No login required
</p> */}
<WoFeedbackForm workOrder={workOrder} />
</>
)}
</div>
</div>
</div>
);
};
export default WoFeedbackPage;

View File

@ -12,8 +12,20 @@ import 'react-toastify/dist/ReactToastify.css';
import LinkField from '../components/LinkField';
import WorkflowActions from '../components/WorkflowActions';
import ActivityLog from '../components/ActivityLog';
import CommentSection from '../components/CommentSection';
import WoFeedbackSummary from '../components/WoFeedbackSummary';
import apiService from '../services/apiService';
import API_CONFIG from '../config/api';
import useDefaultHospital from '../hooks/useDefaultHospital';
import { isSiteEnabledHospital, buildMobileTeamSiteFilters } from '../utils/hospitalUtils';
import useUserPhccSiteFilter from '../hooks/useUserPhccSiteFilter';
import {
applyWorkOrderStatusTimestamps,
calculateTotalHoursSpent,
formatDatetimeForApi,
formatDatetimeForInput,
} from '../utils/workOrderTimingUtils';
// Print Format Configuration
const PRINT_FORMAT_NAME = 'Service Report'; // Change this if your print format has a different name
@ -68,8 +80,6 @@ const WorkOrderDetail: React.FC = () => {
}
};
const [isMaintenanceManager, setIsMaintenanceManager] = useState(false);
const { workOrder, loading, error, refetch } = useWorkOrderDetails(
isDuplicating ? duplicateFromWorkOrder : (isNewWorkOrder ? null : workOrderName || null)
);
@ -111,7 +121,7 @@ const WorkOrderDetail: React.FC = () => {
custom_total_amount?: number;
}>({
company: '',
work_order_type: '',
work_order_type: isNewWorkOrder && !isDuplicating ? 'Repair (CM)' : '',
asset: '',
asset_name: '',
description: '',
@ -123,6 +133,8 @@ const WorkOrderDetail: React.FC = () => {
manufacturer: '',
supplier: '',
serial_number: '',
custom_local_id: '',
custom_moh_id: '',
model: '',
custom_site_contractor: '',
custom_subcontractor: '',
@ -154,6 +166,11 @@ const WorkOrderDetail: React.FC = () => {
__islocal: false,
});
useDefaultHospital(setFormData, {
enabled: isNewWorkOrder && !isCreatingFromAsset,
fields: ['company'],
});
// Check if asset type is Non Biomedical
const isNonBiomedical = formData.asset_type === 'Non Biomedical';
@ -196,6 +213,33 @@ const WorkOrderDetail: React.FC = () => {
}
}, [formData.failure_date, formData.custom_priority_, formData.need_procurement, isEditing, calculateDeadlineDate]);
// Auto-set response/completion timestamps and recalculate hours when status changes
useEffect(() => {
if (!isEditing) return;
setFormData(prev => {
const next = applyWorkOrderStatusTimestamps(prev);
if (
next.first_responded_on === prev.first_responded_on &&
next.completion_date === prev.completion_date &&
next.total_hours_spent === prev.total_hours_spent
) {
return prev;
}
return next;
});
}, [formData.repair_status, isEditing]);
// Keep total hours in sync when both timestamps are present
useEffect(() => {
if (!isEditing) return;
const hours = calculateTotalHoursSpent(formData.first_responded_on, formData.completion_date);
setFormData(prev => {
if ((prev.total_hours_spent || 0) === hours) return prev;
return { ...prev, total_hours_spent: hours };
});
}, [formData.first_responded_on, formData.completion_date, isEditing]);
// Clear asset-related fields when asset type changes to Non Biomedical
useEffect(() => {
if (isNonBiomedical && isEditing) {
@ -205,6 +249,8 @@ const WorkOrderDetail: React.FC = () => {
asset: '',
asset_name: '',
serial_number: '',
custom_local_id: '',
custom_moh_id: '',
manufacturer: '',
supplier: '',
model: '',
@ -244,27 +290,33 @@ const WorkOrderDetail: React.FC = () => {
// Document data for workflow condition evaluation
const workOrderDocData = useMemo(() => {
if (isNewWorkOrder) return undefined;
const docSource = isEditing
? { ...(workOrder || {}), ...formData }
: (workOrder || formData);
return {
// Fields used in workflow conditions
asset_type: workOrder?.asset_type || formData.asset_type || '',
site_name: workOrder?.site_name || formData.site_name || '',
need_procurement: workOrder?.need_procurement || formData.need_procurement || 0,
custom_assign_to_contractor: workOrder?.custom_assign_to_contractor || formData.custom_assign_to_contractor || '',
docstatus: workOrder?.docstatus || formData.docstatus || 0,
// Include other fields that might be used in conditions
company: workOrder?.company || formData.company || '',
department: workOrder?.department || formData.department || '',
repair_status: workOrder?.repair_status || formData.repair_status || '',
asset_type: docSource.asset_type || '',
site_name: docSource.site_name || '',
need_procurement: docSource.need_procurement ?? 0,
custom_assign_to_contractor: docSource.custom_assign_to_contractor || '',
docstatus: docSource.docstatus ?? 0,
company: docSource.company || '',
department: docSource.department || '',
repair_status: docSource.repair_status || '',
};
}, [
isNewWorkOrder,
workOrder?.asset_type, workOrder?.site_name, workOrder?.need_procurement,
workOrder?.custom_assign_to_contractor, workOrder?.docstatus, workOrder?.company,
workOrder?.department, workOrder?.repair_status,
formData.asset_type, formData.site_name, formData.need_procurement,
formData.custom_assign_to_contractor, formData.docstatus, formData.company,
formData.department, formData.repair_status
isEditing,
workOrder,
formData.asset_type,
formData.site_name,
formData.need_procurement,
formData.custom_assign_to_contractor,
formData.docstatus,
formData.company,
formData.department,
formData.repair_status,
]);
// Workflow hook - now with docData for condition evaluation
@ -408,6 +460,57 @@ const WorkOrderDetail: React.FC = () => {
// Department filters based on company
const [departmentFilters, setDepartmentFilters] = useState<Record<string, any>>({});
const { userPhccSiteName, siteLocked } = useUserPhccSiteFilter();
const shouldShowSiteNameField = useMemo(
() => isSiteEnabledHospital(formData.company),
[formData.company]
);
const mobileTeamSiteFilters = useMemo(
() => buildMobileTeamSiteFilters(formData.company, userPhccSiteName),
[formData.company, userPhccSiteName]
);
// Auto-set site name from user profile when available
useEffect(() => {
if (!userPhccSiteName) return;
setFormData((prev) => {
if (prev.site_name) return prev;
return { ...prev, site_name: userPhccSiteName };
});
}, [userPhccSiteName]);
const assignedTechnicianFilters = useMemo(() => {
if (!formData.company) {
return {};
}
return {
custom_site_name: formData.company,
role_profile_name: 'Technician',
};
}, [formData.company]);
const mohSupervisorFilters = useMemo(() => {
if (!formData.company) {
return {};
}
return {
custom_site_name: formData.company,
role_profile_name: 'MOH Supervisor',
};
}, [formData.company]);
const teamLeaderFilters = useMemo(() => {
if (!formData.company) {
return {};
}
return {
custom_site_name: formData.company,
role_profile_name: 'Maintenace Manager',
};
}, [formData.company]);
// Update department filters when company changes
useEffect(() => {
@ -439,7 +542,7 @@ const WorkOrderDetail: React.FC = () => {
try {
setIsLoadingAsset(true);
const response = await apiService.apiCall<any>(
`/api/resource/Asset?filters=[["custom_serial_number","=","${serialNumber}"]]&fields=["name","asset_name","company","department","custom_serial_number","custom_asset_type","custom_manufacturer","supplier","custom_site_contractor","custom_subcontractor","custom_model","custom_service_agreement","custom_service_coverage","custom_start_date","custom_end_date","custom_total_amount","custom_site","custom_team_leader","custom_moh_supervisor"]&limit=1`
`/api/resource/Asset?filters=[["custom_serial_number","=","${serialNumber}"]]&fields=["name","asset_name","company","department","custom_serial_number","custom_local_id","custom_moh_id","custom_asset_type","custom_manufacturer","supplier","custom_site_contractor","custom_subcontractor","custom_model","custom_service_agreement","custom_service_coverage","custom_start_date","custom_end_date","custom_total_amount","custom_site","custom_team_leader","custom_moh_supervisor","custom_smeh_engg"]&limit=1`
);
if (response?.data && response.data.length > 0) {
return response.data[0];
@ -470,6 +573,8 @@ const WorkOrderDetail: React.FC = () => {
company: assetData.company || '',
department: assetData.department || '',
serial_number: assetData.custom_serial_number || '',
custom_local_id: assetData.custom_local_id || '',
custom_moh_id: assetData.custom_moh_id || '',
asset_type: assetData.custom_asset_type || '',
manufacturer: assetData.custom_manufacturer || '',
supplier: assetData.supplier || '',
@ -485,6 +590,7 @@ const WorkOrderDetail: React.FC = () => {
custom_total_amount: assetData.custom_total_amount || 0,
custom_assigned_supervisor: assetData.custom_team_leader || '',
custom_moh_supervisor: assetData.custom_moh_supervisor || '',
custom_assign_to_contractor: assetData.custom_smeh_engg || '',
}));
};
@ -502,6 +608,8 @@ const WorkOrderDetail: React.FC = () => {
asset: '',
asset_name: '',
serial_number: '',
custom_local_id: '',
custom_moh_id: '',
asset_type: '',
manufacturer: '',
supplier: '',
@ -515,6 +623,9 @@ const WorkOrderDetail: React.FC = () => {
custom_start_date: '',
custom_end_date: '',
custom_total_amount: 0,
custom_assigned_supervisor: '',
custom_moh_supervisor: '',
custom_assign_to_contractor: '',
}));
}
};
@ -566,6 +677,8 @@ const WorkOrderDetail: React.FC = () => {
manufacturer: searchParams.get('manufacturer') || '',
supplier: searchParams.get('supplier') || '',
serial_number: searchParams.get('serial_number') || '',
custom_local_id: searchParams.get('local_id') || '',
custom_moh_id: searchParams.get('moh_id') || '',
department: searchParams.get('department') || '',
custom_site_contractor: searchParams.get('site_contractor') || '',
custom_subcontractor: searchParams.get('subcontractor') || '',
@ -574,6 +687,7 @@ const WorkOrderDetail: React.FC = () => {
// Supervisor fields from URL params (mapped from Asset)
custom_assigned_supervisor: searchParams.get('team_leader') || '',
custom_moh_supervisor: searchParams.get('moh_supervisor') || '',
custom_assign_to_contractor: searchParams.get('smeh_engg') || '',
};
setFormData(prev => ({
...prev,
@ -602,13 +716,15 @@ const WorkOrderDetail: React.FC = () => {
manufacturer: workOrder.manufacturer || '',
supplier: workOrder.supplier || '',
serial_number: workOrder.serial_number || '',
custom_local_id: workOrder.custom_local_id || '',
custom_moh_id: workOrder.custom_moh_id || '',
model: workOrder.model || '',
custom_site_contractor: workOrder.custom_site_contractor || '',
custom_subcontractor: workOrder.custom_subcontractor || '',
failure_date: formatDateForInput(workOrder.failure_date) || '',
custom_deadline_date: formatDateForInput(workOrder.custom_deadline_date) || '',
first_responded_on: formatDateForInput(workOrder.first_responded_on) || '',
completion_date: formatDateForInput(workOrder.completion_date) || '',
first_responded_on: formatDatetimeForInput(workOrder.first_responded_on) || '',
completion_date: formatDatetimeForInput(workOrder.completion_date) || '',
actions_performed: workOrder.actions_performed || '',
stock_consumption: workOrder.stock_consumption || 0,
stock_items: workOrder.stock_items || [],
@ -666,6 +782,8 @@ const WorkOrderDetail: React.FC = () => {
asset: '',
asset_name: '',
serial_number: '',
custom_local_id: '',
custom_moh_id: '',
manufacturer: '',
supplier: '',
model: '',
@ -686,8 +804,14 @@ const WorkOrderDetail: React.FC = () => {
return;
}
try {
const savePayload = applyWorkOrderStatusTimestamps({
...formData,
first_responded_on: formatDatetimeForApi(formData.first_responded_on),
completion_date: formatDatetimeForApi(formData.completion_date),
});
if (isNewWorkOrder || isDuplicating) {
const newWorkOrder = await createWorkOrder(formData);
const newWorkOrder = await createWorkOrder(savePayload);
const successMessage = isDuplicating
? 'Work order duplicated successfully!'
: isCreatingFromAsset
@ -700,7 +824,7 @@ const WorkOrderDetail: React.FC = () => {
});
navigate(`/work-orders/${newWorkOrder.name}`);
} else if (workOrderName) {
await updateWorkOrder(workOrderName, formData);
await updateWorkOrder(workOrderName, savePayload);
toast.success('Work order updated successfully!', {
position: "top-right",
autoClose: 3000,
@ -743,24 +867,6 @@ const WorkOrderDetail: React.FC = () => {
}
};
// Check Maintenance Manager role - simple version
useEffect(() => {
if (isNewWorkOrder) return;
fetch('/api/method/asset_lite.api.user_roles.check_has_role', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ roles: 'Maintenance Manager' })
})
.then(res => res.json())
.then(data => {
if (data.message?.has_role) {
setIsMaintenanceManager(true);
}
})
.catch(err => console.error('Role check error:', err));
}, [isNewWorkOrder]);
// Function to call assign_supervisor_or_technician API before workflow action
// const callBeforeWorkflowAction = async (action: string): Promise<{ assigned_to: string | null } | null> => {
@ -898,8 +1004,7 @@ const currentState = workOrder?.workflow_state || formData.workflow_state || 'Dr
const canEditBasedOnWorkflow =
isNewWorkOrder ||
(isSystemManager && currentDocstatus === 0) ||
(isMaintenanceManager && currentState === 'Sent to Team Leader') ||
(!workflowLoading && transitions.length > 0);
workflowCanEdit;
return (
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 p-4 sm:p-6 min-w-0 overflow-x-hidden">
@ -1052,13 +1157,23 @@ const canEditBasedOnWorkflow =
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{/* First Row: Hospital, Asset Type, Site Name (only for Non Biomedical) */}
{/* First Row: Hospital, Asset Type, Site Name (Mobile/Dental hospitals) */}
<div>
<LinkField
label="Hospital"
doctype="Company"
value={formData.company || ''}
onChange={(val) => setFormData({ ...formData, company: val, department: '' })}
onChange={(val) => {
setFormData({
...formData,
company: val,
department: '',
site_name: '',
custom_assign_to_contractor: '',
custom_moh_supervisor: '',
custom_assigned_supervisor: '',
});
}}
disabled={!isEditing}
filters={{ domain: 'Healthcare' }}
/>
@ -1074,15 +1189,17 @@ const canEditBasedOnWorkflow =
/>
</div>
{/* Site Name - Only visible for Non Biomedical */}
{(isNonBiomedical || formData.company?.startsWith('Mobile')) && (
{shouldShowSiteNameField && (
<div>
<LinkField
key={formData.company || 'site-name'}
label="Site Name"
doctype="Mobile Team Site"
value={formData.site_name || ''}
onChange={(val) => setFormData({ ...formData, site_name: val })}
disabled={!isEditing}
disabled={!isEditing || !formData.company || siteLocked}
filters={mobileTeamSiteFilters}
placeholder="Select Site"
/>
</div>
)}
@ -1135,6 +1252,36 @@ const canEditBasedOnWorkflow =
</div>
</div>
<div>
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">
Local ID
</label>
<input
type="text"
name="custom_local_id"
value={formData.custom_local_id || ''}
onChange={handleChange}
disabled={!isEditing || isLoadingAsset}
placeholder="Local ID"
className="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:bg-gray-100 dark:disabled:bg-gray-700 bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
/>
</div>
<div>
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">
MOH ID
</label>
<input
type="text"
name="custom_moh_id"
value={formData.custom_moh_id || ''}
onChange={handleChange}
disabled={!isEditing || isLoadingAsset}
placeholder="MOH ID"
className="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:bg-gray-100 dark:disabled:bg-gray-700 bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
/>
</div>
<div>
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">
Asset Name
@ -1445,31 +1592,43 @@ const canEditBasedOnWorkflow =
<div>
<LinkField
key={`moh-supervisor-${formData.company || 'none'}`}
label="MOH Supervisor"
doctype="User"
query="frappe.core.doctype.user.user.user_query"
value={formData.custom_moh_supervisor || ''}
onChange={(val) => setFormData({ ...formData, custom_moh_supervisor: val })}
disabled={!isEditing}
disabled={!isEditing || !formData.company}
filters={mohSupervisorFilters}
placeholder={formData.company ? 'Select MOH Supervisor' : 'Select Hospital first'}
/>
</div>
<div>
<LinkField
key={`team-leader-${formData.company || 'none'}`}
label="Team Leader"
doctype="User"
query="frappe.core.doctype.user.user.user_query"
value={formData.custom_assigned_supervisor || ''}
onChange={(val) => setFormData({ ...formData, custom_assigned_supervisor: val })}
disabled={!isEditing}
disabled={!isEditing || !formData.company}
filters={teamLeaderFilters}
placeholder={formData.company ? 'Select Team Leader' : 'Select Hospital first'}
/>
</div>
<div>
<LinkField
key={`assigned-technician-${formData.company || 'none'}`}
label="Assigned Technician"
doctype="User"
query="frappe.core.doctype.user.user.user_query"
value={formData.custom_assign_to_contractor || ''}
onChange={(val) => setFormData({ ...formData, custom_assign_to_contractor: val })}
disabled={!isEditing}
disabled={!isEditing || !formData.company}
filters={assignedTechnicianFilters}
placeholder={formData.company ? 'Select Technician' : 'Select Hospital first'}
/>
</div>
@ -1523,44 +1682,47 @@ const canEditBasedOnWorkflow =
<div>
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">
Total Hours Spent
<span className="ml-1 text-xs text-gray-400">(Calculated from response to completion)</span>
</label>
<input
type="number"
name="total_hours_spent"
min="0"
step="0.5"
step="0.01"
value={formData.total_hours_spent || 0}
onChange={handleChange}
disabled={!isEditing}
className="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:bg-gray-100 dark:disabled:bg-gray-700 bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
readOnly
disabled
className="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-md bg-gray-100 dark:bg-gray-700 text-gray-900 dark:text-white"
/>
</div>
<div>
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">
First Responded On
<span className="ml-1 text-xs text-gray-400">(Auto-set on repair start)</span>
</label>
<input
type="date"
type="datetime-local"
name="first_responded_on"
value={formData.first_responded_on}
onChange={handleChange}
disabled={!isEditing}
className="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:bg-gray-100 dark:disabled:bg-gray-700 bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
readOnly
disabled
className="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-md bg-gray-100 dark:bg-gray-700 text-gray-900 dark:text-white"
/>
</div>
<div>
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">
Completion Date
Completion Date &amp; Time
<span className="ml-1 text-xs text-gray-400">(Auto-set on Complete)</span>
</label>
<input
type="date"
type="datetime-local"
name="completion_date"
value={formData.completion_date}
onChange={handleChange}
disabled={!isEditing}
className="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:bg-gray-100 dark:disabled:bg-gray-700 bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
readOnly
disabled
className="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-md bg-gray-100 dark:bg-gray-700 text-gray-900 dark:text-white"
/>
</div>
</div>
@ -2022,6 +2184,11 @@ const canEditBasedOnWorkflow =
</div>
)}
{/* WO Feedback — read-only summary when submitted (submit via public link only) */}
{!isNewWorkOrder &&
['Approved', 'Closed'].includes(currentWorkflowState) &&
workOrderName && <WoFeedbackSummary workOrder={workOrderName} />}
{/* Status Summary Card */}
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-md p-6 border border-gray-200 dark:border-gray-700">
<h2 className="text-base font-semibold text-gray-800 dark:text-white mb-4 pb-2 border-b border-gray-200 dark:border-gray-700">Status Summary</h2>
@ -2067,10 +2234,34 @@ const canEditBasedOnWorkflow =
</div>
)}
</div>
{!isNewWorkOrder && !isDuplicating && (
<ActivityLog
doctype="Work_Order"
docname={workOrderName || null}
creationDate={workOrder?.creation}
createdBy={workOrder?.owner}
initialVisible={5}
collapsible={false}
startCollapsed={false}
compact={true}
/>
)}
</div>
</div>
</form>
{!isNewWorkOrder && !isDuplicating && (
<div className="mt-6 space-y-6 max-w-5xl">
<CommentSection
referenceDoctype="Work_Order"
referenceName={workOrderName || null}
pollInterval={30000}
initialLimit={5}
/>
</div>
)}
{/* Styles for table scrolling */}
<style>{`
/* Wrapper for table - allow natural flow */

View File

@ -1,4 +1,4 @@
import React, { useState, useEffect, useRef, useCallback } from 'react';
import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react';
import { useNavigate, useSearchParams } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { useWorkOrders, useWorkOrderMutations } from '../hooks/useWorkOrder';
@ -10,6 +10,14 @@ import {
} from 'react-icons/fa';
import LinkField from '../components/LinkField';
import { useUserPermissions } from '../hooks/useUserPermissions';
import useRoleProfile from '../hooks/useRoleProfile';
import { mergeWorkOrderFilters } from '../utils/roleAccess';
import workOrderService from '../services/workOrderService';
import ListPagination from '../components/ListPagination';
import ListFilterSortControls from '../components/ListFilterSortControls';
import { applyDateRangeToFilters } from '../utils/listFilterUtils';
import { buildMobileTeamSiteFilters } from '../utils/hospitalUtils';
import useUserPhccSiteFilter from '../hooks/useUserPhccSiteFilter';
// Export column configuration - will be defined inside component to use translations
@ -326,7 +334,20 @@ const WorkOrderList: React.FC = () => {
const { t } = useTranslation();
const navigate = useNavigate();
const [searchParams, setSearchParams] = useSearchParams();
const [page, setPage] = useState(0);
const page = useMemo(() => {
const p = parseInt(searchParams.get('page') || '1', 10);
return Math.max(0, Number.isNaN(p) ? 0 : p - 1);
}, [searchParams]);
const setPage = useCallback((zeroBasedPage: number) => {
setSearchParams((prev) => {
const next = new URLSearchParams(prev);
next.set('page', String(zeroBasedPage + 1));
return next;
});
}, [setSearchParams]);
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState<string | null>(null);
const [actionMenuOpen, setActionMenuOpen] = useState<string | null>(null);
const dropdownRef = useRef<HTMLDivElement>(null);
@ -376,52 +397,55 @@ const WorkOrderList: React.FC = () => {
error: permissionsError,
hasAnyRestrictions
} = useUserPermissions('Work_Order');
const { roleProfile, userEmail } = useRoleProfile();
// Filter states
const [filterWorkOrderId, setFilterWorkOrderId] = useState('');
const [filterAsset, setFilterAsset] = useState('');
const [filterType, setFilterType] = useState('');
const [filterCompany, setFilterCompany] = useState('');
const [filterSiteName, setFilterSiteName] = useState('');
const [filterStatus, setFilterStatus] = useState('');
const [filterSerialNumber, setFilterSerialNumber] = useState('');
const [filterManufacturer, setFilterManufacturer] = useState('');
const [filterSupplier, setFilterSupplier] = useState('');
const [filterDepartment, setFilterDepartment] = useState('');
const [filterPriority, setFilterPriority] = useState('');
const sortOptions = ['creation desc', 'creation asc', 'modified desc', 'modified asc', 'name asc', 'name desc'] as const;
// Filter states (init from URL — persists when navigating back from detail)
const [dateFilterBy, setDateFilterBy] = useState<'' | 'creation' | 'modified'>(() => (searchParams.get('date_filter_by') as '' | 'creation' | 'modified') || '');
const [dateStart, setDateStart] = useState(() => searchParams.get('date_start') || '');
const [dateEnd, setDateEnd] = useState(() => searchParams.get('date_end') || '');
const [sortBy, setSortBy] = useState<string>(() => searchParams.get('sort_by') || 'modified desc');
const [filterWorkOrderId, setFilterWorkOrderId] = useState(() => searchParams.get('work_order_id') || '');
const [filterAsset, setFilterAsset] = useState(() => searchParams.get('asset') || '');
const [filterType, setFilterType] = useState(() => searchParams.get('work_order_type') || '');
const [filterCompany, setFilterCompany] = useState(() => searchParams.get('company') || '');
const { userPhccSiteName, siteLocked } = useUserPhccSiteFilter();
const [filterSiteName, setFilterSiteName] = useState(() => searchParams.get('site_name') || '');
const [filterStatus, setFilterStatus] = useState(() => searchParams.get('status') || '');
const [filterSerialNumber, setFilterSerialNumber] = useState(() => searchParams.get('serial_number') || '');
const [filterManufacturer, setFilterManufacturer] = useState(() => searchParams.get('manufacturer') || '');
const [filterSupplier, setFilterSupplier] = useState(() => searchParams.get('supplier') || '');
const [filterDepartment, setFilterDepartment] = useState(() => searchParams.get('department') || '');
const [filterPriority, setFilterPriority] = useState(() => searchParams.get('priority') || '');
const filtersChangedOnce = useRef(false);
const hasDateFilter = dateFilterBy && (dateStart || dateEnd);
// Advanced filter UI states
const [isFilterExpanded, setIsFilterExpanded] = useState(true);
const [isFilterExpanded, setIsFilterExpanded] = useState(false);
const [activeFilterCount, setActiveFilterCount] = useState(0);
const [savedFilters, setSavedFilters] = useState<any[]>([]);
const [showSaveFilterModal, setShowSaveFilterModal] = useState(false);
const [filterPresetName, setFilterPresetName] = useState('');
// Temporary states for text inputs
const [tempSerialNumber, setTempSerialNumber] = useState('');
const [tempSerialNumber, setTempSerialNumber] = useState(() => searchParams.get('serial_number') || '');
// Debounce timer refs
const serialNumberDebounceRef = useRef<number | null>(null);
// ✅ Load filters from URL on mount
useEffect(() => {
const company = searchParams.get('company');
const priority = searchParams.get('priority');
const status = searchParams.get('status');
const siteName = searchParams.get('site_name');
if (company) setFilterCompany(company);
if (priority) setFilterPriority(priority);
if (status) setFilterStatus(status);
if (siteName) setFilterSiteName(siteName);
}, [searchParams]);
const mobileTeamSiteFilters = useMemo(
() => buildMobileTeamSiteFilters(filterCompany, userPhccSiteName),
[filterCompany, userPhccSiteName]
);
// ✅ Load asset filter from URL on mount
// Auto-apply user's PHCC site when set on profile
useEffect(() => {
if (assetFromUrl) {
setFilterAsset(assetFromUrl);
if (siteLocked && userPhccSiteName) {
setFilterSiteName(userPhccSiteName);
}
}, [assetFromUrl]);
}, [siteLocked, userPhccSiteName]);
// Load saved filters from localStorage on mount
useEffect(() => {
@ -445,9 +469,10 @@ const WorkOrderList: React.FC = () => {
filterSupplier,
filterDepartment,
filterPriority
].filter(Boolean).length;
].filter(Boolean).length + (hasDateFilter ? 1 : 0);
setActiveFilterCount(count);
}, [
hasDateFilter,
filterWorkOrderId,
filterAsset,
filterType,
@ -461,27 +486,37 @@ const WorkOrderList: React.FC = () => {
filterPriority
]);
// Build filters object
// Build filters object for API (reference pattern)
const filters: Record<string, any> = {};
if (filterWorkOrderId) filters['name'] = filterWorkOrderId;
if (filterAsset) filters['asset'] = filterAsset;
if (filterType) filters['work_order_type'] = filterType;
if (filterCompany) filters['company'] = filterCompany;
if (filterSiteName) filters['site_name'] = filterSiteName;
if (filterStatus) filters['repair_status'] = filterStatus;
if (filterSerialNumber) filters['custom_serial_number'] = ['like', `%${filterSerialNumber}%`];
if (filterManufacturer) filters['custom_manufacturer'] = filterManufacturer;
if (filterSupplier) filters['supplier'] = filterSupplier;
if (filterDepartment) filters['department'] = filterDepartment;
if (filterPriority) filters['custom_priority_'] = filterPriority;
if (filterWorkOrderId) filters.name = filterWorkOrderId;
if (filterAsset) filters.asset = filterAsset;
if (filterType) filters.work_order_type = filterType;
if (filterCompany) filters.company = filterCompany;
if (filterSiteName) filters.site_name = filterSiteName;
if (filterStatus) {
const statuses = filterStatus.split(',').map((s) => s.trim()).filter(Boolean);
filters.repair_status = statuses.length > 1 ? ['in', statuses] : filterStatus;
}
if (filterSerialNumber) filters.custom_serial_number = ['like', `%${filterSerialNumber}%`];
if (filterManufacturer) filters.custom_manufacturer = filterManufacturer;
if (filterSupplier) filters.supplier = filterSupplier;
if (filterDepartment) filters.department = filterDepartment;
if (filterPriority) filters.custom_priority_ = filterPriority;
Object.assign(filters, applyDateRangeToFilters({}, dateFilterBy, dateStart, dateEnd));
const orderBy = sortOptions.includes(sortBy as (typeof sortOptions)[number]) ? sortBy : 'modified desc';
const effectiveFilters = useMemo(
() => mergeWorkOrderFilters(filters, permissionFilters, roleProfile, userEmail),
[filters, permissionFilters, roleProfile, userEmail]
);
const { workOrders, totalCount, hasMore, loading, error, refetch } = useWorkOrders(
filters,
effectiveFilters,
limit,
page * limit,
// 'creation desc',
'modified desc',
permissionFilters
orderBy,
{}
);
const { deleteWorkOrder, loading: mutationLoading } = useWorkOrderMutations();
@ -493,19 +528,46 @@ const WorkOrderList: React.FC = () => {
}
}, [loading, initialLoadComplete, permissionsLoading]);
// Reset page when filters change
// Sync filters to URL when user changes a filter (skip first mount)
useEffect(() => {
if (page !== 0) {
setPage(0);
if (!filtersChangedOnce.current) {
filtersChangedOnce.current = true;
return;
}
}, [filterWorkOrderId, filterAsset, filterType, filterCompany, filterSiteName, filterStatus,
filterSerialNumber, filterManufacturer, filterSupplier, filterDepartment, filterPriority]);
setSearchParams((prev) => {
const next = new URLSearchParams(prev);
if (dateFilterBy) next.set('date_filter_by', dateFilterBy); else next.delete('date_filter_by');
if (dateStart) next.set('date_start', dateStart); else next.delete('date_start');
if (dateEnd) next.set('date_end', dateEnd); else next.delete('date_end');
if (filterWorkOrderId) next.set('work_order_id', filterWorkOrderId); else next.delete('work_order_id');
if (filterAsset) next.set('asset', filterAsset); else next.delete('asset');
if (filterType) next.set('work_order_type', filterType); else next.delete('work_order_type');
if (filterCompany) next.set('company', filterCompany); else next.delete('company');
if (filterSiteName) next.set('site_name', filterSiteName); else next.delete('site_name');
if (filterStatus) next.set('status', filterStatus); else next.delete('status');
if (filterSerialNumber) next.set('serial_number', filterSerialNumber); else next.delete('serial_number');
if (filterManufacturer) next.set('manufacturer', filterManufacturer); else next.delete('manufacturer');
if (filterSupplier) next.set('supplier', filterSupplier); else next.delete('supplier');
if (filterDepartment) next.set('department', filterDepartment); else next.delete('department');
if (filterPriority) next.set('priority', filterPriority); else next.delete('priority');
if (sortBy && sortBy !== 'modified desc') next.set('sort_by', sortBy); else next.delete('sort_by');
next.set('page', '1');
return next;
});
}, [
dateFilterBy, dateStart, dateEnd, filterWorkOrderId, filterAsset, filterType,
filterCompany, filterSiteName, filterStatus, filterSerialNumber, filterManufacturer,
filterSupplier, filterDepartment, filterPriority, sortBy,
]);
// Clear selection when filters/page change
// Clear selection on filter/page change
useEffect(() => {
setSelectedRows(new Set());
}, [filterWorkOrderId, filterAsset, filterType, filterCompany, filterSiteName, filterStatus,
filterSerialNumber, filterManufacturer, filterSupplier, filterDepartment, filterPriority, page]);
}, [
dateFilterBy, dateStart, dateEnd, filterWorkOrderId, filterAsset, filterType,
filterCompany, filterSiteName, filterStatus, filterSerialNumber, filterManufacturer,
filterSupplier, filterDepartment, filterPriority, page,
]);
// Debounce function for serial number
const handleSerialNumberChange = (value: string) => {
@ -536,6 +598,10 @@ const WorkOrderList: React.FC = () => {
id: Date.now(),
name: filterPresetName,
filters: {
dateFilterBy,
dateStart,
dateEnd,
sortBy,
filterWorkOrderId,
filterAsset,
filterType,
@ -560,6 +626,10 @@ const WorkOrderList: React.FC = () => {
// Load filter preset
const handleLoadFilterPreset = (preset: any) => {
const f = preset.filters;
setDateFilterBy(f.dateFilterBy || '');
setDateStart(f.dateStart || '');
setDateEnd(f.dateEnd || '');
setSortBy(f.sortBy || 'modified desc');
setFilterWorkOrderId(f.filterWorkOrderId || '');
setFilterAsset(f.filterAsset || '');
setFilterType(f.filterType || '');
@ -638,26 +708,17 @@ const WorkOrderList: React.FC = () => {
while (hasMoreData) {
try {
const response = await fetch('/api/method/frappe.client.get_list', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
doctype: 'Work_Order',
filters: { ...filters, ...permissionFilters },
fields: ['*'],
limit_start: currentPage * pageSize,
limit_page_length: pageSize,
order_by: 'creation desc'
})
});
const response = await workOrderService.getWorkOrders(
effectiveFilters,
['*'],
pageSize,
currentPage * pageSize,
orderBy
);
const results = response.work_orders || [];
const data = await response.json();
const results = data.message || [];
allWorkOrders.push(...results);
if (results.length < pageSize) {
hasMoreData = false;
} else {
@ -675,7 +736,7 @@ const WorkOrderList: React.FC = () => {
}
return allWorkOrders;
}, [filters, permissionFilters]);
}, [effectiveFilters, orderBy]);
// Export handler
const handleExport = async (scope: ExportScope, format: ExportFormat, columns: string[]) => {
@ -786,11 +847,17 @@ const WorkOrderList: React.FC = () => {
};
const handleClearFilters = () => {
setDateFilterBy('');
setDateStart('');
setDateEnd('');
setSortBy('modified desc');
setFilterWorkOrderId('');
setFilterAsset('');
setFilterType('');
setFilterCompany('');
setFilterSiteName('');
if (!siteLocked) {
setFilterSiteName('');
}
setFilterStatus('');
setFilterSerialNumber('');
setTempSerialNumber('');
@ -799,18 +866,32 @@ const WorkOrderList: React.FC = () => {
setFilterDepartment('');
setFilterPriority('');
// ✅ Clear URL params when clearing all filters
if (searchParams.has('asset') || searchParams.has('site_name')) {
searchParams.delete('asset');
searchParams.delete('site_name');
setSearchParams(searchParams);
}
setSearchParams((prev) => {
const next = new URLSearchParams(prev);
next.delete('date_filter_by');
next.delete('date_start');
next.delete('date_end');
next.delete('sort_by');
next.delete('work_order_id');
next.delete('asset');
next.delete('work_order_type');
next.delete('company');
next.delete('site_name');
next.delete('status');
next.delete('serial_number');
next.delete('manufacturer');
next.delete('supplier');
next.delete('department');
next.delete('priority');
next.set('page', '1');
return next;
});
if (serialNumberDebounceRef.current) clearTimeout(serialNumberDebounceRef.current);
};
const hasActiveFilters = filterWorkOrderId || filterAsset || filterType || filterCompany ||
filterSiteName || filterStatus || filterSerialNumber || filterManufacturer ||
const hasActiveFilters = hasDateFilter || filterWorkOrderId || filterAsset || filterType || filterCompany ||
filterSiteName || filterStatus || filterSerialNumber || filterManufacturer ||
filterSupplier || filterDepartment || filterPriority;
const formatDate = (dateString?: string) => {
@ -1013,6 +1094,14 @@ const WorkOrderList: React.FC = () => {
{hasActiveFilters && (
<div className="flex-1 overflow-x-auto scrollbar-hide mx-2">
<div className="flex items-center gap-2 py-1">
{hasDateFilter && (
<span className="inline-flex items-center gap-1.5 px-2.5 py-1 bg-white/90 text-indigo-700 rounded-full text-[10px] font-medium whitespace-nowrap shadow-sm">
<span className="font-semibold">{t('filters.filterBy')}:</span> {dateFilterBy === 'creation' ? t('filters.createdDate') : t('filters.latestModifiedDate')} {dateStart && ` ${dateStart}`} {dateEnd && ` - ${dateEnd}`}
<button onClick={(e) => { e.stopPropagation(); setDateFilterBy(''); setDateStart(''); setDateEnd(''); }} className="hover:text-red-500 transition-colors">
<FaTimes className="text-[9px]" />
</button>
</span>
)}
{filterWorkOrderId && (
<span className="inline-flex items-center gap-1.5 px-2.5 py-1 bg-white/90 text-blue-700 rounded-full text-[10px] font-medium whitespace-nowrap shadow-sm">
<span className="font-semibold">WO ID:</span> {filterWorkOrderId}
@ -1049,9 +1138,11 @@ const WorkOrderList: React.FC = () => {
{filterSiteName && (
<span className="inline-flex items-center gap-1.5 px-2.5 py-1 bg-white/90 text-emerald-700 rounded-full text-[10px] font-medium whitespace-nowrap shadow-sm">
<span className="font-semibold">Site:</span> {filterSiteName}
<button onClick={(e) => { e.stopPropagation(); setFilterSiteName(''); searchParams.delete('site_name'); setSearchParams(searchParams); }} className="hover:text-red-500 transition-colors">
<FaTimes className="text-[9px]" />
</button>
{!siteLocked && (
<button onClick={(e) => { e.stopPropagation(); setFilterSiteName(''); searchParams.delete('site_name'); setSearchParams(searchParams); }} className="hover:text-red-500 transition-colors">
<FaTimes className="text-[9px]" />
</button>
)}
</span>
)}
{filterStatus && (
@ -1168,6 +1259,16 @@ const WorkOrderList: React.FC = () => {
<div className="space-y-5">
<div className="bg-gray-50 dark:bg-gray-900/50 p-3 rounded-lg">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-5 gap-3">
<ListFilterSortControls
sortBy={sortBy}
dateFilterBy={dateFilterBy}
dateStart={dateStart}
dateEnd={dateEnd}
onSortByChange={setSortBy}
onDateFilterByChange={setDateFilterBy}
onDateStartChange={setDateStart}
onDateEndChange={setDateEnd}
/>
{/* Work Order ID */}
<div className="relative z-[60]">
<LinkField
@ -1265,10 +1366,11 @@ const WorkOrderList: React.FC = () => {
value={filterSiteName}
onChange={(val) => setFilterSiteName(val)}
placeholder="Select Site"
disabled={false}
disabled={siteLocked}
filters={mobileTeamSiteFilters}
compact={true}
/>
{filterSiteName && (
{filterSiteName && !siteLocked && (
<button
onClick={() => { setFilterSiteName(''); searchParams.delete('site_name'); setSearchParams(searchParams); }}
className="absolute right-2 top-6 text-gray-400 hover:text-red-500 transition-colors z-10"
@ -1704,37 +1806,14 @@ const WorkOrderList: React.FC = () => {
</table>
</div>
{/* Pagination */}
{workOrders.length > 0 && (
<div className="bg-gray-50 dark:bg-gray-700 px-6 py-4 flex items-center justify-between border-t border-gray-200 dark:border-gray-600">
<div className="text-sm text-gray-700 dark:text-gray-300">
Showing <span className="font-medium">{page * limit + 1}</span> to{' '}
<span className="font-medium">
{Math.min((page + 1) * limit, totalCount)}
</span>{' '}
of <span className="font-medium">{totalCount}</span> results
{hasAnyRestrictions && (
<span className="ml-1 text-amber-600 dark:text-amber-400">(filtered)</span>
)}
</div>
<div className="flex gap-2">
<button
disabled={page === 0}
onClick={() => setPage(page - 1)}
className="px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-md text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-800 hover:bg-gray-50 dark:hover:bg-gray-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
Previous
</button>
<button
disabled={!hasMore}
onClick={() => setPage(page + 1)}
className="px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-md text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-800 hover:bg-gray-50 dark:hover:bg-gray-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
Next
</button>
</div>
</div>
)}
<ListPagination
currentPage={page + 1}
totalCount={totalCount}
pageSize={limit}
hasMore={hasMore}
itemLabel={t('pagination.workOrders')}
onPageChange={(p) => setPage(Math.max(0, p - 1))}
/>
</div>
{/* Delete Confirmation Modal */}

View File

@ -61,6 +61,48 @@ interface NumberCards {
work_orders_open: number;
work_orders_in_progress: number;
work_orders_completed: number;
work_orders_closed: number;
}
interface WorkOrderMetricsCounts {
open: number;
inProgress: number;
completed: number;
completedCombined?: number;
pendingReview: number;
rejected: number;
closed: number;
cancelled?: number;
total: number;
}
interface CompletionByTypeRate {
type: string;
total: number;
open?: number;
inProgress?: number;
pendingReview?: number;
completed?: number;
closed?: number;
completedCombined?: number;
rejected?: number;
cancelled?: number;
}
interface WorkOrderMetricsResponse {
counts: WorkOrderMetricsCounts;
work_order_chart: ChartResponse;
type_status_chart?: ChartResponse;
completion_by_type?: {
labels: string[];
rates: CompletionByTypeRate[];
};
}
interface AssetDeviceStatusSummaryResponse {
total_assets: number;
assets_down: number;
chart: ChartResponse & { total?: number };
}
// Dashboard chart payload
@ -179,8 +221,11 @@ class ApiService {
// CSRF for guest requests (forgot password, post-reset logout)
async getCSRFTokenForGuest(): Promise<string | null> {
if (typeof window !== 'undefined' && (window as any).csrf_token) {
return (window as any).csrf_token;
if (typeof window !== 'undefined') {
const token = (window as any).csrf_token;
if (token && token !== 'None') {
return token;
}
}
try {
const response = await fetch(`${this.baseURL}${this.endpoints.CSRF_TOKEN}`, {
@ -276,40 +321,48 @@ class ApiService {
// Get CSRF Token for authenticated requests
async getCSRFToken(): Promise<string | null> {
try {
// First, try to get CSRF token from window (injected by Frappe in HTML)
if (typeof window !== 'undefined' && (window as any).csrf_token) {
return (window as any).csrf_token;
if (typeof window !== 'undefined') {
const windowToken = (window as any).csrf_token;
if (windowToken && windowToken !== 'None') {
return windowToken;
}
}
// If not in window, try to fetch from API (but only if user is authenticated)
// Check if user is logged in by checking localStorage
const user = localStorage.getItem('user');
if (!user) {
// User not logged in, skip CSRF token fetch
return null;
}
const response = await fetch(`${this.baseURL}${this.endpoints.CSRF_TOKEN}`, {
method: 'GET',
headers: {
'Accept': 'application/json'
Accept: 'application/json',
},
credentials: 'include' // Include cookies for session
credentials: 'include',
});
if (response.ok) {
const data: ApiResponse<string> = await response.json();
return data.message || null;
} else {
// Silently fail - CSRF token not required for GET requests
if (!response.ok) {
return null;
}
} catch (error) {
// Silently fail - CSRF token not required for GET requests
const data: ApiResponse<{ csrf_token?: string }> = await response.json();
const token = data.message?.csrf_token || null;
if (token && typeof window !== 'undefined') {
(window as any).csrf_token = token;
}
return token;
} catch {
return null;
}
}
async refreshCSRFToken(): Promise<string | null> {
if (typeof window !== 'undefined') {
(window as any).csrf_token = null;
}
return this.getCSRFToken();
}
// Generic API call method
async apiCall<T = any>(endpoint: string, options: RequestOptions = {}): Promise<T> {
const url = `${this.baseURL}${endpoint}`;
@ -324,13 +377,14 @@ class ApiService {
...options
};
// Add CSRF token for non-GET requests
// if (defaultOptions.method !== 'GET') {
// Add CSRF token for mutating requests only
const method = (defaultOptions.method || 'GET').toUpperCase();
if (method !== 'GET' && method !== 'HEAD') {
const csrfToken = await this.getCSRFToken();
if (csrfToken) {
(defaultOptions.headers as Record<string, string>)['X-Frappe-CSRF-Token'] = csrfToken;
}
// }
}
try {
const controller = new AbortController();
@ -407,6 +461,8 @@ class ApiService {
}
const data: any = await response.json();
await this.refreshCSRFToken();
// Handle Frappe API response format
// Check if message is a string (like "Logged In")
@ -562,8 +618,42 @@ class ApiService {
return this.apiCall<DashboardStats>(this.endpoints.DASHBOARD_STATS);
}
async getNumberCards(): Promise<NumberCards> {
return this.apiCall<NumberCards>(this.endpoints.DASHBOARD_NUMBER_CARDS);
async getNumberCards(filters?: Record<string, string>): Promise<NumberCards> {
const params = new URLSearchParams();
if (filters?.company) params.append('company', filters.company);
if (filters?.site_name) params.append('site_name', filters.site_name);
const qs = params.toString();
const url = qs
? `${this.endpoints.DASHBOARD_NUMBER_CARDS}?${qs}`
: this.endpoints.DASHBOARD_NUMBER_CARDS;
return this.apiCall<NumberCards>(url);
}
async getDashboardWorkOrderMetrics(filters?: Record<string, string>): Promise<WorkOrderMetricsResponse> {
const params = new URLSearchParams();
if (filters?.company) params.append('company', filters.company);
if (filters?.site_name) params.append('site_name', filters.site_name);
if (filters?.work_order_type) params.append('work_order_type', filters.work_order_type);
if (filters?.from_date) params.append('from_date', filters.from_date);
if (filters?.to_date) params.append('to_date', filters.to_date);
const qs = params.toString();
const url = qs
? `${this.endpoints.DASHBOARD_WORK_ORDER_METRICS}?${qs}`
: this.endpoints.DASHBOARD_WORK_ORDER_METRICS;
return this.apiCall<WorkOrderMetricsResponse>(url);
}
async getAssetDeviceStatusSummary(filters?: Record<string, string>): Promise<AssetDeviceStatusSummaryResponse> {
const params = new URLSearchParams();
if (filters?.company) params.append('company', filters.company);
if (filters?.site_name) params.append('site_name', filters.site_name);
if (filters?.from_date) params.append('from_date', filters.from_date);
if (filters?.to_date) params.append('to_date', filters.to_date);
const qs = params.toString();
const url = qs
? `${this.endpoints.DASHBOARD_ASSET_DEVICE_STATUS}?${qs}`
: this.endpoints.DASHBOARD_ASSET_DEVICE_STATUS;
return this.apiCall<AssetDeviceStatusSummaryResponse>(url);
}
async listDashboardCharts(publicOnly: boolean = true): Promise<{ charts: any[] }> {

View File

@ -0,0 +1,67 @@
import type { AssetStatusReportRow } from '../components/AssetStatusReportModal';
export type AssetDeviceStatusSummaryFilters = {
company?: string;
site_name?: string;
from_date?: string;
to_date?: string;
};
export type AssetDeviceStatusSummaryResponse = {
total_assets: number;
assets_down: number;
chart: {
labels: string[];
datasets: Array<{ name: string; values: number[]; colors: string[] }>;
type: 'Pie';
total: number;
};
};
export async function fetchAssetsForStatusModal(
filters: AssetDeviceStatusSummaryFilters
): Promise<AssetStatusReportRow[]> {
const csrfToken =
typeof window !== 'undefined' && (window as any).csrf_token && (window as any).csrf_token !== 'None'
? (window as any).csrf_token
: null;
const headers: Record<string, string> = {
'Content-Type': 'application/json',
Accept: 'application/json',
};
if (csrfToken) headers['X-Frappe-CSRF-Token'] = csrfToken;
const listFilters: Array<[string, string, unknown]> = [];
if (filters.company) listFilters.push(['company', '=', filters.company]);
if (filters.site_name) listFilters.push(['custom_site', '=', filters.site_name]);
if (filters.from_date && filters.to_date) {
listFilters.push(['creation', 'between', [`${filters.from_date} 00:00:00`, `${filters.to_date} 23:59:59`]]);
} else if (filters.from_date) {
listFilters.push(['creation', '>=', `${filters.from_date} 00:00:00`]);
} else if (filters.to_date) {
listFilters.push(['creation', '<=', `${filters.to_date} 23:59:59`]);
}
const response = await fetch('/api/method/frappe.client.get_list', {
method: 'POST',
headers,
credentials: 'include',
body: JSON.stringify({
doctype: 'Asset',
fields: ['name', 'asset_name', 'custom_device_status', 'location', 'department', 'company', 'custom_site'],
filters: listFilters,
limit_page_length: 500,
order_by: 'modified desc',
}),
});
const payload = await response.json();
if (!response.ok) {
throw new Error(payload?.message || payload?.exc || 'Failed to load assets');
}
const rows = payload?.message || [];
return Array.isArray(rows) ? rows : [];
}

View File

@ -10,10 +10,16 @@ export interface Asset {
company: string;
docstatus?: number; // 0 = Draft, 1 = Submitted, 2 = Cancelled
custom_serial_number?: string;
custom_local_id?: string;
location?: string;
custom_manufacturer?: string;
department?: string;
custom_asset_type?: string;
asset_category?: string;
custom_moh_id?: string;
custom_floor?: string;
custom_moh_engg?: string;
custom_smeh_engg?: string;
custom_manufacturing_year?: string;
custom_model?: string;
custom_class?: string;
@ -111,10 +117,16 @@ export interface CreateAssetData {
asset_name: string;
company: string;
custom_serial_number?: string;
custom_local_id?: string;
location?: string;
custom_manufacturer?: string;
department?: string;
custom_asset_type?: string;
asset_category?: string;
custom_moh_id?: string;
custom_floor?: string;
custom_moh_engg?: string;
custom_smeh_engg?: string;
custom_manufacturing_year?: string;
custom_model?: string;
custom_class?: string;

View File

@ -0,0 +1,147 @@
export const ASSIGNEES_STATUS_REPORT = 'Asset Maintenance Assignees Status Count';
export type AssigneesStatusRow = Record<string, string | number | boolean | null | undefined>;
export type AssigneesStatusFilters = {
from_date?: string;
to_date?: string;
company?: string;
site_name?: string;
};
export type AssigneesStatusChartData = {
labels: string[];
datasets: Array<{ name: string; values: number[]; colors?: string[] }>;
type: 'Bar';
rawRows: AssigneesStatusRow[];
};
const STATUS_GROUPS = [
{ label: 'Planned', color: '#6366F1', fields: ['pending', 'Pending'] },
{
label: 'Completed',
color: '#10B981',
fields: ['completed_on_time', 'completed_within_time', 'delay_in_completion'],
},
{ label: 'Cancelled', color: '#F59E0B', fields: ['cancelled', 'Cancelled'] },
{ label: 'Overdue', color: '#EF4444', fields: ['overdue', 'Overdue'] },
] as const;
export const getRowNumericField = (row: AssigneesStatusRow, ...keys: string[]): number => {
for (const key of keys) {
const value = row[key];
if (value == null || value === '') continue;
const parsed = Number(value);
if (Number.isFinite(parsed)) return parsed;
}
return 0;
};
export const getAssigneeName = (row: AssigneesStatusRow): string => {
const name =
row['Assigned To'] ||
row.assign_to_name ||
row.assigned_to ||
row.assign_to ||
'Unknown';
return String(name).trim() || 'Unknown';
};
export const getAssigneePlanned = (row: AssigneesStatusRow): number =>
getRowNumericField(row, 'pending', 'Pending');
export const getAssigneeCompleted = (row: AssigneesStatusRow): number =>
getRowNumericField(row, 'completed_on_time') +
getRowNumericField(row, 'completed_within_time') +
getRowNumericField(row, 'delay_in_completion');
export const getAssigneeCancelled = (row: AssigneesStatusRow): number =>
getRowNumericField(row, 'cancelled', 'Cancelled');
export const getAssigneeOverdue = (row: AssigneesStatusRow): number =>
getRowNumericField(row, 'overdue', 'Overdue');
export const getAssigneeTotal = (row: AssigneesStatusRow): number =>
getAssigneePlanned(row) +
getAssigneeCompleted(row) +
getAssigneeCancelled(row) +
getAssigneeOverdue(row);
export const buildAssigneesStatusChartData = (
rows: AssigneesStatusRow[]
): AssigneesStatusChartData | null => {
const dataRows = rows.filter(row => row && !row.is_total_row);
if (!dataRows.length) return null;
const statusTotals = STATUS_GROUPS.map(({ label, color, fields }) => ({
label,
color,
total: dataRows.reduce(
(sum, row) => sum + fields.reduce((fieldSum, field) => fieldSum + getRowNumericField(row, field), 0),
0
),
}));
return {
labels: statusTotals.map(item => item.label),
datasets: [
{
name: 'Tasks',
values: statusTotals.map(item => item.total),
colors: statusTotals.map(item => item.color),
},
],
type: 'Bar',
rawRows: dataRows,
};
};
export const runAssigneesStatusReport = async (
filters: AssigneesStatusFilters
): Promise<AssigneesStatusRow[]> => {
const csrfToken =
typeof window !== 'undefined' && (window as any).csrf_token && (window as any).csrf_token !== 'None'
? (window as any).csrf_token
: null;
const headers: Record<string, string> = {
'Content-Type': 'application/json',
Accept: 'application/json',
};
if (csrfToken) {
headers['X-Frappe-CSRF-Token'] = csrfToken;
}
const cleanedFilters: Record<string, string> = {};
Object.entries(filters).forEach(([key, value]) => {
if (value) cleanedFilters[key] = value;
});
const response = await fetch('/api/method/frappe.desk.query_report.run', {
method: 'POST',
headers,
credentials: 'include',
body: JSON.stringify({
report_name: ASSIGNEES_STATUS_REPORT,
filters: cleanedFilters,
ignore_prepared_report: 1,
}),
});
const payload = await response.json();
if (!response.ok) {
throw new Error(payload?.message || payload?.exc || 'Failed to load assignees status report');
}
const result = payload?.message?.result || payload?.message?.message?.result || [];
if (!Array.isArray(result)) return [];
return result.filter(row => row && !row.is_total_row);
};
export const fetchAssigneesStatusChart = async (
filters: AssigneesStatusFilters
): Promise<AssigneesStatusChartData | null> => {
const rows = await runAssigneesStatusReport(filters);
return buildAssigneesStatusChartData(rows);
};

View File

@ -0,0 +1,148 @@
import apiService from './apiService';
import API_CONFIG from '../config/api';
export interface CommentData {
name: string;
owner: string;
creation: string;
modified: string;
modified_by: string;
comment_type: string;
comment_email: string;
comment_by: string;
content: string;
reference_doctype: string;
reference_name: string;
published: number;
seen: number;
}
export interface MentionUser {
name: string;
full_name: string;
user_image?: string;
}
async function getCsrfToken(): Promise<string | null> {
if (typeof window !== 'undefined' && (window as Window & { csrf_token?: string }).csrf_token) {
return (window as Window & { csrf_token?: string }).csrf_token || null;
}
try {
const data = await apiService.apiCall<string>('/api/method/frappe.sessions.get_csrf_token');
return data || null;
} catch {
return null;
}
}
const commentService = {
async getComments(
referenceDoctype: string,
referenceName: string
): Promise<CommentData[]> {
const filters = JSON.stringify([
['reference_doctype', '=', referenceDoctype],
['reference_name', '=', referenceName],
['comment_type', 'in', ['Comment', 'Info', 'Edit', 'Attachment', 'Like']],
]);
const fields = JSON.stringify([
'name', 'owner', 'creation', 'modified', 'modified_by',
'comment_type', 'comment_email', 'comment_by',
'content', 'reference_doctype', 'reference_name',
'published', 'seen',
]);
const res = await apiService.apiCall<any>(
`/api/resource/Comment?filters=${encodeURIComponent(filters)}&fields=${encodeURIComponent(fields)}&order_by=creation asc&limit_page_length=100`
);
return res?.data ?? [];
},
async postComment(
referenceDoctype: string,
referenceName: string,
content: string
): Promise<CommentData> {
const baseUrl = API_CONFIG.BASE_URL || '';
const csrf = await getCsrfToken();
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
if (csrf) headers['X-Frappe-CSRF-Token'] = csrf;
const res = await fetch(`${baseUrl}/api/resource/Comment`, {
method: 'POST',
credentials: 'include',
headers,
body: JSON.stringify({
comment_type: 'Comment',
reference_doctype: referenceDoctype,
reference_name: referenceName,
content,
}),
});
if (!res.ok) {
const err = await res.json().catch(() => ({}));
throw new Error(err?.exc || err?._server_messages || 'Failed to post comment');
}
const json = await res.json();
return json.data;
},
async deleteComment(commentName: string): Promise<void> {
const baseUrl = API_CONFIG.BASE_URL || '';
const csrf = await getCsrfToken();
const headers: Record<string, string> = {};
if (csrf) headers['X-Frappe-CSRF-Token'] = csrf;
const res = await fetch(`${baseUrl}/api/resource/Comment/${encodeURIComponent(commentName)}`, {
method: 'DELETE',
credentials: 'include',
headers,
});
if (!res.ok) {
const err = await res.json().catch(() => ({}));
throw new Error(err?.exc || 'Failed to delete comment');
}
},
async searchUsers(query: string): Promise<MentionUser[]> {
const baseUrl = API_CONFIG.BASE_URL || '';
const filters = query
? JSON.stringify([
['enabled', '=', 1],
['user_type', '=', 'System User'],
['name', 'like', `%${query}%`],
])
: JSON.stringify([
['enabled', '=', 1],
['user_type', '=', 'System User'],
]);
const fields = JSON.stringify(['name', 'full_name', 'user_image']);
const res = await fetch(
`${baseUrl}/api/resource/User?filters=${encodeURIComponent(filters)}&fields=${encodeURIComponent(fields)}&limit_page_length=20&order_by=full_name asc`,
{ credentials: 'include' }
);
if (!res.ok) return [];
const json = await res.json();
return (json.data ?? []).filter(
(u: MentionUser) => u.name !== 'Administrator' && u.name !== 'Guest'
);
},
async getCurrentUser(): Promise<string> {
const baseUrl = API_CONFIG.BASE_URL || '';
const res = await fetch(`${baseUrl}/api/method/frappe.auth.get_logged_user`, {
credentials: 'include',
});
const json = await res.json();
return json.message || '';
},
};
export default commentService;

View File

@ -76,34 +76,69 @@ export interface CreateItemData {
}
class ItemService {
private buildListParams(
filters?: Record<string, any>,
fields?: string[],
limit: number = 20,
offset: number = 0,
orderBy?: string
): URLSearchParams {
const params = new URLSearchParams();
if (filters) {
params.append('filters', JSON.stringify(filters));
}
if (fields) {
params.append('fields', JSON.stringify(fields));
}
params.append('limit_page_length', limit.toString());
params.append('limit_start', offset.toString());
if (orderBy) {
params.append('order_by', orderBy);
}
return params;
}
private async getItemCount(filters?: Record<string, any>): Promise<number> {
const total = await apiService.apiCall<number>(
'/api/method/frappe.client.get_count',
{
method: 'POST',
body: JSON.stringify({
doctype: 'Item',
filters: filters || {},
}),
}
);
return typeof total === 'number' ? total : 0;
}
// Get list of items
async getItems(filters?: Record<string, any>, fields?: string[], limit: number = 20, offset: number = 0): Promise<{ data: Item[]; total: number }> {
async getItems(
filters?: Record<string, any>,
fields?: string[],
limit: number = 20,
offset: number = 0,
orderBy?: string
): Promise<{ data: Item[]; total: number }> {
try {
const params = new URLSearchParams();
if (filters) {
params.append('filters', JSON.stringify(filters));
}
if (fields) {
params.append('fields', JSON.stringify(fields));
}
params.append('limit_page_length', limit.toString());
params.append('limit_start', offset.toString());
const response = await apiService.apiCall<{ data: Item[] }>(
`/api/resource/Item?${params.toString()}`
);
// Get total count by fetching all records (or use a count endpoint if available)
// For now, we'll use the response length and has_more to estimate
// In a real scenario, you might want to use a count endpoint
const total = response.data?.length || 0;
const params = this.buildListParams(filters, fields, limit, offset, orderBy);
const [listResponse, total] = await Promise.all([
apiService.apiCall<{ data: Item[] }>(`/api/resource/Item?${params.toString()}`),
this.getItemCount(filters),
]);
const data = listResponse.data || [];
return {
data: response.data || [],
total: total
data,
total,
};
} catch (error) {
console.error('Error fetching items:', error);

View File

@ -0,0 +1,112 @@
import { getYearToDateRange } from '../utils/dateUtils';
export const TECHNICIAN_WORK_SUMMARY_REPORT = 'Technician Work Order Summary';
export type TechnicianWorkSummaryRow = {
custom_assign_to_contractor?: string;
assigned_technician?: string;
total?: number;
completed?: number;
in_progress?: number;
open?: number;
};
export type TechnicianWorkSummaryFilters = {
work_order_type?: string;
from_date?: string;
to_date?: string;
company?: string;
site_name?: string;
};
export type TechnicianWorkSummaryChartData = {
labels: string[];
datasets: Array<{ name: string; values: number[]; color: string }>;
totals: { completed: number; in_progress: number; open: number; total: number };
rows: TechnicianWorkSummaryRow[];
};
const DATASET_COLORS = {
completed: '#10B981',
in_progress: '#3B82F6',
open: '#F59E0B',
} as const;
export const getDefaultTechnicianSummaryDateRange = () => getYearToDateRange();
export const getTechnicianLabel = (row: TechnicianWorkSummaryRow): string =>
(row.custom_assign_to_contractor || row.assigned_technician || 'Unknown').trim() || 'Unknown';
export const buildTechnicianWorkSummaryChartData = (
rows: TechnicianWorkSummaryRow[]
): TechnicianWorkSummaryChartData | null => {
if (!rows.length) return null;
const labels = rows.map(getTechnicianLabel);
const datasets = [
{ name: 'completed', values: rows.map(r => Number(r.completed) || 0), color: DATASET_COLORS.completed },
{ name: 'in_progress', values: rows.map(r => Number(r.in_progress) || 0), color: DATASET_COLORS.in_progress },
{ name: 'open', values: rows.map(r => Number(r.open) || 0), color: DATASET_COLORS.open },
].filter(ds => ds.values.some(v => v > 0));
const totals = rows.reduce(
(acc, row) => {
acc.completed += Number(row.completed) || 0;
acc.in_progress += Number(row.in_progress) || 0;
acc.open += Number(row.open) || 0;
acc.total += Number(row.total) || 0;
return acc;
},
{ completed: 0, in_progress: 0, open: 0, total: 0 }
);
return { labels, datasets, totals, rows };
};
export const runTechnicianWorkSummaryReport = async (
filters: TechnicianWorkSummaryFilters
): Promise<TechnicianWorkSummaryRow[]> => {
const csrfToken =
typeof window !== 'undefined' && (window as any).csrf_token && (window as any).csrf_token !== 'None'
? (window as any).csrf_token
: null;
const headers: Record<string, string> = {
'Content-Type': 'application/json',
Accept: 'application/json',
};
if (csrfToken) {
headers['X-Frappe-CSRF-Token'] = csrfToken;
}
const cleanedFilters: Record<string, string> = {};
Object.entries(filters).forEach(([key, value]) => {
if (value) cleanedFilters[key] = value;
});
const response = await fetch('/api/method/frappe.desk.query_report.run', {
method: 'POST',
headers,
credentials: 'include',
body: JSON.stringify({
report_name: TECHNICIAN_WORK_SUMMARY_REPORT,
filters: cleanedFilters,
ignore_prepared_report: 1,
}),
});
const payload = await response.json();
if (!response.ok) {
throw new Error(payload?.message || payload?.exc || 'Failed to load technician work summary');
}
const result = payload?.message?.result || payload?.message?.message?.result || [];
return Array.isArray(result) ? result : [];
};
export const fetchTechnicianWorkSummaryChart = async (
filters: TechnicianWorkSummaryFilters
): Promise<TechnicianWorkSummaryChartData | null> => {
const rows = await runTechnicianWorkSummaryReport(filters);
return buildTechnicianWorkSummaryChartData(rows);
};

View File

@ -0,0 +1,104 @@
import { getYearToDateRange } from '../utils/dateUtils';
export const TECHNICIAN_WORKING_HOURS_REPORT = 'Technicians working Hours';
export type TechnicianWorkingHoursRow = {
engineer?: string;
technician_name?: string;
total_hours?: number | string;
is_total_row?: number | boolean;
};
export type TechnicianWorkingHoursFilters = {
work_order_type?: string;
from_date?: string;
to_date?: string;
company?: string;
site_name?: string;
};
export type TechnicianWorkingHoursChartData = {
labels: string[];
datasets: Array<{ name: string; values: number[]; colors?: string[] }>;
totalHours: number;
rows: TechnicianWorkingHoursRow[];
};
export const getDefaultTechnicianHoursDateRange = () => getYearToDateRange();
export const getTechnicianHoursLabel = (row: TechnicianWorkingHoursRow): string =>
(row.technician_name || row.engineer || 'Unknown').trim() || 'Unknown';
export const parseTechnicianHours = (value: unknown): number => {
if (value == null || value === '') return 0;
const parsed = Number(value);
return Number.isFinite(parsed) && parsed >= 0 ? parsed : 0;
};
export const buildTechnicianWorkingHoursChartData = (
rows: TechnicianWorkingHoursRow[]
): TechnicianWorkingHoursChartData | null => {
const dataRows = rows.filter(row => row && !row.is_total_row);
if (!dataRows.length) return null;
const labels = dataRows.map(getTechnicianHoursLabel);
const values = dataRows.map(row => parseTechnicianHours(row.total_hours));
const totalHours = values.reduce((sum, value) => sum + value, 0);
return {
labels,
datasets: [{ name: 'Total Hours', values, colors: ['#6366F1'] }],
totalHours,
rows: dataRows,
};
};
export const runTechnicianWorkingHoursReport = async (
filters: TechnicianWorkingHoursFilters
): Promise<TechnicianWorkingHoursRow[]> => {
const csrfToken =
typeof window !== 'undefined' && (window as any).csrf_token && (window as any).csrf_token !== 'None'
? (window as any).csrf_token
: null;
const headers: Record<string, string> = {
'Content-Type': 'application/json',
Accept: 'application/json',
};
if (csrfToken) {
headers['X-Frappe-CSRF-Token'] = csrfToken;
}
const cleanedFilters: Record<string, string> = {};
Object.entries(filters).forEach(([key, value]) => {
if (value) cleanedFilters[key] = value;
});
const response = await fetch('/api/method/frappe.desk.query_report.run', {
method: 'POST',
headers,
credentials: 'include',
body: JSON.stringify({
report_name: TECHNICIAN_WORKING_HOURS_REPORT,
filters: cleanedFilters,
ignore_prepared_report: 1,
}),
});
const payload = await response.json();
if (!response.ok) {
throw new Error(payload?.message || payload?.exc || 'Failed to load technician working hours');
}
const result = payload?.message?.result || payload?.message?.message?.result || [];
if (!Array.isArray(result)) return [];
return result.filter(row => row && !row.is_total_row);
};
export const fetchTechnicianWorkingHoursChart = async (
filters: TechnicianWorkingHoursFilters
): Promise<TechnicianWorkingHoursChartData | null> => {
const rows = await runTechnicianWorkingHoursReport(filters);
return buildTechnicianWorkingHoursChartData(rows);
};

View File

@ -9,6 +9,9 @@ export interface UserProfile {
last_name?: string;
full_name: string;
role_profile_name?: string;
custom_site_name?: string;
custom_phcc_site_name?: string;
custom_department?: string;
username?: string;
language?: string;
time_zone?: string;
@ -17,6 +20,7 @@ export interface UserProfile {
roles: Array<{
role: string;
}>;
csrf_token?: string;
}
export interface UpdateUserProfileData {
@ -28,58 +32,35 @@ export interface UpdateUserProfileData {
custom_user_id?: string;
}
const storeCsrfToken = (token?: string) => {
if (token && token !== 'None' && typeof window !== 'undefined') {
(window as any).csrf_token = token;
}
};
/**
* Fetch current logged-in user's profile
*/
export const fetchCurrentUserProfile = async (): Promise<UserProfile> => {
try {
// First get the current user's email
const sessionResponse = await fetch('/api/method/frappe.auth.get_logged_user', {
method: 'GET',
headers: {
'Content-Type': 'application/json',
},
credentials: 'include',
});
const sessionData = await sessionResponse.json();
const userEmail = sessionData.message;
if (!userEmail) {
const result = await apiService.apiCall<UserProfile & { user_id?: string }>(
'/api/method/asset_lite.api.custom_api.get_user_details'
);
if (!result?.user_id && !result?.email) {
throw new Error('No user logged in');
}
// Use frappe.client.get which returns all fields including role_profile_name
const response = await fetch('/api/method/frappe.client.get', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
credentials: 'include',
body: JSON.stringify({
doctype: 'User',
name: userEmail,
}),
});
const result = await response.json();
// Debug: Log the response
console.log('frappe.client.get response:', result);
console.log('role_profile_name:', result.message?.role_profile_name);
if (result.message) {
const userData = result.message as UserProfile;
// Roles are included in the response as a child table
if (!userData.roles) {
userData.roles = [];
}
return userData;
}
throw new Error('Failed to fetch user profile');
storeCsrfToken(result.csrf_token);
const userData: UserProfile = {
...result,
name: result.user_id || result.email,
email: result.email || result.user_id || '',
roles: result.roles || [],
};
return userData;
} catch (error) {
console.error('Error fetching user profile:', error);
throw error;
@ -94,21 +75,28 @@ export const updateUserProfile = async (
data: UpdateUserProfileData
): Promise<UserProfile> => {
try {
const csrfToken = await apiService.getCSRFToken();
const headers: Record<string, string> = {
'Content-Type': 'application/json',
Accept: 'application/json',
};
if (csrfToken) {
headers['X-Frappe-CSRF-Token'] = csrfToken;
}
const response = await fetch(`/api/resource/User/${encodeURIComponent(userEmail)}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
},
headers,
credentials: 'include',
body: JSON.stringify(data),
});
const result = await response.json();
if (!response.ok) {
throw new Error(result.exc || result.message || 'Failed to update profile');
}
return result.data as UserProfile;
} catch (error) {
console.error('Error updating user profile:', error);
@ -124,25 +112,34 @@ export const changeUserPassword = async (
oldPassword?: string
): Promise<{ message: string }> => {
try {
if (!oldPassword) {
throw new Error('Current password is required');
}
const csrfToken = await apiService.getCSRFToken();
const headers: Record<string, string> = {
'Content-Type': 'application/json',
Accept: 'application/json',
};
if (csrfToken) {
headers['X-Frappe-CSRF-Token'] = csrfToken;
}
const response = await fetch('/api/method/frappe.core.doctype.user.user.update_password', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
headers,
credentials: 'include',
body: JSON.stringify({
new_password: newPassword,
old_password: oldPassword,
}),
});
const result = await response.json();
if (!response.ok) {
// Parse error message
let errorMsg = 'Failed to change password';
if (result.exc) {
// Try to extract meaningful error from exception
if (result.exc.includes('Incorrect Old Password')) {
errorMsg = 'Incorrect old password';
} else if (result.exc.includes('Password cannot be same')) {
@ -151,14 +148,14 @@ export const changeUserPassword = async (
try {
const messages = JSON.parse(result._server_messages);
errorMsg = JSON.parse(messages[0]).message || errorMsg;
} catch (e) {
} catch {
// Use default error
}
}
}
throw new Error(errorMsg);
}
return { message: 'Password changed successfully' };
} catch (error) {
console.error('Error changing password:', error);
@ -174,7 +171,7 @@ export const fetchRoleProfiles = async (): Promise<Array<{ name: string }>> => {
const response = await apiService.apiCall<any>(
`/api/resource/Role Profile?fields=["name"]&limit_page_length=100`
);
return response?.data || [];
} catch (error) {
console.error('Error fetching role profiles:', error);
@ -187,4 +184,4 @@ export default {
updateUserProfile,
changeUserPassword,
fetchRoleProfiles,
};
};

View File

@ -0,0 +1,86 @@
import API_CONFIG from '../config/api';
import apiService from './apiService';
import { formatFrappeApiError } from '../utils/frappeErrorMessage';
export type WoFeedbackReportFilters = {
work_order?: string;
requester_name?: string;
rating?: string;
company?: string;
site_name?: string;
};
export type WoFeedbackReportColumn = {
label?: string;
fieldname: string;
fieldtype?: string;
options?: string;
width?: number;
};
export type WoFeedbackReportResponse = {
columns?: WoFeedbackReportColumn[];
result?: Record<string, unknown>[];
};
export type WoFeedbackSummaryChartResponse = {
labels?: string[];
datasets?: Array<{ name?: string; values?: number[]; colors?: string[] }>;
type?: string;
};
async function callMethod<T>(method: string, args: Record<string, unknown> = {}): Promise<T> {
const csrf = (await apiService.getCSRFToken()) || (await apiService.getCSRFTokenForGuest());
const headers: Record<string, string> = {
Accept: 'application/json',
'Content-Type': 'application/json',
};
if (csrf) headers['X-Frappe-CSRF-Token'] = csrf;
const res = await fetch(`${API_CONFIG.BASE_URL}/api/method/${method}`, {
method: 'POST',
credentials: 'include',
headers,
body: JSON.stringify(args),
});
const body = await res.json().catch(() => ({}));
if (!res.ok || body.exc) {
throw new Error(formatFrappeApiError(body) || body?.message || `HTTP ${res.status}`);
}
return body.message as T;
}
function cleanFilters(filters: WoFeedbackReportFilters): Record<string, string> {
const cleaned: Record<string, string> = {};
Object.entries(filters).forEach(([key, value]) => {
if (value) cleaned[key] = value;
});
return cleaned;
}
export async function runWoFeedbackReport(
filters: WoFeedbackReportFilters = {}
): Promise<WoFeedbackReportResponse> {
return callMethod<WoFeedbackReportResponse>(
'asset_lite.api.wo_feedback_api.run_wo_feedback_report',
{ filters: cleanFilters(filters) }
);
}
export async function fetchWoFeedbackSummary(
filters: WoFeedbackReportFilters = {}
): Promise<WoFeedbackSummaryChartResponse> {
return callMethod<WoFeedbackSummaryChartResponse>(
'asset_lite.api.wo_feedback_api.get_wo_feedback_summary',
{ filters: cleanFilters(filters) }
);
}
const woFeedbackReportService = {
runWoFeedbackReport,
fetchWoFeedbackSummary,
};
export default woFeedbackReportService;

View File

@ -0,0 +1,146 @@
import API_CONFIG from '../config/api';
import apiService from './apiService';
import { formatFrappeApiError } from '../utils/frappeErrorMessage';
export const WO_FEEDBACK_WEB_FORM = 'wo-feedback';
export const WO_FEEDBACK_DOCTYPE = 'WO Feedback';
export interface WoFeedbackRecord {
name: string;
work_order: string;
requester_name?: string;
rating?: string;
comments?: string;
}
export interface WoFeedbackWebFormConfig {
title: string;
introductionHtml: string;
introductionText: string;
buttonLabel: string;
successTitle: string;
successMessage: string;
ratingOptions: string[];
}
export interface WoFeedbackSubmitPayload {
work_order: string;
requester_name: string;
rating: string;
comments?: string;
}
async function getCsrfToken(): Promise<string | null> {
const authed = await apiService.getCSRFToken();
if (authed) return authed;
return apiService.getCSRFTokenForGuest();
}
async function callMethod<T>(method: string, args: Record<string, unknown> = {}): Promise<T> {
const csrf = await getCsrfToken();
const headers: Record<string, string> = {
Accept: 'application/json',
'Content-Type': 'application/json',
};
if (csrf) headers['X-Frappe-CSRF-Token'] = csrf;
const res = await fetch(`${API_CONFIG.BASE_URL}/api/method/${method}`, {
method: 'POST',
credentials: 'include',
headers,
body: JSON.stringify(args),
});
const body = await res.json().catch(() => ({}));
if (!res.ok || body.exc) {
throw new Error(formatFrappeApiError(body) || body?.message || `HTTP ${res.status}`);
}
// Frappe omits `message` when the Python method returns None — response is `{}`.
if (!Object.prototype.hasOwnProperty.call(body, 'message')) {
return null as T;
}
return body.message as T;
}
function parseSelectOptions(options?: string): string[] {
if (!options) return [];
return options
.split('\n')
.map(o => o.trim())
.filter(Boolean);
}
function stripIntroductionHtml(html?: string): string {
if (!html) return '';
if (typeof document === 'undefined') {
return html.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim();
}
const doc = new DOMParser().parseFromString(html, 'text/html');
return (doc.body.textContent || '').replace(/\s+/g, ' ').trim();
}
export function parseWebFormConfig(raw: { web_form?: Record<string, unknown> }): WoFeedbackWebFormConfig {
const wf = raw?.web_form || {};
const fields = (wf.web_form_fields as Array<{ fieldname?: string; options?: string }>) || [];
const ratingField = fields.find(f => f.fieldname === 'rating');
return {
title: String(wf.title || 'WO Feedback'),
introductionHtml: String(wf.introduction_text || ''),
introductionText: stripIntroductionHtml(String(wf.introduction_text || '')),
buttonLabel: String(wf.button_label || 'Save'),
successTitle: String(wf.success_title || 'Feedback Submitted'),
successMessage: String(wf.success_message || 'Your feedback was submitted successfully.'),
ratingOptions: parseSelectOptions(ratingField?.options),
};
}
export async function getWebFormConfig(): Promise<WoFeedbackWebFormConfig> {
const params = new URLSearchParams({
doctype: WO_FEEDBACK_DOCTYPE,
web_form_name: WO_FEEDBACK_WEB_FORM,
});
const res = await fetch(
`${API_CONFIG.BASE_URL}/api/method/frappe.website.doctype.web_form.web_form.get_form_data?${params}`,
{ credentials: 'include', headers: { Accept: 'application/json' } },
);
const body = await res.json().catch(() => ({}));
if (!res.ok || body.exc) {
throw new Error(formatFrappeApiError(body) || 'Failed to load web form');
}
return parseWebFormConfig(body.message || body);
}
export async function getFeedbackForWorkOrder(workOrder: string): Promise<WoFeedbackRecord | null> {
const result = await callMethod<WoFeedbackRecord | null>(
'asset_lite.api.wo_feedback_api.get_wo_feedback_for_work_order',
{ work_order: workOrder },
);
if (!result?.name) return null;
return result;
}
export async function submitFeedback(payload: WoFeedbackSubmitPayload): Promise<WoFeedbackRecord> {
const data = JSON.stringify({
work_order: payload.work_order,
requester_name: payload.requester_name,
rating: payload.rating,
comments: payload.comments || '',
doctype: WO_FEEDBACK_DOCTYPE,
});
return callMethod<WoFeedbackRecord>(
'frappe.website.doctype.web_form.web_form.accept',
{ web_form: WO_FEEDBACK_WEB_FORM, data },
);
}
const woFeedbackWebFormService = {
getWebFormConfig,
getFeedbackForWorkOrder,
submitFeedback,
parseWebFormConfig,
};
export default woFeedbackWebFormService;

View File

@ -11,6 +11,8 @@ export interface WorkOrder {
asset_type?: string;
manufacturer?: string;
serial_number?: string;
custom_local_id?: string;
custom_moh_id?: string;
custom_serial_number?: string;
custom_manufacturer?: string;
custom_priority_?: string;
@ -78,6 +80,11 @@ export interface StockItem {
total_value: number;
}
export interface WorkOrderListQuery {
filters?: WorkOrderFilters | any[];
or_filters?: any[];
}
export interface WorkOrderListResponse {
work_orders: WorkOrder[];
total_count: number;
@ -142,16 +149,28 @@ class WorkOrderService {
* Get list of work orders with optional filters and pagination
*/
async getWorkOrders(
filters?: WorkOrderFilters,
query?: WorkOrderFilters | WorkOrderListQuery | any[],
fields?: string[],
limit: number = 20,
offset: number = 0,
orderBy?: string
): Promise<WorkOrderListResponse> {
const params = new URLSearchParams();
if (filters) {
params.append('filters', JSON.stringify(filters));
if (query) {
if (Array.isArray(query)) {
params.append('filters', JSON.stringify(query));
} else if ('or_filters' in query || ('filters' in query && !Array.isArray(query) && typeof query === 'object' && query.filters !== undefined)) {
const listQuery = query as WorkOrderListQuery;
if (listQuery.filters !== undefined) {
params.append('filters', JSON.stringify(listQuery.filters));
}
if (listQuery.or_filters?.length) {
params.append('or_filters', JSON.stringify(listQuery.or_filters));
}
} else {
params.append('filters', JSON.stringify(query));
}
}
if (fields && fields.length > 0) {

View File

@ -130,6 +130,7 @@ export const evaluateCondition = (condition: string | undefined, doc: Record<str
console.log('[Workflow] Document data for condition:', {
asset_type: doc.asset_type,
site_name: doc.site_name,
company: doc.company,
need_procurement: doc.need_procurement,
custom_assign_to_contractor: doc.custom_assign_to_contractor,
docstatus: doc.docstatus,
@ -150,9 +151,10 @@ export const evaluateCondition = (condition: string | undefined, doc: Record<str
evalCondition = evalCondition.replace(/doc\.(\w+)/g, (match, fieldName) => {
const value = doc[fieldName];
// Handle undefined, null, or empty string as falsy
if (value === undefined || value === null || value === '') {
return 'false'; // Use false for falsy check compatibility
// Use empty string so != / == comparisons behave correctly.
// Empty string is still falsy for conditions like: doc.site_name and ...
return '""';
}
if (typeof value === 'string') {
return `"${value.replace(/"/g, '\\"')}"`;
@ -166,15 +168,9 @@ export const evaluateCondition = (condition: string | undefined, doc: Record<str
return JSON.stringify(value);
});
// Handle == 1 and == 0 for boolean-like comparisons
evalCondition = evalCondition.replace(/== 1/g, '=== 1');
evalCondition = evalCondition.replace(/== 0/g, '=== 0');
evalCondition = evalCondition.replace(/!= 1/g, '!== 1');
evalCondition = evalCondition.replace(/!= 0/g, '!== 0');
// Fix negation of false (from empty fields) - !false should be true
// This handles cases like "not doc.site_name" where site_name is empty
// Normalize inequality/equality operators
evalCondition = evalCondition.replace(/!=/g, '!==');
evalCondition = evalCondition.replace(/(?<![=!])==(?!=)/g, '===');
console.log('[Workflow] Transformed condition:', evalCondition);
// Evaluate the condition safely
@ -522,6 +518,16 @@ export const getWorkflowStateStyle = (state: string): { bg: string; text: string
text: 'text-blue-800 dark:text-blue-200',
border: 'border-blue-300 dark:border-blue-600'
},
'Sent To Project Manager': {
bg: 'bg-blue-100 dark:bg-blue-900/30',
text: 'text-blue-800 dark:text-blue-200',
border: 'border-blue-300 dark:border-blue-600'
},
'Sent To Site Manager': {
bg: 'bg-blue-100 dark:bg-blue-900/30',
text: 'text-blue-800 dark:text-blue-200',
border: 'border-blue-300 dark:border-blue-600'
},
'Sent to General WOA': {
bg: 'bg-blue-100 dark:bg-blue-900/30',
text: 'text-blue-800 dark:text-blue-200',
@ -587,6 +593,8 @@ export const getActionButtonStyle = (action: string): string => {
'Re-Open': 'bg-blue-600 hover:bg-blue-700 text-white',
'Cancel': 'bg-red-600 hover:bg-red-700 text-white',
'Approve': 'bg-green-600 hover:bg-green-700 text-white',
'Approve & Close': 'bg-green-600 hover:bg-green-700 text-white',
'Work Done': 'bg-green-600 hover:bg-green-700 text-white',
};
return actionStyles[action] || 'bg-blue-600 hover:bg-blue-700 text-white';
@ -607,6 +615,8 @@ export const getActionIcon = (action: string): string => {
'Re-Open': '🔓',
'Cancel': '🚫',
'Approve': '✅',
'Approve & Close': '✅',
'Work Done': '✅',
};
return actionIcons[action] || '▶️';

View File

@ -0,0 +1,58 @@
import { fetchCurrentUserProfile } from '../services/userProfileService';
import { getDefaultRoute } from './roleAccess';
export async function persistUserProfileAndGetRoute(): Promise<string> {
try {
const profile = await fetchCurrentUserProfile();
const storedUser = localStorage.getItem('user');
if (storedUser) {
const parsed = JSON.parse(storedUser);
localStorage.setItem(
'user',
JSON.stringify({
...parsed,
email: profile.email || profile.name || parsed.email,
full_name: profile.full_name || parsed.full_name,
role_profile_name: profile.role_profile_name || '',
custom_site_name: profile.custom_site_name || '',
custom_phcc_site_name: profile.custom_phcc_site_name || '',
})
);
}
return getDefaultRoute(profile.role_profile_name);
} catch (err) {
console.warn('[AppRouting] Could not load role profile, using default route:', err);
return '/dashboard';
}
}
export async function resolveAuthenticatedRoute(): Promise<string | null> {
const storedUser = localStorage.getItem('user');
if (!storedUser) {
return null;
}
try {
const response = await fetch('/api/method/asset_lite.api.custom_api.get_user_details', {
method: 'GET',
headers: { Accept: 'application/json' },
credentials: 'include',
});
if (!response.ok) {
localStorage.removeItem('user');
return null;
}
const data = await response.json();
if (!data.message?.user_id) {
localStorage.removeItem('user');
return null;
}
return persistUserProfileAndGetRoute();
} catch {
localStorage.removeItem('user');
return null;
}
}

View File

@ -0,0 +1,33 @@
import type { DashboardLocationFilters } from './hospitalUtils';
type AssetUrlFilters = {
device_status?: string;
company?: string;
site_name?: string;
};
export function buildAssetUrl(
filters: AssetUrlFilters,
fromDate?: string,
toDate?: string,
locationFilters?: DashboardLocationFilters
): string {
const params = new URLSearchParams();
if (filters.device_status) params.set('device_status', filters.device_status);
if (filters.company || locationFilters?.company) {
params.set('company', filters.company || locationFilters?.company || '');
}
if (filters.site_name || locationFilters?.site_name) {
params.set('site_name', filters.site_name || locationFilters?.site_name || '');
}
if (fromDate || toDate) {
params.set('date_filter_by', 'creation');
if (fromDate) params.set('date_start', fromDate);
if (toDate) params.set('date_end', toDate);
}
const qs = params.toString();
return `/assets${qs ? `?${qs}` : ''}`;
}

View File

@ -0,0 +1,33 @@
import type { DashboardLocationFilters } from './hospitalUtils';
type MaintenanceUrlFilters = {
assignee?: string;
status?: string;
company?: string;
site_name?: string;
};
export function buildMaintenanceUrl(
filters: MaintenanceUrlFilters,
fromDate?: string,
toDate?: string,
locationFilters?: DashboardLocationFilters
): string {
const params = new URLSearchParams();
if (filters.assignee) params.set('assignee', filters.assignee);
if (filters.status) params.set('status', filters.status);
if (filters.company || locationFilters?.company) {
params.set('company', filters.company || locationFilters?.company || '');
}
if (filters.site_name || locationFilters?.site_name) {
params.set('site_name', filters.site_name || locationFilters?.site_name || '');
}
if (fromDate) params.set('date_start', fromDate);
if (toDate) params.set('date_end', toDate);
const qs = params.toString();
return `/maintenance${qs ? `?${qs}` : ''}`;
}

View File

@ -0,0 +1,36 @@
import type { DashboardLocationFilters } from './hospitalUtils';
type WoUrlFilters = {
status?: string;
work_order_type?: string;
company?: string;
site_name?: string;
};
export function buildWoUrl(
filters: WoUrlFilters,
fromDate?: string,
toDate?: string,
locationFilters?: DashboardLocationFilters
): string {
const params = new URLSearchParams();
if (filters.status) params.set('status', filters.status);
if (filters.work_order_type) params.set('work_order_type', filters.work_order_type);
if (filters.company || locationFilters?.company) {
params.set('company', filters.company || locationFilters?.company || '');
}
if (filters.site_name || locationFilters?.site_name) {
params.set('site_name', filters.site_name || locationFilters?.site_name || '');
}
if (fromDate || toDate) {
params.set('date_filter_by', 'creation');
if (fromDate) params.set('date_start', fromDate);
if (toDate) params.set('date_end', toDate);
}
const qs = params.toString();
// Router basename is /asm_app — use app-relative path, not /asm_app/work-orders
return `/work-orders${qs ? `?${qs}` : ''}`;
}

View File

@ -0,0 +1,51 @@
export function isNullOrUnknownLabel(label: string | null | undefined): boolean {
const key = (label || '').trim().toLowerCase();
return key === '' || key === 'null' || key === 'unknown' || key === 'none' || key === 'undefined';
}
export const REPAIR_COMPLETION_BAR_COLORS = [
'#8B5CF6',
'#6366F1',
'#3B82F6',
'#06B6D4',
'#14B8A6',
'#EC4899',
'#A855F7',
'#0EA5E9',
'#10B981',
'#F472B6',
'#7C3AED',
'#2DD4BF',
];
export type CompletionByTypeRate = {
type: string;
total: number;
open?: number;
inProgress?: number;
pendingReview?: number;
completed?: number;
closed?: number;
completedCombined?: number;
rejected?: number;
cancelled?: number;
};
export type CompletionByTypeData = {
labels?: string[];
rates?: CompletionByTypeRate[];
};
export function getCombinedCompletedCount(
completed?: number,
closed?: number
): number {
return (completed || 0) + (closed || 0);
}
export function getRepairCompletionRate(rate: CompletionByTypeRate): number {
const total = rate.total || 0;
if (total <= 0) return 0;
const combined = rate.completedCombined ?? ((rate.completed || 0) + (rate.closed || 0));
return Math.round((combined / total) * 10000) / 100;
}

View File

@ -0,0 +1,16 @@
/** Format a Date as YYYY-MM-DD in local timezone (avoids UTC shift from toISOString). */
export function formatLocalDate(date: Date): string {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
}
export function getYearToDateRange(): { from_date: string; to_date: string } {
const today = new Date();
const yearStart = new Date(today.getFullYear(), 0, 1);
return {
from_date: formatLocalDate(yearStart),
to_date: formatLocalDate(today),
};
}

View File

@ -0,0 +1,146 @@
/**
* Turn long Frappe/Python/MySQL error strings into short text safe for toasts.
* For user-facing duplicate messages with i18n, use `extractDuplicateEntryId` in the UI
* and translate there; `shortFrappeErrorMessage` is English fallbacks for other errors.
*/
const MAX_LEN = 220;
/** Extract MySQL duplicate key value from common Frappe error shapes */
export function extractDuplicateEntryId(raw: string): string | undefined {
const m =
raw.match(/Duplicate entry\s+['"]([^'"]+)['"]\s+for key/i) ||
raw.match(/Duplicate entry\s+([^\s,')]+)\s+for key/i);
return m?.[1]?.trim() || undefined;
}
function stripTracebackAndPickTail(raw: string): string {
let s = raw.replace(/\r\n/g, '\n').trim();
if (s.startsWith('["') && s.endsWith('"]')) {
try {
const parsed = JSON.parse(s) as unknown;
if (Array.isArray(parsed) && typeof parsed[0] === 'string') {
s = parsed[0];
}
} catch {
/* keep s */
}
}
if (s.includes('|')) {
const parts = s.split('|');
const tail = parts[parts.length - 1]?.trim() ?? s;
if (tail.length > 0 && !tail.includes('File "')) {
s = tail;
}
}
if (s.includes('Traceback (most recent call last)')) {
const idx = s.lastIndexOf('IntegrityError');
if (idx >= 0) s = s.slice(idx);
else {
const lastLine = s
.split('\n')
.map((l) => l.trim())
.filter((l) => l.length > 0 && !l.startsWith('File "'))
.pop();
if (lastLine && lastLine.length < 400) s = lastLine;
}
}
return s.replace(/\s+/g, ' ').trim();
}
/**
* Turn Frappe HTML validation messages into plain text for toasts and alerts.
*/
export function frappeHtmlToPlainText(html: string): string {
if (!html || typeof html !== 'string') return '';
let s = html
.replace(/<br\s*\/?>/gi, '\n')
.replace(/<\/(p|div|li|tr)>/gi, '\n')
// Links may contain nested <strong> etc.; keep visible text only
.replace(/<a[^>]*>([\s\S]*?)<\/a>/gi, (_, inner: string) =>
inner.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim(),
)
.replace(/<[^>]+>/g, ' ');
s = s
.replace(/&nbsp;/g, ' ')
.replace(/&amp;/g, '&')
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&#39;/g, "'")
.replace(/&quot;/g, '"');
return s.replace(/[ \t]+\n/g, '\n').replace(/\n{3,}/g, '\n\n').replace(/ +/g, ' ').trim();
}
/** Parse Frappe API error body / thrown Error into a user-readable string (no raw HTML). */
export function formatFrappeApiError(err: unknown): string {
if (err == null) return 'Something went wrong';
if (err instanceof Error) return frappeHtmlToPlainText(err.message) || err.message;
if (typeof err === 'string') return frappeHtmlToPlainText(err);
const e = err as Record<string, unknown>;
let msg = typeof e.message === 'string' ? e.message : '';
if (e._server_messages) {
try {
const raw = e._server_messages;
const arr = typeof raw === 'string' ? JSON.parse(raw) : raw;
if (Array.isArray(arr)) {
const parts: string[] = [];
for (const item of arr) {
try {
const o = typeof item === 'string' ? JSON.parse(item) : item;
if (o && typeof (o as { message?: string }).message === 'string') {
parts.push(frappeHtmlToPlainText((o as { message: string }).message));
} else if (typeof item === 'string') parts.push(frappeHtmlToPlainText(item));
} catch {
if (typeof item === 'string') parts.push(frappeHtmlToPlainText(item));
}
}
if (parts.length) msg = parts.join('\n');
}
} catch {
/* ignore */
}
}
if (!msg && typeof e.exc === 'string' && e.exc.length < 800) msg = e.exc;
if (!msg) return 'Request failed. Check stock, warehouses, and linked documents.';
return frappeHtmlToPlainText(msg);
}
/**
* Short English message for non-duplicate server errors (tracebacks stripped).
*/
export function shortFrappeErrorMessage(raw: string): string {
if (!raw || typeof raw !== 'string') {
return 'Something went wrong. Please try again.';
}
const dupId = extractDuplicateEntryId(raw);
if (dupId || (/1062/i.test(raw) && /Duplicate entry/i.test(raw))) {
return dupId
? `This ID is already in use (${dupId}). Use a different location name or ID.`
: 'This record already exists. Use a unique name or ID.';
}
let s = stripTracebackAndPickTail(raw);
const quoted = s.match(/IntegrityError\([^)]*"([^"]{1,180})"/i);
if (quoted?.[1] && !quoted[1].includes('Traceback')) {
s = quoted[1];
}
s = s.replace(/^\(['"]?\w+['"]?,\s*['"][^'"]*['"]?\s*,\s*/i, '');
if (s.length > MAX_LEN) {
return `${s.slice(0, MAX_LEN - 1)}`;
}
return s || 'Something went wrong. Please try again.';
}
/** True when the raw error indicates a duplicate primary / unique key (MySQL 1062). */
export function isDuplicateKeyError(raw: string): boolean {
return (
!!extractDuplicateEntryId(raw) ||
(/1062/i.test(raw) && /Duplicate entry/i.test(raw)) ||
/IntegrityError\s*\(\s*1062/i.test(raw)
);
}

View File

@ -0,0 +1,93 @@
/** Hospitals that use Mobile Team Site (Site Name) filtering. */
export function isSiteEnabledHospital(company?: string | null): boolean {
if (!company) return false;
return company.startsWith('Mobile') || company.startsWith('Dental');
}
export type DashboardLocationFilters = {
company?: string;
site_name?: string;
from_date?: string;
to_date?: string;
};
/** Filters for dashboard chart/report API (company + site_name + optional dates). */
export function buildDashboardReportFilters(
hospital?: string,
siteName?: string,
fromDate?: string,
toDate?: string
): DashboardLocationFilters {
const filters: DashboardLocationFilters = {};
if (hospital) filters.company = hospital;
if (siteName && isSiteEnabledHospital(hospital)) filters.site_name = siteName;
if (fromDate) filters.from_date = fromDate;
if (toDate) filters.to_date = toDate;
return filters;
}
/** Work Order list API filters. */
export function buildWorkOrderDashboardFilters(
hospital?: string,
siteName?: string
): Record<string, string> {
const filters: Record<string, string> = {};
if (hospital) filters.company = hospital;
if (siteName && isSiteEnabledHospital(hospital)) filters.site_name = siteName;
return filters;
}
/** Asset Maintenance Log list API filters. */
export function buildMaintenanceLogDashboardFilters(
hospital?: string,
siteName?: string
): Record<string, string> {
const filters: Record<string, string> = {};
if (hospital) filters.custom_hospital_name = hospital;
if (siteName && isSiteEnabledHospital(hospital)) filters.site_name = siteName;
return filters;
}
/** Frappe REST Asset resource filter tuples. */
export function buildAssetResourceFilters(
hospital?: string,
siteName?: string
): [string, string, string][] {
const filters: [string, string, string][] = [];
if (hospital) filters.push(['company', '=', hospital]);
if (siteName && isSiteEnabledHospital(hospital)) {
filters.push(['custom_site', '=', siteName]);
}
return filters;
}
/** LinkField filters for Mobile Team Site (Asset custom_site / Work Order site_name). */
export function buildMobileTeamSiteFilters(
company?: string | null,
userPhccSiteName?: string | null
): Record<string, string> {
const filters: Record<string, string> = {};
if (company && isSiteEnabledHospital(company)) {
filters.mobile_team = company;
}
if (userPhccSiteName) {
filters.name = userPhccSiteName;
}
return filters;
}
/** LinkField filters for Company/Hospital based on user permissions. */
export function buildHospitalLinkFilters(
allowedHospitals?: string[]
): Record<string, unknown> {
const filters: Record<string, unknown> = { domain: 'Healthcare' };
if (allowedHospitals?.length) {
filters.name = ['in', allowedHospitals];
}
return filters;
}

View File

@ -0,0 +1,113 @@
/**
* Converts a filter object (including date range keys) to Frappe filter array format.
*/
export type SortOption =
| 'creation desc'
| 'creation asc'
| 'modified desc'
| 'modified asc'
| 'name asc'
| 'name desc'
| 'asset_name asc'
| 'asset_name desc';
export type DateFilterField = '' | 'creation' | 'modified';
const VALID_SORT_OPTIONS: SortOption[] = [
'creation desc',
'creation asc',
'modified desc',
'modified asc',
'name asc',
'name desc',
'asset_name asc',
'asset_name desc',
];
export function normalizeSortBy(value: string, fallback: SortOption = 'creation desc'): SortOption {
return VALID_SORT_OPTIONS.includes(value as SortOption) ? (value as SortOption) : fallback;
}
export function toFrappeFilterArray(filters: Record<string, any>): any[] {
if (!filters || typeof filters !== 'object') return [];
const arr: any[] = [];
for (const [key, value] of Object.entries(filters)) {
if (value === undefined || value === null || value === '') continue;
if (key === 'creation_from') {
arr.push(['creation', '>=', value]);
} else if (key === 'creation_to') {
const d = new Date(value as string);
if (!isNaN(d.getTime())) {
d.setDate(d.getDate() + 1);
arr.push(['creation', '<', d.toISOString().slice(0, 10)]);
}
} else if (key === 'modified_from') {
arr.push(['modified', '>=', value]);
} else if (key === 'modified_to') {
const d = new Date(value as string);
if (!isNaN(d.getTime())) {
d.setDate(d.getDate() + 1);
arr.push(['modified', '<', d.toISOString().slice(0, 10)]);
}
} else if (Array.isArray(value) && value.length >= 2) {
arr.push([key, value[0], value[1]]);
} else {
arr.push([key, '=', value]);
}
}
return arr;
}
export function buildDateRangeFilters(
dateFilterBy: '' | 'creation' | 'modified',
dateStart: string,
dateEnd: string
): Record<string, string> {
if (!dateFilterBy || (!dateStart && !dateEnd)) return {};
const f: Record<string, string> = {};
if (dateFilterBy === 'creation') {
if (dateStart) f.creation_from = dateStart;
if (dateEnd) f.creation_to = dateEnd;
} else {
if (dateStart) f.modified_from = dateStart;
if (dateEnd) f.modified_to = dateEnd;
}
return f;
}
/** Merge date range into object filters for custom list APIs (get_assets, etc.) */
export function applyDateRangeToFilters(
filters: Record<string, any>,
dateFilterBy: '' | 'creation' | 'modified',
dateStart: string,
dateEnd: string
): Record<string, any> {
const result = { ...filters, ...buildDateRangeFilters(dateFilterBy, dateStart, dateEnd) };
const applyRange = (field: 'creation' | 'modified') => {
const fromKey = `${field}_from`;
const toKey = `${field}_to`;
const fromVal = result[fromKey];
const toVal = result[toKey];
delete result[fromKey];
delete result[toKey];
if (fromVal && toVal) {
result[field] = ['between', [`${fromVal} 00:00:00`, `${toVal} 23:59:59`]];
} else if (fromVal) {
result[field] = ['>=', `${fromVal} 00:00:00`];
} else if (toVal) {
const d = new Date(toVal as string);
if (!isNaN(d.getTime())) {
d.setDate(d.getDate() + 1);
result[field] = ['<', `${d.toISOString().slice(0, 10)} 00:00:00`];
}
}
};
if (dateFilterBy === 'creation') applyRange('creation');
if (dateFilterBy === 'modified') applyRange('modified');
return result;
}

View File

@ -0,0 +1,141 @@
const MOBILE_OVERLAY_ID = 'asm-html-report-overlay';
export type HtmlReportHandle = {
write: (html: string) => void;
close: () => void;
};
const isMobileViewport = (): boolean =>
typeof window !== 'undefined' && window.matchMedia('(max-width: 1023px)').matches;
export function toAbsoluteSiteUrl(url: string): string {
if (!url) return url;
if (/^https?:\/\//i.test(url)) return url;
const origin = window.location.origin;
return url.startsWith('/') ? `${origin}${url}` : `${origin}/${url}`;
}
function injectBaseTag(html: string): string {
const baseHref = `${window.location.origin}/`;
if (/<base[\s>]/i.test(html)) return html;
if (/<head[^>]*>/i.test(html)) {
return html.replace(/<head[^>]*>/i, match => `${match}<base href="${baseHref}">`);
}
return `<head><base href="${baseHref}"></head>${html}`;
}
function normalizeHtml(html: string): string {
const trimmed = html.trim();
if (/^<!DOCTYPE/i.test(trimmed) || /^<html/i.test(trimmed)) {
return injectBaseTag(trimmed);
}
return injectBaseTag(`<!DOCTYPE html><html><head><meta charset="utf-8"></head><body>${trimmed}</body></html>`);
}
export function showReportBlockedMessage(): void {
window.alert('Please allow popups for this site to view the report.');
}
function removeMobileOverlay(): void {
document.getElementById(MOBILE_OVERLAY_ID)?.remove();
}
function openMobileOverlay(initialHtml?: string): HtmlReportHandle | null {
document.body.style.overflow = '';
removeMobileOverlay();
const overlay = document.createElement('div');
overlay.id = MOBILE_OVERLAY_ID;
overlay.style.cssText =
'position:fixed;inset:0;z-index:2147483646;background:#fff;display:flex;flex-direction:column;height:100dvh;width:100vw;';
const toolbar = document.createElement('div');
toolbar.style.cssText =
'display:flex;gap:8px;padding:8px 12px;border-bottom:1px solid #e5e7eb;background:#f9fafb;flex-shrink:0;';
const printBtn = document.createElement('button');
printBtn.type = 'button';
printBtn.textContent = 'Print';
printBtn.style.cssText =
'min-height:44px;padding:0 16px;border-radius:8px;border:1px solid #d1d5db;background:#fff;font-size:14px;font-weight:600;';
const closeBtn = document.createElement('button');
closeBtn.type = 'button';
closeBtn.textContent = 'Close';
closeBtn.style.cssText =
'min-height:44px;padding:0 16px;border-radius:8px;border:1px solid #d1d5db;background:#fff;font-size:14px;font-weight:600;margin-left:auto;';
const iframe = document.createElement('iframe');
iframe.title = 'Report';
iframe.style.cssText = 'flex:1;width:100%;border:0;background:#fff;';
let blobUrl: string | null = null;
const setIframeHtml = (html: string) => {
if (blobUrl) {
URL.revokeObjectURL(blobUrl);
blobUrl = null;
}
const normalized = normalizeHtml(html);
const blob = new Blob([normalized], { type: 'text/html;charset=utf-8' });
blobUrl = URL.createObjectURL(blob);
iframe.src = blobUrl;
};
const close = () => {
if (blobUrl) URL.revokeObjectURL(blobUrl);
removeMobileOverlay();
};
printBtn.addEventListener('click', () => {
try {
iframe.contentWindow?.focus();
iframe.contentWindow?.print();
} catch {
window.alert('Unable to print this report on this device.');
}
});
closeBtn.addEventListener('click', close);
toolbar.appendChild(printBtn);
toolbar.appendChild(closeBtn);
overlay.appendChild(toolbar);
overlay.appendChild(iframe);
document.body.appendChild(overlay);
if (initialHtml) {
setIframeHtml(initialHtml);
}
return {
write: setIframeHtml,
close,
};
}
function openDesktopReport(initialHtml?: string): HtmlReportHandle | null {
const win = window.open('', '_blank');
if (!win) return null;
if (initialHtml) {
win.document.open();
win.document.write(normalizeHtml(initialHtml));
win.document.close();
}
return {
write: (html: string) => {
win.document.open();
win.document.write(normalizeHtml(html));
win.document.close();
},
close: () => win.close(),
};
}
export function openHtmlReport(initialHtml?: string): HtmlReportHandle | null {
if (isMobileViewport()) {
return openMobileOverlay(initialHtml);
}
return openDesktopReport(initialHtml);
}

View File

@ -0,0 +1,27 @@
export const compactFilterLabelClass =
'block font-medium text-gray-700 dark:text-gray-300 text-[10px] mb-0.5';
export const compactFilterInputClass =
'w-full px-2 py-1 text-xs border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:outline-none focus:ring-1 focus:ring-emerald-500 disabled:bg-gray-100 dark:disabled:bg-gray-700';
export const compactFilterFieldWrapClass = 'relative w-full mb-2';
export const compactToolbarBtnBaseClass =
'inline-flex items-center gap-1 px-2 py-1 rounded-md text-[11px] font-medium disabled:opacity-50 disabled:cursor-not-allowed';
export const compactToolbarBtnPrimary = (color: 'cyan' | 'purple' | 'emerald' = 'cyan') => {
const colors = {
cyan: 'bg-cyan-600 hover:bg-cyan-700 text-white',
purple: 'bg-purple-600 hover:bg-purple-700 text-white',
emerald: 'bg-emerald-600 hover:bg-emerald-700 text-white',
};
return `${compactToolbarBtnBaseClass} ${colors[color]} disabled:opacity-60`;
};
export const compactToolbarBtnSecondary = `${compactToolbarBtnBaseClass} border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-gray-800`;
export const compactSummaryCardClass = 'rounded-lg border px-3 py-2';
export const compactSummaryLabelClass = 'text-[10px] text-gray-500 dark:text-gray-400';
export const compactSummaryValueClass = 'text-lg font-semibold text-gray-900 dark:text-white';

View File

@ -0,0 +1,147 @@
export const ROLE_PROFILE = {
END_USER: 'End User',
PHCC_END_USER: 'PHCC End User',
TECHNICIAN: 'Technician',
} as const;
export type SidebarLinkId =
| 'dashboard'
| 'assets'
| 'inventory'
| 'work-orders'
| 'ppm-planner'
| 'maintenance-calendar'
| 'active-map'
| 'maintenance-teams'
| 'procurement'
| 'sla'
| 'support';
const END_USER_LINKS: SidebarLinkId[] = [
'assets',
'work-orders',
'support',
];
const TECHNICIAN_LINKS: SidebarLinkId[] = [
'dashboard',
'assets',
'inventory',
'work-orders',
'maintenance-calendar',
'active-map',
'procurement',
'support',
];
export function isEndUserRole(roleProfile?: string | null): boolean {
return (
roleProfile === ROLE_PROFILE.END_USER ||
roleProfile === ROLE_PROFILE.PHCC_END_USER
);
}
export function isTechnicianRole(roleProfile?: string | null): boolean {
return roleProfile === ROLE_PROFILE.TECHNICIAN;
}
export function getDefaultRoute(roleProfile?: string | null): string {
if (isEndUserRole(roleProfile)) {
return '/work-orders';
}
return '/dashboard';
}
export function isSidebarLinkVisible(
linkId: SidebarLinkId,
roleProfile?: string | null
): boolean {
if (isEndUserRole(roleProfile)) {
return END_USER_LINKS.includes(linkId);
}
if (isTechnicianRole(roleProfile)) {
return TECHNICIAN_LINKS.includes(linkId);
}
return true;
}
export function getStoredUserEmail(): string {
try {
const user = localStorage.getItem('user');
if (!user) return '';
const parsed = JSON.parse(user);
return parsed.email || parsed.user_id || parsed.name || '';
} catch {
return '';
}
}
export function getStoredRoleProfile(): string {
try {
const user = localStorage.getItem('user');
if (!user) return '';
const parsed = JSON.parse(user);
return parsed.role_profile_name || '';
} catch {
return '';
}
}
export function buildWorkOrderRoleFilters(
roleProfile: string | null | undefined,
userEmail: string
): { filters?: Record<string, any>; or_filters?: any[] } | null {
if (!userEmail) return null;
if (isEndUserRole(roleProfile)) {
return { filters: { owner: userEmail } };
}
if (isTechnicianRole(roleProfile)) {
return {
or_filters: [
['owner', '=', userEmail],
['custom_assign_to_contractor', '=', userEmail],
['assigned_technician', '=', userEmail],
],
};
}
return null;
}
export type WorkOrderListQuery = {
filters?: Record<string, any> | any[];
or_filters?: any[];
};
export function mergeWorkOrderFilters(
userFilters: Record<string, any>,
permissionFilters: Record<string, any>,
roleProfile?: string | null,
userEmail?: string
): WorkOrderListQuery {
const merged: Record<string, any> = { ...userFilters };
for (const [field, value] of Object.entries(permissionFilters)) {
if (!merged[field]) {
merged[field] = value;
}
}
const roleFilters = buildWorkOrderRoleFilters(roleProfile, userEmail || '');
if (!roleFilters) {
return { filters: merged };
}
return {
filters: {
...merged,
...(roleFilters.filters || {}),
},
...(roleFilters.or_filters ? { or_filters: roleFilters.or_filters } : {}),
};
}

View File

@ -0,0 +1,189 @@
import apiService from '../services/apiService';
import { fetchCurrentUserProfile } from '../services/userProfileService';
let cachedHospitalName: string | null = null;
let cachedPhccSiteName: string | null = null;
let fetchPromise: Promise<UserLocationFilters> | null = null;
export interface UserLocationFilters {
hospital: string;
siteName: string;
isLocked: boolean;
allowedHospitals: string[];
}
async function fetchAllowedHospitals(): Promise<string[]> {
try {
const [companyRes, hospitalRes] = await Promise.all([
apiService.getAllowedValues('Company'),
apiService.getAllowedValues('Hospital'),
]);
if (companyRes.is_admin || hospitalRes.is_admin) {
return [];
}
const values = new Set<string>();
if (companyRes.has_restriction) {
companyRes.allowed_values.forEach((value) => values.add(value));
}
if (hospitalRes.has_restriction) {
hospitalRes.allowed_values.forEach((value) => values.add(value));
}
return Array.from(values).sort();
} catch (error) {
console.error('[userHospital] Failed to load allowed hospitals:', error);
return [];
}
}
function resolveDefaultHospital(
profileHospital: string,
allowedHospitals: string[],
permissionDefault?: string | null
): string {
if (allowedHospitals.length === 1) {
return allowedHospitals[0];
}
if (profileHospital && (!allowedHospitals.length || allowedHospitals.includes(profileHospital))) {
return profileHospital;
}
if (permissionDefault && allowedHospitals.includes(permissionDefault)) {
return permissionDefault;
}
return '';
}
function resolveHospitalLock(allowedHospitals: string[], profileHospital: string): boolean {
if (allowedHospitals.length === 1) {
return true;
}
if (allowedHospitals.length > 1) {
return false;
}
return !!profileHospital;
}
export function getStoredHospitalName(): string {
try {
const user = localStorage.getItem('user');
if (!user) {
return '';
}
const parsed = JSON.parse(user);
return parsed.custom_site_name || '';
} catch {
return '';
}
}
export function getStoredPhccSiteName(): string {
try {
const user = localStorage.getItem('user');
if (!user) {
return '';
}
const parsed = JSON.parse(user);
return parsed.custom_phcc_site_name || '';
} catch {
return '';
}
}
export function persistHospitalToLocalStorage(
customSiteName: string,
customPhccSiteName?: string
): void {
try {
const storedUser = localStorage.getItem('user');
if (!storedUser) {
return;
}
const parsed = JSON.parse(storedUser);
localStorage.setItem(
'user',
JSON.stringify({
...parsed,
custom_site_name: customSiteName,
...(customPhccSiteName !== undefined
? { custom_phcc_site_name: customPhccSiteName }
: {}),
})
);
} catch (error) {
console.warn('[userHospital] Failed to persist hospital to localStorage:', error);
}
}
export async function fetchUserHospitalName(): Promise<string> {
const filters = await fetchUserLocationFilters();
return filters.hospital;
}
export async function fetchUserLocationFilters(): Promise<UserLocationFilters> {
if (fetchPromise) {
return fetchPromise;
}
fetchPromise = (async () => {
try {
const [profile, allowedHospitals, companyPerm] = await Promise.all([
fetchCurrentUserProfile().catch(() => null),
fetchAllowedHospitals(),
apiService.getAllowedValues('Company').catch(() => null),
]);
let hospitalName = profile?.custom_site_name || '';
let siteName = profile?.custom_phcc_site_name || '';
if (!hospitalName) {
const userDetails = await apiService.getUserDetails();
hospitalName = userDetails.custom_site_name || '';
siteName = siteName || userDetails.custom_phcc_site_name || '';
}
const defaultHospital = resolveDefaultHospital(
hospitalName,
allowedHospitals,
companyPerm?.default_value
);
const isLocked = resolveHospitalLock(allowedHospitals, hospitalName);
if (defaultHospital) {
cachedHospitalName = defaultHospital;
cachedPhccSiteName = siteName;
persistHospitalToLocalStorage(defaultHospital, siteName);
}
return {
hospital: defaultHospital,
siteName,
isLocked,
allowedHospitals,
};
} catch (error) {
console.error('[userHospital] Failed to load user location filters:', error);
return {
hospital: getStoredHospitalName(),
siteName: getStoredPhccSiteName(),
isLocked: false,
allowedHospitals: [],
};
} finally {
fetchPromise = null;
}
})();
return fetchPromise;
}
export function clearUserHospitalCache(): void {
cachedHospitalName = null;
cachedPhccSiteName = null;
}

View File

@ -0,0 +1,68 @@
export const WO_FEEDBACK_RATING_OPTIONS = [
'Excellent / ممتاز',
'Good / جيد',
'Average / متوسط',
'Poor / ضعيف',
];
/** Fixed dashboard order: Excellent → Good → Average → Poor */
export function getWoFeedbackRatingRank(label: string): number {
const l = label.toLowerCase();
if (l.includes('excellent') || l.includes('ممتاز')) return 0;
if (l.includes('good') || l.includes('جيد')) return 1;
if (l.includes('average') || l.includes('متوسط')) return 2;
if (l.includes('poor') || l.includes('ضعيف')) return 3;
return -1;
}
export function getWoFeedbackRatingColor(label: string): string {
const rank = getWoFeedbackRatingRank(label);
if (rank === 0) return '#10B981';
if (rank === 1) return '#3B82F6';
if (rank === 2) return '#F59E0B';
if (rank === 3) return '#EF4444';
return '#6B7280';
}
/** Always return all four ratings in fixed order, filling missing counts with 0. */
export function orderWoFeedbackChartData(data: any) {
const apiLabels: string[] = data?.labels || [];
const apiValues: number[] = data?.datasets?.[0]?.values || [];
const byRank = new Map<number, { label: string; value: number }>();
apiLabels.forEach((label: string, i: number) => {
const rank = getWoFeedbackRatingRank(label);
if (rank < 0) return;
const value = Number(apiValues[i]) || 0;
const existing = byRank.get(rank);
if (existing) existing.value += value;
else byRank.set(rank, { label, value });
});
const labels = WO_FEEDBACK_RATING_OPTIONS.map((canonical, rank) => byRank.get(rank)?.label ?? canonical);
const values = WO_FEEDBACK_RATING_OPTIONS.map((_, rank) => byRank.get(rank)?.value ?? 0);
return {
...(data || {}),
labels,
datasets: [{
...(data?.datasets?.[0] || { name: 'Work Orders' }),
values,
}],
};
}
export function applyWoFeedbackChartColors(data: any) {
const ordered = orderWoFeedbackChartData(data);
const colors = ordered.labels.map((label: string) => getWoFeedbackRatingColor(label));
return {
...ordered,
type: 'Pie',
datasets: [{
...(ordered.datasets?.[0] || { name: 'Work Orders', values: [] }),
colors,
values: ordered.datasets?.[0]?.values || [],
}],
};
}

View File

@ -0,0 +1,63 @@
/** Format Frappe datetime for `<input type="datetime-local" />`. */
export function formatDatetimeForInput(dateStr: string | null | undefined): string {
if (!dateStr) return '';
return dateStr.replace(' ', 'T').slice(0, 16);
}
/** Convert datetime-local value to Frappe datetime string. */
export function formatDatetimeForApi(value: string | null | undefined): string {
if (!value) return '';
if (value.includes('T')) {
const [date, time] = value.split('T');
if (!time) return date;
return `${date} ${time.length === 5 ? `${time}:00` : time}`;
}
return value;
}
export function nowDatetimeLocal(): string {
const d = new Date();
const pad = (n: number) => String(n).padStart(2, '0');
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
}
export function calculateTotalHoursSpent(
firstRespondedOn?: string | null,
completionDate?: string | null
): number {
if (!firstRespondedOn || !completionDate) return 0;
const start = new Date(formatDatetimeForApi(firstRespondedOn).replace(' ', 'T'));
const end = new Date(formatDatetimeForApi(completionDate).replace(' ', 'T'));
if (Number.isNaN(start.getTime()) || Number.isNaN(end.getTime()) || end < start) {
return 0;
}
return Math.round(((end.getTime() - start.getTime()) / (1000 * 3600)) * 100) / 100;
}
export function applyWorkOrderStatusTimestamps<T extends {
repair_status?: string;
first_responded_on?: string;
completion_date?: string;
total_hours_spent?: number;
}>(data: T): T {
const next = { ...data };
const now = nowDatetimeLocal();
if (next.repair_status === 'Work In Progress' && !next.first_responded_on) {
next.first_responded_on = now;
}
if (next.repair_status === 'Completed' && !next.completion_date) {
next.completion_date = now;
}
next.total_hours_spent = calculateTotalHoursSpent(
next.first_responded_on,
next.completion_date
);
return next;
}

View File

@ -1274,6 +1274,11 @@ html-parse-stringify@^3.0.1:
dependencies:
void-elements "3.1.0"
html5-qrcode@^2.3.8:
version "2.3.8"
resolved "https://registry.npmjs.org/html5-qrcode/-/html5-qrcode-2.3.8.tgz"
integrity sha512-jsr4vafJhwoLVEDW3n1KvPnCCXWaQfRng0/EEYk1vNcQGcG/htAdhJX0be8YyqMoSz7+hZvOZSTAepsabiuhiQ==
i18next-browser-languagedetector@^8.2.0:
version "8.2.0"
resolved "https://registry.npmjs.org/i18next-browser-languagedetector/-/i18next-browser-languagedetector-8.2.0.tgz"

View File

@ -243,4 +243,7 @@ app_license = "mit"
# }
website_route_rules = [{'from_route': '/asm_app/<path:app_path>', 'to_route': 'asm_app'},]
website_route_rules = [
{'from_route': '/asm_app', 'to_route': 'asm_app'},
{'from_route': '/asm_app/<path:app_path>', 'to_route': 'asm_app'},
]

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

Some files were not shown because too many files have changed in this diff Show More