Initial commit of ASM Ui App

This commit is contained in:
Duradundi 2026-09-09 17:52:32 +05:30
commit 8f04e6b475
197 changed files with 86191 additions and 0 deletions

7
.gitignore vendored Normal file
View File

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

1360
ItemList.tsx Normal file

File diff suppressed because it is too large Load Diff

7
README.md Normal file
View File

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

24
asm_app/.gitignore vendored Normal file
View File

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

73
asm_app/README.md Normal file
View File

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

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

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

16
asm_app/index.html Normal file
View File

@ -0,0 +1,16 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/png" href="/seera-logo.png?v=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>

5184
asm_app/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

51
asm_app/package.json Normal file
View File

@ -0,0 +1,51 @@
{
"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",
"exceljs": "^4.4.0",
"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",
"xlsx-js-style": "^1.2.0"
},
"devDependencies": {
"@eslint/js": "^9.36.0",
"@types/node": "^24.6.0",
"@types/react": "^19.1.16",
"@types/react-dom": "^19.1.9",
"@vitejs/plugin-react": "^5.0.4",
"autoprefixer": "^10.4.22",
"eslint": "^9.36.0",
"eslint-plugin-react-hooks": "^5.2.0",
"eslint-plugin-react-refresh": "^0.4.22",
"globals": "^16.4.0",
"postcss": "^8.5.6",
"tailwindcss": "^3.4.18",
"typescript": "~5.9.3",
"typescript-eslint": "^8.45.0",
"vite": "^7.1.7"
}
}

View File

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

13
asm_app/proxyOptions.ts Normal file
View File

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

BIN
asm_app/public/ehc-logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 395 KiB

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

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

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

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

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

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

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

@ -0,0 +1,453 @@
// 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, { useEffect } from 'react';
import React, { useEffect, useState } from 'react';
import { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router-dom';
import Login from './pages/Login';
import WoFeedbackPage from './pages/WoFeedbackPage';
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';
import PrivacyPolicyPage from './pages/PrivacyPolicyPage';
import DataImport from './pages/DataImport';
import LocationList from './pages/LocationList';
import LocationDetail from './pages/LocationDetail';
import DRApproverGate from './components/DRApproverGate';
import { SidebarLayoutProvider, useSidebarLayout } from './contexts/SidebarLayoutContext';
import { useLanguage } from './contexts/LanguageContext';
import YearlyLogsPage from './pages/Yearlylogs';
const LayoutWithSidebarInner: React.FC<{ children: React.ReactNode; userEmail: string }> = ({
children,
userEmail,
}) => {
const { isRTL } = useLanguage();
const { mobileOpen, closeMobileSidebar } = useSidebarLayout();
useEffect(() => {
const mq = window.matchMedia('(min-width: 1024px)');
const onChange = () => {
if (mq.matches) closeMobileSidebar();
};
mq.addEventListener('change', onChange);
return () => mq.removeEventListener('change', onChange);
}, [closeMobileSidebar]);
const sidebarSlideClasses = isRTL
? mobileOpen
? 'translate-x-0'
: 'translate-x-full lg:translate-x-0'
: mobileOpen
? 'translate-x-0'
: '-translate-x-full lg:translate-x-0';
return (
<div className="flex h-screen overflow-hidden bg-gray-50 dark:bg-gray-900">
{mobileOpen && (
<button
type="button"
aria-label="Close menu"
className="fixed inset-0 z-40 bg-black/50 lg:hidden"
onClick={closeMobileSidebar}
/>
)}
<div
className={[
'fixed top-0 z-50 h-full max-h-screen flex-shrink-0 transition-transform duration-300 ease-out',
'lg:static lg:z-auto lg:translate-x-0',
isRTL ? 'right-0 left-auto' : 'left-0',
sidebarSlideClasses,
].join(' ')}
>
<Sidebar userEmail={userEmail} />
</div>
<div className="flex min-w-0 flex-1 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>
);
};
// 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 (
<SidebarLayoutProvider>
<LayoutWithSidebarInner userEmail={userEmail}>{children}</LayoutWithSidebarInner>
</SidebarLayoutProvider>
);
};
// Protected Route Component
// Protected Route Component
const ProtectedRoute: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const [isAuthenticated, setIsAuthenticated] = React.useState<boolean | null>(null);
useEffect(() => {
const checkAuth = async () => {
// First check localStorage
const user = localStorage.getItem('user');
if (user) {
setIsAuthenticated(true);
return;
}
try {
// Check existing Frappe session
const response = await fetch('/api/method/frappe.auth.get_logged_user', {
credentials: 'include',
});
const data = await response.json();
if (data.message && data.message !== 'Guest') {
// User already logged into Frappe
// Fetch full user info
const userResponse = await fetch(
`/api/method/frappe.client.get?doctype=User&name=${encodeURIComponent(data.message)}`,
{ credentials: 'include' }
);
const userData = await userResponse.json();
if (userData.message) {
localStorage.setItem(
'user',
JSON.stringify({
...userData.message,
email: data.message,
})
);
setIsAuthenticated(true);
return;
}
}
setIsAuthenticated(false);
} catch (err) {
console.error('Auth check failed:', err);
setIsAuthenticated(false);
}
};
checkAuth();
}, []);
if (isAuthenticated === null) {
return (
<div className="flex items-center justify-center h-screen">
Loading...
</div>
);
}
return isAuthenticated ? <>{children}</> : <Navigate to="/login" replace />;
};
// 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="/privacy-policy" element={<PrivacyPolicyPage />} />
<Route path="/wo-feedback" element={<WoFeedbackPage />} />
<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><MaintenanceCalendarPage /></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="/yearly-logs" element={
<ProtectedRoute>
<LayoutWithSidebar><YearlyLogsPage /></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>
} />
<Route path="/data-import" element={
<ProtectedRoute>
<LayoutWithSidebar>
<DRApproverGate>
<DataImport />
</DRApproverGate>
</LayoutWithSidebar>
</ProtectedRoute>
} />
<Route path="/locations/:locationName" element={
<ProtectedRoute>
<LayoutWithSidebar>
<LocationDetail />
</LayoutWithSidebar>
</ProtectedRoute>
} />
<Route path="/locations" element={
<ProtectedRoute>
<LayoutWithSidebar>
<LocationList />
</LayoutWithSidebar>
</ProtectedRoute>
} />
{/* Default redirect */}
<Route path="/" element={<Navigate to="/login" replace />} />
<Route path="*" element={<Navigate to="/login" replace />} />
</Routes>
</Router>
);
};
export default App;

View File

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

View File

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

After

Width:  |  Height:  |  Size: 4.0 KiB

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,27 @@
import React from 'react';
import { Navigate } from 'react-router-dom';
import { FaSpinner } from 'react-icons/fa';
import { useDrApproverRole } from '../hooks/useDrApproverRole';
/**
* Renders children only for users with **DR Approver** role; otherwise redirects to dashboard.
*/
const DRApproverGate: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const role = useDrApproverRole();
if (role === 'loading') {
return (
<div className="flex min-h-[40vh] items-center justify-center bg-gray-50 dark:bg-gray-900">
<FaSpinner className="h-8 w-8 animate-spin text-blue-600" aria-hidden />
</div>
);
}
if (role === 'no') {
return <Navigate to="/dashboard" replace />;
}
return <>{children}</>;
};
export default DRApproverGate;

View File

@ -0,0 +1,230 @@
import React, { useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { FaTimes, FaFileExcel, FaFileCsv } from 'react-icons/fa';
export type TemplateFileFormat = 'xlsx' | 'csv';
export interface TemplateColumn {
key: string;
label: string;
mandatory: boolean;
}
interface DataImportTemplateModalProps {
isOpen: boolean;
onClose: () => void;
columns: TemplateColumn[];
loading: boolean;
onExport: (selectedKeys: string[], format: TemplateFileFormat) => void;
}
const DataImportTemplateModal: React.FC<DataImportTemplateModalProps> = ({
isOpen,
onClose,
columns,
loading,
onExport,
}) => {
const { t } = useTranslation();
const [format, setFormat] = useState<TemplateFileFormat>('xlsx');
const [selected, setSelected] = useState<Set<string>>(new Set());
const selectableKeys = useMemo(() => columns.map((c) => c.key), [columns]);
const mandatoryKeys = useMemo(
() => new Set(columns.filter((c) => c.mandatory).map((c) => c.key)),
[columns]
);
useEffect(() => {
if (!isOpen || columns.length === 0) return;
const mandatory = columns.filter((c) => c.mandatory);
if (mandatory.length > 0) {
setSelected(new Set(mandatory.map((c) => c.key)));
} else {
setSelected(new Set(columns.slice(0, Math.min(8, columns.length)).map((c) => c.key)));
}
}, [isOpen, columns]);
if (!isOpen) return null;
const toggle = (key: string) => {
setSelected((prev) => {
const next = new Set(prev);
if (next.has(key)) next.delete(key);
else next.add(key);
return next;
});
};
const selectAll = () => setSelected(new Set(selectableKeys));
const selectMandatory = () => setSelected(new Set(mandatoryKeys));
const unselectAll = () => setSelected(new Set());
const handleExport = () => {
if (selected.size === 0) return;
onExport(Array.from(selected), format);
};
return (
<div className="fixed inset-0 z-[80] flex items-center justify-center bg-black/50 p-4 backdrop-blur-[2px]">
<div
className="flex max-h-[90vh] w-full max-w-2xl flex-col overflow-hidden rounded-2xl border border-gray-200 bg-white shadow-2xl dark:border-gray-700 dark:bg-gray-800"
role="dialog"
aria-labelledby="data-import-template-title"
>
<div className="flex items-center justify-between border-b border-gray-100 bg-gradient-to-r from-indigo-600 to-indigo-700 px-5 py-4 dark:from-indigo-800 dark:to-indigo-900">
<h2 id="data-import-template-title" className="text-lg font-semibold text-white">
{t('dataImport.templateModal.title')}
</h2>
<button
type="button"
onClick={onClose}
className="rounded-lg p-1.5 text-white/90 transition hover:bg-white/10"
aria-label={t('common.cancel')}
>
<FaTimes size={18} />
</button>
</div>
<div className="flex-1 overflow-y-auto p-5 sm:p-6">
{loading ? (
<div className="flex justify-center py-12 text-sm text-gray-500 dark:text-gray-400">
{t('common.loading')}
</div>
) : (
<>
<div className="grid gap-4 sm:grid-cols-2">
<div>
<label className="mb-1.5 block text-xs font-semibold uppercase tracking-wide text-indigo-600 dark:text-indigo-400">
{t('dataImport.templateModal.fileType')}
</label>
<select
value={format}
onChange={(e) => setFormat(e.target.value as TemplateFileFormat)}
className="w-full rounded-lg border border-gray-300 bg-white px-3 py-2.5 text-sm font-medium text-gray-900 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 dark:border-gray-600 dark:bg-gray-900 dark:text-white"
>
<option value="xlsx">{t('dataImport.templateModal.fileTypeExcel')}</option>
<option value="csv">{t('dataImport.templateModal.fileTypeCsv')}</option>
</select>
</div>
<div>
<label className="mb-1.5 block text-xs font-semibold uppercase tracking-wide text-indigo-600 dark:text-indigo-400">
{t('dataImport.templateModal.exportType')}
</label>
<div className="rounded-lg border border-gray-200 bg-gray-50 px-3 py-2.5 text-sm font-medium text-gray-800 dark:border-gray-600 dark:bg-gray-900/60 dark:text-gray-200">
{t('dataImport.templateModal.exportTypeBlank')}
</div>
</div>
</div>
<p className="mt-3 text-xs text-gray-500 dark:text-gray-400">
{t('dataImport.templateModal.blankHint')}
</p>
<div className="mt-6">
<div className="mb-3 flex flex-wrap items-center justify-between gap-2">
<span className="text-xs font-bold uppercase tracking-wider text-indigo-600 dark:text-indigo-400">
{t('dataImport.templateModal.selectFields')}
</span>
<div className="flex flex-wrap gap-2">
<button
type="button"
onClick={selectAll}
className="rounded-full border border-gray-200 bg-white px-3 py-1 text-xs font-medium text-gray-700 shadow-sm hover:bg-gray-50 dark:border-gray-600 dark:bg-gray-900 dark:text-gray-200 dark:hover:bg-gray-800"
>
{t('dataImport.templateModal.selectAll')}
</button>
<button
type="button"
onClick={selectMandatory}
className="rounded-full border border-gray-200 bg-white px-3 py-1 text-xs font-medium text-gray-700 shadow-sm hover:bg-gray-50 dark:border-gray-600 dark:bg-gray-900 dark:text-gray-200 dark:hover:bg-gray-800"
>
{t('dataImport.templateModal.selectMandatory')}
</button>
<button
type="button"
onClick={unselectAll}
className="rounded-full border border-gray-200 bg-white px-3 py-1 text-xs font-medium text-gray-700 shadow-sm hover:bg-gray-50 dark:border-gray-600 dark:bg-gray-900 dark:text-gray-200 dark:hover:bg-gray-800"
>
{t('dataImport.templateModal.unselectAll')}
</button>
</div>
</div>
<div className="grid max-h-64 grid-cols-1 gap-2 overflow-y-auto rounded-xl border border-gray-100 bg-gray-50/80 p-3 sm:grid-cols-2 dark:border-gray-700 dark:bg-gray-900/40">
{columns.map((col) => {
const checked = selected.has(col.key);
return (
<label
key={col.key}
className={`flex cursor-pointer items-start gap-2.5 rounded-lg border px-3 py-2.5 text-sm transition ${
checked
? 'border-indigo-300 bg-indigo-50/90 dark:border-indigo-700 dark:bg-indigo-950/50'
: 'border-transparent hover:bg-white dark:hover:bg-gray-800/80'
}`}
>
<input
type="checkbox"
checked={checked}
onChange={() => toggle(col.key)}
className="mt-0.5 rounded border-gray-300 text-indigo-600 focus:ring-indigo-500"
/>
<span
className={`leading-snug ${
col.mandatory
? 'font-semibold text-red-600 dark:text-red-400'
: 'font-medium text-gray-800 dark:text-gray-200'
}`}
>
{col.label}
{col.mandatory && (
<span className="ms-1 text-red-500" aria-hidden>
*
</span>
)}
</span>
</label>
);
})}
</div>
</div>
</>
)}
</div>
<div className="flex flex-wrap items-center justify-between gap-3 border-t border-gray-100 bg-gray-50 px-5 py-4 dark:border-gray-700 dark:bg-gray-900/50">
<div className="flex items-center gap-2 text-xs text-gray-500 dark:text-gray-400">
{format === 'xlsx' ? (
<FaFileExcel className="text-green-600" />
) : (
<FaFileCsv className="text-amber-600" />
)}
{selected.size} {t('dataImport.templateModal.fieldsSelected')}
</div>
<div className="flex gap-2">
<button
type="button"
onClick={onClose}
className="rounded-lg px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-200/80 dark:text-gray-300 dark:hover:bg-gray-700"
>
{t('common.cancel')}
</button>
<button
type="button"
disabled={loading || selected.size === 0}
onClick={handleExport}
className="rounded-lg bg-indigo-600 px-5 py-2 text-sm font-semibold text-white shadow-sm hover:bg-indigo-700 disabled:cursor-not-allowed disabled:opacity-50"
>
{t('dataImport.templateModal.export')}
</button>
</div>
</div>
</div>
</div>
);
};
export default DataImportTemplateModal;

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

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

View File

@ -0,0 +1,376 @@
/**
* DynamicField Component
*
* Renders form fields dynamically based on Frappe's field configuration.
* Supports conditional visibility, mandatory, read-only states, and various field types.
*/
import React, { useMemo } from 'react';
import { 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>
);
};

View File

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

View File

