RC1 Asm app latest changes
This commit is contained in:
parent
abc533cf86
commit
74243f4d34
@ -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>
|
||||
|
||||
7
asm_app/package-lock.json
generated
7
asm_app/package-lock.json
generated
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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">
|
||||
{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 className="flex-1 flex flex-col overflow-hidden">
|
||||
</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,10 +136,15 @@ const App: React.FC = () => {
|
||||
|
||||
<Route path="/dashboard" element={
|
||||
<ProtectedRoute>
|
||||
<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>
|
||||
<LayoutWithSidebar><AssetList /></LayoutWithSidebar>
|
||||
@ -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>
|
||||
|
||||
406
asm_app/src/components/ActivityLog.tsx
Normal file
406
asm_app/src/components/ActivityLog.tsx
Normal 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;
|
||||
46
asm_app/src/components/AppEntry.tsx
Normal file
46
asm_app/src/components/AppEntry.tsx
Normal 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;
|
||||
288
asm_app/src/components/AssetStatusReportModal.tsx
Normal file
288
asm_app/src/components/AssetStatusReportModal.tsx
Normal 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;
|
||||
60
asm_app/src/components/AssetUpDownCard.tsx
Normal file
60
asm_app/src/components/AssetUpDownCard.tsx
Normal 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;
|
||||
667
asm_app/src/components/ChartDetailModal.tsx
Normal file
667
asm_app/src/components/ChartDetailModal.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
384
asm_app/src/components/CommentSection.tsx
Normal file
384
asm_app/src/components/CommentSection.tsx
Normal 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;
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
68
asm_app/src/components/HorizontalBarChart.tsx
Normal file
68
asm_app/src/components/HorizontalBarChart.tsx
Normal 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;
|
||||
@ -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) => {
|
||||
|
||||
107
asm_app/src/components/ListFilterSortControls.tsx
Normal file
107
asm_app/src/components/ListFilterSortControls.tsx
Normal 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;
|
||||
138
asm_app/src/components/ListPagination.tsx
Normal file
138
asm_app/src/components/ListPagination.tsx
Normal 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;
|
||||
315
asm_app/src/components/MentionInput.tsx
Normal file
315
asm_app/src/components/MentionInput.tsx
Normal 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="<a href="${profileUrl}" target="_blank">${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
|
||||
·
|
||||
<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;
|
||||
@ -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]);
|
||||
|
||||
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);
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
|
||||
// Try to mark as read, but don't block navigation if it fails
|
||||
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) => {
|
||||
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,46 +96,40 @@ const NotificationBell: React.FC = () => {
|
||||
const unreadNotifications = notifications.filter(n => !n.read);
|
||||
const readNotifications = notifications.filter(n => n.read).slice(0, 10);
|
||||
|
||||
return (
|
||||
<div className="relative" ref={panelRef}>
|
||||
<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"
|
||||
title="Notifications"
|
||||
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
|
||||
"
|
||||
>
|
||||
<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">
|
||||
{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">
|
||||
<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 />
|
||||
Notifications
|
||||
<span className="truncate">Notifications</span>
|
||||
{unreadCount > 0 && (
|
||||
<span className="text-xs bg-red-500 text-white px-2 py-0.5 rounded-full">
|
||||
<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"
|
||||
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>
|
||||
|
||||
{/* Notifications List */}
|
||||
<div className="overflow-y-auto flex-1">
|
||||
<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" />
|
||||
@ -181,7 +162,7 @@ const NotificationBell: React.FC = () => {
|
||||
{formatDate(notif.creation)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="w-2 h-2 bg-blue-500 rounded-full flex-shrink-0 mt-1"></div>
|
||||
<div className="w-2 h-2 bg-blue-500 rounded-full flex-shrink-0 mt-1" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
@ -224,11 +205,43 @@ const NotificationBell: React.FC = () => {
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const closePanel = useCallback(() => setIsOpen(false), []);
|
||||
|
||||
return (
|
||||
<div className="relative" ref={buttonRef}>
|
||||
<button
|
||||
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-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 &&
|
||||
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;
|
||||
|
||||
|
||||
|
||||
164
asm_app/src/components/PieChart.tsx
Normal file
164
asm_app/src/components/PieChart.tsx
Normal 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;
|
||||
286
asm_app/src/components/QueryReportExportModal.tsx
Normal file
286
asm_app/src/components/QueryReportExportModal.tsx
Normal 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;
|
||||
147
asm_app/src/components/RepairStatusCompletionBarChart.tsx
Normal file
147
asm_app/src/components/RepairStatusCompletionBarChart.tsx
Normal 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;
|
||||
87
asm_app/src/components/RepairStatusCompletionCard.tsx
Normal file
87
asm_app/src/components/RepairStatusCompletionCard.tsx
Normal 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;
|
||||
@ -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
|
||||
|
||||
114
asm_app/src/components/StackedBarChart.tsx
Normal file
114
asm_app/src/components/StackedBarChart.tsx
Normal 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;
|
||||
108
asm_app/src/components/TechnicianHoursChartCard.tsx
Normal file
108
asm_app/src/components/TechnicianHoursChartCard.tsx
Normal 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;
|
||||
394
asm_app/src/components/TechnicianWorkOrderSummaryReportModal.tsx
Normal file
394
asm_app/src/components/TechnicianWorkOrderSummaryReportModal.tsx
Normal 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;
|
||||
104
asm_app/src/components/TechnicianWorkedChartCard.tsx
Normal file
104
asm_app/src/components/TechnicianWorkedChartCard.tsx
Normal 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;
|
||||
585
asm_app/src/components/TechnicianWorkingHoursReportModal.tsx
Normal file
585
asm_app/src/components/TechnicianWorkingHoursReportModal.tsx
Normal 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;
|
||||
259
asm_app/src/components/WoFeedbackForm.tsx
Normal file
259
asm_app/src/components/WoFeedbackForm.tsx
Normal 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;
|
||||
336
asm_app/src/components/WoFeedbackReportModal.tsx
Normal file
336
asm_app/src/components/WoFeedbackReportModal.tsx
Normal 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;
|
||||
69
asm_app/src/components/WoFeedbackSummary.tsx
Normal file
69
asm_app/src/components/WoFeedbackSummary.tsx
Normal 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;
|
||||
396
asm_app/src/components/WorkOrderStatusReportModal.tsx
Normal file
396
asm_app/src/components/WorkOrderStatusReportModal.tsx
Normal 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;
|
||||
@ -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
|
||||
|
||||
33
asm_app/src/contexts/SidebarLayoutContext.tsx
Normal file
33
asm_app/src/contexts/SidebarLayoutContext.tsx
Normal 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;
|
||||
@ -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>) {
|
||||
|
||||
120
asm_app/src/hooks/useAuditLogs.ts
Normal file
120
asm_app/src/hooks/useAuditLogs.ts
Normal 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;
|
||||
124
asm_app/src/hooks/useComments.ts
Normal file
124
asm_app/src/hooks/useComments.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
56
asm_app/src/hooks/useDefaultHospital.ts
Normal file
56
asm_app/src/hooks/useDefaultHospital.ts
Normal 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;
|
||||
@ -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) {
|
||||
@ -43,13 +75,14 @@ export function useItems(
|
||||
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;
|
||||
|
||||
102
asm_app/src/hooks/useListSortFilters.ts
Normal file
102
asm_app/src/hooks/useListSortFilters.ts
Normal 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,
|
||||
]
|
||||
);
|
||||
}
|
||||
64
asm_app/src/hooks/useRoleProfile.ts
Normal file
64
asm_app/src/hooks/useRoleProfile.ts
Normal 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;
|
||||
88
asm_app/src/hooks/useUserDashboardFilters.ts
Normal file
88
asm_app/src/hooks/useUserDashboardFilters.ts
Normal 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;
|
||||
62
asm_app/src/hooks/useUserHospitalFilter.ts
Normal file
62
asm_app/src/hooks/useUserHospitalFilter.ts
Normal 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;
|
||||
37
asm_app/src/hooks/useUserPhccSiteFilter.ts
Normal file
37
asm_app/src/hooks/useUserPhccSiteFilter.ts
Normal 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;
|
||||
@ -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);
|
||||
|
||||
@ -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 */
|
||||
|
||||
@ -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": "فرق صيانة"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
@ -403,8 +460,7 @@ const AssetDetail: React.FC = () => {
|
||||
// Otherwise, exclude Non Bio department
|
||||
// Also check if company starts with "Mobile"
|
||||
const isMobileSite =
|
||||
(userSiteName && userSiteName.startsWith('Mobile')) ||
|
||||
(formData.company && formData.company.startsWith('Mobile'));
|
||||
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;
|
||||
const reportHandle = openHtmlReport();
|
||||
if (!reportHandle) {
|
||||
showReportBlockedMessage();
|
||||
return;
|
||||
}
|
||||
|
||||
printWindow.document.write(`
|
||||
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;
|
||||
const reportHandle = openHtmlReport();
|
||||
if (!reportHandle) {
|
||||
showReportBlockedMessage();
|
||||
return;
|
||||
}
|
||||
|
||||
printWindow.document.write(`
|
||||
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>
|
||||
);
|
||||
};
|
||||
|
||||
@ -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('');
|
||||
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}
|
||||
{!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 */}
|
||||
|
||||
@ -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') {
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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 */}
|
||||
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@ -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 */}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
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'));
|
||||
}
|
||||
|
||||
@ -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} />
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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
@ -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({
|
||||
|
||||
@ -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 () => {
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
|
||||
46
asm_app/src/pages/WoFeedbackPage.tsx
Normal file
46
asm_app/src/pages/WoFeedbackPage.tsx
Normal 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;
|
||||
@ -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: '',
|
||||
@ -245,26 +291,32 @@ const WorkOrderDetail: React.FC = () => {
|
||||
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 & 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 */
|
||||
|
||||
@ -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');
|
||||
const mobileTeamSiteFilters = useMemo(
|
||||
() => buildMobileTeamSiteFilters(filterCompany, userPhccSiteName),
|
||||
[filterCompany, userPhccSiteName]
|
||||
);
|
||||
|
||||
if (company) setFilterCompany(company);
|
||||
if (priority) setFilterPriority(priority);
|
||||
if (status) setFilterStatus(status);
|
||||
if (siteName) setFilterSiteName(siteName);
|
||||
}, [searchParams]);
|
||||
|
||||
// ✅ 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,23 +708,14 @@ 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 data = await response.json();
|
||||
const results = data.message || [];
|
||||
const response = await workOrderService.getWorkOrders(
|
||||
effectiveFilters,
|
||||
['*'],
|
||||
pageSize,
|
||||
currentPage * pageSize,
|
||||
orderBy
|
||||
);
|
||||
const results = response.work_orders || [];
|
||||
|
||||
allWorkOrders.push(...results);
|
||||
|
||||
@ -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('');
|
||||
if (!siteLocked) {
|
||||
setFilterSiteName('');
|
||||
}
|
||||
setFilterStatus('');
|
||||
setFilterSerialNumber('');
|
||||
setTempSerialNumber('');
|
||||
@ -799,17 +866,31 @@ 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 ||
|
||||
const hasActiveFilters = hasDateFilter || filterWorkOrderId || filterAsset || filterType || filterCompany ||
|
||||
filterSiteName || filterStatus || filterSerialNumber || filterManufacturer ||
|
||||
filterSupplier || filterDepartment || filterPriority;
|
||||
|
||||
@ -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}
|
||||
{!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 */}
|
||||
|
||||
@ -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();
|
||||
@ -408,6 +462,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")
|
||||
if (typeof data.message === 'string' && data.message === '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[] }> {
|
||||
|
||||
67
asm_app/src/services/assetDeviceStatusService.ts
Normal file
67
asm_app/src/services/assetDeviceStatusService.ts
Normal 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 : [];
|
||||
}
|
||||
@ -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;
|
||||
|
||||
147
asm_app/src/services/assigneesStatusService.ts
Normal file
147
asm_app/src/services/assigneesStatusService.ts
Normal 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);
|
||||
};
|
||||
148
asm_app/src/services/commentService.ts
Normal file
148
asm_app/src/services/commentService.ts
Normal 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;
|
||||
@ -76,9 +76,13 @@ export interface CreateItemData {
|
||||
}
|
||||
|
||||
class ItemService {
|
||||
// Get list of items
|
||||
async getItems(filters?: Record<string, any>, fields?: string[], limit: number = 20, offset: number = 0): Promise<{ data: Item[]; total: number }> {
|
||||
try {
|
||||
private buildListParams(
|
||||
filters?: Record<string, any>,
|
||||
fields?: string[],
|
||||
limit: number = 20,
|
||||
offset: number = 0,
|
||||
orderBy?: string
|
||||
): URLSearchParams {
|
||||
const params = new URLSearchParams();
|
||||
|
||||
if (filters) {
|
||||
@ -92,18 +96,49 @@ class ItemService {
|
||||
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()}`
|
||||
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 || {},
|
||||
}),
|
||||
}
|
||||
);
|
||||
|
||||
// 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;
|
||||
return typeof total === 'number' ? total : 0;
|
||||
}
|
||||
|
||||
// Get list of items
|
||||
async getItems(
|
||||
filters?: Record<string, any>,
|
||||
fields?: string[],
|
||||
limit: number = 20,
|
||||
offset: number = 0,
|
||||
orderBy?: string
|
||||
): Promise<{ data: Item[]; total: number }> {
|
||||
try {
|
||||
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);
|
||||
|
||||
112
asm_app/src/services/technicianWorkSummaryService.ts
Normal file
112
asm_app/src/services/technicianWorkSummaryService.ts
Normal 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);
|
||||
};
|
||||
104
asm_app/src/services/technicianWorkingHoursService.ts
Normal file
104
asm_app/src/services/technicianWorkingHoursService.ts
Normal 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);
|
||||
};
|
||||
@ -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 result = await apiService.apiCall<UserProfile & { user_id?: string }>(
|
||||
'/api/method/asset_lite.api.custom_api.get_user_details'
|
||||
);
|
||||
|
||||
const sessionData = await sessionResponse.json();
|
||||
const userEmail = sessionData.message;
|
||||
|
||||
if (!userEmail) {
|
||||
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,
|
||||
}),
|
||||
});
|
||||
storeCsrfToken(result.csrf_token);
|
||||
|
||||
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 = [];
|
||||
}
|
||||
const userData: UserProfile = {
|
||||
...result,
|
||||
name: result.user_id || result.email,
|
||||
email: result.email || result.user_id || '',
|
||||
roles: result.roles || [],
|
||||
};
|
||||
|
||||
return userData;
|
||||
}
|
||||
|
||||
throw new Error('Failed to fetch user profile');
|
||||
} catch (error) {
|
||||
console.error('Error fetching user profile:', error);
|
||||
throw error;
|
||||
@ -94,11 +75,18 @@ 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),
|
||||
});
|
||||
@ -124,11 +112,22 @@ 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,
|
||||
@ -139,10 +138,8 @@ export const changeUserPassword = async (
|
||||
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,7 +148,7 @@ 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
|
||||
}
|
||||
}
|
||||
|
||||
86
asm_app/src/services/woFeedbackReportService.ts
Normal file
86
asm_app/src/services/woFeedbackReportService.ts
Normal 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;
|
||||
146
asm_app/src/services/woFeedbackWebFormService.ts
Normal file
146
asm_app/src/services/woFeedbackWebFormService.ts
Normal 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;
|
||||
@ -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,7 +149,7 @@ 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,
|
||||
@ -150,8 +157,20 @@ class WorkOrderService {
|
||||
): 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) {
|
||||
|
||||
@ -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] || '▶️';
|
||||
|
||||
58
asm_app/src/utils/appRouting.ts
Normal file
58
asm_app/src/utils/appRouting.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
33
asm_app/src/utils/buildAssetUrl.ts
Normal file
33
asm_app/src/utils/buildAssetUrl.ts
Normal 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}` : ''}`;
|
||||
}
|
||||
33
asm_app/src/utils/buildMaintenanceUrl.ts
Normal file
33
asm_app/src/utils/buildMaintenanceUrl.ts
Normal 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}` : ''}`;
|
||||
}
|
||||
36
asm_app/src/utils/buildWoUrl.ts
Normal file
36
asm_app/src/utils/buildWoUrl.ts
Normal 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}` : ''}`;
|
||||
}
|
||||
51
asm_app/src/utils/chartLabelUtils.ts
Normal file
51
asm_app/src/utils/chartLabelUtils.ts
Normal 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;
|
||||
}
|
||||
16
asm_app/src/utils/dateUtils.ts
Normal file
16
asm_app/src/utils/dateUtils.ts
Normal 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),
|
||||
};
|
||||
}
|
||||
146
asm_app/src/utils/frappeErrorMessage.ts
Normal file
146
asm_app/src/utils/frappeErrorMessage.ts
Normal 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(/ /g, ' ')
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/'/g, "'")
|
||||
.replace(/"/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)
|
||||
);
|
||||
}
|
||||
93
asm_app/src/utils/hospitalUtils.ts
Normal file
93
asm_app/src/utils/hospitalUtils.ts
Normal 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;
|
||||
}
|
||||
113
asm_app/src/utils/listFilterUtils.ts
Normal file
113
asm_app/src/utils/listFilterUtils.ts
Normal 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;
|
||||
}
|
||||
141
asm_app/src/utils/openHtmlReport.ts
Normal file
141
asm_app/src/utils/openHtmlReport.ts
Normal 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);
|
||||
}
|
||||
27
asm_app/src/utils/reportModalStyles.ts
Normal file
27
asm_app/src/utils/reportModalStyles.ts
Normal 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';
|
||||
147
asm_app/src/utils/roleAccess.ts
Normal file
147
asm_app/src/utils/roleAccess.ts
Normal 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 } : {}),
|
||||
};
|
||||
}
|
||||
189
asm_app/src/utils/userHospital.ts
Normal file
189
asm_app/src/utils/userHospital.ts
Normal 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;
|
||||
}
|
||||
68
asm_app/src/utils/woFeedbackChartUtils.ts
Normal file
68
asm_app/src/utils/woFeedbackChartUtils.ts
Normal 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 || [],
|
||||
}],
|
||||
};
|
||||
}
|
||||
|
||||
63
asm_app/src/utils/workOrderTimingUtils.ts
Normal file
63
asm_app/src/utils/workOrderTimingUtils.ts
Normal 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;
|
||||
}
|
||||
@ -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"
|
||||
|
||||
@ -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'},
|
||||
]
|
||||
1863
asm_ui_app/public/asm_app/assets/index-DQ5dWWYO.js
Normal file
1863
asm_ui_app/public/asm_app/assets/index-DQ5dWWYO.js
Normal file
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
Loading…
x
Reference in New Issue
Block a user