Initial commit of asm ui app

This commit is contained in:
Vipeesh 2026-01-02 11:53:11 +00:00
commit 829a9227d8
118 changed files with 39459 additions and 0 deletions

7
.gitignore vendored Normal file
View File

@ -0,0 +1,7 @@
.DS_Store
*.pyc
*.egg-info
*.swp
tags
node_modules
__pycache__

7
README.md Normal file
View File

@ -0,0 +1,7 @@
## ASM UI APP
ASM UI
#### License
mit

24
asm_app/.gitignore vendored Normal file
View File

@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

73
asm_app/README.md Normal file
View File

@ -0,0 +1,73 @@
# React + TypeScript + Vite
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
Currently, two official plugins are available:
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) (or [oxc](https://oxc.rs) when used in [rolldown-vite](https://vite.dev/guide/rolldown)) for Fast Refresh
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
## React Compiler
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
## Expanding the ESLint configuration
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
```js
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Remove tseslint.configs.recommended and replace with this
tseslint.configs.recommendedTypeChecked,
// Alternatively, use this for stricter rules
tseslint.configs.strictTypeChecked,
// Optionally, add this for stylistic rules
tseslint.configs.stylisticTypeChecked,
// Other configs...
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
```js
// eslint.config.js
import reactX from 'eslint-plugin-react-x'
import reactDom from 'eslint-plugin-react-dom'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Enable lint rules for React
reactX.configs['recommended-typescript'],
// Enable lint rules for React DOM
reactDom.configs.recommended,
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```

23
asm_app/eslint.config.js Normal file
View File

@ -0,0 +1,23 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint'
import { defineConfig, globalIgnores } from 'eslint/config'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
js.configs.recommended,
tseslint.configs.recommended,
reactHooks.configs.flat.recommended,
reactRefresh.configs.vite,
],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
},
])

16
asm_app/index.html Normal file
View File

@ -0,0 +1,16 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/png" href="/seera-logo.png?v=1765198405" />
<link rel="apple-touch-icon" href="/seera-logo.png?v=1765198405" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content="Seera Arabia Asset Management System" />
<title>Seera Arabia - Asset Management System</title>
</head>
<body>
<div id="root"></div>
<script>window.csrf_token = '{{ frappe.session.csrf_token }}';</script>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

4160
asm_app/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

49
asm_app/package.json Normal file
View File

@ -0,0 +1,49 @@
{
"name": "asm_app",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "node scripts/inject-image-version.js && vite build --base=/assets/asm_ui_app/asm_app/ && yarn copy-html-entry && yarn copy-public-assets",
"lint": "eslint .",
"preview": "vite preview",
"copy-html-entry": "cp ../asm_ui_app/public/asm_app/index.html ../asm_ui_app/www/asm_app.html",
"copy-public-assets": "cp public/sidebar-background.jpg ../asm_ui_app/public/asm_app/sidebar-background.jpg 2>/dev/null || true && cp public/seera-logo.png ../asm_ui_app/public/asm_app/seera-logo.png 2>/dev/null || true"
},
"dependencies": {
"@types/leaflet": "^1.9.21",
"@types/react-router-dom": "^5.3.3",
"axios": "^1.12.2",
"frappe-react-sdk": "^1.13.0",
"i18next": "^25.7.2",
"i18next-browser-languagedetector": "^8.2.0",
"leaflet": "^1.9.4",
"lucide-react": "^0.553.0",
"react": "^19.1.1",
"react-dom": "^19.1.1",
"react-i18next": "^16.4.0",
"react-icons": "^5.5.0",
"react-leaflet": "^5.0.0",
"react-router-dom": "^7.9.4",
"react-toastify": "^11.0.5",
"xlsx": "^0.18.5"
},
"devDependencies": {
"@eslint/js": "^9.36.0",
"@types/node": "^24.6.0",
"@types/react": "^19.1.16",
"@types/react-dom": "^19.1.9",
"@vitejs/plugin-react": "^5.0.4",
"autoprefixer": "^10.4.22",
"eslint": "^9.36.0",
"eslint-plugin-react-hooks": "^5.2.0",
"eslint-plugin-react-refresh": "^0.4.22",
"globals": "^16.4.0",
"postcss": "^8.5.6",
"tailwindcss": "^3.4.18",
"typescript": "~5.9.3",
"typescript-eslint": "^8.45.0",
"vite": "^7.1.7"
}
}

View File

@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

13
asm_app/proxyOptions.ts Normal file
View File

@ -0,0 +1,13 @@
const common_site_config = require('../../../sites/common_site_config.json');
const { webserver_port } = common_site_config;
export default {
'^/(app|api|assets|files|private)': {
target: `http://127.0.0.1:${webserver_port}`,
ws: true,
router: function(req) {
const site_name = req.headers.host.split(':')[0];
return `http://${site_name}:${webserver_port}`;
}
}
};

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 395 KiB

1
asm_app/public/vite.svg Normal file
View File

@ -0,0 +1 @@
<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>

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

@ -0,0 +1,66 @@
import { statSync } from 'fs';
import { readFileSync, writeFileSync } from 'fs';
import { join } from 'path';
// Get image modification times
const sidebarBgPath = join(process.cwd(), 'public', 'sidebar-background.jpg');
const logoPath = join(process.cwd(), 'public', 'seera-logo.png');
const sidebarPath = join(process.cwd(), 'src', 'components', 'Sidebar.tsx');
const loginPath = join(process.cwd(), 'src', 'pages', 'Login.tsx');
const indexPath = join(process.cwd(), 'index.html');
try {
// Get sidebar background image modification time
const sidebarBgStats = statSync(sidebarBgPath);
const sidebarBgMtime = Math.floor(sidebarBgStats.mtimeMs / 1000);
// Get logo modification time
const logoStats = statSync(logoPath);
const logoMtime = Math.floor(logoStats.mtimeMs / 1000);
// Update Sidebar.tsx
let sidebarContent = readFileSync(sidebarPath, 'utf8');
// Update sidebar background version constant
sidebarContent = sidebarContent.replace(
/(const imageVersion = import\.meta\.env\.DEV[\s\S]*?`\?v=)([\d]+)(`; \/\/ Auto-updated by build script)/,
`$1${sidebarBgMtime}$3`
);
// Update logo version constant
sidebarContent = sidebarContent.replace(
/(const logoVersion = import\.meta\.env\.DEV[\s\S]*?`\?v=)([\d]+)(`; \/\/ Auto-updated by build script)/,
`$1${logoMtime}$3`
);
writeFileSync(sidebarPath, sidebarContent, 'utf8');
console.log(`✓ Updated sidebar background image version to ${sidebarBgMtime}`);
console.log(`✓ Updated seera-logo.png version to ${logoMtime} in Sidebar.tsx`);
// Update Login.tsx
let loginContent = readFileSync(loginPath, 'utf8');
// Update logo version constant
loginContent = loginContent.replace(
/(const logoVersion = import\.meta\.env\.DEV[\s\S]*?`\?v=)([\d]+)(`; \/\/ Auto-updated by build script)/,
`$1${logoMtime}$3`
);
writeFileSync(loginPath, loginContent, 'utf8');
console.log(`✓ Updated seera-logo.png version to ${logoMtime} in Login.tsx`);
// Update index.html favicon
let indexContent = readFileSync(indexPath, 'utf8');
// Update favicon version
indexContent = indexContent.replace(
/seera-logo\.png(\?v=[\d]+)?/g,
`seera-logo.png?v=${logoMtime}`
);
writeFileSync(indexPath, indexContent, 'utf8');
console.log(`✓ Updated seera-logo.png version to ${logoMtime} in index.html`);
} catch (error) {
console.warn('⚠ Could not update image versions:', error.message);
}

42
asm_app/src/App.css Normal file
View File

@ -0,0 +1,42 @@
#root {
max-width: 1280px;
margin: 0 auto;
padding: 2rem;
text-align: center;
}
.logo {
height: 6em;
padding: 1.5em;
will-change: filter;
transition: filter 300ms;
}
.logo:hover {
filter: drop-shadow(0 0 2em #646cffaa);
}
.logo.react:hover {
filter: drop-shadow(0 0 2em #61dafbaa);
}
@keyframes logo-spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
@media (prefers-reduced-motion: no-preference) {
a:nth-of-type(2) .logo {
animation: logo-spin infinite 20s linear;
}
}
.card {
padding: 2em;
}
.read-the-docs {
color: #888;
}

236
asm_app/src/App.tsx Normal file
View File

@ -0,0 +1,236 @@
// import React from 'react';
// import { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router-dom';
// import Test from './pages/Test';
// import Login from './pages/Login';
// const App: React.FC = () => {
// return (
// <Router basename="/react_ui">
// <Routes>
// <Route path="/test" element={<Test />} />
// <Route path="/login" element={<Login />} />
// <Route path="*" element={<Navigate to="/test" replace />} />
// </Routes>
// </Router>
// );
// };
// export default App;
import React from 'react';
import { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router-dom';
import Login from './pages/Login';
import Dashboard from './pages/Dashboard';
import ModernDashboard from './pages/ModernDashboard';
import UsersList from './pages/UsersList';
import EventsList from './pages/EventsList';
import AssetList from './pages/AssetList';
import AssetDetail from './pages/AssetDetail';
import WorkOrderList from './pages/WorkOrderList';
import WorkOrderDetail from './pages/WorkOrderDetail';
import AssetMaintenanceList from './pages/AssetMaintenanceList';
import AssetMaintenanceDetail from './pages/AssetMaintenanceDetail';
import PPMList from './pages/PPMList';
import PPMDetail from './pages/PPMDetail';
import PPMPlanner from './pages/PPMPlanner';
import PPMPlannerList from './pages/PPMPlannerList';
import PPMPlannerDetail from './pages/PPMPlannerDetail';
import MaintenanceCalendarPage from './pages/MaintenanceCalendarPage';
import YearlyPPMPlannerPage from './pages/YearlyPPMPlannerPage';
import ActiveMap from './pages/ActiveMap';
import ItemList from './pages/ItemList';
import ItemDetail from './pages/ItemDetail';
import ComingSoon from './pages/ComingSoon';
import Sidebar from './components/Sidebar';
import Header from './components/Header';
// Layout with Sidebar and Header
const LayoutWithSidebar: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const user = localStorage.getItem('user');
const userEmail = user ? JSON.parse(user).email : '';
return (
<div className="flex h-screen overflow-hidden bg-gray-50 dark:bg-gray-900">
<Sidebar userEmail={userEmail} />
<div className="flex-1 flex flex-col overflow-hidden">
<Header userEmail={userEmail} />
<div className="flex-1 overflow-y-auto bg-gray-50 dark:bg-gray-900">
{children}
</div>
</div>
</div>
);
};
// Protected Route Component
const ProtectedRoute: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const user = localStorage.getItem('user');
return user ? <>{children}</> : <Navigate to="/login" replace />;
};
const App: React.FC = () => {
return (
<Router basename="/asm_app">
<Routes>
<Route path="/login" element={<Login />} />
<Route path="/dashboard" element={
<ProtectedRoute>
<LayoutWithSidebar><ModernDashboard /></LayoutWithSidebar>
</ProtectedRoute>
} />
<Route path="/assets" element={
<ProtectedRoute>
<LayoutWithSidebar><AssetList /></LayoutWithSidebar>
</ProtectedRoute>
} />
<Route path="/assets/:assetName" element={
<ProtectedRoute>
<LayoutWithSidebar><AssetDetail /></LayoutWithSidebar>
</ProtectedRoute>
} />
<Route path="/work-orders" element={
<ProtectedRoute>
<LayoutWithSidebar><WorkOrderList /></LayoutWithSidebar>
</ProtectedRoute>
} />
<Route path="/work-orders/:workOrderName" element={
<ProtectedRoute>
<LayoutWithSidebar><WorkOrderDetail /></LayoutWithSidebar>
</ProtectedRoute>
} />
<Route path="/maintenance" element={
<ProtectedRoute>
<LayoutWithSidebar><AssetMaintenanceList /></LayoutWithSidebar>
</ProtectedRoute>
} />
<Route path="/maintenance/:logName" element={
<ProtectedRoute>
<LayoutWithSidebar><AssetMaintenanceDetail /></LayoutWithSidebar>
</ProtectedRoute>
} />
<Route path="/ppm" element={
<ProtectedRoute>
<LayoutWithSidebar><PPMList /></LayoutWithSidebar>
</ProtectedRoute>
} />
<Route path="/ppm/:ppmName" element={
<ProtectedRoute>
<LayoutWithSidebar><PPMDetail /></LayoutWithSidebar>
</ProtectedRoute>
} />
<Route path="/ppm-planner" element={
<ProtectedRoute>
<LayoutWithSidebar><PPMPlannerList /></LayoutWithSidebar>
</ProtectedRoute>
} />
<Route path="/ppm-planner/new" element={
<ProtectedRoute>
<LayoutWithSidebar><PPMPlanner /></LayoutWithSidebar>
</ProtectedRoute>
} />
<Route path="/ppm-planner/:scheduleName" element={
<ProtectedRoute>
<LayoutWithSidebar><PPMPlannerDetail /></LayoutWithSidebar>
</ProtectedRoute>
} />
<Route path="/maintenance-calendar" element={
<ProtectedRoute>
<LayoutWithSidebar><YearlyPPMPlannerPage /></LayoutWithSidebar>
</ProtectedRoute>
} />
<Route path="/maintenance-calendar/month-view" element={
<ProtectedRoute>
<LayoutWithSidebar><MaintenanceCalendarPage /></LayoutWithSidebar>
</ProtectedRoute>
} />
<Route path="/yearly-ppm-planner" element={
<ProtectedRoute>
<LayoutWithSidebar><YearlyPPMPlannerPage /></LayoutWithSidebar>
</ProtectedRoute>
} />
<Route path="/active-map" element={
<ProtectedRoute>
<LayoutWithSidebar><ActiveMap /></LayoutWithSidebar>
</ProtectedRoute>
} />
<Route path="/inventory" element={
<ProtectedRoute>
<LayoutWithSidebar><ItemList /></LayoutWithSidebar>
</ProtectedRoute>
} />
<Route path="/inventory/:itemName" element={
<ProtectedRoute>
<LayoutWithSidebar><ItemDetail /></LayoutWithSidebar>
</ProtectedRoute>
} />
<Route path="/users" element={
<ProtectedRoute>
<LayoutWithSidebar><UsersList /></LayoutWithSidebar>
</ProtectedRoute>
} />
<Route path="/events" element={
<ProtectedRoute>
<LayoutWithSidebar><EventsList /></LayoutWithSidebar>
</ProtectedRoute>
} />
<Route path="/old-dashboard" element={
<ProtectedRoute>
<LayoutWithSidebar><Dashboard /></LayoutWithSidebar>
</ProtectedRoute>
} />
<Route path="/maintenance-team" element={
<ProtectedRoute>
<LayoutWithSidebar><ComingSoon title="Maintenance Team" /></LayoutWithSidebar>
</ProtectedRoute>
} />
<Route path="/procurement" element={
<ProtectedRoute>
<LayoutWithSidebar><ComingSoon title="Procurement" /></LayoutWithSidebar>
</ProtectedRoute>
} />
<Route path="/sla" element={
<ProtectedRoute>
<LayoutWithSidebar><ComingSoon title="Service Level Agreement (SLA)" /></LayoutWithSidebar>
</ProtectedRoute>
} />
<Route path="/support" element={
<ProtectedRoute>
<LayoutWithSidebar><ComingSoon title="Support" /></LayoutWithSidebar>
</ProtectedRoute>
} />
{/* Default redirect */}
<Route path="/" element={<Navigate to="/login" replace />} />
<Route path="*" element={<Navigate to="/login" replace />} />
</Routes>
</Router>
);
};
export default App;

View File

@ -0,0 +1,181 @@
import axios from 'axios';
import type { AxiosInstance, AxiosResponse } from 'axios';
// Types for Frappe API responses
export interface FrappeResponse<T = any> {
message: T;
exc?: string;
exc_type?: string;
}
export interface FrappeDocType {
name: string;
creation: string;
modified: string;
modified_by: string;
owner: string;
docstatus: number;
idx: number;
[key: string]: any;
}
export interface LoginCredentials {
usr: string;
pwd: string;
}
export interface UserDetails {
full_name: string;
email: string;
user_image: string;
roles: string[];
}
class FrappeAPIClient {
private client: AxiosInstance;
private baseURL: string;
private siteName: string;
constructor() {
this.baseURL = import.meta.env.VITE_FRAPPE_BASE_URL || 'http://localhost:8000';
this.siteName = import.meta.env.VITE_FRAPPE_SITE_NAME || 'seeraasm-med.seeraarabia.com';
this.client = axios.create({
baseURL: this.baseURL,
timeout: parseInt(import.meta.env.VITE_API_TIMEOUT || '10000'),
withCredentials: true, // Important for session cookies
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
},
});
// Request interceptor to add site name to requests
this.client.interceptors.request.use((config) => {
if (config.url?.includes('/api/')) {
config.url = `/${this.siteName}${config.url}`;
}
return config;
});
// Response interceptor for error handling
this.client.interceptors.response.use(
(response) => response,
(error) => {
if (error.response?.status === 401) {
// Handle unauthorized - redirect to login
window.location.href = '/login';
}
return Promise.reject(error);
}
);
}
// Authentication methods
async login(credentials: LoginCredentials): Promise<FrappeResponse<UserDetails>> {
const response: AxiosResponse<FrappeResponse<UserDetails>> = await this.client.post(
'/api/method/login',
credentials
);
return response.data;
}
async logout(): Promise<FrappeResponse> {
const response: AxiosResponse<FrappeResponse> = await this.client.post(
'/api/method/logout'
);
return response.data;
}
async getCurrentUser(): Promise<FrappeResponse<UserDetails>> {
const response: AxiosResponse<FrappeResponse<UserDetails>> = await this.client.get(
'/api/method/frappe.auth.get_logged_user'
);
return response.data;
}
// Generic API methods
async callMethod(method: string, args: any = {}): Promise<FrappeResponse> {
const response: AxiosResponse<FrappeResponse> = await this.client.post(
`/api/method/${method}`,
args
);
return response.data;
}
// Convenience method for GET requests
async frappeGet(method: string, args: any = {}): Promise<FrappeResponse> {
return this.callMethod(method, args);
}
// DocType operations
async getDocTypeRecords(doctype: string, filters: any = {}, fields: string[] = []): Promise<FrappeResponse<FrappeDocType[]>> {
const params = new URLSearchParams();
if (Object.keys(filters).length > 0) {
params.append('filters', JSON.stringify(filters));
}
if (fields.length > 0) {
params.append('fields', JSON.stringify(fields));
}
const response: AxiosResponse<FrappeResponse<FrappeDocType[]>> = await this.client.get(
`/api/resource/${doctype}?${params.toString()}`
);
return response.data;
}
async getDocTypeRecord(doctype: string, name: string): Promise<FrappeResponse<FrappeDocType>> {
const response: AxiosResponse<FrappeResponse<FrappeDocType>> = await this.client.get(
`/api/resource/${doctype}/${name}`
);
return response.data;
}
async createDocTypeRecord(doctype: string, data: any): Promise<FrappeResponse<FrappeDocType>> {
const response: AxiosResponse<FrappeResponse<FrappeDocType>> = await this.client.post(
`/api/resource/${doctype}`,
data
);
return response.data;
}
async updateDocTypeRecord(doctype: string, name: string, data: any): Promise<FrappeResponse<FrappeDocType>> {
const response: AxiosResponse<FrappeResponse<FrappeDocType>> = await this.client.put(
`/api/resource/${doctype}/${name}`,
data
);
return response.data;
}
async deleteDocTypeRecord(doctype: string, name: string): Promise<FrappeResponse> {
const response: AxiosResponse<FrappeResponse> = await this.client.delete(
`/api/resource/${doctype}/${name}`
);
return response.data;
}
// File upload
async uploadFile(file: File, folder: string = 'Home'): Promise<FrappeResponse> {
const formData = new FormData();
formData.append('file', file);
formData.append('folder', folder);
formData.append('is_private', '0');
const response: AxiosResponse<FrappeResponse> = await this.client.post(
'/api/method/upload_file',
formData,
{
headers: {
'Content-Type': 'multipart/form-data',
},
}
);
return response.data;
}
}
// Export singleton instance
export const frappeAPI = new FrappeAPIClient();
export default frappeAPI;

View File

@ -0,0 +1 @@
<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="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>

After

Width:  |  Height:  |  Size: 4.0 KiB

View File

@ -0,0 +1,147 @@
import React, { useState } from 'react';
import apiService from '../services/apiService';
import { ApiError } from '../services/apiService';
interface TestResults {
csrfToken?: string;
dashboardStats?: string;
userDetails?: string;
doctypeRecords?: string;
error?: string;
}
const ApiTest: React.FC = () => {
const [testResults, setTestResults] = useState<TestResults>({});
const [loading, setLoading] = useState<boolean>(false);
const testApiConnection = async (): Promise<void> => {
setLoading(true);
const results: TestResults = {};
try {
// Test 1: Basic connectivity test (skip CSRF token test)
console.log('Testing basic connectivity...');
try {
// Test with a simple API call instead of CSRF token
const response = await fetch('/api/method/frappe.desk.doctype.event.event.get_events', {
method: 'POST',
headers: {
'Accept': 'application/json',
'Content-Type': 'application/json'
},
body: JSON.stringify({
start: new Date().toISOString().split('T')[0],
end: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString().split('T')[0],
}),
signal: AbortSignal.timeout(10000) // 10 second timeout
});
if (response.ok) {
results.csrfToken = '✅ Basic Connectivity: SUCCESS';
} else {
results.csrfToken = `❌ Basic Connectivity: HTTP ${response.status}`;
}
} catch (e) {
results.csrfToken = `❌ Basic Connectivity: ${e instanceof Error ? e.message : 'Unknown error'}`;
}
// Test 2: Test Frappe system endpoint
console.log('Testing Frappe system endpoint...');
try {
// Use a simpler endpoint that doesn't require parameters
await apiService.apiCall('/api/method/frappe.auth.get_logged_user', {
method: 'GET',
headers: {
'Content-Type': 'application/json',
}
});
results.dashboardStats = '✅ Frappe System API: SUCCESS';
} catch (e) {
// If this fails, it's likely because user is not logged in, which is OK
const errorMsg = e instanceof Error ? e.message : 'Unknown';
if (errorMsg.includes('403') || errorMsg.includes('401')) {
results.dashboardStats = '✅ Frappe System API: SUCCESS (auth required)';
} else {
results.dashboardStats = `❌ Frappe System API: ${errorMsg}`;
}
}
// Test 3: Test custom endpoints (these will fail until you deploy the API file)
console.log('Testing Custom User Details...');
try {
const userDetails = await apiService.getUserDetails();
results.userDetails = userDetails ? '✅ Custom API: SUCCESS' : '❌ Custom API: Failed';
} catch (e) {
results.userDetails = `❌ Custom API (Expected): ${e instanceof Error ? e.message : 'Unknown'}`;
}
// Test 4: Test custom dashboard stats
console.log('Testing Custom Dashboard Stats...');
try {
const dashboardStats = await apiService.getDashboardStats();
results.doctypeRecords = dashboardStats ? '✅ Custom Stats: SUCCESS' : '❌ Custom Stats: Failed';
} catch (e) {
results.doctypeRecords = `❌ Custom Stats (Expected): ${e instanceof Error ? e.message : 'Unknown'}`;
}
} catch (error) {
console.error('API Test Error:', error);
if (error instanceof ApiError) {
results.error = `${error.message} (Status: ${error.status})`;
} else {
results.error = error instanceof Error ? error.message : 'Unknown error';
}
}
setTestResults(results);
setLoading(false);
};
return (
<div style={{ padding: '20px', border: '1px solid #ccc', margin: '20px' }}>
<h2>API Connection Test</h2>
<button
onClick={testApiConnection}
disabled={loading}
style={{
padding: '10px 20px',
marginBottom: '20px',
backgroundColor: loading ? '#ccc' : '#007bff',
color: 'white',
border: 'none',
borderRadius: '4px',
cursor: loading ? 'not-allowed' : 'pointer'
}}
>
{loading ? 'Testing...' : 'Test API Connection'}
</button>
<div>
<h3>Test Results:</h3>
<div style={{
background: '#f5f5f5',
padding: '15px',
borderRadius: '5px',
fontSize: '14px'
}}>
<div><strong>1. Basic Connectivity:</strong> {testResults.csrfToken || 'Not tested'}</div>
<div><strong>2. Frappe System API:</strong> {testResults.dashboardStats || 'Not tested'}</div>
<div><strong>3. Custom User API:</strong> {testResults.userDetails || 'Not tested'}</div>
<div><strong>4. Custom Stats API:</strong> {testResults.doctypeRecords || 'Not tested'}</div>
{testResults.error && <div style={{color: 'red'}}><strong>Error:</strong> {testResults.error}</div>}
</div>
<div style={{ marginTop: '10px', fontSize: '12px', color: '#666' }}>
<p><strong>Expected Results:</strong></p>
<ul>
<li> Basic Connectivity should succeed (tests proxy connection)</li>
<li> Frappe System API should succeed (tests Frappe API)</li>
<li> Custom APIs will fail until you deploy the API file to your server</li>
</ul>
<p><strong>If Basic Connectivity fails:</strong> Check your Frappe server is running and accessible</p>
</div>
</div>
</div>
);
};
export default ApiTest;

View File

@ -0,0 +1,29 @@
import React from 'react';
import { useDashboardChart } from '../hooks/useApi';
import SimpleChart from './SimpleChart';
interface Props {
chartName: string;
filters?: Record<string, any>;
}
const ChartTile: React.FC<Props> = ({ chartName, filters }) => {
const { data, loading, error } = useDashboardChart(chartName, filters);
return (
<div className="bg-white rounded-lg shadow p-4 overflow-auto">
<div className="flex items-center justify-between mb-3">
<h4 className="text-sm font-semibold text-gray-800">{chartName}</h4>
</div>
{loading && <div className="text-sm text-gray-500">Loading</div>}
{error && <div className="text-sm text-red-600">{error}</div>}
{!loading && !error && data && (
<SimpleChart type={data.type} labels={data.labels} datasets={data.datasets} />
)}
</div>
);
};
export default ChartTile;

View File

@ -0,0 +1,376 @@
/**
* DynamicField Component
*
* Renders form fields dynamically based on Frappe's field configuration.
* Supports conditional visibility, mandatory, read-only states, and various field types.
*/
import React, { useMemo } from 'react';
import { FieldConfig, evaluateFieldState, parseSelectOptions, getInputType } from '../utils/frappeExpressionEvaluator';
import LinkField from './LinkField';
interface DynamicFieldProps {
fieldConfig: FieldConfig;
value: any;
onChange: (fieldname: string, value: any) => void;
doc: Record<string, any>;
disabled?: boolean;
compact?: boolean;
className?: string;
error?: string;
}
const DynamicField: React.FC<DynamicFieldProps> = ({
fieldConfig,
value,
onChange,
doc,
disabled = false,
compact = false,
className = '',
error
}) => {
// Evaluate field state based on current document
const fieldState = useMemo(() => {
return evaluateFieldState(fieldConfig, doc);
}, [fieldConfig, doc]);
// Don't render if field is not visible
if (!fieldState.isVisible) {
return null;
}
// Skip layout fields (Section Break, Column Break, Tab Break)
if (['Section Break', 'Column Break', 'Tab Break', 'HTML'].includes(fieldConfig.fieldtype)) {
return null;
}
const isDisabled = disabled || fieldState.isReadOnly;
const isRequired = fieldState.isMandatory;
const inputType = getInputType(fieldConfig.fieldtype);
const labelClasses = compact
? 'block text-[10px] font-medium text-gray-700 dark:text-gray-300 mb-0.5'
: 'block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1';
const inputClasses = compact
? `w-full px-2 py-1 text-xs border rounded focus:outline-none focus:ring-1 focus:ring-blue-500 bg-white dark:bg-gray-700 text-gray-900 dark:text-white ${
isDisabled ? 'bg-gray-100 dark:bg-gray-800 cursor-not-allowed' : ''
} ${error ? 'border-red-500' : 'border-gray-300 dark:border-gray-600'}`
: `w-full px-3 py-2 text-sm border rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 bg-white dark:bg-gray-700 text-gray-900 dark:text-white ${
isDisabled ? 'bg-gray-100 dark:bg-gray-800 cursor-not-allowed' : ''
} ${error ? 'border-red-500' : 'border-gray-300 dark:border-gray-600'}`;
const handleChange = (newValue: any) => {
onChange(fieldConfig.fieldname, newValue);
};
// Render based on field type
const renderField = () => {
switch (fieldConfig.fieldtype) {
case 'Link':
return (
<LinkField
label={fieldConfig.label || fieldConfig.fieldname}
doctype={fieldConfig.options || ''}
value={value || ''}
onChange={handleChange}
disabled={isDisabled}
compact={compact}
placeholder={`Select ${fieldConfig.label || fieldConfig.fieldname}`}
/>
);
case 'Select':
const options = parseSelectOptions(fieldConfig.options);
return (
<div className={className}>
<label className={labelClasses}>
{fieldConfig.label || fieldConfig.fieldname}
{isRequired && <span className="text-red-500 ml-1">*</span>}
</label>
<select
value={value || ''}
onChange={(e) => handleChange(e.target.value)}
disabled={isDisabled}
className={inputClasses}
>
<option value="">Select...</option>
{options.map((opt) => (
<option key={opt} value={opt}>{opt}</option>
))}
</select>
{error && <p className="text-red-500 text-xs mt-1">{error}</p>}
{fieldConfig.description && !error && (
<p className="text-gray-500 dark:text-gray-400 text-xs mt-1">{fieldConfig.description}</p>
)}
</div>
);
case 'Check':
return (
<div className={`flex items-center gap-2 ${className}`}>
<input
type="checkbox"
checked={value === 1 || value === true}
onChange={(e) => handleChange(e.target.checked ? 1 : 0)}
disabled={isDisabled}
className="w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 rounded focus:ring-blue-500 dark:focus:ring-blue-600 dark:ring-offset-gray-800 focus:ring-2 dark:bg-gray-700 dark:border-gray-600"
/>
<label className="text-sm text-gray-700 dark:text-gray-300">
{fieldConfig.label || fieldConfig.fieldname}
{isRequired && <span className="text-red-500 ml-1">*</span>}
</label>
</div>
);
case 'Date':
return (
<div className={className}>
<label className={labelClasses}>
{fieldConfig.label || fieldConfig.fieldname}
{isRequired && <span className="text-red-500 ml-1">*</span>}
</label>
<input
type="date"
value={value || ''}
onChange={(e) => handleChange(e.target.value)}
disabled={isDisabled}
className={inputClasses}
/>
{error && <p className="text-red-500 text-xs mt-1">{error}</p>}
</div>
);
case 'Datetime':
return (
<div className={className}>
<label className={labelClasses}>
{fieldConfig.label || fieldConfig.fieldname}
{isRequired && <span className="text-red-500 ml-1">*</span>}
</label>
<input
type="datetime-local"
value={value ? value.replace(' ', 'T').substring(0, 16) : ''}
onChange={(e) => handleChange(e.target.value.replace('T', ' '))}
disabled={isDisabled}
className={inputClasses}
/>
{error && <p className="text-red-500 text-xs mt-1">{error}</p>}
</div>
);
case 'Int':
return (
<div className={className}>
<label className={labelClasses}>
{fieldConfig.label || fieldConfig.fieldname}
{isRequired && <span className="text-red-500 ml-1">*</span>}
</label>
<input
type="number"
value={value ?? ''}
onChange={(e) => handleChange(e.target.value ? parseInt(e.target.value) : null)}
disabled={isDisabled}
className={inputClasses}
step="1"
/>
{error && <p className="text-red-500 text-xs mt-1">{error}</p>}
</div>
);
case 'Float':
case 'Currency':
case 'Percent':
return (
<div className={className}>
<label className={labelClasses}>
{fieldConfig.label || fieldConfig.fieldname}
{isRequired && <span className="text-red-500 ml-1">*</span>}
</label>
<input
type="number"
value={value ?? ''}
onChange={(e) => handleChange(e.target.value ? parseFloat(e.target.value) : null)}
disabled={isDisabled}
className={inputClasses}
step="0.01"
/>
{error && <p className="text-red-500 text-xs mt-1">{error}</p>}
</div>
);
case 'Small Text':
case 'Text':
case 'Long Text':
return (
<div className={className}>
<label className={labelClasses}>
{fieldConfig.label || fieldConfig.fieldname}
{isRequired && <span className="text-red-500 ml-1">*</span>}
</label>
<textarea
value={value || ''}
onChange={(e) => handleChange(e.target.value)}
disabled={isDisabled}
className={inputClasses}
rows={fieldConfig.fieldtype === 'Long Text' ? 6 : 3}
/>
{error && <p className="text-red-500 text-xs mt-1">{error}</p>}
</div>
);
case 'Read Only':
return (
<div className={className}>
<label className={labelClasses}>
{fieldConfig.label || fieldConfig.fieldname}
</label>
<div className={`${inputClasses} bg-gray-50 dark:bg-gray-800`}>
{value || '-'}
</div>
</div>
);
case 'Attach':
case 'Attach Image':
return (
<div className={className}>
<label className={labelClasses}>
{fieldConfig.label || fieldConfig.fieldname}
{isRequired && <span className="text-red-500 ml-1">*</span>}
</label>
{value && (
<div className="mb-2">
{fieldConfig.fieldtype === 'Attach Image' && value ? (
<img src={value} alt={fieldConfig.label} className="w-24 h-24 object-cover rounded" />
) : (
<a href={value} target="_blank" rel="noopener noreferrer" className="text-blue-600 hover:underline text-sm">
{value.split('/').pop()}
</a>
)}
</div>
)}
<input
type="file"
onChange={(e) => {
// Handle file upload - you may need to implement actual upload logic
const file = e.target.files?.[0];
if (file) {
// For now, just store the file name
handleChange(file.name);
}
}}
disabled={isDisabled}
className={inputClasses}
/>
{error && <p className="text-red-500 text-xs mt-1">{error}</p>}
</div>
);
case 'Data':
case 'Password':
default:
return (
<div className={className}>
<label className={labelClasses}>
{fieldConfig.label || fieldConfig.fieldname}
{isRequired && <span className="text-red-500 ml-1">*</span>}
</label>
<input
type={fieldConfig.fieldtype === 'Password' ? 'password' : 'text'}
value={value || ''}
onChange={(e) => handleChange(e.target.value)}
disabled={isDisabled}
className={inputClasses}
placeholder={fieldConfig.description || ''}
/>
{error && <p className="text-red-500 text-xs mt-1">{error}</p>}
</div>
);
}
};
return renderField();
};
export default DynamicField;
/**
* DynamicForm Component
* Renders a complete form based on DocType field configuration
*/
interface DynamicFormProps {
fields: FieldConfig[];
doc: Record<string, any>;
onChange: (fieldname: string, value: any) => void;
errors?: Record<string, string>;
disabled?: boolean;
compact?: boolean;
columns?: 1 | 2 | 3 | 4;
excludeFields?: string[];
includeFields?: string[];
}
export const DynamicForm: React.FC<DynamicFormProps> = ({
fields,
doc,
onChange,
errors = {},
disabled = false,
compact = false,
columns = 2,
excludeFields = [],
includeFields
}) => {
// Filter and sort fields
const visibleFields = useMemo(() => {
let filtered = fields.filter(f => {
// Skip layout fields
if (['Section Break', 'Column Break', 'Tab Break'].includes(f.fieldtype)) {
return false;
}
// Apply exclude filter
if (excludeFields.includes(f.fieldname)) {
return false;
}
// Apply include filter if specified
if (includeFields && !includeFields.includes(f.fieldname)) {
return false;
}
// Check visibility
const state = evaluateFieldState(f, doc);
return state.isVisible;
});
return filtered;
}, [fields, doc, excludeFields, includeFields]);
const gridClass = {
1: 'grid-cols-1',
2: 'grid-cols-1 md:grid-cols-2',
3: 'grid-cols-1 md:grid-cols-2 lg:grid-cols-3',
4: 'grid-cols-1 md:grid-cols-2 lg:grid-cols-4'
}[columns];
return (
<div className={`grid ${gridClass} gap-4`}>
{visibleFields.map(field => (
<DynamicField
key={field.fieldname}
fieldConfig={field}
value={doc[field.fieldname]}
onChange={onChange}
doc={doc}
disabled={disabled}
compact={compact}
error={errors[field.fieldname]}
/>
))}
</div>
);
};

View File

@ -0,0 +1,67 @@
import React from 'react';
import { useNavigate } from 'react-router-dom';
interface FlipCardLink {
id: string;
label: string;
route: string;
visible?: boolean;
}
interface FlipCardProps {
title: string;
links: FlipCardLink[];
icon: React.ReactNode;
}
const FlipCard: React.FC<FlipCardProps> = ({ title, links, icon }) => {
const navigate = useNavigate();
const visibleLinks = links.filter(link => link.visible !== false);
return (
<div className="w-full sm:w-[230px] h-[120px] perspective-1000 m-2.5">
<div className="relative w-full h-full transition-transform duration-700 transform-style-3d group hover:rotate-y-180">
{/* Front Side */}
<div className="absolute w-full h-full backface-hidden rounded-lg overflow-hidden bg-gradient-to-br from-blue-600 to-blue-800 shadow-lg">
<div className="absolute inset-0 bg-black/20" />
<div className="relative h-full flex flex-col items-center justify-end p-4">
<div className="mb-2 text-white text-4xl drop-shadow-lg">
{icon}
</div>
<p className="text-white text-center font-bold text-base sm:text-lg drop-shadow-[0_2px_4px_rgba(0,0,0,0.8)]">
{title}
</p>
</div>
</div>
{/* Back Side */}
<div className="absolute w-full h-full backface-hidden rotate-y-180 rounded-lg overflow-hidden bg-gradient-to-br from-blue-600/90 to-blue-800/90 backdrop-blur-sm shadow-2xl">
<div className="h-full flex flex-col items-center justify-center p-4 gap-2">
{visibleLinks.map((link) => (
<button
key={link.id}
id={link.id}
onClick={() => navigate(link.route)}
className="
w-full px-4 py-2
text-white font-semibold text-sm
bg-white/10 hover:bg-white/20
rounded-md
transition-all duration-200
hover:scale-105 hover:shadow-lg
border border-white/20 hover:border-white/40
"
>
{link.label}
</button>
))}
</div>
</div>
</div>
</div>
);
};
export default FlipCard;

View File

@ -0,0 +1,71 @@
import React from 'react';
import { useNavigate } from 'react-router-dom';
import { useTheme } from '../contexts/ThemeContext';
import { useLanguage } from '../contexts/LanguageContext';
import { useTranslation } from 'react-i18next';
import { Moon, Sun, Languages, LogOut } from 'lucide-react';
import NotificationBell from './NotificationBell';
interface HeaderProps {
userEmail?: string;
}
const Header: React.FC<HeaderProps> = ({ userEmail }) => {
const navigate = useNavigate();
const { theme, toggleTheme } = useTheme();
const { language, changeLanguage } = useLanguage();
const { t } = useTranslation();
const handleLogout = () => {
localStorage.removeItem('user');
localStorage.removeItem('sid');
navigate('/login');
};
return (
<header className="h-14 bg-white dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700 px-4 flex items-center justify-end gap-2 flex-shrink-0">
{/* User Email (optional, can be shown on hover or always) */}
{userEmail && (
<div className="hidden md:block text-sm text-gray-600 dark:text-gray-400 mr-2">
{userEmail}
</div>
)}
{/* Notification Bell */}
<div className="relative">
<NotificationBell />
</div>
{/* Language Toggle */}
<button
onClick={() => changeLanguage(language === 'en' ? 'ar' : 'en')}
className="p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors text-gray-700 dark:text-gray-300"
title={t('common.language')}
>
<Languages size={20} />
</button>
{/* Theme Toggle */}
<button
onClick={toggleTheme}
className="p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors text-gray-700 dark:text-gray-300"
title={theme === 'light' ? t('common.darkMode') : t('common.lightMode')}
>
{theme === 'light' ? <Moon size={20} /> : <Sun size={20} />}
</button>
{/* Logout */}
<button
onClick={handleLogout}
className="p-2 rounded-lg hover:bg-red-50 dark:hover:bg-red-900/20 transition-colors text-red-600 dark:text-red-400"
title={t('common.logout')}
>
<LogOut size={20} />
</button>
</header>
);
};
export default Header;

View File

@ -0,0 +1,305 @@
import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react';
import { createPortal } from 'react-dom';
import apiService from '../services/apiService';
interface LinkFieldProps {
label: string;
doctype: string;
value: string;
onChange: (value: string) => void;
placeholder?: string;
disabled?: boolean;
filters?: Record<string, any>;
compact?: boolean;
usePortal?: boolean; // New prop to enable portal rendering
}
// Stable empty object to avoid re-renders
const EMPTY_FILTERS: Record<string, any> = {};
const LinkField: React.FC<LinkFieldProps> = ({
label,
doctype,
value,
onChange,
placeholder,
disabled = false,
filters,
compact = false,
usePortal = false, // Default to false for backward compatibility
}) => {
const [searchResults, setSearchResults] = useState<{ value: string; description?: string }[]>([]);
const [searchText, setSearchText] = useState('');
const [isDropdownOpen, setDropdownOpen] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const [dropdownPosition, setDropdownPosition] = useState<{ top: number; left: number; width: number }>({ top: 0, left: 0, width: 0 });
const containerRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
const dropdownRef = useRef<HTMLDivElement>(null);
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const lastSearchRef = useRef<string>('');
const hasLoadedRef = useRef<boolean>(false);
// Use stable empty object if filters not provided
const stableFilters = filters || EMPTY_FILTERS;
// Stringify filters for comparison (avoid object reference issues)
const filtersKey = useMemo(() => JSON.stringify(stableFilters), [stableFilters]);
// Fetch link options from ERPNext with filters
const searchLink = useCallback(async (text: string = '', force: boolean = false) => {
// Prevent duplicate calls for the same search text
const searchKey = `${text}-${filtersKey}`;
if (!force && lastSearchRef.current === searchKey) {
return;
}
lastSearchRef.current = searchKey;
setIsLoading(true);
try {
const params = new URLSearchParams({
doctype,
txt: text,
});
// Add filters if provided
if (stableFilters && Object.keys(stableFilters).length > 0) {
params.append('filters', JSON.stringify(stableFilters));
}
const response = await apiService.apiCall<{ value: string; description?: string }[]>(
`/api/method/frappe.desk.search.search_link?${params.toString()}`
);
setSearchResults(response || []);
} catch (error) {
console.error(`Error fetching ${doctype} links:`, error);
setSearchResults([]);
} finally {
setIsLoading(false);
}
}, [doctype, filtersKey, stableFilters]);
// Debounced search for typing
const debouncedSearch = useCallback((text: string) => {
if (debounceRef.current) {
clearTimeout(debounceRef.current);
}
debounceRef.current = setTimeout(() => {
searchLink(text);
}, 300);
}, [searchLink]);
// Fetch default options ONLY when dropdown first opens
useEffect(() => {
if (isDropdownOpen && !hasLoadedRef.current) {
hasLoadedRef.current = true;
searchLink(searchText || '', true);
}
// Reset the loaded flag when dropdown closes
if (!isDropdownOpen) {
hasLoadedRef.current = false;
lastSearchRef.current = '';
}
}, [isDropdownOpen]); // Only depend on isDropdownOpen
// Cleanup debounce on unmount
useEffect(() => {
return () => {
if (debounceRef.current) {
clearTimeout(debounceRef.current);
}
};
}, []);
// Calculate dropdown position for portal rendering
const updateDropdownPosition = useCallback(() => {
if (usePortal && inputRef.current) {
const rect = inputRef.current.getBoundingClientRect();
setDropdownPosition({
top: rect.bottom + window.scrollY,
left: rect.left + window.scrollX,
width: rect.width
});
}
}, [usePortal]);
// Update position when dropdown opens or on scroll/resize
useEffect(() => {
if (isDropdownOpen && usePortal) {
updateDropdownPosition();
const handleUpdate = () => updateDropdownPosition();
window.addEventListener('scroll', handleUpdate, true);
window.addEventListener('resize', handleUpdate);
return () => {
window.removeEventListener('scroll', handleUpdate, true);
window.removeEventListener('resize', handleUpdate);
};
}
}, [isDropdownOpen, usePortal, updateDropdownPosition]);
// Close dropdown when clicking outside
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
const target = event.target as Node;
const clickedOutsideContainer = containerRef.current && !containerRef.current.contains(target);
const clickedOutsideDropdown = usePortal && dropdownRef.current && !dropdownRef.current.contains(target);
// Close if clicked outside both container and dropdown (when using portal)
if (usePortal) {
if (clickedOutsideContainer && clickedOutsideDropdown) {
setDropdownOpen(false);
setSearchText('');
}
} else {
if (clickedOutsideContainer) {
setDropdownOpen(false);
setSearchText('');
}
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, [usePortal]);
// Handle selecting an item from dropdown
const handleSelect = (selectedValue: string) => {
onChange(selectedValue);
setSearchText('');
setDropdownOpen(false);
};
// Handle clearing the field
const handleClear = () => {
onChange('');
setSearchText('');
setDropdownOpen(false);
};
// Render dropdown content
const renderDropdown = () => {
const dropdownClasses = `bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600
rounded-md w-full shadow-lg ${compact ? 'mt-0.5' : 'mt-1'}`;
const positionStyle = usePortal ? {
position: 'fixed' as const,
top: `${dropdownPosition.top}px`,
left: `${dropdownPosition.left}px`,
width: `${dropdownPosition.width}px`,
zIndex: 1050,
marginTop: compact ? '2px' : '4px'
} : {};
if (!isDropdownOpen || disabled) return null;
const dropdownContent = (
<div ref={dropdownRef}>
{/* Loading indicator */}
{isLoading && (
<div className={`${usePortal ? '' : 'absolute z-[1050]'} ${dropdownClasses} text-center text-gray-500 dark:text-gray-400
${compact ? 'p-1.5 text-[10px]' : 'p-3 text-sm'}`}
style={positionStyle}>
<span className="inline-block animate-spin mr-2"></span>
Loading...
</div>
)}
{/* Results list */}
{!isLoading && searchResults.length > 0 && (
<ul className={`${usePortal ? '' : 'absolute z-[1050]'} ${dropdownClasses} overflow-auto
${compact ? 'max-h-36' : 'max-h-48'}`}
style={positionStyle}>
{searchResults.map((item, idx) => (
<li
key={idx}
onClick={() => handleSelect(item.value)}
className={`cursor-pointer text-gray-900 dark:text-gray-100
hover:bg-blue-500 dark:hover:bg-blue-600 hover:text-white
${compact ? 'px-2 py-1 text-xs' : 'px-3 py-2 text-sm'}
${value === item.value ? 'bg-blue-50 dark:bg-blue-700 font-semibold' : ''}`}
>
{item.value}
{item.description && (
<span className={`text-gray-600 dark:text-gray-300 ml-2
${compact ? 'text-[9px] ml-1' : 'text-xs ml-2'}`}>
{item.description}
</span>
)}
</li>
))}
</ul>
)}
{/* No results message */}
{!isLoading && searchResults.length === 0 && (
<div className={`${usePortal ? '' : 'absolute z-[1050]'} ${dropdownClasses} text-center text-gray-500 dark:text-gray-400
${compact ? 'p-1.5 text-[10px]' : 'p-3 text-sm'}`}
style={positionStyle}>
No results found
</div>
)}
</div>
);
return usePortal ? createPortal(dropdownContent, document.body) : dropdownContent;
};
return (
<div ref={containerRef} className={`relative w-full ${compact ? 'mb-2' : 'mb-4'}`}>
<label className={`block font-medium text-gray-700 dark:text-gray-300 ${compact ? 'text-[10px] mb-0.5' : 'text-sm mb-1'}`}>
{label}
</label>
<div className="relative">
<input
ref={inputRef}
type="text"
value={isDropdownOpen ? searchText : value}
placeholder={placeholder || `Select ${label}`}
disabled={disabled}
className={`w-full border border-gray-300 dark:border-gray-600 rounded-md
focus:outline-none disabled:bg-gray-100 dark:disabled:bg-gray-700
bg-white dark:bg-gray-700 text-gray-900 dark:text-white
${compact
? 'px-2 py-1 text-xs focus:ring-1 focus:ring-blue-500 rounded'
: 'px-3 py-2 text-sm focus:ring-2 focus:ring-blue-500'
}
${value ? (compact ? 'pr-5' : 'pr-8') : ''}`}
onFocus={() => {
if (!disabled) {
setDropdownOpen(true);
setSearchText('');
if (usePortal) {
updateDropdownPosition();
}
}
}}
onChange={(e) => {
const text = e.target.value;
setSearchText(text);
debouncedSearch(text); // Use debounced search to prevent rapid API calls
}}
/>
{/* Clear button */}
{value && !disabled && !isDropdownOpen && (
<button
type="button"
onClick={handleClear}
className={`absolute top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300
${compact ? 'right-1 text-xs' : 'right-2 text-sm'}`}
>
</button>
)}
</div>
{/* Render dropdown */}
{renderDropdown()}
</div>
);
};
export default LinkField;

View File

@ -0,0 +1,413 @@
import React, { useState, useMemo, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { useAssetMaintenanceLogs } from '../hooks/useAssetMaintenance';
import { usePMSchedules } from '../hooks/usePMSchedule';
import { FaCheckCircle, FaClock, FaExclamationTriangle, FaChevronLeft, FaChevronRight, FaCalendarAlt } from 'react-icons/fa';
interface MaintenanceCalendarProps {
month?: number;
year?: number;
filters?: Record<string, any>;
viewType?: 'maintenance-log' | 'ppm-planner';
timeView?: 'day-month' | 'year';
}
const MaintenanceCalendar: React.FC<MaintenanceCalendarProps> = ({
month: initialMonth,
year: initialYear,
filters: externalFilters = {},
viewType = 'maintenance-log',
timeView = 'day-month'
}) => {
const navigate = useNavigate();
const today = new Date();
const [currentMonth, setCurrentMonth] = useState(initialMonth ?? today.getMonth());
const [currentYear, setCurrentYear] = useState(initialYear ?? today.getFullYear());
// Fetch maintenance logs for current and next month
const startDate = new Date(currentYear, currentMonth, 1).toISOString().split('T')[0];
const endDate = new Date(currentYear, currentMonth + 1, 0).toISOString().split('T')[0];
// Memoize external filters to prevent object reference changes
const externalFiltersJson = JSON.stringify(externalFilters);
const stableExternalFilters = useMemo(() => externalFilters, [externalFiltersJson]);
// Combine date filter with external filters
const combinedFilters = useMemo(() => ({
due_date: ['between', [startDate, endDate]],
...stableExternalFilters
}), [startDate, endDate, stableExternalFilters]);
// Stable empty filters object for PPM Planner
const emptyFilters = useMemo(() => ({}), []);
const emptyPermissionFilters = useMemo(() => ({}), []);
const { logs, loading: logsLoading } = useAssetMaintenanceLogs(
viewType === 'maintenance-log' ? combinedFilters : emptyFilters,
viewType === 'maintenance-log' ? 1000 : 0,
0,
'due_date asc'
);
// Fetch PM Schedules (PPM Planners) using the custom API - only when viewType is ppm-planner
const { pmSchedules, loading: pmLoading, error: pmError } = usePMSchedules(
viewType === 'ppm-planner' ? stableExternalFilters : emptyFilters,
1000,
0,
'creation desc',
emptyPermissionFilters
);
const loading = viewType === 'maintenance-log' ? logsLoading : pmLoading;
// Filter logs for current month - MUST be defined before being used in useEffect
const currentMonthLogs = useMemo(() => {
if (viewType === 'maintenance-log') {
return logs.filter(log => {
if (!log.due_date) return false;
const logDate = new Date(log.due_date);
return logDate.getMonth() === currentMonth && logDate.getFullYear() === currentYear;
});
} else {
// Filter PM Schedules by month - use due_date (like maintenance logs) to determine which month to show
const filtered = pmSchedules.filter(schedule => {
// Use due_date as primary field (when maintenance is actually due)
// Fallback to start_date if due_date is not available
const dateToUse = schedule.due_date || schedule.start_date;
if (!dateToUse) return false;
// Parse date string and create date at local midnight to avoid timezone issues
const [year, month, day] = dateToUse.split('-').map(Number);
const scheduleDate = new Date(year, month - 1, day);
// Check if the schedule date matches the current month and year
const matches = scheduleDate.getMonth() === currentMonth && scheduleDate.getFullYear() === currentYear;
return matches;
});
return filtered;
}
}, [logs, pmSchedules, currentMonth, currentYear, viewType]);
// Debug: Log PM Schedules when viewType is ppm-planner
useEffect(() => {
if (viewType === 'ppm-planner' && !pmLoading) {
console.log('=== PPM PLANNER DEBUG ===');
console.log('[MaintenanceCalendar] Viewing Month:', currentMonth + 1, 'Year:', currentYear);
console.log('[MaintenanceCalendar] Total PM Schedules fetched:', pmSchedules.length);
console.log('[MaintenanceCalendar] Filtered for current month:', currentMonthLogs.length);
if (currentMonthLogs.length > 0) {
console.log('[MaintenanceCalendar] Schedules showing in this month:');
currentMonthLogs.forEach((s: any) => {
console.log(` - ${s.name}: due_date=${s.due_date}, start_date=${s.start_date}`);
});
} else {
console.log('[MaintenanceCalendar] No schedules match this month.');
console.log('[MaintenanceCalendar] Due dates in fetched data:');
pmSchedules.slice(0, 5).forEach((s: any) => {
const dateToUse = s.due_date || s.start_date;
console.log(` - ${s.name}: due_date=${s.due_date}, start_date=${s.start_date}, will show in: ${dateToUse ? (() => {
const [y, m] = dateToUse.split('-').map(Number);
return `${m}/${y}`;
})() : 'unknown'}`);
});
console.log('[MaintenanceCalendar] TIP: Navigate to the month where due_dates match to see schedules.');
}
console.log('=========================');
}
}, [viewType, pmSchedules, pmLoading, currentMonthLogs.length, currentMonth, currentYear]);
const getStatusColor = (status: string, dueDate: string) => {
const isOverdue = new Date(dueDate) < new Date() && status !== 'Completed';
switch (status) {
case 'Completed':
return 'bg-green-500 text-white border-green-600';
case 'Planned':
return isOverdue ? 'bg-red-500 text-white border-red-600' : 'bg-yellow-500 text-white border-yellow-600';
case 'Overdue':
return 'bg-red-600 text-white border-red-700';
default:
return 'bg-gray-500 text-white border-gray-600';
}
};
const getStatusIcon = (status: string) => {
switch (status) {
case 'Completed':
return <FaCheckCircle className="text-green-500" size={12} />;
case 'Planned':
return <FaClock className="text-yellow-500" size={12} />;
case 'Overdue':
return <FaExclamationTriangle className="text-red-500" size={12} />;
default:
return null;
}
};
// Generate calendar days
const firstDay = new Date(currentYear, currentMonth, 1).getDay();
const daysInMonth = new Date(currentYear, currentMonth + 1, 0).getDate();
const days = Array.from({ length: daysInMonth }, (_, i) => i + 1);
const navigateMonth = (direction: number) => {
if (direction > 0) {
if (currentMonth === 11) {
setCurrentMonth(0);
setCurrentYear(currentYear + 1);
} else {
setCurrentMonth(currentMonth + 1);
}
} else {
if (currentMonth === 0) {
setCurrentMonth(11);
setCurrentYear(currentYear - 1);
} else {
setCurrentMonth(currentMonth - 1);
}
}
};
const getLogsForDay = (day: number) => {
if (viewType === 'maintenance-log') {
return currentMonthLogs.filter(log => {
if (!log.due_date) return false;
const logDate = new Date(log.due_date);
return logDate.getDate() === day;
});
} else {
// For PPM Planner, show if the day matches the due_date (like maintenance logs)
const daySchedules = currentMonthLogs.filter((schedule: any) => {
// Use due_date as primary field (when maintenance is actually due)
// Fallback to start_date if due_date is not available
const dateToUse = schedule.due_date || schedule.start_date;
if (!dateToUse) return false;
// Parse date string and create date at local midnight
const [year, month, dayOfMonth] = dateToUse.split('-').map(Number);
const scheduleDate = new Date(year, month - 1, dayOfMonth);
// Check if the schedule date matches the current day
const matches = scheduleDate.getDate() === day &&
scheduleDate.getMonth() === currentMonth &&
scheduleDate.getFullYear() === currentYear;
return matches;
});
return daySchedules;
}
};
const goToToday = () => {
setCurrentMonth(today.getMonth());
setCurrentYear(today.getFullYear());
};
const monthNames = [
'January', 'February', 'March', 'April', 'May', 'June',
'July', 'August', 'September', 'October', 'November', 'December'
];
const dayNames = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
return (
<div className="bg-white dark:bg-gray-800 rounded-lg shadow h-full flex flex-col overflow-hidden">
<div className="flex-shrink-0 flex justify-between items-center p-4 lg:p-6 border-b border-gray-200 dark:border-gray-700">
<div className="flex items-center gap-2 lg:gap-3">
<FaCalendarAlt className="text-blue-600 dark:text-blue-400" size={20} />
<h2 className="text-xl lg:text-2xl font-bold text-gray-800 dark:text-white">
{monthNames[currentMonth]} {currentYear}
</h2>
</div>
<div className="flex gap-1 lg:gap-2">
<button
onClick={() => navigateMonth(-1)}
className="px-2 py-2 lg:px-4 bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 rounded-lg text-gray-700 dark:text-gray-300 transition-colors"
title="Previous Month"
>
<FaChevronLeft />
</button>
<button
onClick={goToToday}
className="px-2 py-2 lg:px-4 bg-blue-600 hover:bg-blue-700 text-white rounded-lg transition-colors text-xs lg:text-sm font-medium"
>
Today
</button>
<button
onClick={() => navigateMonth(1)}
className="px-2 py-2 lg:px-4 bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 rounded-lg text-gray-700 dark:text-gray-300 transition-colors"
title="Next Month"
>
<FaChevronRight />
</button>
</div>
</div>
{loading ? (
<div className="flex items-center justify-center flex-1">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-500"></div>
<span className="ml-3 text-gray-600 dark:text-gray-400">
Loading {viewType === 'maintenance-log' ? 'maintenance logs' : 'PPM Planners'}...
</span>
</div>
) : (
<>
<div className="flex-1 overflow-auto p-4 lg:p-6">
<div className="grid grid-cols-7 gap-1 lg:gap-2 mb-2">
{dayNames.map(day => (
<div key={day} className="text-center font-semibold p-1 lg:p-2 text-gray-700 dark:text-gray-300 text-xs lg:text-sm">
{day}
</div>
))}
</div>
<div className="grid grid-cols-7 gap-1 lg:gap-2 auto-rows-fr">
{Array.from({ length: firstDay }).map((_, i) => (
<div key={`empty-${i}`} className="p-1 lg:p-2"></div>
))}
{days.map(day => {
const dayLogs = getLogsForDay(day);
const isToday = day === today.getDate() &&
currentMonth === today.getMonth() &&
currentYear === today.getFullYear();
return (
<div
key={day}
className={`border rounded-lg p-1 lg:p-2 min-h-16 lg:min-h-20 hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-colors flex flex-col ${
isToday ? 'border-blue-500 border-2 bg-blue-50 dark:bg-blue-900/20' : 'border-gray-200 dark:border-gray-700'
}`}
>
<div className={`font-semibold mb-1 text-xs lg:text-sm flex-shrink-0 ${isToday ? 'text-blue-700 dark:text-blue-300' : 'text-gray-700 dark:text-gray-300'}`}>
{day}
</div>
<div className="space-y-1 flex-1 overflow-hidden">
{dayLogs.slice(0, 2).map(item => {
if (viewType === 'maintenance-log') {
const log = item as any;
const isOverdue = new Date(log.due_date || '') < new Date() && log.maintenance_status !== 'Completed';
return (
<div
key={log.name}
onClick={() => navigate(`/maintenance/${log.name}`)}
className={`text-xs p-1 rounded border ${getStatusColor(log.maintenance_status || 'Planned', log.due_date || '')} truncate cursor-pointer hover:opacity-80 transition-opacity`}
title={`${log.asset_name || log.name} - ${log.maintenance_status || 'Planned'}${isOverdue ? ' (Overdue)' : ''} - Click to view details`}
>
<div className="truncate font-medium text-xs">{log.asset_name || log.name}</div>
</div>
);
} else {
const schedule = item as any;
// Debug: Log schedule data to see what fields are available
if (import.meta.env.DEV) {
console.log('[MaintenanceCalendar] Schedule data:', {
name: schedule.name,
pm_for: schedule.pm_for,
allFields: Object.keys(schedule)
});
}
// Display PM Name (pm_for) instead of Name, but keep name in hover tooltip
// Check multiple possible field names
const pmName = schedule.pm_for || schedule['pm_for'] || schedule['PM Name'] || null;
const displayText = pmName || schedule.name || 'PPM Planner';
const tooltipText = schedule.name
? `${schedule.name}${schedule.modality ? ` - ${schedule.modality}` : ''}${schedule.hospital ? ` - ${schedule.hospital}` : ''} - Click to view PPM Planner`
: 'Click to view PPM Planner';
return (
<div
key={schedule.name}
onClick={() => navigate(`/ppm-planner/${schedule.name}`)}
className="text-xs p-1 rounded border bg-purple-500 text-white border-purple-600 truncate cursor-pointer hover:opacity-80 transition-opacity"
title={tooltipText}
>
<div className="truncate font-medium text-xs">{displayText}</div>
</div>
);
}
})}
{dayLogs.length > 2 && (
<div className="text-xs text-gray-500 dark:text-gray-400 font-medium">
+{dayLogs.length - 2}
</div>
)}
</div>
</div>
);
})}
</div>
</div>
{/* Legend & Summary - Compact Footer */}
<div className="flex-shrink-0 border-t border-gray-200 dark:border-gray-700 p-3 lg:p-4 bg-gray-50 dark:bg-gray-900/30">
<div className="flex flex-col lg:flex-row justify-between items-center gap-3 lg:gap-4">
{/* Legend */}
<div className="flex flex-wrap gap-3 lg:gap-4 items-center justify-center lg:justify-start">
<div className="flex items-center gap-1.5">
<div className="w-3 h-3 bg-green-500 rounded border border-green-600"></div>
<span className="text-xs text-gray-600 dark:text-gray-400">Completed</span>
</div>
<div className="flex items-center gap-1.5">
<div className="w-3 h-3 bg-yellow-500 rounded border border-yellow-600"></div>
<span className="text-xs text-gray-600 dark:text-gray-400">Planned</span>
</div>
<div className="flex items-center gap-1.5">
<div className="w-3 h-3 bg-red-500 rounded border border-red-600"></div>
<span className="text-xs text-gray-600 dark:text-gray-400">Overdue</span>
</div>
<div className="flex items-center gap-1.5">
<div className="w-3 h-3 border-2 border-blue-500 rounded bg-blue-50 dark:bg-blue-900/20"></div>
<span className="text-xs text-gray-600 dark:text-gray-400">Today</span>
</div>
</div>
{/* Summary */}
<div className="flex gap-4 lg:gap-6 text-center">
{viewType === 'maintenance-log' ? (
<>
<div>
<div className="text-lg lg:text-xl font-bold text-green-600 dark:text-green-400">
{currentMonthLogs.filter((l: any) => l.maintenance_status === 'Completed').length}
</div>
<div className="text-xs text-gray-600 dark:text-gray-400">Completed</div>
</div>
<div>
<div className="text-lg lg:text-xl font-bold text-yellow-600 dark:text-yellow-400">
{currentMonthLogs.filter((l: any) => l.maintenance_status === 'Planned' && new Date(l.due_date || '') >= new Date()).length}
</div>
<div className="text-xs text-gray-600 dark:text-gray-400">Planned</div>
</div>
<div>
<div className="text-lg lg:text-xl font-bold text-red-600 dark:text-red-400">
{currentMonthLogs.filter((l: any) => {
const dueDate = new Date(l.due_date || '');
return dueDate < new Date() && l.maintenance_status !== 'Completed';
}).length}
</div>
<div className="text-xs text-gray-600 dark:text-gray-400">Overdue</div>
</div>
</>
) : (
<div>
<div className="text-lg lg:text-xl font-bold text-purple-600 dark:text-purple-400">
{currentMonthLogs.length}
</div>
<div className="text-xs text-gray-600 dark:text-gray-400">PPM Planners</div>
</div>
)}
</div>
</div>
</div>
</>
)}
</div>
);
};
export default MaintenanceCalendar;

View File

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

View File

@ -0,0 +1,70 @@
import React from 'react';
import { useNavigate } from 'react-router-dom';
interface ShortcutCardProps {
id: string;
title: string;
icon: React.ReactNode;
route: string;
gradient: string;
visible?: boolean;
}
const ShortcutCard: React.FC<ShortcutCardProps> = ({
id,
title,
icon,
route,
gradient,
visible = true
}) => {
const navigate = useNavigate();
if (!visible) return null;
const handleClick = () => {
navigate(route);
};
return (
<div
id={id}
onClick={handleClick}
className={`
relative group cursor-pointer
w-full sm:w-[230px] h-[120px]
rounded-lg overflow-hidden
transform transition-all duration-300 ease-in-out
hover:-translate-y-2 hover:shadow-2xl
border border-gray-200 hover:border-gray-800
${gradient}
`}
>
{/* Background overlay for better text visibility */}
<div className="absolute inset-0 bg-black/20 group-hover:bg-black/30 transition-all duration-300" />
{/* Content */}
<div className="relative h-full flex flex-col items-center justify-end p-4">
{/* Icon */}
<div className="mb-2 transform transition-transform duration-300 group-hover:scale-110">
<div className="text-white text-4xl drop-shadow-lg">
{icon}
</div>
</div>
{/* Title */}
<p className="text-white text-center font-bold text-base sm:text-lg drop-shadow-[0_2px_4px_rgba(0,0,0,0.8)]">
{title}
</p>
</div>
{/* Hover glow effect */}
<div className="absolute inset-0 opacity-0 group-hover:opacity-100 transition-opacity duration-300 pointer-events-none">
<div className="absolute inset-0 bg-gradient-to-t from-white/10 to-transparent" />
</div>
</div>
);
};
export default ShortcutCard;

View File

@ -0,0 +1,354 @@
import React, { useState } from 'react';
import { Link, useLocation } from 'react-router-dom';
import { useLanguage } from '../contexts/LanguageContext';
import { useTranslation } from 'react-i18next';
import {
LayoutDashboard,
Package,
Menu,
X,
ClipboardList,
Calendar,
CalendarCheck,
Map,
Users,
ShoppingCart,
FileText,
HelpCircle
} from 'lucide-react';
interface SidebarLink {
id: string;
title: string;
icon: React.ReactNode;
path: string;
visible: boolean;
}
interface SidebarProps {
userEmail?: string;
}
const Sidebar: React.FC<SidebarProps> = ({ userEmail }) => {
const [isCollapsed, setIsCollapsed] = useState(false);
const location = useLocation();
const { isRTL } = useLanguage();
const { t } = useTranslation();
// Get base URL for assets (handles both dev and production)
// BASE_URL in Vite already includes trailing slash in production, but not in dev
const baseUrl = import.meta.env.BASE_URL || '/';
// Add cache-busting query parameter to force browser to reload updated images
// Version is automatically updated by build script based on file modification time
const imageVersion = import.meta.env.DEV
? `?v=${Date.now()}`
: `?v=1765196376`; // Auto-updated by build script
const logoVersion = import.meta.env.DEV
? `?v=${Date.now()}`
: `?v=1765198405`; // Auto-updated by build script
const backgroundImageUrl = baseUrl.endsWith('/')
? `${baseUrl}sidebar-background.jpg${imageVersion}`
: `${baseUrl}/sidebar-background.jpg${imageVersion}`;
// Role-based visibility logic
// const isMaintenanceManagerKASH = userEmail === 'maintenancemanager-kash@gmail.com';
// const isMaintenanceManagerTH = userEmail === 'maintenancemanager-th@gmail.com';
// const isMaintenanceManagerDAJH = userEmail === 'maintenancemanager-dajh@gmail.com';
const isFinanceManager = userEmail === 'financemanager@gmail.com';
const isEndUser = userEmail && (
userEmail.startsWith('enduser1-kash') ||
userEmail.startsWith('enduser1-dajh') ||
userEmail.startsWith('enduser1-th')
);
// const isTechnician = userEmail && (
// userEmail.startsWith('technician1-kash') ||
// userEmail.startsWith('technician1-dajh') ||
// userEmail.startsWith('technician1-th')
// );
const showAsset = !isFinanceManager && !isEndUser;
// const showInventory = !isFinanceManager && !isEndUser;
const showPreventiveMaintenance = !isFinanceManager && !isEndUser;
const showGeneralWO = !isFinanceManager && !isEndUser;
// const showAMTeam = !isFinanceManager && !isEndUser;
// const showProjectDashboard = !isMaintenanceManagerKASH && !isMaintenanceManagerTH && !isMaintenanceManagerDAJH && !isFinanceManager && !isEndUser && !isTechnician;
// const showSiteDashboards = !isFinanceManager && !isEndUser;
// const showSupplierDashboard = !isFinanceManager && !isEndUser;
// const showSLA = !isFinanceManager && !isEndUser && !isTechnician;
// const showSiteInfo = !isFinanceManager && !isEndUser;
const links: SidebarLink[] = [
{
id: 'dashboard',
title: t('common.dashboard'),
icon: <LayoutDashboard size={20} />,
path: '/dashboard',
visible: true
},
{
id: 'assets',
title: t('common.assets'),
icon: <Package size={20} />,
path: '/assets',
visible: showAsset
},
{
id: 'inventory',
title: 'Inventory',
icon: <Package size={20} />,
path: '/inventory',
visible: true
},
{
id: 'work-orders',
title: t('common.workOrders'),
icon: <ClipboardList size={20} />,
path: '/work-orders',
visible: showGeneralWO
},
// {
// id: 'maintenance',
// title: t('common.maintenance'),
// icon: <Wrench size={20} />,
// path: '/maintenance',
// visible: showPreventiveMaintenance
// },
// {
// id: 'ppm',
// title: t('common.ppm'),
// icon: <Calendar size={20} />,
// path: '/ppm',
// visible: showPreventiveMaintenance
// },
{
id: 'ppm-planner',
title: 'PPM Planner',
icon: <CalendarCheck size={20} />,
path: '/ppm-planner',
visible: showPreventiveMaintenance
},
{
id: 'maintenance-calendar',
title: 'Maintenance Calendar',
icon: <Calendar size={20} />,
path: '/maintenance-calendar',
visible: showPreventiveMaintenance
},
{
id: 'active-map',
title: 'Active Map',
icon: <Map size={20} />,
path: '/active-map',
visible: true
},
{
id: 'maintenance-team',
title: 'Maintenance Team',
icon: <Users size={20} />,
path: '/maintenance-team',
visible: true
},
{
id: 'procurement',
title: 'Procurement',
icon: <ShoppingCart size={20} />,
path: '/procurement',
visible: true
},
{
id: 'sla',
title: 'Service Level Agreement (SLA)',
icon: <FileText size={20} />,
path: '/sla',
visible: true
},
{
id: 'support',
title: 'Support',
icon: <HelpCircle size={20} />,
path: '/support',
visible: true
},
// {
// id: 'vendors',
// title: 'Vendors',
// icon: <Truck size={20} />,
// path: '/vendors',
// visible: showSupplierDashboard
// },
// {
// id: 'dashboard-view',
// title: 'Dashboard',
// icon: <BarChart3 size={20} />,
// path: '/dashboard-view',
// visible: showProjectDashboard
// },
// {
// id: 'sites',
// title: 'Sites',
// icon: <Building2 size={20} />,
// path: '/sites',
// visible: showSiteDashboards
// },
// {
// id: 'active-map',
// title: 'Active Map',
// icon: <MapPin size={20} />,
// path: '/active-map',
// visible: showSiteInfo
// },
// {
// id: 'users',
// title: 'Users',
// icon: <Users size={20} />,
// path: '/users',
// visible: showAMTeam
// },
// {
// id: 'account',
// title: 'Account',
// icon: <FileText size={20} />,
// path: '/account',
// visible: showSLA
// }
];
const visibleLinks = links.filter(link => link.visible);
const isActive = (path: string) => {
return location.pathname === path;
};
return (
<div
className={`
relative
h-screen
transition-all
duration-300
ease-in-out
flex
flex-col
shadow-xl
border-r border-gray-200 dark:border-gray-700
${isCollapsed ? 'w-16' : 'w-64'}
`}
style={{
backgroundImage: `url(${backgroundImageUrl})`,
backgroundSize: 'cover',
backgroundPosition: 'center',
backgroundRepeat: 'no-repeat'
}}
>
{/* Black Overlay */}
<div className="absolute inset-0 bg-black/60 dark:bg-black/70 z-0"></div>
{/* Content Container - Above Overlay */}
<div className="relative z-10 flex flex-col h-full bg-white/0 dark:bg-white/0">
{/* Sidebar Header */}
<div className="flex items-center justify-between p-4 border-b border-gray-200/30 dark:border-gray-700/30">
{!isCollapsed && (
<div className="flex items-center space-x-3">
<div className="w-10 h-10 flex items-center justify-center bg-white/20 dark:bg-white/20 rounded-lg p-1 backdrop-blur-sm">
{/* Seera Arabia Logo */}
<img
src={`${baseUrl}${baseUrl.endsWith('/') ? '' : '/'}seera-logo.png${logoVersion}`}
alt="Seera-ASM"
className="w-full h-full object-contain"
onError={(e) => {
// Fallback to SVG if image not found
e.currentTarget.style.display = 'none';
e.currentTarget.nextElementSibling?.classList.remove('hidden');
}}
/>
<svg className="w-6 h-6 hidden" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M12 2L2 7L12 12L22 7L12 2Z" fill="#6366F1" fillOpacity="0.9"/>
<path d="M2 17L12 22L22 17V12L12 17L2 12V17Z" fill="#8B5CF6" fillOpacity="0.7"/>
<path d="M12 12V17" stroke="#A855F7" strokeWidth="2" strokeLinecap="round"/>
</svg>
</div>
<h1 className="text-white dark:text-white text-lg font-semibold drop-shadow-lg">{t('sidebar.title')}</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={`${baseUrl}${baseUrl.endsWith('/') ? '' : '/'}seera-logo.png?v=1765198405${logoVersion}`}
alt="Seera-ASM"
className="w-full h-full object-contain"
onError={(e) => {
e.currentTarget.style.display = 'none';
e.currentTarget.nextElementSibling?.classList.remove('hidden');
}}
/>
<svg className="w-5 h-5 hidden" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M12 2L2 7L12 12L22 7L12 2Z" fill="#6366F1" fillOpacity="0.9"/>
<path d="M2 17L12 22L22 17V12L12 17L2 12V17Z" fill="#8B5CF6" fillOpacity="0.7"/>
<path d="M12 12V17" stroke="#A855F7" strokeWidth="2" strokeLinecap="round"/>
</svg>
</div>
)}
<button
onClick={() => setIsCollapsed(!isCollapsed)}
className="text-white dark:text-white hover:bg-white/20 dark:hover:bg-white/20 p-2 rounded-lg transition-colors"
>
{isCollapsed ? <Menu size={20} /> : <X size={20} />}
</button>
</div>
{/* Navigation Links */}
<nav className="flex-1 overflow-y-auto py-4">
{visibleLinks.map((link) => (
<Link
key={link.id}
to={link.path}
className={`
flex
items-center
px-4
py-3
text-white dark:text-white
hover:bg-white/20 dark:hover:bg-white/20
hover:text-white dark:hover:text-white
transition-all
duration-200
${isActive(link.path) ? 'bg-white/30 dark:bg-white/30 text-white dark:text-white border-l-4 border-white' : ''}
${isCollapsed ? 'justify-center' : ''}
`}
title={isCollapsed ? link.title : ''}
>
<span>{link.icon}</span>
{!isCollapsed && (
<span className={`${isRTL ? 'mr-4' : 'ml-4'} font-medium`}>{link.title}</span>
)}
</Link>
))}
</nav>
{/* User Info & Version (Bottom) */}
<div className={`${isCollapsed ? 'p-2' : 'p-4'} border-t border-white/10 backdrop-blur-sm bg-white/5 space-y-3 relative z-10`}>
{!isCollapsed && userEmail && (
<div>
<div className="text-white/80 dark:text-white/80 text-xs truncate">
{t('sidebar.loggedInAs')}
</div>
<div className="text-white dark:text-white text-sm font-medium truncate">
{userEmail}
</div>
</div>
)}
{!isCollapsed && (
<div className="text-xs text-white/70 dark:text-white/70 text-center">
{t('sidebar.version')}
</div>
)}
</div>
</div>
</div>
);
};
export default Sidebar;

View File

@ -0,0 +1,90 @@
type Dataset = { name: string; values: number[]; color?: string };
interface Props {
type: 'Bar' | 'Pie' | 'Line' | string;
labels: string[];
datasets: Dataset[];
height?: number;
}
const clamp = (n: number) => (Number.isFinite(n) ? Math.max(0, n) : 0);
export default function SimpleChart({ type, labels, datasets, height = 220 }: Props) {
if (!labels?.length || !datasets?.length) {
return <div className="text-sm text-gray-500">No data</div>;
}
if (type.toLowerCase() === 'pie') {
const values = datasets[0].values.map(clamp);
const total = values.reduce((a, b) => a + b, 0) || 1;
const radius = Math.min(100, height / 2 - 10);
const cx = radius + 10;
const cy = radius + 10;
let cumulative = 0;
const colors = datasets[0].values.map((_, i) => datasets[0].color || defaultColor(i));
return (
<svg width={cx * 2} height={cy * 2} viewBox={`0 0 ${cx * 2} ${cy * 2}`}>
{values.map((v, i) => {
const startAngle = (cumulative / total) * 2 * Math.PI;
cumulative += v;
const endAngle = (cumulative / total) * 2 * Math.PI;
const largeArc = endAngle - startAngle > Math.PI ? 1 : 0;
const x1 = cx + radius * Math.cos(startAngle);
const y1 = cy + radius * Math.sin(startAngle);
const x2 = cx + radius * Math.cos(endAngle);
const y2 = cy + radius * Math.sin(endAngle);
const d = `M ${cx} ${cy} L ${x1} ${y1} A ${radius} ${radius} 0 ${largeArc} 1 ${x2} ${y2} Z`;
return <path key={i} d={d} fill={colors[i]} />;
})}
</svg>
);
}
// Bar chart (stack if multiple datasets)
const series = datasets;
const max = Math.max(...series.flatMap(s => s.values.map(clamp)), 1);
const width = Math.max(labels.length * 60, 300);
const chartHeight = height - 40;
const barWidth = Math.max(20, (width - 40) / labels.length - 10);
return (
<svg width={width} height={height} viewBox={`0 0 ${width} ${height}`}>
{/* Axis */}
<line x1={30} y1={10} x2={30} y2={chartHeight} stroke="#e5e7eb" />
<line x1={30} y1={chartHeight} x2={width - 10} y2={chartHeight} stroke="#e5e7eb" />
{labels.map((label, i) => {
const x = 40 + i * (barWidth + 10);
let yOffset = 0;
return (
<g key={i}>
{series.map((s, si) => {
const v = clamp(s.values[i] || 0);
const h = (v / max) * (chartHeight - 20);
const y = chartHeight - h - yOffset;
const color = s.color || defaultColor(si);
yOffset += h;
return <rect key={si} x={x} y={y} width={barWidth} height={h} fill={color} rx={2} />;
})}
<text x={x + barWidth / 2} y={height - 5} textAnchor="middle" fontSize="10" fill="#6b7280">
{truncate(label, 8)}
</text>
</g>
);
})}
</svg>
);
}
function defaultColor(i: number): string {
const palette = ['#4F46E5', '#10B981', '#F59E0B', '#EF4444', '#6366F1', '#22C55E', '#E11D48'];
return palette[i % palette.length];
}
function truncate(s: string, n: number) {
return s.length > n ? s.slice(0, n - 1) + '…' : s;
}

View File

@ -0,0 +1,200 @@
import React, { useState } from 'react';
import { useWorkflow } from '../hooks/useWorkflow.ts';
import type { WorkflowTransition } from '../services/workflowService';
import { FaSpinner, FaExclamationTriangle, FaInfoCircle } from 'react-icons/fa';
interface WorkflowActionsProps {
doctype: string;
docname: string | null;
workflowState?: string;
onActionComplete?: (action: string, success: boolean) => void;
onStateChange?: () => void;
showStateInfo?: boolean;
className?: string;
}
const WorkflowActions: React.FC<WorkflowActionsProps> = ({
doctype,
docname,
workflowState,
onActionComplete,
onStateChange,
showStateInfo = true,
className = '',
}) => {
const {
transitions,
loading,
actionLoading,
error,
applyAction,
getStateStyle,
getButtonStyle,
getIcon,
} = useWorkflow({
doctype,
docname,
workflowState,
enabled: !!docname,
});
const [confirmAction, setConfirmAction] = useState<string | null>(null);
// Actions that require confirmation
const actionsRequiringConfirmation = ['Reject', 'Cancel', 'Close'];
const handleActionClick = async (action: string) => {
// Check if action requires confirmation
if (actionsRequiringConfirmation.includes(action) && confirmAction !== action) {
setConfirmAction(action);
return;
}
setConfirmAction(null);
const success = await applyAction(action);
if (onActionComplete) {
onActionComplete(action, success);
}
if (success && onStateChange) {
onStateChange();
}
};
const handleCancelConfirm = () => {
setConfirmAction(null);
};
if (!docname) {
return null;
}
const stateStyle = workflowState ? getStateStyle(workflowState) : getStateStyle('Draft');
return (
<div className={`space-y-4 ${className}`}>
{/* Current State Display */}
{showStateInfo && workflowState && (
<div className={`p-4 rounded-lg border ${stateStyle.bg} ${stateStyle.border}`}>
<div className="flex items-center justify-between">
<div>
<p className="text-xs text-gray-500 dark:text-gray-400 mb-1">Workflow State</p>
<p className={`text-lg font-semibold ${stateStyle.text}`}>
{workflowState}
</p>
</div>
<div className={`w-3 h-3 rounded-full ${stateStyle.bg.replace('100', '500').replace('900/30', '500')}`} />
</div>
</div>
)}
{/* Loading State */}
{loading && (
<div className="flex items-center gap-2 text-gray-500 dark:text-gray-400">
<FaSpinner className="animate-spin" />
<span className="text-sm">Loading workflow actions...</span>
</div>
)}
{/* Error Message */}
{error && (
<div className="p-3 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg">
<div className="flex items-start gap-2">
<FaExclamationTriangle className="text-red-500 mt-0.5" />
<p className="text-sm text-red-600 dark:text-red-400">{error}</p>
</div>
</div>
)}
{/* Confirmation Dialog */}
{confirmAction && (
<div className="p-4 bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-lg">
<div className="flex items-start gap-2 mb-3">
<FaExclamationTriangle className="text-yellow-500 mt-0.5" />
<div>
<p className="text-sm font-medium text-yellow-800 dark:text-yellow-200">
Confirm Action
</p>
<p className="text-xs text-yellow-600 dark:text-yellow-400 mt-1">
Are you sure you want to <strong>{confirmAction}</strong> this work order?
</p>
</div>
</div>
<div className="flex gap-2">
<button
onClick={() => handleActionClick(confirmAction)}
disabled={actionLoading}
className="px-3 py-1.5 bg-red-600 hover:bg-red-700 text-white text-sm rounded-md disabled:opacity-50"
>
{actionLoading ? (
<span className="flex items-center gap-1">
<FaSpinner className="animate-spin" size={12} />
Processing...
</span>
) : (
`Yes, ${confirmAction}`
)}
</button>
<button
onClick={handleCancelConfirm}
disabled={actionLoading}
className="px-3 py-1.5 bg-gray-300 hover:bg-gray-400 text-gray-700 text-sm rounded-md disabled:opacity-50"
>
Cancel
</button>
</div>
</div>
)}
{/* Available Actions */}
{!loading && transitions.length > 0 && !confirmAction && (
<div className="space-y-2">
<p className="text-xs font-medium text-gray-500 dark:text-gray-400 flex items-center gap-1">
<FaInfoCircle size={12} />
Available Actions
</p>
<div className="flex flex-wrap gap-2">
{transitions.map((transition: WorkflowTransition, index: number) => (
<button
key={`${transition.action}-${index}`}
onClick={() => handleActionClick(transition.action)}
disabled={actionLoading}
className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors disabled:opacity-50 flex items-center gap-2 ${getButtonStyle(transition.action)}`}
title={`Move to: ${transition.next_state}`}
>
{actionLoading ? (
<FaSpinner className="animate-spin" size={14} />
) : (
<span>{getIcon(transition.action)}</span>
)}
{transition.action}
</button>
))}
</div>
{/* Show next states info */}
<div className="mt-2 text-xs text-gray-500 dark:text-gray-400">
{transitions.map((t: WorkflowTransition, i: number) => (
<span key={i} className="inline-block mr-3">
{t.action} <span className="font-medium">{t.next_state}</span>
</span>
))}
</div>
</div>
)}
{/* No Actions Available */}
{!loading && transitions.length === 0 && docname && (
<div className="p-3 bg-gray-50 dark:bg-gray-700 rounded-lg">
<p className="text-sm text-gray-500 dark:text-gray-400 text-center">
No workflow actions available for your role
</p>
</div>
)}
</div>
);
};
export default WorkflowActions;

102
asm_app/src/config/api.ts Normal file
View File

@ -0,0 +1,102 @@
// API Configuration Types
interface ApiConfig {
BASE_URL: string;
ENDPOINTS: Record<string, string>;
DEFAULT_HEADERS: Record<string, string>;
TIMEOUT: number;
}
const API_CONFIG: ApiConfig = {
// Backend URL - Use proxy in development, direct URL in production
BASE_URL: import.meta.env.DEV
? '' // Use relative URLs in development (goes through Vite proxy)
: import.meta.env.VITE_FRAPPE_BASE_URL || 'https://imanrdh-seeraasm.seeraarabia.com',
// API Endpoints
ENDPOINTS: {
// User Management
USER_DETAILS: '/api/method/asset_lite.api.custom_api.get_user_details',
// Data Management
DOCTYPE_RECORDS: '/api/method/asset_lite.api.custom_api.get_doctype_records',
// Dashboard
DASHBOARD_STATS: '/api/method/asset_lite.api.custom_api.get_dashboard_stats',
DASHBOARD_NUMBER_CARDS: '/api/method/asset_lite.api.dashboard_api.get_number_cards',
DASHBOARD_LIST_CHARTS: '/api/method/asset_lite.api.dashboard_api.list_dashboard_charts',
DASHBOARD_CHART_DATA: '/api/method/asset_lite.api.dashboard_api.get_dashboard_chart_data',
DASHBOARD_REPAIR_COST: '/api/method/asset_lite.api.dashboard_api.get_repair_cost_by_item',
// KYC Management
KYC_DETAILS: '/api/method/asset_lite.api.custom_api.get_kyc_details',
// Asset Management
GET_ASSETS: '/api/method/asset_lite.api.asset_api.get_assets',
GET_ASSET_DETAILS: '/api/method/asset_lite.api.asset_api.get_asset_details',
CREATE_ASSET: '/api/method/asset_lite.api.asset_api.create_asset',
UPDATE_ASSET: '/api/method/asset_lite.api.asset_api.update_asset',
DELETE_ASSET: '/api/method/asset_lite.api.asset_api.delete_asset',
GET_ASSET_FILTERS: '/api/method/asset_lite.api.asset_api.get_asset_filters',
GET_ASSET_STATS: '/api/method/asset_lite.api.asset_api.get_asset_stats',
SEARCH_ASSETS: '/api/method/asset_lite.api.asset_api.search_assets',
SUBMIT_ASSET: '/api/method/asset_lite.api.asset_api.submit_asset',
CANCEL_ASSET: '/api/method/asset_lite.api.asset_api.cancel_asset',
// Work Order Management
GET_WORK_ORDERS: '/api/method/asset_lite.api.work_order_api.get_work_orders',
GET_WORK_ORDER_DETAILS: '/api/method/asset_lite.api.work_order_api.get_work_order_details',
CREATE_WORK_ORDER: '/api/method/asset_lite.api.work_order_api.create_work_order',
UPDATE_WORK_ORDER: '/api/method/asset_lite.api.work_order_api.update_work_order',
DELETE_WORK_ORDER: '/api/method/asset_lite.api.work_order_api.delete_work_order',
UPDATE_WORK_ORDER_STATUS: '/api/method/asset_lite.api.work_order_api.update_work_order_status',
// Asset Maintenance Management
GET_ASSET_MAINTENANCE_LOGS: '/api/method/asset_lite.api.asset_maintenance_api.get_asset_maintenance_logs',
GET_ASSET_MAINTENANCE_LOG_DETAILS: '/api/method/asset_lite.api.asset_maintenance_api.get_asset_maintenance_log_details',
CREATE_ASSET_MAINTENANCE_LOG: '/api/method/asset_lite.api.asset_maintenance_api.create_asset_maintenance_log',
UPDATE_ASSET_MAINTENANCE_LOG: '/api/method/asset_lite.api.asset_maintenance_api.update_asset_maintenance_log',
DELETE_ASSET_MAINTENANCE_LOG: '/api/method/asset_lite.api.asset_maintenance_api.delete_asset_maintenance_log',
UPDATE_MAINTENANCE_STATUS: '/api/method/asset_lite.api.asset_maintenance_api.update_maintenance_status',
GET_MAINTENANCE_LOGS_BY_ASSET: '/api/method/asset_lite.api.asset_maintenance_api.get_maintenance_logs_by_asset',
GET_OVERDUE_MAINTENANCE_LOGS: '/api/method/asset_lite.api.asset_maintenance_api.get_overdue_maintenance_logs',
// PPM (Asset Maintenance) Management
GET_ASSET_MAINTENANCES: '/api/method/asset_lite.api.ppm_api.get_asset_maintenances',
GET_ASSET_MAINTENANCE_DETAILS: '/api/method/asset_lite.api.ppm_api.get_asset_maintenance_details',
CREATE_ASSET_MAINTENANCE: '/api/method/asset_lite.api.ppm_api.create_asset_maintenance',
UPDATE_ASSET_MAINTENANCE: '/api/method/asset_lite.api.ppm_api.update_asset_maintenance',
DELETE_ASSET_MAINTENANCE: '/api/method/asset_lite.api.ppm_api.delete_asset_maintenance',
GET_MAINTENANCE_TASKS: '/api/method/asset_lite.api.ppm_api.get_maintenance_tasks',
GET_SERVICE_COVERAGE: '/api/method/asset_lite.api.ppm_api.get_service_coverage',
GET_MAINTENANCES_BY_ASSET: '/api/method/asset_lite.api.ppm_api.get_maintenances_by_asset',
GET_ACTIVE_SERVICE_CONTRACTS: '/api/method/asset_lite.api.ppm_api.get_active_service_contracts',
// Authentication
LOGIN: '/api/method/login',
LOGOUT: '/api/method/logout',
CSRF_TOKEN: '/api/method/frappe.sessions.get_csrf_token',
// File Upload
UPLOAD_FILE: '/api/method/upload_file',
// User Permission Management - Generic (only these are needed!)
GET_USER_PERMISSIONS: '/api/method/asset_lite.api.userperm_api.get_user_permissions',
GET_PERMISSION_FILTERS: '/api/method/asset_lite.api.userperm_api.get_permission_filters',
GET_ALLOWED_VALUES: '/api/method/asset_lite.api.userperm_api.get_allowed_values',
CHECK_DOCUMENT_ACCESS: '/api/method/asset_lite.api.userperm_api.check_document_access',
GET_CONFIGURED_DOCTYPES: '/api/method/asset_lite.api.userperm_api.get_configured_doctypes',
GET_USER_DEFAULTS: '/api/method/asset_lite.api.userperm_api.get_user_defaults',
},
// Request Configuration
DEFAULT_HEADERS: {
'Content-Type': 'application/json',
'Accept': 'application/json'
},
// Timeout settings - increased for debugging
TIMEOUT: parseInt(import.meta.env.VITE_API_TIMEOUT || '60000'),
};
export default API_CONFIG;

View File

@ -0,0 +1,69 @@
import React, { createContext, useContext, useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { loadFrappeTranslations } from '../i18n';
type Language = 'en' | 'ar';
interface LanguageContextType {
language: Language;
changeLanguage: (lang: Language) => Promise<void>;
isRTL: boolean;
}
const LanguageContext = createContext<LanguageContextType | undefined>(undefined);
export const LanguageProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const { i18n } = useTranslation();
const [language, setLanguage] = useState<Language>(() => {
const saved = localStorage.getItem('i18nextLng') as Language;
return saved === 'ar' ? 'ar' : 'en';
});
const isRTL = language === 'ar';
// Apply language and RTL on mount and when it changes
useEffect(() => {
const root = document.documentElement;
const html = document.documentElement;
// Update i18n language
i18n.changeLanguage(language);
// Update HTML lang attribute
html.setAttribute('lang', language);
// Update HTML dir attribute for RTL
if (isRTL) {
html.setAttribute('dir', 'rtl');
root.classList.add('rtl');
root.classList.remove('ltr');
} else {
html.setAttribute('dir', 'ltr');
root.classList.add('ltr');
root.classList.remove('rtl');
}
}, [language, i18n, isRTL]);
const changeLanguage = async (lang: Language) => {
setLanguage(lang);
localStorage.setItem('i18nextLng', lang);
// Reload translations from Frappe when language changes
await loadFrappeTranslations();
};
return (
<LanguageContext.Provider value={{ language, changeLanguage, isRTL }}>
{children}
</LanguageContext.Provider>
);
};
export const useLanguage = () => {
const context = useContext(LanguageContext);
if (!context) {
throw new Error('useLanguage must be used within LanguageProvider');
}
return context;
};

View File

@ -0,0 +1,48 @@
import React, { createContext, useContext, useState, useEffect } from 'react';
type Theme = 'light' | 'dark';
interface ThemeContextType {
theme: Theme;
toggleTheme: () => void;
}
const ThemeContext = createContext<ThemeContextType | undefined>(undefined);
export const ThemeProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const [theme, setTheme] = useState<Theme>(() => {
const saved = localStorage.getItem('theme');
return (saved as Theme) || 'light';
});
// Apply theme on mount and when it changes
useEffect(() => {
const root = document.documentElement;
localStorage.setItem('theme', theme);
if (theme === 'dark') {
root.classList.add('dark');
} else {
root.classList.remove('dark');
}
}, [theme]);
const toggleTheme = () => {
setTheme(prev => prev === 'light' ? 'dark' : 'light');
};
return (
<ThemeContext.Provider value={{ theme, toggleTheme }}>
{children}
</ThemeContext.Provider>
);
};
export const useTheme = () => {
const context = useContext(ThemeContext);
if (!context) {
throw new Error('useTheme must be used within ThemeProvider');
}
return context;
};

206
asm_app/src/hooks/useApi.ts Normal file
View File

@ -0,0 +1,206 @@
import { useState, useEffect, useCallback } from 'react';
import apiService from '../services/apiService';
import { ApiError } from '../services/apiService';
// Define interfaces locally to avoid import issues
export interface UserDetails {
user_id: string;
full_name: string;
email: string;
user_image?: string;
roles: string[];
permissions: Record<string, {
read: boolean;
write: boolean;
create: boolean;
delete: boolean;
}>;
last_login?: string;
enabled: boolean;
creation: string;
modified: string;
language: string;
}
interface DocTypeRecord {
name: string;
creation: string;
modified: string;
modified_by: string;
owner: string;
docstatus: number;
[key: string]: any;
}
export interface DocTypeRecordsResponse {
records: DocTypeRecord[];
total_count: number;
limit: number;
offset: number;
has_more: boolean;
doctype: string;
}
export interface DashboardStats {
total_users: number;
total_customers: number;
total_items: number;
total_orders: number;
recent_activities: RecentActivity[];
}
export interface NumberCards {
total_assets: number;
work_orders_open: number;
work_orders_in_progress: number;
work_orders_completed: number;
}
interface RecentActivity {
type: string;
name: string;
title: string;
creation: string;
}
interface KycRecord {
name: string;
kyc_status: string;
kyc_type: string;
creation: string;
modified: string;
}
export interface KycDetailsResponse {
records: KycRecord[];
summary: {
total: number;
pending: number;
approved: number;
};
}
// Generic API hook
export function useApi<T>(
apiCall: () => Promise<T>,
dependencies: any[] = []
) {
const [data, setData] = useState<T | null>(null);
const [loading, setLoading] = useState<boolean>(true);
const [error, setError] = useState<string | null>(null);
const fetchData = useCallback(async () => {
try {
setLoading(true);
setError(null);
const result = await apiCall();
setData(result);
} catch (err) {
if (err instanceof ApiError) {
setError(err.message);
} else {
setError(err instanceof Error ? err.message : 'Unknown error');
}
} finally {
setLoading(false);
}
}, dependencies);
useEffect(() => {
fetchData();
}, [fetchData]);
const refetch = useCallback(() => {
fetchData();
}, [fetchData]);
return { data, loading, error, refetch };
}
// Specific API hooks
export function useUserDetails(userId?: string) {
return useApi(
() => apiService.getUserDetails(userId),
[userId]
);
}
export function useDashboardStats() {
return useApi(() => apiService.getDashboardStats());
}
export function useNumberCards() {
return useApi(() => apiService.getNumberCards());
}
export function useDashboardChart(chartName: string, filters?: Record<string, any>) {
return useApi(
() => apiService.getDashboardChartData(chartName, filters),
[chartName, JSON.stringify(filters || {})]
);
}
export function useChartsList(publicOnly: boolean = true) {
return useApi(() => apiService.listDashboardCharts(publicOnly), [publicOnly]);
}
export function useKycDetails() {
return useApi(() => apiService.getKycDetails());
}
export function useDoctypeRecords(
doctype: string,
filters?: Record<string, any>,
fields?: string[],
limit: number = 20,
offset: number = 0
) {
return useApi(
() => apiService.getDoctypeRecords(doctype, filters, fields, limit, offset),
[doctype, JSON.stringify(filters), JSON.stringify(fields), limit, offset]
);
}
// Authentication hook
export function useAuth() {
const [isAuthenticated, setIsAuthenticated] = useState<boolean>(
apiService.isAuthenticated()
);
const login = async (credentials: { email: string; password: string }) => {
try {
const response = await apiService.login(credentials);
// Check if we have any valid response data
if (response && response.message) {
// Set session ID if available
if (response.message.sid) {
apiService.setSessionId(response.message.sid);
}
setIsAuthenticated(true);
return response;
}
throw new Error('Login failed');
} catch (error) {
setIsAuthenticated(false);
throw error;
}
};
const logout = async () => {
try {
await apiService.logout();
} finally {
apiService.setSessionId('');
setIsAuthenticated(false);
}
};
return {
isAuthenticated,
login,
logout
};
}

View File

@ -0,0 +1,379 @@
import { useState, useEffect, useCallback, useRef } from 'react';
import assetService from '../services/assetService';
import type { Asset, AssetFilters, AssetFilterOptions, AssetStats, CreateAssetData } from '../services/assetService';
/**
* Merge user filters with permission filters
* Permission filters take precedence for security
*/
const mergeFilters = (
userFilters: AssetFilters | undefined,
permissionFilters: Record<string, any>
): AssetFilters => {
const merged: AssetFilters = { ...(userFilters || {}) };
// Apply permission filters (they take precedence for security)
for (const [field, value] of Object.entries(permissionFilters)) {
if (!merged[field as keyof AssetFilters]) {
// No user filter on this field, apply permission filter directly
(merged as any)[field] = value;
} else if (Array.isArray(value) && value[0] === 'in') {
// Permission filter is ["in", [...values]]
const permittedValues = value[1] as string[];
const userValue = merged[field as keyof AssetFilters];
if (typeof userValue === 'string') {
// User selected a specific value, check if it's permitted
if (!permittedValues.includes(userValue)) {
// User selected a value they don't have permission for
// Set to empty array to return no results
(merged as any)[field] = ['in', []];
}
// If permitted, keep the user's specific selection
} else if (Array.isArray(userValue) && userValue[0] === 'in') {
// Both are ["in", [...]] format, intersect them
const userValues = userValue[1] as string[];
const intersection = userValues.filter(v => permittedValues.includes(v));
(merged as any)[field] = ['in', intersection];
} else {
// Other filter types, apply permission filter
(merged as any)[field] = value;
}
}
}
return merged;
};
/**
* Hook to fetch list of assets with filters, pagination, and permission-based filtering
*/
export function useAssets(
filters?: AssetFilters,
limit: number = 20,
offset: number = 0,
orderBy?: string,
permissionFilters: Record<string, any> = {} // ← NEW: Permission filters parameter
) {
const [assets, setAssets] = useState<Asset[]>([]);
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);
// Stringify filters to prevent object reference changes from causing re-renders
const filtersJson = JSON.stringify(filters);
const permissionFiltersJson = JSON.stringify(permissionFilters); // ← NEW
useEffect(() => {
// Prevent fetching if already attempted and has error
if (hasAttemptedRef.current && error) {
return;
}
let isCancelled = false;
hasAttemptedRef.current = true;
const fetchAssets = async () => {
try {
setLoading(true);
// ✅ NEW: Merge user filters with permission filters
const mergedFilters = mergeFilters(filters, permissionFilters);
console.log('[useAssets] User filters:', filters);
console.log('[useAssets] Permission filters:', permissionFilters);
console.log('[useAssets] Merged filters:', mergedFilters);
const response = await assetService.getAssets(mergedFilters, undefined, limit, offset, orderBy);
if (!isCancelled) {
setAssets(response.assets);
setTotalCount(response.total_count);
setHasMore(response.has_more);
setError(null);
}
} catch (err) {
if (!isCancelled) {
const errorMessage = err instanceof Error ? err.message : 'Failed to fetch assets';
// Check if it's a 417 error (API not deployed)
if (errorMessage.includes('417') || errorMessage.includes('Expectation Failed') || errorMessage.includes('has no attribute')) {
setError('API endpoint not deployed or misconfigured. Please check FIX_417_ERROR.md for solutions.');
} else {
setError(errorMessage);
}
// Set empty arrays
setAssets([]);
setTotalCount(0);
setHasMore(false);
}
} finally {
if (!isCancelled) {
setLoading(false);
}
}
};
fetchAssets();
return () => {
isCancelled = true;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [filtersJson, permissionFiltersJson, limit, offset, orderBy, refetchTrigger]); // ← Added permissionFiltersJson
const refetch = useCallback(() => {
hasAttemptedRef.current = false; // Reset to allow refetch
setRefetchTrigger(prev => prev + 1);
}, []);
return { assets, totalCount, hasMore, loading, error, refetch };
}
/**
* Hook to fetch a single asset by name
*/
export function useAssetDetails(assetName: string | null) {
const [asset, setAsset] = useState<Asset | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const fetchAsset = useCallback(async () => {
if (!assetName) {
setAsset(null);
setLoading(false);
return;
}
try {
setLoading(true);
setError(null);
const data = await assetService.getAssetDetails(assetName);
setAsset(data);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to fetch asset details');
} finally {
setLoading(false);
}
}, [assetName]);
useEffect(() => {
fetchAsset();
}, [fetchAsset]);
const refetch = useCallback(() => {
fetchAsset();
}, [fetchAsset]);
return { asset, loading, error, refetch };
}
/**
* Hook to manage asset operations (create, update, delete)
*/
export function useAssetMutations() {
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const createAsset = async (assetData: CreateAssetData) => {
try {
setLoading(true);
setError(null);
console.log('[useAssetMutations] Creating asset with data:', assetData);
const response = await assetService.createAsset(assetData);
console.log('[useAssetMutations] Create asset response:', response);
if (response.success) {
return response.asset;
} else {
// Include the backend error message if available
const backendError = (response as any).error || 'Failed to create asset';
throw new Error(backendError);
}
} catch (err) {
console.error('[useAssetMutations] Create asset error:', err);
const errorMessage = err instanceof Error ? err.message : 'Failed to create asset';
setError(errorMessage);
throw err;
} finally {
setLoading(false);
}
};
const updateAsset = async (assetName: string, assetData: Partial<CreateAssetData>) => {
try {
setLoading(true);
setError(null);
console.log('[useAssetMutations] Updating asset:', assetName, 'with data:', assetData);
const response = await assetService.updateAsset(assetName, assetData);
console.log('[useAssetMutations] Update asset response:', response);
if (response.success) {
return response.asset;
} else {
// Include the backend error message if available
const backendError = (response as any).error || 'Failed to update asset';
throw new Error(backendError);
}
} catch (err) {
console.error('[useAssetMutations] Update asset error:', err);
const errorMessage = err instanceof Error ? err.message : 'Failed to update asset';
setError(errorMessage);
throw err;
} finally {
setLoading(false);
}
};
const deleteAsset = async (assetName: string) => {
try {
setLoading(true);
setError(null);
const response = await assetService.deleteAsset(assetName);
if (!response.success) {
throw new Error('Failed to delete asset');
}
return response;
} catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Failed to delete asset';
setError(errorMessage);
throw err;
} finally {
setLoading(false);
}
};
const submitAsset = async (assetName: string) => {
try {
setLoading(true);
setError(null);
console.log('[useAssetMutations] Submitting asset:', assetName);
const response = await assetService.submitAsset(assetName);
console.log('[useAssetMutations] Submit asset response:', response);
return response;
} catch (err) {
console.error('[useAssetMutations] Submit asset error:', err);
const errorMessage = err instanceof Error ? err.message : 'Failed to submit asset';
setError(errorMessage);
throw err;
} finally {
setLoading(false);
}
};
return { createAsset, updateAsset, deleteAsset, submitAsset, loading, error };
}
/**
* Hook to fetch asset filter options
*/
export function useAssetFilters() {
const [filters, setFilters] = useState<AssetFilterOptions | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const fetchFilters = useCallback(async () => {
try {
setLoading(true);
setError(null);
const data = await assetService.getAssetFilters();
setFilters(data);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to fetch filters');
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
fetchFilters();
}, [fetchFilters]);
const refetch = useCallback(() => {
fetchFilters();
}, [fetchFilters]);
return { filters, loading, error, refetch };
}
/**
* Hook to fetch asset statistics
*/
export function useAssetStats() {
const [stats, setStats] = useState<AssetStats | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const fetchStats = useCallback(async () => {
try {
setLoading(true);
setError(null);
const data = await assetService.getAssetStats();
setStats(data);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to fetch statistics');
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
fetchStats();
}, [fetchStats]);
const refetch = useCallback(() => {
fetchStats();
}, [fetchStats]);
return { stats, loading, error, refetch };
}
/**
* Hook for asset search
*/
export function useAssetSearch() {
const [results, setResults] = useState<Asset[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const search = useCallback(async (searchTerm: string, limit: number = 10) => {
if (!searchTerm.trim()) {
setResults([]);
return;
}
try {
setLoading(true);
setError(null);
const data = await assetService.searchAssets(searchTerm, limit);
setResults(data);
} catch (err) {
setError(err instanceof Error ? err.message : 'Search failed');
setResults([]);
} finally {
setLoading(false);
}
}, []);
const clearResults = useCallback(() => {
setResults([]);
setError(null);
}, []);
return { results, loading, error, search, clearResults };
}

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 };
}

View File

@ -0,0 +1,236 @@
/**
* Hook to fetch and manage DocType field configurations from Frappe
* This enables dynamic form behavior based on Frappe's Customize Form settings
*/
import { useState, useEffect, useCallback, useMemo } from 'react';
import apiService from '../services/apiService';
import { FieldConfig, evaluateFieldState, EvaluatedFieldState } from '../utils/frappeExpressionEvaluator';
interface DocTypeMeta {
name: string;
fields: FieldConfig[];
title_field?: string;
image_field?: string;
sort_field?: string;
sort_order?: string;
}
interface UseDocTypeFieldConfigResult {
fields: FieldConfig[];
loading: boolean;
error: string | null;
getFieldConfig: (fieldname: string) => FieldConfig | undefined;
getFieldState: (fieldname: string, doc: Record<string, any>) => EvaluatedFieldState;
getVisibleFields: (doc: Record<string, any>) => FieldConfig[];
getMandatoryFields: (doc: Record<string, any>) => FieldConfig[];
validateDocument: (doc: Record<string, any>) => { valid: boolean; errors: Record<string, string> };
titleField: string | null;
refresh: () => void;
}
// Cache for doctype meta to avoid repeated API calls
const metaCache: Record<string, DocTypeMeta> = {};
export function useDocTypeFieldConfig(doctype: string): UseDocTypeFieldConfigResult {
const [fields, setFields] = useState<FieldConfig[]>([]);
const [titleField, setTitleField] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const fetchMeta = useCallback(async () => {
if (!doctype) {
setLoading(false);
return;
}
// Check cache first
if (metaCache[doctype]) {
setFields(metaCache[doctype].fields);
setTitleField(metaCache[doctype].title_field || null);
setLoading(false);
return;
}
setLoading(true);
setError(null);
try {
// Fetch doctype meta from Frappe
const response = await apiService.apiCall<any>(
`/api/method/frappe.client.get_doc?doctype=DocType&name=${encodeURIComponent(doctype)}`,
{ credentials: 'include' }
);
if (response?.message) {
const meta = response.message;
const fieldConfigs: FieldConfig[] = (meta.fields || []).map((f: any) => ({
fieldname: f.fieldname,
label: f.label,
fieldtype: f.fieldtype,
options: f.options,
reqd: f.reqd,
hidden: f.hidden,
read_only: f.read_only,
depends_on: f.depends_on,
mandatory_depends_on: f.mandatory_depends_on,
read_only_depends_on: f.read_only_depends_on,
fetch_from: f.fetch_from,
fetch_if_empty: f.fetch_if_empty,
default: f.default,
description: f.description,
in_list_view: f.in_list_view,
in_standard_filter: f.in_standard_filter,
permlevel: f.permlevel,
allow_on_submit: f.allow_on_submit,
}));
// Cache the result
metaCache[doctype] = {
name: doctype,
fields: fieldConfigs,
title_field: meta.title_field,
image_field: meta.image_field,
sort_field: meta.sort_field,
sort_order: meta.sort_order,
};
setFields(fieldConfigs);
setTitleField(meta.title_field || null);
}
} catch (err: any) {
console.error(`Failed to fetch DocType meta for ${doctype}:`, err);
setError(err.message || 'Failed to fetch field configuration');
// Try alternative API endpoint (for customized forms)
try {
const customResponse = await apiService.apiCall<any>(
`/api/resource/Customize Form?filters=[["doc_type","=","${doctype}"]]&limit=1`,
{ credentials: 'include' }
);
if (customResponse?.data?.[0]) {
// Fetch the full customize form document
const customDoc = await apiService.apiCall<any>(
`/api/resource/Customize Form/${encodeURIComponent(customResponse.data[0].name)}`,
{ credentials: 'include' }
);
if (customDoc?.data?.fields) {
const fieldConfigs: FieldConfig[] = customDoc.data.fields.map((f: any) => ({
fieldname: f.fieldname,
label: f.label,
fieldtype: f.fieldtype,
options: f.options,
reqd: f.reqd,
hidden: f.hidden,
read_only: f.read_only,
depends_on: f.depends_on,
mandatory_depends_on: f.mandatory_depends_on,
read_only_depends_on: f.read_only_depends_on,
fetch_from: f.fetch_from,
fetch_if_empty: f.fetch_if_empty,
default: f.default,
description: f.description,
in_list_view: f.in_list_view,
in_standard_filter: f.in_standard_filter,
permlevel: f.permlevel,
allow_on_submit: f.allow_on_submit,
}));
metaCache[doctype] = {
name: doctype,
fields: fieldConfigs,
title_field: customDoc.data.title_field,
};
setFields(fieldConfigs);
setTitleField(customDoc.data.title_field || null);
setError(null);
}
}
} catch (customErr) {
console.warn('Customize Form fetch also failed:', customErr);
}
} finally {
setLoading(false);
}
}, [doctype]);
useEffect(() => {
fetchMeta();
}, [fetchMeta]);
// Get config for a specific field
const getFieldConfig = useCallback((fieldname: string): FieldConfig | undefined => {
return fields.find(f => f.fieldname === fieldname);
}, [fields]);
// Get evaluated state for a field based on current document
const getFieldState = useCallback((fieldname: string, doc: Record<string, any>): EvaluatedFieldState => {
const config = getFieldConfig(fieldname);
if (!config) {
return { isVisible: true, isReadOnly: false, isMandatory: false };
}
return evaluateFieldState(config, doc);
}, [getFieldConfig]);
// Get all visible fields for current document state
const getVisibleFields = useCallback((doc: Record<string, any>): FieldConfig[] => {
return fields.filter(field => {
const state = evaluateFieldState(field, doc);
return state.isVisible;
});
}, [fields]);
// Get all mandatory fields for current document state
const getMandatoryFields = useCallback((doc: Record<string, any>): FieldConfig[] => {
return fields.filter(field => {
const state = evaluateFieldState(field, doc);
return state.isVisible && state.isMandatory;
});
}, [fields]);
// Validate document against field requirements
const validateDocument = useCallback((doc: Record<string, any>): { valid: boolean; errors: Record<string, string> } => {
const errors: Record<string, string> = {};
for (const field of fields) {
const state = evaluateFieldState(field, doc);
if (state.isVisible && state.isMandatory) {
const value = doc[field.fieldname];
if (value === undefined || value === null || value === '') {
errors[field.fieldname] = `${field.label || field.fieldname} is required`;
}
}
}
return {
valid: Object.keys(errors).length === 0,
errors
};
}, [fields]);
const refresh = useCallback(() => {
// Clear cache for this doctype
delete metaCache[doctype];
fetchMeta();
}, [doctype, fetchMeta]);
return {
fields,
loading,
error,
getFieldConfig,
getFieldState,
getVisibleFields,
getMandatoryFields,
validateDocument,
titleField,
refresh
};
}
export default useDocTypeFieldConfig;

View File

@ -0,0 +1,77 @@
import { useState, useEffect } from 'react';
import apiService from '../services/apiService';
export interface DocTypeField {
fieldname: string;
fieldtype: string;
label: string;
allow_on_submit: number; // 0 or 1
reqd: number; // 0 or 1 for required
read_only: number; // 0 or 1
}
export const useDocTypeMeta = (doctype: string) => {
const [fields, setFields] = useState<DocTypeField[]>([]);
const [allowOnSubmitFields, setAllowOnSubmitFields] = useState<Set<string>>(new Set());
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
const fetchDocTypeMeta = async () => {
if (!doctype) {
setLoading(false);
return;
}
try {
setLoading(true);
const response = await apiService.apiCall<any>(
`/api/resource/DocType/${doctype}`
);
// Handle different response structures from Frappe API
// Response can be: { data: {...} } or directly {...}
const docTypeData = response.data || response;
const fieldsList: DocTypeField[] = docTypeData.fields || [];
// Extract fields that allow editing on submit
const allowOnSubmitSet = new Set<string>();
fieldsList.forEach((field: DocTypeField) => {
// Check both number (1) and boolean (true) formats
// if (field.allow_on_submit === 1 || field.allow_on_submit === true) {
if (field.allow_on_submit === 1){
allowOnSubmitSet.add(field.fieldname);
}
});
// Debug logging (development only)
if (import.meta.env.DEV) {
console.log(`[DocTypeMeta] Loaded ${fieldsList.length} fields for ${doctype}`);
console.log(`[DocTypeMeta] Fields with allow_on_submit:`, Array.from(allowOnSubmitSet));
}
setFields(fieldsList);
setAllowOnSubmitFields(allowOnSubmitSet);
setError(null);
} catch (err) {
console.error(`[DocTypeMeta] Error fetching DocType meta for ${doctype}:`, err);
setError(err instanceof Error ? err.message : 'Unknown error');
// Don't block the UI if metadata fetch fails - allow all fields to be editable
// This is a graceful degradation
setFields([]);
setAllowOnSubmitFields(new Set());
} finally {
setLoading(false);
}
};
fetchDocTypeMeta();
}, [doctype]);
const isAllowedOnSubmit = (fieldname: string): boolean => {
return allowOnSubmitFields.has(fieldname);
};
return { fields, allowOnSubmitFields, isAllowedOnSubmit, loading, error };
};

View File

@ -0,0 +1,231 @@
/**
* useFrappeFieldBehavior Hook
*
* Integrates with existing forms to provide Frappe's dynamic field behavior:
* - depends_on (conditional visibility)
* - mandatory_depends_on (conditional mandatory)
* - read_only_depends_on (conditional read-only)
* - fetch_from (auto-fetch values)
*
* Usage:
* const { getFieldState, shouldShowField, isMandatory, isReadOnly, processFieldValue } = useFrappeFieldBehavior('Asset', doc);
*/
import { useState, useEffect, useCallback, useMemo } from 'react';
import apiService from '../services/apiService';
import { FieldConfig, evaluateFrappeExpression, parseFetchFrom } from '../utils/frappeExpressionEvaluator';
interface FieldBehaviorState {
isVisible: boolean;
isReadOnly: boolean;
isMandatory: boolean;
}
interface UseFrappeFieldBehaviorResult {
loading: boolean;
error: string | null;
fields: FieldConfig[];
getFieldState: (fieldname: string) => FieldBehaviorState;
shouldShowField: (fieldname: string) => boolean;
isMandatory: (fieldname: string) => boolean;
isReadOnly: (fieldname: string) => boolean;
getFieldLabel: (fieldname: string) => string;
getFieldOptions: (fieldname: string) => string[];
getFetchFromValue: (fieldname: string, linkedDoc: Record<string, any> | null) => any;
validateMandatory: () => { valid: boolean; errors: Record<string, string> };
}
// Cache for doctype fields
const fieldCache: Record<string, FieldConfig[]> = {};
export function useFrappeFieldBehavior(
doctype: string,
doc: Record<string, any>
): UseFrappeFieldBehaviorResult {
const [fields, setFields] = useState<FieldConfig[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
// Fetch doctype field configuration
useEffect(() => {
if (!doctype) {
setLoading(false);
return;
}
// Check cache
if (fieldCache[doctype]) {
setFields(fieldCache[doctype]);
setLoading(false);
return;
}
const fetchFields = async () => {
setLoading(true);
try {
// Try to fetch from DocType
const response = await apiService.apiCall<any>(
`/api/method/frappe.client.get_doc?doctype=DocType&name=${encodeURIComponent(doctype)}`,
{ credentials: 'include' }
);
if (response?.message?.fields) {
const fieldConfigs: FieldConfig[] = response.message.fields.map((f: any) => ({
fieldname: f.fieldname,
label: f.label,
fieldtype: f.fieldtype,
options: f.options,
reqd: f.reqd,
hidden: f.hidden,
read_only: f.read_only,
depends_on: f.depends_on,
mandatory_depends_on: f.mandatory_depends_on,
read_only_depends_on: f.read_only_depends_on,
fetch_from: f.fetch_from,
fetch_if_empty: f.fetch_if_empty,
default: f.default,
description: f.description,
in_list_view: f.in_list_view,
permlevel: f.permlevel,
allow_on_submit: f.allow_on_submit,
}));
fieldCache[doctype] = fieldConfigs;
setFields(fieldConfigs);
}
} catch (err: any) {
console.warn(`Could not fetch DocType meta for ${doctype}:`, err.message);
setError(err.message);
} finally {
setLoading(false);
}
};
fetchFields();
}, [doctype]);
// Create a map for quick field lookup
const fieldMap = useMemo(() => {
const map: Record<string, FieldConfig> = {};
fields.forEach(f => {
map[f.fieldname] = f;
});
return map;
}, [fields]);
// Get complete field state
const getFieldState = useCallback((fieldname: string): FieldBehaviorState => {
const config = fieldMap[fieldname];
if (!config) {
// Field not found in config - return defaults
return { isVisible: true, isReadOnly: false, isMandatory: false };
}
// Base visibility
let isVisible = !(config.hidden === 1 || config.hidden === true);
// Evaluate depends_on
if (config.depends_on && isVisible) {
isVisible = evaluateFrappeExpression(config.depends_on, doc);
}
// Base read-only
let isReadOnly = config.read_only === 1 || config.read_only === true;
// Evaluate read_only_depends_on
if (config.read_only_depends_on) {
isReadOnly = isReadOnly || evaluateFrappeExpression(config.read_only_depends_on, doc);
}
// Base mandatory
let isMandatory = config.reqd === 1 || config.reqd === true;
// Evaluate mandatory_depends_on
if (config.mandatory_depends_on) {
isMandatory = isMandatory || evaluateFrappeExpression(config.mandatory_depends_on, doc);
}
return { isVisible, isReadOnly, isMandatory };
}, [fieldMap, doc]);
// Convenience methods
const shouldShowField = useCallback((fieldname: string): boolean => {
return getFieldState(fieldname).isVisible;
}, [getFieldState]);
const isMandatory = useCallback((fieldname: string): boolean => {
const state = getFieldState(fieldname);
return state.isVisible && state.isMandatory;
}, [getFieldState]);
const isReadOnly = useCallback((fieldname: string): boolean => {
return getFieldState(fieldname).isReadOnly;
}, [getFieldState]);
const getFieldLabel = useCallback((fieldname: string): string => {
const config = fieldMap[fieldname];
return config?.label || fieldname;
}, [fieldMap]);
const getFieldOptions = useCallback((fieldname: string): string[] => {
const config = fieldMap[fieldname];
if (!config?.options) return [];
if (config.fieldtype === 'Select') {
return config.options.split('\n').filter(opt => opt.trim() !== '');
}
return [];
}, [fieldMap]);
// Get value from linked document based on fetch_from
const getFetchFromValue = useCallback((fieldname: string, linkedDoc: Record<string, any> | null): any => {
const config = fieldMap[fieldname];
if (!config?.fetch_from || !linkedDoc) return undefined;
const parsed = parseFetchFrom(config.fetch_from);
if (!parsed) return undefined;
// The linkedDoc should have the target field
return linkedDoc[parsed.targetField];
}, [fieldMap]);
// Validate all mandatory fields
const validateMandatory = useCallback((): { valid: boolean; errors: Record<string, string> } => {
const errors: Record<string, string> = {};
fields.forEach(field => {
const state = getFieldState(field.fieldname);
if (state.isVisible && state.isMandatory) {
const value = doc[field.fieldname];
if (value === undefined || value === null || value === '') {
errors[field.fieldname] = `${field.label || field.fieldname} is required`;
}
}
});
return {
valid: Object.keys(errors).length === 0,
errors
};
}, [fields, doc, getFieldState]);
return {
loading,
error,
fields,
getFieldState,
shouldShowField,
isMandatory,
isReadOnly,
getFieldLabel,
getFieldOptions,
getFetchFromValue,
validateMandatory
};
}
export default useFrappeFieldBehavior;

View File

@ -0,0 +1,195 @@
import { useState, useEffect, useCallback, useRef } from 'react';
import itemService from '../services/itemService';
import type { Item, CreateItemData } from '../services/itemService';
export interface ItemFilters {
item_code?: string;
item_name?: string;
item_group?: string;
custom_hospital_name?: string;
disabled?: number;
is_stock_item?: number;
[key: string]: any;
}
/**
* Hook to fetch list of items with filters and pagination
*/
export function useItems(
filters?: ItemFilters,
limit: number = 20,
offset: number = 0,
orderBy?: string
) {
const [items, setItems] = useState<Item[]>([]);
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 fetchItems = async () => {
try {
setLoading(true);
const fields = ['name', 'item_code', 'item_name', 'item_group', 'stock_uom', 'disabled', 'is_stock_item','custom_hospital_name', 'opening_stock', 'valuation_rate', 'standard_rate', 'creation', 'modified', 'owner', 'docstatus'];
const response = await itemService.getItems(filters, fields, limit, offset);
if (!isCancelled) {
setItems(response.data);
setTotalCount(response.total);
setHasMore(response.data.length === limit);
setError(null);
}
} catch (err) {
if (!isCancelled) {
const errorMessage = err instanceof Error ? err.message : 'Failed to fetch items';
setError(errorMessage);
setItems([]);
setTotalCount(0);
setHasMore(false);
}
} finally {
if (!isCancelled) {
setLoading(false);
}
}
};
fetchItems();
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 { items, totalCount, hasMore, loading, error, refetch };
}
/**
* Hook to fetch a single item by name
*/
export function useItemDetails(itemName: string | null) {
const [item, setItem] = useState<Item | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const fetchItem = useCallback(async () => {
if (!itemName) {
setItem(null);
setLoading(false);
return;
}
try {
setLoading(true);
setError(null);
const data = await itemService.getItem(itemName);
setItem(data);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to fetch item details');
} finally {
setLoading(false);
}
}, [itemName]);
useEffect(() => {
fetchItem();
}, [fetchItem]);
const refetch = useCallback(() => {
fetchItem();
}, [fetchItem]);
return { item, loading, error, refetch };
}
/**
* Hook to manage item operations (create, update, delete)
*/
export function useItemMutations() {
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const createItem = useCallback(async (data: CreateItemData) => {
try {
setLoading(true);
setError(null);
const result = await itemService.createItem(data);
return result;
} catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Failed to create item';
setError(errorMessage);
throw err;
} finally {
setLoading(false);
}
}, []);
const updateItem = useCallback(async (itemName: string, data: Partial<CreateItemData>) => {
try {
setLoading(true);
setError(null);
const result = await itemService.updateItem(itemName, data);
return result;
} catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Failed to update item';
setError(errorMessage);
throw err;
} finally {
setLoading(false);
}
}, []);
const deleteItem = useCallback(async (itemName: string) => {
try {
setLoading(true);
setError(null);
await itemService.deleteItem(itemName);
} catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Failed to delete item';
setError(errorMessage);
throw err;
} finally {
setLoading(false);
}
}, []);
const submitItem = useCallback(async (itemName: string) => {
try {
setLoading(true);
setError(null);
const result = await itemService.submitItem(itemName);
return result;
} catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Failed to submit item';
setError(errorMessage);
throw err;
} finally {
setLoading(false);
}
}, []);
return { createItem, updateItem, deleteItem, submitItem, loading, error };
}

View File

@ -0,0 +1,79 @@
import { useState, useEffect, useCallback } from 'react';
import notificationService, { Notification } from '../services/notificationService';
export function useNotifications() {
const [notifications, setNotifications] = useState<Notification[]>([]);
const [unreadCount, setUnreadCount] = useState(0);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const fetchNotifications = useCallback(async () => {
try {
setLoading(true);
setError(null);
const data = await notificationService.getNotifications();
setNotifications(data);
setUnreadCount(data.filter(n => !n.read).length);
} catch (err: any) {
// Silently handle 417 errors (API not available)
if (err?.message?.includes('417') || err?.message?.includes('EXPECTATION FAILED')) {
setNotifications([]);
setUnreadCount(0);
setError(null); // Don't show error for unavailable API
} else {
const errorMessage = err instanceof Error ? err.message : 'Failed to fetch notifications';
setError(errorMessage);
console.warn('Error fetching notifications:', err);
}
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
fetchNotifications();
// Poll for new notifications every 30 seconds
const interval = setInterval(fetchNotifications, 30000);
return () => clearInterval(interval);
}, [fetchNotifications]);
const markAsRead = useCallback(async (notificationName: string) => {
try {
await notificationService.markAsRead(notificationName);
setNotifications(prev =>
prev.map(n => n.name === notificationName ? { ...n, read: 1 } : n)
);
setUnreadCount(prev => Math.max(0, prev - 1));
} catch (error) {
console.error('Error marking notification as read:', error);
throw error;
}
}, []);
const markAllAsRead = useCallback(async () => {
try {
await notificationService.markAllAsRead();
setNotifications(prev =>
prev.map(n => ({ ...n, read: 1 }))
);
setUnreadCount(0);
} catch (error) {
console.error('Error marking all notifications as read:', error);
throw error;
}
}, []);
return {
notifications,
unreadCount,
loading,
error,
markAsRead,
markAllAsRead,
refetch: fetchNotifications
};
}

View File

@ -0,0 +1,482 @@
import { useState, useEffect, useCallback } from 'react';
import apiService from '../services/apiService';
// Types for PM Schedule Generator
export interface PMEntryLine {
name?: string;
asset: string;
asset_name: string;
start_date: string;
end_date: string;
manufacturer?: string;
model?: string;
idx?: number;
}
export interface PMSchedule {
name: string;
owner?: string;
creation?: string;
modified?: string;
modified_by?: string;
docstatus?: number;
hospital?: string;
modality?: string;
device_status?: string;
start_date?: string;
end_date?: string;
maintenance_team?: string;
maintenance_manager?: string;
periodicity?: string;
assign_to?: string;
due_date?: string;
pm_for?: string; // PM Name field
maintenance_entries?: PMEntryLine[];
doctype?: string;
[key: string]: any; // Allow additional fields
}
export interface CreatePMScheduleData {
hospital: string;
modality?: string;
device_status?: string;
start_date: string;
end_date: string;
maintenance_team?: string;
maintenance_manager?: string;
periodicity: string;
assign_to?: string;
due_date?: string;
maintenance_entries?: PMEntryLine[];
}
// Hook for fetching PM Schedules list
export function usePMSchedules(
filters: Record<string, any> = {},
limit: number = 20,
offset: number = 0,
orderBy: string = 'creation desc',
permissionFilters: Record<string, any> = {}
) {
const [pmSchedules, setPMSchedules] = useState<PMSchedule[]>([]);
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);
// Stringify filters to prevent object reference changes from causing re-renders
const filtersJson = JSON.stringify(filters);
const permissionFiltersJson = JSON.stringify(permissionFilters);
useEffect(() => {
let isCancelled = false;
// Capture values at effect execution time
const currentFiltersJson = filtersJson;
const currentPermissionFiltersJson = permissionFiltersJson;
const currentLimit = limit;
const currentOffset = offset;
const currentOrderBy = orderBy;
const fetchPMSchedules = async () => {
try {
setLoading(true);
setError(null);
// Parse filters from JSON strings to avoid closure issues
let currentFilters: Record<string, any> = {};
let currentPermissionFilters: Record<string, any> = {};
try {
currentFilters = currentFiltersJson ? JSON.parse(currentFiltersJson) : {};
} catch (e) {
currentFilters = {};
}
try {
currentPermissionFilters = currentPermissionFiltersJson ? JSON.parse(currentPermissionFiltersJson) : {};
} catch (e) {
currentPermissionFilters = {};
}
// Merge filters with permission filters
const combinedFilters = { ...currentFilters, ...currentPermissionFilters };
const response = await apiService.apiCall<any>(
'/api/method/asset_lite.api.ppm_generator_api.get_pm_schedules',
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
filters: JSON.stringify(combinedFilters),
limit: currentLimit,
offset: currentOffset,
order_by: currentOrderBy,
include_child_tables: true,
fields: JSON.stringify(['name', 'pm_for', 'hospital', 'modality', 'periodicity', 'start_date', 'end_date', 'due_date']) // Explicitly request pm_for
})
}
);
if (!isCancelled) {
// Handle both response formats: {message: {...}} or direct {...}
const data = response?.message || response;
if (data && data.pm_schedules) {
const schedules = data.pm_schedules || [];
console.log('[usePMSchedules] Loaded', schedules.length, 'PM Schedules');
// Debug: Log first schedule to see available fields - ALWAYS log in dev
if (schedules.length > 0) {
const firstSchedule = schedules[0];
console.log('[usePMSchedules] 🔍 FIRST SCHEDULE FIELDS:', {
name: firstSchedule.name,
pm_for: firstSchedule.pm_for,
'pm_for (bracket)': firstSchedule['pm_for'],
allKeys: Object.keys(firstSchedule),
allKeysList: Object.keys(firstSchedule).join(', '),
fullObject: firstSchedule
});
}
setPMSchedules(schedules);
setTotalCount(data.total_count || 0);
setHasMore(data.has_more || false);
} else {
console.warn('[usePMSchedules] No pm_schedules in response:', response);
setPMSchedules([]);
setTotalCount(0);
setHasMore(false);
}
}
} catch (err) {
if (!isCancelled) {
console.error('Error fetching PM Schedules:', err);
setError(err instanceof Error ? err.message : 'Failed to fetch PM Schedules');
setPMSchedules([]);
setTotalCount(0);
}
} finally {
if (!isCancelled) {
setLoading(false);
}
}
};
fetchPMSchedules();
return () => {
isCancelled = true;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [filtersJson, permissionFiltersJson, limit, offset, orderBy, refetchTrigger]);
const refetch = useCallback(() => {
setRefetchTrigger(prev => prev + 1);
}, []);
return {
pmSchedules,
totalCount,
hasMore,
loading,
error,
refetch
};
}
// Hook for fetching single PM Schedule details
export function usePMScheduleDetails(pmScheduleName: string | null) {
const [pmSchedule, setPMSchedule] = useState<PMSchedule | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const fetchPMSchedule = useCallback(async () => {
if (!pmScheduleName) {
setPMSchedule(null);
setLoading(false);
return;
}
setLoading(true);
setError(null);
try {
const response = await apiService.apiCall<any>(
'/api/method/asset_lite.api.ppm_generator_api.get_pm_schedule_details',
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ pm_schedule_name: pmScheduleName })
}
);
console.log('[usePMScheduleDetails] API Response:', response);
// apiService.apiCall already unwraps the 'message' property
// So response is directly the PM Schedule data OR an error object
if (response && response.name && !response.error) {
console.log('[usePMScheduleDetails] Setting PM Schedule:', response);
setPMSchedule(response);
} else {
const errorMsg = response?.error || 'PM Schedule not found';
console.warn('[usePMScheduleDetails] Error or not found:', errorMsg);
setError(errorMsg);
setPMSchedule(null);
}
} catch (err) {
console.error('Error fetching PM Schedule details:', err);
setError(err instanceof Error ? err.message : 'Failed to fetch PM Schedule');
setPMSchedule(null);
} finally {
setLoading(false);
}
}, [pmScheduleName]);
useEffect(() => {
fetchPMSchedule();
}, [fetchPMSchedule]);
return {
pmSchedule,
loading,
error,
refetch: fetchPMSchedule
};
}
// Hook for PM Schedule mutations (create, update, delete, submit, cancel)
export function usePMScheduleMutations() {
const [loading, setLoading] = useState(false);
const createPMSchedule = async (data: CreatePMScheduleData): Promise<PMSchedule> => {
setLoading(true);
try {
const response = await apiService.apiCall<any>(
'/api/method/asset_lite.api.ppm_generator_api.create_pm_schedule',
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ pm_schedule_data: JSON.stringify(data) })
}
);
// apiService.apiCall already unwraps the 'message' property
if (response?.success) {
return response.pm_schedule;
} else {
throw new Error(response?.error || 'Failed to create PM Schedule');
}
} finally {
setLoading(false);
}
};
const updatePMSchedule = async (name: string, data: Partial<CreatePMScheduleData>): Promise<PMSchedule> => {
setLoading(true);
try {
const response = await apiService.apiCall<any>(
'/api/method/asset_lite.api.ppm_generator_api.update_pm_schedule',
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
pm_schedule_name: name,
pm_schedule_data: JSON.stringify(data)
})
}
);
if (response?.success) {
return response.pm_schedule;
} else {
throw new Error(response?.error || 'Failed to update PM Schedule');
}
} finally {
setLoading(false);
}
};
const deletePMSchedule = async (name: string): Promise<void> => {
setLoading(true);
try {
const response = await apiService.apiCall<any>(
'/api/method/asset_lite.api.ppm_generator_api.delete_pm_schedule',
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ pm_schedule_name: name })
}
);
if (!response?.success) {
throw new Error(response?.error || 'Failed to delete PM Schedule');
}
} finally {
setLoading(false);
}
};
const submitPMSchedule = async (name: string): Promise<PMSchedule> => {
setLoading(true);
try {
const response = await apiService.apiCall<any>(
'/api/method/asset_lite.api.ppm_generator_api.submit_pm_schedule',
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ pm_schedule_name: name })
}
);
if (response?.success) {
return response.pm_schedule;
} else {
throw new Error(response?.error || 'Failed to submit PM Schedule');
}
} finally {
setLoading(false);
}
};
const cancelPMSchedule = async (name: string): Promise<PMSchedule> => {
setLoading(true);
try {
const response = await apiService.apiCall<any>(
'/api/method/asset_lite.api.ppm_generator_api.cancel_pm_schedule',
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ pm_schedule_name: name })
}
);
if (response?.success) {
return response.pm_schedule;
} else {
throw new Error(response?.error || 'Failed to cancel PM Schedule');
}
} finally {
setLoading(false);
}
};
const addMaintenanceEntry = async (pmScheduleName: string, entryData: Partial<PMEntryLine>): Promise<PMEntryLine[]> => {
setLoading(true);
try {
const response = await apiService.apiCall<any>(
'/api/method/asset_lite.api.ppm_generator_api.add_maintenance_entry',
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
pm_schedule_name: pmScheduleName,
entry_data: JSON.stringify(entryData)
})
}
);
if (response?.success) {
return response.maintenance_entries;
} else {
throw new Error(response?.error || 'Failed to add maintenance entry');
}
} finally {
setLoading(false);
}
};
const removeMaintenanceEntry = async (pmScheduleName: string, entryName: string): Promise<PMEntryLine[]> => {
setLoading(true);
try {
const response = await apiService.apiCall<any>(
'/api/method/asset_lite.api.ppm_generator_api.remove_maintenance_entry',
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
pm_schedule_name: pmScheduleName,
entry_name: entryName
})
}
);
if (response?.success) {
return response.maintenance_entries;
} else {
throw new Error(response?.error || 'Failed to remove maintenance entry');
}
} finally {
setLoading(false);
}
};
const updateMaintenanceEntry = async (
pmScheduleName: string,
entryName: string,
entryData: Partial<PMEntryLine>
): Promise<PMEntryLine[]> => {
setLoading(true);
try {
const response = await apiService.apiCall<any>(
'/api/method/asset_lite.api.ppm_generator_api.update_maintenance_entry',
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
pm_schedule_name: pmScheduleName,
entry_name: entryName,
entry_data: JSON.stringify(entryData)
})
}
);
if (response?.success) {
return response.maintenance_entries;
} else {
throw new Error(response?.error || 'Failed to update maintenance entry');
}
} finally {
setLoading(false);
}
};
return {
createPMSchedule,
updatePMSchedule,
deletePMSchedule,
submitPMSchedule,
cancelPMSchedule,
addMaintenanceEntry,
removeMaintenanceEntry,
updateMaintenanceEntry,
loading
};
}
export default {
usePMSchedules,
usePMScheduleDetails,
usePMScheduleMutations
};

View File

@ -0,0 +1,85 @@
import { useState, useEffect, useCallback } from 'react';
import apiService from '../services/apiService';
export interface PMScheduleGenerator {
name: string;
creation?: string;
modified?: string;
modified_by?: string;
owner?: string;
docstatus?: number;
[key: string]: any;
}
export function usePMScheduleGenerators(
filters: Record<string, any> = {},
limit: number = 1000,
offset: number = 0,
orderBy: string = 'creation desc'
) {
const [pmSchedules, setPMSchedules] = useState<PMScheduleGenerator[]>([]);
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);
// Stringify filters to prevent object reference changes from causing re-renders
const filtersJson = JSON.stringify(filters);
useEffect(() => {
let isCancelled = false;
const fetchPMSchedules = async () => {
try {
setLoading(true);
setError(null);
const response = await apiService.getDoctypeRecords(
'PM Schedule Generator',
filters,
['name', 'creation', 'modified', 'docstatus', 'pm_schedule_name'],
limit,
offset
);
if (!isCancelled) {
setPMSchedules(response.records || []);
setTotalCount(response.total_count || 0);
setHasMore(response.has_more || false);
}
} catch (err) {
if (!isCancelled) {
console.error('Error fetching PM Schedule Generators:', err);
setError(err instanceof Error ? err.message : 'Failed to fetch PM Schedule Generators');
setPMSchedules([]);
setTotalCount(0);
}
} finally {
if (!isCancelled) {
setLoading(false);
}
}
};
fetchPMSchedules();
return () => {
isCancelled = true;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [filtersJson, limit, offset, orderBy, refetchTrigger]);
const refetch = useCallback(() => {
setRefetchTrigger(prev => prev + 1);
}, []);
return {
pmSchedules,
totalCount,
hasMore,
loading,
error,
refetch
};
}

174
asm_app/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 };
}

View File

@ -0,0 +1,188 @@
import { useState, useEffect, useCallback, useMemo } from 'react';
import apiService from '../services/apiService';
interface RestrictionInfo {
field: string;
values: string[];
count: number;
}
interface PermissionsState {
isAdmin: boolean;
restrictions: Record<string, RestrictionInfo>;
permissionFilters: Record<string, any>;
targetDoctype: string;
loading: boolean;
error: string | null;
}
/**
* Generic hook for user permissions - works with ANY doctype
*
* Usage:
* const { permissionFilters, restrictions } = useUserPermissions('Asset');
* const { permissionFilters, restrictions } = useUserPermissions('Work Order');
* const { permissionFilters, restrictions } = useUserPermissions('Project');
*/
export const useUserPermissions = (targetDoctype: string = 'Asset') => {
const [state, setState] = useState<PermissionsState>({
isAdmin: false,
restrictions: {},
permissionFilters: {},
targetDoctype,
loading: true,
error: null
});
const fetchPermissions = useCallback(async (doctype?: string) => {
const dt = doctype || targetDoctype;
try {
setState(prev => ({ ...prev, loading: true, error: null, targetDoctype: dt }));
const response = await apiService.getPermissionFilters(dt);
setState({
isAdmin: response.is_admin,
restrictions: response.restrictions || {},
permissionFilters: response.filters || {},
targetDoctype: dt,
loading: false,
error: null
});
return response;
} catch (err) {
console.error(`Error fetching permissions for ${dt}:`, err);
setState(prev => ({
...prev,
loading: false,
error: err instanceof Error ? err.message : 'Failed to fetch permissions'
}));
return null;
}
}, [targetDoctype]);
useEffect(() => {
fetchPermissions();
}, [fetchPermissions]);
// Get allowed values for a permission type (e.g., "Company", "Location")
const getAllowedValues = useCallback((permissionType: string): string[] => {
return state.restrictions[permissionType]?.values || [];
}, [state.restrictions]);
// Check if user has restriction on a permission type
const hasRestriction = useCallback((permissionType: string): boolean => {
if (state.isAdmin) return false;
return !!state.restrictions[permissionType];
}, [state.isAdmin, state.restrictions]);
// Check if any restrictions exist
const hasAnyRestrictions = useMemo(() => {
return !state.isAdmin && Object.keys(state.restrictions).length > 0;
}, [state.isAdmin, state.restrictions]);
// Merge user filters with permission filters
const mergeFilters = useCallback((userFilters: Record<string, any>): Record<string, any> => {
if (state.isAdmin) return userFilters;
const merged = { ...userFilters };
for (const [field, value] of Object.entries(state.permissionFilters)) {
if (!merged[field]) {
merged[field] = value;
} else if (Array.isArray(value) && value[0] === 'in') {
const permittedValues = value[1] as string[];
if (typeof merged[field] === 'string' && !permittedValues.includes(merged[field])) {
merged[field] = ['in', []]; // Return empty - value not permitted
}
}
}
return merged;
}, [state.isAdmin, state.permissionFilters]);
// Get summary of restrictions for display
const restrictionsList = useMemo(() => {
return Object.entries(state.restrictions).map(([type, info]) => ({
type,
field: info.field,
values: info.values,
count: info.count
}));
}, [state.restrictions]);
return {
...state,
refetch: fetchPermissions,
switchDoctype: fetchPermissions,
getAllowedValues,
hasRestriction,
hasAnyRestrictions,
mergeFilters,
restrictionsList
};
};
/**
* Hook to check access to a specific document
*/
export const useDocumentAccess = (doctype: string | null, docname: string | null) => {
const [hasAccess, setHasAccess] = useState<boolean | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (!doctype || !docname) {
setHasAccess(null);
return;
}
const check = async () => {
try {
setLoading(true);
const response = await apiService.checkDocumentAccess(doctype, docname);
setHasAccess(response.has_access);
if (!response.has_access && response.error) {
setError(response.error);
}
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to check access');
setHasAccess(false);
} finally {
setLoading(false);
}
};
check();
}, [doctype, docname]);
return { hasAccess, loading, error };
};
/**
* Hook to get user's default values
*/
export const useUserDefaults = () => {
const [defaults, setDefaults] = useState<Record<string, string>>({});
const [loading, setLoading] = useState(true);
useEffect(() => {
const fetch = async () => {
try {
const response = await apiService.getUserDefaults();
setDefaults(response.defaults || {});
} catch (err) {
console.error('Failed to fetch user defaults:', err);
} finally {
setLoading(false);
}
};
fetch();
}, []);
return { defaults, loading, getDefault: (type: string) => defaults[type] };
};
export default useUserPermissions;

View File

@ -0,0 +1,400 @@
import { useState, useEffect, useCallback, useRef } from 'react';
import workOrderService from '../services/workOrderService';
import type { WorkOrder, WorkOrderFilters, CreateWorkOrderData } from '../services/workOrderService';
/**
* Merge user filters with permission filters
* Permission filters take precedence for security
*/
const mergeFilters = (
userFilters: WorkOrderFilters | undefined,
permissionFilters: Record<string, any>
): WorkOrderFilters => {
const merged: WorkOrderFilters = { ...(userFilters || {}) };
// Apply permission filters (they take precedence for security)
for (const [field, value] of Object.entries(permissionFilters)) {
if (!merged[field as keyof WorkOrderFilters]) {
// No user filter on this field, apply permission filter directly
(merged as any)[field] = value;
} else if (Array.isArray(value) && value[0] === 'in') {
// Permission filter is ["in", [...values]]
const permittedValues = value[1] as string[];
const userValue = merged[field as keyof WorkOrderFilters];
if (typeof userValue === 'string') {
// User selected a specific value, check if it's permitted
if (!permittedValues.includes(userValue)) {
// User selected a value they don't have permission for
// Set to empty array to return no results
(merged as any)[field] = ['in', []];
}
// If permitted, keep the user's specific selection
} else if (Array.isArray(userValue) && userValue[0] === 'in') {
// Both are ["in", [...]] format, intersect them
const userValues = userValue[1] as string[];
const intersection = userValues.filter(v => permittedValues.includes(v));
(merged as any)[field] = ['in', intersection];
} else {
// Other filter types, apply permission filter
(merged as any)[field] = value;
}
}
}
return merged;
};
/**
* Hook to fetch list of work orders with filters, pagination, and permission-based filtering
*/
export function useWorkOrders(
filters?: WorkOrderFilters,
limit: number = 20,
offset: number = 0,
orderBy?: string,
permissionFilters: Record<string, any> = {} // ← NEW: Permission filters parameter
) {
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);
// Stringify filters to prevent object reference changes from causing re-renders
const filtersJson = JSON.stringify(filters);
const permissionFiltersJson = JSON.stringify(permissionFilters); // ← NEW
useEffect(() => {
// Prevent fetching if already attempted and has error
if (hasAttemptedRef.current && error) {
return;
}
let isCancelled = false;
hasAttemptedRef.current = true;
const fetchWorkOrders = async () => {
try {
setLoading(true);
// ✅ NEW: Merge user filters with permission filters
const mergedFilters = mergeFilters(filters, permissionFilters);
console.log('[useWorkOrders] User filters:', filters);
console.log('[useWorkOrders] Permission filters:', permissionFilters);
console.log('[useWorkOrders] Merged filters:', mergedFilters);
const response = await workOrderService.getWorkOrders(mergedFilters, 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';
// Check if it's a 417 error (API not deployed)
if (errorMessage.includes('417') || errorMessage.includes('Expectation Failed') || errorMessage.includes('has no attribute')) {
setError('API endpoint not deployed or misconfigured. Please check FIX_417_ERROR.md for solutions.');
} else {
setError(errorMessage);
}
// Set empty arrays
setWorkOrders([]);
setTotalCount(0);
setHasMore(false);
}
} finally {
if (!isCancelled) {
setLoading(false);
}
}
};
fetchWorkOrders();
return () => {
isCancelled = true;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [filtersJson, permissionFiltersJson, limit, offset, orderBy, refetchTrigger]); // ← Added permissionFiltersJson
const refetch = useCallback(() => {
hasAttemptedRef.current = false; // Reset to allow refetch
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 {
// Include the backend error message if available
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 {
// Include the backend error message if available
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 submitWorkOrder = async (workOrderName: string) => {
try {
setLoading(true);
setError(null);
console.log('[useWorkOrderMutations] Submitting work order:', workOrderName);
const response = await workOrderService.submitWorkOrder(workOrderName);
console.log('[useWorkOrderMutations] Submit work order response:', response);
return response;
} catch (err) {
console.error('[useWorkOrderMutations] Submit work order error:', err);
const errorMessage = err instanceof Error ? err.message : 'Failed to submit 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, submitWorkOrder, updateStatus, loading, error };
}
/**
* Hook to fetch work order filter options
*/
export function useWorkOrderFilters() {
const [filters, setFilters] = useState<any | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const fetchFilters = useCallback(async () => {
try {
setLoading(true);
setError(null);
const data = await workOrderService.getWorkOrderFilters();
setFilters(data);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to fetch filters');
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
fetchFilters();
}, [fetchFilters]);
const refetch = useCallback(() => {
fetchFilters();
}, [fetchFilters]);
return { filters, loading, error, refetch };
}
/**
* Hook to fetch work order statistics
*/
export function useWorkOrderStats() {
const [stats, setStats] = useState<any | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const fetchStats = useCallback(async () => {
try {
setLoading(true);
setError(null);
const data = await workOrderService.getWorkOrderStats();
setStats(data);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to fetch statistics');
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
fetchStats();
}, [fetchStats]);
const refetch = useCallback(() => {
fetchStats();
}, [fetchStats]);
return { stats, loading, error, refetch };
}
/**
* Hook for work order search
*/
export function useWorkOrderSearch() {
const [results, setResults] = useState<WorkOrder[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const search = useCallback(async (searchTerm: string, limit: number = 10) => {
if (!searchTerm.trim()) {
setResults([]);
return;
}
try {
setLoading(true);
setError(null);
const data = await workOrderService.searchWorkOrders(searchTerm, limit);
setResults(data);
} catch (err) {
setError(err instanceof Error ? err.message : 'Search failed');
setResults([]);
} finally {
setLoading(false);
}
}, []);
const clearResults = useCallback(() => {
setResults([]);
setError(null);
}, []);
return { results, loading, error, search, clearResults };
}

View File

@ -0,0 +1,205 @@
import { useState, useEffect, useCallback } from 'react';
import workflowService, {
type WorkflowTransition,
type WorkflowInfo,
getWorkflowStateStyle,
getActionButtonStyle,
getActionIcon
} from '../services/workflowService';
interface UseWorkflowOptions {
doctype: string;
docname: string | null;
workflowState?: string;
enabled?: boolean;
docData?: Record<string, any>; // Added: Document data for condition evaluation
}
interface UseWorkflowReturn {
// State
transitions: WorkflowTransition[];
workflowInfo: WorkflowInfo | null;
userRoles: string[];
currentUser: string;
isSystemManager: boolean;
loading: boolean;
actionLoading: boolean;
error: string | null;
canEdit: boolean;
// Actions
applyAction: (action: string, nextState?: string) => Promise<boolean>;
refreshTransitions: () => Promise<void>;
// Helpers
getStateStyle: (state: string) => { bg: string; text: string; border: string };
getButtonStyle: (action: string) => string;
getIcon: (action: string) => string;
}
export const useWorkflow = ({
doctype,
docname,
workflowState,
enabled = true,
docData, // Added: Document data for condition evaluation
}: UseWorkflowOptions): UseWorkflowReturn => {
const [transitions, setTransitions] = useState<WorkflowTransition[]>([]);
const [workflowInfo, setWorkflowInfo] = useState<WorkflowInfo | null>(null);
const [userRoles, setUserRoles] = useState<string[]>([]);
const [currentUser, setCurrentUser] = useState<string>('');
const [isSystemManagerUser, setIsSystemManagerUser] = useState(false);
const [loading, setLoading] = useState(false);
const [actionLoading, setActionLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [canEdit, setCanEdit] = useState(true);
// Fetch workflow info on mount
useEffect(() => {
if (!enabled) return;
const fetchWorkflowInfo = async () => {
try {
const info = await workflowService.getWorkflowInfo(doctype);
setWorkflowInfo(info);
} catch (err) {
console.error('Error fetching workflow info:', err);
}
};
fetchWorkflowInfo();
}, [doctype, enabled]);
// Fetch user roles, current user, and check System Manager
useEffect(() => {
if (!enabled) return;
const fetchUserInfo = async () => {
try {
const [roles, user, isSysManager] = await Promise.all([
workflowService.getCurrentUserRoles(),
workflowService.getCurrentUser(),
workflowService.isSystemManager(),
]);
setUserRoles(roles);
setCurrentUser(user);
setIsSystemManagerUser(isSysManager);
// System Manager can always edit
if (isSysManager) {
setCanEdit(true);
}
} catch (err) {
console.error('Error fetching user info:', err);
}
};
fetchUserInfo();
}, [enabled]);
// Fetch available transitions when docname, workflowState, or docData changes
const refreshTransitions = useCallback(async () => {
if (!docname || !enabled) {
setTransitions([]);
return;
}
setLoading(true);
setError(null);
try {
// Pass document data for condition evaluation
const availableTransitions = await workflowService.getWorkflowTransitions(
doctype,
docname,
workflowState,
docData // Pass document data
);
console.log('[useWorkflow] Available transitions:', availableTransitions);
setTransitions(availableTransitions);
// Check if user can edit (System Manager always can)
if (workflowState) {
const canUserEdit = await workflowService.canUserEditDocument(doctype, docname, workflowState);
setCanEdit(canUserEdit);
}
} catch (err) {
console.error('Error fetching transitions:', err);
setError('Failed to load workflow actions');
setTransitions([]);
} finally {
setLoading(false);
}
}, [doctype, docname, workflowState, enabled, docData]);
useEffect(() => {
refreshTransitions();
}, [refreshTransitions]);
// Apply workflow action
const applyAction = useCallback(async (action: string, nextState?: string): Promise<boolean> => {
if (!docname) {
setError('Document not saved yet');
return false;
}
setActionLoading(true);
setError(null);
try {
// Pass nextState for System Manager force update if needed
await workflowService.applyWorkflowAction(doctype, docname, action, nextState);
// Refresh transitions after action
await refreshTransitions();
return true;
} catch (err: any) {
console.error('Error applying workflow action:', err);
// Extract error message
let errorMessage = 'Failed to apply action';
if (err.message) {
errorMessage = err.message;
} else if (err._server_messages) {
try {
const serverMessages = JSON.parse(err._server_messages);
errorMessage = serverMessages.map((m: string) => {
try {
return JSON.parse(m).message;
} catch {
return m;
}
}).join('\n');
} catch {
errorMessage = err._server_messages;
}
}
setError(errorMessage);
return false;
} finally {
setActionLoading(false);
}
}, [doctype, docname, refreshTransitions]);
return {
transitions,
workflowInfo,
userRoles,
currentUser,
isSystemManager: isSystemManagerUser,
loading,
actionLoading,
error,
canEdit,
applyAction,
refreshTransitions,
getStateStyle: getWorkflowStateStyle,
getButtonStyle: getActionButtonStyle,
getIcon: getActionIcon,
};
};
export default useWorkflow;

70
asm_app/src/i18n.ts Normal file
View File

@ -0,0 +1,70 @@
import i18n from 'i18next';
import { initReactI18next } from 'react-i18next';
import LanguageDetector from 'i18next-browser-languagedetector';
import enTranslation from './locales/en/translation.json';
import arTranslation from './locales/ar/translation.json';
import { getFrappeTranslations } from './services/translationService';
// Initialize i18n with static translations first (fallback)
i18n
.use(LanguageDetector)
.use(initReactI18next)
.init({
resources: {
en: {
translation: enTranslation
},
ar: {
translation: arTranslation
}
},
fallbackLng: 'en',
defaultNS: 'translation',
interpolation: {
escapeValue: false
},
detection: {
order: ['localStorage', 'navigator'],
caches: ['localStorage']
}
});
// Load translations from Frappe and merge with static translations
export async function loadFrappeTranslations() {
try {
// Only load translations if user is logged in (to avoid 403 errors)
const user = localStorage.getItem('user');
if (!user) {
// User not logged in yet, skip loading translations from Frappe
// They will be loaded after login
return;
}
// Load English translations from Frappe
const enFrappeTranslations = await getFrappeTranslations('en');
if (Object.keys(enFrappeTranslations).length > 0) {
i18n.addResourceBundle('en', 'translation', enFrappeTranslations, true, true);
}
// Load Arabic translations from Frappe
const arFrappeTranslations = await getFrappeTranslations('ar');
if (Object.keys(arFrappeTranslations).length > 0) {
i18n.addResourceBundle('ar', 'translation', arFrappeTranslations, true, true);
}
console.log('✓ Translations loaded from Frappe');
} catch (error) {
// Silently fail - will use static translations
console.warn('⚠ Could not load translations from Frappe, using static translations:', error);
}
}
// Auto-load translations when i18n is ready (only if user is logged in)
i18n.on('initialized', () => {
loadFrappeTranslations();
});
export default i18n;

91
asm_app/src/index.css Normal file
View File

@ -0,0 +1,91 @@
@import url('https://fonts.googleapis.com/css2?family=Poppins:wght@100;200;300;400;500;600;700;800;900&display=swap');
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer utilities {
.perspective-1000 {
perspective: 1000px;
}
.transform-style-3d {
transform-style: preserve-3d;
}
.backface-hidden {
backface-visibility: hidden;
}
.rotate-y-180 {
transform: rotateY(180deg);
}
}
/* Custom Scrollbar Styles */
@layer base {
/* Webkit browsers (Chrome, Safari, Edge) */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: rgb(209, 213, 219); /* gray-300 */
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: rgb(156, 163, 175); /* gray-400 */
}
/* Dark mode scrollbar */
.dark ::-webkit-scrollbar-thumb {
background: rgb(75, 85, 99); /* gray-600 */
}
.dark ::-webkit-scrollbar-thumb:hover {
background: rgb(107, 114, 128); /* gray-500 */
}
/* Firefox */
* {
scrollbar-width: thin;
scrollbar-color: rgb(209, 213, 219) transparent;
}
.dark * {
scrollbar-color: rgb(75, 85, 99) transparent;
}
/* RTL Support */
[dir="rtl"] {
direction: rtl;
text-align: right;
}
[dir="ltr"] {
direction: ltr;
text-align: left;
}
/* RTL spacing utilities */
.rtl .ml-auto {
margin-left: 0;
margin-right: auto;
}
.rtl .mr-auto {
margin-right: 0;
margin-left: auto;
}
/* RTL flex utilities */
.rtl .flex-row-reverse {
flex-direction: row-reverse;
}
}

View File

@ -0,0 +1,176 @@
{
"common": {
"dashboard": "لوحة التحكم",
"assets": "الأصول",
"workOrders": "أوامر العمل",
"maintenance": "صيانة الأصول",
"ppm": "الصيانة الوقائية",
"logout": "تسجيل الخروج",
"login": "تسجيل الدخول",
"email": "البريد الإلكتروني",
"password": "كلمة المرور",
"submit": "إرسال",
"cancel": "إلغاء",
"save": "حفظ",
"delete": "حذف",
"edit": "تعديل",
"create": "إنشاء",
"search": "بحث",
"filter": "تصفية",
"export": "تصدير",
"import": "استيراد",
"loading": "جاري التحميل...",
"noData": "لا توجد بيانات",
"error": "خطأ",
"success": "نجح",
"darkMode": "الوضع الداكن",
"lightMode": "الوضع الفاتح",
"language": "اللغة",
"english": "الإنجليزية",
"arabic": "العربية"
},
"sidebar": {
"title": "أصول سيرا",
"loggedInAs": "تم تسجيل الدخول كـ:",
"version": "أصول سيرا نظام إدارة الأصول الإصدار 1.0"
},
"login": {
"title": "أصول سيرا",
"subtitle": "نظام إدارة الأصول",
"signIn": "قم بتسجيل الدخول للمتابعة",
"emailPlaceholder": "أدخل بريدك الإلكتروني",
"passwordPlaceholder": "أدخل كلمة المرور",
"loginFailed": "فشل تسجيل الدخول. يرجى التحقق من بيانات الاعتماد الخاصة بك.",
"demoLogin": "تسجيل دخول تجريبي"
},
"dashboard": {
"title": "لوحة التحكم",
"loading": "جاري تحميل لوحة التحكم...",
"totalAssets": "إجمالي عدد الأصول",
"openWorkOrders": "أوامر العمل المفتوحة",
"workOrdersInProgress": "أوامر العمل قيد التنفيذ",
"completedWorkOrders": "أوامر العمل المكتملة",
"totalWorkOrders": "إجمالي أوامر العمل",
"overdueWorkOrders": "أوامر العمل المتأخرة",
"upTime": "وقت التشغيل",
"downTime": "وقت التوقف",
"workOrderStatus": "حالة أمر العمل",
"workOrderByType": "أمر العمل حسب النوع",
"maintenanceByAsset": "الصيانة حسب الأصل",
"assigneesStatus": "حالة المكلفين",
"maintenanceFrequency": "تكرار الصيانة",
"maintenanceLogs": "سجلات الصيانة",
"assetUptime": "وقت تشغيل الأصل",
"avgResponseTime": "متوسط وقت الاستجابة",
"maintenanceEfficiency": "كفاءة الصيانة",
"overdueMaintenance": "صيانة متأخرة",
"upDownTimeChart": "مخطط وقت التشغيل والتوقف",
"ppmStatus": "حالة الصيانة الوقائية"
},
"commonFields": {
"assetId": "معرف الأصل",
"assetName": "اسم الأصل",
"serialNumber": "الرقم التسلسلي",
"company": "الشركة/المستشفى",
"location": "الموقع",
"department": "القسم",
"deviceStatus": "حالة الجهاز",
"modality": "الطريقة",
"manufacturer": "الشركة المصنعة",
"supplier": "المورد",
"assetCategory": "فئة الأصل",
"purchaseDate": "تاريخ الشراء",
"purchaseAmount": "مبلغ الشراء",
"availableForUseDate": "تاريخ التوفر للاستخدام",
"createdOn": "تم الإنشاء في",
"modifiedOn": "تم التعديل في",
"createdBy": "تم الإنشاء بواسطة",
"modifiedBy": "تم التعديل بواسطة",
"workOrderId": "معرف أمر العمل",
"workOrderType": "النوع",
"status": "الحالة",
"priority": "الأولوية",
"description": "الوصف",
"assignedTo": "مكلف إلى",
"scheduledDate": "التاريخ المجدول",
"completedDate": "تاريخ الإكمال"
},
"listPages": {
"addNew": "إضافة جديد",
"searchPlaceholder": "بحث...",
"noResults": "لم يتم العثور على نتائج",
"showing": "عرض",
"of": "من",
"results": "نتائج",
"selectAll": "تحديد الكل",
"deselectAll": "إلغاء تحديد الكل",
"selected": "محدد",
"actions": "الإجراءات",
"view": "عرض",
"edit": "تعديل",
"delete": "حذف",
"duplicate": "نسخ",
"export": "تصدير",
"print": "طباعة",
"filters": "المرشحات",
"clearFilters": "مسح المرشحات",
"applyFilters": "تطبيق المرشحات",
"columns": "الأعمدة",
"exportSelected": "تصدير المحدد",
"exportAllOnPage": "تصدير الكل في الصفحة",
"exportAllWithFilters": "تصدير الكل مع المرشحات",
"exportFormat": "تنسيق التصدير",
"csv": "CSV",
"excel": "Excel",
"exporting": "جاري التصدير...",
"exportComplete": "اكتمل التصدير",
"close": "إغلاق",
"loading": "جاري التحميل...",
"refresh": "تحديث"
},
"assets": {
"title": "الأصول",
"addAsset": "إضافة أصل جديد",
"assetDetails": "تفاصيل الأصل"
},
"workOrders": {
"title": "أوامر العمل",
"addWorkOrder": "إضافة أمر عمل جديد",
"workOrderDetails": "تفاصيل أمر العمل",
"newWorkOrder": "أمر عمل جديد",
"duplicateWorkOrder": "نسخ أمر العمل",
"createFromAsset": "إنشاء أمر عمل من الأصل"
},
"maintenance": {
"title": "صيانة الأصول",
"maintenanceLogs": "سجلات الصيانة",
"maintenanceDetails": "تفاصيل الصيانة",
"addMaintenance": "إضافة صيانة جديدة"
},
"ppm": {
"title": "الصيانة الوقائية",
"ppmDetails": "تفاصيل الصيانة الوقائية",
"addPPM": "إضافة صيانة وقائية جديدة"
},
"exportModal": {
"title": "تصدير",
"whatToExport": "ما الذي سيتم تصديره",
"selectedRows": "الصفوف المحددة",
"currentPage": "الصفحة الحالية",
"allWithFilters": "الكل مع المرشحات",
"exportSelected": "تصدير {count} محدد",
"exportPage": "تصدير {count} في الصفحة الحالية",
"exportAll": "تصدير الكل {count}",
"columnsToExport": "الأعمدة للتصدير",
"selectAll": "تحديد الكل",
"selectDefault": "تحديد الافتراضي",
"exporting": "جاري التصدير...",
"exportingSelected": "جاري تصدير {count} صف(وف) محدد(ة)",
"exportingPage": "جاري تصدير {count} صف(وف) من الصفحة الحالية",
"exportingAll": "جاري تصدير جميع {count} صف(وف)",
"selected": "محدد",
"rows": "صفوف"
}
}

View File

@ -0,0 +1,176 @@
{
"common": {
"dashboard": "Dashboard",
"assets": "Assets",
"workOrders": "Work Orders",
"maintenance": "Asset Maintenance",
"ppm": "PPM",
"logout": "Logout",
"login": "Login",
"email": "Email",
"password": "Password",
"submit": "Submit",
"cancel": "Cancel",
"save": "Save",
"delete": "Delete",
"edit": "Edit",
"create": "Create",
"search": "Search",
"filter": "Filter",
"export": "Export",
"import": "Import",
"loading": "Loading...",
"noData": "No data available",
"error": "Error",
"success": "Success",
"darkMode": "Dark Mode",
"lightMode": "Light Mode",
"language": "Language",
"english": "English",
"arabic": "Arabic"
},
"sidebar": {
"title": "Seera-ASM",
"loggedInAs": "Logged in as:",
"version": "Seera-ASM v1.0"
},
"login": {
"title": "Seera-ASM",
"subtitle": "Asset Management System",
"signIn": "Sign in to continue",
"emailPlaceholder": "Enter your email",
"passwordPlaceholder": "Enter your password",
"loginFailed": "Login failed. Please check your credentials.",
"demoLogin": "Demo Login"
},
"dashboard": {
"title": "Dashboard",
"loading": "Loading dashboard...",
"totalAssets": "TOTAL NO. OF ASSETS",
"openWorkOrders": "OPEN WORK ORDERS",
"workOrdersInProgress": "WORK ORDERS IN PROGRESS",
"completedWorkOrders": "COMPLETED WORK ORDERS",
"totalWorkOrders": "TOTAL WORK ORDERS",
"overdueWorkOrders": "OVERDUE WORK ORDERS",
"upTime": "Up Time",
"downTime": "Down Time",
"workOrderStatus": "Work Order Status",
"workOrderByType": "Work Order by Type",
"maintenanceByAsset": "Maintenance by Asset",
"assigneesStatus": "Assignees Status",
"maintenanceFrequency": "Maintenance Frequency",
"maintenanceLogs": "MAINTENANCE LOGS",
"assetUptime": "Asset Uptime",
"avgResponseTime": "Avg Response Time",
"maintenanceEfficiency": "Maintenance Efficiency",
"overdueMaintenance": "Overdue Maintenance",
"upDownTimeChart": "Up & Down Time Chart",
"ppmStatus": "PPM Status"
},
"commonFields": {
"assetId": "Asset ID",
"assetName": "Asset Name",
"serialNumber": "Serial Number",
"company": "Company/Hospital",
"location": "Location",
"department": "Department",
"deviceStatus": "Device Status",
"modality": "Modality",
"manufacturer": "Manufacturer",
"supplier": "Supplier",
"assetCategory": "Asset Category",
"purchaseDate": "Purchase Date",
"purchaseAmount": "Purchase Amount",
"availableForUseDate": "Available For Use Date",
"createdOn": "Created On",
"modifiedOn": "Modified On",
"createdBy": "Created By",
"modifiedBy": "Modified By",
"workOrderId": "Work Order ID",
"workOrderType": "Type",
"status": "Status",
"priority": "Priority",
"description": "Description",
"assignedTo": "Assigned To",
"scheduledDate": "Scheduled Date",
"completedDate": "Completed Date"
},
"listPages": {
"addNew": "Add New",
"searchPlaceholder": "Search...",
"noResults": "No results found",
"showing": "Showing",
"of": "of",
"results": "results",
"selectAll": "Select All",
"deselectAll": "Deselect All",
"selected": "selected",
"actions": "Actions",
"view": "View",
"edit": "Edit",
"delete": "Delete",
"duplicate": "Duplicate",
"export": "Export",
"print": "Print",
"filters": "Filters",
"clearFilters": "Clear Filters",
"applyFilters": "Apply Filters",
"columns": "Columns",
"exportSelected": "Export Selected",
"exportAllOnPage": "Export All on Page",
"exportAllWithFilters": "Export All with Filters",
"exportFormat": "Export Format",
"csv": "CSV",
"excel": "Excel",
"exporting": "Exporting...",
"exportComplete": "Export Complete",
"close": "Close",
"loading": "Loading...",
"refresh": "Refresh"
},
"assets": {
"title": "Assets",
"addAsset": "Add New Asset",
"assetDetails": "Asset Details"
},
"workOrders": {
"title": "Work Orders",
"addWorkOrder": "Add New Work Order",
"workOrderDetails": "Work Order Details",
"newWorkOrder": "New Work Order",
"duplicateWorkOrder": "Duplicate Work Order",
"createFromAsset": "Create Work Order from Asset"
},
"maintenance": {
"title": "Asset Maintenance",
"maintenanceLogs": "Maintenance Logs",
"maintenanceDetails": "Maintenance Details",
"addMaintenance": "Add New Maintenance"
},
"ppm": {
"title": "PPM",
"ppmDetails": "PPM Details",
"addPPM": "Add New PPM"
},
"exportModal": {
"title": "Export",
"whatToExport": "What to Export",
"selectedRows": "Selected Rows",
"currentPage": "Current Page",
"allWithFilters": "All with Filters",
"exportSelected": "Export {count} selected",
"exportPage": "Export {count} on current page",
"exportAll": "Export all {count}",
"columnsToExport": "Columns to Export",
"selectAll": "Select All",
"selectDefault": "Select Default",
"exporting": "Exporting...",
"exportingSelected": "Exporting {count} selected row(s)",
"exportingPage": "Exporting {count} row(s) from current page",
"exportingAll": "Exporting all {count} row(s)",
"selected": "selected",
"rows": "rows"
}
}

17
asm_app/src/main.tsx Normal file
View File

@ -0,0 +1,17 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import './i18n'
import App from './App.tsx'
import { ThemeProvider } from './contexts/ThemeContext'
import { LanguageProvider } from './contexts/LanguageContext'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<LanguageProvider>
<ThemeProvider>
<App />
</ThemeProvider>
</LanguageProvider>
</StrictMode>,
)

View File

@ -0,0 +1,584 @@
/**
* Active Map Page
*
* Displays hospitals/locations on an interactive map with markers showing:
* - Asset counts
* - Work Order counts (Normal/Urgent, by status)
* - Maintenance Log counts (Planned/Completed/Overdue)
*
* Replicates the Frappe "active-map" page functionality
*/
import React, { useState, useEffect, useRef } from 'react';
import { useNavigate } from 'react-router-dom';
import { MapContainer, TileLayer, Marker, Popup, Tooltip, useMap } from 'react-leaflet';
import L from 'leaflet';
import 'leaflet/dist/leaflet.css';
import apiService from '../services/apiService';
import LinkField from '../components/LinkField';
// Fix for default marker icons in React-Leaflet
import icon from 'leaflet/dist/images/marker-icon.png';
import iconShadow from 'leaflet/dist/images/marker-shadow.png';
const DefaultIcon = L.icon({
iconUrl: icon,
shadowUrl: iconShadow,
iconSize: [25, 41],
iconAnchor: [12, 41],
popupAnchor: [1, -34],
tooltipAnchor: [16, -28],
shadowSize: [41, 41]
});
L.Marker.prototype.options.icon = DefaultIcon;
interface LocationData {
name: string;
latitude: number;
longitude: number;
assets: number;
normal_work_orders: number;
urgent_work_orders: number;
planned_maintenance: number;
completed_maintenance: number;
overdue_maintenance: number;
wo_open: number;
wo_progress: number;
wo_review: number;
wo_completed: number;
}
// Component to handle map bounds fitting
const MapBounds: React.FC<{ locations: LocationData[] }> = ({ locations }) => {
const map = useMap();
useEffect(() => {
if (locations.length > 0 && locations.some(l => l.latitude && l.longitude)) {
const bounds = L.latLngBounds(
locations
.filter(l => l.latitude && l.longitude)
.map(l => [l.latitude, l.longitude] as [number, number])
);
map.fitBounds(bounds, { padding: [30, 30], maxZoom: 8 });
} else {
// Fallback: show Saudi Arabia center
map.setView([24.8, 45.5], 6);
}
}, [locations, map]);
return null;
};
const ActiveMap: React.FC = () => {
const navigate = useNavigate();
const [selectedHospital, setSelectedHospital] = useState<string>('');
const [locations, setLocations] = useState<LocationData[]>([]);
const [loading, setLoading] = useState(true);
const markersRef = useRef<Record<string, L.Marker>>({});
// Fetch locations and their counts
const fetchAndRenderData = async () => {
setLoading(true);
try {
// Build filters
const filters: Record<string, any> = {
latitude: ['!=', ''],
longitude: ['!=', '']
};
if (selectedHospital) {
filters.name = selectedHospital;
}
// Fetch locations
const locationsResponse = await apiService.apiCall<any>(
`/api/resource/Location?filters=${encodeURIComponent(JSON.stringify(filters))}&fields=["name","latitude","longitude"]`
);
const locationList = locationsResponse?.data || [];
// For each location, fetch counts
const locationPromises = locationList.map(async (location: any) => {
const counts: Partial<LocationData> = {
assets: 0,
normal_work_orders: 0,
urgent_work_orders: 0,
planned_maintenance: 0,
completed_maintenance: 0,
overdue_maintenance: 0,
wo_open: 0,
wo_progress: 0,
wo_review: 0,
wo_completed: 0
};
try {
// Fetch Asset count - use fields=["name"] to minimize data transfer
const assetsResponse = await apiService.apiCall<any>(
`/api/resource/Asset?filters=${encodeURIComponent(JSON.stringify({ company: location.name }))}&fields=["name"]`
);
counts.assets = assetsResponse?.data?.length || 0;
// Fetch Normal Work Orders
const normalWOResponse = await apiService.apiCall<any>(
`/api/resource/Work_Order?filters=${encodeURIComponent(JSON.stringify({
company: location.name,
custom_priority_: 'Normal',
repair_status: ['in', ['Open', 'Work In Progress']]
}))}&fields=["name"]`
);
counts.normal_work_orders = normalWOResponse?.data?.length || 0;
// Fetch Urgent Work Orders
const urgentWOResponse = await apiService.apiCall<any>(
`/api/resource/Work_Order?filters=${encodeURIComponent(JSON.stringify({
company: location.name,
custom_priority_: 'Urgent',
repair_status: ['in', ['Open', 'Work In Progress']]
}))}&fields=["name"]`
);
counts.urgent_work_orders = urgentWOResponse?.data?.length || 0;
// Fetch WO Status counts
const [woOpen, woProgress, woReview, woCompleted, plannedPM, completedPM, overduePM] = await Promise.all([
// Open
apiService.apiCall<any>(
`/api/resource/Work_Order?filters=${encodeURIComponent(JSON.stringify({
company: location.name,
repair_status: 'Open'
}))}&fields=["name"]`
),
// Work In Progress
apiService.apiCall<any>(
`/api/resource/Work_Order?filters=${encodeURIComponent(JSON.stringify({
company: location.name,
repair_status: 'Work In Progress'
}))}&fields=["name"]`
),
// Pending Review
apiService.apiCall<any>(
`/api/resource/Work_Order?filters=${encodeURIComponent(JSON.stringify({
company: location.name,
repair_status: 'Pending Review'
}))}&fields=["name"]`
),
// Completed
apiService.apiCall<any>(
`/api/resource/Work_Order?filters=${encodeURIComponent(JSON.stringify({
company: location.name,
repair_status: 'Completed'
}))}&fields=["name"]`
),
// Planned Maintenance
apiService.apiCall<any>(
`/api/resource/Asset Maintenance Log?filters=${encodeURIComponent(JSON.stringify({
custom_hospital_name: location.name,
maintenance_status: 'Planned'
}))}&fields=["name"]`
),
// Completed Maintenance
apiService.apiCall<any>(
`/api/resource/Asset Maintenance Log?filters=${encodeURIComponent(JSON.stringify({
custom_hospital_name: location.name,
maintenance_status: 'Completed'
}))}&fields=["name"]`
),
// Overdue Maintenance
apiService.apiCall<any>(
`/api/resource/Asset Maintenance Log?filters=${encodeURIComponent(JSON.stringify({
custom_hospital_name: location.name,
maintenance_status: 'Overdue'
}))}&fields=["name"]`
)
]);
counts.wo_open = woOpen?.data?.length || 0;
counts.wo_progress = woProgress?.data?.length || 0;
counts.wo_review = woReview?.data?.length || 0;
counts.wo_completed = woCompleted?.data?.length || 0;
counts.planned_maintenance = plannedPM?.data?.length || 0;
counts.completed_maintenance = completedPM?.data?.length || 0;
counts.overdue_maintenance = overduePM?.data?.length || 0;
} catch (err) {
console.error(`Error fetching counts for ${location.name}:`, err);
}
return {
name: location.name,
latitude: parseFloat(location.latitude),
longitude: parseFloat(location.longitude),
...counts
} as LocationData;
});
const results = await Promise.all(locationPromises);
setLocations(results.filter(l => !isNaN(l.latitude) && !isNaN(l.longitude)));
} catch (error) {
console.error('Error fetching map data:', error);
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchAndRenderData();
}, [selectedHospital]);
// Navigate to list view with filters
const navigateToWorkOrders = (hospital: string, priority?: string, status?: string) => {
const params = new URLSearchParams();
if (hospital) params.set('company', hospital);
if (priority) params.set('priority', priority);
if (status) params.set('status', status);
navigate(`/work-orders?${params.toString()}`);
};
const navigateToAssets = (hospital: string) => {
const params = new URLSearchParams();
if (hospital) params.set('company', hospital);
navigate(`/assets?${params.toString()}`);
};
const navigateToMaintenanceCalendar = (hospital: string, status?: string) => {
const params = new URLSearchParams();
if (hospital) params.set('hospital', hospital);
if (status) params.set('status', status);
navigate(`/maintenance-calendar?${params.toString()}`);
};
// Create popup content with modern UI matching the application
const createPopupContent = (location: LocationData) => {
return (
<div className="p-4 bg-white dark:bg-gray-800 rounded-lg shadow-lg min-w-[280px] max-w-[320px]">
{/* Hospital Name Header */}
<div className="mb-4 pb-3 border-b border-gray-200 dark:border-gray-700">
<h3 className="text-lg font-bold text-gray-900 dark:text-white">
{location.name}
</h3>
<p className="text-sm text-gray-600 dark:text-gray-400 mt-1">
Total Assets: <span className="font-semibold text-gray-900 dark:text-white">{location.assets}</span>
</p>
</div>
{/* Work Order Status Section */}
<div className="mb-4">
<h4 className="text-sm font-semibold text-gray-800 dark:text-gray-200 mb-2">
Work Order Status
</h4>
<div className="flex gap-2 mb-3">
<button
onClick={() => navigateToWorkOrders(location.name, 'Normal')}
className="px-3 py-1.5 bg-blue-100 dark:bg-blue-900/30 hover:bg-blue-200 dark:hover:bg-blue-900/50 text-blue-700 dark:text-blue-300 rounded-lg text-xs font-semibold transition-colors cursor-pointer"
>
Normal: {location.normal_work_orders}
</button>
<button
onClick={() => navigateToWorkOrders(location.name, 'Urgent')}
className="px-3 py-1.5 bg-red-100 dark:bg-red-900/30 hover:bg-red-200 dark:hover:bg-red-900/50 text-red-700 dark:text-red-300 rounded-lg text-xs font-semibold transition-colors cursor-pointer"
>
Urgent: {location.urgent_work_orders}
</button>
</div>
{/* Status Table */}
<div className="overflow-hidden rounded-lg border border-gray-200 dark:border-gray-700">
<table className="w-full text-xs">
<thead className="bg-gray-50 dark:bg-gray-700">
<tr>
<th className="px-3 py-2 text-left font-semibold text-gray-700 dark:text-gray-300">Status</th>
<th className="px-3 py-2 text-left font-semibold text-gray-700 dark:text-gray-300">Count</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-200 dark:divide-gray-700">
<tr className="bg-red-50 dark:bg-red-900/20 hover:bg-red-100 dark:hover:bg-red-900/30 transition-colors">
<td className="px-3 py-2 text-red-800 dark:text-red-300 font-medium">Open</td>
<td className="px-3 py-2">
<button
onClick={() => navigateToWorkOrders(location.name, undefined, 'Open')}
className="text-red-700 dark:text-red-400 font-bold hover:underline cursor-pointer"
>
{location.wo_open}
</button>
</td>
</tr>
<tr className="bg-yellow-50 dark:bg-yellow-900/20 hover:bg-yellow-100 dark:hover:bg-yellow-900/30 transition-colors">
<td className="px-3 py-2 text-yellow-800 dark:text-yellow-300 font-medium">Work In Progress</td>
<td className="px-3 py-2">
<button
onClick={() => navigateToWorkOrders(location.name, undefined, 'Work In Progress')}
className="text-yellow-700 dark:text-yellow-400 font-bold hover:underline cursor-pointer"
>
{location.wo_progress}
</button>
</td>
</tr>
<tr className="bg-blue-50 dark:bg-blue-900/20 hover:bg-blue-100 dark:hover:bg-blue-900/30 transition-colors">
<td className="px-3 py-2 text-blue-800 dark:text-blue-300 font-medium">Pending Review</td>
<td className="px-3 py-2">
<button
onClick={() => navigateToWorkOrders(location.name, undefined, 'Pending Review')}
className="text-blue-700 dark:text-blue-400 font-bold hover:underline cursor-pointer"
>
{location.wo_review}
</button>
</td>
</tr>
<tr className="bg-green-50 dark:bg-green-900/20 hover:bg-green-100 dark:hover:bg-green-900/30 transition-colors">
<td className="px-3 py-2 text-green-800 dark:text-green-300 font-medium">Completed</td>
<td className="px-3 py-2">
<button
onClick={() => navigateToWorkOrders(location.name, undefined, 'Completed')}
className="text-green-700 dark:text-green-400 font-bold hover:underline cursor-pointer"
>
{location.wo_completed}
</button>
</td>
</tr>
</tbody>
</table>
</div>
</div>
{/* Preventive Maintenance Section */}
<div className="mb-4">
<h4 className="text-sm font-semibold text-gray-800 dark:text-gray-200 mb-2">
Preventive Maintenance
</h4>
<div className="flex flex-wrap gap-2">
<button
onClick={() => navigateToMaintenanceCalendar(location.name, 'Planned')}
className="px-3 py-1.5 bg-orange-100 dark:bg-orange-900/30 hover:bg-orange-200 dark:hover:bg-orange-900/50 text-orange-700 dark:text-orange-300 rounded-lg text-xs font-semibold transition-colors cursor-pointer"
>
Planned: {location.planned_maintenance}
</button>
<button
onClick={() => navigateToMaintenanceCalendar(location.name, 'Completed')}
className="px-3 py-1.5 bg-green-100 dark:bg-green-900/30 hover:bg-green-200 dark:hover:bg-green-900/50 text-green-700 dark:text-green-300 rounded-lg text-xs font-semibold transition-colors cursor-pointer"
>
Completed: {location.completed_maintenance}
</button>
<button
onClick={() => navigateToMaintenanceCalendar(location.name, 'Overdue')}
className="px-3 py-1.5 bg-red-100 dark:bg-red-900/30 hover:bg-red-200 dark:hover:bg-red-900/50 text-red-700 dark:text-red-300 rounded-lg text-xs font-semibold transition-colors cursor-pointer"
>
Overdue: {location.overdue_maintenance}
</button>
</div>
</div>
{/* Action Buttons */}
<div className="flex gap-2 pt-3 border-t border-gray-200 dark:border-gray-700">
<button
onClick={() => navigateToAssets(location.name)}
className="flex-1 px-4 py-2 bg-blue-600 hover:bg-blue-700 dark:bg-blue-700 dark:hover:bg-blue-600 text-white rounded-lg text-sm font-medium transition-colors cursor-pointer"
>
View Assets
</button>
<button
onClick={() => navigateToWorkOrders(location.name)}
className="flex-1 px-4 py-2 bg-purple-600 hover:bg-purple-700 dark:bg-purple-700 dark:hover:bg-purple-600 text-white rounded-lg text-sm font-medium transition-colors cursor-pointer"
>
View Work Orders
</button>
</div>
</div>
);
};
return (
<div className="h-screen flex flex-col bg-gray-50 dark:bg-gray-900">
<div className="flex-shrink-0 bg-white dark:bg-gray-800 shadow-sm border-b border-gray-200 dark:border-gray-700 px-4 py-3">
<h1 className="text-xl font-semibold text-gray-800 dark:text-white">Active Map</h1>
</div>
{/* Filter Container */}
<div className="flex-shrink-0 bg-white dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700 px-4 py-3 relative z-[1000]">
<div className="max-w-md relative z-[1000]">
<LinkField
label="Hospital"
doctype="Location"
value={selectedHospital}
onChange={setSelectedHospital}
filters={{ custom_is_hospital: 1 }}
placeholder="Select hospital (leave empty for all)"
/>
</div>
</div>
{/* Map Container */}
<div className="flex-1 relative" style={{ zIndex: 1 }}>
{loading && (
<div className="absolute inset-0 flex items-center justify-center bg-white bg-opacity-75 z-[1000]">
<div className="text-gray-600 dark:text-gray-300">Loading map data...</div>
</div>
)}
<MapContainer
center={[24.8, 45.5]}
zoom={6}
style={{ height: '100%', width: '100%' }}
zoomControl={true}
>
<TileLayer
attribution='&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
/>
<MapBounds locations={locations} />
{locations.map((location) => {
const urgentIndicator = location.urgent_work_orders > 0 ? '🚨 URGENT! ' : '';
const markerKey = `${location.latitude}-${location.longitude}`;
return (
<Marker
key={markerKey}
position={[location.latitude, location.longitude]}
ref={(ref) => {
if (ref) {
markersRef.current[markerKey] = ref;
// Apply urgent marker styling
if (location.urgent_work_orders > 0) {
setTimeout(() => {
const markerElement = ref.getElement();
if (markerElement) {
markerElement.classList.add('urgent-marker', 'red-marker');
}
}, 100);
}
}
}}
>
<Tooltip
permanent={false}
direction="right"
className="hospital-tooltip-modern"
>
<div className="p-2 bg-white dark:bg-gray-800 rounded-lg shadow-lg min-w-[200px]">
<div className="mb-2 pb-2 border-b border-gray-200 dark:border-gray-700">
<h4 className="text-sm font-bold text-gray-900 dark:text-white">
{urgentIndicator}{location.name}
</h4>
<p className="text-xs text-gray-600 dark:text-gray-400 mt-0.5">
Assets: <span className="font-semibold text-gray-900 dark:text-white">{location.assets}</span>
</p>
</div>
<div className="space-y-1 text-xs">
<div className="flex items-center justify-between">
<span className="text-gray-600 dark:text-gray-400">Normal WOs:</span>
<span className="font-semibold text-blue-700 dark:text-blue-300">{location.normal_work_orders}</span>
</div>
<div className="flex items-center justify-between">
<span className="text-gray-600 dark:text-gray-400">Urgent WOs:</span>
<span className="font-semibold text-red-700 dark:text-red-300">{location.urgent_work_orders}</span>
</div>
<div className="flex items-center justify-between">
<span className="text-gray-600 dark:text-gray-400">Planned PMs:</span>
<span className="font-semibold text-orange-700 dark:text-orange-300">{location.planned_maintenance}</span>
</div>
<div className="flex items-center justify-between">
<span className="text-gray-600 dark:text-gray-400">Completed PMs:</span>
<span className="font-semibold text-green-700 dark:text-green-300">{location.completed_maintenance}</span>
</div>
</div>
</div>
</Tooltip>
<Popup
className="hospital-popup-container"
maxWidth={300}
maxHeight={410}
autoPan={true}
keepInView={true}
closeButton={true}
autoClose={false}
>
{createPopupContent(location)}
</Popup>
</Marker>
);
})}
</MapContainer>
</div>
{/* Custom Styles */}
<style>{`
/* Ensure filter container and dropdowns stay above map */
.leaflet-container {
z-index: 1 !important;
}
/* LinkField dropdown z-index - ensure it's above everything */
[data-linkfield-dropdown],
.linkfield-dropdown,
.react-select__menu,
.react-select__menu-portal,
.select2-container,
.select2-dropdown {
z-index: 1050 !important;
}
/* Any dropdown menu from LinkField */
div[role="listbox"],
ul[role="listbox"],
.dropdown-menu,
.autocomplete-dropdown {
z-index: 1050 !important;
}
.hospital-tooltip-modern {
background: transparent !important;
border: none !important;
box-shadow: none !important;
}
.hospital-tooltip-modern .leaflet-tooltip-content-wrapper {
background: transparent !important;
border: none !important;
box-shadow: none !important;
padding: 0 !important;
}
.hospital-tooltip-modern .leaflet-tooltip-content {
margin: 0 !important;
}
.hospital-popup-container .leaflet-popup-content-wrapper {
padding: 0;
border-radius: 8px;
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05);
}
.hospital-popup-container .leaflet-popup-content {
margin: 0;
width: auto !important;
}
.urgent-marker {
animation: urgent-flash 2s infinite;
}
@keyframes urgent-flash {
0%, 50% {
filter: hue-rotate(0deg) brightness(1) saturate(1);
}
25%, 75% {
filter: hue-rotate(0deg) brightness(1.5) saturate(2) drop-shadow(0 0 10px red);
}
}
.red-marker {
filter: hue-rotate(120deg) saturate(2) brightness(0.8);
}
.leaflet-popup {
z-index: 2000 !important;
}
.leaflet-tooltip {
z-index: 2000 !important;
}
`}</style>
</div>
);
};
export default ActiveMap;

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,501 @@
import React, { useState, useEffect, useRef } from 'react';
import { useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
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 { t } = useTranslation();
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">{t('maintenance.title')}</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

@ -0,0 +1,34 @@
import React from 'react';
import { Construction } from 'lucide-react';
interface ComingSoonProps {
title?: string;
}
const ComingSoon: React.FC<ComingSoonProps> = ({ title = 'Coming Soon' }) => {
return (
<div className="flex items-center justify-center min-h-screen bg-gray-50 dark:bg-gray-900 p-4">
<div className="text-center max-w-md">
<div className="mb-6 flex justify-center">
<div className="bg-blue-100 dark:bg-blue-900/30 p-6 rounded-full">
<Construction size={64} className="text-blue-600 dark:text-blue-400" />
</div>
</div>
<h1 className="text-4xl font-bold text-gray-900 dark:text-white mb-4">
{title}
</h1>
<p className="text-lg text-gray-600 dark:text-gray-400 mb-8">
This feature is currently under development and will be available soon.
</p>
<div className="bg-white dark:bg-gray-800 rounded-lg p-6 shadow-lg">
<p className="text-sm text-gray-500 dark:text-gray-400">
We're working hard to bring you the best experience. Stay tuned for updates!
</p>
</div>
</div>
</div>
);
};
export default ComingSoon;

View File

@ -0,0 +1,409 @@
import React, { useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { useAuth, useDashboardStats, useUserDetails, useNumberCards } from '../hooks/useApi';
import ApiTest from '../components/ApiTest';
import ChartTile from '../components/ChartTile';
// Define interfaces locally
interface UserDetails {
user_id: string;
full_name: string;
email: string;
user_image?: string;
roles: string[];
permissions: Record<string, {
read: boolean;
write: boolean;
create: boolean;
delete: boolean;
}>;
last_login?: string;
enabled: boolean;
creation: string;
modified: string;
language: string;
}
interface DocTypeRecord {
name: string;
creation: string;
modified: string;
modified_by: string;
owner: string;
docstatus: number;
[key: string]: any;
}
const Dashboard: React.FC = () => {
const [user, setUser] = useState<UserDetails | null>(null);
const [recentRecords, setRecentRecords] = useState<DocTypeRecord[]>([]);
const navigate = useNavigate();
const { logout } = useAuth();
// Use the new API hooks
const { loading: statsLoading, error: statsError } = useDashboardStats();
const { data: numberCards } = useNumberCards();
const { data: userDetails, loading: userLoading, error: userError } = useUserDetails();
useEffect(() => {
// Set user from stored data or API response
const storedUser = localStorage.getItem('user');
if (storedUser) {
setUser(JSON.parse(storedUser));
} else if (userDetails) {
setUser(userDetails);
}
// Set demo records for now (you can replace this with real data later)
const demoRecords: DocTypeRecord[] = [
{
name: 'USER001',
full_name: 'John Doe',
email: 'john.doe@seeraarabia.com',
creation: new Date().toISOString(),
modified: new Date().toISOString(),
modified_by: 'system',
owner: 'system',
docstatus: 0
},
{
name: 'USER002',
full_name: 'Jane Smith',
email: 'jane.smith@seeraarabia.com',
creation: new Date(Date.now() - 86400000).toISOString(),
modified: new Date().toISOString(),
modified_by: 'system',
owner: 'system',
docstatus: 0
},
{
name: 'USER003',
full_name: 'Ahmed Al-Rashid',
email: 'ahmed.alrashid@seeraarabia.com',
creation: new Date(Date.now() - 172800000).toISOString(),
modified: new Date().toISOString(),
modified_by: 'system',
owner: 'system',
docstatus: 0
},
{
name: 'USER004',
full_name: 'Sarah Johnson',
email: 'sarah.johnson@seeraarabia.com',
creation: new Date(Date.now() - 259200000).toISOString(),
modified: new Date().toISOString(),
modified_by: 'system',
owner: 'system',
docstatus: 0
},
{
name: 'USER005',
full_name: 'Mohammed Hassan',
email: 'mohammed.hassan@seeraarabia.com',
creation: new Date(Date.now() - 345600000).toISOString(),
modified: new Date().toISOString(),
modified_by: 'system',
owner: 'system',
docstatus: 0
}
];
setRecentRecords(demoRecords);
}, [userDetails]);
const handleLogout = async () => {
try {
await logout();
localStorage.removeItem('user');
navigate('/login');
} catch (err) {
console.error('Logout error:', err);
// Force logout even if API call fails
localStorage.removeItem('user');
navigate('/login');
}
};
if (statsLoading || userLoading) {
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50 dark:bg-gray-900">
<div className="animate-spin rounded-full h-32 w-32 border-b-2 border-indigo-600"></div>
</div>
);
}
return (
<div className="min-h-screen bg-gray-50 dark:bg-gray-900">
{/* Header */}
<header className="bg-white dark:bg-gray-800 shadow">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="flex justify-between items-center py-6">
<div className="flex items-center">
<h1 className="text-3xl font-bold text-gray-900 dark:text-white">Dashboard</h1>
</div>
<div className="flex items-center space-x-4">
<span className="text-sm text-gray-700 dark:text-gray-300">
Welcome, {user?.full_name || 'User'}
</span>
<button
onClick={handleLogout}
className="bg-red-600 hover:bg-red-700 text-white px-4 py-2 rounded-md text-sm font-medium"
>
Logout
</button>
</div>
</div>
</div>
</header>
{/* Main Content */}
<main className="max-w-7xl mx-auto py-6 sm:px-6 lg:px-8">
{(statsError || userError) && (
<div className="mb-6 rounded-md bg-red-50 dark:bg-red-900/20 p-4">
<div className="text-sm text-red-700 dark:text-red-400">
{statsError || userError || 'Failed to load dashboard data'}
</div>
</div>
)}
{/* Stats Cards (from Frappe Number Cards) */}
<div className="grid grid-cols-1 gap-5 sm:grid-cols-2 lg:grid-cols-4 mb-8">
<div className="bg-white dark:bg-gray-800 overflow-hidden shadow rounded-lg">
<div className="p-5">
<div className="flex items-center">
<div className="flex-shrink-0">
<div className="w-8 h-8 bg-indigo-500 rounded-md flex items-center justify-center">
<svg className="w-5 h-5 text-white" fill="currentColor" viewBox="0 0 20 20">
<path d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</div>
</div>
<div className="ml-5 w-0 flex-1">
<dl>
<dt className="text-sm font-medium text-gray-500 dark:text-gray-400 truncate">Total Assets</dt>
<dd className="text-lg font-medium text-gray-900 dark:text-white">
{numberCards?.total_assets ?? '-'}
</dd>
</dl>
</div>
</div>
</div>
</div>
<div className="bg-white dark:bg-gray-800 overflow-hidden shadow rounded-lg">
<div className="p-5">
<div className="flex items-center">
<div className="flex-shrink-0">
<div className="w-8 h-8 bg-green-500 rounded-md flex items-center justify-center">
<svg className="w-5 h-5 text-white" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clipRule="evenodd" />
</svg>
</div>
</div>
<div className="ml-5 w-0 flex-1">
<dl>
<dt className="text-sm font-medium text-gray-500 dark:text-gray-400 truncate">Open Work Orders</dt>
<dd className="text-lg font-medium text-gray-900 dark:text-white">
{numberCards?.work_orders_open ?? '-'}
</dd>
</dl>
</div>
</div>
</div>
</div>
<div className="bg-white dark:bg-gray-800 overflow-hidden shadow rounded-lg">
<div className="p-5">
<div className="flex items-center">
<div className="flex-shrink-0">
<div className="w-8 h-8 bg-yellow-500 rounded-md flex items-center justify-center">
<svg className="w-5 h-5 text-white" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clipRule="evenodd" />
</svg>
</div>
</div>
<div className="ml-5 w-0 flex-1">
<dl>
<dt className="text-sm font-medium text-gray-500 dark:text-gray-400 truncate">In Progress</dt>
<dd className="text-lg font-medium text-gray-900 dark:text-white">
{numberCards?.work_orders_in_progress ?? '-'}
</dd>
</dl>
</div>
</div>
</div>
</div>
<div className="bg-white dark:bg-gray-800 overflow-hidden shadow rounded-lg">
<div className="p-5">
<div className="flex items-center">
<div className="flex-shrink-0">
<div className="w-8 h-8 bg-purple-500 rounded-md flex items-center justify-center">
<svg className="w-5 h-5 text-white" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M3 4a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zm0 4a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zm0 4a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zm0 4a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1z" clipRule="evenodd" />
</svg>
</div>
</div>
<div className="ml-5 w-0 flex-1">
<dl>
<dt className="text-sm font-medium text-gray-500 dark:text-gray-400 truncate">Completed Work Orders</dt>
<dd className="text-lg font-medium text-gray-900 dark:text-white">
{numberCards?.work_orders_completed ?? '-'}
</dd>
</dl>
</div>
</div>
</div>
</div>
</div>
{/* Charts Grid */}
<div className="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-3 gap-6 mb-8">
{[
'Up & Down Time Chart',
'Work Order Status Chart',
'Maintenance - Asset wise Count',
'Asset Maintenance Assignees Status Count',
'Asset Maintenance Frequency Chart',
'PPM Status',
'PPM Template Counts',
'Repair Cost',
].map((name) => (
<ChartTile key={name} chartName={name} />
))}
</div>
{/* Recent Records */}
<div className="bg-white dark:bg-gray-800 shadow overflow-hidden sm:rounded-md">
<div className="px-4 py-5 sm:px-6">
<h3 className="text-lg leading-6 font-medium text-gray-900 dark:text-white">
Recent Records
</h3>
<p className="mt-1 max-w-2xl text-sm text-gray-500 dark:text-gray-400">
Latest entries from your Frappe backend
</p>
</div>
<ul className="divide-y divide-gray-200 dark:divide-gray-700">
{recentRecords.map((record) => (
<li key={record.name}>
<div className="px-4 py-4 flex items-center justify-between">
<div className="flex items-center">
<div className="flex-shrink-0 h-10 w-10">
<div className="h-10 w-10 rounded-full bg-indigo-100 flex items-center justify-center">
<span className="text-sm font-medium text-indigo-600">
{record.full_name?.charAt(0) || record.name.charAt(0)}
</span>
</div>
</div>
<div className="ml-4">
<div className="text-sm font-medium text-gray-900 dark:text-white">
{record.full_name || record.name}
</div>
<div className="text-sm text-gray-500 dark:text-gray-400">
{record.email || 'No email'}
</div>
</div>
</div>
<div className="text-sm text-gray-500 dark:text-gray-400">
{new Date(record.creation).toLocaleDateString()}
</div>
</div>
</li>
))}
</ul>
</div>
{/* Quick Actions */}
<div className="mt-8">
<h3 className="text-lg leading-6 font-medium text-gray-900 dark:text-white mb-4">
Quick Actions
</h3>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
<button
onClick={() => navigate('/users')}
className="bg-white dark:bg-gray-800 p-6 rounded-lg shadow hover:shadow-md transition-shadow"
>
<div className="flex items-center">
<div className="flex-shrink-0">
<div className="w-8 h-8 bg-blue-500 rounded-md flex items-center justify-center">
<svg className="w-5 h-5 text-white" fill="currentColor" viewBox="0 0 20 20">
<path d="M9 6a3 3 0 11-6 0 3 3 0 016 0zM17 6a3 3 0 11-6 0 3 3 0 016 0zM12.93 17c.046-.327.07-.66.07-1a6.97 6.97 0 00-1.5-4.33A5 5 0 0119 16v1h-6.07zM6 11a5 5 0 015 5v1H1v-1a5 5 0 015-5z" />
</svg>
</div>
</div>
<div className="ml-4">
<h4 className="text-sm font-medium text-gray-900 dark:text-white">View Users</h4>
<p className="text-sm text-gray-500 dark:text-gray-400">Manage user accounts</p>
</div>
</div>
</button>
<button
onClick={() => navigate('/settings')}
className="bg-white dark:bg-gray-800 p-6 rounded-lg shadow hover:shadow-md transition-shadow"
>
<div className="flex items-center">
<div className="flex-shrink-0">
<div className="w-8 h-8 bg-gray-500 rounded-md flex items-center justify-center">
<svg className="w-5 h-5 text-white" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M11.49 3.17c-.38-1.56-2.6-1.56-2.98 0a1.532 1.532 0 01-2.286.948c-1.372-.836-2.942.734-2.106 2.106.54.886.061 2.042-.947 2.287-1.561.379-1.561 2.6 0 2.978a1.532 1.532 0 01.947 2.287c-.836 1.372.734 2.942 2.106 2.106a1.532 1.532 0 012.287.947c.379 1.561 2.6 1.561 2.978 0a1.533 1.533 0 012.287-.947c1.372.836 2.942-.734 2.106-2.106a1.533 1.533 0 01.947-2.287c1.561-.379 1.561-2.6 0-2.978a1.532 1.532 0 01-.947-2.287c.836-1.372-.734-2.942-2.106-2.106a1.532 1.532 0 01-2.287-.947zM10 13a3 3 0 100-6 3 3 0 000 6z" clipRule="evenodd" />
</svg>
</div>
</div>
<div className="ml-4">
<h4 className="text-sm font-medium text-gray-900 dark:text-white">Settings</h4>
<p className="text-sm text-gray-500 dark:text-gray-400">Configure your preferences</p>
</div>
</div>
</button>
<button
onClick={() => navigate('/events')}
className="bg-white dark:bg-gray-800 p-6 rounded-lg shadow hover:shadow-md transition-shadow"
>
<div className="flex items-center">
<div className="flex-shrink-0">
<div className="w-8 h-8 bg-purple-500 rounded-md flex items-center justify-center">
<svg className="w-5 h-5 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
</div>
</div>
<div className="ml-4">
<h4 className="text-sm font-medium text-gray-900 dark:text-white">Events</h4>
<p className="text-sm text-gray-500 dark:text-gray-400">View calendar events</p>
</div>
</div>
</button>
<button
onClick={() => navigate('/reports')}
className="bg-white dark:bg-gray-800 p-6 rounded-lg shadow hover:shadow-md transition-shadow"
>
<div className="flex items-center">
<div className="flex-shrink-0">
<div className="w-8 h-8 bg-green-500 rounded-md flex items-center justify-center">
<svg className="w-5 h-5 text-white" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M3 3a1 1 0 000 2v8a2 2 0 002 2h2.586l-1.293 1.293a1 1 0 101.414 1.414L10 15.414l2.293 2.293a1 1 0 001.414-1.414L12.414 15H15a2 2 0 002-2V5a1 1 0 100-2H3zm11.707 4.707a1 1 0 00-1.414-1.414L10 9.586 8.707 8.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clipRule="evenodd" />
</svg>
</div>
</div>
<div className="ml-4">
<h4 className="text-sm font-medium text-gray-900 dark:text-white">Reports</h4>
<p className="text-sm text-gray-500 dark:text-gray-400">View analytics and reports</p>
</div>
</div>
</button>
</div>
</div>
{/* API Test Component */}
<div className="mt-8">
<ApiTest />
</div>
</main>
</div>
);
};
export default Dashboard;

View File

@ -0,0 +1,237 @@
import React, { useState, useEffect } from 'react';
import frappeAPI from '../api/frappeClient';
interface Event {
name: string;
subject: string;
starts_on: string;
ends_on: string;
status: string;
event_type: string;
description?: string;
}
const EventsList: React.FC = () => {
const [events, setEvents] = useState<Event[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
loadEvents();
}, []);
const loadEvents = async () => {
try {
setLoading(true);
// Call the Frappe API for events
const response = await frappeAPI.frappeGet('frappe.desk.doctype.event.event.get_events');
setEvents(response.message || []);
} catch (err: any) {
console.log('API call failed, using demo events:', err);
// Demo events data when API fails
const demoEvents = [
{
name: 'EVT001',
subject: 'Team Meeting - Asset Management Review',
starts_on: new Date().toISOString(),
ends_on: new Date(Date.now() + 3600000).toISOString(),
status: 'Open',
event_type: 'Meeting',
description: 'Monthly review of asset management processes'
},
{
name: 'EVT002',
subject: 'System Maintenance Window',
starts_on: new Date(Date.now() + 86400000).toISOString(),
ends_on: new Date(Date.now() + 86400000 + 7200000).toISOString(),
status: 'Scheduled',
event_type: 'Maintenance',
description: 'Scheduled maintenance for Seera Arabia AMS'
},
{
name: 'EVT003',
subject: 'User Training Session',
starts_on: new Date(Date.now() + 172800000).toISOString(),
ends_on: new Date(Date.now() + 172800000 + 10800000).toISOString(),
status: 'Open',
event_type: 'Training',
description: 'Training session for new users on AMS features'
}
];
setEvents(demoEvents);
setError(null);
} finally {
setLoading(false);
}
};
const formatDate = (dateString: string) => {
return new Date(dateString).toLocaleString();
};
const getStatusColor = (status: string) => {
switch (status.toLowerCase()) {
case 'open':
return 'bg-green-100 text-green-800';
case 'scheduled':
return 'bg-blue-100 text-blue-800';
case 'completed':
return 'bg-gray-100 text-gray-800';
case 'cancelled':
return 'bg-red-100 text-red-800';
default:
return 'bg-gray-100 text-gray-800';
}
};
const getEventTypeColor = (type: string) => {
switch (type.toLowerCase()) {
case 'meeting':
return 'bg-purple-100 text-purple-800';
case 'training':
return 'bg-yellow-100 text-yellow-800';
case 'maintenance':
return 'bg-orange-100 text-orange-800';
default:
return 'bg-gray-100 text-gray-800';
}
};
if (loading) {
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50 dark:bg-gray-900">
<div className="animate-spin rounded-full h-32 w-32 border-b-2 border-indigo-600"></div>
</div>
);
}
return (
<div className="min-h-screen bg-gray-50 dark:bg-gray-900">
{/* Header */}
<header className="bg-white dark:bg-gray-800 shadow">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="flex justify-between items-center py-6">
<div className="flex items-center">
<h1 className="text-3xl font-bold text-gray-900 dark:text-white">Events</h1>
</div>
<div className="flex items-center space-x-4">
<button
onClick={loadEvents}
className="bg-indigo-600 hover:bg-indigo-700 text-white px-4 py-2 rounded-md text-sm font-medium"
>
Refresh Events
</button>
</div>
</div>
</div>
</header>
{/* Main Content */}
<main className="max-w-7xl mx-auto py-6 sm:px-6 lg:px-8">
{error && (
<div className="mb-6 rounded-md bg-red-50 dark:bg-red-900/20 p-4">
<div className="text-sm text-red-700 dark:text-red-400">{error}</div>
</div>
)}
{/* Events List */}
<div className="bg-white dark:bg-gray-800 shadow overflow-hidden sm:rounded-md">
<div className="px-4 py-5 sm:px-6">
<h3 className="text-lg leading-6 font-medium text-gray-900 dark:text-white">
Upcoming Events ({events.length})
</h3>
<p className="mt-1 max-w-2xl text-sm text-gray-500 dark:text-gray-400">
Events from your Frappe backend
</p>
</div>
{events.length === 0 ? (
<div className="text-center py-12">
<svg className="mx-auto h-12 w-12 text-gray-400 dark:text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
<h3 className="mt-2 text-sm font-medium text-gray-900 dark:text-white">No events found</h3>
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
No events are currently scheduled.
</p>
</div>
) : (
<ul className="divide-y divide-gray-200 dark:divide-gray-700">
{events.map((event) => (
<li key={event.name}>
<div className="px-4 py-4 hover:bg-gray-50 dark:hover:bg-gray-700">
<div className="flex items-center justify-between">
<div className="flex-1">
<div className="flex items-center justify-between">
<h4 className="text-lg font-medium text-gray-900 dark:text-white">
{event.subject}
</h4>
<div className="flex space-x-2">
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${getStatusColor(event.status)}`}>
{event.status}
</span>
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${getEventTypeColor(event.event_type)}`}>
{event.event_type}
</span>
</div>
</div>
{event.description && (
<p className="mt-1 text-sm text-gray-600 dark:text-gray-400">
{event.description}
</p>
)}
<div className="mt-2 flex items-center text-sm text-gray-500 dark:text-gray-400">
<svg className="flex-shrink-0 mr-1.5 h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<span>
{formatDate(event.starts_on)} - {formatDate(event.ends_on)}
</span>
</div>
</div>
</div>
</div>
</li>
))}
</ul>
)}
</div>
{/* API Information */}
<div className="mt-8 bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-md p-4">
<div className="flex">
<div className="flex-shrink-0">
<svg className="h-5 w-5 text-blue-400" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z" clipRule="evenodd" />
</svg>
</div>
<div className="ml-3">
<h3 className="text-sm font-medium text-blue-800 dark:text-blue-300">
API Endpoint Information
</h3>
<div className="mt-2 text-sm text-blue-700 dark:text-blue-400">
<p>
<strong>Endpoint:</strong> <code>frappe.desk.doctype.event.event.get_events</code>
</p>
<p>
<strong>Full URL:</strong> <code>https://seeraasm-med.seeraarabia.com/api/method/frappe.desk.doctype.event.event.get_events</code>
</p>
<p>
<strong>Method:</strong> POST (Frappe API standard)
</p>
</div>
</div>
</div>
</div>
</main>
</div>
);
};
export default EventsList;

View File

@ -0,0 +1,680 @@
import React, { useState, useEffect, useRef, useCallback } from 'react';
import { useParams, useNavigate, useSearchParams } from 'react-router-dom';
import { useItemDetails, useItemMutations } from '../hooks/useItem';
import { FaArrowLeft, FaSave, FaEdit, FaCheck, FaTrashAlt, FaSync } from 'react-icons/fa';
import type { CreateItemData } from '../services/itemService';
import LinkField from '../components/LinkField';
import API_CONFIG from '../config/api';
const ItemDetail: React.FC = () => {
const { itemName } = useParams<{ itemName: string }>();
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const duplicateFromItem = searchParams.get('duplicate');
const isNewItem = itemName === 'new';
const isDuplicating = isNewItem && !!duplicateFromItem;
// Balance Qty state (fetched from Bin doctype)
const [balanceQty, setBalanceQty] = useState<number>(0);
const [balanceQtyLoading, setBalanceQtyLoading] = useState<boolean>(false);
// Form data state
const [formData, setFormData] = useState<CreateItemData>({
item_code: '',
item_name: '',
item_group: '',
custom_hospital_name: '',
custom_part_description: '',
stock_uom: 'Nos',
custom_item_cost_per_unit: 0,
disabled: 0,
is_stock_item: 1,
opening_stock: 0,
valuation_rate: 0,
standard_rate: 0,
custom_last_calibration_date: '',
custom_next_due_calibration_date: '',
description: '',
brand: '',
custom_warranty_in_months: '',
valuation_method: '',
has_batch_no: 0,
has_serial_no: 0,
is_purchase_item: 1,
is_sales_item: 1,
country_of_origin: 'Saudi Arabia',
});
const { item, loading, error, refetch: refetchItem } = useItemDetails(
isDuplicating ? duplicateFromItem : (isNewItem ? null : itemName || null)
);
const { createItem, updateItem, submitItem, loading: saving } = useItemMutations();
const [isEditing, setIsEditing] = useState(isNewItem);
// Check document status
const docstatus = item?.docstatus ?? 0;
const isSubmitted = docstatus === 1;
const isCancelled = docstatus === 2;
const isDraft = docstatus === 0;
// Check if Calibration Information should be shown
const showCalibrationInfo = formData.item_group === 'Tools';
// Fetch Balance Qty from Bin doctype
const fetchBalanceQty = useCallback(async (itemCode: string) => {
if (!itemCode) return;
setBalanceQtyLoading(true);
try {
// Get CSRF token
let csrfToken: string | null = null;
if (typeof window !== 'undefined' && (window as any).csrf_token) {
csrfToken = (window as any).csrf_token;
}
// Build filters and fields for Frappe API
const filters = JSON.stringify([['item_code', '=', itemCode]]);
const fields = JSON.stringify(['actual_qty', 'warehouse']);
const url = `${API_CONFIG.BASE_URL}/api/resource/Bin?filters=${encodeURIComponent(filters)}&fields=${encodeURIComponent(fields)}&limit_page_length=0`;
const headers: Record<string, string> = {
'Accept': 'application/json',
'Content-Type': 'application/json',
};
if (csrfToken) {
headers['X-Frappe-CSRF-Token'] = csrfToken;
}
const response = await fetch(url, {
method: 'GET',
headers,
credentials: 'include', // Include cookies for session auth
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const result = await response.json();
// Sum up actual_qty from all warehouses
const totalQty = result.data?.reduce((sum: number, bin: any) => {
return sum + (bin.actual_qty || 0);
}, 0) || 0;
setBalanceQty(totalQty);
} catch (err) {
console.error('Failed to fetch balance qty:', err);
setBalanceQty(0);
} finally {
setBalanceQtyLoading(false);
}
}, []);
// Fetch balance qty when item is loaded (for existing items)
useEffect(() => {
if (!isNewItem && item?.item_code) {
fetchBalanceQty(item.item_code);
}
}, [isNewItem, item?.item_code, fetchBalanceQty]);
// Load item data when item is fetched
useEffect(() => {
if (item && !isDuplicating) {
setFormData({
item_code: item.item_code || '',
item_name: item.item_name || '',
item_group: item.item_group || '',
custom_hospital_name: item.custom_hospital_name || '',
custom_part_description: item.custom_part_description || '',
stock_uom: item.stock_uom || 'Nos',
custom_item_cost_per_unit: item.custom_item_cost_per_unit || 0,
disabled: item.disabled || 0,
is_stock_item: item.is_stock_item ?? 1,
opening_stock: item.opening_stock || 0,
valuation_rate: item.valuation_rate || 0,
standard_rate: item.standard_rate || 0,
custom_last_calibration_date: item.custom_last_calibration_date || '',
custom_next_due_calibration_date: item.custom_next_due_calibration_date || '',
description: item.description || '',
brand: item.brand || '',
custom_warranty_in_months: item.custom_warranty_in_months || '',
valuation_method: item.valuation_method || '',
has_batch_no: item.has_batch_no || 0,
has_serial_no: item.has_serial_no || 0,
is_purchase_item: item.is_purchase_item ?? 1,
is_sales_item: item.is_sales_item ?? 1,
country_of_origin: item.country_of_origin || 'Saudi Arabia',
uoms: item.uoms || [],
item_defaults: item.item_defaults || [],
});
setIsEditing(false);
} else if (isDuplicating && item) {
// When duplicating, copy data but clear name/code
setFormData({
item_code: '',
item_name: item.item_name || '',
item_group: item.item_group || '',
custom_hospital_name: item.custom_hospital_name || '',
custom_part_description: item.custom_part_description || '',
stock_uom: item.stock_uom || 'Nos',
custom_item_cost_per_unit: item.custom_item_cost_per_unit || 0,
disabled: 0,
is_stock_item: item.is_stock_item ?? 1,
opening_stock: item.opening_stock || 0,
valuation_rate: item.valuation_rate || 0,
standard_rate: item.standard_rate || 0,
custom_last_calibration_date: item.custom_last_calibration_date || '',
custom_next_due_calibration_date: item.custom_next_due_calibration_date || '',
description: item.description || '',
brand: item.brand || '',
custom_warranty_in_months: item.custom_warranty_in_months || '',
valuation_method: item.valuation_method || '',
has_batch_no: item.has_batch_no || 0,
has_serial_no: item.has_serial_no || 0,
is_purchase_item: item.is_purchase_item ?? 1,
is_sales_item: item.is_sales_item ?? 1,
country_of_origin: item.country_of_origin || 'Saudi Arabia',
uoms: item.uoms || [],
item_defaults: item.item_defaults || [],
});
}
}, [item, isDuplicating]);
const handleSave = async () => {
try {
if (isNewItem) {
const newItem = await createItem(formData);
navigate(`/inventory/${newItem.name}`);
} else {
await updateItem(itemName!, formData);
await refetchItem();
// Refresh balance qty after update
if (formData.item_code) {
fetchBalanceQty(formData.item_code);
}
setIsEditing(false);
alert('Item updated successfully!');
}
} catch (err) {
alert(`Failed to save: ${err instanceof Error ? err.message : 'Unknown error'}`);
}
};
const handleSubmit = async () => {
if (!itemName || isNewItem) {
alert('Please save the item first before submitting.');
return;
}
try {
await submitItem(itemName);
await refetchItem();
setIsEditing(false);
alert('Item submitted successfully!');
} catch (err) {
alert(`Failed to submit: ${err instanceof Error ? err.message : 'Unknown error'}`);
}
};
const isFieldDisabled = useCallback((fieldname: string): boolean => {
if (!isEditing) return true;
if (isCancelled) return true;
if (isSubmitted) {
// For submitted items, most fields are read-only
// Only allow editing certain fields if needed
return true;
}
return false;
}, [isEditing, isCancelled, isSubmitted]);
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 item...</p>
</div>
</div>
);
}
if (error && !isNewItem) {
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-6">
<h2 className="text-xl font-bold text-red-800 dark:text-red-300 mb-4">Error Loading Item</h2>
<p className="text-red-700 dark:text-red-400 mb-4">{error}</p>
<button
onClick={() => navigate('/inventory')}
className="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded"
>
Back to Inventory
</button>
</div>
</div>
);
}
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 className="flex items-center gap-4">
<button
onClick={() => navigate('/inventory')}
className="text-gray-600 dark:text-gray-400 hover:text-gray-800 dark:hover:text-gray-200"
>
<FaArrowLeft size={20} />
</button>
<div>
<h1 className="text-3xl font-bold text-gray-800 dark:text-white">
{isNewItem ? 'New Item' : item?.item_name || item?.item_code || 'Item'}
</h1>
<p className="text-gray-600 dark:text-gray-400 mt-1">
{isNewItem ? 'Create a new item' : `Item Code: ${item?.item_code || itemName}`}
</p>
</div>
</div>
<div className="flex gap-3">
{!isNewItem && !isEditing && isDraft && (
<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>
)}
{isEditing && (
<>
<button
onClick={() => {
if (isNewItem) {
navigate('/inventory');
} else {
setIsEditing(false);
refetchItem();
}
}}
className="bg-gray-600 hover:bg-gray-700 text-white px-4 py-2 rounded-lg"
>
Cancel
</button>
<button
onClick={handleSave}
disabled={saving}
className="bg-green-600 hover:bg-green-700 text-white px-4 py-2 rounded-lg flex items-center gap-2 disabled:opacity-50"
>
<FaSave />
{saving ? 'Saving...' : 'Save'}
</button>
{/* {!isNewItem && isDraft && (
<button
onClick={handleSubmit}
disabled={saving}
className="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-lg flex items-center gap-2 disabled:opacity-50"
>
<FaCheck />
Submit
</button>
)} */}
</>
)}
</div>
</div>
{/* Form */}
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{/* Basic Information */}
<div className="md:col-span-2">
<h2 className="text-xl font-semibold text-gray-800 dark:text-white mb-4">Basic Information</h2>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Item Code <span className="text-red-500">*</span>
</label>
<input
type="text"
value={formData.item_code}
onChange={(e) => setFormData({ ...formData, item_code: e.target.value })}
disabled={isFieldDisabled('item_code') || !isNewItem}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-700 text-gray-900 dark:text-white disabled:bg-gray-100 dark:disabled:bg-gray-800"
required
/>
</div>
{/* <div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Item Name <span className="text-red-500">*</span>
</label>
<input
type="text"
value={formData.item_name}
onChange={(e) => setFormData({ ...formData, item_name: e.target.value })}
disabled={isFieldDisabled('item_name')}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-700 text-gray-900 dark:text-white disabled:bg-gray-100 dark:disabled:bg-gray-800"
required
/>
</div> */}
<div>
{/* <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Hospital Name <span className="text-red-500">*</span>
</label> */}
<LinkField
label="Hospital"
doctype="Company"
value={formData.custom_hospital_name || ''}
onChange={(value) => setFormData({ ...formData, custom_hospital_name: value })}
disabled={isFieldDisabled('custom_hospital_name')}
placeholder="Select Hospital"
filters={{ domain: 'Healthcare' }}/>
</div>
<div>
{/* <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Item Group
</label> */}
<LinkField
label="Item Group"
doctype="Item Group"
value={formData.item_group || ''}
onChange={(value) => setFormData({ ...formData, item_group: value })}
disabled={isFieldDisabled('item_group')}
placeholder="Select item group"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Stock UOM
</label>
<input
type="text"
value={formData.stock_uom}
onChange={(e) => setFormData({ ...formData, stock_uom: e.target.value })}
disabled={isFieldDisabled('stock_uom')}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-700 text-gray-900 dark:text-white disabled:bg-gray-100 dark:disabled:bg-gray-800"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Part Description
</label>
<input
type="text"
value={formData.custom_part_description}
onChange={(e) => setFormData({ ...formData, custom_part_description: e.target.value })}
disabled={isFieldDisabled('custom_part_description')}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-700 text-gray-900 dark:text-white disabled:bg-gray-100 dark:disabled:bg-gray-800"
/>
</div>
{/* <div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Brand
</label>
<input
type="text"
value={formData.brand}
onChange={(e) => setFormData({ ...formData, brand: e.target.value })}
disabled={isFieldDisabled('brand')}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-700 text-gray-900 dark:text-white disabled:bg-gray-100 dark:disabled:bg-gray-800"
/>
</div> */}
{/* Stock Information */}
<div className="md:col-span-2 mt-6">
<h2 className="text-xl font-semibold text-gray-800 dark:text-white mb-4">Stock Information</h2>
</div>
<div className="md:col-span-2">
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 items-end">
{/* Is Stock Item */}
<div className="flex items-center gap-2 h-[42px]">
<input
type="checkbox"
id="is_stock_item"
checked={formData.is_stock_item === 1}
onChange={(e) => setFormData({ ...formData, is_stock_item: e.target.checked ? 1 : 0 })}
disabled={isFieldDisabled('is_stock_item')}
className="w-4 h-4"
/>
<label htmlFor="is_stock_item" className="text-sm font-medium text-gray-700 dark:text-gray-300">
Is Stock Item
</label>
</div>
{/* Opening Stock - Only show for NEW items when is_stock_item is checked */}
{isNewItem && formData.is_stock_item === 1 && (
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Opening Stock
</label>
<input
type="number"
value={formData.opening_stock}
onChange={(e) => setFormData({ ...formData, opening_stock: parseFloat(e.target.value) || 0 })}
disabled={isFieldDisabled('opening_stock')}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-700 text-gray-900 dark:text-white disabled:bg-gray-100 dark:disabled:bg-gray-800"
/>
</div>
)}
{/* Valuation Rate - Only show for NEW items when is_stock_item is checked */}
{isNewItem && formData.is_stock_item === 1 && (
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Valuation Rate
</label>
<input
type="number"
step="0.01"
value={formData.valuation_rate}
onChange={(e) => setFormData({ ...formData, valuation_rate: parseFloat(e.target.value) || 0 })}
disabled={isFieldDisabled('valuation_rate')}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-700 text-gray-900 dark:text-white disabled:bg-gray-100 dark:disabled:bg-gray-800"
/>
</div>
)}
{/* Balance Qty - Only show for EXISTING items when is_stock_item is checked */}
{!isNewItem && formData.is_stock_item === 1 && (
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Balance Qty
</label>
<div className="flex items-center gap-2">
<input
type="number"
value={balanceQty}
readOnly
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md bg-gray-100 dark:bg-gray-800 text-gray-900 dark:text-white cursor-not-allowed"
/>
<button
type="button"
onClick={() => formData.item_code && fetchBalanceQty(formData.item_code)}
disabled={balanceQtyLoading}
className="p-2 text-blue-600 hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300 disabled:opacity-50"
title="Refresh Balance Qty"
>
<FaSync className={balanceQtyLoading ? 'animate-spin' : ''} />
</button>
</div>
</div>
)}
</div>
</div>
{/* <div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Standard Rate
</label>
<input
type="number"
step="0.01"
value={formData.standard_rate}
onChange={(e) => setFormData({ ...formData, standard_rate: parseFloat(e.target.value) || 0 })}
disabled={isFieldDisabled('standard_rate')}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-700 text-gray-900 dark:text-white disabled:bg-gray-100 dark:disabled:bg-gray-800"
/>
</div> */}
{/* Calibration Information - Only show when Item Group is "Tools" */}
{showCalibrationInfo && (
<>
<div className="md:col-span-2 mt-6">
<h2 className="text-xl font-semibold text-gray-800 dark:text-white mb-4">Calibration Information</h2>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Last Calibration Date
</label>
<input
type="date"
value={formData.custom_last_calibration_date}
onChange={(e) => setFormData({ ...formData, custom_last_calibration_date: e.target.value })}
disabled={isFieldDisabled('custom_last_calibration_date')}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-700 text-gray-900 dark:text-white disabled:bg-gray-100 dark:disabled:bg-gray-800"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Next Due Calibration Date
</label>
<input
type="date"
value={formData.custom_next_due_calibration_date}
onChange={(e) => setFormData({ ...formData, custom_next_due_calibration_date: e.target.value })}
disabled={isFieldDisabled('custom_next_due_calibration_date')}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-700 text-gray-900 dark:text-white disabled:bg-gray-100 dark:disabled:bg-gray-800"
/>
</div>
</>
)}
{/* Additional Information */}
<div className="md:col-span-2 mt-6">
<h2 className="text-xl font-semibold text-gray-800 dark:text-white mb-4">Additional Information</h2>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Description
</label>
<textarea
value={formData.description}
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
disabled={isFieldDisabled('description')}
rows={3}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-700 text-gray-900 dark:text-white disabled:bg-gray-100 dark:disabled:bg-gray-800"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Warranty (Months)
</label>
<input
type="text"
value={formData.custom_warranty_in_months}
onChange={(e) => setFormData({ ...formData, custom_warranty_in_months: e.target.value })}
disabled={isFieldDisabled('custom_warranty_in_months')}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-700 text-gray-900 dark:text-white disabled:bg-gray-100 dark:disabled:bg-gray-800"
/>
</div>
{/* <div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Country of Origin
</label>
<input
type="text"
value={formData.country_of_origin}
onChange={(e) => setFormData({ ...formData, country_of_origin: e.target.value })}
disabled={isFieldDisabled('country_of_origin')}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-700 text-gray-900 dark:text-white disabled:bg-gray-100 dark:disabled:bg-gray-800"
/>
</div> */}
{/* <div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Is Purchase Item
</label>
<input
type="checkbox"
checked={formData.is_purchase_item === 1}
onChange={(e) => setFormData({ ...formData, is_purchase_item: e.target.checked ? 1 : 0 })}
disabled={isFieldDisabled('is_purchase_item')}
className="w-4 h-4"
/>
</div> */}
{/* <div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Is Sales Item
</label>
<input
type="checkbox"
checked={formData.is_sales_item === 1}
onChange={(e) => setFormData({ ...formData, is_sales_item: e.target.checked ? 1 : 0 })}
disabled={isFieldDisabled('is_sales_item')}
className="w-4 h-4"
/>
</div> */}
{/* <div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Has Batch No
</label>
<input
type="checkbox"
checked={formData.has_batch_no === 1}
onChange={(e) => setFormData({ ...formData, has_batch_no: e.target.checked ? 1 : 0 })}
disabled={isFieldDisabled('has_batch_no')}
className="w-4 h-4"
/>
</div> */}
{/* <div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Has Serial No
</label>
<input
type="checkbox"
checked={formData.has_serial_no === 1}
onChange={(e) => setFormData({ ...formData, has_serial_no: e.target.checked ? 1 : 0 })}
disabled={isFieldDisabled('has_serial_no')}
className="w-4 h-4"
/>
</div> */}
{/* <div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Disabled
</label>
<input
type="checkbox"
checked={formData.disabled === 1}
onChange={(e) => setFormData({ ...formData, disabled: e.target.checked ? 1 : 0 })}
disabled={isFieldDisabled('disabled')}
className="w-4 h-4"
/>
</div> */}
</div>
</div>
</div>
);
};
export default ItemDetail;

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,49 @@
import { useKycDetails } from '../hooks/useApi';
// Define interfaces locally
interface KycRecord {
name: string;
kyc_status: string;
kyc_type: string;
creation: string;
modified: string;
}
export default function KYCDetails() {
const { data: kycData, loading, error } = useKycDetails();
if (loading) {
return (
<div className="p-6 min-h-screen bg-gray-50 dark:bg-gray-900">
<div className="animate-spin rounded-full h-32 w-32 border-b-2 border-indigo-600 mx-auto"></div>
</div>
);
}
if (error) {
return (
<div className="p-6 min-h-screen bg-gray-50 dark:bg-gray-900">
<div className="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-md p-4">
<div className="text-sm text-red-700 dark:text-red-400">Error: {error}</div>
</div>
</div>
);
}
return (
<div className="p-6 min-h-screen bg-gray-50 dark:bg-gray-900">
<h1 className="text-xl font-semibold mb-4 text-gray-900 dark:text-white">KYC Details</h1>
{kycData?.records?.map((item: KycRecord) => (
<div key={item.name} className="p-3 border border-gray-300 dark:border-gray-700 rounded-xl mb-2 bg-white dark:bg-gray-800">
<p className="text-gray-900 dark:text-white"><b>Type:</b> {item.kyc_type}</p>
<p className="text-gray-900 dark:text-white"><b>Status:</b> {item.kyc_status}</p>
<p className="text-gray-900 dark:text-white"><b>Created:</b> {new Date(item.creation).toLocaleDateString()}</p>
</div>
)) || (
<div className="text-center py-8 text-gray-500 dark:text-gray-400">
No KYC records found
</div>
)}
</div>
);
}

415
asm_app/src/pages/Login.tsx Normal file
View File

@ -0,0 +1,415 @@
import React, { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { useLanguage } from '../contexts/LanguageContext';
import { loadFrappeTranslations } from '../i18n';
interface LoginFormData {
email: string;
password: string;
}
const Login: React.FC = () => {
const [formData, setFormData] = useState<LoginFormData>({
email: '',
password: '',
});
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const navigate = useNavigate();
const { t } = useTranslation();
const { isRTL } = useLanguage();
// Get base URL for assets
const baseUrl = import.meta.env.BASE_URL || '/';
const logoVersion = import.meta.env.DEV
? `?v=${Date.now()}`
: `?v=1765198405`; // Auto-updated by build script
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const { name, value } = e.target;
setFormData(prev => ({
...prev,
[name]: value,
}));
setError(null);
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setLoading(true);
setError(null);
try {
// Dynamic import to catch any module loading errors
const { useAuth } = await import('../hooks/useApi');
const apiService = (await import('../services/apiService')).default;
const response = await apiService.login(formData);
if (response && response.message) {
const userData = {
...response.message,
email: formData.email
};
localStorage.setItem('user', JSON.stringify(userData));
if (response.message.sid) {
apiService.setSessionId(response.message.sid);
}
// Load translations from Frappe after successful login
try {
await loadFrappeTranslations();
} catch (err) {
console.warn('Could not load translations after login:', err);
}
navigate('/dashboard');
} else {
setError(t('login.loginFailed'));
}
} catch (err: any) {
console.error('Login error:', err);
setError(err.message || t('login.loginFailed'));
} finally {
setLoading(false);
}
};
const handleDemoLogin = async () => {
const demoUser = {
full_name: 'Demo User',
email: 'demo@seeraarabia.com',
user_image: '',
roles: ['System Manager', 'Administrator']
};
localStorage.setItem('user', JSON.stringify(demoUser));
// Load translations from Frappe after demo login
try {
await loadFrappeTranslations();
} catch (err) {
console.warn('Could not load translations after demo login:', err);
}
navigate('/dashboard');
};
return (
<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>
<div className="flex justify-center mb-6">
<div className="w-32 h-32 flex items-center justify-center bg-white dark:bg-gray-800 rounded-2xl shadow-2xl p-4">
<img
src={`${baseUrl}${baseUrl.endsWith('/') ? '' : '/'}seera-logo.png${logoVersion}`}
alt="Seera Arabia"
className="w-full h-full object-contain"
onError={(e) => {
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';
const nextSibling = e.currentTarget.nextElementSibling;
if (nextSibling) {
nextSibling.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">
{t('login.title')}
</h2>
<p className="mt-2 text-center text-sm font-medium text-indigo-600 dark:text-indigo-400">
{t('login.subtitle')}
</p>
<p className="mt-1 text-center text-xs text-gray-600 dark:text-gray-400">
{t('login.signIn')}
</p>
</div>
<form className="mt-8 space-y-6" onSubmit={handleSubmit}>
<div className="rounded-md shadow-sm -space-y-px">
<div>
<label htmlFor="email" className="sr-only">
{t('common.email')}
</label>
<input
id="email"
name="email"
type="email"
required
className="appearance-none rounded-none relative block w-full px-3 py-2 border border-gray-300 dark:border-gray-600 placeholder-gray-500 dark:placeholder-gray-400 text-gray-900 dark:text-white bg-white dark:bg-gray-800 rounded-t-md focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 focus:z-10 sm:text-sm"
placeholder={t('login.emailPlaceholder')}
value={formData.email}
onChange={handleChange}
/>
</div>
<div>
<label htmlFor="password" className="sr-only">
{t('common.password')}
</label>
<input
id="password"
name="password"
type="password"
required
className="appearance-none rounded-none relative block w-full px-3 py-2 border border-gray-300 dark:border-gray-600 placeholder-gray-500 dark:placeholder-gray-400 text-gray-900 dark:text-white bg-white dark:bg-gray-800 rounded-b-md focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 focus:z-10 sm:text-sm"
placeholder={t('login.passwordPlaceholder')}
value={formData.password}
onChange={handleChange}
/>
</div>
</div>
{error && (
<div className="rounded-md bg-red-50 dark:bg-red-900/20 p-4">
<div className="text-sm text-red-700 dark:text-red-400">{error}</div>
</div>
)}
<div className="space-y-3">
<button
type="submit"
disabled={loading}
className="group relative w-full flex justify-center py-2 px-4 border border-transparent text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-50 disabled:cursor-not-allowed"
>
{loading ? (
<div className="flex items-center">
<svg className="animate-spin -ml-1 mr-3 h-5 w-5 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
{t('common.loading')}
</div>
) : (
t('common.login')
)}
</button>
<div className="relative">
<div className="absolute inset-0 flex items-center">
<div className="w-full border-t border-gray-300 dark:border-gray-600" />
</div>
<div className="relative flex justify-center text-sm">
<span className="px-2 bg-gray-50 dark:bg-gray-900 text-gray-500 dark:text-gray-400">or</span>
</div>
</div>
<button
type="button"
onClick={handleDemoLogin}
className="w-full flex justify-center py-2 px-4 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm 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 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
>
🚀 {t('login.demoLogin')}
</button>
</div>
</form>
</div>
</div>
);
};
export default Login;
// import React, { useState } from 'react';
// import { useNavigate } from 'react-router-dom';
// import { useAuth } from '../hooks/useApi';
// interface LoginFormData {
// email: string;
// password: string;
// }
// const Login: React.FC = () => {
// const [formData, setFormData] = useState<LoginFormData>({
// email: '',
// password: '',
// });
// const [loading, setLoading] = useState(false);
// const [error, setError] = useState<string | null>(null);
// const navigate = useNavigate();
// const { login } = useAuth();
// const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
// const { name, value } = e.target;
// setFormData(prev => ({
// ...prev,
// [name]: value,
// }));
// setError(null);
// };
// const handleSubmit = async (e: React.FormEvent) => {
// e.preventDefault();
// setLoading(true);
// setError(null);
// try {
// const response = await login(formData);
// if (response && response.message) {
// // Store user info in localStorage with email field
// const userData = {
// ...response.message,
// email: formData.email // Ensure email is stored
// };
// localStorage.setItem('user', JSON.stringify(userData));
// navigate('/dashboard');
// } else {
// setError('Login failed. Please check your credentials.');
// }
// } catch (err: any) {
// setError(err.message || 'Login failed. Please try again.');
// } finally {
// setLoading(false);
// }
// };
// const handleDemoLogin = () => {
// // Create dummy user data for demo purposes
// const demoUser = {
// full_name: 'Demo User',
// email: 'demo@seeraarabia.com',
// user_image: '',
// roles: ['System Manager', 'Administrator']
// };
// // Store demo user in localStorage
// localStorage.setItem('user', JSON.stringify(demoUser));
// navigate('/dashboard');
// };
// return (
// <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>
// <div className="flex justify-center mb-6">
// <div className="w-32 h-32 flex items-center justify-center bg-white dark:bg-gray-800 rounded-2xl shadow-2xl p-4">
// {/* Seera Arabia Logo */}
// <img
// src="/seera-logo.png?v=1765198405"
// alt="Seera Arabia"
// className="w-full h-full object-contain"
// onError={(e) => {
// // Fallback to gradient background with SVG if image not found
// const container = e.currentTarget.parentElement;
// if (container) {
// container.classList.add('bg-gradient-to-br', 'from-indigo-600', 'to-purple-600');
// }
// e.currentTarget.style.display = 'none';
// e.currentTarget.nextElementSibling?.classList.remove('hidden');
// }}
// />
// <svg className="w-20 h-20 hidden" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
// <path d="M12 2L2 7L12 12L22 7L12 2Z" fill="white" fillOpacity="0.9"/>
// <path d="M2 17L12 22L22 17V12L12 17L2 12V17Z" fill="white" fillOpacity="0.7"/>
// <path d="M12 12V17" stroke="white" strokeWidth="2" strokeLinecap="round"/>
// </svg>
// </div>
// </div>
// <h2 className="text-center text-3xl font-semibold text-gray-900 dark:text-white">
// Seera Arabia
// </h2>
// <p className="mt-2 text-center text-sm font-medium text-indigo-600 dark:text-indigo-400">
// Asset Management System
// </p>
// <p className="mt-1 text-center text-xs text-gray-600 dark:text-gray-400">
// Sign in to continue
// </p>
// </div>
// <form className="mt-8 space-y-6" onSubmit={handleSubmit}>
// <div className="rounded-md shadow-sm -space-y-px">
// <div>
// <label htmlFor="email" className="sr-only">
// Email
// </label>
// <input
// id="email"
// name="email"
// type="email"
// required
// className="appearance-none rounded-none relative block w-full px-3 py-2 border border-gray-300 dark:border-gray-600 placeholder-gray-500 dark:placeholder-gray-400 text-gray-900 dark:text-white bg-white dark:bg-gray-800 rounded-t-md focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 focus:z-10 sm:text-sm"
// placeholder="Email"
// value={formData.email}
// onChange={handleChange}
// />
// </div>
// <div>
// <label htmlFor="password" className="sr-only">
// Password
// </label>
// <input
// id="password"
// name="password"
// type="password"
// required
// className="appearance-none rounded-none relative block w-full px-3 py-2 border border-gray-300 dark:border-gray-600 placeholder-gray-500 dark:placeholder-gray-400 text-gray-900 dark:text-white bg-white dark:bg-gray-800 rounded-b-md focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 focus:z-10 sm:text-sm"
// placeholder="Password"
// value={formData.password}
// onChange={handleChange}
// />
// </div>
// </div>
// {error && (
// <div className="rounded-md bg-red-50 dark:bg-red-900/20 p-4">
// <div className="text-sm text-red-700 dark:text-red-400">{error}</div>
// </div>
// )}
// <div className="space-y-3">
// <button
// type="submit"
// disabled={loading}
// className="group relative w-full flex justify-center py-2 px-4 border border-transparent text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-50 disabled:cursor-not-allowed"
// >
// {loading ? (
// <div className="flex items-center">
// <svg className="animate-spin -ml-1 mr-3 h-5 w-5 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
// <circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
// <path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
// </svg>
// Signing in...
// </div>
// ) : (
// 'Sign in'
// )}
// </button>
// <div className="relative">
// <div className="absolute inset-0 flex items-center">
// <div className="w-full border-t border-gray-300 dark:border-gray-600" />
// </div>
// <div className="relative flex justify-center text-sm">
// <span className="px-2 bg-gray-50 dark:bg-gray-900 text-gray-500 dark:text-gray-400">or</span>
// </div>
// </div>
// <button
// type="button"
// onClick={handleDemoLogin}
// className="w-full flex justify-center py-2 px-4 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm 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 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
// >
// 🚀 {t('login.demoLogin')}
// </button>
// </div>
// </form>
// </div>
// </div>
// );
// };
// export default Login;

View File

@ -0,0 +1,251 @@
import React, { useState, useEffect, useMemo } from 'react';
import { useNavigate, useSearchParams } from 'react-router-dom';
import { FaFilter, FaChevronDown, FaChevronUp, FaTimes, FaCalendarAlt, FaMap } from 'react-icons/fa';
import MaintenanceCalendar from '../components/MaintenanceCalendar';
import LinkField from '../components/LinkField';
const MaintenanceCalendarPage: React.FC = () => {
const navigate = useNavigate();
const [searchParams] = useSearchParams();
// Filter states
const [filterCompany, setFilterCompany] = useState('');
const [filterDepartment, setFilterDepartment] = useState('');
const [filterStatus, setFilterStatus] = useState('');
const [filterAssignTo, setFilterAssignTo] = useState('');
// Load filters from URL on mount
useEffect(() => {
const hospital = searchParams.get('hospital');
const status = searchParams.get('status');
if (hospital) setFilterCompany(hospital);
if (status) setFilterStatus(status);
}, [searchParams]);
// View type states
const [viewType, setViewType] = useState<'maintenance-log' | 'ppm-planner'>('maintenance-log');
// UI states
const [isFilterExpanded, setIsFilterExpanded] = useState(false);
const [activeFilterCount, setActiveFilterCount] = useState(0);
// Update active filter count
useEffect(() => {
const count = [filterCompany, filterDepartment, filterStatus, filterAssignTo].filter(Boolean).length;
setActiveFilterCount(count);
}, [filterCompany, filterDepartment, filterStatus, filterAssignTo]);
const handleClearFilters = () => {
setFilterCompany('');
setFilterDepartment('');
setFilterStatus('');
setFilterAssignTo('');
};
const hasActiveFilters = filterCompany || filterDepartment || filterStatus || filterAssignTo;
// Build filters for calendar - memoized to prevent object reference changes
const calendarFilters: Record<string, any> = useMemo(() => {
const filters: Record<string, any> = {};
if (filterCompany) filters['company'] = filterCompany;
if (filterDepartment) filters['department'] = filterDepartment;
if (filterStatus) filters['maintenance_status'] = filterStatus;
if (filterAssignTo) filters['assign_to_name'] = filterAssignTo;
return filters;
}, [filterCompany, filterDepartment, filterStatus, filterAssignTo]);
return (
<div className="flex flex-col h-screen bg-gray-50 dark:bg-gray-900 overflow-hidden">
{/* Header - Single Row on Desktop/Laptop */}
<div className="flex-shrink-0 bg-white dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700 px-4 py-2.5 lg:px-6">
<div className="flex flex-col md:flex-row md:items-center md:justify-between gap-3 md:gap-4">
{/* Left Side - Title */}
<div className="flex items-center gap-2.5 min-w-0">
<FaCalendarAlt className="text-blue-600 dark:text-blue-400 flex-shrink-0" size={22} />
<div className="min-w-0">
<h1 className="text-lg md:text-xl font-bold text-gray-800 dark:text-white whitespace-nowrap">
Maintenance Calendar
</h1>
</div>
</div>
{/* Right Side - All Controls in Single Row */}
<div className="flex items-end gap-2 md:gap-3 flex-wrap md:flex-nowrap">
{/* View Type Dropdown */}
<div className="relative">
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">
View Type
</label>
<select
value={viewType}
onChange={(e) => setViewType(e.target.value as 'maintenance-log' | 'ppm-planner')}
className="px-2.5 md:px-3 py-1.5 text-xs md:text-sm border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
>
<option value="maintenance-log">Maintenance Log</option>
<option value="ppm-planner">PPM Planner</option>
</select>
</div>
{/* Filters Button */}
<div className="relative">
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1 invisible">
Filters
</label>
<button
onClick={() => setIsFilterExpanded(!isFilterExpanded)}
className={`px-3 md:px-4 py-1.5 md:py-2 border rounded-lg transition-colors flex items-center gap-1.5 md:gap-2 text-xs md:text-sm ${
hasActiveFilters
? 'border-blue-500 bg-blue-50 dark:bg-blue-900/20 text-blue-600 dark:text-blue-400'
: 'border-gray-300 dark:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-700 text-gray-700 dark:text-gray-300'
}`}
>
<FaFilter size={14} />
<span className="hidden sm:inline">Filters</span>
{activeFilterCount > 0 && (
<span className="bg-blue-600 text-white rounded-full w-4 h-4 md:w-5 md:h-5 flex items-center justify-center text-[10px] md:text-xs">
{activeFilterCount}
</span>
)}
{isFilterExpanded ? <FaChevronUp size={12} /> : <FaChevronDown size={12} />}
</button>
</div>
{/* Yearly PPM Planner Button */}
{viewType === 'ppm-planner' && (
<div className="relative">
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1 invisible">
Yearly Map
</label>
<button
onClick={() => navigate('/yearly-ppm-planner')}
className="px-3 md:px-4 py-1.5 md:py-2 bg-purple-600 hover:bg-purple-700 text-white rounded-lg transition-colors flex items-center gap-1.5 md:gap-2 text-xs md:text-sm font-medium whitespace-nowrap"
title="View Yearly PPM Planner Map"
>
<FaMap size={14} />
<span className="hidden sm:inline">Yearly Map</span>
<span className="sm:hidden">Map</span>
</button>
</div>
)}
</div>
</div>
{/* Filter Panel */}
{isFilterExpanded && (
<div className="mt-2.5 md:mt-3 p-3 md:p-4 bg-gray-50 dark:bg-gray-700/50 rounded-lg border border-gray-200 dark:border-gray-600">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-3">
{/* Hospital */}
<div className="relative z-[60]">
<LinkField
label="Hospital"
doctype="Company"
value={filterCompany}
onChange={(val) => setFilterCompany(val)}
placeholder="Select Hospital"
compact={true}
/>
{filterCompany && (
<button
onClick={() => setFilterCompany('')}
className="absolute right-2 top-6 text-gray-400 hover:text-red-500 transition-colors z-10"
>
<FaTimes size={10} />
</button>
)}
</div>
{/* Department */}
<div className="relative z-[55]">
<LinkField
label="Department"
doctype="Department"
value={filterDepartment}
onChange={(val) => setFilterDepartment(val)}
placeholder="All Departments"
compact={true}
/>
{filterDepartment && (
<button
onClick={() => setFilterDepartment('')}
className="absolute right-2 top-6 text-gray-400 hover:text-red-500 transition-colors z-10"
>
<FaTimes size={10} />
</button>
)}
</div>
{/* Status */}
<div className="relative">
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">
Status
</label>
<select
value={filterStatus}
onChange={(e) => setFilterStatus(e.target.value)}
className="w-full px-3 py-1.5 text-sm border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
>
<option value="">All Statuses</option>
<option value="Planned">Planned</option>
<option value="Completed">Completed</option>
<option value="Overdue">Overdue</option>
<option value="Cancelled">Cancelled</option>
</select>
{filterStatus && (
<button
onClick={() => setFilterStatus('')}
className="absolute right-8 top-7 text-gray-400 hover:text-red-500 transition-colors"
>
<FaTimes size={10} />
</button>
)}
</div>
{/* Assigned To */}
<div className="relative z-[50]">
<LinkField
label="Assigned To"
doctype="User"
value={filterAssignTo}
onChange={(val) => setFilterAssignTo(val)}
placeholder="All Technicians"
compact={true}
/>
{filterAssignTo && (
<button
onClick={() => setFilterAssignTo('')}
className="absolute right-2 top-6 text-gray-400 hover:text-red-500 transition-colors z-10"
>
<FaTimes size={10} />
</button>
)}
</div>
</div>
{hasActiveFilters && (
<div className="mt-3 flex justify-end">
<button
onClick={handleClearFilters}
className="px-3 py-1.5 text-sm text-gray-600 dark:text-gray-400 hover:text-gray-800 dark:hover:text-gray-200 flex items-center gap-2"
>
<FaTimes />
Clear Filters
</button>
</div>
)}
</div>
)}
</div>
{/* Calendar */}
<div className="flex-1 overflow-hidden px-3 pb-3 lg:px-4 lg:pb-4">
<MaintenanceCalendar
filters={calendarFilters}
viewType={viewType}
timeView="day-month"
/>
</div>
</div>
);
};
export default MaintenanceCalendarPage;

File diff suppressed because it is too large Load Diff

View 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, FaTools } 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;

View File

@ -0,0 +1,443 @@
import React, { useState, useEffect, useRef } from 'react';
import { useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { usePPMs, usePPMMutations } from '../hooks/usePPM';
import { FaPlus, FaSearch, FaEdit, FaEye, FaTrash, FaCopy, FaEllipsisV, FaFileExport, FaCalendarCheck, FaBuilding } from 'react-icons/fa';
const PPMList: React.FC = () => {
const { t } = useTranslation();
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">{t('listPages.loading')}</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">{t('ppm.title')}</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">{t('listPages.export')}</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">{t('ppm.addPPM')}</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,812 @@
import React, { useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
import ppmPlannerService, { type PPMPlannerFilters, type BulkScheduleData } from '../services/ppmPlannerService';
import { FaFilter, FaCalendar, FaCheckCircle, FaSearch, FaArrowLeft, FaSpinner } from 'react-icons/fa';
import LinkField from '../components/LinkField';
// Updated Asset interface to match backend API response
interface Asset {
name: string;
asset_name: string;
custom_modality?: string;
company?: string;
custom_manufacturer?: string;
custom_device_status?: string;
custom_model?: string;
}
// Updated filters to match backend API parameters
interface AssetFilters {
company?: string;
custom_modality?: string;
custom_manufacturer?: string;
custom_device_status?: string;
custom_model?: string;
department?: string;
}
const PPMPlanner: React.FC = () => {
const { t } = useTranslation();
const navigate = useNavigate();
// Updated filters state to match backend API
const [filters, setFilters] = useState<AssetFilters>({});
const [selectedAssets, setSelectedAssets] = useState<string[]>([]);
const [scheduleData, setScheduleData] = useState({
start_date: '',
end_date: '',
maintenance_team: '',
assign_to: '',
pm_for: '',
maintenance_manager: '',
periodicity: 'Monthly',
maintenance_type: 'Preventive',
no_of_pms: '',
department: ''
});
const [loading, setLoading] = useState(false);
const [fetchingAssets, setFetchingAssets] = useState(false);
const [assets, setAssets] = useState<Asset[]>([]);
const [filterOptions, setFilterOptions] = useState({
modalities: [] as string[],
assetTypes: [] as string[],
departments: [] as string[],
locations: [] as string[],
manufacturers: [] as string[],
models: [] as string[],
company: [] as string[]
});
const [maintenanceTeams, setMaintenanceTeams] = useState<any[]>([]);
const [searchTerm, setSearchTerm] = useState('');
const [successResult, setSuccessResult] = useState<{
show: boolean;
document?: string;
schedules?: string[];
count: number;
type: 'pm_schedule' | 'maintenance_logs';
} | null>(null);
useEffect(() => {
loadFilterOptions();
loadMaintenanceTeams();
}, []);
// Helper function to calculate end_date based on start_date, periodicity, and no_of_pms
const calculateEndDate = (startDate: string, periodicity: string, noOfPms: string): string | null => {
if (!startDate || !periodicity || !noOfPms) {
return null;
}
const noOfPmsNum = parseInt(noOfPms, 10);
if (isNaN(noOfPmsNum) || noOfPmsNum < 1) {
return null;
}
// Start date is PM #1, so we need to add (no_of_pms - 1) periods
const occurrences = noOfPmsNum - 1;
if (occurrences < 0) {
return null;
}
const start = new Date(startDate);
const end = new Date(start);
switch (periodicity) {
case 'Daily':
end.setDate(end.getDate() + occurrences);
break;
case 'Weekly':
end.setDate(end.getDate() + (occurrences * 7));
break;
case 'Monthly':
end.setMonth(end.getMonth() + occurrences);
break;
case 'Quarterly':
end.setMonth(end.getMonth() + (occurrences * 3));
break;
case 'Half-yearly':
end.setMonth(end.getMonth() + (occurrences * 6));
break;
case 'Yearly':
end.setFullYear(end.getFullYear() + occurrences);
break;
case '2 Yearly':
end.setFullYear(end.getFullYear() + (occurrences * 2));
break;
case '3 Yearly':
end.setFullYear(end.getFullYear() + (occurrences * 3));
break;
default:
return null;
}
// Format as YYYY-MM-DD
const year = end.getFullYear();
const month = String(end.getMonth() + 1).padStart(2, '0');
const day = String(end.getDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
};
// Auto-populate maintenance_manager and assign_to when maintenance_team is selected
useEffect(() => {
const fetchTeamDetails = async () => {
if (scheduleData.maintenance_team) {
const teamDetails = await ppmPlannerService.getMaintenanceTeamDetails(scheduleData.maintenance_team);
if (teamDetails) {
setScheduleData(prev => ({
...prev,
maintenance_manager: teamDetails.maintenance_manager || '',
assign_to: (teamDetails.team_members && teamDetails.team_members.length === 1)
? teamDetails.team_members[0]
: prev.assign_to
}));
}
} else {
setScheduleData(prev => ({
...prev,
maintenance_manager: '',
assign_to: ''
}));
}
};
fetchTeamDetails();
}, [scheduleData.maintenance_team]);
// Auto-calculate end_date when start_date, periodicity, or no_of_pms changes
useEffect(() => {
if (scheduleData.start_date && scheduleData.periodicity && scheduleData.no_of_pms) {
const calculatedEndDate = calculateEndDate(
scheduleData.start_date,
scheduleData.periodicity,
scheduleData.no_of_pms
);
if (calculatedEndDate) {
setScheduleData(prev => ({
...prev,
end_date: calculatedEndDate
}));
}
}
}, [scheduleData.start_date, scheduleData.periodicity, scheduleData.no_of_pms]);
const loadFilterOptions = async () => {
const options = await ppmPlannerService.getFilterOptions();
setFilterOptions(options);
};
const loadMaintenanceTeams = async () => {
const teams = await ppmPlannerService.getMaintenanceTeams();
setMaintenanceTeams(teams);
};
// Updated fetchAssets to call the Frappe Server Script API
const fetchAssets = async () => {
setFetchingAssets(true);
try {
// Build query parameters matching backend API
const params = new URLSearchParams();
if (filters.company) {
params.append('company', filters.company);
}
if (filters.custom_modality) {
params.append('custom_modality', filters.custom_modality);
}
if (filters.custom_manufacturer) {
params.append('custom_manufacturer', filters.custom_manufacturer);
}
if (filters.custom_device_status) {
params.append('custom_device_status', filters.custom_device_status);
}
if (filters.custom_model) {
params.append('custom_model', filters.custom_model);
}
if (filters.department) {
params.append('department', filters.department);
}
// Call the Frappe Server Script API
const response = await fetch(`/api/method/get_assets?${params.toString()}`, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
},
credentials: 'include', // Important for Frappe session authentication
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const data = await response.json();
const filteredAssets: Asset[] = data.message || [];
setAssets(filteredAssets);
setSelectedAssets([]);
} catch (error) {
console.error('Error fetching assets:', error);
alert('Failed to fetch assets: ' + (error instanceof Error ? error.message : 'Unknown error'));
} finally {
setFetchingAssets(false);
}
};
const handleFilterChange = (key: keyof AssetFilters, value: string) => {
setFilters(prev => ({ ...prev, [key]: value || undefined }));
};
const toggleAssetSelection = (assetName: string) => {
setSelectedAssets(prev =>
prev.includes(assetName)
? prev.filter(name => name !== assetName)
: [...prev, assetName]
);
};
const handleSelectAll = () => {
const filteredAssets = getFilteredAssets();
if (selectedAssets.length === filteredAssets.length && filteredAssets.length > 0) {
setSelectedAssets([]);
} else {
setSelectedAssets(filteredAssets.map(a => a.name));
}
};
const getFilteredAssets = () => {
if (!searchTerm) return assets;
const term = searchTerm.toLowerCase();
return assets.filter(asset =>
asset.asset_name?.toLowerCase().includes(term) ||
asset.custom_modality?.toLowerCase().includes(term) ||
asset.company?.toLowerCase().includes(term) ||
asset.custom_manufacturer?.toLowerCase().includes(term) ||
asset.custom_model?.toLowerCase().includes(term) ||
asset.custom_device_status?.toLowerCase().includes(term)
);
};
const handleGenerateSchedule = async () => {
if (selectedAssets.length === 0) {
alert('Please select at least one asset');
return;
}
if (!filters.company) {
alert('Please select a Hospital/Company in the filters first');
return;
}
if (!scheduleData.pm_for) {
alert('Please enter a PM Name');
return;
}
if (!scheduleData.start_date || !scheduleData.end_date) {
alert('Please select start and end dates');
return;
}
if (new Date(scheduleData.start_date) > new Date(scheduleData.end_date)) {
alert('Start date must be before end date');
return;
}
// Require assign_to to avoid validation error when Asset Maintenance is auto-created
if (!scheduleData.assign_to) {
alert('Please assign the task to a team member. This is required for Asset Maintenance creation.');
return;
}
const confirmed = window.confirm(
`Are you sure you want to create maintenance schedules for ${selectedAssets.length} asset(s)?`
);
if (!confirmed) return;
setLoading(true);
try {
const bulkData: BulkScheduleData = {
asset_names: selectedAssets,
start_date: scheduleData.start_date,
end_date: scheduleData.end_date,
maintenance_team: scheduleData.maintenance_team || undefined,
assign_to: scheduleData.assign_to || undefined,
maintenance_manager: scheduleData.maintenance_manager || undefined,
periodicity: scheduleData.periodicity,
maintenance_type: scheduleData.maintenance_type,
no_of_pms: scheduleData.no_of_pms || undefined,
pm_for: scheduleData.pm_for || undefined,
hospital: filters.company!,
modality: filters.custom_modality,
manufacturer: filters.custom_manufacturer,
model: filters.custom_model,
department: scheduleData.department || filters.department || undefined,
};
const result = await ppmPlannerService.createBulkMaintenanceSchedules(bulkData);
setSuccessResult({
show: true,
document: result.document,
schedules: result.schedules,
count: result.created || selectedAssets.length,
type: 'pm_schedule' // Always PM Schedule Generator - Frappe will create maintenance logs automatically
});
setSelectedAssets([]);
setScheduleData({
start_date: '',
end_date: '',
maintenance_team: '',
assign_to: '',
pm_for: '',
maintenance_manager: '',
periodicity: 'Monthly',
maintenance_type: 'Preventive',
no_of_pms: ''
});
} catch (error) {
console.error('Error creating schedules:', error);
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
alert(`Failed to create maintenance schedules:\n\n${errorMessage}`);
} finally {
setLoading(false);
}
};
const filteredAssets = getFilteredAssets();
const hasActiveFilters = Object.values(filters).some(v => v);
return (
<div className="p-6 bg-gray-50 dark:bg-gray-900 min-h-screen">
{/* Header */}
<div className="mb-6 flex items-center gap-4">
<button
onClick={() => navigate('/ppm-planner')}
className="text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300 flex items-center gap-2"
>
<FaArrowLeft />
<span>Back to PPM Planner</span>
</button>
<h1 className="text-3xl font-bold text-gray-800 dark:text-white">
PPM Planner - Bulk Schedule Generator
</h1>
</div>
{/* Filter Section - Updated to match backend API */}
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-6 mb-6">
<h2 className="text-xl font-semibold mb-4 flex items-center gap-2 text-gray-800 dark:text-white">
<FaFilter /> Filter Assets
</h2>
<div className="grid grid-cols-2 md:grid-cols-3 gap-4">
{/* Company/Hospital - Required */}
<div>
<LinkField
label="Hospital/Company *"
doctype="Company"
value={filters.company || ''}
onChange={(val) => handleFilterChange('company', val)}
placeholder="Select a hospital/company"
/>
</div>
{/* Modality */}
<div>
<LinkField
label="Modality"
doctype="Modality"
value={filters.custom_modality || ''}
onChange={(val) => handleFilterChange('custom_modality', val)}
placeholder="Leave empty for all modalities"
/>
</div>
{/* Manufacturer */}
<div>
<LinkField
label="Manufacturer"
doctype="Manufacturer"
value={filters.custom_manufacturer || ''}
onChange={(val) => handleFilterChange('custom_manufacturer', val)}
placeholder="Leave empty for all manufacturers"
/>
</div>
{/* Device Status */}
<div>
<label className="block text-sm font-medium mb-2 text-gray-700 dark:text-gray-300">
Device Status
</label>
<select
value={filters.custom_device_status || ''}
onChange={(e) => handleFilterChange('custom_device_status', e.target.value)}
className="w-full px-3 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="Active">Active</option>
<option value="Inactive">Inactive</option>
<option value="Under Maintenance">Under Maintenance</option>
<option value="Decommissioned">Decommissioned</option>
</select>
</div>
{/* Model */}
<div>
<label className="block text-sm font-medium mb-2 text-gray-700 dark:text-gray-300">
Model
</label>
<select
value={filters.custom_model || ''}
onChange={(e) => handleFilterChange('custom_model', e.target.value)}
className="w-full px-3 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="">Select Model (optional)</option>
{filterOptions.models.map(mod => (
<option key={mod} value={mod}>{mod}</option>
))}
</select>
</div>
{/* Department */}
<div>
<LinkField
label="Department"
doctype="Department"
value={filters.department || ''}
onChange={(val) => handleFilterChange('department', val)}
placeholder="Select department (optional)"
/>
</div>
</div>
<div className="mt-4 flex gap-3">
<button
onClick={fetchAssets}
disabled={fetchingAssets}
className="bg-blue-600 hover:bg-blue-700 text-white px-6 py-2 rounded-lg flex items-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed"
>
{fetchingAssets ? (
<>
<FaSpinner className="animate-spin" />
Loading...
</>
) : (
<>
<FaSearch />
Fetch Assets
</>
)}
</button>
{hasActiveFilters && (
<button
onClick={() => {
setFilters({});
setAssets([]);
setSelectedAssets([]);
}}
className="bg-gray-600 hover:bg-gray-700 text-white px-6 py-2 rounded-lg"
>
Clear Filters
</button>
)}
</div>
</div>
{/* Asset Selection Section - Updated table columns */}
{assets.length > 0 && (
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-6 mb-6">
<div className="flex justify-between items-center mb-4">
<h2 className="text-xl font-semibold text-gray-800 dark:text-white">
Select Assets ({selectedAssets.length} of {assets.length} selected)
</h2>
<div className="flex gap-3 items-center">
<div className="relative">
<FaSearch className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400" />
<input
type="text"
placeholder="Search assets..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="pl-10 pr-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>
<button
onClick={handleSelectAll}
className="text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300 px-4 py-2 border border-blue-600 dark:border-blue-400 rounded-lg"
>
{selectedAssets.length === filteredAssets.length && filteredAssets.length > 0 ? 'Deselect All' : 'Select All'}
</button>
</div>
</div>
<div className="max-h-96 overflow-y-auto border border-gray-200 dark:border-gray-700 rounded-lg">
<table className="w-full">
<thead className="bg-gray-100 dark:bg-gray-700 sticky top-0">
<tr>
<th className="text-left p-3 text-sm font-medium text-gray-700 dark:text-gray-300">
<input
type="checkbox"
checked={selectedAssets.length === filteredAssets.length && filteredAssets.length > 0}
onChange={handleSelectAll}
className="rounded"
/>
</th>
<th className="text-left p-3 text-sm font-medium text-gray-700 dark:text-gray-300">Asset Name</th>
<th className="text-left p-3 text-sm font-medium text-gray-700 dark:text-gray-300">Modality</th>
<th className="text-left p-3 text-sm font-medium text-gray-700 dark:text-gray-300">Manufacturer</th>
<th className="text-left p-3 text-sm font-medium text-gray-700 dark:text-gray-300">Model</th>
<th className="text-left p-3 text-sm font-medium text-gray-700 dark:text-gray-300">Status</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-200 dark:divide-gray-700">
{filteredAssets.length === 0 ? (
<tr>
<td colSpan={6} className="p-6 text-center text-gray-500 dark:text-gray-400">
No assets match your search criteria
</td>
</tr>
) : (
filteredAssets.map(asset => (
<tr
key={asset.name}
className={`hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-colors ${
selectedAssets.includes(asset.name) ? 'bg-blue-50 dark:bg-blue-900/20' : ''
}`}
>
<td className="p-3">
<input
type="checkbox"
checked={selectedAssets.includes(asset.name)}
onChange={() => toggleAssetSelection(asset.name)}
className="rounded"
/>
</td>
<td className="p-3 text-sm text-gray-900 dark:text-white font-medium">{asset.asset_name}</td>
<td className="p-3 text-sm text-gray-700 dark:text-gray-300">{asset.custom_modality || '-'}</td>
<td className="p-3 text-sm text-gray-700 dark:text-gray-300">{asset.custom_manufacturer || '-'}</td>
<td className="p-3 text-sm text-gray-700 dark:text-gray-300">{asset.custom_model || '-'}</td>
<td className="p-3 text-sm">
<span className={`px-2 py-1 rounded-full text-xs font-medium ${
asset.custom_device_status === 'Active'
? 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400'
: asset.custom_device_status === 'Inactive'
? 'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400'
: 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300'
}`}>
{asset.custom_device_status || '-'}
</span>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</div>
)}
{/* Schedule Configuration */}
{selectedAssets.length > 0 && (
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-6 mb-6">
<h2 className="text-xl font-semibold mb-4 flex items-center gap-2 text-gray-800 dark:text-white">
<FaCalendar /> Schedule Configuration
</h2>
<div className="grid grid-cols-2 md:grid-cols-3 gap-4">
<div>
<label className="block text-sm font-medium mb-2 text-gray-700 dark:text-gray-300">PPM Name *</label>
<input
type="text"
value={scheduleData.pm_for}
onChange={(e) => setScheduleData(prev => ({ ...prev, pm_for: e.target.value }))}
className="w-full px-3 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"
placeholder="Enter PM Name"
required
/>
</div>
<div>
<label className="block text-sm font-medium mb-2 text-gray-700 dark:text-gray-300">First PPM Date *</label>
<input
type="date"
value={scheduleData.start_date}
onChange={(e) => setScheduleData(prev => ({ ...prev, start_date: e.target.value }))}
className="w-full px-3 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"
required
/>
</div>
<div>
<label className="block text-sm font-medium mb-2 text-gray-700 dark:text-gray-300">Periodicity *</label>
<select
value={scheduleData.periodicity}
onChange={(e) => setScheduleData(prev => ({ ...prev, periodicity: e.target.value }))}
className="w-full px-3 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="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>
<option value="2 Yearly">2 Yearly</option>
<option value="3 Yearly">3 Yearly</option>
</select>
</div>
<div>
<label className="block text-sm font-medium mb-2 text-gray-700 dark:text-gray-300">Maintenance Type</label>
<select
value={scheduleData.maintenance_type}
onChange={(e) => setScheduleData(prev => ({ ...prev, maintenance_type: e.target.value }))}
className="w-full px-3 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="Preventive">Preventive</option>
<option value="Corrective">Corrective</option>
</select>
</div>
<div>
<label className="block text-sm font-medium mb-2 text-gray-700 dark:text-gray-300">No. of PMs</label>
<input
type="number"
value={scheduleData.no_of_pms}
onChange={(e) => setScheduleData(prev => ({ ...prev, no_of_pms: e.target.value }))}
min="1"
className="w-full px-3 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"
placeholder="Enter number of PMs"
/>
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
End date will be auto-calculated
</p>
</div>
<div>
<label className="block text-sm font-medium mb-2 text-gray-700 dark:text-gray-300">Last PPM Date *</label>
<input
type="date"
value={scheduleData.end_date}
onChange={(e) => setScheduleData(prev => ({ ...prev, end_date: e.target.value }))}
min={scheduleData.start_date}
className="w-full px-3 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"
required
/>
</div>
<div>
<LinkField
label="Maintenance Team"
doctype="Asset Maintenance Team"
value={scheduleData.maintenance_team}
onChange={(val) => setScheduleData(prev => ({ ...prev, maintenance_team: val }))}
/>
{scheduleData.maintenance_manager && (
<div className="mt-2 p-2 bg-blue-50 dark:bg-blue-900/20 rounded text-xs text-gray-600 dark:text-gray-400">
<span className="font-medium">Maintenance Manager:</span> {scheduleData.maintenance_manager}
</div>
)}
</div>
<div>
<LinkField
label="Assign To *"
doctype="User"
value={scheduleData.assign_to}
onChange={(val) => setScheduleData(prev => ({ ...prev, assign_to: val }))}
placeholder={scheduleData.maintenance_team ? "Select user (auto-selected if only one team member)" : "Select user to assign tasks"}
/>
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
Required for Asset Maintenance creation
</p>
{scheduleData.assign_to && (
<div className="mt-1 p-2 bg-green-50 dark:bg-green-900/20 rounded text-xs text-gray-600 dark:text-gray-400">
<span className="font-medium">Assigned To:</span> {scheduleData.assign_to}
</div>
)}
</div>
<div>
<LinkField
label="Department"
doctype="Department"
value={scheduleData.department}
onChange={(val) => setScheduleData(prev => ({ ...prev, department: val }))}
placeholder="Select department (optional)"
/>
</div>
</div>
<button
onClick={handleGenerateSchedule}
disabled={loading || !scheduleData.start_date || !scheduleData.end_date || !scheduleData.pm_for || !scheduleData.assign_to}
className="mt-6 bg-green-600 hover:bg-green-700 text-white px-6 py-3 rounded-lg flex items-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed font-medium"
>
{loading ? (
<>
<FaSpinner className="animate-spin" />
Creating Schedules...
</>
) : (
<>
<FaCheckCircle />
Generate Maintenance Schedules ({selectedAssets.length} asset{selectedAssets.length !== 1 ? 's' : ''})
</>
)}
</button>
</div>
)}
{/* Empty State */}
{assets.length === 0 && !fetchingAssets && !successResult?.show && (
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-12 text-center">
<FaFilter className="mx-auto text-4xl text-gray-400 dark:text-gray-600 mb-4" />
<h3 className="text-lg font-semibold text-gray-700 dark:text-gray-300 mb-2">
No Assets Loaded
</h3>
<p className="text-gray-500 dark:text-gray-400 mb-4">
Use the filters above to search for assets, then click "Fetch Assets" to load them.
</p>
<p className="text-sm text-blue-600 dark:text-blue-400">
Note: Only submitted assets without existing maintenance schedules will be shown.
</p>
</div>
)}
{/* Success Result Modal */}
{successResult?.show && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-2xl max-w-lg w-full p-6">
<div className="text-center mb-6">
<div className="w-16 h-16 bg-green-100 dark:bg-green-900/30 rounded-full flex items-center justify-center mx-auto mb-4">
<FaCheckCircle className="text-green-600 dark:text-green-400 text-3xl" />
</div>
<h2 className="text-2xl font-bold text-gray-900 dark:text-white mb-2">
Schedules Created Successfully!
</h2>
<p className="text-gray-600 dark:text-gray-400">
{successResult.count} maintenance schedule{successResult.count !== 1 ? 's' : ''} have been created.
</p>
</div>
<div className="bg-gray-50 dark:bg-gray-700/50 rounded-lg p-4 mb-6">
<h3 className="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-3">
What was created:
</h3>
<div className="space-y-2">
<div className="flex items-center gap-2">
<span className="text-sm text-gray-600 dark:text-gray-400">Document:</span>
<span className="text-sm font-medium text-blue-600 dark:text-blue-400">
{successResult.document}
</span>
</div>
<p className="text-xs text-gray-500 dark:text-gray-400">
A PM Schedule Generator document has been created with {successResult.count} asset(s).
Frappe will automatically create Asset Maintenance Logs when the document is submitted.
You can view and manage it in the PPM Planner section.
</p>
</div>
</div>
<div className="flex flex-col gap-3">
{successResult.document && (
<button
onClick={() => {
navigate(`/ppm-planner/${successResult.document}`);
setSuccessResult(null);
}}
className="w-full bg-blue-600 hover:bg-blue-700 text-white px-4 py-3 rounded-lg font-medium text-center flex items-center justify-center gap-2"
>
<FaCalendar />
View PPM Planner
</button>
)}
<button
onClick={() => navigate('/maintenance-calendar')}
className="w-full bg-purple-600 hover:bg-purple-700 text-white px-4 py-3 rounded-lg font-medium flex items-center justify-center gap-2"
>
<FaCalendar />
View Calendar
</button>
<button
onClick={() => setSuccessResult(null)}
className="w-full bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 text-gray-700 dark:text-gray-300 px-4 py-3 rounded-lg font-medium"
>
Create More Schedules
</button>
</div>
</div>
</div>
)}
</div>
);
};
export default PPMPlanner;

View File

@ -0,0 +1,999 @@
import React, { useState, useEffect } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { usePMScheduleDetails, usePMScheduleMutations } from '../hooks/usePMSchedule';
import { useAssetMaintenanceLogs } from '../hooks/useAssetMaintenance';
import { FaArrowLeft, FaSave, FaEdit, FaCalendarAlt, FaTrash, FaSpinner, FaCheckCircle } from 'react-icons/fa';
import LinkField from '../components/LinkField';
const PPMPlannerDetail: React.FC = () => {
const { scheduleName } = useParams<{ scheduleName: string }>();
const navigate = useNavigate();
const { pmSchedule, loading, error, refetch } = usePMScheduleDetails(scheduleName || null);
const { updatePMSchedule, deletePMSchedule, submitPMSchedule, cancelPMSchedule, loading: saving } = usePMScheduleMutations();
// Fetch all maintenance logs to link with assets
const { logs: maintenanceLogs } = useAssetMaintenanceLogs({}, 10000, 0);
const [isEditing, setIsEditing] = useState(false);
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false);
const [formData, setFormData] = useState({
hospital: '',
modality: '',
device_status: '',
start_date: '',
end_date: '',
maintenance_team: '',
maintenance_manager: '',
periodicity: 'Monthly',
assign_to: '',
due_date: '',
next_pm_date: '',
manufacturer: '',
model: '',
pm_for: '',
asset_name: '',
no_of_pms: ''
});
// Helper function to calculate next PM date for an asset
const calculateNextPMDateForAsset = (assetName: string, periodicity: string, defaultDueDate: string): string | null => {
if (!periodicity || !defaultDueDate) {
return null;
}
// Find maintenance logs for this asset
const assetLogs = maintenanceLogs.filter(log => log.asset_name === assetName);
// Find the most recent completed maintenance log
const completedLogs = assetLogs
.filter(log => log.maintenance_status === 'Completed' && log.completion_date)
.sort((a, b) => {
const dateA = new Date(a.completion_date || 0).getTime();
const dateB = new Date(b.completion_date || 0).getTime();
return dateB - dateA; // Most recent first
});
if (completedLogs.length === 0) {
// No completed logs, use the original due_date (First PM Date)
return defaultDueDate;
}
const lastCompletedLog = completedLogs[0];
const lastDueDate = new Date(lastCompletedLog.due_date || defaultDueDate);
const lastCompletionDate = new Date(lastCompletedLog.completion_date!);
// Calculate delay (days)
const delayDays = Math.max(0, Math.floor((lastCompletionDate.getTime() - lastDueDate.getTime()) / (1000 * 60 * 60 * 24)));
// Calculate next due date based on periodicity
const nextDueDate = new Date(lastCompletionDate);
switch (periodicity) {
case 'Daily':
nextDueDate.setDate(nextDueDate.getDate() + 1);
break;
case 'Weekly':
nextDueDate.setDate(nextDueDate.getDate() + 7);
break;
case 'Monthly':
nextDueDate.setMonth(nextDueDate.getMonth() + 1);
break;
case 'Quarterly':
nextDueDate.setMonth(nextDueDate.getMonth() + 3);
break;
case 'Half-yearly':
nextDueDate.setMonth(nextDueDate.getMonth() + 6);
break;
case 'Yearly':
nextDueDate.setFullYear(nextDueDate.getFullYear() + 1);
break;
case '2 Yearly':
nextDueDate.setFullYear(nextDueDate.getFullYear() + 2);
break;
case '3 Yearly':
nextDueDate.setFullYear(nextDueDate.getFullYear() + 3);
break;
default:
return defaultDueDate;
}
// Add the delay to the next due date
nextDueDate.setDate(nextDueDate.getDate() + delayDays);
// Format as YYYY-MM-DD
const year = nextDueDate.getFullYear();
const month = String(nextDueDate.getMonth() + 1).padStart(2, '0');
const day = String(nextDueDate.getDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
};
// Calculate overall next PM date (earliest among all assets) and update formData
useEffect(() => {
if (pmSchedule && pmSchedule.maintenance_entries && maintenanceLogs.length > 0) {
const nextDates: string[] = [];
pmSchedule.maintenance_entries.forEach((entry: any) => {
const nextDate = calculateNextPMDateForAsset(
entry.asset,
pmSchedule.periodicity || 'Monthly',
pmSchedule.due_date || ''
);
if (nextDate) {
nextDates.push(nextDate);
}
});
if (nextDates.length > 0) {
// Find the earliest next PM date
const earliestDate = nextDates.sort((a, b) => new Date(a).getTime() - new Date(b).getTime())[0];
// Update formData if it's different
setFormData(prev => {
if (prev.next_pm_date !== earliestDate) {
return {
...prev,
next_pm_date: earliestDate
};
}
return prev;
});
}
}
}, [pmSchedule, maintenanceLogs]);
useEffect(() => {
if (pmSchedule) {
setFormData({
hospital: pmSchedule.hospital || '',
modality: pmSchedule.modality || '',
device_status: pmSchedule.device_status || '',
start_date: pmSchedule.start_date || '',
end_date: pmSchedule.end_date || '',
maintenance_team: pmSchedule.maintenance_team || '',
maintenance_manager: pmSchedule.maintenance_manager || '',
periodicity: pmSchedule.periodicity || 'Monthly',
assign_to: pmSchedule.assign_to || '',
due_date: pmSchedule.due_date || '',
next_pm_date: pmSchedule.next_pm_date || '',
manufacturer: pmSchedule.manufacturer || '',
model: pmSchedule.model || '',
pm_for: pmSchedule.pm_for || '',
asset_name: pmSchedule.asset_name || '',
no_of_pms: pmSchedule.no_of_pms || ''
});
}
}, [pmSchedule]);
// Helper function to calculate end_date based on start_date, periodicity, and no_of_pms
const calculateEndDate = (startDate: string, periodicity: string, noOfPms: string): string | null => {
if (!startDate || !periodicity || !noOfPms) {
return null;
}
const noOfPmsNum = parseInt(noOfPms, 10);
if (isNaN(noOfPmsNum) || noOfPmsNum < 1) {
return null;
}
// Start date is PM #1, so we need to add (no_of_pms - 1) periods
const occurrences = noOfPmsNum - 1;
if (occurrences < 0) {
return null;
}
const start = new Date(startDate);
const end = new Date(start);
switch (periodicity) {
case 'Daily':
end.setDate(end.getDate() + occurrences);
break;
case 'Weekly':
end.setDate(end.getDate() + (occurrences * 7));
break;
case 'Monthly':
end.setMonth(end.getMonth() + occurrences);
break;
case 'Quarterly':
end.setMonth(end.getMonth() + (occurrences * 3));
break;
case 'Half-yearly':
end.setMonth(end.getMonth() + (occurrences * 6));
break;
case 'Yearly':
end.setFullYear(end.getFullYear() + occurrences);
break;
case '2 Yearly':
end.setFullYear(end.getFullYear() + (occurrences * 2));
break;
case '3 Yearly':
end.setFullYear(end.getFullYear() + (occurrences * 3));
break;
default:
return null;
}
// Format as YYYY-MM-DD
const year = end.getFullYear();
const month = String(end.getMonth() + 1).padStart(2, '0');
const day = String(end.getDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
};
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement>) => {
const { name, value } = e.target;
setFormData(prev => {
const updated = {
...prev,
[name]: value
};
// Auto-calculate end_date when start_date, periodicity, or no_of_pms changes
if (name === 'start_date' || name === 'periodicity' || name === 'no_of_pms') {
const calculatedEndDate = calculateEndDate(
name === 'start_date' ? value : prev.start_date,
name === 'periodicity' ? value : prev.periodicity,
name === 'no_of_pms' ? value : prev.no_of_pms
);
if (calculatedEndDate) {
updated.end_date = calculatedEndDate;
}
}
return updated;
});
};
const handleSave = async () => {
if (!scheduleName) return;
try {
await updatePMSchedule(scheduleName, formData);
setIsEditing(false);
refetch();
alert('PPM Planner updated successfully');
} catch (err) {
console.error('Error updating PPM Planner:', err);
alert('Failed to update PPM Planner');
}
};
const handleDelete = async () => {
if (!scheduleName) return;
try {
await deletePMSchedule(scheduleName);
navigate('/ppm-planner');
} catch (err) {
console.error('Error deleting PPM Planner:', err);
alert('Failed to delete PPM Planner');
}
};
const handleSubmit = async () => {
if (!scheduleName) return;
try {
await submitPMSchedule(scheduleName);
refetch();
alert('PPM Planner submitted successfully');
} catch (err) {
console.error('Error submitting PPM Planner:', err);
alert('Failed to submit PPM Planner');
}
};
const handleCancel = async () => {
if (!scheduleName) return;
try {
await cancelPMSchedule(scheduleName);
refetch();
alert('PPM Planner cancelled successfully');
} catch (err) {
console.error('Error cancelling PPM Planner:', err);
alert('Failed to cancel PPM Planner');
}
};
if (loading) {
return (
<div className="flex items-center justify-center h-screen bg-gray-50 dark:bg-gray-900">
<div className="flex items-center gap-3">
<FaSpinner className="animate-spin text-blue-600" size={24} />
<span className="text-gray-600 dark:text-gray-400">Loading PPM Planner...</span>
</div>
</div>
);
}
if (error) {
return (
<div className="flex items-center justify-center h-screen bg-gray-50 dark:bg-gray-900">
<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}</p>
<button
onClick={() => navigate('/ppm-planner')}
className="mt-4 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg transition-colors"
>
Back to List
</button>
</div>
</div>
);
}
if (!pmSchedule) {
return (
<div className="flex items-center justify-center h-screen bg-gray-50 dark:bg-gray-900">
<div className="bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-lg p-4">
<p className="text-yellow-600 dark:text-yellow-400">PPM Planner not found</p>
<button
onClick={() => navigate('/ppm-planner')}
className="mt-4 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg transition-colors"
>
Back to List
</button>
</div>
</div>
);
}
const isDraft = pmSchedule.docstatus === 0;
const isSubmitted = pmSchedule.docstatus === 1;
const isCancelled = pmSchedule.docstatus === 2;
return (
<div className="flex flex-col h-screen bg-gray-50 dark:bg-gray-900">
{/* Header */}
<div className="flex-shrink-0 bg-white dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700 px-6 py-4">
<div className="flex justify-between items-center mb-4">
<div className="flex items-center gap-3">
<button
onClick={() => navigate('/ppm-planner')}
className="p-2 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors"
>
<FaArrowLeft className="text-gray-600 dark:text-gray-400" />
</button>
<FaCalendarAlt className="text-blue-600 dark:text-blue-400" size={28} />
<div>
<h1 className="text-2xl font-bold text-gray-800 dark:text-white">{pmSchedule.name}</h1>
<p className="text-sm text-gray-600 dark:text-gray-400">
PPM Planner Details
</p>
</div>
<span className={`ml-4 px-3 py-1 text-sm font-semibold rounded-full ${
isSubmitted
? 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400'
: isDraft
? 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-400'
: 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-400'
}`}>
{isSubmitted ? 'Submitted' : isDraft ? 'Draft' : 'Cancelled'}
</span>
</div>
<div className="flex gap-2">
{isDraft && !isEditing && (
<>
<button
onClick={() => setIsEditing(true)}
className="px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg transition-colors flex items-center gap-2"
>
<FaEdit />
Edit
</button>
<button
onClick={handleSubmit}
disabled={saving}
className="px-4 py-2 bg-green-600 hover:bg-green-700 text-white rounded-lg transition-colors flex items-center gap-2 disabled:opacity-50"
>
<FaCheckCircle />
Submit
</button>
<button
onClick={() => setDeleteConfirmOpen(true)}
disabled={saving}
className="px-4 py-2 bg-red-600 hover:bg-red-700 text-white rounded-lg transition-colors flex items-center gap-2 disabled:opacity-50"
>
<FaTrash />
Delete
</button>
</>
)}
{isEditing && (
<>
<button
onClick={handleSave}
disabled={saving}
className="px-4 py-2 bg-green-600 hover:bg-green-700 text-white rounded-lg transition-colors flex items-center gap-2 disabled:opacity-50"
>
{saving ? <FaSpinner className="animate-spin" /> : <FaSave />}
Save
</button>
<button
onClick={() => {
setIsEditing(false);
setFormData({
hospital: pmSchedule.hospital || '',
modality: pmSchedule.modality || '',
device_status: pmSchedule.device_status || '',
start_date: pmSchedule.start_date || '',
end_date: pmSchedule.end_date || '',
maintenance_team: pmSchedule.maintenance_team || '',
maintenance_manager: pmSchedule.maintenance_manager || '',
periodicity: pmSchedule.periodicity || 'Monthly',
assign_to: pmSchedule.assign_to || '',
due_date: pmSchedule.due_date || '',
next_pm_date: pmSchedule.next_pm_date || '',
manufacturer: pmSchedule.manufacturer || '',
model: pmSchedule.model || '',
pm_for: pmSchedule.pm_for || '',
asset_name: pmSchedule.asset_name || '',
no_of_pms: pmSchedule.no_of_pms || ''
});
}}
disabled={saving}
className="px-4 py-2 bg-gray-600 hover:bg-gray-700 text-white rounded-lg transition-colors disabled:opacity-50"
>
Cancel
</button>
</>
)}
{isSubmitted && (
<button
onClick={handleCancel}
disabled={saving}
className="px-4 py-2 bg-red-600 hover:bg-red-700 text-white rounded-lg transition-colors flex items-center gap-2 disabled:opacity-50"
>
Cancel Document
</button>
)}
</div>
</div>
</div>
{/* Content */}
<div className="flex-1 overflow-auto p-6">
<div className="max-w-4xl mx-auto bg-white dark:bg-gray-800 rounded-lg shadow">
<div className="p-6 space-y-6">
{/* Basic Information */}
<div>
<h2 className="text-lg font-semibold text-gray-800 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">
Hospital *
</label> */}
{isEditing ? (
<LinkField
label = "Hospital *"
doctype="Company"
value={formData.hospital}
onChange={(value) => setFormData(prev => ({ ...prev, hospital: value }))}
placeholder="Select Hospital"
filters={{}}
/>
) : (
<div className="px-3 py-2 bg-gray-50 dark:bg-gray-700 rounded-lg text-gray-900 dark:text-white">
{pmSchedule.hospital || '-'}
</div>
)}
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Modality
</label>
{isEditing ? (
<input
type="text"
name="modality"
value={formData.modality}
onChange={handleChange}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
/>
) : (
<div className="px-3 py-2 bg-gray-50 dark:bg-gray-700 rounded-lg text-gray-900 dark:text-white">
{pmSchedule.modality || '-'}
</div>
)}
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Device Status
</label>
{isEditing ? (
<input
type="text"
name="device_status"
value={formData.device_status}
onChange={handleChange}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
/>
) : (
<div className="px-3 py-2 bg-gray-50 dark:bg-gray-700 rounded-lg text-gray-900 dark:text-white">
{pmSchedule.device_status || '-'}
</div>
)}
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Periodicity *
</label>
{isEditing ? (
<select
name="periodicity"
value={formData.periodicity}
onChange={handleChange}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
>
<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 className="px-3 py-2 bg-gray-50 dark:bg-gray-700 rounded-lg text-gray-900 dark:text-white">
{pmSchedule.periodicity || '-'}
</div>
)}
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Manufacturer
</label>
{isEditing ? (
<input
type="text"
name="manufacturer"
value={formData.manufacturer}
onChange={handleChange}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
/>
) : (
<div className="px-3 py-2 bg-gray-50 dark:bg-gray-700 rounded-lg text-gray-900 dark:text-white">
{pmSchedule.manufacturer || '-'}
</div>
)}
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Model
</label>
{isEditing ? (
<input
type="text"
name="model"
value={formData.model}
onChange={handleChange}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
/>
) : (
<div className="px-3 py-2 bg-gray-50 dark:bg-gray-700 rounded-lg text-gray-900 dark:text-white">
{pmSchedule.model || '-'}
</div>
)}
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
PM Name
</label>
{isEditing ? (
<input
type="text"
name="pm_for"
value={formData.pm_for}
onChange={handleChange}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
/>
) : (
<div className="px-3 py-2 bg-gray-50 dark:bg-gray-700 rounded-lg text-gray-900 dark:text-white">
{pmSchedule.pm_for || '-'}
</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-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
/>
) : (
<div className="px-3 py-2 bg-gray-50 dark:bg-gray-700 rounded-lg text-gray-900 dark:text-white">
{pmSchedule.asset_name || '-'}
</div>
)}
</div>
</div>
</div>
{/* Schedule Dates */}
<div>
<h2 className="text-lg font-semibold text-gray-800 dark:text-white mb-4">Schedule Dates</h2>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Start Date *
</label>
{isEditing ? (
<input
type="date"
name="start_date"
value={formData.start_date}
onChange={handleChange}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
/>
) : (
<div className="px-3 py-2 bg-gray-50 dark:bg-gray-700 rounded-lg text-gray-900 dark:text-white">
{pmSchedule.start_date ? new Date(pmSchedule.start_date).toLocaleDateString() : '-'}
</div>
)}
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
End Date *
</label>
{isEditing ? (
<input
type="date"
name="end_date"
value={formData.end_date}
onChange={handleChange}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
/>
) : (
<div className="px-3 py-2 bg-gray-50 dark:bg-gray-700 rounded-lg text-gray-900 dark:text-white">
{pmSchedule.end_date ? new Date(pmSchedule.end_date).toLocaleDateString() : '-'}
</div>
)}
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
First PM Date
</label>
{isEditing ? (
<input
type="date"
name="due_date"
value={formData.due_date}
onChange={handleChange}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
/>
) : (
<div className="px-3 py-2 bg-gray-50 dark:bg-gray-700 rounded-lg text-gray-900 dark:text-white">
{pmSchedule.due_date ? new Date(pmSchedule.due_date).toLocaleDateString() : '-'}
</div>
)}
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Next PM Date
</label>
{isEditing ? (
<input
type="date"
name="next_pm_date"
value={formData.next_pm_date}
onChange={handleChange}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
/>
) : (
<div className="px-3 py-2 bg-gray-50 dark:bg-gray-700 rounded-lg text-gray-900 dark:text-white">
{formData.next_pm_date ? new Date(formData.next_pm_date).toLocaleDateString() : (pmSchedule.next_pm_date ? new Date(pmSchedule.next_pm_date).toLocaleDateString() : '-')}
</div>
)}
{!isEditing && formData.next_pm_date && (
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
Calculated based on last completion
</p>
)}
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
No. of PMs
</label>
{isEditing ? (
<input
type="number"
name="no_of_pms"
value={formData.no_of_pms}
onChange={handleChange}
min="1"
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
placeholder="Enter number of PMs"
/>
) : (
<div className="px-3 py-2 bg-gray-50 dark:bg-gray-700 rounded-lg text-gray-900 dark:text-white">
{pmSchedule.no_of_pms || '-'}
</div>
)}
{isEditing && (
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
End date will be auto-calculated based on start date, periodicity, and number of PMs
</p>
)}
</div>
</div>
</div>
{/* Assignment */}
<div>
<h2 className="text-lg font-semibold text-gray-800 dark:text-white mb-4">Assignment</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">
Maintenance Team
</label>
{isEditing ? (
<input
type="text"
name="maintenance_team"
value={formData.maintenance_team}
onChange={handleChange}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
/>
) : (
<div className="px-3 py-2 bg-gray-50 dark:bg-gray-700 rounded-lg text-gray-900 dark:text-white">
{pmSchedule.maintenance_team || '-'}
</div>
)}
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Maintenance Manager
</label>
{isEditing ? (
<input
type="text"
name="maintenance_manager"
value={formData.maintenance_manager}
onChange={handleChange}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
/>
) : (
<div className="px-3 py-2 bg-gray-50 dark:bg-gray-700 rounded-lg text-gray-900 dark:text-white">
{pmSchedule.maintenance_manager || '-'}
</div>
)}
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Assign To
</label>
{isEditing ? (
<input
type="text"
name="assign_to"
value={formData.assign_to}
onChange={handleChange}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
/>
) : (
<div className="px-3 py-2 bg-gray-50 dark:bg-gray-700 rounded-lg text-gray-900 dark:text-white">
{pmSchedule.assign_to || '-'}
</div>
)}
</div>
</div>
</div>
{/* Maintenance Entries */}
{pmSchedule.maintenance_entries && pmSchedule.maintenance_entries.length > 0 && (
<div>
<h2 className="text-lg font-semibold text-gray-800 dark:text-white mb-4">Maintenance Entries</h2>
<div className="overflow-x-auto">
<table className="w-full border-collapse border border-gray-300 dark:border-gray-600">
<thead>
<tr className="bg-gray-100 dark:bg-gray-700">
<th className="border border-gray-300 dark:border-gray-600 px-4 py-2 text-left text-sm font-semibold text-gray-700 dark:text-gray-300">
Asset
</th>
<th className="border border-gray-300 dark:border-gray-600 px-4 py-2 text-left text-sm font-semibold text-gray-700 dark:text-gray-300">
Asset Name
</th>
<th className="border border-gray-300 dark:border-gray-600 px-4 py-2 text-left text-sm font-semibold text-gray-700 dark:text-gray-300">
Manufacturer
</th>
<th className="border border-gray-300 dark:border-gray-600 px-4 py-2 text-left text-sm font-semibold text-gray-700 dark:text-gray-300">
Model
</th>
<th className="border border-gray-300 dark:border-gray-600 px-4 py-2 text-left text-sm font-semibold text-gray-700 dark:text-gray-300">
PM Start Date
</th>
<th className="border border-gray-300 dark:border-gray-600 px-4 py-2 text-left text-sm font-semibold text-gray-700 dark:text-gray-300">
PM End Date
</th>
<th className="border border-gray-300 dark:border-gray-600 px-4 py-2 text-left text-sm font-semibold text-gray-700 dark:text-gray-300">
Maintenance Log
</th>
<th className="border border-gray-300 dark:border-gray-600 px-4 py-2 text-left text-sm font-semibold text-gray-700 dark:text-gray-300">
Next PM Date
</th>
</tr>
</thead>
<tbody>
{pmSchedule.maintenance_entries.map((entry: any, index: number) => {
// Find maintenance logs for this asset
const assetLogs = maintenanceLogs.filter(log => log.asset_name === entry.asset);
// Calculate Next PM Date for this asset
const nextPMDate = calculateNextPMDateForAsset(
entry.asset,
pmSchedule.periodicity || 'Monthly',
pmSchedule.due_date || ''
);
return (
<tr key={entry.name || index} className="hover:bg-gray-50 dark:hover:bg-gray-700/50">
<td className="border border-gray-300 dark:border-gray-600 px-4 py-2 text-sm text-gray-900 dark:text-white">
{entry.asset ? (
<button
onClick={() => navigate(`/assets/${entry.asset}`)}
className="text-blue-600 dark:text-blue-400 hover:underline"
>
{entry.asset}
</button>
) : '-'}
</td>
<td className="border border-gray-300 dark:border-gray-600 px-4 py-2 text-sm text-gray-900 dark:text-white">
{entry.asset_name || '-'}
</td>
<td className="border border-gray-300 dark:border-gray-600 px-4 py-2 text-sm text-gray-900 dark:text-white">
{entry.manufacturer || '-'}
</td>
<td className="border border-gray-300 dark:border-gray-600 px-4 py-2 text-sm text-gray-900 dark:text-white">
{entry.model || '-'}
</td>
<td className="border border-gray-300 dark:border-gray-600 px-4 py-2 text-sm text-gray-900 dark:text-white">
{entry.start_date ? new Date(entry.start_date).toLocaleDateString() : '-'}
</td>
<td className="border border-gray-300 dark:border-gray-600 px-4 py-2 text-sm text-gray-900 dark:text-white">
{entry.end_date ? new Date(entry.end_date).toLocaleDateString() : '-'}
</td>
<td className="border border-gray-300 dark:border-gray-600 px-4 py-2 text-sm text-gray-900 dark:text-white">
{assetLogs.length > 0 ? (
<div className="space-y-1">
{assetLogs.map(log => (
<button
key={log.name}
onClick={() => navigate(`/maintenance/${log.name}`)}
className="block text-blue-600 dark:text-blue-400 hover:underline text-left"
title={`Status: ${log.maintenance_status || 'N/A'} | Due: ${log.due_date ? new Date(log.due_date).toLocaleDateString() : 'N/A'}`}
>
{log.name}
</button>
))}
{assetLogs.length > 3 && (
<span className="text-xs text-gray-500 dark:text-gray-400">
+{assetLogs.length - 3} more
</span>
)}
</div>
) : (
<span className="text-gray-400 dark:text-gray-500">No logs</span>
)}
</td>
<td className="border border-gray-300 dark:border-gray-600 px-4 py-2 text-sm text-gray-900 dark:text-white">
{nextPMDate ? (
<span className="font-medium text-blue-600 dark:text-blue-400">
{new Date(nextPMDate).toLocaleDateString()}
</span>
) : (
<span className="text-gray-400 dark:text-gray-500">-</span>
)}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</div>
)}
{/* Metadata */}
<div className="pt-4 border-t border-gray-200 dark:border-gray-700">
<h2 className="text-sm font-semibold text-gray-600 dark:text-gray-400 mb-3">Metadata</h2>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 text-sm">
<div>
<span className="text-gray-500 dark:text-gray-500">Created:</span>
<div className="font-medium text-gray-900 dark:text-white">
{pmSchedule.creation ? new Date(pmSchedule.creation).toLocaleString() : '-'}
</div>
</div>
<div>
<span className="text-gray-500 dark:text-gray-500">Created By:</span>
<div className="font-medium text-gray-900 dark:text-white">{pmSchedule.owner || '-'}</div>
</div>
<div>
<span className="text-gray-500 dark:text-gray-500">Modified:</span>
<div className="font-medium text-gray-900 dark:text-white">
{pmSchedule.modified ? new Date(pmSchedule.modified).toLocaleString() : '-'}
</div>
</div>
<div>
<span className="text-gray-500 dark:text-gray-500">Modified By:</span>
<div className="font-medium text-gray-900 dark:text-white">{pmSchedule.modified_by || '-'}</div>
</div>
{pmSchedule.amended_from && (
<div>
<span className="text-gray-500 dark:text-gray-500">Amended From:</span>
<div className="font-medium text-gray-900 dark:text-white">{pmSchedule.amended_from}</div>
</div>
)}
<div>
<span className="text-gray-500 dark:text-gray-500">Doc Status:</span>
<div className="font-medium text-gray-900 dark:text-white">
{pmSchedule.docstatus === 0 ? 'Draft' : pmSchedule.docstatus === 1 ? 'Submitted' : 'Cancelled'}
</div>
</div>
<div>
<span className="text-gray-500 dark:text-gray-500">IDX:</span>
<div className="font-medium text-gray-900 dark:text-white">{pmSchedule.idx || '-'}</div>
</div>
</div>
</div>
</div>
</div>
</div>
{/* Delete Confirmation Modal */}
{deleteConfirmOpen && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-xl p-6 max-w-md">
<h3 className="text-lg font-semibold text-gray-800 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 Planner? This action cannot be undone.
</p>
<div className="flex gap-3 justify-end">
<button
onClick={() => setDeleteConfirmOpen(false)}
className="px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 text-gray-700 dark:text-gray-300"
disabled={saving}
>
Cancel
</button>
<button
onClick={handleDelete}
className="px-4 py-2 bg-red-600 hover:bg-red-700 text-white rounded-lg transition-colors"
disabled={saving}
>
{saving ? 'Deleting...' : 'Delete'}
</button>
</div>
</div>
</div>
)}
</div>
);
};
export default PPMPlannerDetail;

View File

@ -0,0 +1,452 @@
import React, { useState, useEffect, useRef } from 'react';
import { useNavigate } from 'react-router-dom';
import { FaPlus, FaSearch, FaEdit, FaEye, FaTrash, FaEllipsisV, FaCalendarAlt, FaFilter, FaChevronDown, FaChevronUp, FaTimes } from 'react-icons/fa';
import { usePMSchedules, usePMScheduleMutations } from '../hooks/usePMSchedule';
import LinkField from '../components/LinkField';
const PPMPlannerList: React.FC = () => {
const navigate = useNavigate();
const [page, setPage] = useState(0);
const [searchTerm, setSearchTerm] = useState('');
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState<string | null>(null);
const [actionMenuOpen, setActionMenuOpen] = useState<string | null>(null);
const dropdownRef = useRef<HTMLDivElement>(null);
// Filters
const [isFilterExpanded, setIsFilterExpanded] = useState(false);
const [filterHospital, setFilterHospital] = useState('');
const [filterModality, setFilterModality] = useState('');
const [filterPeriodicity, setFilterPeriodicity] = useState('');
const limit = 20;
// Build filters
const filters: Record<string, any> = {};
if (filterHospital) filters['hospital'] = filterHospital;
if (filterModality) filters['modality'] = filterModality;
if (filterPeriodicity) filters['periodicity'] = filterPeriodicity;
const { pmSchedules, totalCount, hasMore, loading, error, refetch } = usePMSchedules(
filters,
limit,
page * limit,
'creation desc'
);
const { deletePMSchedule, loading: mutationLoading } = usePMScheduleMutations();
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-planner/new');
};
const handleView = (scheduleName: string) => {
navigate(`/ppm-planner/${scheduleName}`);
};
const handleEdit = (scheduleName: string) => {
navigate(`/ppm-planner/${scheduleName}`);
};
const handleDelete = async (scheduleName: string) => {
try {
await deletePMSchedule(scheduleName);
refetch();
setDeleteConfirmOpen(null);
} catch (err) {
console.error('Error deleting PM Schedule:', err);
alert('Failed to delete PM Schedule');
}
};
const handleClearFilters = () => {
setFilterHospital('');
setFilterModality('');
setFilterPeriodicity('');
setPage(0);
};
// Add this helper function before the return statement
const getDocstatus = (schedule: any): number => {
// If docstatus exists at parent level, use it
if (schedule.docstatus !== undefined) {
return Number(schedule.docstatus);
}
// Fallback: derive from first maintenance entry
if (schedule.maintenance_entries?.length > 0) {
return Number(schedule.maintenance_entries[0].docstatus);
}
// Default to Draft (0) if no entries
return 0;
};
const hasActiveFilters = filterHospital || filterModality || filterPeriodicity;
const activeFilterCount = [filterHospital, filterModality, filterPeriodicity].filter(Boolean).length;
// Filter schedules by search term
const filteredSchedules = pmSchedules.filter(schedule => {
if (!searchTerm) return true;
const term = searchTerm.toLowerCase();
return (
schedule.name?.toLowerCase().includes(term) ||
schedule.hospital?.toLowerCase().includes(term) ||
schedule.modality?.toLowerCase().includes(term) ||
schedule.maintenance_team?.toLowerCase().includes(term)
);
});
const totalPages = Math.ceil(totalCount / limit);
return (
<div className="flex flex-col h-screen bg-gray-50 dark:bg-gray-900">
{/* Header */}
<div className="flex-shrink-0 bg-white dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700 px-4 py-3 lg:px-6">
<div className="flex justify-between items-center mb-3">
<div className="flex items-center gap-3">
<FaCalendarAlt className="text-blue-600 dark:text-blue-400" size={24} />
<div>
<h1 className="text-xl font-bold text-gray-800 dark:text-white">PPM Planners</h1>
<p className="text-xs text-gray-600 dark:text-gray-400">
Manage preventive maintenance schedules
</p>
</div>
</div>
<button
onClick={handleCreateNew}
className="px-3 py-1.5 bg-blue-600 hover:bg-blue-700 text-white rounded-lg transition-colors flex items-center gap-2 text-sm"
>
<FaPlus />
<span>Create PPM Planner</span>
</button>
</div>
{/* Search and Filters */}
<div className="flex gap-2">
<div className="flex-1 relative">
<FaSearch className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400" size={14} />
<input
type="text"
placeholder="Search by name, hospital, modality..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="w-full pl-9 pr-3 py-1.5 text-sm border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
/>
</div>
<button
onClick={() => setIsFilterExpanded(!isFilterExpanded)}
className={`px-3 py-1.5 border rounded-lg transition-colors flex items-center gap-2 text-sm ${
hasActiveFilters
? 'border-blue-500 bg-blue-50 dark:bg-blue-900/20 text-blue-600 dark:text-blue-400'
: 'border-gray-300 dark:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-700 text-gray-700 dark:text-gray-300'
}`}
>
<FaFilter />
<span>Filters</span>
{activeFilterCount > 0 && (
<span className="bg-blue-600 text-white rounded-full w-5 h-5 flex items-center justify-center text-xs">
{activeFilterCount}
</span>
)}
{isFilterExpanded ? <FaChevronUp /> : <FaChevronDown />}
</button>
</div>
{/* Filter Panel */}
{isFilterExpanded && (
<div className="mt-3 p-3 bg-gray-50 dark:bg-gray-700/50 rounded-lg border border-gray-200 dark:border-gray-600">
<div className="grid grid-cols-1 md:grid-cols-3 gap-3">
<div>
{/* <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Hospital
</label> */}
<LinkField
label = "Hospital"
doctype="Company"
value={filterHospital}
onChange={setFilterHospital}
placeholder="All Hospitals"
filters={{ domain: "Healthcare" }}
/>
</div>
<div>
{/* <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Modality
</label> */}
<LinkField
label="Modality"
doctype="Modality"
value={filterModality}
onChange={setFilterModality}
placeholder="All Modalities"
filters={{}}
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Periodicity
</label>
<select
value={filterPeriodicity}
onChange={(e) => setFilterPeriodicity(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
>
<option value="">All</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>
{hasActiveFilters && (
<div className="mt-4 flex justify-end">
<button
onClick={handleClearFilters}
className="px-4 py-2 text-sm text-gray-600 dark:text-gray-400 hover:text-gray-800 dark:hover:text-gray-200 flex items-center gap-2"
>
<FaTimes />
Clear Filters
</button>
</div>
)}
</div>
)}
</div>
{/* Content */}
<div className="flex-1 overflow-auto p-4 lg:p-5">
{loading && page === 0 ? (
<div className="flex items-center justify-center h-full">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-500"></div>
<span className="ml-3 text-gray-600 dark:text-gray-400">Loading PPM Planners...</span>
</div>
) : error ? (
<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}</p>
</div>
) : filteredSchedules.length === 0 ? (
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-8 text-center">
<FaCalendarAlt className="mx-auto text-gray-400 mb-4" size={48} />
<h3 className="text-lg font-semibold text-gray-700 dark:text-gray-300 mb-2">
No PPM Planners Found
</h3>
<p className="text-gray-600 dark:text-gray-400 mb-4">
{searchTerm || hasActiveFilters
? 'Try adjusting your search or filters'
: 'Get started by creating your first PPM Planner'}
</p>
{!searchTerm && !hasActiveFilters && (
<button
onClick={handleCreateNew}
className="px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg transition-colors"
>
Create PPM Planner
</button>
)}
</div>
) : (
<div className="bg-white dark:bg-gray-800 rounded-lg shadow overflow-hidden">
<table className="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
<thead className="bg-gray-50 dark:bg-gray-700">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
Name
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
Hospital
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
Modality
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
Periodicity
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
Due Date
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
Status
</th>
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
Actions
</th>
</tr>
</thead>
<tbody className="bg-white dark:bg-gray-800 divide-y divide-gray-200 dark:divide-gray-700">
{filteredSchedules.map((schedule) => (
<tr key={schedule.name} className="hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-colors">
<td className="px-6 py-4 whitespace-nowrap">
<button
onClick={() => handleView(schedule.name)}
className="text-blue-600 dark:text-blue-400 hover:underline font-medium"
>
{schedule.name}
</button>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-700 dark:text-gray-300">
{schedule.hospital || '-'}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-700 dark:text-gray-300">
{schedule.modality || '-'}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-700 dark:text-gray-300">
{schedule.periodicity || '-'}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-700 dark:text-gray-300">
{schedule.due_date ? new Date(schedule.due_date).toLocaleDateString() : '-'}
</td>
{/* <td className="px-6 py-4 whitespace-nowrap">
<span className={`px-2 py-1 text-xs font-semibold rounded-full ${
schedule.docstatus === 1
? 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400'
: schedule.docstatus === 0
? 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-400'
: 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-400'
}`}>
{schedule.docstatus === 1 ? 'Submitted' : schedule.docstatus === 0 ? 'Draft' : 'Cancelled'}
</span>
</td> */}
<td className="px-6 py-4 whitespace-nowrap">
{(() => {
const status = getDocstatus(schedule);
return (
<span className={`px-2 py-1 text-xs font-semibold rounded-full ${
status === 1
? 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400'
: status === 0
? 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-400'
: 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-400'
}`}>
{status === 1 ? 'Submitted' : status === 2 ? 'Cancelled' : 'Draft'}
</span>
);
})()}
</td>
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
<div className="relative inline-block" ref={actionMenuOpen === schedule.name ? dropdownRef : null}>
<button
onClick={() => setActionMenuOpen(actionMenuOpen === schedule.name ? null : schedule.name)}
className="text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200 p-2"
>
<FaEllipsisV />
</button>
{actionMenuOpen === schedule.name && (
<div className="absolute right-0 mt-2 w-48 bg-white dark:bg-gray-700 rounded-lg shadow-lg border border-gray-200 dark:border-gray-600 z-10">
<button
onClick={() => {
handleView(schedule.name);
setActionMenuOpen(null);
}}
className="w-full px-4 py-2 text-left text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-600 flex items-center gap-2"
>
<FaEye /> View
</button>
<button
onClick={() => {
handleEdit(schedule.name);
setActionMenuOpen(null);
}}
className="w-full px-4 py-2 text-left text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-600 flex items-center gap-2"
>
<FaEdit /> Edit
</button>
<button
onClick={() => {
setDeleteConfirmOpen(schedule.name);
setActionMenuOpen(null);
}}
className="w-full px-4 py-2 text-left text-sm text-red-600 dark:text-red-400 hover:bg-gray-100 dark:hover:bg-gray-600 flex items-center gap-2"
>
<FaTrash /> Delete
</button>
</div>
)}
</div>
</td>
</tr>
))}
</tbody>
</table>
{/* Pagination */}
{totalPages > 1 && (
<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-600 dark:text-gray-400">
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 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 disabled:opacity-50 disabled:cursor-not-allowed text-gray-700 dark:text-gray-300"
>
Previous
</button>
<button
onClick={() => setPage(page + 1)}
disabled={!hasMore}
className="px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 disabled:opacity-50 disabled:cursor-not-allowed text-gray-700 dark:text-gray-300"
>
Next
</button>
</div>
</div>
)}
</div>
)}
</div>
{/* Delete Confirmation Modal */}
{deleteConfirmOpen && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-xl p-6 max-w-md">
<h3 className="text-lg font-semibold text-gray-800 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 Planner? This action cannot be undone.
</p>
<div className="flex gap-3 justify-end">
<button
onClick={() => setDeleteConfirmOpen(null)}
className="px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 text-gray-700 dark:text-gray-300"
disabled={mutationLoading}
>
Cancel
</button>
<button
onClick={() => handleDelete(deleteConfirmOpen)}
className="px-4 py-2 bg-red-600 hover:bg-red-700 text-white rounded-lg transition-colors"
disabled={mutationLoading}
>
{mutationLoading ? 'Deleting...' : 'Delete'}
</button>
</div>
</div>
</div>
)}
</div>
);
};
export default PPMPlannerList;

View File

@ -0,0 +1,421 @@
import React, { useState, useEffect, useRef } from 'react';
import { useNavigate } from 'react-router-dom';
import { FaPlus, FaSearch, FaEdit, FaEye, FaTrash, FaEllipsisV, FaCalendarAlt, FaFilter, FaChevronDown, FaChevronUp, FaTimes } from 'react-icons/fa';
import { usePMSchedules, usePMScheduleMutations } from '../hooks/usePMSchedule';
import LinkField from '../components/LinkField';
const PPMPlannerList: React.FC = () => {
const navigate = useNavigate();
const [page, setPage] = useState(0);
const [searchTerm, setSearchTerm] = useState('');
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState<string | null>(null);
const [actionMenuOpen, setActionMenuOpen] = useState<string | null>(null);
const dropdownRef = useRef<HTMLDivElement>(null);
// Filters
const [isFilterExpanded, setIsFilterExpanded] = useState(false);
const [filterHospital, setFilterHospital] = useState('');
const [filterModality, setFilterModality] = useState('');
const [filterPeriodicity, setFilterPeriodicity] = useState('');
const limit = 20;
// Build filters
const filters: Record<string, any> = {};
if (filterHospital) filters['hospital'] = filterHospital;
if (filterModality) filters['modality'] = filterModality;
if (filterPeriodicity) filters['periodicity'] = filterPeriodicity;
const { pmSchedules, totalCount, hasMore, loading, error, refetch } = usePMSchedules(
filters,
limit,
page * limit,
'creation desc'
);
const { deletePMSchedule, loading: mutationLoading } = usePMScheduleMutations();
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-planner/new');
};
const handleView = (scheduleName: string) => {
navigate(`/ppm-planner/${scheduleName}`);
};
const handleEdit = (scheduleName: string) => {
navigate(`/ppm-planner/${scheduleName}`);
};
const handleDelete = async (scheduleName: string) => {
try {
await deletePMSchedule(scheduleName);
refetch();
setDeleteConfirmOpen(null);
} catch (err) {
console.error('Error deleting PM Schedule:', err);
alert('Failed to delete PM Schedule');
}
};
const handleClearFilters = () => {
setFilterHospital('');
setFilterModality('');
setFilterPeriodicity('');
setPage(0);
};
const hasActiveFilters = filterHospital || filterModality || filterPeriodicity;
const activeFilterCount = [filterHospital, filterModality, filterPeriodicity].filter(Boolean).length;
// Filter schedules by search term
const filteredSchedules = pmSchedules.filter(schedule => {
if (!searchTerm) return true;
const term = searchTerm.toLowerCase();
return (
schedule.name?.toLowerCase().includes(term) ||
schedule.hospital?.toLowerCase().includes(term) ||
schedule.modality?.toLowerCase().includes(term) ||
schedule.maintenance_team?.toLowerCase().includes(term)
);
});
const totalPages = Math.ceil(totalCount / limit);
return (
<div className="flex flex-col h-screen bg-gray-50 dark:bg-gray-900">
{/* Header */}
<div className="flex-shrink-0 bg-white dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700 px-4 py-3 lg:px-6">
<div className="flex justify-between items-center mb-3">
<div className="flex items-center gap-3">
<FaCalendarAlt className="text-blue-600 dark:text-blue-400" size={24} />
<div>
<h1 className="text-xl font-bold text-gray-800 dark:text-white">PPM Planners</h1>
<p className="text-xs text-gray-600 dark:text-gray-400">
Manage preventive maintenance schedules
</p>
</div>
</div>
<button
onClick={handleCreateNew}
className="px-3 py-1.5 bg-blue-600 hover:bg-blue-700 text-white rounded-lg transition-colors flex items-center gap-2 text-sm"
>
<FaPlus />
<span>Create PPM Planner</span>
</button>
</div>
{/* Search and Filters */}
<div className="flex gap-2">
<div className="flex-1 relative">
<FaSearch className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400" size={14} />
<input
type="text"
placeholder="Search by name, hospital, modality..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="w-full pl-9 pr-3 py-1.5 text-sm border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
/>
</div>
<button
onClick={() => setIsFilterExpanded(!isFilterExpanded)}
className={`px-3 py-1.5 border rounded-lg transition-colors flex items-center gap-2 text-sm ${
hasActiveFilters
? 'border-blue-500 bg-blue-50 dark:bg-blue-900/20 text-blue-600 dark:text-blue-400'
: 'border-gray-300 dark:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-700 text-gray-700 dark:text-gray-300'
}`}
>
<FaFilter />
<span>Filters</span>
{activeFilterCount > 0 && (
<span className="bg-blue-600 text-white rounded-full w-5 h-5 flex items-center justify-center text-xs">
{activeFilterCount}
</span>
)}
{isFilterExpanded ? <FaChevronUp /> : <FaChevronDown />}
</button>
</div>
{/* Filter Panel */}
{isFilterExpanded && (
<div className="mt-3 p-3 bg-gray-50 dark:bg-gray-700/50 rounded-lg border border-gray-200 dark:border-gray-600">
<div className="grid grid-cols-1 md:grid-cols-3 gap-3">
<div>
{/* <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Hospital
</label> */}
<LinkField
label = "Hospital"
doctype="Company"
value={filterHospital}
onChange={setFilterHospital}
placeholder="All Hospitals"
filters={{ domain: "Healthcare" }}
/>
</div>
<div>
{/* <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Modality
</label> */}
<LinkField
label="Modality"
doctype="Modality"
value={filterModality}
onChange={setFilterModality}
placeholder="All Modalities"
filters={{}}
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Periodicity
</label>
<select
value={filterPeriodicity}
onChange={(e) => setFilterPeriodicity(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
>
<option value="">All</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>
{hasActiveFilters && (
<div className="mt-4 flex justify-end">
<button
onClick={handleClearFilters}
className="px-4 py-2 text-sm text-gray-600 dark:text-gray-400 hover:text-gray-800 dark:hover:text-gray-200 flex items-center gap-2"
>
<FaTimes />
Clear Filters
</button>
</div>
)}
</div>
)}
</div>
{/* Content */}
<div className="flex-1 overflow-auto p-4 lg:p-5">
{loading && page === 0 ? (
<div className="flex items-center justify-center h-full">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-500"></div>
<span className="ml-3 text-gray-600 dark:text-gray-400">Loading PPM Planners...</span>
</div>
) : error ? (
<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}</p>
</div>
) : filteredSchedules.length === 0 ? (
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-8 text-center">
<FaCalendarAlt className="mx-auto text-gray-400 mb-4" size={48} />
<h3 className="text-lg font-semibold text-gray-700 dark:text-gray-300 mb-2">
No PPM Planners Found
</h3>
<p className="text-gray-600 dark:text-gray-400 mb-4">
{searchTerm || hasActiveFilters
? 'Try adjusting your search or filters'
: 'Get started by creating your first PPM Planner'}
</p>
{!searchTerm && !hasActiveFilters && (
<button
onClick={handleCreateNew}
className="px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg transition-colors"
>
Create PPM Planner
</button>
)}
</div>
) : (
<div className="bg-white dark:bg-gray-800 rounded-lg shadow overflow-hidden">
<table className="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
<thead className="bg-gray-50 dark:bg-gray-700">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
Name
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
Hospital
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
Modality
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
Periodicity
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
Due Date
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
Status
</th>
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
Actions
</th>
</tr>
</thead>
<tbody className="bg-white dark:bg-gray-800 divide-y divide-gray-200 dark:divide-gray-700">
{filteredSchedules.map((schedule) => (
<tr key={schedule.name} className="hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-colors">
<td className="px-6 py-4 whitespace-nowrap">
<button
onClick={() => handleView(schedule.name)}
className="text-blue-600 dark:text-blue-400 hover:underline font-medium"
>
{schedule.name}
</button>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-700 dark:text-gray-300">
{schedule.hospital || '-'}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-700 dark:text-gray-300">
{schedule.modality || '-'}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-700 dark:text-gray-300">
{schedule.periodicity || '-'}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-700 dark:text-gray-300">
{schedule.due_date ? new Date(schedule.due_date).toLocaleDateString() : '-'}
</td>
<td className="px-6 py-4 whitespace-nowrap">
<span className={`px-2 py-1 text-xs font-semibold rounded-full ${
schedule.docstatus === 1
? 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400'
: schedule.docstatus === 0
? 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-400'
: 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-400'
}`}>
{schedule.docstatus === 1 ? 'Submitted' : schedule.docstatus === 0 ? 'Draft' : 'Cancelled'}
</span>
</td>
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
<div className="relative inline-block" ref={actionMenuOpen === schedule.name ? dropdownRef : null}>
<button
onClick={() => setActionMenuOpen(actionMenuOpen === schedule.name ? null : schedule.name)}
className="text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200 p-2"
>
<FaEllipsisV />
</button>
{actionMenuOpen === schedule.name && (
<div className="absolute right-0 mt-2 w-48 bg-white dark:bg-gray-700 rounded-lg shadow-lg border border-gray-200 dark:border-gray-600 z-10">
<button
onClick={() => {
handleView(schedule.name);
setActionMenuOpen(null);
}}
className="w-full px-4 py-2 text-left text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-600 flex items-center gap-2"
>
<FaEye /> View
</button>
<button
onClick={() => {
handleEdit(schedule.name);
setActionMenuOpen(null);
}}
className="w-full px-4 py-2 text-left text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-600 flex items-center gap-2"
>
<FaEdit /> Edit
</button>
<button
onClick={() => {
setDeleteConfirmOpen(schedule.name);
setActionMenuOpen(null);
}}
className="w-full px-4 py-2 text-left text-sm text-red-600 dark:text-red-400 hover:bg-gray-100 dark:hover:bg-gray-600 flex items-center gap-2"
>
<FaTrash /> Delete
</button>
</div>
)}
</div>
</td>
</tr>
))}
</tbody>
</table>
{/* Pagination */}
{totalPages > 1 && (
<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-600 dark:text-gray-400">
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 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 disabled:opacity-50 disabled:cursor-not-allowed text-gray-700 dark:text-gray-300"
>
Previous
</button>
<button
onClick={() => setPage(page + 1)}
disabled={!hasMore}
className="px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 disabled:opacity-50 disabled:cursor-not-allowed text-gray-700 dark:text-gray-300"
>
Next
</button>
</div>
</div>
)}
</div>
)}
</div>
{/* Delete Confirmation Modal */}
{deleteConfirmOpen && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-xl p-6 max-w-md">
<h3 className="text-lg font-semibold text-gray-800 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 Planner? This action cannot be undone.
</p>
<div className="flex gap-3 justify-end">
<button
onClick={() => setDeleteConfirmOpen(null)}
className="px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 text-gray-700 dark:text-gray-300"
disabled={mutationLoading}
>
Cancel
</button>
<button
onClick={() => handleDelete(deleteConfirmOpen)}
className="px-4 py-2 bg-red-600 hover:bg-red-700 text-white rounded-lg transition-colors"
disabled={mutationLoading}
>
{mutationLoading ? 'Deleting...' : 'Delete'}
</button>
</div>
</div>
</div>
)}
</div>
);
};
export default PPMPlannerList;

View File

@ -0,0 +1,12 @@
import React from 'react';
const Test: React.FC = () => {
return (
<div style={{ padding: '50px', textAlign: 'center' }}>
<h1>Test Component</h1>
<p>Routing is working correctly!</p>
</div>
);
};
export default Test;

View File

@ -0,0 +1,190 @@
import React, { useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import frappeAPI from '../api/frappeClient';
import type { FrappeDocType } from '../api/frappeClient';
const UsersList: React.FC = () => {
const [users, setUsers] = useState<FrappeDocType[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [searchTerm, setSearchTerm] = useState('');
const navigate = useNavigate();
useEffect(() => {
loadUsers();
}, []);
const loadUsers = async () => {
try {
setLoading(true);
const response = await frappeAPI.getDocTypeRecords(
'User',
{},
['name', 'full_name', 'email', 'enabled', 'creation', 'modified']
);
setUsers(response.message || []);
} catch (err: any) {
setError(err.response?.data?.message || 'Failed to load users');
} finally {
setLoading(false);
}
};
const filteredUsers = users.filter(user =>
user.full_name?.toLowerCase().includes(searchTerm.toLowerCase()) ||
user.email?.toLowerCase().includes(searchTerm.toLowerCase()) ||
user.name?.toLowerCase().includes(searchTerm.toLowerCase())
);
const handleUserClick = (user: FrappeDocType) => {
navigate(`/users/${user.name}`);
};
if (loading) {
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50 dark:bg-gray-900">
<div className="animate-spin rounded-full h-32 w-32 border-b-2 border-indigo-600"></div>
</div>
);
}
return (
<div className="min-h-screen bg-gray-50 dark:bg-gray-900">
{/* Header */}
<header className="bg-white dark:bg-gray-800 shadow">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="flex justify-between items-center py-6">
<div className="flex items-center">
<button
onClick={() => navigate('/dashboard')}
className="mr-4 text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300"
>
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
</svg>
</button>
<h1 className="text-3xl font-bold text-gray-900 dark:text-white">Users</h1>
</div>
<div className="flex items-center space-x-4">
<button
onClick={() => navigate('/dashboard')}
className="bg-gray-600 hover:bg-gray-700 text-white px-4 py-2 rounded-md text-sm font-medium"
>
Back to Dashboard
</button>
</div>
</div>
</div>
</header>
{/* Main Content */}
<main className="max-w-7xl mx-auto py-6 sm:px-6 lg:px-8">
{error && (
<div className="mb-6 rounded-md bg-red-50 dark:bg-red-900/20 p-4">
<div className="text-sm text-red-700 dark:text-red-400">{error}</div>
</div>
)}
{/* Search and Actions */}
<div className="mb-6 flex flex-col sm:flex-row sm:items-center sm:justify-between">
<div className="flex-1 min-w-0">
<div className="relative">
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<svg className="h-5 w-5 text-gray-400 dark:text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
</div>
<input
type="text"
placeholder="Search users..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="block w-full pl-10 pr-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md leading-5 bg-white dark:bg-gray-700 placeholder-gray-500 dark:placeholder-gray-400 text-gray-900 dark:text-white focus:outline-none focus:placeholder-gray-400 focus:ring-1 focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
/>
</div>
</div>
<div className="mt-4 sm:mt-0 sm:ml-4">
<button
onClick={loadUsers}
className="inline-flex items-center px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
>
<svg className="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
Refresh
</button>
</div>
</div>
{/* Users Table */}
<div className="bg-white dark:bg-gray-800 shadow overflow-hidden sm:rounded-md">
<div className="px-4 py-5 sm:px-6">
<h3 className="text-lg leading-6 font-medium text-gray-900 dark:text-white">
Users ({filteredUsers.length})
</h3>
<p className="mt-1 max-w-2xl text-sm text-gray-500 dark:text-gray-400">
Manage user accounts and permissions
</p>
</div>
{filteredUsers.length === 0 ? (
<div className="text-center py-12">
<svg className="mx-auto h-12 w-12 text-gray-400 dark:text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197m13.5-9a2.5 2.5 0 11-5 0 2.5 2.5 0 015 0z" />
</svg>
<h3 className="mt-2 text-sm font-medium text-gray-900 dark:text-white">No users found</h3>
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
{searchTerm ? 'Try adjusting your search terms.' : 'No users available.'}
</p>
</div>
) : (
<ul className="divide-y divide-gray-200 dark:divide-gray-700">
{filteredUsers.map((user) => (
<li key={user.name}>
<div
className="px-4 py-4 flex items-center justify-between hover:bg-gray-50 dark:hover:bg-gray-700 cursor-pointer"
onClick={() => handleUserClick(user)}
>
<div className="flex items-center">
<div className="flex-shrink-0 h-10 w-10">
<div className="h-10 w-10 rounded-full bg-indigo-100 dark:bg-indigo-900 flex items-center justify-center">
<span className="text-sm font-medium text-indigo-600 dark:text-indigo-300">
{user.full_name?.charAt(0) || user.name.charAt(0)}
</span>
</div>
</div>
<div className="ml-4">
<div className="text-sm font-medium text-gray-900 dark:text-white">
{user.full_name || user.name}
</div>
<div className="text-sm text-gray-500 dark:text-gray-400">
{user.email || 'No email'}
</div>
</div>
</div>
<div className="flex items-center space-x-4">
<div className="text-sm text-gray-500 dark:text-gray-400">
<div className="flex items-center">
<div className={`w-2 h-2 rounded-full mr-2 ${user.enabled ? 'bg-green-400' : 'bg-red-400'}`}></div>
{user.enabled ? 'Active' : 'Inactive'}
</div>
</div>
<div className="text-sm text-gray-500 dark:text-gray-400">
Created: {new Date(user.creation).toLocaleDateString()}
</div>
<svg className="w-5 h-5 text-gray-400 dark:text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
</div>
</div>
</li>
))}
</ul>
)}
</div>
</main>
</div>
);
};
export default UsersList;

View File

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,342 @@
import React, { useState, useMemo, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { FaCalendarAlt, FaChevronLeft, FaChevronRight, FaArrowLeft, FaCalendar } from 'react-icons/fa';
import { usePMSchedules } from '../hooks/usePMSchedule';
const YearlyPPMPlannerPage: React.FC = () => {
const navigate = useNavigate();
const today = new Date();
const [startYear, setStartYear] = useState(2025);
const [endYear, setEndYear] = useState(2030);
// Stable empty filters object
const emptyFilters = useMemo(() => ({}), []);
const emptyPermissionFilters = useMemo(() => ({}), []);
// Fetch all PM Schedules (PPM Planners) using the custom API
const { pmSchedules, loading, error } = usePMSchedules(emptyFilters, 1000, 0, 'creation desc', emptyPermissionFilters);
// Helper function to calculate if a month should show based on periodicity
const shouldShowInMonth = (
periodicity: string,
dueDate: Date,
endDate: Date,
checkYear: number,
checkMonth: number
): boolean => {
const periodicityLower = periodicity.toLowerCase().trim();
const dueYear = dueDate.getFullYear();
const dueMonth = dueDate.getMonth();
// Check if the check date is within the valid range (due_date to end_date)
const checkDate = new Date(checkYear, checkMonth, 1);
const monthEnd = new Date(checkYear, checkMonth + 1, 0, 23, 59, 59, 999);
if (checkDate > endDate || monthEnd < dueDate) {
return false; // Outside the valid date range
}
// Calculate months since due date
const monthsSinceDue = (checkYear - dueYear) * 12 + (checkMonth - dueMonth);
switch (periodicityLower) {
case 'daily':
// Show in the month where any day from due_date to end_date falls
return checkDate <= endDate && monthEnd >= dueDate;
case 'weekly':
// Show in the month where any week from due_date to end_date falls
// A week is considered to be in a month if any day of that week is in that month
return checkDate <= endDate && monthEnd >= dueDate;
case 'monthly':
// Show every month from due_date to end_date
return monthsSinceDue >= 0 && checkDate <= endDate;
case 'quarterly':
// Show every 3 months from due_date to end_date
return monthsSinceDue >= 0 && monthsSinceDue % 3 === 0 && checkDate <= endDate;
case 'half-yearly':
case 'half yearly':
// Show every 6 months from due_date to end_date
return monthsSinceDue >= 0 && monthsSinceDue % 6 === 0 && checkDate <= endDate;
case 'yearly':
case 'annually':
// Show in the same month every year from due_date to end_date
return checkMonth === dueMonth && checkDate <= endDate && checkDate >= dueDate;
case '2 yearly':
case '2-yearly':
// Show every 24 months from due_date to end_date
return monthsSinceDue >= 0 && monthsSinceDue % 24 === 0 && checkDate <= endDate;
case '3 yearly':
case '3-yearly':
// Show every 36 months from due_date to end_date
return monthsSinceDue >= 0 && monthsSinceDue % 36 === 0 && checkDate <= endDate;
default:
// Unknown periodicity, show if within date range
return checkDate <= endDate && monthEnd >= dueDate;
}
};
// Group schedules by year and month - create a matrix structure
const schedulesMatrix = useMemo(() => {
const matrix: Record<number, Record<number, typeof pmSchedules>> = {};
// Initialize matrix for all years
for (let year = startYear; year <= endYear; year++) {
matrix[year] = {};
for (let month = 0; month < 12; month++) {
matrix[year][month] = [];
}
}
// Populate matrix with schedules
pmSchedules.forEach(schedule => {
// Parse dates and normalize to date-only
let startDate: Date | null = null;
let endDate: Date | null = null;
let dueDate: Date | null = null;
if (schedule.start_date) {
const [year, month, day] = schedule.start_date.split('-').map(Number);
startDate = new Date(year, month - 1, day);
}
if (schedule.end_date) {
const [year, month, day] = schedule.end_date.split('-').map(Number);
endDate = new Date(year, month - 1, day);
}
// Use due_date as the primary indicator - this is the starting point
if (schedule.due_date) {
const [year, month, day] = schedule.due_date.split('-').map(Number);
dueDate = new Date(year, month - 1, day);
}
// If no due_date, skip this schedule (we need due_date to calculate recurring dates)
if (!dueDate) return;
// Use end_date if available, otherwise use due_date (single occurrence)
const scheduleEndDate = endDate || dueDate;
// Get periodicity (default to 'monthly' if not specified)
const periodicity = schedule.periodicity || 'monthly';
// Iterate through all years and months in the visible range
for (let year = startYear; year <= endYear; year++) {
for (let month = 0; month < 12; month++) {
// Check if this month should show based on periodicity
if (shouldShowInMonth(periodicity, dueDate, scheduleEndDate, year, month)) {
// Check if schedule already exists in this cell (avoid duplicates)
const exists = matrix[year][month].some(s => s.name === schedule.name);
if (!exists) {
matrix[year][month].push(schedule);
}
}
}
}
});
return matrix;
}, [pmSchedules, startYear, endYear]);
// Debug: Log PM Schedules
useEffect(() => {
if (!loading) {
console.log('[YearlyPPMPlannerPage] PM Schedules count:', pmSchedules.length);
console.log('[YearlyPPMPlannerPage] Matrix years:', startYear, 'to', endYear);
}
}, [pmSchedules, loading, startYear, endYear]);
const navigateYears = (direction: number) => {
const range = endYear - startYear + 1;
setStartYear(prev => prev + direction);
setEndYear(prev => prev + direction);
};
const monthNames = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
const fullMonthNames = [
'January', 'February', 'March', 'April', 'May', 'June',
'July', 'August', 'September', 'October', 'November', 'December'
];
// Generate years array
const years = Array.from({ length: endYear - startYear + 1 }, (_, i) => startYear + i);
return (
<div className="flex flex-col h-screen bg-gray-50 dark:bg-gray-900 overflow-hidden">
{/* Header */}
<div className="flex-shrink-0 bg-white dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700 px-4 py-3 lg:px-6">
<div className="flex justify-between items-center">
<div className="flex items-center gap-3">
<button
onClick={() => navigate('/maintenance-calendar/month-view')}
className="p-1.5 hover:bg-gray-200 dark:hover:bg-gray-700 rounded-lg transition-colors"
title="Back to Month View"
>
<FaArrowLeft className="text-gray-600 dark:text-gray-400" size={18} />
</button>
<FaCalendarAlt className="text-blue-600 dark:text-blue-400" size={24} />
<div>
<h1 className="text-xl font-bold text-gray-800 dark:text-white">
Site or Cluster PPM Calendar
</h1>
<p className="text-xs text-gray-600 dark:text-gray-400">
View PM Schedule Generators across multiple years
</p>
</div>
</div>
<button
onClick={() => navigate('/maintenance-calendar/month-view')}
className="px-3 py-1.5 bg-blue-600 hover:bg-blue-700 text-white rounded-lg transition-colors flex items-center gap-2 text-sm font-medium"
title="Go to Month View"
>
<FaCalendar size={14} />
Month
</button>
</div>
</div>
{/* Year Navigation */}
<div className="flex-shrink-0 px-3 lg:px-4 py-2">
<div className="bg-white dark:bg-gray-800 rounded-lg shadow border border-gray-200 dark:border-gray-700 p-2">
<div className="flex items-center justify-between">
<button
onClick={() => navigateYears(-1)}
className="px-3 py-1.5 bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 rounded-lg text-gray-700 dark:text-gray-300 transition-colors"
>
<FaChevronLeft />
</button>
<h2 className="text-lg font-bold text-gray-800 dark:text-white">
{startYear} - {endYear}
</h2>
<button
onClick={() => navigateYears(1)}
className="px-3 py-1.5 bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 rounded-lg text-gray-700 dark:text-gray-300 transition-colors"
>
<FaChevronRight />
</button>
</div>
</div>
</div>
{/* Calendar Grid */}
<div className="flex-1 overflow-auto px-3 pb-3 lg:px-4 lg:pb-4">
{loading ? (
<div className="flex items-center justify-center h-full">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-500"></div>
<span className="ml-3 text-gray-600 dark:text-gray-400">Loading PPM Planners...</span>
</div>
) : error ? (
<div className="flex items-center justify-center h-full">
<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 loading PPM Planners: {error}</p>
</div>
</div>
) : (
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-lg border border-gray-200 dark:border-gray-700 overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full border-collapse">
<thead className="bg-gray-100 dark:bg-gray-700 sticky top-0 z-10">
<tr>
<th className="border border-gray-300 dark:border-gray-600 px-4 py-3 text-left font-bold text-gray-800 dark:text-white bg-gray-200 dark:bg-gray-800">
Year/Month
</th>
{monthNames.map((month, idx) => (
<th
key={idx}
className="border border-gray-300 dark:border-gray-600 px-2 py-3 text-center font-semibold text-gray-800 dark:text-white min-w-[140px]"
>
{month}
</th>
))}
</tr>
</thead>
<tbody>
{years.map(year => (
<tr key={year} className="hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-colors">
<td className="border border-gray-300 dark:border-gray-600 px-4 py-3 font-bold text-gray-800 dark:text-white bg-gray-50 dark:bg-gray-800/50">
{year}
</td>
{monthNames.map((_, monthIndex) => {
const cellSchedules = schedulesMatrix[year]?.[monthIndex] || [];
return (
<td
key={monthIndex}
className="border border-gray-300 dark:border-gray-600 px-2 py-2 align-top min-h-[60px]"
>
{cellSchedules.length > 0 ? (
<div className="space-y-1">
{cellSchedules.map(schedule => {
// Display PM Name (pm_for) instead of Name
// Check multiple possible field names
const pmName = schedule.pm_for || (schedule as any).pm_for || (schedule as any)['PM Name'] || null;
// Debug log for first item only
if (cellSchedules.indexOf(schedule) === 0) {
console.log('[YearlyCalendar] 🔍 SCHEDULE IN CELL:', {
name: schedule.name,
pm_for: schedule.pm_for,
'pm_for (bracket)': (schedule as any)['pm_for'],
allKeys: Object.keys(schedule),
pmName: pmName
});
}
const displayText = pmName || schedule.name || 'PPM Planner';
// Build hover tooltip: Name, Modality, Hospital
const tooltipParts: string[] = [];
if (schedule.name) {
tooltipParts.push(schedule.name);
}
if (schedule.modality) {
tooltipParts.push(schedule.modality);
}
if (schedule.hospital) {
tooltipParts.push(schedule.hospital);
}
const tooltipText = tooltipParts.length > 0
? `${tooltipParts.join(' - ')} - Click to view details`
: 'Click to view details';
return (
<div
key={schedule.name}
onClick={() => navigate(`/ppm-planner/${schedule.name}`)}
className="text-[10px] p-1 bg-blue-50 dark:bg-blue-900/30 border border-blue-200 dark:border-blue-700 rounded cursor-pointer hover:bg-blue-100 dark:hover:bg-blue-900/50 transition-colors group"
title={tooltipText}
>
<div className="font-medium text-blue-900 dark:text-blue-300 leading-tight group-hover:underline break-words">
{displayText}
</div>
</div>
);
})}
</div>
) : (
<div className="text-xs text-gray-300 dark:text-gray-700 text-center py-1">
-
</div>
)}
</td>
);
})}
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
</div>
</div>
);
};
export default YearlyPPMPlannerPage;

View File

@ -0,0 +1,568 @@
import API_CONFIG from '../config/api';
// Define interfaces locally to avoid import issues
interface ApiResponse<T = any> {
message?: T;
error?: string;
status_code?: number;
}
interface UserDetails {
user_id: string;
full_name: string;
email: string;
user_image?: string;
roles: string[];
permissions: Record<string, {
read: boolean;
write: boolean;
create: boolean;
delete: boolean;
}>;
last_login?: string;
enabled: boolean;
creation: string;
modified: string;
language: string;
custom_site_name?: string;
custom_phcc_site_name?: string;
}
interface DocTypeRecord {
name: string;
creation: string;
modified: string;
modified_by: string;
owner: string;
docstatus: number;
[key: string]: any;
}
interface DocTypeRecordsResponse {
records: DocTypeRecord[];
total_count: number;
limit: number;
offset: number;
has_more: boolean;
doctype: string;
}
interface DashboardStats {
total_users: number;
total_customers: number;
total_items: number;
total_orders: number;
recent_activities: RecentActivity[];
}
// Dashboard number cards
interface NumberCards {
total_assets: number;
work_orders_open: number;
work_orders_in_progress: number;
work_orders_completed: number;
}
// Dashboard chart payload
interface ChartDataset { name: string; values: number[]; color?: string }
interface ChartResponse {
labels: string[];
datasets: ChartDataset[];
type: 'Bar' | 'Pie' | 'Line' | string;
options?: Record<string, any>;
}
interface RecentActivity {
type: string;
name: string;
title: string;
creation: string;
}
interface KycRecord {
name: string;
kyc_status: string;
kyc_type: string;
creation: string;
modified: string;
}
interface KycDetailsResponse {
records: KycRecord[];
summary: {
total: number;
pending: number;
approved: number;
};
}
interface LoginResponse {
message: {
full_name: string;
user_id: string;
sid: string;
};
}
interface LoginCredentials {
email: string;
password: string;
}
interface FileUploadOptions {
file: File;
doctype: string;
docname: string;
fieldname: string;
}
interface RequestOptions {
method?: 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH';
headers?: Record<string, string>;
body?: any;
}
// USER PERMISSION INTERFACES
interface RestrictionInfo {
field: string;
values: string[];
count: number;
}
interface PermissionFiltersResponse {
is_admin: boolean;
filters: Record<string, any>;
restrictions: Record<string, RestrictionInfo>;
target_doctype: string;
user?: string;
total_restrictions?: number;
warning?: string;
}
interface AllowedValuesResponse {
is_admin: boolean;
allowed_values: string[];
default_value?: string | null;
has_restriction: boolean;
allow_doctype: string;
}
interface DocumentAccessResponse {
has_access: boolean;
is_admin?: boolean;
no_restrictions?: boolean;
error?: string;
denied_by?: string;
field?: string;
document_value?: string;
allowed_values?: string[];
}
interface UserDefaultsResponse {
is_admin: boolean;
defaults: Record<string, string>;
}
class ApiService {
private baseURL: string;
// private token: string | null = null;
private endpoints: Record<string, string>;
private defaultHeaders: Record<string, string>;
private timeout: number;
constructor() {
this.baseURL = API_CONFIG.BASE_URL;
this.endpoints = API_CONFIG.ENDPOINTS;
this.defaultHeaders = API_CONFIG.DEFAULT_HEADERS;
this.timeout = API_CONFIG.TIMEOUT;
}
// Get CSRF Token for authenticated requests
async getCSRFToken(): Promise<string | null> {
try {
// First, try to get CSRF token from window (injected by Frappe in HTML)
if (typeof window !== 'undefined' && (window as any).csrf_token) {
return (window as any).csrf_token;
}
// If not in window, try to fetch from API (but only if user is authenticated)
// Check if user is logged in by checking localStorage
const user = localStorage.getItem('user');
if (!user) {
// User not logged in, skip CSRF token fetch
return null;
}
const response = await fetch(`${this.baseURL}${this.endpoints.CSRF_TOKEN}`, {
method: 'GET',
headers: {
'Accept': 'application/json'
},
credentials: 'include' // Include cookies for session
});
if (response.ok) {
const data: ApiResponse<string> = await response.json();
return data.message || null;
} else {
// Silently fail - CSRF token not required for GET requests
return null;
}
} catch (error) {
// Silently fail - CSRF token not required for GET requests
return null;
}
}
// Generic API call method
async apiCall<T = any>(endpoint: string, options: RequestOptions = {}): Promise<T> {
const url = `${this.baseURL}${endpoint}`;
const defaultOptions: RequestInit = {
method: 'GET',
headers: {
...this.defaultHeaders,
...options.headers,
// 'Authorization': `token ${this.token}`
},
...options
};
// Add CSRF token for non-GET requests
// if (defaultOptions.method !== 'GET') {
const csrfToken = await this.getCSRFToken();
if (csrfToken) {
(defaultOptions.headers as Record<string, string>)['X-Frappe-CSRF-Token'] = csrfToken;
}
// }
try {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), this.timeout);
const response = await fetch(url, {
...defaultOptions,
signal: controller.signal
});
clearTimeout(timeoutId);
if (!response.ok) {
const errorData: ApiResponse = await response.json().catch(() => ({}));
throw new ApiError(
errorData.error || `HTTP error! status: ${response.status}`,
response.status
);
}
const data: ApiResponse<T> = await response.json();
// Handle Frappe API response format
if (data.message !== undefined) {
return data.message;
}
return data as T;
} catch (error) {
if (error instanceof Error) {
console.error('API call failed:', error);
throw new ApiError(error.message);
}
throw error;
}
}
// Authentication Methods
async login(credentials: LoginCredentials): Promise<LoginResponse> {
// Only log in development mode (hide sensitive data in production)
if (import.meta.env.DEV) {
console.log('[API Service] Login attempt for:', credentials.email);
}
const formData = new FormData();
formData.append('usr', credentials.email);
formData.append('pwd', credentials.password);
const url = `${this.baseURL}${this.endpoints.LOGIN}`;
try {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), this.timeout);
const response = await fetch(url, {
method: 'POST',
headers: {
'Accept': 'application/json'
},
body: formData,
credentials: 'include', // Important: Include cookies
signal: controller.signal
});
clearTimeout(timeoutId);
if (!response.ok) {
const errorData: ApiResponse = await response.json().catch(() => ({}));
// Hide detailed error messages in production
const errorMessage = import.meta.env.DEV
? (errorData.error || `HTTP error! status: ${response.status}`)
: 'Invalid credentials. Please try again.';
throw new ApiError(errorMessage, response.status);
}
const data: any = await response.json();
// Handle Frappe API response format
// Check if message is a string (like "Logged In")
if (typeof data.message === 'string' && data.message === 'Logged In') {
const userData = {
full_name: data.full_name,
user_id: data.user || data.email,
home_page: data.home_page,
sid: data.sid
};
if (import.meta.env.DEV) {
console.log('[API Service] Login successful');
}
return { message: userData } as LoginResponse;
}
// If message contains user object
if (data.message && typeof data.message === 'object') {
if (import.meta.env.DEV) {
console.log('[API Service] Login successful');
}
return { message: data.message } as LoginResponse;
}
// Sometimes Frappe returns full_name, user directly
if (data.full_name || data.user) {
if (import.meta.env.DEV) {
console.log('[API Service] Login successful');
}
return { message: data } as LoginResponse;
}
return { message: data } as LoginResponse;
} catch (error) {
if (error instanceof Error) {
// Only log detailed errors in development
if (import.meta.env.DEV) {
console.error('[API Service] Login error:', error.message);
}
throw new ApiError(
import.meta.env.DEV ? error.message : 'Login failed. Please try again.'
);
}
throw error;
}
}
// async login(credentials: LoginCredentials): Promise<LoginResponse> {
// const formData = new FormData();
// formData.append('usr', credentials.email);
// formData.append('pwd', credentials.password);
// const response = await fetch(`${this.baseURL}/api/method/login`, {
// method: 'POST',
// body: formData,
// });
// const data = await response.json();
// if (data.message === 'Logged In') {
// // Now get API keys or generate token
// await this.fetchApiKeys(credentials);
// return { message: data } as LoginResponse;
// }
// throw new ApiError('Login failed');
// }
// private async fetchApiKeys(credentials: LoginCredentials): Promise<void> {
// // Use Basic Auth to get API keys
// const basicAuth = btoa(`${credentials.email}:${credentials.password}`);
// // First, check if user has API keys, if not generate them
// const response = await fetch(
// `${this.baseURL}/api/method/frappe.core.doctype.user.user.generate_keys?user=${encodeURIComponent(credentials.email)}`,
// {
// method: 'POST',
// headers: {
// 'Authorization': `Basic ${basicAuth}`,
// 'Accept': 'application/json',
// },
// }
// );
// const data = await response.json();
// if (data.message?.api_secret) {
// // Fetch the api_key from user doc
// const userResponse = await fetch(
// `${this.baseURL}/api/resource/User/${encodeURIComponent(credentials.email)}`,
// {
// method: 'GET',
// headers: {
// 'Authorization': `Basic ${basicAuth}`,
// 'Accept': 'application/json',
// },
// }
// );
// const userData = await userResponse.json();
// const apiKey = userData.data.api_key;
// const apiSecret = data.message.api_secret;
// // Store the token
// this.token = `${apiKey}:${apiSecret}`;
// localStorage.setItem('auth_token', this.token);
// localStorage.setItem('user_email', credentials.email);
// }
// }
async logout(): Promise<void> {
await this.apiCall(this.endpoints.LOGOUT, {
method: 'POST'
});
}
// User Management
async getUserDetails(userId?: string): Promise<UserDetails> {
const params = userId ? `?user_id=${userId}` : '';
return this.apiCall<UserDetails>(`${this.endpoints.USER_DETAILS}${params}`);
}
// Data Management
async getDoctypeRecords(
doctype: string,
filters?: Record<string, any>,
fields?: string[],
limit: number = 20,
offset: number = 0
): Promise<DocTypeRecordsResponse> {
const params = new URLSearchParams({
doctype,
limit: limit.toString(),
offset: offset.toString()
});
if (filters) {
params.append('filters', JSON.stringify(filters));
}
if (fields) {
params.append('fields', JSON.stringify(fields));
}
return this.apiCall<DocTypeRecordsResponse>(`${this.endpoints.DOCTYPE_RECORDS}?${params}`);
}
// Dashboard
async getDashboardStats(): Promise<DashboardStats> {
return this.apiCall<DashboardStats>(this.endpoints.DASHBOARD_STATS);
}
async getNumberCards(): Promise<NumberCards> {
return this.apiCall<NumberCards>(this.endpoints.DASHBOARD_NUMBER_CARDS);
}
async listDashboardCharts(publicOnly: boolean = true): Promise<{ charts: any[] }> {
const params = new URLSearchParams({ public_only: publicOnly ? '1' : '0' });
return this.apiCall<{ charts: any[] }>(`${this.endpoints.DASHBOARD_LIST_CHARTS}?${params}`);
}
async getDashboardChartData(chartName: string, filters?: Record<string, any>): Promise<ChartResponse> {
const params = new URLSearchParams({ chart_name: chartName });
if (filters) params.append('report_filters', JSON.stringify(filters));
return this.apiCall<ChartResponse>(`${this.endpoints.DASHBOARD_CHART_DATA}?${params}`);
}
// KYC Management
async getKycDetails(): Promise<KycDetailsResponse> {
return this.apiCall<KycDetailsResponse>(this.endpoints.KYC_DETAILS);
}
// File Upload
async uploadFile(options: FileUploadOptions): Promise<any> {
const formData = new FormData();
formData.append('file', options.file);
formData.append('doctype', options.doctype);
formData.append('docname', options.docname);
formData.append('fieldname', options.fieldname);
return this.apiCall(this.endpoints.UPLOAD_FILE, {
method: 'POST',
headers: {}, // Don't set Content-Type for FormData
body: formData
});
}
// USER PERMISSION METHODS
async getUserPermissions(userId?: string): Promise<any> {
const params = userId ? `?user=${encodeURIComponent(userId)}` : '';
return this.apiCall(`${this.endpoints.GET_USER_PERMISSIONS}${params}`);
}
async getPermissionFilters(targetDoctype: string, userId?: string): Promise<PermissionFiltersResponse> {
const params = new URLSearchParams({ target_doctype: targetDoctype });
if (userId) params.append('user', userId);
return this.apiCall<PermissionFiltersResponse>(`${this.endpoints.GET_PERMISSION_FILTERS}?${params}`);
}
async getAllowedValues(allowDoctype: string, userId?: string): Promise<AllowedValuesResponse> {
const params = new URLSearchParams({ allow_doctype: allowDoctype });
if (userId) params.append('user', userId);
return this.apiCall<AllowedValuesResponse>(`${this.endpoints.GET_ALLOWED_VALUES}?${params}`);
}
async checkDocumentAccess(doctype: string, docname: string, userId?: string): Promise<DocumentAccessResponse> {
const params = new URLSearchParams({ doctype, docname });
if (userId) params.append('user', userId);
return this.apiCall<DocumentAccessResponse>(`${this.endpoints.CHECK_DOCUMENT_ACCESS}?${params}`);
}
async getConfiguredDoctypes(): Promise<any> {
return this.apiCall(this.endpoints.GET_CONFIGURED_DOCTYPES);
}
async getUserDefaults(userId?: string): Promise<UserDefaultsResponse> {
const params = userId ? `?user=${encodeURIComponent(userId)}` : '';
return this.apiCall<UserDefaultsResponse>(`${this.endpoints.GET_USER_DEFAULTS}${params}`);
}
// Utility Methods
isAuthenticated(): boolean {
// Check if user is authenticated (implement based on your auth strategy)
return !!localStorage.getItem('frappe_session_id');
}
getSessionId(): string | null {
return localStorage.getItem('frappe_session_id');
}
setSessionId(sessionId: string): void {
localStorage.setItem('frappe_session_id', sessionId);
}
}
// Custom Error Class
class ApiError extends Error {
public status?: number;
public code?: string;
constructor(message: string, status?: number, code?: string) {
super(message);
this.name = 'ApiError';
this.status = status;
this.code = code;
}
}
// Create and export singleton instance
const apiService = new ApiService();
export default apiService;
export { ApiError };

View File

@ -0,0 +1,439 @@
import apiService from './apiService';
import API_CONFIG from '../config/api';
// Cast apiService to any to bypass strict typing
const api = apiService as any;
// PPM Table Row Interface (child table)
export interface PPMTableRow {
name?: string;
idx?: number;
docstatus?: number;
doctype?: string;
owner?: string;
parent?: string;
parentfield?: string;
parenttype?: string;
maintenance_name: string;
working: number | boolean;
defect_found: number | boolean;
not_working: number | boolean;
__islocal?: number;
__unsaved?: number;
}
// Asset Maintenance Log Interface
export interface AssetMaintenanceLog {
name: string;
owner?: string;
creation?: string;
modified?: string;
modified_by?: string;
docstatus?: number;
idx?: number;
// Workflow
workflow_state?: string;
// Asset Information
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 Information
task?: string;
task_name?: string;
maintenance_type?: string;
periodicity?: string;
custom_template?: string;
// Status & Dates
maintenance_status?: string;
due_date?: string;
completion_date?: string;
// Assignment
assign_to_name?: string;
// Certificate
has_certificate?: number | boolean;
// Early Completion
custom_early_completion?: string; // "Yes" or "No"
custom_early_completion_reason?: string;
// MOH Approval
custom_accepted_by_moh?: number | boolean;
custom_accepted_by_moh_?: number | boolean;
// Overdue
custom_pm_overdue_reason?: string;
// PPM Table (child table)
custom_table?: PPMTableRow[];
// Description/Notes
description?: string;
}
// Response interface for list
export interface AssetMaintenanceListResponse {
asset_maintenance_logs: AssetMaintenanceLog[];
total_count: number;
limit: number;
offset: number;
has_more: boolean;
}
// Filters interface
export interface MaintenanceFilters {
maintenance_status?: string;
asset_name?: string;
custom_hospital_name?: string;
maintenance_type?: string;
workflow_state?: string;
periodicity?: string;
assign_to_name?: string;
custom_asset_type?: string;
[key: string]: any;
}
// Create/Update data interface
export interface CreateMaintenanceData {
// Asset Information
asset_name?: string;
custom_asset_type?: string;
item_code?: string;
item_name?: string;
custom_asset_names?: string;
custom_hospital_name?: string;
// Task Information
task?: string;
task_name?: string;
maintenance_type?: string;
periodicity?: string;
custom_template?: string;
// Status & Dates
maintenance_status?: string;
due_date?: string;
completion_date?: string;
// Assignment
assign_to_name?: string;
// Certificate
has_certificate?: number | boolean;
// Early Completion
custom_early_completion?: string; // "Yes" or "No"
custom_early_completion_reason?: string;
// MOH Approval
custom_accepted_by_moh?: number | boolean;
custom_accepted_by_moh_?: number | boolean;
// Overdue
custom_pm_overdue_reason?: string;
// PPM Table (child table)
custom_table?: PPMTableRow[];
// Description/Notes
description?: string;
// Allow additional fields
[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);
}
// Request child tables to be included
params.append('include_child_tables', 'true');
const endpoint = `${API_CONFIG.ENDPOINTS.GET_ASSET_MAINTENANCE_LOGS}?${params.toString()}`;
return api.apiCall(endpoint);
}
/**
* Get detailed information about a specific maintenance log
*/
async getMaintenanceLogDetails(logName: string): Promise<AssetMaintenanceLog> {
const params = new URLSearchParams();
params.append('log_name', logName);
params.append('include_child_tables', 'true');
const endpoint = `${API_CONFIG.ENDPOINTS.GET_ASSET_MAINTENANCE_LOG_DETAILS}?${params.toString()}`;
return api.apiCall(endpoint);
}
/**
* Create a new maintenance log
*/
async createMaintenanceLog(logData: CreateMaintenanceData): Promise<{ success: boolean; asset_maintenance_log: AssetMaintenanceLog; message: string; error?: string }> {
// Prepare data - ensure child table is properly formatted
const preparedData = this.prepareLogData(logData);
return api.apiCall(API_CONFIG.ENDPOINTS.CREATE_ASSET_MAINTENANCE_LOG, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ log_data: preparedData })
});
}
/**
* Update an existing maintenance log
*/
async updateMaintenanceLog(
logName: string,
logData: Partial<CreateMaintenanceData>
): Promise<{ success: boolean; asset_maintenance_log: AssetMaintenanceLog; message: string; error?: string }> {
// Prepare data - ensure child table is properly formatted
const preparedData = this.prepareLogData(logData);
return api.apiCall(API_CONFIG.ENDPOINTS.UPDATE_ASSET_MAINTENANCE_LOG, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
log_name: logName,
log_data: preparedData
})
});
}
/**
* Delete a maintenance log
*/
async deleteMaintenanceLog(logName: string): Promise<{ success: boolean; message: string }> {
return api.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 api.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());
params.append('include_child_tables', 'true');
const endpoint = `${API_CONFIG.ENDPOINTS.GET_MAINTENANCE_LOGS_BY_ASSET}?${params.toString()}`;
return api.apiCall(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());
params.append('include_child_tables', 'true');
const endpoint = `${API_CONFIG.ENDPOINTS.GET_OVERDUE_MAINTENANCE_LOGS}?${params.toString()}`;
return api.apiCall(endpoint);
}
/**
* Add a PPM table row to maintenance log
*/
async addPPMTableRow(
logName: string,
rowData: Partial<PPMTableRow>
): Promise<{ success: boolean; custom_table: PPMTableRow[]; message: string }> {
return api.apiCall(API_CONFIG.ENDPOINTS.ADD_PPM_TABLE_ROW || '/api/method/asset_lite.api.asset_maintenance_api.add_ppm_table_row', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
log_name: logName,
row_data: this.cleanPPMRow(rowData)
})
});
}
/**
* Remove a PPM table row from maintenance log
*/
async removePPMTableRow(
logName: string,
rowName: string
): Promise<{ success: boolean; custom_table: PPMTableRow[]; message: string }> {
return api.apiCall(API_CONFIG.ENDPOINTS.REMOVE_PPM_TABLE_ROW || '/api/method/asset_lite.api.asset_maintenance_api.remove_ppm_table_row', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
log_name: logName,
row_name: rowName
})
});
}
/**
* Update a PPM table row
*/
async updatePPMTableRow(
logName: string,
rowName: string,
rowData: Partial<PPMTableRow>
): Promise<{ success: boolean; custom_table: PPMTableRow[]; message: string }> {
return api.apiCall(API_CONFIG.ENDPOINTS.UPDATE_PPM_TABLE_ROW || '/api/method/asset_lite.api.asset_maintenance_api.update_ppm_table_row', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
log_name: logName,
row_name: rowName,
row_data: this.cleanPPMRow(rowData)
})
});
}
/**
* Clean a single PPM row - only include essential fields
*/
private cleanPPMRow(row: Partial<PPMTableRow>): Record<string, any> {
return {
// Only include the name if it exists and is not a temp name
...(row.name && !row.name.startsWith('new-') ? { name: row.name } : {}),
maintenance_name: row.maintenance_name || '',
working: typeof row.working === 'boolean' ? (row.working ? 1 : 0) : (row.working || 0),
defect_found: typeof row.defect_found === 'boolean' ? (row.defect_found ? 1 : 0) : (row.defect_found || 0),
not_working: typeof row.not_working === 'boolean' ? (row.not_working ? 1 : 0) : (row.not_working || 0),
};
}
/**
* Helper method to prepare log data for API
* Ensures child tables and boolean fields are properly formatted
*/
private prepareLogData(logData: Partial<CreateMaintenanceData>): Record<string, any> {
const prepared: Record<string, any> = {};
// Copy only the fields we want to send, excluding internal React state
const allowedFields = [
'asset_name', 'custom_asset_type', 'item_code', 'item_name',
'custom_asset_names', 'custom_hospital_name', 'task', 'task_name',
'maintenance_type', 'periodicity', 'custom_template', 'maintenance_status',
'due_date', 'completion_date', 'assign_to_name', 'has_certificate',
'custom_early_completion', 'custom_early_completion_reason',
'custom_accepted_by_moh', 'custom_accepted_by_moh_', 'custom_pm_overdue_reason',
'description', 'custom_table'
];
for (const field of allowedFields) {
if (logData[field] !== undefined && logData[field] !== null && logData[field] !== '') {
prepared[field] = logData[field];
}
}
// Convert boolean values to integers for Frappe
if (typeof prepared.has_certificate === 'boolean') {
prepared.has_certificate = prepared.has_certificate ? 1 : 0;
}
if (typeof prepared.custom_accepted_by_moh === 'boolean') {
prepared.custom_accepted_by_moh = prepared.custom_accepted_by_moh ? 1 : 0;
}
if (typeof prepared.custom_accepted_by_moh_ === 'boolean') {
prepared.custom_accepted_by_moh_ = prepared.custom_accepted_by_moh_ ? 1 : 0;
}
// Prepare PPM Table rows - clean up each row
if (prepared.custom_table && Array.isArray(prepared.custom_table)) {
prepared.custom_table = prepared.custom_table.map((row: any) => this.cleanPPMRow(row));
}
return prepared;
}
}
// Create and export singleton instance
const assetMaintenanceService = new AssetMaintenanceService();
export default assetMaintenanceService;

View File

@ -0,0 +1,294 @@
import apiService from './apiService';
import API_CONFIG from '../config/api';
// Asset Interfaces
export interface Asset {
name: string;
asset_name: string;
company: string;
docstatus?: number; // 0 = Draft, 1 = Submitted, 2 = Cancelled
custom_serial_number?: string;
location?: string;
custom_manufacturer?: string;
department?: string;
custom_asset_type?: string;
custom_manufacturing_year?: string;
custom_model?: string;
custom_class?: string;
custom_device_status?: string;
custom_down_time?: number;
asset_owner_company?: string;
custom_up_time?: number;
custom_total_hours?: number;
custom_modality?: string;
custom_attach_image?: string;
custom_site_contractor?: string;
custom_site?: string;
custom_total_amount?: number;
creation?: string;
modified?: string;
owner?: string;
modified_by?: string;
status?: string;
calculate_depreciation?: boolean;
gross_purchase_amount?: number;
available_for_use_date?:string;
finance_books?: AssetFinanceBookRow[];
custom_spare_parts?: Array<{
item_code?: string;
item_name?: string;
qty?: number;
rate?: number;
amount?: number;
uom?: string;
work_order?: string;
}>;
custom_total_spare_parts_amount?: number;
// Checkbox fields
custom_warranty?: boolean;
custom_extended_warranty?: boolean;
custom__service_contract?: boolean;
custom_covering_spare_parts?: boolean;
custom_spare_parts_labour?: boolean;
custom_covering_labour?: boolean;
custom_ppm_only?: boolean;
custom_support_plan?: string;
// Service agreement fields
custom_service_agreement?: string;
custom_service_coverage?: string;
custom_start_date?: string;
custom_end_date?: string;
}
export interface AssetListResponse {
assets: Asset[];
total_count: number;
limit: number;
offset: number;
has_more: boolean;
}
export interface AssetFilters {
company?: string;
location?: string;
department?: string;
custom_asset_type?: string;
custom_manufacturer?: string;
custom_device_status?: string;
[key: string]: any;
}
export interface AssetFilterOptions {
companies: string[];
locations: string[];
departments: string[];
asset_types: string[];
manufacturers: string[];
device_statuses: string[];
}
export interface AssetStats {
total_assets: number;
by_status: Record<string, number>;
by_company: Record<string, number>;
by_type: Record<string, number>;
total_amount: number;
}
// Add child row type
export interface AssetFinanceBookRow {
finance_book?: string;
depreciation_method?: string;
total_number_of_depreciations?: number;
frequency_of_depreciation?: number;
depreciation_start_date?: string; // YYYY-MM-DD
}
export interface CreateAssetData {
asset_name: string;
company: string;
custom_serial_number?: string;
location?: string;
custom_manufacturer?: string;
department?: string;
custom_asset_type?: string;
custom_manufacturing_year?: string;
custom_model?: string;
custom_class?: string;
custom_device_status?: string;
custom_down_time?: number;
asset_owner_company?: string;
custom_up_time?: number;
custom_total_hours?: number;
custom_modality?: string;
custom_attach_image?: string;
custom_site_contractor?: string;
custom_site?: string;
custom_total_amount?: number;
calculate_depreciation?: boolean;
finance_books?: AssetFinanceBookRow[];
// Checkbox fields
custom_warranty?: boolean;
custom_extended_warranty?: boolean;
custom__service_contract?: boolean;
custom_covering_spare_parts?: boolean;
custom_spare_parts_labour?: boolean;
custom_covering_labour?: boolean;
custom_ppm_only?: boolean;
custom_support_plan?: string;
// Spare parts
custom_spare_parts?: Array<{
item_code?: string;
item_name?: string;
qty?: number;
rate?: number;
amount?: number;
uom?: string;
work_order?: string;
}>;
custom_total_spare_parts_amount?: number;
[key: string]: any;
}
class AssetService {
/**
* Get list of assets with optional filters and pagination
*/
async getAssets(
filters?: AssetFilters,
fields?: string[],
limit: number = 20,
offset: number = 0,
orderBy?: string
): Promise<AssetListResponse> {
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_ASSETS}?${params.toString()}`;
return apiService.apiCall<AssetListResponse>(endpoint);
}
/**
* Get detailed information about a specific asset
*/
async getAssetDetails(assetName: string): Promise<Asset> {
const endpoint = `${API_CONFIG.ENDPOINTS.GET_ASSET_DETAILS}?asset_name=${encodeURIComponent(assetName)}`;
return apiService.apiCall<Asset>(endpoint);
}
/**
* Create a new asset
*/
async createAsset(assetData: CreateAssetData): Promise<{ success: boolean; asset: Asset; message: string }> {
return apiService.apiCall(API_CONFIG.ENDPOINTS.CREATE_ASSET, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ asset_data: assetData })
});
}
/**
* Update an existing asset
*/
async updateAsset(
assetName: string,
assetData: Partial<CreateAssetData>
): Promise<{ success: boolean; asset: Asset; message: string }> {
return apiService.apiCall(API_CONFIG.ENDPOINTS.UPDATE_ASSET, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
asset_name: assetName,
asset_data: assetData
})
});
}
/**
* Delete an asset
*/
async deleteAsset(assetName: string): Promise<{ success: boolean; message: string }> {
return apiService.apiCall(API_CONFIG.ENDPOINTS.DELETE_ASSET, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ asset_name: assetName })
});
}
/**
* Get available filter options
*/
async getAssetFilters(): Promise<AssetFilterOptions> {
return apiService.apiCall<AssetFilterOptions>(API_CONFIG.ENDPOINTS.GET_ASSET_FILTERS);
}
/**
* Get asset statistics
*/
async getAssetStats(): Promise<AssetStats> {
return apiService.apiCall<AssetStats>(API_CONFIG.ENDPOINTS.GET_ASSET_STATS);
}
/**
* Search assets by keyword
*/
async searchAssets(searchTerm: string, limit: number = 10): Promise<Asset[]> {
const endpoint = `${API_CONFIG.ENDPOINTS.SEARCH_ASSETS}?search_term=${encodeURIComponent(searchTerm)}&limit=${limit}`;
return apiService.apiCall<Asset[]>(endpoint);
}
/**
* Submit an asset document (changes docstatus from 0 to 1)
*/
// async submitAsset(assetName: string): Promise<{ message: string }> {
// return apiService.apiCall('/api/method/frappe.client.submit', {
// method: 'POST',
// headers: {
// 'Content-Type': 'application/json',
// },
// body: JSON.stringify({
// doc: {
// doctype: 'Asset',
// name: assetName
// }
// })
// });
// }
async submitAsset(assetName: string): Promise<{ success: boolean; asset: Asset; message: string }> {
return apiService.apiCall(API_CONFIG.ENDPOINTS.SUBMIT_ASSET || '/api/method/asset_lite.api.asset_api.submit_asset', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
asset_name: assetName
})
});
}
}
// Create and export singleton instance
const assetService = new AssetService();
export default assetService;

View File

@ -0,0 +1,216 @@
import apiService from './apiService';
// Item Interfaces
export interface Item {
name: string;
item_code: string;
item_name: string;
item_group?: string;
custom_part_description?: string;
stock_uom?: string;
custom_item_cost_per_unit?: number;
disabled?: number;
is_stock_item?: number;
opening_stock?: number;
valuation_rate?: number;
standard_rate?: number;
custom_last_calibration_date?: string;
custom_next_due_calibration_date?: string;
description?: string;
brand?: string;
custom_warranty_in_months?: string;
valuation_method?: string;
has_batch_no?: number;
has_serial_no?: number;
is_purchase_item?: number;
is_sales_item?: number;
country_of_origin?: string;
creation?: string;
modified?: string;
owner?: string;
modified_by?: string;
docstatus?: number;
uoms?: Array<{
uom?: string;
conversion_factor?: number;
}>;
item_defaults?: Array<{
company?: string;
default_warehouse?: string;
}>;
[key: string]: any;
}
export interface CreateItemData {
item_code: string;
item_name: string;
item_group?: string;
custom_part_description?: string;
stock_uom?: string;
custom_item_cost_per_unit?: number;
disabled?: number;
is_stock_item?: number;
opening_stock?: number;
valuation_rate?: number;
standard_rate?: number;
custom_last_calibration_date?: string;
custom_next_due_calibration_date?: string;
description?: string;
brand?: string;
custom_warranty_in_months?: string;
valuation_method?: string;
has_batch_no?: number;
has_serial_no?: number;
is_purchase_item?: number;
is_sales_item?: number;
country_of_origin?: string;
uoms?: Array<{
uom?: string;
conversion_factor?: number;
}>;
item_defaults?: Array<{
company?: string;
default_warehouse?: string;
}>;
[key: string]: any;
}
class ItemService {
// Get list of items
async getItems(filters?: Record<string, any>, fields?: string[], limit: number = 20, offset: number = 0): Promise<{ data: Item[]; total: number }> {
try {
const params = new URLSearchParams();
if (filters) {
params.append('filters', JSON.stringify(filters));
}
if (fields) {
params.append('fields', JSON.stringify(fields));
}
params.append('limit_page_length', limit.toString());
params.append('limit_start', offset.toString());
const response = await apiService.apiCall<{ data: Item[] }>(
`/api/resource/Item?${params.toString()}`
);
// Get total count by fetching all records (or use a count endpoint if available)
// For now, we'll use the response length and has_more to estimate
// In a real scenario, you might want to use a count endpoint
const total = response.data?.length || 0;
return {
data: response.data || [],
total: total
};
} catch (error) {
console.error('Error fetching items:', error);
throw error;
}
}
// Get single item by name
async getItem(itemName: string): Promise<Item> {
try {
const response = await apiService.apiCall<{ data: Item }>(
`/api/resource/Item/${itemName}`
);
return response.data;
} catch (error) {
console.error('Error fetching item:', error);
throw error;
}
}
// Create new item
async createItem(itemData: CreateItemData): Promise<Item> {
try {
const response = await apiService.apiCall<{ data: Item }>(
'/api/resource/Item',
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(itemData),
}
);
return response.data;
} catch (error) {
console.error('Error creating item:', error);
throw error;
}
}
// Update item
async updateItem(itemName: string, itemData: Partial<CreateItemData>): Promise<Item> {
try {
const response = await apiService.apiCall<{ data: Item }>(
`/api/resource/Item/${itemName}`,
{
method: 'PUT',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(itemData),
}
);
return response.data;
} catch (error) {
console.error('Error updating item:', error);
throw error;
}
}
// Delete item
async deleteItem(itemName: string): Promise<void> {
try {
await apiService.apiCall(
`/api/resource/Item/${itemName}`,
{
method: 'DELETE',
}
);
} catch (error) {
console.error('Error deleting item:', error);
throw error;
}
}
// Submit item
async submitItem(itemName: string): Promise<Item> {
try {
const response = await apiService.apiCall<{ data: Item }>(
`/api/resource/Item/${itemName}/submit`,
{
method: 'POST',
}
);
return response.data;
} catch (error) {
console.error('Error submitting item:', error);
throw error;
}
}
// Cancel item
async cancelItem(itemName: string): Promise<Item> {
try {
const response = await apiService.apiCall<{ data: Item }>(
`/api/resource/Item/${itemName}/cancel`,
{
method: 'POST',
}
);
return response.data;
} catch (error) {
console.error('Error cancelling item:', error);
throw error;
}
}
}
export default new ItemService();

View File

@ -0,0 +1,127 @@
import apiService from './apiService';
export interface Notification {
name: string;
for_user: string;
subject?: string;
email_content?: string;
document_type?: string;
document_name?: string;
read: number;
creation: string;
type?: string;
from_user?: string;
}
class NotificationService {
/**
* Get notifications for current user
*/
async getNotifications(limit: number = 50, offset: number = 0) {
const user = localStorage.getItem('user');
const userEmail = user ? JSON.parse(user).email : '';
if (!userEmail) {
return [];
}
try {
const filters = JSON.stringify([['for_user', '=', userEmail]]);
const fields = JSON.stringify(['name', 'subject', 'email_content', 'document_type', 'document_name', 'read', 'creation', 'from_user', 'type']);
const response = await apiService.apiCall<any>(
`/api/resource/Notification Log?filters=${encodeURIComponent(filters)}&fields=${encodeURIComponent(fields)}&order_by=creation desc&limit=${limit}&offset=${offset}`
);
console.log('[NotificationService] Fetched notifications:', response?.data);
if (response?.data?.length > 0) {
console.log('[NotificationService] First notification sample:', response.data[0]);
}
return response?.data || [];
} catch (error: any) {
// 417 (Expectation Failed) is common when Notification doctype isn't accessible via resource API
// Silently return empty array - notifications feature will be disabled
if (error?.message?.includes('417') || error?.message?.includes('EXPECTATION FAILED')) {
return [];
}
// Only log non-417 errors
if (!error?.message?.includes('417')) {
console.warn('Notifications API not available:', error?.message || 'Unknown error');
}
return [];
}
}
/**
* Mark notification as read
*/
async markAsRead(notificationName: string) {
try {
return await apiService.apiCall(
`/api/resource/Notification Log/${notificationName}`,
{
method: 'PUT',
body: JSON.stringify({ read: 1 })
}
);
} catch (error: any) {
// Silently handle 417 errors (API not available) or permission errors
if (error?.message?.includes('417') ||
error?.message?.includes('EXPECTATION FAILED') ||
error?.message?.includes('PermissionError') ||
error?.message?.includes('Insufficient Permission')) {
console.warn('[NotificationService] Cannot mark as read (permissions)');
return { success: false, reason: 'permission_denied' };
}
console.warn('Error marking notification as read:', error?.message || 'Unknown error');
throw error;
}
}
/**
* Mark all notifications as read
*/
async markAllAsRead() {
try {
const notifications = await this.getNotifications(1000);
const unread = notifications.filter(n => !n.read);
let markedCount = 0;
for (const notif of unread) {
try {
const result = await this.markAsRead(notif.name);
if (result?.success !== false) {
markedCount++;
}
} catch (error) {
// Continue with other notifications even if one fails
console.error(`Error marking notification ${notif.name} as read:`, error);
}
}
return { success: true, marked: markedCount, total: unread.length };
} catch (error) {
console.error('Error marking all notifications as read:', error);
// Don't throw - return a failed result instead
return { success: false, marked: 0, total: 0 };
}
}
/**
* Get unread count
*/
async getUnreadCount(): Promise<number> {
try {
const notifications = await this.getNotifications(1000);
return notifications.filter(n => !n.read).length;
} catch (error) {
console.error('Error getting unread count:', error);
return 0;
}
}
}
export default new NotificationService();

View File

@ -0,0 +1,462 @@
import apiService from './apiService';
import API_CONFIG from '../config/api';
export interface PPMPlannerFilters {
modality?: string; // 'Biomedical' | 'Non-Biomedical'
asset_type?: string;
department?: string;
location?: string;
manufacturer?: string;
model?: string;
company?: string;
}
export interface BulkScheduleData {
asset_names: string[];
start_date: string;
end_date: string;
maintenance_team?: string;
assign_to?: string; // User to assign to
maintenance_manager?: string; // Maintenance manager (auto-fetched from team)
periodicity: string;
maintenance_type?: string;
no_of_pms?: string;
pm_for?: string; // PM Name - required field
hospital: string; // Required - Company/Hospital name
modality?: string;
manufacturer?: string;
model?: string;
department?: string;
}
export interface MaintenanceTeamDetails {
name: string;
maintenance_manager?: string;
team_members?: string[]; // Array of user names
}
class PPMPlannerService {
/**
* Submit a PM Schedule Generator document
*/
private async submitDocument(docName: string, headers: Record<string, string>): Promise<void> {
try {
// First, fetch the full document
const getDocResponse = await this.fetchWithTimeout(
`${API_CONFIG.BASE_URL}/api/resource/PM Schedule Generator/${encodeURIComponent(docName)}`,
{
method: 'GET',
headers: {
'Accept': 'application/json',
},
credentials: 'include',
},
30000
);
if (!getDocResponse.ok) {
const errorText = await getDocResponse.text();
console.warn('Failed to fetch document for submit:', errorText);
return;
}
const docData = await getDocResponse.json();
const fullDoc = docData.data;
if (!fullDoc) {
console.warn('No document data received');
return;
}
// Now submit with the full document
const submitResponse = await this.fetchWithTimeout(
`${API_CONFIG.BASE_URL}/api/method/frappe.client.submit`,
{
method: 'POST',
headers,
credentials: 'include',
body: JSON.stringify({
doc: fullDoc
})
},
60000 // 60 seconds for submit
);
if (!submitResponse.ok) {
const errorText = await submitResponse.text();
console.warn('Failed to submit document:', errorText);
// Don't throw - document is created even if submit fails
} else {
console.log('✅ Document submitted successfully');
}
} catch (error: any) {
console.warn('Error submitting document:', error.message);
// Don't throw - document is created even if submit fails
}
}
/**
* Helper function to make fetch requests with timeout
*/
private async fetchWithTimeout(
url: string,
options: RequestInit,
timeoutMs: number = 120000 // 120 seconds for bulk operations
): Promise<Response> {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
try {
const response = await fetch(url, {
...options,
signal: controller.signal
});
clearTimeout(timeoutId);
return response;
} catch (error: any) {
clearTimeout(timeoutId);
if (error.name === 'AbortError') {
throw new Error(`Request timeout after ${timeoutMs}ms. The server may be slow or the request is too large.`);
}
if (error.message?.includes('Failed to fetch') || error.message?.includes('ERR_CONNECTION_TIMED_OUT')) {
throw new Error(`Connection timeout. Please check:\n1. Your internet connection\n2. Server is accessible\n3. Try with fewer assets`);
}
throw error;
}
}
/**
* Get filtered assets based on PPM planner criteria
*/
async getFilteredAssets(filters: PPMPlannerFilters) {
const filterArray: any[] = [];
if (filters.modality) {
filterArray.push(['custom_modality', '=', filters.modality]);
}
if (filters.asset_type) {
filterArray.push(['custom_asset_type', '=', filters.asset_type]);
}
if (filters.department) {
filterArray.push(['department', '=', filters.department]);
}
if (filters.location) {
filterArray.push(['location', '=', filters.location]);
}
if (filters.manufacturer) {
filterArray.push(['custom_manufacturer', '=', filters.manufacturer]);
}
if (filters.model) {
filterArray.push(['custom_model', '=', filters.model]);
}
const filtersJson = JSON.stringify(filterArray);
const fields = JSON.stringify([
'name',
'asset_name',
'custom_asset_type',
'department',
'location',
'custom_manufacturer',
'custom_model',
'custom_modality',
'company'
]);
const response = await apiService.apiCall<any>(
`/api/resource/Asset?filters=${encodeURIComponent(filtersJson)}&fields=${encodeURIComponent(fields)}&limit_page_length=1000`
);
return response?.data || [];
}
/**
* Create bulk maintenance schedules for multiple assets
* Uses the existing "PM Schedule Generator" doctype workflow
*/
async createBulkMaintenanceSchedules(data: BulkScheduleData) {
// Validate required fields
if (!data.hospital) {
throw new Error('Hospital/Company is required to create PM Schedule Generator');
}
if (!data.periodicity) {
throw new Error('Periodicity is required');
}
if (data.asset_names.length === 0) {
throw new Error('At least one asset must be selected');
}
// Warn if too many assets (might cause timeout)
if (data.asset_names.length > 50) {
console.warn(`Creating schedules for ${data.asset_names.length} assets. This may take a while...`);
}
// Build the PM Schedule Generator document with correct structure
const pmScheduleDoc = {
doctype: 'PM Schedule Generator',
hospital: data.hospital, // Required: Link to Company
start_date: data.start_date, // Required: Date
end_date: data.end_date, // Required: Date
periodicity: data.periodicity, // Required: Select
pm_for: data.pm_for || null, // Required: PM Name
no_of_pms: data.no_of_pms || null,
maintenance_team: data.maintenance_team || null,
maintenance_manager: data.maintenance_manager || null, // Auto-fetched from team
assign_to: data.assign_to || null, // User to assign to (auto-selected if only one member)
modality: data.modality || null,
manufacturer: data.manufacturer || null,
model: data.model || null,
department: data.department || null,
// Child table: PM Entry Line
maintenance_entries: data.asset_names.map(assetName => ({
doctype: 'PM Entry Line', // Correct child doctype name
asset: assetName, // Link to Asset
start_date: data.start_date,
end_date: data.end_date
}))
};
console.log('Creating PM Schedule Generator with document:', JSON.stringify(pmScheduleDoc, null, 2));
// Get CSRF token
const csrfToken = await apiService.getCSRFToken();
const headers: Record<string, string> = {
'Content-Type': 'application/json',
'Accept': 'application/json',
};
if (csrfToken) {
headers['X-Frappe-CSRF-Token'] = csrfToken;
}
// Method 1: Try frappe.client.insert (best for child tables)
try {
const insertResponse = await this.fetchWithTimeout(
`${API_CONFIG.BASE_URL}/api/method/frappe.client.insert`,
{
method: 'POST',
headers,
credentials: 'include',
body: JSON.stringify({ doc: pmScheduleDoc })
},
120000 // 120 seconds timeout
);
const responseText = await insertResponse.text();
console.log('frappe.client.insert response:', responseText);
if (!insertResponse.ok) {
throw new Error(`HTTP ${insertResponse.status}: ${responseText}`);
}
const insertResult = JSON.parse(responseText);
const docName = insertResult?.message?.name;
if (docName) {
console.log('✅ PM Schedule Generator created successfully:', docName);
// Submit the document
await this.submitDocument(docName, headers);
return {
success: true,
created: data.asset_names.length,
document: docName,
message: `PM Schedule Generator "${docName}" created and submitted with ${data.asset_names.length} assets`
};
}
throw new Error('No document name in response');
} catch (insertError: any) {
console.warn('Method 1 (frappe.client.insert) failed:', insertError.message);
// Method 2: Try Resource API
try {
// Remove doctype from document for Resource API
const { doctype, maintenance_entries, ...parentDoc } = pmScheduleDoc;
const resourceDoc = {
...parentDoc,
maintenance_entries: maintenance_entries.map(({ doctype: _, ...entry }) => entry)
};
console.log('Trying Resource API with:', JSON.stringify(resourceDoc, null, 2));
const resourceResponse = await this.fetchWithTimeout(
`${API_CONFIG.BASE_URL}/api/resource/PM%20Schedule%20Generator`,
{
method: 'POST',
headers,
credentials: 'include',
body: JSON.stringify(resourceDoc)
},
120000 // 120 seconds timeout
);
const responseText = await resourceResponse.text();
console.log('Resource API response:', responseText);
if (!resourceResponse.ok) {
throw new Error(`HTTP ${resourceResponse.status}: ${responseText}`);
}
const resourceResult = JSON.parse(responseText);
const docName = resourceResult?.data?.name;
if (docName) {
console.log('✅ PM Schedule Generator created via Resource API:', docName);
// Submit the document
await this.submitDocument(docName, headers);
return {
success: true,
created: data.asset_names.length,
document: docName,
message: `PM Schedule Generator "${docName}" created and submitted with ${data.asset_names.length} assets`
};
}
throw new Error('No document name in response');
} catch (resourceError: any) {
console.warn('Method 2 (Resource API) failed:', resourceError.message);
// Both methods failed - provide helpful error message
// Note: We do NOT create Asset Maintenance Log entries as fallback
// Frappe will automatically create them when PM Schedule Generator is submitted
const errorSummary: string[] = [];
// Check if all errors are timeouts
const allTimeouts =
(insertError?.message?.includes('timeout') || insertError?.message?.includes('Failed to fetch')) &&
(resourceError?.message?.includes('timeout') || resourceError?.message?.includes('Failed to fetch'));
if (allTimeouts) {
errorSummary.push(
`⚠️ Connection timeout detected. This usually means:\n` +
`• The server is taking too long to process ${data.asset_names.length} assets\n` +
`• Network connection is slow or unstable\n` +
`• Server may be overloaded\n\n` +
`💡 Suggestions:\n` +
`• Try with fewer assets (10-20 at a time)\n` +
`• Check your internet connection\n` +
`• Try again later if server is busy`
);
} else {
errorSummary.push(`Failed to create PM Schedule Generator. Errors:`);
if (insertError?.message) {
errorSummary.push(`• frappe.client.insert: ${insertError.message.substring(0, 150)}`);
}
if (resourceError?.message) {
errorSummary.push(`• Resource API: ${resourceError.message.substring(0, 150)}`);
}
errorSummary.push(`\nPlease ensure:\n1. Hospital (${data.hospital}) is valid\n2. You have permission to create PM Schedule Generator\n3. All required fields are filled (including PM Name and Assign To)`);
}
throw new Error(`Failed to create PM Schedule Generator.\n\n${errorSummary.join('\n')}`);
}
}
}
/**
* Get filter options for dropdowns
*/
async getFilterOptions() {
try {
const response = await apiService.apiCall<any>(
`/api/resource/Asset?fields=${encodeURIComponent(JSON.stringify(['custom_modality', 'custom_asset_type', 'department', 'location', 'custom_manufacturer', 'custom_model']))}&limit_page_length=1000`
);
const assets = response?.data || [];
const options = {
modalities: [...new Set(assets.map((a: any) => a.custom_modality).filter(Boolean))] as string[],
assetTypes: [...new Set(assets.map((a: any) => a.custom_asset_type).filter(Boolean))] as string[],
departments: [...new Set(assets.map((a: any) => a.department).filter(Boolean))] as string[],
locations: [...new Set(assets.map((a: any) => a.location).filter(Boolean))] as string[],
manufacturers: [...new Set(assets.map((a: any) => a.custom_manufacturer).filter(Boolean))] as string[],
models: [...new Set(assets.map((a: any) => a.custom_model).filter(Boolean))] as string[],
company: [...new Set(assets.map((a: any) => a.company).filter(Boolean))] as string[]
};
return options;
} catch (error) {
console.error('Error fetching filter options:', error);
return {
modalities: [],
assetTypes: [],
departments: [],
locations: [],
manufacturers: [],
models: [],
company: [],
};
}
}
/**
* Get maintenance teams
* Uses the correct doctype name: Asset Maintenance Team
* Standard Frappe fields: name (required), company (optional)
*/
async getMaintenanceTeams() {
try {
// Use Asset Maintenance Team doctype - only request 'name' field (required)
// We'll use 'name' as both the value and display name
const response = await apiService.apiCall<any>(
`/api/resource/Asset Maintenance Team?fields=${encodeURIComponent(JSON.stringify(['name']))}&limit_page_length=1000`
);
if (response?.data && response.data.length > 0) {
// Map the response - use 'name' as both identifier and display
return response.data.map((team: any) => ({
name: team.name,
maintenance_team_name: team.name // Use name as display name
}));
}
return [];
} catch (error: any) {
// Silently return empty array if doctype doesn't exist or fields are wrong
// Maintenance teams feature will work with manual text input
console.warn('Could not fetch maintenance teams:', error?.message || 'Unknown error');
return [];
}
}
/**
* Get maintenance team details including manager and members
*/
async getMaintenanceTeamDetails(teamName: string): Promise<MaintenanceTeamDetails | null> {
try {
const response = await apiService.apiCall<any>(
`/api/resource/Asset Maintenance Team/${encodeURIComponent(teamName)}`
);
if (response?.data) {
const team = response.data;
// Extract team members from the child table
const teamMembers: string[] = [];
if (team.maintenance_team_members && Array.isArray(team.maintenance_team_members)) {
team.maintenance_team_members.forEach((member: any) => {
if (member.team_member) {
teamMembers.push(member.team_member);
}
});
}
return {
name: team.name,
maintenance_manager: team.maintenance_manager || undefined,
team_members: teamMembers.length > 0 ? teamMembers : undefined
};
}
return null;
} catch (error: any) {
console.warn('Could not fetch maintenance team details:', error?.message || 'Unknown error');
return null;
}
}
}
export default new PPMPlannerService();

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,105 @@
import apiService from './apiService';
export interface TranslationRecord {
source_text: string;
translated_text: string;
language: string;
context?: string;
}
export interface TranslationsMap {
[key: string]: string;
}
/**
* Fetch translations from Frappe Translation doctype
* Frappe stores translations in the "Translation" doctype, not "Language"
* Language doctype is just for enabling languages
*/
export async function fetchTranslationsFromFrappe(language: string): Promise<TranslationsMap> {
try {
// Fetch all translation records for the specified language
// Frappe uses "Translation" doctype with fields: source_text, translated_text, language, context
const response = await apiService.getDoctypeRecords(
'Translation',
{ language: language },
['source_text', 'translated_text', 'context'],
10000, // Large limit to get all translations
0
);
const translations: TranslationsMap = {};
if (response.records && response.records.length > 0) {
response.records.forEach((record: any) => {
const sourceText = record.source_text;
const translatedText = record.translated_text || sourceText;
// Frappe translations can have:
// 1. source_text as the key (e.g., "Dashboard")
// 2. context for namespacing (e.g., "common.Dashboard" if context is "common")
// 3. Or source_text might already be a nested key (e.g., "common.dashboard")
if (record.context) {
// If context exists, create nested structure: context.source_text
const key = `${record.context}.${sourceText}`;
translations[key] = translatedText;
} else if (sourceText.includes('.')) {
// If source_text already contains dots, use it as-is (already nested)
translations[sourceText] = translatedText;
} else {
// Flat structure: use source_text as key
translations[sourceText] = translatedText;
}
});
}
return translations;
} catch (error) {
console.error('Error fetching translations from Frappe:', error);
// Return empty object on error, will fall back to static translations
return {};
}
}
/**
* Convert flat translation map to nested structure for i18next
* Handles keys like "common.dashboard" -> { common: { dashboard: "..." } }
* Also handles simple keys without dots
*/
export function nestTranslations(flatTranslations: TranslationsMap): Record<string, any> {
const nested: Record<string, any> = {};
Object.keys(flatTranslations).forEach((key) => {
// If key contains dots, create nested structure
if (key.includes('.')) {
const parts = key.split('.');
let current = nested;
for (let i = 0; i < parts.length - 1; i++) {
const part = parts[i];
if (!current[part]) {
current[part] = {};
}
current = current[part];
}
current[parts[parts.length - 1]] = flatTranslations[key];
} else {
// Simple key without dots - add directly
nested[key] = flatTranslations[key];
}
});
return nested;
}
/**
* Get translations for a specific language from Frappe
* Returns nested translation object ready for i18next
*/
export async function getFrappeTranslations(language: string): Promise<Record<string, any>> {
const flatTranslations = await fetchTranslationsFromFrappe(language);
return nestTranslations(flatTranslations);
}

View File

@ -0,0 +1,335 @@
import apiService from './apiService';
import API_CONFIG from '../config/api';
// Work Order Interfaces
export interface WorkOrder {
completion_date: string;
name: string;
company?: string;
naming_series?: string;
work_order_type?: string;
asset_type?: string;
manufacturer?: string;
serial_number?: string;
custom_serial_number?: string;
custom_manufacturer?: 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;
stock_items?: StockItem[];
site_name?: string;
custom_assign_to_contractor?: string;
}
export interface StockItem {
item_code: string;
item_name?: string;
warehouse: string;
consumed_quantity: number;
valuation_rate: number;
custom_available_stock: number;
total_value: 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;
custom_manufacturer?: string;
supplier?: string;
custom_serial_number?: string;
custom_priority_?: string;
[key: string]: any;
}
export interface WorkOrderFilterOptions {
companies: string[];
departments: string[];
work_order_types: string[];
repair_statuses: string[];
workflow_states: string[];
manufacturers: string[];
suppliers: string[];
priorities: string[];
}
export interface WorkOrderStats {
total_work_orders: number;
by_status: Record<string, number>;
by_company: Record<string, number>;
by_type: Record<string, number>;
by_priority: Record<string, number>;
total_repair_cost: number;
avg_resolution_time: number;
}
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;
custom_manufacturer?: string;
supplier?: string;
custom_serial_number?: 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
})
});
}
/**
* Get available filter options
*/
async getWorkOrderFilters(): Promise<WorkOrderFilterOptions> {
return apiService.apiCall<WorkOrderFilterOptions>(API_CONFIG.ENDPOINTS.GET_WORK_ORDER_FILTERS);
}
/**
* Get work order statistics
*/
async getWorkOrderStats(): Promise<WorkOrderStats> {
return apiService.apiCall<WorkOrderStats>(API_CONFIG.ENDPOINTS.GET_WORK_ORDER_STATS);
}
/**
* Search work orders by keyword
*/
async searchWorkOrders(searchTerm: string, limit: number = 10): Promise<WorkOrder[]> {
const endpoint = `${API_CONFIG.ENDPOINTS.SEARCH_WORK_ORDERS}?search_term=${encodeURIComponent(searchTerm)}&limit=${limit}`;
return apiService.apiCall<WorkOrder[]>(endpoint);
}
/**
* Submit a work order document (changes docstatus from 0 to 1)
*/
async submitWorkOrder(workOrderName: string): Promise<{ message: string }> {
return apiService.apiCall('/api/method/frappe.client.submit', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
doc: {
doctype: 'Asset Repair',
name: workOrderName
}
})
});
}
/**
* Cancel a work order document (changes docstatus from 1 to 2)
*/
async cancelWorkOrder(workOrderName: string): Promise<{ message: string }> {
return apiService.apiCall('/api/method/frappe.client.cancel', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
doc: {
doctype: 'Asset Repair',
name: workOrderName
}
})
});
}
/**
* Get work orders for a specific asset
*/
async getWorkOrdersByAsset(assetName: string, limit: number = 50): Promise<WorkOrder[]> {
const filters: WorkOrderFilters = { asset: assetName };
const response = await this.getWorkOrders(filters, undefined, limit, 0, 'creation desc');
return response.work_orders;
}
/**
* Get open work orders (not completed or cancelled)
*/
async getOpenWorkOrders(limit: number = 50): Promise<WorkOrder[]> {
const filters: WorkOrderFilters = {
repair_status: ['not in', ['Completed', 'Cancelled']] as any
};
const response = await this.getWorkOrders(filters, undefined, limit, 0, 'creation desc');
return response.work_orders;
}
/**
* Get work orders by priority
*/
async getWorkOrdersByPriority(priority: string, limit: number = 50): Promise<WorkOrder[]> {
const filters: WorkOrderFilters = { custom_priority_: priority };
const response = await this.getWorkOrders(filters, undefined, limit, 0, 'creation desc');
return response.work_orders;
}
}
// Create and export singleton instance
const workOrderService = new WorkOrderService();
export default workOrderService;

View File

@ -0,0 +1,626 @@
import apiService from './apiService';
export interface WorkflowTransition {
name: string;
action: string;
next_state: string;
allowed: string;
condition?: string;
state: string;
}
export interface WorkflowState {
state: string;
doc_status: string;
update_field?: string;
update_value?: string;
allow_edit: string;
style?: string;
}
export interface WorkflowInfo {
workflow_name: string;
workflow_state: string;
workflow_state_field: string;
transitions: WorkflowTransition[];
states: WorkflowState[];
}
// Cache for current user and roles
let cachedUser: string | null = null;
let cachedRoles: string[] | null = null;
/**
* Set current user manually (call this from your AuthContext or login handler)
*/
export const setCurrentUser = (user: string, roles?: string[]) => {
cachedUser = user;
if (roles) {
cachedRoles = roles;
}
console.log('[Workflow] User set manually:', user, 'Roles:', roles);
};
/**
* Clear cached user (call this on logout)
*/
export const clearCurrentUser = () => {
cachedUser = null;
cachedRoles = null;
};
/**
* Get current logged in user and roles using apiService.getUserDetails()
*/
export const getCurrentUserAndRoles = async (): Promise<{ user: string; roles: string[] }> => {
try {
// Check cache first
if (cachedUser && cachedRoles) {
console.log('[Workflow] Using cached user:', cachedUser, 'roles:', cachedRoles);
return { user: cachedUser, roles: cachedRoles };
}
// Use the existing getUserDetails() from apiService
const userDetails = await apiService.getUserDetails();
if (userDetails && userDetails.email) {
const user = userDetails.email || userDetails.user_id;
const roles = userDetails.roles || [];
// Cache for future use
cachedUser = user;
cachedRoles = roles;
console.log('[Workflow] User from getUserDetails():', user);
console.log('[Workflow] Roles from getUserDetails():', roles);
return { user, roles };
}
console.warn('[Workflow] getUserDetails() returned no user');
return { user: '', roles: [] };
} catch (error) {
console.error('[Workflow] Error getting user details:', error);
return { user: '', roles: [] };
}
};
/**
* Get current logged in user
*/
export const getCurrentUser = async (): Promise<string> => {
const { user } = await getCurrentUserAndRoles();
return user;
};
/**
* Get current user's roles
*/
export const getCurrentUserRoles = async (): Promise<string[]> => {
const { roles } = await getCurrentUserAndRoles();
return roles;
};
/**
* Check if current user has System Manager role
*/
export const isSystemManager = async (): Promise<boolean> => {
try {
const roles = await getCurrentUserRoles();
const isSysManager = roles.includes('System Manager');
console.log('[Workflow] Is System Manager:', isSysManager, 'Roles:', roles);
return isSysManager;
} catch (error) {
console.error('[Workflow] Error checking System Manager role:', error);
return false;
}
};
/**
* Evaluate a workflow condition against document data
* Supports simple Python-like conditions used in Frappe workflows
*/
export const evaluateCondition = (condition: string | undefined, doc: Record<string, any>): boolean => {
if (!condition || condition.trim() === '') {
return true; // No condition means always valid
}
try {
console.log('[Workflow] Evaluating condition:', condition);
console.log('[Workflow] Document data for condition:', {
asset_type: doc.asset_type,
site_name: doc.site_name,
need_procurement: doc.need_procurement,
custom_assign_to_contractor: doc.custom_assign_to_contractor,
docstatus: doc.docstatus,
});
// Create a safe evaluation context
let evalCondition = condition;
// Replace Python-style operators with JavaScript equivalents
evalCondition = evalCondition.replace(/\band\b/g, '&&');
evalCondition = evalCondition.replace(/\bor\b/g, '||');
evalCondition = evalCondition.replace(/\bnot\s+/g, '!');
evalCondition = evalCondition.replace(/\bTrue\b/g, 'true');
evalCondition = evalCondition.replace(/\bFalse\b/g, 'false');
evalCondition = evalCondition.replace(/\bNone\b/g, 'null');
// Replace doc.field_name with actual values
evalCondition = evalCondition.replace(/doc\.(\w+)/g, (match, fieldName) => {
const value = doc[fieldName];
// Handle undefined, null, or empty string as falsy
if (value === undefined || value === null || value === '') {
return 'false'; // Use false for falsy check compatibility
}
if (typeof value === 'string') {
return `"${value.replace(/"/g, '\\"')}"`;
}
if (typeof value === 'boolean') {
return value ? 'true' : 'false';
}
if (typeof value === 'number') {
return String(value);
}
return JSON.stringify(value);
});
// Handle == 1 and == 0 for boolean-like comparisons
evalCondition = evalCondition.replace(/== 1/g, '=== 1');
evalCondition = evalCondition.replace(/== 0/g, '=== 0');
evalCondition = evalCondition.replace(/!= 1/g, '!== 1');
evalCondition = evalCondition.replace(/!= 0/g, '!== 0');
// Fix negation of false (from empty fields) - !false should be true
// This handles cases like "not doc.site_name" where site_name is empty
console.log('[Workflow] Transformed condition:', evalCondition);
// Evaluate the condition safely
const result = new Function(`return (${evalCondition})`)();
console.log('[Workflow] Condition result:', result);
return Boolean(result);
} catch (error) {
console.error('[Workflow] Error evaluating condition:', condition, error);
// If we can't evaluate, default to false for safety
return false;
}
};
/**
* Get workflow info for a doctype
*/
export const getWorkflowInfo = async (doctype: string): Promise<WorkflowInfo | null> => {
try {
console.log('[Workflow] Getting workflow info for doctype:', doctype);
// First get the workflow name for this doctype
const workflowResponse = await apiService.apiCall<any>(
`/api/resource/Workflow?filters=[["document_type","=","${doctype}"],["is_active","=",1]]&fields=["name","workflow_state_field"]&limit=1`
);
console.log('[Workflow] Workflow response:', workflowResponse);
if (!workflowResponse?.data || workflowResponse.data.length === 0) {
console.warn('[Workflow] No active workflow found for doctype:', doctype);
return null;
}
const workflowName = workflowResponse.data[0].name;
console.log('[Workflow] Found workflow:', workflowName);
// Get full workflow details
const fullWorkflow = await apiService.apiCall<any>(
`/api/resource/Workflow/${encodeURIComponent(workflowName)}`
);
console.log('[Workflow] Full workflow data:', fullWorkflow?.data);
console.log('[Workflow] Transitions count:', fullWorkflow?.data?.transitions?.length);
console.log('[Workflow] States count:', fullWorkflow?.data?.states?.length);
return {
workflow_name: fullWorkflow.data.name,
workflow_state: '',
workflow_state_field: fullWorkflow.data.workflow_state_field,
transitions: fullWorkflow.data.transitions || [],
states: fullWorkflow.data.states || [],
};
} catch (error) {
console.error('[Workflow] Error fetching workflow info:', error);
return null;
}
};
/**
* Get all transitions for current state (for System Manager)
* Now includes condition evaluation and proper deduplication
*/
export const getAllTransitionsForState = async (
doctype: string,
currentState: string,
docData?: Record<string, any>
): Promise<WorkflowTransition[]> => {
try {
console.log('[Workflow] Getting all transitions for state:', currentState);
const workflowInfo = await getWorkflowInfo(doctype);
if (!workflowInfo) {
console.warn('[Workflow] No workflow info found');
return [];
}
console.log('[Workflow] All transitions from workflow:', workflowInfo.transitions.length);
// Filter transitions that start from the current state
let filteredTransitions = workflowInfo.transitions.filter(t => t.state === currentState);
console.log('[Workflow] Transitions for state', currentState, ':', filteredTransitions.length);
// If document data is provided, evaluate conditions
if (docData) {
filteredTransitions = filteredTransitions.filter(t => {
const conditionMet = evaluateCondition(t.condition, docData);
console.log(`[Workflow] Transition "${t.action}" (allowed: ${t.allowed}) condition "${t.condition || 'none'}" = ${conditionMet}`);
return conditionMet;
});
console.log('[Workflow] Transitions after condition evaluation:', filteredTransitions.length);
}
// Deduplicate transitions with same action AND same next_state
// This handles cases where multiple roles can trigger the same action
const seenActions = new Set<string>();
const uniqueTransitions: WorkflowTransition[] = [];
for (const transition of filteredTransitions) {
const key = `${transition.action}::${transition.next_state}`;
if (!seenActions.has(key)) {
seenActions.add(key);
uniqueTransitions.push(transition);
} else {
console.log(`[Workflow] Skipping duplicate: ${transition.action}${transition.next_state} (allowed: ${transition.allowed})`);
}
}
console.log('[Workflow] Unique transitions after deduplication:', uniqueTransitions.length);
return uniqueTransitions;
} catch (error) {
console.error('[Workflow] Error fetching all transitions:', error);
return [];
}
};
/**
* Get available workflow transitions for a document
* System Manager gets all transitions for the current state (with conditions evaluated)
* Other users get transitions based on their role
*/
export const getWorkflowTransitions = async (
doctype: string,
docname: string,
currentState?: string,
docData?: Record<string, any>
): Promise<WorkflowTransition[]> => {
try {
console.log('[Workflow] getWorkflowTransitions called with:', { doctype, docname, currentState });
// Check if user is System Manager
const isSysManager = await isSystemManager();
const userRoles = await getCurrentUserRoles();
console.log('[Workflow] User is System Manager:', isSysManager);
console.log('[Workflow] User roles:', userRoles);
if (isSysManager && currentState) {
console.log('[Workflow] System Manager detected, getting all transitions for state:', currentState);
// System Manager gets all transitions for current state (already deduplicated in getAllTransitionsForState)
const uniqueTransitions = await getAllTransitionsForState(doctype, currentState, docData);
console.log('[Workflow] Final transitions for System Manager:', uniqueTransitions.map(t => `${t.action}${t.next_state}`));
return uniqueTransitions;
}
// For non-System Manager users, use Frappe's built-in permission check
console.log('[Workflow] Non-System Manager, using Frappe API');
const response = await apiService.apiCall<any>(
'/api/method/frappe.model.workflow.get_transitions',
{
method: 'POST',
body: JSON.stringify({
doc: JSON.stringify({ doctype, name: docname }),
}),
}
);
console.log('[Workflow] Frappe transitions raw response:', response);
// Handle different response structures
let transitions: WorkflowTransition[] = [];
if (Array.isArray(response)) {
transitions = response;
} else if (response?.message && Array.isArray(response.message)) {
transitions = response.message;
} else if (response?.data && Array.isArray(response.data)) {
transitions = response.data;
} else if (response?.data?.message && Array.isArray(response.data.message)) {
transitions = response.data.message;
}
console.log('[Workflow] Parsed transitions:', transitions);
console.log('[Workflow] Transitions count:', transitions.length);
// If Frappe API didn't return transitions but we have docData, try local filtering
if (transitions.length === 0 && currentState && docData) {
console.log('[Workflow] Frappe API returned no transitions, trying local filtering');
const workflowInfo = await getWorkflowInfo(doctype);
if (workflowInfo) {
// Filter by state, role, and conditions
const localTransitions = workflowInfo.transitions.filter(t => {
// Check state
if (t.state !== currentState) return false;
// Check role
if (!userRoles.includes(t.allowed)) return false;
// Check condition
if (!evaluateCondition(t.condition, docData)) return false;
return true;
});
// Deduplicate
const seenActions = new Set<string>();
const uniqueTransitions: WorkflowTransition[] = [];
for (const transition of localTransitions) {
const key = `${transition.action}::${transition.next_state}`;
if (!seenActions.has(key)) {
seenActions.add(key);
uniqueTransitions.push(transition);
}
}
console.log('[Workflow] Local filtered transitions:', uniqueTransitions);
return uniqueTransitions;
}
}
return transitions;
} catch (error) {
console.error('[Workflow] Error fetching workflow transitions:', error);
return [];
}
};
/**
* Apply a workflow action to a document
* System Manager can apply any action, others follow normal workflow rules
*/
export const applyWorkflowAction = async (
doctype: string,
docname: string,
action: string,
nextState?: string
): Promise<any> => {
try {
console.log('[Workflow] Applying action:', { doctype, docname, action, nextState });
// Check if user is System Manager
const isSysManager = await isSystemManager();
if (isSysManager && nextState) {
// System Manager can directly update workflow state
// First try normal workflow action
try {
const response = await apiService.apiCall<any>(
'/api/method/frappe.model.workflow.apply_workflow',
{
method: 'POST',
body: JSON.stringify({
doc: JSON.stringify({ doctype, name: docname }),
action: action,
}),
}
);
console.log('[Workflow] Action applied successfully via workflow API');
return response?.message;
} catch (workflowError) {
// If normal workflow fails, System Manager can force update
console.log('[Workflow] Normal workflow failed, System Manager forcing state change...');
const updateResponse = await apiService.apiCall<any>(
`/api/resource/${doctype}/${encodeURIComponent(docname)}`,
{
method: 'PUT',
body: JSON.stringify({
workflow_state: nextState,
}),
}
);
console.log('[Workflow] Force update response:', updateResponse);
return updateResponse?.data;
}
}
// Normal workflow action for non-System Manager
const response = await apiService.apiCall<any>(
'/api/method/frappe.model.workflow.apply_workflow',
{
method: 'POST',
body: JSON.stringify({
doc: JSON.stringify({ doctype, name: docname }),
action: action,
}),
}
);
console.log('[Workflow] Action applied successfully');
return response?.message;
} catch (error) {
console.error('[Workflow] Error applying workflow action:', error);
throw error;
}
};
/**
* Check if user can edit document based on workflow state
* System Manager can always edit
*/
export const canUserEditDocument = async (
doctype: string,
docname: string,
workflowState: string
): Promise<boolean> => {
try {
// System Manager can always edit
const isSysManager = await isSystemManager();
if (isSysManager) {
console.log('[Workflow] System Manager can always edit');
return true;
}
const workflowInfo = await getWorkflowInfo(doctype);
if (!workflowInfo) return true; // No workflow, allow edit
const userRoles = await getCurrentUserRoles();
// Find all state entries that match the current state
const matchingStates = workflowInfo.states.filter(s => s.state === workflowState);
if (matchingStates.length === 0) return true;
// Check if user has any of the roles that can edit in this state
const canEdit = matchingStates.some(stateInfo => userRoles.includes(stateInfo.allow_edit));
console.log('[Workflow] Can user edit:', canEdit, 'User roles:', userRoles, 'Allowed roles:', matchingStates.map(s => s.allow_edit));
return canEdit;
} catch (error) {
console.error('[Workflow] Error checking edit permission:', error);
return false;
}
};
/**
* Get workflow state style/color
*/
export const getWorkflowStateStyle = (state: string): { bg: string; text: string; border: string } => {
const stateStyles: Record<string, { bg: string; text: string; border: string }> = {
'Draft': {
bg: 'bg-gray-100 dark:bg-gray-700',
text: 'text-gray-800 dark:text-gray-200',
border: 'border-gray-300 dark:border-gray-600'
},
'Sent To Maintenance manger': {
bg: 'bg-blue-100 dark:bg-blue-900/30',
text: 'text-blue-800 dark:text-blue-200',
border: 'border-blue-300 dark:border-blue-600'
},
'Sent to General WOA': {
bg: 'bg-blue-100 dark:bg-blue-900/30',
text: 'text-blue-800 dark:text-blue-200',
border: 'border-blue-300 dark:border-blue-600'
},
'Repair InProgress': {
bg: 'bg-yellow-100 dark:bg-yellow-900/30',
text: 'text-yellow-800 dark:text-yellow-200',
border: 'border-yellow-300 dark:border-yellow-600'
},
'Pending Purchase': {
bg: 'bg-orange-100 dark:bg-orange-900/30',
text: 'text-orange-800 dark:text-orange-200',
border: 'border-orange-300 dark:border-orange-600'
},
'Pending Approval': {
bg: 'bg-purple-100 dark:bg-purple-900/30',
text: 'text-purple-800 dark:text-purple-200',
border: 'border-purple-300 dark:border-purple-600'
},
'Completed': {
bg: 'bg-green-100 dark:bg-green-900/30',
text: 'text-green-800 dark:text-green-200',
border: 'border-green-300 dark:border-green-600'
},
'Rejected': {
bg: 'bg-red-100 dark:bg-red-900/30',
text: 'text-red-800 dark:text-red-200',
border: 'border-red-300 dark:border-red-600'
},
'Cancelled': {
bg: 'bg-red-100 dark:bg-red-900/30',
text: 'text-red-800 dark:text-red-200',
border: 'border-red-300 dark:border-red-600'
},
'Closed': {
bg: 'bg-gray-100 dark:bg-gray-700',
text: 'text-gray-800 dark:text-gray-200',
border: 'border-gray-300 dark:border-gray-600'
},
'Applied': {
bg: 'bg-blue-100 dark:bg-blue-900/30',
text: 'text-blue-800 dark:text-blue-200',
border: 'border-blue-300 dark:border-blue-600'
},
};
return stateStyles[state] || stateStyles['Draft'];
};
/**
* Get action button style based on action name
*/
export const getActionButtonStyle = (action: string): string => {
const actionStyles: Record<string, string> = {
'Apply': 'bg-blue-600 hover:bg-blue-700 text-white',
'Send For Repair': 'bg-yellow-600 hover:bg-yellow-700 text-white',
'Send For Approval': 'bg-purple-600 hover:bg-purple-700 text-white',
'Material Request': 'bg-orange-600 hover:bg-orange-700 text-white',
'Accept': 'bg-green-600 hover:bg-green-700 text-white',
'Reject': 'bg-red-600 hover:bg-red-700 text-white',
'Close': 'bg-gray-600 hover:bg-gray-700 text-white',
'Re-Open': 'bg-blue-600 hover:bg-blue-700 text-white',
'Cancel': 'bg-red-600 hover:bg-red-700 text-white',
'Approve': 'bg-green-600 hover:bg-green-700 text-white',
};
return actionStyles[action] || 'bg-blue-600 hover:bg-blue-700 text-white';
};
/**
* Get action icon based on action name
*/
export const getActionIcon = (action: string): string => {
const actionIcons: Record<string, string> = {
'Apply': '📤',
'Send For Repair': '🔧',
'Send For Approval': '📋',
'Material Request': '📦',
'Accept': '✅',
'Reject': '❌',
'Close': '🔒',
'Re-Open': '🔓',
'Cancel': '🚫',
'Approve': '✅',
};
return actionIcons[action] || '▶️';
};
export default {
getWorkflowTransitions,
applyWorkflowAction,
getWorkflowInfo,
getCurrentUserRoles,
getCurrentUser,
getCurrentUserAndRoles,
setCurrentUser,
clearCurrentUser,
canUserEditDocument,
getWorkflowStateStyle,
getActionButtonStyle,
getActionIcon,
isSystemManager,
getAllTransitionsForState,
evaluateCondition,
};

124
asm_app/src/types/api.ts Normal file
View File

@ -0,0 +1,124 @@
// API Response Types
export interface ApiResponse<T = any> {
message?: T;
error?: string;
status_code?: number;
}
// User Types
export interface UserDetails {
user_id: string;
full_name: string;
email: string;
user_image?: string;
roles: string[];
permissions: Record<string, {
read: boolean;
write: boolean;
create: boolean;
delete: boolean;
}>;
last_login?: string;
enabled: boolean;
creation: string;
modified: string;
language: string;
}
// DocType Record Types
export interface DocTypeRecord {
name: string;
creation: string;
modified: string;
modified_by: string;
owner: string;
docstatus: number;
[key: string]: any; // Allow additional fields
}
export interface DocTypeRecordsResponse {
records: DocTypeRecord[];
total_count: number;
limit: number;
offset: number;
has_more: boolean;
doctype: string;
}
// Dashboard Types
export interface DashboardStats {
total_users: number;
total_customers: number;
total_items: number;
total_orders: number;
recent_activities: RecentActivity[];
}
export interface RecentActivity {
type: string;
name: string;
title: string;
creation: string;
}
// KYC Types
export interface KycRecord {
name: string;
kyc_status: string;
kyc_type: string;
creation: string;
modified: string;
}
export interface KycDetailsResponse {
records: KycRecord[];
summary: {
total: number;
pending: number;
approved: number;
};
}
// API Configuration Types
export interface ApiConfig {
BASE_URL: string;
ENDPOINTS: Record<string, string>;
DEFAULT_HEADERS: Record<string, string>;
TIMEOUT: number;
}
// Request Options
export interface RequestOptions {
method?: 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH';
headers?: Record<string, string>;
body?: any;
}
// Error Types
export interface ApiError {
message: string;
status?: number;
code?: string;
}
// Login Types
export interface LoginResponse {
message: {
full_name: string;
user_id: string;
sid: string;
};
}
export interface LoginCredentials {
email: string;
password: string;
}
// File Upload Types
export interface FileUploadOptions {
file: File;
doctype: string;
docname: string;
fieldname: string;
}

View File

@ -0,0 +1,220 @@
/**
* Frappe Expression Evaluator
*
* This utility evaluates Frappe's conditional expressions like:
* - "eval:doc.field_name == 'value'"
* - "eval:!doc.__islocal"
* - "eval:doc.is_existing_asset == 1"
* - "eval:doc.company && doc.company.startsWith('Mobile')"
* - "field_name" (simple field check - truthy)
* - "eval:(doc.is_existing_asset)"
*/
export interface FieldConfig {
fieldname: string;
label?: string;
fieldtype: string;
options?: string;
reqd?: number | boolean;
hidden?: number | boolean;
read_only?: number | boolean;
depends_on?: string;
mandatory_depends_on?: string;
read_only_depends_on?: string;
fetch_from?: string;
fetch_if_empty?: number | boolean;
default?: string;
description?: string;
in_list_view?: number | boolean;
in_standard_filter?: number | boolean;
permlevel?: number;
allow_on_submit?: number | boolean;
}
export interface EvaluatedFieldState {
isVisible: boolean;
isReadOnly: boolean;
isMandatory: boolean;
}
/**
* Safely evaluates a Frappe expression
* @param expression - The expression string (e.g., "eval:doc.field == 1")
* @param doc - The document object to evaluate against
* @returns boolean result of the expression
*/
export function evaluateFrappeExpression(expression: string | undefined, doc: Record<string, any>): boolean {
if (!expression) return false;
// Trim whitespace
expression = expression.trim();
// Empty string = false
if (!expression) return false;
// Simple field reference (not an eval expression)
if (!expression.startsWith('eval:')) {
// Check if it's a simple field name - return truthy value of that field
const fieldValue = doc[expression];
return !!fieldValue;
}
// Extract the actual expression after "eval:"
const evalExpression = expression.substring(5).trim();
try {
// Create a safe evaluation context
// We need to handle various Frappe expression patterns:
// - doc.field_name
// - doc.__islocal
// - doc.field_name == 'value'
// - !doc.field_name
// - doc.field && doc.field2
// - doc.field || doc.field2
// - doc.field.startsWith('value')
// Create the evaluation function with doc in scope
// Using Function constructor for dynamic evaluation (safer than eval)
const evalFunc = new Function('doc', `
try {
return Boolean(${evalExpression});
} catch (e) {
console.warn('Expression evaluation error:', e);
return false;
}
`);
return evalFunc(doc);
} catch (error) {
console.warn(`Failed to evaluate expression: ${expression}`, error);
return false;
}
}
/**
* Evaluates all field states (visible, readonly, mandatory) based on field config
* @param fieldConfig - The field configuration from Frappe
* @param doc - The current document data
* @returns EvaluatedFieldState
*/
export function evaluateFieldState(fieldConfig: FieldConfig, doc: Record<string, any>): EvaluatedFieldState {
// Base states from field config
let isVisible = !(fieldConfig.hidden === 1 || fieldConfig.hidden === true);
let isReadOnly = fieldConfig.read_only === 1 || fieldConfig.read_only === true;
let isMandatory = fieldConfig.reqd === 1 || fieldConfig.reqd === true;
// Evaluate depends_on (visibility)
if (fieldConfig.depends_on) {
isVisible = isVisible && evaluateFrappeExpression(fieldConfig.depends_on, doc);
}
// Evaluate mandatory_depends_on (conditional mandatory)
if (fieldConfig.mandatory_depends_on) {
const conditionalMandatory = evaluateFrappeExpression(fieldConfig.mandatory_depends_on, doc);
isMandatory = isMandatory || conditionalMandatory;
}
// Evaluate read_only_depends_on (conditional read-only)
if (fieldConfig.read_only_depends_on) {
const conditionalReadOnly = evaluateFrappeExpression(fieldConfig.read_only_depends_on, doc);
isReadOnly = isReadOnly || conditionalReadOnly;
}
return {
isVisible,
isReadOnly,
isMandatory
};
}
/**
* Parses fetch_from expression to get the linked doctype and field
* @param fetchFrom - e.g., "production_item.item_name" or "company.default_currency"
* @returns { linkField: string, targetField: string } or null
*/
export function parseFetchFrom(fetchFrom: string | undefined): { linkField: string; targetField: string } | null {
if (!fetchFrom) return null;
const parts = fetchFrom.split('.');
if (parts.length !== 2) return null;
return {
linkField: parts[0],
targetField: parts[1]
};
}
/**
* Gets the default value for a field, evaluating if it's an expression
* @param defaultValue - The default value string
* @param doc - The current document
* @returns The resolved default value
*/
export function getDefaultValue(defaultValue: string | undefined, doc: Record<string, any>): any {
if (!defaultValue) return undefined;
// Common Frappe defaults
if (defaultValue === 'Today' || defaultValue === 'today') {
return new Date().toISOString().split('T')[0];
}
if (defaultValue === 'Now' || defaultValue === 'now') {
return new Date().toISOString();
}
// Check if it's a number
if (!isNaN(Number(defaultValue))) {
return Number(defaultValue);
}
// Return as-is for strings
return defaultValue;
}
/**
* Maps Frappe fieldtype to HTML input type
*/
export function getInputType(fieldtype: string): string {
switch (fieldtype) {
case 'Data':
case 'Small Text':
case 'Text':
case 'Long Text':
return 'text';
case 'Int':
case 'Float':
case 'Currency':
case 'Percent':
return 'number';
case 'Date':
return 'date';
case 'Datetime':
return 'datetime-local';
case 'Time':
return 'time';
case 'Check':
return 'checkbox';
case 'Password':
return 'password';
case 'Link':
case 'Select':
return 'select';
case 'Attach':
case 'Attach Image':
return 'file';
case 'Read Only':
case 'HTML':
return 'readonly';
default:
return 'text';
}
}
/**
* Parses Select field options string into array
*/
export function parseSelectOptions(options: string | undefined): string[] {
if (!options) return [];
return options.split('\n').filter(opt => opt.trim() !== '');
}

View File

@ -0,0 +1,11 @@
export default {
darkMode: 'class',
content: [
"./index.html",
"./src/**/*.{js,ts,jsx,tsx}",
],
theme: {
extend: {},
},
plugins: [],
}

28
asm_app/tsconfig.app.json Normal file
View File

@ -0,0 +1,28 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"types": ["vite/client"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["src"]
}

7
asm_app/tsconfig.json Normal file
View File

@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

View File

@ -0,0 +1,26 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "ES2023",
"lib": ["ES2023"],
"module": "ESNext",
"types": ["node"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["vite.config.ts"]
}

24
asm_app/vite.config.ts Normal file
View File

@ -0,0 +1,24 @@
import path from 'path';
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react'
import proxyOptions from './proxyOptions';
// https://vitejs.dev/config/
export default defineConfig({
plugins: [react()],
server: {
port: 8080,
host: '0.0.0.0',
proxy: proxyOptions
},
resolve: {
alias: {
'@': path.resolve(__dirname, 'src')
}
},
build: {
outDir: '../asm_ui_app/public/asm_app',
emptyOutDir: true,
target: 'es2015',
},
});

2116
asm_app/yarn.lock Normal file

File diff suppressed because it is too large Load Diff

1
asm_ui_app/__init__.py Normal file
View File

@ -0,0 +1 @@
__version__ = "0.0.1"

View File

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