@ -0,0 +1,171 @@
import React, { useState, useEffect } from 'react';
import { useTheme } from '../contexts/ThemeContext';
import { useLanguage } from '../contexts/LanguageContext';
import { useTranslation } from 'react-i18next';
import { Moon, Sun, Languages, LogOut, Menu, UserCircle } from 'lucide-react';
import NotificationBell from './NotificationBell';
import { useSidebarLayout } from '../contexts/SidebarLayoutContext';
import { useNavigate } from 'react-router-dom';
interface HeaderProps {
userEmail?: string;
}
const Header: React.FC<HeaderProps> = () => {
const { theme, toggleTheme } = useTheme();
const { language, changeLanguage } = useLanguage();
const { t } = useTranslation();
const { openMobileSidebar } = useSidebarLayout();
const navigate = useNavigate();
const [userFullName, setUserFullName] = useState<string>('');
const [showTooltip, setShowTooltip] = useState(false);
// Fetch full name — same two-call pattern as Sidebar
useEffect(() => {
const fetchUserFullName = async () => {
try {
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 email = userData.message;
if (email) {
const fullNameResponse = await fetch(
`/api/resource/User/${encodeURIComponent(email)}?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 {
setUserFullName(email);
}
}
} catch (err) {
console.error('Error fetching user full name:', err);
}
};
fetchUserFullName();
}, []);
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-between gap-2 flex-shrink-0">
{/* Mobile menu button */}
<button
type="button"
onClick={openMobileSidebar}
className="lg:hidden p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors text-gray-700 dark:text-gray-300 -ms-1"
aria-label={t('common.menu', { defaultValue: 'Open menu' })}
title={t('common.menu', { defaultValue: 'Menu' })}
>
<Menu size={22} />
</button>
<div className="flex-1 lg:flex-none" aria-hidden="true" />
<div className="flex items-center justify-end gap-2">
{/* User Profile Icon with tooltip */}
{userFullName && (
<div className="relative">
<button
type="button"
onClick={() => navigate('/user-profile')}
onMouseEnter={() => setShowTooltip(true)}
onMouseLeave={() => setShowTooltip(false)}
// className="p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors text-gray-700 dark:text-gray-300"
// title={userFullName}
className="p-2 rounded-lg bg-[#7911cc] hover:bg-[#6a0fb5] transition-colors text-white"
title={userFullName}
>
<UserCircle size={20} />
</button>
{showTooltip && (
<div className="absolute right-0 top-full mt-1 z-50 px-3 py-1.5 bg-gray-800 dark:bg-gray-700 text-white text-xs rounded-lg whitespace-nowrap shadow-lg">
{userFullName}
{/* Tooltip arrow */}
<div className="absolute -top-1 right-3 w-2 h-2 bg-gray-800 dark:bg-gray-700 rotate-45" />
</div>
)}
</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>
</div>
</header>
);
};
export default Header;

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,514 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import { FaExternalLinkAlt } from 'react-icons/fa';
const CIVIL_PARENT_TYPE = 'Civil works-الأعمال المدنية';
const FIELDS_ORDER = [
'total_wo', 'not_assigned', 'pending_with_contractor', 'completed_by_contractor',
'pending_with_moh', 'total_completed', 'urgent_total', 'urgent_completed',
'urgent_pending_review', 'contractor_completion_rate', 'completion_rate',
] as const;
// ── Props ─────────────────────────────────────────────────────────────────────
interface KpiMonthlyReportCardProps {
kpiMonthlyChartData: any;
dashFromDate: string;
/** Pre-rendered chart node from ModernDashboard (which owns GroupedBarChart) */
chartElement: React.ReactNode;
onOpenDetail: (exportExcelFn: () => void) => void;
}
// ── Component ─────────────────────────────────────────────────────────────────
const KpiMonthlyReportCard: React.FC<KpiMonthlyReportCardProps> = ({
kpiMonthlyChartData,
dashFromDate,
chartElement,
onOpenDetail,
}) => {
const { t } = useTranslation();
const handleExportExcel = async () => {
if (!kpiMonthlyChartData?.labels?.length) return;
const MONTH_NAMES = [
'JANUARY','FEBRUARY','MARCH','APRIL','MAY','JUNE',
'JULY','AUGUST','SEPTEMBER','OCTOBER','NOVEMBER','DECEMBER',
];
const now = dashFromDate ? new Date(dashFromDate + 'T00:00:00') : new Date();
const monthLabel = `${MONTH_NAMES[now.getMonth()]}-${now.getFullYear()}`;
const datasets: any[] = kpiMonthlyChartData.allDatasets || kpiMonthlyChartData.datasets || [];
const labels: string[] = kpiMonthlyChartData.labels;
const ALIASES: Record<string, string[]> = {
total_wo: ['Total WO', 'Total WOs', 'total_wo', 'total_wos'],
not_assigned: ['Not Assigned', 'not_assigned'],
pending_with_contractor: ['Pending with Contractor', 'pending_with_contractor'],
completed_by_contractor: ['Total Completed by Contractor', 'Completed by Contractor', 'completed_by_contractor'],
pending_with_moh: ['Total Pending with MOH', 'Pending with MOH', 'pending_with_moh'],
total_completed: ['Total Completed', 'total_completed'],
urgent_total: ['Urgent', 'Urgent Total', 'urgent_total'],
urgent_completed: ['Completed', 'Urgent Completed', 'urgent_completed'],
urgent_pending_review: ['Pending', 'Urgent Pending Review', 'urgent_pending_review'],
contractor_completion_rate: ['Contractor Completion Rate (%)', 'contractor_completion_rate', 'Total WO Completion Rate %'],
completion_rate: ['Total Completion Rate (%)', 'completion_rate', 'Urgent WO Completion Rate %', 'Urgent Completion Rate (%)'],
};
const getVal = (fieldKey: string, deptIdx: number): number => {
for (const alias of ALIASES[fieldKey] || [fieldKey]) {
const ds = datasets.find((d: any) => d.name === alias);
if (ds?.values?.[deptIdx] !== undefined) return Number(ds.values[deptIdx]) || 0;
}
return 0;
};
const CIVIL_SUB_SET = new Set(['CARPENTARY', 'MASONRY', 'PAINTING', 'PLUMPING']);
const civilSubIndices: number[] = [];
let civilParentIdx: number | null = null;
labels.forEach((lbl: string, i: number) => {
if (CIVIL_SUB_SET.has(lbl)) civilSubIndices.push(i);
if (lbl === CIVIL_PARENT_TYPE) civilParentIdx = i;
});
const civilSubIdxSet = new Set(civilSubIndices);
const civilAgg: Record<string, number> = {};
if (civilParentIdx !== null) {
FIELDS_ORDER.forEach(f => { civilAgg[f] = getVal(f, civilParentIdx!); });
} else {
FIELDS_ORDER.forEach(f => { civilAgg[f] = civilSubIndices.reduce((s, i) => s + getVal(f, i), 0); });
}
if (civilAgg['total_wo'] > 0) {
civilAgg['contractor_completion_rate'] = parseFloat(((civilAgg['completed_by_contractor'] / civilAgg['total_wo']) * 100).toFixed(1));
civilAgg['completion_rate'] = parseFloat(((civilAgg['total_completed'] / civilAgg['total_wo']) * 100).toFixed(1));
}
const civilUrgRate = civilAgg['urgent_total'] > 0
? parseFloat(((civilAgg['urgent_completed'] / civilAgg['urgent_total']) * 100).toFixed(1))
: 0;
const noneVals: Record<string, number> = {};
FIELDS_ORDER.forEach(f => {
const subSum = civilSubIndices.reduce((s, i) => s + getVal(f, i), 0);
noneVals[f] = Math.max(0, (civilAgg[f] ?? 0) - subSum);
});
if (noneVals['total_wo'] > 0) {
noneVals['contractor_completion_rate'] = parseFloat(((noneVals['completed_by_contractor'] / noneVals['total_wo']) * 100).toFixed(1));
noneVals['completion_rate'] = parseFloat(((noneVals['total_completed'] / noneVals['total_wo']) * 100).toFixed(1));
}
const noneUrgRate = noneVals['urgent_total'] > 0
? parseFloat(((noneVals['urgent_completed'] / noneVals['urgent_total']) * 100).toFixed(1))
: 0;
const sum = (fieldKey: string) => labels.reduce((acc: number, lbl: string, i: number) => {
if (lbl === CIVIL_PARENT_TYPE) return acc;
return acc + getVal(fieldKey, i);
}, 0) + (noneVals[fieldKey] ?? 0);
type ExcelRow = { label: string; isCivilParent?: boolean; isCivilSub?: boolean; isNone?: boolean; dataIdx: number | null; };
const excelRows: ExcelRow[] = [];
civilSubIndices.forEach(i => {
excelRows.push({ label: labels[i], dataIdx: i, isCivilSub: true });
});
excelRows.push({ label: 'None', dataIdx: null, isCivilSub: true, isNone: true });
excelRows.push({ label: CIVIL_PARENT_TYPE, dataIdx: null, isCivilParent: true });
const DEPT_ORDER = ['Electrical', 'HVAC', 'Mechanical', 'Medical Gas', 'Electronics', 'Electronic'];
const otherRows: { label: string; dataIdx: number }[] = [];
labels.forEach((lbl: string, i: number) => {
if (civilSubIdxSet.has(i)) return;
if (lbl === CIVIL_PARENT_TYPE) return;
otherRows.push({ label: lbl, dataIdx: i });
});
otherRows.sort((a, b) => {
const ai = DEPT_ORDER.findIndex(d => a.label.toLowerCase().includes(d.toLowerCase()));
const bi = DEPT_ORDER.findIndex(d => b.label.toLowerCase().includes(d.toLowerCase()));
return (ai === -1 ? 999 : ai) - (bi === -1 ? 999 : bi);
});
otherRows.forEach(r => excelRows.push({ label: r.label, dataIdx: r.dataIdx }));
// ── Draw chart on hidden canvas → PNG base64 ────────────────────────────
const chartBase64 = await new Promise<string>((resolve) => {
const canvas = document.createElement('canvas');
canvas.width = 900;
canvas.height = 400;
const ctx = canvas.getContext('2d')!;
ctx.fillStyle = '#FFFFFF';
ctx.fillRect(0, 0, canvas.width, canvas.height);
const chartOrderedLabels: string[] = excelRows.map(r => r.label);
const chartDatasets = [
{ label: 'Total WO', color: '#4472C4', values: excelRows.map(r => {
if (r.isCivilParent) return civilAgg['total_wo'] ?? 0;
if (r.isNone) return noneVals['total_wo'] ?? 0;
if (r.dataIdx === null) return 0;
return getVal('total_wo', r.dataIdx);
})},
{ label: 'Completed', color: '#70AD47', values: excelRows.map(r => {
if (r.isCivilParent) return civilAgg['total_completed'] ?? 0;
if (r.isNone) return noneVals['total_completed'] ?? 0;
if (r.dataIdx === null) return 0;
return getVal('total_completed', r.dataIdx);
})},
{ label: 'Pending', color: '#ED7D31', values: excelRows.map(r => {
if (r.isCivilParent) return civilAgg['pending_with_contractor'] ?? 0;
if (r.isNone) return noneVals['pending_with_contractor'] ?? 0;
if (r.dataIdx === null) return 0;
return getVal('pending_with_contractor', r.dataIdx);
})},
];
const paddingLeft = 60;
const paddingRight = 20;
const paddingTop = 40;
const paddingBottom = 80;
const chartW = canvas.width - paddingLeft - paddingRight;
const chartH = canvas.height - paddingTop - paddingBottom;
const allVals = chartDatasets.flatMap(d => d.values);
const maxVal = Math.max(...allVals, 1);
const numGroups = excelRows.length;
const groupWidth = chartW / numGroups;
const barCount = chartDatasets.length;
const barWidth = Math.min(groupWidth * 0.22, 28);
const groupPad = (groupWidth - barWidth * barCount) / 2;
ctx.strokeStyle = '#E0E0E0';
ctx.lineWidth = 1;
for (let i = 0; i <= 5; i++) {
const y = paddingTop + chartH - (i / 5) * chartH;
ctx.beginPath();
ctx.moveTo(paddingLeft, y);
ctx.lineTo(paddingLeft + chartW, y);
ctx.stroke();
ctx.fillStyle = '#666666';
ctx.font = '11px Arial';
ctx.textAlign = 'right';
ctx.fillText(Math.round((maxVal * i) / 5).toString(), paddingLeft - 6, y + 4);
}
chartDatasets.forEach((ds, dsIdx) => {
ds.values.forEach((val, grpIdx) => {
const barH = (val / maxVal) * chartH;
const x = paddingLeft + grpIdx * groupWidth + groupPad + dsIdx * barWidth;
const y = paddingTop + chartH - barH;
ctx.fillStyle = ds.color;
ctx.fillRect(x, y, barWidth - 2, barH);
if (barH > 16) {
ctx.fillStyle = '#333333';
ctx.font = '9px Arial';
ctx.textAlign = 'center';
ctx.fillText(val.toString(), x + (barWidth - 2) / 2, y - 3);
}
});
});
ctx.fillStyle = '#333333';
ctx.font = '10px Arial';
ctx.textAlign = 'center';
chartOrderedLabels.forEach((label, i) => {
const x = paddingLeft + i * groupWidth + groupWidth / 2;
const y = paddingTop + chartH + 14;
const short = label.length > 12 ? label.substring(0, 11) + '…' : label;
ctx.fillText(short, x, y);
});
const legendY = canvas.height - 20;
let legendX = paddingLeft;
chartDatasets.forEach(ds => {
ctx.fillStyle = ds.color;
ctx.fillRect(legendX, legendY - 10, 14, 14);
ctx.fillStyle = '#333333';
ctx.font = '11px Arial';
ctx.textAlign = 'left';
ctx.fillText(ds.label, legendX + 18, legendY + 2);
legendX += ctx.measureText(ds.label).width + 40;
});
ctx.fillStyle = '#1F4E79';
ctx.font = 'bold 13px Arial';
ctx.textAlign = 'center';
ctx.fillText(`KPI REPORT FOR THE MONTH OF ${monthLabel}`, canvas.width / 2, 22);
resolve(canvas.toDataURL('image/png').split(',')[1]);
});
// ── Fetch hospital logo ─────────────────────────────────────────────────
let logoBase64: string | null = null;
const logoExtension: 'png' | 'jpeg' = 'png';
try {
const baseUrl = import.meta.env.BASE_URL || '/';
const logoUrl = `${baseUrl}${baseUrl.endsWith('/') ? '' : '/'}kfsh-logo.png`;
const logoRes = await fetch(logoUrl);
if (logoRes.ok) {
const arrayBuffer = await logoRes.arrayBuffer();
logoBase64 = btoa(String.fromCharCode(...new Uint8Array(arrayBuffer)));
}
} catch (e) {
console.warn('Could not load hospital logo:', e);
}
// ── Build workbook ──────────────────────────────────────────────────────
const ExcelJS = (await import('exceljs')).default;
const wb = new ExcelJS.Workbook();
const ws = wb.addWorksheet('KPI Monthly Report');
ws.columns = [
{ width: 36 }, { width: 10 }, { width: 18 }, { width: 20 },
{ width: 20 }, { width: 14 }, { width: 16 }, { width: 10 },
{ width: 13 }, { width: 11 }, { width: 18 }, { width: 18 }, { width: 18 },
];
const mkFill = (hex: string): ExcelJS.Fill => ({
type: 'pattern', pattern: 'solid',
fgColor: { argb: 'FF' + hex.replace('#', '') },
});
const mkFont = (bold = false, size = 10, color = '000000'): Partial<ExcelJS.Font> => ({
name: 'Arial', bold, size, color: { argb: 'FF' + color },
});
const mkBorder = (): Partial<ExcelJS.Borders> => ({
top: { style: 'thin', color: { argb: 'FF000000' } },
bottom: { style: 'thin', color: { argb: 'FF000000' } },
left: { style: 'thin', color: { argb: 'FF000000' } },
right: { style: 'thin', color: { argb: 'FF000000' } },
});
const mkAlign = (h: ExcelJS.Alignment['horizontal'] = 'center', wrap = true): Partial<ExcelJS.Alignment> => ({
horizontal: h, vertical: 'middle', wrapText: wrap,
});
const styleCell = (
cell: ExcelJS.Cell,
opts: { bold?: boolean; size?: number; color?: string; bg?: string; h?: ExcelJS.Alignment['horizontal']; wrap?: boolean; brd?: boolean }
) => {
cell.font = mkFont(opts.bold, opts.size ?? 10, opts.color ?? '000000');
cell.alignment = mkAlign(opts.h ?? 'center', opts.wrap ?? true);
if (opts.bg) cell.fill = mkFill(opts.bg);
if (opts.brd !== false) cell.border = mkBorder();
};
const HDR_BG = '1F4E79';
const PRIO_BG = '7030A0';
const TITLE_BG = '00B050';
const ALT_BG = 'DCE6F1';
const PRIO_ALT = 'E2EFDA';
const PRIO_NRM = 'F2FAF0';
const TOTAL_BG = 'BDD7EE';
ws.getRow(1).height = 55;
if (logoBase64) {
const logoId = wb.addImage({ base64: logoBase64, extension: logoExtension });
ws.addImage(logoId, { tl: { col: 0, row: 0 }, br: { col: 3, row: 1 }, editAs: 'oneCell' });
ws.mergeCells('D1:K1');
const r1 = ws.getCell('D1');
r1.value = 'King Fahad Specialist Hospital';
styleCell(r1, { bold: true, size: 13, h: 'center', brd: false });
ws.mergeCells('L1:M1');
const targetCell = ws.getCell('L1');
targetCell.value = 'TARGET :95%';
styleCell(targetCell, { bold: true, size: 12, color: 'FF0000', bg: 'FFE699', h: 'center', brd: true });
} else {
ws.mergeCells('A1:K1');
const r1 = ws.getCell('A1');
r1.value = 'King Fahad Specialist Hospital';
styleCell(r1, { bold: true, size: 13, h: 'center', brd: false });
ws.mergeCells('L1:M1');
const targetCell = ws.getCell('L1');
targetCell.value = 'TARGET :95%';
styleCell(targetCell, { bold: true, size: 12, color: 'FF0000', bg: 'FFE699', h: 'center', brd: true });
}
ws.mergeCells('A2:M2');
const r2 = ws.getCell('A2');
r2.value = `CORRECTIVE MONTHLY KPI REPORT (INT / EXT) ${monthLabel}`;
styleCell(r2, { bold: true, size: 12, color: 'FFFFFF', bg: TITLE_BG, h: 'center' });
ws.getRow(2).height = 26;
const HDR_COLS_SPAN: [string, string][] = [
['A3', 'A4'], ['B3', 'B4'], ['C3', 'C4'], ['D3', 'D4'],
['E3', 'E4'], ['F3', 'F4'], ['G3', 'G4'],
['K3', 'K4'], ['L3', 'L4'], ['M3', 'M4'],
];
const HDR_LABELS = [
'DEPARTMENT', 'TOTAL WO', 'Not Assigned',
'Pending with contractor\n(WO on Progress)',
'TOTAL COMPLETED\nby Contractor',
'PENDING\nwith MOH', 'TOTAL COMPLETED',
'Contractor\nCompletion Rate %',
'TOTAL WO\nCompletion Rate %',
'URGENT WO\nCompletion Rate %',
];
HDR_COLS_SPAN.forEach(([start, end], idx) => {
ws.mergeCells(`${start}:${end}`);
const c = ws.getCell(start);
c.value = HDR_LABELS[idx];
styleCell(c, { bold: true, size: 9, color: 'FFFFFF', bg: HDR_BG });
});
ws.mergeCells('H3:J3');
const prioCell = ws.getCell('H3');
prioCell.value = 'PRIORITY';
styleCell(prioCell, { bold: true, size: 10, color: 'FFFFFF', bg: PRIO_BG });
ws.getRow(3).height = 42;
[['H4', 'URGENT'], ['I4', 'COMPLETED'], ['J4', 'PENDING']].forEach(([addr, label]) => {
const c = ws.getCell(addr);
c.value = label;
styleCell(c, { bold: true, size: 9, color: 'FFFFFF', bg: PRIO_BG });
});
ws.getRow(4).height = 18;
const CIVIL_PARENT_BG = '1F4E79';
const CIVIL_SUB_BG = 'D6E4F0';
const CIVIL_SUB_ALT_BG = 'EBF2F9';
const DATA_START = 5;
excelRows.forEach((eRow, rowOffset) => {
const rowNum = DATA_START + rowOffset;
const altRow = rowOffset % 2 === 1;
const bg = altRow ? ALT_BG : 'FFFFFF';
const pbg = altRow ? PRIO_ALT : PRIO_NRM;
const row = ws.getRow(rowNum);
row.height = eRow.isCivilSub ? 16 : 18;
const getV = (field: string): number => {
if (eRow.isCivilParent) return civilAgg[field] ?? 0;
if (eRow.isNone) return noneVals[field] ?? 0;
if (eRow.dataIdx === null) return 0;
return getVal(field, eRow.dataIdx);
};
const urgT = getV('urgent_total');
const urgC = getV('urgent_completed');
const urgRate = eRow.isCivilParent ? civilUrgRate
: eRow.isNone ? noneUrgRate
: (urgT > 0 ? parseFloat(((urgC / urgT) * 100).toFixed(1)) : 0);
const rowData = [
eRow.isCivilSub ? ` ${eRow.label}` : eRow.label,
getV('total_wo'),
getV('not_assigned'),
getV('pending_with_contractor'),
getV('completed_by_contractor'),
getV('pending_with_moh'),
getV('total_completed'),
getV('urgent_total'),
getV('urgent_completed'),
getV('urgent_pending_review'),
getV('contractor_completion_rate') > 0 ? `${getV('contractor_completion_rate').toFixed(1)}%` : '-',
getV('completion_rate') > 0 ? `${getV('completion_rate').toFixed(1)}%` : '-',
urgRate > 0 ? `${urgRate.toFixed(1)}%` : '-',
];
rowData.forEach((val, colIdx) => {
const cell = row.getCell(colIdx + 1);
cell.value = val;
let cellBg: string;
if (eRow.isCivilParent) {
cellBg = colIdx >= 7 && colIdx <= 9 ? PRIO_BG : CIVIL_PARENT_BG;
} else if (eRow.isCivilSub) {
cellBg = colIdx >= 7 && colIdx <= 9 ? pbg : (altRow ? CIVIL_SUB_ALT_BG : CIVIL_SUB_BG);
} else {
cellBg = (colIdx >= 7 && colIdx <= 9) ? pbg : bg;
}
styleCell(cell, {
bold: eRow.isCivilParent || colIdx === 0,
size: eRow.isCivilSub ? 9 : 10,
color: eRow.isCivilParent ? 'FFFFFF' : '000000',
bg: cellBg,
h: colIdx === 0 ? 'left' : 'center',
});
});
});
const totalRowNum = DATA_START + excelRows.length;
const totalRow = ws.getRow(totalRowNum);
totalRow.height = 22;
const grandTotalWo = sum('total_wo');
const grandTotalComp = sum('total_completed');
const grandUrgTotal = sum('urgent_total');
const grandUrgComp = sum('urgent_completed');
const grandContRate = grandTotalWo > 0 ? `${((sum('completed_by_contractor') / grandTotalWo) * 100).toFixed(1)}%` : '-';
const grandRate1 = grandTotalWo > 0 ? `${((grandTotalComp / grandTotalWo) * 100).toFixed(1)}%` : '-';
const grandRate2 = grandUrgTotal > 0 ? `${((grandUrgComp / grandUrgTotal) * 100).toFixed(1)}%` : '-';
const totalData = [
'GRAND TOTAL', grandTotalWo, sum('not_assigned'), sum('pending_with_contractor'),
sum('completed_by_contractor'), sum('pending_with_moh'), grandTotalComp,
grandUrgTotal, grandUrgComp, sum('urgent_pending_review'),
grandContRate, grandRate1, grandRate2,
];
totalData.forEach((val, colIdx) => {
const cell = totalRow.getCell(colIdx + 1);
cell.value = val;
styleCell(cell, { bold: true, size: 10, bg: TOTAL_BG, h: colIdx === 0 ? 'left' : 'center' });
});
const sepRow = totalRowNum + 1;
const chartTitleRow = sepRow + 1;
ws.mergeCells(`A${chartTitleRow}:M${chartTitleRow}`);
const ctCell = ws.getCell(`A${chartTitleRow}`);
ctCell.value = `KPI REPORT FOR THE MONTH OF ${monthLabel}`;
styleCell(ctCell, { bold: true, size: 12, color: 'FFFFFF', bg: TITLE_BG, h: 'center' });
ws.getRow(chartTitleRow).height = 24;
const imageId = wb.addImage({ base64: chartBase64, extension: 'png' });
ws.addImage(imageId, {
tl: { col: 0, row: chartTitleRow },
br: { col: 13, row: chartTitleRow + 20 },
editAs: 'oneCell',
});
ws.views = [{ state: 'frozen', xSplit: 1, ySplit: 4 }];
ws.pageSetup = { orientation: 'landscape', fitToPage: true, fitToWidth: 1 };
const buffer = await wb.xlsx.writeBuffer();
const blob = new Blob([buffer], {
type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
});
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = `KPI_Monthly_Report_${monthLabel}.xlsx`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
};
return (
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-sm hover:shadow-md transition-all p-4 border border-gray-200 dark:border-gray-700">
<div className="flex items-center justify-between mb-3">
<h3 className="text-sm font-semibold text-gray-800 dark:text-white">KPI Monthly Report</h3>
<div className="flex items-center gap-1">
<button
onClick={() => onOpenDetail(handleExportExcel)}
className="p-1.5 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors"
title={t('dashboard.viewFullReport')}
>
<FaExternalLinkAlt className="w-3.5 h-3.5 text-gray-400" />
</button>
</div>
</div>
{!kpiMonthlyChartData || !kpiMonthlyChartData.datasets?.length ? (
<div className="h-48 flex items-center justify-center text-gray-400">
<div className="text-xs">No chart data available</div>
</div>
) : (
chartElement
)}
</div>
);
};
export default KpiMonthlyReportCard;
export { KpiMonthlyReportCard };
export type { KpiMonthlyReportCardProps };

View File

@ -0,0 +1,484 @@
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;
/** When true, only the input is rendered (use an outer <label> / FL). */
hideLabel?: boolean;
placeholder?: string;
disabled?: boolean;
filters?: Record<string, any>;
compact?: boolean;
usePortal?: boolean;
// QuickCreate props
allowQuickCreate?: boolean;
onQuickCreateSuccess?: (newRecord: any) => void;
quickCreateInitialValues?: Record<string, any>;
// Custom query support
query?: string;
/** When false, hides the clear ✕ button */
clearable?: boolean;
}
// Stable empty object to avoid re-renders
const EMPTY_FILTERS: Record<string, any> = {};
const LinkField: React.FC<LinkFieldProps> = ({
label,
doctype,
value,
onChange,
hideLabel = false,
placeholder,
disabled = false,
filters,
compact = false,
usePortal = false, // Default to false for backward compatibility
// QuickCreate props with defaults
allowQuickCreate = false,
onQuickCreateSuccess,
quickCreateInitialValues = {},
query,
clearable = true,
}) => {
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' : hideLabel ? 'mb-0' : 'mb-4'}`}
>
{!hideLabel && (
<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 */}
{clearable && 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;

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

View File

@ -0,0 +1,83 @@
import React, { useMemo } from 'react';
import { MapContainer, TileLayer, Marker, useMapEvents } from 'react-leaflet';
import L from 'leaflet';
import 'leaflet/dist/leaflet.css';
import { useTranslation } from 'react-i18next';
import icon from 'leaflet/dist/images/marker-icon.png';
import iconShadow from 'leaflet/dist/images/marker-shadow.png';
const DefaultIcon = L.icon({
iconUrl: icon,
shadowUrl: iconShadow,
iconSize: [25, 41],
iconAnchor: [12, 41],
popupAnchor: [1, -34],
shadowSize: [41, 41],
});
L.Marker.prototype.options.icon = DefaultIcon;
const DEFAULT_CENTER: [number, number] = [19.076, 72.8777];
function MapClickHandler({ onPick }: { onPick: (lat: number, lng: number) => void }) {
useMapEvents({
click(e) {
onPick(e.latlng.lat, e.latlng.lng);
},
});
return null;
}
export interface LocationLatLngMapProps {
latitude: string;
longitude: string;
onPositionChange: (lat: number, lng: number) => void;
}
/**
* Compact Leaflet map: click to set point, drag marker to adjust (same stack as Active Map).
*/
const LocationLatLngMap: React.FC<LocationLatLngMapProps> = ({
latitude,
longitude,
onPositionChange,
}) => {
const { t } = useTranslation();
const lat = parseFloat(latitude);
const lng = parseFloat(longitude);
const hasPoint = Number.isFinite(lat) && Number.isFinite(lng);
const center = useMemo<[number, number]>(
() => (hasPoint ? [lat, lng] : DEFAULT_CENTER),
[hasPoint, lat, lng]
);
const zoom = hasPoint ? 14 : 11;
return (
<div className="space-y-2">
<p className="text-xs text-gray-500 dark:text-gray-400">{t('locations.mapHint')}</p>
<div className="relative z-0 h-64 w-full overflow-hidden rounded-lg border border-gray-200 dark:border-gray-600">
<MapContainer center={center} zoom={zoom} className="h-full w-full" scrollWheelZoom>
<TileLayer
attribution='&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>'
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
/>
<MapClickHandler onPick={onPositionChange} />
{hasPoint && (
<Marker
position={[lat, lng]}
draggable
eventHandlers={{
dragend: (e) => {
const p = e.target.getLatLng();
onPositionChange(p.lat, p.lng);
},
}}
/>
)}
</MapContainer>
</div>
</div>
);
};
export default LocationLatLngMap;

File diff suppressed because it is too large Load Diff

View 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="&lt;a href=&quot;${profileUrl}&quot; target=&quot;_blank&quot;&gt;${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
&nbsp;·&nbsp;
<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;

View File

@ -0,0 +1,283 @@
import React, { useState, useRef, useEffect } from 'react';
import { Bell } from 'lucide-react';
import { useNotifications } from '../hooks/useNotifications';
import { useNavigate } from 'react-router-dom';
import { 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 } = 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={[
// Mobile: true popup (not full page)
'fixed left-1/2 top-16 w-[calc(100vw-1rem)] max-w-sm -translate-x-1/2',
// Desktop: classic dropdown anchored to bell
'sm:absolute sm:left-auto sm:right-0 sm:top-auto sm:mt-2 sm:w-80 sm:max-w-none sm:translate-x-0',
'bg-white dark:bg-gray-800 rounded-lg shadow-xl border border-gray-200 dark:border-gray-700',
'z-[9999] overflow-hidden flex flex-col',
// Height: keep it “half-page” on mobile; preserve old max height on desktop
'max-h-[60vh] sm:max-h-96',
].join(' ')}
>
{/* 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;

View File

@ -0,0 +1,374 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import { FaExternalLinkAlt } from 'react-icons/fa';
// ── Props ─────────────────────────────────────────────────────────────────────
interface PpmKpiReportCardProps {
ppmKpiChartData: any;
dashFromDate: string;
/** Pre-rendered chart node from ModernDashboard (which owns GroupedBarChart) */
chartElement: React.ReactNode;
onOpenDetail: (exportExcelFn: () => void) => void;
}
// ── Component ─────────────────────────────────────────────────────────────────
const PpmKpiReportCard: React.FC<PpmKpiReportCardProps> = ({
ppmKpiChartData,
dashFromDate,
chartElement,
onOpenDetail,
}) => {
const { t } = useTranslation();
const handleExportExcel = async () => {
if (!ppmKpiChartData?.labels?.length) return;
const MONTH_NAMES = [
'JANUARY','FEBRUARY','MARCH','APRIL','MAY','JUNE',
'JULY','AUGUST','SEPTEMBER','OCTOBER','NOVEMBER','DECEMBER',
];
const now = dashFromDate ? new Date(dashFromDate + 'T00:00:00') : new Date();
const monthLabel = `${MONTH_NAMES[now.getMonth()]}-${now.getFullYear()}`;
const labels: string[] = ppmKpiChartData.labels;
const datasets: any[] = ppmKpiChartData.datasets || [];
const getVal = (fieldName: string, idx: number): number => {
const ds = datasets.find((d: any) => d.name === fieldName);
return Number(ds?.values?.[idx]) || 0;
};
// ── Draw chart ────────────────────────────────────────────────────────────
const chartBase64 = await new Promise<string>((resolve) => {
const canvas = document.createElement('canvas');
canvas.width = 900;
canvas.height = 420;
const ctx = canvas.getContext('2d')!;
ctx.fillStyle = '#FCE8D8';
ctx.fillRect(0, 0, canvas.width, canvas.height);
const DEPT_COLORS = [
'#4472C4','#ED7D31','#A9D18E','#7030A0',
'#FF0000','#00B0F0','#92D050','#FFC000',
];
const xGroups = ['Total WOs', 'Pending', 'Completed'];
const deptData = labels.map((lbl: string, i: number) => ({
label: (lbl || '').toUpperCase(),
color: DEPT_COLORS[i % DEPT_COLORS.length],
values: xGroups.map(g => getVal(g, i)),
}));
const paddingLeft = 60;
const paddingRight = 20;
const paddingTop = 50;
const paddingBottom = 90;
const chartW = canvas.width - paddingLeft - paddingRight;
const chartH = canvas.height - paddingTop - paddingBottom;
const allVals = deptData.flatMap(d => d.values);
const maxVal = Math.max(...allVals, 1);
const numGroups = xGroups.length;
const groupWidth = chartW / numGroups;
const numDepts = deptData.length;
const barWidth = Math.min((groupWidth * 0.8) / numDepts, 30);
const groupPad = (groupWidth - barWidth * numDepts) / 2;
ctx.strokeStyle = '#D0D0D0';
ctx.lineWidth = 1;
for (let i = 0; i <= 5; i++) {
const y = paddingTop + chartH - (i / 5) * chartH;
ctx.beginPath();
ctx.moveTo(paddingLeft, y);
ctx.lineTo(paddingLeft + chartW, y);
ctx.stroke();
ctx.fillStyle = '#555555';
ctx.font = '11px Arial';
ctx.textAlign = 'right';
ctx.fillText(Math.round((maxVal * i) / 5).toString(), paddingLeft - 6, y + 4);
}
xGroups.forEach((group, grpIdx) => {
deptData.forEach((dept, deptIdx) => {
const val = dept.values[grpIdx];
const barH = maxVal > 0 ? (val / maxVal) * chartH : 0;
const x = paddingLeft + grpIdx * groupWidth + groupPad + deptIdx * barWidth;
const y = paddingTop + chartH - barH;
ctx.fillStyle = dept.color;
ctx.fillRect(x, y, barWidth - 1, barH);
if (val > 0) {
ctx.fillStyle = '#222222';
ctx.font = '8px Arial';
ctx.textAlign = 'center';
ctx.fillText(val.toString(), x + (barWidth - 1) / 2, y - 3);
}
});
});
ctx.fillStyle = '#333333';
ctx.font = '11px Arial';
ctx.textAlign = 'center';
xGroups.forEach((group, i) => {
const x = paddingLeft + i * groupWidth + groupWidth / 2;
const y = paddingTop + chartH + 16;
ctx.fillText(group, x, y);
});
ctx.strokeStyle = '#999999';
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(paddingLeft, paddingTop + chartH);
ctx.lineTo(paddingLeft + chartW, paddingTop + chartH);
ctx.stroke();
const legendY = paddingTop + chartH + 36;
const itemW = 110;
const perRow = Math.floor(chartW / itemW);
deptData.forEach((dept, i) => {
const row = Math.floor(i / perRow);
const col = i % perRow;
const lx = paddingLeft + col * itemW;
const ly = legendY + row * 16;
ctx.fillStyle = dept.color;
ctx.fillRect(lx, ly - 9, 12, 12);
ctx.fillStyle = '#333333';
ctx.font = '10px Arial';
ctx.textAlign = 'left';
const short = dept.label.length > 10 ? dept.label.substring(0, 9) + '…' : dept.label;
ctx.fillText(short, lx + 16, ly);
});
ctx.fillStyle = '#7030A0';
ctx.font = 'bold 14px Arial';
ctx.textAlign = 'center';
ctx.fillText(`PPM FOR ${monthLabel}`, canvas.width / 2, 28);
resolve(canvas.toDataURL('image/png').split(',')[1]);
});
// ── Load logos ────────────────────────────────────────────────────────────
const loadLogo = async (path: string): Promise<string | null> => {
try {
const baseUrl = import.meta.env.BASE_URL || '/';
const url = `${baseUrl}${baseUrl.endsWith('/') ? '' : '/'}${path}`;
const res = await fetch(url);
if (!res.ok) return null;
const buf = await res.arrayBuffer();
return btoa(String.fromCharCode(...new Uint8Array(buf)));
} catch { return null; }
};
const [logoLeft, logoRight] = await Promise.all([
loadLogo('kfsh-logo.png'),
loadLogo('ehc-logo.png'),
]);
// ── Build workbook ────────────────────────────────────────────────────────
const ExcelJS = (await import('exceljs')).default;
const wb = new ExcelJS.Workbook();
const ws = wb.addWorksheet('PPM KPI Report');
ws.columns = [
{ width: 20 }, { width: 12 }, { width: 12 },
{ width: 12 }, { width: 14 },
];
const mkFill = (hex: string): ExcelJS.Fill => ({
type: 'pattern', pattern: 'solid',
fgColor: { argb: 'FF' + hex.replace('#', '') },
});
const mkFont = (bold = false, size = 10, color = '000000'): Partial<ExcelJS.Font> => ({
name: 'Arial', bold, size, color: { argb: 'FF' + color },
});
const mkBorder = (style: ExcelJS.BorderStyle = 'thin'): Partial<ExcelJS.Borders> => ({
top: { style, color: { argb: 'FF000000' } },
bottom: { style, color: { argb: 'FF000000' } },
left: { style, color: { argb: 'FF000000' } },
right: { style, color: { argb: 'FF000000' } },
});
const mkAlign = (
h: ExcelJS.Alignment['horizontal'] = 'center', wrap = true
): Partial<ExcelJS.Alignment> => ({ horizontal: h, vertical: 'middle', wrapText: wrap });
const styleCell = (
cell: ExcelJS.Cell,
opts: {
bold?: boolean; size?: number; color?: string; bg?: string;
h?: ExcelJS.Alignment['horizontal']; wrap?: boolean;
brd?: boolean; brdStyle?: ExcelJS.BorderStyle;
}
) => {
cell.font = mkFont(opts.bold, opts.size ?? 10, opts.color ?? '000000');
cell.alignment = mkAlign(opts.h ?? 'center', opts.wrap ?? true);
if (opts.bg) cell.fill = mkFill(opts.bg);
if (opts.brd !== false) cell.border = mkBorder(opts.brdStyle ?? 'thin');
};
const TITLE_BG = '70AD47';
const HDR_BG = 'F2F2F2';
const ROW_YELLOW = 'FFFFC7';
const RATE_BG = 'D9B3FF';
const TOTAL_BG = 'D9B3FF';
ws.getRow(1).height = 30;
ws.getRow(2).height = 20;
ws.getRow(3).height = 16;
ws.mergeCells('A1:B3');
ws.mergeCells('C1:C3');
ws.mergeCells('D1:E3');
if (logoLeft) {
const id = wb.addImage({ base64: logoLeft, extension: 'png' });
ws.addImage(id, { tl: { col: 0, row: 0 }, br: { col: 2, row: 3 }, editAs: 'oneCell' });
} else {
const c = ws.getCell('A1');
c.value = 'King Fahad Specialist Hospital';
styleCell(c, { bold: true, size: 10, h: 'center', brd: false });
}
if (logoRight) {
const id = wb.addImage({ base64: logoRight, extension: 'png' });
ws.addImage(id, { tl: { col: 3, row: 0 }, br: { col: 5, row: 3 }, editAs: 'oneCell' });
} else {
const c = ws.getCell('D1');
c.value = 'Eastern Health Cluster';
styleCell(c, { bold: true, size: 10, h: 'center', brd: false });
}
ws.getRow(4).height = 6;
ws.mergeCells('A5:E5');
const titleCell = ws.getCell('A5');
titleCell.value = `PPM JOB ORDERS KPI REPORT FOR THE MONTH OF ${monthLabel}`;
styleCell(titleCell, { bold: true, size: 11, color: 'FFFFFF', bg: TITLE_BG, h: 'center' });
ws.getRow(5).height = 24;
ws.getRow(6).height = 6;
const HDR_LABELS = ['DEPARTMENT', 'Totl Wos', 'PENDING', 'COMPLETED', 'Completion\nRate'];
HDR_LABELS.forEach((lbl, i) => {
const cell = ws.getRow(7).getCell(i + 1);
cell.value = lbl;
if (i === 4) {
cell.fill = mkFill(RATE_BG);
cell.font = mkFont(true, 10, '000000');
cell.alignment = mkAlign('center', true);
cell.border = mkBorder();
} else {
styleCell(cell, { bold: true, size: 10, bg: HDR_BG, h: 'center' });
}
});
ws.getRow(7).height = 28;
const DATA_START = 8;
labels.forEach((label: string, idx: number) => {
const rowNum = DATA_START + idx;
const altRow = idx % 2 === 0;
const bg = altRow ? ROW_YELLOW : 'FFFFFF';
const row = ws.getRow(rowNum);
row.height = 18;
const totalWOs_ = getVal('Total WOs', idx);
const completed_ = getVal('Completed', idx);
const pending_ = getVal('Pending', idx);
const rate_ = totalWOs_ > 0
? parseFloat(((completed_ / totalWOs_) * 100).toFixed(0))
: 0;
const rowData = [
(label || '').toUpperCase(),
totalWOs_,
pending_,
completed_,
rate_ > 0 ? `${rate_}%` : '0%',
];
rowData.forEach((val, colIdx) => {
const cell = row.getCell(colIdx + 1);
cell.value = val;
const cellBg = colIdx === 4 ? RATE_BG : bg;
styleCell(cell, { bold: colIdx === 4, size: 10, bg: cellBg, h: 'center' });
});
});
const totalRowNum = DATA_START + labels.length;
const totalRow = ws.getRow(totalRowNum);
totalRow.height = 20;
const grandTotal = labels.reduce((s: number, _: string, i: number) => s + getVal('Total WOs', i), 0);
const grandComp = labels.reduce((s: number, _: string, i: number) => s + getVal('Completed', i), 0);
const grandPend = labels.reduce((s: number, _: string, i: number) => s + getVal('Pending', i), 0);
const grandRate = grandTotal > 0 ? `${((grandComp / grandTotal) * 100).toFixed(0)}%` : '0%';
['TOTAL', grandTotal, grandPend, grandComp, grandRate].forEach((val, colIdx) => {
const cell = totalRow.getCell(colIdx + 1);
cell.value = val;
styleCell(cell, { bold: true, size: 10, bg: TOTAL_BG, h: 'center', brdStyle: 'medium' });
});
const sepRow = totalRowNum + 1;
ws.getRow(sepRow).height = 8;
const chartTitleRow = sepRow + 1;
ws.mergeCells(`A${chartTitleRow}:E${chartTitleRow}`);
const ctCell = ws.getCell(`A${chartTitleRow}`);
ctCell.value = `PPM FOR ${monthLabel}`;
styleCell(ctCell, { bold: true, size: 12, color: '7030A0', bg: 'F8E4FF', h: 'center', brd: false });
ws.getRow(chartTitleRow).height = 22;
const imageId = wb.addImage({ base64: chartBase64, extension: 'png' });
ws.addImage(imageId, {
tl: { col: 0, row: chartTitleRow },
br: { col: 5, row: chartTitleRow + 22 },
editAs: 'oneCell',
});
ws.views = [{ state: 'frozen', xSplit: 1, ySplit: 7 }];
ws.pageSetup = { orientation: 'landscape', fitToPage: true, fitToWidth: 1 };
const buffer = await wb.xlsx.writeBuffer();
const blob = new Blob([buffer], {
type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
});
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = `PPM_KPI_Report_${monthLabel}.xlsx`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
};
return (
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-sm hover:shadow-md transition-all p-4 border border-gray-200 dark:border-gray-700">
<div className="flex items-center justify-between mb-3">
<h3 className="text-sm font-semibold text-gray-800 dark:text-white">PPM KPI Report</h3>
<div className="flex items-center gap-1">
<button
onClick={() => onOpenDetail(handleExportExcel)}
className="p-1.5 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors"
title={t('dashboard.viewFullReport')}
>
<FaExternalLinkAlt className="w-3.5 h-3.5 text-gray-400" />
</button>
</div>
</div>
{!ppmKpiChartData || !ppmKpiChartData.datasets?.length ? (
<div className="h-48 flex items-center justify-center text-gray-400">
<div className="text-xs">No chart data available</div>
</div>
) : (
chartElement
)}
</div>
);
};
export default PpmKpiReportCard;
export { PpmKpiReportCard };
export type { PpmKpiReportCardProps };

View File

@ -0,0 +1,240 @@
import React, { useEffect, useId, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import { useNavigate } from 'react-router-dom';
import { Html5Qrcode } from 'html5-qrcode';
import { FaCamera, FaTimes } from 'react-icons/fa';
import { parseQrPayloadToAppPath } from '../utils/qrNavigation';
export interface QRScannerProps {
isOpen: boolean;
onClose: () => void;
/** Override default navigation; receive original QR text + parsed in-app path */
onScan?: (decodedText: string, appPath: string | null) => void;
title?: string;
/** Prefer rear camera on phones */
facingMode?: 'environment' | 'user';
}
/**
* Camera QR scanner modal. On success, navigates inside the SPA (including mobile webview)
* using React Router so users stay in the app instead of opening an external browser.
*/
const QRScanner: React.FC<QRScannerProps> = ({
isOpen,
onClose,
onScan,
title = 'Scan QR Code',
facingMode = 'environment',
}) => {
const navigate = useNavigate();
const reactId = useId();
const readerId = `qr-reader-${reactId.replace(/:/g, '')}`;
const scannerRef = useRef<Html5Qrcode | null>(null);
const handledRef = useRef(false);
const [error, setError] = useState<string | null>(null);
const [starting, setStarting] = useState(false);
const stopScanner = async () => {
const scanner = scannerRef.current;
scannerRef.current = null;
if (!scanner) return;
try {
if (scanner.isScanning) {
await scanner.stop();
}
scanner.clear();
} catch {
// ignore stop/clear errors when camera already released
}
};
const handleDecoded = async (decodedText: string) => {
if (handledRef.current) return;
handledRef.current = true;
const appPath = parseQrPayloadToAppPath(decodedText);
if (onScan) {
await stopScanner();
onClose();
onScan(decodedText, appPath);
return;
}
if (appPath) {
await stopScanner();
onClose();
// In-app navigation — works in mobile webview / PWA without leaving the app
navigate(appPath);
return;
}
handledRef.current = false;
setError(`Unrecognized QR code: ${decodedText}`);
};
useEffect(() => {
if (!isOpen) return;
let cancelled = false;
handledRef.current = false;
setError(null);
setStarting(true);
const start = async () => {
try {
await stopScanner();
if (cancelled) return;
const scanner = new Html5Qrcode(readerId);
scannerRef.current = scanner;
await scanner.start(
{ facingMode },
{
fps: 10,
qrbox: (viewfinderWidth, viewfinderHeight) => {
const edge = Math.min(viewfinderWidth, viewfinderHeight);
const size = Math.max(180, Math.floor(edge * 0.7));
return { width: size, height: size };
},
aspectRatio: 1,
},
(decodedText) => {
void handleDecoded(decodedText);
},
() => {
// ignore frame-level "not found" callbacks
}
);
} catch (err) {
console.error('QR scanner start failed:', err);
if (!cancelled) {
const message =
err instanceof Error ? err.message : 'Could not open camera';
setError(
message.includes('NotAllowedError') || message.toLowerCase().includes('permission')
? 'Camera permission denied. Please allow camera access and try again.'
: `Camera unavailable: ${message}`
);
}
} finally {
if (!cancelled) setStarting(false);
}
};
// Small delay so the reader DOM node is mounted
const timer = window.setTimeout(() => {
void start();
}, 100);
return () => {
cancelled = true;
window.clearTimeout(timer);
void stopScanner();
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isOpen, readerId, facingMode]);
if (!isOpen) return null;
return createPortal(
<div className="fixed inset-0 z-[10050] flex items-center justify-center bg-black/70 p-4">
<div className="w-full max-w-md overflow-hidden rounded-xl bg-white shadow-2xl dark:bg-gray-800">
<div className="flex items-center justify-between border-b border-gray-200 px-4 py-3 dark:border-gray-700">
<h3 className="flex items-center gap-2 text-base font-semibold text-gray-900 dark:text-white">
<FaCamera className="text-teal-600" />
{title}
</h3>
<button
type="button"
onClick={() => {
void stopScanner().then(onClose);
}}
className="rounded-lg p-2 text-gray-500 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-700"
aria-label="Close scanner"
>
<FaTimes />
</button>
</div>
<div className="space-y-3 p-4">
<p className="text-xs text-gray-500 dark:text-gray-400">
Point the camera at an asset QR code. You will stay inside the app.
</p>
{starting && !error && (
<p className="text-center text-sm text-teal-600 dark:text-teal-400">Starting camera…</p>
)}
{error && (
<div className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700 dark:border-red-800 dark:bg-red-900/20 dark:text-red-300">
{error}
</div>
)}
<div
id={readerId}
className="overflow-hidden rounded-lg bg-black [&_video]:max-h-[60vh] [&_video]:w-full [&_video]:object-cover"
/>
</div>
<div className="border-t border-gray-200 px-4 py-3 dark:border-gray-700">
<button
type="button"
onClick={() => {
void stopScanner().then(onClose);
}}
className="w-full rounded-lg bg-gray-200 px-4 py-2 text-sm font-medium text-gray-800 hover:bg-gray-300 dark:bg-gray-700 dark:text-gray-100 dark:hover:bg-gray-600"
>
Cancel
</button>
</div>
</div>
</div>,
document.body
);
};
export interface ScanQRButtonProps {
className?: string;
label?: string;
title?: string;
onScan?: (decodedText: string, appPath: string | null) => void;
}
/**
* Reusable Scan QR trigger + scanner modal. Use anywhere you need camera QR → in-app navigation.
*/
export const ScanQRButton: React.FC<ScanQRButtonProps> = ({
className,
label = 'Scan QR',
title,
onScan,
}) => {
const [isOpen, setIsOpen] = useState(false);
return (
<>
<button
type="button"
onClick={() => setIsOpen(true)}
className={
className ||
'bg-teal-600 hover:bg-teal-700 text-white px-4 py-2 rounded-lg flex items-center justify-center gap-2 shadow transition-all w-full sm:w-auto'
}
>
<FaCamera />
<span className="font-medium">{label}</span>
</button>
<QRScanner
isOpen={isOpen}
onClose={() => setIsOpen(false)}
onScan={onScan}
title={title}
/>
</>
);
};
export default QRScanner;

View File

@ -0,0 +1,286 @@
import React, { useEffect, useMemo, useState } from 'react';
import * as XLSX from 'xlsx';
import {
FaCheckSquare,
FaDownload,
FaFileExcel,
FaFileExport,
FaFilePdf,
FaSpinner,
FaSquare,
FaTimes,
} from 'react-icons/fa';
export interface QueryReportColumn {
fieldname: string;
label?: string;
}
export type QueryReportExportFormat = 'excel' | 'pdf';
export interface QueryReportExportModalProps {
isOpen: boolean;
onClose: () => void;
title?: string;
columns: QueryReportColumn[];
rows: Record<string, unknown>[];
fileNamePrefix?: string;
}
function formatCellValue(value: unknown): string {
if (value === null || value === undefined || value === '') return '';
return String(value);
}
function downloadExcel(
rows: Record<string, unknown>[],
columns: QueryReportColumn[],
fileName: string,
) {
const wsData = [
columns.map((c) => c.label || c.fieldname),
...rows.map((row) => columns.map((c) => formatCellValue(row[c.fieldname]))),
];
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);
}
function downloadPdf(
rows: Record<string, unknown>[],
columns: QueryReportColumn[],
reportTitle: string,
) {
const printWindow = window.open('', '_blank');
if (!printWindow) {
window.alert('Please allow popups for this site to export PDF.');
return;
}
const tableHTML = `
<!DOCTYPE html>
<html>
<head>
<title>${reportTitle}</title>
<style>
body { font-family: Arial, sans-serif; margin: 20px; }
h1 { text-align: center; color: #333; margin-bottom: 8px; font-size: 18px; }
.meta { text-align: center; color: #666; margin-bottom: 20px; font-size: 11px; }
table { width: 100%; border-collapse: collapse; font-size: 10px; }
th, td { border: 1px solid #ccc; padding: 6px 8px; text-align: left; vertical-align: top; }
th { background: #047857; color: white; }
tr:nth-child(even) { background: #f9fafb; }
@media print { body { margin: 0; } }
</style>
</head>
<body>
<h1>${reportTitle}</h1>
<div class="meta">
Generated on: ${new Date().toLocaleString()} | Total Records: ${rows.length}
</div>
<table>
<thead>
<tr>
${columns.map((c) => `<th>${c.label || c.fieldname}</th>`).join('')}
</tr>
</thead>
<tbody>
${rows.map((row) => `
<tr>
${columns.map((c) => `<td>${formatCellValue(row[c.fieldname]) || '—'}</td>`).join('')}
</tr>
`).join('')}
</tbody>
</table>
<script>window.onload = function() { window.print(); }</script>
</body>
</html>
`;
printWindow.document.write(tableHTML);
printWindow.document.close();
}
const QueryReportExportModal: React.FC<QueryReportExportModalProps> = ({
isOpen,
onClose,
title = 'Export Report',
columns,
rows,
fileNamePrefix = 'report',
}) => {
const [format, setFormat] = useState<QueryReportExportFormat>('excel');
const [checkedKeys, setCheckedKeys] = useState<Set<string>>(new Set());
const [isExporting, setIsExporting] = useState(false);
const columnOptions = useMemo(
() => columns.filter((c) => c.fieldname),
[columns],
);
useEffect(() => {
if (!isOpen) return;
setFormat('excel');
setCheckedKeys(new Set(columnOptions.map((c) => c.fieldname)));
}, [isOpen, columnOptions]);
if (!isOpen) return null;
const chosenColumns = columnOptions.filter((c) => checkedKeys.has(c.fieldname));
const canExport = rows.length > 0 && chosenColumns.length > 0;
const toggleColumn = (key: string) => {
setCheckedKeys((prev) => {
const next = new Set(prev);
if (next.has(key)) next.delete(key);
else next.add(key);
return next;
});
};
const selectAll = () => setCheckedKeys(new Set(columnOptions.map((c) => c.fieldname)));
const selectNone = () => setCheckedKeys(new Set());
const handleExport = async () => {
if (!canExport) return;
setIsExporting(true);
try {
const datePart = new Date().toISOString().split('T')[0];
if (format === 'excel') {
downloadExcel(rows, chosenColumns, `${fileNamePrefix}_${datePart}.xlsx`);
} else {
downloadPdf(rows, chosenColumns, title);
}
onClose();
} catch (err) {
console.error('Export failed:', err);
window.alert(`Export failed: ${err instanceof Error ? err.message : 'Unknown error'}`);
} finally {
setIsExporting(false);
}
};
return (
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-[80] p-4">
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-2xl w-full max-w-lg max-h-[90vh] flex flex-col overflow-hidden">
<div className="bg-gradient-to-r from-emerald-600 to-teal-600 px-5 py-4 flex items-center justify-between shrink-0">
<div className="flex items-center gap-2">
<FaFileExport className="text-white" />
<h3 className="text-base font-semibold text-white">{title}</h3>
</div>
<button
type="button"
onClick={onClose}
disabled={isExporting}
className="text-white/80 hover:text-white p-1 rounded-lg hover:bg-white/20"
>
<FaTimes />
</button>
</div>
<div className="p-5 space-y-5 overflow-y-auto flex-1">
<div>
<h4 className="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-2">File format</h4>
<div className="flex gap-3">
<label className={`flex-1 flex items-center gap-2 p-3 rounded-lg border cursor-pointer ${
format === 'excel'
? 'border-emerald-500 bg-emerald-50 dark:bg-emerald-900/20'
: 'border-gray-200 dark:border-gray-700'
}`}>
<input
type="radio"
name="wo_feedback_export_format"
checked={format === 'excel'}
onChange={() => setFormat('excel')}
className="text-emerald-600"
/>
<FaFileExcel className="text-green-700" />
<span className="text-sm font-medium text-gray-800 dark:text-gray-200">Excel (.xlsx)</span>
</label>
<label className={`flex-1 flex items-center gap-2 p-3 rounded-lg border cursor-pointer ${
format === 'pdf'
? 'border-emerald-500 bg-emerald-50 dark:bg-emerald-900/20'
: 'border-gray-200 dark:border-gray-700'
}`}>
<input
type="radio"
name="wo_feedback_export_format"
checked={format === 'pdf'}
onChange={() => setFormat('pdf')}
className="text-emerald-600"
/>
<FaFilePdf className="text-red-600" />
<span className="text-sm font-medium text-gray-800 dark:text-gray-200">PDF</span>
</label>
</div>
</div>
<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
</h4>
<div className="flex gap-3 text-xs text-emerald-600 dark:text-emerald-400">
<button type="button" onClick={selectAll} className="hover:underline">All</button>
<button type="button" onClick={selectNone} className="hover:underline">None</button>
</div>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 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">
{columnOptions.map((col) => {
const checked = checkedKeys.has(col.fieldname);
return (
<button
key={col.fieldname}
type="button"
onClick={() => toggleColumn(col.fieldname)}
className={`flex items-center gap-2 px-2 py-1.5 rounded text-left text-xs transition-colors ${
checked
? 'bg-emerald-100 dark:bg-emerald-900/30 text-emerald-800 dark:text-emerald-200'
: 'hover:bg-gray-100 dark:hover:bg-gray-800 text-gray-600 dark:text-gray-400'
}`}
>
{checked
? <FaCheckSquare size={13} className="text-emerald-600 shrink-0" />
: <FaSquare size={13} className="text-gray-300 shrink-0" />}
<span className="truncate" title={col.label || col.fieldname}>
{col.label || col.fieldname}
</span>
</button>
);
})}
</div>
<p className="text-xs text-gray-400 mt-1.5">
{checkedKeys.size} of {columnOptions.length} columns selected · {rows.length} row{rows.length !== 1 ? 's' : ''}
</p>
</div>
</div>
<div className="px-5 py-4 border-t border-gray-200 dark:border-gray-700 flex justify-end gap-3 shrink-0">
<button
type="button"
onClick={onClose}
disabled={isExporting}
className="px-4 py-2 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg"
>
Cancel
</button>
<button
type="button"
onClick={handleExport}
disabled={!canExport || isExporting}
className="px-4 py-2 text-sm font-medium text-white bg-emerald-600 hover:bg-emerald-700 rounded-lg flex items-center gap-2 disabled:opacity-50"
>
{isExporting ? (
<><FaSpinner className="animate-spin" size={14} /> Exporting…</>
) : (
<><FaDownload size={14} /> Export</>
)}
</button>
</div>
</div>
</div>
);
};
export default QueryReportExportModal;

View File

@ -0,0 +1,408 @@
/**
* 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',
},
],
},
// 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 — single field like Building; Company is set automatically on save.
'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',
},
],
},
// 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;

View File

@ -0,0 +1,708 @@
import React, { useState, useEffect, useCallback, useRef, useMemo } from 'react';
import {
FaTimes,
FaPlus,
FaSpinner,
FaCheckCircle,
FaTimesCircle,
FaExclamationTriangle,
FaSearch,
FaSave,
} from 'react-icons/fa';
import { toast } from 'react-toastify';
import {
type QuickCreateDoctypeConfig,
type QuickCreateFieldConfig,
getQuickCreateConfig
} from './QuickCreateConfig';
import apiService from '../services/apiService';
/** Stable empty object — do not use inline `{}` as default props (new reference every render). */
const EMPTY_RECORD: Record<string, any> = Object.freeze({});
// 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);
const filtersRef = useRef<Record<string, any>>(filters ?? EMPTY_RECORD);
filtersRef.current = filters ?? EMPTY_RECORD;
// Search function (filters via ref so parent can pass inline merges without resetting every render)
const searchLink = useCallback(async (text: string = '') => {
if (!doctype) return;
setIsLoading(true);
try {
const params = new URLSearchParams({
doctype,
txt: text,
page_length: '20',
});
const f = filtersRef.current;
if (f && Object.keys(f).length > 0) {
params.append('filters', JSON.stringify(f));
}
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]);
// 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) void searchLink(searchText);
}, [isOpen, searchLink]);
// 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
/** When set (e.g. Data Import / Location page), replaces footer "Create" label and icon */
submitLabel?: string;
submittingLabel?: string;
/** Replaces modal header title from config */
titleOverride?: string;
}
const QuickCreateModal: React.FC<QuickCreateModalProps> = ({
doctype,
isOpen,
onClose,
onSuccess,
initialValues,
parentFilters,
customConfig,
submitLabel,
submittingLabel,
titleOverride,
}) => {
const initialValuesKey = useMemo(() => {
if (initialValues === undefined) return '__undef__';
if (initialValues === null) return '__null__';
if (typeof initialValues === 'object' && Object.keys(initialValues).length === 0) return '__empty__';
try {
return JSON.stringify(initialValues);
} catch {
return '__nonserial__';
}
}, [initialValues]);
const initialValuesRef = useRef(initialValues);
initialValuesRef.current = initialValues;
const safeInitialValues = useMemo((): Record<string, any> => {
const v = initialValuesRef.current;
if (v == null) return EMPTY_RECORD;
if (typeof v === 'object' && Object.keys(v).length === 0) return EMPTY_RECORD;
return v;
}, [initialValuesKey]);
const parentFiltersKey = useMemo(() => {
if (parentFilters === undefined) return '__undef__';
if (parentFilters === null) return '__null__';
if (typeof parentFilters === 'object' && Object.keys(parentFilters).length === 0) return '__empty__';
try {
return JSON.stringify(parentFilters);
} catch {
return '__nonserial__';
}
}, [parentFilters]);
const parentFiltersRef = useRef(parentFilters);
parentFiltersRef.current = parentFilters;
const safeParentFilters = useMemo((): Record<string, any> => {
const v = parentFiltersRef.current;
if (v == null) return EMPTY_RECORD;
if (typeof v === 'object' && Object.keys(v).length === 0) return EMPTY_RECORD;
return v;
}, [parentFiltersKey]);
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, ...safeInitialValues });
}
}, [doctype, customConfig, safeInitialValues]);
// 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, ...safeInitialValues });
setErrors({});
}
}, [isOpen, config, safeInitialValues]);
// 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;
}
});
if (config.doctype === 'Department') {
try {
const res = await fetch('/api/resource/Company?fields=["name"]&limit_page_length=1', {
credentials: 'include',
});
if (res.ok) {
const json = (await res.json()) as { data?: Array<{ name?: string }> };
const cn = json.data?.[0]?.name;
if (cn && String(dataToSubmit.company ?? '').trim() === '') {
dataToSubmit.company = cn;
}
}
} catch {
/* ignore */
}
}
// Location: validate uniqueness by location_name (not document ID)
if (config.doctype === 'Location') {
const candidate = String(dataToSubmit.location_name ?? '').trim();
if (candidate) {
try {
const exists = await apiService.apiCall<Array<{ name: string }>>('/api/method/frappe.client.get_list', {
method: 'POST',
body: JSON.stringify({
doctype: 'Location',
fields: ['name'],
filters: [['Location', 'location_name', '=', candidate]],
limit_page_length: 1,
}),
});
if (Array.isArray(exists) && exists.length > 0) {
setErrors((prev) => ({ ...prev, location_name: 'Location name already exists.' }));
toast.error(`Location name "${candidate}" already exists. Choose a different name.`, {
position: 'top-right',
autoClose: 5000,
icon: <FaTimesCircle />,
});
return;
}
} catch {
// ignore: if pre-check fails, server will enforce.
}
}
}
// Make API call to create the record
const response = await apiService.apiCall<any>(
`/api/resource/${config.doctype}`,
{
method: 'POST',
body: JSON.stringify(dataToSubmit),
}
);
const newRecord =
response && typeof response === 'object' && 'data' in response && response.data
? response.data
: response && typeof response === 'object' && typeof (response as { name?: unknown }).name === 'string'
? response
: null;
if (newRecord && typeof (newRecord as { name?: unknown }).name === 'string') {
toast.success(`${config.title.replace('Create New ', '')} created successfully!`, {
position: 'top-right',
autoClose: 3000,
icon: <FaCheckCircle />,
});
if (config.afterCreate) {
config.afterCreate(newRecord);
}
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 || {}), ...safeParentFilters }}
/>
);
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} />
{titleOverride ?? 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} />
{submittingLabel ?? 'Creating...'}
</>
) : (
<>
{submitLabel ? <FaSave size={14} /> : <FaPlus size={14} />}
{submitLabel ?? 'Create'}
</>
)}
</button>
</div>
</div>
</div>
);
};
export default QuickCreateModal;

