Initial commit of Asm UI app
This commit is contained in:
commit
2571aa6996
7
.gitignore
vendored
Normal file
7
.gitignore
vendored
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
.DS_Store
|
||||||
|
*.pyc
|
||||||
|
*.egg-info
|
||||||
|
*.swp
|
||||||
|
tags
|
||||||
|
node_modules
|
||||||
|
__pycache__
|
||||||
1360
ItemList.tsx
Normal file
1360
ItemList.tsx
Normal file
File diff suppressed because it is too large
Load Diff
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=1768316563" />
|
||||||
|
<link rel="apple-touch-icon" href="/seera-logo.png?v=1768316563" />
|
||||||
|
<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;
|
||||||
|
}
|
||||||
293
asm_app/src/App.tsx
Normal file
293
asm_app/src/App.tsx
Normal file
@ -0,0 +1,293 @@
|
|||||||
|
// 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';
|
||||||
|
import IssueList from './pages/IssueList';
|
||||||
|
import IssueDetail from './pages/IssueDetail';
|
||||||
|
import MaintenanceTeamList from './pages/MaintenanceTeamList';
|
||||||
|
import MaintenanceTeamDetail from './pages/MaintenanceTeamDetail';
|
||||||
|
import InspectionList from './pages/InspectionList';
|
||||||
|
import InspectionDetail from './pages/InspectionDetail';
|
||||||
|
import SupportPlanList from './pages/SupportPlanList';
|
||||||
|
import SupportPlanDetail from './pages/SupportPlanDetail';
|
||||||
|
import UserProfilePage from './pages/UserProfilePage';
|
||||||
|
|
||||||
|
// 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="/maintenance-teams" element={
|
||||||
|
<ProtectedRoute>
|
||||||
|
<LayoutWithSidebar><MaintenanceTeamList /></LayoutWithSidebar>
|
||||||
|
</ProtectedRoute>
|
||||||
|
} />
|
||||||
|
|
||||||
|
<Route path="/maintenance-teams/:teamName" element={
|
||||||
|
<ProtectedRoute>
|
||||||
|
<LayoutWithSidebar><MaintenanceTeamDetail /></LayoutWithSidebar>
|
||||||
|
</ProtectedRoute>
|
||||||
|
} />
|
||||||
|
|
||||||
|
<Route path="/inspections" element={
|
||||||
|
<ProtectedRoute>
|
||||||
|
<LayoutWithSidebar><InspectionList /></LayoutWithSidebar>
|
||||||
|
</ProtectedRoute>
|
||||||
|
} />
|
||||||
|
|
||||||
|
<Route path="/inspections/:inspectionName" element={
|
||||||
|
<ProtectedRoute>
|
||||||
|
<LayoutWithSidebar><InspectionDetail /></LayoutWithSidebar>
|
||||||
|
</ProtectedRoute>
|
||||||
|
} />
|
||||||
|
|
||||||
|
<Route path="/procurement" element={
|
||||||
|
<ProtectedRoute>
|
||||||
|
<LayoutWithSidebar><ComingSoon title="Procurement" /></LayoutWithSidebar>
|
||||||
|
</ProtectedRoute>
|
||||||
|
} />
|
||||||
|
|
||||||
|
<Route path="/sla" element={
|
||||||
|
<ProtectedRoute>
|
||||||
|
<LayoutWithSidebar><SupportPlanList/></LayoutWithSidebar>
|
||||||
|
</ProtectedRoute>
|
||||||
|
} />
|
||||||
|
|
||||||
|
<Route path="/sla/:slaName" element={
|
||||||
|
<ProtectedRoute>
|
||||||
|
<LayoutWithSidebar><SupportPlanDetail/></LayoutWithSidebar>
|
||||||
|
</ProtectedRoute>
|
||||||
|
} />
|
||||||
|
|
||||||
|
{/* <Route path="/support" element={
|
||||||
|
<ProtectedRoute>
|
||||||
|
<LayoutWithSidebar><ComingSoon title="Support" /></LayoutWithSidebar>
|
||||||
|
</ProtectedRoute>
|
||||||
|
} /> */}
|
||||||
|
<Route path="/support" element={
|
||||||
|
<ProtectedRoute>
|
||||||
|
<LayoutWithSidebar><IssueList /></LayoutWithSidebar>
|
||||||
|
</ProtectedRoute>
|
||||||
|
} />
|
||||||
|
|
||||||
|
<Route path="/support/:issueName" element={
|
||||||
|
<ProtectedRoute>
|
||||||
|
<LayoutWithSidebar><IssueDetail /></LayoutWithSidebar>
|
||||||
|
</ProtectedRoute>
|
||||||
|
} />
|
||||||
|
|
||||||
|
|
||||||
|
<Route path="/user-profile" element={
|
||||||
|
<ProtectedRoute>
|
||||||
|
<LayoutWithSidebar><UserProfilePage /></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 |
450
asm_app/src/components/ActivityLog.tsx
Normal file
450
asm_app/src/components/ActivityLog.tsx
Normal file
@ -0,0 +1,450 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import {
|
||||||
|
FaHistory,
|
||||||
|
FaSync,
|
||||||
|
FaChevronDown,
|
||||||
|
FaChevronUp,
|
||||||
|
FaUser,
|
||||||
|
FaClock,
|
||||||
|
FaCheckCircle,
|
||||||
|
FaSpinner,
|
||||||
|
} from 'react-icons/fa';
|
||||||
|
import { useAuditLogs } from '../hooks/useAuditLogs';
|
||||||
|
import type { AuditLogEntry, VersionChange } from '../hooks/useAuditLogs';
|
||||||
|
|
||||||
|
// ============== PROPS ==============
|
||||||
|
|
||||||
|
interface ActivityLogProps {
|
||||||
|
/** Frappe DocType name (e.g. 'Asset', 'Inspection', 'Work_Order') */
|
||||||
|
doctype: string;
|
||||||
|
/** Document name / ID */
|
||||||
|
docname: string | null;
|
||||||
|
/** Document creation date (for "Created" entry at bottom) */
|
||||||
|
creationDate?: string;
|
||||||
|
/** Document owner/creator email */
|
||||||
|
createdBy?: string;
|
||||||
|
/** Title shown in header */
|
||||||
|
title?: string;
|
||||||
|
/** Max entries to fetch */
|
||||||
|
limit?: number;
|
||||||
|
/** Number of entries visible before "Show All" */
|
||||||
|
initialVisible?: number;
|
||||||
|
/** Allow collapse/expand */
|
||||||
|
collapsible?: boolean;
|
||||||
|
/** Start collapsed */
|
||||||
|
startCollapsed?: boolean;
|
||||||
|
/** Compact mode for sidebar placement */
|
||||||
|
compact?: boolean;
|
||||||
|
/** Additional CSS class */
|
||||||
|
className?: string;
|
||||||
|
/** Callback after refresh */
|
||||||
|
onRefresh?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============== HELPER FUNCTIONS ==============
|
||||||
|
|
||||||
|
const formatFieldName = (fieldName: string): string => {
|
||||||
|
if (!fieldName) return '';
|
||||||
|
return fieldName
|
||||||
|
.replace(/^custom_/, '')
|
||||||
|
.replace(/_/g, ' ')
|
||||||
|
.replace(/\b\w/g, (char) => char.toUpperCase());
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatValue = (value: any): string => {
|
||||||
|
if (value === null || value === undefined) return '(empty)';
|
||||||
|
if (value === '') return '(empty)';
|
||||||
|
if (value === 0) return '0';
|
||||||
|
if (value === 1) return '1';
|
||||||
|
if (typeof value === 'boolean') return value ? 'Yes' : 'No';
|
||||||
|
if (typeof value === 'object') return JSON.stringify(value);
|
||||||
|
return String(value);
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatAuditDate = (dateStr: string): string => {
|
||||||
|
if (!dateStr) return '';
|
||||||
|
const date = new Date(dateStr);
|
||||||
|
const now = new Date();
|
||||||
|
const diffMs = now.getTime() - date.getTime();
|
||||||
|
const diffMins = Math.floor(diffMs / 60000);
|
||||||
|
const diffHours = Math.floor(diffMs / 3600000);
|
||||||
|
const diffDays = Math.floor(diffMs / 86400000);
|
||||||
|
|
||||||
|
if (diffMins < 1) return 'Just now';
|
||||||
|
if (diffMins < 60) return `${diffMins} min${diffMins > 1 ? 's' : ''} ago`;
|
||||||
|
if (diffHours < 24) return `${diffHours} hour${diffHours > 1 ? 's' : ''} ago`;
|
||||||
|
if (diffDays < 7) return `${diffDays} day${diffDays > 1 ? 's' : ''} ago`;
|
||||||
|
|
||||||
|
return date.toLocaleDateString('en-US', {
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
year: date.getFullYear() !== now.getFullYear() ? 'numeric' : undefined,
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatUsername = (email: string): string => {
|
||||||
|
if (!email) return 'Unknown';
|
||||||
|
const atIndex = email.indexOf('@');
|
||||||
|
if (atIndex === -1) return email;
|
||||||
|
return email.substring(0, atIndex);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getChangeColor = (fieldName: string): string => {
|
||||||
|
const lower = fieldName.toLowerCase();
|
||||||
|
if (lower.includes('status') || lower.includes('state') || lower.includes('workflow')) {
|
||||||
|
return 'text-purple-600 dark:text-purple-400';
|
||||||
|
}
|
||||||
|
if (lower.includes('date')) {
|
||||||
|
return 'text-blue-600 dark:text-blue-400';
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
lower.includes('technician') ||
|
||||||
|
lower.includes('supervisor') ||
|
||||||
|
lower.includes('assigned') ||
|
||||||
|
lower.includes('location') ||
|
||||||
|
lower.includes('department') ||
|
||||||
|
lower.includes('building') ||
|
||||||
|
lower.includes('room')
|
||||||
|
) {
|
||||||
|
return 'text-green-600 dark:text-green-400';
|
||||||
|
}
|
||||||
|
return 'text-gray-600 dark:text-gray-400';
|
||||||
|
};
|
||||||
|
|
||||||
|
// ============== SUB-COMPONENTS ==============
|
||||||
|
|
||||||
|
/** Single timeline entry */
|
||||||
|
const TimelineEntry: React.FC<{
|
||||||
|
log: AuditLogEntry;
|
||||||
|
isLatest: boolean;
|
||||||
|
compact: boolean;
|
||||||
|
}> = ({ log, isLatest, compact }) => {
|
||||||
|
const dotSize = compact ? 'w-2.5 h-2.5' : 'w-3 h-3';
|
||||||
|
const avatarSize = compact ? 'w-5 h-5' : 'w-6 h-6';
|
||||||
|
const iconSize = compact ? 8 : 10;
|
||||||
|
const textSize = compact ? 'text-[10px]' : 'text-xs';
|
||||||
|
const valueSize = compact ? 'text-[9px]' : 'text-[10px]';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`relative ${compact ? 'pl-6' : 'pl-8'}`}>
|
||||||
|
{/* Timeline dot */}
|
||||||
|
<div
|
||||||
|
className={`absolute ${compact ? 'left-1' : 'left-1.5'} top-1.5 ${dotSize} rounded-full border-2 border-white dark:border-gray-800 ${
|
||||||
|
isLatest ? 'bg-blue-500' : 'bg-gray-300 dark:bg-gray-600'
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Entry content */}
|
||||||
|
<div
|
||||||
|
className={`${compact ? 'p-2' : 'p-3'} rounded-lg ${
|
||||||
|
isLatest
|
||||||
|
? 'bg-blue-50 dark:bg-blue-900/20 border border-blue-100 dark:border-blue-800/50'
|
||||||
|
: 'bg-gray-50 dark:bg-gray-700/50'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between mb-1.5">
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<div
|
||||||
|
className={`${avatarSize} rounded-full bg-gray-200 dark:bg-gray-600 flex items-center justify-center`}
|
||||||
|
>
|
||||||
|
<FaUser className="text-gray-500 dark:text-gray-400" size={iconSize} />
|
||||||
|
</div>
|
||||||
|
<span className={`${textSize} font-medium text-gray-700 dark:text-gray-300`}>
|
||||||
|
{formatUsername(log.owner)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className={`flex items-center gap-1 ${textSize} text-gray-500 dark:text-gray-400`}>
|
||||||
|
<FaClock size={iconSize} />
|
||||||
|
<span title={new Date(log.creation).toLocaleString()}>
|
||||||
|
{formatAuditDate(log.creation)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Changes */}
|
||||||
|
<div className="space-y-1">
|
||||||
|
{log.changes.length > 0 ? (
|
||||||
|
log.changes.map((change, i) => (
|
||||||
|
<div key={i} className={textSize}>
|
||||||
|
<span className={`font-medium ${getChangeColor(change.field)}`}>
|
||||||
|
{formatFieldName(change.field)}
|
||||||
|
</span>
|
||||||
|
<span className="text-gray-500 dark:text-gray-400"> changed from </span>
|
||||||
|
<span
|
||||||
|
className={`px-1 py-0.5 bg-red-100 dark:bg-red-900/30 text-red-600 dark:text-red-400 rounded ${valueSize} font-mono`}
|
||||||
|
>
|
||||||
|
{formatValue(change.oldValue)}
|
||||||
|
</span>
|
||||||
|
<span className="text-gray-500 dark:text-gray-400"> → </span>
|
||||||
|
<span
|
||||||
|
className={`px-1 py-0.5 bg-green-100 dark:bg-green-900/30 text-green-600 dark:text-green-400 rounded ${valueSize} font-mono`}
|
||||||
|
>
|
||||||
|
{formatValue(change.newValue)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<p className={`${textSize} text-gray-500 dark:text-gray-400 italic`}>Document updated</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{log.added && log.added.length > 0 && (
|
||||||
|
<div className={`${textSize} text-green-600 dark:text-green-400`}>
|
||||||
|
<span className="font-medium">Added:</span> {log.added.length} item(s)
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{log.removed && log.removed.length > 0 && (
|
||||||
|
<div className={`${textSize} text-red-600 dark:text-red-400`}>
|
||||||
|
<span className="font-medium">Removed:</span> {log.removed.length} item(s)
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{log.rowChanged && log.rowChanged.length > 0 && (
|
||||||
|
<div className={`${textSize} text-orange-600 dark:text-orange-400`}>
|
||||||
|
<span className="font-medium">Modified:</span> {log.rowChanged.length} row(s)
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/** "Created this document" entry */
|
||||||
|
const CreatedEntry: React.FC<{
|
||||||
|
creationDate: string;
|
||||||
|
createdBy: string;
|
||||||
|
doctype: string;
|
||||||
|
compact: boolean;
|
||||||
|
}> = ({ creationDate, createdBy, doctype, compact }) => {
|
||||||
|
const dotSize = compact ? 'w-2.5 h-2.5' : 'w-3 h-3';
|
||||||
|
const avatarSize = compact ? 'w-5 h-5' : 'w-6 h-6';
|
||||||
|
const iconSize = compact ? 8 : 10;
|
||||||
|
const textSize = compact ? 'text-[10px]' : 'text-xs';
|
||||||
|
|
||||||
|
// Clean doctype for display (e.g. "Work_Order" → "Work Order")
|
||||||
|
const displayDoctype = doctype.replace(/_/g, ' ');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`relative ${compact ? 'pl-6' : 'pl-8'}`}>
|
||||||
|
<div
|
||||||
|
className={`absolute ${compact ? 'left-1' : 'left-1.5'} top-1.5 ${dotSize} rounded-full border-2 border-white dark:border-gray-800 bg-green-500`}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className={`${compact ? 'p-2' : 'p-3'} rounded-lg bg-green-50 dark:bg-green-900/20 border border-green-100 dark:border-green-800/50`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between mb-1">
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<div
|
||||||
|
className={`${avatarSize} rounded-full bg-green-200 dark:bg-green-800 flex items-center justify-center`}
|
||||||
|
>
|
||||||
|
<FaUser className="text-green-600 dark:text-green-400" size={iconSize} />
|
||||||
|
</div>
|
||||||
|
<span className={`${textSize} font-medium text-gray-700 dark:text-gray-300`}>
|
||||||
|
{formatUsername(createdBy)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className={`flex items-center gap-1 ${textSize} text-gray-500 dark:text-gray-400`}>
|
||||||
|
<FaClock size={iconSize} />
|
||||||
|
<span title={new Date(creationDate).toLocaleString()}>
|
||||||
|
{formatAuditDate(creationDate)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
className={`inline-flex items-center gap-1 px-1.5 py-0.5 bg-green-100 dark:bg-green-800/50 text-green-700 dark:text-green-300 rounded ${textSize} font-medium`}
|
||||||
|
>
|
||||||
|
<FaCheckCircle size={iconSize} />
|
||||||
|
Created this {displayDoctype}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ============== MAIN COMPONENT ==============
|
||||||
|
|
||||||
|
const ActivityLog: React.FC<ActivityLogProps> = ({
|
||||||
|
doctype,
|
||||||
|
docname,
|
||||||
|
creationDate,
|
||||||
|
createdBy,
|
||||||
|
title = 'Activity Log',
|
||||||
|
limit = 50,
|
||||||
|
initialVisible = 5,
|
||||||
|
collapsible = true,
|
||||||
|
startCollapsed = false,
|
||||||
|
compact = false,
|
||||||
|
className = '',
|
||||||
|
onRefresh,
|
||||||
|
}) => {
|
||||||
|
const [isExpanded, setIsExpanded] = useState(!startCollapsed);
|
||||||
|
const [showAll, setShowAll] = useState(false);
|
||||||
|
|
||||||
|
const { auditLogs, loading, refetch } = useAuditLogs({
|
||||||
|
doctype,
|
||||||
|
docname,
|
||||||
|
limit,
|
||||||
|
enabled: !!docname,
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleRefresh = () => {
|
||||||
|
refetch();
|
||||||
|
onRefresh?.();
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!docname) return null;
|
||||||
|
|
||||||
|
const headerIconSize = compact ? 14 : 16;
|
||||||
|
const headerTextClass = compact ? 'text-sm' : 'text-base';
|
||||||
|
const timelineLineLeft = compact ? 'left-2' : 'left-3';
|
||||||
|
const showMoreTextSize = compact ? 'text-[10px]' : 'text-xs';
|
||||||
|
const showMoreIconSize = compact ? 8 : 10;
|
||||||
|
|
||||||
|
const visibleLogs = showAll ? auditLogs : auditLogs.slice(0, initialVisible);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`bg-white dark:bg-gray-800 rounded-xl shadow-md border border-gray-200 dark:border-gray-700 overflow-hidden ${className}`}
|
||||||
|
>
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between p-3 border-b border-gray-200 dark:border-gray-700">
|
||||||
|
<div
|
||||||
|
className={`flex items-center gap-2 flex-1 ${collapsible ? 'cursor-pointer' : ''}`}
|
||||||
|
onClick={() => collapsible && setIsExpanded(!isExpanded)}
|
||||||
|
>
|
||||||
|
<FaHistory className="text-blue-500" size={headerIconSize} />
|
||||||
|
<h2 className={`${headerTextClass} font-semibold text-gray-800 dark:text-white`}>
|
||||||
|
{title}
|
||||||
|
</h2>
|
||||||
|
{auditLogs.length > 0 && (
|
||||||
|
<span className="px-1.5 py-0.5 bg-blue-100 dark:bg-blue-900/30 text-blue-600 dark:text-blue-400 rounded-full text-[10px] font-medium">
|
||||||
|
{auditLogs.length}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
handleRefresh();
|
||||||
|
}}
|
||||||
|
disabled={loading}
|
||||||
|
className="p-1 text-gray-400 hover:text-blue-500 hover:bg-blue-50 dark:hover:bg-blue-900/20 rounded transition-colors disabled:opacity-50"
|
||||||
|
title="Refresh activity log"
|
||||||
|
>
|
||||||
|
<FaSync className={loading ? 'animate-spin' : ''} size={compact ? 10 : 12} />
|
||||||
|
</button>
|
||||||
|
{collapsible && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setIsExpanded(!isExpanded)}
|
||||||
|
className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors p-1"
|
||||||
|
>
|
||||||
|
{isExpanded ? (
|
||||||
|
<FaChevronUp size={compact ? 12 : 14} />
|
||||||
|
) : (
|
||||||
|
<FaChevronDown size={compact ? 12 : 14} />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
{isExpanded && (
|
||||||
|
<div className="p-3">
|
||||||
|
{/* Loading */}
|
||||||
|
{loading && (
|
||||||
|
<div className="flex items-center justify-center py-6">
|
||||||
|
<FaSpinner className="animate-spin text-blue-500 mr-2" size={14} />
|
||||||
|
<span className="text-xs text-gray-500 dark:text-gray-400">Loading...</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Empty State */}
|
||||||
|
{!loading && auditLogs.length === 0 && (
|
||||||
|
<div className="relative">
|
||||||
|
<div className={`absolute ${timelineLineLeft} top-0 bottom-0 w-0.5 bg-gray-200 dark:bg-gray-700`} />
|
||||||
|
|
||||||
|
<div className={`relative ${compact ? 'pl-6' : 'pl-8'} mb-3`}>
|
||||||
|
<div
|
||||||
|
className={`absolute ${compact ? 'left-1' : 'left-1.5'} top-1 ${compact ? 'w-2.5 h-2.5' : 'w-3 h-3'} rounded-full border-2 border-white dark:border-gray-800 bg-gray-300 dark:bg-gray-600`}
|
||||||
|
/>
|
||||||
|
<div className={`${compact ? 'p-2' : 'p-3'} rounded-lg bg-gray-50 dark:bg-gray-700/50`}>
|
||||||
|
<p className={`${compact ? 'text-[10px]' : 'text-xs'} text-gray-500 dark:text-gray-400 italic`}>
|
||||||
|
No changes recorded yet
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{creationDate && createdBy && (
|
||||||
|
<CreatedEntry
|
||||||
|
creationDate={creationDate}
|
||||||
|
createdBy={createdBy}
|
||||||
|
doctype={doctype}
|
||||||
|
compact={compact}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Timeline */}
|
||||||
|
{!loading && auditLogs.length > 0 && (
|
||||||
|
<div className="relative">
|
||||||
|
<div className={`absolute ${timelineLineLeft} top-0 bottom-0 w-0.5 bg-gray-200 dark:bg-gray-700`} />
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
{visibleLogs.map((log, index) => (
|
||||||
|
<TimelineEntry
|
||||||
|
key={log.name}
|
||||||
|
log={log}
|
||||||
|
isLatest={index === 0}
|
||||||
|
compact={compact}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Show More/Less */}
|
||||||
|
{auditLogs.length > initialVisible && (
|
||||||
|
<div className="mt-3 text-center">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowAll(!showAll)}
|
||||||
|
className={`inline-flex items-center gap-1 px-2 py-1 ${showMoreTextSize} font-medium text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300 hover:bg-blue-50 dark:hover:bg-blue-900/20 rounded-md transition-colors`}
|
||||||
|
>
|
||||||
|
{showAll ? (
|
||||||
|
<>
|
||||||
|
<FaChevronUp size={showMoreIconSize} /> Show Less
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<FaChevronDown size={showMoreIconSize} /> Show All ({auditLogs.length})
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Created entry at bottom */}
|
||||||
|
{creationDate && createdBy && (
|
||||||
|
<div className="mt-3">
|
||||||
|
<CreatedEntry
|
||||||
|
creationDate={creationDate}
|
||||||
|
createdBy={createdBy}
|
||||||
|
doctype={doctype}
|
||||||
|
compact={compact}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ActivityLog;
|
||||||
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;
|
||||||
|
|
||||||
|
|
||||||
448
asm_app/src/components/CommentSection.tsx
Normal file
448
asm_app/src/components/CommentSection.tsx
Normal file
@ -0,0 +1,448 @@
|
|||||||
|
import React, { useState, useMemo } from 'react';
|
||||||
|
import {
|
||||||
|
FaComments,
|
||||||
|
FaUser,
|
||||||
|
FaTrash,
|
||||||
|
FaClock,
|
||||||
|
FaSpinner,
|
||||||
|
FaSync,
|
||||||
|
FaChevronDown,
|
||||||
|
FaChevronUp,
|
||||||
|
FaExclamationTriangle,
|
||||||
|
FaCheckCircle,
|
||||||
|
FaTimesCircle,
|
||||||
|
FaInfoCircle,
|
||||||
|
FaPaperclip,
|
||||||
|
FaThumbsUp,
|
||||||
|
FaEdit,
|
||||||
|
} from 'react-icons/fa';
|
||||||
|
import { toast } from 'react-toastify';
|
||||||
|
import { useComments } from '../hooks/useComments';
|
||||||
|
import MentionInput from './MentionInput';
|
||||||
|
import API_CONFIG from '../config/api';
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// CommentSection – drop-in comment / discussion panel
|
||||||
|
//
|
||||||
|
// Usage:
|
||||||
|
// <CommentSection
|
||||||
|
// referenceDoctype="Inspection"
|
||||||
|
// referenceName={inspectionName}
|
||||||
|
// />
|
||||||
|
//
|
||||||
|
// That's it! Place it at the bottom of any detail page.
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
interface CommentSectionProps {
|
||||||
|
/** ERPNext doctype (e.g. "Inspection", "Asset", "Work Order") */
|
||||||
|
referenceDoctype: string;
|
||||||
|
/** Document name / ID. Pass null for unsaved/new docs. */
|
||||||
|
referenceName: string | null;
|
||||||
|
/** Heading text (default "Comments & Discussion") */
|
||||||
|
title?: string;
|
||||||
|
/** Auto-poll interval in ms. 0 to disable. Default 30000 */
|
||||||
|
pollInterval?: number;
|
||||||
|
/** Max comments to show before "Show more". Default 5 */
|
||||||
|
initialLimit?: number;
|
||||||
|
/** Collapse-able section? Default true */
|
||||||
|
collapsible?: boolean;
|
||||||
|
/** Start collapsed? Default false */
|
||||||
|
startCollapsed?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Comment type icon and color ────────────────────────────
|
||||||
|
const commentTypeMeta: Record<
|
||||||
|
string,
|
||||||
|
{ icon: React.ReactNode; color: string; label: string }
|
||||||
|
> = {
|
||||||
|
Comment: {
|
||||||
|
icon: <FaComments size={10} />,
|
||||||
|
color: 'text-blue-600 dark:text-blue-400',
|
||||||
|
label: 'Comment',
|
||||||
|
},
|
||||||
|
Info: {
|
||||||
|
icon: <FaInfoCircle size={10} />,
|
||||||
|
color: 'text-gray-500 dark:text-gray-400',
|
||||||
|
label: 'Info',
|
||||||
|
},
|
||||||
|
Edit: {
|
||||||
|
icon: <FaEdit size={10} />,
|
||||||
|
color: 'text-orange-500 dark:text-orange-400',
|
||||||
|
label: 'Edit',
|
||||||
|
},
|
||||||
|
Attachment: {
|
||||||
|
icon: <FaPaperclip size={10} />,
|
||||||
|
color: 'text-purple-500 dark:text-purple-400',
|
||||||
|
label: 'Attachment',
|
||||||
|
},
|
||||||
|
Like: {
|
||||||
|
icon: <FaThumbsUp size={10} />,
|
||||||
|
color: 'text-pink-500 dark:text-pink-400',
|
||||||
|
label: 'Like',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Helpers ────────────────────────────────────────────────
|
||||||
|
const timeAgo = (dateStr: string): string => {
|
||||||
|
if (!dateStr) return '';
|
||||||
|
const date = new Date(dateStr);
|
||||||
|
const now = new Date();
|
||||||
|
const diffMs = now.getTime() - date.getTime();
|
||||||
|
const mins = Math.floor(diffMs / 60000);
|
||||||
|
const hrs = Math.floor(diffMs / 3600000);
|
||||||
|
const days = Math.floor(diffMs / 86400000);
|
||||||
|
|
||||||
|
if (mins < 1) return 'Just now';
|
||||||
|
if (mins < 60) return `${mins}m ago`;
|
||||||
|
if (hrs < 24) return `${hrs}h ago`;
|
||||||
|
if (days < 7) return `${days}d ago`;
|
||||||
|
return date.toLocaleDateString('en-US', {
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
year: date.getFullYear() !== now.getFullYear() ? 'numeric' : undefined,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const emailToName = (email: string): string => {
|
||||||
|
if (!email) return 'Unknown';
|
||||||
|
const at = email.indexOf('@');
|
||||||
|
if (at === -1) return email;
|
||||||
|
// Title-case the part before @
|
||||||
|
return email
|
||||||
|
.substring(0, at)
|
||||||
|
.replace(/[._-]/g, ' ')
|
||||||
|
.replace(/\b\w/g, (c) => c.toUpperCase());
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render comment HTML content safely.
|
||||||
|
* We trust content from our own backend (Frappe generates it).
|
||||||
|
*/
|
||||||
|
const CommentContent: React.FC<{ html: string }> = ({ html }) => {
|
||||||
|
const cleaned = html
|
||||||
|
.replace(/<div class="ql-editor[^"]*">/g, '')
|
||||||
|
.replace(/<\/div>$/g, '');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="comment-content text-sm text-gray-800 dark:text-gray-200 leading-relaxed
|
||||||
|
[&_a]:text-teal-600 [&_a]:dark:text-teal-400 [&_a]:underline [&_a]:font-medium
|
||||||
|
[&_.mention]:text-teal-700 [&_.mention]:dark:text-teal-300 [&_.mention]:font-semibold
|
||||||
|
[&_.mention]:bg-teal-50 [&_.mention]:dark:bg-teal-900/30 [&_.mention]:px-1 [&_.mention]:py-0.5
|
||||||
|
[&_.mention]:rounded [&_.mention]:pointer-events-none [&_.mention]:cursor-default
|
||||||
|
[&_.mention_a]:no-underline [&_.mention_a]:text-inherit
|
||||||
|
[&_p]:my-0"
|
||||||
|
dangerouslySetInnerHTML={{ __html: cleaned }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
// const CommentContent: React.FC<{ html: string }> = ({ html }) => {
|
||||||
|
// const cleaned = html
|
||||||
|
// .replace(/<div class="ql-editor[^"]*">/g, '')
|
||||||
|
// .replace(/<\/div>$/g, '');
|
||||||
|
|
||||||
|
// return (
|
||||||
|
// <div
|
||||||
|
// className="comment-content text-sm text-gray-800 dark:text-gray-200 leading-relaxed
|
||||||
|
// [&_a]:text-teal-600 [&_a]:dark:text-teal-400 [&_a]:underline [&_a]:font-medium
|
||||||
|
// [&_.mention]:text-teal-700 [&_.mention]:dark:text-teal-300 [&_.mention]:font-semibold
|
||||||
|
// [&_.mention]:bg-teal-50 [&_.mention]:dark:bg-teal-900/30 [&_.mention]:px-0.5 [&_.mention]:rounded
|
||||||
|
// [&_p]:my-0"
|
||||||
|
// dangerouslySetInnerHTML={{ __html: cleaned }}
|
||||||
|
// />
|
||||||
|
// );
|
||||||
|
// };
|
||||||
|
|
||||||
|
// ── Main Component ─────────────────────────────────────────
|
||||||
|
const CommentSection: React.FC<CommentSectionProps> = ({
|
||||||
|
referenceDoctype,
|
||||||
|
referenceName,
|
||||||
|
title = 'Comments & Discussion',
|
||||||
|
pollInterval = 30000,
|
||||||
|
initialLimit = 5,
|
||||||
|
collapsible = true,
|
||||||
|
startCollapsed = false,
|
||||||
|
}) => {
|
||||||
|
const {
|
||||||
|
comments,
|
||||||
|
loading,
|
||||||
|
posting,
|
||||||
|
error,
|
||||||
|
currentUser,
|
||||||
|
refetch,
|
||||||
|
postComment,
|
||||||
|
deleteComment,
|
||||||
|
mentionUsers,
|
||||||
|
mentionLoading,
|
||||||
|
searchMentionUsers,
|
||||||
|
} = useComments({
|
||||||
|
referenceDoctype,
|
||||||
|
referenceName,
|
||||||
|
pollInterval,
|
||||||
|
});
|
||||||
|
|
||||||
|
const [expanded, setExpanded] = useState(!startCollapsed);
|
||||||
|
const [showAll, setShowAll] = useState(false);
|
||||||
|
const [draftText, setDraftText] = useState('');
|
||||||
|
const [deletingId, setDeletingId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Only user-posted "Comment" type entries (for the count badge)
|
||||||
|
const userComments = useMemo(
|
||||||
|
() => comments.filter((c) => c.comment_type === 'Comment'),
|
||||||
|
[comments]
|
||||||
|
);
|
||||||
|
|
||||||
|
// All activity items sorted chronologically
|
||||||
|
const visibleComments = useMemo(() => {
|
||||||
|
if (showAll) return comments;
|
||||||
|
return comments.slice(-initialLimit); // latest N
|
||||||
|
}, [comments, showAll, initialLimit]);
|
||||||
|
|
||||||
|
// ── Handlers ────────────────────────────────────────────
|
||||||
|
const handlePost = async (html: string) => {
|
||||||
|
try {
|
||||||
|
await postComment(html);
|
||||||
|
setDraftText('');
|
||||||
|
toast.success('Comment posted!', {
|
||||||
|
position: 'top-right',
|
||||||
|
autoClose: 2000,
|
||||||
|
icon: <FaCheckCircle />,
|
||||||
|
});
|
||||||
|
} catch (err: any) {
|
||||||
|
toast.error(`Failed to post comment: ${err.message || 'Unknown error'}`, {
|
||||||
|
position: 'top-right',
|
||||||
|
autoClose: 5000,
|
||||||
|
icon: <FaTimesCircle />,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = async (commentName: string) => {
|
||||||
|
setDeletingId(commentName);
|
||||||
|
try {
|
||||||
|
await deleteComment(commentName);
|
||||||
|
toast.success('Comment deleted', {
|
||||||
|
position: 'top-right',
|
||||||
|
autoClose: 2000,
|
||||||
|
icon: <FaCheckCircle />,
|
||||||
|
});
|
||||||
|
} catch (err: any) {
|
||||||
|
toast.error(`Failed to delete: ${err.message || 'Unknown error'}`, {
|
||||||
|
position: 'top-right',
|
||||||
|
autoClose: 5000,
|
||||||
|
icon: <FaTimesCircle />,
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setDeletingId(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Guard: can't comment on unsaved docs ────────────────
|
||||||
|
if (!referenceName) {
|
||||||
|
return (
|
||||||
|
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-md p-6 border border-gray-200 dark:border-gray-700">
|
||||||
|
<div className="flex items-center gap-2 text-gray-500 dark:text-gray-400">
|
||||||
|
<FaComments className="text-gray-400" />
|
||||||
|
<span className="text-sm">Save the document first to enable comments.</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-md border border-gray-200 dark:border-gray-700 overflow-hidden">
|
||||||
|
{/* ── Section header ──────────────────────────────────── */}
|
||||||
|
<div
|
||||||
|
className={`flex items-center justify-between px-5 py-3 border-b border-gray-200 dark:border-gray-700 ${
|
||||||
|
collapsible ? 'cursor-pointer select-none' : ''
|
||||||
|
}`}
|
||||||
|
onClick={() => collapsible && setExpanded((v) => !v)}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<FaComments className="text-teal-500" size={16} />
|
||||||
|
<h2 className="text-base font-semibold text-gray-800 dark:text-white">
|
||||||
|
{title}
|
||||||
|
</h2>
|
||||||
|
{userComments.length > 0 && (
|
||||||
|
<span className="px-2 py-0.5 bg-teal-100 dark:bg-teal-900/30 text-teal-700 dark:text-teal-300 rounded-full text-xs font-medium">
|
||||||
|
{userComments.length}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{/* Refresh */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
refetch();
|
||||||
|
}}
|
||||||
|
disabled={loading}
|
||||||
|
className="p-1.5 text-gray-400 hover:text-teal-500 hover:bg-teal-50 dark:hover:bg-teal-900/20 rounded transition-colors disabled:opacity-50"
|
||||||
|
title="Refresh comments"
|
||||||
|
>
|
||||||
|
<FaSync className={loading ? 'animate-spin' : ''} size={11} />
|
||||||
|
</button>
|
||||||
|
{collapsible && (
|
||||||
|
<span className="text-gray-400 dark:text-gray-500">
|
||||||
|
{expanded ? <FaChevronUp size={12} /> : <FaChevronDown size={12} />}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Body ────────────────────────────────────────────── */}
|
||||||
|
{expanded && (
|
||||||
|
<div className="p-5 space-y-5">
|
||||||
|
{/* Error state */}
|
||||||
|
{error && (
|
||||||
|
<div className="flex items-center gap-2 text-red-600 dark:text-red-400 text-sm bg-red-50 dark:bg-red-900/20 rounded-lg p-3">
|
||||||
|
<FaExclamationTriangle size={12} />
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Loading */}
|
||||||
|
{loading && comments.length === 0 && (
|
||||||
|
<div className="flex items-center justify-center py-8">
|
||||||
|
<FaSpinner className="animate-spin text-teal-500 mr-2" size={16} />
|
||||||
|
<span className="text-sm text-gray-500 dark:text-gray-400">Loading comments…</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── Activity timeline ────────────────────────────── */}
|
||||||
|
{comments.length > 0 && (
|
||||||
|
<>
|
||||||
|
{/* Show older button */}
|
||||||
|
{!showAll && comments.length > initialLimit && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowAll(true)}
|
||||||
|
className="w-full text-center py-2 text-xs text-teal-600 dark:text-teal-400
|
||||||
|
hover:bg-teal-50 dark:hover:bg-teal-900/20 rounded-lg transition-colors font-medium"
|
||||||
|
>
|
||||||
|
Show {comments.length - initialLimit} older comment{comments.length - initialLimit !== 1 ? 's' : ''}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
{visibleComments.map((comment) => {
|
||||||
|
const meta = commentTypeMeta[comment.comment_type] || commentTypeMeta.Comment;
|
||||||
|
const isOwn = comment.comment_email === currentUser || comment.owner === currentUser;
|
||||||
|
const isDeleting = deletingId === comment.name;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={comment.name}
|
||||||
|
className={`group relative rounded-lg border transition-colors ${
|
||||||
|
comment.comment_type === 'Comment'
|
||||||
|
? 'bg-white dark:bg-gray-800 border-gray-200 dark:border-gray-700 hover:border-gray-300 dark:hover:border-gray-600'
|
||||||
|
: 'bg-gray-50 dark:bg-gray-800/50 border-gray-100 dark:border-gray-700/50'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="px-4 py-3">
|
||||||
|
{/* Header row */}
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{/* Avatar */}
|
||||||
|
<div className="w-7 h-7 rounded-full bg-teal-100 dark:bg-teal-900/40 flex items-center justify-center flex-shrink-0">
|
||||||
|
<FaUser className="text-teal-600 dark:text-teal-400" size={10} />
|
||||||
|
</div>
|
||||||
|
{/* Name & type */}
|
||||||
|
<div className="min-w-0">
|
||||||
|
<span className="text-sm font-medium text-gray-800 dark:text-gray-200">
|
||||||
|
{comment.comment_by || emailToName(comment.comment_email || comment.owner)}
|
||||||
|
</span>
|
||||||
|
{comment.comment_type !== 'Comment' && (
|
||||||
|
<span className={`ml-2 inline-flex items-center gap-1 text-[10px] font-medium ${meta.color}`}>
|
||||||
|
{meta.icon}
|
||||||
|
{meta.label}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{/* Time */}
|
||||||
|
<span
|
||||||
|
className="text-[11px] text-gray-400 dark:text-gray-500 flex items-center gap-1"
|
||||||
|
title={new Date(comment.creation).toLocaleString()}
|
||||||
|
>
|
||||||
|
<FaClock size={9} />
|
||||||
|
{timeAgo(comment.creation)}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{/* Delete (only own comments) */}
|
||||||
|
{isOwn && comment.comment_type === 'Comment' && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleDelete(comment.name)}
|
||||||
|
disabled={isDeleting}
|
||||||
|
className="opacity-0 group-hover:opacity-100 p-1 text-gray-400 hover:text-red-500
|
||||||
|
hover:bg-red-50 dark:hover:bg-red-900/20 rounded transition-all disabled:opacity-50"
|
||||||
|
title="Delete comment"
|
||||||
|
>
|
||||||
|
{isDeleting ? (
|
||||||
|
<FaSpinner className="animate-spin" size={10} />
|
||||||
|
) : (
|
||||||
|
<FaTrash size={10} />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<div className="ml-9">
|
||||||
|
<CommentContent html={comment.content} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Show less */}
|
||||||
|
{showAll && comments.length > initialLimit && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowAll(false)}
|
||||||
|
className="w-full text-center py-2 text-xs text-teal-600 dark:text-teal-400
|
||||||
|
hover:bg-teal-50 dark:hover:bg-teal-900/20 rounded-lg transition-colors font-medium"
|
||||||
|
>
|
||||||
|
Show fewer comments
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Empty state */}
|
||||||
|
{!loading && comments.length === 0 && (
|
||||||
|
<div className="text-center py-8">
|
||||||
|
<FaComments className="mx-auto text-gray-300 dark:text-gray-600 mb-2" size={28} />
|
||||||
|
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
No comments yet. Start the discussion!
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── New comment input ────────────────────────────── */}
|
||||||
|
<div className="pt-3 border-t border-gray-200 dark:border-gray-700">
|
||||||
|
<MentionInput
|
||||||
|
value={draftText}
|
||||||
|
onChange={setDraftText}
|
||||||
|
onSubmit={handlePost}
|
||||||
|
disabled={!referenceName}
|
||||||
|
posting={posting}
|
||||||
|
mentionUsers={mentionUsers}
|
||||||
|
mentionLoading={mentionLoading}
|
||||||
|
onMentionSearch={searchMentionUsers}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default CommentSection;
|
||||||
539
asm_app/src/components/DeleteRequestButton.tsx
Normal file
539
asm_app/src/components/DeleteRequestButton.tsx
Normal file
@ -0,0 +1,539 @@
|
|||||||
|
// components/DeleteRequestButton.tsx
|
||||||
|
import React, { useState, useRef, useEffect } from 'react';
|
||||||
|
import { FaTrash, FaCheckCircle, FaExclamationTriangle, FaSpinner, FaTimes, FaChevronDown, FaBan } from 'react-icons/fa';
|
||||||
|
import { useDeleteRequest, type DeleteStatus } from '../hooks/useDeleteRequest';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
|
||||||
|
interface DeleteRequestButtonProps {
|
||||||
|
doctype: string;
|
||||||
|
docname: string | null | undefined;
|
||||||
|
currentDeleteStatus: DeleteStatus;
|
||||||
|
userRoles: string[];
|
||||||
|
isSystemManager: boolean;
|
||||||
|
/** Called after any successful status change — use to refetch your document */
|
||||||
|
onStatusChange?: (newStatus: DeleteStatus) => void;
|
||||||
|
/** Extra classes on the button wrapper div */
|
||||||
|
className?: string;
|
||||||
|
/** If true, renders buttons inline (row). Default: column (stacked). */
|
||||||
|
inline?: boolean;
|
||||||
|
redirectOnDelete?: string;
|
||||||
|
triggerMode?: boolean; // ← ADD THIS
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Confirmation Modal ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
interface ConfirmModalProps {
|
||||||
|
title: string;
|
||||||
|
message: string;
|
||||||
|
confirmLabel: string;
|
||||||
|
confirmClass: string;
|
||||||
|
icon: React.ReactNode;
|
||||||
|
loading: boolean;
|
||||||
|
onConfirm: () => void;
|
||||||
|
onCancel: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ConfirmModal: React.FC<ConfirmModalProps> = ({
|
||||||
|
title,
|
||||||
|
message,
|
||||||
|
confirmLabel,
|
||||||
|
confirmClass,
|
||||||
|
icon,
|
||||||
|
loading,
|
||||||
|
onConfirm,
|
||||||
|
onCancel,
|
||||||
|
}) => (
|
||||||
|
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-[9999]">
|
||||||
|
<div className="bg-white dark:bg-gray-800 rounded-xl p-6 max-w-md w-full mx-4 shadow-2xl">
|
||||||
|
<div className="flex items-start gap-3 mb-4">
|
||||||
|
<span className="mt-0.5 flex-shrink-0 text-xl">{icon}</span>
|
||||||
|
<div>
|
||||||
|
<h3 className="text-base font-semibold text-gray-800 dark:text-white">{title}</h3>
|
||||||
|
<p className="text-sm text-gray-600 dark:text-gray-400 mt-1">{message}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-end gap-3">
|
||||||
|
<button
|
||||||
|
onClick={onCancel}
|
||||||
|
disabled={loading}
|
||||||
|
className="px-4 py-2 bg-gray-200 hover:bg-gray-300 dark:bg-gray-600 dark:hover:bg-gray-500 text-gray-700 dark:text-gray-200 rounded-lg text-sm font-medium disabled:opacity-50"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={onConfirm}
|
||||||
|
disabled={loading}
|
||||||
|
className={`px-4 py-2 text-white rounded-lg text-sm font-medium flex items-center gap-2 disabled:opacity-50 ${confirmClass}`}
|
||||||
|
>
|
||||||
|
{loading ? <FaSpinner className="animate-spin" size={13} /> : null}
|
||||||
|
{confirmLabel}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
// ─── Split Dropdown Button ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
interface SplitDropdownButtonProps {
|
||||||
|
primaryLabel: string;
|
||||||
|
primaryClass: string;
|
||||||
|
primaryIcon: React.ReactNode;
|
||||||
|
onPrimary: () => void;
|
||||||
|
secondaryLabel: string;
|
||||||
|
secondaryClass: string;
|
||||||
|
secondaryIcon: React.ReactNode;
|
||||||
|
onSecondary: () => void;
|
||||||
|
disabled?: boolean;
|
||||||
|
loading?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SplitDropdownButton: React.FC<SplitDropdownButtonProps> = ({
|
||||||
|
primaryLabel,
|
||||||
|
primaryClass,
|
||||||
|
primaryIcon,
|
||||||
|
onPrimary,
|
||||||
|
secondaryLabel,
|
||||||
|
secondaryClass,
|
||||||
|
secondaryIcon,
|
||||||
|
onSecondary,
|
||||||
|
disabled = false,
|
||||||
|
loading = false,
|
||||||
|
}) => {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
// Close on outside click
|
||||||
|
useEffect(() => {
|
||||||
|
const handler = (e: MouseEvent) => {
|
||||||
|
if (ref.current && !ref.current.contains(e.target as Node)) {
|
||||||
|
setOpen(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if (open) document.addEventListener('mousedown', handler);
|
||||||
|
return () => document.removeEventListener('mousedown', handler);
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={ref} className="relative inline-flex rounded-lg overflow-visible">
|
||||||
|
{/* Primary action button */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={disabled}
|
||||||
|
onClick={onPrimary}
|
||||||
|
className={`px-4 py-2 text-white text-sm font-medium flex items-center gap-2 disabled:opacity-50 transition-colors rounded-l-lg ${primaryClass}`}
|
||||||
|
>
|
||||||
|
{loading ? <FaSpinner className="animate-spin" size={13} /> : primaryIcon}
|
||||||
|
{primaryLabel}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Divider */}
|
||||||
|
<span className="w-px bg-white/30" />
|
||||||
|
|
||||||
|
{/* Chevron toggle */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={disabled}
|
||||||
|
onClick={() => setOpen((v) => !v)}
|
||||||
|
className={`px-2 py-2 text-white text-sm font-medium flex items-center disabled:opacity-50 transition-colors rounded-r-lg ${primaryClass}`}
|
||||||
|
aria-label="More options"
|
||||||
|
>
|
||||||
|
<FaChevronDown size={11} className={`transition-transform ${open ? 'rotate-180' : ''}`} />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Dropdown */}
|
||||||
|
{open && (
|
||||||
|
<div className="absolute top-full left-0 mt-1 z-50 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg shadow-lg min-w-[160px] overflow-hidden">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={disabled}
|
||||||
|
onClick={() => { setOpen(false); onSecondary(); }}
|
||||||
|
className={`w-full px-4 py-2.5 text-sm font-medium flex items-center gap-2 transition-colors ${secondaryClass}`}
|
||||||
|
>
|
||||||
|
{secondaryIcon}
|
||||||
|
{secondaryLabel}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─── Status Badge ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const StatusBadge: React.FC<{ status: DeleteStatus }> = ({ status }) => {
|
||||||
|
if (!status) return null;
|
||||||
|
|
||||||
|
const config: Record<string, { bg: string; text: string; label: string }> = {
|
||||||
|
'Delete Request With Supervisor': {
|
||||||
|
bg: 'bg-orange-100 dark:bg-orange-900/30',
|
||||||
|
text: 'text-orange-700 dark:text-orange-300',
|
||||||
|
label: '⏳ Delete Request Pending Supervisor',
|
||||||
|
},
|
||||||
|
'Delete Request With CM': {
|
||||||
|
bg: 'bg-yellow-100 dark:bg-yellow-900/30',
|
||||||
|
text: 'text-yellow-700 dark:text-yellow-300',
|
||||||
|
label: '⏳ Delete Request Pending CM',
|
||||||
|
},
|
||||||
|
Deleted: {
|
||||||
|
bg: 'bg-red-100 dark:bg-red-900/30',
|
||||||
|
text: 'text-red-700 dark:text-red-300',
|
||||||
|
label: '🗑 Marked for Deletion',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const c = config[status as string];
|
||||||
|
if (!c) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={`inline-flex items-center px-2.5 py-1 rounded-full text-xs font-medium ${c.bg} ${c.text}`}
|
||||||
|
>
|
||||||
|
{c.label}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─── Main Component ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
type PendingAction =
|
||||||
|
| 'raise'
|
||||||
|
| 'supervisor_approve'
|
||||||
|
| 'supervisor_reject'
|
||||||
|
| 'cm_approve'
|
||||||
|
| 'cm_reject'
|
||||||
|
| 'direct'
|
||||||
|
| null;
|
||||||
|
|
||||||
|
const DeleteRequestButton: React.FC<DeleteRequestButtonProps> = ({
|
||||||
|
doctype,
|
||||||
|
docname,
|
||||||
|
currentDeleteStatus,
|
||||||
|
userRoles,
|
||||||
|
isSystemManager,
|
||||||
|
onStatusChange,
|
||||||
|
className = '',
|
||||||
|
inline = false,
|
||||||
|
redirectOnDelete,
|
||||||
|
triggerMode = false,
|
||||||
|
}) => {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [pendingAction, setPendingAction] = useState<PendingAction>(null);
|
||||||
|
const [triggerOpen, setTriggerOpen] = useState(false); // ← ADD THIS
|
||||||
|
const triggerRef = useRef<HTMLDivElement>(null); // ← ADD THIS
|
||||||
|
|
||||||
|
const {
|
||||||
|
showRaiseRequest,
|
||||||
|
showApproveAsSupervisor,
|
||||||
|
showApproveAsCM,
|
||||||
|
showDirectDelete,
|
||||||
|
deleteStatus,
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
raiseRequest,
|
||||||
|
approveAsSupervisor,
|
||||||
|
approveAsCM,
|
||||||
|
directDelete,
|
||||||
|
rejectRequest, // ← new: resets delete_status to ""
|
||||||
|
} = useDeleteRequest({
|
||||||
|
doctype,
|
||||||
|
docname,
|
||||||
|
currentDeleteStatus,
|
||||||
|
userRoles,
|
||||||
|
isSystemManager,
|
||||||
|
onSuccess: (newStatus) => {
|
||||||
|
onStatusChange?.(newStatus);
|
||||||
|
if (newStatus === 'Deleted' && redirectOnDelete) {
|
||||||
|
navigate(redirectOnDelete);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Close trigger dropdown on outside click
|
||||||
|
useEffect(() => {
|
||||||
|
if (!triggerOpen) return;
|
||||||
|
const handler = (e: MouseEvent) => {
|
||||||
|
if (triggerRef.current && !triggerRef.current.contains(e.target as Node)) {
|
||||||
|
setTriggerOpen(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
document.addEventListener('mousedown', handler);
|
||||||
|
return () => document.removeEventListener('mousedown', handler);
|
||||||
|
}, [triggerOpen]);
|
||||||
|
|
||||||
|
const handleConfirm = async () => {
|
||||||
|
if (!pendingAction) return;
|
||||||
|
const actionMap: Record<NonNullable<PendingAction>, () => Promise<void>> = {
|
||||||
|
raise: raiseRequest,
|
||||||
|
supervisor_approve: approveAsSupervisor,
|
||||||
|
supervisor_reject: rejectRequest,
|
||||||
|
cm_approve: approveAsCM,
|
||||||
|
cm_reject: rejectRequest,
|
||||||
|
direct: directDelete,
|
||||||
|
};
|
||||||
|
await actionMap[pendingAction]();
|
||||||
|
setPendingAction(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Modal config per action ──────────────────────────────────────────────
|
||||||
|
const modalConfig: Record<
|
||||||
|
NonNullable<PendingAction>,
|
||||||
|
{ title: string; message: string; confirmLabel: string; confirmClass: string; icon: React.ReactNode }
|
||||||
|
> = {
|
||||||
|
raise: {
|
||||||
|
title: 'Request Deletion',
|
||||||
|
message: 'This will raise a deletion request to the Supervisor for review.',
|
||||||
|
confirmLabel: 'Raise Request',
|
||||||
|
confirmClass: 'bg-orange-600 hover:bg-orange-700',
|
||||||
|
icon: <FaExclamationTriangle className="text-orange-500" />,
|
||||||
|
},
|
||||||
|
supervisor_approve: {
|
||||||
|
title: 'Approve Deletion Request',
|
||||||
|
message: 'This will forward the deletion request to the Cluster Manager for final approval.',
|
||||||
|
confirmLabel: 'Approve & Forward',
|
||||||
|
confirmClass: 'bg-yellow-600 hover:bg-yellow-700',
|
||||||
|
icon: <FaCheckCircle className="text-yellow-500" />,
|
||||||
|
},
|
||||||
|
supervisor_reject: {
|
||||||
|
title: 'Reject Deletion Request',
|
||||||
|
message: 'This will reject the deletion request and clear the status. The record will remain active.',
|
||||||
|
confirmLabel: 'Reject Request',
|
||||||
|
confirmClass: 'bg-gray-600 hover:bg-gray-700',
|
||||||
|
icon: <FaBan className="text-gray-500" />,
|
||||||
|
},
|
||||||
|
cm_approve: {
|
||||||
|
title: 'Approve & Mark as Deleted',
|
||||||
|
message: 'This will mark the record as Deleted.',
|
||||||
|
confirmLabel: 'Approve & Delete',
|
||||||
|
confirmClass: 'bg-red-600 hover:bg-red-700',
|
||||||
|
icon: <FaTrash className="text-red-500" />,
|
||||||
|
},
|
||||||
|
cm_reject: {
|
||||||
|
title: 'Reject Deletion Request',
|
||||||
|
message: 'This will reject the deletion request and clear the status. The record will remain active.',
|
||||||
|
confirmLabel: 'Reject Request',
|
||||||
|
confirmClass: 'bg-gray-600 hover:bg-gray-700',
|
||||||
|
icon: <FaBan className="text-gray-500" />,
|
||||||
|
},
|
||||||
|
direct: {
|
||||||
|
title: 'Mark as Deleted',
|
||||||
|
message: 'This will immediately Delete The Record.',
|
||||||
|
confirmLabel: 'Delete',
|
||||||
|
confirmClass: 'bg-red-600 hover:bg-red-700',
|
||||||
|
icon: <FaTrash className="text-red-500" />,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const statusTooltip: Record<string, string> = {
|
||||||
|
'Delete Request With Supervisor': 'Delete Request Pending Supervisor',
|
||||||
|
'Delete Request With CM': 'Delete Request Pending Cluster Manager',
|
||||||
|
'Deleted': 'Marked for Deletion',
|
||||||
|
};
|
||||||
|
|
||||||
|
if (triggerMode) {
|
||||||
|
const hasRequest = !!deleteStatus;
|
||||||
|
const iconColor = hasRequest
|
||||||
|
? 'text-orange-500 dark:text-orange-400 hover:bg-orange-50 dark:hover:bg-orange-900/20'
|
||||||
|
: 'text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20';
|
||||||
|
|
||||||
|
const nothingToShow =
|
||||||
|
!showRaiseRequest && !showApproveAsSupervisor && !showApproveAsCM && !showDirectDelete && !deleteStatus;
|
||||||
|
|
||||||
|
if (nothingToShow) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{pendingAction && (
|
||||||
|
<ConfirmModal
|
||||||
|
{...modalConfig[pendingAction]}
|
||||||
|
loading={loading}
|
||||||
|
onConfirm={handleConfirm}
|
||||||
|
onCancel={() => setPendingAction(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<div className="relative" ref={triggerRef}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setTriggerOpen((v) => !v)}
|
||||||
|
title={deleteStatus ? statusTooltip[deleteStatus as string] : 'Request Deletion'}
|
||||||
|
className={`p-2 rounded transition-colors ${iconColor}`}
|
||||||
|
disabled={loading || !docname}
|
||||||
|
>
|
||||||
|
{loading ? <FaSpinner className="animate-spin" size={14} /> : <FaTrash size={14} />}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{triggerOpen && (
|
||||||
|
<div className="absolute right-0 mt-1 z-50 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg shadow-xl p-3 min-w-[230px]">
|
||||||
|
{deleteStatus && (
|
||||||
|
<div className="mb-3">
|
||||||
|
<StatusBadge status={deleteStatus} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{error && (
|
||||||
|
<p className="text-xs text-red-500 mb-2 flex items-center gap-1">
|
||||||
|
<FaExclamationTriangle size={10} /> {error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
{showRaiseRequest && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={loading || !docname}
|
||||||
|
onClick={() => { setTriggerOpen(false); setPendingAction('raise'); }}
|
||||||
|
className="w-full px-3 py-2 bg-orange-600 hover:bg-orange-700 text-white rounded-lg text-xs font-medium flex items-center gap-2 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<FaTrash size={11} /> Request Deletion
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{showApproveAsSupervisor && (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={loading || !docname}
|
||||||
|
onClick={() => { setTriggerOpen(false); setPendingAction('supervisor_approve'); }}
|
||||||
|
className="w-full px-3 py-2 bg-yellow-600 hover:bg-yellow-700 text-white rounded-lg text-xs font-medium flex items-center gap-2 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<FaCheckCircle size={11} /> Approve & Forward to CM
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={loading || !docname}
|
||||||
|
onClick={() => { setTriggerOpen(false); setPendingAction('supervisor_reject'); }}
|
||||||
|
className="w-full px-3 py-2 bg-gray-200 hover:bg-gray-300 dark:bg-gray-600 dark:hover:bg-gray-500 text-gray-700 dark:text-gray-200 rounded-lg text-xs font-medium flex items-center gap-2 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<FaBan size={11} /> Reject Request
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{showApproveAsCM && (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={loading || !docname}
|
||||||
|
onClick={() => { setTriggerOpen(false); setPendingAction('cm_approve'); }}
|
||||||
|
className="w-full px-3 py-2 bg-red-600 hover:bg-red-700 text-white rounded-lg text-xs font-medium flex items-center gap-2 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<FaCheckCircle size={11} /> Approve & Delete
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={loading || !docname}
|
||||||
|
onClick={() => { setTriggerOpen(false); setPendingAction('cm_reject'); }}
|
||||||
|
className="w-full px-3 py-2 bg-gray-200 hover:bg-gray-300 dark:bg-gray-600 dark:hover:bg-gray-500 text-gray-700 dark:text-gray-200 rounded-lg text-xs font-medium flex items-center gap-2 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<FaBan size={11} /> Reject Request
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{showDirectDelete && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={loading || !docname}
|
||||||
|
onClick={() => { setTriggerOpen(false); setPendingAction('direct'); }}
|
||||||
|
className="w-full px-3 py-2 bg-red-600 hover:bg-red-700 text-white rounded-lg text-xs font-medium flex items-center gap-2 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<FaTrash size={11} /> Delete
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const layoutClass = inline
|
||||||
|
? 'flex flex-row flex-wrap items-center gap-2'
|
||||||
|
: 'flex flex-col gap-2';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* Confirmation Modal */}
|
||||||
|
{pendingAction && (
|
||||||
|
<ConfirmModal
|
||||||
|
{...modalConfig[pendingAction]}
|
||||||
|
loading={loading}
|
||||||
|
onConfirm={handleConfirm}
|
||||||
|
onCancel={() => setPendingAction(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className={`${layoutClass} ${className}`}>
|
||||||
|
{/* Status Badge — always shown when a status exists */}
|
||||||
|
{deleteStatus && <StatusBadge status={deleteStatus} />}
|
||||||
|
|
||||||
|
{/* Error */}
|
||||||
|
{error && (
|
||||||
|
<p className="text-xs text-red-500 dark:text-red-400 flex items-center gap-1">
|
||||||
|
<FaExclamationTriangle size={11} /> {error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── Raise Request Button (no reject needed here) ─────────────────── */}
|
||||||
|
{showRaiseRequest && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={loading || !docname}
|
||||||
|
onClick={() => setPendingAction('raise')}
|
||||||
|
className="px-4 py-2 bg-orange-600 hover:bg-orange-700 text-white rounded-lg text-sm font-medium flex items-center gap-2 disabled:opacity-50 transition-colors"
|
||||||
|
>
|
||||||
|
{loading ? <FaSpinner className="animate-spin" size={13} /> : <FaTrash size={13} />}
|
||||||
|
Request Deletion
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── Supervisor: Approve (split) + Reject ────────────────────────── */}
|
||||||
|
{showApproveAsSupervisor && (
|
||||||
|
<SplitDropdownButton
|
||||||
|
disabled={loading || !docname}
|
||||||
|
loading={loading}
|
||||||
|
// Primary — Approve & Forward
|
||||||
|
primaryLabel="Approve Request"
|
||||||
|
primaryClass="bg-yellow-600 hover:bg-yellow-700"
|
||||||
|
primaryIcon={<FaCheckCircle size={13} />}
|
||||||
|
onPrimary={() => setPendingAction('supervisor_approve')}
|
||||||
|
// Secondary — Reject
|
||||||
|
secondaryLabel="Reject Request"
|
||||||
|
secondaryClass="text-gray-700 dark:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-700"
|
||||||
|
secondaryIcon={<FaBan size={13} className="text-gray-500" />}
|
||||||
|
onSecondary={() => setPendingAction('supervisor_reject')}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── CM: Approve & Delete (split) + Reject ───────────────────────── */}
|
||||||
|
{showApproveAsCM && (
|
||||||
|
<SplitDropdownButton
|
||||||
|
disabled={loading || !docname}
|
||||||
|
loading={loading}
|
||||||
|
// Primary — Approve & Delete
|
||||||
|
primaryLabel="Approve & Delete"
|
||||||
|
primaryClass="bg-red-600 hover:bg-red-700"
|
||||||
|
primaryIcon={<FaCheckCircle size={13} />}
|
||||||
|
onPrimary={() => setPendingAction('cm_approve')}
|
||||||
|
// Secondary — Reject
|
||||||
|
secondaryLabel="Reject Request"
|
||||||
|
secondaryClass="text-gray-700 dark:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-700"
|
||||||
|
secondaryIcon={<FaBan size={13} className="text-gray-500" />}
|
||||||
|
onSecondary={() => setPendingAction('cm_reject')}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── CM / SysManager: Direct Delete (no reject needed) ───────────── */}
|
||||||
|
{showDirectDelete && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={loading || !docname}
|
||||||
|
onClick={() => setPendingAction('direct')}
|
||||||
|
className="px-4 py-2 bg-red-600 hover:bg-red-700 text-white rounded-lg text-sm font-medium flex items-center gap-2 disabled:opacity-50 transition-colors"
|
||||||
|
>
|
||||||
|
{loading ? <FaSpinner className="animate-spin" size={13} /> : <FaTrash size={13} />}
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default DeleteRequestButton;
|
||||||
519
asm_app/src/components/DynamicExportModal.tsx
Normal file
519
asm_app/src/components/DynamicExportModal.tsx
Normal file
@ -0,0 +1,519 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import * as XLSX from 'xlsx';
|
||||||
|
import {
|
||||||
|
FaFileExport, FaTimes, FaFileCsv, FaFileExcel, FaDownload,
|
||||||
|
FaSearch, FaCheckSquare, FaSquare, FaSpinner,
|
||||||
|
} from 'react-icons/fa';
|
||||||
|
import { useDoctypeFields, type DoctypeField } from '../hooks/useDoctypeFields';
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────
|
||||||
|
// Types
|
||||||
|
// ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
export type ExportFormat = 'csv' | 'excel';
|
||||||
|
export type ExportScope = 'selected' | 'all_on_page' | 'all_with_filters';
|
||||||
|
|
||||||
|
export interface DynamicExportModalProps {
|
||||||
|
/** Whether the modal is open */
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
|
||||||
|
/** Frappe DocType name, e.g. "Work_Order", "Asset" */
|
||||||
|
doctype: string;
|
||||||
|
|
||||||
|
/** Counts for the three scope options */
|
||||||
|
selectedCount: number;
|
||||||
|
pageCount: number;
|
||||||
|
totalCount: number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Called when the user clicks Export.
|
||||||
|
* `rows` is the flat array of objects to export (already resolved by parent).
|
||||||
|
* `columns` is the list of chosen column keys.
|
||||||
|
* `format` is 'csv' | 'excel'.
|
||||||
|
*
|
||||||
|
* Alternatively you can pass `onFetchAll` and let the modal handle fetching.
|
||||||
|
*/
|
||||||
|
onExport?: (scope: ExportScope, format: ExportFormat, columns: string[]) => Promise<void> | void;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* If provided, the modal will call this to fetch ALL records when
|
||||||
|
* scope === 'all_with_filters'. The parent just needs to provide page data.
|
||||||
|
*/
|
||||||
|
onFetchAll?: () => Promise<any[]>;
|
||||||
|
|
||||||
|
/** Current page data (used for 'all_on_page' and 'selected') */
|
||||||
|
pageData: any[];
|
||||||
|
|
||||||
|
/** Set of selected row names/ids */
|
||||||
|
selectedRows?: Set<string>;
|
||||||
|
rowKey?: string; // default: 'name'
|
||||||
|
|
||||||
|
/** Optional: extra columns to inject (e.g. computed / virtual fields) */
|
||||||
|
extraColumns?: DoctypeField[];
|
||||||
|
|
||||||
|
/** Optional: columns to hide even if they exist in DocType */
|
||||||
|
hiddenColumns?: string[];
|
||||||
|
|
||||||
|
/** Optional: override default-checked columns (fieldnames) */
|
||||||
|
defaultColumns?: string[];
|
||||||
|
|
||||||
|
/** File name prefix, e.g. "work_orders" → "work_orders_2025-01-01.csv" */
|
||||||
|
fileNamePrefix?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────
|
||||||
|
// Helpers
|
||||||
|
// ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
function formatValue(value: any): string {
|
||||||
|
if (value === null || value === undefined) return '';
|
||||||
|
if (typeof value === 'boolean') return value ? 'Yes' : 'No';
|
||||||
|
return String(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function downloadCSV(rows: any[], columns: DoctypeField[], fileName: string) {
|
||||||
|
const headers = columns.map(c => c.label);
|
||||||
|
const body = rows.map(row =>
|
||||||
|
columns.map(c => {
|
||||||
|
const val = formatValue(row[c.key]);
|
||||||
|
// Escape CSV
|
||||||
|
if (val.includes(',') || val.includes('"') || val.includes('\n')) {
|
||||||
|
return `"${val.replace(/"/g, '""')}"`;
|
||||||
|
}
|
||||||
|
return val;
|
||||||
|
}).join(',')
|
||||||
|
);
|
||||||
|
const csv = [headers.join(','), ...body].join('\n');
|
||||||
|
const blob = new Blob(['\ufeff' + csv], { type: 'text/csv;charset=utf-8;' });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = fileName;
|
||||||
|
a.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
function downloadExcel(rows: any[], columns: DoctypeField[], fileName: string) {
|
||||||
|
const wsData = [
|
||||||
|
columns.map(c => c.label),
|
||||||
|
...rows.map(row => columns.map(c => formatValue(row[c.key]))),
|
||||||
|
];
|
||||||
|
const ws = XLSX.utils.aoa_to_sheet(wsData);
|
||||||
|
const wb = XLSX.utils.book_new();
|
||||||
|
XLSX.utils.book_append_sheet(wb, ws, 'Export');
|
||||||
|
XLSX.writeFile(wb, fileName);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────
|
||||||
|
// Component
|
||||||
|
// ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
const DynamicExportModal: React.FC<DynamicExportModalProps> = ({
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
doctype,
|
||||||
|
selectedCount,
|
||||||
|
pageCount,
|
||||||
|
totalCount,
|
||||||
|
onExport,
|
||||||
|
onFetchAll,
|
||||||
|
pageData,
|
||||||
|
selectedRows,
|
||||||
|
rowKey = 'name',
|
||||||
|
extraColumns = [],
|
||||||
|
hiddenColumns = [],
|
||||||
|
defaultColumns,
|
||||||
|
fileNamePrefix,
|
||||||
|
}) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { fields, loading: fieldsLoading } = useDoctypeFields(doctype);
|
||||||
|
|
||||||
|
// ── Derived column list ──────────────────────────────────────
|
||||||
|
const allColumns: DoctypeField[] = React.useMemo(() => {
|
||||||
|
const hidden = new Set(hiddenColumns);
|
||||||
|
|
||||||
|
// Merge fetched fields + extra columns, remove hidden
|
||||||
|
const base = [
|
||||||
|
...fields.filter(f => !hidden.has(f.key)),
|
||||||
|
...extraColumns.filter(f => !hidden.has(f.key)),
|
||||||
|
];
|
||||||
|
|
||||||
|
// Apply defaultColumns override if provided
|
||||||
|
if (defaultColumns) {
|
||||||
|
const defaultSet = new Set(defaultColumns);
|
||||||
|
return base.map(f => ({ ...f, default: defaultSet.has(f.key) }));
|
||||||
|
}
|
||||||
|
|
||||||
|
return base;
|
||||||
|
}, [fields, extraColumns, hiddenColumns, defaultColumns]);
|
||||||
|
|
||||||
|
// ── Local state ───────────────────────────────────────────────
|
||||||
|
const [scope, setScope] = useState<ExportScope>(selectedCount > 0 ? 'selected' : 'all_with_filters');
|
||||||
|
const [format, setFormat] = useState<ExportFormat>('csv');
|
||||||
|
const [checkedKeys, setCheckedKeys] = useState<Set<string>>(new Set());
|
||||||
|
const [search, setSearch] = useState('');
|
||||||
|
const [isExporting, setIsExporting] = useState(false);
|
||||||
|
|
||||||
|
// Track whether we have seeded checkedKeys for this modal open session
|
||||||
|
const initializedRef = React.useRef(false);
|
||||||
|
|
||||||
|
// Sync scope when selectedCount changes
|
||||||
|
useEffect(() => {
|
||||||
|
setScope(selectedCount > 0 ? 'selected' : 'all_with_filters');
|
||||||
|
}, [selectedCount]);
|
||||||
|
|
||||||
|
// Seed default checked columns ONCE when allColumns first populates.
|
||||||
|
// Using a ref guard prevents re-seeding when allColumns recomputes due to
|
||||||
|
// inline prop arrays (defaultColumns={[...]} creates a new reference every
|
||||||
|
// parent render), which would silently wipe out the user's All/None/Default selection.
|
||||||
|
useEffect(() => {
|
||||||
|
if (allColumns.length === 0) return;
|
||||||
|
if (initializedRef.current) return;
|
||||||
|
initializedRef.current = true;
|
||||||
|
setCheckedKeys(new Set(allColumns.filter(c => c.default).map(c => c.key)));
|
||||||
|
}, [allColumns]);
|
||||||
|
|
||||||
|
// Reset the seed flag when modal closes so next open re-initializes cleanly
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isOpen) {
|
||||||
|
initializedRef.current = false;
|
||||||
|
setCheckedKeys(new Set());
|
||||||
|
}
|
||||||
|
}, [isOpen]);
|
||||||
|
|
||||||
|
if (!isOpen) return null;
|
||||||
|
|
||||||
|
// ── Column helpers ────────────────────────────────────────────
|
||||||
|
const filteredColumns = search.trim()
|
||||||
|
? allColumns.filter(c =>
|
||||||
|
c.label.toLowerCase().includes(search.toLowerCase()) ||
|
||||||
|
c.key.toLowerCase().includes(search.toLowerCase())
|
||||||
|
)
|
||||||
|
: allColumns;
|
||||||
|
|
||||||
|
const toggleColumn = (key: string) => {
|
||||||
|
setCheckedKeys(prev => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
next.has(key) ? next.delete(key) : next.add(key);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const selectAll = () => setCheckedKeys(new Set(allColumns.map(c => c.key)));
|
||||||
|
const selectDefault = () => setCheckedKeys(new Set(allColumns.filter(c => c.default).map(c => c.key)));
|
||||||
|
const selectNone = () => setCheckedKeys(new Set());
|
||||||
|
|
||||||
|
// ── Export handler ────────────────────────────────────────────
|
||||||
|
const handleExport = async () => {
|
||||||
|
if (checkedKeys.size === 0) return;
|
||||||
|
setIsExporting(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// If parent handles everything
|
||||||
|
if (onExport) {
|
||||||
|
await onExport(scope, format, [...checkedKeys]);
|
||||||
|
onClose();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Otherwise handle internally
|
||||||
|
let rows: any[] = [];
|
||||||
|
if (scope === 'selected') {
|
||||||
|
const sel = selectedRows ?? new Set<string>();
|
||||||
|
rows = pageData.filter(r => sel.has(r[rowKey]));
|
||||||
|
} else if (scope === 'all_on_page') {
|
||||||
|
rows = pageData;
|
||||||
|
} else {
|
||||||
|
if (!onFetchAll) {
|
||||||
|
alert('onFetchAll not provided for all_with_filters scope');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
rows = await onFetchAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (rows.length === 0) {
|
||||||
|
alert('No data to export.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const chosenCols = allColumns.filter(c => checkedKeys.has(c.key));
|
||||||
|
const prefix = fileNamePrefix ?? doctype.toLowerCase().replace(/\s+/g, '_');
|
||||||
|
const datePart = new Date().toISOString().split('T')[0];
|
||||||
|
const fileName = `${prefix}_export_${datePart}.${format === 'csv' ? 'csv' : 'xlsx'}`;
|
||||||
|
|
||||||
|
if (format === 'csv') {
|
||||||
|
downloadCSV(rows, chosenCols, fileName);
|
||||||
|
} else {
|
||||||
|
downloadExcel(rows, chosenCols, fileName);
|
||||||
|
}
|
||||||
|
|
||||||
|
onClose();
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Export failed:', err);
|
||||||
|
alert(`Export failed: ${err instanceof Error ? err.message : 'Unknown error'}`);
|
||||||
|
} finally {
|
||||||
|
setIsExporting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Render ────────────────────────────────────────────────────
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-[70] p-4">
|
||||||
|
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-xl w-full max-w-2xl max-h-[92vh] flex flex-col animate-scale-in">
|
||||||
|
|
||||||
|
{/* ── Header ── */}
|
||||||
|
<div className="bg-gradient-to-r from-green-500 to-green-600 px-6 py-4 rounded-t-lg flex-shrink-0">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<FaFileExport className="text-white text-xl" />
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-semibold text-white">Export {doctype.replace(/_/g, ' ')}</h3>
|
||||||
|
<p className="text-green-100 text-xs mt-0.5">
|
||||||
|
{allColumns.length} fields available · {checkedKeys.size} selected
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button onClick={onClose} className="text-white/80 hover:text-white transition-colors" disabled={isExporting}>
|
||||||
|
<FaTimes size={20} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Body (scrollable) ── */}
|
||||||
|
<div className="flex-1 overflow-y-auto p-6 space-y-6">
|
||||||
|
|
||||||
|
{/* Scope */}
|
||||||
|
<div>
|
||||||
|
<h4 className="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-3">What to export</h4>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{/* Selected rows */}
|
||||||
|
<ScopeOption
|
||||||
|
value="selected"
|
||||||
|
current={scope}
|
||||||
|
onChange={setScope}
|
||||||
|
disabled={selectedCount === 0}
|
||||||
|
badge={selectedCount}
|
||||||
|
badgeColor="green"
|
||||||
|
label="Selected rows"
|
||||||
|
sub={`${selectedCount} row${selectedCount !== 1 ? 's' : ''} selected`}
|
||||||
|
/>
|
||||||
|
{/* Current page */}
|
||||||
|
<ScopeOption
|
||||||
|
value="all_on_page"
|
||||||
|
current={scope}
|
||||||
|
onChange={setScope}
|
||||||
|
badge={pageCount}
|
||||||
|
badgeColor="blue"
|
||||||
|
label="Current page"
|
||||||
|
sub={`${pageCount} rows on this page`}
|
||||||
|
/>
|
||||||
|
{/* All with filters */}
|
||||||
|
<ScopeOption
|
||||||
|
value="all_with_filters"
|
||||||
|
current={scope}
|
||||||
|
onChange={setScope}
|
||||||
|
badge={totalCount}
|
||||||
|
badgeColor="purple"
|
||||||
|
label="All records (current filters)"
|
||||||
|
sub={`${totalCount} total matching records`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Format */}
|
||||||
|
<div>
|
||||||
|
<h4 className="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-3">File format</h4>
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<FormatOption value="csv" current={format} onChange={setFormat}
|
||||||
|
icon={<FaFileCsv className="text-green-600 text-xl" />}
|
||||||
|
label="CSV" sub="Universal, works everywhere" />
|
||||||
|
<FormatOption value="excel" current={format} onChange={setFormat}
|
||||||
|
icon={<FaFileExcel className="text-green-700 text-xl" />}
|
||||||
|
label="Excel (.xlsx)" sub="Native Excel workbook" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Column picker */}
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<h4 className="text-sm font-semibold text-gray-700 dark:text-gray-300">
|
||||||
|
Columns to export
|
||||||
|
{fieldsLoading && <FaSpinner className="inline ml-2 animate-spin text-gray-400" size={12} />}
|
||||||
|
</h4>
|
||||||
|
<div className="flex gap-3 text-xs text-blue-600 dark:text-blue-400">
|
||||||
|
<button onClick={selectAll} className="hover:underline">All</button>
|
||||||
|
<button onClick={selectDefault} className="hover:underline">Default</button>
|
||||||
|
<button onClick={selectNone} className="hover:underline">None</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Search */}
|
||||||
|
<div className="relative mb-2">
|
||||||
|
<FaSearch className="absolute left-2.5 top-1/2 -translate-y-1/2 text-gray-400" size={12} />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={search}
|
||||||
|
onChange={e => setSearch(e.target.value)}
|
||||||
|
placeholder="Search fields…"
|
||||||
|
className="w-full pl-8 pr-3 py-1.5 text-xs border border-gray-300 dark:border-gray-600 rounded-md focus:outline-none focus:ring-2 focus:ring-green-500 bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
|
||||||
|
/>
|
||||||
|
{search && (
|
||||||
|
<button onClick={() => setSearch('')} className="absolute right-2 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600">
|
||||||
|
<FaTimes size={10} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Field grid */}
|
||||||
|
{fieldsLoading ? (
|
||||||
|
<div className="flex items-center justify-center h-24 text-gray-400 text-sm gap-2">
|
||||||
|
<FaSpinner className="animate-spin" /> Loading fields…
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-3 gap-1.5 max-h-52 overflow-y-auto p-2 bg-gray-50 dark:bg-gray-900/50 rounded-lg border border-gray-200 dark:border-gray-700">
|
||||||
|
{filteredColumns.map(col => {
|
||||||
|
const checked = checkedKeys.has(col.key);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={col.key}
|
||||||
|
onClick={() => toggleColumn(col.key)}
|
||||||
|
className={`flex items-center gap-2 px-2 py-1.5 rounded cursor-pointer transition-all text-xs select-none ${
|
||||||
|
checked
|
||||||
|
? 'bg-green-100 dark:bg-green-900/30 text-green-800 dark:text-green-200'
|
||||||
|
: 'hover:bg-gray-100 dark:hover:bg-gray-800 text-gray-600 dark:text-gray-400'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span className="flex-shrink-0">
|
||||||
|
{checked
|
||||||
|
? <FaCheckSquare size={13} className="text-green-600" />
|
||||||
|
: <FaSquare size={13} className="text-gray-300 dark:text-gray-600" />}
|
||||||
|
</span>
|
||||||
|
<span className="truncate" title={`${col.label} (${col.key})`}>
|
||||||
|
{col.label}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{filteredColumns.length === 0 && (
|
||||||
|
<p className="col-span-3 text-center text-gray-400 text-xs py-4">
|
||||||
|
No fields match "{search}"
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<p className="text-xs text-gray-400 mt-1.5">
|
||||||
|
{checkedKeys.size} of {allColumns.length} fields selected
|
||||||
|
{search && ` · showing ${filteredColumns.length} matching`}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Footer ── */}
|
||||||
|
<div className="flex-shrink-0 px-6 py-4 bg-gray-50 dark:bg-gray-900/50 border-t border-gray-200 dark:border-gray-700 rounded-b-lg flex justify-between items-center">
|
||||||
|
<p className="text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
{scope === 'selected' && `Exporting ${selectedCount} selected row${selectedCount !== 1 ? 's' : ''}`}
|
||||||
|
{scope === 'all_on_page' && `Exporting ${pageCount} rows from current page`}
|
||||||
|
{scope === 'all_with_filters' && `Exporting up to ${totalCount} records`}
|
||||||
|
</p>
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
disabled={isExporting}
|
||||||
|
className="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleExport}
|
||||||
|
disabled={checkedKeys.size === 0 || isExporting}
|
||||||
|
className="px-4 py-2 text-sm font-medium text-white bg-green-600 hover:bg-green-700 rounded-lg transition-colors flex items-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
{isExporting ? (
|
||||||
|
<><FaSpinner className="animate-spin" size={14} /> Exporting…</>
|
||||||
|
) : (
|
||||||
|
<><FaDownload size={14} /> Export</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────
|
||||||
|
// Sub-components
|
||||||
|
// ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
const BADGE_COLORS: Record<string, string> = {
|
||||||
|
green: 'bg-green-100 dark:bg-green-900/40 text-green-700 dark:text-green-300',
|
||||||
|
blue: 'bg-blue-100 dark:bg-blue-900/40 text-blue-700 dark:text-blue-300',
|
||||||
|
purple: 'bg-purple-100 dark:bg-purple-900/40 text-purple-700 dark:text-purple-300',
|
||||||
|
};
|
||||||
|
|
||||||
|
interface ScopeOptionProps {
|
||||||
|
value: ExportScope;
|
||||||
|
current: ExportScope;
|
||||||
|
onChange: (v: ExportScope) => void;
|
||||||
|
disabled?: boolean;
|
||||||
|
label: string;
|
||||||
|
sub: string;
|
||||||
|
badge: number;
|
||||||
|
badgeColor: 'green' | 'blue' | 'purple';
|
||||||
|
}
|
||||||
|
|
||||||
|
const ScopeOption: React.FC<ScopeOptionProps> = ({ value, current, onChange, disabled, label, sub, badge, badgeColor }) => (
|
||||||
|
<label className={`flex items-center gap-3 p-3 rounded-lg border cursor-pointer transition-all ${
|
||||||
|
current === value
|
||||||
|
? 'border-green-500 bg-green-50 dark:bg-green-900/20'
|
||||||
|
: 'border-gray-200 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-700/50'
|
||||||
|
} ${disabled ? 'opacity-50 cursor-not-allowed' : ''}`}>
|
||||||
|
<input
|
||||||
|
type="radio" name="export_scope" value={value}
|
||||||
|
checked={current === value}
|
||||||
|
onChange={() => !disabled && onChange(value)}
|
||||||
|
disabled={disabled}
|
||||||
|
className="text-green-600 focus:ring-green-500"
|
||||||
|
/>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="font-medium text-sm text-gray-900 dark:text-white">{label}</div>
|
||||||
|
<div className="text-xs text-gray-500 dark:text-gray-400">{sub}</div>
|
||||||
|
</div>
|
||||||
|
<span className={`px-2 py-0.5 rounded text-xs font-semibold flex-shrink-0 ${BADGE_COLORS[badgeColor]}`}>
|
||||||
|
{badge.toLocaleString()}
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
|
||||||
|
interface FormatOptionProps {
|
||||||
|
value: ExportFormat;
|
||||||
|
current: ExportFormat;
|
||||||
|
onChange: (v: ExportFormat) => void;
|
||||||
|
icon: React.ReactNode;
|
||||||
|
label: string;
|
||||||
|
sub: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const FormatOption: React.FC<FormatOptionProps> = ({ value, current, onChange, icon, label, sub }) => (
|
||||||
|
<label className={`flex-1 flex items-center gap-3 p-3 rounded-lg border cursor-pointer transition-all ${
|
||||||
|
current === value
|
||||||
|
? 'border-green-500 bg-green-50 dark:bg-green-900/20'
|
||||||
|
: 'border-gray-200 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-700/50'
|
||||||
|
}`}>
|
||||||
|
<input
|
||||||
|
type="radio" name="export_format" value={value}
|
||||||
|
checked={current === value}
|
||||||
|
onChange={() => onChange(value)}
|
||||||
|
className="text-green-600 focus:ring-green-500"
|
||||||
|
/>
|
||||||
|
{icon}
|
||||||
|
<div>
|
||||||
|
<div className="font-medium text-sm text-gray-900 dark:text-white">{label}</div>
|
||||||
|
<div className="text-xs text-gray-500 dark:text-gray-400">{sub}</div>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
|
||||||
|
export default DynamicExportModal;
|
||||||
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 { type 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;
|
||||||
|
|
||||||
103
asm_app/src/components/Header.tsx
Normal file
103
asm_app/src/components/Header.tsx
Normal file
@ -0,0 +1,103 @@
|
|||||||
|
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');
|
||||||
|
// };
|
||||||
|
|
||||||
|
const handleLogout = async () => {
|
||||||
|
localStorage.removeItem('user');
|
||||||
|
localStorage.removeItem('sid');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const csrfToken = document.cookie
|
||||||
|
.split('; ')
|
||||||
|
.find(row => row.startsWith('X-Frappe-CSRF-Token='))
|
||||||
|
?.split('=')[1] || '';
|
||||||
|
|
||||||
|
// Step 1: Kill server-side session in Redis
|
||||||
|
await fetch('/api/method/frappe.auth.logout', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'X-Frappe-CSRF-Token': csrfToken,
|
||||||
|
},
|
||||||
|
credentials: 'include',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Step 2: Clear Frappe web session cookies fully
|
||||||
|
await fetch('/?cmd=web_logout', {
|
||||||
|
credentials: 'include',
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Logout error:', err);
|
||||||
|
} finally {
|
||||||
|
window.location.href = '/asm_app/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;
|
||||||
|
|
||||||
|
|
||||||
1478
asm_app/src/components/InspectionReportModal.tsx
Normal file
1478
asm_app/src/components/InspectionReportModal.tsx
Normal file
File diff suppressed because it is too large
Load Diff
469
asm_app/src/components/LinkField.tsx
Normal file
469
asm_app/src/components/LinkField.tsx
Normal file
@ -0,0 +1,469 @@
|
|||||||
|
import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react';
|
||||||
|
import { createPortal } from 'react-dom';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { FaPlus } from 'react-icons/fa';
|
||||||
|
import apiService from '../services/apiService';
|
||||||
|
import { supportsQuickCreate } from '../components/QuickCreateConfig';
|
||||||
|
import { hasCreatePermission } from '../services/permissionService';
|
||||||
|
import QuickCreateModal from '../components/QuickCreateModal';
|
||||||
|
|
||||||
|
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 props for QuickCreate functionality
|
||||||
|
allowQuickCreate?: boolean; // Enable/disable quick create (default: false)
|
||||||
|
onQuickCreateSuccess?: (newRecord: any) => void; // Callback after quick create
|
||||||
|
quickCreateInitialValues?: Record<string, any>; // Initial values for quick create form
|
||||||
|
query?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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,
|
||||||
|
// QuickCreate props with defaults
|
||||||
|
allowQuickCreate = false, // Default to false - must explicitly enable per field
|
||||||
|
onQuickCreateSuccess,
|
||||||
|
quickCreateInitialValues = {},
|
||||||
|
query,
|
||||||
|
}) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
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 });
|
||||||
|
|
||||||
|
// QuickCreate modal state
|
||||||
|
const [showQuickCreate, setShowQuickCreate] = useState(false);
|
||||||
|
|
||||||
|
// Permission state for QuickCreate
|
||||||
|
// null = not checked yet, true = allowed, false = denied
|
||||||
|
const [hasPermission, setHasPermission] = useState<boolean | null>(null);
|
||||||
|
|
||||||
|
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]);
|
||||||
|
|
||||||
|
// Check if doctype has QuickCreate config
|
||||||
|
const hasQuickCreateConfig = useMemo(() => {
|
||||||
|
const supported = supportsQuickCreate(doctype);
|
||||||
|
console.log(`[LinkField] ${doctype} hasQuickCreateConfig: ${supported}`);
|
||||||
|
return supported;
|
||||||
|
}, [doctype]);
|
||||||
|
|
||||||
|
// Check permission ONLY when allowQuickCreate is enabled AND doctype has config
|
||||||
|
useEffect(() => {
|
||||||
|
// Reset state when doctype or allowQuickCreate changes
|
||||||
|
setHasPermission(null);
|
||||||
|
|
||||||
|
// Only check permission if allowQuickCreate is true AND doctype has config
|
||||||
|
if (allowQuickCreate && hasQuickCreateConfig) {
|
||||||
|
console.log(`[LinkField] Checking permission for ${doctype}...`);
|
||||||
|
|
||||||
|
hasCreatePermission(doctype)
|
||||||
|
.then((result) => {
|
||||||
|
console.log(`[LinkField] Permission for ${doctype}: ${result}`);
|
||||||
|
setHasPermission(result);
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
console.error(`[LinkField] Permission check failed for ${doctype}:`, err);
|
||||||
|
setHasPermission(false);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// If allowQuickCreate is false or no config, don't show button
|
||||||
|
setHasPermission(false);
|
||||||
|
|
||||||
|
if (allowQuickCreate && !hasQuickCreateConfig) {
|
||||||
|
console.warn(`[LinkField] ${doctype}: allowQuickCreate=true but no config in QuickCreateConfig.ts`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [allowQuickCreate, doctype, hasQuickCreateConfig]);
|
||||||
|
|
||||||
|
// Final check: show button only if ALL conditions are met:
|
||||||
|
// 1. allowQuickCreate={true} is set on the field
|
||||||
|
// 2. Doctype has config in QuickCreateConfig.ts
|
||||||
|
// 3. Permission check passed (hasPermission === true)
|
||||||
|
const canQuickCreate = useMemo(() => {
|
||||||
|
const result = allowQuickCreate && hasQuickCreateConfig && hasPermission === true;
|
||||||
|
console.log(`[LinkField] canQuickCreate for ${doctype}: ${result}`, {
|
||||||
|
allowQuickCreate,
|
||||||
|
hasQuickCreateConfig,
|
||||||
|
hasPermission
|
||||||
|
});
|
||||||
|
return result;
|
||||||
|
}, [allowQuickCreate, hasQuickCreateConfig, hasPermission, doctype]);
|
||||||
|
|
||||||
|
/// 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}-${query || ''}`;
|
||||||
|
if (!force && lastSearchRef.current === searchKey) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
lastSearchRef.current = searchKey;
|
||||||
|
|
||||||
|
setIsLoading(true);
|
||||||
|
try {
|
||||||
|
let response: { value: string; description?: string }[] | null = null;
|
||||||
|
|
||||||
|
if (query) {
|
||||||
|
// Use custom query method
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
txt: text,
|
||||||
|
doctype: doctype,
|
||||||
|
searchfield: 'name',
|
||||||
|
start: '0',
|
||||||
|
page_len: '50',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add filters if provided
|
||||||
|
if (stableFilters && Object.keys(stableFilters).length > 0) {
|
||||||
|
params.append('filters', JSON.stringify(stableFilters));
|
||||||
|
}
|
||||||
|
|
||||||
|
const customResponse = await apiService.apiCall<any>(
|
||||||
|
`/api/method/${query}?${params.toString()}`
|
||||||
|
);
|
||||||
|
|
||||||
|
// Custom query returns array of arrays: [[value, description], ...]
|
||||||
|
// Convert to expected format
|
||||||
|
if (Array.isArray(customResponse)) {
|
||||||
|
response = customResponse.map((item: any) => {
|
||||||
|
if (Array.isArray(item)) {
|
||||||
|
return { value: item[0], description: item[1] || undefined };
|
||||||
|
}
|
||||||
|
return { value: item.value || item.name || item, description: item.description };
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
response = [];
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Use standard Frappe search_link
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
doctype,
|
||||||
|
txt: text,
|
||||||
|
page_length: '50',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add filters if provided
|
||||||
|
if (stableFilters && Object.keys(stableFilters).length > 0) {
|
||||||
|
params.append('filters', JSON.stringify(stableFilters));
|
||||||
|
}
|
||||||
|
|
||||||
|
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, query]);
|
||||||
|
|
||||||
|
// 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);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle opening QuickCreate modal
|
||||||
|
const handleOpenQuickCreate = () => {
|
||||||
|
setDropdownOpen(false);
|
||||||
|
setSearchText('');
|
||||||
|
setShowQuickCreate(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle QuickCreate success
|
||||||
|
const handleQuickCreateSuccess = (newRecord: any) => {
|
||||||
|
// Get the name/value from the new record
|
||||||
|
const newValue = newRecord.name || newRecord[Object.keys(newRecord)[0]];
|
||||||
|
handleSelect(newValue);
|
||||||
|
|
||||||
|
// Call external callback if provided
|
||||||
|
if (onQuickCreateSuccess) {
|
||||||
|
onQuickCreateSuccess(newRecord);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 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>
|
||||||
|
{t('linkField.loading')}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Results list with QuickCreate option */}
|
||||||
|
{!isLoading && (
|
||||||
|
<div className={`${usePortal ? '' : 'absolute z-[1050]'} ${dropdownClasses} overflow-hidden`}
|
||||||
|
style={positionStyle}>
|
||||||
|
|
||||||
|
{/* Results */}
|
||||||
|
{searchResults.length > 0 ? (
|
||||||
|
<ul className={`overflow-auto ${compact ? 'max-h-36' : 'max-h-48'}`}>
|
||||||
|
{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>
|
||||||
|
) : (
|
||||||
|
<div className={`text-center text-gray-500 dark:text-gray-400
|
||||||
|
${compact ? 'p-1.5 text-[10px]' : 'p-3 text-sm'}`}>
|
||||||
|
{t('linkField.noResultsFound')}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* QuickCreate Button - Only shows if all conditions are met */}
|
||||||
|
{canQuickCreate && (
|
||||||
|
<>
|
||||||
|
<div className="border-t border-gray-200 dark:border-gray-700" />
|
||||||
|
<div
|
||||||
|
onClick={handleOpenQuickCreate}
|
||||||
|
className={`cursor-pointer flex items-center gap-2
|
||||||
|
text-green-600 dark:text-green-400
|
||||||
|
hover:bg-green-50 dark:hover:bg-green-900/20
|
||||||
|
hover:text-green-700 dark:hover:text-green-300
|
||||||
|
transition-colors
|
||||||
|
${compact ? 'px-2 py-1.5 text-xs' : 'px-3 py-2.5 text-sm'}`}
|
||||||
|
>
|
||||||
|
<FaPlus size={compact ? 10 : 12} />
|
||||||
|
<span className="font-medium">
|
||||||
|
{t('linkField.createNewDoctype', { doctype: doctype.replace(/_/g, ' ') })}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</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 || t('linkField.selectLabel', { 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);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 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>
|
||||||
|
|
||||||
|
{/* QuickCreate Modal */}
|
||||||
|
<QuickCreateModal
|
||||||
|
doctype={doctype}
|
||||||
|
isOpen={showQuickCreate}
|
||||||
|
onClose={() => setShowQuickCreate(false)}
|
||||||
|
onSuccess={handleQuickCreateSuccess}
|
||||||
|
initialValues={quickCreateInitialValues}
|
||||||
|
parentFilters={stableFilters}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default LinkField;
|
||||||
151
asm_app/src/components/ListPagination.tsx
Normal file
151
asm_app/src/components/ListPagination.tsx
Normal file
@ -0,0 +1,151 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
export interface ListPaginationProps {
|
||||||
|
/** Current page (1-based for display) */
|
||||||
|
currentPage: number;
|
||||||
|
/** Total number of items (optional - when missing we don't show "of N" or page numbers) */
|
||||||
|
totalCount?: number;
|
||||||
|
/** Page size (limit per page) */
|
||||||
|
pageSize: number;
|
||||||
|
/** Whether there is a next page (when totalCount is not available) */
|
||||||
|
hasMore?: boolean;
|
||||||
|
/** Label for the list, e.g. "items", "issues" */
|
||||||
|
itemLabel?: string;
|
||||||
|
/** Callback when user changes page. Receives 1-based page number. */
|
||||||
|
onPageChange: (page: number) => void;
|
||||||
|
/** Optional class for the container */
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reusable list pagination: Previous/Next, optional page number buttons,
|
||||||
|
* "Go to page" input, and "Showing X to Y of Z" text.
|
||||||
|
* Page is 1-based in this component (URLs and display).
|
||||||
|
*/
|
||||||
|
const ListPagination: React.FC<ListPaginationProps> = ({
|
||||||
|
currentPage,
|
||||||
|
totalCount = 0,
|
||||||
|
pageSize,
|
||||||
|
hasMore = false,
|
||||||
|
itemLabel,
|
||||||
|
onPageChange,
|
||||||
|
className = '',
|
||||||
|
}) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const displayLabel = itemLabel ?? t('listPages.results');
|
||||||
|
const totalPages = totalCount > 0 ? Math.max(1, Math.ceil(totalCount / pageSize)) : 0;
|
||||||
|
const hasTotal = totalCount > 0;
|
||||||
|
const start = (currentPage - 1) * pageSize + 1;
|
||||||
|
const end = hasTotal
|
||||||
|
? Math.min(currentPage * pageSize, totalCount)
|
||||||
|
: currentPage * pageSize;
|
||||||
|
|
||||||
|
const [goToInput, setGoToInput] = useState('');
|
||||||
|
|
||||||
|
const handleGoToSubmit = (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const num = parseInt(goToInput.trim(), 10);
|
||||||
|
if (!Number.isNaN(num) && num >= 1) {
|
||||||
|
const target = hasTotal ? Math.min(num, totalPages) : num;
|
||||||
|
onPageChange(target);
|
||||||
|
setGoToInput('');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Page numbers to show: first, last, and window around current (e.g. 1 ... 4 5 6 ... 20)
|
||||||
|
const getPageNumbers = (): (number | 'ellipsis')[] => {
|
||||||
|
if (totalPages <= 7) {
|
||||||
|
return Array.from({ length: totalPages }, (_, i) => i + 1);
|
||||||
|
}
|
||||||
|
const pages: (number | 'ellipsis')[] = [];
|
||||||
|
pages.push(1);
|
||||||
|
if (currentPage > 3) pages.push('ellipsis');
|
||||||
|
for (let p = Math.max(2, currentPage - 1); p <= Math.min(totalPages - 1, currentPage + 1); p++) {
|
||||||
|
if (!pages.includes(p)) pages.push(p);
|
||||||
|
}
|
||||||
|
if (currentPage < totalPages - 2) pages.push('ellipsis');
|
||||||
|
if (totalPages > 1) pages.push(totalPages);
|
||||||
|
return pages;
|
||||||
|
};
|
||||||
|
|
||||||
|
const showPagination = hasMore || currentPage > 1 || (hasTotal && totalPages > 1);
|
||||||
|
|
||||||
|
if (!showPagination) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`flex flex-wrap items-center justify-between gap-3 px-6 py-4 border-t border-gray-200 dark:border-gray-700 ${className}`}>
|
||||||
|
<div className="text-sm text-gray-700 dark:text-gray-300">
|
||||||
|
{hasTotal
|
||||||
|
? t('pagination.showingToOf', { start, end, total: totalCount, label: displayLabel })
|
||||||
|
: t('pagination.showingTo', { start, end, label: displayLabel })}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onPageChange(currentPage - 1)}
|
||||||
|
disabled={currentPage <= 1}
|
||||||
|
className="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-600 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
{t('pagination.previous')}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{hasTotal && totalPages > 1 && (
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
{getPageNumbers().map((p, i) =>
|
||||||
|
p === 'ellipsis' ? (
|
||||||
|
<span key={`e-${i}`} className="px-2 text-gray-500 dark:text-gray-400">
|
||||||
|
…
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
key={p}
|
||||||
|
type="button"
|
||||||
|
onClick={() => onPageChange(p)}
|
||||||
|
className={`min-w-[2rem] px-2 py-1 text-sm font-medium rounded-lg transition-colors ${
|
||||||
|
p === currentPage
|
||||||
|
? 'bg-blue-600 text-white border border-blue-600'
|
||||||
|
: 'text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-600'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{p}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onPageChange(currentPage + 1)}
|
||||||
|
disabled={hasTotal ? currentPage >= totalPages : !hasMore}
|
||||||
|
className="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-600 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
{t('pagination.next')}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<form onSubmit={handleGoToSubmit} className="flex items-center gap-1 ml-2">
|
||||||
|
<span className="text-sm text-gray-600 dark:text-gray-400 whitespace-nowrap">{t('pagination.goTo')}</span>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
max={hasTotal ? totalPages : undefined}
|
||||||
|
value={goToInput}
|
||||||
|
onChange={(e) => setGoToInput(e.target.value)}
|
||||||
|
placeholder={hasTotal ? `1-${totalPages}` : t('pagination.page')}
|
||||||
|
className="w-14 px-2 py-1 text-sm border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="px-2 py-1 text-sm font-medium text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300 border border-gray-300 dark:border-gray-600 rounded hover:bg-gray-50 dark:hover:bg-gray-700"
|
||||||
|
>
|
||||||
|
{t('pagination.go')}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ListPagination;
|
||||||
433
asm_app/src/components/MaintenanceCalendar.tsx
Normal file
433
asm_app/src/components/MaintenanceCalendar.tsx
Normal file
@ -0,0 +1,433 @@
|
|||||||
|
import React, { useState, useMemo, useEffect } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
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 { t } = useTranslation();
|
||||||
|
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 = [
|
||||||
|
t('maintenanceCalendarPage.months.january'),
|
||||||
|
t('maintenanceCalendarPage.months.february'),
|
||||||
|
t('maintenanceCalendarPage.months.march'),
|
||||||
|
t('maintenanceCalendarPage.months.april'),
|
||||||
|
t('maintenanceCalendarPage.months.may'),
|
||||||
|
t('maintenanceCalendarPage.months.june'),
|
||||||
|
t('maintenanceCalendarPage.months.july'),
|
||||||
|
t('maintenanceCalendarPage.months.august'),
|
||||||
|
t('maintenanceCalendarPage.months.september'),
|
||||||
|
t('maintenanceCalendarPage.months.october'),
|
||||||
|
t('maintenanceCalendarPage.months.november'),
|
||||||
|
t('maintenanceCalendarPage.months.december'),
|
||||||
|
];
|
||||||
|
|
||||||
|
const dayNames = [
|
||||||
|
t('maintenanceCalendarPage.days.sun'),
|
||||||
|
t('maintenanceCalendarPage.days.mon'),
|
||||||
|
t('maintenanceCalendarPage.days.tue'),
|
||||||
|
t('maintenanceCalendarPage.days.wed'),
|
||||||
|
t('maintenanceCalendarPage.days.thu'),
|
||||||
|
t('maintenanceCalendarPage.days.fri'),
|
||||||
|
t('maintenanceCalendarPage.days.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={t('maintenanceCalendarPage.previousMonth')}
|
||||||
|
>
|
||||||
|
<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"
|
||||||
|
>
|
||||||
|
{t('maintenanceCalendarPage.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={t('maintenanceCalendarPage.nextMonth')}
|
||||||
|
>
|
||||||
|
<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">
|
||||||
|
{viewType === 'maintenance-log' ? t('maintenanceCalendarPage.loadingLogs') : t('maintenanceCalendarPage.loadingPpm')}
|
||||||
|
</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 ? ` ${t('maintenanceCalendarPage.overdueInTooltip')}` : ''} - ${t('maintenanceCalendarPage.clickToViewDetails')}`}
|
||||||
|
>
|
||||||
|
<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 || t('maintenanceCalendarPage.ppmPlannerDefault');
|
||||||
|
const tooltipText = schedule.name
|
||||||
|
? `${schedule.name}${schedule.modality ? ` - ${schedule.modality}` : ''}${schedule.hospital ? ` - ${schedule.hospital}` : ''} - ${t('maintenanceCalendarPage.clickToViewPpmPlanner')}`
|
||||||
|
: t('maintenanceCalendarPage.clickToViewPpmPlanner');
|
||||||
|
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">{t('maintenanceCalendarPage.legendCompleted')}</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">{t('maintenanceCalendarPage.legendPlanned')}</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">{t('maintenanceCalendarPage.legendOverdue')}</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">{t('maintenanceCalendarPage.legendToday')}</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">{t('maintenanceCalendarPage.legendCompleted')}</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">{t('maintenanceCalendarPage.legendPlanned')}</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">{t('maintenanceCalendarPage.legendOverdue')}</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">{t('maintenanceCalendarPage.summaryPpmPlanners')}</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default MaintenanceCalendar;
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
361
asm_app/src/components/MentionInput.tsx
Normal file
361
asm_app/src/components/MentionInput.tsx
Normal file
@ -0,0 +1,361 @@
|
|||||||
|
import React, { useState, useRef, useEffect, useCallback, type KeyboardEvent } from 'react';
|
||||||
|
import { FaSpinner, FaUser } from 'react-icons/fa';
|
||||||
|
import type { MentionUser } from '../services/commentService';
|
||||||
|
import API_CONFIG from '../config/api';
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// MentionInput – a textarea that shows a dropdown when user
|
||||||
|
// types '@' and lets them pick a user to @mention.
|
||||||
|
//
|
||||||
|
// The final output is an HTML string with Frappe-style mention
|
||||||
|
// markup so ERPNext recognises it exactly like its own editor.
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
interface MentionInputProps {
|
||||||
|
value: string; // plain-text draft
|
||||||
|
onChange: (text: string) => void;
|
||||||
|
onSubmit: (html: string) => void; // returns formatted HTML
|
||||||
|
placeholder?: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
mentionUsers: MentionUser[];
|
||||||
|
mentionLoading: boolean;
|
||||||
|
onMentionSearch: (query: string) => void;
|
||||||
|
posting?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Stores an inserted mention so we can convert to HTML later */
|
||||||
|
interface InsertedMention {
|
||||||
|
startIndex: number;
|
||||||
|
displayText: string;
|
||||||
|
userId: string; // email
|
||||||
|
fullName: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const MentionInput: React.FC<MentionInputProps> = ({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
onSubmit,
|
||||||
|
placeholder = 'Type a comment… Use @ to mention someone',
|
||||||
|
disabled = false,
|
||||||
|
mentionUsers,
|
||||||
|
mentionLoading,
|
||||||
|
onMentionSearch,
|
||||||
|
posting = false,
|
||||||
|
}) => {
|
||||||
|
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||||
|
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
// Mention trigger state
|
||||||
|
const [showMentionDropdown, setShowMentionDropdown] = useState(false);
|
||||||
|
const [mentionQuery, setMentionQuery] = useState('');
|
||||||
|
const [mentionStartPos, setMentionStartPos] = useState<number | null>(null);
|
||||||
|
const [selectedIndex, setSelectedIndex] = useState(0);
|
||||||
|
const [insertedMentions, setInsertedMentions] = useState<InsertedMention[]>([]);
|
||||||
|
|
||||||
|
// Dropdown position
|
||||||
|
const [dropdownPos, setDropdownPos] = useState({ top: 0, left: 0 });
|
||||||
|
|
||||||
|
// ── Calculate dropdown position relative to textarea ────
|
||||||
|
const updateDropdownPosition = useCallback(() => {
|
||||||
|
const ta = textareaRef.current;
|
||||||
|
if (!ta) return;
|
||||||
|
// Place the dropdown above the textarea bottom
|
||||||
|
const rect = ta.getBoundingClientRect();
|
||||||
|
const parentRect = ta.offsetParent?.getBoundingClientRect() ?? rect;
|
||||||
|
setDropdownPos({
|
||||||
|
top: ta.offsetTop - 4, // above textarea
|
||||||
|
left: ta.offsetLeft,
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// ── Handle text changes ─────────────────────────────────
|
||||||
|
const handleChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||||
|
const newValue = e.target.value;
|
||||||
|
const cursorPos = e.target.selectionStart ?? 0;
|
||||||
|
onChange(newValue);
|
||||||
|
|
||||||
|
// Check for active mention trigger
|
||||||
|
const textBeforeCursor = newValue.substring(0, cursorPos);
|
||||||
|
const lastAtIndex = textBeforeCursor.lastIndexOf('@');
|
||||||
|
|
||||||
|
if (lastAtIndex !== -1) {
|
||||||
|
// Check that @ is at start or preceded by a space/newline
|
||||||
|
const charBefore = lastAtIndex > 0 ? newValue[lastAtIndex - 1] : ' ';
|
||||||
|
if (charBefore === ' ' || charBefore === '\n' || lastAtIndex === 0) {
|
||||||
|
const query = textBeforeCursor.substring(lastAtIndex + 1);
|
||||||
|
// Only activate if query doesn't contain spaces (single-word search)
|
||||||
|
if (!query.includes(' ') || query.length <= 30) {
|
||||||
|
setShowMentionDropdown(true);
|
||||||
|
setMentionQuery(query);
|
||||||
|
setMentionStartPos(lastAtIndex);
|
||||||
|
setSelectedIndex(0);
|
||||||
|
onMentionSearch(query);
|
||||||
|
updateDropdownPosition();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// No active mention
|
||||||
|
setShowMentionDropdown(false);
|
||||||
|
setMentionStartPos(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Insert a mention into the text ──────────────────────
|
||||||
|
const insertMention = useCallback(
|
||||||
|
(user: MentionUser) => {
|
||||||
|
if (mentionStartPos === null) return;
|
||||||
|
|
||||||
|
const ta = textareaRef.current;
|
||||||
|
const before = value.substring(0, mentionStartPos);
|
||||||
|
const cursorPos = ta?.selectionStart ?? mentionStartPos + mentionQuery.length + 1;
|
||||||
|
const after = value.substring(cursorPos);
|
||||||
|
|
||||||
|
const displayText = user.full_name || user.name;
|
||||||
|
const newText = `${before}@${displayText} ${after}`;
|
||||||
|
|
||||||
|
// Track the mention
|
||||||
|
setInsertedMentions((prev) => [
|
||||||
|
...prev,
|
||||||
|
{
|
||||||
|
startIndex: mentionStartPos,
|
||||||
|
displayText,
|
||||||
|
userId: user.name,
|
||||||
|
fullName: user.full_name || user.name,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
onChange(newText);
|
||||||
|
setShowMentionDropdown(false);
|
||||||
|
setMentionStartPos(null);
|
||||||
|
setMentionQuery('');
|
||||||
|
|
||||||
|
// Refocus and place cursor after mention
|
||||||
|
setTimeout(() => {
|
||||||
|
if (ta) {
|
||||||
|
ta.focus();
|
||||||
|
const newPos = before.length + displayText.length + 2; // +2 for @ and space
|
||||||
|
ta.selectionStart = newPos;
|
||||||
|
ta.selectionEnd = newPos;
|
||||||
|
}
|
||||||
|
}, 0);
|
||||||
|
},
|
||||||
|
[mentionStartPos, mentionQuery, value, onChange]
|
||||||
|
);
|
||||||
|
|
||||||
|
// ── Keyboard navigation inside dropdown ─────────────────
|
||||||
|
const handleKeyDown = (e: KeyboardEvent<HTMLTextAreaElement>) => {
|
||||||
|
if (showMentionDropdown && mentionUsers.length > 0) {
|
||||||
|
switch (e.key) {
|
||||||
|
case 'ArrowDown':
|
||||||
|
e.preventDefault();
|
||||||
|
setSelectedIndex((prev) => Math.min(prev + 1, mentionUsers.length - 1));
|
||||||
|
return;
|
||||||
|
case 'ArrowUp':
|
||||||
|
e.preventDefault();
|
||||||
|
setSelectedIndex((prev) => Math.max(prev - 1, 0));
|
||||||
|
return;
|
||||||
|
case 'Enter':
|
||||||
|
e.preventDefault();
|
||||||
|
insertMention(mentionUsers[selectedIndex]);
|
||||||
|
return;
|
||||||
|
case 'Escape':
|
||||||
|
e.preventDefault();
|
||||||
|
setShowMentionDropdown(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ctrl/Cmd + Enter to submit
|
||||||
|
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) {
|
||||||
|
e.preventDefault();
|
||||||
|
handleSubmit();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Build Frappe-compatible HTML from plain text + mentions ─
|
||||||
|
const buildHtml = useCallback(
|
||||||
|
(text: string): string => {
|
||||||
|
let html = text;
|
||||||
|
const baseUrl = API_CONFIG.BASE_URL || window.location.origin;
|
||||||
|
|
||||||
|
// Replace each tracked @mention with the Frappe mention markup
|
||||||
|
// Process in reverse order of startIndex so positions don't shift
|
||||||
|
const sorted = [...insertedMentions].sort((a, b) => b.startIndex - a.startIndex);
|
||||||
|
|
||||||
|
for (const m of sorted) {
|
||||||
|
const mentionText = `@${m.displayText}`;
|
||||||
|
const idx = html.indexOf(mentionText);
|
||||||
|
if (idx === -1) continue;
|
||||||
|
|
||||||
|
const profileUrl = `${baseUrl}/app/user-profile/${encodeURIComponent(m.userId)}`;
|
||||||
|
const mentionHtml =
|
||||||
|
`<span class="mention" ` +
|
||||||
|
`data-id="${m.userId}" ` +
|
||||||
|
`data-value="<a href="${profileUrl}" target="_blank">${m.fullName}" ` +
|
||||||
|
`data-denotation-char="@" ` +
|
||||||
|
`data-is-group="false" ` +
|
||||||
|
`data-link="${profileUrl}">` +
|
||||||
|
`\uFEFF<span contenteditable="false">` +
|
||||||
|
`<span class="ql-mention-denotation-char">@</span>` +
|
||||||
|
`<a href="${profileUrl}" target="_blank">${m.fullName}</a>` +
|
||||||
|
`</span>\uFEFF</span>`;
|
||||||
|
|
||||||
|
html = html.substring(0, idx) + mentionHtml + html.substring(idx + mentionText.length);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Escape remaining HTML chars (basic), then wrap newlines
|
||||||
|
// We do NOT escape the mention markup we just inserted
|
||||||
|
// Instead, split by mention spans, escape non-mention parts, and rejoin
|
||||||
|
// Simple approach: since mentions are already HTML, just convert newlines to <br>
|
||||||
|
html = html.replace(/\n/g, '<br>');
|
||||||
|
|
||||||
|
return `<div class="ql-editor read-mode"><p>${html}</p></div>`;
|
||||||
|
},
|
||||||
|
[insertedMentions]
|
||||||
|
);
|
||||||
|
|
||||||
|
// ── Submit handler ──────────────────────────────────────
|
||||||
|
const handleSubmit = () => {
|
||||||
|
const trimmed = value.trim();
|
||||||
|
if (!trimmed || posting) return;
|
||||||
|
|
||||||
|
const html = buildHtml(trimmed);
|
||||||
|
onSubmit(html);
|
||||||
|
setInsertedMentions([]);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Close dropdown on outside click ─────────────────────
|
||||||
|
useEffect(() => {
|
||||||
|
const handleClickOutside = (e: MouseEvent) => {
|
||||||
|
if (
|
||||||
|
dropdownRef.current &&
|
||||||
|
!dropdownRef.current.contains(e.target as Node) &&
|
||||||
|
textareaRef.current &&
|
||||||
|
!textareaRef.current.contains(e.target as Node)
|
||||||
|
) {
|
||||||
|
setShowMentionDropdown(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
document.addEventListener('mousedown', handleClickOutside);
|
||||||
|
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// ── Scroll selected item into view ──────────────────────
|
||||||
|
useEffect(() => {
|
||||||
|
if (!dropdownRef.current) return;
|
||||||
|
const item = dropdownRef.current.querySelector(`[data-idx="${selectedIndex}"]`);
|
||||||
|
item?.scrollIntoView({ block: 'nearest' });
|
||||||
|
}, [selectedIndex]);
|
||||||
|
|
||||||
|
const baseUrl = API_CONFIG.BASE_URL || '';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative">
|
||||||
|
{/* ── Mention dropdown ──────────────────────────────── */}
|
||||||
|
{showMentionDropdown && (
|
||||||
|
<div
|
||||||
|
ref={dropdownRef}
|
||||||
|
className="absolute z-50 bottom-full mb-1 w-72 max-h-52 overflow-y-auto
|
||||||
|
bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-600
|
||||||
|
rounded-lg shadow-lg"
|
||||||
|
style={{ left: 0 }}
|
||||||
|
>
|
||||||
|
{mentionLoading && mentionUsers.length === 0 ? (
|
||||||
|
<div className="flex items-center gap-2 px-3 py-4 text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
<FaSpinner className="animate-spin" size={12} />
|
||||||
|
Searching users…
|
||||||
|
</div>
|
||||||
|
) : mentionUsers.length === 0 ? (
|
||||||
|
<div className="px-3 py-4 text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
No users found
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
mentionUsers.map((user, idx) => (
|
||||||
|
<button
|
||||||
|
key={user.name}
|
||||||
|
data-idx={idx}
|
||||||
|
type="button"
|
||||||
|
className={`w-full flex items-center gap-2.5 px-3 py-2 text-left transition-colors
|
||||||
|
${idx === selectedIndex
|
||||||
|
? 'bg-teal-50 dark:bg-teal-900/30 text-teal-800 dark:text-teal-200'
|
||||||
|
: 'text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700'
|
||||||
|
}`}
|
||||||
|
onMouseEnter={() => setSelectedIndex(idx)}
|
||||||
|
onMouseDown={(e) => {
|
||||||
|
e.preventDefault(); // keep focus on textarea
|
||||||
|
insertMention(user);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Avatar */}
|
||||||
|
{user.user_image ? (
|
||||||
|
<img
|
||||||
|
src={`${baseUrl}${user.user_image}`}
|
||||||
|
alt=""
|
||||||
|
className="w-7 h-7 rounded-full object-cover flex-shrink-0"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="w-7 h-7 rounded-full bg-gray-200 dark:bg-gray-600 flex items-center justify-center flex-shrink-0">
|
||||||
|
<FaUser className="text-gray-500 dark:text-gray-400" size={10} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="text-sm font-medium truncate">
|
||||||
|
{user.full_name || user.name}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-gray-500 dark:text-gray-400 truncate">
|
||||||
|
{user.name}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── Textarea + submit ──────────────────────────────── */}
|
||||||
|
<div className="flex gap-2 items-end">
|
||||||
|
<textarea
|
||||||
|
ref={textareaRef}
|
||||||
|
value={value}
|
||||||
|
onChange={handleChange}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
placeholder={placeholder}
|
||||||
|
disabled={disabled || posting}
|
||||||
|
rows={3}
|
||||||
|
className="flex-1 px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg
|
||||||
|
bg-white dark:bg-gray-700 text-gray-900 dark:text-white text-sm
|
||||||
|
disabled:bg-gray-100 dark:disabled:bg-gray-800
|
||||||
|
focus:outline-none focus:ring-2 focus:ring-teal-500 resize-none
|
||||||
|
placeholder:text-gray-400 dark:placeholder:text-gray-500"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleSubmit}
|
||||||
|
disabled={disabled || posting || !value.trim()}
|
||||||
|
className="px-4 py-2 bg-teal-600 hover:bg-teal-700 disabled:bg-teal-600/50
|
||||||
|
text-white text-sm font-medium rounded-lg transition-colors
|
||||||
|
disabled:cursor-not-allowed flex items-center gap-1.5 h-10 flex-shrink-0"
|
||||||
|
>
|
||||||
|
{posting ? (
|
||||||
|
<>
|
||||||
|
<FaSpinner className="animate-spin" size={12} />
|
||||||
|
<span>Posting…</span>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<span>Comment</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Hint */}
|
||||||
|
<p className="mt-1 text-[10px] text-gray-400 dark:text-gray-500">
|
||||||
|
<kbd className="px-1 py-0.5 bg-gray-100 dark:bg-gray-700 rounded text-[9px]">@</kbd> to mention
|
||||||
|
·
|
||||||
|
<kbd className="px-1 py-0.5 bg-gray-100 dark:bg-gray-700 rounded text-[9px]">Ctrl+Enter</kbd> to post
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default MentionInput;
|
||||||
272
asm_app/src/components/NotificationBell.tsx
Normal file
272
asm_app/src/components/NotificationBell.tsx
Normal file
@ -0,0 +1,272 @@
|
|||||||
|
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 stripHtml = (html: string): string => {
|
||||||
|
if (!html) return '';
|
||||||
|
const doc = new DOMParser().parseFromString(html, 'text/html');
|
||||||
|
return (doc.body.textContent || '').replace(/\uFEFF/g, '').replace(/\s+/g, ' ').trim();
|
||||||
|
};
|
||||||
|
|
||||||
|
const NotificationBell: React.FC = () => {
|
||||||
|
const { notifications, unreadCount, markAsRead, markAllAsRead, loading } = useNotifications();
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
const [markingAll, setMarkingAll] = 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 handleMarkAllAsRead = async () => {
|
||||||
|
setMarkingAll(true);
|
||||||
|
try {
|
||||||
|
await markAllAsRead();
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('[NotificationBell] Could not mark all as read:', error);
|
||||||
|
} finally {
|
||||||
|
setMarkingAll(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
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 if (normalizedType === 'Inspection') {
|
||||||
|
console.log('[NotificationBell] Navigating to inspection:', `/inspections/${docName}`);
|
||||||
|
navigate(`/inspections/${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={handleMarkAllAsRead}
|
||||||
|
disabled={markingAll}
|
||||||
|
className={`text-xs text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300 hover:underline ${
|
||||||
|
markingAll ? 'opacity-50 cursor-not-allowed' : ''
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{markingAll ? 'Marking...' : '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">
|
||||||
|
{stripHtml((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">
|
||||||
|
{stripHtml((notif as any).email_content || '')}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{/* <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">
|
||||||
|
{stripHtml((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">
|
||||||
|
{stripHtml((notif as any).email_content || '')}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{/* <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;
|
||||||
455
asm_app/src/components/QuickCreateConfig.tsx
Normal file
455
asm_app/src/components/QuickCreateConfig.tsx
Normal file
@ -0,0 +1,455 @@
|
|||||||
|
/**
|
||||||
|
* QuickCreate Configuration
|
||||||
|
*
|
||||||
|
* This file defines the configuration for quick-creating new records
|
||||||
|
* from LinkField dropdowns. Add new doctypes as needed.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface QuickCreateFieldConfig {
|
||||||
|
fieldname: string;
|
||||||
|
label: string;
|
||||||
|
fieldtype: 'Data' | 'Text' | 'Select' | 'Link' | 'Check' | 'Int' | 'Float' | 'Date' | 'Datetime';
|
||||||
|
required?: boolean;
|
||||||
|
placeholder?: string;
|
||||||
|
options?: string[] | { label: string; value: string }[]; // For Select fieldtype
|
||||||
|
linkDoctype?: string; // For Link fieldtype
|
||||||
|
linkFilters?: Record<string, any>; // Filters for Link fieldtype
|
||||||
|
defaultValue?: any;
|
||||||
|
description?: string;
|
||||||
|
hidden?: boolean;
|
||||||
|
readOnly?: boolean;
|
||||||
|
dependsOn?: string; // Field name this field depends on
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface QuickCreateDoctypeConfig {
|
||||||
|
doctype: string;
|
||||||
|
title: string; // Display title for the modal
|
||||||
|
fields: QuickCreateFieldConfig[];
|
||||||
|
titleField?: string; // The main field that represents the record name (defaults to 'name')
|
||||||
|
afterCreate?: (newRecord: any) => void; // Callback after successful creation
|
||||||
|
validateBeforeCreate?: (data: Record<string, any>) => string | null; // Return error message or null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Configuration for all doctypes that support quick creation
|
||||||
|
* Add new doctypes here as needed
|
||||||
|
*/
|
||||||
|
export const QUICK_CREATE_CONFIG: Record<string, QuickCreateDoctypeConfig> = {
|
||||||
|
// Location Doctype
|
||||||
|
'Location': {
|
||||||
|
doctype: 'Location',
|
||||||
|
title: 'Create New Location',
|
||||||
|
titleField: 'location_name',
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
fieldname: 'location_name',
|
||||||
|
label: 'Location Name',
|
||||||
|
fieldtype: 'Data',
|
||||||
|
required: true,
|
||||||
|
placeholder: 'Enter location name',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fieldname: 'parent_location',
|
||||||
|
label: 'Parent Location',
|
||||||
|
fieldtype: 'Link',
|
||||||
|
linkDoctype: 'Location',
|
||||||
|
placeholder: 'Select parent location (optional)',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fieldname: 'is_group',
|
||||||
|
label: 'Is Group',
|
||||||
|
fieldtype: 'Check',
|
||||||
|
defaultValue: 0,
|
||||||
|
description: 'Check if this location contains sub-locations',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fieldname: 'latitude',
|
||||||
|
label: 'Latitude',
|
||||||
|
fieldtype: 'Float',
|
||||||
|
placeholder: 'e.g., 24.7136',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fieldname: 'longitude',
|
||||||
|
label: 'Longitude',
|
||||||
|
fieldtype: 'Float',
|
||||||
|
placeholder: 'e.g., 46.6753',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
// Room Doctype
|
||||||
|
'Room': {
|
||||||
|
doctype: 'Room',
|
||||||
|
title: 'Create New Room',
|
||||||
|
titleField: 'name',
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
fieldname: 'room',
|
||||||
|
label: 'Room Name/Number',
|
||||||
|
fieldtype: 'Data',
|
||||||
|
required: true,
|
||||||
|
placeholder: 'Enter room name or number',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fieldname: 'building',
|
||||||
|
label: 'Building',
|
||||||
|
fieldtype: 'Link',
|
||||||
|
linkDoctype: 'Building',
|
||||||
|
placeholder: 'Select building',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fieldname: 'location_name',
|
||||||
|
label: 'Location Name',
|
||||||
|
fieldtype: 'Link',
|
||||||
|
linkDoctype: 'Location',
|
||||||
|
placeholder: 'Select Location',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fieldname: 'department',
|
||||||
|
label: 'Department',
|
||||||
|
fieldtype: 'Link',
|
||||||
|
linkDoctype: 'Department',
|
||||||
|
placeholder: 'Select department',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
// Building Doctype
|
||||||
|
'Building': {
|
||||||
|
doctype: 'Building',
|
||||||
|
title: 'Create New Building',
|
||||||
|
titleField: 'name',
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
fieldname: 'building',
|
||||||
|
label: 'Building Name',
|
||||||
|
fieldtype: 'Data',
|
||||||
|
required: true,
|
||||||
|
placeholder: 'Enter building name',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
// Extension Directory Doctype
|
||||||
|
'Extension Directory': {
|
||||||
|
doctype: 'Extension Directory',
|
||||||
|
title: 'Create New Extension',
|
||||||
|
titleField: 'name',
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
fieldname: 'extension_number',
|
||||||
|
label: 'Extension Number',
|
||||||
|
fieldtype: 'Data',
|
||||||
|
required: true,
|
||||||
|
placeholder: 'Enter extension number',
|
||||||
|
},
|
||||||
|
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
// Department Doctype
|
||||||
|
'Department': {
|
||||||
|
doctype: 'Department',
|
||||||
|
title: 'Create New Department',
|
||||||
|
titleField: 'department_name',
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
fieldname: 'department_name',
|
||||||
|
label: 'Department Name',
|
||||||
|
fieldtype: 'Data',
|
||||||
|
required: true,
|
||||||
|
placeholder: 'Enter department name',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fieldname: 'company',
|
||||||
|
label: 'Company',
|
||||||
|
fieldtype: 'Link',
|
||||||
|
linkDoctype: 'Company',
|
||||||
|
required: true,
|
||||||
|
placeholder: 'Select company',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fieldname: 'parent_department',
|
||||||
|
label: 'Parent Department',
|
||||||
|
fieldtype: 'Link',
|
||||||
|
linkDoctype: 'Department',
|
||||||
|
placeholder: 'Select parent department',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fieldname: 'is_group',
|
||||||
|
label: 'Is Group',
|
||||||
|
fieldtype: 'Check',
|
||||||
|
defaultValue: 0,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
// Issue Type (Work Order Type)
|
||||||
|
'Issue Type': {
|
||||||
|
doctype: 'Issue Type',
|
||||||
|
title: 'Create New Issue Type',
|
||||||
|
titleField: 'name',
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
fieldname: '__newname',
|
||||||
|
label: 'Issue Type Name',
|
||||||
|
fieldtype: 'Data',
|
||||||
|
required: true,
|
||||||
|
placeholder: 'Enter issue type name',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fieldname: 'description',
|
||||||
|
label: 'Description',
|
||||||
|
fieldtype: 'Text',
|
||||||
|
placeholder: 'Enter description',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
// Manufacturer
|
||||||
|
'Manufacturer': {
|
||||||
|
doctype: 'Manufacturer',
|
||||||
|
title: 'Create New Manufacturer',
|
||||||
|
titleField: 'name',
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
fieldname: 'short_name',
|
||||||
|
label: 'Manufacturer Name',
|
||||||
|
fieldtype: 'Data',
|
||||||
|
required: true,
|
||||||
|
placeholder: 'Enter manufacturer name',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fieldname: 'full_name',
|
||||||
|
label: 'Full Name',
|
||||||
|
fieldtype: 'Data',
|
||||||
|
placeholder: 'Enter full company name',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fieldname: 'website',
|
||||||
|
label: 'Website',
|
||||||
|
fieldtype: 'Data',
|
||||||
|
placeholder: 'https://example.com',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fieldname: 'country',
|
||||||
|
label: 'Country',
|
||||||
|
fieldtype: 'Link',
|
||||||
|
linkDoctype: 'Country',
|
||||||
|
placeholder: 'Select country',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
// Supplier
|
||||||
|
'Supplier': {
|
||||||
|
doctype: 'Supplier',
|
||||||
|
title: 'Create New Supplier',
|
||||||
|
titleField: 'supplier_name',
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
fieldname: 'supplier_name',
|
||||||
|
label: 'Supplier Name',
|
||||||
|
fieldtype: 'Data',
|
||||||
|
required: true,
|
||||||
|
placeholder: 'Enter supplier name',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fieldname: 'supplier_group',
|
||||||
|
label: 'Supplier Group',
|
||||||
|
fieldtype: 'Link',
|
||||||
|
linkDoctype: 'Supplier Group',
|
||||||
|
placeholder: 'Select supplier group',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fieldname: 'supplier_type',
|
||||||
|
label: 'Supplier Type',
|
||||||
|
fieldtype: 'Select',
|
||||||
|
options: ['Company', 'Individual'],
|
||||||
|
defaultValue: 'Company',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fieldname: 'country',
|
||||||
|
label: 'Country',
|
||||||
|
fieldtype: 'Link',
|
||||||
|
linkDoctype: 'Country',
|
||||||
|
placeholder: 'Select country',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
// Warehouse
|
||||||
|
'Warehouse': {
|
||||||
|
doctype: 'Warehouse',
|
||||||
|
title: 'Create New Warehouse',
|
||||||
|
titleField: 'warehouse_name',
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
fieldname: 'warehouse_name',
|
||||||
|
label: 'Warehouse Name',
|
||||||
|
fieldtype: 'Data',
|
||||||
|
required: true,
|
||||||
|
placeholder: 'Enter warehouse name',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fieldname: 'company',
|
||||||
|
label: 'Company',
|
||||||
|
fieldtype: 'Link',
|
||||||
|
linkDoctype: 'Company',
|
||||||
|
required: true,
|
||||||
|
placeholder: 'Select company',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fieldname: 'parent_warehouse',
|
||||||
|
label: 'Parent Warehouse',
|
||||||
|
fieldtype: 'Link',
|
||||||
|
linkDoctype: 'Warehouse',
|
||||||
|
placeholder: 'Select parent warehouse',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fieldname: 'is_group',
|
||||||
|
label: 'Is Group',
|
||||||
|
fieldtype: 'Check',
|
||||||
|
defaultValue: 0,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
// Item
|
||||||
|
'Item': {
|
||||||
|
doctype: 'Item',
|
||||||
|
title: 'Create New Item',
|
||||||
|
titleField: 'item_name',
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
fieldname: 'item_code',
|
||||||
|
label: 'Item Code',
|
||||||
|
fieldtype: 'Data',
|
||||||
|
required: true,
|
||||||
|
placeholder: 'Enter item code',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fieldname: 'item_name',
|
||||||
|
label: 'Item Name',
|
||||||
|
fieldtype: 'Data',
|
||||||
|
required: true,
|
||||||
|
placeholder: 'Enter item name',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fieldname: 'item_group',
|
||||||
|
label: 'Item Group',
|
||||||
|
fieldtype: 'Link',
|
||||||
|
linkDoctype: 'Item Group',
|
||||||
|
required: true,
|
||||||
|
placeholder: 'Select item group',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fieldname: 'stock_uom',
|
||||||
|
label: 'Default Unit of Measure',
|
||||||
|
fieldtype: 'Link',
|
||||||
|
linkDoctype: 'UOM',
|
||||||
|
required: true,
|
||||||
|
defaultValue: 'Nos',
|
||||||
|
placeholder: 'Select UOM',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fieldname: 'is_stock_item',
|
||||||
|
label: 'Maintain Stock',
|
||||||
|
fieldtype: 'Check',
|
||||||
|
defaultValue: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fieldname: 'description',
|
||||||
|
label: 'Description',
|
||||||
|
fieldtype: 'Text',
|
||||||
|
placeholder: 'Enter item description',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
// Asset
|
||||||
|
'Asset': {
|
||||||
|
doctype: 'Asset',
|
||||||
|
title: 'Create New Asset',
|
||||||
|
titleField: 'asset_name',
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
fieldname: 'asset_name',
|
||||||
|
label: 'Asset Name',
|
||||||
|
fieldtype: 'Data',
|
||||||
|
required: true,
|
||||||
|
placeholder: 'Enter asset name',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fieldname: 'item_code',
|
||||||
|
label: 'Item Code',
|
||||||
|
fieldtype: 'Link',
|
||||||
|
linkDoctype: 'Item',
|
||||||
|
required: true,
|
||||||
|
linkFilters: { is_fixed_asset: 1 },
|
||||||
|
placeholder: 'Select item',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fieldname: 'company',
|
||||||
|
label: 'Company',
|
||||||
|
fieldtype: 'Link',
|
||||||
|
linkDoctype: 'Company',
|
||||||
|
required: true,
|
||||||
|
placeholder: 'Select company',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fieldname: 'location',
|
||||||
|
label: 'Location',
|
||||||
|
fieldtype: 'Link',
|
||||||
|
linkDoctype: 'Location',
|
||||||
|
placeholder: 'Select location',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fieldname: 'custodian',
|
||||||
|
label: 'Custodian',
|
||||||
|
fieldtype: 'Link',
|
||||||
|
linkDoctype: 'Employee',
|
||||||
|
placeholder: 'Select custodian',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
// Technical Department
|
||||||
|
'Technical Department': {
|
||||||
|
doctype: 'Technical Department',
|
||||||
|
title: 'Create New Technical Department',
|
||||||
|
titleField: 'name',
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
fieldname: 'department',
|
||||||
|
label: 'Department Name',
|
||||||
|
fieldtype: 'Data',
|
||||||
|
required: true,
|
||||||
|
placeholder: 'Enter technical department name',
|
||||||
|
}
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper function to get configuration for a doctype
|
||||||
|
*/
|
||||||
|
export const getQuickCreateConfig = (doctype: string): QuickCreateDoctypeConfig | null => {
|
||||||
|
return QUICK_CREATE_CONFIG[doctype] || null;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a doctype supports quick creation
|
||||||
|
*/
|
||||||
|
export const supportsQuickCreate = (doctype: string): boolean => {
|
||||||
|
return doctype in QUICK_CREATE_CONFIG;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add or update a doctype configuration at runtime
|
||||||
|
*/
|
||||||
|
export const registerQuickCreateConfig = (config: QuickCreateDoctypeConfig): void => {
|
||||||
|
QUICK_CREATE_CONFIG[config.doctype] = config;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default QUICK_CREATE_CONFIG;
|
||||||
602
asm_app/src/components/QuickCreateModal.tsx
Normal file
602
asm_app/src/components/QuickCreateModal.tsx
Normal file
@ -0,0 +1,602 @@
|
|||||||
|
import React, { useState, useEffect, useCallback, useRef } from 'react';
|
||||||
|
import {
|
||||||
|
FaTimes,
|
||||||
|
FaPlus,
|
||||||
|
FaSpinner,
|
||||||
|
FaCheckCircle,
|
||||||
|
FaTimesCircle,
|
||||||
|
FaExclamationTriangle,
|
||||||
|
FaSearch
|
||||||
|
} from 'react-icons/fa';
|
||||||
|
import { toast } from 'react-toastify';
|
||||||
|
import {
|
||||||
|
type QuickCreateDoctypeConfig,
|
||||||
|
type QuickCreateFieldConfig,
|
||||||
|
getQuickCreateConfig
|
||||||
|
} from './QuickCreateConfig';
|
||||||
|
import apiService from '../services/apiService';
|
||||||
|
|
||||||
|
// Simple Link Input component for use inside modal (avoids circular dependency)
|
||||||
|
interface SimpleLinkInputProps {
|
||||||
|
doctype: string;
|
||||||
|
value: string;
|
||||||
|
onChange: (value: string) => void;
|
||||||
|
disabled?: boolean;
|
||||||
|
placeholder?: string;
|
||||||
|
filters?: Record<string, any>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SimpleLinkInput: React.FC<SimpleLinkInputProps> = ({
|
||||||
|
doctype,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
disabled = false,
|
||||||
|
placeholder = 'Search...',
|
||||||
|
filters = {},
|
||||||
|
}) => {
|
||||||
|
const [searchText, setSearchText] = useState('');
|
||||||
|
const [results, setResults] = useState<{ value: string; description?: string }[]>([]);
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
|
||||||
|
// Search function
|
||||||
|
const searchLink = useCallback(async (text: string = '') => {
|
||||||
|
if (!doctype) return;
|
||||||
|
setIsLoading(true);
|
||||||
|
try {
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
doctype,
|
||||||
|
txt: text,
|
||||||
|
page_length: '20',
|
||||||
|
});
|
||||||
|
if (filters && Object.keys(filters).length > 0) {
|
||||||
|
params.append('filters', JSON.stringify(filters));
|
||||||
|
}
|
||||||
|
const response = await apiService.apiCall<{ value: string; description?: string }[]>(
|
||||||
|
`/api/method/frappe.desk.search.search_link?${params.toString()}`
|
||||||
|
);
|
||||||
|
setResults(response || []);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Error fetching ${doctype} links:`, error);
|
||||||
|
setResults([]);
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
}, [doctype, filters]);
|
||||||
|
|
||||||
|
// Debounced search
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isOpen) return;
|
||||||
|
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||||
|
debounceRef.current = setTimeout(() => searchLink(searchText), 300);
|
||||||
|
return () => { if (debounceRef.current) clearTimeout(debounceRef.current); };
|
||||||
|
}, [searchText, isOpen, searchLink]);
|
||||||
|
|
||||||
|
// Load on open
|
||||||
|
useEffect(() => {
|
||||||
|
if (isOpen) searchLink(searchText);
|
||||||
|
}, [isOpen]);
|
||||||
|
|
||||||
|
// Close on outside click
|
||||||
|
useEffect(() => {
|
||||||
|
const handleClickOutside = (e: MouseEvent) => {
|
||||||
|
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
|
||||||
|
setIsOpen(false);
|
||||||
|
setSearchText('');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
document.addEventListener('mousedown', handleClickOutside);
|
||||||
|
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleSelect = (val: string) => {
|
||||||
|
onChange(val);
|
||||||
|
setSearchText('');
|
||||||
|
setIsOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClear = () => {
|
||||||
|
onChange('');
|
||||||
|
setSearchText('');
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={containerRef} className="relative">
|
||||||
|
<div className="relative">
|
||||||
|
<FaSearch className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400" size={12} />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={isOpen ? searchText : value}
|
||||||
|
placeholder={placeholder}
|
||||||
|
disabled={disabled}
|
||||||
|
className={`w-full pl-9 pr-8 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-md
|
||||||
|
focus:outline-none focus:ring-2 focus:ring-blue-500
|
||||||
|
disabled:bg-gray-100 dark:disabled:bg-gray-700
|
||||||
|
bg-white dark:bg-gray-700 text-gray-900 dark:text-white`}
|
||||||
|
onFocus={() => !disabled && setIsOpen(true)}
|
||||||
|
onChange={(e) => {
|
||||||
|
setSearchText(e.target.value);
|
||||||
|
setIsOpen(true);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{value && !disabled && !isOpen && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleClear}
|
||||||
|
className="absolute right-2 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600"
|
||||||
|
>
|
||||||
|
<FaTimes size={12} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isOpen && !disabled && (
|
||||||
|
<div className="absolute z-50 w-full mt-1 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-md shadow-lg max-h-40 overflow-auto">
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="p-3 text-center text-gray-500 dark:text-gray-400 text-sm">
|
||||||
|
<FaSpinner className="animate-spin inline mr-2" size={12} />
|
||||||
|
Loading...
|
||||||
|
</div>
|
||||||
|
) : results.length > 0 ? (
|
||||||
|
<ul>
|
||||||
|
{results.map((item, idx) => (
|
||||||
|
<li
|
||||||
|
key={idx}
|
||||||
|
onClick={() => handleSelect(item.value)}
|
||||||
|
className={`px-3 py-2 cursor-pointer text-sm hover:bg-blue-500 hover:text-white
|
||||||
|
${value === item.value ? 'bg-blue-50 dark:bg-blue-900/30' : ''}`}
|
||||||
|
>
|
||||||
|
{item.value}
|
||||||
|
{item.description && (
|
||||||
|
<span className="text-xs text-gray-500 ml-2">{item.description}</span>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
) : (
|
||||||
|
<div className="p-3 text-center text-gray-500 dark:text-gray-400 text-sm">
|
||||||
|
No results found
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
interface QuickCreateModalProps {
|
||||||
|
doctype: string;
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onSuccess: (newRecord: any) => void;
|
||||||
|
initialValues?: Record<string, any>;
|
||||||
|
parentFilters?: Record<string, any>; // Filters to pass down to link fields
|
||||||
|
customConfig?: QuickCreateDoctypeConfig; // Override default config
|
||||||
|
}
|
||||||
|
|
||||||
|
const QuickCreateModal: React.FC<QuickCreateModalProps> = ({
|
||||||
|
doctype,
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
onSuccess,
|
||||||
|
initialValues = {},
|
||||||
|
parentFilters = {},
|
||||||
|
customConfig,
|
||||||
|
}) => {
|
||||||
|
const [formData, setFormData] = useState<Record<string, any>>({});
|
||||||
|
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||||
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
const [config, setConfig] = useState<QuickCreateDoctypeConfig | null>(null);
|
||||||
|
|
||||||
|
// Get configuration for the doctype
|
||||||
|
useEffect(() => {
|
||||||
|
const doctypeConfig = customConfig || getQuickCreateConfig(doctype);
|
||||||
|
setConfig(doctypeConfig);
|
||||||
|
|
||||||
|
if (doctypeConfig) {
|
||||||
|
// Initialize form data with default values
|
||||||
|
const defaultData: Record<string, any> = {};
|
||||||
|
doctypeConfig.fields.forEach((field) => {
|
||||||
|
if (field.defaultValue !== undefined) {
|
||||||
|
defaultData[field.fieldname] = field.defaultValue;
|
||||||
|
} else if (field.fieldtype === 'Check') {
|
||||||
|
defaultData[field.fieldname] = 0;
|
||||||
|
} else {
|
||||||
|
defaultData[field.fieldname] = '';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Merge with initial values
|
||||||
|
setFormData({ ...defaultData, ...initialValues });
|
||||||
|
}
|
||||||
|
}, [doctype, customConfig, initialValues]);
|
||||||
|
|
||||||
|
// Reset form when modal opens
|
||||||
|
useEffect(() => {
|
||||||
|
if (isOpen && config) {
|
||||||
|
const defaultData: Record<string, any> = {};
|
||||||
|
config.fields.forEach((field) => {
|
||||||
|
if (field.defaultValue !== undefined) {
|
||||||
|
defaultData[field.fieldname] = field.defaultValue;
|
||||||
|
} else if (field.fieldtype === 'Check') {
|
||||||
|
defaultData[field.fieldname] = 0;
|
||||||
|
} else {
|
||||||
|
defaultData[field.fieldname] = '';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
setFormData({ ...defaultData, ...initialValues });
|
||||||
|
setErrors({});
|
||||||
|
}
|
||||||
|
}, [isOpen, config, initialValues]);
|
||||||
|
|
||||||
|
// Handle field change
|
||||||
|
const handleFieldChange = useCallback((fieldname: string, value: any) => {
|
||||||
|
setFormData((prev) => ({ ...prev, [fieldname]: value }));
|
||||||
|
// Clear error for this field
|
||||||
|
setErrors((prev) => {
|
||||||
|
const newErrors = { ...prev };
|
||||||
|
delete newErrors[fieldname];
|
||||||
|
return newErrors;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Validate form
|
||||||
|
const validateForm = useCallback((): boolean => {
|
||||||
|
if (!config) return false;
|
||||||
|
|
||||||
|
const newErrors: Record<string, string> = {};
|
||||||
|
|
||||||
|
config.fields.forEach((field) => {
|
||||||
|
if (field.required && !field.hidden) {
|
||||||
|
const value = formData[field.fieldname];
|
||||||
|
if (value === undefined || value === null || value === '') {
|
||||||
|
newErrors[field.fieldname] = `${field.label} is required`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Run custom validation if provided
|
||||||
|
if (config.validateBeforeCreate) {
|
||||||
|
const customError = config.validateBeforeCreate(formData);
|
||||||
|
if (customError) {
|
||||||
|
toast.error(customError, {
|
||||||
|
position: 'top-right',
|
||||||
|
autoClose: 4000,
|
||||||
|
icon: <FaExclamationTriangle />,
|
||||||
|
});
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setErrors(newErrors);
|
||||||
|
return Object.keys(newErrors).length === 0;
|
||||||
|
}, [config, formData]);
|
||||||
|
|
||||||
|
// Handle form submission
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
if (!validateForm() || !config) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsSubmitting(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Prepare data for API - only include non-empty fields
|
||||||
|
const dataToSubmit: Record<string, any> = {};
|
||||||
|
Object.entries(formData).forEach(([key, value]) => {
|
||||||
|
if (value !== '' && value !== null && value !== undefined) {
|
||||||
|
dataToSubmit[key] = value;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Make API call to create the record
|
||||||
|
const response = await apiService.apiCall<any>(
|
||||||
|
`/api/resource/${config.doctype}`,
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(dataToSubmit),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (response?.data) {
|
||||||
|
const newRecord = response.data;
|
||||||
|
|
||||||
|
toast.success(`${config.title.replace('Create New ', '')} created successfully!`, {
|
||||||
|
position: 'top-right',
|
||||||
|
autoClose: 3000,
|
||||||
|
icon: <FaCheckCircle />,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Call afterCreate callback if provided
|
||||||
|
if (config.afterCreate) {
|
||||||
|
config.afterCreate(newRecord);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Call onSuccess callback with the new record
|
||||||
|
onSuccess(newRecord);
|
||||||
|
onClose();
|
||||||
|
} else {
|
||||||
|
throw new Error('Failed to create record');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error creating record:', err);
|
||||||
|
const errorMessage = err instanceof Error ? err.message : 'Unknown error occurred';
|
||||||
|
|
||||||
|
// Check for duplicate entry error
|
||||||
|
if (errorMessage.includes('Duplicate') || errorMessage.includes('already exists')) {
|
||||||
|
toast.error(`A record with this name already exists. Please use a different name.`, {
|
||||||
|
position: 'top-right',
|
||||||
|
autoClose: 5000,
|
||||||
|
icon: <FaTimesCircle />,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
toast.error(`Failed to create: ${errorMessage}`, {
|
||||||
|
position: 'top-right',
|
||||||
|
autoClose: 5000,
|
||||||
|
icon: <FaTimesCircle />,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setIsSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Render a single field based on its type
|
||||||
|
const renderField = (field: QuickCreateFieldConfig) => {
|
||||||
|
if (field.hidden) return null;
|
||||||
|
|
||||||
|
const value = formData[field.fieldname];
|
||||||
|
const error = errors[field.fieldname];
|
||||||
|
const isDisabled = field.readOnly || isSubmitting;
|
||||||
|
|
||||||
|
// Check if field should be shown based on depends_on
|
||||||
|
if (field.dependsOn) {
|
||||||
|
const dependsOnValue = formData[field.dependsOn];
|
||||||
|
if (!dependsOnValue) return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const baseInputClass = `w-full px-3 py-2 text-sm border rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500
|
||||||
|
${error ? 'border-red-500' : 'border-gray-300 dark:border-gray-600'}
|
||||||
|
${isDisabled ? 'bg-gray-100 dark:bg-gray-700 cursor-not-allowed' : 'bg-white dark:bg-gray-700'}
|
||||||
|
text-gray-900 dark:text-white`;
|
||||||
|
|
||||||
|
switch (field.fieldtype) {
|
||||||
|
case 'Data':
|
||||||
|
return (
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={value || ''}
|
||||||
|
onChange={(e) => handleFieldChange(field.fieldname, e.target.value)}
|
||||||
|
placeholder={field.placeholder}
|
||||||
|
disabled={isDisabled}
|
||||||
|
className={baseInputClass}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
case 'Text':
|
||||||
|
return (
|
||||||
|
<textarea
|
||||||
|
value={value || ''}
|
||||||
|
onChange={(e) => handleFieldChange(field.fieldname, e.target.value)}
|
||||||
|
placeholder={field.placeholder}
|
||||||
|
disabled={isDisabled}
|
||||||
|
rows={3}
|
||||||
|
className={`${baseInputClass} resize-none`}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
case 'Select':
|
||||||
|
return (
|
||||||
|
<select
|
||||||
|
value={value || ''}
|
||||||
|
onChange={(e) => handleFieldChange(field.fieldname, e.target.value)}
|
||||||
|
disabled={isDisabled}
|
||||||
|
className={baseInputClass}
|
||||||
|
>
|
||||||
|
<option value="">Select {field.label}</option>
|
||||||
|
{(field.options || []).map((option) => {
|
||||||
|
const optionValue = typeof option === 'string' ? option : option.value;
|
||||||
|
const optionLabel = typeof option === 'string' ? option : option.label;
|
||||||
|
return (
|
||||||
|
<option key={optionValue} value={optionValue}>
|
||||||
|
{optionLabel}
|
||||||
|
</option>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</select>
|
||||||
|
);
|
||||||
|
|
||||||
|
case 'Link':
|
||||||
|
return (
|
||||||
|
<SimpleLinkInput
|
||||||
|
doctype={field.linkDoctype || ''}
|
||||||
|
value={value || ''}
|
||||||
|
onChange={(val) => handleFieldChange(field.fieldname, val)}
|
||||||
|
disabled={isDisabled}
|
||||||
|
placeholder={field.placeholder}
|
||||||
|
// filters={{ ...field.linkFilters, ...parentFilters }}
|
||||||
|
filters={field.linkFilters || {}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
case 'Check':
|
||||||
|
return (
|
||||||
|
<label className="flex items-center gap-2 cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={value === 1 || value === true}
|
||||||
|
onChange={(e) => handleFieldChange(field.fieldname, 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"
|
||||||
|
/>
|
||||||
|
<span className="text-sm text-gray-700 dark:text-gray-300">
|
||||||
|
{field.description || field.label}
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
|
||||||
|
case 'Int':
|
||||||
|
return (
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={value || ''}
|
||||||
|
onChange={(e) => handleFieldChange(field.fieldname, parseInt(e.target.value) || '')}
|
||||||
|
placeholder={field.placeholder}
|
||||||
|
disabled={isDisabled}
|
||||||
|
step="1"
|
||||||
|
className={baseInputClass}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
case 'Float':
|
||||||
|
return (
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={value || ''}
|
||||||
|
onChange={(e) => handleFieldChange(field.fieldname, parseFloat(e.target.value) || '')}
|
||||||
|
placeholder={field.placeholder}
|
||||||
|
disabled={isDisabled}
|
||||||
|
step="0.01"
|
||||||
|
className={baseInputClass}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
case 'Date':
|
||||||
|
return (
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={value || ''}
|
||||||
|
onChange={(e) => handleFieldChange(field.fieldname, e.target.value)}
|
||||||
|
disabled={isDisabled}
|
||||||
|
className={baseInputClass}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
case 'Datetime':
|
||||||
|
return (
|
||||||
|
<input
|
||||||
|
type="datetime-local"
|
||||||
|
value={value || ''}
|
||||||
|
onChange={(e) => handleFieldChange(field.fieldname, e.target.value)}
|
||||||
|
disabled={isDisabled}
|
||||||
|
className={baseInputClass}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
default:
|
||||||
|
return (
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={value || ''}
|
||||||
|
onChange={(e) => handleFieldChange(field.fieldname, e.target.value)}
|
||||||
|
placeholder={field.placeholder}
|
||||||
|
disabled={isDisabled}
|
||||||
|
className={baseInputClass}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!isOpen || !config) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-[9999]">
|
||||||
|
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-xl w-full max-w-lg mx-4 max-h-[90vh] flex flex-col">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between p-4 border-b border-gray-200 dark:border-gray-700">
|
||||||
|
<h3 className="text-lg font-semibold text-gray-800 dark:text-white flex items-center gap-2">
|
||||||
|
<FaPlus className="text-blue-500" size={16} />
|
||||||
|
{config.title}
|
||||||
|
</h3>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
disabled={isSubmitting}
|
||||||
|
className="p-1 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 rounded transition-colors disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<FaTimes size={18} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Form */}
|
||||||
|
<form onSubmit={handleSubmit} className="flex-1 overflow-y-auto p-4">
|
||||||
|
<div className="space-y-4">
|
||||||
|
{config.fields.map((field) => {
|
||||||
|
if (field.hidden) return null;
|
||||||
|
|
||||||
|
// Check depends_on
|
||||||
|
if (field.dependsOn) {
|
||||||
|
const dependsOnValue = formData[field.dependsOn];
|
||||||
|
if (!dependsOnValue) return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={field.fieldname}>
|
||||||
|
{field.fieldtype !== 'Check' && (
|
||||||
|
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||||
|
{field.label}
|
||||||
|
{field.required && <span className="text-red-500 ml-1">*</span>}
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{renderField(field)}
|
||||||
|
|
||||||
|
{field.description && field.fieldtype !== 'Check' && (
|
||||||
|
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
{field.description}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{errors[field.fieldname] && (
|
||||||
|
<p className="mt-1 text-xs text-red-500 flex items-center gap-1">
|
||||||
|
<FaExclamationTriangle size={10} />
|
||||||
|
{errors[field.fieldname]}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<div className="flex justify-end gap-3 p-4 border-t border-gray-200 dark:border-gray-700">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
disabled={isSubmitting}
|
||||||
|
className="px-4 py-2 text-sm font-medium text-gray-700 bg-gray-200 hover:bg-gray-300 rounded-lg transition-colors disabled:opacity-50"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
onClick={handleSubmit}
|
||||||
|
disabled={isSubmitting}
|
||||||
|
className="px-4 py-2 text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 rounded-lg transition-colors disabled:opacity-50 flex items-center gap-2"
|
||||||
|
>
|
||||||
|
{isSubmitting ? (
|
||||||
|
<>
|
||||||
|
<FaSpinner className="animate-spin" size={14} />
|
||||||
|
Creating...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<FaPlus size={14} />
|
||||||
|
Create
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default QuickCreateModal;
|
||||||
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;
|
||||||
|
|
||||||
642
asm_app/src/components/Sidebar.tsx
Normal file
642
asm_app/src/components/Sidebar.tsx
Normal file
@ -0,0 +1,642 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { Link, useLocation, useNavigate } from 'react-router-dom';
|
||||||
|
import { useLanguage } from '../contexts/LanguageContext';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import {
|
||||||
|
LayoutDashboard,
|
||||||
|
Package,
|
||||||
|
Box,
|
||||||
|
Menu,
|
||||||
|
X,
|
||||||
|
ClipboardList,
|
||||||
|
Calendar,
|
||||||
|
CalendarCheck,
|
||||||
|
Map,
|
||||||
|
Users,
|
||||||
|
ShoppingCart,
|
||||||
|
FileText,
|
||||||
|
HelpCircle,
|
||||||
|
UserCircle,
|
||||||
|
Trash2
|
||||||
|
|
||||||
|
} from 'lucide-react';
|
||||||
|
|
||||||
|
import { FaClipboardCheck } from 'react-icons/fa';
|
||||||
|
|
||||||
|
interface SidebarLink {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
icon: React.ReactNode;
|
||||||
|
path: string;
|
||||||
|
visible: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SidebarProps {
|
||||||
|
userEmail?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ✅ Role definitions
|
||||||
|
const ADMIN_ROLES = [
|
||||||
|
'System Manager',
|
||||||
|
'Contractor Supervisor',
|
||||||
|
'Contractor Manager',
|
||||||
|
'Work Control',
|
||||||
|
'Contractor Engineer'
|
||||||
|
];
|
||||||
|
|
||||||
|
const TECHNICIAN_ROLE = 'Technician';
|
||||||
|
const END_USER_ROLE = 'End User';
|
||||||
|
|
||||||
|
const Sidebar: React.FC<SidebarProps> = ({ userEmail }) => {
|
||||||
|
const [isCollapsed, setIsCollapsed] = useState(false);
|
||||||
|
const location = useLocation();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const { isRTL } = useLanguage();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
// ✅ Role-based state
|
||||||
|
const [userRoles, setUserRoles] = useState<{
|
||||||
|
isAdmin: boolean;
|
||||||
|
isTechnician: boolean;
|
||||||
|
isEndUser: boolean;
|
||||||
|
isLoading: boolean;
|
||||||
|
}>({
|
||||||
|
isAdmin: false,
|
||||||
|
isTechnician: false,
|
||||||
|
isEndUser: false,
|
||||||
|
isLoading: true
|
||||||
|
});
|
||||||
|
|
||||||
|
const [userFullName, setUserFullName] = useState<string>('');
|
||||||
|
|
||||||
|
// 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=1768316563`; // Auto-updated by build script
|
||||||
|
const logoVersion = import.meta.env.DEV
|
||||||
|
? `?v=${Date.now()}`
|
||||||
|
: `?v=1768316563`; // Auto-updated by build script
|
||||||
|
const backgroundImageUrl = baseUrl.endsWith('/')
|
||||||
|
? `${baseUrl}sidebar-background.jpg${imageVersion}`
|
||||||
|
: `${baseUrl}/sidebar-background.jpg${imageVersion}`;
|
||||||
|
|
||||||
|
// ✅ Fetch user roles on mount
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchUserRoles = async () => {
|
||||||
|
try {
|
||||||
|
// Check for admin roles
|
||||||
|
const adminResponse = await fetch('/api/method/asset_lite.api.user_roles.check_has_role', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
roles: ADMIN_ROLES.join(',')
|
||||||
|
})
|
||||||
|
});
|
||||||
|
const adminData = await adminResponse.json();
|
||||||
|
const isAdmin = adminData.message?.has_role || false;
|
||||||
|
|
||||||
|
// Check for technician role
|
||||||
|
const techResponse = await fetch('/api/method/asset_lite.api.user_roles.check_has_role', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
roles: TECHNICIAN_ROLE
|
||||||
|
})
|
||||||
|
});
|
||||||
|
const techData = await techResponse.json();
|
||||||
|
const isTechnician = techData.message?.has_role || false;
|
||||||
|
|
||||||
|
// Check for end user role
|
||||||
|
const endUserResponse = await fetch('/api/method/asset_lite.api.user_roles.check_has_role', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
roles: END_USER_ROLE
|
||||||
|
})
|
||||||
|
});
|
||||||
|
const endUserData = await endUserResponse.json();
|
||||||
|
const isEndUser = endUserData.message?.has_role || false;
|
||||||
|
|
||||||
|
setUserRoles({
|
||||||
|
isAdmin,
|
||||||
|
isTechnician,
|
||||||
|
isEndUser,
|
||||||
|
isLoading: false
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('User roles:', { isAdmin, isTechnician, isEndUser });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching user roles:', error);
|
||||||
|
// Default to showing minimal items on error
|
||||||
|
setUserRoles({
|
||||||
|
isAdmin: false,
|
||||||
|
isTechnician: false,
|
||||||
|
isEndUser: true, // Default to end user (minimal access)
|
||||||
|
isLoading: false
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchUserRoles();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// ✅ Fetch user full name on mount
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchUserFullName = async () => {
|
||||||
|
try {
|
||||||
|
// First get the logged-in user
|
||||||
|
const userResponse = await fetch('/api/method/frappe.auth.get_logged_user', {
|
||||||
|
method: 'GET',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
credentials: 'include'
|
||||||
|
});
|
||||||
|
const userData = await userResponse.json();
|
||||||
|
const userEmail = userData.message;
|
||||||
|
|
||||||
|
if (userEmail) {
|
||||||
|
// Then fetch the user's full name
|
||||||
|
const fullNameResponse = await fetch(`/api/resource/User/${encodeURIComponent(userEmail)}?fields=["full_name"]`, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
credentials: 'include'
|
||||||
|
});
|
||||||
|
const fullNameData = await fullNameResponse.json();
|
||||||
|
|
||||||
|
if (fullNameData.data?.full_name) {
|
||||||
|
setUserFullName(fullNameData.data.full_name);
|
||||||
|
} else {
|
||||||
|
// Fallback to email if full name not found
|
||||||
|
setUserFullName(userEmail);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching user full name:', error);
|
||||||
|
// Fallback to email prop if API fails
|
||||||
|
if (userEmail) {
|
||||||
|
setUserFullName(userEmail);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchUserFullName();
|
||||||
|
}, [userEmail]);
|
||||||
|
|
||||||
|
// ✅ Visibility logic based on roles
|
||||||
|
// Admin: sees everything
|
||||||
|
// Technician: Work Order, Inspection, Procurement, Support, Active Map
|
||||||
|
// End User: Work Order, Support
|
||||||
|
// If user has multiple roles, they see the union of all permissions
|
||||||
|
|
||||||
|
const getVisibility = (linkId: string): boolean => {
|
||||||
|
const { isAdmin, isTechnician, isEndUser, isLoading } = userRoles;
|
||||||
|
|
||||||
|
// While loading, show nothing or minimal
|
||||||
|
if (isLoading) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Admin sees everything
|
||||||
|
if (isAdmin) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Define what each role can see
|
||||||
|
const endUserLinks = ['work-orders', 'support', 'assets', 'inventory'];
|
||||||
|
const technicianLinks = ['work-orders', 'inspections', 'procurement', 'support', 'active-map', 'assets', 'inventory'];
|
||||||
|
|
||||||
|
// Check visibility based on roles (union of permissions)
|
||||||
|
let canSee = false;
|
||||||
|
|
||||||
|
if (isEndUser && endUserLinks.includes(linkId)) {
|
||||||
|
canSee = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isTechnician && technicianLinks.includes(linkId)) {
|
||||||
|
canSee = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If user has no recognized role, show minimal access (end user)
|
||||||
|
if (!isAdmin && !isTechnician && !isEndUser) {
|
||||||
|
canSee = endUserLinks.includes(linkId);
|
||||||
|
}
|
||||||
|
|
||||||
|
return canSee;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Role-based visibility logic (keeping old code commented)
|
||||||
|
// 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: userRoles.isAdmin // Only admin sees dashboard
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'assets',
|
||||||
|
title: t('common.assets'),
|
||||||
|
icon: <Package size={20} />,
|
||||||
|
path: '/assets',
|
||||||
|
visible: getVisibility('assets')
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'inventory',
|
||||||
|
title: t('sidebar.inventory'),
|
||||||
|
icon: <Box size={20} />,
|
||||||
|
path: '/inventory',
|
||||||
|
visible: getVisibility('inventory')
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'work-orders',
|
||||||
|
title: t('common.workOrders'),
|
||||||
|
icon: <ClipboardList size={20} />,
|
||||||
|
path: '/work-orders',
|
||||||
|
visible: getVisibility('work-orders')
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'inspections',
|
||||||
|
title: t('sidebar.inspection'),
|
||||||
|
icon: <FaClipboardCheck size={20} />,
|
||||||
|
path: '/inspections',
|
||||||
|
visible: getVisibility('inspections')
|
||||||
|
},
|
||||||
|
// {
|
||||||
|
// 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: t('sidebar.ppmPlanner'),
|
||||||
|
icon: <CalendarCheck size={20} />,
|
||||||
|
path: '/ppm-planner',
|
||||||
|
visible: userRoles.isAdmin
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'maintenance-calendar',
|
||||||
|
title: t('sidebar.maintenanceCalendar'),
|
||||||
|
icon: <Calendar size={20} />,
|
||||||
|
path: '/maintenance-calendar',
|
||||||
|
visible: userRoles.isAdmin
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'active-map',
|
||||||
|
title: t('sidebar.activeMap'),
|
||||||
|
icon: <Map size={20} />,
|
||||||
|
path: '/active-map',
|
||||||
|
visible: getVisibility('active-map')
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'maintenance-teams',
|
||||||
|
title: t('sidebar.maintenanceTeam'),
|
||||||
|
icon: <Users size={20} />,
|
||||||
|
path: '/maintenance-teams',
|
||||||
|
visible: userRoles.isAdmin // Only admin
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'procurement',
|
||||||
|
title: t('sidebar.procurement'),
|
||||||
|
icon: <ShoppingCart size={20} />,
|
||||||
|
path: '/procurement',
|
||||||
|
visible: getVisibility('procurement')
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'sla',
|
||||||
|
title: t('sidebar.sla'),
|
||||||
|
icon: <FileText size={20} />,
|
||||||
|
path: '/sla',
|
||||||
|
visible: userRoles.isAdmin // Only admin
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'support',
|
||||||
|
title: t('sidebar.support'),
|
||||||
|
icon: <HelpCircle size={20} />,
|
||||||
|
path: '/support',
|
||||||
|
visible: getVisibility('support')
|
||||||
|
},
|
||||||
|
|
||||||
|
// {
|
||||||
|
// id: 'delete-requests',
|
||||||
|
// title: t('sidebar.deleteRequests'),
|
||||||
|
// icon: <Trash2 size={20} />,
|
||||||
|
// path: '/delete-requests',
|
||||||
|
// visible: userRoles.isAdmin // Only admin sees delete requests
|
||||||
|
// },
|
||||||
|
|
||||||
|
// {
|
||||||
|
// 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;
|
||||||
|
};
|
||||||
|
|
||||||
|
// ✅ Handle User Profile click
|
||||||
|
const handleUserProfileClick = () => {
|
||||||
|
navigate('/user-profile');
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
// ✅ Show loading state while fetching roles
|
||||||
|
if (userRoles.isLoading) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`
|
||||||
|
relative
|
||||||
|
h-screen
|
||||||
|
w-64
|
||||||
|
flex
|
||||||
|
flex-col
|
||||||
|
items-center
|
||||||
|
justify-center
|
||||||
|
shadow-xl
|
||||||
|
border-r border-gray-200 dark:border-gray-700
|
||||||
|
`}
|
||||||
|
style={{
|
||||||
|
backgroundImage: `url(${backgroundImageUrl})`,
|
||||||
|
backgroundSize: 'cover',
|
||||||
|
backgroundPosition: 'center',
|
||||||
|
backgroundRepeat: 'no-repeat'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="absolute inset-0 bg-black/60 dark:bg-black/70 z-0"></div>
|
||||||
|
<div className="relative z-10 text-white">
|
||||||
|
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-white mx-auto"></div>
|
||||||
|
<p className="mt-2 text-sm">{t('common.loading')}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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> */}
|
||||||
|
{!isCollapsed && (userFullName || 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">
|
||||||
|
{userFullName || userEmail}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ✅ User Profile Button */}
|
||||||
|
<button
|
||||||
|
onClick={handleUserProfileClick}
|
||||||
|
className={`
|
||||||
|
mt-3 w-full flex items-center justify-center gap-2
|
||||||
|
px-3 py-2
|
||||||
|
bg-white/20 hover:bg-white/30
|
||||||
|
text-white
|
||||||
|
rounded-lg
|
||||||
|
transition-all duration-200
|
||||||
|
text-sm font-medium
|
||||||
|
${isActive('/user-profile') ? 'bg-white/40 border border-white/50' : ''}
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
<UserCircle size={18} />
|
||||||
|
<span>{t('sidebar.userProfile')}</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Collapsed state - just show icon button */}
|
||||||
|
{isCollapsed && (
|
||||||
|
<button
|
||||||
|
onClick={handleUserProfileClick}
|
||||||
|
className={`
|
||||||
|
w-full flex items-center justify-center
|
||||||
|
p-2
|
||||||
|
bg-white/20 hover:bg-white/30
|
||||||
|
text-white
|
||||||
|
rounded-lg
|
||||||
|
transition-all duration-200
|
||||||
|
${isActive('/user-profile') ? 'bg-white/40 border border-white/50' : ''}
|
||||||
|
`}
|
||||||
|
title={t('sidebar.userProfile')}
|
||||||
|
>
|
||||||
|
<UserCircle size={20} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
{!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;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
829
asm_app/src/components/TechnicianWorkOrderSummaryReportModal.tsx
Normal file
829
asm_app/src/components/TechnicianWorkOrderSummaryReportModal.tsx
Normal file
@ -0,0 +1,829 @@
|
|||||||
|
import React, { useState, useEffect, useCallback } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import * as XLSX from 'xlsx';
|
||||||
|
import {
|
||||||
|
FaTimes,
|
||||||
|
FaFileExcel,
|
||||||
|
FaFileCsv,
|
||||||
|
FaFilePdf,
|
||||||
|
FaPrint,
|
||||||
|
FaSpinner,
|
||||||
|
FaFilter,
|
||||||
|
FaChevronDown,
|
||||||
|
FaChevronUp,
|
||||||
|
FaSearch,
|
||||||
|
FaSync,
|
||||||
|
FaExternalLinkAlt,
|
||||||
|
FaHardHat,
|
||||||
|
FaUsers,
|
||||||
|
FaCheckCircle,
|
||||||
|
FaClock,
|
||||||
|
FaFolderOpen,
|
||||||
|
FaArrowLeft
|
||||||
|
} from 'react-icons/fa';
|
||||||
|
|
||||||
|
interface ReportColumn {
|
||||||
|
label: string;
|
||||||
|
fieldname: string;
|
||||||
|
fieldtype: string;
|
||||||
|
options?: string;
|
||||||
|
width?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── CHANGE 1: Added permittedIssueTypes and isAdmin to props interface ──
|
||||||
|
interface TechnicianWorkOrderSummaryReportModalProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
permittedIssueTypes?: string[];
|
||||||
|
isAdmin?: boolean;
|
||||||
|
defaultWorkOrderType?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── CHANGE 2: Destructure new props with safe defaults ──
|
||||||
|
const TechnicianWorkOrderSummaryReportModal: React.FC<TechnicianWorkOrderSummaryReportModalProps> = ({
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
permittedIssueTypes = [],
|
||||||
|
isAdmin = true,
|
||||||
|
defaultWorkOrderType = ''
|
||||||
|
}) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
// State
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [reportData, setReportData] = useState<any[]>([]);
|
||||||
|
const [columns, setColumns] = useState<ReportColumn[]>([]);
|
||||||
|
const [filtersExpanded, setFiltersExpanded] = useState(false);
|
||||||
|
|
||||||
|
// Export states
|
||||||
|
const [isExporting, setIsExporting] = useState(false);
|
||||||
|
|
||||||
|
// All issue types for admin dropdown
|
||||||
|
const [allIssueTypes, setAllIssueTypes] = useState<string[]>([]);
|
||||||
|
|
||||||
|
// Fetch all Issue Types for admin dropdown
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isAdmin) return;
|
||||||
|
const fetchIssueTypes = async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetch(
|
||||||
|
'/api/resource/Issue Type?fields=["name"]&limit_page_length=0&order_by=name asc',
|
||||||
|
{ headers: { Accept: 'application/json' }, credentials: 'include' }
|
||||||
|
);
|
||||||
|
const data = await response.json();
|
||||||
|
if (data.data) {
|
||||||
|
setAllIssueTypes(data.data.map((d: any) => d.name));
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching issue types:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
fetchIssueTypes();
|
||||||
|
}, [isAdmin]);
|
||||||
|
|
||||||
|
// ── CHANGE 3: Added filterWorkOrderType state ──
|
||||||
|
const [filterWorkOrderType, setFilterWorkOrderType] = useState('');
|
||||||
|
|
||||||
|
// ── CHANGE 4: Auto-apply permitted Issue Type or dashboard global filter ──
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isAdmin && permittedIssueTypes.length === 1) {
|
||||||
|
// Single permitted type — lock it in automatically
|
||||||
|
setFilterWorkOrderType(permittedIssueTypes[0]);
|
||||||
|
} else if (defaultWorkOrderType) {
|
||||||
|
setFilterWorkOrderType(defaultWorkOrderType);
|
||||||
|
} else {
|
||||||
|
setFilterWorkOrderType('');
|
||||||
|
}
|
||||||
|
}, [permittedIssueTypes, isAdmin, defaultWorkOrderType]);
|
||||||
|
|
||||||
|
// Report name constant
|
||||||
|
const REPORT_NAME = 'Technician Work Order Summary';
|
||||||
|
|
||||||
|
// ── CHANGE 5: fetchReportData now builds filters from permissions ──
|
||||||
|
const fetchReportData = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Build filters based on user permissions
|
||||||
|
const filters: Record<string, any> = {};
|
||||||
|
|
||||||
|
// if (filterWorkOrderType) {
|
||||||
|
// // Explicit filter selected (or auto-locked for single-permitted users)
|
||||||
|
// filters.work_order_type = filterWorkOrderType;
|
||||||
|
|
||||||
|
// } else if (!isAdmin && permittedIssueTypes.length > 0) {
|
||||||
|
// filters.work_order_type = permittedIssueTypes.length === 1
|
||||||
|
// ? permittedIssueTypes[0]
|
||||||
|
// : ['in', permittedIssueTypes];
|
||||||
|
// }
|
||||||
|
// isAdmin with no filterWorkOrderType → no filter = sees everything
|
||||||
|
|
||||||
|
if (filterWorkOrderType) {
|
||||||
|
filters.work_order_type = filterWorkOrderType;
|
||||||
|
} else if (!isAdmin && permittedIssueTypes.length === 1) {
|
||||||
|
filters.work_order_type = permittedIssueTypes[0];
|
||||||
|
}
|
||||||
|
// Multiple permitted types: no filter sent, client-side filtering below
|
||||||
|
|
||||||
|
const response = await fetch('/api/method/frappe.desk.query_report.run', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
credentials: 'include',
|
||||||
|
body: JSON.stringify({
|
||||||
|
report_name: REPORT_NAME,
|
||||||
|
filters,
|
||||||
|
ignore_prepared_report: 1
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
|
||||||
|
if (result.exc) {
|
||||||
|
throw new Error(result.exc);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.message) {
|
||||||
|
if (result.message.columns && result.message.columns.length > 0) {
|
||||||
|
setColumns(result.message.columns);
|
||||||
|
}
|
||||||
|
// if (result.message.result) {
|
||||||
|
// setReportData(result.message.result);
|
||||||
|
// } else {
|
||||||
|
// setReportData([]);
|
||||||
|
// }
|
||||||
|
if (result.message.result) {
|
||||||
|
let rows = result.message.result;
|
||||||
|
if (!isAdmin && permittedIssueTypes.length > 1 && !filterWorkOrderType) {
|
||||||
|
rows = rows.filter((r: any) =>
|
||||||
|
permittedIssueTypes.includes(r.work_order_type)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
setReportData(rows);
|
||||||
|
} else {
|
||||||
|
setReportData([]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error fetching report:', err);
|
||||||
|
setError(err instanceof Error ? err.message : 'Failed to fetch report data');
|
||||||
|
setReportData([]);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
// ── CHANGE 5 cont: Added filterWorkOrderType, isAdmin, permittedIssueTypes to deps ──
|
||||||
|
}, [filterWorkOrderType, isAdmin, permittedIssueTypes]);
|
||||||
|
|
||||||
|
// Fetch data when modal opens or filters change
|
||||||
|
useEffect(() => {
|
||||||
|
if (isOpen) {
|
||||||
|
fetchReportData();
|
||||||
|
}
|
||||||
|
}, [isOpen, fetchReportData]);
|
||||||
|
|
||||||
|
// Handle escape key to close
|
||||||
|
useEffect(() => {
|
||||||
|
const handleEscape = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'Escape' && isOpen) {
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
document.addEventListener('keydown', handleEscape);
|
||||||
|
return () => document.removeEventListener('keydown', handleEscape);
|
||||||
|
}, [isOpen, onClose]);
|
||||||
|
|
||||||
|
// Prevent body scroll when modal is open
|
||||||
|
useEffect(() => {
|
||||||
|
if (isOpen) {
|
||||||
|
document.body.style.overflow = 'hidden';
|
||||||
|
} else {
|
||||||
|
document.body.style.overflow = 'unset';
|
||||||
|
}
|
||||||
|
return () => {
|
||||||
|
document.body.style.overflow = 'unset';
|
||||||
|
};
|
||||||
|
}, [isOpen]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate summary statistics
|
||||||
|
*/
|
||||||
|
const getSummaryStats = () => {
|
||||||
|
const totalTechnicians = reportData.length;
|
||||||
|
const totalWorkOrders = reportData.reduce((sum, row) => sum + (parseInt(row.total) || 0), 0);
|
||||||
|
const totalCompleted = reportData.reduce((sum, row) => sum + (parseInt(row.completed) || 0), 0);
|
||||||
|
const totalInProgress = reportData.reduce((sum, row) => sum + (parseInt(row.in_progress) || 0), 0);
|
||||||
|
const totalOpen = reportData.reduce((sum, row) => sum + (parseInt(row.open) || 0), 0);
|
||||||
|
const completionRate = totalWorkOrders > 0 ? ((totalCompleted / totalWorkOrders) * 100).toFixed(1) : '0';
|
||||||
|
return { totalTechnicians, totalWorkOrders, totalCompleted, totalInProgress, totalOpen, completionRate };
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Export to CSV
|
||||||
|
*/
|
||||||
|
const handleExportCSV = () => {
|
||||||
|
if (reportData.length === 0) return;
|
||||||
|
setIsExporting(true);
|
||||||
|
try {
|
||||||
|
const headers = columns.map(col => col.label);
|
||||||
|
const csvContent = [
|
||||||
|
headers.join(','),
|
||||||
|
...reportData.map(row =>
|
||||||
|
columns.map(col => {
|
||||||
|
let value = row[col.fieldname] || '';
|
||||||
|
if (typeof value === 'string' && (value.includes(',') || value.includes('"') || value.includes('\n'))) {
|
||||||
|
value = `"${value.replace(/"/g, '""')}"`;
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}).join(',')
|
||||||
|
)
|
||||||
|
].join('\n');
|
||||||
|
const blob = new Blob(['\ufeff' + csvContent], { type: 'text/csv;charset=utf-8;' });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const link = document.createElement('a');
|
||||||
|
link.href = url;
|
||||||
|
link.download = `technician_work_order_summary_${new Date().toISOString().split('T')[0]}.csv`;
|
||||||
|
link.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
} finally {
|
||||||
|
setIsExporting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Export to Excel
|
||||||
|
*/
|
||||||
|
const handleExportExcel = () => {
|
||||||
|
if (reportData.length === 0) return;
|
||||||
|
setIsExporting(true);
|
||||||
|
try {
|
||||||
|
const headers = columns.map(col => col.label);
|
||||||
|
const worksheetData = [
|
||||||
|
headers,
|
||||||
|
...reportData.map(row => columns.map(col => row[col.fieldname] || ''))
|
||||||
|
];
|
||||||
|
const worksheet = XLSX.utils.aoa_to_sheet(worksheetData);
|
||||||
|
const colWidths = columns.map(col => ({ wch: col.width ? Math.floor(col.width / 7) : 20 }));
|
||||||
|
worksheet['!cols'] = colWidths;
|
||||||
|
const workbook = XLSX.utils.book_new();
|
||||||
|
XLSX.utils.book_append_sheet(workbook, worksheet, 'Work Order Summary');
|
||||||
|
XLSX.writeFile(workbook, `technician_work_order_summary_${new Date().toISOString().split('T')[0]}.xlsx`);
|
||||||
|
} finally {
|
||||||
|
setIsExporting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Print report
|
||||||
|
*/
|
||||||
|
const handlePrint = () => {
|
||||||
|
const stats = getSummaryStats();
|
||||||
|
const printWindow = window.open('', '_blank');
|
||||||
|
if (!printWindow) {
|
||||||
|
alert('Please allow popups for this site to print the report.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const tableHTML = `
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<title>Technician Work Order Summary Report</title>
|
||||||
|
<style>
|
||||||
|
body { font-family: Arial, sans-serif; margin: 20px; }
|
||||||
|
h1 { text-align: center; color: #333; margin-bottom: 20px; }
|
||||||
|
.meta { text-align: center; color: #666; margin-bottom: 20px; font-size: 12px; }
|
||||||
|
.summary { display: flex; justify-content: center; gap: 20px; margin-bottom: 20px; padding: 15px; background: #f8f9fa; border-radius: 8px; flex-wrap: wrap; }
|
||||||
|
.summary-item { text-align: center; min-width: 100px; }
|
||||||
|
.summary-label { font-size: 11px; color: #666; }
|
||||||
|
.summary-value { font-size: 20px; font-weight: bold; color: #333; }
|
||||||
|
.summary-value.completed { color: #10B981; }
|
||||||
|
.summary-value.in-progress { color: #3B82F6; }
|
||||||
|
.summary-value.open { color: #F59E0B; }
|
||||||
|
table { width: 100%; border-collapse: collapse; font-size: 12px; }
|
||||||
|
th { background-color: #8B5CF6; color: white; padding: 10px 8px; text-align: left; font-weight: 600; }
|
||||||
|
td { padding: 10px 8px; border-bottom: 1px solid #ddd; }
|
||||||
|
tr:nth-child(even) { background-color: #f9f9f9; }
|
||||||
|
tr:hover { background-color: #f5f5f5; }
|
||||||
|
.count-cell { font-weight: 600; text-align: center; }
|
||||||
|
.completed { color: #10B981; }
|
||||||
|
.in-progress { color: #3B82F6; }
|
||||||
|
.open { color: #F59E0B; }
|
||||||
|
@media print {
|
||||||
|
body { margin: 0; }
|
||||||
|
table { page-break-inside: auto; }
|
||||||
|
tr { page-break-inside: avoid; page-break-after: auto; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1>Technician Work Order Summary Report</h1>
|
||||||
|
<div class="meta">
|
||||||
|
Generated on: ${new Date().toLocaleString()}
|
||||||
|
${filterWorkOrderType ? ` | Department: ${filterWorkOrderType}` : ''}
|
||||||
|
</div>
|
||||||
|
<div class="summary">
|
||||||
|
<div class="summary-item">
|
||||||
|
<div class="summary-label">Total Technicians</div>
|
||||||
|
<div class="summary-value">${stats.totalTechnicians}</div>
|
||||||
|
</div>
|
||||||
|
<div class="summary-item">
|
||||||
|
<div class="summary-label">Total Work Orders</div>
|
||||||
|
<div class="summary-value">${stats.totalWorkOrders}</div>
|
||||||
|
</div>
|
||||||
|
<div class="summary-item">
|
||||||
|
<div class="summary-label">Completed</div>
|
||||||
|
<div class="summary-value completed">${stats.totalCompleted}</div>
|
||||||
|
</div>
|
||||||
|
<div class="summary-item">
|
||||||
|
<div class="summary-label">In Progress</div>
|
||||||
|
<div class="summary-value in-progress">${stats.totalInProgress}</div>
|
||||||
|
</div>
|
||||||
|
<div class="summary-item">
|
||||||
|
<div class="summary-label">Open</div>
|
||||||
|
<div class="summary-value open">${stats.totalOpen}</div>
|
||||||
|
</div>
|
||||||
|
<div class="summary-item">
|
||||||
|
<div class="summary-label">Completion Rate</div>
|
||||||
|
<div class="summary-value completed">${stats.completionRate}%</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>#</th>
|
||||||
|
${columns.map(col => `<th>${col.label}</th>`).join('')}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
${reportData.map((row, index) => `
|
||||||
|
<tr>
|
||||||
|
<td>${index + 1}</td>
|
||||||
|
${columns.map(col => {
|
||||||
|
let value = row[col.fieldname] || '-';
|
||||||
|
let className = '';
|
||||||
|
if (col.fieldname === 'completed') className = 'count-cell completed';
|
||||||
|
else if (col.fieldname === 'in_progress') className = 'count-cell in-progress';
|
||||||
|
else if (col.fieldname === 'open') className = 'count-cell open';
|
||||||
|
else if (col.fieldname === 'total') className = 'count-cell';
|
||||||
|
return `<td class="${className}">${value}</td>`;
|
||||||
|
}).join('')}
|
||||||
|
</tr>
|
||||||
|
`).join('')}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<script>
|
||||||
|
window.onload = function() { window.print(); }
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
`;
|
||||||
|
printWindow.document.write(tableHTML);
|
||||||
|
printWindow.document.close();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleExportPDF = () => {
|
||||||
|
handlePrint();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleOpenInERPNext = () => {
|
||||||
|
const baseUrl = window.location.origin;
|
||||||
|
const url = `${baseUrl}/app/query-report/${encodeURIComponent(REPORT_NAME)}`;
|
||||||
|
window.open(url, '_blank');
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatCellValue = (value: any, column: ReportColumn) => {
|
||||||
|
if (value === null || value === undefined || value === '') return '-';
|
||||||
|
switch (column.fieldtype) {
|
||||||
|
case 'Int':
|
||||||
|
return parseInt(value) || 0;
|
||||||
|
case 'Float':
|
||||||
|
return typeof value === 'number' ? value.toFixed(2) : value;
|
||||||
|
default:
|
||||||
|
return String(value);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!isOpen) return null;
|
||||||
|
|
||||||
|
const stats = getSummaryStats();
|
||||||
|
|
||||||
|
// ── CHANGE 6: Derived values for filter badge count ──
|
||||||
|
const hasActiveFilters = !!filterWorkOrderType;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-[80] bg-gray-50 dark:bg-gray-900 flex flex-col">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="bg-gradient-to-r from-purple-600 to-indigo-600 px-6 py-4 flex-shrink-0 shadow-lg">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="p-2 text-white/80 hover:text-white hover:bg-white/20 rounded-lg transition-colors"
|
||||||
|
title="Go Back"
|
||||||
|
>
|
||||||
|
<FaArrowLeft size={18} />
|
||||||
|
</button>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<FaHardHat className="text-white text-xl" />
|
||||||
|
<div>
|
||||||
|
<h2 className="text-xl font-bold text-white">Technician Work Order Summary</h2>
|
||||||
|
<p className="text-white/70 text-sm">
|
||||||
|
{reportData.length} technician{reportData.length !== 1 ? 's' : ''} found
|
||||||
|
{/* ── CHANGE 6 cont: Show active department in header ── */}
|
||||||
|
{filterWorkOrderType && (
|
||||||
|
<span className="ml-2 bg-white/20 px-2 py-0.5 rounded-full text-xs">
|
||||||
|
{filterWorkOrderType}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="p-2 text-white/80 hover:text-white hover:bg-white/20 rounded-lg transition-colors"
|
||||||
|
title="Close (Esc)"
|
||||||
|
>
|
||||||
|
<FaTimes size={20} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Summary Cards */}
|
||||||
|
{!loading && reportData.length > 0 && (
|
||||||
|
<div className="bg-white dark:bg-gray-800 px-6 py-4 border-b border-gray-200 dark:border-gray-700 flex-shrink-0">
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-4">
|
||||||
|
<div className="bg-indigo-50 dark:bg-indigo-900/20 rounded-lg p-4 border border-indigo-200 dark:border-indigo-800">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<FaUsers className="text-indigo-500 text-xl" />
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-indigo-600 dark:text-indigo-400 font-medium">Technicians</p>
|
||||||
|
<p className="text-xl font-bold text-indigo-700 dark:text-indigo-300">{stats.totalTechnicians}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/* <div className="bg-purple-50 dark:bg-purple-900/20 rounded-lg p-4 border border-purple-200 dark:border-purple-800">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<FaHardHat className="text-purple-500 text-xl" />
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-purple-600 dark:text-purple-400 font-medium">Total Work Orders</p>
|
||||||
|
<p className="text-xl font-bold text-purple-700 dark:text-purple-300">{stats.totalWorkOrders}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="bg-green-50 dark:bg-green-900/20 rounded-lg p-4 border border-green-200 dark:border-green-800">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<FaCheckCircle className="text-green-500 text-xl" />
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-green-600 dark:text-green-400 font-medium">Completed</p>
|
||||||
|
<p className="text-xl font-bold text-green-700 dark:text-green-300">{stats.totalCompleted}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="bg-blue-50 dark:bg-blue-900/20 rounded-lg p-4 border border-blue-200 dark:border-blue-800">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<FaClock className="text-blue-500 text-xl" />
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-blue-600 dark:text-blue-400 font-medium">In Progress</p>
|
||||||
|
<p className="text-xl font-bold text-blue-700 dark:text-blue-300">{stats.totalInProgress}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="bg-amber-50 dark:bg-amber-900/20 rounded-lg p-4 border border-amber-200 dark:border-amber-800">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<FaFolderOpen className="text-amber-500 text-xl" />
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-amber-600 dark:text-amber-400 font-medium">Open</p>
|
||||||
|
<p className="text-xl font-bold text-amber-700 dark:text-amber-300">{stats.totalOpen}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="bg-emerald-50 dark:bg-emerald-900/20 rounded-lg p-4 border border-emerald-200 dark:border-emerald-800">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<FaCheckCircle className="text-emerald-500 text-xl" />
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-emerald-600 dark:text-emerald-400 font-medium">Completion Rate</p>
|
||||||
|
<p className="text-xl font-bold text-emerald-700 dark:text-emerald-300">{stats.completionRate}%</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div> */}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Toolbar */}
|
||||||
|
<div className="bg-white dark:bg-gray-800 px-6 py-3 border-b border-gray-200 dark:border-gray-700 flex-shrink-0 shadow-sm">
|
||||||
|
<div className="flex items-center justify-between gap-4 flex-wrap">
|
||||||
|
{/* Filter Toggle */}
|
||||||
|
<button
|
||||||
|
onClick={() => setFiltersExpanded(!filtersExpanded)}
|
||||||
|
className="flex items-center gap-2 px-4 py-2 bg-gray-100 dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg text-sm font-medium text-gray-700 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors"
|
||||||
|
>
|
||||||
|
<FaFilter size={12} />
|
||||||
|
Filters
|
||||||
|
{/* ── CHANGE 6 cont: Show badge when filter is active ── */}
|
||||||
|
{hasActiveFilters && (
|
||||||
|
<span className="bg-purple-100 dark:bg-purple-900/50 text-purple-600 dark:text-purple-400 px-2 py-0.5 rounded-full text-xs font-bold">
|
||||||
|
1
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{filtersExpanded ? <FaChevronUp size={10} /> : <FaChevronDown size={10} />}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Action Buttons */}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
onClick={fetchReportData}
|
||||||
|
disabled={loading}
|
||||||
|
className="p-2.5 bg-gray-100 dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-600 dark:text-gray-400 hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors disabled:opacity-50"
|
||||||
|
title="Refresh"
|
||||||
|
>
|
||||||
|
<FaSync className={loading ? 'animate-spin' : ''} size={14} />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-1 bg-gray-100 dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg overflow-hidden">
|
||||||
|
<button
|
||||||
|
onClick={handleExportCSV}
|
||||||
|
disabled={reportData.length === 0 || isExporting}
|
||||||
|
className="px-4 py-2.5 text-sm font-medium text-gray-700 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors flex items-center gap-2 disabled:opacity-50"
|
||||||
|
title="Export as CSV"
|
||||||
|
>
|
||||||
|
<FaFileCsv className="text-green-600" size={14} />
|
||||||
|
<span>CSV</span>
|
||||||
|
</button>
|
||||||
|
<div className="w-px h-6 bg-gray-300 dark:bg-gray-600"></div>
|
||||||
|
<button
|
||||||
|
onClick={handleExportExcel}
|
||||||
|
disabled={reportData.length === 0 || isExporting}
|
||||||
|
className="px-4 py-2.5 text-sm font-medium text-gray-700 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors flex items-center gap-2 disabled:opacity-50"
|
||||||
|
title="Export as Excel"
|
||||||
|
>
|
||||||
|
<FaFileExcel className="text-green-700" size={14} />
|
||||||
|
<span>Excel</span>
|
||||||
|
</button>
|
||||||
|
<div className="w-px h-6 bg-gray-300 dark:bg-gray-600"></div>
|
||||||
|
<button
|
||||||
|
onClick={handleExportPDF}
|
||||||
|
disabled={reportData.length === 0}
|
||||||
|
className="px-4 py-2.5 text-sm font-medium text-gray-700 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors flex items-center gap-2 disabled:opacity-50"
|
||||||
|
title="Export as PDF"
|
||||||
|
>
|
||||||
|
<FaFilePdf className="text-red-600" size={14} />
|
||||||
|
<span>PDF</span>
|
||||||
|
</button>
|
||||||
|
<div className="w-px h-6 bg-gray-300 dark:bg-gray-600"></div>
|
||||||
|
<button
|
||||||
|
onClick={handlePrint}
|
||||||
|
disabled={reportData.length === 0}
|
||||||
|
className="px-4 py-2.5 text-sm font-medium text-gray-700 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors flex items-center gap-2 disabled:opacity-50"
|
||||||
|
title="Print"
|
||||||
|
>
|
||||||
|
<FaPrint className="text-purple-600" size={14} />
|
||||||
|
<span>Print</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── CHANGE 7: Expandable Filters — replaced placeholder with real filter UI ── */}
|
||||||
|
{filtersExpanded && (
|
||||||
|
<div className="mt-4 p-4 bg-gray-50 dark:bg-gray-900/50 rounded-lg border border-gray-200 dark:border-gray-700">
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||||
|
{/* Technical Department Filter */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||||
|
Technical Department
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{/* Non-admin with exactly 1 permitted type → show locked/read-only field */}
|
||||||
|
{!isAdmin && permittedIssueTypes.length === 1 ? (
|
||||||
|
<div className="w-full px-3 py-2 text-sm border border-gray-200 dark:border-gray-600 rounded-lg bg-gray-50 dark:bg-gray-600 text-gray-700 dark:text-gray-300 flex items-center gap-2">
|
||||||
|
<span className="w-2 h-2 rounded-full bg-purple-400 flex-shrink-0"></span>
|
||||||
|
{filterWorkOrderType}
|
||||||
|
<span className="ml-auto text-[10px] text-gray-400 dark:text-gray-500 uppercase tracking-wide">
|
||||||
|
Restricted
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
/* Admin or non-admin with multiple permitted types → show dropdown */
|
||||||
|
<select
|
||||||
|
value={filterWorkOrderType}
|
||||||
|
onChange={(e) => setFilterWorkOrderType(e.target.value)}
|
||||||
|
className="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-purple-500 bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
|
||||||
|
>
|
||||||
|
<option value="">
|
||||||
|
{isAdmin ? 'All Departments' : 'Select Department'}
|
||||||
|
</option>
|
||||||
|
{/* Admin sees all options (passed from parent); non-admin sees only permitted ones */}
|
||||||
|
{/* {permittedIssueTypes.map(type => ( */}
|
||||||
|
{(isAdmin ? allIssueTypes : permittedIssueTypes).map(type => (
|
||||||
|
<option key={type} value={type}>{type}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Spacer columns */}
|
||||||
|
<div></div>
|
||||||
|
<div></div>
|
||||||
|
|
||||||
|
{/* Clear filter button — only shown to admins (non-admins can't clear permission filter) */}
|
||||||
|
<div className="flex items-end">
|
||||||
|
{isAdmin && (
|
||||||
|
<button
|
||||||
|
onClick={() => setFilterWorkOrderType('')}
|
||||||
|
disabled={!hasActiveFilters}
|
||||||
|
className="px-4 py-2 text-sm font-medium text-gray-600 dark:text-gray-400 hover:text-red-600 dark:hover:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20 rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<FaTimes size={12} />
|
||||||
|
Clear Filters
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<div className="flex-1 overflow-auto p-6">
|
||||||
|
{/* Loading State */}
|
||||||
|
{loading && (
|
||||||
|
<div className="flex items-center justify-center py-20">
|
||||||
|
<div className="text-center">
|
||||||
|
<FaSpinner className="animate-spin text-purple-500 text-5xl mx-auto mb-4" />
|
||||||
|
<p className="text-gray-600 dark:text-gray-400 text-lg">Loading report data...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Error State */}
|
||||||
|
{error && !loading && (
|
||||||
|
<div className="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-8 text-center max-w-lg mx-auto">
|
||||||
|
<p className="text-red-600 dark:text-red-400 mb-4 text-lg">{error}</p>
|
||||||
|
<button
|
||||||
|
onClick={fetchReportData}
|
||||||
|
className="px-6 py-2 bg-red-600 hover:bg-red-700 text-white rounded-lg text-sm font-medium transition-colors"
|
||||||
|
>
|
||||||
|
Try Again
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Empty State */}
|
||||||
|
{!loading && !error && reportData.length === 0 && (
|
||||||
|
<div className="text-center py-20">
|
||||||
|
<FaSearch className="text-gray-300 dark:text-gray-600 text-6xl mx-auto mb-4" />
|
||||||
|
<p className="text-gray-500 dark:text-gray-400 text-xl">No data found</p>
|
||||||
|
<p className="text-gray-400 dark:text-gray-500 text-sm mt-2">
|
||||||
|
No technicians have been assigned to work orders yet.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Data Table */}
|
||||||
|
{!loading && !error && reportData.length > 0 && (
|
||||||
|
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 overflow-hidden shadow-sm">
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full">
|
||||||
|
<thead className="bg-gray-100 dark:bg-gray-700 sticky top-0">
|
||||||
|
<tr>
|
||||||
|
<th className="px-4 py-3 text-left text-xs font-semibold text-gray-600 dark:text-gray-300 uppercase tracking-wider w-16">
|
||||||
|
#
|
||||||
|
</th>
|
||||||
|
{columns.map((col, index) => (
|
||||||
|
<th
|
||||||
|
key={index}
|
||||||
|
className={`px-4 py-3 text-xs font-semibold text-gray-600 dark:text-gray-300 uppercase tracking-wider whitespace-nowrap ${
|
||||||
|
col.fieldtype === 'Int' ? 'text-center' : 'text-left'
|
||||||
|
}`}
|
||||||
|
style={{ minWidth: col.width || 120 }}
|
||||||
|
>
|
||||||
|
{col.label}
|
||||||
|
</th>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-gray-200 dark:divide-gray-700">
|
||||||
|
{reportData.map((row, rowIndex) => (
|
||||||
|
<tr
|
||||||
|
key={rowIndex}
|
||||||
|
className="hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-colors"
|
||||||
|
>
|
||||||
|
<td className="px-4 py-3 text-sm text-gray-500 dark:text-gray-400 font-medium">
|
||||||
|
{rowIndex + 1}
|
||||||
|
</td>
|
||||||
|
{columns.map((col, colIndex) => {
|
||||||
|
const value = row[col.fieldname];
|
||||||
|
const formattedValue = formatCellValue(value, col);
|
||||||
|
|
||||||
|
if (col.fieldname === 'assigned_technician') {
|
||||||
|
return (
|
||||||
|
<td key={colIndex} className="px-4 py-3 whitespace-nowrap">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="w-8 h-8 rounded-full bg-purple-100 dark:bg-purple-900/30 flex items-center justify-center">
|
||||||
|
<FaHardHat className="text-purple-500 dark:text-purple-400" size={14} />
|
||||||
|
</div>
|
||||||
|
<span className="text-sm font-medium text-gray-800 dark:text-gray-200">
|
||||||
|
{formattedValue}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (col.fieldname === 'total') {
|
||||||
|
return (
|
||||||
|
<td key={colIndex} className="px-4 py-3 text-center whitespace-nowrap">
|
||||||
|
<span className="inline-flex items-center justify-center min-w-[40px] px-3 py-1 rounded-full text-sm font-bold bg-indigo-100 dark:bg-indigo-900/30 text-indigo-700 dark:text-indigo-300">
|
||||||
|
{formattedValue}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (col.fieldname === 'completed') {
|
||||||
|
return (
|
||||||
|
<td key={colIndex} className="px-4 py-3 text-center whitespace-nowrap">
|
||||||
|
<span className="inline-flex items-center justify-center min-w-[40px] px-3 py-1 rounded-full text-sm font-bold bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-300">
|
||||||
|
{formattedValue}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (col.fieldname === 'in_progress') {
|
||||||
|
return (
|
||||||
|
<td key={colIndex} className="px-4 py-3 text-center whitespace-nowrap">
|
||||||
|
<span className="inline-flex items-center justify-center min-w-[40px] px-3 py-1 rounded-full text-sm font-bold bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300">
|
||||||
|
{formattedValue}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (col.fieldname === 'open') {
|
||||||
|
return (
|
||||||
|
<td key={colIndex} className="px-4 py-3 text-center whitespace-nowrap">
|
||||||
|
<span className="inline-flex items-center justify-center min-w-[40px] px-3 py-1 rounded-full text-sm font-bold bg-amber-100 dark:bg-amber-900/30 text-amber-700 dark:text-amber-300">
|
||||||
|
{formattedValue}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<td
|
||||||
|
key={colIndex}
|
||||||
|
className="px-4 py-3 text-sm text-gray-700 dark:text-gray-300"
|
||||||
|
>
|
||||||
|
{formattedValue}
|
||||||
|
</td>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<div className="bg-white dark:bg-gray-800 px-6 py-4 border-t border-gray-200 dark:border-gray-700 flex-shrink-0 shadow-inner">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
Showing {reportData.length} technician{reportData.length !== 1 ? 's' : ''}
|
||||||
|
{/* ── CHANGE 6 cont: Show active department in footer ── */}
|
||||||
|
{filterWorkOrderType && (
|
||||||
|
<span className="ml-2 text-purple-600 dark:text-purple-400">
|
||||||
|
· {filterWorkOrderType}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{stats.totalWorkOrders > 0 && (
|
||||||
|
<span className="ml-2 text-purple-600 dark:text-purple-400">
|
||||||
|
• Total: {stats.totalWorkOrders} work orders
|
||||||
|
• <span className="text-green-600">{stats.totalCompleted} completed</span>
|
||||||
|
• <span className="text-blue-600">{stats.totalInProgress} in progress</span>
|
||||||
|
• <span className="text-amber-600">{stats.totalOpen} open</span>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="px-6 py-2 bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 text-gray-700 dark:text-gray-300 rounded-lg text-sm font-medium transition-colors"
|
||||||
|
>
|
||||||
|
Close
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default TechnicianWorkOrderSummaryReportModal;
|
||||||
847
asm_app/src/components/TechnicianWorkingHoursReportModal.tsx
Normal file
847
asm_app/src/components/TechnicianWorkingHoursReportModal.tsx
Normal file
@ -0,0 +1,847 @@
|
|||||||
|
import React, { useState, useEffect, useCallback } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import * as XLSX from 'xlsx';
|
||||||
|
import {
|
||||||
|
FaTimes,
|
||||||
|
FaFileExcel,
|
||||||
|
FaFileCsv,
|
||||||
|
FaFilePdf,
|
||||||
|
FaPrint,
|
||||||
|
FaSpinner,
|
||||||
|
FaFilter,
|
||||||
|
FaChevronDown,
|
||||||
|
FaChevronUp,
|
||||||
|
FaSearch,
|
||||||
|
FaSync,
|
||||||
|
FaExternalLinkAlt,
|
||||||
|
FaClock,
|
||||||
|
FaArrowLeft,
|
||||||
|
FaUserCog,
|
||||||
|
FaUsers
|
||||||
|
} from 'react-icons/fa';
|
||||||
|
|
||||||
|
interface ReportColumn {
|
||||||
|
label: string;
|
||||||
|
fieldname: string;
|
||||||
|
fieldtype: string;
|
||||||
|
options?: string;
|
||||||
|
width?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TechnicianWorkingHoursReportModalProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
permittedIssueTypes?: string[];
|
||||||
|
isAdmin?: boolean;
|
||||||
|
defaultWorkOrderType?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TechnicianWorkingHoursReportModal: React.FC<TechnicianWorkingHoursReportModalProps> = ({
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
permittedIssueTypes = [],
|
||||||
|
isAdmin = true,
|
||||||
|
defaultWorkOrderType = ''
|
||||||
|
}) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
// State
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [reportData, setReportData] = useState<any[]>([]);
|
||||||
|
const [columns, setColumns] = useState<ReportColumn[]>([]);
|
||||||
|
const [filtersExpanded, setFiltersExpanded] = useState(true);
|
||||||
|
|
||||||
|
// Filter states
|
||||||
|
const [filterFromDate, setFilterFromDate] = useState('');
|
||||||
|
const [filterToDate, setFilterToDate] = useState('');
|
||||||
|
const [filterWorkOrderType, setFilterWorkOrderType] = useState('');
|
||||||
|
|
||||||
|
// Export states
|
||||||
|
const [isExporting, setIsExporting] = useState(false);
|
||||||
|
|
||||||
|
// All issue types for admin dropdown
|
||||||
|
const [allIssueTypes, setAllIssueTypes] = useState<string[]>([]);
|
||||||
|
|
||||||
|
// Fetch all Issue Types for admin dropdown
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isAdmin) return;
|
||||||
|
const fetchIssueTypes = async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetch(
|
||||||
|
'/api/resource/Issue Type?fields=["name"]&limit_page_length=0&order_by=name asc',
|
||||||
|
{ headers: { Accept: 'application/json' }, credentials: 'include' }
|
||||||
|
);
|
||||||
|
const data = await response.json();
|
||||||
|
if (data.data) {
|
||||||
|
setAllIssueTypes(data.data.map((d: any) => d.name));
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching issue types:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
fetchIssueTypes();
|
||||||
|
}, [isAdmin]);
|
||||||
|
|
||||||
|
// Auto-apply permitted Issue Type or dashboard global filter as default
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isAdmin && permittedIssueTypes.length === 1) {
|
||||||
|
setFilterWorkOrderType(permittedIssueTypes[0]);
|
||||||
|
} else if (defaultWorkOrderType) {
|
||||||
|
setFilterWorkOrderType(defaultWorkOrderType);
|
||||||
|
} else {
|
||||||
|
setFilterWorkOrderType('');
|
||||||
|
}
|
||||||
|
}, [permittedIssueTypes, isAdmin, defaultWorkOrderType]);
|
||||||
|
|
||||||
|
// Report name constant
|
||||||
|
const REPORT_NAME = 'Technicians working Hours';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch report data from Frappe API
|
||||||
|
*/
|
||||||
|
const fetchReportData = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Build filters object
|
||||||
|
const filters: Record<string, any> = {};
|
||||||
|
if (filterFromDate) filters.from_date = filterFromDate;
|
||||||
|
if (filterToDate) filters.to_date = filterToDate;
|
||||||
|
// if (filterWorkOrderType) {
|
||||||
|
// filters.work_order_type = filterWorkOrderType;
|
||||||
|
// } else if (!isAdmin && permittedIssueTypes.length > 0) {
|
||||||
|
// filters.work_order_type = permittedIssueTypes.length === 1
|
||||||
|
// ? permittedIssueTypes[0]
|
||||||
|
// : ['in', permittedIssueTypes];
|
||||||
|
// }
|
||||||
|
|
||||||
|
|
||||||
|
if (filterWorkOrderType) {
|
||||||
|
filters.work_order_type = filterWorkOrderType;
|
||||||
|
} else if (!isAdmin && permittedIssueTypes.length > 0) {
|
||||||
|
if (permittedIssueTypes.length === 1) {
|
||||||
|
filters.work_order_type = permittedIssueTypes[0];
|
||||||
|
}
|
||||||
|
// For multiple permitted types: don't send filter at all —
|
||||||
|
// the report will return all types, then we filter client-side below
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch('/api/method/frappe.desk.query_report.run', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
credentials: 'include',
|
||||||
|
body: JSON.stringify({
|
||||||
|
report_name: REPORT_NAME,
|
||||||
|
filters: filters,
|
||||||
|
ignore_prepared_report: 1
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
|
||||||
|
if (result.exc) {
|
||||||
|
throw new Error(result.exc);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.message) {
|
||||||
|
// Set columns from report
|
||||||
|
if (result.message.columns && result.message.columns.length > 0) {
|
||||||
|
setColumns(result.message.columns);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set data
|
||||||
|
// if (result.message.result) {
|
||||||
|
// setReportData(result.message.result);
|
||||||
|
// } else {
|
||||||
|
// setReportData([]);
|
||||||
|
// }
|
||||||
|
if (result.message.result) {
|
||||||
|
let rows = result.message.result;
|
||||||
|
// Client-side filter when user has multiple permitted types
|
||||||
|
if (!isAdmin && permittedIssueTypes.length > 1 && !filterWorkOrderType) {
|
||||||
|
rows = rows.filter((r: any) =>
|
||||||
|
permittedIssueTypes.includes(r.work_order_type)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
setReportData(rows);
|
||||||
|
} else {
|
||||||
|
setReportData([]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error fetching report:', err);
|
||||||
|
setError(err instanceof Error ? err.message : 'Failed to fetch report data');
|
||||||
|
setReportData([]);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [filterFromDate, filterToDate, filterWorkOrderType, isAdmin, permittedIssueTypes]);
|
||||||
|
|
||||||
|
// Fetch data when modal opens or filters change
|
||||||
|
useEffect(() => {
|
||||||
|
if (isOpen) {
|
||||||
|
fetchReportData();
|
||||||
|
}
|
||||||
|
}, [isOpen, fetchReportData]);
|
||||||
|
|
||||||
|
// Handle escape key to close
|
||||||
|
useEffect(() => {
|
||||||
|
const handleEscape = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'Escape' && isOpen) {
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener('keydown', handleEscape);
|
||||||
|
return () => document.removeEventListener('keydown', handleEscape);
|
||||||
|
}, [isOpen, onClose]);
|
||||||
|
|
||||||
|
// Prevent body scroll when modal is open
|
||||||
|
useEffect(() => {
|
||||||
|
if (isOpen) {
|
||||||
|
document.body.style.overflow = 'hidden';
|
||||||
|
} else {
|
||||||
|
document.body.style.overflow = 'unset';
|
||||||
|
}
|
||||||
|
return () => {
|
||||||
|
document.body.style.overflow = 'unset';
|
||||||
|
};
|
||||||
|
}, [isOpen]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear all filters
|
||||||
|
*/
|
||||||
|
const handleClearFilters = () => {
|
||||||
|
setFilterFromDate('');
|
||||||
|
setFilterToDate('');
|
||||||
|
if (isAdmin) setFilterWorkOrderType('');
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate summary statistics
|
||||||
|
*/
|
||||||
|
const getSummaryStats = () => {
|
||||||
|
const totalTechnicians = reportData.length;
|
||||||
|
const totalHours = reportData.reduce((sum, row) => sum + (parseFloat(row.total_hours) || 0), 0);
|
||||||
|
const avgHours = totalTechnicians > 0 ? totalHours / totalTechnicians : 0;
|
||||||
|
|
||||||
|
return { totalTechnicians, totalHours, avgHours };
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Export to CSV
|
||||||
|
*/
|
||||||
|
const handleExportCSV = () => {
|
||||||
|
if (reportData.length === 0) return;
|
||||||
|
setIsExporting(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const headers = columns.map(col => col.label);
|
||||||
|
const csvContent = [
|
||||||
|
headers.join(','),
|
||||||
|
...reportData.map(row =>
|
||||||
|
columns.map(col => {
|
||||||
|
let value = row[col.fieldname] || '';
|
||||||
|
// Escape commas and quotes
|
||||||
|
if (typeof value === 'string' && (value.includes(',') || value.includes('"') || value.includes('\n'))) {
|
||||||
|
value = `"${value.replace(/"/g, '""')}"`;
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}).join(',')
|
||||||
|
)
|
||||||
|
].join('\n');
|
||||||
|
|
||||||
|
const blob = new Blob(['\ufeff' + csvContent], { type: 'text/csv;charset=utf-8;' });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const link = document.createElement('a');
|
||||||
|
link.href = url;
|
||||||
|
link.download = `technician_working_hours_${new Date().toISOString().split('T')[0]}.csv`;
|
||||||
|
link.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
} finally {
|
||||||
|
setIsExporting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Export to Excel
|
||||||
|
*/
|
||||||
|
const handleExportExcel = () => {
|
||||||
|
if (reportData.length === 0) return;
|
||||||
|
setIsExporting(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const headers = columns.map(col => col.label);
|
||||||
|
const worksheetData = [
|
||||||
|
headers,
|
||||||
|
...reportData.map(row => columns.map(col => row[col.fieldname] || ''))
|
||||||
|
];
|
||||||
|
|
||||||
|
const worksheet = XLSX.utils.aoa_to_sheet(worksheetData);
|
||||||
|
|
||||||
|
// Set column widths
|
||||||
|
const colWidths = columns.map(col => ({ wch: col.width ? Math.floor(col.width / 7) : 20 }));
|
||||||
|
worksheet['!cols'] = colWidths;
|
||||||
|
|
||||||
|
const workbook = XLSX.utils.book_new();
|
||||||
|
XLSX.utils.book_append_sheet(workbook, worksheet, 'Technician Hours');
|
||||||
|
|
||||||
|
XLSX.writeFile(workbook, `technician_working_hours_${new Date().toISOString().split('T')[0]}.xlsx`);
|
||||||
|
} finally {
|
||||||
|
setIsExporting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Print report
|
||||||
|
*/
|
||||||
|
const handlePrint = () => {
|
||||||
|
const stats = getSummaryStats();
|
||||||
|
|
||||||
|
const printWindow = window.open('', '_blank');
|
||||||
|
if (!printWindow) {
|
||||||
|
alert('Please allow popups for this site to print the report.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const tableHTML = `
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<title>Technicians Working Hours Report</title>
|
||||||
|
<style>
|
||||||
|
body { font-family: Arial, sans-serif; margin: 20px; }
|
||||||
|
h1 { text-align: center; color: #333; margin-bottom: 20px; }
|
||||||
|
.meta { text-align: center; color: #666; margin-bottom: 20px; font-size: 12px; }
|
||||||
|
.summary { display: flex; justify-content: center; gap: 30px; margin-bottom: 20px; padding: 15px; background: #f8f9fa; border-radius: 8px; }
|
||||||
|
.summary-item { text-align: center; }
|
||||||
|
.summary-label { font-size: 11px; color: #666; }
|
||||||
|
.summary-value { font-size: 20px; font-weight: bold; color: #333; }
|
||||||
|
.summary-value.hours { color: #0891B2; }
|
||||||
|
table { width: 100%; border-collapse: collapse; font-size: 12px; }
|
||||||
|
th { background-color: #0891B2; color: white; padding: 10px 8px; text-align: left; font-weight: 600; }
|
||||||
|
td { padding: 10px 8px; border-bottom: 1px solid #ddd; }
|
||||||
|
tr:nth-child(even) { background-color: #f9f9f9; }
|
||||||
|
tr:hover { background-color: #f5f5f5; }
|
||||||
|
.hours-cell { font-weight: 600; color: #0891B2; }
|
||||||
|
@media print {
|
||||||
|
body { margin: 0; }
|
||||||
|
table { page-break-inside: auto; }
|
||||||
|
tr { page-break-inside: avoid; page-break-after: auto; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1>Technicians Working Hours Report</h1>
|
||||||
|
<div class="meta">
|
||||||
|
Generated on: ${new Date().toLocaleString()}
|
||||||
|
${filterFromDate ? ` | From: ${filterFromDate}` : ''}
|
||||||
|
${filterToDate ? ` | To: ${filterToDate}` : ''}
|
||||||
|
</div>
|
||||||
|
<div class="summary">
|
||||||
|
<div class="summary-item">
|
||||||
|
<div class="summary-label">Total Technicians</div>
|
||||||
|
<div class="summary-value">${stats.totalTechnicians}</div>
|
||||||
|
</div>
|
||||||
|
<div class="summary-item">
|
||||||
|
<div class="summary-label">Total Hours</div>
|
||||||
|
<div class="summary-value hours">${stats.totalHours.toFixed(2)}</div>
|
||||||
|
</div>
|
||||||
|
<div class="summary-item">
|
||||||
|
<div class="summary-label">Average Hours</div>
|
||||||
|
<div class="summary-value hours">${stats.avgHours.toFixed(2)}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>#</th>
|
||||||
|
${columns.map(col => `<th>${col.label}</th>`).join('')}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
${reportData.map((row, index) => `
|
||||||
|
<tr>
|
||||||
|
<td>${index + 1}</td>
|
||||||
|
${columns.map(col => {
|
||||||
|
let value = row[col.fieldname] || '-';
|
||||||
|
let className = '';
|
||||||
|
|
||||||
|
// Add class for hours column
|
||||||
|
if (col.fieldname === 'total_hours') {
|
||||||
|
className = 'hours-cell';
|
||||||
|
value = typeof value === 'number' ? value.toFixed(2) : value;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `<td class="${className}">${value}</td>`;
|
||||||
|
}).join('')}
|
||||||
|
</tr>
|
||||||
|
`).join('')}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<script>
|
||||||
|
window.onload = function() { window.print(); }
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
`;
|
||||||
|
|
||||||
|
printWindow.document.write(tableHTML);
|
||||||
|
printWindow.document.close();
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Export to PDF (using browser print)
|
||||||
|
*/
|
||||||
|
const handleExportPDF = () => {
|
||||||
|
handlePrint(); // Uses print dialog which can save as PDF
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Open in ERPNext
|
||||||
|
*/
|
||||||
|
const handleOpenInERPNext = () => {
|
||||||
|
const baseUrl = window.location.origin;
|
||||||
|
let url = `${baseUrl}/app/query-report/${encodeURIComponent(REPORT_NAME)}`;
|
||||||
|
|
||||||
|
// Add filters to URL
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (filterFromDate) params.append('from_date', filterFromDate);
|
||||||
|
if (filterToDate) params.append('to_date', filterToDate);
|
||||||
|
|
||||||
|
if (params.toString()) {
|
||||||
|
url += `?${params.toString()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
window.open(url, '_blank');
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format cell value based on fieldtype
|
||||||
|
*/
|
||||||
|
const formatCellValue = (value: any, column: ReportColumn) => {
|
||||||
|
if (value === null || value === undefined || value === '') return '-';
|
||||||
|
|
||||||
|
switch (column.fieldtype) {
|
||||||
|
case 'Float':
|
||||||
|
return typeof value === 'number' ? value.toFixed(2) : value;
|
||||||
|
case 'Date':
|
||||||
|
return new Date(value).toLocaleDateString('en-US', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric'
|
||||||
|
});
|
||||||
|
case 'Datetime':
|
||||||
|
return new Date(value).toLocaleString('en-US', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit'
|
||||||
|
});
|
||||||
|
default:
|
||||||
|
return String(value);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!isOpen) return null;
|
||||||
|
|
||||||
|
const stats = getSummaryStats();
|
||||||
|
const hasActiveFilters = filterFromDate || filterToDate || filterWorkOrderType;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-[80] bg-gray-50 dark:bg-gray-900 flex flex-col">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="bg-gradient-to-r from-cyan-600 to-blue-600 px-6 py-4 flex-shrink-0 shadow-lg">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
{/* Back Button */}
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="p-2 text-white/80 hover:text-white hover:bg-white/20 rounded-lg transition-colors"
|
||||||
|
title="Go Back"
|
||||||
|
>
|
||||||
|
<FaArrowLeft size={18} />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<FaClock className="text-white text-xl" />
|
||||||
|
<div>
|
||||||
|
<h2 className="text-xl font-bold text-white">Technicians Working Hours</h2>
|
||||||
|
<p className="text-white/70 text-sm">
|
||||||
|
{reportData.length} technician{reportData.length !== 1 ? 's' : ''} found
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{/* Open in ERPNext Button */}
|
||||||
|
{/* <button
|
||||||
|
onClick={handleOpenInERPNext}
|
||||||
|
className="px-4 py-2 bg-white/20 hover:bg-white/30 text-white rounded-lg text-sm font-medium transition-all flex items-center gap-2"
|
||||||
|
title="Open in ERPNext"
|
||||||
|
>
|
||||||
|
<FaExternalLinkAlt size={12} />
|
||||||
|
<span>Open in ERPNext</span>
|
||||||
|
</button> */}
|
||||||
|
|
||||||
|
{/* Close Button */}
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="p-2 text-white/80 hover:text-white hover:bg-white/20 rounded-lg transition-colors"
|
||||||
|
title="Close (Esc)"
|
||||||
|
>
|
||||||
|
<FaTimes size={20} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Summary Cards */}
|
||||||
|
{!loading && reportData.length > 0 && (
|
||||||
|
<div className="bg-white dark:bg-gray-800 px-6 py-4 border-b border-gray-200 dark:border-gray-700 flex-shrink-0">
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||||
|
<div className="bg-blue-50 dark:bg-blue-900/20 rounded-lg p-4 border border-blue-200 dark:border-blue-800">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<FaUsers className="text-blue-500 text-2xl" />
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-blue-600 dark:text-blue-400 font-medium">Total Technicians</p>
|
||||||
|
<p className="text-2xl font-bold text-blue-700 dark:text-blue-300">{stats.totalTechnicians}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="bg-cyan-50 dark:bg-cyan-900/20 rounded-lg p-4 border border-cyan-200 dark:border-cyan-800">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<FaClock className="text-cyan-500 text-2xl" />
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-cyan-600 dark:text-cyan-400 font-medium">Total Hours Worked</p>
|
||||||
|
<p className="text-2xl font-bold text-cyan-700 dark:text-cyan-300">{stats.totalHours.toFixed(2)}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="bg-purple-50 dark:bg-purple-900/20 rounded-lg p-4 border border-purple-200 dark:border-purple-800">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<FaUserCog className="text-purple-500 text-2xl" />
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-purple-600 dark:text-purple-400 font-medium">Average Hours/Technician</p>
|
||||||
|
<p className="text-2xl font-bold text-purple-700 dark:text-purple-300">{stats.avgHours.toFixed(2)}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Toolbar */}
|
||||||
|
<div className="bg-white dark:bg-gray-800 px-6 py-3 border-b border-gray-200 dark:border-gray-700 flex-shrink-0 shadow-sm">
|
||||||
|
<div className="flex items-center justify-between gap-4 flex-wrap">
|
||||||
|
{/* Filter Toggle */}
|
||||||
|
<button
|
||||||
|
onClick={() => setFiltersExpanded(!filtersExpanded)}
|
||||||
|
className="flex items-center gap-2 px-4 py-2 bg-gray-100 dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg text-sm font-medium text-gray-700 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors"
|
||||||
|
>
|
||||||
|
<FaFilter size={12} />
|
||||||
|
Filters
|
||||||
|
{hasActiveFilters && (
|
||||||
|
<span className="bg-cyan-100 dark:bg-cyan-900/50 text-cyan-600 dark:text-cyan-400 px-2 py-0.5 rounded-full text-xs font-bold">
|
||||||
|
{[filterFromDate, filterToDate, filterWorkOrderType].filter(Boolean).length}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{filtersExpanded ? <FaChevronUp size={10} /> : <FaChevronDown size={10} />}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Action Buttons */}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{/* Refresh */}
|
||||||
|
<button
|
||||||
|
onClick={fetchReportData}
|
||||||
|
disabled={loading}
|
||||||
|
className="p-2.5 bg-gray-100 dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-600 dark:text-gray-400 hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors disabled:opacity-50"
|
||||||
|
title="Refresh"
|
||||||
|
>
|
||||||
|
<FaSync className={loading ? 'animate-spin' : ''} size={14} />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Export Buttons */}
|
||||||
|
<div className="flex items-center gap-1 bg-gray-100 dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg overflow-hidden">
|
||||||
|
<button
|
||||||
|
onClick={handleExportCSV}
|
||||||
|
disabled={reportData.length === 0 || isExporting}
|
||||||
|
className="px-4 py-2.5 text-sm font-medium text-gray-700 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors flex items-center gap-2 disabled:opacity-50"
|
||||||
|
title="Export as CSV"
|
||||||
|
>
|
||||||
|
<FaFileCsv className="text-green-600" size={14} />
|
||||||
|
<span>CSV</span>
|
||||||
|
</button>
|
||||||
|
<div className="w-px h-6 bg-gray-300 dark:bg-gray-600"></div>
|
||||||
|
<button
|
||||||
|
onClick={handleExportExcel}
|
||||||
|
disabled={reportData.length === 0 || isExporting}
|
||||||
|
className="px-4 py-2.5 text-sm font-medium text-gray-700 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors flex items-center gap-2 disabled:opacity-50"
|
||||||
|
title="Export as Excel"
|
||||||
|
>
|
||||||
|
<FaFileExcel className="text-green-700" size={14} />
|
||||||
|
<span>Excel</span>
|
||||||
|
</button>
|
||||||
|
<div className="w-px h-6 bg-gray-300 dark:bg-gray-600"></div>
|
||||||
|
<button
|
||||||
|
onClick={handleExportPDF}
|
||||||
|
disabled={reportData.length === 0}
|
||||||
|
className="px-4 py-2.5 text-sm font-medium text-gray-700 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors flex items-center gap-2 disabled:opacity-50"
|
||||||
|
title="Export as PDF"
|
||||||
|
>
|
||||||
|
<FaFilePdf className="text-red-600" size={14} />
|
||||||
|
<span>PDF</span>
|
||||||
|
</button>
|
||||||
|
<div className="w-px h-6 bg-gray-300 dark:bg-gray-600"></div>
|
||||||
|
<button
|
||||||
|
onClick={handlePrint}
|
||||||
|
disabled={reportData.length === 0}
|
||||||
|
className="px-4 py-2.5 text-sm font-medium text-gray-700 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors flex items-center gap-2 disabled:opacity-50"
|
||||||
|
title="Print"
|
||||||
|
>
|
||||||
|
<FaPrint className="text-purple-600" size={14} />
|
||||||
|
<span>Print</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Expandable Filters */}
|
||||||
|
{filtersExpanded && (
|
||||||
|
<div className="mt-4 p-4 bg-gray-50 dark:bg-gray-900/50 rounded-lg border border-gray-200 dark:border-gray-700">
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||||
|
{/* From Date Filter */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||||
|
From Date
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={filterFromDate}
|
||||||
|
onChange={(e) => setFilterFromDate(e.target.value)}
|
||||||
|
className="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-cyan-500 bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* To Date Filter */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||||
|
To Date
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={filterToDate}
|
||||||
|
onChange={(e) => setFilterToDate(e.target.value)}
|
||||||
|
className="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-cyan-500 bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Technical Department Filter */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||||
|
Technical Department
|
||||||
|
</label>
|
||||||
|
{!isAdmin && permittedIssueTypes.length === 1 ? (
|
||||||
|
<div className="w-full px-3 py-2 text-sm border border-gray-200 dark:border-gray-600 rounded-lg bg-gray-50 dark:bg-gray-600 text-gray-700 dark:text-gray-300 flex items-center gap-2">
|
||||||
|
<span className="w-2 h-2 rounded-full bg-indigo-400 flex-shrink-0"></span>
|
||||||
|
{filterWorkOrderType}
|
||||||
|
<span className="ml-auto text-[10px] text-gray-400 dark:text-gray-500 uppercase">Restricted</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<select
|
||||||
|
value={filterWorkOrderType}
|
||||||
|
onChange={(e) => setFilterWorkOrderType(e.target.value)}
|
||||||
|
className="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-cyan-500 bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
|
||||||
|
>
|
||||||
|
<option value="">All Departments</option>
|
||||||
|
{/*{(!isAdmin && permittedIssueTypes.length > 0 ? permittedIssueTypes : []).map(type => ( */}
|
||||||
|
{(isAdmin ? allIssueTypes : permittedIssueTypes).map(type => (
|
||||||
|
<option key={type} value={type}>{type}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Clear Filters Button */}
|
||||||
|
<div className="flex items-end">
|
||||||
|
<button
|
||||||
|
onClick={handleClearFilters}
|
||||||
|
disabled={!hasActiveFilters}
|
||||||
|
className="px-4 py-2 text-sm font-medium text-gray-600 dark:text-gray-400 hover:text-red-600 dark:hover:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20 rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<FaTimes size={12} />
|
||||||
|
Clear Filters
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content - Full height scrollable area */}
|
||||||
|
<div className="flex-1 overflow-auto p-6">
|
||||||
|
{/* Loading State */}
|
||||||
|
{loading && (
|
||||||
|
<div className="flex items-center justify-center py-20">
|
||||||
|
<div className="text-center">
|
||||||
|
<FaSpinner className="animate-spin text-cyan-500 text-5xl mx-auto mb-4" />
|
||||||
|
<p className="text-gray-600 dark:text-gray-400 text-lg">Loading report data...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Error State */}
|
||||||
|
{error && !loading && (
|
||||||
|
<div className="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-8 text-center max-w-lg mx-auto">
|
||||||
|
<p className="text-red-600 dark:text-red-400 mb-4 text-lg">{error}</p>
|
||||||
|
<button
|
||||||
|
onClick={fetchReportData}
|
||||||
|
className="px-6 py-2 bg-red-600 hover:bg-red-700 text-white rounded-lg text-sm font-medium transition-colors"
|
||||||
|
>
|
||||||
|
Try Again
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Empty State */}
|
||||||
|
{!loading && !error && reportData.length === 0 && (
|
||||||
|
<div className="text-center py-20">
|
||||||
|
<FaSearch className="text-gray-300 dark:text-gray-600 text-6xl mx-auto mb-4" />
|
||||||
|
<p className="text-gray-500 dark:text-gray-400 text-xl">No data found</p>
|
||||||
|
<p className="text-gray-400 dark:text-gray-500 text-sm mt-2">
|
||||||
|
Try adjusting your date range filters
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Data Table */}
|
||||||
|
{!loading && !error && reportData.length > 0 && (
|
||||||
|
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 overflow-hidden shadow-sm">
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full">
|
||||||
|
<thead className="bg-gray-100 dark:bg-gray-700 sticky top-0">
|
||||||
|
<tr>
|
||||||
|
<th className="px-4 py-3 text-left text-xs font-semibold text-gray-600 dark:text-gray-300 uppercase tracking-wider w-16">
|
||||||
|
#
|
||||||
|
</th>
|
||||||
|
{columns.map((col, index) => (
|
||||||
|
<th
|
||||||
|
key={index}
|
||||||
|
className="px-4 py-3 text-left text-xs font-semibold text-gray-600 dark:text-gray-300 uppercase tracking-wider whitespace-nowrap"
|
||||||
|
style={{ minWidth: col.width || 150 }}
|
||||||
|
>
|
||||||
|
{col.label}
|
||||||
|
</th>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-gray-200 dark:divide-gray-700">
|
||||||
|
{reportData.map((row, rowIndex) => (
|
||||||
|
<tr
|
||||||
|
key={rowIndex}
|
||||||
|
className="hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-colors"
|
||||||
|
>
|
||||||
|
<td className="px-4 py-3 text-sm text-gray-500 dark:text-gray-400 font-medium">
|
||||||
|
{rowIndex + 1}
|
||||||
|
</td>
|
||||||
|
{columns.map((col, colIndex) => {
|
||||||
|
const value = row[col.fieldname];
|
||||||
|
const formattedValue = formatCellValue(value, col);
|
||||||
|
|
||||||
|
// Special rendering for total_hours
|
||||||
|
if (col.fieldname === 'total_hours') {
|
||||||
|
return (
|
||||||
|
<td key={colIndex} className="px-4 py-3 whitespace-nowrap">
|
||||||
|
<span className="inline-flex items-center gap-1 px-3 py-1 rounded-full text-sm font-semibold bg-cyan-100 dark:bg-cyan-900/30 text-cyan-700 dark:text-cyan-300">
|
||||||
|
<FaClock size={12} />
|
||||||
|
{formattedValue} hrs
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Link to user for engineer field
|
||||||
|
if (col.fieldname === 'engineer' && value) {
|
||||||
|
return (
|
||||||
|
<td key={colIndex} className="px-4 py-3 whitespace-nowrap">
|
||||||
|
<a
|
||||||
|
href={`/app/user/${value}`}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="text-cyan-600 dark:text-cyan-400 hover:underline font-medium text-sm"
|
||||||
|
>
|
||||||
|
{formattedValue}
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Technician name with icon
|
||||||
|
if (col.fieldname === 'technician_name') {
|
||||||
|
return (
|
||||||
|
<td key={colIndex} className="px-4 py-3 whitespace-nowrap">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="w-8 h-8 rounded-full bg-gray-200 dark:bg-gray-600 flex items-center justify-center">
|
||||||
|
<FaUserCog className="text-gray-500 dark:text-gray-400" size={14} />
|
||||||
|
</div>
|
||||||
|
<span className="text-sm font-medium text-gray-800 dark:text-gray-200">
|
||||||
|
{formattedValue}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default rendering
|
||||||
|
return (
|
||||||
|
<td
|
||||||
|
key={colIndex}
|
||||||
|
className="px-4 py-3 text-sm text-gray-700 dark:text-gray-300"
|
||||||
|
title={typeof value === 'string' && value.length > 50 ? value : undefined}
|
||||||
|
>
|
||||||
|
<div className="max-w-xs truncate">
|
||||||
|
{formattedValue}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<div className="bg-white dark:bg-gray-800 px-6 py-4 border-t border-gray-200 dark:border-gray-700 flex-shrink-0 shadow-inner">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
Showing {reportData.length} technician{reportData.length !== 1 ? 's' : ''}
|
||||||
|
{hasActiveFilters && ' (filtered)'}
|
||||||
|
{stats.totalHours > 0 && (
|
||||||
|
<span className="ml-2 text-cyan-600 dark:text-cyan-400">
|
||||||
|
• Total: {stats.totalHours.toFixed(2)} hours
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="px-6 py-2 bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 text-gray-700 dark:text-gray-300 rounded-lg text-sm font-medium transition-colors"
|
||||||
|
>
|
||||||
|
Close
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default TechnicianWorkingHoursReportModal;
|
||||||
731
asm_app/src/components/WorkOrderReportModal.tsx
Normal file
731
asm_app/src/components/WorkOrderReportModal.tsx
Normal file
@ -0,0 +1,731 @@
|
|||||||
|
import React, { useState, useEffect, useCallback } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import * as XLSX from 'xlsx';
|
||||||
|
import {
|
||||||
|
FaTimes,
|
||||||
|
FaFileExcel,
|
||||||
|
FaFileCsv,
|
||||||
|
FaFilePdf,
|
||||||
|
FaPrint,
|
||||||
|
FaSpinner,
|
||||||
|
FaFilter,
|
||||||
|
FaChevronDown,
|
||||||
|
FaChevronUp,
|
||||||
|
FaSearch,
|
||||||
|
FaSync,
|
||||||
|
FaExternalLinkAlt,
|
||||||
|
FaTable,
|
||||||
|
FaArrowLeft
|
||||||
|
} from 'react-icons/fa';
|
||||||
|
import LinkField from './LinkField';
|
||||||
|
|
||||||
|
interface ReportColumn {
|
||||||
|
label: string;
|
||||||
|
fieldname: string;
|
||||||
|
fieldtype: string;
|
||||||
|
options?: string;
|
||||||
|
width?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ReportFilter {
|
||||||
|
fieldname: string;
|
||||||
|
label: string;
|
||||||
|
fieldtype: string;
|
||||||
|
options?: string;
|
||||||
|
mandatory?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface WorkOrderReportModalProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const WorkOrderReportModal: React.FC<WorkOrderReportModalProps> = ({ isOpen, onClose }) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
// State
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [reportData, setReportData] = useState<any[]>([]);
|
||||||
|
const [columns, setColumns] = useState<ReportColumn[]>([]);
|
||||||
|
const [filtersExpanded, setFiltersExpanded] = useState(true);
|
||||||
|
|
||||||
|
// Filter states
|
||||||
|
const [filterWorkOrderType, setFilterWorkOrderType] = useState('');
|
||||||
|
const [filterStatus, setFilterStatus] = useState('');
|
||||||
|
|
||||||
|
// Export states
|
||||||
|
const [isExporting, setIsExporting] = useState(false);
|
||||||
|
|
||||||
|
// Report name constant
|
||||||
|
const REPORT_NAME = 'Work Order Data';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch report data from Frappe API
|
||||||
|
*/
|
||||||
|
const fetchReportData = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Build filters object
|
||||||
|
const filters: Record<string, any> = {};
|
||||||
|
if (filterWorkOrderType) filters.work_order_type = filterWorkOrderType;
|
||||||
|
if (filterStatus) filters.repair_status = filterStatus;
|
||||||
|
|
||||||
|
const response = await fetch('/api/method/frappe.desk.query_report.run', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
credentials: 'include',
|
||||||
|
body: JSON.stringify({
|
||||||
|
report_name: REPORT_NAME,
|
||||||
|
filters: filters,
|
||||||
|
ignore_prepared_report: 1
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
|
||||||
|
if (result.exc) {
|
||||||
|
throw new Error(result.exc);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.message) {
|
||||||
|
// Set columns from report
|
||||||
|
if (result.message.columns && result.message.columns.length > 0) {
|
||||||
|
setColumns(result.message.columns);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set data
|
||||||
|
if (result.message.result) {
|
||||||
|
setReportData(result.message.result);
|
||||||
|
} else {
|
||||||
|
setReportData([]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error fetching report:', err);
|
||||||
|
setError(err instanceof Error ? err.message : 'Failed to fetch report data');
|
||||||
|
setReportData([]);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [filterWorkOrderType, filterStatus]);
|
||||||
|
|
||||||
|
// Fetch data when modal opens or filters change
|
||||||
|
useEffect(() => {
|
||||||
|
if (isOpen) {
|
||||||
|
fetchReportData();
|
||||||
|
}
|
||||||
|
}, [isOpen, fetchReportData]);
|
||||||
|
|
||||||
|
// Handle escape key to close
|
||||||
|
useEffect(() => {
|
||||||
|
const handleEscape = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'Escape' && isOpen) {
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener('keydown', handleEscape);
|
||||||
|
return () => document.removeEventListener('keydown', handleEscape);
|
||||||
|
}, [isOpen, onClose]);
|
||||||
|
|
||||||
|
// Prevent body scroll when modal is open
|
||||||
|
useEffect(() => {
|
||||||
|
if (isOpen) {
|
||||||
|
document.body.style.overflow = 'hidden';
|
||||||
|
} else {
|
||||||
|
document.body.style.overflow = 'unset';
|
||||||
|
}
|
||||||
|
return () => {
|
||||||
|
document.body.style.overflow = 'unset';
|
||||||
|
};
|
||||||
|
}, [isOpen]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear all filters
|
||||||
|
*/
|
||||||
|
const handleClearFilters = () => {
|
||||||
|
setFilterWorkOrderType('');
|
||||||
|
setFilterStatus('');
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Export to CSV
|
||||||
|
*/
|
||||||
|
const handleExportCSV = () => {
|
||||||
|
if (reportData.length === 0) return;
|
||||||
|
setIsExporting(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const headers = columns.map(col => col.label);
|
||||||
|
const csvContent = [
|
||||||
|
headers.join(','),
|
||||||
|
...reportData.map(row =>
|
||||||
|
columns.map(col => {
|
||||||
|
let value = row[col.fieldname] || '';
|
||||||
|
// Escape commas and quotes
|
||||||
|
if (typeof value === 'string' && (value.includes(',') || value.includes('"') || value.includes('\n'))) {
|
||||||
|
value = `"${value.replace(/"/g, '""')}"`;
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}).join(',')
|
||||||
|
)
|
||||||
|
].join('\n');
|
||||||
|
|
||||||
|
const blob = new Blob(['\ufeff' + csvContent], { type: 'text/csv;charset=utf-8;' });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const link = document.createElement('a');
|
||||||
|
link.href = url;
|
||||||
|
link.download = `work_order_report_${new Date().toISOString().split('T')[0]}.csv`;
|
||||||
|
link.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
} finally {
|
||||||
|
setIsExporting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Export to Excel
|
||||||
|
*/
|
||||||
|
const handleExportExcel = () => {
|
||||||
|
if (reportData.length === 0) return;
|
||||||
|
setIsExporting(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const headers = columns.map(col => col.label);
|
||||||
|
const worksheetData = [
|
||||||
|
headers,
|
||||||
|
...reportData.map(row => columns.map(col => row[col.fieldname] || ''))
|
||||||
|
];
|
||||||
|
|
||||||
|
const worksheet = XLSX.utils.aoa_to_sheet(worksheetData);
|
||||||
|
|
||||||
|
// Set column widths
|
||||||
|
const colWidths = columns.map(col => ({ wch: col.width ? Math.floor(col.width / 7) : 15 }));
|
||||||
|
worksheet['!cols'] = colWidths;
|
||||||
|
|
||||||
|
const workbook = XLSX.utils.book_new();
|
||||||
|
XLSX.utils.book_append_sheet(workbook, worksheet, 'Work Order Report');
|
||||||
|
|
||||||
|
XLSX.writeFile(workbook, `work_order_report_${new Date().toISOString().split('T')[0]}.xlsx`);
|
||||||
|
} finally {
|
||||||
|
setIsExporting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Print report
|
||||||
|
*/
|
||||||
|
const handlePrint = () => {
|
||||||
|
const printWindow = window.open('', '_blank');
|
||||||
|
if (!printWindow) {
|
||||||
|
alert('Please allow popups for this site to print the report.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const tableHTML = `
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<title>Work Order Report</title>
|
||||||
|
<style>
|
||||||
|
body { font-family: Arial, sans-serif; margin: 20px; }
|
||||||
|
h1 { text-align: center; color: #333; margin-bottom: 20px; }
|
||||||
|
.meta { text-align: center; color: #666; margin-bottom: 20px; font-size: 12px; }
|
||||||
|
table { width: 100%; border-collapse: collapse; font-size: 11px; }
|
||||||
|
th { background-color: #4A90D9; color: white; padding: 10px 8px; text-align: left; font-weight: 600; }
|
||||||
|
td { padding: 8px; border-bottom: 1px solid #ddd; }
|
||||||
|
tr:nth-child(even) { background-color: #f9f9f9; }
|
||||||
|
tr:hover { background-color: #f5f5f5; }
|
||||||
|
.status-open { color: #D97706; font-weight: 500; }
|
||||||
|
.status-completed { color: #059669; font-weight: 500; }
|
||||||
|
.status-inprogress { color: #2563EB; font-weight: 500; }
|
||||||
|
.priority-urgent { color: #DC2626; font-weight: 600; }
|
||||||
|
.priority-normal { color: #6B7280; }
|
||||||
|
@media print {
|
||||||
|
body { margin: 0; }
|
||||||
|
table { page-break-inside: auto; }
|
||||||
|
tr { page-break-inside: avoid; page-break-after: auto; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1>Work Order Report</h1>
|
||||||
|
<div class="meta">
|
||||||
|
Generated on: ${new Date().toLocaleString()} | Total Records: ${reportData.length}
|
||||||
|
${filterWorkOrderType ? ` | Type: ${filterWorkOrderType}` : ''}
|
||||||
|
${filterStatus ? ` | Status: ${filterStatus}` : ''}
|
||||||
|
</div>
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
${columns.map(col => `<th>${col.label}</th>`).join('')}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
${reportData.map(row => `
|
||||||
|
<tr>
|
||||||
|
${columns.map(col => {
|
||||||
|
let value = row[col.fieldname] || '-';
|
||||||
|
let className = '';
|
||||||
|
|
||||||
|
// Add status classes
|
||||||
|
if (col.fieldname === 'repair_status') {
|
||||||
|
if (value.toLowerCase().includes('open')) className = 'status-open';
|
||||||
|
else if (value.toLowerCase().includes('completed')) className = 'status-completed';
|
||||||
|
else if (value.toLowerCase().includes('progress')) className = 'status-inprogress';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add priority classes
|
||||||
|
if (col.fieldname === 'custom_priority_') {
|
||||||
|
if (value.toLowerCase() === 'urgent') className = 'priority-urgent';
|
||||||
|
else className = 'priority-normal';
|
||||||
|
}
|
||||||
|
|
||||||
|
return `<td class="${className}">${value}</td>`;
|
||||||
|
}).join('')}
|
||||||
|
</tr>
|
||||||
|
`).join('')}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<script>
|
||||||
|
window.onload = function() { window.print(); }
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
`;
|
||||||
|
|
||||||
|
printWindow.document.write(tableHTML);
|
||||||
|
printWindow.document.close();
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Export to PDF (using browser print)
|
||||||
|
*/
|
||||||
|
const handleExportPDF = () => {
|
||||||
|
handlePrint(); // Uses print dialog which can save as PDF
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Open in ERPNext
|
||||||
|
*/
|
||||||
|
const handleOpenInERPNext = () => {
|
||||||
|
const baseUrl = window.location.origin;
|
||||||
|
let url = `${baseUrl}/app/query-report/${encodeURIComponent(REPORT_NAME)}`;
|
||||||
|
|
||||||
|
// Add filters to URL
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (filterWorkOrderType) params.append('work_order_type', filterWorkOrderType);
|
||||||
|
if (filterStatus) params.append('repair_status', filterStatus);
|
||||||
|
|
||||||
|
if (params.toString()) {
|
||||||
|
url += `?${params.toString()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
window.open(url, '_blank');
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get status badge color
|
||||||
|
*/
|
||||||
|
const getStatusColor = (status: string) => {
|
||||||
|
switch (status?.toLowerCase()) {
|
||||||
|
case 'completed':
|
||||||
|
case 'closed':
|
||||||
|
return 'bg-green-100 dark:bg-green-900/30 text-green-800 dark:text-green-300';
|
||||||
|
case 'work in progress':
|
||||||
|
return 'bg-blue-100 dark:bg-blue-900/30 text-blue-800 dark:text-blue-300';
|
||||||
|
case 'open':
|
||||||
|
case 'pending review':
|
||||||
|
return 'bg-yellow-100 dark:bg-yellow-900/30 text-yellow-800 dark:text-yellow-300';
|
||||||
|
case 'cancelled':
|
||||||
|
return 'bg-red-100 dark:bg-red-900/30 text-red-800 dark:text-red-300';
|
||||||
|
default:
|
||||||
|
return 'bg-gray-100 dark:bg-gray-700 text-gray-800 dark:text-gray-300';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get priority badge color
|
||||||
|
*/
|
||||||
|
const getPriorityColor = (priority: string) => {
|
||||||
|
switch (priority?.toLowerCase()) {
|
||||||
|
case 'urgent':
|
||||||
|
return 'bg-red-100 dark:bg-red-900/30 text-red-800 dark:text-red-300';
|
||||||
|
case 'medium':
|
||||||
|
return 'bg-orange-100 dark:bg-orange-900/30 text-orange-800 dark:text-orange-300';
|
||||||
|
default:
|
||||||
|
return 'bg-gray-100 dark:bg-gray-700 text-gray-800 dark:text-gray-300';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format cell value based on fieldtype
|
||||||
|
*/
|
||||||
|
const formatCellValue = (value: any, column: ReportColumn) => {
|
||||||
|
if (value === null || value === undefined || value === '') return '-';
|
||||||
|
|
||||||
|
switch (column.fieldtype) {
|
||||||
|
case 'Date':
|
||||||
|
return new Date(value).toLocaleDateString('en-US', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric'
|
||||||
|
});
|
||||||
|
case 'Datetime':
|
||||||
|
return new Date(value).toLocaleString('en-US', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit'
|
||||||
|
});
|
||||||
|
case 'Currency':
|
||||||
|
return new Intl.NumberFormat('en-US', {
|
||||||
|
style: 'currency',
|
||||||
|
currency: 'SAR'
|
||||||
|
}).format(value);
|
||||||
|
case 'Link':
|
||||||
|
return value;
|
||||||
|
default:
|
||||||
|
return String(value);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!isOpen) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-[80] bg-gray-50 dark:bg-gray-900 flex flex-col">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="bg-gradient-to-r from-indigo-600 to-purple-600 px-6 py-4 flex-shrink-0 shadow-lg">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
{/* Back Button */}
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="p-2 text-white/80 hover:text-white hover:bg-white/20 rounded-lg transition-colors"
|
||||||
|
title="Go Back"
|
||||||
|
>
|
||||||
|
<FaArrowLeft size={18} />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<FaTable className="text-white text-xl" />
|
||||||
|
<div>
|
||||||
|
<h2 className="text-xl font-bold text-white">Work Order Report</h2>
|
||||||
|
<p className="text-white/70 text-sm">
|
||||||
|
{reportData.length} record{reportData.length !== 1 ? 's' : ''} found
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{/* Open in ERPNext Button */}
|
||||||
|
{/* <button
|
||||||
|
onClick={handleOpenInERPNext}
|
||||||
|
className="px-4 py-2 bg-white/20 hover:bg-white/30 text-white rounded-lg text-sm font-medium transition-all flex items-center gap-2"
|
||||||
|
title="Open in ERPNext"
|
||||||
|
>
|
||||||
|
<FaExternalLinkAlt size={12} />
|
||||||
|
<span>Open in ERPNext</span>
|
||||||
|
</button> */}
|
||||||
|
|
||||||
|
{/* Close Button */}
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="p-2 text-white/80 hover:text-white hover:bg-white/20 rounded-lg transition-colors"
|
||||||
|
title="Close (Esc)"
|
||||||
|
>
|
||||||
|
<FaTimes size={20} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Toolbar */}
|
||||||
|
<div className="bg-white dark:bg-gray-800 px-6 py-3 border-b border-gray-200 dark:border-gray-700 flex-shrink-0 shadow-sm">
|
||||||
|
<div className="flex items-center justify-between gap-4 flex-wrap">
|
||||||
|
{/* Filter Toggle */}
|
||||||
|
<button
|
||||||
|
onClick={() => setFiltersExpanded(!filtersExpanded)}
|
||||||
|
className="flex items-center gap-2 px-4 py-2 bg-gray-100 dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg text-sm font-medium text-gray-700 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors"
|
||||||
|
>
|
||||||
|
<FaFilter size={12} />
|
||||||
|
Filters
|
||||||
|
{(filterWorkOrderType || filterStatus) && (
|
||||||
|
<span className="bg-indigo-100 dark:bg-indigo-900/50 text-indigo-600 dark:text-indigo-400 px-2 py-0.5 rounded-full text-xs font-bold">
|
||||||
|
{[filterWorkOrderType, filterStatus].filter(Boolean).length}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{filtersExpanded ? <FaChevronUp size={10} /> : <FaChevronDown size={10} />}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Action Buttons */}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{/* Refresh */}
|
||||||
|
<button
|
||||||
|
onClick={fetchReportData}
|
||||||
|
disabled={loading}
|
||||||
|
className="p-2.5 bg-gray-100 dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-600 dark:text-gray-400 hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors disabled:opacity-50"
|
||||||
|
title="Refresh"
|
||||||
|
>
|
||||||
|
<FaSync className={loading ? 'animate-spin' : ''} size={14} />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Export Buttons */}
|
||||||
|
<div className="flex items-center gap-1 bg-gray-100 dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg overflow-hidden">
|
||||||
|
<button
|
||||||
|
onClick={handleExportCSV}
|
||||||
|
disabled={reportData.length === 0 || isExporting}
|
||||||
|
className="px-4 py-2.5 text-sm font-medium text-gray-700 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors flex items-center gap-2 disabled:opacity-50"
|
||||||
|
title="Export as CSV"
|
||||||
|
>
|
||||||
|
<FaFileCsv className="text-green-600" size={14} />
|
||||||
|
<span>CSV</span>
|
||||||
|
</button>
|
||||||
|
<div className="w-px h-6 bg-gray-300 dark:bg-gray-600"></div>
|
||||||
|
<button
|
||||||
|
onClick={handleExportExcel}
|
||||||
|
disabled={reportData.length === 0 || isExporting}
|
||||||
|
className="px-4 py-2.5 text-sm font-medium text-gray-700 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors flex items-center gap-2 disabled:opacity-50"
|
||||||
|
title="Export as Excel"
|
||||||
|
>
|
||||||
|
<FaFileExcel className="text-green-700" size={14} />
|
||||||
|
<span>Excel</span>
|
||||||
|
</button>
|
||||||
|
<div className="w-px h-6 bg-gray-300 dark:bg-gray-600"></div>
|
||||||
|
<button
|
||||||
|
onClick={handleExportPDF}
|
||||||
|
disabled={reportData.length === 0}
|
||||||
|
className="px-4 py-2.5 text-sm font-medium text-gray-700 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors flex items-center gap-2 disabled:opacity-50"
|
||||||
|
title="Export as PDF"
|
||||||
|
>
|
||||||
|
<FaFilePdf className="text-red-600" size={14} />
|
||||||
|
<span>PDF</span>
|
||||||
|
</button>
|
||||||
|
<div className="w-px h-6 bg-gray-300 dark:bg-gray-600"></div>
|
||||||
|
<button
|
||||||
|
onClick={handlePrint}
|
||||||
|
disabled={reportData.length === 0}
|
||||||
|
className="px-4 py-2.5 text-sm font-medium text-gray-700 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors flex items-center gap-2 disabled:opacity-50"
|
||||||
|
title="Print"
|
||||||
|
>
|
||||||
|
<FaPrint className="text-purple-600" size={14} />
|
||||||
|
<span>Print</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Expandable Filters */}
|
||||||
|
{filtersExpanded && (
|
||||||
|
<div className="mt-4 p-4 bg-gray-50 dark:bg-gray-900/50 rounded-lg border border-gray-200 dark:border-gray-700">
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||||
|
{/* Work Order Type Filter */}
|
||||||
|
<div className="relative z-[70]">
|
||||||
|
<LinkField
|
||||||
|
label="Work Order Type"
|
||||||
|
doctype="Issue Type"
|
||||||
|
value={filterWorkOrderType}
|
||||||
|
onChange={(val) => setFilterWorkOrderType(val)}
|
||||||
|
placeholder="All Types"
|
||||||
|
disabled={false}
|
||||||
|
compact={false}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Status Filter */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||||
|
Work Order Status
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={filterStatus}
|
||||||
|
onChange={(e) => setFilterStatus(e.target.value)}
|
||||||
|
className="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-indigo-500 bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
|
||||||
|
>
|
||||||
|
<option value="">All Status</option>
|
||||||
|
<option value="Open">Open</option>
|
||||||
|
<option value="Work In Progress">Work In Progress</option>
|
||||||
|
<option value="Pending Review">Pending Review</option>
|
||||||
|
<option value="Completed">Completed</option>
|
||||||
|
<option value="Executed">Executed</option>
|
||||||
|
<option value="Cancelled">Cancelled</option>
|
||||||
|
<option value="Closed">Closed</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Spacer */}
|
||||||
|
<div></div>
|
||||||
|
|
||||||
|
{/* Clear Filters Button */}
|
||||||
|
<div className="flex items-end">
|
||||||
|
<button
|
||||||
|
onClick={handleClearFilters}
|
||||||
|
disabled={!filterWorkOrderType && !filterStatus}
|
||||||
|
className="px-4 py-2 text-sm font-medium text-gray-600 dark:text-gray-400 hover:text-red-600 dark:hover:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20 rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<FaTimes size={12} />
|
||||||
|
Clear Filters
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content - Full height scrollable area */}
|
||||||
|
<div className="flex-1 overflow-auto p-6">
|
||||||
|
{/* Loading State */}
|
||||||
|
{loading && (
|
||||||
|
<div className="flex items-center justify-center py-20">
|
||||||
|
<div className="text-center">
|
||||||
|
<FaSpinner className="animate-spin text-indigo-500 text-5xl mx-auto mb-4" />
|
||||||
|
<p className="text-gray-600 dark:text-gray-400 text-lg">Loading report data...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Error State */}
|
||||||
|
{error && !loading && (
|
||||||
|
<div className="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-8 text-center max-w-lg mx-auto">
|
||||||
|
<p className="text-red-600 dark:text-red-400 mb-4 text-lg">{error}</p>
|
||||||
|
<button
|
||||||
|
onClick={fetchReportData}
|
||||||
|
className="px-6 py-2 bg-red-600 hover:bg-red-700 text-white rounded-lg text-sm font-medium transition-colors"
|
||||||
|
>
|
||||||
|
Try Again
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Empty State */}
|
||||||
|
{!loading && !error && reportData.length === 0 && (
|
||||||
|
<div className="text-center py-20">
|
||||||
|
<FaSearch className="text-gray-300 dark:text-gray-600 text-6xl mx-auto mb-4" />
|
||||||
|
<p className="text-gray-500 dark:text-gray-400 text-xl">No data found</p>
|
||||||
|
<p className="text-gray-400 dark:text-gray-500 text-sm mt-2">
|
||||||
|
Try adjusting your filters
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Data Table */}
|
||||||
|
{!loading && !error && reportData.length > 0 && (
|
||||||
|
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 overflow-hidden shadow-sm">
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full">
|
||||||
|
<thead className="bg-gray-100 dark:bg-gray-700 sticky top-0">
|
||||||
|
<tr>
|
||||||
|
<th className="px-4 py-3 text-left text-xs font-semibold text-gray-600 dark:text-gray-300 uppercase tracking-wider w-16">
|
||||||
|
#
|
||||||
|
</th>
|
||||||
|
{columns.map((col, index) => (
|
||||||
|
<th
|
||||||
|
key={index}
|
||||||
|
className="px-4 py-3 text-left text-xs font-semibold text-gray-600 dark:text-gray-300 uppercase tracking-wider whitespace-nowrap"
|
||||||
|
style={{ minWidth: col.width || 120 }}
|
||||||
|
>
|
||||||
|
{col.label}
|
||||||
|
</th>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-gray-200 dark:divide-gray-700">
|
||||||
|
{reportData.map((row, rowIndex) => (
|
||||||
|
<tr
|
||||||
|
key={rowIndex}
|
||||||
|
className="hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-colors"
|
||||||
|
>
|
||||||
|
<td className="px-4 py-3 text-sm text-gray-500 dark:text-gray-400 font-medium">
|
||||||
|
{rowIndex + 1}
|
||||||
|
</td>
|
||||||
|
{columns.map((col, colIndex) => {
|
||||||
|
const value = row[col.fieldname];
|
||||||
|
const formattedValue = formatCellValue(value, col);
|
||||||
|
|
||||||
|
// Special rendering for status
|
||||||
|
if (col.fieldname === 'repair_status') {
|
||||||
|
return (
|
||||||
|
<td key={colIndex} className="px-4 py-3 whitespace-nowrap">
|
||||||
|
<span className={`px-3 py-1 rounded-full text-xs font-medium ${getStatusColor(value)}`}>
|
||||||
|
{formattedValue}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Special rendering for priority
|
||||||
|
if (col.fieldname === 'custom_priority_') {
|
||||||
|
return (
|
||||||
|
<td key={colIndex} className="px-4 py-3 whitespace-nowrap">
|
||||||
|
<span className={`px-3 py-1 rounded-full text-xs font-medium ${getPriorityColor(value)}`}>
|
||||||
|
{formattedValue}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Link fields - make clickable
|
||||||
|
if (col.fieldtype === 'Link' && col.fieldname === 'name') {
|
||||||
|
return (
|
||||||
|
<td key={colIndex} className="px-4 py-3 whitespace-nowrap">
|
||||||
|
<a
|
||||||
|
href={`/asm_app/work-orders/${value}`}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="text-indigo-600 dark:text-indigo-400 hover:underline font-medium text-sm"
|
||||||
|
>
|
||||||
|
{formattedValue}
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default rendering
|
||||||
|
return (
|
||||||
|
<td
|
||||||
|
key={colIndex}
|
||||||
|
className="px-4 py-3 text-sm text-gray-700 dark:text-gray-300"
|
||||||
|
title={typeof value === 'string' && value.length > 50 ? value : undefined}
|
||||||
|
>
|
||||||
|
<div className="max-w-xs truncate">
|
||||||
|
{formattedValue}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<div className="bg-white dark:bg-gray-800 px-6 py-4 border-t border-gray-200 dark:border-gray-700 flex-shrink-0 shadow-inner">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
Showing {reportData.length} record{reportData.length !== 1 ? 's' : ''}
|
||||||
|
{(filterWorkOrderType || filterStatus) && ' (filtered)'}
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="px-6 py-2 bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 text-gray-700 dark:text-gray-300 rounded-lg text-sm font-medium transition-colors"
|
||||||
|
>
|
||||||
|
Close
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default WorkOrderReportModal;
|
||||||
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;
|
||||||
105
asm_app/src/config/api.ts
Normal file
105
asm_app/src/config/api.ts
Normal file
@ -0,0 +1,105 @@
|
|||||||
|
// 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://kfsh-dammam-asm.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',
|
||||||
|
|
||||||
|
TECHNICIAN_WORKING_HOURS: '/api/method/asset_lite.api.dashboard_api.get_technician_working_hours',
|
||||||
|
TECHNICIAN_WORK_SUMMARY: '/api/method/asset_lite.api.dashboard_api.get_technician_work_summary',
|
||||||
|
|
||||||
|
// 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 };
|
||||||
|
}
|
||||||
|
|
||||||
111
asm_app/src/hooks/useAuditLogs.ts
Normal file
111
asm_app/src/hooks/useAuditLogs.ts
Normal file
@ -0,0 +1,111 @@
|
|||||||
|
import { useState, useCallback, useEffect } from 'react';
|
||||||
|
import apiService from '../services/apiService';
|
||||||
|
|
||||||
|
// ============== INTERFACES ==============
|
||||||
|
|
||||||
|
export interface VersionChange {
|
||||||
|
field: string;
|
||||||
|
oldValue: any;
|
||||||
|
newValue: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AuditLogEntry {
|
||||||
|
name: string;
|
||||||
|
owner: string;
|
||||||
|
creation: string;
|
||||||
|
changes: VersionChange[];
|
||||||
|
added: any[];
|
||||||
|
removed: any[];
|
||||||
|
rowChanged: any[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UseAuditLogsOptions {
|
||||||
|
doctype: string;
|
||||||
|
docname: string | null;
|
||||||
|
limit?: number;
|
||||||
|
enabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UseAuditLogsReturn {
|
||||||
|
auditLogs: AuditLogEntry[];
|
||||||
|
loading: boolean;
|
||||||
|
error: string | null;
|
||||||
|
refetch: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============== HOOK ==============
|
||||||
|
|
||||||
|
export const useAuditLogs = ({
|
||||||
|
doctype,
|
||||||
|
docname,
|
||||||
|
limit = 50,
|
||||||
|
enabled = true,
|
||||||
|
}: UseAuditLogsOptions): UseAuditLogsReturn => {
|
||||||
|
const [auditLogs, setAuditLogs] = useState<AuditLogEntry[]>([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const fetchAuditLogs = useCallback(async () => {
|
||||||
|
if (!enabled || !doctype || !docname) return;
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await apiService.apiCall<any>(
|
||||||
|
`/api/resource/Version?filters=[["ref_doctype","=","${encodeURIComponent(doctype)}"],["docname","=","${encodeURIComponent(docname)}"]]&fields=["name","owner","creation","data"]&order_by=creation desc&limit=${limit}`
|
||||||
|
);
|
||||||
|
|
||||||
|
if (response?.data && response.data.length > 0) {
|
||||||
|
const parsedLogs: AuditLogEntry[] = response.data.map((version: any) => {
|
||||||
|
let parsedData = { added: [], changed: [], removed: [], row_changed: [] };
|
||||||
|
try {
|
||||||
|
parsedData = JSON.parse(version.data || '{}');
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Error parsing version data:', e);
|
||||||
|
}
|
||||||
|
|
||||||
|
const changes: VersionChange[] = (parsedData.changed || []).map((change: any[]) => ({
|
||||||
|
field: change[0] || '',
|
||||||
|
oldValue: change[1],
|
||||||
|
newValue: change[2],
|
||||||
|
}));
|
||||||
|
|
||||||
|
return {
|
||||||
|
name: version.name,
|
||||||
|
owner: version.owner,
|
||||||
|
creation: version.creation,
|
||||||
|
changes,
|
||||||
|
added: parsedData.added || [],
|
||||||
|
removed: parsedData.removed || [],
|
||||||
|
rowChanged: parsedData.row_changed || [],
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
setAuditLogs(parsedLogs);
|
||||||
|
} else {
|
||||||
|
setAuditLogs([]);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`Error fetching audit logs for ${doctype}/${docname}:`, err);
|
||||||
|
setError(err instanceof Error ? err.message : 'Failed to load activity log');
|
||||||
|
setAuditLogs([]);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [doctype, docname, limit, enabled]);
|
||||||
|
|
||||||
|
// Initial fetch
|
||||||
|
useEffect(() => {
|
||||||
|
fetchAuditLogs();
|
||||||
|
}, [fetchAuditLogs]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
auditLogs,
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
refetch: fetchAuditLogs,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export default useAuditLogs;
|
||||||
143
asm_app/src/hooks/useComments.ts
Normal file
143
asm_app/src/hooks/useComments.ts
Normal file
@ -0,0 +1,143 @@
|
|||||||
|
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||||
|
import commentService, { type CommentData, type MentionUser } from '../services/commentService';
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// useComments – reusable hook for any doctype's comment section
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
interface UseCommentsOptions {
|
||||||
|
referenceDoctype: string;
|
||||||
|
referenceName: string | null;
|
||||||
|
/** Auto-refresh interval in ms (0 = off). Default 30 000 */
|
||||||
|
pollInterval?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UseCommentsReturn {
|
||||||
|
comments: CommentData[];
|
||||||
|
loading: boolean;
|
||||||
|
posting: boolean;
|
||||||
|
error: string | null;
|
||||||
|
currentUser: string;
|
||||||
|
refetch: () => Promise<void>;
|
||||||
|
postComment: (content: string) => Promise<void>;
|
||||||
|
deleteComment: (commentName: string) => Promise<void>;
|
||||||
|
// Mention helpers
|
||||||
|
mentionUsers: MentionUser[];
|
||||||
|
mentionLoading: boolean;
|
||||||
|
searchMentionUsers: (query: string) => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useComments({
|
||||||
|
referenceDoctype,
|
||||||
|
referenceName,
|
||||||
|
pollInterval = 30000,
|
||||||
|
}: UseCommentsOptions): UseCommentsReturn {
|
||||||
|
const [comments, setComments] = useState<CommentData[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [posting, setPosting] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [currentUser, setCurrentUser] = useState('');
|
||||||
|
|
||||||
|
// Mention state
|
||||||
|
const [mentionUsers, setMentionUsers] = useState<MentionUser[]>([]);
|
||||||
|
const [mentionLoading, setMentionLoading] = useState(false);
|
||||||
|
|
||||||
|
const mentionSearchTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
|
||||||
|
// ── Fetch current user once ─────────────────────────────
|
||||||
|
useEffect(() => {
|
||||||
|
commentService.getCurrentUser().then(setCurrentUser).catch(() => {});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// ── Fetch comments ──────────────────────────────────────
|
||||||
|
const fetchComments = useCallback(async () => {
|
||||||
|
if (!referenceName) {
|
||||||
|
setComments([]);
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const data = await commentService.getComments(referenceDoctype, referenceName);
|
||||||
|
setComments(data);
|
||||||
|
setError(null);
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error('Error fetching comments:', err);
|
||||||
|
setError(err.message || 'Failed to load comments');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [referenceDoctype, referenceName]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setLoading(true);
|
||||||
|
fetchComments();
|
||||||
|
}, [fetchComments]);
|
||||||
|
|
||||||
|
// ── Optional polling ────────────────────────────────────
|
||||||
|
useEffect(() => {
|
||||||
|
if (!pollInterval || !referenceName) return;
|
||||||
|
const id = setInterval(fetchComments, pollInterval);
|
||||||
|
return () => clearInterval(id);
|
||||||
|
}, [pollInterval, fetchComments, referenceName]);
|
||||||
|
|
||||||
|
// ── Post comment ────────────────────────────────────────
|
||||||
|
const postComment = useCallback(
|
||||||
|
async (content: string) => {
|
||||||
|
if (!referenceName) return;
|
||||||
|
setPosting(true);
|
||||||
|
try {
|
||||||
|
await commentService.postComment(referenceDoctype, referenceName, content);
|
||||||
|
await fetchComments();
|
||||||
|
} catch (err: any) {
|
||||||
|
throw err; // let caller handle toast
|
||||||
|
} finally {
|
||||||
|
setPosting(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[referenceDoctype, referenceName, fetchComments]
|
||||||
|
);
|
||||||
|
|
||||||
|
// ── Delete comment ──────────────────────────────────────
|
||||||
|
const deleteComment = useCallback(
|
||||||
|
async (commentName: string) => {
|
||||||
|
try {
|
||||||
|
await commentService.deleteComment(commentName);
|
||||||
|
setComments((prev) => prev.filter((c) => c.name !== commentName));
|
||||||
|
} catch (err: any) {
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
// ── Mention user search (debounced) ─────────────────────
|
||||||
|
const searchMentionUsers = useCallback(async (query: string) => {
|
||||||
|
if (mentionSearchTimer.current) clearTimeout(mentionSearchTimer.current);
|
||||||
|
|
||||||
|
setMentionLoading(true);
|
||||||
|
mentionSearchTimer.current = setTimeout(async () => {
|
||||||
|
try {
|
||||||
|
const users = await commentService.searchUsers(query);
|
||||||
|
setMentionUsers(users);
|
||||||
|
} catch {
|
||||||
|
setMentionUsers([]);
|
||||||
|
} finally {
|
||||||
|
setMentionLoading(false);
|
||||||
|
}
|
||||||
|
}, 250);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return {
|
||||||
|
comments,
|
||||||
|
loading,
|
||||||
|
posting,
|
||||||
|
error,
|
||||||
|
currentUser,
|
||||||
|
refetch: fetchComments,
|
||||||
|
postComment,
|
||||||
|
deleteComment,
|
||||||
|
mentionUsers,
|
||||||
|
mentionLoading,
|
||||||
|
searchMentionUsers,
|
||||||
|
};
|
||||||
|
}
|
||||||
218
asm_app/src/hooks/useDeleteRequest.ts
Normal file
218
asm_app/src/hooks/useDeleteRequest.ts
Normal file
@ -0,0 +1,218 @@
|
|||||||
|
// hooks/useDeleteRequest.ts
|
||||||
|
import { useState, useCallback, useMemo } from 'react';
|
||||||
|
import {
|
||||||
|
updateDeleteStatus,
|
||||||
|
type DeleteStatus,
|
||||||
|
} from '../services/deleteRequestService';
|
||||||
|
|
||||||
|
export type UserRoleContext = {
|
||||||
|
userRoles: string[];
|
||||||
|
isSystemManager: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type DeleteRequestAction =
|
||||||
|
| 'raise_request' // End user cannot; roles below Supervisor raise to Supervisor
|
||||||
|
| 'approve_supervisor' // Contractor Supervisor → set to "Delete Request With CM"
|
||||||
|
| 'approve_cm' // Cluster Manager → set to "Deleted"
|
||||||
|
| 'direct_delete' // CM or System Manager can skip straight to "Deleted"
|
||||||
|
| null;
|
||||||
|
|
||||||
|
export interface DeleteRequestState {
|
||||||
|
/** What button(s) to show */
|
||||||
|
showRaiseRequest: boolean;
|
||||||
|
showApproveAsSupervisor: boolean; // "Approve Request" (Supervisor view)
|
||||||
|
showApproveAsCM: boolean; // "Approve & Delete" (CM view)
|
||||||
|
showDirectDelete: boolean; // CM / System Manager direct delete
|
||||||
|
/** Current status */
|
||||||
|
deleteStatus: DeleteStatus;
|
||||||
|
/** Loading / error */
|
||||||
|
loading: boolean;
|
||||||
|
error: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Role resolution order (highest first):
|
||||||
|
* System Manager > Cluster Manager > Contractor Supervisor > everyone else (non-End-user)
|
||||||
|
*
|
||||||
|
* "End user" role: cannot raise a request at all.
|
||||||
|
*/
|
||||||
|
function resolveHighestRole(userRoles: string[], isSystemManager: boolean): string {
|
||||||
|
|
||||||
|
console.log('[DeleteRequest] userRoles:', userRoles, '| isSystemManager:', isSystemManager);
|
||||||
|
|
||||||
|
if (isSystemManager || userRoles.includes('System Manager')) return 'System Manager';
|
||||||
|
if (userRoles.includes('Cluster Manager')) return 'Cluster Manager';
|
||||||
|
// if (userRoles.includes('Contractor Supervisor')) return 'Contractor Supervisor';
|
||||||
|
if (userRoles.includes('Contractor Supervisor') || userRoles.includes('DR Approver'))
|
||||||
|
return 'Contractor Supervisor';
|
||||||
|
if (userRoles.includes('End user')) return 'End user';
|
||||||
|
// Any other authenticated role (Work Control, Technician, etc.) can raise to Supervisor
|
||||||
|
return 'Other';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Derive which buttons should be visible given the current delete status and user role.
|
||||||
|
*
|
||||||
|
* Rules (from your spec):
|
||||||
|
* ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
* No status set yet (null / ''):
|
||||||
|
* • End user → nothing (no delete rights at all)
|
||||||
|
* • Other / Work Ctrl → show "Request Deletion" → sets to "Delete Request With Supervisor"
|
||||||
|
* • Contractor Sup → show "Request Deletion" → sets to "Delete Request With CM"
|
||||||
|
* • Cluster Manager → show "Delete" (direct) → sets to "Deleted"
|
||||||
|
* • System Manager → show "Delete" (direct) → sets to "Deleted"
|
||||||
|
*
|
||||||
|
* status = "Delete Request With Supervisor":
|
||||||
|
* • Contractor Sup → show "Approve Request" → sets to "Delete Request With CM"
|
||||||
|
* • CM / Sys Mgr → show "Approve & Delete" → sets to "Deleted"
|
||||||
|
* • Others → nothing new (request already raised)
|
||||||
|
*
|
||||||
|
* status = "Delete Request With CM":
|
||||||
|
* • CM / Sys Mgr → show "Approve & Delete" → sets to "Deleted"
|
||||||
|
* • Others → nothing
|
||||||
|
*
|
||||||
|
* status = "Deleted":
|
||||||
|
* → nothing (entry already marked deleted)
|
||||||
|
* ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
*/
|
||||||
|
function computeVisibility(
|
||||||
|
role: string,
|
||||||
|
deleteStatus: string | null | undefined,
|
||||||
|
): Pick<
|
||||||
|
DeleteRequestState,
|
||||||
|
'showRaiseRequest' | 'showApproveAsSupervisor' | 'showApproveAsCM' | 'showDirectDelete'
|
||||||
|
> {
|
||||||
|
const none = {
|
||||||
|
showRaiseRequest: false,
|
||||||
|
showApproveAsSupervisor: false,
|
||||||
|
showApproveAsCM: false,
|
||||||
|
showDirectDelete: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Already deleted — nothing to show
|
||||||
|
if (deleteStatus === 'Deleted') return none;
|
||||||
|
|
||||||
|
if (!deleteStatus || deleteStatus === '') {
|
||||||
|
// Fresh document — no delete request raised yet
|
||||||
|
if (role === 'End user') return none;
|
||||||
|
if (role === 'System Manager' || role === 'Cluster Manager') {
|
||||||
|
return { ...none, showDirectDelete: true };
|
||||||
|
}
|
||||||
|
// Contractor Supervisor: raise directly to CM level
|
||||||
|
if (role === 'Contractor Supervisor') {
|
||||||
|
return { ...none, showRaiseRequest: true };
|
||||||
|
}
|
||||||
|
// Work Control / Other authenticated roles
|
||||||
|
return { ...none, showRaiseRequest: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (deleteStatus === 'Delete Request With Supervisor') {
|
||||||
|
if (role === 'Contractor Supervisor') {
|
||||||
|
return { ...none, showApproveAsSupervisor: true };
|
||||||
|
}
|
||||||
|
if (role === 'System Manager' || role === 'Cluster Manager') {
|
||||||
|
return { ...none, showApproveAsCM: true };
|
||||||
|
}
|
||||||
|
return none; // request already in-flight for others
|
||||||
|
}
|
||||||
|
|
||||||
|
if (deleteStatus === 'Delete Request With CM') {
|
||||||
|
if (role === 'System Manager' || role === 'Cluster Manager') {
|
||||||
|
return { ...none, showApproveAsCM: true };
|
||||||
|
}
|
||||||
|
return none;
|
||||||
|
}
|
||||||
|
|
||||||
|
return none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determine the next status to set when a button is clicked.
|
||||||
|
* Reject always resets to '' (empty) regardless of role — clears the request entirely.
|
||||||
|
*/
|
||||||
|
function nextStatus(
|
||||||
|
action: 'raise' | 'supervisor_approve' | 'cm_approve' | 'direct' | 'reject',
|
||||||
|
role: string
|
||||||
|
): DeleteStatus {
|
||||||
|
if (action === 'reject') return '' as DeleteStatus;
|
||||||
|
if (action === 'direct' || action === 'cm_approve') return 'Deleted';
|
||||||
|
if (action === 'supervisor_approve') return 'Delete Request With CM';
|
||||||
|
// raise_request
|
||||||
|
if (role === 'Contractor Supervisor') return 'Delete Request With CM';
|
||||||
|
return 'Delete Request With Supervisor';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
interface UseDeleteRequestOptions {
|
||||||
|
doctype: string;
|
||||||
|
docname: string | null | undefined;
|
||||||
|
currentDeleteStatus: DeleteStatus;
|
||||||
|
userRoles: string[];
|
||||||
|
isSystemManager: boolean;
|
||||||
|
onSuccess?: (newStatus: DeleteStatus) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useDeleteRequest({
|
||||||
|
doctype,
|
||||||
|
docname,
|
||||||
|
currentDeleteStatus,
|
||||||
|
userRoles,
|
||||||
|
isSystemManager,
|
||||||
|
onSuccess,
|
||||||
|
}: UseDeleteRequestOptions) {
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [localStatus, setLocalStatus] = useState<DeleteStatus>(currentDeleteStatus);
|
||||||
|
|
||||||
|
// Keep local status in sync when prop changes (e.g. after refetch)
|
||||||
|
const effectiveStatus: DeleteStatus =
|
||||||
|
localStatus !== currentDeleteStatus ? localStatus : currentDeleteStatus;
|
||||||
|
|
||||||
|
const highestRole = useMemo(
|
||||||
|
() => resolveHighestRole(userRoles, isSystemManager),
|
||||||
|
[userRoles, isSystemManager]
|
||||||
|
);
|
||||||
|
|
||||||
|
const visibility = useMemo(
|
||||||
|
() => computeVisibility(highestRole, effectiveStatus),
|
||||||
|
[highestRole, effectiveStatus]
|
||||||
|
);
|
||||||
|
|
||||||
|
const execute = useCallback(
|
||||||
|
async (action: 'raise' | 'supervisor_approve' | 'cm_approve' | 'direct' | 'reject') => {
|
||||||
|
if (!docname) return;
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
const targetStatus = nextStatus(action, highestRole);
|
||||||
|
const result = await updateDeleteStatus(doctype, docname, targetStatus);
|
||||||
|
|
||||||
|
setLoading(false);
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
setLocalStatus(targetStatus);
|
||||||
|
onSuccess?.(targetStatus);
|
||||||
|
} else {
|
||||||
|
setError(result.error || 'Failed to update delete status');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[doctype, docname, highestRole, onSuccess]
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...visibility,
|
||||||
|
deleteStatus: effectiveStatus,
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
highestRole,
|
||||||
|
/** Actions */
|
||||||
|
raiseRequest: () => execute('raise'),
|
||||||
|
approveAsSupervisor: () => execute('supervisor_approve'),
|
||||||
|
approveAsCM: () => execute('cm_approve'),
|
||||||
|
directDelete: () => execute('direct'),
|
||||||
|
rejectRequest: () => execute('reject'), // ← resets status to '' (empty)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export type { DeleteStatus };
|
||||||
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 };
|
||||||
|
};
|
||||||
|
|
||||||
153
asm_app/src/hooks/useDoctypeFields.ts
Normal file
153
asm_app/src/hooks/useDoctypeFields.ts
Normal file
@ -0,0 +1,153 @@
|
|||||||
|
/**
|
||||||
|
* useDoctypeFields.ts — v6 (server-side field resolution)
|
||||||
|
*
|
||||||
|
* ROOT CAUSE OF ALL PREVIOUS VERSIONS:
|
||||||
|
* ─────────────────────────────────────
|
||||||
|
* Frappe's meta APIs (/api/resource/DocType and /api/resource/Custom Field)
|
||||||
|
* are ROLE-FILTERED by design. Frappe strips fields the current user's role
|
||||||
|
* cannot read BEFORE returning the response. No client-side cache strategy
|
||||||
|
* can fix this because the *source data* is already wrong.
|
||||||
|
*
|
||||||
|
* Example:
|
||||||
|
* Contractor Engineer → /api/resource/DocType/Work_Order → 6 fields
|
||||||
|
* Administrator → /api/resource/DocType/Work_Order → 45 fields
|
||||||
|
*
|
||||||
|
* THE REAL FIX:
|
||||||
|
* ─────────────
|
||||||
|
* Call a whitelisted Python function (`asset_lite.api.doctype_fields.get_export_fields`)
|
||||||
|
* that uses `frappe.get_meta()` and `frappe.get_all(..., ignore_permissions=True)`.
|
||||||
|
* These bypass field-level role filtering and always return the complete list.
|
||||||
|
*
|
||||||
|
* The in-memory cache below is fine to key by doctype only (no user suffix
|
||||||
|
* needed) because the server now returns the same field list for everyone.
|
||||||
|
* sessionStorage is intentionally NOT used — a hard refresh always gets fresh
|
||||||
|
* data, avoiding the "need Ctrl+Shift+R" problem entirely.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useState, useEffect, useRef } from 'react';
|
||||||
|
|
||||||
|
export interface DoctypeField {
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
fieldtype: string;
|
||||||
|
default: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Default fields per DocType ────────────────────────────────────────────────
|
||||||
|
const DEFAULT_FIELDS: Record<string, Set<string>> = {
|
||||||
|
Work_Order: new Set([
|
||||||
|
'name', 'asset', 'asset_name', 'work_order_type', 'company',
|
||||||
|
'department', 'repair_status', 'workflow_state', 'custom_priority_',
|
||||||
|
'creation', 'modified',
|
||||||
|
]),
|
||||||
|
Asset: new Set([
|
||||||
|
'name', 'asset_name', 'custom_serial_number', 'company',
|
||||||
|
'location', 'custom_device_status', 'modified',
|
||||||
|
]),
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── In-memory cache (tab lifetime only, keyed by doctype) ────────────────────
|
||||||
|
// Safe to key by doctype only now because the server returns role-independent
|
||||||
|
// results. Clears automatically on page refresh — no stale data ever.
|
||||||
|
const memCache = new Map<string, DoctypeField[]>();
|
||||||
|
|
||||||
|
// ── Hook ──────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function useDoctypeFields(doctype: string) {
|
||||||
|
const [fields, setFields] = useState<DoctypeField[]>([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const fetchingRef = useRef(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!doctype) return;
|
||||||
|
let cancelled = false;
|
||||||
|
|
||||||
|
const run = async () => {
|
||||||
|
if (fetchingRef.current) return;
|
||||||
|
fetchingRef.current = true;
|
||||||
|
|
||||||
|
// ── In-memory cache hit ───────────────────────────────────────────────
|
||||||
|
if (memCache.has(doctype)) {
|
||||||
|
if (!cancelled) setFields(memCache.get(doctype)!);
|
||||||
|
fetchingRef.current = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!cancelled) { setLoading(true); setError(null); }
|
||||||
|
|
||||||
|
try {
|
||||||
|
// ── Call the server-side whitelisted function ─────────────────────
|
||||||
|
// This uses frappe.get_meta() + ignore_permissions=True internally,
|
||||||
|
// so it returns ALL fields regardless of the current user's role.
|
||||||
|
const res = await fetch(
|
||||||
|
'/api/method/asset_lite.api.doctype_fields.get_export_fields',
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
credentials: 'include',
|
||||||
|
body: JSON.stringify({ doctype }),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(`Server returned ${res.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await res.json();
|
||||||
|
|
||||||
|
if (data.exc) {
|
||||||
|
throw new Error(data.exc);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Server returns: [{ fieldname, label, fieldtype }, ...]
|
||||||
|
const raw: { fieldname: string; label: string; fieldtype: string }[] =
|
||||||
|
data.message || [];
|
||||||
|
|
||||||
|
const defaultSet = DEFAULT_FIELDS[doctype];
|
||||||
|
|
||||||
|
const normalized: DoctypeField[] = raw.map((f, idx) => ({
|
||||||
|
key: f.fieldname,
|
||||||
|
label: f.label || f.fieldname,
|
||||||
|
fieldtype: f.fieldtype || 'Data',
|
||||||
|
default: defaultSet ? defaultSet.has(f.fieldname) : idx < 8,
|
||||||
|
}));
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
`[useDoctypeFields] ✅ "${doctype}": ${normalized.length} fields from server`
|
||||||
|
);
|
||||||
|
|
||||||
|
memCache.set(doctype, normalized);
|
||||||
|
if (!cancelled) setFields(normalized);
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[useDoctypeFields] ❌', err);
|
||||||
|
if (!cancelled) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Failed to fetch fields');
|
||||||
|
// Minimal fallback so the export modal isn't completely broken
|
||||||
|
setFields([
|
||||||
|
{ key: 'name', label: 'ID', fieldtype: 'Data', default: true },
|
||||||
|
{ key: 'creation', label: 'Created On', fieldtype: 'Datetime', default: false },
|
||||||
|
{ key: 'modified', label: 'Modified On', fieldtype: 'Datetime', default: true },
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
fetchingRef.current = false;
|
||||||
|
if (!cancelled) setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
run();
|
||||||
|
return () => { cancelled = true; };
|
||||||
|
}, [doctype]);
|
||||||
|
|
||||||
|
/** Force a re-fetch from the server (e.g. after DocType schema changes). */
|
||||||
|
const refetchFields = () => {
|
||||||
|
memCache.delete(doctype);
|
||||||
|
fetchingRef.current = false;
|
||||||
|
setFields([]);
|
||||||
|
setError(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
return { fields, loading, error, refetchFields };
|
||||||
|
}
|
||||||
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;
|
||||||
|
|
||||||
167
asm_app/src/hooks/useInspection.ts
Normal file
167
asm_app/src/hooks/useInspection.ts
Normal file
@ -0,0 +1,167 @@
|
|||||||
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
|
import inspectionService, {
|
||||||
|
type Inspection,
|
||||||
|
type InspectionListParams,
|
||||||
|
type CreateInspectionData,
|
||||||
|
} from '../services/inspectionService';
|
||||||
|
|
||||||
|
interface UseInspectionListResult {
|
||||||
|
inspections: Inspection[];
|
||||||
|
loading: boolean;
|
||||||
|
error: string | null;
|
||||||
|
totalCount: number;
|
||||||
|
refetch: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UseInspectionDetailsResult {
|
||||||
|
inspection: Inspection | null;
|
||||||
|
loading: boolean;
|
||||||
|
error: string | null;
|
||||||
|
refetch: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UseInspectionMutationsResult {
|
||||||
|
createInspection: (data: CreateInspectionData) => Promise<Inspection>;
|
||||||
|
updateInspection: (name: string, data: Partial<CreateInspectionData>) => Promise<Inspection>;
|
||||||
|
deleteInspection: (name: string) => Promise<void>;
|
||||||
|
loading: boolean;
|
||||||
|
error: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hook for fetching inspection list
|
||||||
|
export function useInspectionList(params: InspectionListParams = {}): UseInspectionListResult {
|
||||||
|
const [inspections, setInspections] = useState<Inspection[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [totalCount, setTotalCount] = useState(0);
|
||||||
|
|
||||||
|
const fetchInspections = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
// Fetch inspections and count in parallel
|
||||||
|
const [listResponse, count] = await Promise.all([
|
||||||
|
inspectionService.getInspections(params),
|
||||||
|
inspectionService.getInspectionCount(params.filters || {})
|
||||||
|
]);
|
||||||
|
|
||||||
|
setInspections(listResponse.data);
|
||||||
|
setTotalCount(count);
|
||||||
|
} catch (err) {
|
||||||
|
const errorMessage = err instanceof Error ? err.message : 'Failed to fetch inspections';
|
||||||
|
setError(errorMessage);
|
||||||
|
console.error('Error fetching inspections:', err);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [JSON.stringify(params)]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchInspections();
|
||||||
|
}, [fetchInspections]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
inspections,
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
totalCount,
|
||||||
|
refetch: fetchInspections
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hook for fetching single inspection details
|
||||||
|
export function useInspectionDetails(name: string | null): UseInspectionDetailsResult {
|
||||||
|
const [inspection, setInspection] = useState<Inspection | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const fetchInspection = useCallback(async () => {
|
||||||
|
if (!name) {
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
const data = await inspectionService.getInspection(name);
|
||||||
|
setInspection(data);
|
||||||
|
} catch (err) {
|
||||||
|
const errorMessage = err instanceof Error ? err.message : 'Failed to fetch inspection';
|
||||||
|
setError(errorMessage);
|
||||||
|
console.error('Error fetching inspection:', err);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [name]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchInspection();
|
||||||
|
}, [fetchInspection]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
inspection,
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
refetch: fetchInspection
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hook for inspection mutations (create, update, delete)
|
||||||
|
export function useInspectionMutations(): UseInspectionMutationsResult {
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const createInspection = async (data: CreateInspectionData): Promise<Inspection> => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
const result = await inspectionService.createInspection(data);
|
||||||
|
return result;
|
||||||
|
} catch (err) {
|
||||||
|
const errorMessage = err instanceof Error ? err.message : 'Failed to create inspection';
|
||||||
|
setError(errorMessage);
|
||||||
|
throw err;
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateInspection = async (name: string, data: Partial<CreateInspectionData>): Promise<Inspection> => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
const result = await inspectionService.updateInspection(name, data);
|
||||||
|
return result;
|
||||||
|
} catch (err) {
|
||||||
|
const errorMessage = err instanceof Error ? err.message : 'Failed to update inspection';
|
||||||
|
setError(errorMessage);
|
||||||
|
throw err;
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const deleteInspection = async (name: string): Promise<void> => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
await inspectionService.deleteInspection(name);
|
||||||
|
} catch (err) {
|
||||||
|
const errorMessage = err instanceof Error ? err.message : 'Failed to delete inspection';
|
||||||
|
setError(errorMessage);
|
||||||
|
throw err;
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
createInspection,
|
||||||
|
updateInspection,
|
||||||
|
deleteInspection,
|
||||||
|
loading,
|
||||||
|
error
|
||||||
|
};
|
||||||
|
}
|
||||||
133
asm_app/src/hooks/useIssue.ts
Normal file
133
asm_app/src/hooks/useIssue.ts
Normal file
@ -0,0 +1,133 @@
|
|||||||
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
|
import issueService, { type Issue, type CreateIssueData, type IssueListParams } from '../services/issueService';
|
||||||
|
|
||||||
|
// Hook for fetching issue list
|
||||||
|
export const useIssueList = (params: IssueListParams = {}) => {
|
||||||
|
const [issues, setIssues] = useState<Issue[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [totalCount, setTotalCount] = useState(0);
|
||||||
|
|
||||||
|
const fetchIssues = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
const response = await issueService.getIssues(params);
|
||||||
|
setIssues(response.data);
|
||||||
|
|
||||||
|
// Get total count for pagination
|
||||||
|
const count = await issueService.getIssueCount(params.filters);
|
||||||
|
setTotalCount(count);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Failed to fetch issues');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [JSON.stringify(params)]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchIssues();
|
||||||
|
}, [fetchIssues]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
issues,
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
totalCount,
|
||||||
|
refetch: fetchIssues,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
// Hook for fetching single issue details
|
||||||
|
export const useIssueDetails = (issueName: string | null) => {
|
||||||
|
const [issue, setIssue] = useState<Issue | null>(null);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const fetchIssue = useCallback(async () => {
|
||||||
|
if (!issueName) {
|
||||||
|
setIssue(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
const data = await issueService.getIssue(issueName);
|
||||||
|
setIssue(data);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Failed to fetch issue details');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [issueName]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchIssue();
|
||||||
|
}, [fetchIssue]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
issue,
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
refetch: fetchIssue,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
// Hook for issue mutations (create, update, delete)
|
||||||
|
export const useIssueMutations = () => {
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const createIssue = async (data: CreateIssueData): Promise<Issue> => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
const result = await issueService.createIssue(data);
|
||||||
|
return result;
|
||||||
|
} catch (err) {
|
||||||
|
const errorMessage = err instanceof Error ? err.message : 'Failed to create issue';
|
||||||
|
setError(errorMessage);
|
||||||
|
throw err;
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateIssue = async (name: string, data: Partial<CreateIssueData>): Promise<Issue> => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
const result = await issueService.updateIssue(name, data);
|
||||||
|
return result;
|
||||||
|
} catch (err) {
|
||||||
|
const errorMessage = err instanceof Error ? err.message : 'Failed to update issue';
|
||||||
|
setError(errorMessage);
|
||||||
|
throw err;
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const deleteIssue = async (name: string): Promise<void> => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
await issueService.deleteIssue(name);
|
||||||
|
} catch (err) {
|
||||||
|
const errorMessage = err instanceof Error ? err.message : 'Failed to delete issue';
|
||||||
|
setError(errorMessage);
|
||||||
|
throw err;
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
createIssue,
|
||||||
|
updateIssue,
|
||||||
|
deleteIssue,
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
};
|
||||||
|
};
|
||||||
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', 'is_fixed_asset', 'custom_hospital_name', 'opening_stock', 'valuation_rate', 'standard_rate', 'creation', 'modified', 'owner', 'docstatus', 'custom_serial_no', 'custom_date_in', 'custom_code', 'custom_type', 'custom_volts', 'custom_w', 'custom_delete_status'];
|
||||||
|
const response = await itemService.getItems(filters, fields, limit, offset, orderBy);
|
||||||
|
|
||||||
|
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 };
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
142
asm_app/src/hooks/useMaintenanceTeam.ts
Normal file
142
asm_app/src/hooks/useMaintenanceTeam.ts
Normal file
@ -0,0 +1,142 @@
|
|||||||
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
|
import maintenanceTeamService, {
|
||||||
|
type MaintenanceTeam,
|
||||||
|
type CreateMaintenanceTeamData,
|
||||||
|
type MaintenanceTeamListParams
|
||||||
|
} from '../services/maintenanceTeamService';
|
||||||
|
|
||||||
|
// Hook for fetching maintenance team list
|
||||||
|
export const useMaintenanceTeamList = (params: MaintenanceTeamListParams = {}) => {
|
||||||
|
const [teams, setTeams] = useState<MaintenanceTeam[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [totalCount, setTotalCount] = useState(0);
|
||||||
|
|
||||||
|
const fetchTeams = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
const response = await maintenanceTeamService.getMaintenanceTeams(params);
|
||||||
|
setTeams(response.data);
|
||||||
|
|
||||||
|
// Get total count for pagination
|
||||||
|
const count = await maintenanceTeamService.getMaintenanceTeamCount(params.filters);
|
||||||
|
setTotalCount(count);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Failed to fetch maintenance teams');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [JSON.stringify(params)]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchTeams();
|
||||||
|
}, [fetchTeams]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
teams,
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
totalCount,
|
||||||
|
refetch: fetchTeams,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
// Hook for fetching single maintenance team details
|
||||||
|
export const useMaintenanceTeamDetails = (teamName: string | null) => {
|
||||||
|
const [team, setTeam] = useState<MaintenanceTeam | null>(null);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const fetchTeam = useCallback(async () => {
|
||||||
|
if (!teamName) {
|
||||||
|
setTeam(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
const data = await maintenanceTeamService.getMaintenanceTeam(teamName);
|
||||||
|
setTeam(data);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Failed to fetch maintenance team details');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [teamName]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchTeam();
|
||||||
|
}, [fetchTeam]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
team,
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
refetch: fetchTeam,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
// Hook for maintenance team mutations (create, update, delete)
|
||||||
|
export const useMaintenanceTeamMutations = () => {
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const createTeam = async (data: CreateMaintenanceTeamData): Promise<MaintenanceTeam> => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
const result = await maintenanceTeamService.createMaintenanceTeam(data);
|
||||||
|
return result;
|
||||||
|
} catch (err) {
|
||||||
|
const errorMessage = err instanceof Error ? err.message : 'Failed to create maintenance team';
|
||||||
|
setError(errorMessage);
|
||||||
|
throw err;
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateTeam = async (name: string, data: Partial<CreateMaintenanceTeamData>): Promise<MaintenanceTeam> => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
const result = await maintenanceTeamService.updateMaintenanceTeam(name, data);
|
||||||
|
return result;
|
||||||
|
} catch (err) {
|
||||||
|
const errorMessage = err instanceof Error ? err.message : 'Failed to update maintenance team';
|
||||||
|
setError(errorMessage);
|
||||||
|
throw err;
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const deleteTeam = async (name: string): Promise<void> => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
await maintenanceTeamService.deleteMaintenanceTeam(name);
|
||||||
|
} catch (err) {
|
||||||
|
const errorMessage = err instanceof Error ? err.message : 'Failed to delete maintenance team';
|
||||||
|
setError(errorMessage);
|
||||||
|
throw err;
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getUserFullName = async (email: string): Promise<string> => {
|
||||||
|
return await maintenanceTeamService.getUserFullName(email);
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
createTeam,
|
||||||
|
updateTeam,
|
||||||
|
deleteTeam,
|
||||||
|
getUserFullName,
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
};
|
||||||
|
};
|
||||||
98
asm_app/src/hooks/useNotifications.ts
Normal file
98
asm_app/src/hooks/useNotifications.ts
Normal file
@ -0,0 +1,98 @@
|
|||||||
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
|
import notificationService, { type 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();
|
||||||
|
const filtered = data.filter(
|
||||||
|
(n) => !n.subject?.startsWith('Failed to send email')
|
||||||
|
);
|
||||||
|
setNotifications(filtered);
|
||||||
|
setUnreadCount(filtered.filter((n) => !n.read).length);
|
||||||
|
// 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);
|
||||||
|
} 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) => {
|
||||||
|
// Optimistic update
|
||||||
|
const previousNotifications = notifications;
|
||||||
|
const previousCount = unreadCount;
|
||||||
|
|
||||||
|
setNotifications(prev =>
|
||||||
|
prev.map(n => n.name === notificationName ? { ...n, read: 1 } : n)
|
||||||
|
);
|
||||||
|
setUnreadCount(prev => Math.max(0, prev - 1));
|
||||||
|
|
||||||
|
try {
|
||||||
|
await notificationService.markAsRead(notificationName);
|
||||||
|
} catch (error) {
|
||||||
|
// Rollback on failure
|
||||||
|
console.error('Error marking notification as read:', error);
|
||||||
|
setNotifications(previousNotifications);
|
||||||
|
setUnreadCount(previousCount);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}, [notifications, unreadCount]);
|
||||||
|
|
||||||
|
const markAllAsRead = useCallback(async () => {
|
||||||
|
// Optimistic update — clear red badges immediately
|
||||||
|
const previousNotifications = notifications;
|
||||||
|
const previousCount = unreadCount;
|
||||||
|
|
||||||
|
setNotifications(prev =>
|
||||||
|
prev.map(n => ({ ...n, read: 1 }))
|
||||||
|
);
|
||||||
|
setUnreadCount(0);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await notificationService.markAllAsRead();
|
||||||
|
} catch (error) {
|
||||||
|
// Rollback on failure
|
||||||
|
console.error('Error marking all notifications as read:', error);
|
||||||
|
setNotifications(previousNotifications);
|
||||||
|
setUnreadCount(previousCount);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}, [notifications, unreadCount]);
|
||||||
|
|
||||||
|
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 };
|
||||||
|
}
|
||||||
|
|
||||||
164
asm_app/src/hooks/useSupportPlan.ts
Normal file
164
asm_app/src/hooks/useSupportPlan.ts
Normal file
@ -0,0 +1,164 @@
|
|||||||
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
|
import supportPlanService, {
|
||||||
|
type SupportPlan,
|
||||||
|
type SupportPlanListParams,
|
||||||
|
type CreateSupportPlanData
|
||||||
|
} from '../services/supportPlanService';
|
||||||
|
|
||||||
|
// Hook for fetching support plan list
|
||||||
|
export const useSupportPlanList = (params: SupportPlanListParams = {}) => {
|
||||||
|
const [supportPlans, setSupportPlans] = useState<SupportPlan[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [totalCount, setTotalCount] = useState(0);
|
||||||
|
|
||||||
|
const fetchSupportPlans = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
// Fetch list
|
||||||
|
const response = await supportPlanService.getSupportPlans(params);
|
||||||
|
setSupportPlans(response.data);
|
||||||
|
|
||||||
|
// Fetch count separately for accurate pagination
|
||||||
|
const countResponse = await fetch('/api/method/frappe.client.get_count', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
doctype: 'Support Plans',
|
||||||
|
filters: params.filters || {}
|
||||||
|
})
|
||||||
|
});
|
||||||
|
const countData = await countResponse.json();
|
||||||
|
setTotalCount(countData.message || response.data.length);
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Failed to fetch support plans');
|
||||||
|
setSupportPlans([]);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [
|
||||||
|
params.filters,
|
||||||
|
params.limit_start,
|
||||||
|
params.limit_page_length,
|
||||||
|
params.order_by
|
||||||
|
]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchSupportPlans();
|
||||||
|
}, [fetchSupportPlans]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
supportPlans,
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
totalCount,
|
||||||
|
refetch: fetchSupportPlans
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
// Hook for fetching single support plan details
|
||||||
|
export const useSupportPlanDetails = (name: string | null) => {
|
||||||
|
const [supportPlan, setSupportPlan] = useState<SupportPlan | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const fetchSupportPlan = useCallback(async () => {
|
||||||
|
if (!name) {
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
const data = await supportPlanService.getSupportPlan(name);
|
||||||
|
setSupportPlan(data);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Failed to fetch support plan');
|
||||||
|
setSupportPlan(null);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [name]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchSupportPlan();
|
||||||
|
}, [fetchSupportPlan]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
supportPlan,
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
refetch: fetchSupportPlan
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
// Hook for support plan mutations (create, update, delete)
|
||||||
|
export const useSupportPlanMutations = () => {
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const createSupportPlan = useCallback(async (data: CreateSupportPlanData): Promise<SupportPlan> => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
const result = await supportPlanService.createSupportPlan(data);
|
||||||
|
return result;
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : 'Failed to create support plan';
|
||||||
|
setError(message);
|
||||||
|
throw err;
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const updateSupportPlan = useCallback(async (
|
||||||
|
name: string,
|
||||||
|
data: Partial<CreateSupportPlanData>
|
||||||
|
): Promise<SupportPlan> => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
const result = await supportPlanService.updateSupportPlan(name, data);
|
||||||
|
return result;
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : 'Failed to update support plan';
|
||||||
|
setError(message);
|
||||||
|
throw err;
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const deleteSupportPlan = useCallback(async (name: string): Promise<void> => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
await supportPlanService.deleteSupportPlan(name);
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : 'Failed to delete support plan';
|
||||||
|
setError(message);
|
||||||
|
throw err;
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return {
|
||||||
|
createSupportPlan,
|
||||||
|
updateSupportPlan,
|
||||||
|
deleteSupportPlan,
|
||||||
|
loading,
|
||||||
|
error
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export default {
|
||||||
|
useSupportPlanList,
|
||||||
|
useSupportPlanDetails,
|
||||||
|
useSupportPlanMutations
|
||||||
|
};
|
||||||
135
asm_app/src/hooks/useTechnicianFilter.ts
Normal file
135
asm_app/src/hooks/useTechnicianFilter.ts
Normal file
@ -0,0 +1,135 @@
|
|||||||
|
import { useState, useEffect, useMemo } from 'react';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Roles that have full access to all work orders
|
||||||
|
* Users with ONLY "Technician" role (and none of these) will have restricted view
|
||||||
|
*/
|
||||||
|
const FULL_ACCESS_ROLES = [
|
||||||
|
'System Manager',
|
||||||
|
'Administrator',
|
||||||
|
'Contractor Supervisor',
|
||||||
|
'Contractor Manager',
|
||||||
|
'Work Control',
|
||||||
|
'End user'
|
||||||
|
];
|
||||||
|
|
||||||
|
interface TechnicianFilterResult {
|
||||||
|
currentUser: string;
|
||||||
|
isTechnicianOnly: boolean;
|
||||||
|
technicianOrFilters: any[][] | undefined;
|
||||||
|
loading: boolean;
|
||||||
|
error: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook to determine if current user is a Technician-only user
|
||||||
|
* and build appropriate OR filters for work order visibility
|
||||||
|
*
|
||||||
|
* Logic:
|
||||||
|
* - If user has ONLY "Technician" role (no higher roles), they see only:
|
||||||
|
* - Work orders they own (owner = user)
|
||||||
|
* - Work orders assigned to them (assigned_technician = user)
|
||||||
|
* - Work orders where they're in additional technicians (custom_add_technicians contains user)
|
||||||
|
* - If user has Technician + any higher role, they see all work orders
|
||||||
|
* - If user has only higher roles (no Technician), they see all work orders
|
||||||
|
*/
|
||||||
|
export function useTechnicianFilter(): TechnicianFilterResult {
|
||||||
|
const [currentUser, setCurrentUser] = useState<string>('');
|
||||||
|
const [isTechnicianOnly, setIsTechnicianOnly] = useState(false);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const checkTechnicianRole = async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
|
||||||
|
// Fetch user info with roles from the API
|
||||||
|
const response = await fetch('/api/method/asset_lite.api.user_roles.get_user_info_with_roles', {
|
||||||
|
method: 'GET',
|
||||||
|
headers: { 'Content-Type': 'application/json' }
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`HTTP error! status: ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data.message) {
|
||||||
|
const { user, roles } = data.message;
|
||||||
|
setCurrentUser(user || '');
|
||||||
|
|
||||||
|
if (!user || user === 'Guest') {
|
||||||
|
setIsTechnicianOnly(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if user has Technician role
|
||||||
|
const hasTechnician = roles.includes('Technician');
|
||||||
|
|
||||||
|
// Check if user has any full access role
|
||||||
|
const hasFullAccess = roles.some((r: string) => FULL_ACCESS_ROLES.includes(r));
|
||||||
|
|
||||||
|
// User is Technician-only if they have Technician but NO full access roles
|
||||||
|
const technicianOnly = hasTechnician && !hasFullAccess;
|
||||||
|
|
||||||
|
console.log('[useTechnicianFilter] User:', user);
|
||||||
|
console.log('[useTechnicianFilter] Roles:', roles);
|
||||||
|
console.log('[useTechnicianFilter] Has Technician:', hasTechnician);
|
||||||
|
console.log('[useTechnicianFilter] Has Full Access:', hasFullAccess);
|
||||||
|
console.log('[useTechnicianFilter] Is Technician Only:', technicianOnly);
|
||||||
|
|
||||||
|
setIsTechnicianOnly(technicianOnly);
|
||||||
|
}
|
||||||
|
|
||||||
|
setError(null);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[useTechnicianFilter] Error checking technician role:', err);
|
||||||
|
setError(err instanceof Error ? err.message : 'Failed to check user roles');
|
||||||
|
// Default to showing nothing if we can't verify permissions
|
||||||
|
setIsTechnicianOnly(false);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
checkTechnicianRole();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build OR filters for Technician-only users
|
||||||
|
* These filters ensure technicians only see work orders they're associated with
|
||||||
|
*
|
||||||
|
* Frappe or_filters format: [["field", "operator", "value"], ...]
|
||||||
|
* Records matching ANY of these conditions will be included
|
||||||
|
*/
|
||||||
|
const technicianOrFilters = useMemo(() => {
|
||||||
|
// If not Technician-only or no user, don't apply OR filters
|
||||||
|
if (!isTechnicianOnly || !currentUser) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('[useTechnicianFilter] Building OR filters for user:', currentUser);
|
||||||
|
|
||||||
|
// Return OR filters - user sees work orders where they are:
|
||||||
|
// 1. The owner (creator) of the work order
|
||||||
|
// 2. The assigned technician
|
||||||
|
// 3. Listed in additional technicians field (using LIKE for comma-separated values)
|
||||||
|
return [
|
||||||
|
['owner', '=', currentUser],
|
||||||
|
['assigned_technician', '=', currentUser],
|
||||||
|
['custom_add_technicians', 'like', `%${currentUser}%`]
|
||||||
|
];
|
||||||
|
}, [isTechnicianOnly, currentUser]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
currentUser,
|
||||||
|
isTechnicianOnly,
|
||||||
|
technicianOrFilters,
|
||||||
|
loading,
|
||||||
|
error
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default useTechnicianFilter;
|
||||||
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;
|
||||||
420
asm_app/src/hooks/useWorkOrder.ts
Normal file
420
asm_app/src/hooks/useWorkOrder.ts
Normal file
@ -0,0 +1,420 @@
|
|||||||
|
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
|
||||||
|
*
|
||||||
|
* @param filters - User-defined filters
|
||||||
|
* @param limit - Number of records per page
|
||||||
|
* @param offset - Starting position for pagination
|
||||||
|
* @param orderBy - Sort order
|
||||||
|
* @param permissionFilters - Permission-based filters (AND logic)
|
||||||
|
* @param orFilters - OR filters for technician-only filtering (shows records matching ANY condition)
|
||||||
|
* Format: [["field", "operator", "value"], ...]
|
||||||
|
* Example: [["owner", "=", "user@email.com"], ["assigned_technician", "=", "user@email.com"]]
|
||||||
|
*/
|
||||||
|
export function useWorkOrders(
|
||||||
|
filters?: WorkOrderFilters,
|
||||||
|
limit: number = 20,
|
||||||
|
offset: number = 0,
|
||||||
|
orderBy?: string,
|
||||||
|
permissionFilters: Record<string, any> = {},
|
||||||
|
orFilters?: any[][] // ✅ NEW: OR filters for technician filtering
|
||||||
|
) {
|
||||||
|
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);
|
||||||
|
const orFiltersJson = JSON.stringify(orFilters); // ✅ 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);
|
||||||
|
|
||||||
|
// ✅ 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] OR filters:', orFilters);
|
||||||
|
console.log('[useWorkOrders] Merged filters:', mergedFilters);
|
||||||
|
|
||||||
|
// ✅ Pass orFilters to the service
|
||||||
|
const response = await workOrderService.getWorkOrders(
|
||||||
|
mergedFilters,
|
||||||
|
undefined,
|
||||||
|
limit,
|
||||||
|
offset,
|
||||||
|
orderBy,
|
||||||
|
orFilters // ✅ NEW parameter
|
||||||
|
);
|
||||||
|
|
||||||
|
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, orFiltersJson, limit, offset, orderBy, refetchTrigger]); // ✅ Added orFiltersJson
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
1550
asm_app/src/locales/ar/translation.json
Normal file
1550
asm_app/src/locales/ar/translation.json
Normal file
File diff suppressed because it is too large
Load Diff
1559
asm_app/src/locales/en/translation.json
Normal file
1559
asm_app/src/locales/en/translation.json
Normal file
File diff suppressed because it is too large
Load Diff
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>,
|
||||||
|
)
|
||||||
1076
asm_app/src/pages/ActiveMap.tsx
Normal file
1076
asm_app/src/pages/ActiveMap.tsx
Normal file
File diff suppressed because it is too large
Load Diff
4008
asm_app/src/pages/AssetDetail.tsx
Normal file
4008
asm_app/src/pages/AssetDetail.tsx
Normal file
File diff suppressed because it is too large
Load Diff
2292
asm_app/src/pages/AssetList.tsx
Normal file
2292
asm_app/src/pages/AssetList.tsx
Normal file
File diff suppressed because it is too large
Load Diff
1028
asm_app/src/pages/AssetMaintenanceDetail.tsx
Normal file
1028
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(t('maintenance.deletedSuccessfully'));
|
||||||
|
} 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">{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">⚠️ {t('maintenance.apiNotAvailable')}</h2>
|
||||||
|
<div className="text-yellow-700 dark:text-yellow-400 space-y-3">
|
||||||
|
<p><strong>{t('maintenance.apiNotDeployed')}</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"
|
||||||
|
>
|
||||||
|
{t('maintenance.tryCreatingNew')}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={refetch}
|
||||||
|
className="bg-gray-600 hover:bg-gray-700 text-white px-4 py-2 rounded"
|
||||||
|
>
|
||||||
|
{t('common.tryAgain')}
|
||||||
|
</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">
|
||||||
|
{t('maintenance.listTotal', { count: totalCount })}
|
||||||
|
</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">{t('listPages.exportAllOnPage')}</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('maintenance.addMaintenance')}</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={t('listPages.searchPlaceholder')}
|
||||||
|
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="">{t('listPages.allStatuses')}</option>
|
||||||
|
<option value="Planned">{t('maintenance.status.planned')}</option>
|
||||||
|
<option value="Completed">{t('maintenance.status.completed')}</option>
|
||||||
|
<option value="Overdue">{t('maintenance.status.overdue')}</option>
|
||||||
|
<option value="Cancelled">{t('maintenance.status.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">
|
||||||
|
{t('maintenance.logId')}
|
||||||
|
</th>
|
||||||
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||||
|
{t('commonFields.assetShort')}
|
||||||
|
</th>
|
||||||
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||||
|
{t('commonFields.typeShort')}
|
||||||
|
</th>
|
||||||
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||||
|
{t('ppm.dueDate')}
|
||||||
|
</th>
|
||||||
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||||
|
{t('commonFields.status')}
|
||||||
|
</th>
|
||||||
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||||
|
{t('listPages.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>{t('listPages.noMaintenanceLogsFound')}</p>
|
||||||
|
<button
|
||||||
|
onClick={handleCreateNew}
|
||||||
|
className="mt-4 text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300 underline"
|
||||||
|
>
|
||||||
|
{t('listPages.createFirstMaintenanceLog')}
|
||||||
|
</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">
|
||||||
|
Access Currently Denied
|
||||||
|
</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;
|
||||||
255
asm_app/src/pages/DeleteRequestsPage.tsx
Normal file
255
asm_app/src/pages/DeleteRequestsPage.tsx
Normal file
@ -0,0 +1,255 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { FaTrash, FaCheckCircle, FaTimesCircle, FaClock, FaChevronRight } from 'react-icons/fa';
|
||||||
|
|
||||||
|
interface DeleteRequest {
|
||||||
|
name: string;
|
||||||
|
target_doctype: string;
|
||||||
|
target_name: string;
|
||||||
|
target_display: string;
|
||||||
|
reason: string;
|
||||||
|
department: string;
|
||||||
|
requested_by: string;
|
||||||
|
workflow_state: string;
|
||||||
|
creation: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const STATE_COLORS: Record<string, string> = {
|
||||||
|
'Pending Supervisor': 'bg-yellow-100 dark:bg-yellow-900/30 text-yellow-800 dark:text-yellow-300',
|
||||||
|
'Pending Cluster Manager': 'bg-orange-100 dark:bg-orange-900/30 text-orange-800 dark:text-orange-300',
|
||||||
|
'Approved': 'bg-green-100 dark:bg-green-900/30 text-green-800 dark:text-green-300',
|
||||||
|
'Rejected': 'bg-red-100 dark:bg-red-900/30 text-red-800 dark:text-red-300',
|
||||||
|
};
|
||||||
|
|
||||||
|
const DOCTYPE_LABELS: Record<string, string> = {
|
||||||
|
Work_Order: 'Work Order',
|
||||||
|
Asset: 'Asset',
|
||||||
|
'Maintenance Schedule': 'PPM',
|
||||||
|
'Stock Entry': 'Inventory',
|
||||||
|
};
|
||||||
|
|
||||||
|
const DeleteRequestsPage: React.FC = () => {
|
||||||
|
const [requests, setRequests] = useState<DeleteRequest[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [actionLoading, setActionLoading] = useState<string | null>(null);
|
||||||
|
const [filterState, setFilterState] = useState('');
|
||||||
|
const [userRole, setUserRole] = useState<'supervisor' | 'cluster_manager' | 'other'>('other');
|
||||||
|
|
||||||
|
// Detect user role to show relevant actions
|
||||||
|
useEffect(() => {
|
||||||
|
const detectRole = async () => {
|
||||||
|
const cmRes = await fetch('/api/method/asset_lite.api.user_roles.check_has_role', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ roles: 'Cluster Manager' })
|
||||||
|
});
|
||||||
|
const cmData = await cmRes.json();
|
||||||
|
if (cmData.message?.has_role) { setUserRole('cluster_manager'); return; }
|
||||||
|
|
||||||
|
const supRes = await fetch('/api/method/asset_lite.api.user_roles.check_has_role', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ roles: 'Contractor Supervisor' })
|
||||||
|
});
|
||||||
|
const supData = await supRes.json();
|
||||||
|
if (supData.message?.has_role) setUserRole('supervisor');
|
||||||
|
};
|
||||||
|
detectRole();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const fetchRequests = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const filters: any = {};
|
||||||
|
if (filterState) filters['workflow_state'] = filterState;
|
||||||
|
|
||||||
|
const res = await fetch('/api/method/frappe.client.get_list', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
doctype: 'Delete Request',
|
||||||
|
filters,
|
||||||
|
fields: [
|
||||||
|
'name', 'target_doctype', 'target_name', 'target_display',
|
||||||
|
'reason', 'department', 'requested_by', 'workflow_state', 'creation'
|
||||||
|
],
|
||||||
|
order_by: 'creation desc',
|
||||||
|
limit_page_length: 100
|
||||||
|
})
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
setRequests(data.message || []);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to fetch delete requests', err);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => { fetchRequests(); }, [filterState]);
|
||||||
|
|
||||||
|
const applyAction = async (requestName: string, action: string) => {
|
||||||
|
setActionLoading(requestName + action);
|
||||||
|
try {
|
||||||
|
// First get the full doc
|
||||||
|
const docRes = await fetch('/api/method/frappe.client.get', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ doctype: 'Delete Request', name: requestName })
|
||||||
|
});
|
||||||
|
const docData = await docRes.json();
|
||||||
|
|
||||||
|
// Apply workflow action
|
||||||
|
const res = await fetch('/api/method/frappe.model.workflow.apply_workflow', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
credentials: 'include',
|
||||||
|
body: JSON.stringify({ doc: docData.message, action })
|
||||||
|
});
|
||||||
|
const result = await res.json();
|
||||||
|
if (result.exc) throw new Error(result.exc);
|
||||||
|
|
||||||
|
alert(`Action "${action}" applied successfully.`);
|
||||||
|
fetchRequests();
|
||||||
|
} catch (err) {
|
||||||
|
alert(`Failed: ${err instanceof Error ? err.message : 'Unknown error'}`);
|
||||||
|
} finally {
|
||||||
|
setActionLoading(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// What actions can the current user take on a given request
|
||||||
|
const getAvailableActions = (req: DeleteRequest) => {
|
||||||
|
if (userRole === 'supervisor' && req.workflow_state === 'Pending Supervisor') {
|
||||||
|
return [
|
||||||
|
{ label: 'Forward to Cluster Manager', action: 'Send For Approval', color: 'bg-blue-600 hover:bg-blue-700' },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
if (userRole === 'cluster_manager' && req.workflow_state === 'Pending Cluster Manager') {
|
||||||
|
return [
|
||||||
|
{ label: 'Approve & Delete', action: 'Approve', color: 'bg-green-600 hover:bg-green-700' },
|
||||||
|
{ label: 'Reject', action: 'Reject', color: 'bg-red-600 hover:bg-red-700' },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
};
|
||||||
|
|
||||||
|
const pendingCount = requests.filter(r =>
|
||||||
|
r.workflow_state === 'Pending Supervisor' || r.workflow_state === 'Pending Cluster Manager'
|
||||||
|
).length;
|
||||||
|
|
||||||
|
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 flex items-center gap-3">
|
||||||
|
<FaTrash className="text-red-500" />
|
||||||
|
Deletion Requests
|
||||||
|
</h1>
|
||||||
|
<p className="text-gray-500 dark:text-gray-400 mt-1">
|
||||||
|
{pendingCount > 0
|
||||||
|
? <span className="text-orange-600 font-medium">{pendingCount} pending your review</span>
|
||||||
|
: 'No pending requests'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Filter */}
|
||||||
|
<select
|
||||||
|
value={filterState}
|
||||||
|
onChange={(e) => setFilterState(e.target.value)}
|
||||||
|
className="px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
|
||||||
|
>
|
||||||
|
<option value="">All Statuses</option>
|
||||||
|
<option value="Pending Supervisor">Pending Supervisor</option>
|
||||||
|
<option value="Pending Cluster Manager">Pending Cluster Manager</option>
|
||||||
|
<option value="Approved">Approved</option>
|
||||||
|
<option value="Rejected">Rejected</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Table */}
|
||||||
|
<div className="bg-white dark:bg-gray-800 rounded-lg shadow overflow-hidden">
|
||||||
|
{loading ? (
|
||||||
|
<div className="flex items-center justify-center py-20">
|
||||||
|
<div className="animate-spin rounded-full h-10 w-10 border-b-2 border-red-500"></div>
|
||||||
|
</div>
|
||||||
|
) : requests.length === 0 ? (
|
||||||
|
<div className="text-center py-20 text-gray-400">
|
||||||
|
<FaTrash className="text-5xl mx-auto mb-3 opacity-30" />
|
||||||
|
<p>No deletion requests found</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<table className="w-full">
|
||||||
|
<thead className="bg-gray-100 dark:bg-gray-700">
|
||||||
|
<tr>
|
||||||
|
{['Request ID', 'Type', 'Record', 'Department', 'Requested By', 'Reason', 'Status', 'Date', 'Actions'].map(h => (
|
||||||
|
<th key={h} className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">
|
||||||
|
{h}
|
||||||
|
</th>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-gray-200 dark:divide-gray-700">
|
||||||
|
{requests.map((req) => {
|
||||||
|
const actions = getAvailableActions(req);
|
||||||
|
const isActing = actionLoading?.startsWith(req.name);
|
||||||
|
return (
|
||||||
|
<tr key={req.name} className="hover:bg-gray-50 dark:hover:bg-gray-700/50">
|
||||||
|
<td className="px-4 py-3 text-xs font-mono text-gray-600 dark:text-gray-400">
|
||||||
|
{req.name}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<span className="px-2 py-1 bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300 rounded text-xs font-medium">
|
||||||
|
{DOCTYPE_LABELS[req.target_doctype] || req.target_doctype}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-sm font-medium text-gray-900 dark:text-white">
|
||||||
|
{req.target_display || req.target_name}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
{req.department || '-'}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
{req.requested_by}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 max-w-xs">
|
||||||
|
<p className="text-sm text-gray-700 dark:text-gray-300 truncate" title={req.reason}>
|
||||||
|
{req.reason}
|
||||||
|
</p>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<span className={`px-2 py-1 rounded-full text-xs font-semibold ${STATE_COLORS[req.workflow_state] || 'bg-gray-100 text-gray-700'}`}>
|
||||||
|
{req.workflow_state}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-xs text-gray-500 dark:text-gray-400 whitespace-nowrap">
|
||||||
|
{new Date(req.creation).toLocaleDateString()}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{actions.length > 0 ? actions.map(({ label, action, color }) => (
|
||||||
|
<button
|
||||||
|
key={action}
|
||||||
|
onClick={() => applyAction(req.name, action)}
|
||||||
|
disabled={!!isActing}
|
||||||
|
className={`px-3 py-1.5 text-xs font-medium text-white rounded-lg transition-colors ${color} disabled:opacity-50 disabled:cursor-not-allowed`}
|
||||||
|
>
|
||||||
|
{isActing ? '...' : label}
|
||||||
|
</button>
|
||||||
|
)) : (
|
||||||
|
<span className="text-xs text-gray-400 italic">No actions</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default DeleteRequestsPage;
|
||||||
239
asm_app/src/pages/EventsList.tsx
Normal file
239
asm_app/src/pages/EventsList.tsx
Normal file
@ -0,0 +1,239 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
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 { t } = useTranslation();
|
||||||
|
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">{t('events.title')}</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"
|
||||||
|
>
|
||||||
|
{t('events.refreshEvents')}
|
||||||
|
</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">
|
||||||
|
{t('events.upcomingEvents')} ({events.length})
|
||||||
|
</h3>
|
||||||
|
<p className="mt-1 max-w-2xl text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
{t('events.eventsFromFrappe')}
|
||||||
|
</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">{t('events.noEventsFound')}</h3>
|
||||||
|
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
{t('events.noEventsScheduled')}
|
||||||
|
</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;
|
||||||
2074
asm_app/src/pages/InspectionDetail.tsx
Normal file
2074
asm_app/src/pages/InspectionDetail.tsx
Normal file
File diff suppressed because it is too large
Load Diff
919
asm_app/src/pages/InspectionList.tsx
Normal file
919
asm_app/src/pages/InspectionList.tsx
Normal file
@ -0,0 +1,919 @@
|
|||||||
|
import React, { useState, useMemo, useEffect, useCallback, useRef } from 'react';
|
||||||
|
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { useInspectionList } from '../hooks/useInspection';
|
||||||
|
import { useUserPermissions } from '../hooks/useUserPermissions'; // ← ADDED
|
||||||
|
import InspectionReportModal from '../components/InspectionReportModal';
|
||||||
|
import ListPagination from '../components/ListPagination';
|
||||||
|
import * as XLSX from 'xlsx';
|
||||||
|
import {
|
||||||
|
FaPlus,
|
||||||
|
FaFilter,
|
||||||
|
FaSync,
|
||||||
|
FaEye,
|
||||||
|
FaChevronLeft,
|
||||||
|
FaChevronRight,
|
||||||
|
FaClipboardCheck,
|
||||||
|
FaTimes,
|
||||||
|
FaSave,
|
||||||
|
FaStar,
|
||||||
|
FaTrash,
|
||||||
|
FaEdit,
|
||||||
|
FaCheckSquare,
|
||||||
|
FaSquare,
|
||||||
|
FaFileExport,
|
||||||
|
FaFileExcel,
|
||||||
|
FaFileCsv,
|
||||||
|
FaDownload,
|
||||||
|
FaExternalLinkAlt
|
||||||
|
} from 'react-icons/fa';
|
||||||
|
import LinkField from '../components/LinkField';
|
||||||
|
import { buildDateRangeFilters, toFrappeFilterArray } from '../utils/listFilterUtils';
|
||||||
|
import DeleteRequestButton from '../components/DeleteRequestButton';
|
||||||
|
import type { DeleteStatus } from '../services/deleteRequestService';
|
||||||
|
|
||||||
|
// Export types
|
||||||
|
type ExportFormat = 'csv' | 'excel';
|
||||||
|
type ExportScope = 'selected' | 'all_on_page' | 'all_with_filters';
|
||||||
|
|
||||||
|
interface ExportModalProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
selectedCount: number;
|
||||||
|
totalCount: number;
|
||||||
|
pageCount: number;
|
||||||
|
onExport: (scope: ExportScope, format: ExportFormat, columns: string[]) => void;
|
||||||
|
isExporting: boolean;
|
||||||
|
exportColumns: Array<{key: string, labelKey: string, default: boolean}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ExportModal: React.FC<ExportModalProps> = ({
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
selectedCount,
|
||||||
|
totalCount,
|
||||||
|
pageCount,
|
||||||
|
onExport,
|
||||||
|
isExporting,
|
||||||
|
exportColumns
|
||||||
|
}) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [scope, setScope] = useState<ExportScope>(selectedCount > 0 ? 'selected' : 'all_with_filters');
|
||||||
|
const [format, setFormat] = useState<ExportFormat>('csv');
|
||||||
|
const [selectedColumns, setSelectedColumns] = useState<string[]>(
|
||||||
|
exportColumns.filter(c => c.default).map(c => c.key)
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (selectedCount > 0) {
|
||||||
|
setScope('selected');
|
||||||
|
} else {
|
||||||
|
setScope('all_with_filters');
|
||||||
|
}
|
||||||
|
}, [selectedCount]);
|
||||||
|
|
||||||
|
const toggleColumn = (key: string) => {
|
||||||
|
setSelectedColumns(prev =>
|
||||||
|
prev.includes(key) ? prev.filter(k => k !== key) : [...prev, key]
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const selectAllColumns = () => setSelectedColumns(exportColumns.map(c => c.key));
|
||||||
|
const selectDefaultColumns = () => setSelectedColumns(exportColumns.filter(c => c.default).map(c => c.key));
|
||||||
|
|
||||||
|
if (!isOpen) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-[70] p-4">
|
||||||
|
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-xl max-w-2xl w-full max-h-[90vh] overflow-hidden animate-scale-in">
|
||||||
|
<div className="bg-gradient-to-r from-teal-500 to-teal-600 px-6 py-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<FaFileExport className="text-white text-xl" />
|
||||||
|
<h3 className="text-lg font-semibold text-white">{t('inspections.export.title')}</h3>
|
||||||
|
</div>
|
||||||
|
<button onClick={onClose} className="text-white/80 hover:text-white transition-colors" disabled={isExporting}>
|
||||||
|
<FaTimes size={20} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-6 overflow-y-auto max-h-[calc(90vh-180px)]">
|
||||||
|
{/* Scope Selection */}
|
||||||
|
<div className="mb-6">
|
||||||
|
<h4 className="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-3">{t('inspections.export.selectData')}</h4>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className={`flex items-center gap-3 p-3 rounded-lg border cursor-pointer transition-all ${scope === 'selected' ? 'border-teal-500 bg-teal-50 dark:bg-teal-900/20' : 'border-gray-200 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-700/50'} ${selectedCount === 0 ? 'opacity-50 cursor-not-allowed' : ''}`}>
|
||||||
|
<input type="radio" name="scope" value="selected" checked={scope === 'selected'} onChange={() => setScope('selected')} disabled={selectedCount === 0} className="text-teal-600 focus:ring-teal-500" />
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="font-medium text-gray-900 dark:text-white">{t('inspections.export.selectedRows')}</div>
|
||||||
|
<div className="text-sm text-gray-500 dark:text-gray-400">{t('inspections.export.selectedCount', { count: selectedCount })}</div>
|
||||||
|
</div>
|
||||||
|
{selectedCount > 0 && <span className="bg-teal-100 dark:bg-teal-900/40 text-teal-700 dark:text-teal-300 px-2 py-1 rounded text-xs font-medium">{selectedCount} selected</span>}
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className={`flex items-center gap-3 p-3 rounded-lg border cursor-pointer transition-all ${scope === 'all_on_page' ? 'border-teal-500 bg-teal-50 dark:bg-teal-900/20' : 'border-gray-200 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-700/50'}`}>
|
||||||
|
<input type="radio" name="scope" value="all_on_page" checked={scope === 'all_on_page'} onChange={() => setScope('all_on_page')} className="text-teal-600 focus:ring-teal-500" />
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="font-medium text-gray-900 dark:text-white">{t('inspections.export.currentPage')}</div>
|
||||||
|
<div className="text-sm text-gray-500 dark:text-gray-400">{t('inspections.export.currentPageCount', { count: pageCount })}</div>
|
||||||
|
</div>
|
||||||
|
<span className="bg-blue-100 dark:bg-blue-900/40 text-blue-700 dark:text-blue-300 px-2 py-1 rounded text-xs font-medium">{pageCount} rows</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className={`flex items-center gap-3 p-3 rounded-lg border cursor-pointer transition-all ${scope === 'all_with_filters' ? 'border-teal-500 bg-teal-50 dark:bg-teal-900/20' : 'border-gray-200 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-700/50'}`}>
|
||||||
|
<input type="radio" name="scope" value="all_with_filters" checked={scope === 'all_with_filters'} onChange={() => setScope('all_with_filters')} className="text-teal-600 focus:ring-teal-500" />
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="font-medium text-gray-900 dark:text-white">{t('inspections.export.allWithFilters')}</div>
|
||||||
|
<div className="text-sm text-gray-500 dark:text-gray-400">{t('inspections.export.allWithFiltersCount', { count: totalCount })}</div>
|
||||||
|
</div>
|
||||||
|
<span className="bg-purple-100 dark:bg-purple-900/40 text-purple-700 dark:text-purple-300 px-2 py-1 rounded text-xs font-medium">{totalCount} total</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Format Selection */}
|
||||||
|
<div className="mb-6">
|
||||||
|
<h4 className="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-3">{t('inspections.export.exportFormat')}</h4>
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<label className={`flex-1 flex items-center gap-3 p-3 rounded-lg border cursor-pointer transition-all ${format === 'csv' ? 'border-teal-500 bg-teal-50 dark:bg-teal-900/20' : 'border-gray-200 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-700/50'}`}>
|
||||||
|
<input type="radio" name="format" value="csv" checked={format === 'csv'} onChange={() => setFormat('csv')} className="text-teal-600 focus:ring-teal-500" />
|
||||||
|
<FaFileCsv className="text-teal-600 text-xl" />
|
||||||
|
<div>
|
||||||
|
<div className="font-medium text-gray-900 dark:text-white">{t('inspections.export.csv')}</div>
|
||||||
|
<div className="text-xs text-gray-500 dark:text-gray-400">{t('inspections.export.csvDesc')}</div>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
<label className={`flex-1 flex items-center gap-3 p-3 rounded-lg border cursor-pointer transition-all ${format === 'excel' ? 'border-teal-500 bg-teal-50 dark:bg-teal-900/20' : 'border-gray-200 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-700/50'}`}>
|
||||||
|
<input type="radio" name="format" value="excel" checked={format === 'excel'} onChange={() => setFormat('excel')} className="text-teal-600 focus:ring-teal-500" />
|
||||||
|
<FaFileExcel className="text-green-700 text-xl" />
|
||||||
|
<div>
|
||||||
|
<div className="font-medium text-gray-900 dark:text-white">{t('inspections.export.excel')}</div>
|
||||||
|
<div className="text-xs text-gray-500 dark:text-gray-400">{t('inspections.export.excelDesc')}</div>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Column Selection */}
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center justify-between mb-3">
|
||||||
|
<h4 className="text-sm font-semibold text-gray-700 dark:text-gray-300">{t('inspections.export.columnsToExport')}</h4>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button onClick={selectAllColumns} className="text-xs text-blue-600 dark:text-blue-400 hover:underline">{t('inspections.export.selectAll')}</button>
|
||||||
|
<span className="text-gray-300 dark:text-gray-600">|</span>
|
||||||
|
<button onClick={selectDefaultColumns} className="text-xs text-blue-600 dark:text-blue-400 hover:underline">{t('inspections.export.resetToDefault')}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-3 gap-2 max-h-48 overflow-y-auto p-3 bg-gray-50 dark:bg-gray-900/50 rounded-lg">
|
||||||
|
{exportColumns.map((col) => (
|
||||||
|
<label key={col.key} className={`flex items-center gap-2 p-2 rounded cursor-pointer transition-all ${selectedColumns.includes(col.key) ? 'bg-teal-100 dark:bg-teal-900/30 text-teal-800 dark:text-teal-300' : 'hover:bg-gray-100 dark:hover:bg-gray-800 text-gray-700 dark:text-gray-400'}`}>
|
||||||
|
<input type="checkbox" checked={selectedColumns.includes(col.key)} onChange={() => toggleColumn(col.key)} className="rounded text-teal-600 focus:ring-teal-500" />
|
||||||
|
<span className="text-sm truncate">{t(col.labelKey)}</span>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-gray-500 dark:text-gray-400 mt-2">{t('inspections.export.columnsSelected', { count: selectedColumns.length })}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<div className="px-6 py-4 bg-gray-50 dark:bg-gray-900/50 border-t border-gray-200 dark:border-gray-700 flex justify-between items-center">
|
||||||
|
<div className="text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
{scope === 'selected' && t('inspections.export.exportingSelected', { count: selectedCount })}
|
||||||
|
{scope === 'all_on_page' && t('inspections.export.exportingPage', { count: pageCount })}
|
||||||
|
{scope === 'all_with_filters' && t('inspections.export.exportingAll', { count: totalCount })}
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<button onClick={onClose} className="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors" disabled={isExporting}>{t('common.cancel')}</button>
|
||||||
|
<button onClick={() => onExport(scope, format, selectedColumns)} disabled={selectedColumns.length === 0 || isExporting} className="px-4 py-2 text-sm font-medium text-white bg-teal-600 hover:bg-teal-700 rounded-lg transition-colors flex items-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed">
|
||||||
|
{isExporting ? (<><div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white"></div>{t('inspections.export.exporting')}</>) : (<><FaDownload />{t('inspections.export.exportButton')}</>)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Status badge styles
|
||||||
|
const getStatusStyle = (status: string) => {
|
||||||
|
switch (status?.toLowerCase()) {
|
||||||
|
case 'open': return 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300';
|
||||||
|
case 'in progress': return 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-300';
|
||||||
|
case 'closed': return 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300';
|
||||||
|
case 'pending review': return 'bg-purple-100 text-purple-800 dark:bg-purple-900/30 dark:text-purple-300';
|
||||||
|
default: return 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Workflow state badge styles
|
||||||
|
const getWorkflowStateStyle = (state: string) => {
|
||||||
|
switch (state?.toLowerCase()) {
|
||||||
|
case 'draft': return 'bg-orange-100 text-orange-800 dark:bg-orange-900/30 dark:text-orange-300';
|
||||||
|
case 'sent to supervisor': return 'bg-purple-100 text-purple-800 dark:bg-purple-900/30 dark:text-purple-300';
|
||||||
|
case 'closed': return 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300';
|
||||||
|
default: return 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Inspection type badge styles
|
||||||
|
const getInspectionTypeStyle = (type: string) => {
|
||||||
|
switch (type?.toLowerCase()) {
|
||||||
|
case 'inspection': return 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300';
|
||||||
|
case 'safety inspection': return 'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-300';
|
||||||
|
default: return 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const InspectionList: React.FC = () => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
|
|
||||||
|
// ── Permission hook — same pattern as ModernDashboard ────────────────────
|
||||||
|
// useUserPermissions('Issue Type') calls apiService.getPermissionFilters('Issue Type')
|
||||||
|
// which hits the correct endpoint internally — no manual fetch needed.
|
||||||
|
const [permittedIssueTypes, setPermittedIssueTypes] = useState<string[]>([]);
|
||||||
|
const [isWoAdmin, setIsWoAdmin] = useState(true);
|
||||||
|
|
||||||
|
const currentPage = useMemo(() => {
|
||||||
|
const p = parseInt(searchParams.get('page') || '1', 10);
|
||||||
|
return Number.isNaN(p) || p < 1 ? 1 : p;
|
||||||
|
}, [searchParams]);
|
||||||
|
|
||||||
|
const setCurrentPage = useCallback((pageOrUpdater: number | ((p: number) => number)) => {
|
||||||
|
const next = typeof pageOrUpdater === 'function' ? pageOrUpdater(currentPage) : pageOrUpdater;
|
||||||
|
setSearchParams((prev) => {
|
||||||
|
const nextParams = new URLSearchParams(prev);
|
||||||
|
nextParams.set('page', String(next));
|
||||||
|
return nextParams;
|
||||||
|
});
|
||||||
|
}, [currentPage, setSearchParams]);
|
||||||
|
|
||||||
|
const EXPORT_COLUMNS = [
|
||||||
|
{ key: 'name', labelKey: 'inspections.export.inspectionId', default: true },
|
||||||
|
{ key: 'inspection_type', labelKey: 'inspections.export.inspectionType', default: true },
|
||||||
|
{ key: 'status', labelKey: 'inspections.export.status', default: true },
|
||||||
|
{ key: 'workflow_state', labelKey: 'inspections.export.workflowState', default: true },
|
||||||
|
{ key: 'inspection_date', labelKey: 'inspections.export.inspectionDate', default: true },
|
||||||
|
{ key: 'target_closure_date', labelKey: 'inspections.export.targetClosureDate', default: true },
|
||||||
|
{ key: 'requested_by', labelKey: 'inspections.export.requestedBy', default: true },
|
||||||
|
{ key: 'work_order_type', labelKey: 'inspections.export.technicalDepartment', default: false },
|
||||||
|
{ key: 'extension_no', labelKey: 'inspections.export.extensionNo', default: false },
|
||||||
|
{ key: 'department', labelKey: 'inspections.export.department', default: false },
|
||||||
|
{ key: 'location', labelKey: 'inspections.export.location', default: false },
|
||||||
|
{ key: 'assigned_technician', labelKey: 'inspections.export.assignedTechnician', default: false },
|
||||||
|
{ key: 'linked_corrective_wo_no', labelKey: 'inspections.export.linkedWorkOrder', default: true },
|
||||||
|
{ key: 'observation_note', labelKey: 'inspections.export.observationNote', default: false },
|
||||||
|
{ key: 'technical_response', labelKey: 'inspections.export.technicalResponse', default: false },
|
||||||
|
{ key: 'creation', labelKey: 'inspections.export.createdOn', default: false },
|
||||||
|
{ key: 'modified', labelKey: 'inspections.export.modifiedOn', default: false },
|
||||||
|
{ key: 'owner', labelKey: 'inspections.export.createdBy', default: false },
|
||||||
|
];
|
||||||
|
|
||||||
|
const [pageSize] = useState(20);
|
||||||
|
const [initialLoadComplete, setInitialLoadComplete] = useState(false);
|
||||||
|
const [selectedRows, setSelectedRows] = useState<Set<string>>(new Set());
|
||||||
|
const [showExportModal, setShowExportModal] = useState(false);
|
||||||
|
const [isExporting, setIsExporting] = useState(false);
|
||||||
|
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState<string | null>(null);
|
||||||
|
const [showReportModal, setShowReportModal] = useState(false);
|
||||||
|
|
||||||
|
// ✅ Roles allowed to delete inspections and add new inspections
|
||||||
|
const INSPECTION_ALLOWED_ROLES = [
|
||||||
|
'System Manager',
|
||||||
|
'Contractor Supervisor',
|
||||||
|
'Contractor Manager',
|
||||||
|
'Work Control'
|
||||||
|
];
|
||||||
|
|
||||||
|
// ✅ State for role-based permissions
|
||||||
|
const [canDelete, setCanDelete] = useState(false);
|
||||||
|
const [canAddInspection, setCanAddInspection] = useState(false);
|
||||||
|
const [userRoles, setUserRoles] = useState<string[]>([]);
|
||||||
|
const [listIsSystemManager, setListIsSystemManager] = useState(false);
|
||||||
|
|
||||||
|
// ✅ Check user permissions based on roles
|
||||||
|
useEffect(() => {
|
||||||
|
const checkPermissions = async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/method/asset_lite.api.user_roles.check_has_role', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ roles: INSPECTION_ALLOWED_ROLES.join(',') })
|
||||||
|
});
|
||||||
|
const data = await response.json();
|
||||||
|
if (data.message) {
|
||||||
|
const hasPermission = data.message.has_role;
|
||||||
|
setCanDelete(hasPermission);
|
||||||
|
setCanAddInspection(hasPermission);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch full roles array for DeleteRequestButton
|
||||||
|
const rolesListResponse = await fetch('/api/method/asset_lite.api.user_roles.get_user_roles', {
|
||||||
|
credentials: 'include'
|
||||||
|
});
|
||||||
|
const rolesListData = await rolesListResponse.json();
|
||||||
|
const rolesList = Array.isArray(rolesListData.message) ? rolesListData.message : [];
|
||||||
|
setUserRoles(rolesList);
|
||||||
|
setListIsSystemManager(rolesList.includes('System Manager'));
|
||||||
|
|
||||||
|
// Fetch Issue Type permissions via Work_Order mapping (confirmed working endpoint)
|
||||||
|
try {
|
||||||
|
const permRes = await fetch(
|
||||||
|
'/api/method/asset_lite.api.userperm_api.get_permission_filters?target_doctype=Work_Order',
|
||||||
|
{ credentials: 'include' }
|
||||||
|
);
|
||||||
|
const permData = await permRes.json();
|
||||||
|
const msg = permData.message || {};
|
||||||
|
setIsWoAdmin(msg.is_admin ?? true);
|
||||||
|
setPermittedIssueTypes(msg.restrictions?.['Issue Type']?.values || []);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Error fetching issue type permissions:', e);
|
||||||
|
setIsWoAdmin(true);
|
||||||
|
setPermittedIssueTypes([]);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error checking permissions:', error);
|
||||||
|
setCanDelete(false);
|
||||||
|
setCanAddInspection(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
checkPermissions();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Filters
|
||||||
|
const [dateFilterBy, setDateFilterBy] = useState<'' | 'creation' | 'modified'>(() => (searchParams.get('date_filter_by') as '' | 'creation' | 'modified') || '');
|
||||||
|
const [dateStart, setDateStart] = useState<string>(() => searchParams.get('date_start') || '');
|
||||||
|
const [dateEnd, setDateEnd] = useState<string>(() => searchParams.get('date_end') || '');
|
||||||
|
const [statusFilter, setStatusFilter] = useState<string>(() => searchParams.get('status') || '');
|
||||||
|
const [workflowStateFilter, setWorkflowStateFilter] = useState<string>(() => searchParams.get('workflow_state') || '');
|
||||||
|
const [inspectionTypeFilter, setInspectionTypeFilter] = useState<string>(() => searchParams.get('inspection_type') || '');
|
||||||
|
const [workOrderFilter, setWorkOrderFilter] = useState<string>(() => searchParams.get('work_order') || '');
|
||||||
|
const [departmentFilter, setDepartmentFilter] = useState<string>(() => searchParams.get('department') || '');
|
||||||
|
|
||||||
|
const [sortBy, setSortBy] = useState<string>(() => searchParams.get('sort_by') || 'creation desc');
|
||||||
|
const [isFilterExpanded, setIsFilterExpanded] = useState(false);
|
||||||
|
const [activeFilterCount, setActiveFilterCount] = useState(0);
|
||||||
|
const [savedFilters, setSavedFilters] = useState<any[]>([]);
|
||||||
|
const [showSaveFilterModal, setShowSaveFilterModal] = useState(false);
|
||||||
|
const [filterPresetName, setFilterPresetName] = useState('');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const saved = localStorage.getItem('inspectionFilterPresets');
|
||||||
|
if (saved) setSavedFilters(JSON.parse(saved));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const hasDateFilter = dateFilterBy && (dateStart || dateEnd);
|
||||||
|
useEffect(() => {
|
||||||
|
const count = [statusFilter, workflowStateFilter, inspectionTypeFilter, workOrderFilter, departmentFilter].filter(Boolean).length + (hasDateFilter ? 1 : 0);
|
||||||
|
setActiveFilterCount(count);
|
||||||
|
}, [statusFilter, workflowStateFilter, inspectionTypeFilter, workOrderFilter, departmentFilter, hasDateFilter]);
|
||||||
|
|
||||||
|
const apiFilters = useMemo(() => {
|
||||||
|
const filters: Record<string, any> = {};
|
||||||
|
if (statusFilter) filters['status'] = statusFilter;
|
||||||
|
if (workflowStateFilter) filters['workflow_state'] = workflowStateFilter;
|
||||||
|
if (inspectionTypeFilter) filters['inspection_type'] = inspectionTypeFilter;
|
||||||
|
if (workOrderFilter) filters['linked_corrective_wo_no'] = workOrderFilter;
|
||||||
|
if (departmentFilter) filters['work_order_type'] = departmentFilter;
|
||||||
|
Object.assign(filters, buildDateRangeFilters(dateFilterBy, dateStart, dateEnd));
|
||||||
|
filters['custom_delete_status'] = ['!=', 'Deleted'];
|
||||||
|
return filters;
|
||||||
|
}, [statusFilter, workflowStateFilter, inspectionTypeFilter, workOrderFilter, departmentFilter, dateFilterBy, dateStart, dateEnd]);
|
||||||
|
|
||||||
|
const orderBy = ['creation desc', 'creation asc', 'modified desc', 'modified asc', 'name asc', 'name desc'].includes(sortBy) ? sortBy : 'creation desc';
|
||||||
|
const { inspections, loading, error, totalCount, refetch } = useInspectionList({
|
||||||
|
filters: apiFilters,
|
||||||
|
limit_start: (currentPage - 1) * pageSize,
|
||||||
|
limit_page_length: pageSize,
|
||||||
|
order_by: orderBy,
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => { if (!loading && !initialLoadComplete) setInitialLoadComplete(true); }, [loading, initialLoadComplete]);
|
||||||
|
|
||||||
|
const filtersChangedOnce = useRef(false);
|
||||||
|
useEffect(() => {
|
||||||
|
if (!filtersChangedOnce.current) {
|
||||||
|
filtersChangedOnce.current = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSearchParams((prev) => {
|
||||||
|
const next = new URLSearchParams(prev);
|
||||||
|
if (dateFilterBy) next.set('date_filter_by', dateFilterBy); else next.delete('date_filter_by');
|
||||||
|
if (dateStart) next.set('date_start', dateStart); else next.delete('date_start');
|
||||||
|
if (dateEnd) next.set('date_end', dateEnd); else next.delete('date_end');
|
||||||
|
if (statusFilter) next.set('status', statusFilter); else next.delete('status');
|
||||||
|
if (workflowStateFilter) next.set('workflow_state', workflowStateFilter); else next.delete('workflow_state');
|
||||||
|
if (inspectionTypeFilter) next.set('inspection_type', inspectionTypeFilter); else next.delete('inspection_type');
|
||||||
|
if (workOrderFilter) next.set('work_order', workOrderFilter); else next.delete('work_order');
|
||||||
|
if (departmentFilter) next.set('department', departmentFilter); else next.delete('department');
|
||||||
|
if (sortBy && sortBy !== 'creation desc') next.set('sort_by', sortBy); else next.delete('sort_by');
|
||||||
|
next.set('page', '1');
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}, [dateFilterBy, dateStart, dateEnd, statusFilter, workflowStateFilter, inspectionTypeFilter, workOrderFilter, departmentFilter, sortBy]);
|
||||||
|
|
||||||
|
useEffect(() => { setSelectedRows(new Set()); }, [dateFilterBy, dateStart, dateEnd, statusFilter, workflowStateFilter, inspectionTypeFilter, workOrderFilter, departmentFilter, currentPage]);
|
||||||
|
|
||||||
|
const getDeleteStatusRowClass = (deleteStatus: string | undefined): string => {
|
||||||
|
switch (deleteStatus) {
|
||||||
|
case 'Delete Request With Supervisor': return 'bg-orange-50 dark:bg-orange-900/10';
|
||||||
|
case 'Delete Request With CM': return 'bg-yellow-50 dark:bg-yellow-900/10';
|
||||||
|
case 'Deleted': return 'bg-red-50 dark:bg-red-900/10';
|
||||||
|
default: return '';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const totalPages = Math.ceil(totalCount / pageSize);
|
||||||
|
const formatDate = (dateStr: string) => dateStr ? new Date(dateStr).toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' }) : '-';
|
||||||
|
|
||||||
|
const clearFilters = () => {
|
||||||
|
setDateFilterBy(''); setDateStart(''); setDateEnd('');
|
||||||
|
setSortBy('creation desc');
|
||||||
|
setStatusFilter(''); setWorkflowStateFilter(''); setInspectionTypeFilter(''); setWorkOrderFilter(''); setDepartmentFilter('');
|
||||||
|
setSearchParams((prev) => {
|
||||||
|
const next = new URLSearchParams(prev);
|
||||||
|
next.delete('date_filter_by'); next.delete('date_start'); next.delete('date_end');
|
||||||
|
next.delete('sort_by');
|
||||||
|
next.delete('status'); next.delete('workflow_state'); next.delete('inspection_type'); next.delete('work_order'); next.delete('department');
|
||||||
|
next.set('page', '1');
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
const hasActiveFilters = hasDateFilter || !!statusFilter || !!workflowStateFilter || !!inspectionTypeFilter || !!workOrderFilter || !!departmentFilter;
|
||||||
|
|
||||||
|
const handleSaveFilterPreset = () => {
|
||||||
|
if (!filterPresetName.trim()) { alert(t('common.enterFilterName')); return; }
|
||||||
|
const preset = { id: Date.now(), name: filterPresetName, filters: { dateFilterBy, dateStart, dateEnd, sortBy, statusFilter, workflowStateFilter, inspectionTypeFilter, workOrderFilter, departmentFilter } };
|
||||||
|
const updated = [...savedFilters, preset];
|
||||||
|
setSavedFilters(updated);
|
||||||
|
setFilterPresetName('');
|
||||||
|
setShowSaveFilterModal(false);
|
||||||
|
localStorage.setItem('inspectionFilterPresets', JSON.stringify(updated));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleLoadFilterPreset = (preset: any) => {
|
||||||
|
const f = preset.filters;
|
||||||
|
setDateFilterBy(f.dateFilterBy || ''); setDateStart(f.dateStart || ''); setDateEnd(f.dateEnd || '');
|
||||||
|
setSortBy(f.sortBy || 'creation desc');
|
||||||
|
setStatusFilter(f.statusFilter || '');
|
||||||
|
setWorkflowStateFilter(f.workflowStateFilter || '');
|
||||||
|
setInspectionTypeFilter(f.inspectionTypeFilter || '');
|
||||||
|
setWorkOrderFilter(f.workOrderFilter || '');
|
||||||
|
setDepartmentFilter(f.departmentFilter || '');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteFilterPreset = (id: number) => {
|
||||||
|
const updated = savedFilters.filter(f => f.id !== id);
|
||||||
|
setSavedFilters(updated);
|
||||||
|
localStorage.setItem('inspectionFilterPresets', JSON.stringify(updated));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSelectRow = (name: string) => {
|
||||||
|
setSelectedRows(prev => { const newSet = new Set(prev); newSet.has(name) ? newSet.delete(name) : newSet.add(name); return newSet; });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSelectAll = () => { selectedRows.size === inspections.length ? setSelectedRows(new Set()) : setSelectedRows(new Set(inspections.map(i => i.name))); };
|
||||||
|
const isAllSelected = inspections.length > 0 && selectedRows.size === inspections.length;
|
||||||
|
const isSomeSelected = selectedRows.size > 0 && selectedRows.size < inspections.length;
|
||||||
|
|
||||||
|
const fetchAllInspectionsForExport = useCallback(async (): Promise<any[]> => {
|
||||||
|
const allInspections: any[] = [];
|
||||||
|
let currentPageNum = 0;
|
||||||
|
const pageSizeNum = 100;
|
||||||
|
let hasMoreData = true;
|
||||||
|
const filterArrays = toFrappeFilterArray(apiFilters);
|
||||||
|
while (hasMoreData) {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/method/frappe.client.get_list', {
|
||||||
|
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ doctype: 'Inspection', filters: filterArrays.length > 0 ? filterArrays : {}, fields: ['*'], limit_start: currentPageNum * pageSizeNum, limit_page_length: pageSizeNum, order_by: orderBy })
|
||||||
|
});
|
||||||
|
const data = await response.json();
|
||||||
|
const results = data.message || [];
|
||||||
|
allInspections.push(...results);
|
||||||
|
if (results.length < pageSizeNum) hasMoreData = false; else currentPageNum++;
|
||||||
|
if (currentPageNum > 100) { console.warn('Export safety limit reached'); hasMoreData = false; }
|
||||||
|
} catch (error) { console.error('Error fetching inspections for export:', error); throw error; }
|
||||||
|
}
|
||||||
|
return allInspections;
|
||||||
|
}, [apiFilters, orderBy]);
|
||||||
|
|
||||||
|
const handleExport = async (scope: ExportScope, format: ExportFormat, columns: string[]) => {
|
||||||
|
setIsExporting(true);
|
||||||
|
try {
|
||||||
|
let dataToExport: any[] = [];
|
||||||
|
switch (scope) {
|
||||||
|
case 'selected': dataToExport = inspections.filter(i => selectedRows.has(i.name)); break;
|
||||||
|
case 'all_on_page': dataToExport = inspections; break;
|
||||||
|
case 'all_with_filters': dataToExport = await fetchAllInspectionsForExport(); break;
|
||||||
|
}
|
||||||
|
if (dataToExport.length === 0) { alert(t('assets.noDataToExport')); return; }
|
||||||
|
const columnLabels = columns.map(key => t(EXPORT_COLUMNS.find(c => c.key === key)?.labelKey || key));
|
||||||
|
|
||||||
|
if (format === 'csv') {
|
||||||
|
const csvContent = [columnLabels.join(','), ...dataToExport.map(item => columns.map(key => { let value = item[key] || ''; if (typeof value === 'string' && (value.includes(',') || value.includes('"') || value.includes('\n'))) value = `"${value.replace(/"/g, '""')}"`; return value; }).join(','))].join('\n');
|
||||||
|
const blob = new Blob(['\ufeff' + csvContent], { type: 'text/csv;charset=utf-8;' });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const link = document.createElement('a');
|
||||||
|
link.href = url; link.download = `inspections_export_${new Date().toISOString().split('T')[0]}.csv`; link.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
} else if (format === 'excel') {
|
||||||
|
const worksheetData = [columnLabels, ...dataToExport.map(item => columns.map(key => item[key] || ''))];
|
||||||
|
const worksheet = XLSX.utils.aoa_to_sheet(worksheetData);
|
||||||
|
const workbook = XLSX.utils.book_new();
|
||||||
|
XLSX.utils.book_append_sheet(workbook, worksheet, 'Inspections');
|
||||||
|
XLSX.writeFile(workbook, `inspections_export_${new Date().toISOString().split('T')[0]}.xlsx`);
|
||||||
|
}
|
||||||
|
setShowExportModal(false); setSelectedRows(new Set());
|
||||||
|
} catch (error) { console.error('Export failed:', error); alert(`Export failed: ${error instanceof Error ? error.message : 'Unknown error'}`); }
|
||||||
|
finally { setIsExporting(false); }
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = async (name: string) => {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/resource/Inspection/${name}`, { method: 'DELETE', headers: { 'Content-Type': 'application/json' } });
|
||||||
|
if (!response.ok) throw new Error('Failed to delete');
|
||||||
|
setDeleteConfirmOpen(null); refetch(); alert(t('inspections.deletedSuccessfully'));
|
||||||
|
} catch (err) { alert(`Failed to delete: ${err instanceof Error ? err.message : 'Unknown error'}`); }
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading && !initialLoadComplete) {
|
||||||
|
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-teal-500 mx-auto"></div>
|
||||||
|
<p className="mt-4 text-gray-600 dark:text-gray-400">{t('inspections.loadingInspections')}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
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">{t('inspections.errorLoadingInspections')}</h2>
|
||||||
|
<p className="text-red-700 dark:text-red-400 mb-4">{error}</p>
|
||||||
|
<button onClick={refetch} className="bg-teal-600 hover:bg-teal-700 text-white px-4 py-2 rounded">{t('common.tryAgain')}</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>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<FaClipboardCheck className="text-3xl text-teal-600 dark:text-teal-400" />
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-gray-800 dark:text-white">{t('inspections.title')}</h1>
|
||||||
|
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
{t('common.total')}: {totalCount}
|
||||||
|
{selectedRows.size > 0 && <span className="ml-2 text-teal-600 dark:text-teal-400">• {selectedRows.size} selected</span>}
|
||||||
|
{loading && initialLoadComplete && <span className="ml-2 inline-flex items-center gap-1 text-xs text-teal-600 dark:text-teal-400"><div className="animate-spin rounded-full h-3 w-3 border-b-2 border-teal-500"></div>Updating...</span>}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<button onClick={() => setIsFilterExpanded(!isFilterExpanded)} className={`px-4 py-2 rounded-lg flex items-center gap-2 transition-colors ${isFilterExpanded || hasActiveFilters ? 'bg-teal-100 text-teal-700 dark:bg-teal-900/30 dark:text-teal-300' : 'bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-600'}`}>
|
||||||
|
<FaFilter />{t('listPages.filters')}
|
||||||
|
{activeFilterCount > 0 && <span className="bg-teal-600 text-white text-xs px-1.5 py-0.5 rounded-full">{activeFilterCount}</span>}
|
||||||
|
</button>
|
||||||
|
<button onClick={refetch} disabled={loading} className="px-4 py-2 rounded-lg bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-600 flex items-center gap-2 disabled:opacity-50">
|
||||||
|
<FaSync className={loading ? 'animate-spin' : ''} />{t('listPages.refresh')}
|
||||||
|
</button>
|
||||||
|
{/* Inspection Report Button */}
|
||||||
|
<button
|
||||||
|
onClick={() => setShowReportModal(true)}
|
||||||
|
className="bg-cyan-600 hover:bg-cyan-700 text-white px-4 py-2 rounded-lg flex items-center gap-2 shadow transition-all"
|
||||||
|
>
|
||||||
|
<FaClipboardCheck />
|
||||||
|
<span className="font-medium">{t('inspections.inspectionReport')}</span>
|
||||||
|
</button>
|
||||||
|
<button onClick={() => setShowExportModal(true)} className="bg-green-600 hover:bg-green-700 text-white px-4 py-2 rounded-lg flex items-center gap-2 shadow transition-all" disabled={totalCount === 0}>
|
||||||
|
<FaFileExport /><span className="font-medium">{t('common.export')}</span>
|
||||||
|
{selectedRows.size > 0 && <span className="bg-white/20 px-1.5 py-0.5 rounded text-xs">{selectedRows.size}</span>}
|
||||||
|
</button>
|
||||||
|
{canAddInspection && (
|
||||||
|
<button onClick={() => navigate('/inspections/new')} className="bg-teal-600 hover:bg-teal-700 text-white px-4 py-2 rounded-lg flex items-center gap-2 shadow-lg transition-all hover:shadow-xl">
|
||||||
|
<FaPlus /><span className="font-medium">{t('inspections.newInspection')}</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Stats Cards */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-4">
|
||||||
|
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-4 border border-gray-200 dark:border-gray-700">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div><p className="text-sm text-gray-500 dark:text-gray-400">{t('common.total')}</p><p className="text-2xl font-bold text-gray-800 dark:text-white">{totalCount}</p></div>
|
||||||
|
<FaClipboardCheck className="text-3xl text-teal-500" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-4 border border-gray-200 dark:border-gray-700">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div><p className="text-sm text-gray-500 dark:text-gray-400">{t('inspections.stats.draft')}</p><p className="text-2xl font-bold text-orange-600">{inspections.filter(i => i.workflow_state === 'Draft').length}</p></div>
|
||||||
|
<div className="w-10 h-10 rounded-full bg-orange-100 dark:bg-orange-900/30 flex items-center justify-center"><span className="text-orange-600 font-bold">D</span></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-4 border border-gray-200 dark:border-gray-700">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div><p className="text-sm text-gray-500 dark:text-gray-400">{t('inspections.stats.pendingApproval')}</p><p className="text-2xl font-bold text-purple-600">{inspections.filter(i => i.workflow_state === 'Sent to Supervisor').length}</p></div>
|
||||||
|
<div className="w-10 h-10 rounded-full bg-purple-100 dark:bg-purple-900/30 flex items-center justify-center"><span className="text-purple-600 font-bold">P</span></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-4 border border-gray-200 dark:border-gray-700">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div><p className="text-sm text-gray-500 dark:text-gray-400">{t('inspections.stats.closed')}</p><p className="text-2xl font-bold text-green-600">{inspections.filter(i => i.workflow_state === 'Closed').length}</p></div>
|
||||||
|
<div className="w-10 h-10 rounded-full bg-green-100 dark:bg-green-900/30 flex items-center justify-center"><span className="text-green-600 font-bold">C</span></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Expandable Filter Panel */}
|
||||||
|
{isFilterExpanded && (
|
||||||
|
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-lg border border-gray-200 dark:border-gray-700 mb-4">
|
||||||
|
<div className="bg-gradient-to-r from-teal-500 to-teal-600 dark:from-teal-600 dark:to-teal-700 px-4 py-3 rounded-t-lg">
|
||||||
|
<div className="flex items-center justify-between gap-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<FaFilter className="text-white" size={16} /><h3 className="text-white font-semibold text-sm">{t('listPages.filters')}</h3>
|
||||||
|
{activeFilterCount > 0 && <span className="bg-white text-teal-600 px-2 py-0.5 rounded-full text-xs font-bold">{activeFilterCount}</span>}
|
||||||
|
</div>
|
||||||
|
{hasActiveFilters && (
|
||||||
|
<div className="flex-1 overflow-x-auto scrollbar-hide mx-2">
|
||||||
|
<div className="flex items-center gap-2 py-1">
|
||||||
|
{statusFilter && <span className="inline-flex items-center gap-1.5 px-2.5 py-1 bg-white/90 text-blue-700 rounded-full text-[10px] font-medium whitespace-nowrap shadow-sm"><span className="font-semibold">{t('inspections.filterStatus')}:</span> {t(`inspections.status.${(statusFilter || '').toLowerCase().replace(/\s+/g, '_')}`, { defaultValue: statusFilter })}<button onClick={() => setStatusFilter('')} className="hover:text-red-500"><FaTimes className="text-[9px]" /></button></span>}
|
||||||
|
{workflowStateFilter && <span className="inline-flex items-center gap-1.5 px-2.5 py-1 bg-white/90 text-purple-700 rounded-full text-[10px] font-medium whitespace-nowrap shadow-sm"><span className="font-semibold">{t('inspections.filterWorkflow')}:</span> {t(`inspections.workflowState.${(workflowStateFilter || '').toLowerCase().replace(/\s+/g, '_')}`, { defaultValue: workflowStateFilter })}<button onClick={() => setWorkflowStateFilter('')} className="hover:text-red-500"><FaTimes className="text-[9px]" /></button></span>}
|
||||||
|
{inspectionTypeFilter && <span className="inline-flex items-center gap-1.5 px-2.5 py-1 bg-white/90 text-teal-700 rounded-full text-[10px] font-medium whitespace-nowrap shadow-sm"><span className="font-semibold">{t('inspections.filterType')}:</span> {t(`inspections.typeMap.${(inspectionTypeFilter || '').toLowerCase().replace(/\s+/g, '_')}`, { defaultValue: inspectionTypeFilter })}<button onClick={() => setInspectionTypeFilter('')} className="hover:text-red-500"><FaTimes className="text-[9px]" /></button></span>}
|
||||||
|
{workOrderFilter && <span className="inline-flex items-center gap-1.5 px-2.5 py-1 bg-white/90 text-orange-700 rounded-full text-[10px] font-medium whitespace-nowrap shadow-sm"><span className="font-semibold">{t('inspections.filterWorkOrder')}:</span> {workOrderFilter}<button onClick={() => setWorkOrderFilter('')} className="hover:text-red-500"><FaTimes className="text-[9px]" /></button></span>}
|
||||||
|
{departmentFilter && <span className="inline-flex items-center gap-1.5 px-2.5 py-1 bg-white/90 text-green-700 rounded-full text-[10px] font-medium whitespace-nowrap shadow-sm"><span className="font-semibold">{t('inspections.filterDepartment')}:</span> {departmentFilter}<button onClick={() => setDepartmentFilter('')} className="hover:text-red-500"><FaTimes className="text-[9px]" /></button></span>}
|
||||||
|
{hasDateFilter && <span className="inline-flex items-center gap-1.5 px-2.5 py-1 bg-white/90 text-indigo-700 rounded-full text-[10px] font-medium whitespace-nowrap shadow-sm"><span className="font-semibold">{t('filters.filterBy')}:</span> {dateFilterBy === 'creation' ? t('filters.createdDate') : t('filters.latestModifiedDate')} {dateStart && ` ${dateStart}`} {dateEnd && ` - ${dateEnd}`}<button onClick={() => { setDateFilterBy(''); setDateStart(''); setDateEnd(''); }} className="hover:text-red-500"><FaTimes className="text-[9px]" /></button></span>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="flex items-center gap-2 flex-shrink-0">
|
||||||
|
{activeFilterCount > 0 && <button onClick={() => setShowSaveFilterModal(true)} className="px-3 py-1.5 bg-white text-teal-600 hover:bg-teal-50 rounded-md text-xs font-medium transition-all flex items-center gap-1.5"><FaSave size={12} /><span className="hidden sm:inline">{t('common.save')}</span></button>}
|
||||||
|
{hasActiveFilters && <button onClick={clearFilters} className="px-3 py-1.5 bg-red-500 hover:bg-red-600 text-white rounded-md text-xs font-medium transition-all flex items-center gap-1.5"><FaTimes size={12} /><span className="hidden sm:inline">{t('common.clearFilters')}</span></button>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="p-4">
|
||||||
|
{savedFilters.length > 0 && (
|
||||||
|
<div className="mb-4 pb-4 border-b border-gray-200 dark:border-gray-700">
|
||||||
|
<h4 className="text-xs font-semibold text-gray-700 dark:text-gray-300 mb-2 flex items-center gap-2"><FaStar className="text-yellow-500" size={12} />{t('inspections.savedFilters')}</h4>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{savedFilters.map((preset) => (
|
||||||
|
<div key={preset.id} className="group relative inline-flex items-center gap-2 px-3 py-1.5 bg-gradient-to-r from-teal-100 to-blue-100 dark:from-teal-900/30 dark:to-blue-900/30 border border-teal-200 dark:border-teal-700 rounded-lg hover:shadow-md transition-all">
|
||||||
|
<button onClick={() => handleLoadFilterPreset(preset)} className="text-xs font-medium text-teal-700 dark:text-teal-300">{preset.name}</button>
|
||||||
|
<button onClick={() => handleDeleteFilterPreset(preset.id)} className="opacity-0 group-hover:opacity-100 text-red-500 hover:text-red-700 transition-opacity"><FaTrash size={10} /></button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="bg-gray-50 dark:bg-gray-900/50 p-3 rounded-lg">
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-5 gap-3">
|
||||||
|
{/* Sort By */}
|
||||||
|
<div className="relative">
|
||||||
|
<label className="block text-[10px] font-medium text-gray-700 dark:text-gray-300 mb-0.5">{t('filters.sortBy')}</label>
|
||||||
|
<select value={sortBy} onChange={(e) => { setSortBy(e.target.value); setCurrentPage(1); }} className="w-full px-2 py-1 text-xs border border-gray-300 dark:border-gray-600 rounded focus:outline-none focus:ring-2 focus:ring-teal-500 bg-white dark:bg-gray-700 text-gray-900 dark:text-white">
|
||||||
|
<option value="creation desc">{t('filters.sortCreationNewest')}</option>
|
||||||
|
<option value="creation asc">{t('filters.sortCreationOldest')}</option>
|
||||||
|
<option value="modified desc">{t('filters.sortModifiedNewest')}</option>
|
||||||
|
<option value="modified asc">{t('filters.sortModifiedOldest')}</option>
|
||||||
|
<option value="name asc">{t('filters.sortNameAsc')}</option>
|
||||||
|
<option value="name desc">{t('filters.sortNameDesc')}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
{/* Date range */}
|
||||||
|
<div className="relative">
|
||||||
|
<label className="block text-[10px] font-medium text-gray-700 dark:text-gray-300 mb-0.5">{t('filters.filterBy')}</label>
|
||||||
|
<select value={dateFilterBy} onChange={(e) => { const v = e.target.value as '' | 'creation' | 'modified'; setDateFilterBy(v); setCurrentPage(1); }} className="w-full px-2 py-1 text-xs border border-gray-300 dark:border-gray-600 rounded focus:outline-none focus:ring-2 focus:ring-teal-500 bg-white dark:bg-gray-700 text-gray-900 dark:text-white">
|
||||||
|
<option value="">{t('filters.filterBy')}</option>
|
||||||
|
<option value="creation">{t('filters.createdDate')}</option>
|
||||||
|
<option value="modified">{t('filters.latestModifiedDate')}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
{dateFilterBy && (
|
||||||
|
<>
|
||||||
|
<div className="relative">
|
||||||
|
<label className="block text-[10px] font-medium text-gray-700 dark:text-gray-300 mb-0.5">{t('filters.startDate')}</label>
|
||||||
|
<input type="date" value={dateStart} onChange={(e) => { const v = e.target.value; setDateStart(v); if (dateEnd && v > dateEnd) setDateEnd(v); setCurrentPage(1); }} className="w-full px-2 py-1 text-xs border border-gray-300 dark:border-gray-600 rounded focus:outline-none focus:ring-2 focus:ring-teal-500 bg-white dark:bg-gray-700 text-gray-900 dark:text-white" />
|
||||||
|
</div>
|
||||||
|
<div className="relative">
|
||||||
|
<label className="block text-[10px] font-medium text-gray-700 dark:text-gray-300 mb-0.5">{t('filters.endDate')}</label>
|
||||||
|
<input type="date" value={dateEnd} onChange={(e) => { setDateEnd(e.target.value); setCurrentPage(1); }} min={dateStart || undefined} className="w-full px-2 py-1 text-xs border border-gray-300 dark:border-gray-600 rounded focus:outline-none focus:ring-2 focus:ring-teal-500 bg-white dark:bg-gray-700 text-gray-900 dark:text-white" />
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{/* Status Filter */}
|
||||||
|
<div className="relative">
|
||||||
|
<label className="block text-[10px] font-medium text-gray-700 dark:text-gray-300 mb-0.5">{t('filters.status')}</label>
|
||||||
|
<select value={statusFilter} onChange={(e) => { setStatusFilter(e.target.value); setCurrentPage(1); }} className="w-full px-2 py-1 text-xs border border-gray-300 dark:border-gray-600 rounded focus:outline-none focus:ring-2 focus:ring-teal-500 bg-white dark:bg-gray-700 text-gray-900 dark:text-white">
|
||||||
|
<option value="">{t('filters.allStatuses')}</option>
|
||||||
|
<option value="Open">{t('inspections.status.open')}</option>
|
||||||
|
<option value="In Progress">{t('inspections.status.in_progress')}</option>
|
||||||
|
<option value="Pending Review">{t('inspections.status.pending_review')}</option>
|
||||||
|
<option value="Closed">{t('inspections.status.closed')}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
{/* Workflow State Filter */}
|
||||||
|
<div className="relative">
|
||||||
|
<label className="block text-[10px] font-medium text-gray-700 dark:text-gray-300 mb-0.5">{t('filters.workflowState')}</label>
|
||||||
|
<select value={workflowStateFilter} onChange={(e) => { setWorkflowStateFilter(e.target.value); setCurrentPage(1); }} className="w-full px-2 py-1 text-xs border border-gray-300 dark:border-gray-600 rounded focus:outline-none focus:ring-2 focus:ring-teal-500 bg-white dark:bg-gray-700 text-gray-900 dark:text-white">
|
||||||
|
<option value="">{t('filters.allStates')}</option>
|
||||||
|
<option value="Draft">{t('inspections.workflowState.draft')}</option>
|
||||||
|
<option value="Sent to Work Control">{t('inspections.workflowState.sent_to_work_control')}</option>
|
||||||
|
<option value="Sent to technician">{t('inspections.workflowState.sent_to_technician')}</option>
|
||||||
|
<option value="Sent to Supervisor">{t('inspections.workflowState.sent_to_supervisor')}</option>
|
||||||
|
<option value="Closed">{t('inspections.workflowState.closed')}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
{/* Inspection Type Filter */}
|
||||||
|
<div className="relative">
|
||||||
|
<label className="block text-[10px] font-medium text-gray-700 dark:text-gray-300 mb-0.5">{t('inspections.type')}</label>
|
||||||
|
<select value={inspectionTypeFilter} onChange={(e) => { setInspectionTypeFilter(e.target.value); setCurrentPage(1); }} className="w-full px-2 py-1 text-xs border border-gray-300 dark:border-gray-600 rounded focus:outline-none focus:ring-2 focus:ring-teal-500 bg-white dark:bg-gray-700 text-gray-900 dark:text-white">
|
||||||
|
<option value="">{t('filters.allTypes')}</option>
|
||||||
|
<option value="Inspection">{t('inspections.typeMap.inspection')}</option>
|
||||||
|
<option value="Safety Inspection">{t('inspections.typeMap.safety_inspection')}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
{/* Work Order Filter */}
|
||||||
|
<div className="relative z-[60]">
|
||||||
|
<LinkField label={t('inspections.filterWorkOrder')}
|
||||||
|
doctype="Work_Order" value={workOrderFilter} onChange={(val) => { setWorkOrderFilter(val); setCurrentPage(1); }}
|
||||||
|
placeholder={t('inspections.selectWorkOrder')} disabled={false}
|
||||||
|
compact={true}
|
||||||
|
filters={{ custom_delete_status: ['!=', 'Deleted'] }}
|
||||||
|
/>
|
||||||
|
{workOrderFilter && <button onClick={() => setWorkOrderFilter('')} className="absolute right-2 top-6 text-gray-400 hover:text-red-500 transition-colors z-10"><FaTimes size={10} /></button>}
|
||||||
|
</div>
|
||||||
|
{/* Department Filter */}
|
||||||
|
<div className="relative z-[59]">
|
||||||
|
<LinkField label={t('inspections.technicalDepartment')} doctype="Issue Type" value={departmentFilter} onChange={(val) => { setDepartmentFilter(val); setCurrentPage(1); }} placeholder={t('inspections.selectDepartment')} disabled={false} compact={true} />
|
||||||
|
{departmentFilter && <button onClick={() => setDepartmentFilter('')} className="absolute right-2 top-6 text-gray-400 hover:text-red-500 transition-colors z-10"><FaTimes size={10} /></button>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Save Filter Modal */}
|
||||||
|
{showSaveFilterModal && (
|
||||||
|
<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-lg shadow-xl max-w-md w-full p-6 animate-scale-in">
|
||||||
|
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-4">{t('common.saveFilterPreset')}</h3>
|
||||||
|
<input type="text" value={filterPresetName} onChange={(e) => setFilterPresetName(e.target.value)} onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); handleSaveFilterPreset(); } }} placeholder={t('common.enterFilterName')} className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md focus:outline-none focus:ring-2 focus:ring-teal-500 bg-white dark:bg-gray-700 text-gray-900 dark:text-white mb-4" autoFocus />
|
||||||
|
<div className="flex gap-2 justify-end">
|
||||||
|
<button onClick={() => { setShowSaveFilterModal(false); setFilterPresetName(''); }} className="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-md transition-colors">{t('common.cancel')}</button>
|
||||||
|
<button onClick={handleSaveFilterPreset} className="px-4 py-2 text-sm font-medium text-white bg-teal-600 hover:bg-teal-700 rounded-md transition-colors flex items-center gap-2"><FaSave size={12} />{t('common.saveFilter')}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Export Modal */}
|
||||||
|
<ExportModal isOpen={showExportModal} onClose={() => setShowExportModal(false)} selectedCount={selectedRows.size} totalCount={totalCount} pageCount={inspections.length} onExport={handleExport} isExporting={isExporting} exportColumns={EXPORT_COLUMNS} />
|
||||||
|
|
||||||
|
{/* Table */}
|
||||||
|
<div className="bg-white dark:bg-gray-800 rounded-lg shadow border border-gray-200 dark:border-gray-700 overflow-hidden relative">
|
||||||
|
{loading && initialLoadComplete && (
|
||||||
|
<div className="absolute inset-0 bg-white/60 dark:bg-gray-800/60 flex items-center justify-center z-10 backdrop-blur-[1px]">
|
||||||
|
<div className="flex items-center gap-3 bg-white dark:bg-gray-700 px-4 py-2 rounded-lg shadow-lg"><div className="animate-spin rounded-full h-5 w-5 border-b-2 border-teal-500"></div><span className="text-sm text-gray-600 dark:text-gray-300">{t('common.filtering')}</span></div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<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-4 py-3 text-left">
|
||||||
|
<button onClick={handleSelectAll} className="text-gray-500 dark:text-gray-400 hover:text-teal-600 dark:hover:text-teal-400 transition-colors" title={isAllSelected ? t('common.deselectAllTitle') : t('common.selectAllTitle')}>
|
||||||
|
{isAllSelected ? <FaCheckSquare className="text-teal-600 dark:text-teal-400" size={18} /> : isSomeSelected ? <div className="relative"><FaSquare size={18} /><div className="absolute inset-0 flex items-center justify-center"><div className="w-2 h-0.5 bg-current"></div></div></div> : <FaSquare size={18} />}
|
||||||
|
</button>
|
||||||
|
</th>
|
||||||
|
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">{t('inspections.inspectionId')}</th>
|
||||||
|
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">{t('inspections.type')}</th>
|
||||||
|
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">{t('filters.status')}</th>
|
||||||
|
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">{t('inspections.workflowStateHeader')}</th>
|
||||||
|
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">{t('inspections.targetDate')}</th>
|
||||||
|
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">{t('inspections.requestedBy')}</th>
|
||||||
|
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">{t('inspections.linkedWorkOrder')}</th>
|
||||||
|
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">{t('listPages.actions')}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="bg-white dark:bg-gray-800 divide-y divide-gray-200 dark:divide-gray-700">
|
||||||
|
{inspections.length === 0 ? (
|
||||||
|
<tr><td colSpan={9} className="px-4 py-12 text-center text-gray-500 dark:text-gray-400">
|
||||||
|
<div className="flex flex-col items-center"><FaClipboardCheck className="text-4xl text-gray-300 dark:text-gray-600 mb-2" /><p>{t('inspections.noInspectionsFound')}</p>
|
||||||
|
{hasActiveFilters ? (
|
||||||
|
<button onClick={clearFilters} className="mt-4 text-teal-600 dark:text-teal-400 hover:underline">{t('common.clearFilters')}</button>
|
||||||
|
) : canAddInspection ? (
|
||||||
|
<button onClick={() => navigate('/inspections/new')} className="mt-4 text-teal-600 dark:text-teal-400 hover:underline">{t('inspections.createFirstInspection')}</button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</td></tr>
|
||||||
|
) : inspections.map((inspection) => (
|
||||||
|
<tr key={inspection.name} className={`hover:bg-gray-50 dark:hover:bg-gray-700/50 cursor-pointer transition-colors ${selectedRows.has(inspection.name) ? 'bg-teal-50 dark:bg-teal-900/20' : getDeleteStatusRowClass(inspection.custom_delete_status)}`} title={inspection.custom_delete_status ? `Delete Status: ${inspection.custom_delete_status}` : undefined} onClick={() => navigate(`/inspections/${inspection.name}`)}>
|
||||||
|
<td className="px-4 py-3" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<button onClick={() => handleSelectRow(inspection.name)} className="text-gray-500 dark:text-gray-400 hover:text-teal-600 dark:hover:text-teal-400 transition-colors">
|
||||||
|
{selectedRows.has(inspection.name) ? <FaCheckSquare className="text-teal-600 dark:text-teal-400" size={18} /> : <FaSquare size={18} />}
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3"><span className="text-sm font-medium text-teal-600 dark:text-teal-400">{inspection.name}</span></td>
|
||||||
|
<td className="px-4 py-3">{inspection.inspection_type ? <span className={`inline-flex px-2 py-1 text-xs font-medium rounded-full ${getInspectionTypeStyle(inspection.inspection_type)}`}>{t(`inspections.typeMap.${(inspection.inspection_type || '').toLowerCase().replace(/\s+/g, '_')}`, { defaultValue: inspection.inspection_type })}</span> : <span className="text-gray-400">-</span>}</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<span className={`inline-flex px-2 py-1 text-xs font-medium rounded-full ${getStatusStyle(inspection.status)}`}>
|
||||||
|
{inspection.status ? t(`inspections.status.${(inspection.status || '').toLowerCase().replace(/\s+/g, '_')}`, { defaultValue: inspection.status }) : '-'}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3"><span className={`inline-flex px-2 py-1 text-xs font-medium rounded-full ${getWorkflowStateStyle(inspection.workflow_state)}`}>{inspection.workflow_state ? t(`inspections.workflowState.${(inspection.workflow_state || '').toLowerCase().replace(/\s+/g, '_')}`, { defaultValue: inspection.workflow_state }) : '-'}</span></td>
|
||||||
|
<td className="px-4 py-3"><span className="text-sm text-gray-600 dark:text-gray-300">{formatDate(inspection.target_closure_date || '')}</span></td>
|
||||||
|
<td className="px-4 py-3"><span className="text-sm text-gray-600 dark:text-gray-300 line-clamp-1">{inspection.requested_by || '-'}</span></td>
|
||||||
|
<td className="px-4 py-3" onClick={(e) => e.stopPropagation()}>
|
||||||
|
{inspection.linked_corrective_wo_no ? (
|
||||||
|
<button onClick={() => navigate(`/work-orders/${inspection.linked_corrective_wo_no}`)} className="text-sm text-blue-600 dark:text-blue-400 hover:underline flex items-center gap-1">
|
||||||
|
{inspection.linked_corrective_wo_no}
|
||||||
|
<FaExternalLinkAlt size={10} />
|
||||||
|
</button>
|
||||||
|
) : <span className="text-gray-400">-</span>}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<div className="flex items-center gap-1" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<button onClick={() => navigate(`/inspections/${inspection.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={t('inspections.viewDetails')}><FaEye /></button>
|
||||||
|
<button onClick={() => navigate(`/inspections/${inspection.name}?edit=true`)} 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={t('inspections.editInspection')}><FaEdit /></button>
|
||||||
|
<div onClick={(e) => e.stopPropagation()}>
|
||||||
|
<DeleteRequestButton
|
||||||
|
doctype="Inspection"
|
||||||
|
docname={inspection.name}
|
||||||
|
currentDeleteStatus={(inspection.custom_delete_status ?? null) as DeleteStatus}
|
||||||
|
userRoles={userRoles}
|
||||||
|
isSystemManager={listIsSystemManager}
|
||||||
|
triggerMode
|
||||||
|
redirectOnDelete="/inspections"
|
||||||
|
onStatusChange={() => refetch()}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ListPagination
|
||||||
|
currentPage={currentPage}
|
||||||
|
totalCount={totalCount}
|
||||||
|
pageSize={pageSize}
|
||||||
|
itemLabel={t('pagination.inspections')}
|
||||||
|
onPageChange={(p) => setCurrentPage(p)}
|
||||||
|
/>
|
||||||
|
</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">{t('inspections.deleteInspection')}</h3>
|
||||||
|
<p className="text-sm text-gray-600 dark:text-gray-400 mb-4">{t('inspections.deleteConfirmMessage')}</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>{t('inspections.inspectionId')}:</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">{t('common.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"><FaTrash />{t('common.delete')}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Inspection Report Modal */}
|
||||||
|
<InspectionReportModal
|
||||||
|
isOpen={showReportModal}
|
||||||
|
onClose={() => setShowReportModal(false)}
|
||||||
|
permittedIssueTypes={permittedIssueTypes}
|
||||||
|
isAdmin={isWoAdmin}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<style>{`
|
||||||
|
@keyframes scale-in { from { transform: scale(0.95); opacity: 0; } to { transform: scale(1); opacity: 1; } }
|
||||||
|
.animate-scale-in { animation: scale-in 0.2s ease-out; }
|
||||||
|
.scrollbar-hide { -ms-overflow-style: none; scrollbar-width: none; }
|
||||||
|
.scrollbar-hide::-webkit-scrollbar { display: none; }
|
||||||
|
`}</style>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default InspectionList;
|
||||||
700
asm_app/src/pages/IssueDetail.tsx
Normal file
700
asm_app/src/pages/IssueDetail.tsx
Normal file
@ -0,0 +1,700 @@
|
|||||||
|
import React, { useState, useEffect, useCallback } from 'react';
|
||||||
|
import { useParams, useNavigate } from 'react-router-dom';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { useIssueDetails, useIssueMutations } from '../hooks/useIssue';
|
||||||
|
import {
|
||||||
|
FaArrowLeft,
|
||||||
|
FaSave,
|
||||||
|
FaEdit,
|
||||||
|
FaTrash,
|
||||||
|
FaCheckCircle,
|
||||||
|
FaTimesCircle,
|
||||||
|
FaExclamationTriangle,
|
||||||
|
FaClock,
|
||||||
|
FaUser,
|
||||||
|
FaBuilding,
|
||||||
|
FaEnvelope,
|
||||||
|
FaCalendarAlt,
|
||||||
|
FaTag,
|
||||||
|
FaComment
|
||||||
|
} from 'react-icons/fa';
|
||||||
|
import { toast, ToastContainer, Bounce } from 'react-toastify';
|
||||||
|
import 'react-toastify/dist/ReactToastify.css';
|
||||||
|
import LinkField from '../components/LinkField';
|
||||||
|
import type { CreateIssueData } from '../services/issueService';
|
||||||
|
import CommentSection from '../components/CommentSection';
|
||||||
|
|
||||||
|
// Helper to get today's date in YYYY-MM-DD format
|
||||||
|
const getTodayDate = (): string => {
|
||||||
|
return new Date().toISOString().split('T')[0];
|
||||||
|
};
|
||||||
|
|
||||||
|
// Helper to get current time in HH:MM:SS format
|
||||||
|
const getCurrentTime = (): string => {
|
||||||
|
return new Date().toTimeString().split(' ')[0];
|
||||||
|
};
|
||||||
|
|
||||||
|
// Status badge styles
|
||||||
|
const getStatusStyle = (status: string) => {
|
||||||
|
switch (status?.toLowerCase()) {
|
||||||
|
case 'open':
|
||||||
|
return { bg: 'bg-blue-100 dark:bg-blue-900/30', text: 'text-blue-800 dark:text-blue-300', border: 'border-blue-200 dark:border-blue-800' };
|
||||||
|
case 'replied':
|
||||||
|
return { bg: 'bg-purple-100 dark:bg-purple-900/30', text: 'text-purple-800 dark:text-purple-300', border: 'border-purple-200 dark:border-purple-800' };
|
||||||
|
case 'on hold':
|
||||||
|
return { bg: 'bg-yellow-100 dark:bg-yellow-900/30', text: 'text-yellow-800 dark:text-yellow-300', border: 'border-yellow-200 dark:border-yellow-800' };
|
||||||
|
case 'resolved':
|
||||||
|
return { bg: 'bg-green-100 dark:bg-green-900/30', text: 'text-green-800 dark:text-green-300', border: 'border-green-200 dark:border-green-800' };
|
||||||
|
case 'closed':
|
||||||
|
return { bg: 'bg-gray-100 dark:bg-gray-700', text: 'text-gray-800 dark:text-gray-300', border: 'border-gray-200 dark:border-gray-600' };
|
||||||
|
default:
|
||||||
|
return { bg: 'bg-gray-100 dark:bg-gray-700', text: 'text-gray-800 dark:text-gray-300', border: 'border-gray-200 dark:border-gray-600' };
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const IssueDetail: React.FC = () => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { issueName } = useParams<{ issueName: string }>();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const isNewIssue = issueName === 'new';
|
||||||
|
|
||||||
|
// Form data state
|
||||||
|
const [formData, setFormData] = useState<CreateIssueData & {
|
||||||
|
opening_date?: string;
|
||||||
|
opening_time?: string;
|
||||||
|
first_responded_on?: string;
|
||||||
|
sla_resolution_date?: string;
|
||||||
|
sla_resolution_by?: string;
|
||||||
|
}>({
|
||||||
|
subject: '',
|
||||||
|
raised_by: '',
|
||||||
|
status: 'Open',
|
||||||
|
priority: '',
|
||||||
|
issue_type: '',
|
||||||
|
description: '',
|
||||||
|
contact: '',
|
||||||
|
company: '',
|
||||||
|
customer: '',
|
||||||
|
project: '',
|
||||||
|
resolution_details: '',
|
||||||
|
opening_date: isNewIssue ? getTodayDate() : '',
|
||||||
|
opening_time: isNewIssue ? getCurrentTime() : '',
|
||||||
|
first_responded_on: '',
|
||||||
|
sla_resolution_date: '',
|
||||||
|
sla_resolution_by: '',
|
||||||
|
});
|
||||||
|
|
||||||
|
const { issue, loading, error, refetch } = useIssueDetails(isNewIssue ? null : issueName || null);
|
||||||
|
const { createIssue, updateIssue, deleteIssue, loading: saving } = useIssueMutations();
|
||||||
|
|
||||||
|
const [isEditing, setIsEditing] = useState(isNewIssue);
|
||||||
|
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
||||||
|
|
||||||
|
// Load issue data when fetched
|
||||||
|
useEffect(() => {
|
||||||
|
if (issue && !isNewIssue) {
|
||||||
|
setFormData({
|
||||||
|
subject: issue.subject || '',
|
||||||
|
raised_by: issue.raised_by || '',
|
||||||
|
status: issue.status || 'Open',
|
||||||
|
priority: issue.priority || '',
|
||||||
|
issue_type: issue.issue_type || '',
|
||||||
|
description: issue.description || '',
|
||||||
|
contact: issue.contact || '',
|
||||||
|
company: issue.company || '',
|
||||||
|
customer: issue.customer || '',
|
||||||
|
project: issue.project || '',
|
||||||
|
resolution_details: issue.resolution_details || '',
|
||||||
|
opening_date: issue.opening_date || '',
|
||||||
|
opening_time: issue.opening_time || '',
|
||||||
|
first_responded_on: issue.first_responded_on ? issue.first_responded_on.split(' ')[0] : '',
|
||||||
|
sla_resolution_date: issue.sla_resolution_date ? issue.sla_resolution_date.split(' ')[0] : '',
|
||||||
|
sla_resolution_by: issue.sla_resolution_by || '',
|
||||||
|
});
|
||||||
|
setIsEditing(false);
|
||||||
|
}
|
||||||
|
}, [issue, isNewIssue]);
|
||||||
|
|
||||||
|
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement>) => {
|
||||||
|
const { name, value } = e.target;
|
||||||
|
setFormData(prev => ({ ...prev, [name]: value }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
if (!formData.subject) {
|
||||||
|
toast.error('Please enter a subject', {
|
||||||
|
position: "top-right",
|
||||||
|
autoClose: 4000,
|
||||||
|
icon: <FaTimesCircle />
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (isNewIssue) {
|
||||||
|
const newIssue = await createIssue(formData);
|
||||||
|
toast.success('Issue created successfully!', {
|
||||||
|
position: "top-right",
|
||||||
|
autoClose: 3000,
|
||||||
|
icon: <FaCheckCircle />
|
||||||
|
});
|
||||||
|
navigate(`/support/${newIssue.name}`);
|
||||||
|
} else {
|
||||||
|
await updateIssue(issueName!, formData);
|
||||||
|
toast.success('Issue updated successfully!', {
|
||||||
|
position: "top-right",
|
||||||
|
autoClose: 3000,
|
||||||
|
icon: <FaCheckCircle />
|
||||||
|
});
|
||||||
|
setIsEditing(false);
|
||||||
|
refetch();
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
const errorMessage = err instanceof Error ? err.message : 'Unknown error';
|
||||||
|
toast.error(`Failed to save: ${errorMessage}`, {
|
||||||
|
position: "top-right",
|
||||||
|
autoClose: 6000,
|
||||||
|
icon: <FaTimesCircle />
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = async () => {
|
||||||
|
try {
|
||||||
|
await deleteIssue(issueName!);
|
||||||
|
toast.success('Issue deleted successfully!', {
|
||||||
|
position: "top-right",
|
||||||
|
autoClose: 3000,
|
||||||
|
icon: <FaCheckCircle />
|
||||||
|
});
|
||||||
|
navigate(-1);
|
||||||
|
} catch (err) {
|
||||||
|
const errorMessage = err instanceof Error ? err.message : 'Unknown error';
|
||||||
|
toast.error(`Failed to delete: ${errorMessage}`, {
|
||||||
|
position: "top-right",
|
||||||
|
autoClose: 6000,
|
||||||
|
icon: <FaTimesCircle />
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const isFieldDisabled = useCallback((fieldname: string): boolean => {
|
||||||
|
if (!isEditing) return true;
|
||||||
|
// Some fields are always read-only
|
||||||
|
if (['opening_date', 'opening_time'].includes(fieldname) && !isNewIssue) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}, [isEditing, isNewIssue]);
|
||||||
|
|
||||||
|
// Format datetime
|
||||||
|
const formatDateTime = (dateStr: string) => {
|
||||||
|
if (!dateStr) return '-';
|
||||||
|
return new Date(dateStr).toLocaleString();
|
||||||
|
};
|
||||||
|
|
||||||
|
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 issue details...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error && !isNewIssue) {
|
||||||
|
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 Issue</h2>
|
||||||
|
<p className="text-red-700 dark:text-red-400 mb-4">{error}</p>
|
||||||
|
<button
|
||||||
|
onClick={() => navigate(-1)}
|
||||||
|
className="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded"
|
||||||
|
>
|
||||||
|
Back to Issues
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentStatus = issue?.status || formData.status || 'Open';
|
||||||
|
const statusStyle = getStatusStyle(currentStatus);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 p-6">
|
||||||
|
{/* Toast Container */}
|
||||||
|
<ToastContainer
|
||||||
|
position="top-right"
|
||||||
|
autoClose={4000}
|
||||||
|
hideProgressBar={false}
|
||||||
|
newestOnTop
|
||||||
|
closeOnClick
|
||||||
|
rtl={false}
|
||||||
|
pauseOnFocusLoss
|
||||||
|
draggable
|
||||||
|
pauseOnHover
|
||||||
|
theme="colored"
|
||||||
|
transition={Bounce}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Header */}
|
||||||
|
<div className="mb-6 flex justify-between items-center">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<button
|
||||||
|
onClick={() => navigate(-1)}
|
||||||
|
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-2xl font-bold text-gray-800 dark:text-white flex items-center gap-3">
|
||||||
|
{isNewIssue ? t('issues.newIssue') : issue?.name || t('issues.issueDetails')}
|
||||||
|
{!isNewIssue && (
|
||||||
|
<span className={`px-3 py-1 rounded-full text-sm font-medium ${statusStyle.bg} ${statusStyle.text} ${statusStyle.border} border`}>
|
||||||
|
{currentStatus}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</h1>
|
||||||
|
<p className="text-gray-600 dark:text-gray-400 mt-1">
|
||||||
|
{isNewIssue ? t('issues.createNewIssue') : formData.subject}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-3">
|
||||||
|
{!isNewIssue && !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 />
|
||||||
|
{t('common.edit')}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowDeleteConfirm(true)}
|
||||||
|
className="bg-red-600 hover:bg-red-700 text-white px-4 py-2 rounded-lg flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<FaTrash />
|
||||||
|
{t('common.delete')}
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{isEditing && (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
if (isNewIssue) {
|
||||||
|
navigate(-1);
|
||||||
|
} else {
|
||||||
|
setIsEditing(false);
|
||||||
|
refetch();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="bg-gray-600 hover:bg-gray-700 text-white px-4 py-2 rounded-lg"
|
||||||
|
>
|
||||||
|
{t('common.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 ? t('common.saving') : t('common.save')}
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Delete Confirmation Modal */}
|
||||||
|
{showDeleteConfirm && (
|
||||||
|
<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 p-6 max-w-md w-full mx-4 shadow-xl">
|
||||||
|
<div className="flex items-start gap-3 mb-4">
|
||||||
|
<FaExclamationTriangle className="text-red-500 text-xl mt-0.5" />
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-semibold text-gray-800 dark:text-white">Delete Issue</h3>
|
||||||
|
<p className="text-gray-600 dark:text-gray-400 mt-1">
|
||||||
|
Are you sure you want to delete this issue? This action cannot be undone.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-end gap-3">
|
||||||
|
<button
|
||||||
|
onClick={() => setShowDeleteConfirm(false)}
|
||||||
|
className="px-4 py-2 bg-gray-300 hover:bg-gray-400 text-gray-700 rounded-lg"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleDelete}
|
||||||
|
disabled={saving}
|
||||||
|
className="px-4 py-2 bg-red-600 hover:bg-red-700 text-white rounded-lg disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{saving ? 'Deleting...' : 'Delete'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Form */}
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||||
|
{/* Main Content - Left Column */}
|
||||||
|
<div className="lg:col-span-2 space-y-6">
|
||||||
|
{/* Issue Details */}
|
||||||
|
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-md p-6 border border-gray-200 dark:border-gray-700">
|
||||||
|
<h2 className="text-lg font-semibold text-gray-800 dark:text-white mb-4 pb-2 border-b border-gray-200 dark:border-gray-700 flex items-center gap-2">
|
||||||
|
<FaComment className="text-blue-500" />
|
||||||
|
{t('issues.issueDetails')}
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||||
|
{t('issues.subject')} <span className="text-red-500">*</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="subject"
|
||||||
|
value={formData.subject}
|
||||||
|
onChange={handleChange}
|
||||||
|
disabled={isFieldDisabled('subject')}
|
||||||
|
placeholder={t('issues.enterSubject')}
|
||||||
|
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 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||||
|
{t('commonFields.status')}
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
name="status"
|
||||||
|
value={formData.status}
|
||||||
|
onChange={handleChange}
|
||||||
|
disabled={isFieldDisabled('status')}
|
||||||
|
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 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
|
>
|
||||||
|
<option value="Open">Open</option>
|
||||||
|
<option value="Replied">Replied</option>
|
||||||
|
<option value="On Hold">On Hold</option>
|
||||||
|
<option value="Resolved">Resolved</option>
|
||||||
|
<option value="Closed">Closed</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<LinkField
|
||||||
|
label={t('commonFields.priority')}
|
||||||
|
doctype="Issue Priority"
|
||||||
|
value={formData.priority || ''}
|
||||||
|
onChange={(val) => setFormData({ ...formData, priority: val })}
|
||||||
|
disabled={isFieldDisabled('priority')}
|
||||||
|
placeholder={t('issues.selectPriority')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<LinkField
|
||||||
|
label={t('issues.issueType')}
|
||||||
|
doctype="Issue Type"
|
||||||
|
value={formData.issue_type || ''}
|
||||||
|
onChange={(val) => setFormData({ ...formData, issue_type: val })}
|
||||||
|
disabled={isFieldDisabled('issue_type')}
|
||||||
|
placeholder={t('issues.selectIssueType')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||||
|
{t('commonFields.description')}
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
name="description"
|
||||||
|
value={formData.description}
|
||||||
|
onChange={handleChange}
|
||||||
|
disabled={isFieldDisabled('description')}
|
||||||
|
placeholder={t('issues.describeIssue')}
|
||||||
|
rows={5}
|
||||||
|
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 focus:outline-none focus:ring-2 focus:ring-blue-500 resize-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Contact Information */}
|
||||||
|
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-md p-6 border border-gray-200 dark:border-gray-700">
|
||||||
|
<h2 className="text-lg font-semibold text-gray-800 dark:text-white mb-4 pb-2 border-b border-gray-200 dark:border-gray-700 flex items-center gap-2">
|
||||||
|
<FaUser className="text-green-500" />
|
||||||
|
{t('issues.contactInformation')}
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||||
|
{t('issues.raisedBy')}
|
||||||
|
</label>
|
||||||
|
<div className="relative">
|
||||||
|
<FaEnvelope className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400" />
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
name="raised_by"
|
||||||
|
value={formData.raised_by}
|
||||||
|
onChange={handleChange}
|
||||||
|
disabled={isFieldDisabled('raised_by')}
|
||||||
|
placeholder={t('common.email')}
|
||||||
|
className="w-full pl-10 pr-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 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* <div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||||
|
Contact Name
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="contact"
|
||||||
|
value={formData.contact}
|
||||||
|
onChange={handleChange}
|
||||||
|
disabled={isFieldDisabled('contact')}
|
||||||
|
placeholder="Contact person 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 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
|
/>
|
||||||
|
</div> */}
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<LinkField
|
||||||
|
label={t('commonFields.company')}
|
||||||
|
doctype="Company"
|
||||||
|
value={formData.company || ''}
|
||||||
|
onChange={(val) => setFormData({ ...formData, company: val })}
|
||||||
|
disabled={isFieldDisabled('company')}
|
||||||
|
placeholder={t('issues.selectCompany')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* <div>
|
||||||
|
<LinkField
|
||||||
|
label="Customer"
|
||||||
|
doctype="Customer"
|
||||||
|
value={formData.customer || ''}
|
||||||
|
onChange={(val) => setFormData({ ...formData, customer: val })}
|
||||||
|
disabled={isFieldDisabled('customer')}
|
||||||
|
placeholder="Select customer"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<LinkField
|
||||||
|
label="Project"
|
||||||
|
doctype="Project"
|
||||||
|
value={formData.project || ''}
|
||||||
|
onChange={(val) => setFormData({ ...formData, project: val })}
|
||||||
|
disabled={isFieldDisabled('project')}
|
||||||
|
placeholder="Select project"
|
||||||
|
/>
|
||||||
|
</div> */}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Resolution */}
|
||||||
|
{!isNewIssue && (
|
||||||
|
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-md p-6 border border-gray-200 dark:border-gray-700">
|
||||||
|
<h2 className="text-lg font-semibold text-gray-800 dark:text-white mb-4 pb-2 border-b border-gray-200 dark:border-gray-700 flex items-center gap-2">
|
||||||
|
<FaCheckCircle className="text-purple-500" />
|
||||||
|
{t('issues.resolution')}
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||||
|
{t('issues.firstRespondedOn')}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
name="first_responded_on"
|
||||||
|
value={formData.first_responded_on || ''}
|
||||||
|
onChange={handleChange}
|
||||||
|
disabled={isFieldDisabled('first_responded_on')}
|
||||||
|
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 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||||
|
{t('issues.resolutionDate')}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
name="sla_resolution_date"
|
||||||
|
value={formData.sla_resolution_date || ''}
|
||||||
|
onChange={handleChange}
|
||||||
|
disabled={isFieldDisabled('sla_resolution_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 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<LinkField
|
||||||
|
label={t('issues.resolvedBy')}
|
||||||
|
doctype="User"
|
||||||
|
value={formData.sla_resolution_by || ''}
|
||||||
|
onChange={(val) => setFormData({ ...formData, sla_resolution_by: val })}
|
||||||
|
disabled={isFieldDisabled('sla_resolution_by')}
|
||||||
|
placeholder={t('maintenance.selectUser')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||||
|
{t('issues.resolutionDetails')}
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
name="resolution_details"
|
||||||
|
value={formData.resolution_details}
|
||||||
|
onChange={handleChange}
|
||||||
|
disabled={isFieldDisabled('resolution_details')}
|
||||||
|
placeholder={t('issues.describeResolution')}
|
||||||
|
rows={4}
|
||||||
|
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 focus:outline-none focus:ring-2 focus:ring-blue-500 resize-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ✅ ADD THIS — Comments Section */}
|
||||||
|
{!isNewIssue && (
|
||||||
|
<CommentSection
|
||||||
|
referenceDoctype="Issue"
|
||||||
|
referenceName={issueName || null}
|
||||||
|
title="Comments & Discussion" // optional, default shown
|
||||||
|
pollInterval={30000} // optional, auto-refresh every 30s (0 = off)
|
||||||
|
initialLimit={5} // optional, comments shown before "show more"
|
||||||
|
collapsible={true} // optional, allow collapse/expand
|
||||||
|
startCollapsed={false} // optional, start collapsed
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Sidebar - Right Column */}
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Status Card */}
|
||||||
|
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-md p-6 border border-gray-200 dark:border-gray-700">
|
||||||
|
<h2 className="text-lg font-semibold text-gray-800 dark:text-white mb-4 pb-2 border-b border-gray-200 dark:border-gray-700 flex items-center gap-2">
|
||||||
|
<FaTag className="text-orange-500" />
|
||||||
|
{t('issues.statusInformation')}
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className={`p-4 rounded-lg border ${statusStyle.bg} ${statusStyle.border}`}>
|
||||||
|
<p className="text-xs text-gray-500 dark:text-gray-400 mb-1">{t('issues.currentStatus')}</p>
|
||||||
|
<p className={`text-xl font-semibold ${statusStyle.text}`}>
|
||||||
|
{currentStatus}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{formData.priority && (
|
||||||
|
<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">{t('commonFields.priority')}</p>
|
||||||
|
<p className="text-sm font-medium text-gray-900 dark:text-white">
|
||||||
|
{formData.priority}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{formData.issue_type && (
|
||||||
|
<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">{t('issues.issueType')}</p>
|
||||||
|
<p className="text-sm font-medium text-gray-900 dark:text-white">
|
||||||
|
{formData.issue_type}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Timeline Card */}
|
||||||
|
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-md p-6 border border-gray-200 dark:border-gray-700">
|
||||||
|
<h2 className="text-lg font-semibold text-gray-800 dark:text-white mb-4 pb-2 border-b border-gray-200 dark:border-gray-700 flex items-center gap-2">
|
||||||
|
<FaCalendarAlt className="text-teal-500" />
|
||||||
|
{t('issues.timeline')}
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="p-4 bg-gray-50 dark:bg-gray-700 rounded-lg">
|
||||||
|
<p className="text-xs text-gray-500 dark:text-gray-400 mb-1">{t('issues.openingDate')}</p>
|
||||||
|
<p className="text-sm text-gray-900 dark:text-white">
|
||||||
|
{formData.opening_date || '-'}
|
||||||
|
</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">Opening Time</p>
|
||||||
|
<p className="text-sm text-gray-900 dark:text-white">
|
||||||
|
{formData.opening_time || '-'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!isNewIssue && issue && (
|
||||||
|
<>
|
||||||
|
<div className="p-4 bg-gray-50 dark:bg-gray-700 rounded-lg">
|
||||||
|
<p className="text-xs text-gray-500 dark:text-gray-400 mb-1">Created</p>
|
||||||
|
<p className="text-sm text-gray-900 dark:text-white">
|
||||||
|
{formatDateTime(issue.creation)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-4 bg-gray-50 dark:bg-gray-700 rounded-lg">
|
||||||
|
<p className="text-xs text-gray-500 dark:text-gray-400 mb-1">Last Modified</p>
|
||||||
|
<p className="text-sm text-gray-900 dark:text-white">
|
||||||
|
{formatDateTime(issue.modified)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-4 bg-gray-50 dark:bg-gray-700 rounded-lg">
|
||||||
|
<p className="text-xs text-gray-500 dark:text-gray-400 mb-1">Modified By</p>
|
||||||
|
<p className="text-sm text-gray-900 dark:text-white">
|
||||||
|
{issue.modified_by || '-'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Company Info Card */}
|
||||||
|
{formData.company && !isNewIssue && (
|
||||||
|
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-md p-6 border border-gray-200 dark:border-gray-700">
|
||||||
|
<h2 className="text-lg font-semibold text-gray-800 dark:text-white mb-4 pb-2 border-b border-gray-200 dark:border-gray-700 flex items-center gap-2">
|
||||||
|
<FaBuilding className="text-indigo-500" />
|
||||||
|
Company
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<div className="p-4 bg-gray-50 dark:bg-gray-700 rounded-lg">
|
||||||
|
<p className="text-sm font-medium text-gray-900 dark:text-white">
|
||||||
|
{formData.company}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default IssueDetail;
|
||||||
779
asm_app/src/pages/IssueList.tsx
Normal file
779
asm_app/src/pages/IssueList.tsx
Normal file
@ -0,0 +1,779 @@
|
|||||||
|
import React, { useState, useMemo, useEffect, useCallback, useRef } from 'react';
|
||||||
|
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { useIssueList } from '../hooks/useIssue';
|
||||||
|
import ListPagination from '../components/ListPagination';
|
||||||
|
import * as XLSX from 'xlsx';
|
||||||
|
import {
|
||||||
|
FaPlus,
|
||||||
|
FaFilter,
|
||||||
|
FaSync,
|
||||||
|
FaEye,
|
||||||
|
FaChevronLeft,
|
||||||
|
FaChevronRight,
|
||||||
|
FaExclamationCircle,
|
||||||
|
FaCheckCircle,
|
||||||
|
FaClock,
|
||||||
|
FaTimesCircle,
|
||||||
|
FaHeadset,
|
||||||
|
FaTimes,
|
||||||
|
FaSave,
|
||||||
|
FaStar,
|
||||||
|
FaTrash,
|
||||||
|
FaEdit,
|
||||||
|
FaCheckSquare,
|
||||||
|
FaSquare,
|
||||||
|
FaFileExport,
|
||||||
|
FaFileExcel,
|
||||||
|
FaFileCsv,
|
||||||
|
FaDownload
|
||||||
|
} from 'react-icons/fa';
|
||||||
|
import LinkField from '../components/LinkField';
|
||||||
|
import { buildDateRangeFilters, toFrappeFilterArray } from '../utils/listFilterUtils';
|
||||||
|
|
||||||
|
// Export types
|
||||||
|
type ExportFormat = 'csv' | 'excel';
|
||||||
|
type ExportScope = 'selected' | 'all_on_page' | 'all_with_filters';
|
||||||
|
|
||||||
|
interface ExportModalProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
selectedCount: number;
|
||||||
|
totalCount: number;
|
||||||
|
pageCount: number;
|
||||||
|
onExport: (scope: ExportScope, format: ExportFormat, columns: string[]) => void;
|
||||||
|
isExporting: boolean;
|
||||||
|
exportColumns: Array<{key: string, label: string, default: boolean}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ExportModal: React.FC<ExportModalProps> = ({
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
selectedCount,
|
||||||
|
totalCount,
|
||||||
|
pageCount,
|
||||||
|
onExport,
|
||||||
|
isExporting,
|
||||||
|
exportColumns
|
||||||
|
}) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [scope, setScope] = useState<ExportScope>(selectedCount > 0 ? 'selected' : 'all_with_filters');
|
||||||
|
const [format, setFormat] = useState<ExportFormat>('csv');
|
||||||
|
const [selectedColumns, setSelectedColumns] = useState<string[]>(
|
||||||
|
exportColumns.filter(c => c.default).map(c => c.key)
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (selectedCount > 0) {
|
||||||
|
setScope('selected');
|
||||||
|
} else {
|
||||||
|
setScope('all_with_filters');
|
||||||
|
}
|
||||||
|
}, [selectedCount]);
|
||||||
|
|
||||||
|
const toggleColumn = (key: string) => {
|
||||||
|
setSelectedColumns(prev =>
|
||||||
|
prev.includes(key) ? prev.filter(k => k !== key) : [...prev, key]
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const selectAllColumns = () => setSelectedColumns(exportColumns.map(c => c.key));
|
||||||
|
const selectDefaultColumns = () => setSelectedColumns(exportColumns.filter(c => c.default).map(c => c.key));
|
||||||
|
|
||||||
|
if (!isOpen) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-[70] p-4">
|
||||||
|
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-xl max-w-2xl w-full max-h-[90vh] overflow-hidden animate-scale-in">
|
||||||
|
<div className="bg-gradient-to-r from-green-500 to-green-600 px-6 py-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<FaFileExport className="text-white text-xl" />
|
||||||
|
<h3 className="text-lg font-semibold text-white">{t('issues.export.title')}</h3>
|
||||||
|
</div>
|
||||||
|
<button onClick={onClose} className="text-white/80 hover:text-white transition-colors" disabled={isExporting}>
|
||||||
|
<FaTimes size={20} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-6 overflow-y-auto max-h-[calc(90vh-180px)]">
|
||||||
|
<div className="mb-6">
|
||||||
|
<h4 className="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-3">{t('issues.export.selectData')}</h4>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className={`flex items-center gap-3 p-3 rounded-lg border cursor-pointer transition-all ${scope === 'selected' ? 'border-green-500 bg-green-50 dark:bg-green-900/20' : 'border-gray-200 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-700/50'} ${selectedCount === 0 ? 'opacity-50 cursor-not-allowed' : ''}`}>
|
||||||
|
<input type="radio" name="scope" value="selected" checked={scope === 'selected'} onChange={() => setScope('selected')} disabled={selectedCount === 0} className="text-green-600 focus:ring-green-500" />
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="font-medium text-gray-900 dark:text-white">{t('issues.export.selectedRows')}</div>
|
||||||
|
<div className="text-sm text-gray-500 dark:text-gray-400">{t('issues.export.selectedCount', { count: selectedCount })}</div>
|
||||||
|
</div>
|
||||||
|
{selectedCount > 0 && <span className="bg-green-100 dark:bg-green-900/40 text-green-700 dark:text-green-300 px-2 py-1 rounded text-xs font-medium">{selectedCount} selected</span>}
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className={`flex items-center gap-3 p-3 rounded-lg border cursor-pointer transition-all ${scope === 'all_on_page' ? 'border-green-500 bg-green-50 dark:bg-green-900/20' : 'border-gray-200 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-700/50'}`}>
|
||||||
|
<input type="radio" name="scope" value="all_on_page" checked={scope === 'all_on_page'} onChange={() => setScope('all_on_page')} className="text-green-600 focus:ring-green-500" />
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="font-medium text-gray-900 dark:text-white">{t('issues.export.currentPage')}</div>
|
||||||
|
<div className="text-sm text-gray-500 dark:text-gray-400">{t('issues.export.currentPageCount', { count: pageCount })}</div>
|
||||||
|
</div>
|
||||||
|
<span className="bg-blue-100 dark:bg-blue-900/40 text-blue-700 dark:text-blue-300 px-2 py-1 rounded text-xs font-medium">{pageCount} rows</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className={`flex items-center gap-3 p-3 rounded-lg border cursor-pointer transition-all ${scope === 'all_with_filters' ? 'border-green-500 bg-green-50 dark:bg-green-900/20' : 'border-gray-200 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-700/50'}`}>
|
||||||
|
<input type="radio" name="scope" value="all_with_filters" checked={scope === 'all_with_filters'} onChange={() => setScope('all_with_filters')} className="text-green-600 focus:ring-green-500" />
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="font-medium text-gray-900 dark:text-white">{t('issues.export.allWithFilters')}</div>
|
||||||
|
<div className="text-sm text-gray-500 dark:text-gray-400">{t('issues.export.allWithFiltersCount', { count: totalCount })}</div>
|
||||||
|
</div>
|
||||||
|
<span className="bg-purple-100 dark:bg-purple-900/40 text-purple-700 dark:text-purple-300 px-2 py-1 rounded text-xs font-medium">{totalCount} total</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mb-6">
|
||||||
|
<h4 className="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-3">{t('issues.export.exportFormat')}</h4>
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<label className={`flex-1 flex items-center gap-3 p-3 rounded-lg border cursor-pointer transition-all ${format === 'csv' ? 'border-green-500 bg-green-50 dark:bg-green-900/20' : 'border-gray-200 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-700/50'}`}>
|
||||||
|
<input type="radio" name="format" value="csv" checked={format === 'csv'} onChange={() => setFormat('csv')} className="text-green-600 focus:ring-green-500" />
|
||||||
|
<FaFileCsv className="text-green-600 text-xl" />
|
||||||
|
<div>
|
||||||
|
<div className="font-medium text-gray-900 dark:text-white">{t('issues.export.csv')}</div>
|
||||||
|
<div className="text-xs text-gray-500 dark:text-gray-400">{t('issues.export.csvDesc')}</div>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
<label className={`flex-1 flex items-center gap-3 p-3 rounded-lg border cursor-pointer transition-all ${format === 'excel' ? 'border-green-500 bg-green-50 dark:bg-green-900/20' : 'border-gray-200 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-700/50'}`}>
|
||||||
|
<input type="radio" name="format" value="excel" checked={format === 'excel'} onChange={() => setFormat('excel')} className="text-green-600 focus:ring-green-500" />
|
||||||
|
<FaFileExcel className="text-green-700 text-xl" />
|
||||||
|
<div>
|
||||||
|
<div className="font-medium text-gray-900 dark:text-white">{t('issues.export.excel')}</div>
|
||||||
|
<div className="text-xs text-gray-500 dark:text-gray-400">{t('issues.export.excelDesc')}</div>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center justify-between mb-3">
|
||||||
|
<h4 className="text-sm font-semibold text-gray-700 dark:text-gray-300">{t('issues.export.columnsToExport')}</h4>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button onClick={selectAllColumns} className="text-xs text-blue-600 dark:text-blue-400 hover:underline">{t('issues.export.selectAll')}</button>
|
||||||
|
<span className="text-gray-300 dark:text-gray-600">|</span>
|
||||||
|
<button onClick={selectDefaultColumns} className="text-xs text-blue-600 dark:text-blue-400 hover:underline">{t('issues.export.resetToDefault')}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-3 gap-2 max-h-48 overflow-y-auto p-3 bg-gray-50 dark:bg-gray-900/50 rounded-lg">
|
||||||
|
{exportColumns.map((col) => (
|
||||||
|
<label key={col.key} className={`flex items-center gap-2 p-2 rounded cursor-pointer transition-all ${selectedColumns.includes(col.key) ? 'bg-green-100 dark:bg-green-900/30 text-green-800 dark:text-green-300' : 'hover:bg-gray-100 dark:hover:bg-gray-800 text-gray-700 dark:text-gray-400'}`}>
|
||||||
|
<input type="checkbox" checked={selectedColumns.includes(col.key)} onChange={() => toggleColumn(col.key)} className="rounded text-green-600 focus:ring-green-500" />
|
||||||
|
<span className="text-sm truncate">{col.label}</span>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-gray-500 dark:text-gray-400 mt-2">{t('issues.export.columnsSelected', { count: selectedColumns.length })}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="px-6 py-4 bg-gray-50 dark:bg-gray-900/50 border-t border-gray-200 dark:border-gray-700 flex justify-between items-center">
|
||||||
|
<div className="text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
{scope === 'selected' && t('issues.export.exportingSelected', { count: selectedCount })}
|
||||||
|
{scope === 'all_on_page' && t('issues.export.exportingPage', { count: pageCount })}
|
||||||
|
{scope === 'all_with_filters' && t('issues.export.exportingAll', { count: totalCount })}
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<button onClick={onClose} className="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors" disabled={isExporting}>{t('common.cancel')}</button>
|
||||||
|
<button onClick={() => onExport(scope, format, selectedColumns)} disabled={selectedColumns.length === 0 || isExporting} className="px-4 py-2 text-sm font-medium text-white bg-green-600 hover:bg-green-700 rounded-lg transition-colors flex items-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed">
|
||||||
|
{isExporting ? (<><div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white"></div>{t('issues.export.exporting')}</>) : (<><FaDownload />{t('issues.export.exportButton')}</>)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Status badge colors
|
||||||
|
const getStatusStyle = (status: string) => {
|
||||||
|
switch (status?.toLowerCase()) {
|
||||||
|
case 'open': return 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300';
|
||||||
|
case 'replied': return 'bg-purple-100 text-purple-800 dark:bg-purple-900/30 dark:text-purple-300';
|
||||||
|
case 'on hold': return 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-300';
|
||||||
|
case 'resolved': return 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300';
|
||||||
|
case 'closed': return 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300';
|
||||||
|
default: return 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Priority badge colors
|
||||||
|
const getPriorityStyle = (priority: string) => {
|
||||||
|
switch (priority?.toLowerCase()) {
|
||||||
|
case 'high': return 'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-300';
|
||||||
|
case 'medium': return 'bg-orange-100 text-orange-800 dark:bg-orange-900/30 dark:text-orange-300';
|
||||||
|
case 'low': return 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300';
|
||||||
|
default: return 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const IssueList: React.FC = () => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
|
const currentPage = useMemo(() => {
|
||||||
|
const p = parseInt(searchParams.get('page') || '1', 10);
|
||||||
|
return Number.isNaN(p) || p < 1 ? 1 : p;
|
||||||
|
}, [searchParams]);
|
||||||
|
const setCurrentPage = useCallback((pageOrUpdater: number | ((p: number) => number)) => {
|
||||||
|
const next = typeof pageOrUpdater === 'function' ? pageOrUpdater(currentPage) : pageOrUpdater;
|
||||||
|
setSearchParams((prev) => {
|
||||||
|
const nextParams = new URLSearchParams(prev);
|
||||||
|
nextParams.set('page', String(next));
|
||||||
|
return nextParams;
|
||||||
|
});
|
||||||
|
}, [currentPage, setSearchParams]);
|
||||||
|
|
||||||
|
const EXPORT_COLUMNS = [
|
||||||
|
{ key: 'name', label: t('issues.issueId'), default: true },
|
||||||
|
{ key: 'subject', label: t('issues.subject'), default: true },
|
||||||
|
{ key: 'status', label: t('commonFields.status'), default: true },
|
||||||
|
{ key: 'priority', label: t('commonFields.priority'), default: true },
|
||||||
|
{ key: 'raised_by', label: t('issues.raisedBy'), default: true },
|
||||||
|
{ key: 'company', label: t('commonFields.company'), default: true },
|
||||||
|
{ key: 'contact', label: t('issues.contact'), default: false },
|
||||||
|
{ key: 'issue_type', label: t('issues.issueType'), default: false },
|
||||||
|
{ key: 'opening_date', label: t('issues.openingDate'), default: true },
|
||||||
|
{ key: 'sla_resolution_date', label: t('issues.resolutionDate'), default: false },
|
||||||
|
{ key: 'sla_resolution_by', label: t('issues.resolvedBy'), default: false },
|
||||||
|
{ key: 'first_responded_on', label: t('issues.firstRespondedOn'), default: false },
|
||||||
|
{ key: 'description', label: t('commonFields.description'), default: false },
|
||||||
|
{ key: 'resolution_details', label: t('issues.resolutionDetails'), default: false },
|
||||||
|
{ key: 'creation', label: t('commonFields.createdOn'), default: false },
|
||||||
|
{ key: 'modified', label: t('commonFields.modifiedOn'), default: false },
|
||||||
|
{ key: 'owner', label: t('commonFields.createdBy'), default: false },
|
||||||
|
];
|
||||||
|
|
||||||
|
const [pageSize] = useState(20);
|
||||||
|
const [initialLoadComplete, setInitialLoadComplete] = useState(false);
|
||||||
|
const [selectedRows, setSelectedRows] = useState<Set<string>>(new Set());
|
||||||
|
const [showExportModal, setShowExportModal] = useState(false);
|
||||||
|
const [isExporting, setIsExporting] = useState(false);
|
||||||
|
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const [dateFilterBy, setDateFilterBy] = useState<'' | 'creation' | 'modified'>(() => (searchParams.get('date_filter_by') as '' | 'creation' | 'modified') || '');
|
||||||
|
const [dateStart, setDateStart] = useState<string>(() => searchParams.get('date_start') || '');
|
||||||
|
const [dateEnd, setDateEnd] = useState<string>(() => searchParams.get('date_end') || '');
|
||||||
|
const [statusFilter, setStatusFilter] = useState<string>(() => searchParams.get('status') || '');
|
||||||
|
const [priorityFilter, setPriorityFilter] = useState<string>(() => searchParams.get('priority') || '');
|
||||||
|
const [companyFilter, setCompanyFilter] = useState<string>(() => searchParams.get('company') || '');
|
||||||
|
const [issueIdFilter, setIssueIdFilter] = useState<string>(() => searchParams.get('issue_id') || '');
|
||||||
|
const [sortBy, setSortBy] = useState<string>(() => searchParams.get('sort_by') || 'creation desc');
|
||||||
|
|
||||||
|
const [isFilterExpanded, setIsFilterExpanded] = useState(false);
|
||||||
|
const [activeFilterCount, setActiveFilterCount] = useState(0);
|
||||||
|
const [savedFilters, setSavedFilters] = useState<any[]>([]);
|
||||||
|
const [showSaveFilterModal, setShowSaveFilterModal] = useState(false);
|
||||||
|
const [filterPresetName, setFilterPresetName] = useState('');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const saved = localStorage.getItem('issueFilterPresets');
|
||||||
|
if (saved) setSavedFilters(JSON.parse(saved));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const hasDateFilter = dateFilterBy && (dateStart || dateEnd);
|
||||||
|
useEffect(() => {
|
||||||
|
const count = [statusFilter, priorityFilter, companyFilter, issueIdFilter].filter(Boolean).length + (hasDateFilter ? 1 : 0);
|
||||||
|
setActiveFilterCount(count);
|
||||||
|
}, [statusFilter, priorityFilter, companyFilter, issueIdFilter, hasDateFilter]);
|
||||||
|
|
||||||
|
const apiFilters = useMemo(() => {
|
||||||
|
const filters: Record<string, any> = {};
|
||||||
|
if (statusFilter) filters['status'] = statusFilter;
|
||||||
|
if (priorityFilter) filters['priority'] = priorityFilter;
|
||||||
|
if (companyFilter) filters['company'] = companyFilter;
|
||||||
|
if (issueIdFilter) filters['name'] = issueIdFilter;
|
||||||
|
Object.assign(filters, buildDateRangeFilters(dateFilterBy, dateStart, dateEnd));
|
||||||
|
return filters;
|
||||||
|
}, [statusFilter, priorityFilter, companyFilter, issueIdFilter, dateFilterBy, dateStart, dateEnd]);
|
||||||
|
|
||||||
|
const orderBy = ['creation desc', 'creation asc', 'modified desc', 'modified asc', 'name asc', 'name desc'].includes(sortBy) ? sortBy : 'creation desc';
|
||||||
|
const { issues, loading, error, totalCount, refetch } = useIssueList({
|
||||||
|
filters: apiFilters,
|
||||||
|
limit_start: (currentPage - 1) * pageSize,
|
||||||
|
limit_page_length: pageSize,
|
||||||
|
order_by: orderBy,
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => { if (!loading && !initialLoadComplete) setInitialLoadComplete(true); }, [loading, initialLoadComplete]);
|
||||||
|
const filtersChangedOnce = useRef(false);
|
||||||
|
useEffect(() => {
|
||||||
|
if (!filtersChangedOnce.current) {
|
||||||
|
filtersChangedOnce.current = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSearchParams((prev) => {
|
||||||
|
const next = new URLSearchParams(prev);
|
||||||
|
if (dateFilterBy) next.set('date_filter_by', dateFilterBy); else next.delete('date_filter_by');
|
||||||
|
if (dateStart) next.set('date_start', dateStart); else next.delete('date_start');
|
||||||
|
if (dateEnd) next.set('date_end', dateEnd); else next.delete('date_end');
|
||||||
|
if (statusFilter) next.set('status', statusFilter); else next.delete('status');
|
||||||
|
if (priorityFilter) next.set('priority', priorityFilter); else next.delete('priority');
|
||||||
|
if (companyFilter) next.set('company', companyFilter); else next.delete('company');
|
||||||
|
if (issueIdFilter) next.set('issue_id', issueIdFilter); else next.delete('issue_id');
|
||||||
|
if (sortBy && sortBy !== 'creation desc') next.set('sort_by', sortBy); else next.delete('sort_by');
|
||||||
|
next.set('page', '1');
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}, [dateFilterBy, dateStart, dateEnd, statusFilter, priorityFilter, companyFilter, issueIdFilter, sortBy]);
|
||||||
|
useEffect(() => { setSelectedRows(new Set()); }, [dateFilterBy, dateStart, dateEnd, statusFilter, priorityFilter, companyFilter, issueIdFilter, currentPage]);
|
||||||
|
|
||||||
|
const totalPages = Math.ceil(totalCount / pageSize);
|
||||||
|
const formatDate = (dateStr: string) => dateStr ? new Date(dateStr).toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' }) : '-';
|
||||||
|
|
||||||
|
const clearFilters = () => {
|
||||||
|
setDateFilterBy(''); setDateStart(''); setDateEnd('');
|
||||||
|
setSortBy('creation desc');
|
||||||
|
setStatusFilter(''); setPriorityFilter(''); setCompanyFilter(''); setIssueIdFilter('');
|
||||||
|
setSearchParams((prev) => {
|
||||||
|
const next = new URLSearchParams(prev);
|
||||||
|
next.delete('date_filter_by'); next.delete('date_start'); next.delete('date_end');
|
||||||
|
next.delete('sort_by');
|
||||||
|
next.delete('status'); next.delete('priority'); next.delete('company'); next.delete('issue_id');
|
||||||
|
next.set('page', '1');
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
const hasActiveFilters = hasDateFilter || !!statusFilter || !!priorityFilter || !!companyFilter || !!issueIdFilter;
|
||||||
|
|
||||||
|
const handleSaveFilterPreset = () => {
|
||||||
|
if (!filterPresetName.trim()) { alert('Please enter a filter name'); return; }
|
||||||
|
const preset = { id: Date.now(), name: filterPresetName, filters: { dateFilterBy, dateStart, dateEnd, sortBy, statusFilter, priorityFilter, companyFilter, issueIdFilter } };
|
||||||
|
const updated = [...savedFilters, preset];
|
||||||
|
setSavedFilters(updated);
|
||||||
|
setFilterPresetName('');
|
||||||
|
setShowSaveFilterModal(false);
|
||||||
|
localStorage.setItem('issueFilterPresets', JSON.stringify(updated));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleLoadFilterPreset = (preset: any) => {
|
||||||
|
const f = preset.filters;
|
||||||
|
setDateFilterBy(f.dateFilterBy || ''); setDateStart(f.dateStart || ''); setDateEnd(f.dateEnd || '');
|
||||||
|
setSortBy(f.sortBy || 'creation desc');
|
||||||
|
setStatusFilter(f.statusFilter || ''); setPriorityFilter(f.priorityFilter || '');
|
||||||
|
setCompanyFilter(f.companyFilter || ''); setIssueIdFilter(f.issueIdFilter || '');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteFilterPreset = (id: number) => {
|
||||||
|
const updated = savedFilters.filter(f => f.id !== id);
|
||||||
|
setSavedFilters(updated);
|
||||||
|
localStorage.setItem('issueFilterPresets', JSON.stringify(updated));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSelectRow = (issueName: string) => {
|
||||||
|
setSelectedRows(prev => { const newSet = new Set(prev); newSet.has(issueName) ? newSet.delete(issueName) : newSet.add(issueName); return newSet; });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSelectAll = () => { selectedRows.size === issues.length ? setSelectedRows(new Set()) : setSelectedRows(new Set(issues.map(i => i.name))); };
|
||||||
|
const isAllSelected = issues.length > 0 && selectedRows.size === issues.length;
|
||||||
|
const isSomeSelected = selectedRows.size > 0 && selectedRows.size < issues.length;
|
||||||
|
|
||||||
|
const fetchAllIssuesForExport = useCallback(async (): Promise<any[]> => {
|
||||||
|
const allIssues: any[] = [];
|
||||||
|
let currentPageNum = 0;
|
||||||
|
const pageSizeNum = 100;
|
||||||
|
let hasMoreData = true;
|
||||||
|
const filterArrays = toFrappeFilterArray(apiFilters);
|
||||||
|
while (hasMoreData) {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/method/frappe.client.get_list', {
|
||||||
|
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ doctype: 'Issue', filters: filterArrays.length > 0 ? filterArrays : {}, fields: ['*'], limit_start: currentPageNum * pageSizeNum, limit_page_length: pageSizeNum, order_by: orderBy })
|
||||||
|
});
|
||||||
|
const data = await response.json();
|
||||||
|
const results = data.message || [];
|
||||||
|
allIssues.push(...results);
|
||||||
|
if (results.length < pageSizeNum) hasMoreData = false; else currentPageNum++;
|
||||||
|
if (currentPageNum > 100) { console.warn('Export safety limit reached'); hasMoreData = false; }
|
||||||
|
} catch (error) { console.error('Error fetching issues for export:', error); throw error; }
|
||||||
|
}
|
||||||
|
return allIssues;
|
||||||
|
}, [apiFilters, orderBy]);
|
||||||
|
|
||||||
|
const handleExport = async (scope: ExportScope, format: ExportFormat, columns: string[]) => {
|
||||||
|
setIsExporting(true);
|
||||||
|
try {
|
||||||
|
let dataToExport: any[] = [];
|
||||||
|
switch (scope) {
|
||||||
|
case 'selected': dataToExport = issues.filter(i => selectedRows.has(i.name)); break;
|
||||||
|
case 'all_on_page': dataToExport = issues; break;
|
||||||
|
case 'all_with_filters': dataToExport = await fetchAllIssuesForExport(); break;
|
||||||
|
}
|
||||||
|
if (dataToExport.length === 0) { alert(t('assets.noDataToExport')); return; }
|
||||||
|
const columnLabels = columns.map(key => EXPORT_COLUMNS.find(c => c.key === key)?.label || key);
|
||||||
|
|
||||||
|
if (format === 'csv') {
|
||||||
|
const csvContent = [columnLabels.join(','), ...dataToExport.map(issue => columns.map(key => { let value = issue[key] || ''; if (typeof value === 'string' && (value.includes(',') || value.includes('"') || value.includes('\n'))) value = `"${value.replace(/"/g, '""')}"`; return value; }).join(','))].join('\n');
|
||||||
|
const blob = new Blob(['\ufeff' + csvContent], { type: 'text/csv;charset=utf-8;' });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const link = document.createElement('a');
|
||||||
|
link.href = url; link.download = `issues_export_${new Date().toISOString().split('T')[0]}.csv`; link.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
} else if (format === 'excel') {
|
||||||
|
const worksheetData = [columnLabels, ...dataToExport.map(issue => columns.map(key => issue[key] || ''))];
|
||||||
|
const worksheet = XLSX.utils.aoa_to_sheet(worksheetData);
|
||||||
|
const workbook = XLSX.utils.book_new();
|
||||||
|
XLSX.utils.book_append_sheet(workbook, worksheet, 'Issues');
|
||||||
|
XLSX.writeFile(workbook, `issues_export_${new Date().toISOString().split('T')[0]}.xlsx`);
|
||||||
|
}
|
||||||
|
setShowExportModal(false); setSelectedRows(new Set());
|
||||||
|
} catch (error) { console.error('Export failed:', error); alert(`Export failed: ${error instanceof Error ? error.message : 'Unknown error'}`); }
|
||||||
|
finally { setIsExporting(false); }
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = async (issueName: string) => {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/resource/Issue/${issueName}`, { method: 'DELETE', headers: { 'Content-Type': 'application/json' } });
|
||||||
|
if (!response.ok) throw new Error('Failed to delete');
|
||||||
|
setDeleteConfirmOpen(null); refetch(); alert(t('issues.deletedSuccessfully'));
|
||||||
|
} catch (err) { alert(`Failed to delete: ${err instanceof Error ? err.message : 'Unknown error'}`); }
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading && !initialLoadComplete) {
|
||||||
|
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('issues.loadingIssues')}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
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">{t('issues.errorLoadingIssues')}</h2>
|
||||||
|
<p className="text-red-700 dark:text-red-400 mb-4">{error}</p>
|
||||||
|
<button onClick={refetch} className="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded">{t('common.tryAgain')}</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>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<FaHeadset className="text-3xl text-blue-600 dark:text-blue-400" />
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-gray-800 dark:text-white">{t('issues.listTitle')}</h1>
|
||||||
|
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
{t('issues.listTotal')}: {totalCount}
|
||||||
|
{selectedRows.size > 0 && <span className="ml-2 text-blue-600 dark:text-blue-400">• {selectedRows.size} {t('issues.listSelected')}</span>}
|
||||||
|
{loading && initialLoadComplete && <span className="ml-2 inline-flex items-center gap-1 text-xs text-blue-600 dark:text-blue-400"><div className="animate-spin rounded-full h-3 w-3 border-b-2 border-blue-500"></div>{t('common.filtering')}</span>}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<button onClick={() => setIsFilterExpanded(!isFilterExpanded)} className={`px-4 py-2 rounded-lg flex items-center gap-2 transition-colors ${isFilterExpanded || hasActiveFilters ? 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-300' : 'bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-600'}`}>
|
||||||
|
<FaFilter />{t('listPages.filters')}
|
||||||
|
{activeFilterCount > 0 && <span className="bg-blue-600 text-white text-xs px-1.5 py-0.5 rounded-full">{activeFilterCount}</span>}
|
||||||
|
</button>
|
||||||
|
<button onClick={refetch} disabled={loading} className="px-4 py-2 rounded-lg bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-600 flex items-center gap-2 disabled:opacity-50">
|
||||||
|
<FaSync className={loading ? 'animate-spin' : ''} />{t('listPages.refresh')}
|
||||||
|
</button>
|
||||||
|
<button onClick={() => setShowExportModal(true)} className="bg-green-600 hover:bg-green-700 text-white px-4 py-2 rounded-lg flex items-center gap-2 shadow transition-all" disabled={totalCount === 0}>
|
||||||
|
<FaFileExport /><span className="font-medium">{t('listPages.export')}</span>
|
||||||
|
{selectedRows.size > 0 && <span className="bg-white/20 px-1.5 py-0.5 rounded text-xs">{selectedRows.size}</span>}
|
||||||
|
</button>
|
||||||
|
<button onClick={() => navigate('/support/new')} className="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-lg flex items-center gap-2 shadow-lg transition-all hover:shadow-xl">
|
||||||
|
<FaPlus /><span className="font-medium">{t('issues.newIssue')}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Stats Cards */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-4">
|
||||||
|
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-4 border border-gray-200 dark:border-gray-700">
|
||||||
|
<div className="flex items-center justify-between"><div><p className="text-sm text-gray-500 dark:text-gray-400">{t('issues.statsTotalIssues')}</p><p className="text-2xl font-bold text-gray-800 dark:text-white">{totalCount}</p></div><FaExclamationCircle className="text-3xl text-blue-500" /></div>
|
||||||
|
</div>
|
||||||
|
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-4 border border-gray-200 dark:border-gray-700">
|
||||||
|
<div className="flex items-center justify-between"><div><p className="text-sm text-gray-500 dark:text-gray-400">{t('issues.statsOpen')}</p><p className="text-2xl font-bold text-blue-600">{issues.filter(i => i.status === 'Open').length}</p></div><FaClock className="text-3xl text-blue-500" /></div>
|
||||||
|
</div>
|
||||||
|
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-4 border border-gray-200 dark:border-gray-700">
|
||||||
|
<div className="flex items-center justify-between"><div><p className="text-sm text-gray-500 dark:text-gray-400">{t('issues.statsResolved')}</p><p className="text-2xl font-bold text-green-600">{issues.filter(i => i.status === 'Resolved').length}</p></div><FaCheckCircle className="text-3xl text-green-500" /></div>
|
||||||
|
</div>
|
||||||
|
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-4 border border-gray-200 dark:border-gray-700">
|
||||||
|
<div className="flex items-center justify-between"><div><p className="text-sm text-gray-500 dark:text-gray-400">{t('issues.statsClosed')}</p><p className="text-2xl font-bold text-gray-600 dark:text-gray-300">{issues.filter(i => i.status === 'Closed').length}</p></div><FaTimesCircle className="text-3xl text-gray-500" /></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Expandable Filter Panel */}
|
||||||
|
{isFilterExpanded && (
|
||||||
|
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-lg border border-gray-200 dark:border-gray-700 mb-4">
|
||||||
|
<div className="bg-gradient-to-r from-blue-500 to-blue-600 dark:from-blue-600 dark:to-blue-700 px-4 py-3 rounded-t-lg">
|
||||||
|
<div className="flex items-center justify-between gap-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<FaFilter className="text-white" size={16} /><h3 className="text-white font-semibold text-sm">{t('listPages.filters')}</h3>
|
||||||
|
{activeFilterCount > 0 && <span className="bg-white text-blue-600 px-2 py-0.5 rounded-full text-xs font-bold">{activeFilterCount}</span>}
|
||||||
|
</div>
|
||||||
|
{hasActiveFilters && (
|
||||||
|
<div className="flex-1 overflow-x-auto scrollbar-hide mx-2">
|
||||||
|
<div className="flex items-center gap-2 py-1">
|
||||||
|
{issueIdFilter && <span className="inline-flex items-center gap-1.5 px-2.5 py-1 bg-white/90 text-blue-700 rounded-full text-[10px] font-medium whitespace-nowrap shadow-sm"><span className="font-semibold">{t('issues.issueId')}:</span> {issueIdFilter}<button onClick={() => setIssueIdFilter('')} className="hover:text-red-500"><FaTimes className="text-[9px]" /></button></span>}
|
||||||
|
{statusFilter && <span className="inline-flex items-center gap-1.5 px-2.5 py-1 bg-white/90 text-green-700 rounded-full text-[10px] font-medium whitespace-nowrap shadow-sm"><span className="font-semibold">{t('filters.status')}:</span> {statusFilter}<button onClick={() => setStatusFilter('')} className="hover:text-red-500"><FaTimes className="text-[9px]" /></button></span>}
|
||||||
|
{priorityFilter && <span className="inline-flex items-center gap-1.5 px-2.5 py-1 bg-white/90 text-orange-700 rounded-full text-[10px] font-medium whitespace-nowrap shadow-sm"><span className="font-semibold">{t('filters.priority')}:</span> {priorityFilter}<button onClick={() => setPriorityFilter('')} className="hover:text-red-500"><FaTimes className="text-[9px]" /></button></span>}
|
||||||
|
{companyFilter && <span className="inline-flex items-center gap-1.5 px-2.5 py-1 bg-white/90 text-purple-700 rounded-full text-[10px] font-medium whitespace-nowrap shadow-sm"><span className="font-semibold">{t('filters.filterByCompany')}:</span> {companyFilter}<button onClick={() => setCompanyFilter('')} className="hover:text-red-500"><FaTimes className="text-[9px]" /></button></span>}
|
||||||
|
{hasDateFilter && <span className="inline-flex items-center gap-1.5 px-2.5 py-1 bg-white/90 text-indigo-700 rounded-full text-[10px] font-medium whitespace-nowrap shadow-sm"><span className="font-semibold">{t('filters.filterBy')}:</span> {dateFilterBy === 'creation' ? t('filters.createdDate') : t('filters.latestModifiedDate')} {dateStart && ` ${dateStart}`} {dateEnd && ` - ${dateEnd}`}<button onClick={() => { setDateFilterBy(''); setDateStart(''); setDateEnd(''); }} className="hover:text-red-500"><FaTimes className="text-[9px]" /></button></span>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="flex items-center gap-2 flex-shrink-0">
|
||||||
|
{activeFilterCount > 0 && <button onClick={() => setShowSaveFilterModal(true)} className="px-3 py-1.5 bg-white text-blue-600 hover:bg-blue-50 rounded-md text-xs font-medium transition-all flex items-center gap-1.5"><FaSave size={12} /><span className="hidden sm:inline">{t('listPages.saveFilterPreset')}</span></button>}
|
||||||
|
{hasActiveFilters && <button onClick={clearFilters} className="px-3 py-1.5 bg-red-500 hover:bg-red-600 text-white rounded-md text-xs font-medium transition-all flex items-center gap-1.5"><FaTimes size={12} /><span className="hidden sm:inline">{t('listPages.clearFilters')}</span></button>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="p-4">
|
||||||
|
{savedFilters.length > 0 && (
|
||||||
|
<div className="mb-4 pb-4 border-b border-gray-200 dark:border-gray-700">
|
||||||
|
<h4 className="text-xs font-semibold text-gray-700 dark:text-gray-300 mb-2 flex items-center gap-2"><FaStar className="text-yellow-500" size={12} />{t('inspections.savedFilters')}</h4>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{savedFilters.map((preset) => (
|
||||||
|
<div key={preset.id} className="group relative inline-flex items-center gap-2 px-3 py-1.5 bg-gradient-to-r from-purple-100 to-blue-100 dark:from-purple-900/30 dark:to-blue-900/30 border border-purple-200 dark:border-purple-700 rounded-lg hover:shadow-md transition-all">
|
||||||
|
<button onClick={() => handleLoadFilterPreset(preset)} className="text-xs font-medium text-purple-700 dark:text-purple-300">{preset.name}</button>
|
||||||
|
<button onClick={() => handleDeleteFilterPreset(preset.id)} className="opacity-0 group-hover:opacity-100 text-red-500 hover:text-red-700 transition-opacity"><FaTrash size={10} /></button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="bg-gray-50 dark:bg-gray-900/50 p-3 rounded-lg">
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-3">
|
||||||
|
<div className="relative">
|
||||||
|
<label className="block text-[10px] font-medium text-gray-700 dark:text-gray-300 mb-0.5">{t('filters.sortBy')}</label>
|
||||||
|
<select value={sortBy} onChange={(e) => { setSortBy(e.target.value); setCurrentPage(1); }} className="w-full px-2 py-1 text-xs border border-gray-300 dark:border-gray-600 rounded focus:outline-none focus:ring-2 focus:ring-blue-500 bg-white dark:bg-gray-700 text-gray-900 dark:text-white">
|
||||||
|
<option value="creation desc">{t('filters.sortCreationNewest')}</option>
|
||||||
|
<option value="creation asc">{t('filters.sortCreationOldest')}</option>
|
||||||
|
<option value="modified desc">{t('filters.sortModifiedNewest')}</option>
|
||||||
|
<option value="modified asc">{t('filters.sortModifiedOldest')}</option>
|
||||||
|
<option value="name asc">{t('filters.sortNameAsc')}</option>
|
||||||
|
<option value="name desc">{t('filters.sortNameDesc')}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="relative">
|
||||||
|
<label className="block text-[10px] font-medium text-gray-700 dark:text-gray-300 mb-0.5">{t('filters.filterBy')}</label>
|
||||||
|
<select value={dateFilterBy} onChange={(e) => { const v = e.target.value as '' | 'creation' | 'modified'; setDateFilterBy(v); setCurrentPage(1); }} className="w-full px-2 py-1 text-xs border border-gray-300 dark:border-gray-600 rounded focus:outline-none focus:ring-2 focus:ring-blue-500 bg-white dark:bg-gray-700 text-gray-900 dark:text-white">
|
||||||
|
<option value="">{t('filters.filterBy')}</option>
|
||||||
|
<option value="creation">{t('filters.createdDate')}</option>
|
||||||
|
<option value="modified">{t('filters.latestModifiedDate')}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
{dateFilterBy && (
|
||||||
|
<>
|
||||||
|
<div className="relative">
|
||||||
|
<label className="block text-[10px] font-medium text-gray-700 dark:text-gray-300 mb-0.5">{t('filters.startDate')}</label>
|
||||||
|
<input type="date" value={dateStart} onChange={(e) => { const v = e.target.value; setDateStart(v); if (dateEnd && v > dateEnd) setDateEnd(v); setCurrentPage(1); }} className="w-full px-2 py-1 text-xs border border-gray-300 dark:border-gray-600 rounded focus:outline-none focus:ring-2 focus:ring-blue-500 bg-white dark:bg-gray-700 text-gray-900 dark:text-white" />
|
||||||
|
</div>
|
||||||
|
<div className="relative">
|
||||||
|
<label className="block text-[10px] font-medium text-gray-700 dark:text-gray-300 mb-0.5">{t('filters.endDate')}</label>
|
||||||
|
<input type="date" value={dateEnd} onChange={(e) => { setDateEnd(e.target.value); setCurrentPage(1); }} min={dateStart || undefined} className="w-full px-2 py-1 text-xs border border-gray-300 dark:border-gray-600 rounded focus:outline-none focus:ring-2 focus:ring-blue-500 bg-white dark:bg-gray-700 text-gray-900 dark:text-white" />
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<div className="relative z-[60]">
|
||||||
|
<LinkField
|
||||||
|
label={t('issues.issueId')}
|
||||||
|
doctype="Issue"
|
||||||
|
value={issueIdFilter}
|
||||||
|
onChange={(val) => { setIssueIdFilter(val); setCurrentPage(1); }}
|
||||||
|
placeholder={t('linkField.selectLabel', { label: t('issues.issueId') })}
|
||||||
|
disabled={false}
|
||||||
|
compact={true}
|
||||||
|
/>
|
||||||
|
{issueIdFilter && <button onClick={() => setIssueIdFilter('')} className="absolute right-2 top-6 text-gray-400 hover:text-red-500 transition-colors z-10"><FaTimes size={10} /></button>}
|
||||||
|
</div>
|
||||||
|
<div className="relative">
|
||||||
|
<label className="block text-[10px] font-medium text-gray-700 dark:text-gray-300 mb-0.5">
|
||||||
|
{t('filters.status')}
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={statusFilter}
|
||||||
|
onChange={(e) => { setStatusFilter(e.target.value); setCurrentPage(1); }}
|
||||||
|
className="w-full px-2 py-1 text-xs border border-gray-300 dark:border-gray-600 rounded focus:outline-none focus:ring-2 focus:ring-blue-500 bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
|
||||||
|
>
|
||||||
|
<option value="">{t('filters.allStatuses')}</option>
|
||||||
|
<option value="Open">{t('issues.status.open')}</option>
|
||||||
|
<option value="Replied">{t('issues.status.replied')}</option>
|
||||||
|
<option value="On Hold">{t('issues.status.on_hold')}</option>
|
||||||
|
<option value="Resolved">{t('issues.status.resolved')}</option>
|
||||||
|
<option value="Closed">{t('issues.status.closed')}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="relative z-[59]">
|
||||||
|
<LinkField label={t('commonFields.priority')} doctype="Issue Priority" value={priorityFilter} onChange={(val) => { setPriorityFilter(val); setCurrentPage(1); }} placeholder={t('issues.allPriorities')} disabled={false} compact={true} />
|
||||||
|
{priorityFilter && <button onClick={() => setPriorityFilter('')} className="absolute right-2 top-6 text-gray-400 hover:text-red-500 transition-colors z-10"><FaTimes size={10} /></button>}
|
||||||
|
</div>
|
||||||
|
<div className="relative z-[58]">
|
||||||
|
<LinkField label={t('commonFields.company')} doctype="Company" value={companyFilter} onChange={(val) => { setCompanyFilter(val); setCurrentPage(1); }} placeholder={t('issues.allCompanies')} disabled={false} compact={true} />
|
||||||
|
{companyFilter && <button onClick={() => setCompanyFilter('')} className="absolute right-2 top-6 text-gray-400 hover:text-red-500 transition-colors z-10"><FaTimes size={10} /></button>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Save Filter Modal */}
|
||||||
|
{showSaveFilterModal && (
|
||||||
|
<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-lg shadow-xl max-w-md w-full p-6 animate-scale-in">
|
||||||
|
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-4">Save Filter Preset</h3>
|
||||||
|
<input type="text" value={filterPresetName} onChange={(e) => setFilterPresetName(e.target.value)} onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); handleSaveFilterPreset(); } }} placeholder="Enter filter name (e.g., 'Open High Priority')" className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 bg-white dark:bg-gray-700 text-gray-900 dark:text-white mb-4" autoFocus />
|
||||||
|
<div className="flex gap-2 justify-end">
|
||||||
|
<button onClick={() => { setShowSaveFilterModal(false); setFilterPresetName(''); }} className="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-md transition-colors">Cancel</button>
|
||||||
|
<button onClick={handleSaveFilterPreset} className="px-4 py-2 text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 rounded-md transition-colors flex items-center gap-2"><FaSave size={12} />Save Filter</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Export Modal */}
|
||||||
|
<ExportModal isOpen={showExportModal} onClose={() => setShowExportModal(false)} selectedCount={selectedRows.size} totalCount={totalCount} pageCount={issues.length} onExport={handleExport} isExporting={isExporting} exportColumns={EXPORT_COLUMNS} />
|
||||||
|
|
||||||
|
{/* Table */}
|
||||||
|
<div className="bg-white dark:bg-gray-800 rounded-lg shadow border border-gray-200 dark:border-gray-700 overflow-hidden relative">
|
||||||
|
{loading && initialLoadComplete && (
|
||||||
|
<div className="absolute inset-0 bg-white/60 dark:bg-gray-800/60 flex items-center justify-center z-10 backdrop-blur-[1px]">
|
||||||
|
<div className="flex items-center gap-3 bg-white dark:bg-gray-700 px-4 py-2 rounded-lg shadow-lg">
|
||||||
|
<div className="animate-spin rounded-full h-5 w-5 border-b-2 border-blue-500"></div>
|
||||||
|
<span className="text-sm text-gray-600 dark:text-gray-300">
|
||||||
|
{t('common.filtering')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<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-4 py-3 text-left">
|
||||||
|
<button onClick={handleSelectAll} className="text-gray-500 dark:text-gray-400 hover:text-blue-600 dark:hover:text-blue-400 transition-colors" title={isAllSelected ? t('listPages.deselectAllTitle') : t('listPages.selectAllTitle')}>
|
||||||
|
{isAllSelected ? <FaCheckSquare className="text-blue-600 dark:text-blue-400" size={18} /> : isSomeSelected ? <div className="relative"><FaSquare size={18} /><div className="absolute inset-0 flex items-center justify-center"><div className="w-2 h-0.5 bg-current"></div></div></div> : <FaSquare size={18} />}
|
||||||
|
</button>
|
||||||
|
</th>
|
||||||
|
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">{t('issues.issueId')}</th>
|
||||||
|
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">{t('issues.subject')}</th>
|
||||||
|
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">{t('commonFields.status')}</th>
|
||||||
|
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">{t('commonFields.priority')}</th>
|
||||||
|
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">{t('commonFields.company')}</th>
|
||||||
|
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">{t('issues.openingDate')}</th>
|
||||||
|
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">{t('listPages.actions')}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="bg-white dark:bg-gray-800 divide-y divide-gray-200 dark:divide-gray-700">
|
||||||
|
{issues.length === 0 ? (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={8} className="px-4 py-12 text-center text-gray-500 dark:text-gray-400">
|
||||||
|
<div className="flex flex-col items-center">
|
||||||
|
<FaHeadset className="text-4xl text-gray-300 dark:text-gray-600 mb-2" />
|
||||||
|
<p>{t('issues.noIssuesFound')}</p>
|
||||||
|
{hasActiveFilters ? (
|
||||||
|
<button
|
||||||
|
onClick={clearFilters}
|
||||||
|
className="mt-4 text-blue-600 dark:text-blue-400 hover:underline"
|
||||||
|
>
|
||||||
|
{t('common.clearFilters')}
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
onClick={() => navigate('/support/new')}
|
||||||
|
className="mt-4 text-blue-600 dark:text-blue-400 hover:underline"
|
||||||
|
>
|
||||||
|
{t('issues.createFirstIssue')}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
) : issues.map((issue) => (
|
||||||
|
<tr key={issue.name} className={`hover:bg-gray-50 dark:hover:bg-gray-700/50 cursor-pointer transition-colors ${selectedRows.has(issue.name) ? 'bg-blue-50 dark:bg-blue-900/20' : ''}`} onClick={() => navigate(`/support/${issue.name}`)}>
|
||||||
|
<td className="px-4 py-3" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<button onClick={() => handleSelectRow(issue.name)} className="text-gray-500 dark:text-gray-400 hover:text-blue-600 dark:hover:text-blue-400 transition-colors">
|
||||||
|
{selectedRows.has(issue.name) ? <FaCheckSquare className="text-blue-600 dark:text-blue-400" size={18} /> : <FaSquare size={18} />}
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3"><span className="text-sm font-medium text-blue-600 dark:text-blue-400">{issue.name}</span></td>
|
||||||
|
<td className="px-4 py-3"><span className="text-sm text-gray-900 dark:text-white line-clamp-1">{issue.subject || '-'}</span></td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<span className={`inline-flex px-2 py-1 text-xs font-medium rounded-full ${getStatusStyle(issue.status)}`}>
|
||||||
|
{issue.status
|
||||||
|
? t(`issues.status.${(issue.status as string).toLowerCase().replace(/\\s+/g, '_')}`, issue.status)
|
||||||
|
: '-'}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
{issue.priority ? (
|
||||||
|
<span className={`inline-flex px-2 py-1 text-xs font-medium rounded-full ${getPriorityStyle(issue.priority)}`}>
|
||||||
|
{t(`issues.priority.${(issue.priority as string).toLowerCase()}`, issue.priority)}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-gray-400">-</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3"><span className="text-sm text-gray-600 dark:text-gray-300 line-clamp-1">{issue.company || '-'}</span></td>
|
||||||
|
<td className="px-4 py-3"><span className="text-sm text-gray-600 dark:text-gray-300">{formatDate(issue.opening_date)}</span></td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<div className="flex items-center gap-1" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<button onClick={() => navigate(`/support/${issue.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={t('issues.viewDetails')}><FaEye /></button>
|
||||||
|
<button onClick={() => navigate(`/support/${issue.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={t('issues.editIssue')}><FaEdit /></button>
|
||||||
|
<button onClick={() => setDeleteConfirmOpen(issue.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={t('issues.deleteIssue')}><FaTrash /></button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ListPagination
|
||||||
|
currentPage={currentPage}
|
||||||
|
totalCount={totalCount}
|
||||||
|
pageSize={pageSize}
|
||||||
|
itemLabel={t('pagination.issues')}
|
||||||
|
onPageChange={(p) => setCurrentPage(p)}
|
||||||
|
/>
|
||||||
|
</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">{t('issues.deleteIssue')}</h3>
|
||||||
|
<p className="text-sm text-gray-600 dark:text-gray-400 mb-4">{t('issues.deleteConfirmMessage')}</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>{t('issues.issueId')}:</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">{t('common.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"><FaTrash />{t('issues.deleteIssue')}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<style>{`
|
||||||
|
@keyframes scale-in { from { transform: scale(0.95); opacity: 0; } to { transform: scale(1); opacity: 1; } }
|
||||||
|
.animate-scale-in { animation: scale-in 0.2s ease-out; }
|
||||||
|
.scrollbar-hide { -ms-overflow-style: none; scrollbar-width: none; }
|
||||||
|
.scrollbar-hide::-webkit-scrollbar { display: none; }
|
||||||
|
`}</style>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default IssueList;
|
||||||
662
asm_app/src/pages/ItemDetail.tsx
Normal file
662
asm_app/src/pages/ItemDetail.tsx
Normal file
@ -0,0 +1,662 @@
|
|||||||
|
import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react';
|
||||||
|
import { useParams, useNavigate, useSearchParams, useLocation } from 'react-router-dom';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
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';
|
||||||
|
import CommentSection from '../components/CommentSection';
|
||||||
|
import ActivityLog from '../components/ActivityLog';
|
||||||
|
|
||||||
|
import DeleteRequestButton from '../components/DeleteRequestButton';
|
||||||
|
import type { DeleteStatus } from '../services/deleteRequestService';
|
||||||
|
import apiService from '../services/apiService';
|
||||||
|
|
||||||
|
const ItemDetail: React.FC = () => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
// const { itemName } = useParams<{ itemName: string }>();
|
||||||
|
// const navigate = useNavigate();
|
||||||
|
// const [searchParams] = useSearchParams();
|
||||||
|
// const duplicateFromItem = searchParams.get('duplicate');
|
||||||
|
|
||||||
|
// const isNewItem = itemName === 'new';
|
||||||
|
// const isDuplicating = isNewItem && !!duplicateFromItem;
|
||||||
|
|
||||||
|
const { itemName: rawItemName } = useParams<{ itemName: string }>();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const location = useLocation();
|
||||||
|
const [searchParams] = useSearchParams();
|
||||||
|
const duplicateFromItem = searchParams.get('duplicate');
|
||||||
|
|
||||||
|
// Extract item name from pathname directly to preserve # characters
|
||||||
|
// which browsers strip from useParams as URL fragments
|
||||||
|
// const itemName = useMemo(() => {
|
||||||
|
// const prefix = '/inventory/';
|
||||||
|
// const idx = location.pathname.indexOf(prefix);
|
||||||
|
// if (idx !== -1) {
|
||||||
|
// const encoded = location.pathname.slice(idx + prefix.length);
|
||||||
|
// const decoded = decodeURIComponent(encoded);
|
||||||
|
// return decoded;
|
||||||
|
// }
|
||||||
|
// return rawItemName || '';
|
||||||
|
// }, [location.pathname, rawItemName]);
|
||||||
|
|
||||||
|
const itemName = useMemo(() => {
|
||||||
|
if (rawItemName === 'new') return 'new';
|
||||||
|
|
||||||
|
// Use the raw encoded pathname and decode it ourselves
|
||||||
|
// to avoid React Router's automatic decoding losing # info
|
||||||
|
const prefix = '/inventory/';
|
||||||
|
const fullPath = window.location.pathname; // e.g. /asm_app/inventory/DELUGE%20VALVE%20%20NO%23%201
|
||||||
|
const idx = fullPath.indexOf(prefix);
|
||||||
|
if (idx !== -1) {
|
||||||
|
const encoded = fullPath.slice(idx + prefix.length);
|
||||||
|
try {
|
||||||
|
return decodeURIComponent(encoded);
|
||||||
|
} catch {
|
||||||
|
return encoded;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return rawItemName || '';
|
||||||
|
}, [rawItemName, location.pathname]);
|
||||||
|
|
||||||
|
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);
|
||||||
|
|
||||||
|
const [userRoles, setUserRoles] = useState<string[]>([]);
|
||||||
|
const [isSystemManager, setIsSystemManager] = useState(false);
|
||||||
|
const [rolesLoaded, setRolesLoaded] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchRoles = async () => {
|
||||||
|
try {
|
||||||
|
const response = await apiService.apiCall<any>(
|
||||||
|
'/api/method/asset_lite.api.user_roles.get_user_roles'
|
||||||
|
);
|
||||||
|
const roles = Array.isArray(response) ? response : (response?.message || []);
|
||||||
|
setUserRoles(roles);
|
||||||
|
setIsSystemManager(roles.includes('System Manager'));
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error fetching roles:', err);
|
||||||
|
} finally {
|
||||||
|
setRolesLoaded(true);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
fetchRoles();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Form data state
|
||||||
|
const [formData, setFormData] = useState<CreateItemData>({
|
||||||
|
item_code: '',
|
||||||
|
item_name: '',
|
||||||
|
item_group: '',
|
||||||
|
custom_technical_department: '',
|
||||||
|
custom_hospital_name: '',
|
||||||
|
custom_part_description: '',
|
||||||
|
stock_uom: 'Nos',
|
||||||
|
custom_item_cost_per_unit: 0,
|
||||||
|
disabled: 0,
|
||||||
|
is_stock_item: 1,
|
||||||
|
is_fixed_asset: 0,
|
||||||
|
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,
|
||||||
|
custom_serial_no: '',
|
||||||
|
custom_date_in: '',
|
||||||
|
custom_code: '',
|
||||||
|
custom_type: '',
|
||||||
|
custom_volts: undefined as number | undefined,
|
||||||
|
custom_w: undefined as number | undefined,
|
||||||
|
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;
|
||||||
|
const hasDeleteRequest = !!(item?.custom_delete_status);
|
||||||
|
|
||||||
|
// 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_technical_department: item.custom_technical_department || '',
|
||||||
|
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,
|
||||||
|
is_fixed_asset: item.is_fixed_asset ?? 0,
|
||||||
|
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 || [],
|
||||||
|
custom_serial_no: item.custom_serial_no || '',
|
||||||
|
custom_date_in: item.custom_date_in || '',
|
||||||
|
custom_code: item.custom_code || '',
|
||||||
|
custom_type: item.custom_type || '',
|
||||||
|
custom_volts: item.custom_volts,
|
||||||
|
custom_w: item.custom_w,
|
||||||
|
});
|
||||||
|
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_technical_department: item.custom_technical_department || '',
|
||||||
|
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,
|
||||||
|
is_fixed_asset: item.is_fixed_asset ?? 0,
|
||||||
|
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 || [],
|
||||||
|
custom_serial_no: item.custom_serial_no || '',
|
||||||
|
custom_date_in: item.custom_date_in || '',
|
||||||
|
custom_code: item.custom_code || '',
|
||||||
|
custom_type: item.custom_type || '',
|
||||||
|
custom_volts: item.custom_volts,
|
||||||
|
custom_w: item.custom_w,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [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(t('items.itemUpdatedSuccessfully'));
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
alert(`${t('items.failedToSave')}: ${err instanceof Error ? err.message : 'Unknown error'}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = async () => {
|
||||||
|
if (!itemName || isNewItem) {
|
||||||
|
alert(t('items.pleaseSaveFirst'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await submitItem(itemName);
|
||||||
|
await refetchItem();
|
||||||
|
setIsEditing(false);
|
||||||
|
alert(t('items.submittedSuccessfully'));
|
||||||
|
} catch (err) {
|
||||||
|
alert(`${t('items.failedToSubmit')}: ${err instanceof Error ? err.message : 'Unknown error'}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const isFieldDisabled = useCallback((fieldname: string): boolean => {
|
||||||
|
if (!isEditing) return true;
|
||||||
|
if (isCancelled) return true;
|
||||||
|
if (hasDeleteRequest) 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, hasDeleteRequest]);
|
||||||
|
|
||||||
|
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">{t('items.loadingItem')}</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">{t('items.errorLoadingItem')}</h2>
|
||||||
|
<p className="text-red-700 dark:text-red-400 mb-4">{error}</p>
|
||||||
|
<button
|
||||||
|
onClick={() => navigate(-1)}
|
||||||
|
className="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded"
|
||||||
|
>
|
||||||
|
{t('items.backToInventory')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const inputClassName = "w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:bg-gray-100 dark:disabled:bg-gray-700 bg-white dark:bg-gray-700 text-gray-900 dark:text-white";
|
||||||
|
const labelClassName = "block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1";
|
||||||
|
const sectionHeaderClassName = "text-base font-semibold text-gray-800 dark:text-white mb-4 pb-2 border-b border-gray-200 dark:border-gray-700";
|
||||||
|
const cardClassName = "bg-white dark:bg-gray-800 rounded-xl shadow-md p-6 border border-gray-200 dark:border-gray-700";
|
||||||
|
|
||||||
|
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(-1)}
|
||||||
|
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 font-medium">
|
||||||
|
{isNewItem ? t('items.newItem') : item?.item_name || item?.item_code || t('items.title')}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
{!isNewItem && (
|
||||||
|
<span className="px-3 py-1 rounded-full text-xs font-medium bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300">
|
||||||
|
{item?.item_code || itemName}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-3">
|
||||||
|
{!isNewItem && !isEditing && isDraft && !hasDeleteRequest && (
|
||||||
|
<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 />
|
||||||
|
{t('common.edit')}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{/* {!isNewItem && !isEditing && rolesLoaded && (
|
||||||
|
<DeleteRequestButton
|
||||||
|
doctype="Item"
|
||||||
|
docname={itemName}
|
||||||
|
currentDeleteStatus={(item?.custom_delete_status ?? null) as DeleteStatus}
|
||||||
|
userRoles={userRoles}
|
||||||
|
isSystemManager={isSystemManager}
|
||||||
|
inline
|
||||||
|
redirectOnDelete="/inventory"
|
||||||
|
onStatusChange={() => refetchItem()}
|
||||||
|
/>
|
||||||
|
)} */}
|
||||||
|
{isEditing && (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
if (isNewItem) navigate(-1);
|
||||||
|
else { setIsEditing(false); refetchItem(); }
|
||||||
|
}}
|
||||||
|
className="bg-gray-600 hover:bg-gray-700 text-white px-4 py-2 rounded-lg"
|
||||||
|
>
|
||||||
|
{t('common.cancel')}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="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 ? t('common.saving') : t('common.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 />
|
||||||
|
{t('common.submit')}
|
||||||
|
</button>
|
||||||
|
)} */}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Form - Grid Layout matching AssetDetail */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||||
|
{/* COLUMN 1: Basic Information */}
|
||||||
|
<div className={cardClassName}>
|
||||||
|
<h2 className={sectionHeaderClassName}>{t('items.basicInformation')}</h2>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className={labelClassName}>{t('items.itemCode')} <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={inputClassName}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<LinkField
|
||||||
|
label={t('commonFields.hospital')}
|
||||||
|
doctype="Company"
|
||||||
|
value={formData.custom_hospital_name || ''}
|
||||||
|
onChange={(value) => setFormData({ ...formData, custom_hospital_name: value })}
|
||||||
|
disabled={isFieldDisabled('custom_hospital_name')}
|
||||||
|
placeholder={t('items.selectHospital')}
|
||||||
|
filters={{ domain: 'Healthcare' }}
|
||||||
|
/>
|
||||||
|
<LinkField
|
||||||
|
label={t('items.itemGroup')}
|
||||||
|
doctype="Item Group"
|
||||||
|
value={formData.item_group || ''}
|
||||||
|
onChange={(value) => setFormData({ ...formData, item_group: value })}
|
||||||
|
disabled={isFieldDisabled('item_group')}
|
||||||
|
placeholder={t('items.selectItemGroup')}
|
||||||
|
/>
|
||||||
|
<LinkField
|
||||||
|
label={t('items.technicalDepartment')}
|
||||||
|
doctype="Issue Type"
|
||||||
|
value={formData.custom_technical_department || ''}
|
||||||
|
onChange={(value) => setFormData({ ...formData, custom_technical_department: value })}
|
||||||
|
disabled={isFieldDisabled('custom_technical_department')}
|
||||||
|
placeholder={t('items.selectTechnicalDepartment')}
|
||||||
|
/>
|
||||||
|
<div>
|
||||||
|
<label className={labelClassName}>{t('items.stockUOM')}</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={formData.stock_uom}
|
||||||
|
onChange={(e) => setFormData({ ...formData, stock_uom: e.target.value })}
|
||||||
|
disabled={isFieldDisabled('stock_uom')}
|
||||||
|
className={inputClassName}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className={labelClassName}>{t('items.partDescription')}</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={inputClassName}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* COLUMN 2: Inventory Details */}
|
||||||
|
<div className={cardClassName}>
|
||||||
|
<h2 className={sectionHeaderClassName}>{t('items.inventoryDetails')}</h2>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className={labelClassName}>{t('items.serialNo')}</label>
|
||||||
|
<input type="text" value={formData.custom_serial_no} onChange={(e) => setFormData({ ...formData, custom_serial_no: e.target.value })} disabled={isFieldDisabled('custom_serial_no')} className={inputClassName} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className={labelClassName}>{t('items.dateIn')}</label>
|
||||||
|
<input type="date" value={formData.custom_date_in} onChange={(e) => setFormData({ ...formData, custom_date_in: e.target.value })} disabled={isFieldDisabled('custom_date_in')} className={inputClassName} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className={labelClassName}>{t('items.watts')}</label>
|
||||||
|
<input type="number" step="0.01" value={formData.custom_w ?? ''} onChange={(e) => { const v = parseFloat(e.target.value); setFormData({ ...formData, custom_w: e.target.value === '' || isNaN(v) ? undefined : v }); }} disabled={isFieldDisabled('custom_w')} className={inputClassName} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className={labelClassName}>{t('items.volts')}</label>
|
||||||
|
<input type="number" step="0.01" value={formData.custom_volts ?? ''} onChange={(e) => { const v = parseFloat(e.target.value); setFormData({ ...formData, custom_volts: e.target.value === '' || isNaN(v) ? undefined : v }); }} disabled={isFieldDisabled('custom_volts')} className={inputClassName} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className={labelClassName}>{t('items.type')}</label>
|
||||||
|
<input type="text" value={formData.custom_type} onChange={(e) => setFormData({ ...formData, custom_type: e.target.value })} disabled={isFieldDisabled('custom_type')} className={inputClassName} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className={labelClassName}>{t('items.code')}</label>
|
||||||
|
<input type="text" value={formData.custom_code} onChange={(e) => setFormData({ ...formData, custom_code: e.target.value })} disabled={isFieldDisabled('custom_code')} className={inputClassName} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* COLUMN 3: Stock & Additional Information */}
|
||||||
|
<div className={cardClassName}>
|
||||||
|
<h2 className={sectionHeaderClassName}>{t('items.stockInformation')}</h2>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<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">{t('items.isStockItem')}</label>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<input type="checkbox" id="is_fixed_asset" checked={formData.is_fixed_asset === 1} onChange={(e) => setFormData({ ...formData, is_fixed_asset: e.target.checked ? 1 : 0 })} disabled={isFieldDisabled('is_fixed_asset')} className="w-4 h-4" />
|
||||||
|
<label htmlFor="is_fixed_asset" className="text-sm font-medium text-gray-700 dark:text-gray-300">{t('items.isFixedAsset')}</label>
|
||||||
|
</div>
|
||||||
|
{isNewItem && formData.is_stock_item === 1 && (
|
||||||
|
<div>
|
||||||
|
<label className={labelClassName}>{t('items.openingStock')}</label>
|
||||||
|
<input type="number" value={formData.opening_stock} onChange={(e) => setFormData({ ...formData, opening_stock: parseFloat(e.target.value) || 0 })} disabled={isFieldDisabled('opening_stock')} className={inputClassName} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{formData.is_stock_item === 1 && (
|
||||||
|
<div>
|
||||||
|
<label className={labelClassName}>{t('items.valuationRate')}</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={inputClassName} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!isNewItem && formData.is_stock_item === 1 && (
|
||||||
|
<div>
|
||||||
|
<label className={labelClassName}>{t('items.balanceQty')}</label>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<input type="number" value={balanceQty} readOnly className={`${inputClassName} bg-gray-100 dark:bg-gray-800 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={t('items.refreshBalanceQty')}>
|
||||||
|
<FaSync className={balanceQtyLoading ? 'animate-spin' : ''} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Calibration - when Item Group is Tools */}
|
||||||
|
{showCalibrationInfo && (
|
||||||
|
<>
|
||||||
|
<h2 className={`${sectionHeaderClassName} mt-6`}>{t('items.calibrationInformation')}</h2>
|
||||||
|
<div className="space-y-4 mt-4">
|
||||||
|
<div>
|
||||||
|
<label className={labelClassName}>{t('items.lastCalibrationDate')}</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={inputClassName} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className={labelClassName}>{t('items.nextCalibrationDate')}</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={inputClassName} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<h2 className={`${sectionHeaderClassName} mt-6`}>{t('items.additionalInformation')}</h2>
|
||||||
|
<div className="space-y-4 mt-4">
|
||||||
|
<div>
|
||||||
|
<label className={labelClassName}>{t('commonFields.description')}</label>
|
||||||
|
<textarea value={formData.description} onChange={(e) => setFormData({ ...formData, description: e.target.value })} disabled={isFieldDisabled('description')} rows={3} className={inputClassName} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className={labelClassName}>{t('items.warrantyMonths')}</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={inputClassName} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div> {/* ← closes grid */}
|
||||||
|
|
||||||
|
{/* Comments Section */}
|
||||||
|
{!isNewItem && (
|
||||||
|
<div className="mt-6">
|
||||||
|
<CommentSection
|
||||||
|
referenceDoctype="Item"
|
||||||
|
referenceName={itemName || null}
|
||||||
|
title="Comments & Discussion"
|
||||||
|
pollInterval={30000}
|
||||||
|
initialLimit={5}
|
||||||
|
collapsible={true}
|
||||||
|
startCollapsed={false}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Activity Log */}
|
||||||
|
{!isNewItem && !isDuplicating && (
|
||||||
|
<div className="mt-6">
|
||||||
|
<ActivityLog
|
||||||
|
doctype="Item"
|
||||||
|
docname={itemName || null}
|
||||||
|
creationDate={item?.creation}
|
||||||
|
createdBy={item?.owner}
|
||||||
|
compact={false}
|
||||||
|
initialVisible={5}
|
||||||
|
collapsible={true}
|
||||||
|
startCollapsed={true}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Delete Request */}
|
||||||
|
{!isNewItem && rolesLoaded && (
|
||||||
|
<div className="mt-6 max-w-sm">
|
||||||
|
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-md p-6 border border-gray-200 dark:border-gray-700">
|
||||||
|
<h2 className="text-base font-semibold text-gray-800 dark:text-white mb-4 pb-2 border-b border-gray-200 dark:border-gray-700">
|
||||||
|
Delete Request
|
||||||
|
</h2>
|
||||||
|
<DeleteRequestButton
|
||||||
|
doctype="Item"
|
||||||
|
docname={itemName}
|
||||||
|
currentDeleteStatus={(item?.custom_delete_status ?? null) as DeleteStatus}
|
||||||
|
userRoles={userRoles}
|
||||||
|
isSystemManager={isSystemManager}
|
||||||
|
redirectOnDelete="/inventory"
|
||||||
|
onStatusChange={() => refetchItem()}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
</div> {/* ← closes min-h-screen wrapper */}
|
||||||
|
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ItemDetail;
|
||||||
1570
asm_app/src/pages/ItemList.tsx
Normal file
1570
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=1768316563`; // 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">{t('login.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;
|
||||||
253
asm_app/src/pages/MaintenanceCalendarPage.tsx
Normal file
253
asm_app/src/pages/MaintenanceCalendarPage.tsx
Normal file
@ -0,0 +1,253 @@
|
|||||||
|
import React, { useState, useEffect, useMemo } from 'react';
|
||||||
|
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
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 { t } = useTranslation();
|
||||||
|
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">
|
||||||
|
{t('maintenanceCalendarPage.title')}
|
||||||
|
</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">
|
||||||
|
{t('maintenanceCalendarPage.viewType')}
|
||||||
|
</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">{t('maintenanceCalendarPage.maintenanceLog')}</option>
|
||||||
|
<option value="ppm-planner">{t('maintenanceCalendarPage.ppmPlanner')}</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">
|
||||||
|
{t('maintenanceCalendarPage.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">{t('maintenanceCalendarPage.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">
|
||||||
|
{t('maintenanceCalendarPage.yearlyMap')}
|
||||||
|
</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={t('maintenanceCalendarPage.yearlyMapTitle')}
|
||||||
|
>
|
||||||
|
<FaMap size={14} />
|
||||||
|
<span className="hidden sm:inline">{t('maintenanceCalendarPage.yearlyMap')}</span>
|
||||||
|
<span className="sm:hidden">{t('maintenanceCalendarPage.mapShort')}</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={t('maintenanceCalendarPage.hospital')}
|
||||||
|
doctype="Company"
|
||||||
|
value={filterCompany}
|
||||||
|
onChange={(val) => setFilterCompany(val)}
|
||||||
|
placeholder={t('maintenanceCalendarPage.selectHospital')}
|
||||||
|
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={t('maintenanceCalendarPage.department')}
|
||||||
|
doctype="Department"
|
||||||
|
value={filterDepartment}
|
||||||
|
onChange={(val) => setFilterDepartment(val)}
|
||||||
|
placeholder={t('maintenanceCalendarPage.allDepartments')}
|
||||||
|
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">
|
||||||
|
{t('maintenanceCalendarPage.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="">{t('maintenanceCalendarPage.allStatuses')}</option>
|
||||||
|
<option value="Planned">{t('maintenanceCalendarPage.planned')}</option>
|
||||||
|
<option value="Completed">{t('maintenanceCalendarPage.completed')}</option>
|
||||||
|
<option value="Overdue">{t('maintenanceCalendarPage.overdue')}</option>
|
||||||
|
<option value="Cancelled">{t('maintenanceCalendarPage.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={t('maintenanceCalendarPage.assignedTo')}
|
||||||
|
doctype="User"
|
||||||
|
value={filterAssignTo}
|
||||||
|
onChange={(val) => setFilterAssignTo(val)}
|
||||||
|
placeholder={t('maintenanceCalendarPage.allTechnicians')}
|
||||||
|
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 />
|
||||||
|
{t('maintenanceCalendarPage.clearFilters')}
|
||||||
|
</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;
|
||||||
637
asm_app/src/pages/MaintenanceTeamDetail.tsx
Normal file
637
asm_app/src/pages/MaintenanceTeamDetail.tsx
Normal file
@ -0,0 +1,637 @@
|
|||||||
|
import React, { useState, useEffect, useCallback } from 'react';
|
||||||
|
import { useParams, useNavigate, useSearchParams } from 'react-router-dom';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { useMaintenanceTeamDetails, useMaintenanceTeamMutations } from '../hooks/useMaintenanceTeam';
|
||||||
|
import {
|
||||||
|
FaArrowLeft,
|
||||||
|
FaSave,
|
||||||
|
FaEdit,
|
||||||
|
FaTrash,
|
||||||
|
FaCheckCircle,
|
||||||
|
FaTimesCircle,
|
||||||
|
FaExclamationTriangle,
|
||||||
|
FaUsers,
|
||||||
|
FaUserTie,
|
||||||
|
FaBuilding,
|
||||||
|
FaPlus,
|
||||||
|
FaUserPlus,
|
||||||
|
FaTimes
|
||||||
|
} from 'react-icons/fa';
|
||||||
|
import { toast, ToastContainer, Bounce } from 'react-toastify';
|
||||||
|
import 'react-toastify/dist/ReactToastify.css';
|
||||||
|
import LinkField from '../components/LinkField';
|
||||||
|
import type { CreateMaintenanceTeamData, MaintenanceTeamMember } from '../services/maintenanceTeamService';
|
||||||
|
import CommentSection from '../components/CommentSection';
|
||||||
|
|
||||||
|
const MaintenanceTeamDetail: React.FC = () => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { teamName } = useParams<{ teamName: string }>();
|
||||||
|
const [searchParams] = useSearchParams();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const isNewTeam = teamName === 'new';
|
||||||
|
const duplicateFrom = searchParams.get('duplicate');
|
||||||
|
|
||||||
|
const [formData, setFormData] = useState<CreateMaintenanceTeamData>({
|
||||||
|
maintenance_team_name: '',
|
||||||
|
maintenance_manager: '',
|
||||||
|
maintenance_manager_name: '',
|
||||||
|
company: '',
|
||||||
|
custom_expertise: '',
|
||||||
|
maintenance_team_members: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
const { team, loading, error, refetch } = useMaintenanceTeamDetails(
|
||||||
|
isNewTeam ? (duplicateFrom || null) : (teamName || null)
|
||||||
|
);
|
||||||
|
const { createTeam, updateTeam, deleteTeam, getUserFullName, loading: saving } = useMaintenanceTeamMutations();
|
||||||
|
|
||||||
|
const [isEditing, setIsEditing] = useState(isNewTeam);
|
||||||
|
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
||||||
|
const [showDeleteMemberConfirm, setShowDeleteMemberConfirm] = useState<number | null>(null);
|
||||||
|
const [checkingMember, setCheckingMember] = useState<number | null>(null); // Track which row is being checked
|
||||||
|
|
||||||
|
// Check if a team member exists in other teams
|
||||||
|
const checkMemberInOtherTeams = async (memberEmail: string): Promise<{ exists: boolean; teamName?: string }> => {
|
||||||
|
if (!memberEmail) return { exists: false };
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Method 1: Try querying via run_doc_method or SQL
|
||||||
|
const response = await fetch('/api/method/frappe.client.get_list', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Accept': 'application/json',
|
||||||
|
},
|
||||||
|
credentials: 'include',
|
||||||
|
body: JSON.stringify({
|
||||||
|
doctype: 'Asset Maintenance Team',
|
||||||
|
filters: {},
|
||||||
|
fields: ['name', 'maintenance_team_name', 'maintenance_team_members.team_member'],
|
||||||
|
limit_page_length: 0, // Get all
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
// Fallback: Query all teams and check manually
|
||||||
|
return await checkMemberInOtherTeamsFallback(memberEmail);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
const results = data.message || [];
|
||||||
|
|
||||||
|
// Check each team for the member
|
||||||
|
for (const teamData of results) {
|
||||||
|
// Skip current team
|
||||||
|
if (teamData.name === teamName || teamData.name === team?.name) continue;
|
||||||
|
|
||||||
|
if (teamData['maintenance_team_members.team_member'] === memberEmail) {
|
||||||
|
return { exists: true, teamName: teamData.maintenance_team_name || teamData.name };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If the above doesn't work, use fallback
|
||||||
|
return await checkMemberInOtherTeamsFallback(memberEmail);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error checking member in other teams:', error);
|
||||||
|
// Try fallback method
|
||||||
|
return await checkMemberInOtherTeamsFallback(memberEmail);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Fallback method: Fetch all teams with their members
|
||||||
|
const checkMemberInOtherTeamsFallback = async (memberEmail: string): Promise<{ exists: boolean; teamName?: string }> => {
|
||||||
|
try {
|
||||||
|
// Get list of all maintenance teams
|
||||||
|
const listResponse = await fetch('/api/resource/Asset Maintenance Team?fields=["name","maintenance_team_name"]&limit_page_length=0', {
|
||||||
|
method: 'GET',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
credentials: 'include',
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!listResponse.ok) return { exists: false };
|
||||||
|
|
||||||
|
const listData = await listResponse.json();
|
||||||
|
const teams = listData.data || [];
|
||||||
|
|
||||||
|
// Check each team's members
|
||||||
|
for (const teamInfo of teams) {
|
||||||
|
// Skip current team
|
||||||
|
if (teamInfo.name === teamName || teamInfo.name === team?.name) continue;
|
||||||
|
|
||||||
|
// Fetch full team details including members
|
||||||
|
const teamResponse = await fetch(`/api/resource/Asset Maintenance Team/${encodeURIComponent(teamInfo.name)}`, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
credentials: 'include',
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!teamResponse.ok) continue;
|
||||||
|
|
||||||
|
const teamData = await teamResponse.json();
|
||||||
|
const members = teamData.data?.maintenance_team_members || [];
|
||||||
|
|
||||||
|
// Check if member exists in this team
|
||||||
|
const memberExists = members.some((m: any) => m.team_member === memberEmail);
|
||||||
|
|
||||||
|
if (memberExists) {
|
||||||
|
return {
|
||||||
|
exists: true,
|
||||||
|
teamName: teamData.data?.maintenance_team_name || teamInfo.name
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { exists: false };
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Fallback check failed:', error);
|
||||||
|
return { exists: false };
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Load team data when fetched
|
||||||
|
useEffect(() => {
|
||||||
|
if (team) {
|
||||||
|
setFormData({
|
||||||
|
maintenance_team_name: isNewTeam && duplicateFrom ? `${team.maintenance_team_name} (Copy)` : team.maintenance_team_name || '',
|
||||||
|
maintenance_manager: team.maintenance_manager || '',
|
||||||
|
maintenance_manager_name: team.maintenance_manager_name || '',
|
||||||
|
company: team.company || '',
|
||||||
|
custom_expertise: team.custom_expertise || '',
|
||||||
|
maintenance_team_members: team.maintenance_team_members?.map((m, idx) => ({
|
||||||
|
...m,
|
||||||
|
idx: idx + 1,
|
||||||
|
name: isNewTeam ? undefined : m.name, // Clear name for duplicates
|
||||||
|
})) || [],
|
||||||
|
});
|
||||||
|
if (!isNewTeam) setIsEditing(false);
|
||||||
|
}
|
||||||
|
}, [team, isNewTeam, duplicateFrom]);
|
||||||
|
|
||||||
|
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement>) => {
|
||||||
|
const { name, value } = e.target;
|
||||||
|
setFormData(prev => ({ ...prev, [name]: value }));
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle manager change and fetch full name
|
||||||
|
const handleManagerChange = async (email: string) => {
|
||||||
|
setFormData(prev => ({ ...prev, maintenance_manager: email }));
|
||||||
|
if (email) {
|
||||||
|
const fullName = await getUserFullName(email);
|
||||||
|
setFormData(prev => ({ ...prev, maintenance_manager_name: fullName }));
|
||||||
|
} else {
|
||||||
|
setFormData(prev => ({ ...prev, maintenance_manager_name: '' }));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle team member change
|
||||||
|
const handleMemberChange = async (index: number, field: string, value: string) => {
|
||||||
|
// If team_member changed, fetch full name and check for duplicates
|
||||||
|
if (field === 'team_member' && value) {
|
||||||
|
// Check if member is already in current team (other rows)
|
||||||
|
const existsInCurrentTeam = formData.maintenance_team_members?.some(
|
||||||
|
(m, i) => i !== index && m.team_member === value
|
||||||
|
);
|
||||||
|
|
||||||
|
if (existsInCurrentTeam) {
|
||||||
|
toast.error('This member is already added to this team!', {
|
||||||
|
position: "top-right",
|
||||||
|
autoClose: 4000,
|
||||||
|
icon: <FaTimesCircle />,
|
||||||
|
});
|
||||||
|
return; // Don't update if already in current team
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show checking state
|
||||||
|
setCheckingMember(index);
|
||||||
|
toast.info('Checking member availability...', {
|
||||||
|
position: "top-right",
|
||||||
|
autoClose: 2000,
|
||||||
|
icon: () => <span>🔍</span>,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Check if member exists in other teams
|
||||||
|
const { exists, teamName: otherTeamName } = await checkMemberInOtherTeams(value);
|
||||||
|
setCheckingMember(null);
|
||||||
|
|
||||||
|
if (exists) {
|
||||||
|
toast.error(
|
||||||
|
<div>
|
||||||
|
<strong>Cannot add member!</strong>
|
||||||
|
<br />
|
||||||
|
<span className="text-sm">This member is already assigned to: <b>{otherTeamName}</b></span>
|
||||||
|
</div>,
|
||||||
|
{
|
||||||
|
position: "top-right",
|
||||||
|
autoClose: 5000,
|
||||||
|
icon: <FaTimesCircle />,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
return; // Don't update if already in another team
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch full name
|
||||||
|
const fullName = await getUserFullName(value);
|
||||||
|
|
||||||
|
// Update the member data
|
||||||
|
const updatedMembers = [...(formData.maintenance_team_members || [])];
|
||||||
|
updatedMembers[index] = {
|
||||||
|
...updatedMembers[index],
|
||||||
|
team_member: value,
|
||||||
|
full_name: fullName
|
||||||
|
};
|
||||||
|
setFormData(prev => ({ ...prev, maintenance_team_members: updatedMembers }));
|
||||||
|
|
||||||
|
toast.success('Member added successfully!', {
|
||||||
|
position: "top-right",
|
||||||
|
autoClose: 2000,
|
||||||
|
icon: <FaCheckCircle />,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// For other fields (like role), just update directly
|
||||||
|
const updatedMembers = [...(formData.maintenance_team_members || [])];
|
||||||
|
updatedMembers[index] = { ...updatedMembers[index], [field]: value };
|
||||||
|
setFormData(prev => ({ ...prev, maintenance_team_members: updatedMembers }));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Add new team member
|
||||||
|
const handleAddMember = () => {
|
||||||
|
const newMember: MaintenanceTeamMember = {
|
||||||
|
team_member: '',
|
||||||
|
full_name: '',
|
||||||
|
maintenance_role: '',
|
||||||
|
idx: (formData.maintenance_team_members?.length || 0) + 1,
|
||||||
|
};
|
||||||
|
setFormData(prev => ({
|
||||||
|
...prev,
|
||||||
|
maintenance_team_members: [...(prev.maintenance_team_members || []), newMember],
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
// Remove team member
|
||||||
|
const handleRemoveMember = (index: number) => {
|
||||||
|
const updatedMembers = formData.maintenance_team_members?.filter((_, i) => i !== index) || [];
|
||||||
|
// Re-index
|
||||||
|
updatedMembers.forEach((m, i) => { m.idx = i + 1; });
|
||||||
|
setFormData(prev => ({ ...prev, maintenance_team_members: updatedMembers }));
|
||||||
|
setShowDeleteMemberConfirm(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
if (!formData.maintenance_team_name) {
|
||||||
|
toast.error('Please enter a team name', { position: "top-right", autoClose: 4000, icon: <FaTimesCircle /> });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Clean up member data for submission
|
||||||
|
const cleanedData = {
|
||||||
|
...formData,
|
||||||
|
maintenance_team_members: formData.maintenance_team_members?.map(m => ({
|
||||||
|
team_member: m.team_member,
|
||||||
|
full_name: m.full_name,
|
||||||
|
maintenance_role: m.maintenance_role,
|
||||||
|
})).filter(m => m.team_member), // Only include members with team_member set
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isNewTeam) {
|
||||||
|
const newTeam = await createTeam(cleanedData);
|
||||||
|
toast.success('Maintenance Team created successfully!', { position: "top-right", autoClose: 3000, icon: <FaCheckCircle /> });
|
||||||
|
navigate(`/maintenance-teams/${newTeam.name}`);
|
||||||
|
} else {
|
||||||
|
await updateTeam(teamName!, cleanedData);
|
||||||
|
toast.success('Maintenance Team updated successfully!', { position: "top-right", autoClose: 3000, icon: <FaCheckCircle /> });
|
||||||
|
setIsEditing(false);
|
||||||
|
refetch();
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
const errorMessage = err instanceof Error ? err.message : 'Unknown error';
|
||||||
|
toast.error(`Failed to save: ${errorMessage}`, { position: "top-right", autoClose: 6000, icon: <FaTimesCircle /> });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = async () => {
|
||||||
|
try {
|
||||||
|
await deleteTeam(teamName!);
|
||||||
|
toast.success('Maintenance Team deleted successfully!', { position: "top-right", autoClose: 3000, icon: <FaCheckCircle /> });
|
||||||
|
navigate(-1);
|
||||||
|
} catch (err) {
|
||||||
|
const errorMessage = err instanceof Error ? err.message : 'Unknown error';
|
||||||
|
toast.error(`Failed to delete: ${errorMessage}`, { position: "top-right", autoClose: 6000, icon: <FaTimesCircle /> });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const isFieldDisabled = useCallback((fieldname: string): boolean => {
|
||||||
|
if (!isEditing) return true;
|
||||||
|
return false;
|
||||||
|
}, [isEditing]);
|
||||||
|
|
||||||
|
const formatDateTime = (dateStr?: string) => dateStr ? new Date(dateStr).toLocaleString() : '-';
|
||||||
|
|
||||||
|
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-indigo-500 mx-auto"></div>
|
||||||
|
<p className="mt-4 text-gray-600 dark:text-gray-400">Loading maintenance team...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error && !isNewTeam) {
|
||||||
|
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">{t('maintenance.errorLoadingTeam')}</h2>
|
||||||
|
<p className="text-red-700 dark:text-red-400 mb-4">{error}</p>
|
||||||
|
<button onClick={() => navigate(-1)} className="bg-indigo-600 hover:bg-indigo-700 text-white px-4 py-2 rounded">{t('maintenance.backToTeams')}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 p-6">
|
||||||
|
<ToastContainer position="top-right" autoClose={4000} hideProgressBar={false} newestOnTop closeOnClick rtl={false} pauseOnFocusLoss draggable pauseOnHover theme="colored" transition={Bounce} />
|
||||||
|
|
||||||
|
{/* Header */}
|
||||||
|
<div className="mb-6 flex justify-between items-center">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<button onClick={() => navigate(-1)} 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-2xl font-bold text-gray-800 dark:text-white flex items-center gap-3">
|
||||||
|
<FaUsers className="text-indigo-500" />
|
||||||
|
{isNewTeam ? 'New Maintenance Team' : team?.maintenance_team_name || 'Maintenance Team'}
|
||||||
|
</h1>
|
||||||
|
<p className="text-gray-600 dark:text-gray-400 mt-1">
|
||||||
|
{isNewTeam ? 'Create a new maintenance team' : team?.name}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-3">
|
||||||
|
{!isNewTeam && !isEditing && (
|
||||||
|
<>
|
||||||
|
<button onClick={() => setIsEditing(true)} className="bg-indigo-600 hover:bg-indigo-700 text-white px-4 py-2 rounded-lg flex items-center gap-2">
|
||||||
|
<FaEdit />{t('common.edit')}
|
||||||
|
</button>
|
||||||
|
<button onClick={() => setShowDeleteConfirm(true)} className="bg-red-600 hover:bg-red-700 text-white px-4 py-2 rounded-lg flex items-center gap-2">
|
||||||
|
<FaTrash />{t('common.delete')}
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{isEditing && (
|
||||||
|
<>
|
||||||
|
<button onClick={() => { if (isNewTeam) navigate(-1); else { setIsEditing(false); refetch(); } }} className="bg-gray-600 hover:bg-gray-700 text-white px-4 py-2 rounded-lg">
|
||||||
|
{t('common.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 ? t('common.saving') : t('common.save')}
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Delete Confirmation Modal */}
|
||||||
|
{showDeleteConfirm && (
|
||||||
|
<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 p-6 max-w-md w-full mx-4 shadow-xl">
|
||||||
|
<div className="flex items-start gap-3 mb-4">
|
||||||
|
<FaExclamationTriangle className="text-red-500 text-xl mt-0.5" />
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-semibold text-gray-800 dark:text-white">{t('maintenance.deleteTeam')}</h3>
|
||||||
|
<p className="text-gray-600 dark:text-gray-400 mt-1">{t('confirmations.cannotUndo')}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-end gap-3">
|
||||||
|
<button onClick={() => setShowDeleteConfirm(false)} className="px-4 py-2 bg-gray-300 hover:bg-gray-400 text-gray-700 rounded-lg">{t('common.cancel')}</button>
|
||||||
|
<button onClick={handleDelete} disabled={saving} className="px-4 py-2 bg-red-600 hover:bg-red-700 text-white rounded-lg disabled:opacity-50">{saving ? t('common.deleting') : t('common.delete')}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Form */}
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||||
|
{/* Main Content */}
|
||||||
|
<div className="lg:col-span-2 space-y-6">
|
||||||
|
{/* Team Information */}
|
||||||
|
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-md p-6 border border-gray-200 dark:border-gray-700">
|
||||||
|
<h2 className="text-lg font-semibold text-gray-800 dark:text-white mb-4 pb-2 border-b border-gray-200 dark:border-gray-700 flex items-center gap-2">
|
||||||
|
<FaUsers className="text-indigo-500" />{t('maintenance.teamInformation')}
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div className="md:col-span-2">
|
||||||
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||||
|
{t('maintenance.teamName')} <span className="text-red-500">*</span>
|
||||||
|
</label>
|
||||||
|
<input type="text" name="maintenance_team_name" value={formData.maintenance_team_name} onChange={handleChange} disabled={isFieldDisabled('maintenance_team_name')} placeholder={t('maintenance.enterTeamName')} 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 focus:outline-none focus:ring-2 focus:ring-indigo-500" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<LinkField label={t('commonFields.hospital')} doctype="Company" value={formData.company || ''} onChange={(val) => setFormData({ ...formData, company: val })} disabled={isFieldDisabled('company')} placeholder={t('maintenance.selectHospital')} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<LinkField label={t('maintenance.expertise')} doctype="Issue Type" value={formData.custom_expertise || ''} onChange={(val) => setFormData({ ...formData, custom_expertise: val })} disabled={isFieldDisabled('custom_expertise')} placeholder={t('maintenance.selectExpertise')} allowQuickCreate={true}/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<LinkField label={t('maintenance.manager')} doctype="User" value={formData.maintenance_manager || ''} onChange={handleManagerChange} disabled={isFieldDisabled('maintenance_manager')} placeholder={t('maintenance.selectManager')} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">{t('maintenance.managerName')}</label>
|
||||||
|
<input type="text" value={formData.maintenance_manager_name || ''} disabled className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-400" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Team Members */}
|
||||||
|
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-md p-6 border border-gray-200 dark:border-gray-700">
|
||||||
|
<div className="flex items-center justify-between mb-4 pb-2 border-b border-gray-200 dark:border-gray-700">
|
||||||
|
<h2 className="text-lg font-semibold text-gray-800 dark:text-white flex items-center gap-2">
|
||||||
|
<FaUserPlus className="text-green-500" />Team Members
|
||||||
|
</h2>
|
||||||
|
{isEditing && (
|
||||||
|
<button onClick={handleAddMember} disabled={checkingMember !== null} className="bg-green-600 hover:bg-green-700 text-white px-3 py-1.5 rounded-lg flex items-center gap-2 text-sm disabled:opacity-50 disabled:cursor-not-allowed">
|
||||||
|
<FaPlus size={12} />Add Member
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{formData.maintenance_team_members && formData.maintenance_team_members.length > 0 ? (
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full">
|
||||||
|
<thead className="bg-gray-50 dark:bg-gray-700">
|
||||||
|
<tr>
|
||||||
|
<th className="px-3 py-2 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">#</th>
|
||||||
|
<th className="px-3 py-2 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">Team Member<span className="text-red-500">*</span></th>
|
||||||
|
<th className="px-3 py-2 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">Full Name</th>
|
||||||
|
<th className="px-3 py-2 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">Role<span className="text-red-500">*</span></th>
|
||||||
|
{isEditing && <th className="px-3 py-2 text-center text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">Action</th>}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-gray-200 dark:divide-gray-700">
|
||||||
|
{formData.maintenance_team_members.map((member, index) => (
|
||||||
|
<tr key={index} className={`hover:bg-gray-50 dark:hover:bg-gray-700/50 ${checkingMember === index ? 'opacity-70' : ''}`}>
|
||||||
|
<td className="px-3 py-2 text-sm text-gray-600 dark:text-gray-400">{index + 1}</td>
|
||||||
|
<td className="px-3 py-2">
|
||||||
|
{isEditing ? (
|
||||||
|
<div className="relative">
|
||||||
|
<LinkField label="" doctype="User" value={member.team_member || ''} onChange={(val) => handleMemberChange(index, 'team_member', val)} disabled={checkingMember !== null} placeholder={t('maintenance.selectUser')} compact={true} />
|
||||||
|
{checkingMember === index && (
|
||||||
|
<div className="absolute right-2 top-1/2 -translate-y-1/2">
|
||||||
|
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-indigo-500"></div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<span className="text-sm text-gray-900 dark:text-white">{member.team_member || '-'}</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2 text-sm text-gray-600 dark:text-gray-300">{member.full_name || '-'}</td>
|
||||||
|
<td className="px-3 py-2">
|
||||||
|
{isEditing ? (
|
||||||
|
<LinkField label="" doctype="Role" value={member.maintenance_role || ''} onChange={(val) => handleMemberChange(index, 'maintenance_role', val)} disabled={checkingMember !== null} placeholder={t('maintenance.selectRole')} compact={true} />
|
||||||
|
) : (
|
||||||
|
<span className="text-sm text-gray-600 dark:text-gray-300">{member.maintenance_role || '-'}</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
{isEditing && (
|
||||||
|
<td className="px-3 py-2 text-center">
|
||||||
|
<button onClick={() => setShowDeleteMemberConfirm(index)} className="text-red-600 dark:text-red-400 hover:text-red-800 dark:hover:text-red-300 p-1.5 hover:bg-red-50 dark:hover:bg-red-900/30 rounded transition-colors" title={t('maintenance.removeMember')}>
|
||||||
|
<FaTrash size={14} />
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
)}
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="text-center py-8 text-gray-500 dark:text-gray-400">
|
||||||
|
<FaUsers className="text-4xl mx-auto mb-2 text-gray-300 dark:text-gray-600" />
|
||||||
|
<p>{t('maintenance.noTeamMembersYet')}</p>
|
||||||
|
{isEditing && (
|
||||||
|
<button onClick={handleAddMember} className="mt-3 text-indigo-600 dark:text-indigo-400 hover:underline">
|
||||||
|
+ {t('maintenance.addFirstMember')}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ✅ ADD THIS — Comments Section */}
|
||||||
|
{!isNewTeam && (
|
||||||
|
<CommentSection
|
||||||
|
referenceDoctype="Asset Maintenance Team"
|
||||||
|
referenceName={teamName || null}
|
||||||
|
title="Comments & Discussion" // optional, default shown
|
||||||
|
pollInterval={30000} // optional, auto-refresh every 30s (0 = off)
|
||||||
|
initialLimit={5} // optional, comments shown before "show more"
|
||||||
|
collapsible={true} // optional, allow collapse/expand
|
||||||
|
startCollapsed={false} // optional, start collapsed
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Sidebar */}
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Team Summary Card */}
|
||||||
|
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-md p-6 border border-gray-200 dark:border-gray-700">
|
||||||
|
<h2 className="text-lg font-semibold text-gray-800 dark:text-white mb-4 pb-2 border-b border-gray-200 dark:border-gray-700 flex items-center gap-2">
|
||||||
|
<FaUserTie className="text-blue-500" />{t('maintenance.teamSummary')}
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="p-4 bg-indigo-50 dark:bg-indigo-900/30 rounded-lg border border-indigo-200 dark:border-indigo-800">
|
||||||
|
<p className="text-xs text-gray-500 dark:text-gray-400 mb-1">{t('maintenance.totalMembers')}</p>
|
||||||
|
<p className="text-2xl font-bold text-indigo-600 dark:text-indigo-300">
|
||||||
|
{formData.maintenance_team_members?.filter(m => m.team_member).length || 0}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{formData.maintenance_manager_name && (
|
||||||
|
<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">{t('maintenance.manager')}</p>
|
||||||
|
<p className="text-sm font-medium text-gray-900 dark:text-white">{formData.maintenance_manager_name}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{formData.company && (
|
||||||
|
<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">Hospital</p>
|
||||||
|
<p className="text-sm font-medium text-gray-900 dark:text-white">{formData.company}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{formData.custom_expertise && (
|
||||||
|
<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">Expertise</p>
|
||||||
|
<span className="inline-flex px-2 py-1 text-xs font-medium rounded-full bg-indigo-100 text-indigo-800 dark:bg-indigo-900/30 dark:text-indigo-300">
|
||||||
|
{formData.custom_expertise}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Timeline Card */}
|
||||||
|
{!isNewTeam && team && (
|
||||||
|
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-md p-6 border border-gray-200 dark:border-gray-700">
|
||||||
|
<h2 className="text-lg font-semibold text-gray-800 dark:text-white mb-4 pb-2 border-b border-gray-200 dark:border-gray-700 flex items-center gap-2">
|
||||||
|
<FaBuilding className="text-teal-500" />Details
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="p-4 bg-gray-50 dark:bg-gray-700 rounded-lg">
|
||||||
|
<p className="text-xs text-gray-500 dark:text-gray-400 mb-1">Created</p>
|
||||||
|
<p className="text-sm text-gray-900 dark:text-white">{formatDateTime(team.creation)}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-4 bg-gray-50 dark:bg-gray-700 rounded-lg">
|
||||||
|
<p className="text-xs text-gray-500 dark:text-gray-400 mb-1">Last Modified</p>
|
||||||
|
<p className="text-sm text-gray-900 dark:text-white">{formatDateTime(team.modified)}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-4 bg-gray-50 dark:bg-gray-700 rounded-lg">
|
||||||
|
<p className="text-xs text-gray-500 dark:text-gray-400 mb-1">Modified By</p>
|
||||||
|
<p className="text-sm text-gray-900 dark:text-white">{team.modified_by || '-'}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Delete Member Confirmation Modal */}
|
||||||
|
{showDeleteMemberConfirm !== null && (
|
||||||
|
<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 p-6 max-w-sm w-full mx-4 shadow-xl">
|
||||||
|
<div className="flex items-start gap-3 mb-4">
|
||||||
|
<FaExclamationTriangle className="text-orange-500 text-xl mt-0.5" />
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-semibold text-gray-800 dark:text-white">Remove Team Member</h3>
|
||||||
|
<p className="text-gray-600 dark:text-gray-400 mt-1">Are you sure you want to remove this team member?</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-end gap-3">
|
||||||
|
<button onClick={() => setShowDeleteMemberConfirm(null)} className="px-4 py-2 bg-gray-300 hover:bg-gray-400 text-gray-700 rounded-lg">Cancel</button>
|
||||||
|
<button onClick={() => handleRemoveMember(showDeleteMemberConfirm)} className="px-4 py-2 bg-red-600 hover:bg-red-700 text-white rounded-lg">Remove</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default MaintenanceTeamDetail;
|
||||||
674
asm_app/src/pages/MaintenanceTeamList.tsx
Normal file
674
asm_app/src/pages/MaintenanceTeamList.tsx
Normal file
@ -0,0 +1,674 @@
|
|||||||
|
import React, { useState, useMemo, useEffect, useCallback, useRef } from 'react';
|
||||||
|
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { useMaintenanceTeamList } from '../hooks/useMaintenanceTeam';
|
||||||
|
import * as XLSX from 'xlsx';
|
||||||
|
import {
|
||||||
|
FaPlus,
|
||||||
|
FaFilter,
|
||||||
|
FaSync,
|
||||||
|
FaEye,
|
||||||
|
FaChevronLeft,
|
||||||
|
FaChevronRight,
|
||||||
|
FaTimes,
|
||||||
|
FaSave,
|
||||||
|
FaStar,
|
||||||
|
FaTrash,
|
||||||
|
FaEdit,
|
||||||
|
FaCheckSquare,
|
||||||
|
FaSquare,
|
||||||
|
FaFileExport,
|
||||||
|
FaFileExcel,
|
||||||
|
FaFileCsv,
|
||||||
|
FaDownload,
|
||||||
|
FaUsers,
|
||||||
|
FaUserTie,
|
||||||
|
FaBuilding,
|
||||||
|
FaCopy
|
||||||
|
} from 'react-icons/fa';
|
||||||
|
import LinkField from '../components/LinkField';
|
||||||
|
import ListPagination from '../components/ListPagination';
|
||||||
|
import { buildDateRangeFilters, toFrappeFilterArray } from '../utils/listFilterUtils';
|
||||||
|
|
||||||
|
// Export types
|
||||||
|
type ExportFormat = 'csv' | 'excel';
|
||||||
|
type ExportScope = 'selected' | 'all_on_page' | 'all_with_filters';
|
||||||
|
|
||||||
|
interface ExportModalProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
selectedCount: number;
|
||||||
|
totalCount: number;
|
||||||
|
pageCount: number;
|
||||||
|
onExport: (scope: ExportScope, format: ExportFormat, columns: string[]) => void;
|
||||||
|
isExporting: boolean;
|
||||||
|
exportColumns: Array<{key: string, label: string, default: boolean}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ExportModal: React.FC<ExportModalProps> = ({
|
||||||
|
isOpen, onClose, selectedCount, totalCount, pageCount, onExport, isExporting, exportColumns
|
||||||
|
}) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [scope, setScope] = useState<ExportScope>(selectedCount > 0 ? 'selected' : 'all_with_filters');
|
||||||
|
const [format, setFormat] = useState<ExportFormat>('csv');
|
||||||
|
const [selectedColumns, setSelectedColumns] = useState<string[]>(exportColumns.filter(c => c.default).map(c => c.key));
|
||||||
|
|
||||||
|
useEffect(() => { setScope(selectedCount > 0 ? 'selected' : 'all_with_filters'); }, [selectedCount]);
|
||||||
|
|
||||||
|
const toggleColumn = (key: string) => setSelectedColumns(prev => prev.includes(key) ? prev.filter(k => k !== key) : [...prev, key]);
|
||||||
|
const selectAllColumns = () => setSelectedColumns(exportColumns.map(c => c.key));
|
||||||
|
const selectDefaultColumns = () => setSelectedColumns(exportColumns.filter(c => c.default).map(c => c.key));
|
||||||
|
|
||||||
|
if (!isOpen) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-[70] p-4">
|
||||||
|
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-xl max-w-2xl w-full max-h-[90vh] overflow-hidden animate-scale-in">
|
||||||
|
<div className="bg-gradient-to-r from-green-500 to-green-600 px-6 py-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<FaFileExport className="text-white text-xl" />
|
||||||
|
<h3 className="text-lg font-semibold text-white">{t('maintenance.export.title')}</h3>
|
||||||
|
</div>
|
||||||
|
<button onClick={onClose} className="text-white/80 hover:text-white transition-colors" disabled={isExporting}>
|
||||||
|
<FaTimes size={20} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-6 overflow-y-auto max-h-[calc(90vh-180px)]">
|
||||||
|
<div className="mb-6">
|
||||||
|
<h4 className="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-3">{t('maintenance.export.selectData')}</h4>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className={`flex items-center gap-3 p-3 rounded-lg border cursor-pointer transition-all ${scope === 'selected' ? 'border-green-500 bg-green-50 dark:bg-green-900/20' : 'border-gray-200 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-700/50'} ${selectedCount === 0 ? 'opacity-50 cursor-not-allowed' : ''}`}>
|
||||||
|
<input type="radio" name="scope" value="selected" checked={scope === 'selected'} onChange={() => setScope('selected')} disabled={selectedCount === 0} className="text-green-600 focus:ring-green-500" />
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="font-medium text-gray-900 dark:text-white">{t('maintenance.export.selectedRows')}</div>
|
||||||
|
<div className="text-sm text-gray-500 dark:text-gray-400">{t('maintenance.export.selectedCount', { count: selectedCount })}</div>
|
||||||
|
</div>
|
||||||
|
{selectedCount > 0 && <span className="bg-green-100 dark:bg-green-900/40 text-green-700 dark:text-green-300 px-2 py-1 rounded text-xs font-medium">{selectedCount} selected</span>}
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className={`flex items-center gap-3 p-3 rounded-lg border cursor-pointer transition-all ${scope === 'all_on_page' ? 'border-green-500 bg-green-50 dark:bg-green-900/20' : 'border-gray-200 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-700/50'}`}>
|
||||||
|
<input type="radio" name="scope" value="all_on_page" checked={scope === 'all_on_page'} onChange={() => setScope('all_on_page')} className="text-green-600 focus:ring-green-500" />
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="font-medium text-gray-900 dark:text-white">{t('maintenance.export.currentPage')}</div>
|
||||||
|
<div className="text-sm text-gray-500 dark:text-gray-400">{t('maintenance.export.currentPageCount', { count: pageCount })}</div>
|
||||||
|
</div>
|
||||||
|
<span className="bg-blue-100 dark:bg-blue-900/40 text-blue-700 dark:text-blue-300 px-2 py-1 rounded text-xs font-medium">{pageCount} rows</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className={`flex items-center gap-3 p-3 rounded-lg border cursor-pointer transition-all ${scope === 'all_with_filters' ? 'border-green-500 bg-green-50 dark:bg-green-900/20' : 'border-gray-200 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-700/50'}`}>
|
||||||
|
<input type="radio" name="scope" value="all_with_filters" checked={scope === 'all_with_filters'} onChange={() => setScope('all_with_filters')} className="text-green-600 focus:ring-green-500" />
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="font-medium text-gray-900 dark:text-white">{t('maintenance.export.allWithFilters')}</div>
|
||||||
|
<div className="text-sm text-gray-500 dark:text-gray-400">{t('maintenance.export.allWithFiltersCount', { count: totalCount })}</div>
|
||||||
|
</div>
|
||||||
|
<span className="bg-purple-100 dark:bg-purple-900/40 text-purple-700 dark:text-purple-300 px-2 py-1 rounded text-xs font-medium">{totalCount} total</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mb-6">
|
||||||
|
<h4 className="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-3">{t('maintenance.export.exportFormat')}</h4>
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<label className={`flex-1 flex items-center gap-3 p-3 rounded-lg border cursor-pointer transition-all ${format === 'csv' ? 'border-green-500 bg-green-50 dark:bg-green-900/20' : 'border-gray-200 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-700/50'}`}>
|
||||||
|
<input type="radio" name="format" value="csv" checked={format === 'csv'} onChange={() => setFormat('csv')} className="text-green-600 focus:ring-green-500" />
|
||||||
|
<FaFileCsv className="text-green-600 text-xl" />
|
||||||
|
<div><div className="font-medium text-gray-900 dark:text-white">{t('maintenance.export.csv')}</div><div className="text-xs text-gray-500 dark:text-gray-400">{t('maintenance.export.csvDesc')}</div></div>
|
||||||
|
</label>
|
||||||
|
<label className={`flex-1 flex items-center gap-3 p-3 rounded-lg border cursor-pointer transition-all ${format === 'excel' ? 'border-green-500 bg-green-50 dark:bg-green-900/20' : 'border-gray-200 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-700/50'}`}>
|
||||||
|
<input type="radio" name="format" value="excel" checked={format === 'excel'} onChange={() => setFormat('excel')} className="text-green-600 focus:ring-green-500" />
|
||||||
|
<FaFileExcel className="text-green-700 text-xl" />
|
||||||
|
<div><div className="font-medium text-gray-900 dark:text-white">{t('maintenance.export.excel')}</div><div className="text-xs text-gray-500 dark:text-gray-400">{t('maintenance.export.excelDesc')}</div></div>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center justify-between mb-3">
|
||||||
|
<h4 className="text-sm font-semibold text-gray-700 dark:text-gray-300">{t('maintenance.export.columnsToExport')}</h4>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button onClick={selectAllColumns} className="text-xs text-blue-600 dark:text-blue-400 hover:underline">{t('maintenance.export.selectAll')}</button>
|
||||||
|
<span className="text-gray-300 dark:text-gray-600">|</span>
|
||||||
|
<button onClick={selectDefaultColumns} className="text-xs text-blue-600 dark:text-blue-400 hover:underline">{t('maintenance.export.resetToDefault')}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-3 gap-2 max-h-48 overflow-y-auto p-3 bg-gray-50 dark:bg-gray-900/50 rounded-lg">
|
||||||
|
{exportColumns.map((col) => (
|
||||||
|
<label key={col.key} className={`flex items-center gap-2 p-2 rounded cursor-pointer transition-all ${selectedColumns.includes(col.key) ? 'bg-green-100 dark:bg-green-900/30 text-green-800 dark:text-green-300' : 'hover:bg-gray-100 dark:hover:bg-gray-800 text-gray-700 dark:text-gray-400'}`}>
|
||||||
|
<input type="checkbox" checked={selectedColumns.includes(col.key)} onChange={() => toggleColumn(col.key)} className="rounded text-green-600 focus:ring-green-500" />
|
||||||
|
<span className="text-sm truncate">{col.label}</span>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-gray-500 dark:text-gray-400 mt-2">{t('maintenance.export.columnsSelected', { count: selectedColumns.length })}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="px-6 py-4 bg-gray-50 dark:bg-gray-900/50 border-t border-gray-200 dark:border-gray-700 flex justify-between items-center">
|
||||||
|
<div className="text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
{scope === 'selected' && t('maintenance.export.exportingSelected', { count: selectedCount })}
|
||||||
|
{scope === 'all_on_page' && t('maintenance.export.exportingPage', { count: pageCount })}
|
||||||
|
{scope === 'all_with_filters' && t('maintenance.export.exportingAll', { count: totalCount })}
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<button onClick={onClose} className="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors" disabled={isExporting}>{t('common.cancel')}</button>
|
||||||
|
<button onClick={() => onExport(scope, format, selectedColumns)} disabled={selectedColumns.length === 0 || isExporting} className="px-4 py-2 text-sm font-medium text-white bg-green-600 hover:bg-green-700 rounded-lg transition-colors flex items-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed">
|
||||||
|
{isExporting ? (<><div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white"></div>{t('maintenance.export.exporting')}</>) : (<><FaDownload />{t('maintenance.export.exportButton')}</>)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const MaintenanceTeamList: React.FC = () => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const EXPORT_COLUMNS = [
|
||||||
|
{ key: 'name', label: t('maintenance.teamId'), default: true },
|
||||||
|
{ key: 'maintenance_team_name', label: t('maintenance.teamName'), default: true },
|
||||||
|
{ key: 'maintenance_manager', label: t('maintenance.managerEmail'), default: true },
|
||||||
|
{ key: 'maintenance_manager_name', label: t('maintenance.managerName'), default: true },
|
||||||
|
{ key: 'company', label: t('commonFields.hospital'), default: true },
|
||||||
|
{ key: 'custom_expertise', label: t('maintenance.expertise'), default: true },
|
||||||
|
{ key: 'creation', label: t('commonFields.createdOn'), default: false },
|
||||||
|
{ key: 'modified', label: t('commonFields.modifiedOn'), default: false },
|
||||||
|
];
|
||||||
|
|
||||||
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
|
const currentPage = useMemo(() => {
|
||||||
|
const p = parseInt(searchParams.get('page') || '1', 10);
|
||||||
|
return Number.isNaN(p) || p < 1 ? 1 : p;
|
||||||
|
}, [searchParams]);
|
||||||
|
const setCurrentPage = useCallback((pageOrUpdater: number | ((p: number) => number)) => {
|
||||||
|
const next = typeof pageOrUpdater === 'function' ? pageOrUpdater(currentPage) : pageOrUpdater;
|
||||||
|
setSearchParams((prev) => {
|
||||||
|
const nextParams = new URLSearchParams(prev);
|
||||||
|
nextParams.set('page', String(next));
|
||||||
|
return nextParams;
|
||||||
|
});
|
||||||
|
}, [currentPage, setSearchParams]);
|
||||||
|
const [pageSize] = useState(20);
|
||||||
|
const [initialLoadComplete, setInitialLoadComplete] = useState(false);
|
||||||
|
const [selectedRows, setSelectedRows] = useState<Set<string>>(new Set());
|
||||||
|
const [showExportModal, setShowExportModal] = useState(false);
|
||||||
|
const [isExporting, setIsExporting] = useState(false);
|
||||||
|
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const [dateFilterBy, setDateFilterBy] = useState<'' | 'creation' | 'modified'>(() => (searchParams.get('date_filter_by') as '' | 'creation' | 'modified') || '');
|
||||||
|
const [dateStart, setDateStart] = useState<string>(() => searchParams.get('date_start') || '');
|
||||||
|
const [dateEnd, setDateEnd] = useState<string>(() => searchParams.get('date_end') || '');
|
||||||
|
const [companyFilter, setCompanyFilter] = useState<string>(() => searchParams.get('company') || '');
|
||||||
|
const [teamNameFilter, setTeamNameFilter] = useState<string>(() => searchParams.get('team_name') || '');
|
||||||
|
const [sortBy, setSortBy] = useState<string>(() => searchParams.get('sort_by') || 'creation desc');
|
||||||
|
|
||||||
|
const [isFilterExpanded, setIsFilterExpanded] = useState(false);
|
||||||
|
const [activeFilterCount, setActiveFilterCount] = useState(0);
|
||||||
|
const [savedFilters, setSavedFilters] = useState<any[]>([]);
|
||||||
|
const [showSaveFilterModal, setShowSaveFilterModal] = useState(false);
|
||||||
|
const [filterPresetName, setFilterPresetName] = useState('');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const saved = localStorage.getItem('maintenanceTeamFilterPresets');
|
||||||
|
if (saved) setSavedFilters(JSON.parse(saved));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const hasDateFilter = dateFilterBy && (dateStart || dateEnd);
|
||||||
|
useEffect(() => {
|
||||||
|
const count = [companyFilter, teamNameFilter].filter(Boolean).length + (hasDateFilter ? 1 : 0);
|
||||||
|
setActiveFilterCount(count);
|
||||||
|
}, [companyFilter, teamNameFilter, hasDateFilter]);
|
||||||
|
|
||||||
|
const apiFilters = useMemo(() => {
|
||||||
|
const filters: Record<string, any> = {};
|
||||||
|
if (companyFilter) filters['company'] = companyFilter;
|
||||||
|
if (teamNameFilter) filters['name'] = teamNameFilter;
|
||||||
|
Object.assign(filters, buildDateRangeFilters(dateFilterBy, dateStart, dateEnd));
|
||||||
|
return filters;
|
||||||
|
}, [companyFilter, teamNameFilter, dateFilterBy, dateStart, dateEnd]);
|
||||||
|
|
||||||
|
const orderBy = ['creation desc', 'creation asc', 'modified desc', 'modified asc', 'name asc', 'name desc', 'maintenance_team_name asc', 'maintenance_team_name desc'].includes(sortBy) ? sortBy : 'creation desc';
|
||||||
|
const { teams, loading, error, totalCount, refetch } = useMaintenanceTeamList({
|
||||||
|
filters: apiFilters,
|
||||||
|
limit_start: (currentPage - 1) * pageSize,
|
||||||
|
limit_page_length: pageSize,
|
||||||
|
order_by: orderBy,
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => { if (!loading && !initialLoadComplete) setInitialLoadComplete(true); }, [loading, initialLoadComplete]);
|
||||||
|
const filtersChangedOnce = useRef(false);
|
||||||
|
useEffect(() => {
|
||||||
|
if (!filtersChangedOnce.current) {
|
||||||
|
filtersChangedOnce.current = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSearchParams((prev) => {
|
||||||
|
const next = new URLSearchParams(prev);
|
||||||
|
if (dateFilterBy) next.set('date_filter_by', dateFilterBy); else next.delete('date_filter_by');
|
||||||
|
if (dateStart) next.set('date_start', dateStart); else next.delete('date_start');
|
||||||
|
if (dateEnd) next.set('date_end', dateEnd); else next.delete('date_end');
|
||||||
|
if (companyFilter) next.set('company', companyFilter); else next.delete('company');
|
||||||
|
if (teamNameFilter) next.set('team_name', teamNameFilter); else next.delete('team_name');
|
||||||
|
if (sortBy && sortBy !== 'creation desc') next.set('sort_by', sortBy); else next.delete('sort_by');
|
||||||
|
next.set('page', '1');
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}, [dateFilterBy, dateStart, dateEnd, companyFilter, teamNameFilter, sortBy]);
|
||||||
|
useEffect(() => { setSelectedRows(new Set()); }, [dateFilterBy, dateStart, dateEnd, companyFilter, teamNameFilter, currentPage]);
|
||||||
|
|
||||||
|
const totalPages = Math.ceil(totalCount / pageSize);
|
||||||
|
const formatDate = (dateStr?: string) => dateStr ? new Date(dateStr).toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' }) : '-';
|
||||||
|
|
||||||
|
const clearFilters = () => {
|
||||||
|
setDateFilterBy(''); setDateStart(''); setDateEnd('');
|
||||||
|
setSortBy('creation desc');
|
||||||
|
setCompanyFilter(''); setTeamNameFilter('');
|
||||||
|
setSearchParams((prev) => {
|
||||||
|
const next = new URLSearchParams(prev);
|
||||||
|
next.delete('date_filter_by'); next.delete('date_start'); next.delete('date_end');
|
||||||
|
next.delete('sort_by');
|
||||||
|
next.delete('company'); next.delete('team_name');
|
||||||
|
next.set('page', '1');
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
const hasActiveFilters = hasDateFilter || !!companyFilter || !!teamNameFilter;
|
||||||
|
|
||||||
|
const handleSaveFilterPreset = () => {
|
||||||
|
if (!filterPresetName.trim()) { alert('Please enter a filter name'); return; }
|
||||||
|
const preset = { id: Date.now(), name: filterPresetName, filters: { dateFilterBy, dateStart, dateEnd, sortBy, companyFilter, teamNameFilter } };
|
||||||
|
const updated = [...savedFilters, preset];
|
||||||
|
setSavedFilters(updated);
|
||||||
|
setFilterPresetName('');
|
||||||
|
setShowSaveFilterModal(false);
|
||||||
|
localStorage.setItem('maintenanceTeamFilterPresets', JSON.stringify(updated));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleLoadFilterPreset = (preset: any) => {
|
||||||
|
const f = preset.filters;
|
||||||
|
setDateFilterBy(f.dateFilterBy || ''); setDateStart(f.dateStart || ''); setDateEnd(f.dateEnd || '');
|
||||||
|
setSortBy(f.sortBy || 'creation desc');
|
||||||
|
setCompanyFilter(f.companyFilter || '');
|
||||||
|
setTeamNameFilter(f.teamNameFilter || '');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteFilterPreset = (id: number) => {
|
||||||
|
const updated = savedFilters.filter(f => f.id !== id);
|
||||||
|
setSavedFilters(updated);
|
||||||
|
localStorage.setItem('maintenanceTeamFilterPresets', JSON.stringify(updated));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSelectRow = (teamName: string) => {
|
||||||
|
setSelectedRows(prev => { const newSet = new Set(prev); newSet.has(teamName) ? newSet.delete(teamName) : newSet.add(teamName); return newSet; });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSelectAll = () => { selectedRows.size === teams.length ? setSelectedRows(new Set()) : setSelectedRows(new Set(teams.map(t => t.name))); };
|
||||||
|
const isAllSelected = teams.length > 0 && selectedRows.size === teams.length;
|
||||||
|
const isSomeSelected = selectedRows.size > 0 && selectedRows.size < teams.length;
|
||||||
|
|
||||||
|
const fetchAllTeamsForExport = useCallback(async (): Promise<any[]> => {
|
||||||
|
const allTeams: any[] = [];
|
||||||
|
let currentPageNum = 0;
|
||||||
|
const pageSizeNum = 100;
|
||||||
|
let hasMoreData = true;
|
||||||
|
const filterArrays = toFrappeFilterArray(apiFilters);
|
||||||
|
while (hasMoreData) {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/method/frappe.client.get_list', {
|
||||||
|
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ doctype: 'Asset Maintenance Team', filters: filterArrays.length > 0 ? filterArrays : {}, fields: ['*'], limit_start: currentPageNum * pageSizeNum, limit_page_length: pageSizeNum, order_by: orderBy })
|
||||||
|
});
|
||||||
|
const data = await response.json();
|
||||||
|
const results = data.message || [];
|
||||||
|
allTeams.push(...results);
|
||||||
|
if (results.length < pageSizeNum) hasMoreData = false; else currentPageNum++;
|
||||||
|
if (currentPageNum > 100) { console.warn('Export safety limit reached'); hasMoreData = false; }
|
||||||
|
} catch (error) { console.error('Error fetching teams for export:', error); throw error; }
|
||||||
|
}
|
||||||
|
return allTeams;
|
||||||
|
}, [apiFilters]);
|
||||||
|
|
||||||
|
const handleExport = async (scope: ExportScope, format: ExportFormat, columns: string[]) => {
|
||||||
|
setIsExporting(true);
|
||||||
|
try {
|
||||||
|
let dataToExport: any[] = [];
|
||||||
|
switch (scope) {
|
||||||
|
case 'selected': dataToExport = teams.filter(t => selectedRows.has(t.name)); break;
|
||||||
|
case 'all_on_page': dataToExport = teams; break;
|
||||||
|
case 'all_with_filters': dataToExport = await fetchAllTeamsForExport(); break;
|
||||||
|
}
|
||||||
|
if (dataToExport.length === 0) { alert(t('assets.noDataToExport')); return; }
|
||||||
|
const columnLabels = columns.map(key => EXPORT_COLUMNS.find(c => c.key === key)?.label || key);
|
||||||
|
|
||||||
|
if (format === 'csv') {
|
||||||
|
const csvContent = [columnLabels.join(','), ...dataToExport.map(team => columns.map(key => { let value = team[key] || ''; if (typeof value === 'string' && (value.includes(',') || value.includes('"') || value.includes('\n'))) value = `"${value.replace(/"/g, '""')}"`; return value; }).join(','))].join('\n');
|
||||||
|
const blob = new Blob(['\ufeff' + csvContent], { type: 'text/csv;charset=utf-8;' });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const link = document.createElement('a');
|
||||||
|
link.href = url; link.download = `maintenance_teams_export_${new Date().toISOString().split('T')[0]}.csv`; link.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
} else if (format === 'excel') {
|
||||||
|
const worksheetData = [columnLabels, ...dataToExport.map(team => columns.map(key => team[key] || ''))];
|
||||||
|
const worksheet = XLSX.utils.aoa_to_sheet(worksheetData);
|
||||||
|
const workbook = XLSX.utils.book_new();
|
||||||
|
XLSX.utils.book_append_sheet(workbook, worksheet, 'Maintenance Teams');
|
||||||
|
XLSX.writeFile(workbook, `maintenance_teams_export_${new Date().toISOString().split('T')[0]}.xlsx`);
|
||||||
|
}
|
||||||
|
setShowExportModal(false); setSelectedRows(new Set());
|
||||||
|
} catch (error) { console.error('Export failed:', error); alert(`Export failed: ${error instanceof Error ? error.message : 'Unknown error'}`); }
|
||||||
|
finally { setIsExporting(false); }
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = async (teamName: string) => {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/resource/Asset Maintenance Team/${encodeURIComponent(teamName)}`, { method: 'DELETE', headers: { 'Content-Type': 'application/json' } });
|
||||||
|
if (!response.ok) throw new Error('Failed to delete');
|
||||||
|
setDeleteConfirmOpen(null); refetch(); alert(t('maintenance.deletedSuccessfully'));
|
||||||
|
} catch (err) { alert(`Failed to delete: ${err instanceof Error ? err.message : 'Unknown error'}`); }
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading && !initialLoadComplete) {
|
||||||
|
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('maintenance.loadingTeams')}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
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">{t('maintenance.errorLoadingTeams')}</h2>
|
||||||
|
<p className="text-red-700 dark:text-red-400 mb-4">{error}</p>
|
||||||
|
<button onClick={refetch} className="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded">{t('common.tryAgain')}</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>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<FaUsers className="text-3xl text-indigo-600 dark:text-indigo-400" />
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-gray-800 dark:text-white">{t('maintenance.listTitle')}</h1>
|
||||||
|
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
{t('issues.listTotal')}: {totalCount}
|
||||||
|
{selectedRows.size > 0 && <span className="ml-2 text-blue-600 dark:text-blue-400">• {selectedRows.size} {t('issues.listSelected')}</span>}
|
||||||
|
{loading && initialLoadComplete && <span className="ml-2 inline-flex items-center gap-1 text-xs text-blue-600 dark:text-blue-400"><div className="animate-spin rounded-full h-3 w-3 border-b-2 border-blue-500"></div>{t('common.filtering')}</span>}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<button onClick={() => setIsFilterExpanded(!isFilterExpanded)} className={`px-4 py-2 rounded-lg flex items-center gap-2 transition-colors ${isFilterExpanded || hasActiveFilters ? 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-300' : 'bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-600'}`}>
|
||||||
|
<FaFilter />{t('listPages.filters')}
|
||||||
|
{activeFilterCount > 0 && <span className="bg-blue-600 text-white text-xs px-1.5 py-0.5 rounded-full">{activeFilterCount}</span>}
|
||||||
|
</button>
|
||||||
|
<button onClick={refetch} disabled={loading} className="px-4 py-2 rounded-lg bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-600 flex items-center gap-2 disabled:opacity-50">
|
||||||
|
<FaSync className={loading ? 'animate-spin' : ''} />{t('listPages.refresh')}
|
||||||
|
</button>
|
||||||
|
<button onClick={() => setShowExportModal(true)} className="bg-green-600 hover:bg-green-700 text-white px-4 py-2 rounded-lg flex items-center gap-2 shadow transition-all" disabled={totalCount === 0}>
|
||||||
|
<FaFileExport /><span className="font-medium">{t('listPages.export')}</span>
|
||||||
|
{selectedRows.size > 0 && <span className="bg-white/20 px-1.5 py-0.5 rounded text-xs">{selectedRows.size}</span>}
|
||||||
|
</button>
|
||||||
|
<button onClick={() => navigate('/maintenance-teams/new')} className="bg-indigo-600 hover:bg-indigo-700 text-white px-4 py-2 rounded-lg flex items-center gap-2 shadow-lg transition-all hover:shadow-xl">
|
||||||
|
<FaPlus /><span className="font-medium">{t('maintenance.newMaintenanceTeam')}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Stats Cards */}
|
||||||
|
{/* <div className="grid grid-cols-1 md:grid-cols-3 gap-4 mb-4">
|
||||||
|
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-4 border border-gray-200 dark:border-gray-700">
|
||||||
|
<div className="flex items-center justify-between"><div><p className="text-sm text-gray-500 dark:text-gray-400">Total Teams</p><p className="text-2xl font-bold text-gray-800 dark:text-white">{totalCount}</p></div><FaUsers className="text-3xl text-indigo-500" /></div>
|
||||||
|
</div>
|
||||||
|
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-4 border border-gray-200 dark:border-gray-700">
|
||||||
|
<div className="flex items-center justify-between"><div><p className="text-sm text-gray-500 dark:text-gray-400">Managers</p><p className="text-2xl font-bold text-blue-600">{new Set(teams.map(t => t.maintenance_manager).filter(Boolean)).size}</p></div><FaUserTie className="text-3xl text-blue-500" /></div>
|
||||||
|
</div>
|
||||||
|
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-4 border border-gray-200 dark:border-gray-700">
|
||||||
|
<div className="flex items-center justify-between"><div><p className="text-sm text-gray-500 dark:text-gray-400">Hospitals</p><p className="text-2xl font-bold text-green-600">{new Set(teams.map(t => t.company).filter(Boolean)).size}</p></div><FaBuilding className="text-3xl text-green-500" /></div>
|
||||||
|
</div>
|
||||||
|
</div> */}
|
||||||
|
|
||||||
|
{/* Expandable Filter Panel */}
|
||||||
|
{isFilterExpanded && (
|
||||||
|
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-lg border border-gray-200 dark:border-gray-700 mb-4">
|
||||||
|
<div className="bg-gradient-to-r from-indigo-500 to-indigo-600 dark:from-indigo-600 dark:to-indigo-700 px-4 py-3 rounded-t-lg">
|
||||||
|
<div className="flex items-center justify-between gap-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<FaFilter className="text-white" size={16} /><h3 className="text-white font-semibold text-sm">{t('listPages.filters')}</h3>
|
||||||
|
{activeFilterCount > 0 && <span className="bg-white text-indigo-600 px-2 py-0.5 rounded-full text-xs font-bold">{activeFilterCount}</span>}
|
||||||
|
</div>
|
||||||
|
{hasActiveFilters && (
|
||||||
|
<div className="flex-1 overflow-x-auto scrollbar-hide mx-2">
|
||||||
|
<div className="flex items-center gap-2 py-1">
|
||||||
|
{hasDateFilter && <span className="inline-flex items-center gap-1.5 px-2.5 py-1 bg-white/90 text-amber-700 rounded-full text-[10px] font-medium whitespace-nowrap shadow-sm"><span className="font-semibold">{dateFilterBy === 'creation' ? t('filters.createdDate') : t('filters.latestModifiedDate')}:</span> {[dateStart, dateEnd].filter(Boolean).join(' – ')}<button onClick={() => { setDateFilterBy(''); setDateStart(''); setDateEnd(''); }} className="hover:text-red-500"><FaTimes className="text-[9px]" /></button></span>}
|
||||||
|
{companyFilter && <span className="inline-flex items-center gap-1.5 px-2.5 py-1 bg-white/90 text-green-700 rounded-full text-[10px] font-medium whitespace-nowrap shadow-sm"><span className="font-semibold">{t('filters.hospital')}:</span> {companyFilter}<button onClick={() => setCompanyFilter('')} className="hover:text-red-500"><FaTimes className="text-[9px]" /></button></span>}
|
||||||
|
{teamNameFilter && <span className="inline-flex items-center gap-1.5 px-2.5 py-1 bg-white/90 text-indigo-700 rounded-full text-[10px] font-medium whitespace-nowrap shadow-sm"><span className="font-semibold">{t('maintenance.teamName')}:</span> {teamNameFilter}<button onClick={() => setTeamNameFilter('')} className="hover:text-red-500"><FaTimes className="text-[9px]" /></button></span>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="flex items-center gap-2 flex-shrink-0">
|
||||||
|
{activeFilterCount > 0 && <button onClick={() => setShowSaveFilterModal(true)} className="px-3 py-1.5 bg-white text-indigo-600 hover:bg-indigo-50 rounded-md text-xs font-medium transition-all flex items-center gap-1.5"><FaSave size={12} /><span className="hidden sm:inline">{t('listPages.saveFilterPreset')}</span></button>}
|
||||||
|
{hasActiveFilters && <button onClick={clearFilters} className="px-3 py-1.5 bg-red-500 hover:bg-red-600 text-white rounded-md text-xs font-medium transition-all flex items-center gap-1.5"><FaTimes size={12} /><span className="hidden sm:inline">{t('listPages.clearFilters')}</span></button>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="p-4">
|
||||||
|
{savedFilters.length > 0 && (
|
||||||
|
<div className="mb-4 pb-4 border-b border-gray-200 dark:border-gray-700">
|
||||||
|
<h4 className="text-xs font-semibold text-gray-700 dark:text-gray-300 mb-2 flex items-center gap-2"><FaStar className="text-yellow-500" size={12} />{t('inspections.savedFilters')}</h4>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{savedFilters.map((preset) => (
|
||||||
|
<div key={preset.id} className="group relative inline-flex items-center gap-2 px-3 py-1.5 bg-gradient-to-r from-purple-100 to-indigo-100 dark:from-purple-900/30 dark:to-indigo-900/30 border border-purple-200 dark:border-purple-700 rounded-lg hover:shadow-md transition-all">
|
||||||
|
<button onClick={() => handleLoadFilterPreset(preset)} className="text-xs font-medium text-purple-700 dark:text-purple-300">{preset.name}</button>
|
||||||
|
<button onClick={() => handleDeleteFilterPreset(preset.id)} className="opacity-0 group-hover:opacity-100 text-red-500 hover:text-red-700 transition-opacity"><FaTrash size={10} /></button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="bg-gray-50 dark:bg-gray-900/50 p-3 rounded-lg">
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-3 mb-3">
|
||||||
|
{/* Sort By */}
|
||||||
|
<div className="relative">
|
||||||
|
<label className="block text-[10px] font-medium text-gray-700 dark:text-gray-300 mb-0.5">{t('filters.sortBy')}</label>
|
||||||
|
<select
|
||||||
|
value={sortBy}
|
||||||
|
onChange={(e) => { setSortBy(e.target.value); setCurrentPage(1); }}
|
||||||
|
className="w-full px-2 py-1 text-xs border border-gray-300 dark:border-gray-600 rounded focus:outline-none focus:ring-2 focus:ring-indigo-500 bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
|
||||||
|
>
|
||||||
|
<option value="creation desc">{t('filters.sortCreationNewest')}</option>
|
||||||
|
<option value="creation asc">{t('filters.sortCreationOldest')}</option>
|
||||||
|
<option value="modified desc">{t('filters.sortModifiedNewest')}</option>
|
||||||
|
<option value="modified asc">{t('filters.sortModifiedOldest')}</option>
|
||||||
|
<option value="name asc">{t('filters.sortNameAsc')}</option>
|
||||||
|
<option value="name desc">{t('filters.sortNameDesc')}</option>
|
||||||
|
<option value="maintenance_team_name asc">{t('filters.sortTeamNameAsc')}</option>
|
||||||
|
<option value="maintenance_team_name desc">{t('filters.sortTeamNameDesc')}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="relative">
|
||||||
|
<label className="block text-[10px] font-medium text-gray-700 dark:text-gray-300 mb-0.5">{t('filters.filterBy')}</label>
|
||||||
|
<select value={dateFilterBy} onChange={(e) => { const v = e.target.value as '' | 'creation' | 'modified'; setDateFilterBy(v); setCurrentPage(1); }} className="w-full px-2 py-1 text-xs border border-gray-300 dark:border-gray-600 rounded focus:outline-none focus:ring-2 focus:ring-indigo-500 bg-white dark:bg-gray-700 text-gray-900 dark:text-white">
|
||||||
|
<option value="">{t('filters.filterBy')}</option>
|
||||||
|
<option value="creation">{t('filters.createdDate')}</option>
|
||||||
|
<option value="modified">{t('filters.latestModifiedDate')}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
{dateFilterBy && (
|
||||||
|
<>
|
||||||
|
<div className="relative">
|
||||||
|
<label className="block text-[10px] font-medium text-gray-700 dark:text-gray-300 mb-0.5">{t('filters.startDate')}</label>
|
||||||
|
<input type="date" value={dateStart} onChange={(e) => { const v = e.target.value; setDateStart(v); if (dateEnd && v > dateEnd) setDateEnd(v); setCurrentPage(1); }} className="w-full px-2 py-1 text-xs border border-gray-300 dark:border-gray-600 rounded focus:outline-none focus:ring-2 focus:ring-indigo-500 bg-white dark:bg-gray-700 text-gray-900 dark:text-white" />
|
||||||
|
</div>
|
||||||
|
<div className="relative">
|
||||||
|
<label className="block text-[10px] font-medium text-gray-700 dark:text-gray-300 mb-0.5">{t('filters.endDate')}</label>
|
||||||
|
<input type="date" value={dateEnd} onChange={(e) => { setDateEnd(e.target.value); setCurrentPage(1); }} min={dateStart || undefined} className="w-full px-2 py-1 text-xs border border-gray-300 dark:border-gray-600 rounded focus:outline-none focus:ring-2 focus:ring-indigo-500 bg-white dark:bg-gray-700 text-gray-900 dark:text-white" />
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||||
|
<div className="relative z-[60]">
|
||||||
|
<LinkField label="Hospital" doctype="Company" value={companyFilter} onChange={(val) => { setCompanyFilter(val); setCurrentPage(1); }} placeholder="Select Hospital" disabled={false} compact={true} />
|
||||||
|
{companyFilter && <button onClick={() => setCompanyFilter('')} className="absolute right-2 top-6 text-gray-400 hover:text-red-500 transition-colors z-10"><FaTimes size={10} /></button>}
|
||||||
|
</div>
|
||||||
|
<div className="relative z-[59]">
|
||||||
|
<LinkField label={t('maintenance.teamName')} doctype="Asset Maintenance Team" value={teamNameFilter} onChange={(val) => { setTeamNameFilter(val); setCurrentPage(1); }} placeholder={t('maintenance.selectTeam')} disabled={false} compact={true} />
|
||||||
|
{teamNameFilter && <button onClick={() => setTeamNameFilter('')} className="absolute right-2 top-6 text-gray-400 hover:text-red-500 transition-colors z-10"><FaTimes size={10} /></button>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Save Filter Modal */}
|
||||||
|
{showSaveFilterModal && (
|
||||||
|
<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-lg shadow-xl max-w-md w-full p-6 animate-scale-in">
|
||||||
|
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-4">{t('listPages.saveFilterPreset')}</h3>
|
||||||
|
<input type="text" value={filterPresetName} onChange={(e) => setFilterPresetName(e.target.value)} onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); handleSaveFilterPreset(); } }} placeholder={t('listPages.enterFilterName')} className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md focus:outline-none focus:ring-2 focus:ring-indigo-500 bg-white dark:bg-gray-700 text-gray-900 dark:text-white mb-4" autoFocus />
|
||||||
|
<div className="flex gap-2 justify-end">
|
||||||
|
<button onClick={() => { setShowSaveFilterModal(false); setFilterPresetName(''); }} className="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-md transition-colors">{t('common.cancel')}</button>
|
||||||
|
<button onClick={handleSaveFilterPreset} className="px-4 py-2 text-sm font-medium text-white bg-indigo-600 hover:bg-indigo-700 rounded-md transition-colors flex items-center gap-2"><FaSave size={12} />{t('listPages.saveFilter')}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Export Modal */}
|
||||||
|
<ExportModal isOpen={showExportModal} onClose={() => setShowExportModal(false)} selectedCount={selectedRows.size} totalCount={totalCount} pageCount={teams.length} onExport={handleExport} isExporting={isExporting} exportColumns={EXPORT_COLUMNS} />
|
||||||
|
|
||||||
|
{/* Table */}
|
||||||
|
<div className="bg-white dark:bg-gray-800 rounded-lg shadow border border-gray-200 dark:border-gray-700 overflow-hidden relative">
|
||||||
|
{loading && initialLoadComplete && (
|
||||||
|
<div className="absolute inset-0 bg-white/60 dark:bg-gray-800/60 flex items-center justify-center z-10 backdrop-blur-[1px]">
|
||||||
|
<div className="flex items-center gap-3 bg-white dark:bg-gray-700 px-4 py-2 rounded-lg shadow-lg"><div className="animate-spin rounded-full h-5 w-5 border-b-2 border-indigo-500"></div><span className="text-sm text-gray-600 dark:text-gray-300">{t('listPages.filtering')}</span></div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<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-4 py-3 text-left">
|
||||||
|
<button onClick={handleSelectAll} className="text-gray-500 dark:text-gray-400 hover:text-indigo-600 dark:hover:text-indigo-400 transition-colors" title={isAllSelected ? t('listPages.deselectAllTitle') : t('listPages.selectAllTitle')}>
|
||||||
|
{isAllSelected ? <FaCheckSquare className="text-indigo-600 dark:text-indigo-400" size={18} /> : isSomeSelected ? <div className="relative"><FaSquare size={18} /><div className="absolute inset-0 flex items-center justify-center"><div className="w-2 h-0.5 bg-current"></div></div></div> : <FaSquare size={18} />}
|
||||||
|
</button>
|
||||||
|
</th>
|
||||||
|
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">{t('maintenance.teamName')}</th>
|
||||||
|
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">{t('maintenance.managerName')}</th>
|
||||||
|
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">{t('commonFields.hospital')}</th>
|
||||||
|
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">{t('maintenance.expertise')}</th>
|
||||||
|
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">{t('commonFields.createdOn')}</th>
|
||||||
|
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">{t('listPages.actions')}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="bg-white dark:bg-gray-800 divide-y divide-gray-200 dark:divide-gray-700">
|
||||||
|
{teams.length === 0 ? (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={7} className="px-4 py-12 text-center text-gray-500 dark:text-gray-400">
|
||||||
|
<div className="flex flex-col items-center">
|
||||||
|
<FaUsers className="text-4xl text-gray-300 dark:text-gray-600 mb-2" />
|
||||||
|
<p>{t('listPages.noMaintenanceTeamsFound')}</p>
|
||||||
|
{hasActiveFilters ? (
|
||||||
|
<button onClick={clearFilters} className="mt-4 text-indigo-600 dark:text-indigo-400 hover:underline">
|
||||||
|
{t('common.clearFilters')}
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<button onClick={() => navigate('/maintenance-teams/new')} className="mt-4 text-indigo-600 dark:text-indigo-400 hover:underline">
|
||||||
|
{t('listPages.createFirstTeam')}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
) : teams.map((team) => (
|
||||||
|
<tr key={team.name} className={`hover:bg-gray-50 dark:hover:bg-gray-700/50 cursor-pointer transition-colors ${selectedRows.has(team.name) ? 'bg-indigo-50 dark:bg-indigo-900/20' : ''}`} onClick={() => navigate(`/maintenance-teams/${team.name}`)}>
|
||||||
|
<td className="px-4 py-3" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<button onClick={() => handleSelectRow(team.name)} className="text-gray-500 dark:text-gray-400 hover:text-indigo-600 dark:hover:text-indigo-400 transition-colors">
|
||||||
|
{selectedRows.has(team.name) ? <FaCheckSquare className="text-indigo-600 dark:text-indigo-400" size={18} /> : <FaSquare size={18} />}
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<div className="text-sm font-medium text-gray-900 dark:text-white">{team.maintenance_team_name}</div>
|
||||||
|
<div className="text-xs text-gray-500 dark:text-gray-400">{team.name}</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<div className="text-sm text-gray-900 dark:text-white">{team.maintenance_manager_name || '-'}</div>
|
||||||
|
<div className="text-xs text-gray-500 dark:text-gray-400">{team.maintenance_manager || '-'}</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3"><span className="text-sm text-gray-600 dark:text-gray-300">{team.company || '-'}</span></td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
{team.custom_expertise ? (
|
||||||
|
<span className="inline-flex px-2 py-1 text-xs font-medium rounded-full bg-indigo-100 text-indigo-800 dark:bg-indigo-900/30 dark:text-indigo-300">{team.custom_expertise}</span>
|
||||||
|
) : <span className="text-gray-400">-</span>}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3"><span className="text-sm text-gray-600 dark:text-gray-300">{formatDate(team.creation)}</span></td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<div className="flex items-center gap-1" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<button onClick={() => navigate(`/maintenance-teams/${team.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={t('maintenance.viewDetails')}><FaEye /></button>
|
||||||
|
<button onClick={() => navigate(`/maintenance-teams/${team.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={t('maintenance.editTeam')}><FaEdit /></button>
|
||||||
|
<button onClick={() => navigate(`/maintenance-teams/new?duplicate=${team.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={t('maintenance.duplicateTeam')}><FaCopy /></button>
|
||||||
|
<button onClick={() => setDeleteConfirmOpen(team.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={t('maintenance.deleteTeam')}><FaTrash /></button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ListPagination
|
||||||
|
currentPage={currentPage}
|
||||||
|
totalCount={totalCount}
|
||||||
|
pageSize={pageSize}
|
||||||
|
itemLabel={t('pagination.teams')}
|
||||||
|
onPageChange={(p) => setCurrentPage(p)}
|
||||||
|
/>
|
||||||
|
</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">{t('maintenance.deleteTeam')}</h3>
|
||||||
|
<p className="text-sm text-gray-600 dark:text-gray-400 mb-4">{t('maintenance.deleteConfirmMessage')}</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>{t('maintenance.team')}:</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">{t('common.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"><FaTrash />{t('maintenance.deleteTeamButton')}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<style>{`
|
||||||
|
@keyframes scale-in { from { transform: scale(0.95); opacity: 0; } to { transform: scale(1); opacity: 1; } }
|
||||||
|
.animate-scale-in { animation: scale-in 0.2s ease-out; }
|
||||||
|
.scrollbar-hide { -ms-overflow-style: none; scrollbar-width: none; }
|
||||||
|
.scrollbar-hide::-webkit-scrollbar { display: none; }
|
||||||
|
`}</style>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default MaintenanceTeamList;
|
||||||
6075
asm_app/src/pages/ModernDashboard.tsx
Normal file
6075
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
408
asm_app/src/pages/PPMDetail.tsx
Normal file
408
asm_app/src/pages/PPMDetail.tsx
Normal file
@ -0,0 +1,408 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { useParams, useNavigate, useSearchParams } from 'react-router-dom';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
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 { t } = useTranslation();
|
||||||
|
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(t('ppm.detail.pleaseEnterAssetName'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (isNewPPM || isDuplicating) {
|
||||||
|
const result = await createPPM(formData);
|
||||||
|
const successMessage = isDuplicating
|
||||||
|
? t('ppm.detail.duplicatedSuccessfully')
|
||||||
|
: t('ppm.detail.createdSuccessfully');
|
||||||
|
alert(successMessage);
|
||||||
|
if (result.asset_maintenance?.name) {
|
||||||
|
navigate(`/ppm/${result.asset_maintenance.name}`);
|
||||||
|
} else {
|
||||||
|
refetch();
|
||||||
|
navigate(-1);
|
||||||
|
}
|
||||||
|
} else if (ppmName) {
|
||||||
|
await updatePPM(ppmName, formData);
|
||||||
|
alert(t('ppm.detail.updatedSuccessfully'));
|
||||||
|
setIsEditing(false);
|
||||||
|
refetch();
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('PPM save error:', err);
|
||||||
|
alert(t('ppm.detail.failedToSave') + ': ' + (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">{t('ppm.detail.loadingSchedule')}</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">{t('ppm.detail.errorLoading')}: {error}</p>
|
||||||
|
<button
|
||||||
|
onClick={() => navigate(-1)}
|
||||||
|
className="mt-2 text-red-700 dark:text-red-400 underline hover:text-red-800 dark:hover:text-red-300"
|
||||||
|
>
|
||||||
|
{t('ppm.detail.backToSchedules')}
|
||||||
|
</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(-1)}
|
||||||
|
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 ? t('ppm.detail.duplicateSchedule') : (isNewPPM ? t('ppm.detail.newSchedule') : t('ppm.detail.scheduleDetails'))}
|
||||||
|
</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 />
|
||||||
|
{t('common.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">{t('ppm.detail.basicInformation')}</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">
|
||||||
|
{t('ppm.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">
|
||||||
|
{t('ppm.detail.assetName')} *
|
||||||
|
</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">
|
||||||
|
{t('ppm.assetType')}
|
||||||
|
</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">
|
||||||
|
{t('ppm.detail.maintenanceTeam')}
|
||||||
|
</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">
|
||||||
|
{t('ppm.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={t('ppm.detail.frequencyPlaceholder')}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<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">{t('ppm.detail.financialInformation')}</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">
|
||||||
|
{t('ppm.detail.numberOfPMs')}
|
||||||
|
</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">
|
||||||
|
{t('ppm.detail.pricePerPM')}
|
||||||
|
</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">
|
||||||
|
{t('ppm.totalAmount')}
|
||||||
|
</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">{t('ppm.detail.scheduleInformation')}</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">{t('ppm.pmId')}</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">{t('users.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">
|
||||||
|
{t('ppm.detail.scheduleInfoAfterCreation')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Action Buttons */}
|
||||||
|
{isEditing && (
|
||||||
|
<div className="mt-6 flex justify-end gap-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
if (isNewPPM) {
|
||||||
|
navigate(-1);
|
||||||
|
} 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"
|
||||||
|
>
|
||||||
|
{t('common.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 ? t('common.saving') : (isNewPPM ? t('common.create') : t('ppm.detail.saveChanges'))}
|
||||||
|
</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(t('ppm.deletedSuccessfully'));
|
||||||
|
} 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">⚠️ {t('ppm.apiNotAvailable')}</h2>
|
||||||
|
<div className="text-yellow-700 dark:text-yellow-400 space-y-3">
|
||||||
|
<p><strong>{t('ppm.apiNotDeployed')}</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"
|
||||||
|
>
|
||||||
|
{t('ppm.tryCreatingNew')}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={refetch}
|
||||||
|
className="bg-gray-600 hover:bg-gray-700 text-white px-4 py-2 rounded"
|
||||||
|
>
|
||||||
|
{t('common.tryAgain')}
|
||||||
|
</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">
|
||||||
|
{t('ppm.listTotal', { count: totalCount })}
|
||||||
|
</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={t('ppm.searchPlaceholder')}
|
||||||
|
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={t('ppm.filterByCompany')}
|
||||||
|
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">
|
||||||
|
{t('ppm.pmId')}
|
||||||
|
</th>
|
||||||
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||||
|
{t('ppm.company')}
|
||||||
|
</th>
|
||||||
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||||
|
{t('ppm.asset')}
|
||||||
|
</th>
|
||||||
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||||
|
{t('ppm.assetType')}
|
||||||
|
</th>
|
||||||
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||||
|
{t('ppm.frequency')}
|
||||||
|
</th>
|
||||||
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||||
|
{t('ppm.noOfPMs')}
|
||||||
|
</th>
|
||||||
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||||
|
{t('ppm.totalAmount')}
|
||||||
|
</th>
|
||||||
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||||
|
{t('listPages.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>{t('ppm.noSchedulesFound')}</p>
|
||||||
|
<button
|
||||||
|
onClick={handleCreateNew}
|
||||||
|
className="mt-4 text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300 underline"
|
||||||
|
>
|
||||||
|
{t('ppm.createFirstSchedule')}
|
||||||
|
</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 />
|
||||||
|
{t('listPages.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 />
|
||||||
|
{t('listPages.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 />
|
||||||
|
{t('listPages.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 />
|
||||||
|
{t('listPages.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 />
|
||||||
|
{t('listPages.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">
|
||||||
|
{t('pagination.showingToOf', { start: page * limit + 1, end: Math.min((page + 1) * limit, totalCount), total: totalCount, label: t('listPages.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"
|
||||||
|
>
|
||||||
|
{t('pagination.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"
|
||||||
|
>
|
||||||
|
{t('pagination.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">{t('ppm.confirmDelete')}</h3>
|
||||||
|
<p className="text-gray-600 dark:text-gray-400 mb-6">
|
||||||
|
{t('ppm.deleteConfirmMessage')}
|
||||||
|
</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"
|
||||||
|
>
|
||||||
|
{t('common.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 ? t('common.deleting') : t('common.delete')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default PPMList;
|
||||||
|
|
||||||
825
asm_app/src/pages/PPMPlanner.tsx
Normal file
825
asm_app/src/pages/PPMPlanner.tsx
Normal file
@ -0,0 +1,825 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import ppmPlannerService, { 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;
|
||||||
|
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 {
|
||||||
|
// Get full asset details for selected assets (including manufacturer and model)
|
||||||
|
const selectedAssetDetails = assets
|
||||||
|
.filter(asset => selectedAssets.includes(asset.name))
|
||||||
|
.map(asset => ({
|
||||||
|
name: asset.name,
|
||||||
|
custom_manufacturer: asset.custom_manufacturer,
|
||||||
|
custom_model: asset.custom_model,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const bulkData: BulkScheduleData = {
|
||||||
|
assets: selectedAssetDetails, // Pass full asset details
|
||||||
|
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!,
|
||||||
|
// Form-level fields from filters
|
||||||
|
modality: filters.custom_modality,
|
||||||
|
manufacturer: filters.custom_manufacturer,
|
||||||
|
model: filters.custom_model,
|
||||||
|
department: scheduleData.department || filters.department || undefined,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Debug logs
|
||||||
|
console.log('=== DEBUG: Selected Asset Details ===', selectedAssetDetails);
|
||||||
|
console.log('=== DEBUG: bulkData ===', bulkData);
|
||||||
|
|
||||||
|
const result = await ppmPlannerService.createBulkMaintenanceSchedules(bulkData);
|
||||||
|
|
||||||
|
setSuccessResult({
|
||||||
|
show: true,
|
||||||
|
document: result.document,
|
||||||
|
count: result.created || selectedAssets.length,
|
||||||
|
type: 'pm_schedule'
|
||||||
|
});
|
||||||
|
|
||||||
|
setSelectedAssets([]);
|
||||||
|
setScheduleData({
|
||||||
|
start_date: '',
|
||||||
|
end_date: '',
|
||||||
|
maintenance_team: '',
|
||||||
|
assign_to: '',
|
||||||
|
pm_for: '',
|
||||||
|
maintenance_manager: '',
|
||||||
|
periodicity: 'Monthly',
|
||||||
|
maintenance_type: 'Preventive',
|
||||||
|
no_of_pms: '',
|
||||||
|
department: ''
|
||||||
|
});
|
||||||
|
} 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;
|
||||||
1913
asm_app/src/pages/PPMPlannerDetail.tsx
Normal file
1913
asm_app/src/pages/PPMPlannerDetail.tsx
Normal file
File diff suppressed because it is too large
Load Diff
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