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"> <html lang="en">
<head> <head>
<meta charset="UTF-8" /> <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" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>frappe-frontend</title> <title>frappe-frontend</title>
</head> </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 EventsList from './pages/EventsList';
import AssetList from './pages/AssetList'; import AssetList from './pages/AssetList';
import AssetDetail from './pages/AssetDetail'; 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'; import Sidebar from './components/Sidebar';
// Layout with 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 <Route
path="/old-dashboard" path="/old-dashboard"
element={ element={

View File

@ -2,23 +2,23 @@ import React, { useState } from 'react';
import { Link, useLocation, useNavigate } from 'react-router-dom'; import { Link, useLocation, useNavigate } from 'react-router-dom';
import { useTheme } from '../contexts/ThemeContext'; import { useTheme } from '../contexts/ThemeContext';
import { import {
FaTools, LayoutDashboard,
FaBox, Package,
FaWrench, Wrench,
FaCog, Users,
FaUsers, BarChart3,
FaChartBar, Building2,
FaBuilding, Truck,
FaTruck, FileText,
FaFileContract, MapPin,
FaInfoCircle, Menu,
FaBars, X,
FaTimes, Moon,
FaHome, Sun,
FaMoon, LogOut,
FaSun, ClipboardList,
FaSignOutAlt Calendar
} from 'react-icons/fa'; } from 'lucide-react';
interface SidebarLink { interface SidebarLink {
id: string; id: string;
@ -75,80 +75,87 @@ const Sidebar: React.FC<SidebarProps> = ({ userEmail }) => {
{ {
id: 'dashboard', id: 'dashboard',
title: 'Dashboard', title: 'Dashboard',
icon: <FaHome />, icon: <LayoutDashboard size={20} />,
path: '/dashboard', path: '/dashboard',
visible: true visible: true
}, },
{ {
id: 'assets', id: 'assets',
title: 'Assets', title: 'Assets',
icon: <FaTools />, icon: <Package size={20} />,
path: '/assets', path: '/assets',
visible: showAsset visible: showAsset
}, },
{ {
id: 'work-orders', id: 'work-orders',
title: 'Work Orders', title: 'Work Orders',
icon: <FaCog />, icon: <ClipboardList size={20} />,
path: '/work-orders', path: '/work-orders',
visible: showGeneralWO visible: showGeneralWO
}, },
{ {
id: 'ppm', id: 'maintenance',
title: 'PPM', title: 'Asset Maintenance',
icon: <FaWrench />, icon: <Wrench size={20} />,
path: '/ppm', path: '/maintenance',
visible: showPreventiveMaintenance visible: showPreventiveMaintenance
}, },
{ {
id: 'inventory', id: 'ppm',
title: 'Inventory', title: 'PPM',
icon: <FaBox />, icon: <Calendar size={20} />,
path: '/inventory', path: '/ppm',
visible: showInventory visible: showPreventiveMaintenance
}, },
{ // {
id: 'vendors', // id: 'inventory',
title: 'Vendors', // title: 'Inventory',
icon: <FaTruck />, // icon: <Package size={20} />,
path: '/vendors', // path: '/inventory',
visible: showSupplierDashboard // visible: showInventory
}, // },
{ // {
id: 'dashboard-view', // id: 'vendors',
title: 'Dashboard', // title: 'Vendors',
icon: <FaChartBar />, // icon: <Truck size={20} />,
path: '/dashboard-view', // path: '/vendors',
visible: showProjectDashboard // visible: showSupplierDashboard
}, // },
{ // {
id: 'sites', // id: 'dashboard-view',
title: 'Sites', // title: 'Dashboard',
icon: <FaBuilding />, // icon: <BarChart3 size={20} />,
path: '/sites', // path: '/dashboard-view',
visible: showSiteDashboards // visible: showProjectDashboard
}, // },
{ // {
id: 'active-map', // id: 'sites',
title: 'Active Map', // title: 'Sites',
icon: <FaInfoCircle />, // icon: <Building2 size={20} />,
path: '/active-map', // path: '/sites',
visible: showSiteInfo // visible: showSiteDashboards
}, // },
{ // {
id: 'users', // id: 'active-map',
title: 'Users', // title: 'Active Map',
icon: <FaUsers />, // icon: <MapPin size={20} />,
path: '/users', // path: '/active-map',
visible: showAMTeam // visible: showSiteInfo
}, // },
{ // {
id: 'account', // id: 'users',
title: 'Account', // title: 'Users',
icon: <FaFileContract />, // icon: <Users size={20} />,
path: '/account', // path: '/users',
visible: showSLA // visible: showAMTeam
} // },
// {
// id: 'account',
// title: 'Account',
// icon: <FileText size={20} />,
// path: '/account',
// visible: showSLA
// }
]; ];
const visibleLinks = links.filter(link => link.visible); const visibleLinks = links.filter(link => link.visible);
@ -175,18 +182,51 @@ const Sidebar: React.FC<SidebarProps> = ({ userEmail }) => {
{/* Sidebar Header */} {/* Sidebar Header */}
<div className="flex items-center justify-between p-4 border-b border-gray-200 dark:border-gray-700"> <div className="flex items-center justify-between p-4 border-b border-gray-200 dark:border-gray-700">
{!isCollapsed && ( {!isCollapsed && (
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-3">
<div className="w-8 h-8 bg-gradient-to-br from-indigo-600 to-purple-600 rounded-lg flex items-center justify-center"> <div className="w-10 h-10 flex items-center justify-center bg-white dark:bg-gray-700 rounded-lg p-1">
<span className="text-white font-bold text-sm">AL</span> {/* 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> </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> </div>
)} )}
<button <button
onClick={() => setIsCollapsed(!isCollapsed)} 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" 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> </button>
</div> </div>
@ -211,7 +251,7 @@ const Sidebar: React.FC<SidebarProps> = ({ userEmail }) => {
`} `}
title={isCollapsed ? link.title : ''} title={isCollapsed ? link.title : ''}
> >
<span className="text-xl">{link.icon}</span> <span>{link.icon}</span>
{!isCollapsed && ( {!isCollapsed && (
<span className="ml-4 font-medium">{link.title}</span> <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" 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') : ''} 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 && ( {!isCollapsed && (
<span className="ml-2 text-sm font-medium"> <span className="ml-2 text-sm font-medium">
{theme === 'light' ? 'Dark Mode' : 'Light Mode'} {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" 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' : ''} title={isCollapsed ? 'Logout' : ''}
> >
<FaSignOutAlt size={16} /> <LogOut size={18} />
{!isCollapsed && ( {!isCollapsed && (
<span className="ml-2 text-sm font-medium">Logout</span> <span className="ml-2 text-sm font-medium">Logout</span>
)} )}
@ -260,7 +300,7 @@ const Sidebar: React.FC<SidebarProps> = ({ userEmail }) => {
{!isCollapsed && ( {!isCollapsed && (
<div className="text-xs text-gray-400 dark:text-gray-500 text-center"> <div className="text-xs text-gray-400 dark:text-gray-500 text-center">
Asset Lite v1.0 Seera Arabia AMS v1.0
</div> </div>
)} )}
</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', GET_ASSET_STATS: '/api/method/asset_lite.api.asset_api.get_asset_stats',
SEARCH_ASSETS: '/api/method/asset_lite.api.asset_api.search_assets', 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 // Authentication
LOGIN: '/api/method/login', LOGIN: '/api/method/login',
LOGOUT: '/api/method/logout', 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 base;
@tailwind components; @tailwind components;
@tailwind utilities; @tailwind utilities;

View File

@ -13,7 +13,6 @@ const AssetDetail: React.FC = () => {
const isNewAsset = assetName === 'new'; const isNewAsset = assetName === 'new';
const isDuplicating = isNewAsset && !!duplicateFromAsset; const isDuplicating = isNewAsset && !!duplicateFromAsset;
// If duplicating, fetch the source asset
const { asset, loading, error } = useAssetDetails( const { asset, loading, error } = useAssetDetails(
isDuplicating ? duplicateFromAsset : (isNewAsset ? null : assetName || null) isDuplicating ? duplicateFromAsset : (isNewAsset ? null : assetName || null)
); );
@ -41,13 +40,12 @@ const AssetDetail: React.FC = () => {
custom_total_amount: 0 custom_total_amount: 0
}); });
// Load asset data for editing or duplicating
useEffect(() => { useEffect(() => {
if (asset) { if (asset) {
setFormData({ setFormData({
asset_name: isDuplicating ? `${asset.asset_name} (Copy)` : (asset.asset_name || ''), asset_name: isDuplicating ? `${asset.asset_name} (Copy)` : (asset.asset_name || ''),
company: asset.company || '', company: asset.company || '',
custom_serial_number: isDuplicating ? '' : (asset.custom_serial_number || ''), // Clear serial number for duplicates custom_serial_number: isDuplicating ? '' : (asset.custom_serial_number || ''),
location: asset.location || '', location: asset.location || '',
custom_manufacturer: asset.custom_manufacturer || '', custom_manufacturer: asset.custom_manufacturer || '',
department: asset.department || '', department: asset.department || '',
@ -78,7 +76,6 @@ const AssetDetail: React.FC = () => {
const handleSubmit = async (e: React.FormEvent) => { const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
// Validate required fields
if (!formData.asset_name) { if (!formData.asset_name) {
alert('Please enter an Asset Name'); alert('Please enter an Asset Name');
return; return;
@ -89,7 +86,6 @@ const AssetDetail: React.FC = () => {
return; return;
} }
// Show console log for debugging
console.log('Submitting asset data:', formData); console.log('Submitting asset data:', formData);
try { try {
@ -107,10 +103,8 @@ const AssetDetail: React.FC = () => {
} }
} catch (err) { } catch (err) {
console.error('Asset save error:', err); console.error('Asset save error:', err);
const errorMessage = err instanceof Error ? err.message : 'Unknown error'; const errorMessage = err instanceof Error ? err.message : 'Unknown error';
// Check if it's an API deployment issue
if (errorMessage.includes('404') || errorMessage.includes('not found') || if (errorMessage.includes('404') || errorMessage.includes('not found') ||
errorMessage.includes('has no attribute') || errorMessage.includes('417')) { errorMessage.includes('has no attribute') || errorMessage.includes('417')) {
alert( alert(
@ -157,7 +151,6 @@ const AssetDetail: React.FC = () => {
); );
} }
// Show error for duplicate if source asset not found
if (error && isDuplicating) { if (error && isDuplicating) {
return ( return (
<div className="p-6 bg-gray-50 dark:bg-gray-900 min-h-screen"> <div className="p-6 bg-gray-50 dark:bg-gray-900 min-h-screen">
@ -166,7 +159,7 @@ const AssetDetail: React.FC = () => {
Source Asset Not Found Source Asset Not Found
</h3> </h3>
<p className="text-yellow-700 dark:text-yellow-400"> <p className="text-yellow-700 dark:text-yellow-400">
The asset you're trying to duplicate could not be found. It may have been deleted or you may not have permission to access it. The asset you're trying to duplicate could not be found.
</p> </p>
<div className="mt-4 flex gap-3"> <div className="mt-4 flex gap-3">
<button <button
@ -197,7 +190,7 @@ const AssetDetail: React.FC = () => {
className="text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300 flex items-center gap-2" className="text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300 flex items-center gap-2"
> >
<FaArrowLeft /> <FaArrowLeft />
<span className="text-gray-900 dark:text-white"> <span className="text-gray-900 dark:text-white font-medium">
{isDuplicating ? 'Duplicate Asset' : (isNewAsset ? 'New Asset Details' : 'Asset Details')} {isDuplicating ? 'Duplicate Asset' : (isNewAsset ? 'New Asset Details' : 'Asset Details')}
</span> </span>
</button> </button>
@ -222,7 +215,7 @@ const AssetDetail: React.FC = () => {
setIsEditing(false); setIsEditing(false);
} }
}} }}
className="bg-gray-300 hover:bg-gray-400 text-gray-700 px-6 py-2 rounded-lg" className="bg-gray-300 hover:bg-gray-400 text-gray-700 dark:text-gray-800 px-6 py-2 rounded-lg"
disabled={saving} disabled={saving}
> >
Cancel Cancel
@ -240,16 +233,18 @@ const AssetDetail: React.FC = () => {
</div> </div>
</div> </div>
<form onSubmit={handleSubmit} className="space-y-6"> <form onSubmit={handleSubmit}>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6"> {/* 4-Column Grid Layout */}
{/* Left Column - Asset Information & Technical Specs & Location */} <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
<div className="lg:col-span-2 space-y-6">
{/* Asset Information */} {/* COLUMN 1: Asset Information */}
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-6"> <div className="bg-white dark:bg-gray-800 rounded-lg shadow-md p-6">
<h2 className="text-lg font-semibold text-gray-800 dark:text-white mb-4">Asset Information</h2> <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">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4"> Asset Information
</h2>
<div className="space-y-4">
<div> <div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"> <label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">
Asset Name <span className="text-red-500">*</span> Asset Name <span className="text-red-500">*</span>
</label> </label>
<input <input
@ -260,12 +255,12 @@ const AssetDetail: React.FC = () => {
placeholder="e.g. Laptop Model X" placeholder="e.g. Laptop Model X"
required required
disabled={!isEditing} disabled={!isEditing}
className="w-full px-3 py-2 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" 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>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"> <label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">
Category <span className="text-red-500">*</span> Category <span className="text-red-500">*</span>
</label> </label>
<select <select
@ -274,7 +269,7 @@ const AssetDetail: React.FC = () => {
onChange={handleChange} onChange={handleChange}
required required
disabled={!isEditing} disabled={!isEditing}
className="w-full px-3 py-2 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" 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 category</option> <option value="">Select category</option>
<option value="Medical Equipment">Medical Equipment</option> <option value="Medical Equipment">Medical Equipment</option>
@ -285,15 +280,15 @@ const AssetDetail: React.FC = () => {
</div> </div>
<div> <div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"> <label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">
Modality <span className="text-red-500">*</span> Modality
</label> </label>
<select <select
name="custom_modality" name="custom_modality"
value={formData.custom_modality} value={formData.custom_modality}
onChange={handleChange} onChange={handleChange}
disabled={!isEditing} disabled={!isEditing}
className="w-full px-3 py-2 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" 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 modality</option> <option value="">Select modality</option>
<option value="X-Ray">X-Ray</option> <option value="X-Ray">X-Ray</option>
@ -305,15 +300,15 @@ const AssetDetail: React.FC = () => {
</div> </div>
<div> <div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"> <label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">
Class <span className="text-red-500">*</span> Class
</label> </label>
<select <select
name="custom_class" name="custom_class"
value={formData.custom_class} value={formData.custom_class}
onChange={handleChange} onChange={handleChange}
disabled={!isEditing} disabled={!isEditing}
className="w-full px-3 py-2 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" 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 class</option> <option value="">Select class</option>
<option value="Class A">Class A</option> <option value="Class A">Class A</option>
@ -322,15 +317,15 @@ const AssetDetail: React.FC = () => {
</select> </select>
</div> </div>
<div className="md:col-span-2"> <div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"> <label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">
Asset ID <span className="text-red-500">*</span> Asset ID
</label> </label>
<input <input
type="text" type="text"
value={isNewAsset || isDuplicating ? 'Auto-generated' : asset?.name} value={isNewAsset || isDuplicating ? 'Auto-generated' : asset?.name}
disabled 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" 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 && ( {isDuplicating && (
<p className="mt-1 text-xs text-blue-600 dark:text-blue-400"> <p className="mt-1 text-xs text-blue-600 dark:text-blue-400">
@ -341,13 +336,15 @@ const AssetDetail: React.FC = () => {
</div> </div>
</div> </div>
{/* Technical Specs */} {/* COLUMN 2: Technical Specs */}
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-6"> <div className="bg-white dark:bg-gray-800 rounded-lg shadow-md p-6">
<h2 className="text-lg font-semibold text-gray-800 dark:text-white mb-4">Technical Specs</h2> <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">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4"> Technical Specs
</h2>
<div className="space-y-4">
<div> <div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"> <label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">
Serial No. Serial Code
</label> </label>
<input <input
type="text" type="text"
@ -356,36 +353,36 @@ const AssetDetail: React.FC = () => {
onChange={handleChange} onChange={handleChange}
placeholder="e.g. SN-12345" placeholder="e.g. SN-12345"
disabled={!isEditing} disabled={!isEditing}
className="w-full px-3 py-2 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" 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>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"> <label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">
System ID System ID
</label> </label>
<input <input
type="text" type="text"
placeholder="e.g. SYS-755" placeholder="e.g. SYS-755"
disabled={!isEditing} disabled={!isEditing}
className="w-full px-3 py-2 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" 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>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"> <label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">
Serial No.2 Serial No.2
</label> </label>
<input <input
type="text" type="text"
placeholder="e.g. SR-V021-A" placeholder="e.g. SR-V021-A"
disabled={!isEditing} disabled={!isEditing}
className="w-full px-3 py-2 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" 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>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"> <label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">
Manufacturer Manufacturer
</label> </label>
<input <input
@ -395,12 +392,12 @@ const AssetDetail: React.FC = () => {
onChange={handleChange} onChange={handleChange}
placeholder="Manufacturer name" placeholder="Manufacturer name"
disabled={!isEditing} disabled={!isEditing}
className="w-full px-3 py-2 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" 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>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"> <label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">
Model Model
</label> </label>
<input <input
@ -410,47 +407,49 @@ const AssetDetail: React.FC = () => {
onChange={handleChange} onChange={handleChange}
placeholder="Model number" placeholder="Model number"
disabled={!isEditing} disabled={!isEditing}
className="w-full px-3 py-2 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" 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>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"> <label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">
Model Number Model Number
</label> </label>
<input <input
type="text" type="text"
placeholder="Model number" placeholder="Model number"
disabled={!isEditing} disabled={!isEditing}
className="w-full px-3 py-2 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" 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>
</div> </div>
{/* Location */} {/* COLUMN 3: Location */}
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-6"> <div className="bg-white dark:bg-gray-800 rounded-lg shadow-md p-6">
<h2 className="text-lg font-semibold text-gray-800 dark:text-white mb-4">Location</h2> <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">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4"> Location
</h2>
<div className="space-y-4">
<div> <div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"> <label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">
Company Site
</label> </label>
<select <select
name="company" name="company"
value={formData.company} value={formData.company}
onChange={handleChange} onChange={handleChange}
disabled={!isEditing} disabled={!isEditing}
className="w-full px-3 py-2 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" 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 company</option> <option value="">Select site</option>
<option value="ABC Hospital">ABC Hospital</option> <option value="ABC Hospital">ABC Hospital</option>
<option value="XYZ Clinic">XYZ Clinic</option> <option value="XYZ Clinic">XYZ Clinic</option>
</select> </select>
</div> </div>
<div> <div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"> <label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">
Department Department
</label> </label>
<select <select
@ -458,7 +457,7 @@ const AssetDetail: React.FC = () => {
value={formData.department} value={formData.department}
onChange={handleChange} onChange={handleChange}
disabled={!isEditing} disabled={!isEditing}
className="w-full px-3 py-2 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" 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 department</option> <option value="">Select department</option>
<option value="Radiology">Radiology</option> <option value="Radiology">Radiology</option>
@ -468,7 +467,7 @@ const AssetDetail: React.FC = () => {
</div> </div>
<div> <div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"> <label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">
Building Building
</label> </label>
<input <input
@ -478,340 +477,56 @@ const AssetDetail: React.FC = () => {
onChange={handleChange} onChange={handleChange}
placeholder="Building name" placeholder="Building name"
disabled={!isEditing} disabled={!isEditing}
className="w-full px-3 py-2 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" 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>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"> <label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">
Area/Unit Area/Unit
</label> </label>
<input <input
type="text" type="text"
placeholder="Area or unit" placeholder="Area or unit"
disabled={!isEditing} disabled={!isEditing}
className="w-full px-3 py-2 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" 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>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"> <label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">
Room Number Room Number
</label> </label>
<input <input
type="text" type="text"
placeholder="e.g. Room 001-002" placeholder="e.g. Room 001-002"
disabled={!isEditing} disabled={!isEditing}
className="w-full px-3 py-2 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" 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>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"> <label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">
Assigned To Assigned To
</label> </label>
<input <input
type="text" type="text"
placeholder="Person or department" placeholder="Person or department"
disabled={!isEditing} disabled={!isEditing}
className="w-full px-3 py-2 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" 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>
</div> </div>
{/* Coverage */} {/* COLUMN 4: More Details */}
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-6"> <div className="bg-white dark:bg-gray-800 rounded-lg shadow-md p-6">
<h2 className="text-lg font-semibold text-gray-800 dark:text-white mb-4">Coverage</h2> <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">
<div className="grid grid-cols-1 md:grid-cols-3 gap-4"> More Details
</h2>
<div className="space-y-4">
<div> <div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"> <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 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-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Contract Number
</label>
<input
type="text"
disabled={!isEditing}
className="w-full px-3 py-2 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-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Subcontractor
</label>
<input
type="text"
disabled={!isEditing}
className="w-full px-3 py-2 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-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Service Agreement
</label>
<select
disabled={!isEditing}
className="w-full px-3 py-2 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</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Service Coverage
</label>
<select
disabled={!isEditing}
className="w-full px-3 py-2 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</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Start Date
</label>
<input
type="date"
disabled={!isEditing}
className="w-full px-3 py-2 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-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
End Date
</label>
<input
type="date"
disabled={!isEditing}
className="w-full px-3 py-2 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-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Total Amount
</label>
<input
type="number"
name="custom_total_amount"
value={formData.custom_total_amount}
onChange={handleChange}
disabled={!isEditing}
className="w-full px-3 py-2 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-3">
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Comments
</label>
<textarea
rows={2}
disabled={!isEditing}
className="w-full px-3 py-2 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>
{/* Acquisition Details */}
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
<h2 className="text-lg font-semibold text-gray-800 dark:text-white mb-4">Acquisition Details</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-1">
Purchase Order Number
</label>
<input
type="text"
placeholder="PO number"
disabled={!isEditing}
className="w-full px-3 py-2 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-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Supplier/Vendor
</label>
<select
disabled={!isEditing}
className="w-full px-3 py-2 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</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Gross Purchase Amount
</label>
<select
disabled={!isEditing}
className="w-full px-3 py-2 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="">Price</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Purchase Date
</label>
<input
type="date"
disabled={!isEditing}
className="w-full px-3 py-2 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-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Installation Date
</label>
<input
type="date"
disabled={!isEditing}
className="w-full px-3 py-2 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-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Available For Use Date
</label>
<input
type="date"
disabled={!isEditing}
className="w-full px-3 py-2 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>
{/* Financial Details */}
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
<h2 className="text-lg font-semibold text-gray-800 dark:text-white mb-4">Financial Details</h2>
<p className="text-sm text-gray-600 dark:text-gray-400 mb-4">
The depreciation method is an accounting method used to allocate the cost of a tangible asset over its useful life.
</p>
<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-1">
Depreciation Method
</label>
<select
disabled={!isEditing}
className="w-full px-3 py-2 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="">Straight Line</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Depreciation Rate (%)
</label>
<input
type="number"
disabled={!isEditing}
className="w-full px-3 py-2 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-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Current Value
</label>
<input
type="number"
disabled={!isEditing}
className="w-full px-3 py-2 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-3">
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Annual Rate
</label>
<input
type="number"
disabled={!isEditing}
className="w-full px-3 py-2 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-3">
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Current Value
</label>
<input
type="number"
disabled={!isEditing}
className="w-full px-3 py-2 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>
{/* End-of-Life Details */}
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
<h2 className="text-lg font-semibold text-gray-800 dark:text-white mb-4">End-of-Life Details</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-1">
Expected End-of-Life Date
</label>
<input
type="date"
disabled={!isEditing}
className="w-full px-3 py-2 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-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Disposal Method
</label>
<select
disabled={!isEditing}
className="w-full px-3 py-2 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="">Recycling</option>
<option value="Donation">Donation</option>
<option value="Sale">Sale</option>
</select>
</div>
</div>
</div>
</div>
{/* Right Column - More Details */}
<div className="space-y-6">
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
<h2 className="text-lg font-semibold text-gray-800 dark:text-white mb-4">More Details</h2>
<div className="mb-4">
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Condition Condition
</label> </label>
<select <select
@ -819,7 +534,7 @@ const AssetDetail: React.FC = () => {
value={formData.custom_device_status} value={formData.custom_device_status}
onChange={handleChange} onChange={handleChange}
disabled={!isEditing} disabled={!isEditing}
className="w-full px-3 py-2 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" 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 status</option> <option value="">Select status</option>
<option value="Operational">Operational</option> <option value="Operational">Operational</option>
@ -829,26 +544,288 @@ const AssetDetail: React.FC = () => {
</div> </div>
{/* QR Code */} {/* QR Code */}
<div className="flex justify-center my-6"> <div className="flex justify-center my-4">
<div className="border-2 border-gray-300 dark:border-gray-600 p-4 rounded-lg"> <div className="border-2 border-gray-300 dark:border-gray-600 p-3 rounded-lg bg-white dark:bg-gray-700">
<FaQrcode size={120} className="text-gray-400 dark:text-gray-500" /> <FaQrcode size={120} className="text-gray-400 dark:text-gray-500" />
</div> </div>
</div> </div>
<div className="mb-4"> <div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"> <label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">
Description Description
</label> </label>
<textarea <textarea
rows={4} rows={4}
placeholder="Brief description of the asset" placeholder="Brief description of the asset"
disabled={!isEditing} disabled={!isEditing}
className="w-full px-3 py-2 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" 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>
</div> </div>
</div> </div>
{/* Bottom Sections - 3 Columns */}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6 mt-6">
{/* Coverage */}
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-md p-6">
<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">
Coverage
</h2>
<div className="space-y-4">
<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">
Contract Number
</label>
<input
type="text"
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">
Service Agreement
</label>
<select
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</option>
</select>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">
Start Date
</label>
<input
type="date"
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">
End Date
</label>
<input
type="date"
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>
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">
Total Amount
</label>
<input
type="number"
name="custom_total_amount"
value={formData.custom_total_amount}
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">
Comments
</label>
<textarea
rows={2}
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>
{/* Acquisition Details */}
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-md p-6">
<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">
Acquisition Details
</h2>
<div className="space-y-4">
<div>
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">
Purchase Order Number
</label>
<input
type="text"
placeholder="PO number"
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">
Supplier/Vendor
</label>
<select
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</option>
</select>
</div>
<div>
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">
Gross Purchase Amount
</label>
<input
type="number"
placeholder="Price"
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">
Purchase Date
</label>
<input
type="date"
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">
Installation Date
</label>
<input
type="date"
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">
Available For Use Date
</label>
<input
type="date"
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>
{/* Financial Details */}
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-md p-6">
<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">
Financial Details
</h2>
<div className="space-y-4">
<div>
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">
Depreciation Method
</label>
<select
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="">Straight Line</option>
</select>
</div>
<div>
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">
Depreciation Rate (%)
</label>
<input
type="number"
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">
Annual Rate
</label>
<input
type="number"
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">
Current Value
</label>
<input
type="number"
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>
{/* End-of-Life Details - Full Width */}
<div className="grid grid-cols-1 lg:grid-cols-4 gap-6 mt-6">
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-md p-6">
<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">
End-of-Life Details
</h2>
<div className="space-y-4">
<div>
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">
Expected End-of-Life Date
</label>
<input
type="date"
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">
Disposal Method
</label>
<select
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="">Recycling</option>
<option value="Donation">Donation</option>
<option value="Sale">Sale</option>
</select>
</div>
</div>
</div>
</div>
</form> </form>
</div> </div>
); );

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="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 className="max-w-md w-full space-y-8">
<div> <div>
<h2 className="mt-6 text-center text-3xl font-extrabold text-gray-900 dark:text-white"> <div className="flex justify-center mb-6">
Sign in to your account <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> </h2>
<p className="mt-2 text-center text-sm text-gray-600 dark:text-gray-400"> <p className="mt-2 text-center text-sm font-medium text-indigo-600 dark:text-indigo-400">
Connect to your Frappe backend Asset Management System
</p>
<p className="mt-1 text-center text-xs text-gray-600 dark:text-gray-400">
Sign in to continue
</p> </p>
</div> </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;