View File

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

View File

@ -0,0 +1,598 @@
import React, { useState, useEffect } from 'react';
import { Link, useLocation, useNavigate } from 'react-router-dom';
import { useLanguage } from '../contexts/LanguageContext';
import { useSidebarLayout } from '../contexts/SidebarLayoutContext';
import { useTranslation } from 'react-i18next';
import {
LayoutDashboard,
Package,
Box,
Menu,
X,
ClipboardList,
Calendar,
CalendarCheck,
Map,
Users,
ShoppingCart,
FileText,
HelpCircle,
UserCircle,
Trash2,
Upload
} from 'lucide-react';
import { FaClipboardCheck } from 'react-icons/fa';
import { getUserInfoWithRoles } from '../services/permissionService';
interface SidebarLink {
id: string;
title: string;
icon: React.ReactNode;
path: string;
visible: boolean;
}
interface SidebarProps {
userEmail?: string;
}
/** True when viewport is lg (1024px) or wider — desktop sidebar in-flow */
function useLgUp(): boolean {
const [lg, setLg] = useState(() =>
typeof window !== 'undefined' ? window.matchMedia('(min-width: 1024px)').matches : false
);
useEffect(() => {
const mq = window.matchMedia('(min-width: 1024px)');
const onChange = () => setLg(mq.matches);
mq.addEventListener('change', onChange);
return () => mq.removeEventListener('change', onChange);
}, []);
return lg;
}
// ✅ 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';
function deriveSidebarRoles(roles: string[]) {
const roleSet = new Set(roles);
return {
isAdmin: ADMIN_ROLES.some((role) => roleSet.has(role)),
isTechnician: roleSet.has(TECHNICIAN_ROLE),
isEndUser: roleSet.has(END_USER_ROLE),
isDRApprover: roleSet.has('DR Approver'),
};
}
const Sidebar: React.FC<SidebarProps> = ({ userEmail }) => {
const [isCollapsed, setIsCollapsed] = useState(false);
const location = useLocation();
const navigate = useNavigate();
const isLgUp = useLgUp();
const { closeMobileSidebar } = useSidebarLayout();
const { isRTL } = useLanguage();
const { t } = useTranslation();
const afterNav = () => {
if (!isLgUp) closeMobileSidebar();
};
// ✅ Role-based state
const [userRoles, setUserRoles] = useState<{
isAdmin: boolean;
isTechnician: boolean;
isEndUser: boolean;
isDRApprover: boolean;
isLoading: boolean;
}>({
isAdmin: false,
isTechnician: false,
isEndUser: false,
isDRApprover: 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 + full name in one request (cached in permissionService)
useEffect(() => {
let cancelled = false;
const fetchUserContext = async () => {
try {
const info = await getUserInfoWithRoles();
if (cancelled) return;
const derived = deriveSidebarRoles(info.roles);
setUserRoles({
...derived,
isLoading: false,
});
setUserFullName(info.full_name || info.user || userEmail || '');
} catch (error) {
console.error('Error fetching user roles:', error);
if (!cancelled) {
setUserRoles({
isAdmin: false,
isTechnician: false,
isEndUser: true,
isDRApprover: false,
isLoading: false,
});
if (userEmail) setUserFullName(userEmail);
}
}
};
fetchUserContext();
return () => { cancelled = true; };
}, [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 || userRoles.isTechnician
// 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: 'data-import',
title: t('sidebar.dataImport'),
icon: <Upload size={20} />,
path: '/data-import',
visible: userRoles.isDRApprover
},
{
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;
};
const handleUserProfileClick = () => {
navigate('/user-profile');
afterNav();
};
const sidebarWidthClass = isLgUp && isCollapsed ? 'w-16' : 'w-64';
const edgeBorderClass = isRTL
? 'border-l border-gray-200 dark:border-gray-700'
: 'border-r border-gray-200 dark:border-gray-700';
return (
<div
className={`
relative
h-screen
transition-all
duration-300
ease-in-out
flex
flex-col
shadow-xl
${edgeBorderClass}
${sidebarWidthClass}
`}
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
type="button"
onClick={() => {
if (isLgUp) setIsCollapsed(!isCollapsed);
else closeMobileSidebar();
}}
className="text-white dark:text-white hover:bg-white/20 dark:hover:bg-white/20 p-2 rounded-lg transition-colors"
aria-label={isLgUp ? (isCollapsed ? t('common.expandSidebar', { defaultValue: 'Expand sidebar' }) : t('common.collapseSidebar', { defaultValue: 'Collapse sidebar' })) : t('common.closeMenu', { defaultValue: 'Close menu' })}
>
{isLgUp ? (isCollapsed ? <Menu size={20} /> : <X size={20} />) : <X size={20} />}
</button>
</div>
{/* Navigation Links */}
<nav className="flex-1 overflow-y-auto py-4">
{userRoles.isLoading ? (
Array.from({ length: 6 }).map((_, index) => (
<div
key={`sidebar-skeleton-${index}`}
className={`mx-3 my-1.5 h-11 rounded-lg bg-white/10 animate-pulse ${isCollapsed ? 'mx-2' : ''}`}
/>
))
) : (
visibleLinks.map((link) => (
<Link
key={link.id}
to={link.path}
onClick={afterNav}
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 ${isRTL ? 'border-r-4' : '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`}> */}
<div className={`${isCollapsed ? 'p-1' : 'p-2'} border-t border-white/10 backdrop-blur-sm bg-white/5 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>
<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-[10px] text-white/50 dark:text-white/50 text-center leading-none">
{t('sidebar.version')}
</div>
)}
</div>
</div>
</div>
);
};
export default Sidebar;

View File

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

View File

@ -0,0 +1,626 @@
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;
}
interface TechnicianWorkOrderSummaryReportModalProps {
isOpen: boolean;
onClose: () => void;
permittedIssueTypes?: string[];
isAdmin?: boolean;
defaultWorkOrderType?: string;
defaultFromDate?: string;
defaultToDate?: string;
}
const TechnicianWorkOrderSummaryReportModal: React.FC<TechnicianWorkOrderSummaryReportModalProps> = ({
isOpen,
onClose,
permittedIssueTypes = [],
isAdmin = true,
defaultWorkOrderType = '',
defaultFromDate = '',
defaultToDate = ''
}) => {
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);
// Filter states — initialized from dashboard-level defaults
const [filterFromDate, setFilterFromDate] = useState(defaultFromDate);
const [filterToDate, setFilterToDate] = useState(defaultToDate);
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]);
// Sync date filters when dashboard-level defaults change
useEffect(() => { setFilterFromDate(defaultFromDate); }, [defaultFromDate]);
useEffect(() => { setFilterToDate(defaultToDate); }, [defaultToDate]);
// Auto-apply permitted Issue Type or dashboard global filter
useEffect(() => {
if (!isAdmin && permittedIssueTypes.length === 1) {
setFilterWorkOrderType(permittedIssueTypes[0]);
} else if (defaultWorkOrderType) {
setFilterWorkOrderType(defaultWorkOrderType);
} else {
setFilterWorkOrderType('');
}
}, [permittedIssueTypes, isAdmin, defaultWorkOrderType]);
const REPORT_NAME = 'Technician Work Order Summary';
const fetchReportData = useCallback(async () => {
setLoading(true);
setError(null);
try {
const filters: Record<string, any> = {};
// Date filters
if (filterFromDate) filters.from_date = filterFromDate;
if (filterToDate) filters.to_date = filterToDate;
// Work order type filters
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) {
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);
}
}, [filterFromDate, filterToDate, filterWorkOrderType, isAdmin, permittedIssueTypes]);
useEffect(() => {
if (isOpen) {
fetchReportData();
}
}, [isOpen, fetchReportData]);
useEffect(() => {
const handleEscape = (e: KeyboardEvent) => {
if (e.key === 'Escape' && isOpen) onClose();
};
document.addEventListener('keydown', handleEscape);
return () => document.removeEventListener('keydown', handleEscape);
}, [isOpen, onClose]);
useEffect(() => {
if (isOpen) {
document.body.style.overflow = 'hidden';
} else {
document.body.style.overflow = 'unset';
}
return () => { document.body.style.overflow = 'unset'; };
}, [isOpen]);
const handleClearFilters = () => {
setFilterFromDate('');
setFilterToDate('');
if (isAdmin) setFilterWorkOrderType('');
};
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 };
};
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);
}
};
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);
}
};
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; }
.count-cell { font-weight: 600; text-align: center; }
.completed { color: #10B981; }
.in-progress { color: #3B82F6; }
.open { color: #F59E0B; }
@media print { body { margin: 0; } tr { page-break-inside: avoid; } }
</style>
</head>
<body>
<h1>Technician Work Order Summary Report</h1>
<div class="meta">
Generated on: ${new Date().toLocaleString()}
${filterFromDate ? ` | From: ${filterFromDate}` : ''}
${filterToDate ? ` | To: ${filterToDate}` : ''}
${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 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();
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-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
{filterWorkOrderType && (
<span className="ml-2 bg-white/20 px-2 py-0.5 rounded-full text-xs">{filterWorkOrderType}</span>
)}
</p>
</div>
</div>
</div>
<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>
{/* 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>
</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">
<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-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">
{[filterFromDate, filterToDate, filterWorkOrderType].filter(Boolean).length}
</span>
)}
{filtersExpanded ? <FaChevronUp size={10} /> : <FaChevronDown size={10} />}
</button>
<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">
<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">
<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">
<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">
<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 */}
<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-purple-500 bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
/>
</div>
{/* To Date */}
<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-purple-500 bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
/>
</div>
{/* Technical Department */}
<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-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>
) : (
<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>
{(isAdmin ? allIssueTypes : permittedIssueTypes).map(type => (
<option key={type} value={type}>{type}</option>
))}
</select>
)}
</div>
{/* Clear Filters */}
<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 */}
<div className="flex-1 overflow-auto p-6">
{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 && !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>
)}
{!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>
)}
{!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' : ''}
{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;

View File

@ -0,0 +1,625 @@
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;
defaultFromDate?: string;
defaultToDate?: string;
}
const TechnicianWorkingHoursReportModal: React.FC<TechnicianWorkingHoursReportModalProps> = ({
isOpen,
onClose,
permittedIssueTypes = [],
isAdmin = true,
defaultWorkOrderType = '',
defaultFromDate = '',
defaultToDate = ''
}) => {
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 — initialized from dashboard-level defaults
const [filterFromDate, setFilterFromDate] = useState(defaultFromDate);
const [filterToDate, setFilterToDate] = useState(defaultToDate);
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]);
// Sync date filters when dashboard-level defaults change
useEffect(() => { setFilterFromDate(defaultFromDate); }, [defaultFromDate]);
useEffect(() => { setFilterToDate(defaultToDate); }, [defaultToDate]);
// 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';
const fetchReportData = useCallback(async () => {
setLoading(true);
setError(null);
try {
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) {
if (permittedIssueTypes.length === 1) {
filters.work_order_type = permittedIssueTypes[0];
}
}
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) {
if (result.message.columns && result.message.columns.length > 0) {
setColumns(result.message.columns);
}
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);
}
}, [filterFromDate, filterToDate, filterWorkOrderType, isAdmin, permittedIssueTypes]);
useEffect(() => {
if (isOpen) {
fetchReportData();
}
}, [isOpen, fetchReportData]);
useEffect(() => {
const handleEscape = (e: KeyboardEvent) => {
if (e.key === 'Escape' && isOpen) onClose();
};
document.addEventListener('keydown', handleEscape);
return () => document.removeEventListener('keydown', handleEscape);
}, [isOpen, onClose]);
useEffect(() => {
if (isOpen) {
document.body.style.overflow = 'hidden';
} else {
document.body.style.overflow = 'unset';
}
return () => { document.body.style.overflow = 'unset'; };
}, [isOpen]);
const handleClearFilters = () => {
setFilterFromDate('');
setFilterToDate('');
if (isAdmin) setFilterWorkOrderType('');
};
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 };
};
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_working_hours_${new Date().toISOString().split('T')[0]}.csv`;
link.click();
URL.revokeObjectURL(url);
} finally {
setIsExporting(false);
}
};
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, 'Technician Hours');
XLSX.writeFile(workbook, `technician_working_hours_${new Date().toISOString().split('T')[0]}.xlsx`);
} finally {
setIsExporting(false);
}
};
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; }
.hours-cell { font-weight: 600; color: #0891B2; }
@media print { body { margin: 0; } tr { page-break-inside: avoid; } }
</style>
</head>
<body>
<h1>Technicians Working Hours Report</h1>
<div class="meta">
Generated on: ${new Date().toLocaleString()}
${filterFromDate ? ` | From: ${filterFromDate}` : ''}
${filterToDate ? ` | To: ${filterToDate}` : ''}
${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 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 = col.fieldname === 'total_hours' ? 'hours-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 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">
<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>
<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>
{/* 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">
<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>
<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">
<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">
<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">
<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">
<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 */}
<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 */}
<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 */}
<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 ? allIssueTypes : permittedIssueTypes).map(type => (
<option key={type} value={type}>{type}</option>
))}
</select>
)}
</div>
{/* Clear Filters */}
<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 */}
<div className="flex-1 overflow-auto p-6">
{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 && !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>
)}
{!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>
)}
{!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);
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>
);
}
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>
);
}
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>
);
}
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;

