Initial commit of asm ui app
This commit is contained in:
commit
829a9227d8
7
.gitignore
vendored
Normal file
7
.gitignore
vendored
Normal file
@ -0,0 +1,7 @@
|
||||
.DS_Store
|
||||
*.pyc
|
||||
*.egg-info
|
||||
*.swp
|
||||
tags
|
||||
node_modules
|
||||
__pycache__
|
||||
24
asm_app/.gitignore
vendored
Normal file
24
asm_app/.gitignore
vendored
Normal 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
73
asm_app/README.md
Normal 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
23
asm_app/eslint.config.js
Normal 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
16
asm_app/index.html
Normal 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
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
49
asm_app/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
6
asm_app/postcss.config.js
Normal file
6
asm_app/postcss.config.js
Normal file
@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
13
asm_app/proxyOptions.ts
Normal file
13
asm_app/proxyOptions.ts
Normal 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}`;
|
||||
}
|
||||
}
|
||||
};
|
||||
BIN
asm_app/public/seera-logo.png
Normal file
BIN
asm_app/public/seera-logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 37 KiB |
BIN
asm_app/public/sidebar-background.jpg
Normal file
BIN
asm_app/public/sidebar-background.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 395 KiB |
1
asm_app/public/vite.svg
Normal file
1
asm_app/public/vite.svg
Normal 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 |
66
asm_app/scripts/inject-image-version.js
Normal file
66
asm_app/scripts/inject-image-version.js
Normal 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
42
asm_app/src/App.css
Normal 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
236
asm_app/src/App.tsx
Normal 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;
|
||||
181
asm_app/src/api/frappeClient.ts
Normal file
181
asm_app/src/api/frappeClient.ts
Normal 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;
|
||||
1
asm_app/src/assets/react.svg
Normal file
1
asm_app/src/assets/react.svg
Normal 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 |
147
asm_app/src/components/ApiTest.tsx
Normal file
147
asm_app/src/components/ApiTest.tsx
Normal 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;
|
||||
29
asm_app/src/components/ChartTile.tsx
Normal file
29
asm_app/src/components/ChartTile.tsx
Normal 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;
|
||||
|
||||
|
||||
376
asm_app/src/components/DynamicField.tsx
Normal file
376
asm_app/src/components/DynamicField.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
|
||||
67
asm_app/src/components/FlipCard.tsx
Normal file
67
asm_app/src/components/FlipCard.tsx
Normal 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;
|
||||
|
||||
71
asm_app/src/components/Header.tsx
Normal file
71
asm_app/src/components/Header.tsx
Normal 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;
|
||||
|
||||
|
||||
305
asm_app/src/components/LinkField.tsx
Normal file
305
asm_app/src/components/LinkField.tsx
Normal 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;
|
||||
413
asm_app/src/components/MaintenanceCalendar.tsx
Normal file
413
asm_app/src/components/MaintenanceCalendar.tsx
Normal 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;
|
||||
|
||||
|
||||
|
||||
234
asm_app/src/components/NotificationBell.tsx
Normal file
234
asm_app/src/components/NotificationBell.tsx
Normal 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;
|
||||
|
||||
|
||||
70
asm_app/src/components/ShortcutCard.tsx
Normal file
70
asm_app/src/components/ShortcutCard.tsx
Normal 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;
|
||||
|
||||
354
asm_app/src/components/Sidebar.tsx
Normal file
354
asm_app/src/components/Sidebar.tsx
Normal 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;
|
||||
|
||||
90
asm_app/src/components/SimpleChart.tsx
Normal file
90
asm_app/src/components/SimpleChart.tsx
Normal 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;
|
||||
}
|
||||
|
||||
|
||||
200
asm_app/src/components/WorkflowActions.tsx
Normal file
200
asm_app/src/components/WorkflowActions.tsx
Normal 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
102
asm_app/src/config/api.ts
Normal 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;
|
||||
69
asm_app/src/contexts/LanguageContext.tsx
Normal file
69
asm_app/src/contexts/LanguageContext.tsx
Normal 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;
|
||||
};
|
||||
|
||||
|
||||
48
asm_app/src/contexts/ThemeContext.tsx
Normal file
48
asm_app/src/contexts/ThemeContext.tsx
Normal 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
206
asm_app/src/hooks/useApi.ts
Normal 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
|
||||
};
|
||||
}
|
||||
379
asm_app/src/hooks/useAsset.ts
Normal file
379
asm_app/src/hooks/useAsset.ts
Normal 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 };
|
||||
}
|
||||
288
asm_app/src/hooks/useAssetMaintenance.ts
Normal file
288
asm_app/src/hooks/useAssetMaintenance.ts
Normal 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 };
|
||||
}
|
||||
|
||||
236
asm_app/src/hooks/useDocTypeFieldConfig.ts
Normal file
236
asm_app/src/hooks/useDocTypeFieldConfig.ts
Normal 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;
|
||||
|
||||
77
asm_app/src/hooks/useDocTypeMeta.ts
Normal file
77
asm_app/src/hooks/useDocTypeMeta.ts
Normal 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 };
|
||||
};
|
||||
|
||||
231
asm_app/src/hooks/useFrappeFieldBehavior.ts
Normal file
231
asm_app/src/hooks/useFrappeFieldBehavior.ts
Normal 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;
|
||||
|
||||
195
asm_app/src/hooks/useItem.ts
Normal file
195
asm_app/src/hooks/useItem.ts
Normal 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 };
|
||||
}
|
||||
|
||||
|
||||
|
||||
79
asm_app/src/hooks/useNotifications.ts
Normal file
79
asm_app/src/hooks/useNotifications.ts
Normal 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
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
482
asm_app/src/hooks/usePMSchedule.ts
Normal file
482
asm_app/src/hooks/usePMSchedule.ts
Normal 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
|
||||
};
|
||||
85
asm_app/src/hooks/usePMScheduleGenerator.ts
Normal file
85
asm_app/src/hooks/usePMScheduleGenerator.ts
Normal 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
174
asm_app/src/hooks/usePPM.ts
Normal 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 };
|
||||
}
|
||||
|
||||
188
asm_app/src/hooks/useUserPermissions.ts
Normal file
188
asm_app/src/hooks/useUserPermissions.ts
Normal 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;
|
||||
400
asm_app/src/hooks/useWorkOrder.ts
Normal file
400
asm_app/src/hooks/useWorkOrder.ts
Normal 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 };
|
||||
}
|
||||
205
asm_app/src/hooks/useWorkflow.ts
Normal file
205
asm_app/src/hooks/useWorkflow.ts
Normal 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
70
asm_app/src/i18n.ts
Normal 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
91
asm_app/src/index.css
Normal 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;
|
||||
}
|
||||
}
|
||||
176
asm_app/src/locales/ar/translation.json
Normal file
176
asm_app/src/locales/ar/translation.json
Normal 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": "صفوف"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
176
asm_app/src/locales/en/translation.json
Normal file
176
asm_app/src/locales/en/translation.json
Normal 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
17
asm_app/src/main.tsx
Normal 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>,
|
||||
)
|
||||
584
asm_app/src/pages/ActiveMap.tsx
Normal file
584
asm_app/src/pages/ActiveMap.tsx
Normal 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='© <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;
|
||||
|
||||
3625
asm_app/src/pages/AssetDetail.tsx
Normal file
3625
asm_app/src/pages/AssetDetail.tsx
Normal file
File diff suppressed because it is too large
Load Diff
1865
asm_app/src/pages/AssetList.tsx
Normal file
1865
asm_app/src/pages/AssetList.tsx
Normal file
File diff suppressed because it is too large
Load Diff
1026
asm_app/src/pages/AssetMaintenanceDetail.tsx
Normal file
1026
asm_app/src/pages/AssetMaintenanceDetail.tsx
Normal file
File diff suppressed because it is too large
Load Diff
501
asm_app/src/pages/AssetMaintenanceList.tsx
Normal file
501
asm_app/src/pages/AssetMaintenanceList.tsx
Normal 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;
|
||||
|
||||
0
asm_app/src/pages/AssetMaintenanceLog.tsx
Normal file
0
asm_app/src/pages/AssetMaintenanceLog.tsx
Normal file
34
asm_app/src/pages/ComingSoon.tsx
Normal file
34
asm_app/src/pages/ComingSoon.tsx
Normal 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;
|
||||
|
||||
409
asm_app/src/pages/Dashboard.tsx
Normal file
409
asm_app/src/pages/Dashboard.tsx
Normal 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;
|
||||
237
asm_app/src/pages/EventsList.tsx
Normal file
237
asm_app/src/pages/EventsList.tsx
Normal 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;
|
||||
680
asm_app/src/pages/ItemDetail.tsx
Normal file
680
asm_app/src/pages/ItemDetail.tsx
Normal 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;
|
||||
1357
asm_app/src/pages/ItemList.tsx
Normal file
1357
asm_app/src/pages/ItemList.tsx
Normal file
File diff suppressed because it is too large
Load Diff
49
asm_app/src/pages/KYCDetails.tsx
Normal file
49
asm_app/src/pages/KYCDetails.tsx
Normal 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
415
asm_app/src/pages/Login.tsx
Normal 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;
|
||||
251
asm_app/src/pages/MaintenanceCalendarPage.tsx
Normal file
251
asm_app/src/pages/MaintenanceCalendarPage.tsx
Normal 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;
|
||||
1298
asm_app/src/pages/ModernDashboard.tsx
Normal file
1298
asm_app/src/pages/ModernDashboard.tsx
Normal file
File diff suppressed because it is too large
Load Diff
0
asm_app/src/pages/PPM.tsx
Normal file
0
asm_app/src/pages/PPM.tsx
Normal file
406
asm_app/src/pages/PPMDetail.tsx
Normal file
406
asm_app/src/pages/PPMDetail.tsx
Normal 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;
|
||||
|
||||
443
asm_app/src/pages/PPMList.tsx
Normal file
443
asm_app/src/pages/PPMList.tsx
Normal 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;
|
||||
|
||||
812
asm_app/src/pages/PPMPlanner.tsx
Normal file
812
asm_app/src/pages/PPMPlanner.tsx
Normal 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;
|
||||
999
asm_app/src/pages/PPMPlannerDetail.tsx
Normal file
999
asm_app/src/pages/PPMPlannerDetail.tsx
Normal 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;
|
||||
|
||||
|
||||
452
asm_app/src/pages/PPMPlannerList.tsx
Normal file
452
asm_app/src/pages/PPMPlannerList.tsx
Normal 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;
|
||||
|
||||
|
||||
|
||||
421
asm_app/src/pages/PPMPlannerList.tsx-OLD
Normal file
421
asm_app/src/pages/PPMPlannerList.tsx-OLD
Normal 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;
|
||||
|
||||
|
||||
12
asm_app/src/pages/Test.tsx
Normal file
12
asm_app/src/pages/Test.tsx
Normal 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;
|
||||
190
asm_app/src/pages/UsersList.tsx
Normal file
190
asm_app/src/pages/UsersList.tsx
Normal 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;
|
||||
0
asm_app/src/pages/WorkOrder.tsx
Normal file
0
asm_app/src/pages/WorkOrder.tsx
Normal file
2063
asm_app/src/pages/WorkOrderDetail.tsx
Normal file
2063
asm_app/src/pages/WorkOrderDetail.tsx
Normal file
File diff suppressed because it is too large
Load Diff
1770
asm_app/src/pages/WorkOrderList.tsx
Normal file
1770
asm_app/src/pages/WorkOrderList.tsx
Normal file
File diff suppressed because it is too large
Load Diff
342
asm_app/src/pages/YearlyPPMPlannerPage.tsx
Normal file
342
asm_app/src/pages/YearlyPPMPlannerPage.tsx
Normal 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;
|
||||
568
asm_app/src/services/apiService.ts
Normal file
568
asm_app/src/services/apiService.ts
Normal 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 };
|
||||
439
asm_app/src/services/assetMaintenanceService.ts
Normal file
439
asm_app/src/services/assetMaintenanceService.ts
Normal 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;
|
||||
294
asm_app/src/services/assetService.ts
Normal file
294
asm_app/src/services/assetService.ts
Normal 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;
|
||||
|
||||
216
asm_app/src/services/itemService.ts
Normal file
216
asm_app/src/services/itemService.ts
Normal 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();
|
||||
|
||||
127
asm_app/src/services/notificationService.ts
Normal file
127
asm_app/src/services/notificationService.ts
Normal 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();
|
||||
|
||||
|
||||
462
asm_app/src/services/ppmPlannerService.ts
Normal file
462
asm_app/src/services/ppmPlannerService.ts
Normal 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();
|
||||
|
||||
|
||||
242
asm_app/src/services/ppmService.ts
Normal file
242
asm_app/src/services/ppmService.ts
Normal 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;
|
||||
|
||||
105
asm_app/src/services/translationService.ts
Normal file
105
asm_app/src/services/translationService.ts
Normal 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);
|
||||
}
|
||||
|
||||
335
asm_app/src/services/workOrderService.ts
Normal file
335
asm_app/src/services/workOrderService.ts
Normal 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;
|
||||
626
asm_app/src/services/workflowService.ts
Normal file
626
asm_app/src/services/workflowService.ts
Normal 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
124
asm_app/src/types/api.ts
Normal 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;
|
||||
}
|
||||
220
asm_app/src/utils/frappeExpressionEvaluator.ts
Normal file
220
asm_app/src/utils/frappeExpressionEvaluator.ts
Normal 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() !== '');
|
||||
}
|
||||
|
||||
11
asm_app/tailwind.config.js
Normal file
11
asm_app/tailwind.config.js
Normal 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
28
asm_app/tsconfig.app.json
Normal 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
7
asm_app/tsconfig.json
Normal file
@ -0,0 +1,7 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
26
asm_app/tsconfig.node.json
Normal file
26
asm_app/tsconfig.node.json
Normal 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
24
asm_app/vite.config.ts
Normal 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
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
1
asm_ui_app/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
__version__ = "0.0.1"
|
||||
0
asm_ui_app/asm_ui_app/__init__.py
Normal file
0
asm_ui_app/asm_ui_app/__init__.py
Normal file
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user