add latest changes and new pages updated dashboard

This commit is contained in:
Akhib.Shaik 2025-11-12 14:22:51 +05:30
parent f5a80773cb
commit 037c11f06a
22 changed files with 6186 additions and 925 deletions

View File

@ -2,7 +2,7 @@
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<link rel="icon" type="image/svg+xml" href="/seera-logo.png" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>frappe-frontend</title>
</head>

BIN
public/seera-logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

View File

@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

Before

Width:  |  Height:  |  Size: 1.5 KiB

View File

@ -7,6 +7,12 @@ import UsersList from './pages/UsersList';
import EventsList from './pages/EventsList';
import AssetList from './pages/AssetList';
import AssetDetail from './pages/AssetDetail';
import WorkOrderList from './pages/WorkOrderList';
import WorkOrderDetail from './pages/WorkOrderDetail';
import AssetMaintenanceList from './pages/AssetMaintenanceList';
import AssetMaintenanceDetail from './pages/AssetMaintenanceDetail';
import PPMList from './pages/PPMList';
import PPMDetail from './pages/PPMDetail';
import Sidebar from './components/Sidebar';
// Layout with Sidebar
@ -71,6 +77,72 @@ const App: React.FC = () => {
}
/>
<Route
path="/work-orders"
element={
<ProtectedRoute>
<LayoutWithSidebar>
<WorkOrderList />
</LayoutWithSidebar>
</ProtectedRoute>
}
/>
<Route
path="/work-orders/:workOrderName"
element={
<ProtectedRoute>
<LayoutWithSidebar>
<WorkOrderDetail />
</LayoutWithSidebar>
</ProtectedRoute>
}
/>
<Route
path="/maintenance"
element={
<ProtectedRoute>
<LayoutWithSidebar>
<AssetMaintenanceList />
</LayoutWithSidebar>
</ProtectedRoute>
}
/>
<Route
path="/maintenance/:logName"
element={
<ProtectedRoute>
<LayoutWithSidebar>
<AssetMaintenanceDetail />
</LayoutWithSidebar>
</ProtectedRoute>
}
/>
<Route
path="/ppm"
element={
<ProtectedRoute>
<LayoutWithSidebar>
<PPMList />
</LayoutWithSidebar>
</ProtectedRoute>
}
/>
<Route
path="/ppm/:ppmName"
element={
<ProtectedRoute>
<LayoutWithSidebar>
<PPMDetail />
</LayoutWithSidebar>
</ProtectedRoute>
}
/>
<Route
path="/old-dashboard"
element={

View File

@ -2,23 +2,23 @@ import React, { useState } from 'react';
import { Link, useLocation, useNavigate } from 'react-router-dom';
import { useTheme } from '../contexts/ThemeContext';
import {
FaTools,
FaBox,
FaWrench,
FaCog,
FaUsers,
FaChartBar,
FaBuilding,
FaTruck,
FaFileContract,
FaInfoCircle,
FaBars,
FaTimes,
FaHome,
FaMoon,
FaSun,
FaSignOutAlt
} from 'react-icons/fa';
LayoutDashboard,
Package,
Wrench,
Users,
BarChart3,
Building2,
Truck,
FileText,
MapPin,
Menu,
X,
Moon,
Sun,
LogOut,
ClipboardList,
Calendar
} from 'lucide-react';
interface SidebarLink {
id: string;
@ -75,80 +75,87 @@ const Sidebar: React.FC<SidebarProps> = ({ userEmail }) => {
{
id: 'dashboard',
title: 'Dashboard',
icon: <FaHome />,
icon: <LayoutDashboard size={20} />,
path: '/dashboard',
visible: true
},
{
id: 'assets',
title: 'Assets',
icon: <FaTools />,
icon: <Package size={20} />,
path: '/assets',
visible: showAsset
},
{
id: 'work-orders',
title: 'Work Orders',
icon: <FaCog />,
icon: <ClipboardList size={20} />,
path: '/work-orders',
visible: showGeneralWO
},
{
id: 'ppm',
title: 'PPM',
icon: <FaWrench />,
path: '/ppm',
id: 'maintenance',
title: 'Asset Maintenance',
icon: <Wrench size={20} />,
path: '/maintenance',
visible: showPreventiveMaintenance
},
{
id: 'inventory',
title: 'Inventory',
icon: <FaBox />,
path: '/inventory',
visible: showInventory
id: 'ppm',
title: 'PPM',
icon: <Calendar size={20} />,
path: '/ppm',
visible: showPreventiveMaintenance
},
{
id: 'vendors',
title: 'Vendors',
icon: <FaTruck />,
path: '/vendors',
visible: showSupplierDashboard
},
{
id: 'dashboard-view',
title: 'Dashboard',
icon: <FaChartBar />,
path: '/dashboard-view',
visible: showProjectDashboard
},
{
id: 'sites',
title: 'Sites',
icon: <FaBuilding />,
path: '/sites',
visible: showSiteDashboards
},
{
id: 'active-map',
title: 'Active Map',
icon: <FaInfoCircle />,
path: '/active-map',
visible: showSiteInfo
},
{
id: 'users',
title: 'Users',
icon: <FaUsers />,
path: '/users',
visible: showAMTeam
},
{
id: 'account',
title: 'Account',
icon: <FaFileContract />,
path: '/account',
visible: showSLA
}
// {
// id: 'inventory',
// title: 'Inventory',
// icon: <Package size={20} />,
// path: '/inventory',
// visible: showInventory
// },
// {
// id: 'vendors',
// title: 'Vendors',
// icon: <Truck size={20} />,
// path: '/vendors',
// visible: showSupplierDashboard
// },
// {
// id: 'dashboard-view',
// title: 'Dashboard',
// icon: <BarChart3 size={20} />,
// path: '/dashboard-view',
// visible: showProjectDashboard
// },
// {
// id: 'sites',
// title: 'Sites',
// icon: <Building2 size={20} />,
// path: '/sites',
// visible: showSiteDashboards
// },
// {
// id: 'active-map',
// title: 'Active Map',
// icon: <MapPin size={20} />,
// path: '/active-map',
// visible: showSiteInfo
// },
// {
// id: 'users',
// title: 'Users',
// icon: <Users size={20} />,
// path: '/users',
// visible: showAMTeam
// },
// {
// id: 'account',
// title: 'Account',
// icon: <FileText size={20} />,
// path: '/account',
// visible: showSLA
// }
];
const visibleLinks = links.filter(link => link.visible);
@ -175,18 +182,51 @@ const Sidebar: React.FC<SidebarProps> = ({ userEmail }) => {
{/* Sidebar Header */}
<div className="flex items-center justify-between p-4 border-b border-gray-200 dark:border-gray-700">
{!isCollapsed && (
<div className="flex items-center space-x-2">
<div className="w-8 h-8 bg-gradient-to-br from-indigo-600 to-purple-600 rounded-lg flex items-center justify-center">
<span className="text-white font-bold text-sm">AL</span>
<div className="flex items-center space-x-3">
<div className="w-10 h-10 flex items-center justify-center bg-white dark:bg-gray-700 rounded-lg p-1">
{/* Seera Arabia Logo */}
<img
src="/seera-logo.png"
alt="Seera Arabia"
className="w-full h-full object-contain"
onError={(e) => {
// Fallback to SVG if image not found
e.currentTarget.style.display = 'none';
e.currentTarget.nextElementSibling?.classList.remove('hidden');
}}
/>
<svg className="w-6 h-6 hidden" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M12 2L2 7L12 12L22 7L12 2Z" fill="#6366F1" fillOpacity="0.9"/>
<path d="M2 17L12 22L22 17V12L12 17L2 12V17Z" fill="#8B5CF6" fillOpacity="0.7"/>
<path d="M12 12V17" stroke="#A855F7" strokeWidth="2" strokeLinecap="round"/>
</svg>
</div>
<h1 className="text-gray-900 dark:text-white text-xl font-bold">Asset Lite</h1>
<h1 className="text-gray-900 dark:text-white text-lg font-semibold">Seera Arabia</h1>
</div>
)}
{isCollapsed && (
<div className="w-8 h-8 flex items-center justify-center bg-white dark:bg-gray-700 rounded-lg p-1">
<img
src="/seera-logo.png"
alt="Seera Arabia"
className="w-full h-full object-contain"
onError={(e) => {
e.currentTarget.style.display = 'none';
e.currentTarget.nextElementSibling?.classList.remove('hidden');
}}
/>
<svg className="w-5 h-5 hidden" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M12 2L2 7L12 12L22 7L12 2Z" fill="#6366F1" fillOpacity="0.9"/>
<path d="M2 17L12 22L22 17V12L12 17L2 12V17Z" fill="#8B5CF6" fillOpacity="0.7"/>
<path d="M12 12V17" stroke="#A855F7" strokeWidth="2" strokeLinecap="round"/>
</svg>
</div>
)}
<button
onClick={() => setIsCollapsed(!isCollapsed)}
className="text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 p-2 rounded-lg transition-colors"
>
{isCollapsed ? <FaBars size={20} /> : <FaTimes size={20} />}
{isCollapsed ? <Menu size={20} /> : <X size={20} />}
</button>
</div>
@ -211,7 +251,7 @@ const Sidebar: React.FC<SidebarProps> = ({ userEmail }) => {
`}
title={isCollapsed ? link.title : ''}
>
<span className="text-xl">{link.icon}</span>
<span>{link.icon}</span>
{!isCollapsed && (
<span className="ml-4 font-medium">{link.title}</span>
)}
@ -227,7 +267,7 @@ const Sidebar: React.FC<SidebarProps> = ({ userEmail }) => {
className="w-full flex items-center justify-center px-4 py-2 rounded-lg bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors text-gray-700 dark:text-gray-300"
title={isCollapsed ? (theme === 'light' ? 'Dark Mode' : 'Light Mode') : ''}
>
{theme === 'light' ? <FaMoon size={16} /> : <FaSun size={16} />}
{theme === 'light' ? <Moon size={18} /> : <Sun size={18} />}
{!isCollapsed && (
<span className="ml-2 text-sm font-medium">
{theme === 'light' ? 'Dark Mode' : 'Light Mode'}
@ -241,7 +281,7 @@ const Sidebar: React.FC<SidebarProps> = ({ userEmail }) => {
className="w-full flex items-center justify-center px-4 py-2 rounded-lg bg-red-50 dark:bg-red-900/20 hover:bg-red-100 dark:hover:bg-red-900/30 transition-colors text-red-600 dark:text-red-400"
title={isCollapsed ? 'Logout' : ''}
>
<FaSignOutAlt size={16} />
<LogOut size={18} />
{!isCollapsed && (
<span className="ml-2 text-sm font-medium">Logout</span>
)}
@ -260,7 +300,7 @@ const Sidebar: React.FC<SidebarProps> = ({ userEmail }) => {
{!isCollapsed && (
<div className="text-xs text-gray-400 dark:text-gray-500 text-center">
Asset Lite v1.0
Seera Arabia AMS v1.0
</div>
)}
</div>

View File

@ -40,6 +40,35 @@ const API_CONFIG: ApiConfig = {
GET_ASSET_STATS: '/api/method/asset_lite.api.asset_api.get_asset_stats',
SEARCH_ASSETS: '/api/method/asset_lite.api.asset_api.search_assets',
// Work Order Management
GET_WORK_ORDERS: '/api/method/asset_lite.api.work_order_api.get_work_orders',
GET_WORK_ORDER_DETAILS: '/api/method/asset_lite.api.work_order_api.get_work_order_details',
CREATE_WORK_ORDER: '/api/method/asset_lite.api.work_order_api.create_work_order',
UPDATE_WORK_ORDER: '/api/method/asset_lite.api.work_order_api.update_work_order',
DELETE_WORK_ORDER: '/api/method/asset_lite.api.work_order_api.delete_work_order',
UPDATE_WORK_ORDER_STATUS: '/api/method/asset_lite.api.work_order_api.update_work_order_status',
// Asset Maintenance Management
GET_ASSET_MAINTENANCE_LOGS: '/api/method/asset_lite.api.asset_maintenance_api.get_asset_maintenance_logs',
GET_ASSET_MAINTENANCE_LOG_DETAILS: '/api/method/asset_lite.api.asset_maintenance_api.get_asset_maintenance_log_details',
CREATE_ASSET_MAINTENANCE_LOG: '/api/method/asset_lite.api.asset_maintenance_api.create_asset_maintenance_log',
UPDATE_ASSET_MAINTENANCE_LOG: '/api/method/asset_lite.api.asset_maintenance_api.update_asset_maintenance_log',
DELETE_ASSET_MAINTENANCE_LOG: '/api/method/asset_lite.api.asset_maintenance_api.delete_asset_maintenance_log',
UPDATE_MAINTENANCE_STATUS: '/api/method/asset_lite.api.asset_maintenance_api.update_maintenance_status',
GET_MAINTENANCE_LOGS_BY_ASSET: '/api/method/asset_lite.api.asset_maintenance_api.get_maintenance_logs_by_asset',
GET_OVERDUE_MAINTENANCE_LOGS: '/api/method/asset_lite.api.asset_maintenance_api.get_overdue_maintenance_logs',
// PPM (Asset Maintenance) Management
GET_ASSET_MAINTENANCES: '/api/method/asset_lite.api.ppm_api.get_asset_maintenances',
GET_ASSET_MAINTENANCE_DETAILS: '/api/method/asset_lite.api.ppm_api.get_asset_maintenance_details',
CREATE_ASSET_MAINTENANCE: '/api/method/asset_lite.api.ppm_api.create_asset_maintenance',
UPDATE_ASSET_MAINTENANCE: '/api/method/asset_lite.api.ppm_api.update_asset_maintenance',
DELETE_ASSET_MAINTENANCE: '/api/method/asset_lite.api.ppm_api.delete_asset_maintenance',
GET_MAINTENANCE_TASKS: '/api/method/asset_lite.api.ppm_api.get_maintenance_tasks',
GET_SERVICE_COVERAGE: '/api/method/asset_lite.api.ppm_api.get_service_coverage',
GET_MAINTENANCES_BY_ASSET: '/api/method/asset_lite.api.ppm_api.get_maintenances_by_asset',
GET_ACTIVE_SERVICE_CONTRACTS: '/api/method/asset_lite.api.ppm_api.get_active_service_contracts',
// Authentication
LOGIN: '/api/method/login',
LOGOUT: '/api/method/logout',

View File

@ -0,0 +1,288 @@
import { useState, useEffect, useCallback, useRef } from 'react';
import assetMaintenanceService from '../services/assetMaintenanceService';
import type { AssetMaintenanceLog, MaintenanceFilters, CreateMaintenanceData } from '../services/assetMaintenanceService';
/**
* Hook to fetch list of asset maintenance logs with filters and pagination
*/
export function useAssetMaintenanceLogs(
filters?: MaintenanceFilters,
limit: number = 20,
offset: number = 0,
orderBy?: string
) {
const [logs, setLogs] = useState<AssetMaintenanceLog[]>([]);
const [totalCount, setTotalCount] = useState(0);
const [hasMore, setHasMore] = useState(false);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [refetchTrigger, setRefetchTrigger] = useState(0);
const hasAttemptedRef = useRef(false);
const filtersJson = JSON.stringify(filters);
useEffect(() => {
if (hasAttemptedRef.current && error) {
return;
}
let isCancelled = false;
hasAttemptedRef.current = true;
const fetchLogs = async () => {
try {
setLoading(true);
const response = await assetMaintenanceService.getMaintenanceLogs(filters, undefined, limit, offset, orderBy);
if (!isCancelled) {
setLogs(response.asset_maintenance_logs);
setTotalCount(response.total_count);
setHasMore(response.has_more);
setError(null);
}
} catch (err) {
if (!isCancelled) {
const errorMessage = err instanceof Error ? err.message : 'Failed to fetch maintenance logs';
if (errorMessage.includes('417') || errorMessage.includes('Expectation Failed') || errorMessage.includes('has no attribute')) {
setError('API endpoint not deployed. Please deploy asset_maintenance_api.py to your Frappe server.');
} else {
setError(errorMessage);
}
setLogs([]);
setTotalCount(0);
setHasMore(false);
}
} finally {
if (!isCancelled) {
setLoading(false);
}
}
};
fetchLogs();
return () => {
isCancelled = true;
};
}, [filtersJson, limit, offset, orderBy, refetchTrigger]);
const refetch = useCallback(() => {
hasAttemptedRef.current = false;
setRefetchTrigger(prev => prev + 1);
}, []);
return { logs, totalCount, hasMore, loading, error, refetch };
}
/**
* Hook to fetch a single maintenance log by name
*/
export function useMaintenanceLogDetails(logName: string | null) {
const [log, setLog] = useState<AssetMaintenanceLog | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const fetchLog = useCallback(async () => {
if (!logName) {
setLog(null);
setLoading(false);
return;
}
try {
setLoading(true);
setError(null);
const data = await assetMaintenanceService.getMaintenanceLogDetails(logName);
setLog(data);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to fetch maintenance log details');
} finally {
setLoading(false);
}
}, [logName]);
useEffect(() => {
fetchLog();
}, [fetchLog]);
const refetch = useCallback(() => {
fetchLog();
}, [fetchLog]);
return { log, loading, error, refetch };
}
/**
* Hook to manage maintenance log operations
*/
export function useMaintenanceMutations() {
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const createLog = async (logData: CreateMaintenanceData) => {
try {
setLoading(true);
setError(null);
console.log('[useMaintenanceMutations] Creating maintenance log:', logData);
const response = await assetMaintenanceService.createMaintenanceLog(logData);
console.log('[useMaintenanceMutations] Create response:', response);
if (response.success) {
return response.asset_maintenance_log;
} else {
const backendError = (response as any).error || 'Failed to create maintenance log';
throw new Error(backendError);
}
} catch (err) {
console.error('[useMaintenanceMutations] Create error:', err);
const errorMessage = err instanceof Error ? err.message : 'Failed to create maintenance log';
setError(errorMessage);
throw err;
} finally {
setLoading(false);
}
};
const updateLog = async (logName: string, logData: Partial<CreateMaintenanceData>) => {
try {
setLoading(true);
setError(null);
console.log('[useMaintenanceMutations] Updating maintenance log:', logName, logData);
const response = await assetMaintenanceService.updateMaintenanceLog(logName, logData);
console.log('[useMaintenanceMutations] Update response:', response);
if (response.success) {
return response.asset_maintenance_log;
} else {
const backendError = (response as any).error || 'Failed to update maintenance log';
throw new Error(backendError);
}
} catch (err) {
console.error('[useMaintenanceMutations] Update error:', err);
const errorMessage = err instanceof Error ? err.message : 'Failed to update maintenance log';
setError(errorMessage);
throw err;
} finally {
setLoading(false);
}
};
const deleteLog = async (logName: string) => {
try {
setLoading(true);
setError(null);
const response = await assetMaintenanceService.deleteMaintenanceLog(logName);
if (!response.success) {
throw new Error('Failed to delete maintenance log');
}
return response;
} catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Failed to delete maintenance log';
setError(errorMessage);
throw err;
} finally {
setLoading(false);
}
};
const updateStatus = async (logName: string, maintenanceStatus?: string, workflowState?: string) => {
try {
setLoading(true);
setError(null);
const response = await assetMaintenanceService.updateMaintenanceStatus(logName, maintenanceStatus, workflowState);
if (response.success) {
return response.asset_maintenance_log;
} else {
throw new Error('Failed to update maintenance status');
}
} catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Failed to update status';
setError(errorMessage);
throw err;
} finally {
setLoading(false);
}
};
return { createLog, updateLog, deleteLog, updateStatus, loading, error };
}
/**
* Hook to fetch maintenance logs for a specific asset
*/
export function useAssetMaintenanceHistory(assetName: string | null) {
const [logs, setLogs] = useState<AssetMaintenanceLog[]>([]);
const [totalCount, setTotalCount] = useState(0);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const fetchHistory = useCallback(async () => {
if (!assetName) {
setLogs([]);
setLoading(false);
return;
}
try {
setLoading(true);
setError(null);
const response = await assetMaintenanceService.getMaintenanceLogsByAsset(assetName);
setLogs(response.asset_maintenance_logs);
setTotalCount(response.total_count);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to fetch maintenance history');
} finally {
setLoading(false);
}
}, [assetName]);
useEffect(() => {
fetchHistory();
}, [fetchHistory]);
return { logs, totalCount, loading, error, refetch: fetchHistory };
}
/**
* Hook to fetch overdue maintenance logs
*/
export function useOverdueMaintenanceLogs() {
const [logs, setLogs] = useState<AssetMaintenanceLog[]>([]);
const [totalCount, setTotalCount] = useState(0);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const fetchOverdue = useCallback(async () => {
try {
setLoading(true);
setError(null);
const response = await assetMaintenanceService.getOverdueMaintenanceLogs();
setLogs(response.asset_maintenance_logs);
setTotalCount(response.total_count);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to fetch overdue maintenance');
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
fetchOverdue();
}, [fetchOverdue]);
return { logs, totalCount, loading, error, refetch: fetchOverdue };
}

174
src/hooks/usePPM.ts Normal file
View File

@ -0,0 +1,174 @@
import { useState, useEffect, useCallback, useRef } from 'react';
import ppmService from '../services/ppmService';
import type { AssetMaintenance, PPMFilters, CreatePPMData } from '../services/ppmService';
/**
* Hook to fetch list of asset maintenances (PPM schedules) with filters and pagination
*/
export function usePPMs(
filters?: PPMFilters,
limit: number = 20,
offset: number = 0,
orderBy?: string
) {
const [ppms, setPPMs] = useState<AssetMaintenance[]>([]);
const [totalCount, setTotalCount] = useState(0);
const [hasMore, setHasMore] = useState(false);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [refetchTrigger, setRefetchTrigger] = useState(0);
const hasAttemptedRef = useRef(false);
const filtersJson = JSON.stringify(filters);
useEffect(() => {
if (hasAttemptedRef.current && error) {
return;
}
let isCancelled = false;
hasAttemptedRef.current = true;
const fetchPPMs = async () => {
try {
setLoading(true);
const response = await ppmService.getAssetMaintenances(filters, undefined, limit, offset, orderBy);
if (!isCancelled) {
setPPMs(response.asset_maintenances);
setTotalCount(response.total_count);
setHasMore(response.has_more);
setError(null);
}
} catch (err) {
if (!isCancelled) {
const errorMessage = err instanceof Error ? err.message : 'Failed to fetch PPM schedules';
if (errorMessage.includes('417') || errorMessage.includes('Expectation Failed') || errorMessage.includes('has no attribute')) {
setError('API endpoint not deployed. Please deploy ppm_api.py to your Frappe server.');
} else {
setError(errorMessage);
}
setPPMs([]);
setTotalCount(0);
setHasMore(false);
}
} finally {
if (!isCancelled) {
setLoading(false);
}
}
};
fetchPPMs();
return () => {
isCancelled = true;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [filtersJson, limit, offset, orderBy, refetchTrigger]);
const refetch = useCallback(() => {
hasAttemptedRef.current = false;
setRefetchTrigger(prev => prev + 1);
}, []);
return { ppms, totalCount, hasMore, loading, error, refetch };
}
/**
* Hook to fetch a single PPM schedule by name
*/
export function usePPMDetails(ppmName: string | null) {
const [ppm, setPPM] = useState<AssetMaintenance | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const fetchPPM = useCallback(async () => {
if (!ppmName) {
setPPM(null);
setLoading(false);
return;
}
try {
setLoading(true);
setError(null);
const data = await ppmService.getAssetMaintenanceDetails(ppmName);
setPPM(data);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to fetch PPM details');
} finally {
setLoading(false);
}
}, [ppmName]);
useEffect(() => {
fetchPPM();
}, [fetchPPM]);
const refetch = useCallback(() => {
fetchPPM();
}, [fetchPPM]);
return { ppm, loading, error, refetch };
}
/**
* Hook to manage PPM operations (create, update, delete)
*/
export function usePPMMutations() {
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const createPPM = useCallback(async (data: CreatePPMData) => {
try {
setLoading(true);
setError(null);
const result = await ppmService.createAssetMaintenance(data);
return result;
} catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Failed to create PPM schedule';
setError(errorMessage);
throw err;
} finally {
setLoading(false);
}
}, []);
const updatePPM = useCallback(async (ppmName: string, data: Partial<CreatePPMData>) => {
try {
setLoading(true);
setError(null);
const result = await ppmService.updateAssetMaintenance(ppmName, data);
return result;
} catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Failed to update PPM schedule';
setError(errorMessage);
throw err;
} finally {
setLoading(false);
}
}, []);
const deletePPM = useCallback(async (ppmName: string) => {
try {
setLoading(true);
setError(null);
const result = await ppmService.deleteAssetMaintenance(ppmName);
return result;
} catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Failed to delete PPM schedule';
setError(errorMessage);
throw err;
} finally {
setLoading(false);
}
}, []);
return { createPPM, updatePPM, deletePPM, loading, error };
}

220
src/hooks/useWorkOrder.ts Normal file
View File

@ -0,0 +1,220 @@
import { useState, useEffect, useCallback, useRef } from 'react';
import workOrderService from '../services/workOrderService';
import type { WorkOrder, WorkOrderFilters, CreateWorkOrderData } from '../services/workOrderService';
/**
* Hook to fetch list of work orders with filters and pagination
*/
export function useWorkOrders(
filters?: WorkOrderFilters,
limit: number = 20,
offset: number = 0,
orderBy?: string
) {
const [workOrders, setWorkOrders] = useState<WorkOrder[]>([]);
const [totalCount, setTotalCount] = useState(0);
const [hasMore, setHasMore] = useState(false);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [refetchTrigger, setRefetchTrigger] = useState(0);
const hasAttemptedRef = useRef(false);
const filtersJson = JSON.stringify(filters);
useEffect(() => {
if (hasAttemptedRef.current && error) {
return;
}
let isCancelled = false;
hasAttemptedRef.current = true;
const fetchWorkOrders = async () => {
try {
setLoading(true);
const response = await workOrderService.getWorkOrders(filters, undefined, limit, offset, orderBy);
if (!isCancelled) {
setWorkOrders(response.work_orders);
setTotalCount(response.total_count);
setHasMore(response.has_more);
setError(null);
}
} catch (err) {
if (!isCancelled) {
const errorMessage = err instanceof Error ? err.message : 'Failed to fetch work orders';
if (errorMessage.includes('417') || errorMessage.includes('Expectation Failed') || errorMessage.includes('has no attribute')) {
setError('API endpoint not deployed. Please deploy work_order_api.py to your Frappe server.');
} else {
setError(errorMessage);
}
setWorkOrders([]);
setTotalCount(0);
setHasMore(false);
}
} finally {
if (!isCancelled) {
setLoading(false);
}
}
};
fetchWorkOrders();
return () => {
isCancelled = true;
};
}, [filtersJson, limit, offset, orderBy, refetchTrigger]);
const refetch = useCallback(() => {
hasAttemptedRef.current = false;
setRefetchTrigger(prev => prev + 1);
}, []);
return { workOrders, totalCount, hasMore, loading, error, refetch };
}
/**
* Hook to fetch a single work order by name
*/
export function useWorkOrderDetails(workOrderName: string | null) {
const [workOrder, setWorkOrder] = useState<WorkOrder | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const fetchWorkOrder = useCallback(async () => {
if (!workOrderName) {
setWorkOrder(null);
setLoading(false);
return;
}
try {
setLoading(true);
setError(null);
const data = await workOrderService.getWorkOrderDetails(workOrderName);
setWorkOrder(data);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to fetch work order details');
} finally {
setLoading(false);
}
}, [workOrderName]);
useEffect(() => {
fetchWorkOrder();
}, [fetchWorkOrder]);
const refetch = useCallback(() => {
fetchWorkOrder();
}, [fetchWorkOrder]);
return { workOrder, loading, error, refetch };
}
/**
* Hook to manage work order operations (create, update, delete)
*/
export function useWorkOrderMutations() {
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const createWorkOrder = async (workOrderData: CreateWorkOrderData) => {
try {
setLoading(true);
setError(null);
console.log('[useWorkOrderMutations] Creating work order with data:', workOrderData);
const response = await workOrderService.createWorkOrder(workOrderData);
console.log('[useWorkOrderMutations] Create work order response:', response);
if (response.success) {
return response.work_order;
} else {
const backendError = (response as any).error || 'Failed to create work order';
throw new Error(backendError);
}
} catch (err) {
console.error('[useWorkOrderMutations] Create work order error:', err);
const errorMessage = err instanceof Error ? err.message : 'Failed to create work order';
setError(errorMessage);
throw err;
} finally {
setLoading(false);
}
};
const updateWorkOrder = async (workOrderName: string, workOrderData: Partial<CreateWorkOrderData>) => {
try {
setLoading(true);
setError(null);
console.log('[useWorkOrderMutations] Updating work order:', workOrderName, 'with data:', workOrderData);
const response = await workOrderService.updateWorkOrder(workOrderName, workOrderData);
console.log('[useWorkOrderMutations] Update work order response:', response);
if (response.success) {
return response.work_order;
} else {
const backendError = (response as any).error || 'Failed to update work order';
throw new Error(backendError);
}
} catch (err) {
console.error('[useWorkOrderMutations] Update work order error:', err);
const errorMessage = err instanceof Error ? err.message : 'Failed to update work order';
setError(errorMessage);
throw err;
} finally {
setLoading(false);
}
};
const deleteWorkOrder = async (workOrderName: string) => {
try {
setLoading(true);
setError(null);
const response = await workOrderService.deleteWorkOrder(workOrderName);
if (!response.success) {
throw new Error('Failed to delete work order');
}
return response;
} catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Failed to delete work order';
setError(errorMessage);
throw err;
} finally {
setLoading(false);
}
};
const updateStatus = async (workOrderName: string, repairStatus?: string, workflowState?: string) => {
try {
setLoading(true);
setError(null);
const response = await workOrderService.updateWorkOrderStatus(workOrderName, repairStatus, workflowState);
if (response.success) {
return response.work_order;
} else {
throw new Error('Failed to update work order status');
}
} catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Failed to update status';
setError(errorMessage);
throw err;
} finally {
setLoading(false);
}
};
return { createWorkOrder, updateWorkOrder, deleteWorkOrder, updateStatus, loading, error };
}

View File

@ -1,3 +1,5 @@
@import url('https://fonts.googleapis.com/css2?family=Poppins:wght@100;200;300;400;500;600;700;800;900&display=swap');
@tailwind base;
@tailwind components;
@tailwind utilities;

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,423 @@
import React, { useState, useEffect } from 'react';
import { useParams, useNavigate, useSearchParams } from 'react-router-dom';
import { useMaintenanceLogDetails, useMaintenanceMutations } from '../hooks/useAssetMaintenance';
import { FaArrowLeft, FaSave, FaEdit, FaCheckCircle, FaClock } from 'react-icons/fa';
import type { CreateMaintenanceData } from '../services/assetMaintenanceService';
const AssetMaintenanceDetail: React.FC = () => {
const { logName } = useParams<{ logName: string }>();
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const duplicateFromLog = searchParams.get('duplicate');
const isNewLog = logName === 'new';
const isDuplicating = isNewLog && !!duplicateFromLog;
const { log, loading, error } = useMaintenanceLogDetails(
isDuplicating ? duplicateFromLog : (isNewLog ? null : logName || null)
);
const { createLog, updateLog, updateStatus, loading: saving } = useMaintenanceMutations();
const [isEditing, setIsEditing] = useState(isNewLog);
const [formData, setFormData] = useState<CreateMaintenanceData>({
asset_name: '',
task: '',
task_name: '',
maintenance_type: 'Preventive',
periodicity: '',
maintenance_status: 'Planned',
due_date: '',
assign_to_name: '',
description: '',
});
useEffect(() => {
if (log) {
setFormData({
asset_name: log.asset_name || '',
task: log.task || '',
task_name: log.task_name || '',
maintenance_type: log.maintenance_type || 'Preventive',
periodicity: log.periodicity || '',
maintenance_status: isDuplicating ? 'Planned' : (log.maintenance_status || 'Planned'),
due_date: log.due_date || '',
assign_to_name: log.assign_to_name || '',
description: '',
});
}
}, [log, isDuplicating]);
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement>) => {
const { name, value } = e.target;
setFormData(prev => ({
...prev,
[name]: value
}));
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!formData.asset_name) {
alert('Please enter Asset Name');
return;
}
if (!formData.maintenance_type) {
alert('Please select Maintenance Type');
return;
}
console.log('Submitting maintenance log data:', formData);
try {
if (isNewLog || isDuplicating) {
const newLog = await createLog(formData);
const successMessage = isDuplicating
? 'Maintenance log duplicated successfully!'
: 'Maintenance log created successfully!';
alert(successMessage);
navigate(`/maintenance/${newLog.name}`);
} else if (logName) {
await updateLog(logName, formData);
alert('Maintenance log updated successfully!');
setIsEditing(false);
}
} catch (err) {
console.error('Maintenance log save error:', err);
alert('Failed to save: ' + (err instanceof Error ? err.message : 'Unknown error'));
}
};
const handleStatusUpdate = async (newStatus: string) => {
if (!logName || isNewLog) return;
try {
await updateStatus(logName, newStatus);
alert('Status updated successfully!');
window.location.reload();
} catch (err) {
alert('Failed to update status: ' + (err instanceof Error ? err.message : 'Unknown error'));
}
};
if (loading) {
return (
<div className="flex items-center justify-center h-screen bg-gray-50 dark:bg-gray-900">
<div className="text-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-500 mx-auto"></div>
<p className="mt-4 text-gray-600 dark:text-gray-400">Loading maintenance log...</p>
</div>
</div>
);
}
if (error && !isNewLog && !isDuplicating) {
return (
<div className="p-6 bg-gray-50 dark:bg-gray-900 min-h-screen">
<div className="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-4">
<p className="text-red-600 dark:text-red-400">Error: {error}</p>
<button
onClick={() => navigate('/maintenance')}
className="mt-2 text-red-700 dark:text-red-400 underline hover:text-red-800 dark:hover:text-red-300"
>
Back to maintenance logs
</button>
</div>
</div>
);
}
return (
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 p-6">
{/* Header */}
<div className="mb-6 flex justify-between items-center">
<div className="flex items-center gap-4">
<button
onClick={() => navigate('/maintenance')}
className="text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300 flex items-center gap-2"
>
<FaArrowLeft />
<span className="text-gray-900 dark:text-white">
{isDuplicating ? 'Duplicate Maintenance Log' : (isNewLog ? 'New Maintenance Log' : 'Maintenance Log Details')}
</span>
</button>
</div>
<div className="flex items-center gap-3">
{!isNewLog && !isEditing && (
<>
<button
onClick={() => setIsEditing(true)}
className="bg-blue-600 hover:bg-blue-700 text-white px-6 py-2 rounded-lg flex items-center gap-2"
>
<FaEdit />
Edit
</button>
{log?.maintenance_status !== 'Completed' && (
<button
onClick={() => handleStatusUpdate('Completed')}
className="bg-green-600 hover:bg-green-700 text-white px-4 py-2 rounded-lg flex items-center gap-2"
disabled={saving}
>
<FaCheckCircle />
Mark Complete
</button>
)}
</>
)}
{isEditing && (
<>
<button
onClick={() => {
if (isNewLog) {
navigate('/maintenance');
} else {
setIsEditing(false);
}
}}
className="bg-gray-300 hover:bg-gray-400 text-gray-700 px-6 py-2 rounded-lg"
disabled={saving}
>
Cancel
</button>
<button
onClick={handleSubmit}
disabled={saving}
className="bg-green-600 hover:bg-green-700 text-white px-6 py-2 rounded-lg flex items-center gap-2 disabled:opacity-50"
>
<FaSave />
{saving ? 'Saving...' : 'Save Changes'}
</button>
</>
)}
</div>
</div>
<form onSubmit={handleSubmit} className="space-y-6">
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Left Column */}
<div className="lg:col-span-2 space-y-6">
{/* Maintenance Information */}
<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">Maintenance Information</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="md:col-span-2">
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">
Log ID
</label>
<input
type="text"
value={isNewLog || isDuplicating ? 'Auto-generated' : log?.name}
disabled
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md bg-gray-100 dark:bg-gray-700 text-gray-900 dark:text-white"
/>
{isDuplicating && (
<p className="mt-1 text-xs text-blue-600 dark:text-blue-400">
💡 Duplicating from: {duplicateFromLog}
</p>
)}
</div>
<div>
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">
Asset Name <span className="text-red-500">*</span>
</label>
<input
type="text"
name="asset_name"
value={formData.asset_name}
onChange={handleChange}
required
disabled={!isEditing}
placeholder="Asset name or 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">
Maintenance Type <span className="text-red-500">*</span>
</label>
<select
name="maintenance_type"
value={formData.maintenance_type}
onChange={handleChange}
required
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"
>
<option value="Preventive">Preventive</option>
<option value="Corrective">Corrective</option>
<option value="Calibration">Calibration</option>
<option value="Inspection">Inspection</option>
</select>
</div>
<div>
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">
Task Name
</label>
<input
type="text"
name="task_name"
value={formData.task_name}
onChange={handleChange}
disabled={!isEditing}
placeholder="Maintenance task name"
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">
Periodicity
</label>
<select
name="periodicity"
value={formData.periodicity}
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"
>
<option value="">Select periodicity</option>
<option value="Daily">Daily</option>
<option value="Weekly">Weekly</option>
<option value="Monthly">Monthly</option>
<option value="Quarterly">Quarterly</option>
<option value="Half-yearly">Half-yearly</option>
<option value="Yearly">Yearly</option>
</select>
</div>
<div>
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">
Status
</label>
<select
name="maintenance_status"
value={formData.maintenance_status}
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"
>
<option value="Planned">Planned</option>
<option value="Completed">Completed</option>
<option value="Overdue">Overdue</option>
<option value="Cancelled">Cancelled</option>
</select>
</div>
<div>
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">
Due Date
</label>
<input
type="date"
name="due_date"
value={formData.due_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"
/>
</div>
<div>
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">
Assigned To
</label>
<input
type="text"
name="assign_to_name"
value={formData.assign_to_name}
onChange={handleChange}
disabled={!isEditing}
placeholder="Technician name"
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 className="md:col-span-2">
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">
Description / Notes
</label>
<textarea
name="description"
value={formData.description}
onChange={handleChange}
rows={4}
disabled={!isEditing}
placeholder="Maintenance notes and details..."
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>
</div>
</div>
{/* Right Column - Status Summary */}
<div className="space-y-6">
<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>
{!isNewLog && log && (
<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">Current Status</p>
<p className="text-lg font-semibold text-gray-900 dark:text-white">
{log.maintenance_status || 'Planned'}
</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">Due Date</p>
<p className="text-sm text-gray-900 dark:text-white">
{log.due_date ? new Date(log.due_date).toLocaleDateString() : 'Not set'}
</p>
{log.due_date && new Date(log.due_date) < new Date() && log.maintenance_status !== 'Completed' && (
<p className="text-xs text-red-600 dark:text-red-400 font-semibold mt-1">
Overdue
</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">Type</p>
<p className="text-sm text-gray-900 dark:text-white">
{log.maintenance_type || 'N/A'}
</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">Assigned To</p>
<p className="text-sm text-gray-900 dark:text-white">
{log.assign_to_name || 'Unassigned'}
</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">Created</p>
<p className="text-xs text-gray-900 dark:text-white">
{log.creation ? new Date(log.creation).toLocaleString() : '-'}
</p>
</div>
</div>
)}
{isNewLog && (
<div className="text-center py-8">
<FaClock className="text-4xl text-gray-400 dark:text-gray-500 mx-auto mb-2" />
<p className="text-sm text-gray-500 dark:text-gray-400">
Status information will appear after creation
</p>
</div>
)}
</div>
</div>
</div>
</form>
</div>
);
};
export default AssetMaintenanceDetail;

View File

@ -0,0 +1,499 @@
import React, { useState, useEffect, useRef } from 'react';
import { useNavigate } from 'react-router-dom';
import { useAssetMaintenanceLogs, useMaintenanceMutations } from '../hooks/useAssetMaintenance';
import { FaPlus, FaSearch, FaEdit, FaEye, FaTrash, FaCopy, FaEllipsisV, FaDownload, FaPrint, FaFileExport, FaCheckCircle, FaClock, FaExclamationTriangle, FaCalendarCheck } from 'react-icons/fa';
const AssetMaintenanceList: React.FC = () => {
const navigate = useNavigate();
const [page, setPage] = useState(0);
const [searchTerm, setSearchTerm] = useState('');
const [statusFilter, setStatusFilter] = useState<string>('');
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 } : {};
const { logs, totalCount, hasMore, loading, error, refetch } = useAssetMaintenanceLogs(
filters,
limit,
page * limit,
'due_date asc'
);
const { deleteLog, loading: mutationLoading } = useMaintenanceMutations();
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
setActionMenuOpen(null);
}
};
if (actionMenuOpen) {
document.addEventListener('mousedown', handleClickOutside);
}
return () => {
document.removeEventListener('mousedown', handleClickOutside);
};
}, [actionMenuOpen]);
const handleCreateNew = () => {
navigate('/maintenance/new');
};
const handleView = (logName: string) => {
navigate(`/maintenance/${logName}`);
};
const handleEdit = (logName: string) => {
navigate(`/maintenance/${logName}`);
};
const handleDelete = async (logName: string) => {
try {
await deleteLog(logName);
setDeleteConfirmOpen(null);
refetch();
alert('Maintenance log deleted successfully!');
} catch (err) {
alert(`Failed to delete: ${err instanceof Error ? err.message : 'Unknown error'}`);
}
};
const handleDuplicate = (logName: string) => {
navigate(`/maintenance/new?duplicate=${logName}`);
};
const handleExport = (log: any) => {
const dataStr = JSON.stringify(log, null, 2);
const dataBlob = new Blob([dataStr], { type: 'application/json' });
const url = URL.createObjectURL(dataBlob);
const link = document.createElement('a');
link.href = url;
link.download = `maintenance_${log.name}.json`;
link.click();
URL.revokeObjectURL(url);
};
const handlePrint = (logName: string) => {
window.open(`/maintenance/${logName}?print=true`, '_blank');
};
const handleExportAll = () => {
const headers = ['Log ID', 'Asset', 'Type', 'Status', 'Due Date', 'Assigned To'];
const csvContent = [
headers.join(','),
...logs.map(log => [
log.name,
log.asset_name || '',
log.maintenance_type || '',
log.maintenance_status || '',
log.due_date || '',
log.assign_to_name || ''
].join(','))
].join('\n');
const dataBlob = new Blob([csvContent], { type: 'text/csv' });
const url = URL.createObjectURL(dataBlob);
const link = document.createElement('a');
link.href = url;
link.download = `maintenance_logs_${new Date().toISOString().split('T')[0]}.csv`;
link.click();
URL.revokeObjectURL(url);
};
const getStatusIcon = (status: string) => {
switch (status?.toLowerCase()) {
case 'completed':
return <FaCheckCircle className="text-green-500" />;
case 'planned':
return <FaCalendarCheck className="text-blue-500" />;
case 'overdue':
return <FaExclamationTriangle className="text-red-500" />;
default:
return <FaClock className="text-gray-400" />;
}
};
const getStatusColor = (status: string) => {
switch (status?.toLowerCase()) {
case 'completed':
return 'bg-green-100 dark:bg-green-900/30 text-green-800 dark:text-green-300';
case 'planned':
return 'bg-blue-100 dark:bg-blue-900/30 text-blue-800 dark:text-blue-300';
case 'overdue':
return 'bg-red-100 dark:bg-red-900/30 text-red-800 dark:text-red-300';
case 'cancelled':
return 'bg-gray-100 dark:bg-gray-700 text-gray-800 dark:text-gray-300';
default:
return 'bg-yellow-100 dark:bg-yellow-900/30 text-yellow-800 dark:text-yellow-300';
}
};
const isOverdue = (dueDate: string, status: string) => {
if (!dueDate || status?.toLowerCase() === 'completed') return false;
return new Date(dueDate) < new Date();
};
if (loading && page === 0) {
return (
<div className="flex items-center justify-center h-screen bg-gray-50 dark:bg-gray-900">
<div className="text-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-500 mx-auto"></div>
<p className="mt-4 text-gray-600 dark:text-gray-400">Loading maintenance logs...</p>
</div>
</div>
);
}
if (error) {
return (
<div className="p-6 bg-gray-50 dark:bg-gray-900 min-h-screen">
<div className="bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-lg p-6">
<h2 className="text-xl font-bold text-yellow-800 dark:text-yellow-300 mb-4"> Maintenance API Not Available</h2>
<div className="text-yellow-700 dark:text-yellow-400 space-y-3">
<p><strong>The Asset Maintenance API endpoint is not deployed yet.</strong></p>
<div className="mt-4 flex gap-3">
<button
onClick={() => navigate('/maintenance/new')}
className="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded"
>
Try Creating New (Demo)
</button>
<button
onClick={refetch}
className="bg-gray-600 hover:bg-gray-700 text-white px-4 py-2 rounded"
>
Try Again
</button>
</div>
</div>
<div className="mt-4 p-4 bg-white dark:bg-gray-800 rounded border border-yellow-300 dark:border-yellow-700">
<p className="text-sm text-gray-600 dark:text-gray-400">
<strong>Technical Error:</strong> {error}
</p>
</div>
</div>
</div>
);
}
const filteredLogs = logs.filter(log =>
log.name?.toLowerCase().includes(searchTerm.toLowerCase()) ||
log.asset_name?.toLowerCase().includes(searchTerm.toLowerCase()) ||
log.task_name?.toLowerCase().includes(searchTerm.toLowerCase())
);
return (
<div className="p-6 bg-gray-50 dark:bg-gray-900 min-h-screen">
{/* Header */}
<div className="mb-6 flex justify-between items-center">
<div>
<h1 className="text-3xl font-bold text-gray-800 dark:text-white">Asset Maintenance</h1>
<p className="text-gray-600 dark:text-gray-400 mt-1">
Total: {totalCount} maintenance log{totalCount !== 1 ? 's' : ''}
</p>
</div>
<div className="flex gap-3">
<button
onClick={handleExportAll}
className="bg-green-600 hover:bg-green-700 text-white px-4 py-3 rounded-lg flex items-center gap-2 shadow transition-all"
disabled={logs.length === 0}
>
<FaFileExport />
<span className="font-medium">Export All</span>
</button>
<button
onClick={handleCreateNew}
className="bg-blue-600 hover:bg-blue-700 text-white px-6 py-3 rounded-lg flex items-center gap-2 shadow-lg transition-all hover:shadow-xl"
>
<FaPlus />
<span className="font-medium">New Maintenance Log</span>
</button>
</div>
</div>
{/* Filters Bar */}
<div className="mb-6 grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-4">
<div className="flex items-center gap-2 border border-gray-300 dark:border-gray-600 rounded-lg px-4 py-2 bg-white dark:bg-gray-700">
<FaSearch className="text-gray-400 dark:text-gray-500" />
<input
type="text"
placeholder="Search by ID, asset, task..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="flex-1 outline-none text-gray-700 dark:text-gray-200 bg-transparent"
/>
</div>
</div>
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-4">
<select
value={statusFilter}
onChange={(e) => {
setStatusFilter(e.target.value);
setPage(0);
}}
className="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-700 dark:text-gray-200 focus:outline-none focus:ring-2 focus:ring-blue-500"
>
<option value="">All Statuses</option>
<option value="Planned">Planned</option>
<option value="Completed">Completed</option>
<option value="Overdue">Overdue</option>
<option value="Cancelled">Cancelled</option>
</select>
</div>
</div>
{/* Maintenance Logs Table */}
<div className="bg-white dark:bg-gray-800 rounded-lg shadow overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full">
<thead className="bg-gray-100 dark:bg-gray-700 border-b border-gray-200 dark:border-gray-600">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
Log ID
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
Asset
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
Type
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
Due Date
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
Status
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
Actions
</th>
</tr>
</thead>
<tbody className="bg-white dark:bg-gray-800 divide-y divide-gray-200 dark:divide-gray-700">
{filteredLogs.length === 0 ? (
<tr>
<td colSpan={6} className="px-6 py-12 text-center text-gray-500 dark:text-gray-400">
<div className="flex flex-col items-center">
<FaSearch className="text-4xl text-gray-300 dark:text-gray-600 mb-2" />
<p>No maintenance logs found</p>
<button
onClick={handleCreateNew}
className="mt-4 text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300 underline"
>
Create your first maintenance log
</button>
</div>
</td>
</tr>
) : (
filteredLogs.map((log) => {
const overdue = isOverdue(log.due_date || '', log.maintenance_status || '');
return (
<tr
key={log.name}
className={`hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors cursor-pointer ${
overdue ? 'bg-red-50 dark:bg-red-900/10' : ''
}`}
onClick={() => handleView(log.name)}
>
<td className="px-6 py-4 whitespace-nowrap">
<div className="font-medium text-gray-900 dark:text-white">{log.name}</div>
<div className="text-sm text-gray-500 dark:text-gray-400">
{log.creation ? new Date(log.creation).toLocaleDateString() : ''}
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm text-gray-900 dark:text-white">{log.asset_name || '-'}</div>
<div className="text-xs text-gray-500 dark:text-gray-400">{log.custom_asset_type || ''}</div>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-700 dark:text-gray-300">
{log.maintenance_type || '-'}
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm text-gray-900 dark:text-white">
{log.due_date ? new Date(log.due_date).toLocaleDateString() : '-'}
</div>
{overdue && (
<div className="text-xs text-red-600 dark:text-red-400 font-semibold">
Overdue
</div>
)}
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="flex items-center gap-2">
{getStatusIcon(log.maintenance_status || '')}
<span className={`px-3 py-1 inline-flex text-xs leading-5 font-semibold rounded-full ${getStatusColor(log.maintenance_status || '')}`}>
{log.maintenance_status || 'Unknown'}
</span>
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium">
<div className="flex items-center gap-1" onClick={(e) => e.stopPropagation()}>
<button
onClick={() => handleView(log.name)}
className="text-blue-600 dark:text-blue-400 hover:text-blue-900 dark:hover:text-blue-300 p-2 hover:bg-blue-50 dark:hover:bg-blue-900/30 rounded transition-colors"
title="View Details"
>
<FaEye />
</button>
<button
onClick={() => handleEdit(log.name)}
className="text-green-600 dark:text-green-400 hover:text-green-900 dark:hover:text-green-300 p-2 hover:bg-green-50 dark:hover:bg-green-900/30 rounded transition-colors"
title="Edit Log"
>
<FaEdit />
</button>
<button
onClick={() => handleDuplicate(log.name)}
className="text-purple-600 dark:text-purple-400 hover:text-purple-900 dark:hover:text-purple-300 p-2 hover:bg-purple-50 dark:hover:bg-purple-900/30 rounded transition-colors"
title="Duplicate"
>
<FaCopy />
</button>
<button
onClick={() => setDeleteConfirmOpen(log.name)}
className="text-red-600 dark:text-red-400 hover:text-red-900 dark:hover:text-red-300 p-2 hover:bg-red-50 dark:hover:bg-red-900/30 rounded transition-colors"
title="Delete"
disabled={mutationLoading}
>
<FaTrash />
</button>
<div className="relative" ref={actionMenuOpen === log.name ? dropdownRef : null}>
<button
onClick={() => setActionMenuOpen(actionMenuOpen === log.name ? null : log.name)}
className="text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200 p-2 hover:bg-gray-50 dark:hover:bg-gray-700 rounded transition-colors"
title="More Actions"
>
<FaEllipsisV />
</button>
{actionMenuOpen === log.name && (
<div className="absolute right-0 mt-2 w-48 bg-white dark:bg-gray-800 rounded-lg shadow-lg border border-gray-200 dark:border-gray-700 z-10">
<button
onClick={() => {
handleExport(log);
setActionMenuOpen(null);
}}
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 rounded-t-lg"
>
<FaDownload className="text-blue-500" />
Export as JSON
</button>
<button
onClick={() => {
handlePrint(log.name);
setActionMenuOpen(null);
}}
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 rounded-b-lg"
>
<FaPrint className="text-purple-500" />
Print Log
</button>
</div>
)}
</div>
</div>
</td>
</tr>
);
})
)}
</tbody>
</table>
</div>
{/* Pagination */}
{filteredLogs.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
</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>
)}
</div>
{/* Delete Confirmation Modal */}
{deleteConfirmOpen && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white dark:bg-gray-800 rounded-lg p-6 max-w-md w-full mx-4 shadow-2xl">
<div className="flex items-start gap-4">
<div className="flex-shrink-0 w-12 h-12 rounded-full bg-red-100 dark:bg-red-900/30 flex items-center justify-center">
<FaTrash className="text-red-600 dark:text-red-400 text-xl" />
</div>
<div className="flex-1">
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-2">
Delete Maintenance Log
</h3>
<p className="text-sm text-gray-600 dark:text-gray-400 mb-4">
Are you sure you want to delete this maintenance log? This action cannot be undone.
</p>
<div className="bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-md p-3 mb-4">
<p className="text-xs text-yellow-800 dark:text-yellow-300">
<strong>Log ID:</strong> {deleteConfirmOpen}
</p>
</div>
<div className="flex gap-3 justify-end">
<button
onClick={() => setDeleteConfirmOpen(null)}
className="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 rounded-lg transition-colors"
disabled={mutationLoading}
>
Cancel
</button>
<button
onClick={() => handleDelete(deleteConfirmOpen)}
className="px-4 py-2 text-sm font-medium text-white bg-red-600 hover:bg-red-700 rounded-lg transition-colors flex items-center gap-2 disabled:opacity-50"
disabled={mutationLoading}
>
{mutationLoading ? (
<>
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white"></div>
Deleting...
</>
) : (
<>
<FaTrash />
Delete Log
</>
)}
</button>
</div>
</div>
</div>
</div>
</div>
)}
</div>
);
};
export default AssetMaintenanceList;

View File

@ -70,11 +70,38 @@ const Login: React.FC = () => {
<div className="min-h-screen flex items-center justify-center bg-gray-50 dark:bg-gray-900 py-12 px-4 sm:px-6 lg:px-8">
<div className="max-w-md w-full space-y-8">
<div>
<h2 className="mt-6 text-center text-3xl font-extrabold text-gray-900 dark:text-white">
Sign in to your account
<div className="flex justify-center mb-6">
<div className="w-32 h-32 flex items-center justify-center bg-white dark:bg-gray-800 rounded-2xl shadow-2xl p-4">
{/* Seera Arabia Logo */}
<img
src="/seera-logo.png"
alt="Seera Arabia"
className="w-full h-full object-contain"
onError={(e) => {
// Fallback to gradient background with SVG if image not found
const container = e.currentTarget.parentElement;
if (container) {
container.classList.add('bg-gradient-to-br', 'from-indigo-600', 'to-purple-600');
}
e.currentTarget.style.display = 'none';
e.currentTarget.nextElementSibling?.classList.remove('hidden');
}}
/>
<svg className="w-20 h-20 hidden" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M12 2L2 7L12 12L22 7L12 2Z" fill="white" fillOpacity="0.9"/>
<path d="M2 17L12 22L22 17V12L12 17L2 12V17Z" fill="white" fillOpacity="0.7"/>
<path d="M12 12V17" stroke="white" strokeWidth="2" strokeLinecap="round"/>
</svg>
</div>
</div>
<h2 className="text-center text-3xl font-semibold text-gray-900 dark:text-white">
Seera Arabia
</h2>
<p className="mt-2 text-center text-sm text-gray-600 dark:text-gray-400">
Connect to your Frappe backend
<p className="mt-2 text-center text-sm font-medium text-indigo-600 dark:text-indigo-400">
Asset Management System
</p>
<p className="mt-1 text-center text-xs text-gray-600 dark:text-gray-400">
Sign in to continue
</p>
</div>

File diff suppressed because it is too large Load Diff

406
src/pages/PPMDetail.tsx Normal file
View File

@ -0,0 +1,406 @@
import React, { useState, useEffect } from 'react';
import { useParams, useNavigate, useSearchParams } from 'react-router-dom';
import { usePPMDetails, usePPMMutations } from '../hooks/usePPM';
import { FaArrowLeft, FaSave, FaEdit, FaBuilding, FaTools, FaCalendarCheck, FaDollarSign } from 'react-icons/fa';
import type { CreatePPMData } from '../services/ppmService';
const PPMDetail: React.FC = () => {
const { ppmName } = useParams<{ ppmName: string }>();
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const duplicateFromPPM = searchParams.get('duplicate');
const isNewPPM = ppmName === 'new';
const isDuplicating = isNewPPM && !!duplicateFromPPM;
const { ppm, loading, error, refetch } = usePPMDetails(
isDuplicating ? duplicateFromPPM : (isNewPPM ? null : ppmName || null)
);
const { createPPM, updatePPM, loading: saving } = usePPMMutations();
const [isEditing, setIsEditing] = useState(isNewPPM);
const [formData, setFormData] = useState<CreatePPMData>({
company: '',
asset_name: '',
custom_asset_type: '',
maintenance_team: '',
custom_frequency: '',
custom_total_amount: 0,
custom_no_of_pms: 0,
custom_price_per_pm: 0,
});
useEffect(() => {
if (ppm) {
setFormData({
company: ppm.company || '',
asset_name: ppm.asset_name || '',
custom_asset_type: ppm.custom_asset_type || '',
maintenance_team: ppm.maintenance_team || '',
custom_frequency: ppm.custom_frequency || '',
custom_total_amount: ppm.custom_total_amount || 0,
custom_no_of_pms: ppm.custom_no_of_pms || 0,
custom_price_per_pm: ppm.custom_price_per_pm || 0,
});
}
}, [ppm, isDuplicating]);
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement>) => {
const { name, value } = e.target;
setFormData(prev => ({
...prev,
[name]: name.includes('amount') || name.includes('pms') || name.includes('price')
? parseFloat(value) || 0
: value
}));
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!formData.asset_name) {
alert('Please enter Asset Name');
return;
}
try {
if (isNewPPM || isDuplicating) {
const result = await createPPM(formData);
const successMessage = isDuplicating
? 'PPM schedule duplicated successfully!'
: 'PPM schedule created successfully!';
alert(successMessage);
if (result.asset_maintenance?.name) {
navigate(`/ppm/${result.asset_maintenance.name}`);
} else {
refetch();
navigate('/ppm');
}
} else if (ppmName) {
await updatePPM(ppmName, formData);
alert('PPM schedule updated successfully!');
setIsEditing(false);
refetch();
}
} catch (err) {
console.error('PPM save error:', err);
alert('Failed to save: ' + (err instanceof Error ? err.message : 'Unknown error'));
}
};
if (loading) {
return (
<div className="flex items-center justify-center h-screen bg-gray-50 dark:bg-gray-900">
<div className="text-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-500 mx-auto"></div>
<p className="mt-4 text-gray-600 dark:text-gray-400">Loading PPM schedule...</p>
</div>
</div>
);
}
if (error && !isNewPPM && !isDuplicating) {
return (
<div className="p-6 bg-gray-50 dark:bg-gray-900 min-h-screen">
<div className="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-4">
<p className="text-red-600 dark:text-red-400">Error: {error}</p>
<button
onClick={() => navigate('/ppm')}
className="mt-2 text-red-700 dark:text-red-400 underline hover:text-red-800 dark:hover:text-red-300"
>
Back to PPM schedules
</button>
</div>
</div>
);
}
return (
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 p-6">
{/* Header */}
<div className="mb-6 flex justify-between items-center">
<div className="flex items-center gap-4">
<button
onClick={() => navigate('/ppm')}
className="text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300 flex items-center gap-2"
>
<FaArrowLeft />
<span className="text-gray-900 dark:text-white">
{isDuplicating ? 'Duplicate PPM Schedule' : (isNewPPM ? 'New PPM Schedule' : 'PPM Schedule Details')}
</span>
</button>
</div>
<div className="flex items-center gap-3">
{!isNewPPM && !isEditing && (
<button
onClick={() => setIsEditing(true)}
className="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-lg flex items-center gap-2"
>
<FaEdit />
Edit
</button>
)}
</div>
</div>
<form onSubmit={handleSubmit}>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Main Form */}
<div className="lg:col-span-2 space-y-6">
{/* Basic Information */}
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
<h2 className="text-xl font-semibold text-gray-900 dark:text-white mb-4">Basic Information</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Company *
</label>
{isEditing ? (
<input
type="text"
name="company"
value={formData.company}
onChange={handleChange}
className="w-full px-4 py-2 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-blue-500"
required
/>
) : (
<div className="p-4 bg-gray-50 dark:bg-gray-700 rounded-lg">
<p className="text-gray-900 dark:text-white">{ppm?.company || '-'}</p>
</div>
)}
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Asset Name *
</label>
{isEditing ? (
<input
type="text"
name="asset_name"
value={formData.asset_name}
onChange={handleChange}
className="w-full px-4 py-2 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-blue-500"
required
/>
) : (
<div className="p-4 bg-gray-50 dark:bg-gray-700 rounded-lg">
<p className="text-gray-900 dark:text-white">{ppm?.asset_name || '-'}</p>
</div>
)}
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Asset Type
</label>
{isEditing ? (
<input
type="text"
name="custom_asset_type"
value={formData.custom_asset_type}
onChange={handleChange}
className="w-full px-4 py-2 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-blue-500"
/>
) : (
<div className="p-4 bg-gray-50 dark:bg-gray-700 rounded-lg">
<p className="text-gray-900 dark:text-white">{ppm?.custom_asset_type || '-'}</p>
</div>
)}
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Maintenance Team
</label>
{isEditing ? (
<input
type="text"
name="maintenance_team"
value={formData.maintenance_team}
onChange={handleChange}
className="w-full px-4 py-2 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-blue-500"
/>
) : (
<div className="p-4 bg-gray-50 dark:bg-gray-700 rounded-lg">
<p className="text-gray-900 dark:text-white">{ppm?.maintenance_team || '-'}</p>
</div>
)}
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Frequency
</label>
{isEditing ? (
<input
type="text"
name="custom_frequency"
value={formData.custom_frequency}
onChange={handleChange}
className="w-full px-4 py-2 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-blue-500"
placeholder="e.g., Monthly, Quarterly, Yearly"
/>
) : (
<div className="p-4 bg-gray-50 dark:bg-gray-700 rounded-lg">
<p className="text-gray-900 dark:text-white">{ppm?.custom_frequency || '-'}</p>
</div>
)}
</div>
</div>
</div>
{/* Financial Information */}
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
<h2 className="text-xl font-semibold text-gray-900 dark:text-white mb-4">Financial Information</h2>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Number of PMs
</label>
{isEditing ? (
<input
type="number"
name="custom_no_of_pms"
value={formData.custom_no_of_pms}
onChange={handleChange}
className="w-full px-4 py-2 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-blue-500"
min="0"
/>
) : (
<div className="p-4 bg-gray-50 dark:bg-gray-700 rounded-lg">
<p className="text-gray-900 dark:text-white">{ppm?.custom_no_of_pms || '-'}</p>
</div>
)}
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Price per PM
</label>
{isEditing ? (
<input
type="number"
name="custom_price_per_pm"
value={formData.custom_price_per_pm}
onChange={handleChange}
className="w-full px-4 py-2 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-blue-500"
min="0"
step="0.01"
/>
) : (
<div className="p-4 bg-gray-50 dark:bg-gray-700 rounded-lg">
<p className="text-gray-900 dark:text-white">
{ppm?.custom_price_per_pm ? `$${ppm.custom_price_per_pm.toLocaleString()}` : '-'}
</p>
</div>
)}
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Total Amount
</label>
{isEditing ? (
<input
type="number"
name="custom_total_amount"
value={formData.custom_total_amount}
onChange={handleChange}
className="w-full px-4 py-2 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-blue-500"
min="0"
step="0.01"
/>
) : (
<div className="p-4 bg-gray-50 dark:bg-gray-700 rounded-lg">
<p className="text-gray-900 dark:text-white font-semibold">
{ppm?.custom_total_amount ? `$${ppm.custom_total_amount.toLocaleString()}` : '-'}
</p>
</div>
)}
</div>
</div>
</div>
</div>
{/* Sidebar Info */}
<div className="lg:col-span-1">
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-6 space-y-4">
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-4">Schedule Information</h3>
{!isNewPPM && ppm && (
<>
<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">PPM ID</p>
<p className="text-sm font-medium text-gray-900 dark:text-white">{ppm.name}</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">Created</p>
<p className="text-xs text-gray-900 dark:text-white">
{ppm.creation ? new Date(ppm.creation).toLocaleString() : '-'}
</p>
</div>
</>
)}
{isNewPPM && (
<div className="text-center py-8">
<FaTools className="text-4xl text-gray-400 dark:text-gray-500 mx-auto mb-2" />
<p className="text-sm text-gray-500 dark:text-gray-400">
Schedule information will appear after creation
</p>
</div>
)}
</div>
</div>
</div>
{/* Action Buttons */}
{isEditing && (
<div className="mt-6 flex justify-end gap-3">
<button
type="button"
onClick={() => {
if (isNewPPM) {
navigate('/ppm');
} else {
setIsEditing(false);
if (ppm) {
setFormData({
company: ppm.company || '',
asset_name: ppm.asset_name || '',
custom_asset_type: ppm.custom_asset_type || '',
maintenance_team: ppm.maintenance_team || '',
custom_frequency: ppm.custom_frequency || '',
custom_total_amount: ppm.custom_total_amount || 0,
custom_no_of_pms: ppm.custom_no_of_pms || 0,
custom_price_per_pm: ppm.custom_price_per_pm || 0,
});
}
}
}}
className="px-6 py-2 border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700"
>
Cancel
</button>
<button
type="submit"
disabled={saving}
className="px-6 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg flex items-center gap-2 disabled:opacity-50"
>
<FaSave />
{saving ? 'Saving...' : (isNewPPM ? 'Create' : 'Save Changes')}
</button>
</div>
)}
</form>
</div>
);
};
export default PPMDetail;

441
src/pages/PPMList.tsx Normal file
View File

@ -0,0 +1,441 @@
import React, { useState, useEffect, useRef } from 'react';
import { useNavigate } from 'react-router-dom';
import { usePPMs, usePPMMutations } from '../hooks/usePPM';
import { FaPlus, FaSearch, FaEdit, FaEye, FaTrash, FaCopy, FaEllipsisV, FaFileExport, FaCalendarCheck, FaTools, FaBuilding } from 'react-icons/fa';
const PPMList: React.FC = () => {
const navigate = useNavigate();
const [page, setPage] = useState(0);
const [searchTerm, setSearchTerm] = useState('');
const [companyFilter, setCompanyFilter] = useState<string>('');
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState<string | null>(null);
const [actionMenuOpen, setActionMenuOpen] = useState<string | null>(null);
const dropdownRef = useRef<HTMLDivElement>(null);
const limit = 20;
const filters = companyFilter ? { company: companyFilter } : {};
const { ppms, totalCount, hasMore, loading, error, refetch } = usePPMs(
filters,
limit,
page * limit,
'creation desc'
);
const { deletePPM, loading: mutationLoading } = usePPMMutations();
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
setActionMenuOpen(null);
}
};
if (actionMenuOpen) {
document.addEventListener('mousedown', handleClickOutside);
}
return () => {
document.removeEventListener('mousedown', handleClickOutside);
};
}, [actionMenuOpen]);
const handleCreateNew = () => {
navigate('/ppm/new');
};
const handleView = (ppmName: string) => {
navigate(`/ppm/${ppmName}`);
};
const handleEdit = (ppmName: string) => {
navigate(`/ppm/${ppmName}`);
};
const handleDelete = async (ppmName: string) => {
try {
await deletePPM(ppmName);
setDeleteConfirmOpen(null);
refetch();
alert('PPM schedule deleted successfully!');
} catch (err) {
alert(`Failed to delete: ${err instanceof Error ? err.message : 'Unknown error'}`);
}
};
const handleDuplicate = (ppmName: string) => {
navigate(`/ppm/new?duplicate=${ppmName}`);
};
const handleExport = (ppm: any) => {
const dataStr = JSON.stringify(ppm, null, 2);
const dataBlob = new Blob([dataStr], { type: 'application/json' });
const url = URL.createObjectURL(dataBlob);
const link = document.createElement('a');
link.href = url;
link.download = `ppm_${ppm.name}.json`;
link.click();
URL.revokeObjectURL(url);
};
const handleExportAll = () => {
const headers = ['PPM ID', 'Company', 'Asset', 'Asset Type', 'Frequency', 'No. of PMs', 'Total Amount'];
const csvContent = [
headers.join(','),
...ppms.map(ppm => [
ppm.name,
ppm.company || '',
ppm.asset_name || '',
ppm.custom_asset_type || '',
ppm.custom_frequency || '',
ppm.custom_no_of_pms || '',
ppm.custom_total_amount || ''
].join(','))
].join('\n');
const dataBlob = new Blob([csvContent], { type: 'text/csv' });
const url = URL.createObjectURL(dataBlob);
const link = document.createElement('a');
link.href = url;
link.download = `ppm_schedules_${new Date().toISOString().split('T')[0]}.csv`;
link.click();
URL.revokeObjectURL(url);
};
if (loading && page === 0) {
return (
<div className="flex items-center justify-center h-screen bg-gray-50 dark:bg-gray-900">
<div className="text-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-500 mx-auto"></div>
<p className="mt-4 text-gray-600 dark:text-gray-400">Loading PPM schedules...</p>
</div>
</div>
);
}
if (error) {
return (
<div className="p-6 bg-gray-50 dark:bg-gray-900 min-h-screen">
<div className="bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-lg p-6">
<h2 className="text-xl font-bold text-yellow-800 dark:text-yellow-300 mb-4"> PPM API Not Available</h2>
<div className="text-yellow-700 dark:text-yellow-400 space-y-3">
<p><strong>The PPM API endpoint is not deployed yet.</strong></p>
<div className="mt-4 flex gap-3">
<button
onClick={() => navigate('/ppm/new')}
className="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded"
>
Try Creating New (Demo)
</button>
<button
onClick={refetch}
className="bg-gray-600 hover:bg-gray-700 text-white px-4 py-2 rounded"
>
Try Again
</button>
</div>
</div>
<div className="mt-4 p-4 bg-white dark:bg-gray-800 rounded border border-yellow-300 dark:border-yellow-700">
<p className="text-sm text-gray-600 dark:text-gray-400">
<strong>Technical Error:</strong> {error}
</p>
</div>
</div>
</div>
);
}
const filteredPPMs = ppms.filter(ppm =>
ppm.name?.toLowerCase().includes(searchTerm.toLowerCase()) ||
ppm.asset_name?.toLowerCase().includes(searchTerm.toLowerCase()) ||
ppm.company?.toLowerCase().includes(searchTerm.toLowerCase()) ||
ppm.custom_asset_type?.toLowerCase().includes(searchTerm.toLowerCase())
);
return (
<div className="p-6 bg-gray-50 dark:bg-gray-900 min-h-screen">
{/* Header */}
<div className="mb-6 flex justify-between items-center">
<div>
<h1 className="text-3xl font-bold text-gray-800 dark:text-white">PPM Schedules</h1>
<p className="text-gray-600 dark:text-gray-400 mt-1">
Total: {totalCount} PPM schedule{totalCount !== 1 ? 's' : ''}
</p>
</div>
<div className="flex gap-3">
<button
onClick={handleExportAll}
className="bg-green-600 hover:bg-green-700 text-white px-4 py-3 rounded-lg flex items-center gap-2 shadow transition-all"
disabled={ppms.length === 0}
>
<FaFileExport />
<span className="font-medium">Export All</span>
</button>
<button
onClick={handleCreateNew}
className="bg-blue-600 hover:bg-blue-700 text-white px-6 py-3 rounded-lg flex items-center gap-2 shadow-lg transition-all hover:shadow-xl"
>
<FaPlus />
<span className="font-medium">New PPM Schedule</span>
</button>
</div>
</div>
{/* Filters Bar */}
<div className="mb-6 grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-4">
<div className="flex items-center gap-2 border border-gray-300 dark:border-gray-600 rounded-lg px-4 py-2 bg-white dark:bg-gray-700">
<FaSearch className="text-gray-400 dark:text-gray-500" />
<input
type="text"
placeholder="Search by ID, asset, company..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="flex-1 outline-none text-gray-700 dark:text-gray-200 bg-transparent"
/>
</div>
</div>
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-4">
<input
type="text"
placeholder="Filter by Company"
value={companyFilter}
onChange={(e) => {
setCompanyFilter(e.target.value);
setPage(0);
}}
className="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-700 dark:text-gray-200 focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
</div>
{/* PPM Schedules Table */}
<div className="bg-white dark:bg-gray-800 rounded-lg shadow overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full">
<thead className="bg-gray-100 dark:bg-gray-700 border-b border-gray-200 dark:border-gray-600">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
PPM ID
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
Company
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
Asset
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
Asset Type
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
Frequency
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
No. of PMs
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
Total Amount
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
Actions
</th>
</tr>
</thead>
<tbody className="bg-white dark:bg-gray-800 divide-y divide-gray-200 dark:divide-gray-700">
{filteredPPMs.length === 0 ? (
<tr>
<td colSpan={8} className="px-6 py-12 text-center text-gray-500 dark:text-gray-400">
<div className="flex flex-col items-center">
<FaSearch className="text-4xl text-gray-300 dark:text-gray-600 mb-2" />
<p>No PPM schedules found</p>
<button
onClick={handleCreateNew}
className="mt-4 text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300 underline"
>
Create your first PPM schedule
</button>
</div>
</td>
</tr>
) : (
filteredPPMs.map((ppm) => (
<tr
key={ppm.name}
className="hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-colors cursor-pointer"
onClick={() => handleView(ppm.name)}
>
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm font-medium text-gray-900 dark:text-white">
{ppm.name}
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="flex items-center gap-2">
<FaBuilding className="text-gray-400" />
<span className="text-sm text-gray-700 dark:text-gray-300">
{ppm.company || '-'}
</span>
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm text-gray-700 dark:text-gray-300">
{ppm.asset_name || '-'}
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm text-gray-700 dark:text-gray-300">
{ppm.custom_asset_type || '-'}
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="flex items-center gap-2">
<FaCalendarCheck className="text-blue-500" />
<span className="text-sm text-gray-700 dark:text-gray-300">
{ppm.custom_frequency || '-'}
</span>
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm text-gray-700 dark:text-gray-300">
{ppm.custom_no_of_pms || '-'}
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm font-medium text-gray-900 dark:text-white">
{ppm.custom_total_amount ? `$${ppm.custom_total_amount.toLocaleString()}` : '-'}
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium">
<div className="relative" onClick={(e) => e.stopPropagation()}>
<button
onClick={() => setActionMenuOpen(actionMenuOpen === ppm.name ? null : ppm.name)}
className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700"
>
<FaEllipsisV />
</button>
{actionMenuOpen === ppm.name && (
<div
ref={dropdownRef}
className="absolute right-0 mt-2 w-48 bg-white dark:bg-gray-800 rounded-lg shadow-lg z-10 border border-gray-200 dark:border-gray-700"
>
<button
onClick={() => {
handleView(ppm.name);
setActionMenuOpen(null);
}}
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"
>
<FaEye />
View
</button>
<button
onClick={() => {
handleEdit(ppm.name);
setActionMenuOpen(null);
}}
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"
>
<FaEdit />
Edit
</button>
<button
onClick={() => {
handleDuplicate(ppm.name);
setActionMenuOpen(null);
}}
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"
>
<FaCopy />
Duplicate
</button>
<button
onClick={() => {
handleExport(ppm);
setActionMenuOpen(null);
}}
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"
>
<FaFileExport />
Export
</button>
<div className="border-t border-gray-200 dark:border-gray-700"></div>
<button
onClick={() => {
setDeleteConfirmOpen(ppm.name);
setActionMenuOpen(null);
}}
className="w-full text-left px-4 py-2 text-sm text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20 flex items-center gap-2"
>
<FaTrash />
Delete
</button>
</div>
)}
</div>
</td>
</tr>
))
)}
</tbody>
</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>
)}
</div>
{/* Delete Confirmation Modal */}
{deleteConfirmOpen && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white dark:bg-gray-800 rounded-lg p-6 max-w-md w-full mx-4">
<h3 className="text-lg font-bold text-gray-900 dark:text-white mb-4">Confirm Delete</h3>
<p className="text-gray-600 dark:text-gray-400 mb-6">
Are you sure you want to delete this PPM schedule? This action cannot be undone.
</p>
<div className="flex gap-3 justify-end">
<button
onClick={() => setDeleteConfirmOpen(null)}
className="px-4 py-2 text-gray-700 dark:text-gray-300 bg-gray-200 dark:bg-gray-700 rounded-lg hover:bg-gray-300 dark:hover:bg-gray-600"
>
Cancel
</button>
<button
onClick={() => handleDelete(deleteConfirmOpen)}
disabled={mutationLoading}
className="px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 disabled:opacity-50"
>
{mutationLoading ? 'Deleting...' : 'Delete'}
</button>
</div>
</div>
</div>
)}
</div>
);
};
export default PPMList;

View File

@ -0,0 +1,571 @@
import React, { useState, useEffect } from 'react';
import { useParams, useNavigate, useSearchParams } from 'react-router-dom';
import { useWorkOrderDetails, useWorkOrderMutations } from '../hooks/useWorkOrder';
import { FaArrowLeft, FaSave, FaEdit, FaCheckCircle, FaClock } from 'react-icons/fa';
import type { CreateWorkOrderData } from '../services/workOrderService';
const WorkOrderDetail: React.FC = () => {
const { workOrderName } = useParams<{ workOrderName: string }>();
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const duplicateFromWorkOrder = searchParams.get('duplicate');
const isNewWorkOrder = workOrderName === 'new';
const isDuplicating = isNewWorkOrder && !!duplicateFromWorkOrder;
const { workOrder, loading, error } = useWorkOrderDetails(
isDuplicating ? duplicateFromWorkOrder : (isNewWorkOrder ? null : workOrderName || null)
);
const { createWorkOrder, updateWorkOrder, updateStatus, loading: saving } = useWorkOrderMutations();
const [isEditing, setIsEditing] = useState(isNewWorkOrder);
const [formData, setFormData] = useState<CreateWorkOrderData>({
company: '',
work_order_type: '',
asset: '',
asset_name: '',
description: '',
repair_status: 'Open',
workflow_state: '',
department: '',
custom_priority_: 'Normal',
asset_type: '',
manufacturer: '',
serial_number: '',
model: '',
custom_site_contractor: '',
custom_subcontractor: '',
failure_date: '',
custom_deadline_date: '',
});
useEffect(() => {
if (workOrder) {
setFormData({
company: workOrder.company || '',
work_order_type: workOrder.work_order_type || '',
asset: workOrder.asset || '',
asset_name: isDuplicating ? `${workOrder.asset_name} (Copy)` : (workOrder.asset_name || ''),
description: workOrder.description || '',
repair_status: isDuplicating ? 'Open' : (workOrder.repair_status || 'Open'),
workflow_state: workOrder.workflow_state || '',
department: workOrder.department || '',
custom_priority_: workOrder.custom_priority_ || 'Normal',
asset_type: workOrder.asset_type || '',
manufacturer: workOrder.manufacturer || '',
serial_number: workOrder.serial_number || '',
model: workOrder.model || '',
custom_site_contractor: workOrder.custom_site_contractor || '',
custom_subcontractor: workOrder.custom_subcontractor || '',
failure_date: workOrder.failure_date || '',
custom_deadline_date: workOrder.custom_deadline_date || '',
});
}
}, [workOrder, isDuplicating]);
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement>) => {
const { name, value } = e.target;
setFormData(prev => ({
...prev,
[name]: value
}));
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!formData.work_order_type) {
alert('Please select a Work Order Type');
return;
}
console.log('Submitting work order data:', formData);
try {
if (isNewWorkOrder || isDuplicating) {
const newWorkOrder = await createWorkOrder(formData);
const successMessage = isDuplicating
? 'Work order duplicated successfully!'
: 'Work order created successfully!';
alert(successMessage);
navigate(`/work-orders/${newWorkOrder.name}`);
} else if (workOrderName) {
await updateWorkOrder(workOrderName, formData);
alert('Work order updated successfully!');
setIsEditing(false);
}
} catch (err) {
console.error('Work order save error:', err);
const errorMessage = err instanceof Error ? err.message : 'Unknown error';
if (errorMessage.includes('404') || errorMessage.includes('not found') ||
errorMessage.includes('has no attribute') || errorMessage.includes('417')) {
alert(
'⚠️ Work Order API Not Deployed\n\n' +
'The Work Order API endpoint is not deployed on your Frappe server yet.\n\n' +
'Deploy work_order_api.py to: frappe-bench/apps/asset_lite/asset_lite/api/\n\n' +
'Error: ' + errorMessage
);
} else {
alert('Failed to save work order:\n\n' + errorMessage);
}
}
};
const handleStatusUpdate = async (newStatus: string) => {
if (!workOrderName || isNewWorkOrder) return;
try {
await updateStatus(workOrderName, newStatus);
alert('Status updated successfully!');
window.location.reload();
} catch (err) {
alert('Failed to update status: ' + (err instanceof Error ? err.message : 'Unknown error'));
}
};
if (loading) {
return (
<div className="flex items-center justify-center h-screen bg-gray-50 dark:bg-gray-900">
<div className="text-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-500 mx-auto"></div>
<p className="mt-4 text-gray-600 dark:text-gray-400">Loading work order details...</p>
</div>
</div>
);
}
if (error && !isNewWorkOrder && !isDuplicating) {
return (
<div className="p-6 bg-gray-50 dark:bg-gray-900 min-h-screen">
<div className="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-4">
<p className="text-red-600 dark:text-red-400">Error: {error}</p>
<button
onClick={() => navigate('/work-orders')}
className="mt-2 text-red-700 dark:text-red-400 underline hover:text-red-800 dark:hover:text-red-300"
>
Back to work orders list
</button>
</div>
</div>
);
}
return (
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 p-6">
{/* Header */}
<div className="mb-6 flex justify-between items-center">
<div className="flex items-center gap-4">
<button
onClick={() => navigate('/work-orders')}
className="text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300 flex items-center gap-2"
>
<FaArrowLeft />
<span className="text-gray-900 dark:text-white">
{isDuplicating ? 'Duplicate Work Order' : (isNewWorkOrder ? 'New Work Order' : 'Work Order Details')}
</span>
</button>
</div>
<div className="flex items-center gap-3">
{!isNewWorkOrder && !isEditing && (
<>
<button
onClick={() => setIsEditing(true)}
className="bg-blue-600 hover:bg-blue-700 text-white px-6 py-2 rounded-lg flex items-center gap-2"
>
<FaEdit />
Edit
</button>
{/* Quick Status Update Buttons */}
{workOrder?.repair_status !== 'Completed' && (
<button
onClick={() => handleStatusUpdate('Completed')}
className="bg-green-600 hover:bg-green-700 text-white px-4 py-2 rounded-lg flex items-center gap-2"
disabled={saving}
>
<FaCheckCircle />
Mark Complete
</button>
)}
{workOrder?.repair_status !== 'In Progress' && workOrder?.repair_status !== 'Completed' && (
<button
onClick={() => handleStatusUpdate('In Progress')}
className="bg-yellow-600 hover:bg-yellow-700 text-white px-4 py-2 rounded-lg flex items-center gap-2"
disabled={saving}
>
<FaClock />
Start Work
</button>
)}
</>
)}
{isEditing && (
<>
<button
onClick={() => {
if (isNewWorkOrder) {
navigate('/work-orders');
} else {
setIsEditing(false);
}
}}
className="bg-gray-300 hover:bg-gray-400 text-gray-700 px-6 py-2 rounded-lg"
disabled={saving}
>
Cancel
</button>
<button
onClick={handleSubmit}
disabled={saving}
className="bg-green-600 hover:bg-green-700 text-white px-6 py-2 rounded-lg flex items-center gap-2 disabled:opacity-50"
>
<FaSave />
{saving ? 'Saving...' : 'Save Changes'}
</button>
</>
)}
</div>
</div>
<form onSubmit={handleSubmit} className="space-y-6">
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Left Column - Main Info */}
<div className="lg:col-span-2 space-y-6">
{/* Work Order Information */}
<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">Work Order Information</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">
Work Order ID
</label>
<input
type="text"
value={isNewWorkOrder || isDuplicating ? 'Auto-generated' : workOrder?.name}
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-500 dark:text-gray-400"
/>
{isDuplicating && (
<p className="mt-1 text-xs text-blue-600 dark:text-blue-400">
💡 Duplicating from: {duplicateFromWorkOrder}
</p>
)}
</div>
<div>
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">
Work Order Type <span className="text-red-500">*</span>
</label>
<select
name="work_order_type"
value={formData.work_order_type}
onChange={handleChange}
required
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"
>
<option value="">Select type</option>
<option value="Preventive Maintenance">Preventive Maintenance</option>
<option value="Corrective Maintenance">Corrective Maintenance</option>
<option value="Breakdown Maintenance">Breakdown Maintenance</option>
<option value="Calibration">Calibration</option>
<option value="Inspection">Inspection</option>
</select>
</div>
<div>
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">
Priority
</label>
<select
name="custom_priority_"
value={formData.custom_priority_}
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"
>
<option value="Low">Low</option>
<option value="Normal">Normal</option>
<option value="Medium">Medium</option>
<option value="High">High</option>
<option value="Urgent">Urgent</option>
</select>
</div>
<div>
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">
Status
</label>
<select
name="repair_status"
value={formData.repair_status}
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"
>
<option value="Open">Open</option>
<option value="In Progress">In Progress</option>
<option value="Pending">Pending</option>
<option value="Completed">Completed</option>
<option value="Cancelled">Cancelled</option>
</select>
</div>
<div>
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">
Company
</label>
<input
type="text"
name="company"
value={formData.company}
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"
/>
</div>
<div>
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">
Description
</label>
<textarea
name="description"
value={formData.description}
onChange={handleChange}
rows={3}
disabled={!isEditing}
placeholder="Describe the issue or maintenance task..."
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>
</div>
{/* COLUMN 2: Asset Information */}
<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">
Asset Information
</h2>
<div className="space-y-4">
<div>
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">
Asset ID
</label>
<input
type="text"
name="asset"
value={formData.asset}
onChange={handleChange}
disabled={!isEditing}
placeholder="e.g. ACC-ASS-2025-00001"
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
</label>
<input
type="text"
name="asset_name"
value={formData.asset_name}
onChange={handleChange}
disabled={!isEditing}
placeholder="Asset name"
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 Type
</label>
<input
type="text"
name="asset_type"
value={formData.asset_type}
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"
/>
</div>
<div>
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">
Manufacturer
</label>
<input
type="text"
name="manufacturer"
value={formData.manufacturer}
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"
/>
</div>
<div>
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">
Serial Number
</label>
<input
type="text"
name="serial_number"
value={formData.serial_number}
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"
/>
</div>
<div>
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">
Model
</label>
<input
type="text"
name="model"
value={formData.model}
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"
/>
</div>
</div>
</div>
{/* Location & Assignment */}
<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">Location & Assignment</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">
Department
</label>
<input
type="text"
name="department"
value={formData.department}
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"
/>
</div>
<div>
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">
Site Contractor
</label>
<input
type="text"
name="custom_site_contractor"
value={formData.custom_site_contractor}
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"
/>
</div>
<div>
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">
Subcontractor
</label>
<input
type="text"
name="custom_subcontractor"
value={formData.custom_subcontractor}
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"
/>
</div>
<div>
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">
Failure Date
</label>
<input
type="date"
name="failure_date"
value={formData.failure_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"
/>
</div>
<div>
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">
Deadline Date
</label>
<input
type="date"
name="custom_deadline_date"
value={formData.custom_deadline_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"
/>
</div>
</div>
</div>
</div>
{/* Right Column - Status & Summary */}
<div className="space-y-6">
<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>
{!isNewWorkOrder && workOrder && (
<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">Current Status</p>
<p className="text-lg font-semibold text-gray-900 dark:text-white">
{workOrder.repair_status || 'Open'}
</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">Priority</p>
<p className="text-lg font-semibold text-gray-900 dark:text-white">
{workOrder.custom_priority_ || 'Normal'}
</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">Created</p>
<p className="text-sm text-gray-900 dark:text-white">
{workOrder.creation ? new Date(workOrder.creation).toLocaleString() : '-'}
</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">
{workOrder.modified ? new Date(workOrder.modified).toLocaleString() : '-'}
</p>
</div>
</div>
)}
{isNewWorkOrder && (
<div className="text-center py-8">
<FaClock className="text-4xl text-gray-400 dark:text-gray-500 mx-auto mb-2" />
<p className="text-sm text-gray-500 dark:text-gray-400">
Status information will appear after creation
</p>
</div>
)}
</div>
</div>
</div>
</form>
</div>
);
};
export default WorkOrderDetail;

513
src/pages/WorkOrderList.tsx Normal file
View File

@ -0,0 +1,513 @@
import React, { useState, useEffect, useRef } from 'react';
import { useNavigate } from 'react-router-dom';
import { useWorkOrders, useWorkOrderMutations } from '../hooks/useWorkOrder';
import { FaPlus, FaSearch, FaEdit, FaEye, FaTrash, FaCopy, FaEllipsisV, FaDownload, FaPrint, FaFileExport, FaCheckCircle, FaClock, FaExclamationTriangle } from 'react-icons/fa';
const WorkOrderList: React.FC = () => {
const navigate = useNavigate();
const [page, setPage] = useState(0);
const [searchTerm, setSearchTerm] = useState('');
const [statusFilter, setStatusFilter] = useState<string>('');
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 ? { repair_status: statusFilter } : {};
const { workOrders, totalCount, hasMore, loading, error, refetch } = useWorkOrders(
filters,
limit,
page * limit,
'creation desc'
);
const { deleteWorkOrder, loading: mutationLoading } = useWorkOrderMutations();
// Close dropdown when clicking outside
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
setActionMenuOpen(null);
}
};
if (actionMenuOpen) {
document.addEventListener('mousedown', handleClickOutside);
}
return () => {
document.removeEventListener('mousedown', handleClickOutside);
};
}, [actionMenuOpen]);
const handleCreateNew = () => {
navigate('/work-orders/new');
};
const handleView = (workOrderName: string) => {
navigate(`/work-orders/${workOrderName}`);
};
const handleEdit = (workOrderName: string) => {
navigate(`/work-orders/${workOrderName}`);
};
const handleDelete = async (workOrderName: string) => {
try {
await deleteWorkOrder(workOrderName);
setDeleteConfirmOpen(null);
refetch();
alert('Work order deleted successfully!');
} catch (err) {
alert(`Failed to delete work order: ${err instanceof Error ? err.message : 'Unknown error'}`);
}
};
const handleDuplicate = (workOrderName: string) => {
navigate(`/work-orders/new?duplicate=${workOrderName}`);
};
const handleExport = (workOrder: any) => {
const dataStr = JSON.stringify(workOrder, null, 2);
const dataBlob = new Blob([dataStr], { type: 'application/json' });
const url = URL.createObjectURL(dataBlob);
const link = document.createElement('a');
link.href = url;
link.download = `work_order_${workOrder.name}.json`;
link.click();
URL.revokeObjectURL(url);
};
const handlePrint = (workOrderName: string) => {
window.open(`/work-orders/${workOrderName}?print=true`, '_blank');
};
const handleExportAll = () => {
const headers = ['Work Order ID', 'Asset', 'Type', 'Status', 'Department', 'Priority', 'Created'];
const csvContent = [
headers.join(','),
...workOrders.map(wo => [
wo.name,
wo.asset_name || wo.asset || '',
wo.work_order_type || '',
wo.repair_status || '',
wo.department || '',
wo.custom_priority_ || '',
wo.creation ? new Date(wo.creation).toLocaleDateString() : ''
].join(','))
].join('\n');
const dataBlob = new Blob([csvContent], { type: 'text/csv' });
const url = URL.createObjectURL(dataBlob);
const link = document.createElement('a');
link.href = url;
link.download = `work_orders_export_${new Date().toISOString().split('T')[0]}.csv`;
link.click();
URL.revokeObjectURL(url);
};
const getStatusIcon = (status: string) => {
switch (status?.toLowerCase()) {
case 'completed':
return <FaCheckCircle className="text-green-500" />;
case 'in progress':
return <FaClock className="text-blue-500" />;
case 'pending':
return <FaExclamationTriangle className="text-yellow-500" />;
default:
return <FaClock className="text-gray-400" />;
}
};
const getStatusColor = (status: string) => {
switch (status?.toLowerCase()) {
case 'completed':
return 'bg-green-100 dark:bg-green-900/30 text-green-800 dark:text-green-300';
case 'in progress':
return 'bg-blue-100 dark:bg-blue-900/30 text-blue-800 dark:text-blue-300';
case 'pending':
case 'open':
return 'bg-yellow-100 dark:bg-yellow-900/30 text-yellow-800 dark:text-yellow-300';
case 'cancelled':
return 'bg-red-100 dark:bg-red-900/30 text-red-800 dark:text-red-300';
default:
return 'bg-gray-100 dark:bg-gray-700 text-gray-800 dark:text-gray-300';
}
};
const getPriorityColor = (priority: string) => {
switch (priority?.toLowerCase()) {
case 'high':
case 'urgent':
return 'bg-red-100 dark:bg-red-900/30 text-red-800 dark:text-red-300';
case 'medium':
return 'bg-orange-100 dark:bg-orange-900/30 text-orange-800 dark:text-orange-300';
case 'low':
return 'bg-green-100 dark:bg-green-900/30 text-green-800 dark:text-green-300';
default:
return 'bg-gray-100 dark:bg-gray-700 text-gray-800 dark:text-gray-300';
}
};
if (loading && page === 0) {
return (
<div className="flex items-center justify-center h-screen bg-gray-50 dark:bg-gray-900">
<div className="text-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-500 mx-auto"></div>
<p className="mt-4 text-gray-600 dark:text-gray-400">Loading work orders...</p>
</div>
</div>
);
}
if (error) {
return (
<div className="p-6 bg-gray-50 dark:bg-gray-900 min-h-screen">
<div className="bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-lg p-6">
<h2 className="text-xl font-bold text-yellow-800 dark:text-yellow-300 mb-4"> Work Order API Not Available</h2>
<div className="text-yellow-700 dark:text-yellow-400 space-y-3">
<p><strong>The Work Order API endpoint is not deployed yet.</strong></p>
<p>To fix this, deploy the work_order_api.py file to your Frappe server.</p>
<div className="mt-4 flex gap-3">
<button
onClick={() => navigate('/work-orders/new')}
className="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded"
>
Try Creating New (Demo)
</button>
<button
onClick={refetch}
className="bg-gray-600 hover:bg-gray-700 text-white px-4 py-2 rounded"
>
Try Again
</button>
</div>
</div>
<div className="mt-4 p-4 bg-white dark:bg-gray-800 rounded border border-yellow-300 dark:border-yellow-700">
<p className="text-sm text-gray-600 dark:text-gray-400">
<strong>Technical Error:</strong> {error}
</p>
</div>
</div>
</div>
);
}
// Filter work orders by search term
const filteredWorkOrders = workOrders.filter(wo =>
wo.name?.toLowerCase().includes(searchTerm.toLowerCase()) ||
wo.asset_name?.toLowerCase().includes(searchTerm.toLowerCase()) ||
wo.description?.toLowerCase().includes(searchTerm.toLowerCase()) ||
wo.asset?.toLowerCase().includes(searchTerm.toLowerCase())
);
return (
<div className="p-6 bg-gray-50 dark:bg-gray-900 min-h-screen">
{/* Header */}
<div className="mb-6 flex justify-between items-center">
<div>
<h1 className="text-3xl font-bold text-gray-800 dark:text-white">Work Orders</h1>
<p className="text-gray-600 dark:text-gray-400 mt-1">
Total: {totalCount} work order{totalCount !== 1 ? 's' : ''}
</p>
</div>
<div className="flex gap-3">
<button
onClick={handleExportAll}
className="bg-green-600 hover:bg-green-700 text-white px-4 py-3 rounded-lg flex items-center gap-2 shadow transition-all"
disabled={workOrders.length === 0}
>
<FaFileExport />
<span className="font-medium">Export All</span>
</button>
<button
onClick={handleCreateNew}
className="bg-blue-600 hover:bg-blue-700 text-white px-6 py-3 rounded-lg flex items-center gap-2 shadow-lg transition-all hover:shadow-xl"
>
<FaPlus />
<span className="font-medium">New Work Order</span>
</button>
</div>
</div>
{/* Filters Bar */}
<div className="mb-6 grid grid-cols-1 md:grid-cols-2 gap-4">
{/* Search Bar */}
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-4">
<div className="flex items-center gap-2 border border-gray-300 dark:border-gray-600 rounded-lg px-4 py-2 bg-white dark:bg-gray-700">
<FaSearch className="text-gray-400 dark:text-gray-500" />
<input
type="text"
placeholder="Search by ID, asset name, description..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="flex-1 outline-none text-gray-700 dark:text-gray-200 bg-transparent"
/>
</div>
</div>
{/* Status Filter */}
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-4">
<select
value={statusFilter}
onChange={(e) => {
setStatusFilter(e.target.value);
setPage(0);
}}
className="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-700 dark:text-gray-200 focus:outline-none focus:ring-2 focus:ring-blue-500"
>
<option value="">All Statuses</option>
<option value="Open">Open</option>
<option value="In Progress">In Progress</option>
<option value="Pending">Pending</option>
<option value="Completed">Completed</option>
<option value="Cancelled">Cancelled</option>
</select>
</div>
</div>
{/* Work Orders Table */}
<div className="bg-white dark:bg-gray-800 rounded-lg shadow overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full">
<thead className="bg-gray-100 dark:bg-gray-700 border-b border-gray-200 dark:border-gray-600">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
Work Order ID
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
Asset
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
Type
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
Department
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
Status
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
Priority
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
Actions
</th>
</tr>
</thead>
<tbody className="bg-white dark:bg-gray-800 divide-y divide-gray-200 dark:divide-gray-700">
{filteredWorkOrders.length === 0 ? (
<tr>
<td colSpan={7} className="px-6 py-12 text-center text-gray-500 dark:text-gray-400">
<div className="flex flex-col items-center">
<FaSearch className="text-4xl text-gray-300 dark:text-gray-600 mb-2" />
<p>No work orders found</p>
<button
onClick={handleCreateNew}
className="mt-4 text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300 underline"
>
Create your first work order
</button>
</div>
</td>
</tr>
) : (
filteredWorkOrders.map((workOrder) => (
<tr
key={workOrder.name}
className="hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors cursor-pointer"
onClick={() => handleView(workOrder.name)}
>
<td className="px-6 py-4 whitespace-nowrap">
<div className="font-medium text-gray-900 dark:text-white">{workOrder.name}</div>
<div className="text-sm text-gray-500 dark:text-gray-400">
{workOrder.creation ? new Date(workOrder.creation).toLocaleDateString() : ''}
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm text-gray-900 dark:text-white">{workOrder.asset_name || '-'}</div>
<div className="text-xs text-gray-500 dark:text-gray-400">{workOrder.asset || ''}</div>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-700 dark:text-gray-300">
{workOrder.work_order_type || '-'}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-700 dark:text-gray-300">
{workOrder.department || '-'}
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="flex items-center gap-2">
{getStatusIcon(workOrder.repair_status || '')}
<span className={`px-3 py-1 inline-flex text-xs leading-5 font-semibold rounded-full ${getStatusColor(workOrder.repair_status || '')}`}>
{workOrder.repair_status || 'Unknown'}
</span>
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<span className={`px-3 py-1 inline-flex text-xs leading-5 font-semibold rounded-full ${getPriorityColor(workOrder.custom_priority_ || '')}`}>
{workOrder.custom_priority_ || 'Normal'}
</span>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium">
<div className="flex items-center gap-1" onClick={(e) => e.stopPropagation()}>
<button
onClick={() => handleView(workOrder.name)}
className="text-blue-600 dark:text-blue-400 hover:text-blue-900 dark:hover:text-blue-300 p-2 hover:bg-blue-50 dark:hover:bg-blue-900/30 rounded transition-colors"
title="View Details"
>
<FaEye />
</button>
<button
onClick={() => handleEdit(workOrder.name)}
className="text-green-600 dark:text-green-400 hover:text-green-900 dark:hover:text-green-300 p-2 hover:bg-green-50 dark:hover:bg-green-900/30 rounded transition-colors"
title="Edit Work Order"
>
<FaEdit />
</button>
<button
onClick={() => handleDuplicate(workOrder.name)}
className="text-purple-600 dark:text-purple-400 hover:text-purple-900 dark:hover:text-purple-300 p-2 hover:bg-purple-50 dark:hover:bg-purple-900/30 rounded transition-colors"
title="Duplicate Work Order"
>
<FaCopy />
</button>
<button
onClick={() => setDeleteConfirmOpen(workOrder.name)}
className="text-red-600 dark:text-red-400 hover:text-red-900 dark:hover:text-red-300 p-2 hover:bg-red-50 dark:hover:bg-red-900/30 rounded transition-colors"
title="Delete Work Order"
disabled={mutationLoading}
>
<FaTrash />
</button>
{/* More Actions Dropdown */}
<div className="relative" ref={actionMenuOpen === workOrder.name ? dropdownRef : null}>
<button
onClick={() => setActionMenuOpen(actionMenuOpen === workOrder.name ? null : workOrder.name)}
className="text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200 p-2 hover:bg-gray-50 dark:hover:bg-gray-700 rounded transition-colors"
title="More Actions"
>
<FaEllipsisV />
</button>
{actionMenuOpen === workOrder.name && (
<div className="absolute right-0 mt-2 w-48 bg-white dark:bg-gray-800 rounded-lg shadow-lg border border-gray-200 dark:border-gray-700 z-10">
<button
onClick={() => {
handleExport(workOrder);
setActionMenuOpen(null);
}}
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 rounded-t-lg"
>
<FaDownload className="text-blue-500" />
Export as JSON
</button>
<button
onClick={() => {
handlePrint(workOrder.name);
setActionMenuOpen(null);
}}
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 rounded-b-lg"
>
<FaPrint className="text-purple-500" />
Print Work Order
</button>
</div>
)}
</div>
</div>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
{/* Pagination */}
{filteredWorkOrders.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
</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>
)}
</div>
{/* Delete Confirmation Modal */}
{deleteConfirmOpen && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white dark:bg-gray-800 rounded-lg p-6 max-w-md w-full mx-4 shadow-2xl">
<div className="flex items-start gap-4">
<div className="flex-shrink-0 w-12 h-12 rounded-full bg-red-100 dark:bg-red-900/30 flex items-center justify-center">
<FaTrash className="text-red-600 dark:text-red-400 text-xl" />
</div>
<div className="flex-1">
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-2">
Delete Work Order
</h3>
<p className="text-sm text-gray-600 dark:text-gray-400 mb-4">
Are you sure you want to delete this work order? This action cannot be undone.
</p>
<div className="bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-md p-3 mb-4">
<p className="text-xs text-yellow-800 dark:text-yellow-300">
<strong>Work Order ID:</strong> {deleteConfirmOpen}
</p>
</div>
<div className="flex gap-3 justify-end">
<button
onClick={() => setDeleteConfirmOpen(null)}
className="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 rounded-lg transition-colors"
disabled={mutationLoading}
>
Cancel
</button>
<button
onClick={() => handleDelete(deleteConfirmOpen)}
className="px-4 py-2 text-sm font-medium text-white bg-red-600 hover:bg-red-700 rounded-lg transition-colors flex items-center gap-2 disabled:opacity-50"
disabled={mutationLoading}
>
{mutationLoading ? (
<>
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white"></div>
Deleting...
</>
) : (
<>
<FaTrash />
Delete Work Order
</>
)}
</button>
</div>
</div>
</div>
</div>
</div>
)}
</div>
);
};
export default WorkOrderList;

View File

@ -0,0 +1,220 @@
import apiService from './apiService';
import API_CONFIG from '../config/api';
// Asset Maintenance Interfaces
export interface AssetMaintenanceLog {
name: string;
asset_maintenance?: string;
naming_series?: string;
asset_name?: string;
custom_asset_type?: string;
item_code?: string;
item_name?: string;
custom_asset_names?: string;
custom_hospital_name?: string;
task?: string;
task_name?: string;
maintenance_type?: string;
periodicity?: string;
has_certificate?: number;
custom_early_completion?: number;
maintenance_status?: string;
custom_pm_overdue_reason?: string;
custom_accepted_by_moh?: string;
assign_to_name?: string;
due_date?: string;
custom_accepted_by_moh_?: string;
custom_template?: string;
workflow_state?: string;
creation?: string;
modified?: string;
owner?: string;
modified_by?: string;
docstatus?: number;
idx?: number;
}
export interface AssetMaintenanceListResponse {
asset_maintenance_logs: AssetMaintenanceLog[];
total_count: number;
limit: number;
offset: number;
has_more: boolean;
}
export interface MaintenanceFilters {
maintenance_status?: string;
asset_name?: string;
custom_hospital_name?: string;
maintenance_type?: string;
[key: string]: any;
}
export interface CreateMaintenanceData {
asset_name?: string;
task?: string;
task_name?: string;
maintenance_type?: string;
periodicity?: string;
maintenance_status?: string;
due_date?: string;
assign_to_name?: string;
description?: string;
[key: string]: any;
}
class AssetMaintenanceService {
/**
* Get list of asset maintenance logs with optional filters and pagination
*/
async getMaintenanceLogs(
filters?: MaintenanceFilters,
fields?: string[],
limit: number = 20,
offset: number = 0,
orderBy?: string
): Promise<AssetMaintenanceListResponse> {
const params = new URLSearchParams();
if (filters) {
params.append('filters', JSON.stringify(filters));
}
if (fields && fields.length > 0) {
params.append('fields', JSON.stringify(fields));
}
params.append('limit', limit.toString());
params.append('offset', offset.toString());
if (orderBy) {
params.append('order_by', orderBy);
}
const endpoint = `${API_CONFIG.ENDPOINTS.GET_ASSET_MAINTENANCE_LOGS}?${params.toString()}`;
return apiService.apiCall<AssetMaintenanceListResponse>(endpoint);
}
/**
* Get detailed information about a specific maintenance log
*/
async getMaintenanceLogDetails(logName: string): Promise<AssetMaintenanceLog> {
const endpoint = `${API_CONFIG.ENDPOINTS.GET_ASSET_MAINTENANCE_LOG_DETAILS}?log_name=${encodeURIComponent(logName)}`;
return apiService.apiCall<AssetMaintenanceLog>(endpoint);
}
/**
* Create a new maintenance log
*/
async createMaintenanceLog(logData: CreateMaintenanceData): Promise<{ success: boolean; asset_maintenance_log: AssetMaintenanceLog; message: string }> {
return apiService.apiCall(API_CONFIG.ENDPOINTS.CREATE_ASSET_MAINTENANCE_LOG, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ log_data: logData })
});
}
/**
* Update an existing maintenance log
*/
async updateMaintenanceLog(
logName: string,
logData: Partial<CreateMaintenanceData>
): Promise<{ success: boolean; asset_maintenance_log: AssetMaintenanceLog; message: string }> {
return apiService.apiCall(API_CONFIG.ENDPOINTS.UPDATE_ASSET_MAINTENANCE_LOG, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
log_name: logName,
log_data: logData
})
});
}
/**
* Delete a maintenance log
*/
async deleteMaintenanceLog(logName: string): Promise<{ success: boolean; message: string }> {
return apiService.apiCall(API_CONFIG.ENDPOINTS.DELETE_ASSET_MAINTENANCE_LOG, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ log_name: logName })
});
}
/**
* Update maintenance log status
*/
async updateMaintenanceStatus(
logName: string,
maintenanceStatus?: string,
workflowState?: string
): Promise<{ success: boolean; asset_maintenance_log: AssetMaintenanceLog; message: string }> {
return apiService.apiCall(API_CONFIG.ENDPOINTS.UPDATE_MAINTENANCE_STATUS, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
log_name: logName,
maintenance_status: maintenanceStatus,
workflow_state: workflowState
})
});
}
/**
* Get maintenance logs for a specific asset
*/
async getMaintenanceLogsByAsset(
assetName: string,
filters?: MaintenanceFilters,
limit: number = 20,
offset: number = 0
): Promise<AssetMaintenanceListResponse> {
const params = new URLSearchParams();
params.append('asset_name', assetName);
if (filters) {
params.append('filters', JSON.stringify(filters));
}
params.append('limit', limit.toString());
params.append('offset', offset.toString());
const endpoint = `${API_CONFIG.ENDPOINTS.GET_MAINTENANCE_LOGS_BY_ASSET}?${params.toString()}`;
return apiService.apiCall<AssetMaintenanceListResponse>(endpoint);
}
/**
* Get overdue maintenance logs
*/
async getOverdueMaintenanceLogs(
filters?: MaintenanceFilters,
limit: number = 20,
offset: number = 0
): Promise<AssetMaintenanceListResponse> {
const params = new URLSearchParams();
if (filters) {
params.append('filters', JSON.stringify(filters));
}
params.append('limit', limit.toString());
params.append('offset', offset.toString());
const endpoint = `${API_CONFIG.ENDPOINTS.GET_OVERDUE_MAINTENANCE_LOGS}?${params.toString()}`;
return apiService.apiCall<AssetMaintenanceListResponse>(endpoint);
}
}
// Create and export singleton instance
const assetMaintenanceService = new AssetMaintenanceService();
export default assetMaintenanceService;

242
src/services/ppmService.ts Normal file
View File

@ -0,0 +1,242 @@
import apiService from './apiService';
import API_CONFIG from '../config/api';
// PPM (Asset Maintenance) Interfaces
export interface AssetMaintenance {
name: string;
company?: string;
asset_name?: string;
custom_asset_type?: string;
asset_category?: string;
custom_type_of_maintenance?: string;
custom_asset_name?: string;
item_code?: string;
item_name?: string;
maintenance_team?: string;
custom_pm_schedule?: string;
maintenance_manager?: string;
maintenance_manager_name?: string;
custom_warranty?: string;
custom_warranty_status?: string;
custom_service_contract?: number;
custom_service_contract_status?: string;
custom_frequency?: string;
custom_total_amount?: number;
custom_no_of_pms?: number;
custom_price_per_pm?: number;
creation?: string;
modified?: string;
owner?: string;
modified_by?: string;
docstatus?: number;
idx?: number;
}
export interface AssetMaintenanceListResponse {
asset_maintenances: AssetMaintenance[];
total_count: number;
limit: number;
offset: number;
has_more: boolean;
}
export interface MaintenanceTask {
name: string;
parent?: string;
task?: string;
task_name?: string;
start_date?: string;
end_date?: string;
periodicity?: string;
maintenance_type?: string;
maintenance_status?: string;
assign_to?: string;
assign_to_name?: string;
next_due_date?: string;
last_completion_date?: string;
[key: string]: any;
}
export interface ServiceCoverage {
name: string;
parent?: string;
[key: string]: any;
}
export interface PPMFilters {
company?: string;
asset_name?: string;
custom_asset_type?: string;
maintenance_team?: string;
custom_service_contract?: number;
[key: string]: any;
}
export interface CreatePPMData {
company?: string;
asset_name?: string;
custom_asset_type?: string;
maintenance_team?: string;
custom_frequency?: string;
custom_total_amount?: number;
[key: string]: any;
}
class PPMService {
/**
* Get list of asset maintenances (PPM schedules) with optional filters and pagination
*/
async getAssetMaintenances(
filters?: PPMFilters,
fields?: string[],
limit: number = 20,
offset: number = 0,
orderBy?: string
): Promise<AssetMaintenanceListResponse> {
const params = new URLSearchParams();
if (filters) {
params.append('filters', JSON.stringify(filters));
}
if (fields && fields.length > 0) {
params.append('fields', JSON.stringify(fields));
}
params.append('limit', limit.toString());
params.append('offset', offset.toString());
if (orderBy) {
params.append('order_by', orderBy);
}
const endpoint = `${API_CONFIG.ENDPOINTS.GET_ASSET_MAINTENANCES}?${params.toString()}`;
return apiService.apiCall<AssetMaintenanceListResponse>(endpoint);
}
/**
* Get detailed information about a specific asset maintenance
*/
async getAssetMaintenanceDetails(maintenanceName: string): Promise<AssetMaintenance> {
const params = new URLSearchParams();
params.append('maintenance_name', maintenanceName);
const endpoint = `${API_CONFIG.ENDPOINTS.GET_ASSET_MAINTENANCE_DETAILS}?${params.toString()}`;
return apiService.apiCall<AssetMaintenance>(endpoint);
}
/**
* Create a new asset maintenance (PPM schedule)
*/
async createAssetMaintenance(data: CreatePPMData): Promise<{ success: boolean; asset_maintenance: AssetMaintenance; message?: string }> {
const endpoint = `${API_CONFIG.ENDPOINTS.CREATE_ASSET_MAINTENANCE}`;
return apiService.apiCall<{ success: boolean; asset_maintenance: AssetMaintenance; message?: string }>(
endpoint,
{
method: 'POST',
body: JSON.stringify({ maintenance_data: JSON.stringify(data) })
}
);
}
/**
* Update an existing asset maintenance
*/
async updateAssetMaintenance(
maintenanceName: string,
data: Partial<CreatePPMData>
): Promise<{ success: boolean; asset_maintenance: AssetMaintenance; message?: string }> {
const endpoint = `${API_CONFIG.ENDPOINTS.UPDATE_ASSET_MAINTENANCE}`;
return apiService.apiCall<{ success: boolean; asset_maintenance: AssetMaintenance; message?: string }>(
endpoint,
{
method: 'POST',
body: JSON.stringify({
maintenance_name: maintenanceName,
maintenance_data: JSON.stringify(data)
})
}
);
}
/**
* Delete an asset maintenance
*/
async deleteAssetMaintenance(maintenanceName: string): Promise<{ success: boolean; message?: string }> {
const endpoint = `${API_CONFIG.ENDPOINTS.DELETE_ASSET_MAINTENANCE}`;
return apiService.apiCall<{ success: boolean; message?: string }>(
endpoint,
{
method: 'POST',
body: JSON.stringify({ maintenance_name: maintenanceName })
}
);
}
/**
* Get all maintenance tasks for a specific asset maintenance
*/
async getMaintenanceTasks(maintenanceName: string): Promise<{ maintenance_tasks: MaintenanceTask[]; total_count: number }> {
const params = new URLSearchParams();
params.append('maintenance_name', maintenanceName);
const endpoint = `${API_CONFIG.ENDPOINTS.GET_MAINTENANCE_TASKS}?${params.toString()}`;
return apiService.apiCall<{ maintenance_tasks: MaintenanceTask[]; total_count: number }>(endpoint);
}
/**
* Get service coverage for a specific asset maintenance
*/
async getServiceCoverage(maintenanceName: string): Promise<{ service_coverage: ServiceCoverage[]; total_count: number }> {
const params = new URLSearchParams();
params.append('maintenance_name', maintenanceName);
const endpoint = `${API_CONFIG.ENDPOINTS.GET_SERVICE_COVERAGE}?${params.toString()}`;
return apiService.apiCall<{ service_coverage: ServiceCoverage[]; total_count: number }>(endpoint);
}
/**
* Get all maintenance schedules for a specific asset
*/
async getMaintenancesByAsset(
assetName: string,
filters?: PPMFilters,
limit: number = 20,
offset: number = 0
): Promise<AssetMaintenanceListResponse> {
const params = new URLSearchParams();
params.append('asset_name', assetName);
if (filters) {
params.append('filters', JSON.stringify(filters));
}
params.append('limit', limit.toString());
params.append('offset', offset.toString());
const endpoint = `${API_CONFIG.ENDPOINTS.GET_MAINTENANCES_BY_ASSET}?${params.toString()}`;
return apiService.apiCall<AssetMaintenanceListResponse>(endpoint);
}
/**
* Get all asset maintenances with active service contracts
*/
async getActiveServiceContracts(
filters?: PPMFilters,
limit: number = 20,
offset: number = 0
): Promise<AssetMaintenanceListResponse> {
const params = new URLSearchParams();
if (filters) {
params.append('filters', JSON.stringify(filters));
}
params.append('limit', limit.toString());
params.append('offset', offset.toString());
const endpoint = `${API_CONFIG.ENDPOINTS.GET_ACTIVE_SERVICE_CONTRACTS}?${params.toString()}`;
return apiService.apiCall<AssetMaintenanceListResponse>(endpoint);
}
}
const ppmService = new PPMService();
export default ppmService;

View File

@ -0,0 +1,205 @@
import apiService from './apiService';
import API_CONFIG from '../config/api';
// Work Order Interfaces
export interface WorkOrder {
name: string;
company?: string;
naming_series?: string;
work_order_type?: string;
asset_type?: string;
manufacturer?: string;
serial_number?: string;
custom_priority_?: string;
asset?: string;
custom_maintenance_manager?: string;
department?: string;
repair_status?: string;
asset_name?: string;
supplier?: string;
custom_pending_reason?: string;
model?: string;
custom_site_contractor?: string;
custom_subcontractor?: string;
custom_service_agreement?: string;
custom_service_coverage?: string;
custom_start_date?: string;
custom_end_date?: string;
custom_total_amount?: number;
warranty?: string;
service_contract?: string;
covering_spare_parts?: string;
spare_parts_labour?: string;
covering_labour?: string;
ppm_only?: number;
failure_date?: string;
total_hours_spent?: number;
job_completed?: string;
custom_difference?: number;
custom_vendors_hrs?: number;
custom_deadline_date?: string;
custom_diffrence?: number;
feedback_rating?: number;
first_responded_on?: string;
penalty?: number;
custom_assigned_supervisor?: string;
stock_consumption?: number;
need_procurement?: number;
repair_cost?: number;
total_repair_cost?: number;
capitalize_repair_cost?: number;
increase_in_asset_life?: number;
description?: string;
actions_performed?: string;
bio_med_dept?: string;
workflow_state?: string;
creation?: string;
modified?: string;
owner?: string;
modified_by?: string;
docstatus?: number;
idx?: number;
}
export interface WorkOrderListResponse {
work_orders: WorkOrder[];
total_count: number;
limit: number;
offset: number;
has_more: boolean;
}
export interface WorkOrderFilters {
company?: string;
department?: string;
work_order_type?: string;
repair_status?: string;
workflow_state?: string;
asset?: string;
[key: string]: any;
}
export interface CreateWorkOrderData {
company?: string;
work_order_type?: string;
asset?: string;
asset_name?: string;
description?: string;
repair_status?: string;
workflow_state?: string;
department?: string;
custom_priority_?: string;
[key: string]: any;
}
class WorkOrderService {
/**
* Get list of work orders with optional filters and pagination
*/
async getWorkOrders(
filters?: WorkOrderFilters,
fields?: string[],
limit: number = 20,
offset: number = 0,
orderBy?: string
): Promise<WorkOrderListResponse> {
const params = new URLSearchParams();
if (filters) {
params.append('filters', JSON.stringify(filters));
}
if (fields && fields.length > 0) {
params.append('fields', JSON.stringify(fields));
}
params.append('limit', limit.toString());
params.append('offset', offset.toString());
if (orderBy) {
params.append('order_by', orderBy);
}
const endpoint = `${API_CONFIG.ENDPOINTS.GET_WORK_ORDERS}?${params.toString()}`;
return apiService.apiCall<WorkOrderListResponse>(endpoint);
}
/**
* Get detailed information about a specific work order
*/
async getWorkOrderDetails(workOrderName: string): Promise<WorkOrder> {
const endpoint = `${API_CONFIG.ENDPOINTS.GET_WORK_ORDER_DETAILS}?work_order_name=${encodeURIComponent(workOrderName)}`;
return apiService.apiCall<WorkOrder>(endpoint);
}
/**
* Create a new work order
*/
async createWorkOrder(workOrderData: CreateWorkOrderData): Promise<{ success: boolean; work_order: WorkOrder; message: string }> {
return apiService.apiCall(API_CONFIG.ENDPOINTS.CREATE_WORK_ORDER, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ work_order_data: workOrderData })
});
}
/**
* Update an existing work order
*/
async updateWorkOrder(
workOrderName: string,
workOrderData: Partial<CreateWorkOrderData>
): Promise<{ success: boolean; work_order: WorkOrder; message: string }> {
return apiService.apiCall(API_CONFIG.ENDPOINTS.UPDATE_WORK_ORDER, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
work_order_name: workOrderName,
work_order_data: workOrderData
})
});
}
/**
* Delete a work order
*/
async deleteWorkOrder(workOrderName: string): Promise<{ success: boolean; message: string }> {
return apiService.apiCall(API_CONFIG.ENDPOINTS.DELETE_WORK_ORDER, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ work_order_name: workOrderName })
});
}
/**
* Update work order status
*/
async updateWorkOrderStatus(
workOrderName: string,
repairStatus?: string,
workflowState?: string
): Promise<{ success: boolean; work_order: WorkOrder; message: string }> {
return apiService.apiCall(API_CONFIG.ENDPOINTS.UPDATE_WORK_ORDER_STATUS, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
work_order_name: workOrderName,
repair_status: repairStatus,
workflow_state: workflowState
})
});
}
}
// Create and export singleton instance
const workOrderService = new WorkOrderService();
export default workOrderService;