View File

@ -0,0 +1,259 @@
import React, { useEffect, useState } from 'react';
import { FaCheckCircle, FaSpinner } from 'react-icons/fa';
import woFeedbackWebFormService, {
type WoFeedbackRecord,
type WoFeedbackWebFormConfig,
} from '../services/woFeedbackWebFormService';
export interface WoFeedbackFormProps {
workOrder: string;
defaultRequesterName?: string;
compact?: boolean;
onSuccess?: (record: WoFeedbackRecord) => void;
}
type FormPhase = 'loading' | 'form' | 'existing' | 'success' | 'error';
const inputCls =
'w-full 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 focus:outline-none focus:ring-2 focus:ring-indigo-400';
const WoFeedbackForm: React.FC<WoFeedbackFormProps> = ({
workOrder,
defaultRequesterName = '',
compact = false,
onSuccess,
}) => {
const [phase, setPhase] = useState<FormPhase>('loading');
const [config, setConfig] = useState<WoFeedbackWebFormConfig | null>(null);
const [existing, setExisting] = useState<WoFeedbackRecord | null>(null);
const [requesterName, setRequesterName] = useState(defaultRequesterName);
const [rating, setRating] = useState('');
const [comments, setComments] = useState('');
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState('');
useEffect(() => {
setRequesterName(defaultRequesterName);
}, [defaultRequesterName]);
useEffect(() => {
if (!workOrder) {
setPhase('error');
setError('Work Order is required.');
return;
}
let cancelled = false;
(async () => {
setPhase('loading');
setError('');
try {
const [cfg, fb] = await Promise.all([
woFeedbackWebFormService.getWebFormConfig(),
woFeedbackWebFormService.getFeedbackForWorkOrder(workOrder),
]);
if (cancelled) return;
setConfig(cfg);
if (fb) {
setExisting(fb);
setPhase('existing');
} else {
setPhase('form');
}
} catch (e) {
if (cancelled) return;
setError(e instanceof Error ? e.message : 'Failed to load feedback form');
setPhase('error');
}
})();
return () => { cancelled = true; };
}, [workOrder]);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!requesterName.trim()) {
setError('Requester Name is required.');
return;
}
if (!rating) {
setError('Please select a rating.');
return;
}
setSubmitting(true);
setError('');
try {
const saved = await woFeedbackWebFormService.submitFeedback({
work_order: workOrder,
requester_name: requesterName.trim(),
rating,
comments: comments.trim(),
});
setExisting(saved);
setPhase('success');
onSuccess?.(saved);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to submit feedback');
} finally {
setSubmitting(false);
}
};
if (phase === 'loading') {
return (
<div className={`flex items-center justify-center gap-2 text-gray-500 ${compact ? 'py-4' : 'py-10'}`}>
<FaSpinner className="animate-spin text-indigo-500" />
<span className="text-sm">Loading feedback form…</span>
</div>
);
}
if (phase === 'error' && !config) {
return (
<div className="rounded-lg border border-red-200 bg-red-50 dark:bg-red-900/20 dark:border-red-800 px-4 py-3 text-sm text-red-700 dark:text-red-300">
{error || 'Unable to load feedback form.'}
</div>
);
}
const title = config?.title || 'WO Feedback';
if (phase === 'existing' && existing) {
return (
<div className={compact ? 'space-y-3' : 'space-y-4'}>
{!compact && config?.introductionText && (
<p className="text-sm text-gray-600 dark:text-gray-400">{config.introductionText}</p>
)}
<div className="rounded-lg border border-green-200 dark:border-green-800 bg-green-50 dark:bg-green-900/20 p-4 text-center">
<FaCheckCircle className="mx-auto text-green-600 mb-2" />
<p className="text-sm font-semibold text-green-800 dark:text-green-300">Feedback already submitted</p>
<p className="text-xs text-green-700 dark:text-green-400 mt-2">
<span className="font-medium">Requester:</span> {existing.requester_name || '—'}
</p>
<p className="text-sm font-bold text-green-800 dark:text-green-200 mt-1">
{existing.rating || '—'}
</p>
{existing.comments && (
<p className="text-xs text-green-700 dark:text-green-400 mt-2 text-left">
<span className="font-medium">Comments:</span> {existing.comments}
</p>
)}
</div>
</div>
);
}
if (phase === 'success' && existing) {
return (
<div className={`rounded-lg border border-green-200 dark:border-green-800 bg-green-50 dark:bg-green-900/20 p-4 text-center ${compact ? '' : 'py-8'}`}>
<FaCheckCircle className="mx-auto text-green-600 text-2xl mb-3" />
<h3 className="text-base font-bold text-green-800 dark:text-green-200">
{config?.successTitle || 'Feedback Submitted'}
</h3>
<p className="text-sm text-green-700 dark:text-green-300 mt-1">
{config?.successMessage || 'Your feedback was submitted successfully.'}
</p>
<p className="text-xs text-green-600 dark:text-green-400 mt-3">
{existing.rating}
</p>
{existing.comments && (
<p className="text-xs text-green-600 dark:text-green-400 mt-2">
{existing.comments}
</p>
)}
</div>
);
}
return (
<form onSubmit={handleSubmit} className={compact ? 'space-y-3' : 'space-y-4'}>
{!compact && (
<div>
<h3 className="text-base font-semibold text-gray-900 dark:text-white">{title}</h3>
{config?.introductionText && (
<p className="text-sm text-gray-600 dark:text-gray-400 mt-1">{config.introductionText}</p>
)}
</div>
)}
{compact && config?.introductionText && (
<p className="text-xs text-gray-500 dark:text-gray-400">{config.introductionText}</p>
)}
<div>
<label className="block text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase mb-1">
Work Order
</label>
<div className="px-3 py-2 text-sm bg-gray-100 dark:bg-gray-900/40 border border-gray-200 dark:border-gray-700 rounded-lg text-gray-800 dark:text-gray-200">
{workOrder}
</div>
</div>
<div>
<label className="block text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase mb-1">
Requester Name <span className="text-red-500">*</span>
</label>
<input
type="text"
value={requesterName}
onChange={e => setRequesterName(e.target.value)}
className={inputCls}
placeholder="Your name"
required
/>
</div>
<div>
<label className="block text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase mb-1">
Rating <span className="text-red-500">*</span>
</label>
<select
value={rating}
onChange={e => setRating(e.target.value)}
className={inputCls}
required
>
<option value="">Select rating…</option>
{(config?.ratingOptions || []).map(opt => (
<option key={opt} value={opt}>{opt}</option>
))}
</select>
</div>
<div>
<label className="block text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase mb-1">
Comments
</label>
<textarea
value={comments}
onChange={e => setComments(e.target.value)}
className={`${inputCls} min-h-[88px] resize-y`}
placeholder="Optional comments…"
rows={3}
/>
</div>
{error && (
<p className="text-sm text-red-600 dark:text-red-400">{error}</p>
)}
<button
type="submit"
disabled={submitting}
className="w-full flex items-center justify-center gap-2 px-4 py-2.5 bg-indigo-600 hover:bg-indigo-700 text-white rounded-lg text-sm font-medium disabled:opacity-50"
>
{submitting ? (
<>
<FaSpinner className="animate-spin" size={14} />
Submitting…
</>
) : (
config?.buttonLabel || 'Save'
)}
</button>
</form>
);
};
export default WoFeedbackForm;

View File

@ -0,0 +1,279 @@
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { FaExternalLinkAlt, FaFileExport, FaStar, FaTimes } from 'react-icons/fa';
import LinkField from './LinkField';
import QueryReportExportModal from './QueryReportExportModal';
import { WO_FEEDBACK_RATING_OPTIONS } from '../utils/woFeedbackChartUtils';
interface WoFeedbackReportModalProps {
isOpen: boolean;
onClose: () => void;
}
interface AppliedFilters {
work_order: string;
requester_name: string;
rating: string;
}
const EMPTY_FILTERS: AppliedFilters = { work_order: '', requester_name: '', rating: '' };
const WoFeedbackReportModal: React.FC<WoFeedbackReportModalProps> = ({ isOpen, onClose }) => {
const [reportData, setReportData] = useState<any>(null);
const [loading, setLoading] = useState(false);
const [refreshing, setRefreshing] = useState(false);
const [workOrder, setWorkOrder] = useState('');
const [requesterNameDraft, setRequesterNameDraft] = useState('');
const [rating, setRating] = useState('');
const [appliedFilters, setAppliedFilters] = useState<AppliedFilters>(EMPTY_FILTERS);
const [showExportModal, setShowExportModal] = useState(false);
const hasLoadedOnce = useRef(false);
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
if (!isOpen) {
setWorkOrder('');
setRequesterNameDraft('');
setRating('');
setAppliedFilters(EMPTY_FILTERS);
setReportData(null);
setLoading(false);
setRefreshing(false);
hasLoadedOnce.current = false;
setShowExportModal(false);
if (debounceRef.current) clearTimeout(debounceRef.current);
}
}, [isOpen]);
useEffect(() => {
if (!isOpen) return;
if (debounceRef.current) clearTimeout(debounceRef.current);
debounceRef.current = setTimeout(() => {
setAppliedFilters({
work_order: workOrder.trim(),
requester_name: requesterNameDraft.trim(),
rating,
});
}, 450);
return () => {
if (debounceRef.current) clearTimeout(debounceRef.current);
};
}, [isOpen, workOrder, requesterNameDraft, rating]);
const fetchReport = useCallback(async (filters: AppliedFilters) => {
const isRefresh = hasLoadedOnce.current;
if (isRefresh) setRefreshing(true);
else setLoading(true);
try {
const payload: Record<string, string> = {};
if (filters.work_order) payload.work_order = filters.work_order;
if (filters.requester_name) payload.requester_name = filters.requester_name;
if (filters.rating) payload.rating = filters.rating;
const res = await fetch('/api/method/frappe.desk.query_report.run', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({
report_name: 'WO feedback',
filters: payload,
ignore_prepared_report: 1,
}),
});
const data = await res.json();
setReportData(data.message || null);
hasLoadedOnce.current = true;
} catch (e) {
console.error('WO feedback report error:', e);
if (!isRefresh) setReportData(null);
} finally {
setLoading(false);
setRefreshing(false);
}
}, []);
useEffect(() => {
if (!isOpen) return;
fetchReport(appliedFilters);
}, [isOpen, appliedFilters, fetchReport]);
if (!isOpen) return null;
const columns: any[] = reportData?.columns || [];
const rows: any[] = (reportData?.result || []).filter(
(r: any) => r && typeof r === 'object' && !r.is_total_row,
);
const hasActiveFilter = Boolean(workOrder.trim() || requesterNameDraft.trim() || rating);
const showInitialLoader = loading && !hasLoadedOnce.current;
const showEmpty = !showInitialLoader && rows.length === 0;
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-xl shadow-2xl max-w-5xl w-full max-h-[90vh] overflow-hidden flex flex-col">
<div className="bg-gradient-to-r from-emerald-600 to-teal-600 px-6 py-4 flex items-center justify-between shrink-0">
<div className="flex items-center gap-3">
<FaStar className="text-white text-xl" />
<h3 className="text-lg font-semibold text-white">WO Feedback Report</h3>
</div>
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => setShowExportModal(true)}
disabled={showInitialLoader || rows.length === 0}
className="inline-flex items-center gap-1.5 px-3 py-1.5 text-sm font-medium text-emerald-700 bg-white hover:bg-emerald-50 rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
<FaFileExport size={12} /> Export
</button>
<button
type="button"
onClick={onClose}
className="text-white/80 hover:text-white p-1 rounded-lg hover:bg-white/20 transition-colors"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
</div>
<div className="px-6 py-3 border-b border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-900/30 shrink-0">
<div className="grid grid-cols-1 md:grid-cols-3 gap-3">
<div>
<label className="block text-xs font-medium text-gray-500 mb-1">Work Order</label>
<LinkField
label="Work Order"
hideLabel
doctype="Work_Order"
value={workOrder}
onChange={setWorkOrder}
placeholder="Filter by work order…"
compact
/>
</div>
<div>
<label className="block text-xs font-medium text-gray-500 mb-1">Requester Name</label>
<input
type="text"
value={requesterNameDraft}
onChange={e => setRequesterNameDraft(e.target.value)}
placeholder="Filter by requester…"
className="w-full 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 focus:outline-none focus:ring-2 focus:ring-emerald-500"
/>
</div>
<div>
<label className="block text-xs font-medium text-gray-500 mb-1">Rating</label>
<select
value={rating}
onChange={e => setRating(e.target.value)}
className="w-full 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 focus:outline-none focus:ring-2 focus:ring-emerald-500"
>
<option value="">All ratings</option>
{WO_FEEDBACK_RATING_OPTIONS.map(opt => (
<option key={opt} value={opt}>{opt}</option>
))}
</select>
</div>
</div>
{hasActiveFilter && (
<button
type="button"
onClick={() => {
setWorkOrder('');
setRequesterNameDraft('');
setRating('');
}}
className="mt-3 text-xs text-red-500 hover:text-red-700 flex items-center gap-1 px-2 py-1 rounded-lg hover:bg-red-50 dark:hover:bg-red-900/20 transition-colors"
>
<FaTimes size={10} /> Clear filters
</button>
)}
</div>
<div className="p-6 overflow-auto flex-1 relative min-h-[240px]">
{showInitialLoader ? (
<div className="flex items-center justify-center py-16">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-emerald-600" />
</div>
) : showEmpty ? (
<div className="text-center text-gray-400 py-16">No feedback records found</div>
) : (
<div className={`overflow-x-auto transition-opacity duration-200 ${refreshing ? 'opacity-60' : 'opacity-100'}`}>
{refreshing && (
<div className="absolute top-6 right-6 z-10">
<div className="animate-spin rounded-full h-5 w-5 border-2 border-emerald-600 border-t-transparent" />
</div>
)}
<table className="w-full text-sm border-collapse">
<thead>
<tr className="border-b border-gray-200 dark:border-gray-700">
{columns.map((col: any, i: number) => (
<th
key={i}
className="py-3 px-4 font-semibold text-gray-700 dark:text-gray-300 bg-gray-50 dark:bg-gray-900/50 text-left"
>
{col.label || col.fieldname}
</th>
))}
</tr>
</thead>
<tbody>
{rows.map((row: any, ri: number) => (
<tr
key={ri}
className="border-b border-gray-100 dark:border-gray-700/50 hover:bg-emerald-50/40 dark:hover:bg-emerald-900/10 transition-colors"
>
{columns.map((col: any, ci: number) => {
const fieldLower = (col.fieldname || '').toLowerCase();
const isWoCol = fieldLower === 'work_order';
const cellVal = row[col.fieldname] ?? '—';
const woId = isWoCol ? String(cellVal) : '';
const isClickable = isWoCol && woId && woId !== '—';
return (
<td
key={ci}
className={`py-2.5 px-4 align-top ${
isClickable
? 'cursor-pointer text-emerald-600 dark:text-emerald-400 font-semibold hover:underline'
: 'text-gray-800 dark:text-gray-200'
}`}
onClick={isClickable
? () => { window.location.href = `/asm_app/work-orders/${encodeURIComponent(woId)}`; }
: undefined}
title={isClickable ? `Open Work Order ${woId}` : undefined}
>
{isClickable ? (
<span className="inline-flex items-center gap-1.5">
{cellVal}
<FaExternalLinkAlt size={9} className="opacity-50 shrink-0" />
</span>
) : (
cellVal
)}
</td>
);
})}
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
</div>
<QueryReportExportModal
isOpen={showExportModal}
onClose={() => setShowExportModal(false)}
title="WO Feedback Report"
columns={columns}
rows={rows}
fileNamePrefix="wo_feedback_report"
/>
</div>
);
};
export default WoFeedbackReportModal;

View File

@ -0,0 +1,69 @@
import React, { useEffect, useState } from 'react';
import { FaStar } from 'react-icons/fa';
import woFeedbackWebFormService, { type WoFeedbackRecord } from '../services/woFeedbackWebFormService';
export interface WoFeedbackSummaryProps {
workOrder: string;
}
const WoFeedbackSummary: React.FC<WoFeedbackSummaryProps> = ({ workOrder }) => {
const [loading, setLoading] = useState(true);
const [feedback, setFeedback] = useState<WoFeedbackRecord | null>(null);
useEffect(() => {
if (!workOrder) {
setLoading(false);
setFeedback(null);
return;
}
let cancelled = false;
(async () => {
setLoading(true);
try {
const fb = await woFeedbackWebFormService.getFeedbackForWorkOrder(workOrder);
if (!cancelled) setFeedback(fb);
} catch {
if (!cancelled) setFeedback(null);
} finally {
if (!cancelled) setLoading(false);
}
})();
return () => { cancelled = true; };
}, [workOrder]);
if (loading || !feedback?.rating) return null;
return (
<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">
WO Feedback
</h2>
<div className="flex items-start gap-3">
<FaStar className="text-amber-500 mt-0.5 shrink-0" />
<div>
<p className="text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase mb-1">
Rating
</p>
<p className="text-sm font-semibold text-gray-900 dark:text-white">
{feedback.rating}
</p>
{feedback.requester_name && (
<p className="text-xs text-gray-500 dark:text-gray-400 mt-2">
<span className="font-medium">Requester:</span> {feedback.requester_name}
</p>
)}
{feedback.comments && (
<p className="text-xs text-gray-500 dark:text-gray-400 mt-2">
<span className="font-medium">Comments:</span> {feedback.comments}
</p>
)}
</div>
</div>
</div>
);
};
export default WoFeedbackSummary;

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,206 @@
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;
hiddenActions?: string[];
}
const WorkflowActions: React.FC<WorkflowActionsProps> = ({
doctype,
docname,
workflowState,
onActionComplete,
onStateChange,
showStateInfo = true,
className = '',
hiddenActions = [],
}) => {
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) => {
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');
// Filter out hidden actions
const visibleTransitions = transitions.filter(
(t) => !hiddenActions.includes(t.action)
);
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 && visibleTransitions.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">
{visibleTransitions.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">
{visibleTransitions.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 && visibleTransitions.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;

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

@ -0,0 +1,110 @@
// 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',
DASHBOARD_WO_METRICS: '/api/method/asset_lite.api.dashboard_api.get_dashboard_work_order_metrics',
DASHBOARD_ASSET_STATUS: '/api/method/asset_lite.api.dashboard_api.get_asset_device_status_summary',
DASHBOARD_MAINTENANCE_INSIGHTS: '/api/method/asset_lite.api.dashboard_api.get_maintenance_insights',
// 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',
GET_WORK_ORDER_FEEDBACK: '/api/method/asset_lite.api.work_order_api.get_work_order_feedback',
SAVE_WORK_ORDER_FEEDBACK: '/api/method/asset_lite.api.work_order_api.save_work_order_feedback',
// Asset Maintenance Management
GET_ASSET_MAINTENANCE_LOGS: '/api/method/asset_lite.api.asset_maintenance_api.get_asset_maintenance_logs',
GET_ASSET_MAINTENANCE_LOG_DETAILS: '/api/method/asset_lite.api.asset_maintenance_api.get_asset_maintenance_log_details',
CREATE_ASSET_MAINTENANCE_LOG: '/api/method/asset_lite.api.asset_maintenance_api.create_asset_maintenance_log',
UPDATE_ASSET_MAINTENANCE_LOG: '/api/method/asset_lite.api.asset_maintenance_api.update_asset_maintenance_log',
DELETE_ASSET_MAINTENANCE_LOG: '/api/method/asset_lite.api.asset_maintenance_api.delete_asset_maintenance_log',
UPDATE_MAINTENANCE_STATUS: '/api/method/asset_lite.api.asset_maintenance_api.update_maintenance_status',
GET_MAINTENANCE_LOGS_BY_ASSET: '/api/method/asset_lite.api.asset_maintenance_api.get_maintenance_logs_by_asset',
GET_OVERDUE_MAINTENANCE_LOGS: '/api/method/asset_lite.api.asset_maintenance_api.get_overdue_maintenance_logs',
// PPM (Asset Maintenance) Management
GET_ASSET_MAINTENANCES: '/api/method/asset_lite.api.ppm_api.get_asset_maintenances',
GET_ASSET_MAINTENANCE_DETAILS: '/api/method/asset_lite.api.ppm_api.get_asset_maintenance_details',
CREATE_ASSET_MAINTENANCE: '/api/method/asset_lite.api.ppm_api.create_asset_maintenance',
UPDATE_ASSET_MAINTENANCE: '/api/method/asset_lite.api.ppm_api.update_asset_maintenance',
DELETE_ASSET_MAINTENANCE: '/api/method/asset_lite.api.ppm_api.delete_asset_maintenance',
GET_MAINTENANCE_TASKS: '/api/method/asset_lite.api.ppm_api.get_maintenance_tasks',
GET_SERVICE_COVERAGE: '/api/method/asset_lite.api.ppm_api.get_service_coverage',
GET_MAINTENANCES_BY_ASSET: '/api/method/asset_lite.api.ppm_api.get_maintenances_by_asset',
GET_ACTIVE_SERVICE_CONTRACTS: '/api/method/asset_lite.api.ppm_api.get_active_service_contracts',
// Authentication
LOGIN: '/api/method/login',
LOGOUT: '/api/method/logout',
CSRF_TOKEN: '/api/method/frappe.sessions.get_csrf_token',
// File Upload
UPLOAD_FILE: '/api/method/upload_file',
// User Permission Management - Generic (only these are needed!)
GET_USER_PERMISSIONS: '/api/method/asset_lite.api.userperm_api.get_user_permissions',
GET_PERMISSION_FILTERS: '/api/method/asset_lite.api.userperm_api.get_permission_filters',
GET_ALLOWED_VALUES: '/api/method/asset_lite.api.userperm_api.get_allowed_values',
CHECK_DOCUMENT_ACCESS: '/api/method/asset_lite.api.userperm_api.check_document_access',
GET_CONFIGURED_DOCTYPES: '/api/method/asset_lite.api.userperm_api.get_configured_doctypes',
GET_USER_DEFAULTS: '/api/method/asset_lite.api.userperm_api.get_user_defaults',
},
// Request Configuration
DEFAULT_HEADERS: {
'Content-Type': 'application/json',
'Accept': 'application/json'
},
// Timeout settings - increased for debugging
TIMEOUT: parseInt(import.meta.env.VITE_API_TIMEOUT || '60000'),
};
export default API_CONFIG;

View File

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

View File

@ -0,0 +1,37 @@
import React, { createContext, useContext, useState, useCallback, useMemo } from 'react';
type SidebarLayoutContextValue = {
mobileOpen: boolean;
setMobileOpen: (open: boolean) => void;
openMobileSidebar: () => void;
closeMobileSidebar: () => void;
};
const SidebarLayoutContext = createContext<SidebarLayoutContextValue | null>(null);
export const SidebarLayoutProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const [mobileOpen, setMobileOpen] = useState(false);
const openMobileSidebar = useCallback(() => setMobileOpen(true), []);
const closeMobileSidebar = useCallback(() => setMobileOpen(false), []);
const value = useMemo(
() => ({
mobileOpen,
setMobileOpen,
openMobileSidebar,
closeMobileSidebar,
}),
[mobileOpen, openMobileSidebar, closeMobileSidebar]
);
return <SidebarLayoutContext.Provider value={value}>{children}</SidebarLayoutContext.Provider>;
};
export function useSidebarLayout(): SidebarLayoutContextValue {
const ctx = useContext(SidebarLayoutContext);
if (!ctx) {
throw new Error('useSidebarLayout must be used within SidebarLayoutProvider');
}
return ctx;
}

View File

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

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

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

View File

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

View File

@ -0,0 +1,334 @@
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 };
}
export interface AssetPmCounts {
planned: number;
completed: number;
overdue: number;
}
/**
* Hook to fetch PM status counts for a specific asset
*/
export function useAssetPmCounts(assetName: string | null) {
const [counts, setCounts] = useState<AssetPmCounts>({ planned: 0, completed: 0, overdue: 0 });
const [loading, setLoading] = useState(false);
const fetchCounts = useCallback(async () => {
if (!assetName || assetName === 'new') {
setCounts({ planned: 0, completed: 0, overdue: 0 });
setLoading(false);
return;
}
try {
setLoading(true);
const [plannedRes, completedRes, overdueRes] = await Promise.all([
assetMaintenanceService.getMaintenanceLogsByAsset(assetName, { maintenance_status: 'Planned' }, 1, 0),
assetMaintenanceService.getMaintenanceLogsByAsset(assetName, { maintenance_status: 'Completed' }, 1, 0),
assetMaintenanceService.getMaintenanceLogsByAsset(assetName, { maintenance_status: 'Overdue' }, 1, 0),
]);
setCounts({
planned: plannedRes.total_count,
completed: completedRes.total_count,
overdue: overdueRes.total_count,
});
} catch {
setCounts({ planned: 0, completed: 0, overdue: 0 });
} finally {
setLoading(false);
}
}, [assetName]);
useEffect(() => {
fetchCounts();
}, [fetchCounts]);
return { ...counts, loading, refetch: fetchCounts };
}

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

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

View File

@ -0,0 +1,223 @@
// 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') || userRoles.includes('DR Approver'))
// return 'Contractor Supervisor';
// if (userRoles.includes('DR Approver')) return 'DR Approver';
if (userRoles.includes('Contractor Supervisor')) return 'Contractor Supervisor';
if (userRoles.includes('DR Approver')) return 'DR Approver';
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') {
if (role === 'Contractor Supervisor' || role === 'DR Approver') {
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' || role === 'DR Approver') {
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';
if (role === 'Contractor Supervisor' || role === 'DR Approver') 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 };

View File

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

View File

@ -0,0 +1,157 @@
/**
* 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',
]),
'Asset Maintenance Log': new Set([
'name', 'asset_name', 'task_name', 'maintenance_type', 'maintenance_status',
'due_date', 'assign_to_name', 'workflow_state', 'custom_hospital_name', 'creation',
]),
};
// ── 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 };
}

View File

@ -0,0 +1,34 @@
import { useState, useEffect } from 'react';
export type DrApproverState = 'loading' | 'yes' | 'no';
/**
* True when the current session user has the **DR Approver** role (via asset_lite API).
*/
export function useDrApproverRole(): DrApproverState {
const [state, setState] = useState<DrApproverState>('loading');
useEffect(() => {
let cancelled = false;
(async () => {
try {
const res = await fetch('/api/method/asset_lite.api.user_roles.check_has_role', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ roles: 'DR Approver' }),
});
const data = await res.json();
if (cancelled) return;
setState(data.message?.has_role ? 'yes' : 'no');
} catch {
if (!cancelled) setState('no');
}
})();
return () => {
cancelled = true;
};
}, []);
return state;
}

View File

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

View File

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

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

View File

@ -0,0 +1,195 @@
import { useState, useEffect, useCallback, useRef } from 'react';
import itemService from '../services/itemService';
import type { Item, CreateItemData } from '../services/itemService';
export interface ItemFilters {
item_code?: string;
item_name?: string;
item_group?: string;
custom_hospital_name?: string;
disabled?: number;
is_stock_item?: number;
[key: string]: any;
}
/**
* Hook to fetch list of items with filters and pagination
*/
export function useItems(
filters?: ItemFilters,
limit: number = 20,
offset: number = 0,
orderBy?: string
) {
const [items, setItems] = useState<Item[]>([]);
const [totalCount, setTotalCount] = useState(0);
const [hasMore, setHasMore] = useState(false);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [refetchTrigger, setRefetchTrigger] = useState(0);
const hasAttemptedRef = useRef(false);
const filtersJson = JSON.stringify(filters);
useEffect(() => {
// if (hasAttemptedRef.current && error) {
// return;
// }
let isCancelled = false;
// hasAttemptedRef.current = true;
const fetchItems = async () => {
try {
setLoading(true);
const fields = ['name', 'item_code', 'item_name', 'item_group', 'stock_uom', 'disabled', 'is_stock_item', '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 };
}

View File

@ -0,0 +1,165 @@
import { useState, useEffect, useCallback, useRef } from 'react';
import locationService, {
type LocationDoc,
type LocationListParams,
type SaveLocationPayload,
} from '../services/locationService';
export const useLocationList = (params: LocationListParams) => {
const [locations, setLocations] = useState<LocationDoc[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [totalCount, setTotalCount] = useState(0);
const filtersKey = JSON.stringify(params.filters ?? {});
const orFiltersKey = JSON.stringify(params.or_filters ?? []);
const paramsRef = useRef(params);
paramsRef.current = params;
useEffect(() => {
let cancelled = false;
(async () => {
try {
setLoading(true);
setError(null);
const p = paramsRef.current;
const response = await locationService.getLocations(p);
if (!cancelled) setLocations(response.data);
const count = await locationService.getLocationCount({
filters: p.filters,
or_filters: p.or_filters,
});
if (!cancelled) setTotalCount(count);
} catch (err) {
if (!cancelled) {
setError(err instanceof Error ? err.message : 'Failed to fetch locations');
}
} finally {
if (!cancelled) setLoading(false);
}
})();
return () => {
cancelled = true;
};
}, [
params.limit_start,
params.limit_page_length,
params.order_by,
filtersKey,
orFiltersKey,
]);
const refetch = useCallback(async () => {
const p = paramsRef.current;
try {
setLoading(true);
setError(null);
const response = await locationService.getLocations(p);
setLocations(response.data);
const count = await locationService.getLocationCount({
filters: p.filters,
or_filters: p.or_filters,
});
setTotalCount(count);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to fetch locations');
} finally {
setLoading(false);
}
}, []);
return { locations, loading, error, totalCount, refetch };
};
export const useLocationDetail = (name: string | null) => {
const [doc, setDoc] = useState<LocationDoc | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const fetchDoc = useCallback(async () => {
if (!name) {
setDoc(null);
return;
}
try {
setLoading(true);
setError(null);
const data = await locationService.getLocation(name);
setDoc(data);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load location');
setDoc(null);
} finally {
setLoading(false);
}
}, [name]);
useEffect(() => {
fetchDoc();
}, [fetchDoc]);
return { doc, loading, error, refetch: fetchDoc };
};
export const useLocationMutations = () => {
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const createLocation = async (data: SaveLocationPayload): Promise<LocationDoc> => {
try {
setLoading(true);
setError(null);
return await locationService.createLocation(data);
} catch (err) {
const msg = err instanceof Error ? err.message : 'Failed to create';
setError(msg);
throw err;
} finally {
setLoading(false);
}
};
const updateLocation = async (name: string, data: Partial<SaveLocationPayload>): Promise<LocationDoc> => {
try {
setLoading(true);
setError(null);
return await locationService.updateLocation(name, data);
} catch (err) {
const msg = err instanceof Error ? err.message : 'Failed to save';
setError(msg);
throw err;
} finally {
setLoading(false);
}
};
const renameLocation = useCallback(async (oldName: string, newName: string) => {
try {
setLoading(true);
setError(null);
await locationService.renameLocation(oldName, newName);
} catch (err) {
const msg = err instanceof Error ? err.message : 'Failed to rename';
setError(msg);
throw err;
} finally {
setLoading(false);
}
}, []);
const deleteLocation = useCallback(async (name: string) => {
try {
setLoading(true);
setError(null);
await locationService.deleteLocation(name);
} catch (err) {
const msg = err instanceof Error ? err.message : 'Failed to delete';
setError(msg);
throw err;
} finally {
setLoading(false);
}
}, []);
return { createLocation, updateLocation, renameLocation, deleteLocation, loading, error };
};

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

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

View File

@ -0,0 +1,495 @@
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;
/** Present when the DocType / custom field stores it; otherwise UI may derive a count from dates. */
no_of_pms?: number | string;
next_pm_date?: string;
asset_name?: string;
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;
asset_name?: string;
manufacturer?: string;
model?: 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,
// Only request fields that exist on the site DocType (unknown fields make frappe.get_all fail).
fields: JSON.stringify([
'name',
'asset_name',
'hospital',
'modality',
'manufacturer',
'periodicity',
'start_date',
'end_date',
'due_date',
'docstatus',
'maintenance_team',
'assign_to',
]),
}),
}
);
if (!isCancelled) {
// apiCall already unwraps Frappe's top-level `message`
const data = response?.message ?? response;
if (data?.error) {
setError(typeof data.error === 'string' ? data.error : 'Failed to load PM schedules');
setPMSchedules([]);
setTotalCount(0);
setHasMore(false);
return;
}
if (data && Array.isArray(data.pm_schedules)) {
const schedules = data.pm_schedules;
setPMSchedules(schedules);
setTotalCount(data.total_count || 0);
setHasMore(data.has_more || false);
} else {
console.warn('[usePMSchedules] Unexpected response shape:', response);
setPMSchedules([]);
setTotalCount(0);
setHasMore(false);
}
}
} catch (err) {
if (!isCancelled) {
console.error('Error fetching PM Schedules:', err);
setError(err instanceof Error ? err.message : 'Failed to fetch PM Schedules');
setPMSchedules([]);
setTotalCount(0);
}
} finally {
if (!isCancelled) {
setLoading(false);
}
}
};
fetchPMSchedules();
return () => {
isCancelled = true;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [filtersJson, permissionFiltersJson, limit, offset, orderBy, refetchTrigger]);
const refetch = useCallback(() => {
setRefetchTrigger(prev => prev + 1);
}, []);
return {
pmSchedules,
totalCount,
hasMore,
loading,
error,
refetch
};
}
// Hook for fetching single PM Schedule details
export function usePMScheduleDetails(pmScheduleName: string | null) {
const [pmSchedule, setPMSchedule] = useState<PMSchedule | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const fetchPMSchedule = useCallback(async () => {
if (!pmScheduleName) {
setPMSchedule(null);
setLoading(false);
return;
}
setLoading(true);
setError(null);
try {
const response = await apiService.apiCall<any>(
'/api/method/asset_lite.api.ppm_generator_api.get_pm_schedule_details',
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ pm_schedule_name: pmScheduleName })
}
);
console.log('[usePMScheduleDetails] API Response:', response);
// apiService.apiCall already unwraps the 'message' property
// So response is directly the PM Schedule data OR an error object
if (response && response.name && !response.error) {
console.log('[usePMScheduleDetails] Setting PM Schedule:', response);
setPMSchedule(response);
} else {
const errorMsg = response?.error || 'PM Schedule not found';
console.warn('[usePMScheduleDetails] Error or not found:', errorMsg);
setError(errorMsg);
setPMSchedule(null);
}
} catch (err) {
console.error('Error fetching PM Schedule details:', err);
setError(err instanceof Error ? err.message : 'Failed to fetch PM Schedule');
setPMSchedule(null);
} finally {
setLoading(false);
}
}, [pmScheduleName]);
useEffect(() => {
fetchPMSchedule();
}, [fetchPMSchedule]);
return {
pmSchedule,
loading,
error,
refetch: fetchPMSchedule
};
}
// Hook for PM Schedule mutations (create, update, delete, submit, cancel)
export function usePMScheduleMutations() {
const [loading, setLoading] = useState(false);
const createPMSchedule = async (data: CreatePMScheduleData): Promise<PMSchedule> => {
setLoading(true);
try {
const response = await apiService.apiCall<any>(
'/api/method/asset_lite.api.ppm_generator_api.create_pm_schedule',
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ pm_schedule_data: JSON.stringify(data) })
}
);
// apiService.apiCall already unwraps the 'message' property
if (response?.success) {
return response.pm_schedule;
} else {
throw new Error(response?.error || 'Failed to create PM Schedule');
}
} finally {
setLoading(false);
}
};
const updatePMSchedule = async (name: string, data: Partial<CreatePMScheduleData>): Promise<PMSchedule> => {
setLoading(true);
try {
const response = await apiService.apiCall<any>(
'/api/method/asset_lite.api.ppm_generator_api.update_pm_schedule',
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
pm_schedule_name: name,
pm_schedule_data: JSON.stringify(data)
})
}
);
if (response?.success) {
return response.pm_schedule;
} else {
throw new Error(response?.error || 'Failed to update PM Schedule');
}
} finally {
setLoading(false);
}
};
const deletePMSchedule = async (name: string): Promise<void> => {
setLoading(true);
try {
const response = await apiService.apiCall<any>(
'/api/method/asset_lite.api.ppm_generator_api.delete_pm_schedule',
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ pm_schedule_name: name })
}
);
if (!response?.success) {
throw new Error(response?.error || 'Failed to delete PM Schedule');
}
} finally {
setLoading(false);
}
};
const submitPMSchedule = async (name: string): Promise<PMSchedule> => {
setLoading(true);
try {
const response = await apiService.apiCall<any>(
'/api/method/asset_lite.api.ppm_generator_api.submit_pm_schedule',
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ pm_schedule_name: name })
}
);
if (response?.success) {
return response.pm_schedule;
} else {
throw new Error(response?.error || 'Failed to submit PM Schedule');
}
} finally {
setLoading(false);
}
};
const cancelPMSchedule = async (name: string): Promise<PMSchedule> => {
setLoading(true);
try {
const response = await apiService.apiCall<any>(
'/api/method/asset_lite.api.ppm_generator_api.cancel_pm_schedule',
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ pm_schedule_name: name })
}
);
if (response?.success) {
return response.pm_schedule;
} else {
throw new Error(response?.error || 'Failed to cancel PM Schedule');
}
} finally {
setLoading(false);
}
};
const addMaintenanceEntry = async (pmScheduleName: string, entryData: Partial<PMEntryLine>): Promise<PMEntryLine[]> => {
setLoading(true);
try {
const response = await apiService.apiCall<any>(
'/api/method/asset_lite.api.ppm_generator_api.add_maintenance_entry',
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
pm_schedule_name: pmScheduleName,
entry_data: JSON.stringify(entryData)
})
}
);
if (response?.success) {
return response.maintenance_entries;
} else {
throw new Error(response?.error || 'Failed to add maintenance entry');
}
} finally {
setLoading(false);
}
};
const removeMaintenanceEntry = async (pmScheduleName: string, entryName: string): Promise<PMEntryLine[]> => {
setLoading(true);
try {
const response = await apiService.apiCall<any>(
'/api/method/asset_lite.api.ppm_generator_api.remove_maintenance_entry',
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
pm_schedule_name: pmScheduleName,
entry_name: entryName
})
}
);
if (response?.success) {
return response.maintenance_entries;
} else {
throw new Error(response?.error || 'Failed to remove maintenance entry');
}
} finally {
setLoading(false);
}
};
const updateMaintenanceEntry = async (
pmScheduleName: string,
entryName: string,
entryData: Partial<PMEntryLine>
): Promise<PMEntryLine[]> => {
setLoading(true);
try {
const response = await apiService.apiCall<any>(
'/api/method/asset_lite.api.ppm_generator_api.update_maintenance_entry',
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
pm_schedule_name: pmScheduleName,
entry_name: entryName,
entry_data: JSON.stringify(entryData)
})
}
);
if (response?.success) {
return response.maintenance_entries;
} else {
throw new Error(response?.error || 'Failed to update maintenance entry');
}
} finally {
setLoading(false);
}
};
return {
createPMSchedule,
updatePMSchedule,
deletePMSchedule,
submitPMSchedule,
cancelPMSchedule,
addMaintenanceEntry,
removeMaintenanceEntry,
updateMaintenanceEntry,
loading
};
}
export default {
usePMSchedules,
usePMScheduleDetails,
usePMScheduleMutations
};

View File

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

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

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

View File

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

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

View File

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

View File

@ -0,0 +1,415 @@
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[][]
) {
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);
const mergedFilters = mergeFilters(filters, permissionFilters);
const rows = await workOrderService.fetchWorkOrderListPage(
mergedFilters,
undefined,
limit,
offset,
orderBy,
orFilters
);
if (!isCancelled) {
setWorkOrders(rows);
setHasMore(rows.length === limit);
setError(null);
setLoading(false);
}
const total = await workOrderService.fetchWorkOrderCount(mergedFilters, orFilters);
if (!isCancelled) {
setTotalCount(total || rows.length);
setHasMore(offset + limit < (total || rows.length));
}
} catch (err) {
if (!isCancelled) {
const errorMessage = err instanceof Error ? err.message : 'Failed to fetch work orders';
if (errorMessage.includes('417') || errorMessage.includes('Expectation Failed') || errorMessage.includes('has no attribute')) {
setError('API endpoint not deployed or misconfigured. Please check FIX_417_ERROR.md for solutions.');
} else {
setError(errorMessage);
}
setWorkOrders([]);
setTotalCount(0);
setHasMore(false);
setLoading(false);
}
}
};
fetchWorkOrders();
return () => {
isCancelled = true;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [filtersJson, permissionFiltersJson, orFiltersJson, limit, offset, orderBy, refetchTrigger]);
const refetch = useCallback(() => {
hasAttemptedRef.current = false;
setRefetchTrigger(prev => prev + 1);
}, []);
return { workOrders, totalCount, hasMore, loading, error, refetch };
}
/**
* Hook to fetch a single work order by name
*/
export function useWorkOrderDetails(workOrderName: string | null) {
const [workOrder, setWorkOrder] = useState<WorkOrder | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const fetchWorkOrder = useCallback(async () => {
if (!workOrderName) {
setWorkOrder(null);
setLoading(false);
return;
}
try {
setLoading(true);
setError(null);
const data = await workOrderService.getWorkOrderDetails(workOrderName);
setWorkOrder(data);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to fetch work order details');
} finally {
setLoading(false);
}
}, [workOrderName]);
useEffect(() => {
fetchWorkOrder();
}, [fetchWorkOrder]);
const refetch = useCallback(() => {
fetchWorkOrder();
}, [fetchWorkOrder]);
return { workOrder, loading, error, refetch };
}
/**
* Hook to manage work order operations (create, update, delete)
*/
export function useWorkOrderMutations() {
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const createWorkOrder = async (workOrderData: CreateWorkOrderData) => {
try {
setLoading(true);
setError(null);
console.log('[useWorkOrderMutations] Creating work order with data:', workOrderData);
const response = await workOrderService.createWorkOrder(workOrderData);
console.log('[useWorkOrderMutations] Create work order response:', response);
if (response.success) {
return response.work_order;
} else {
// Include the backend error message if available
const backendError = (response as any).error || 'Failed to create work order';
throw new Error(backendError);
}
} catch (err) {
console.error('[useWorkOrderMutations] Create work order error:', err);
const errorMessage = err instanceof Error ? err.message : 'Failed to create work order';
setError(errorMessage);
throw err;
} finally {
setLoading(false);
}
};
const updateWorkOrder = async (workOrderName: string, workOrderData: Partial<CreateWorkOrderData>) => {
try {
setLoading(true);
setError(null);
console.log('[useWorkOrderMutations] Updating work order:', workOrderName, 'with data:', workOrderData);
const response = await workOrderService.updateWorkOrder(workOrderName, workOrderData);
console.log('[useWorkOrderMutations] Update work order response:', response);
if (response.success) {
return response.work_order;
} else {
// Include the backend error message if available
const backendError = (response as any).error || 'Failed to update work order';
throw new Error(backendError);
}
} catch (err) {
console.error('[useWorkOrderMutations] Update work order error:', err);
const errorMessage = err instanceof Error ? err.message : 'Failed to update work order';
setError(errorMessage);
throw err;
} finally {
setLoading(false);
}
};
const deleteWorkOrder = async (workOrderName: string) => {
try {
setLoading(true);
setError(null);
const response = await workOrderService.deleteWorkOrder(workOrderName);
if (!response.success) {
throw new Error('Failed to delete work order');
}
return response;
} catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Failed to delete work order';
setError(errorMessage);
throw err;
} finally {
setLoading(false);
}
};
const submitWorkOrder = async (workOrderName: string) => {
try {
setLoading(true);
setError(null);
console.log('[useWorkOrderMutations] Submitting work order:', workOrderName);
const response = await workOrderService.submitWorkOrder(workOrderName);
console.log('[useWorkOrderMutations] Submit work order response:', response);
return response;
} catch (err) {
console.error('[useWorkOrderMutations] Submit work order error:', err);
const errorMessage = err instanceof Error ? err.message : 'Failed to submit work order';
setError(errorMessage);
throw err;
} finally {
setLoading(false);
}
};
const updateStatus = async (workOrderName: string, repairStatus?: string, workflowState?: string) => {
try {
setLoading(true);
setError(null);
const response = await workOrderService.updateWorkOrderStatus(workOrderName, repairStatus, workflowState);
if (response.success) {
return response.work_order;
} else {
throw new Error('Failed to update work order status');
}
} catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Failed to update status';
setError(errorMessage);
throw err;
} finally {
setLoading(false);
}
};
return { createWorkOrder, updateWorkOrder, deleteWorkOrder, submitWorkOrder, updateStatus, loading, error };
}
/**
* Hook to fetch work order filter options
*/
export function useWorkOrderFilters() {
const [filters, setFilters] = useState<any | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const fetchFilters = useCallback(async () => {
try {
setLoading(true);
setError(null);
const data = await workOrderService.getWorkOrderFilters();
setFilters(data);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to fetch filters');
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
fetchFilters();
}, [fetchFilters]);
const refetch = useCallback(() => {
fetchFilters();
}, [fetchFilters]);
return { filters, loading, error, refetch };
}
/**
* Hook to fetch work order statistics
*/
export function useWorkOrderStats() {
const [stats, setStats] = useState<any | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const fetchStats = useCallback(async () => {
try {
setLoading(true);
setError(null);
const data = await workOrderService.getWorkOrderStats();
setStats(data);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to fetch statistics');
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
fetchStats();
}, [fetchStats]);
const refetch = useCallback(() => {
fetchStats();
}, [fetchStats]);
return { stats, loading, error, refetch };
}
/**
* Hook for work order search
*/
export function useWorkOrderSearch() {
const [results, setResults] = useState<WorkOrder[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const search = useCallback(async (searchTerm: string, limit: number = 10) => {
if (!searchTerm.trim()) {
setResults([]);
return;
}
try {
setLoading(true);
setError(null);
const data = await workOrderService.searchWorkOrders(searchTerm, limit);
setResults(data);
} catch (err) {
setError(err instanceof Error ? err.message : 'Search failed');
setResults([]);
} finally {
setLoading(false);
}
}, []);
const clearResults = useCallback(() => {
setResults([]);
setError(null);
}, []);
return { results, loading, error, search, clearResults };
}

View File

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

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

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

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

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

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

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

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

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

@ -0,0 +1,839 @@
import React, { useMemo, useState, useCallback, useRef, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { toast, ToastContainer, Bounce } from 'react-toastify';
import 'react-toastify/dist/ReactToastify.css';
import {
FaDownload,
FaFileAlt,
FaSpinner,
FaCheckCircle,
FaTimesCircle,
FaPlus,
FaListUl,
FaTimes,
} from 'react-icons/fa';
import { useDoctypeFields } from '../hooks/useDoctypeFields';
import { useDocTypeMeta } from '../hooks/useDocTypeMeta';
import DataImportTemplateModal from '../components/DataImportTemplateModal';
import type { TemplateFileFormat } from '../components/DataImportTemplateModal';
import {
SKIP_FIELD_TYPES,
SKIP_FIELD_NAMES,
parseLocationFile,
exportLocationBlankTemplate,
validateLocationHeaders,
rewriteImportFileFirstRowToFieldnames,
resolveDepartmentLinksInImportFile,
resolveBuildingLinksInImportFile,
stageDataImportWithPreview,
runFrappeDataImportFromExisting,
hasBlockingImportWarnings,
isBlockingImportWarning,
parseMissingLinkValues,
linkDoctypeFromFrappeMissingValuesMessage,
insertMissingLinkMasters,
type ImportRowLog,
type ImportPreviewWarning,
type ImportPreviewColumn,
type StagedImportPreview,
} from '../services/dataImportService';
import { htmlToPlainText } from '../utils/htmlToPlainText';
/**
* UI label remains "Location", but the actual master doctype for import/template is "Infrastructure Location".
* This keeps the user-facing wording stable while importing the correct doctype.
*/
const DISPLAY_IMPORT_DOCTYPE = 'Location';
const IMPORT_TARGET_DOCTYPE = 'Infrastructure Location';
/** Same display names in modal, template header row, and import header validation. */
const INFRA_LOCATION_LABELS: Record<string, string> = {
building: 'Building',
room_no: 'Room No',
department: 'Department',
location: 'Location',
};
const FALLBACK_FIELD_LIST: Array<{ key: string; label: string }> = [
{ key: 'location_name', label: 'Location Name' },
{ key: 'company', label: 'Company' },
{ key: 'parent_location', label: 'Parent Location' },
{ key: 'is_group', label: 'Is Group' },
];
const DataImport: React.FC = () => {
const { t } = useTranslation();
const navigate = useNavigate();
const { fields: exportFields, loading: fieldsLoading } = useDoctypeFields(IMPORT_TARGET_DOCTYPE);
const { fields: metaFields, loading: metaLoading } = useDocTypeMeta(IMPORT_TARGET_DOCTYPE);
const [templateModalOpen, setTemplateModalOpen] = useState(false);
const [file, setFile] = useState<File | null>(null);
const [importing, setImporting] = useState(false);
const [progressLabel, setProgressLabel] = useState<string | null>(null);
const [logs, setLogs] = useState<ImportRowLog[] | null>(null);
const [previewLoading, setPreviewLoading] = useState(false);
const [previewWarnings, setPreviewWarnings] = useState<ImportPreviewWarning[]>([]);
const [previewColumns, setPreviewColumns] = useState<ImportPreviewColumn[]>([]);
const [stagedPreview, setStagedPreview] = useState<StagedImportPreview | null>(null);
const [headerPreviewIssues, setHeaderPreviewIssues] = useState<ImportRowLog[] | null>(null);
const [previewRunError, setPreviewRunError] = useState<string | null>(null);
const [addingMastersCol, setAddingMastersCol] = useState<string | null>(null);
const [importMappingNotes, setImportMappingNotes] = useState<string[]>([]);
/** True when validation finished but the workbook could not be replaced with the aligned copy (see banner). */
const [alignedWorkbookNotApplied, setAlignedWorkbookNotApplied] = useState(false);
const previewGenRef = useRef(0);
/** After Add master we `setFile` with the resolved workbook; skip re-running the pipeline in `useEffect` (already ran). */
const skipNextFilePipelineRef = useRef(false);
/** Latest `file` for async pipeline completion (avoid applying a stale mapped file after clear/replace). */
const fileRef = useRef<File | null>(null);
/** Reuse this Data Import doc on the next preview upload instead of creating new records each time. */
const lastStagedDataImportNameRef = useRef<string | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const metaByName = useMemo(() => {
const m = new Map<string, { reqd: number; read_only: number }>();
metaFields.forEach((f) => {
m.set(f.fieldname, { reqd: f.reqd, read_only: f.read_only });
});
return m;
}, [metaFields]);
const importableColumns = useMemo(() => {
const list = exportFields
.filter(
(f) =>
!SKIP_FIELD_TYPES.has(f.fieldtype) &&
!f.key.startsWith('_') &&
!SKIP_FIELD_NAMES.has(f.key) &&
f.key !== 'name'
)
.map((f) => {
const m = metaByName.get(f.key);
return {
key: f.key,
label: INFRA_LOCATION_LABELS[f.key] ?? f.label ?? f.key,
fieldtype: f.fieldtype,
mandatory: m?.reqd === 1,
readOnly: m?.read_only === 1,
};
})
.filter((c) => !c.readOnly);
if (list.length > 0) {
return list.map(({ key, label, mandatory }) => ({ key, label, mandatory }));
}
return FALLBACK_FIELD_LIST.map((f) => ({ ...f, mandatory: false }));
}, [exportFields, metaByName]);
const resolverFields = useMemo(() => {
if (exportFields.length > 0) {
return exportFields
.filter((f) => !SKIP_FIELD_TYPES.has(f.fieldtype) && !f.key.startsWith('_') && f.key !== 'name')
.map((f) => ({ key: f.key, label: INFRA_LOCATION_LABELS[f.key] ?? f.label ?? f.key }));
}
return FALLBACK_FIELD_LIST;
}, [exportFields]);
const schemaLoading = fieldsLoading || metaLoading;
useEffect(() => {
fileRef.current = file;
}, [file]);
const importSummary = useMemo(() => {
if (!logs || logs.length === 0) return null;
const total = logs.length;
const ok = logs.filter((r) => r.success).length;
return { total, ok };
}, [logs]);
const blockingPreviewIssues = useMemo(() => {
if (headerPreviewIssues && headerPreviewIssues.length > 0) return true;
return hasBlockingImportWarnings(previewWarnings);
}, [headerPreviewIssues, previewWarnings]);
const getColumnForWarning = useCallback((col: number | string | undefined) => {
if (col === undefined || col === '') return undefined;
const idx = typeof col === 'string' ? parseInt(col, 10) : Number(col);
if (Number.isNaN(idx)) return undefined;
return previewColumns[idx];
}, [previewColumns]);
const getLinkedDoctypeForWarning = useCallback(
(w: ImportPreviewWarning): string | undefined => {
const colMeta = getColumnForWarning(w.col);
const fromCol =
colMeta?.df?.fieldtype === 'Link' && colMeta.df.options ? colMeta.df.options : undefined;
if (fromCol) return fromCol;
return linkDoctypeFromFrappeMissingValuesMessage(w.message);
},
[getColumnForWarning]
);
const previewWarningsSorted = useMemo(() => {
return [...previewWarnings].sort((a, b) => {
const na = typeof a.col === 'number' ? a.col : parseInt(String(a.col ?? ''), 10);
const nb = typeof b.col === 'number' ? b.col : parseInt(String(b.col ?? ''), 10);
if (Number.isNaN(na) && Number.isNaN(nb)) return 0;
if (Number.isNaN(na)) return 1;
if (Number.isNaN(nb)) return -1;
return na - nb;
});
}, [previewWarnings]);
const executePreviewPipeline = useCallback(
async (
sourceFile: File,
linkPreferred?: {
preferredDepartmentNames?: Record<string, string>;
preferredBuildingNames?: Record<string, string>;
}
): Promise<File | null> => {
const gen = ++previewGenRef.current;
setPreviewLoading(true);
setPreviewRunError(null);
setHeaderPreviewIssues(null);
setStagedPreview(null);
setPreviewWarnings([]);
setPreviewColumns([]);
setImportMappingNotes([]);
setProgressLabel(null);
try {
const { headersOrdered, rows: rawRows } = await parseLocationFile(sourceFile);
if (!rawRows.length) {
if (gen !== previewGenRef.current) return null;
setPreviewRunError(t('dataImport.errors.noRows'));
return null;
}
if (exportFields.length === 0 && !fieldsLoading) {
if (gen !== previewGenRef.current) return null;
setPreviewRunError(t('dataImport.errors.noSchema'));
return null;
}
const headerIssues = validateLocationHeaders(headersOrdered, resolverFields);
if (headerIssues.length > 0) {
if (gen !== previewGenRef.current) return null;
setHeaderPreviewIssues(
headerIssues.map((issue) => ({
rowIndex: issue.rowIndex,
success: false,
message: issue.message,
}))
);
return null;
}
const fileForImport = await rewriteImportFileFirstRowToFieldnames(sourceFile, resolverFields);
const fileDept = await resolveDepartmentLinksInImportFile(fileForImport, resolverFields, {
preferredNames: linkPreferred?.preferredDepartmentNames,
});
const buildingResult = await resolveBuildingLinksInImportFile(fileDept, resolverFields, {
preferredNames: linkPreferred?.preferredBuildingNames,
});
if (gen !== previewGenRef.current) return null;
setImportMappingNotes(buildingResult.mappingMessages);
const staged = await stageDataImportWithPreview(buildingResult.file, {
referenceDoctype: IMPORT_TARGET_DOCTYPE,
importType: 'Insert New Records',
onProgress: (label) => setProgressLabel(label),
reuseDataImportName: lastStagedDataImportNameRef.current,
});
if (gen !== previewGenRef.current) return null;
lastStagedDataImportNameRef.current = staged.dataImportName;
setStagedPreview(staged);
setPreviewWarnings(staged.warnings);
setPreviewColumns(staged.columns);
return buildingResult.file;
} catch (err) {
if (gen !== previewGenRef.current) return null;
console.error(err);
const msg = err instanceof Error ? err.message : String(err);
if (msg === 'EMPTY_WORKBOOK') {
setPreviewRunError(t('dataImport.errors.noRows'));
return null;
}
setPreviewRunError(htmlToPlainText(msg).slice(0, 500));
return null;
} finally {
if (gen === previewGenRef.current) {
setPreviewLoading(false);
setProgressLabel(null);
}
}
},
[exportFields.length, fieldsLoading, resolverFields, t]
);
useEffect(() => {
if (!file) {
setPreviewLoading(false);
setStagedPreview(null);
setPreviewWarnings([]);
setPreviewColumns([]);
setHeaderPreviewIssues(null);
setPreviewRunError(null);
setImportMappingNotes([]);
setAlignedWorkbookNotApplied(false);
lastStagedDataImportNameRef.current = null;
return;
}
if (schemaLoading) return;
if (skipNextFilePipelineRef.current) {
skipNextFilePipelineRef.current = false;
return;
}
void (async () => {
const snapshot = file;
const out = await executePreviewPipeline(snapshot);
if (out && fileRef.current === snapshot) {
skipNextFilePipelineRef.current = true;
setAlignedWorkbookNotApplied(false);
setFile(out);
return;
}
if (!out && fileRef.current === snapshot) {
setAlignedWorkbookNotApplied(true);
if (import.meta.env.DEV) {
console.debug('[DataImport] Aligned workbook not applied: pipeline returned null (fix errors or retry).');
}
} else {
setAlignedWorkbookNotApplied(false);
}
})();
}, [file, schemaLoading, executePreviewPipeline]);
const handleExportTemplate = useCallback(
(selectedKeys: string[], format: TemplateFileFormat) => {
const cols = importableColumns.filter((c) => selectedKeys.includes(c.key));
if (cols.length === 0) {
toast.error(t('dataImport.templateModal.nothingSelected'), { icon: <FaTimesCircle /> });
return;
}
exportLocationBlankTemplate(
cols.map((c) => ({ key: c.key, label: c.label })),
format,
'location_import_template'
);
toast.success(t('dataImport.templateDownloaded'), { icon: <FaCheckCircle /> });
setTemplateModalOpen(false);
},
[importableColumns, t]
);
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const f = e.target.files?.[0];
setAlignedWorkbookNotApplied(false);
lastStagedDataImportNameRef.current = null;
setFile(f ?? null);
setLogs(null);
};
const clearSelectedFile = () => {
setFile(null);
setLogs(null);
setImportMappingNotes([]);
setAlignedWorkbookNotApplied(false);
lastStagedDataImportNameRef.current = null;
if (fileInputRef.current) fileInputRef.current.value = '';
};
const handleDownloadCurrentImportFile = useCallback(() => {
if (!file) return;
const url = URL.createObjectURL(file);
const a = document.createElement('a');
a.href = url;
a.download = file.name || 'import.xlsx';
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(url);
}, [file]);
const handleAddMastersForWarning = async (w: ImportPreviewWarning) => {
const linkedDoctype = getLinkedDoctypeForWarning(w);
if (!linkedDoctype) return;
const values = parseMissingLinkValues(w.message);
if (!values?.length) return;
setAddingMastersCol(`${String(w.col ?? '')}:${linkedDoctype}`);
try {
const { created, errors, resolvedNamesByInput } = await insertMissingLinkMasters(
linkedDoctype,
values
);
if (created > 0) {
toast.success(t('dataImport.mastersCreated', { count: created }), { icon: <FaCheckCircle /> });
}
if (errors.length > 0) {
toast.warning(errors.slice(0, 4).join(' · '), { icon: <FaTimesCircle /> });
}
if (file) {
const preferredDepartmentNames = linkedDoctype === 'Department' ? resolvedNamesByInput : undefined;
const preferredBuildingNames = linkedDoctype === 'Building' ? resolvedNamesByInput : undefined;
const mappedFile = await executePreviewPipeline(file, {
preferredDepartmentNames,
preferredBuildingNames,
});
if (mappedFile) {
skipNextFilePipelineRef.current = true;
setAlignedWorkbookNotApplied(false);
setFile(mappedFile);
} else {
setAlignedWorkbookNotApplied(true);
if (import.meta.env.DEV) {
console.debug('[DataImport] Add masters: pipeline returned null; aligned file not applied.');
}
}
}
} catch (err) {
console.error(err);
const msg = err instanceof Error ? err.message : String(err);
toast.error(htmlToPlainText(msg).slice(0, 400), { icon: <FaTimesCircle /> });
} finally {
setAddingMastersCol(null);
}
};
const handleImport = async () => {
if (!file) {
toast.error(t('dataImport.errors.noFile'), { icon: <FaTimesCircle /> });
return;
}
if (!stagedPreview?.dataImportName) {
toast.error(t('dataImport.errors.previewNotReady'), { icon: <FaTimesCircle /> });
return;
}
if (blockingPreviewIssues) {
toast.error(t('dataImport.errors.fixPreviewFirst'), { icon: <FaTimesCircle /> });
return;
}
setImporting(true);
setProgressLabel(null);
setLogs(null);
try {
const result = await runFrappeDataImportFromExisting(stagedPreview.dataImportName, {
onProgress: (label) => setProgressLabel(label),
});
setLogs(result.logs);
const fail = result.logs.filter((r) => !r.success).length;
if (result.finalStatus === 'Success' && fail === 0) {
toast.success(t('dataImport.toastImportSuccess'), { icon: <FaCheckCircle /> });
} else if (result.finalStatus === 'Partial Success' || fail > 0) {
toast.warning(t('dataImport.toastImportPartial'), { icon: <FaTimesCircle /> });
} else if (result.finalStatus === 'Error') {
toast.error(t('dataImport.toastImportError'), { icon: <FaTimesCircle /> });
} else {
toast.info(t('dataImport.toastImportStatus', { status: result.finalStatus }), {
icon: <FaCheckCircle />,
});
}
} catch (err) {
console.error(err);
const msg = err instanceof Error ? err.message : String(err);
toast.error(htmlToPlainText(msg).slice(0, 500), { icon: <FaTimesCircle /> });
} finally {
setImporting(false);
setProgressLabel(null);
}
};
return (
<div className="min-h-screen w-full min-w-0 overflow-x-hidden bg-gray-50 p-4 sm:p-6 dark:bg-gray-900">
<ToastContainer
position="top-right"
autoClose={4000}
hideProgressBar={false}
newestOnTop
closeOnClick
rtl={false}
pauseOnFocusLoss
draggable
pauseOnHover
theme="colored"
transition={Bounce}
/>
<DataImportTemplateModal
isOpen={templateModalOpen}
onClose={() => setTemplateModalOpen(false)}
columns={importableColumns}
loading={schemaLoading}
onExport={handleExportTemplate}
/>
<div className="w-full min-w-0">
<div className="mb-6 flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<div className="min-w-0">
<h1 className="text-2xl font-bold break-words text-gray-800 dark:text-white sm:text-3xl">
{t('dataImport.title')}
</h1>
</div>
</div>
<div className="rounded-lg border border-gray-200 bg-white p-6 shadow-lg dark:border-gray-700 dark:bg-gray-800">
<div className="space-y-6">
<div>
<label className="mb-1.5 block text-sm font-medium text-gray-700 dark:text-gray-300">
{t('dataImport.documentType')} <span className="text-red-500">*</span>
</label>
<select
disabled
value={DISPLAY_IMPORT_DOCTYPE}
className="w-full cursor-not-allowed rounded-lg border border-gray-200 bg-gray-100 px-3 py-2.5 text-sm font-semibold text-gray-800 opacity-90 dark:border-gray-600 dark:bg-gray-900/80 dark:text-white"
aria-readonly
>
<option value={DISPLAY_IMPORT_DOCTYPE}>{DISPLAY_IMPORT_DOCTYPE}</option>
</select>
<div className="mt-2.5 rounded-lg border border-blue-100 bg-blue-50/70 px-3 py-2 dark:border-blue-900/50 dark:bg-blue-950/35">
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<p className="text-sm leading-snug text-gray-600 dark:text-gray-300">{t('dataImport.locationQuickHint')}</p>
<div className="flex flex-wrap gap-1.5">
<button
type="button"
onClick={() => navigate('/locations/new')}
className="inline-flex items-center justify-center gap-1.5 rounded-lg border border-blue-200 bg-white px-2.5 py-1.5 text-sm font-semibold text-blue-700 shadow-sm transition hover:bg-blue-50 dark:border-blue-800 dark:bg-gray-900 dark:text-blue-300 dark:hover:bg-gray-800"
>
<FaPlus size={13} />
{t('dataImport.btnAddLocation')}
</button>
<button
type="button"
onClick={() => navigate('/locations')}
className="inline-flex items-center justify-center gap-1.5 rounded-lg border border-gray-200 bg-white px-2.5 py-1.5 text-sm font-medium text-gray-800 shadow-sm transition hover:bg-gray-50 dark:border-gray-600 dark:bg-gray-900 dark:text-gray-200 dark:hover:bg-gray-800"
>
<FaListUl size={13} className="text-blue-600 dark:text-blue-400" />
{t('dataImport.btnViewLocations')}
</button>
</div>
</div>
</div>
</div>
<div>
<label className="mb-1.5 block text-sm font-medium text-gray-700 dark:text-gray-300">
{t('dataImport.fileLabel')} <span className="text-red-500">*</span>
</label>
<div className="flex flex-col gap-3 sm:flex-row sm:flex-wrap sm:items-center">
<label className="inline-flex cursor-pointer items-center justify-center rounded-lg border border-gray-300 bg-white px-4 py-2.5 text-sm font-medium text-gray-700 shadow transition hover:bg-gray-50 dark:border-gray-600 dark:bg-gray-900 dark:text-gray-200 dark:hover:bg-gray-800">
<FaFileAlt className="mr-2 text-blue-600 dark:text-blue-400" />
{t('dataImport.chooseFile')}
<input
ref={fileInputRef}
type="file"
accept=".xlsx,.xls,.csv,text/csv,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet,application/vnd.ms-excel"
className="hidden"
onChange={handleFileChange}
disabled={importing}
/>
</label>
<button
type="button"
onClick={() => setTemplateModalOpen(true)}
disabled={importing || schemaLoading}
className="inline-flex items-center justify-center rounded-lg border border-gray-300 bg-white px-4 py-2.5 text-sm font-medium text-gray-800 shadow transition hover:bg-gray-50 dark:border-gray-600 dark:bg-gray-900 dark:text-gray-200 dark:hover:bg-gray-800"
>
<FaDownload className="mr-2 text-gray-600 dark:text-gray-400" />
{t('dataImport.downloadTemplate')}
</button>
</div>
{file && (
<div className="mt-2 flex max-w-full flex-col gap-1.5 sm:flex-row sm:flex-wrap sm:items-center sm:gap-2">
<div className="inline-flex max-w-full flex-wrap items-center gap-0.5 text-sm text-gray-600 dark:text-gray-400">
<span className="min-w-0 truncate">
{file.name} ({Math.max(1, Math.round(file.size / 1024))} KB)
</span>
<button
type="button"
onClick={clearSelectedFile}
disabled={importing || previewLoading}
className="-m-0.5 inline-flex items-center justify-center rounded p-0.5 text-gray-500 transition hover:bg-gray-100 hover:text-gray-900 disabled:cursor-not-allowed disabled:opacity-50 dark:text-gray-400 dark:hover:bg-gray-800 dark:hover:text-gray-100"
aria-label={t('dataImport.clearFile', { defaultValue: 'Remove file' })}
title={t('dataImport.clearFile', { defaultValue: 'Remove file' })}
>
<FaTimes size={13} />
</button>
</div>
<button
type="button"
onClick={handleDownloadCurrentImportFile}
disabled={importing || previewLoading}
className="inline-flex w-fit items-center justify-center gap-0.5 rounded border border-gray-300 bg-white px-1.5 py-0.5 text-[10px] font-medium leading-tight text-gray-800 shadow-sm transition hover:bg-gray-50 disabled:cursor-not-allowed disabled:opacity-50 dark:border-gray-600 dark:bg-gray-900 dark:text-gray-200 dark:hover:bg-gray-800"
>
<FaDownload className="text-gray-600 dark:text-gray-400" size={10} />
{t('dataImport.downloadFile')}
</button>
</div>
)}
<p className="mt-2 text-xs text-gray-500 dark:text-gray-400">{t('dataImport.fileHint')}</p>
</div>
{previewRunError && (
<div className="rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-900 dark:border-red-900/50 dark:bg-red-950/30 dark:text-red-100">
{previewRunError}
</div>
)}
{alignedWorkbookNotApplied && file && !previewLoading && (
<div className="rounded-lg border border-amber-300 bg-amber-50 px-3 py-2 text-xs text-amber-950 dark:border-amber-800/60 dark:bg-amber-950/30 dark:text-amber-100">
{t('dataImport.alignedWorkbookNotApplied')}
</div>
)}
{(headerPreviewIssues && headerPreviewIssues.length > 0) || previewWarnings.length > 0 ? (
<div className="rounded border border-amber-200 bg-amber-50/90 px-3 py-2.5 dark:border-amber-900/40 dark:bg-amber-950/25">
<h2 className="text-sm font-semibold text-slate-800 dark:text-slate-100">
{t('dataImport.previewTitle')}
</h2>
<div className="mt-2 max-h-56 overflow-y-auto rounded border border-amber-200/70 bg-white text-xs dark:border-amber-900/35 dark:bg-gray-900/50">
<ul className="divide-y divide-amber-100 dark:divide-amber-900/40">
{headerPreviewIssues?.map((issue, idx) => (
<li key={`hdr-${issue.rowIndex}-${idx}`} className="px-2.5 py-1.5 leading-snug">
<span className="font-medium text-gray-900 dark:text-gray-100">
{t('dataImport.previewHeaderIssue')}
</span>
<span className="text-gray-600 dark:text-gray-400"> — </span>
<span className="break-words text-gray-700 dark:text-gray-300">{issue.message}</span>
</li>
))}
{previewWarningsSorted.map((w, idx) => {
const colMeta = getColumnForWarning(w.col);
const isInfo = w.type === 'info';
const linkDt = getLinkedDoctypeForWarning(w);
const canAdd =
Boolean(linkDt) &&
!isInfo &&
(parseMissingLinkValues(w.message)?.length ?? 0) > 0;
const busy =
linkDt &&
addingMastersCol === `${String(w.col ?? '')}:${linkDt}`;
const titleParts: string[] = [];
if (w.row != null && String(w.row).trim() !== '') {
titleParts.push(t('dataImport.previewRowHeading', { row: String(w.row) }));
}
if (w.col != null && String(w.col).trim() !== '') {
titleParts.push(
t('dataImport.previewColumnHeading', {
n: String(w.col),
label: colMeta?.header_title ?? linkDt ?? '—',
})
);
}
const title = titleParts.join(' · ');
return (
<li
key={`pw-${String(w.col)}-${String(w.row)}-${idx}-${w.message.slice(0, 24)}`}
className={`px-2.5 py-1.5 leading-snug ${
isInfo
? 'bg-blue-50/50 dark:bg-blue-950/20'
: isBlockingImportWarning(w)
? ''
: 'bg-gray-50/80 dark:bg-gray-900/30'
}`}
>
<div className="flex flex-col gap-1 sm:flex-row sm:items-start sm:justify-between sm:gap-2">
<div className="min-w-0">
{title ? (
<p className="font-medium text-gray-900 dark:text-gray-100">{title}</p>
) : (
<p className="font-medium text-gray-900 dark:text-gray-100">
{t('dataImport.previewGenericHeading')}
</p>
)}
<p className="break-words text-gray-700 dark:text-gray-300">
{htmlToPlainText(w.message)}
</p>
{canAdd && linkDt && (
<p className="mt-0.5 text-[11px] text-gray-600 dark:text-gray-400">
{t('dataImport.previewSaveHintPrefix')}{' '}
<span className="font-bold text-gray-800 dark:text-gray-200">
{t('dataImport.previewSaveAction')}
</span>{' '}
{t('dataImport.previewSaveHintSuffix')}
</p>
)}
</div>
{canAdd && linkDt && (
<button
type="button"
disabled={busy || previewLoading || importing}
onClick={() => handleAddMastersForWarning(w)}
className="inline-flex shrink-0 items-center justify-center rounded border border-blue-400 bg-blue-600 px-2.5 py-1 text-[11px] font-bold text-white shadow-sm transition hover:bg-blue-700 disabled:cursor-not-allowed disabled:opacity-50 dark:border-blue-700 dark:bg-blue-700 dark:hover:bg-blue-600"
>
{busy ? <FaSpinner className="animate-spin" size={11} /> : t('dataImport.previewSaveAction')}
</button>
)}
</div>
</li>
);
})}
</ul>
</div>
</div>
) : null}
{importMappingNotes.length > 0 && (
<div className="rounded-lg border border-blue-200 bg-blue-50/90 px-3 py-2 text-xs text-blue-950 dark:border-blue-900/40 dark:bg-blue-950/35 dark:text-blue-100">
<p className="font-semibold">{t('dataImport.normalizationNotes')}</p>
<ul className="mt-1 list-inside list-disc space-y-0.5">
{importMappingNotes.map((m, i) => (
<li key={`norm-${i}-${m.slice(0, 24)}`} className="break-words">
{m}
</li>
))}
</ul>
</div>
)}
{progressLabel && (
<div className="rounded-lg border border-blue-100 bg-blue-50 px-4 py-3 text-sm text-blue-900 dark:border-blue-900 dark:bg-blue-950/40 dark:text-blue-100">
<span className="inline-flex items-center gap-2">
<FaSpinner className="animate-spin" />
{progressLabel}
</span>
</div>
)}
<div className="flex flex-wrap items-center gap-3 border-t border-gray-200 pt-6 dark:border-gray-700">
<button
type="button"
onClick={handleImport}
disabled={
importing ||
!file ||
schemaLoading ||
previewLoading ||
!stagedPreview?.dataImportName ||
blockingPreviewIssues
}
className="inline-flex items-center justify-center rounded-lg bg-blue-600 px-6 py-2.5 text-sm font-semibold text-white shadow-lg transition hover:bg-blue-700 hover:shadow-xl disabled:cursor-not-allowed disabled:opacity-50"
>
{importing ? (
<>
<FaSpinner className="mr-2 animate-spin" />
{t('dataImport.importing')}
</>
) : previewLoading ? (
<>
<FaSpinner className="mr-2 animate-spin" />
{t('dataImport.validatingFile')}
</>
) : (
t('dataImport.startImport')
)}
</button>
{importSummary && !importing && (
<div className="flex min-w-0 flex-wrap items-center gap-2">
<span
className={`text-sm font-medium ${
importSummary.ok === importSummary.total
? 'text-green-700 dark:text-green-400'
: importSummary.ok === 0
? 'text-red-700 dark:text-red-400'
: 'text-amber-700 dark:text-amber-400'
}`}
>
{importSummary.ok === 0
? t('dataImport.importSummaryLineAllFailed', { total: importSummary.total })
: importSummary.ok === importSummary.total
? t('dataImport.importSummaryLineAllOk', { total: importSummary.total })
: t('dataImport.importSummaryLine', {
ok: importSummary.ok,
total: importSummary.total,
})}
</span>
<span
className={`inline-flex shrink-0 rounded-full px-2.5 py-0.5 text-xs font-semibold ${
importSummary.ok === importSummary.total
? 'bg-green-100 text-green-800 dark:bg-green-950/50 dark:text-green-300'
: importSummary.ok === 0
? 'bg-red-100 text-red-800 dark:bg-red-950/50 dark:text-red-300'
: 'bg-amber-100 text-amber-900 dark:bg-amber-950/40 dark:text-amber-200'
}`}
>
{importSummary.ok === importSummary.total
? t('dataImport.importSummaryBadgeAll')
: importSummary.ok === 0
? t('dataImport.importSummaryBadgeNone')
: t('dataImport.importSummaryBadgePartial')}
</span>
</div>
)}
</div>
</div>
</div>
{logs && logs.length > 0 && (
<div className="mt-6 rounded-lg border border-gray-200 bg-white p-4 shadow-lg dark:border-gray-700 dark:bg-gray-800">
<h2 className="mb-3 text-lg font-semibold text-gray-800 dark:text-white">{t('dataImport.logsTitle')}</h2>
<div className="max-h-96 overflow-auto rounded-lg border border-gray-200 dark:border-gray-600">
<table className="min-w-full divide-y divide-gray-200 text-sm dark:divide-gray-600">
<thead className="bg-gray-50 dark:bg-gray-900/80">
<tr>
<th className="px-3 py-2 text-start font-medium text-gray-700 dark:text-gray-300">
{t('dataImport.logRow')}
</th>
<th className="px-3 py-2 text-start font-medium text-gray-700 dark:text-gray-300">
{t('dataImport.logStatus')}
</th>
<th className="px-3 py-2 text-start font-medium text-gray-700 dark:text-gray-300">
{t('dataImport.logMessage')}
</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100 dark:divide-gray-700">
{logs.map((log, idx) => (
<tr
key={`${log.rowIndex}-${idx}-${log.message.slice(0, 24)}`}
className={log.success ? 'bg-white dark:bg-gray-800' : 'bg-red-50/60 dark:bg-red-950/20'}
>
<td className="px-3 py-2 font-mono text-gray-800 dark:text-gray-200">{log.rowIndex}</td>
<td className="px-3 py-2">
{log.success ? (
<span className="inline-flex items-center gap-1 text-green-700 dark:text-green-400">
<FaCheckCircle size={12} /> {t('dataImport.statusOk')}
</span>
) : (
<span className="inline-flex items-center gap-1 text-red-700 dark:text-red-400">
<FaTimesCircle size={12} /> {t('dataImport.statusFailed')}
</span>
)}
</td>
<td className="max-w-2xl break-words px-3 py-2 text-gray-700 dark:text-gray-300">
{log.success && log.importedDocname
? t('dataImport.successImported', { name: log.importedDocname })
: htmlToPlainText(log.message)}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
</div>
</div>
);
};
export default DataImport;

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

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

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