Initial commit of Asm_ui_app app

This commit is contained in:
Duradundi 2026-08-24 13:36:46 +05:30
commit 1a0e38e10c
188 changed files with 57528 additions and 0 deletions

7
.gitignore vendored Normal file
View File

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

7
README.md Normal file
View File

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

24
asm_app/.gitignore vendored Normal file
View File

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

73
asm_app/README.md Normal file
View File

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

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

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

16
asm_app/index.html Normal file
View File

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

4167
asm_app/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

50
asm_app/package.json Normal file
View File

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

View File

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

13
asm_app/proxyOptions.ts Normal file
View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 395 KiB

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

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

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

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

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

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

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

@ -0,0 +1,330 @@
// 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 { BrowserRouter as Router, Routes, Route, Navigate, useLocation } from 'react-router-dom';
import Login from './pages/Login';
import Dashboard from './pages/Dashboard';
import ModernDashboard from './pages/ModernDashboard';
import WoFeedbackPage from './pages/WoFeedbackPage';
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 AppEntry from './components/AppEntry';
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 { useLanguage } from './contexts/LanguageContext';
import { SidebarLayoutProvider, useSidebarLayout } from './contexts/SidebarLayoutContext';
import IssueList from './pages/IssueList';
import IssueDetail from './pages/IssueDetail';
import MaintenanceTeamList from './pages/MaintenanceTeamList';
import MaintenanceTeamDetail from './pages/MaintenanceTeamDetail';
import UserProfilePage from './pages/UserProfilePage';
import { getDefaultRoute, getStoredRoleProfile, isEndUserRole } from './utils/roleAccess';
// Layout with Sidebar and Header
const LayoutWithSidebarInner: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const location = useLocation();
const { isRTL } = useLanguage();
const { mobileOpen, closeMobileSidebar } = useSidebarLayout();
const user = localStorage.getItem('user');
const userEmail = user ? JSON.parse(user).email : '';
useEffect(() => {
const mq = window.matchMedia('(min-width: 1024px)');
const handleChange = () => {
if (mq.matches) closeMobileSidebar();
};
handleChange();
mq.addEventListener('change', handleChange);
return () => mq.removeEventListener('change', handleChange);
}, [closeMobileSidebar]);
return (
<div className="flex h-screen overflow-hidden bg-gray-50 dark:bg-gray-900">
{mobileOpen && (
<button
type="button"
className="fixed inset-0 z-40 bg-black/50 lg:hidden"
onClick={closeMobileSidebar}
aria-label="Close menu"
/>
)}
<div
className={`
fixed top-0 z-50 h-full transition-transform duration-300 ease-in-out lg:static lg:z-auto lg:translate-x-0
${isRTL
? `right-0 ${mobileOpen ? 'translate-x-0' : 'translate-x-full'}`
: `left-0 ${mobileOpen ? 'translate-x-0' : '-translate-x-full'}`
}
`}
>
<Sidebar userEmail={userEmail} />
</div>
<div className="flex min-w-0 flex-1 flex-col overflow-hidden">
<Header userEmail={userEmail} />
<div
key={location.pathname}
className="flex-1 overflow-y-auto bg-gray-50 dark:bg-gray-900"
>
{children}
</div>
</div>
</div>
);
};
const LayoutWithSidebar: React.FC<{ children: React.ReactNode }> = ({ children }) => (
<SidebarLayoutProvider>
<LayoutWithSidebarInner>{children}</LayoutWithSidebarInner>
</SidebarLayoutProvider>
);
// Protected Route Component
const ProtectedRoute: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const user = localStorage.getItem('user');
return user ? <>{children}</> : <Navigate to="/login" replace />;
};
const EndUserDashboardGuard: React.FC<{ children: React.ReactNode }> = ({ children }) => {
if (isEndUserRole(getStoredRoleProfile())) {
return <Navigate to={getDefaultRoute(getStoredRoleProfile())} replace />;
}
return <>{children}</>;
};
const App: React.FC = () => {
return (
<Router basename="/asm_app">
<Routes>
<Route path="/login" element={<Login />} />
<Route path="/dashboard" element={
<ProtectedRoute>
<EndUserDashboardGuard>
<LayoutWithSidebar><ModernDashboard /></LayoutWithSidebar>
</EndUserDashboardGuard>
</ProtectedRoute>
} />
<Route path="/wo-feedback" element={<WoFeedbackPage />} />
<Route path="/work-order-feedback" element={<Navigate to="/wo-feedback" replace />} />
<Route path="/assets" element={
<ProtectedRoute>
<LayoutWithSidebar><AssetList /></LayoutWithSidebar>
</ProtectedRoute>
} />
<Route path="/assets/:assetName" element={
<ProtectedRoute>
<LayoutWithSidebar><AssetDetail /></LayoutWithSidebar>
</ProtectedRoute>
} />
<Route path="/work-orders" element={
<ProtectedRoute>
<LayoutWithSidebar><WorkOrderList /></LayoutWithSidebar>
</ProtectedRoute>
} />
<Route path="/work-orders/:workOrderName" element={
<ProtectedRoute>
<LayoutWithSidebar><WorkOrderDetail /></LayoutWithSidebar>
</ProtectedRoute>
} />
<Route path="/maintenance" element={
<ProtectedRoute>
<LayoutWithSidebar><AssetMaintenanceList /></LayoutWithSidebar>
</ProtectedRoute>
} />
<Route path="/maintenance/:logName" element={
<ProtectedRoute>
<LayoutWithSidebar><AssetMaintenanceDetail /></LayoutWithSidebar>
</ProtectedRoute>
} />
<Route path="/ppm" element={
<ProtectedRoute>
<LayoutWithSidebar><PPMList /></LayoutWithSidebar>
</ProtectedRoute>
} />
<Route path="/ppm/:ppmName" element={
<ProtectedRoute>
<LayoutWithSidebar><PPMDetail /></LayoutWithSidebar>
</ProtectedRoute>
} />
<Route path="/ppm-planner" element={
<ProtectedRoute>
<LayoutWithSidebar><PPMPlannerList /></LayoutWithSidebar>
</ProtectedRoute>
} />
<Route path="/ppm-planner/new" element={
<ProtectedRoute>
<LayoutWithSidebar><PPMPlanner /></LayoutWithSidebar>
</ProtectedRoute>
} />
<Route path="/ppm-planner/:scheduleName" element={
<ProtectedRoute>
<LayoutWithSidebar><PPMPlannerDetail /></LayoutWithSidebar>
</ProtectedRoute>
} />
<Route path="/maintenance-calendar" element={
<ProtectedRoute>
<LayoutWithSidebar><YearlyPPMPlannerPage /></LayoutWithSidebar>
</ProtectedRoute>
} />
<Route path="/maintenance-calendar/month-view" element={
<ProtectedRoute>
<LayoutWithSidebar><MaintenanceCalendarPage /></LayoutWithSidebar>
</ProtectedRoute>
} />
<Route path="/yearly-ppm-planner" element={
<ProtectedRoute>
<LayoutWithSidebar><YearlyPPMPlannerPage /></LayoutWithSidebar>
</ProtectedRoute>
} />
<Route path="/active-map" element={
<ProtectedRoute>
<LayoutWithSidebar><ActiveMap /></LayoutWithSidebar>
</ProtectedRoute>
} />
<Route path="/inventory" element={
<ProtectedRoute>
<LayoutWithSidebar><ItemList /></LayoutWithSidebar>
</ProtectedRoute>
} />
<Route path="/inventory/:itemName" element={
<ProtectedRoute>
<LayoutWithSidebar><ItemDetail /></LayoutWithSidebar>
</ProtectedRoute>
} />
<Route path="/users" element={
<ProtectedRoute>
<LayoutWithSidebar><UsersList /></LayoutWithSidebar>
</ProtectedRoute>
} />
<Route path="/events" element={
<ProtectedRoute>
<LayoutWithSidebar><EventsList /></LayoutWithSidebar>
</ProtectedRoute>
} />
<Route path="/old-dashboard" element={
<ProtectedRoute>
<LayoutWithSidebar><Dashboard /></LayoutWithSidebar>
</ProtectedRoute>
} />
{/* <Route path="/maintenance-team" element={
<ProtectedRoute>
<LayoutWithSidebar><ComingSoon title="Maintenance Team" /></LayoutWithSidebar>
</ProtectedRoute>
} /> */}
<Route path="/maintenance-teams" element={
<ProtectedRoute>
<LayoutWithSidebar><MaintenanceTeamList /></LayoutWithSidebar>
</ProtectedRoute>
} />
<Route path="/maintenance-teams/:teamName" element={
<ProtectedRoute>
<LayoutWithSidebar><MaintenanceTeamDetail /></LayoutWithSidebar>
</ProtectedRoute>
} />
<Route path="/procurement" element={
<ProtectedRoute>
<LayoutWithSidebar><ComingSoon title="Procurement" /></LayoutWithSidebar>
</ProtectedRoute>
} />
<Route path="/sla" element={
<ProtectedRoute>
<LayoutWithSidebar><ComingSoon title="Service Level Agreement (SLA)" /></LayoutWithSidebar>
</ProtectedRoute>
} />
{/* <Route path="/support" element={
<ProtectedRoute>
<LayoutWithSidebar><ComingSoon title="Support" /></LayoutWithSidebar>
</ProtectedRoute>
} /> */}
<Route path="/support" element={
<ProtectedRoute>
<LayoutWithSidebar><IssueList /></LayoutWithSidebar>
</ProtectedRoute>
} />
<Route path="/support/:issueName" element={
<ProtectedRoute>
<LayoutWithSidebar><IssueDetail /></LayoutWithSidebar>
</ProtectedRoute>
} />
<Route path="/user-profile" element={
<ProtectedRoute>
<LayoutWithSidebar><UserProfilePage /></LayoutWithSidebar>
</ProtectedRoute>
} />
{/* Default redirect */}
<Route index element={<AppEntry />} />
<Route path="/" element={<AppEntry />} />
<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,406 @@
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 } from '../hooks/useAuditLogs';
interface ActivityLogProps {
doctype: string;
docname: string | null;
creationDate?: string;
createdBy?: string;
title?: string;
limit?: number;
initialVisible?: number;
collapsible?: boolean;
startCollapsed?: boolean;
compact?: boolean;
className?: string;
onRefresh?: () => void;
}
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';
};
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'}`}>
<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'
}`}
/>
<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'
}`}
>
<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>
<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>
);
};
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';
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>
);
};
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}`}
>
<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>
{isExpanded && (
<div className="p-3">
{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>
)}
{!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>
)}
{!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>
{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>
)}
{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,46 @@
import React, { useEffect, useState } from 'react';
import { Navigate, useNavigate } from 'react-router-dom';
import { resolveAuthenticatedRoute } from '../utils/appRouting';
const AppEntry: React.FC = () => {
const navigate = useNavigate();
const [target, setTarget] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
resolveAuthenticatedRoute()
.then((route) => {
if (cancelled) return;
if (route) {
setTarget(route);
} else {
navigate('/login', { replace: true });
}
})
.catch(() => {
if (!cancelled) {
navigate('/login', { replace: true });
}
});
return () => {
cancelled = true;
};
}, [navigate]);
if (target) {
return <Navigate to={target} replace />;
}
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50 dark:bg-gray-900">
<div className="text-center">
<div className="animate-spin rounded-full h-10 w-10 border-b-2 border-indigo-600 mx-auto" />
<p className="mt-4 text-gray-600 dark:text-gray-400 text-sm">Loading...</p>
</div>
</div>
);
};
export default AppEntry;

View File

@ -0,0 +1,288 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { FaBoxes, FaExternalLinkAlt, FaSync, FaTimes } from 'react-icons/fa';
import { useTranslation } from 'react-i18next';
import type { PieChartData } from './PieChart';
import { buildAssetUrl } from '../utils/buildAssetUrl';
import type { DashboardLocationFilters } from '../utils/hospitalUtils';
import type { AssetDeviceStatusSummaryFilters } from '../services/assetDeviceStatusService';
import {
compactFilterFieldWrapClass,
compactFilterInputClass,
compactFilterLabelClass,
compactToolbarBtnPrimary,
} from '../utils/reportModalStyles';
export type AssetStatusReportRow = {
name: string;
asset_name?: string;
custom_device_status?: string;
location?: string;
department?: string;
company?: string;
custom_site?: string;
};
type AssetStatusRefreshResult = {
chart: PieChartData | null;
total_assets: number;
assets: AssetStatusReportRow[];
};
type AssetStatusReportModalProps = {
isOpen: boolean;
onClose: () => void;
chartData: PieChartData | null;
defaultFromDate?: string;
defaultToDate?: string;
locationFilters?: DashboardLocationFilters;
onRefresh?: (filters: AssetDeviceStatusSummaryFilters) => Promise<AssetStatusRefreshResult>;
};
type StatusCardStyle = {
bg: string;
border: string;
text: string;
bar: string;
};
const getStatusCardStyle = (label: string, chartColor?: string): StatusCardStyle => {
const key = (label || '').trim().toLowerCase();
if (key === 'up') {
return { bg: 'bg-emerald-50 dark:bg-emerald-900/20', border: 'border-emerald-200 dark:border-emerald-800', text: 'text-emerald-700 dark:text-emerald-300', bar: '#10B981' };
}
if (key === 'down') {
return { bg: 'bg-red-50 dark:bg-red-900/20', border: 'border-red-200 dark:border-red-800', text: 'text-red-700 dark:text-red-300', bar: '#EF4444' };
}
if (key.includes('maintenance')) {
return { bg: 'bg-amber-50 dark:bg-amber-900/20', border: 'border-amber-200 dark:border-amber-800', text: 'text-amber-700 dark:text-amber-300', bar: '#F59E0B' };
}
if (key.includes('decommission')) {
return { bg: 'bg-gray-50 dark:bg-gray-800', border: 'border-gray-200 dark:border-gray-700', text: 'text-gray-600 dark:text-gray-300', bar: '#6B7280' };
}
return {
bg: 'bg-indigo-50 dark:bg-indigo-900/20',
border: 'border-indigo-200 dark:border-indigo-800',
text: 'text-indigo-700 dark:text-indigo-300',
bar: chartColor || '#6366F1',
};
};
const APP_BASE = '/asm_app';
const AssetStatusReportModal: React.FC<AssetStatusReportModalProps> = ({
isOpen,
onClose,
chartData,
defaultFromDate = '',
defaultToDate = '',
locationFilters,
onRefresh,
}) => {
const { t } = useTranslation();
const [loading, setLoading] = useState(false);
const [fromDate, setFromDate] = useState(defaultFromDate);
const [toDate, setToDate] = useState(defaultToDate);
const [localChartData, setLocalChartData] = useState<PieChartData | null>(chartData);
useEffect(() => {
if (!isOpen) return;
setFromDate(defaultFromDate);
setToDate(defaultToDate);
setLocalChartData(chartData);
}, [isOpen, defaultFromDate, defaultToDate, chartData]);
const buildFilters = useCallback((): AssetDeviceStatusSummaryFilters => {
const filters: AssetDeviceStatusSummaryFilters = {};
if (locationFilters?.company) filters.company = locationFilters.company;
if (locationFilters?.site_name) filters.site_name = locationFilters.site_name;
if (fromDate) filters.from_date = fromDate;
if (toDate) filters.to_date = toDate;
return filters;
}, [fromDate, toDate, locationFilters]);
const loadData = useCallback(async () => {
if (!onRefresh) return;
setLoading(true);
try {
const result = await onRefresh(buildFilters());
setLocalChartData(result.chart);
} catch (err) {
console.error('Failed to load asset status overview:', err);
setLocalChartData(null);
} finally {
setLoading(false);
}
}, [onRefresh, buildFilters]);
useEffect(() => {
if (!isOpen || !onRefresh) return;
loadData();
}, [isOpen, onRefresh]); // eslint-disable-line react-hooks/exhaustive-deps
const totalAssets = localChartData?.total ?? 0;
const statusRows = useMemo(() => {
const labels = localChartData?.labels || [];
const values = localChartData?.datasets?.[0]?.values || [];
const colors = localChartData?.datasets?.[0]?.colors || [];
return labels.map((label, index) => ({
label,
count: Number(values[index]) || 0,
color: colors[index],
percentage: totalAssets > 0 ? ((Number(values[index]) || 0) / totalAssets) * 100 : 0,
}));
}, [localChartData, totalAssets]);
const navigateToAssets = useCallback(
(deviceStatus?: string) => {
const path = buildAssetUrl(
{ device_status: deviceStatus, ...locationFilters },
fromDate,
toDate,
locationFilters
);
window.location.href = `${APP_BASE}${path}`;
},
[fromDate, toDate, locationFilters]
);
if (!isOpen) return null;
return (
<div className="fixed inset-0 z-[80] flex items-center justify-center p-4">
<button type="button" className="absolute inset-0 bg-black/50 backdrop-blur-sm" onClick={onClose} aria-label="Close" />
<div className="relative w-full max-w-2xl max-h-[90vh] overflow-hidden rounded-xl bg-white dark:bg-gray-900 shadow-2xl flex flex-col">
<div className="bg-gradient-to-r from-teal-500 to-emerald-600 text-white px-4 py-3">
<div className="flex items-start justify-between gap-3">
<div className="flex items-center gap-2 min-w-0">
<div className="p-1.5 rounded-lg bg-white/15">
<FaBoxes className="w-4 h-4" />
</div>
<div>
<h2 className="text-base font-semibold">
{t('dashboard.assetStatusOverview', { defaultValue: 'Asset Status Overview' })}
</h2>
<p className="text-xs text-white/85 mt-0.5">
{t('dashboard.clickStatusCardHint', { defaultValue: 'Click a card to view filtered assets' })}
</p>
</div>
</div>
<button type="button" onClick={onClose} className="p-1.5 rounded-lg hover:bg-white/10">
<FaTimes className="text-sm" />
</button>
</div>
</div>
<div className="px-3 py-2 bg-teal-50 dark:bg-teal-900/20 border-b border-teal-100 dark:border-teal-900/40">
<div className="flex flex-wrap items-end gap-2">
<div className={`${compactFilterFieldWrapClass} mb-0 w-[8.5rem]`}>
<label className={compactFilterLabelClass}>
{t('dashboard.fromDate', { defaultValue: 'From Date' })}
</label>
<input
type="date"
value={fromDate}
onChange={e => setFromDate(e.target.value)}
className={compactFilterInputClass}
/>
</div>
<div className={`${compactFilterFieldWrapClass} mb-0 w-[8.5rem]`}>
<label className={compactFilterLabelClass}>
{t('dashboard.toDate', { defaultValue: 'To Date' })}
</label>
<input
type="date"
value={toDate}
onChange={e => setToDate(e.target.value)}
className={compactFilterInputClass}
/>
</div>
<button
type="button"
onClick={loadData}
disabled={loading || !onRefresh}
className={`${compactToolbarBtnPrimary('emerald')} mb-0.5`}
>
<FaSync className={`text-[10px] ${loading ? 'animate-spin' : ''}`} />
{t('dashboard.refreshReport', { defaultValue: 'Refresh' })}
</button>
{(locationFilters?.company || locationFilters?.site_name) && (
<div className="flex flex-wrap gap-1.5 ml-auto pb-0.5">
{locationFilters?.company && (
<span className="px-2 py-0.5 rounded-full bg-white dark:bg-gray-800 border border-teal-200 dark:border-teal-800 text-teal-800 dark:text-teal-200 text-[10px]">
{locationFilters.company}
</span>
)}
{locationFilters?.site_name && (
<span className="px-2 py-0.5 rounded-full bg-white dark:bg-gray-800 border border-teal-200 dark:border-teal-800 text-teal-800 dark:text-teal-200 text-[10px]">
{locationFilters.site_name}
</span>
)}
</div>
)}
</div>
</div>
<div className="p-3 overflow-y-auto flex-1 space-y-3">
<button
type="button"
onClick={() => navigateToAssets()}
className="w-full text-left rounded-lg border border-indigo-200 dark:border-indigo-800 bg-indigo-50 dark:bg-indigo-900/20 p-3 hover:shadow-md transition-all group"
>
<div className="flex items-center justify-between gap-3">
<div>
<div className="text-xl font-bold text-indigo-700 dark:text-indigo-300">{totalAssets.toLocaleString()}</div>
<div className="text-xs font-medium text-indigo-600 dark:text-indigo-300 mt-0.5">
{t('dashboard.totalAssets', { defaultValue: 'Total Assets' })}
</div>
</div>
<span className="inline-flex items-center gap-1 text-[10px] font-semibold text-indigo-600 dark:text-indigo-300 group-hover:underline">
{t('dashboard.viewAll', { defaultValue: 'View all' })}
<FaExternalLinkAlt className="w-2.5 h-2.5" />
</span>
</div>
</button>
{loading ? (
<div className="py-8 flex justify-center">
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-teal-600" />
</div>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
{statusRows.map((row) => {
const style = getStatusCardStyle(row.label, row.color);
return (
<button
key={row.label}
type="button"
onClick={() => navigateToAssets(row.label)}
className={`text-left rounded-lg border p-3 transition-all hover:shadow-md group ${style.bg} ${style.border}`}
>
<div className={`text-lg font-bold ${style.text}`}>{row.count.toLocaleString()}</div>
<div className={`text-[10px] font-semibold uppercase tracking-wide mt-0.5 ${style.text}`}>{row.label}</div>
<div className="mt-2 h-1.5 rounded-full bg-white/70 dark:bg-gray-900/30 overflow-hidden">
<div className="h-full rounded-full" style={{ width: `${row.percentage}%`, backgroundColor: style.bar }} />
</div>
<div className="mt-1.5 flex items-center justify-between text-[10px] text-gray-600 dark:text-gray-400">
<span>{row.percentage.toFixed(1)}% {t('dashboard.ofTotal', { defaultValue: 'of total' })}</span>
<span className="opacity-0 group-hover:opacity-100 transition-opacity font-medium">
{t('dashboard.viewFiltered', { defaultValue: 'View ↗' })}
</span>
</div>
</button>
);
})}
</div>
)}
</div>
</div>
</div>
);
};
export default AssetStatusReportModal;

View File

@ -0,0 +1,60 @@
import React from 'react';
import { FaExternalLinkAlt } from 'react-icons/fa';
import { useTranslation } from 'react-i18next';
import PieChart, { type PieChartData } from './PieChart';
type AssetUpDownCardProps = {
title: string;
data: PieChartData | null;
loading?: boolean;
totalAssets: number;
onOpenReport: () => void;
};
const AssetUpDownCard: React.FC<AssetUpDownCardProps> = ({
title,
data,
loading = false,
totalAssets,
onOpenReport,
}) => {
const { t } = useTranslation();
return (
<div className="bg-white dark:bg-gray-800 rounded-lg shadow hover:shadow-md transition-all px-5 py-5 border border-gray-200 dark:border-gray-700">
<div className="flex items-center justify-between mb-4">
<div>
<h3 className="text-base font-semibold text-gray-900 dark:text-white">{title}</h3>
<p className="text-sm text-gray-600 dark:text-gray-400 mt-1">
{totalAssets.toLocaleString()} {t('dashboard.totalAssetsLabel', { defaultValue: 'Total' })}
</p>
</div>
<button
type="button"
onClick={onOpenReport}
className="p-2 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg text-teal-600 dark:text-teal-300"
title={t('dashboard.viewAssetStatusOverview', { defaultValue: 'View asset status overview' })}
>
<FaExternalLinkAlt className="w-4 h-4" />
</button>
</div>
{loading ? (
<div className="h-64 flex items-center justify-center">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-teal-600" />
</div>
) : !data?.labels?.length ? (
<div className="h-64 flex items-center justify-center text-gray-400 text-sm">
{t('dashboard.noAssetStatusData', { defaultValue: 'No asset status data' })}
</div>
) : (
<PieChart
data={data}
emptyMessage={t('dashboard.noAssetStatusData', { defaultValue: 'No asset status data' })}
/>
)}
</div>
);
};
export default AssetUpDownCard;

View File

@ -0,0 +1,667 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { FaSync, FaTimes } from 'react-icons/fa';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
import apiService from '../services/apiService';
import {
buildAssigneesStatusChartData,
getAssigneeCancelled,
getAssigneeCompleted,
getAssigneeName,
getAssigneeOverdue,
getAssigneePlanned,
getAssigneeTotal,
runAssigneesStatusReport,
} from '../services/assigneesStatusService';
import { buildMaintenanceUrl } from '../utils/buildMaintenanceUrl';
import { buildWoUrl } from '../utils/buildWoUrl';
import { getRepairCompletionRate } from '../utils/chartLabelUtils';
import type { DashboardLocationFilters } from '../utils/hospitalUtils';
type ChartDataset = {
name?: string;
values?: number[];
colors?: string[];
};
type ChartData = {
labels?: string[];
datasets?: ChartDataset[];
hideFooterTotal?: boolean;
rawRows?: Record<string, unknown>[];
};
type ChartDetailModalProps = {
isOpen: boolean;
onClose: () => void;
title: string;
initialData?: ChartData | null;
redirectContext?: 'wo_status' | 'wo_type' | 'assignees_status' | null;
defaultFromDate?: string;
defaultToDate?: string;
locationFilters?: DashboardLocationFilters;
workOrderType?: string;
};
const APP_BASE = '/asm_app';
const ChartDetailModal: React.FC<ChartDetailModalProps> = ({
isOpen,
onClose,
title,
initialData = null,
redirectContext = null,
defaultFromDate = '',
defaultToDate = '',
locationFilters,
workOrderType = '',
}) => {
const { t } = useTranslation();
const navigate = useNavigate();
const [fromDate, setFromDate] = useState(defaultFromDate);
const [toDate, setToDate] = useState(defaultToDate);
const [chartData, setChartData] = useState<ChartData | null>(initialData);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const buildMetricsFilters = useCallback(
(from: string, to: string) => {
const filters: Record<string, string> = {};
if (from) filters.from_date = from;
if (to) filters.to_date = to;
if (locationFilters?.company) filters.company = locationFilters.company;
if (locationFilters?.site_name) filters.site_name = locationFilters.site_name;
if (workOrderType) filters.work_order_type = workOrderType;
return filters;
},
[locationFilters, workOrderType]
);
const loadData = useCallback(
async (from: string, to: string) => {
if (redirectContext === 'wo_status') {
setLoading(true);
setError(null);
try {
const result = await apiService.getDashboardWorkOrderMetrics(buildMetricsFilters(from, to));
setChartData(result.work_order_chart);
} catch (err) {
setChartData(null);
setError(err instanceof Error ? err.message : 'Failed to load report data');
} finally {
setLoading(false);
}
return;
}
if (redirectContext === 'wo_type') {
setLoading(true);
setError(null);
try {
const result = await apiService.getDashboardWorkOrderMetrics(buildMetricsFilters(from, to));
const rates = result.completion_by_type?.rates || [];
setChartData({
labels: rates.map(item => item.type),
datasets: [
{
name: 'Completion Rate (%)',
values: rates.map(item => getRepairCompletionRate(item)),
},
{
name: 'Completed WOs',
values: rates.map(
item => item.completedCombined ?? (item.completed || 0) + (item.closed || 0)
),
},
{
name: 'In Progress',
values: rates.map(item => item.inProgress || 0),
},
{
name: 'Rejected',
values: rates.map(item => item.rejected || 0),
},
{
name: 'Total WOs',
values: rates.map(item => item.total || 0),
},
],
hideFooterTotal: true,
});
} catch (err) {
setChartData(null);
setError(err instanceof Error ? err.message : 'Failed to load report data');
} finally {
setLoading(false);
}
return;
}
if (redirectContext === 'assignees_status') {
setLoading(true);
setError(null);
try {
const rows = await runAssigneesStatusReport(buildMetricsFilters(from, to));
setChartData(buildAssigneesStatusChartData(rows));
} catch (err) {
setChartData(null);
setError(err instanceof Error ? err.message : 'Failed to load report data');
} finally {
setLoading(false);
}
return;
}
setChartData(initialData);
},
[redirectContext, initialData, buildMetricsFilters]
);
useEffect(() => {
if (!isOpen) return;
setFromDate(defaultFromDate);
setToDate(defaultToDate);
loadData(defaultFromDate, defaultToDate);
}, [isOpen, defaultFromDate, defaultToDate, loadData]);
useEffect(() => {
if (!isOpen) return;
const previousOverflow = document.body.style.overflow;
document.body.style.overflow = 'hidden';
return () => {
document.body.style.overflow = previousOverflow;
};
}, [isOpen]);
useEffect(() => {
if (!isOpen) return;
const onKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') onClose();
};
window.addEventListener('keydown', onKeyDown);
return () => window.removeEventListener('keydown', onKeyDown);
}, [isOpen, onClose]);
const statusRows = useMemo(() => {
const labels = chartData?.labels || [];
const values = chartData?.datasets?.[0]?.values || [];
const colors = chartData?.datasets?.[0]?.colors || [];
return labels.map((label, index) => ({
label,
value: Number(values[index]) || 0,
color: colors[index] || '#6366F1',
}));
}, [chartData]);
const typeRows = useMemo(() => {
const labels = chartData?.labels || [];
const datasets = chartData?.datasets || [];
return labels.map((label, rowIndex) => ({
label,
cells: datasets.map(dataset => Number(dataset.values?.[rowIndex]) || 0),
}));
}, [chartData]);
const typeColumns = chartData?.datasets?.map(dataset => dataset.name || '') || [];
const statusTotal = useMemo(() => statusRows.reduce((sum, row) => sum + row.value, 0), [statusRows]);
const assigneeRows = useMemo(() => {
const rawRows = chartData?.rawRows || [];
return rawRows.map(row => ({
assignee: getAssigneeName(row),
planned: getAssigneePlanned(row),
completed: getAssigneeCompleted(row),
cancelled: getAssigneeCancelled(row),
overdue: getAssigneeOverdue(row),
total: getAssigneeTotal(row),
}));
}, [chartData?.rawRows]);
const assigneeTotals = useMemo(
() =>
assigneeRows.reduce(
(acc, row) => {
acc.planned += row.planned;
acc.completed += row.completed;
acc.cancelled += row.cancelled;
acc.overdue += row.overdue;
acc.total += row.total;
return acc;
},
{ planned: 0, completed: 0, cancelled: 0, overdue: 0, total: 0 }
),
[assigneeRows]
);
const handleApply = () => {
loadData(fromDate, toDate);
};
const handleClearDates = () => {
setFromDate('');
setToDate('');
loadData('', '');
};
const navigateToWorkOrders = (params: {
work_order_type?: string;
status?: string;
}) => {
const path = buildWoUrl(
{
work_order_type: params.work_order_type,
status: params.status,
company: locationFilters?.company,
site_name: locationFilters?.site_name,
},
fromDate || undefined,
toDate || undefined,
locationFilters
);
onClose();
window.location.href = `${APP_BASE}${path}`;
};
const navigateToMaintenance = (assignee: string, status: string) => {
const path = buildMaintenanceUrl(
{ assignee, status },
fromDate || undefined,
toDate || undefined,
locationFilters
);
onClose();
window.location.href = `${APP_BASE}${path}`;
};
const handleAssigneeCellClick = (assignee: string, status: string, value: number) => {
if (redirectContext !== 'assignees_status' || value <= 0) return;
navigateToMaintenance(assignee, status);
};
const handleStatusRowClick = (status: string) => {
if (redirectContext !== 'wo_status') return;
onClose();
navigate(
buildWoUrl({ status }, fromDate || undefined, toDate || undefined, locationFilters)
);
};
const handleTypeCellClick = (rowLabel: string, columnName: string) => {
if (redirectContext !== 'wo_type') return;
if (columnName === 'Rejected') {
navigateToWorkOrders({ work_order_type: rowLabel, status: 'Rejected' });
return;
}
if (columnName === 'In Progress') {
navigateToWorkOrders({ work_order_type: rowLabel, status: 'Work In Progress' });
return;
}
if (columnName === 'Completed WOs' || columnName === 'Completion Rate (%)') {
navigateToWorkOrders({ work_order_type: rowLabel, status: 'Completed,Closed' });
return;
}
navigateToWorkOrders({ work_order_type: rowLabel });
};
const dateRangeLabel =
fromDate || toDate
? t('dashboard.activeDateRange', {
defaultValue: 'Date range: {{from}} to {{to}}',
from: fromDate || '—',
to: toDate || '—',
})
: t('dashboard.allDates', { defaultValue: 'Showing all dates' });
if (!isOpen) return null;
const isTypeMode = redirectContext === 'wo_type';
const isAssigneeMode = redirectContext === 'assignees_status' || !!(chartData?.rawRows?.length);
return (
<div className="fixed inset-0 z-[70] flex items-center justify-center p-4 bg-black/50 backdrop-blur-sm">
<div className="w-full max-w-4xl max-h-[90vh] overflow-hidden rounded-xl bg-white dark:bg-gray-900 shadow-2xl border border-gray-200 dark:border-gray-700 flex flex-col">
<div
className={`px-5 py-4 border-b border-gray-200 dark:border-gray-700 ${
isTypeMode || isAssigneeMode
? 'bg-gradient-to-r from-indigo-600 to-purple-600 text-white'
: ''
}`}
>
<div className="flex items-center justify-between gap-3">
<div>
<h3 className={`text-lg font-semibold ${isTypeMode || isAssigneeMode ? 'text-white' : 'text-gray-900 dark:text-white'}`}>
{title}
</h3>
<p
className={`text-xs mt-1 ${
isTypeMode || isAssigneeMode ? 'text-white/85' : 'text-indigo-600 dark:text-indigo-300'
}`}
>
{isTypeMode
? t('dashboard.clickRowToFilter', {
defaultValue: 'Click a row or value to view filtered work orders',
})
: dateRangeLabel}
</p>
</div>
<button
type="button"
onClick={onClose}
className={`p-2 rounded-lg ${
isTypeMode || isAssigneeMode
? 'hover:bg-white/10 text-white'
: 'hover:bg-gray-100 dark:hover:bg-gray-800 text-gray-500'
}`}
aria-label="Close"
>
<FaTimes />
</button>
</div>
</div>
<div className="px-5 py-3 border-b border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800/50">
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-3 items-end">
<div>
<label className="block text-xs font-medium text-gray-600 dark:text-gray-300 mb-1">
{t('dashboard.fromDate', { defaultValue: 'From Date' })}
</label>
<input
type="date"
value={fromDate}
onChange={e => setFromDate(e.target.value)}
className="w-full rounded-md border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-900 px-3 py-2 text-sm"
/>
</div>
<div>
<label className="block text-xs font-medium text-gray-600 dark:text-gray-300 mb-1">
{t('dashboard.toDate', { defaultValue: 'To Date' })}
</label>
<input
type="date"
value={toDate}
onChange={e => setToDate(e.target.value)}
className="w-full rounded-md border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-900 px-3 py-2 text-sm"
/>
</div>
<div className="flex flex-wrap gap-2 lg:col-span-2">
<button
type="button"
onClick={handleApply}
disabled={loading}
className="inline-flex items-center gap-2 px-3 py-2 rounded-md bg-indigo-600 hover:bg-indigo-700 text-white text-sm disabled:opacity-60"
>
<FaSync className={loading ? 'animate-spin' : ''} />
{t('dashboard.applyFilters', { defaultValue: 'Apply' })}
</button>
<button
type="button"
onClick={handleClearDates}
disabled={loading}
className="px-3 py-2 rounded-md border border-gray-300 dark:border-gray-600 text-sm text-gray-700 dark:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-700 disabled:opacity-60"
>
{t('dashboard.clearDates', { defaultValue: 'Clear dates' })}
</button>
</div>
</div>
{isTypeMode && (
<p className="text-xs text-gray-500 dark:text-gray-400 mt-2">{dateRangeLabel}</p>
)}
</div>
<div className="overflow-y-auto flex-1">
{error && (
<div className="mx-5 mt-4 rounded-md border border-red-200 bg-red-50 text-red-700 px-4 py-3 text-sm">
{error}
</div>
)}
{isTypeMode ? (
<table className="min-w-full text-sm">
<thead className="bg-gray-50 dark:bg-gray-800/80 sticky top-0">
<tr>
<th className="px-4 py-3 text-left font-semibold text-gray-700 dark:text-gray-200">
{t('dashboard.woType', { defaultValue: 'WO Type' })}
</th>
{typeColumns.map(column => (
<th
key={column}
className="px-4 py-3 text-right font-semibold text-gray-700 dark:text-gray-200 whitespace-nowrap"
>
{column}
</th>
))}
</tr>
</thead>
<tbody>
{loading ? (
<tr>
<td colSpan={typeColumns.length + 1} className="px-5 py-10 text-center text-gray-500">
{t('common.loading', { defaultValue: 'Loading...' })}
</td>
</tr>
) : typeRows.length === 0 ? (
<tr>
<td colSpan={typeColumns.length + 1} className="px-5 py-10 text-center text-gray-500">
{t('common.noData', { defaultValue: 'No data' })}
</td>
</tr>
) : (
typeRows.map(row => (
<tr key={row.label} className="border-t border-gray-100 dark:border-gray-800">
<td className="px-4 py-3">
<button
type="button"
onClick={() => handleTypeCellClick(row.label, 'Total WOs')}
className="text-left font-medium text-indigo-700 dark:text-indigo-300 hover:underline"
>
{row.label}
</button>
</td>
{row.cells.map((value, index) => (
<td key={`${row.label}-${typeColumns[index]}`} className="px-4 py-3 text-right">
<button
type="button"
onClick={() => handleTypeCellClick(row.label, typeColumns[index])}
className="font-semibold text-gray-900 dark:text-white hover:text-indigo-600 dark:hover:text-indigo-300"
>
{typeColumns[index] === 'Completion Rate (%)'
? `${value.toFixed(2)}%`
: value}
</button>
</td>
))}
</tr>
))
)}
</tbody>
</table>
) : isAssigneeMode ? (
<table className="min-w-full text-sm">
<thead className="bg-gray-50 dark:bg-gray-800/80 sticky top-0">
<tr>
<th className="px-4 py-3 text-left font-semibold text-gray-700 dark:text-gray-200">
{t('dashboard.assignedTo', { defaultValue: 'Assigned To' })}
</th>
<th className="px-4 py-3 text-right font-semibold text-indigo-600 dark:text-indigo-300">
{t('dashboard.plannedTasks', { defaultValue: 'Planned' })}
</th>
<th className="px-4 py-3 text-right font-semibold text-green-600 dark:text-green-400">
{t('dashboard.completedTasks', { defaultValue: 'Completed' })}
</th>
<th className="px-4 py-3 text-right font-semibold text-amber-600 dark:text-amber-400">
{t('dashboard.cancelledTasks', { defaultValue: 'Cancelled' })}
</th>
<th className="px-4 py-3 text-right font-semibold text-red-600 dark:text-red-400">
{t('dashboard.overdueTasks', { defaultValue: 'Overdue' })}
</th>
<th className="px-4 py-3 text-right font-semibold text-gray-700 dark:text-gray-200">
{t('dashboard.totalTasks', { defaultValue: 'Total' })}
</th>
</tr>
</thead>
<tbody>
{loading ? (
<tr>
<td colSpan={6} className="px-5 py-10 text-center text-gray-500">
{t('common.loading', { defaultValue: 'Loading...' })}
</td>
</tr>
) : assigneeRows.length === 0 ? (
<tr>
<td colSpan={6} className="px-5 py-10 text-center text-gray-500">
{t('common.noData', { defaultValue: 'No data' })}
</td>
</tr>
) : (
assigneeRows.map(row => (
<tr key={row.assignee} className="border-t border-gray-100 dark:border-gray-800">
<td className="px-4 py-3 text-gray-800 dark:text-gray-100">{row.assignee}</td>
<td className="px-4 py-3 text-right">
<AssigneeCountButton
value={row.planned}
onClick={() => handleAssigneeCellClick(row.assignee, 'Planned', row.planned)}
/>
</td>
<td className="px-4 py-3 text-right">
<AssigneeCountButton
value={row.completed}
onClick={() => handleAssigneeCellClick(row.assignee, 'Completed', row.completed)}
/>
</td>
<td className="px-4 py-3 text-right">
<AssigneeCountButton
value={row.cancelled}
onClick={() => handleAssigneeCellClick(row.assignee, 'Cancelled', row.cancelled)}
/>
</td>
<td className="px-4 py-3 text-right">
<AssigneeCountButton
value={row.overdue}
onClick={() => handleAssigneeCellClick(row.assignee, 'Overdue', row.overdue)}
/>
</td>
<td className="px-4 py-3 text-right font-semibold text-gray-900 dark:text-white">
{row.total}
</td>
</tr>
))
)}
</tbody>
{!loading && assigneeRows.length > 0 && (
<tfoot>
<tr className="border-t border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800/80">
<td className="px-4 py-3 font-semibold text-gray-800 dark:text-gray-100">
{t('dashboard.totalTasks', { defaultValue: 'Total' })}
</td>
<td className="px-4 py-3 text-right font-semibold text-indigo-600 dark:text-indigo-300">
{assigneeTotals.planned}
</td>
<td className="px-4 py-3 text-right font-semibold text-green-600 dark:text-green-400">
{assigneeTotals.completed}
</td>
<td className="px-4 py-3 text-right font-semibold text-amber-600 dark:text-amber-400">
{assigneeTotals.cancelled}
</td>
<td className="px-4 py-3 text-right font-semibold text-red-600 dark:text-red-400">
{assigneeTotals.overdue}
</td>
<td className="px-4 py-3 text-right font-semibold text-gray-900 dark:text-white">
{assigneeTotals.total}
</td>
</tr>
</tfoot>
)}
</table>
) : (
<table className="min-w-full text-sm">
<thead className="bg-gray-50 dark:bg-gray-800/80 sticky top-0">
<tr>
<th className="px-5 py-3 text-left font-semibold text-gray-700 dark:text-gray-200">
{t('commonFields.status', { defaultValue: 'Status' })}
</th>
<th className="px-5 py-3 text-right font-semibold text-gray-700 dark:text-gray-200">
{t('dashboard.workOrders', { defaultValue: 'Work Orders' })}
</th>
</tr>
</thead>
<tbody>
{loading ? (
<tr>
<td colSpan={2} className="px-5 py-10 text-center text-gray-500">
{t('common.loading', { defaultValue: 'Loading...' })}
</td>
</tr>
) : statusRows.length === 0 ? (
<tr>
<td colSpan={2} className="px-5 py-10 text-center text-gray-500">
{t('common.noData', { defaultValue: 'No data' })}
</td>
</tr>
) : (
statusRows.map(row => (
<tr
key={row.label}
className={`border-t border-gray-100 dark:border-gray-800 ${
redirectContext === 'wo_status'
? 'cursor-pointer hover:bg-indigo-50 dark:hover:bg-indigo-900/20'
: ''
}`}
onClick={() => handleStatusRowClick(row.label)}
>
<td className="px-5 py-3">
<div className="flex items-center gap-2">
<span
className="w-2.5 h-2.5 rounded-full flex-shrink-0"
style={{ backgroundColor: row.color }}
/>
<span className="text-gray-800 dark:text-gray-100">{row.label}</span>
</div>
</td>
<td className="px-5 py-3 text-right font-semibold text-gray-900 dark:text-white">
{row.value}
</td>
</tr>
))
)}
</tbody>
{!loading && statusRows.length > 0 && !chartData?.hideFooterTotal && (
<tfoot>
<tr className="border-t border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800/80">
<td className="px-5 py-3 font-semibold text-gray-800 dark:text-gray-100">
{t('dashboard.totalWorkOrders', { defaultValue: 'Total Work Orders' })}
</td>
<td className="px-5 py-3 text-right font-semibold text-gray-900 dark:text-white">
{statusTotal}
</td>
</tr>
</tfoot>
)}
</table>
)}
</div>
</div>
</div>
);
};
export default ChartDetailModal;
const AssigneeCountButton: React.FC<{ value: number; onClick: () => void }> = ({ value, onClick }) => {
if (value <= 0) {
return <span className="text-gray-400">0</span>;
}
return (
<button
type="button"
onClick={onClick}
className="font-semibold text-gray-900 dark:text-white hover:text-indigo-600 dark:hover:text-indigo-300"
>
{value}
</button>
);
};

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

View File

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

View File

@ -0,0 +1,164 @@
import React, { useEffect, useState } from 'react';
import { useNavigate, useLocation } from 'react-router-dom';
import { useTheme } from '../contexts/ThemeContext';
import { useLanguage } from '../contexts/LanguageContext';
import { useTranslation } from 'react-i18next';
import { Moon, Sun, Languages, LogOut, UserCircle, Menu } from 'lucide-react';
import NotificationBell from './NotificationBell';
import { useSidebarLayout } from '../contexts/SidebarLayoutContext';
interface HeaderProps {
userEmail?: string;
}
const getStoredUserDisplayName = (): string => {
try {
const user = localStorage.getItem('user');
if (!user) return '';
const parsed = JSON.parse(user);
return parsed.full_name || parsed.email || parsed.user_id || parsed.name || '';
} catch {
return '';
}
};
const Header: React.FC<HeaderProps> = ({ userEmail }) => {
const navigate = useNavigate();
const location = useLocation();
const { theme, toggleTheme } = useTheme();
const { language, changeLanguage } = useLanguage();
const { t } = useTranslation();
const [userDisplayName, setUserDisplayName] = useState<string>(getStoredUserDisplayName);
const { openMobileSidebar } = useSidebarLayout();
useEffect(() => {
const fetchUserDisplayName = 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();
setUserDisplayName(fullNameData.data?.full_name || email);
return;
}
} catch (error) {
console.error('Error fetching user display name:', error);
}
if (userEmail) {
setUserDisplayName(userEmail);
}
};
fetchUserDisplayName();
}, [userEmail]);
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] || '';
await fetch('/api/method/frappe.auth.logout', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Frappe-CSRF-Token': csrfToken,
},
credentials: 'include',
});
await fetch('/?cmd=web_logout', {
credentials: 'include',
});
} catch (err) {
console.error('Logout error:', err);
} finally {
window.location.href = '/asm_app/login';
}
};
const isProfileActive = location.pathname === '/user-profile';
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">
<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"
aria-label={t('common.menu', { defaultValue: 'Open menu' })}
>
<Menu size={20} />
</button>
<div className="flex items-center justify-end gap-2 flex-1 min-w-0">
<button
type="button"
onClick={() => navigate('/user-profile')}
className={`
flex items-center gap-2 px-3 py-2 rounded-lg transition-colors text-white
${isProfileActive
? 'bg-indigo-700 ring-2 ring-indigo-300 dark:ring-indigo-500'
: 'bg-indigo-600 hover:bg-indigo-700'}
`}
aria-label={userDisplayName || t('common.userProfile', { defaultValue: 'User Profile' })}
>
{userDisplayName && (
<span className="text-sm font-medium max-w-[180px] truncate">
{userDisplayName}
</span>
)}
<UserCircle size={20} className="flex-shrink-0" />
</button>
<div className="relative">
<NotificationBell />
</div>
<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>
<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>
<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;

View File

@ -0,0 +1,68 @@
import React, { useMemo } from 'react';
type HorizontalBarDataset = {
name: string;
values: number[];
colors?: string[];
};
type HorizontalBarChartProps = {
labels: string[];
datasets: HorizontalBarDataset[];
valueLabel?: string;
};
const HorizontalBarChart: React.FC<HorizontalBarChartProps> = ({
labels,
datasets,
valueLabel = '',
}) => {
const primaryDataset = datasets[0];
const values = primaryDataset?.values || [];
const maxValue = useMemo(() => Math.max(...values.map(value => Number(value) || 0), 1), [values]);
if (!labels.length || !primaryDataset) {
return (
<div className="h-48 flex items-center justify-center text-gray-400 text-xs">
No chart data available
</div>
);
}
const formatValue = (value: number) => {
const rounded = Math.round(value * 100) / 100;
return valueLabel ? `${rounded} ${valueLabel}` : String(rounded);
};
return (
<div className="max-h-72 overflow-y-auto pr-1 space-y-2">
{labels.map((label, rowIndex) => {
const value = Number(values[rowIndex]) || 0;
const widthPct = (value / maxValue) * 100;
return (
<div key={`${label}-${rowIndex}`} className="grid grid-cols-[7rem_1fr_auto] gap-2 items-center">
<div className="text-xs text-gray-700 dark:text-gray-300 truncate text-right" title={label}>
{label}
</div>
<div className="h-7 bg-gray-100 dark:bg-gray-700/60 rounded-md overflow-hidden">
<div
className="h-full rounded-md bg-gradient-to-r from-indigo-500 to-purple-500 transition-opacity"
style={{
width: `${widthPct}%`,
minWidth: value > 0 ? '4px' : 0,
}}
/>
</div>
<div className="text-xs font-semibold text-gray-700 dark:text-gray-200 whitespace-nowrap">
{formatValue(value)}
</div>
</div>
);
})}
</div>
);
};
export default HorizontalBarChart;

View File

@ -0,0 +1,312 @@
import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react';
import { createPortal } from 'react-dom';
import apiService from '../services/apiService';
interface LinkFieldProps {
label: string;
doctype: string;
value: string;
onChange: (value: string) => void;
placeholder?: string;
disabled?: boolean;
filters?: Record<string, any>;
query?: string;
compact?: boolean;
usePortal?: boolean; // New prop to enable portal rendering
}
// Stable empty object to avoid re-renders
const EMPTY_FILTERS: Record<string, any> = {};
const LinkField: React.FC<LinkFieldProps> = ({
label,
doctype,
value,
onChange,
placeholder,
disabled = false,
filters,
query,
compact = false,
usePortal = false, // Default to false for backward compatibility
}) => {
const [searchResults, setSearchResults] = useState<{ value: string; description?: string }[]>([]);
const [searchText, setSearchText] = useState('');
const [isDropdownOpen, setDropdownOpen] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const [dropdownPosition, setDropdownPosition] = useState<{ top: number; left: number; width: number }>({ top: 0, left: 0, width: 0 });
const containerRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
const dropdownRef = useRef<HTMLDivElement>(null);
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const lastSearchRef = useRef<string>('');
const hasLoadedRef = useRef<boolean>(false);
// Use stable empty object if filters not provided
const stableFilters = filters || EMPTY_FILTERS;
// Stringify filters for comparison (avoid object reference issues)
const filtersKey = useMemo(() => JSON.stringify(stableFilters), [stableFilters]);
const queryKey = query || '';
// 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}-${queryKey}`;
if (!force && lastSearchRef.current === searchKey) {
return;
}
lastSearchRef.current = searchKey;
setIsLoading(true);
try {
const params = new URLSearchParams({
doctype,
txt: text,
});
// Add filters if provided
if (stableFilters && Object.keys(stableFilters).length > 0) {
params.append('filters', JSON.stringify(stableFilters));
}
if (query) {
params.append('query', query);
}
const response = await apiService.apiCall<{ value: string; description?: string }[]>(
`/api/method/frappe.desk.search.search_link?${params.toString()}`
);
setSearchResults(response || []);
} catch (error) {
console.error(`Error fetching ${doctype} links:`, error);
setSearchResults([]);
} finally {
setIsLoading(false);
}
}, [doctype, filtersKey, queryKey, 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);
};
// Render dropdown content
const renderDropdown = () => {
const dropdownClasses = `bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600
rounded-md w-full shadow-lg ${compact ? 'mt-0.5' : 'mt-1'}`;
const positionStyle = usePortal ? {
position: 'fixed' as const,
top: `${dropdownPosition.top}px`,
left: `${dropdownPosition.left}px`,
width: `${dropdownPosition.width}px`,
zIndex: 1050,
marginTop: compact ? '2px' : '4px'
} : {};
if (!isDropdownOpen || disabled) return null;
const dropdownContent = (
<div ref={dropdownRef}>
{/* Loading indicator */}
{isLoading && (
<div className={`${usePortal ? '' : 'absolute z-[1050]'} ${dropdownClasses} text-center text-gray-500 dark:text-gray-400
${compact ? 'p-1.5 text-[10px]' : 'p-3 text-sm'}`}
style={positionStyle}>
<span className="inline-block animate-spin mr-2"></span>
Loading...
</div>
)}
{/* Results list */}
{!isLoading && searchResults.length > 0 && (
<ul className={`${usePortal ? '' : 'absolute z-[1050]'} ${dropdownClasses} overflow-auto
${compact ? 'max-h-36' : 'max-h-48'}`}
style={positionStyle}>
{searchResults.map((item, idx) => (
<li
key={idx}
onClick={() => handleSelect(item.value)}
className={`cursor-pointer text-gray-900 dark:text-gray-100
hover:bg-blue-500 dark:hover:bg-blue-600 hover:text-white
${compact ? 'px-2 py-1 text-xs' : 'px-3 py-2 text-sm'}
${value === item.value ? 'bg-blue-50 dark:bg-blue-700 font-semibold' : ''}`}
>
{item.value}
{item.description && (
<span className={`text-gray-600 dark:text-gray-300 ml-2
${compact ? 'text-[9px] ml-1' : 'text-xs ml-2'}`}>
{item.description}
</span>
)}
</li>
))}
</ul>
)}
{/* No results message */}
{!isLoading && searchResults.length === 0 && (
<div className={`${usePortal ? '' : 'absolute z-[1050]'} ${dropdownClasses} text-center text-gray-500 dark:text-gray-400
${compact ? 'p-1.5 text-[10px]' : 'p-3 text-sm'}`}
style={positionStyle}>
No results found
</div>
)}
</div>
);
return usePortal ? createPortal(dropdownContent, document.body) : dropdownContent;
};
return (
<div ref={containerRef} className={`relative w-full ${compact ? 'mb-2' : 'mb-4'}`}>
<label className={`block font-medium text-gray-700 dark:text-gray-300 ${compact ? 'text-[10px] mb-0.5' : 'text-sm mb-1'}`}>
{label}
</label>
<div className="relative">
<input
ref={inputRef}
type="text"
value={isDropdownOpen ? searchText : value}
placeholder={placeholder || `Select ${label}`}
disabled={disabled}
className={`w-full border border-gray-300 dark:border-gray-600 rounded-md
focus:outline-none disabled:bg-gray-100 dark:disabled:bg-gray-700
bg-white dark:bg-gray-700 text-gray-900 dark:text-white
${compact
? 'px-2 py-1 text-xs focus:ring-1 focus:ring-blue-500 rounded'
: 'px-3 py-2 text-sm focus:ring-2 focus:ring-blue-500'
}
${value ? (compact ? 'pr-5' : 'pr-8') : ''}`}
onFocus={() => {
if (!disabled) {
setDropdownOpen(true);
setSearchText('');
if (usePortal) {
updateDropdownPosition();
}
}
}}
onChange={(e) => {
const text = e.target.value;
setSearchText(text);
debouncedSearch(text); // Use debounced search to prevent rapid API calls
}}
/>
{/* Clear button */}
{value && !disabled && !isDropdownOpen && (
<button
type="button"
onClick={handleClear}
className={`absolute top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300
${compact ? 'right-1 text-xs' : 'right-2 text-sm'}`}
>
</button>
)}
</div>
{/* Render dropdown */}
{renderDropdown()}
</div>
);
};
export default LinkField;

View File

@ -0,0 +1,107 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import type { DateFilterField } from '../utils/listFilterUtils';
interface ListFilterSortControlsProps {
sortBy: string;
dateFilterBy: DateFilterField;
dateStart: string;
dateEnd: string;
onSortByChange: (value: string) => void;
onDateFilterByChange: (value: DateFilterField) => void;
onDateStartChange: (value: string) => void;
onDateEndChange: (value: string) => void;
/** Show asset_name sort options (Asset list only) */
includeAssetNameSort?: boolean;
}
/** Inline sort + date filter fields — place inside the same filter grid as other filters */
const ListFilterSortControls: React.FC<ListFilterSortControlsProps> = ({
sortBy,
dateFilterBy,
dateStart,
dateEnd,
onSortByChange,
onDateFilterByChange,
onDateStartChange,
onDateEndChange,
includeAssetNameSort = false,
}) => {
const { t } = useTranslation();
const fieldClass =
'w-full px-2 py-1 text-xs border border-gray-300 dark:border-gray-600 rounded focus:outline-none focus:ring-2 focus:ring-blue-500 bg-white dark:bg-gray-700 text-gray-900 dark:text-white';
return (
<>
<div className="relative">
<label className="block text-[10px] font-medium text-gray-700 dark:text-gray-300 mb-0.5">
{t('filters.sortBy', { defaultValue: 'Sort By' })}
</label>
<select value={sortBy} onChange={(e) => onSortByChange(e.target.value)} className={fieldClass}>
<option value="creation desc">{t('filters.sortCreationNewest', { defaultValue: 'Created (Newest)' })}</option>
<option value="creation asc">{t('filters.sortCreationOldest', { defaultValue: 'Created (Oldest)' })}</option>
<option value="modified desc">{t('filters.sortModifiedNewest', { defaultValue: 'Modified (Newest)' })}</option>
<option value="modified asc">{t('filters.sortModifiedOldest', { defaultValue: 'Modified (Oldest)' })}</option>
{includeAssetNameSort && (
<>
<option value="asset_name asc">{t('filters.sortAssetNameAsc', { defaultValue: 'Asset Name (A-Z)' })}</option>
<option value="asset_name desc">{t('filters.sortAssetNameDesc', { defaultValue: 'Asset Name (Z-A)' })}</option>
</>
)}
<option value="name asc">{t('filters.sortNameAsc', { defaultValue: 'Name (A-Z)' })}</option>
<option value="name desc">{t('filters.sortNameDesc', { defaultValue: 'Name (Z-A)' })}</option>
</select>
</div>
<div className="relative">
<label className="block text-[10px] font-medium text-gray-700 dark:text-gray-300 mb-0.5">
{t('filters.filterBy', { defaultValue: 'Filter By' })}
</label>
<select
value={dateFilterBy}
onChange={(e) => onDateFilterByChange(e.target.value as DateFilterField)}
className={fieldClass}
>
<option value="">{t('filters.filterBy', { defaultValue: 'Filter By' })}</option>
<option value="creation">{t('filters.createdDate', { defaultValue: 'Created Date' })}</option>
<option value="modified">{t('filters.latestModifiedDate', { defaultValue: 'Latest Modified Date' })}</option>
</select>
</div>
{dateFilterBy && (
<>
<div className="relative">
<label className="block text-[10px] font-medium text-gray-700 dark:text-gray-300 mb-0.5">
{t('filters.startDate', { defaultValue: 'Start Date' })}
</label>
<input
type="date"
value={dateStart}
onChange={(e) => {
const v = e.target.value;
onDateStartChange(v);
if (dateEnd && v > dateEnd) onDateEndChange(v);
}}
className={fieldClass}
/>
</div>
<div className="relative">
<label className="block text-[10px] font-medium text-gray-700 dark:text-gray-300 mb-0.5">
{t('filters.endDate', { defaultValue: 'End Date' })}
</label>
<input
type="date"
value={dateEnd}
onChange={(e) => onDateEndChange(e.target.value)}
min={dateStart || undefined}
className={fieldClass}
/>
</div>
</>
)}
</>
);
};
export default ListFilterSortControls;

View File

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

View File

@ -0,0 +1,315 @@
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';
interface MentionInputProps {
value: string;
onChange: (text: string) => void;
onSubmit: (html: string) => void;
placeholder?: string;
disabled?: boolean;
mentionUsers: MentionUser[];
mentionLoading: boolean;
onMentionSearch: (query: string) => void;
posting?: boolean;
}
interface InsertedMention {
startIndex: number;
displayText: string;
userId: string;
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);
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[]>([]);
const updateDropdownPosition = useCallback(() => {
const ta = textareaRef.current;
if (!ta) return;
ta.getBoundingClientRect();
ta.offsetParent?.getBoundingClientRect();
}, []);
const handleChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
const newValue = e.target.value;
const cursorPos = e.target.selectionStart ?? 0;
onChange(newValue);
const textBeforeCursor = newValue.substring(0, cursorPos);
const lastAtIndex = textBeforeCursor.lastIndexOf('@');
if (lastAtIndex !== -1) {
const charBefore = lastAtIndex > 0 ? newValue[lastAtIndex - 1] : ' ';
if (charBefore === ' ' || charBefore === '\n' || lastAtIndex === 0) {
const query = textBeforeCursor.substring(lastAtIndex + 1);
if (!query.includes(' ') || query.length <= 30) {
setShowMentionDropdown(true);
setMentionQuery(query);
setMentionStartPos(lastAtIndex);
setSelectedIndex(0);
onMentionSearch(query);
updateDropdownPosition();
return;
}
}
}
setShowMentionDropdown(false);
setMentionStartPos(null);
};
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}`;
setInsertedMentions((prev) => [
...prev,
{
startIndex: mentionStartPos,
displayText,
userId: user.name,
fullName: user.full_name || user.name,
},
]);
onChange(newText);
setShowMentionDropdown(false);
setMentionStartPos(null);
setMentionQuery('');
setTimeout(() => {
if (ta) {
ta.focus();
const newPos = before.length + displayText.length + 2;
ta.selectionStart = newPos;
ta.selectionEnd = newPos;
}
}, 0);
},
[mentionStartPos, mentionQuery, value, onChange]
);
const buildHtml = useCallback(
(text: string): string => {
let html = text;
const baseUrl = API_CONFIG.BASE_URL || window.location.origin;
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);
}
html = html.replace(/\n/g, '<br>');
return `<div class="ql-editor read-mode"><p>${html}</p></div>`;
},
[insertedMentions]
);
const handleSubmit = () => {
const trimmed = value.trim();
if (!trimmed || posting) return;
const html = buildHtml(trimmed);
onSubmit(html);
setInsertedMentions([]);
};
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;
}
}
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) {
e.preventDefault();
handleSubmit();
}
};
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);
}, []);
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">
{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();
insertMention(user);
}}
>
{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>
)}
<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>
<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,247 @@
import React, { useState, useRef, useEffect, useCallback } from 'react';
import { createPortal } from 'react-dom';
import { Bell } from 'lucide-react';
import { useNotifications } from '../hooks/useNotifications';
import { useNavigate } from 'react-router-dom';
import { FaBell } from 'react-icons/fa';
const NotificationBell: React.FC = () => {
const { notifications, unreadCount, markAsRead, markAllAsRead } = useNotifications();
const [isOpen, setIsOpen] = useState(false);
const buttonRef = useRef<HTMLDivElement>(null);
const panelRef = useRef<HTMLDivElement>(null);
const navigate = useNavigate();
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
const target = event.target as Node;
if (buttonRef.current?.contains(target) || panelRef.current?.contains(target)) {
return;
}
setIsOpen(false);
};
if (isOpen) {
document.addEventListener('mousedown', handleClickOutside);
}
return () => {
document.removeEventListener('mousedown', handleClickOutside);
};
}, [isOpen]);
useEffect(() => {
if (!isOpen) return;
const mq = window.matchMedia('(max-width: 1023px)');
if (!mq.matches) return;
const previousOverflow = document.body.style.overflow;
document.body.style.overflow = 'hidden';
return () => {
document.body.style.overflow = previousOverflow;
};
}, [isOpen]);
const handleNotificationClick = async (notification: any) => {
if (!notification.read) {
try {
await markAsRead(notification.name);
} catch (error) {
console.warn('[NotificationBell] Could not mark as read (permission issue):', error);
}
}
if (notification.document_type && notification.document_name) {
const docType = notification.document_type;
const docName = notification.document_name;
const normalizedType = docType.replace(/_/g, ' ').trim();
if (normalizedType === 'Asset Maintenance Log' || normalizedType === 'Asset Maintenance') {
navigate(`/maintenance/${docName}`);
} else if (normalizedType === 'Work Order' || normalizedType === 'Asset Repair') {
navigate(`/work-orders/${docName}`);
} else if (normalizedType === 'Asset') {
navigate(`/assets/${docName}`);
} else if (normalizedType === 'PM Schedule Generator' || normalizedType === 'PM Schedule') {
navigate(`/ppm-planner/${docName}`);
} else if (normalizedType === 'PPM') {
navigate(`/ppm/${docName}`);
} else if (normalizedType === 'Item') {
navigate(`/inventory/${docName}`);
} else {
const frappeRoute = docType.toLowerCase().replace(/\s+/g, '-').replace(/_/g, '-');
window.open(`/app/${frappeRoute}/${docName}`, '_blank');
}
}
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);
const renderPanel = () => (
<div
ref={panelRef}
className="
fixed z-[9999] flex flex-col overflow-hidden
bg-white dark:bg-gray-800 rounded-xl shadow-2xl
border border-gray-200 dark:border-gray-700
left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2
w-[calc(100vw-2rem)] max-w-md max-h-[75vh]
lg:left-auto lg:top-[3.75rem] lg:right-4 lg:translate-x-0 lg:translate-y-0 lg:w-80 lg:max-h-96
"
>
<div className="p-4 border-b border-gray-200 dark:border-gray-700 flex items-center justify-between gap-2">
<h3 className="font-semibold text-gray-800 dark:text-white flex items-center gap-2 min-w-0">
<FaBell />
<span className="truncate">Notifications</span>
{unreadCount > 0 && (
<span className="text-xs bg-red-500 text-white px-2 py-0.5 rounded-full shrink-0">
{unreadCount} new
</span>
)}
</h3>
{unreadCount > 0 && (
<button
type="button"
onClick={markAllAsRead}
className="text-xs text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300 shrink-0"
>
Mark all read
</button>
)}
</div>
<div className="overflow-y-auto flex-1 min-h-0">
{notifications.length === 0 ? (
<div className="p-8 text-center text-gray-500 dark:text-gray-400">
<FaBell className="mx-auto text-3xl mb-2 opacity-50" />
<p>No notifications</p>
</div>
) : (
<>
{unreadNotifications.length > 0 && (
<div className="p-2">
<div className="text-xs font-semibold text-gray-500 dark:text-gray-400 px-2 mb-1">
NEW
</div>
{unreadNotifications.map(notif => (
<div
key={notif.name}
onClick={() => handleNotificationClick(notif)}
className="p-3 hover:bg-gray-50 dark:hover:bg-gray-700 cursor-pointer border-l-4 border-blue-500 bg-blue-50 dark:bg-blue-900/20"
>
<div className="flex items-start justify-between gap-2">
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-gray-900 dark:text-white truncate">
{(notif as any).subject || notif.document_type || 'Notification'}
</p>
{(notif as any).email_content && (
<p className="text-xs text-gray-600 dark:text-gray-400 mt-1 line-clamp-2">
{(notif as any).email_content}
</p>
)}
<p className="text-xs text-gray-500 dark:text-gray-500 mt-1">
{formatDate(notif.creation)}
</p>
</div>
<div className="w-2 h-2 bg-blue-500 rounded-full flex-shrink-0 mt-1" />
</div>
</div>
))}
</div>
)}
{readNotifications.length > 0 && (
<div className="p-2 border-t border-gray-200 dark:border-gray-700">
{unreadNotifications.length > 0 && (
<div className="text-xs font-semibold text-gray-500 dark:text-gray-400 px-2 mb-1">
EARLIER
</div>
)}
{readNotifications.map(notif => (
<div
key={notif.name}
onClick={() => handleNotificationClick(notif)}
className="p-3 hover:bg-gray-50 dark:hover:bg-gray-700 cursor-pointer"
>
<div className="flex items-start justify-between gap-2">
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-gray-700 dark:text-gray-300 truncate">
{(notif as any).subject || notif.document_type || 'Notification'}
</p>
{(notif as any).email_content && (
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1 line-clamp-2">
{(notif as any).email_content}
</p>
)}
<p className="text-xs text-gray-400 dark:text-gray-500 mt-1">
{formatDate(notif.creation)}
</p>
</div>
</div>
</div>
))}
</div>
)}
</>
)}
</div>
</div>
);
const closePanel = useCallback(() => setIsOpen(false), []);
return (
<div className="relative" ref={buttonRef}>
<button
type="button"
onClick={() => setIsOpen(prev => !prev)}
className="relative p-2 min-h-[44px] min-w-[44px] flex items-center justify-center 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"
aria-expanded={isOpen}
aria-label="Notifications"
>
<Bell size={20} />
{unreadCount > 0 && (
<span className="absolute top-1 right-1 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 &&
createPortal(
<>
<button
type="button"
className="fixed inset-0 z-[9998] bg-black/40 lg:bg-black/20"
onClick={closePanel}
aria-label="Close notifications"
/>
{renderPanel()}
</>,
document.body
)}
</div>
);
};
export default NotificationBell;

View File

@ -0,0 +1,164 @@
import React, { useCallback, useState } from 'react';
export type PieChartData = {
labels?: string[];
datasets?: Array<{ name?: string; values?: number[]; colors?: string[] }>;
total?: number;
};
type ChartTooltipState = {
x: number;
y: number;
text: string;
};
const FALLBACK_COLORS = [
'#6366F1',
'#8B5CF6',
'#06B6D4',
'#EC4899',
'#F59E0B',
'#10B981',
'#EF4444',
'#3B82F6',
];
const toNumber = (value: unknown): number => {
const n = Number(value);
return Number.isFinite(n) && n >= 0 ? n : 0;
};
const formatTooltip = (label: string, value: number, total?: number): string => {
if (total != null && total > 0) {
return `${label}: ${value} (${((value / total) * 100).toFixed(1)}%)`;
}
return `${label}: ${value}`;
};
const useChartTooltip = () => {
const [tooltip, setTooltip] = useState<ChartTooltipState | null>(null);
const show = useCallback(
(clientX: number, clientY: number, label: string, value: number, total?: number) => {
setTooltip({ x: clientX, y: clientY, text: formatTooltip(label, value, total) });
},
[]
);
const hide = useCallback(() => setTooltip(null), []);
return { tooltip, show, hide };
};
const ChartTooltipOverlay: React.FC<{ tooltip: ChartTooltipState | null }> = ({ tooltip }) => {
if (!tooltip) return null;
return (
<div
className="fixed z-[9999] pointer-events-none px-2.5 py-1.5 rounded text-xs font-medium text-white bg-gray-900 shadow-lg whitespace-nowrap"
style={{ left: tooltip.x, top: tooltip.y - 8, transform: 'translate(-50%, -100%)' }}
role="tooltip"
>
{tooltip.text}
</div>
);
};
type PieChartProps = {
data: PieChartData | null;
emptyMessage?: string;
};
const PieChart: React.FC<PieChartProps> = ({ data, emptyMessage = 'No data available' }) => {
const { tooltip, show, hide } = useChartTooltip();
const labels = data?.labels || [];
const values = (data?.datasets?.[0]?.values || []).map(toNumber);
const datasetColors = data?.datasets?.[0]?.colors;
const colors =
datasetColors && datasetColors.length === values.length
? datasetColors
: values.map((_, i) => FALLBACK_COLORS[i % FALLBACK_COLORS.length]);
const total = data?.total ?? values.reduce((sum, val) => sum + val, 0);
if (total <= 0 || labels.length === 0) {
return (
<div className="h-48 flex items-center justify-center text-gray-400 text-sm">{emptyMessage}</div>
);
}
const radius = 100;
const cx = radius + 10;
const cy = radius + 10;
let cumulative = 0;
const slices = values.map((value, i) => {
const startAngle = (cumulative / total) * 2 * Math.PI - Math.PI / 2;
cumulative += value;
const endAngle = (cumulative / total) * 2 * Math.PI - Math.PI / 2;
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);
return {
path: `M ${cx} ${cy} L ${x1} ${y1} A ${radius} ${radius} 0 ${largeArc} 1 ${x2} ${y2} Z`,
color: colors[i],
label: labels[i],
value,
percentage: ((value / total) * 100).toFixed(1),
};
});
const isSingleSlice = slices.length === 1;
return (
<div className="flex flex-col md:flex-row items-center justify-around gap-4" onMouseLeave={hide}>
<ChartTooltipOverlay tooltip={tooltip} />
<svg width={cx * 2} height={cy * 2} viewBox={`0 0 ${cx * 2} ${cy * 2}`} className="max-w-xs shrink-0">
{isSingleSlice ? (
<circle
cx={cx}
cy={cy}
r={radius}
fill={slices[0].color}
className="hover:opacity-90 transition-opacity drop-shadow-lg"
onMouseEnter={(e) => show(e.clientX, e.clientY, slices[0].label, slices[0].value, total)}
onMouseMove={(e) => show(e.clientX, e.clientY, slices[0].label, slices[0].value, total)}
onMouseLeave={hide}
>
<title>{formatTooltip(slices[0].label, slices[0].value, total)}</title>
</circle>
) : (
slices.map((slice, i) => (
<path
key={i}
d={slice.path}
fill={slice.color}
className="hover:opacity-80 transition-opacity drop-shadow-md"
onMouseEnter={(e) => show(e.clientX, e.clientY, slice.label, slice.value, total)}
onMouseMove={(e) => show(e.clientX, e.clientY, slice.label, slice.value, total)}
onMouseLeave={hide}
>
<title>{formatTooltip(slice.label, slice.value, total)}</title>
</path>
))
)}
</svg>
<div className="flex flex-col gap-2 min-w-[140px]">
{slices.map((slice, i) => (
<div key={i} className="flex items-center gap-2 text-xs text-gray-600 dark:text-gray-300">
<span className="w-3 h-3 rounded-full shrink-0" style={{ backgroundColor: slice.color }} />
<span className="truncate flex-1">{slice.label}</span>
<span className="font-semibold text-gray-800 dark:text-white">{slice.value}</span>
</div>
))}
</div>
</div>
);
};
export default PieChart;

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,147 @@
import React, { useCallback, useMemo, useState } from 'react';
import {
CompletionByTypeData,
CompletionByTypeRate,
REPAIR_COMPLETION_BAR_COLORS,
getRepairCompletionRate,
isNullOrUnknownLabel,
} from '../utils/chartLabelUtils';
type TooltipState = {
x: number;
y: number;
rate: CompletionByTypeRate;
color: string;
percentage: number;
};
type RepairStatusCompletionBarChartProps = {
data: CompletionByTypeData | null;
};
const RepairStatusCompletionBarChart: React.FC<RepairStatusCompletionBarChartProps> = ({ data }) => {
const [tooltip, setTooltip] = useState<TooltipState | null>(null);
const rates = useMemo(
() => (data?.rates || []).filter(item => !isNullOrUnknownLabel(item.type)),
[data]
);
const hideTooltip = useCallback(() => setTooltip(null), []);
if (!rates.length) {
return (
<div className="h-44 flex items-center justify-center text-gray-400 text-sm">
No completion data available
</div>
);
}
const chartHeight = 160;
const barWidth = 44;
const barGap = 18;
const paddingLeft = 36;
const paddingRight = 16;
const width = paddingLeft + rates.length * (barWidth + barGap) + paddingRight;
const gridLines = [0, 25, 50, 75, 100];
return (
<div className="relative w-full overflow-x-auto" onMouseLeave={hideTooltip}>
{tooltip && (
<div
className="fixed z-[9999] pointer-events-none rounded-lg bg-gray-900 text-white px-3 py-2 text-xs shadow-lg max-w-[220px]"
style={{ left: tooltip.x, top: tooltip.y - 8, transform: 'translate(-50%, -100%)' }}
>
<div className="flex items-center gap-2 mb-1">
<span className="w-2.5 h-2.5 rounded-sm shrink-0" style={{ backgroundColor: tooltip.color }} />
<span className="font-semibold truncate">{tooltip.rate.type}</span>
</div>
<div className="text-emerald-300 font-semibold">{tooltip.percentage.toFixed(1)}% Completed</div>
<div className="text-gray-300 mt-1">
{(tooltip.rate.completedCombined ?? (tooltip.rate.completed || 0) + (tooltip.rate.closed || 0))} completed ·{' '}
{tooltip.rate.inProgress || 0} prog · {tooltip.rate.rejected || 0} rej
</div>
<div className="text-gray-400">{tooltip.rate.total || 0} total</div>
</div>
)}
<svg width={width} height={chartHeight + 56} className="min-w-full">
{gridLines.map(level => {
const y = chartHeight - (level / 100) * chartHeight + 8;
return (
<g key={level}>
<line x1={paddingLeft} y1={y} x2={width - paddingRight} y2={y} stroke="#E5E7EB" strokeWidth="1" />
<text x={paddingLeft - 8} y={y + 4} textAnchor="end" className="fill-gray-400 text-[10px]">
{level}%
</text>
</g>
);
})}
{rates.map((rate, index) => {
const percentage = getRepairCompletionRate(rate);
const barHeight = Math.max(2, (percentage / 100) * chartHeight);
const x = paddingLeft + index * (barWidth + barGap);
const y = chartHeight - barHeight + 8;
const color = REPAIR_COMPLETION_BAR_COLORS[index % REPAIR_COMPLETION_BAR_COLORS.length];
const label = rate.type.length > 12 ? `${rate.type.slice(0, 10)}` : rate.type;
return (
<g key={rate.type}>
<rect
x={x}
y={y}
width={barWidth}
height={barHeight}
rx={4}
fill={color}
className="hover:opacity-85 transition-opacity cursor-default"
onMouseEnter={(e) =>
setTooltip({
x: e.clientX,
y: e.clientY,
rate,
color,
percentage,
})
}
onMouseMove={(e) =>
setTooltip({
x: e.clientX,
y: e.clientY,
rate,
color,
percentage,
})
}
onMouseLeave={hideTooltip}
/>
<text
x={x + barWidth / 2}
y={chartHeight + 24}
textAnchor="middle"
className="fill-gray-500 text-[10px]"
>
{label}
</text>
</g>
);
})}
</svg>
<div className="flex flex-wrap gap-3 mt-1 px-1">
{rates.slice(0, 6).map((rate, index) => (
<div key={rate.type} className="flex items-center gap-1.5 text-[10px] text-gray-500">
<span
className="w-2.5 h-2.5 rounded-sm shrink-0"
style={{ backgroundColor: REPAIR_COMPLETION_BAR_COLORS[index % REPAIR_COMPLETION_BAR_COLORS.length] }}
/>
<span className="truncate max-w-[100px]">{rate.type}</span>
</div>
))}
</div>
</div>
);
};
export default RepairStatusCompletionBarChart;

View File

@ -0,0 +1,87 @@
import React, { useMemo } from 'react';
import { FaCheckCircle, FaExternalLinkAlt } from 'react-icons/fa';
import { useTranslation } from 'react-i18next';
import RepairStatusCompletionBarChart from './RepairStatusCompletionBarChart';
import type { CompletionByTypeData } from '../utils/chartLabelUtils';
type RepairStatusCompletionCardProps = {
loading?: boolean;
totalWorkOrders: number;
completedWorkOrders: number;
inProgressWorkOrders: number;
rejectedWorkOrders: number;
byTypeChartData: CompletionByTypeData | null;
onOpenReport: () => void;
};
const RepairStatusCompletionCard: React.FC<RepairStatusCompletionCardProps> = ({
loading = false,
totalWorkOrders,
completedWorkOrders,
inProgressWorkOrders,
rejectedWorkOrders,
byTypeChartData,
onOpenReport,
}) => {
const { t } = useTranslation();
const completionRate = useMemo(() => {
if (totalWorkOrders <= 0) return '0.00';
return ((completedWorkOrders / totalWorkOrders) * 100).toFixed(2);
}, [completedWorkOrders, totalWorkOrders]);
return (
<div className="bg-white dark:bg-gray-800 rounded-lg shadow hover:shadow-md transition-all px-5 py-4 border border-gray-200 dark:border-gray-700">
<div className="flex items-start justify-between gap-3 mb-3">
<div className="flex items-start gap-3 min-w-0">
<div className="w-11 h-11 rounded-full bg-emerald-50 dark:bg-emerald-900/30 flex items-center justify-center shrink-0">
<FaCheckCircle className="text-emerald-600 dark:text-emerald-300 text-lg" />
</div>
<div>
<div className="text-2xl font-semibold text-gray-900 dark:text-white">{completionRate}%</div>
<div className="text-sm font-medium text-emerald-700 dark:text-emerald-300">
{t('dashboard.overallCompletionRate', { defaultValue: 'Overall Completion Rate' })}
</div>
</div>
</div>
<button
type="button"
onClick={onOpenReport}
className="p-2 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg text-emerald-600 dark:text-emerald-300 shrink-0"
title={t('dashboard.viewCompletionDetails', { defaultValue: 'View completion details' })}
>
<FaExternalLinkAlt className="w-4 h-4" />
</button>
</div>
{loading ? (
<div className="h-44 flex items-center justify-center">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-emerald-600" />
</div>
) : (
<RepairStatusCompletionBarChart data={byTypeChartData} />
)}
<div className="grid grid-cols-2 sm:grid-cols-4 gap-2 mt-3 pt-3 border-t border-gray-200 dark:border-gray-700">
<div className="rounded-lg bg-indigo-50 dark:bg-indigo-900/20 px-3 py-2 text-center">
<div className="text-lg font-semibold text-indigo-700 dark:text-indigo-300">{totalWorkOrders}</div>
<div className="text-[10px] uppercase tracking-wide text-gray-500 dark:text-gray-400">Total</div>
</div>
<div className="rounded-lg bg-blue-50 dark:bg-blue-900/20 px-3 py-2 text-center">
<div className="text-lg font-semibold text-blue-700 dark:text-blue-300">{inProgressWorkOrders}</div>
<div className="text-[10px] uppercase tracking-wide text-gray-500 dark:text-gray-400">In Progress</div>
</div>
<div className="rounded-lg bg-red-50 dark:bg-red-900/20 px-3 py-2 text-center">
<div className="text-lg font-semibold text-red-700 dark:text-red-300">{rejectedWorkOrders}</div>
<div className="text-[10px] uppercase tracking-wide text-gray-500 dark:text-gray-400">Rejected</div>
</div>
<div className="rounded-lg bg-emerald-50 dark:bg-emerald-900/20 px-3 py-2 text-center">
<div className="text-lg font-semibold text-emerald-700 dark:text-emerald-300">{completedWorkOrders}</div>
<div className="text-[10px] uppercase tracking-wide text-gray-500 dark:text-gray-400">Completed</div>
</div>
</div>
</div>
);
};
export default RepairStatusCompletionCard;

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,388 @@
import React, { useEffect, useState } from 'react';
import { Link, useLocation } from 'react-router-dom';
import { useLanguage } from '../contexts/LanguageContext';
import { useSidebarLayout } from '../contexts/SidebarLayoutContext';
import { useTranslation } from 'react-i18next';
import useRoleProfile from '../hooks/useRoleProfile';
import {
isEndUserRole,
isTechnicianRole,
isSidebarLinkVisible,
type SidebarLinkId,
} from '../utils/roleAccess';
import {
LayoutDashboard,
Package,
Menu,
X,
ClipboardList,
Calendar,
CalendarCheck,
Map,
Users,
ShoppingCart,
FileText,
HelpCircle,
} from 'lucide-react';
interface SidebarLink {
id: string;
title: string;
icon: React.ReactNode;
path: string;
visible: boolean;
}
interface SidebarProps {
userEmail?: string;
}
function useLgUp(): boolean {
const [isLgUp, setIsLgUp] = useState(() =>
typeof window !== 'undefined' ? window.matchMedia('(min-width: 1024px)').matches : true
);
useEffect(() => {
const mq = window.matchMedia('(min-width: 1024px)');
const handleChange = () => setIsLgUp(mq.matches);
handleChange();
mq.addEventListener('change', handleChange);
return () => mq.removeEventListener('change', handleChange);
}, []);
return isLgUp;
}
const Sidebar: React.FC<SidebarProps> = ({ userEmail }) => {
const [isCollapsed, setIsCollapsed] = useState(false);
const location = useLocation();
const { isRTL } = useLanguage();
const { t } = useTranslation();
const { closeMobileSidebar } = useSidebarLayout();
const isLgUp = useLgUp();
const { roleProfile } = useRoleProfile();
const isRoleRestricted = isEndUserRole(roleProfile) || isTechnicianRole(roleProfile);
// 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=1785757497`; // Auto-updated by build script
const logoVersion = import.meta.env.DEV
? `?v=${Date.now()}`
: `?v=1785757497`; // Auto-updated by build script
const backgroundImageUrl = baseUrl.endsWith('/')
? `${baseUrl}sidebar-background.jpg${imageVersion}`
: `${baseUrl}/sidebar-background.jpg${imageVersion}`;
const isFinanceManager = userEmail === 'financemanager@gmail.com';
const getDefaultLinkVisibility = (linkId: SidebarLinkId): boolean => {
switch (linkId) {
case 'assets':
case 'work-orders':
case 'ppm-planner':
case 'maintenance-calendar':
return !isFinanceManager;
default:
return true;
}
};
const links: SidebarLink[] = [
{
id: 'dashboard',
title: t('common.dashboard'),
icon: <LayoutDashboard size={20} />,
path: '/dashboard',
visible: isRoleRestricted
? isSidebarLinkVisible('dashboard', roleProfile)
: getDefaultLinkVisibility('dashboard')
},
{
id: 'assets',
title: t('common.assets'),
icon: <Package size={20} />,
path: '/assets',
visible: isRoleRestricted
? isSidebarLinkVisible('assets', roleProfile)
: getDefaultLinkVisibility('assets')
},
{
id: 'inventory',
title: 'Inventory',
icon: <Package size={20} />,
path: '/inventory',
visible: isRoleRestricted
? isSidebarLinkVisible('inventory', roleProfile)
: getDefaultLinkVisibility('inventory')
},
{
id: 'work-orders',
title: t('common.workOrders'),
icon: <ClipboardList size={20} />,
path: '/work-orders',
visible: isRoleRestricted
? isSidebarLinkVisible('work-orders', roleProfile)
: getDefaultLinkVisibility('work-orders')
},
// {
// id: 'maintenance',
// title: t('common.maintenance'),
// icon: <Wrench size={20} />,
// path: '/maintenance',
// visible: showPreventiveMaintenance
// },
// {
// id: 'ppm',
// title: t('common.ppm'),
// icon: <Calendar size={20} />,
// path: '/ppm',
// visible: showPreventiveMaintenance
// },
{
id: 'ppm-planner',
title: 'PPM Planner',
icon: <CalendarCheck size={20} />,
path: '/ppm-planner',
visible: isRoleRestricted
? isSidebarLinkVisible('ppm-planner', roleProfile)
: getDefaultLinkVisibility('ppm-planner')
},
{
id: 'maintenance-calendar',
title: 'Maintenance Calendar',
icon: <Calendar size={20} />,
path: '/maintenance-calendar',
visible: isRoleRestricted
? isSidebarLinkVisible('maintenance-calendar', roleProfile)
: getDefaultLinkVisibility('maintenance-calendar')
},
{
id: 'active-map',
title: 'Active Map',
icon: <Map size={20} />,
path: '/active-map',
visible: isRoleRestricted
? isSidebarLinkVisible('active-map', roleProfile)
: getDefaultLinkVisibility('active-map')
},
{
id: 'maintenance-teams',
title: 'Maintenance Team',
icon: <Users size={20} />,
path: '/maintenance-teams',
visible: isRoleRestricted
? isSidebarLinkVisible('maintenance-teams', roleProfile)
: getDefaultLinkVisibility('maintenance-teams')
},
{
id: 'procurement',
title: 'Procurement',
icon: <ShoppingCart size={20} />,
path: '/procurement',
visible: isRoleRestricted
? isSidebarLinkVisible('procurement', roleProfile)
: getDefaultLinkVisibility('procurement')
},
{
id: 'sla',
title: 'Service Level Agreement (SLA)',
icon: <FileText size={20} />,
path: '/sla',
visible: isRoleRestricted
? isSidebarLinkVisible('sla', roleProfile)
: getDefaultLinkVisibility('sla')
},
{
id: 'support',
title: 'Support',
icon: <HelpCircle size={20} />,
path: '/support',
visible: isRoleRestricted
? isSidebarLinkVisible('support', roleProfile)
: getDefaultLinkVisibility('support')
},
// {
// 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 afterNav = () => {
if (!isLgUp) {
closeMobileSidebar();
}
};
return (
<div
className={`
relative
h-screen
transition-all
duration-300
ease-in-out
flex
flex-col
shadow-xl
border-r border-gray-200 dark:border-gray-700
w-64
${isCollapsed ? 'lg:w-16' : 'lg:w-64'}
`}
style={{
backgroundImage: `url(${backgroundImageUrl})`,
backgroundSize: 'cover',
backgroundPosition: 'center',
backgroundRepeat: 'no-repeat'
}}
>
{/* Black Overlay */}
<div className="absolute inset-0 bg-black/60 dark:bg-black/70 z-0"></div>
{/* Content Container - Above Overlay */}
<div className="relative z-10 flex flex-col h-full bg-white/0 dark:bg-white/0">
{/* Sidebar Header */}
<div className="flex items-center justify-between p-4 border-b border-gray-200/30 dark:border-gray-700/30">
{!isCollapsed && (
<div className="flex items-center space-x-3">
<div className="w-10 h-10 flex items-center justify-center bg-white/20 dark:bg-white/20 rounded-lg p-1 backdrop-blur-sm">
{/* Seera Arabia Logo */}
<img
src={`${baseUrl}${baseUrl.endsWith('/') ? '' : '/'}seera-logo.png${logoVersion}`}
alt="Seera-ASM"
className="w-full h-full object-contain"
onError={(e) => {
// Fallback to SVG if image not found
e.currentTarget.style.display = 'none';
e.currentTarget.nextElementSibling?.classList.remove('hidden');
}}
/>
<svg className="w-6 h-6 hidden" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M12 2L2 7L12 12L22 7L12 2Z" fill="#6366F1" fillOpacity="0.9"/>
<path d="M2 17L12 22L22 17V12L12 17L2 12V17Z" fill="#8B5CF6" fillOpacity="0.7"/>
<path d="M12 12V17" stroke="#A855F7" strokeWidth="2" strokeLinecap="round"/>
</svg>
</div>
<h1 className="text-white dark:text-white text-lg font-semibold drop-shadow-lg">{t('sidebar.title')}</h1>
</div>
)}
{isCollapsed && (
<div className="w-8 h-8 flex items-center justify-center bg-white dark:bg-gray-700 rounded-lg p-1">
<img
src={`${baseUrl}${baseUrl.endsWith('/') ? '' : '/'}seera-logo.png?v=1765198405${logoVersion}`}
alt="Seera-ASM"
className="w-full h-full object-contain"
onError={(e) => {
e.currentTarget.style.display = 'none';
e.currentTarget.nextElementSibling?.classList.remove('hidden');
}}
/>
<svg className="w-5 h-5 hidden" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M12 2L2 7L12 12L22 7L12 2Z" fill="#6366F1" fillOpacity="0.9"/>
<path d="M2 17L12 22L22 17V12L12 17L2 12V17Z" fill="#8B5CF6" fillOpacity="0.7"/>
<path d="M12 12V17" stroke="#A855F7" strokeWidth="2" strokeLinecap="round"/>
</svg>
</div>
)}
<button
onClick={() => setIsCollapsed(!isCollapsed)}
className="hidden lg:inline-flex text-white dark:text-white hover:bg-white/20 dark:hover:bg-white/20 p-2 rounded-lg transition-colors"
>
{isCollapsed ? <Menu size={20} /> : <X size={20} />}
</button>
</div>
{/* Navigation Links */}
<nav className="flex-1 overflow-y-auto py-4">
{visibleLinks.map((link) => (
<Link
key={link.id}
to={link.path}
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 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>
{/* Version (Bottom) */}
<div className={`${isCollapsed ? 'p-2' : 'p-4'} border-t border-white/10 backdrop-blur-sm bg-white/5 relative z-10`}>
{!isCollapsed && (
<div className="text-xs text-white/70 dark:text-white/70 text-center">
{t('sidebar.version')}
</div>
)}
</div>
</div>
</div>
);
};
export default Sidebar;

View File

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

View File

@ -0,0 +1,114 @@
import React, { useMemo, useState } from 'react';
type StackedDataset = {
name: string;
values: number[];
color: string;
};
type StackedBarChartProps = {
labels: string[];
datasets: StackedDataset[];
};
const StackedBarChart: React.FC<StackedBarChartProps> = ({
labels,
datasets,
}) => {
const [tooltip, setTooltip] = useState<{ x: number; y: number; text: string } | null>(null);
const rowTotals = useMemo(
() =>
labels.map((_, index) =>
datasets.reduce((sum, dataset) => sum + (Number(dataset.values[index]) || 0), 0)
),
[labels, datasets]
);
const maxTotal = useMemo(() => Math.max(...rowTotals, 1), [rowTotals]);
if (!labels.length || !datasets.length) {
return (
<div className="h-48 flex items-center justify-center text-gray-400 text-xs">
No chart data available
</div>
);
}
return (
<div className="relative" onMouseLeave={() => setTooltip(null)}>
{tooltip && (
<div
className="fixed z-[90] pointer-events-none rounded-md bg-gray-900 text-white text-xs px-2 py-1 shadow-lg"
style={{ left: tooltip.x + 12, top: tooltip.y + 12 }}
>
{tooltip.text}
</div>
)}
<div className="max-h-72 overflow-y-auto pr-1 space-y-2">
{labels.map((label, rowIndex) => {
const total = rowTotals[rowIndex] || 0;
return (
<div key={`${label}-${rowIndex}`} className="grid grid-cols-[120px_1fr_36px] gap-2 items-center">
<div
className="text-xs text-gray-700 dark:text-gray-300 truncate"
title={label}
>
{label}
</div>
<div className="h-7 bg-gray-100 dark:bg-gray-700/60 rounded-md overflow-hidden flex">
{datasets.map(dataset => {
const value = Number(dataset.values[rowIndex]) || 0;
if (value <= 0) return null;
const widthPct = (value / maxTotal) * 100;
return (
<div
key={`${dataset.name}-${rowIndex}`}
className="h-full transition-opacity hover:opacity-85 cursor-pointer"
style={{
width: `${widthPct}%`,
backgroundColor: dataset.color,
minWidth: value > 0 ? '4px' : 0,
}}
onMouseEnter={e =>
setTooltip({
x: e.clientX,
y: e.clientY,
text: `${label}: ${dataset.name.replace('_', ' ')} = ${value}`,
})
}
onMouseMove={e =>
setTooltip({
x: e.clientX,
y: e.clientY,
text: `${label}: ${dataset.name.replace('_', ' ')} = ${value}`,
})
}
/>
);
})}
</div>
<div className="text-xs font-semibold text-gray-700 dark:text-gray-200 text-right">
{total}
</div>
</div>
);
})}
</div>
<div className="flex flex-wrap items-center gap-3 mt-3 pt-2 border-t border-gray-200 dark:border-gray-700">
{datasets.map(dataset => (
<div key={dataset.name} className="flex items-center gap-1.5">
<span className="w-2.5 h-2.5 rounded-sm" style={{ backgroundColor: dataset.color }} />
<span className="text-xs text-gray-600 dark:text-gray-400 capitalize">
{dataset.name.replace('_', ' ')}
</span>
</div>
))}
</div>
</div>
);
};
export default StackedBarChart;

View File

@ -0,0 +1,108 @@
import React, { useEffect, useRef, useState } from 'react';
import { FaUserClock } from 'react-icons/fa';
import { useTranslation } from 'react-i18next';
import HorizontalBarChart from './HorizontalBarChart';
import type { TechnicianWorkingHoursChartData } from '../services/technicianWorkingHoursService';
type TechnicianHoursChartCardProps = {
title: string;
data: TechnicianWorkingHoursChartData | null;
loading?: boolean;
onOpenReport: () => void;
};
const TechnicianHoursChartCard: React.FC<TechnicianHoursChartCardProps> = ({
title,
data,
loading = false,
onOpenReport,
}) => {
const { t } = useTranslation();
const [menuOpen, setMenuOpen] = useState(false);
const menuRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (menuRef.current && !menuRef.current.contains(event.target as Node)) {
setMenuOpen(false);
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, []);
const totalHours = data?.totalHours ?? 0;
const subtitle = data
? t('dashboard.technicianHoursSubtitle', {
total: Math.round(totalHours * 100) / 100,
defaultValue: `Total: ${Math.round(totalHours * 100) / 100} hrs`,
})
: t('dashboard.technicianHoursEmpty', { defaultValue: 'No technician hours data' });
return (
<div className="bg-white dark:bg-gray-800 rounded-lg shadow hover:shadow-md transition-all p-3 border border-gray-200 dark:border-gray-700">
<div className="flex items-start justify-between gap-2 mb-2">
<div className="min-w-0">
<div className="flex items-center gap-2">
<button
type="button"
onClick={onOpenReport}
className="p-1.5 rounded-full bg-indigo-50 dark:bg-indigo-900/30 text-indigo-600 dark:text-indigo-300 hover:bg-indigo-100 dark:hover:bg-indigo-900/50 transition-colors shrink-0"
title={t('dashboard.viewFullReport', { defaultValue: 'View Full Report' })}
>
<FaUserClock className="w-3.5 h-3.5" />
</button>
<div className="min-w-0">
<h3 className="text-sm font-semibold text-gray-800 dark:text-white truncate">{title}</h3>
<p className="text-[11px] text-indigo-600 dark:text-indigo-300 mt-0.5">{subtitle}</p>
</div>
</div>
</div>
<div className="relative" ref={menuRef}>
<button
type="button"
onClick={() => setMenuOpen(prev => !prev)}
className="p-1.5 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg"
aria-label="Chart menu"
>
<svg className="w-4 h-4 text-gray-500" fill="currentColor" viewBox="0 0 20 20">
<path d="M10 6a2 2 0 110-4 2 2 0 010 4zM10 12a2 2 0 110-4 2 2 0 010 4zM10 18a2 2 0 110-4 2 2 0 010 4z" />
</svg>
</button>
{menuOpen && (
<div className="absolute right-0 mt-1 w-44 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg shadow-lg z-20 py-1">
<button
type="button"
className="w-full text-left px-3 py-2 text-xs text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-gray-700"
onClick={() => {
setMenuOpen(false);
onOpenReport();
}}
>
{t('dashboard.viewFullReport', { defaultValue: 'View Full Report' })}
</button>
</div>
)}
</div>
</div>
{loading ? (
<div className="h-48 flex flex-col items-center justify-center gap-2">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-indigo-600" />
<span className="text-xs text-gray-500">
{t('dashboard.loadingTechnicianHours', { defaultValue: 'Loading technician hours…' })}
</span>
</div>
) : !data || !data.datasets.length ? (
<div className="h-48 flex items-center justify-center text-gray-400 text-xs">
{t('dashboard.noChartData', { defaultValue: 'No chart data available' })}
</div>
) : (
<HorizontalBarChart labels={data.labels} datasets={data.datasets} valueLabel="hrs" />
)}
</div>
);
};
export default TechnicianHoursChartCard;

View File

@ -0,0 +1,394 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { FaArrowLeft, FaDownload, FaFileExcel, FaPrint, FaSync, FaTimes } from 'react-icons/fa';
import { useTranslation } from 'react-i18next';
import * as XLSX from 'xlsx';
import LinkField from './LinkField';
import {
buildTechnicianWorkSummaryChartData,
getTechnicianLabel,
runTechnicianWorkSummaryReport,
type TechnicianWorkSummaryFilters,
type TechnicianWorkSummaryRow,
} from '../services/technicianWorkSummaryService';
import { buildMobileTeamSiteFilters, isSiteEnabledHospital } from '../utils/hospitalUtils';
import {
compactFilterFieldWrapClass,
compactFilterInputClass,
compactFilterLabelClass,
compactSummaryCardClass,
compactSummaryLabelClass,
compactSummaryValueClass,
compactToolbarBtnPrimary,
compactToolbarBtnSecondary,
} from '../utils/reportModalStyles';
type TechnicianWorkOrderSummaryReportModalProps = {
isOpen: boolean;
onClose: () => void;
permittedIssueTypes?: string[];
isAdmin?: boolean;
defaultWorkOrderType?: string;
defaultFromDate?: string;
defaultToDate?: string;
defaultCompany?: string;
defaultSiteName?: string;
companyLocked?: boolean;
siteLocked?: boolean;
hospitalLinkFilters?: Record<string, unknown>;
};
const TechnicianWorkOrderSummaryReportModal: React.FC<TechnicianWorkOrderSummaryReportModalProps> = ({
isOpen,
onClose,
permittedIssueTypes = [],
isAdmin = false,
defaultWorkOrderType = '',
defaultFromDate = '',
defaultToDate = '',
defaultCompany = '',
defaultSiteName = '',
companyLocked = false,
siteLocked = false,
hospitalLinkFilters,
}) => {
const { t } = useTranslation();
const [fromDate, setFromDate] = useState(defaultFromDate);
const [toDate, setToDate] = useState(defaultToDate);
const [workOrderType, setWorkOrderType] = useState(defaultWorkOrderType);
const [company, setCompany] = useState(defaultCompany);
const [siteName, setSiteName] = useState(defaultSiteName);
const [rows, setRows] = useState<TechnicianWorkSummaryRow[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const singlePermittedType = !isAdmin && permittedIssueTypes.length === 1 ? permittedIssueTypes[0] : '';
const typeLocked = !!singlePermittedType;
const showSiteFilter = isSiteEnabledHospital(company);
const mobileTeamSiteFilters = useMemo(
() => buildMobileTeamSiteFilters(company, siteLocked ? siteName : undefined),
[company, siteName, siteLocked]
);
useEffect(() => {
if (!isOpen) return;
setFromDate(defaultFromDate);
setToDate(defaultToDate);
setWorkOrderType(singlePermittedType || defaultWorkOrderType);
setCompany(defaultCompany);
setSiteName(defaultSiteName);
}, [
isOpen,
defaultFromDate,
defaultToDate,
defaultWorkOrderType,
defaultCompany,
defaultSiteName,
singlePermittedType,
]);
useEffect(() => {
if (!isOpen) return;
const previousOverflow = document.body.style.overflow;
document.body.style.overflow = 'hidden';
return () => {
document.body.style.overflow = previousOverflow;
};
}, [isOpen]);
useEffect(() => {
if (!isOpen) return;
const onKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') onClose();
};
window.addEventListener('keydown', onKeyDown);
return () => window.removeEventListener('keydown', onKeyDown);
}, [isOpen, onClose]);
const buildFilters = useCallback((): TechnicianWorkSummaryFilters => {
const filters: TechnicianWorkSummaryFilters = {};
if (fromDate) filters.from_date = fromDate;
if (toDate) filters.to_date = toDate;
if (workOrderType) filters.work_order_type = workOrderType;
if (company) filters.company = company;
if (siteName && showSiteFilter) filters.site_name = siteName;
return filters;
}, [fromDate, toDate, workOrderType, company, siteName, showSiteFilter]);
const loadReport = useCallback(async () => {
setLoading(true);
setError(null);
try {
let result = await runTechnicianWorkSummaryReport(buildFilters());
setRows(result);
} catch (err) {
setRows([]);
setError(err instanceof Error ? err.message : 'Failed to load report');
} finally {
setLoading(false);
}
}, [buildFilters]);
useEffect(() => {
if (isOpen) {
loadReport();
}
}, [isOpen, loadReport]);
const summary = useMemo(() => {
const chart = buildTechnicianWorkSummaryChartData(rows);
return chart?.totals || { completed: 0, in_progress: 0, open: 0, total: 0 };
}, [rows]);
const exportRows = useMemo(
() =>
rows.map(row => ({
Technician: getTechnicianLabel(row),
Total: Number(row.total) || 0,
Completed: Number(row.completed) || 0,
'In Progress': Number(row.in_progress) || 0,
Open: Number(row.open) || 0,
})),
[rows]
);
const handleExportCsv = () => {
if (!exportRows.length) return;
const headers = Object.keys(exportRows[0]);
const csv = [
headers.join(','),
...exportRows.map(row => headers.map(h => `"${String((row as any)[h]).replace(/"/g, '""')}"`).join(',')),
].join('\n');
const blob = new Blob([csv], { 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.csv';
link.click();
URL.revokeObjectURL(url);
};
const handleExportExcel = () => {
if (!exportRows.length) return;
const worksheet = XLSX.utils.json_to_sheet(exportRows);
const workbook = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(workbook, worksheet, 'Technicians');
XLSX.writeFile(workbook, 'technician-work-order-summary.xlsx');
};
const handlePrint = () => {
window.print();
};
if (!isOpen) return null;
return (
<div className="fixed inset-0 z-[80] bg-black/50 backdrop-blur-sm">
<div className="fixed inset-0 bg-white dark:bg-gray-900 flex flex-col print:static print:inset-auto">
<div className="bg-gradient-to-r from-purple-700 via-indigo-700 to-purple-800 text-white px-4 sm:px-5 py-3 print:bg-white print:text-black">
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-2 min-w-0">
<button
type="button"
onClick={onClose}
className="p-1.5 rounded-lg hover:bg-white/10 print:hidden"
aria-label="Close"
>
<FaArrowLeft className="text-sm" />
</button>
<div className="min-w-0">
<h2 className="text-base sm:text-lg font-semibold truncate">
{t('dashboard.techniciansWorked', { defaultValue: 'Technicians Work Summary' })}
</h2>
<p className="text-xs sm:text-sm text-purple-100 print:text-gray-600">
{t('dashboard.techniciansWorkedReportHint', {
defaultValue: 'Work orders grouped by assigned contractor',
})}
</p>
</div>
</div>
<button
type="button"
onClick={onClose}
className="p-2 rounded-lg hover:bg-white/10 print:hidden"
aria-label="Close"
>
<FaTimes />
</button>
</div>
</div>
<div className="flex-1 overflow-y-auto p-3 sm:p-4 space-y-3">
<div className="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg p-3 print:hidden">
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-5 gap-2">
<div className={compactFilterFieldWrapClass}>
<label className={compactFilterLabelClass}>
{t('dashboard.fromDate', { defaultValue: 'From Date' })}
</label>
<input
type="date"
value={fromDate}
onChange={e => setFromDate(e.target.value)}
className={compactFilterInputClass}
/>
</div>
<div className={compactFilterFieldWrapClass}>
<label className={compactFilterLabelClass}>
{t('dashboard.toDate', { defaultValue: 'To Date' })}
</label>
<input
type="date"
value={toDate}
onChange={e => setToDate(e.target.value)}
className={compactFilterInputClass}
/>
</div>
<LinkField
label={t('dashboard.technicalDepartment', { defaultValue: 'Technical Department' })}
doctype="Issue Type"
value={workOrderType}
onChange={setWorkOrderType}
placeholder={t('dashboard.allDepartments', { defaultValue: 'All Departments' })}
disabled={typeLocked}
compact
/>
<LinkField
label={t('commonFields.hospital', { defaultValue: 'Hospital' })}
doctype="Company"
value={company}
onChange={value => {
setCompany(value);
if (!isSiteEnabledHospital(value)) setSiteName('');
}}
placeholder={t('dashboard.allHospitals', { defaultValue: 'All Hospitals' })}
disabled={companyLocked}
compact
filters={hospitalLinkFilters}
/>
{showSiteFilter && (
<LinkField
label={t('commonFields.siteName', { defaultValue: 'PHCC Site' })}
doctype="Mobile Team Site"
value={siteName}
onChange={setSiteName}
placeholder={t('dashboard.allSites', { defaultValue: 'All Sites' })}
disabled={siteLocked}
compact
filters={mobileTeamSiteFilters}
/>
)}
</div>
<div className="flex flex-wrap items-center gap-1.5 mt-2">
<button
type="button"
onClick={loadReport}
disabled={loading}
className={compactToolbarBtnPrimary('purple')}
>
<FaSync className={`text-[10px] ${loading ? 'animate-spin' : ''}`} />
{t('dashboard.refreshReport', { defaultValue: 'Refresh' })}
</button>
<button
type="button"
onClick={handleExportCsv}
disabled={!exportRows.length}
className={compactToolbarBtnSecondary}
>
<FaDownload className="text-[10px]" />
CSV
</button>
<button
type="button"
onClick={handleExportExcel}
disabled={!exportRows.length}
className={compactToolbarBtnSecondary}
>
<FaFileExcel className="text-[10px]" />
Excel
</button>
<button
type="button"
onClick={handlePrint}
disabled={!exportRows.length}
className={compactToolbarBtnSecondary}
>
<FaPrint className="text-[10px]" />
{t('listPages.print', { defaultValue: 'Print' })}
</button>
</div>
</div>
<div className="grid grid-cols-2 lg:grid-cols-4 gap-2">
<SummaryStat label={t('dashboard.totalWorkOrders', { defaultValue: 'Total' })} value={summary.total} />
<SummaryStat label={t('dashboard.completedWorkOrders', { defaultValue: 'Completed' })} value={summary.completed} color="text-green-600" />
<SummaryStat label={t('dashboard.workOrdersInProgress', { defaultValue: 'In Progress' })} value={summary.in_progress} color="text-blue-600" />
<SummaryStat label={t('dashboard.openWorkOrders', { defaultValue: 'Open' })} value={summary.open} color="text-amber-600" />
</div>
{error && (
<div className="rounded-md border border-red-200 bg-red-50 text-red-700 px-4 py-3 text-sm">
{error}
</div>
)}
<div className="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden">
<div className="overflow-x-auto">
<table className="min-w-full text-sm">
<thead className="bg-gray-50 dark:bg-gray-900/60">
<tr>
<th className="px-4 py-3 text-left font-semibold text-gray-700 dark:text-gray-200">
{t('commonFields.assignedContractor', { defaultValue: 'Assigned Contractor' })}
</th>
<th className="px-4 py-3 text-right font-semibold text-gray-700 dark:text-gray-200">Total</th>
<th className="px-4 py-3 text-right font-semibold text-green-700 dark:text-green-400">Completed</th>
<th className="px-4 py-3 text-right font-semibold text-blue-700 dark:text-blue-400">In Progress</th>
<th className="px-4 py-3 text-right font-semibold text-amber-700 dark:text-amber-400">Open</th>
</tr>
</thead>
<tbody>
{loading ? (
<tr>
<td colSpan={5} className="px-4 py-10 text-center text-gray-500">
{t('common.loading', { defaultValue: 'Loading...' })}
</td>
</tr>
) : rows.length === 0 ? (
<tr>
<td colSpan={5} className="px-4 py-10 text-center text-gray-500">
{t('common.noData', { defaultValue: 'No data' })}
</td>
</tr>
) : (
rows.map((row, index) => (
<tr key={`${getTechnicianLabel(row)}-${index}`} className="border-t border-gray-100 dark:border-gray-700">
<td className="px-4 py-3 text-gray-800 dark:text-gray-100">{getTechnicianLabel(row)}</td>
<td className="px-4 py-3 text-right font-medium">{Number(row.total) || 0}</td>
<td className="px-4 py-3 text-right text-green-700 dark:text-green-400">{Number(row.completed) || 0}</td>
<td className="px-4 py-3 text-right text-blue-700 dark:text-blue-400">{Number(row.in_progress) || 0}</td>
<td className="px-4 py-3 text-right text-amber-700 dark:text-amber-400">{Number(row.open) || 0}</td>
</tr>
))
)}
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
);
};
const SummaryStat: React.FC<{ label: string; value: number; color?: string }> = ({
label,
value,
color = 'text-gray-900 dark:text-white',
}) => (
<div className={`${compactSummaryCardClass} border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800`}>
<div className={compactSummaryLabelClass}>{label}</div>
<div className={`${compactSummaryValueClass} mt-0.5 ${color}`}>{value}</div>
</div>
);
export default TechnicianWorkOrderSummaryReportModal;

View File

@ -0,0 +1,104 @@
import React, { useEffect, useRef, useState } from 'react';
import { FaHardHat } from 'react-icons/fa';
import { useTranslation } from 'react-i18next';
import StackedBarChart from './StackedBarChart';
import type { TechnicianWorkSummaryChartData } from '../services/technicianWorkSummaryService';
type TechnicianWorkedChartCardProps = {
title: string;
data: TechnicianWorkSummaryChartData | null;
loading?: boolean;
onOpenReport: () => void;
};
const TechnicianWorkedChartCard: React.FC<TechnicianWorkedChartCardProps> = ({
title,
data,
loading = false,
onOpenReport,
}) => {
const { t } = useTranslation();
const [menuOpen, setMenuOpen] = useState(false);
const menuRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (menuRef.current && !menuRef.current.contains(event.target as Node)) {
setMenuOpen(false);
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, []);
const subtitle = data
? t('dashboard.techniciansWorkedSubtitle', {
completed: data.totals.completed,
inProgress: data.totals.in_progress,
open: data.totals.open,
defaultValue: `${data.totals.completed} done • ${data.totals.in_progress} wip • ${data.totals.open} open`,
})
: t('dashboard.techniciansWorkedEmpty', { defaultValue: 'No technician data' });
return (
<div className="bg-white dark:bg-gray-800 rounded-lg shadow hover:shadow-md transition-all p-3 border border-gray-200 dark:border-gray-700">
<div className="flex items-start justify-between gap-2 mb-2">
<div className="min-w-0">
<div className="flex items-center gap-2">
<h3 className="text-sm font-semibold text-gray-800 dark:text-white truncate">{title}</h3>
<button
type="button"
onClick={onOpenReport}
className="p-1.5 rounded-lg bg-purple-50 dark:bg-purple-900/30 text-purple-600 dark:text-purple-300 hover:bg-purple-100 dark:hover:bg-purple-900/50 transition-colors"
title={t('dashboard.viewFullReport', { defaultValue: 'View Full Report' })}
>
<FaHardHat className="w-3.5 h-3.5" />
</button>
</div>
<p className="text-[11px] text-gray-500 dark:text-gray-400 mt-0.5">{subtitle}</p>
</div>
<div className="relative" ref={menuRef}>
<button
type="button"
onClick={() => setMenuOpen(prev => !prev)}
className="p-1.5 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg"
aria-label="Chart menu"
>
<svg className="w-4 h-4 text-gray-500" fill="currentColor" viewBox="0 0 20 20">
<path d="M10 6a2 2 0 110-4 2 2 0 010 4zM10 12a2 2 0 110-4 2 2 0 010 4zM10 18a2 2 0 110-4 2 2 0 010 4z" />
</svg>
</button>
{menuOpen && (
<div className="absolute right-0 mt-1 w-44 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg shadow-lg z-20 py-1">
<button
type="button"
className="w-full text-left px-3 py-2 text-xs text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-gray-700"
onClick={() => {
setMenuOpen(false);
onOpenReport();
}}
>
{t('dashboard.viewFullReport', { defaultValue: 'View Full Report' })}
</button>
</div>
)}
</div>
</div>
{loading ? (
<div className="h-48 flex items-center justify-center">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-purple-600" />
</div>
) : !data || !data.datasets.length ? (
<div className="h-48 flex items-center justify-center text-gray-400 text-xs">
{t('dashboard.noChartData', { defaultValue: 'No chart data available' })}
</div>
) : (
<StackedBarChart labels={data.labels} datasets={data.datasets} />
)}
</div>
);
};
export default TechnicianWorkedChartCard;

View File

@ -0,0 +1,585 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import {
FaArrowLeft,
FaClock,
FaDownload,
FaFileExcel,
FaFilter,
FaPrint,
FaSync,
FaTimes,
FaUserCog,
FaUsers,
} from 'react-icons/fa';
import { useTranslation } from 'react-i18next';
import * as XLSX from 'xlsx';
import LinkField from './LinkField';
import {
getTechnicianHoursLabel,
parseTechnicianHours,
runTechnicianWorkingHoursReport,
type TechnicianWorkingHoursFilters,
type TechnicianWorkingHoursRow,
} from '../services/technicianWorkingHoursService';
import { buildMobileTeamSiteFilters, isSiteEnabledHospital } from '../utils/hospitalUtils';
import {
compactFilterFieldWrapClass,
compactFilterInputClass,
compactFilterLabelClass,
compactSummaryCardClass,
compactSummaryLabelClass,
compactSummaryValueClass,
compactToolbarBtnPrimary,
compactToolbarBtnSecondary,
} from '../utils/reportModalStyles';
type TechnicianWorkingHoursReportModalProps = {
isOpen: boolean;
onClose: () => void;
permittedIssueTypes?: string[];
isAdmin?: boolean;
defaultWorkOrderType?: string;
defaultFromDate?: string;
defaultToDate?: string;
defaultCompany?: string;
defaultSiteName?: string;
companyLocked?: boolean;
siteLocked?: boolean;
hospitalLinkFilters?: Record<string, unknown>;
};
const TechnicianWorkingHoursReportModal: React.FC<TechnicianWorkingHoursReportModalProps> = ({
isOpen,
onClose,
permittedIssueTypes = [],
isAdmin = false,
defaultWorkOrderType = '',
defaultFromDate = '',
defaultToDate = '',
defaultCompany = '',
defaultSiteName = '',
companyLocked = false,
siteLocked = false,
hospitalLinkFilters,
}) => {
const { t } = useTranslation();
const [fromDate, setFromDate] = useState(defaultFromDate);
const [toDate, setToDate] = useState(defaultToDate);
const [workOrderType, setWorkOrderType] = useState(defaultWorkOrderType);
const [company, setCompany] = useState(defaultCompany);
const [siteName, setSiteName] = useState(defaultSiteName);
const [rows, setRows] = useState<TechnicianWorkingHoursRow[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [filtersExpanded, setFiltersExpanded] = useState(true);
const singlePermittedType = !isAdmin && permittedIssueTypes.length === 1 ? permittedIssueTypes[0] : '';
const typeLocked = !!singlePermittedType;
const showSiteFilter = isSiteEnabledHospital(company);
const mobileTeamSiteFilters = useMemo(
() => buildMobileTeamSiteFilters(company, siteLocked ? siteName : undefined),
[company, siteName, siteLocked]
);
useEffect(() => {
if (!isOpen) return;
setFromDate(defaultFromDate);
setToDate(defaultToDate);
setWorkOrderType(singlePermittedType || defaultWorkOrderType);
setCompany(defaultCompany);
setSiteName(defaultSiteName);
setFiltersExpanded(true);
}, [
isOpen,
defaultFromDate,
defaultToDate,
defaultWorkOrderType,
defaultCompany,
defaultSiteName,
singlePermittedType,
]);
useEffect(() => {
if (!isOpen) return;
const previousOverflow = document.body.style.overflow;
document.body.style.overflow = 'hidden';
return () => {
document.body.style.overflow = previousOverflow;
};
}, [isOpen]);
useEffect(() => {
if (!isOpen) return;
const onKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') onClose();
};
window.addEventListener('keydown', onKeyDown);
return () => window.removeEventListener('keydown', onKeyDown);
}, [isOpen, onClose]);
const activeFilterCount = useMemo(() => {
let count = 0;
if (fromDate) count += 1;
if (toDate) count += 1;
if (workOrderType) count += 1;
if (company) count += 1;
if (siteName && showSiteFilter) count += 1;
return count;
}, [fromDate, toDate, workOrderType, company, siteName, showSiteFilter]);
const buildFilters = useCallback((): TechnicianWorkingHoursFilters => {
const filters: TechnicianWorkingHoursFilters = {};
if (fromDate) filters.from_date = fromDate;
if (toDate) filters.to_date = toDate;
if (workOrderType) filters.work_order_type = workOrderType;
if (company) filters.company = company;
if (siteName && showSiteFilter) filters.site_name = siteName;
return filters;
}, [fromDate, toDate, workOrderType, company, siteName, showSiteFilter]);
const loadReport = useCallback(async () => {
setLoading(true);
setError(null);
try {
const result = await runTechnicianWorkingHoursReport(buildFilters());
setRows(result);
} catch (err) {
setRows([]);
setError(err instanceof Error ? err.message : 'Failed to load report');
} finally {
setLoading(false);
}
}, [buildFilters]);
useEffect(() => {
if (isOpen) {
loadReport();
}
}, [isOpen, loadReport]);
const summary = useMemo(() => {
const totalTechnicians = rows.length;
const totalHours = rows.reduce((sum, row) => sum + parseTechnicianHours(row.total_hours), 0);
const avgHours = totalTechnicians > 0 ? totalHours / totalTechnicians : 0;
return {
totalTechnicians,
totalHours: Math.round(totalHours * 100) / 100,
avgHours: Math.round(avgHours * 100) / 100,
};
}, [rows]);
const exportRows = useMemo(
() =>
rows.map(row => ({
Engineer: row.engineer || '',
'Technician Name': getTechnicianHoursLabel(row),
'Total Hours': parseTechnicianHours(row.total_hours),
})),
[rows]
);
const handleClearFilters = () => {
setFromDate(defaultFromDate);
setToDate(defaultToDate);
setWorkOrderType(singlePermittedType || defaultWorkOrderType);
setCompany(defaultCompany);
setSiteName(defaultSiteName);
};
const handleExportCsv = () => {
if (!exportRows.length) return;
const headers = Object.keys(exportRows[0]);
const csv = [
headers.join(','),
...exportRows.map(row => headers.map(h => `"${String((row as any)[h]).replace(/"/g, '""')}"`).join(',')),
].join('\n');
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = 'technicians-working-hours.csv';
link.click();
URL.revokeObjectURL(url);
};
const handleExportExcel = () => {
if (!exportRows.length) return;
const worksheet = XLSX.utils.json_to_sheet(exportRows);
const workbook = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(workbook, worksheet, 'Technicians');
XLSX.writeFile(workbook, 'technicians-working-hours.xlsx');
};
const handlePrint = () => {
if (!exportRows.length) return;
const tableRows = rows
.map(
(row, index) => `
<tr>
<td>${index + 1}</td>
<td>${getTechnicianHoursLabel(row)}</td>
<td>${row.engineer || ''}</td>
<td class="hours">${parseTechnicianHours(row.total_hours)} hrs</td>
</tr>
`
)
.join('');
const printWindow = window.open('', '_blank');
if (!printWindow) return;
printWindow.document.write(`
<!DOCTYPE html>
<html>
<head>
<title>Technicians Working Hours</title>
<style>
body { font-family: Arial, sans-serif; padding: 24px; color: #111827; }
h1 { color: #0891B2; margin-bottom: 8px; }
p { color: #6B7280; margin-top: 0; }
table { width: 100%; border-collapse: collapse; margin-top: 20px; }
th { background: #0891B2; color: white; text-align: left; padding: 10px 12px; }
td { border-bottom: 1px solid #E5E7EB; padding: 10px 12px; }
.hours { color: #0891B2; font-weight: 700; }
</style>
</head>
<body>
<h1>Technicians Working Hours</h1>
<p>${rows.length} technicians ${summary.totalHours} total hours</p>
<table>
<thead>
<tr>
<th>#</th>
<th>Technician Name</th>
<th>Engineer</th>
<th>Total Hours</th>
</tr>
</thead>
<tbody>${tableRows}</tbody>
</table>
</body>
</html>
`);
printWindow.document.close();
printWindow.focus();
printWindow.print();
};
if (!isOpen) return null;
return (
<div className="fixed inset-0 z-[80] bg-black/50 backdrop-blur-sm">
<div className="fixed inset-0 bg-white dark:bg-gray-900 flex flex-col">
<div className="bg-gradient-to-r from-cyan-600 to-blue-600 text-white px-4 sm:px-5 py-3">
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-2 min-w-0">
<button
type="button"
onClick={onClose}
className="p-1.5 rounded-lg hover:bg-white/10"
aria-label="Back"
>
<FaArrowLeft className="text-sm" />
</button>
<div className="w-8 h-8 rounded-full bg-white/15 flex items-center justify-center shrink-0">
<FaClock className="text-sm" />
</div>
<div className="min-w-0">
<h2 className="text-base sm:text-lg font-semibold truncate">
{t('dashboard.technicianWorkingHours', { defaultValue: 'Technicians Working Hours' })}
</h2>
<p className="text-xs sm:text-sm text-cyan-100">
{t('dashboard.technicianHoursFound', {
count: summary.totalTechnicians,
defaultValue: `${summary.totalTechnicians} technicians found`,
})}
</p>
</div>
</div>
<button
type="button"
onClick={onClose}
className="p-2 rounded-lg hover:bg-white/10"
aria-label="Close"
>
<FaTimes />
</button>
</div>
</div>
<div className="flex-1 overflow-y-auto p-3 sm:p-4 space-y-3">
{!loading && rows.length > 0 && (
<div className="grid grid-cols-1 sm:grid-cols-3 gap-2">
<SummaryCard
icon={<FaUsers className="text-blue-500" />}
label={t('dashboard.totalTechnicians', { defaultValue: 'Total Technicians' })}
value={String(summary.totalTechnicians)}
theme="blue"
/>
<SummaryCard
icon={<FaClock className="text-cyan-500" />}
label={t('dashboard.totalHoursWorked', { defaultValue: 'Total Hours Worked' })}
value={`${summary.totalHours} hrs`}
theme="cyan"
/>
<SummaryCard
icon={<FaUserCog className="text-purple-500" />}
label={t('dashboard.avgHoursPerTechnician', { defaultValue: 'Average Hours/Technician' })}
value={`${summary.avgHours} hrs`}
theme="purple"
/>
</div>
)}
<div className="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg">
<div className="flex flex-wrap items-center justify-between gap-2 px-3 py-2 border-b border-gray-200 dark:border-gray-700">
<button
type="button"
onClick={() => setFiltersExpanded(prev => !prev)}
className="inline-flex items-center gap-1.5 text-xs font-medium text-gray-700 dark:text-gray-200"
>
<FaFilter className="text-cyan-600 text-xs" />
{t('dashboard.filters', { defaultValue: 'Filters' })}
{activeFilterCount > 0 && (
<span className="inline-flex items-center justify-center min-w-[1rem] h-4 px-1 rounded-full bg-cyan-100 text-cyan-700 text-[10px] font-semibold">
{activeFilterCount}
</span>
)}
</button>
<div className="flex flex-wrap items-center gap-1.5">
<button
type="button"
onClick={loadReport}
disabled={loading}
className={compactToolbarBtnPrimary('cyan')}
>
<FaSync className={`text-[10px] ${loading ? 'animate-spin' : ''}`} />
{t('dashboard.refreshReport', { defaultValue: 'Refresh' })}
</button>
<button
type="button"
onClick={handleExportCsv}
disabled={!exportRows.length}
className={compactToolbarBtnSecondary}
>
<FaDownload className="text-[10px]" />
CSV
</button>
<button
type="button"
onClick={handleExportExcel}
disabled={!exportRows.length}
className={compactToolbarBtnSecondary}
>
<FaFileExcel className="text-[10px]" />
Excel
</button>
<button
type="button"
onClick={handlePrint}
disabled={!exportRows.length}
className={compactToolbarBtnSecondary}
>
<FaPrint className="text-[10px]" />
PDF/Print
</button>
</div>
</div>
{filtersExpanded && (
<div className="p-3 border-b border-gray-200 dark:border-gray-700">
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-5 gap-2">
<div className={compactFilterFieldWrapClass}>
<label className={compactFilterLabelClass}>
{t('dashboard.fromDate', { defaultValue: 'From Date' })}
</label>
<input
type="date"
value={fromDate}
onChange={e => setFromDate(e.target.value)}
className={compactFilterInputClass}
/>
</div>
<div className={compactFilterFieldWrapClass}>
<label className={compactFilterLabelClass}>
{t('dashboard.toDate', { defaultValue: 'To Date' })}
</label>
<input
type="date"
value={toDate}
onChange={e => setToDate(e.target.value)}
className={compactFilterInputClass}
/>
</div>
<LinkField
label={t('dashboard.technicalDepartment', { defaultValue: 'Technical Department' })}
doctype="Issue Type"
value={workOrderType}
onChange={setWorkOrderType}
placeholder={t('dashboard.allDepartments', { defaultValue: 'All Departments' })}
disabled={typeLocked}
compact
/>
<LinkField
label={t('commonFields.hospital', { defaultValue: 'Hospital' })}
doctype="Company"
value={company}
onChange={value => {
setCompany(value);
if (!isSiteEnabledHospital(value)) setSiteName('');
}}
placeholder={t('dashboard.allHospitals', { defaultValue: 'All Hospitals' })}
disabled={companyLocked}
compact
filters={hospitalLinkFilters}
/>
{showSiteFilter && (
<LinkField
label={t('commonFields.siteName', { defaultValue: 'PHCC Site' })}
doctype="Mobile Team Site"
value={siteName}
onChange={setSiteName}
placeholder={t('dashboard.allSites', { defaultValue: 'All Sites' })}
disabled={siteLocked}
compact
filters={mobileTeamSiteFilters}
/>
)}
</div>
<div className="mt-2">
<button
type="button"
onClick={handleClearFilters}
className="text-[11px] text-cyan-700 dark:text-cyan-300 hover:underline"
>
{t('dashboard.clearFilters', { defaultValue: 'Clear Filters' })}
</button>
</div>
</div>
)}
</div>
{error && (
<div className="rounded-md border border-red-200 bg-red-50 text-red-700 px-4 py-3 text-sm">
{error}
</div>
)}
<div className="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden">
<div className="overflow-x-auto">
<table className="min-w-full text-sm">
<thead className="bg-gray-50 dark:bg-gray-900/60 sticky top-0 z-10">
<tr>
<th className="px-4 py-3 text-left font-semibold text-gray-700 dark:text-gray-200 w-12">#</th>
<th className="px-4 py-3 text-left font-semibold text-gray-700 dark:text-gray-200">
{t('dashboard.technicianName', { defaultValue: 'Technician Name' })}
</th>
<th className="px-4 py-3 text-left font-semibold text-gray-700 dark:text-gray-200">
{t('dashboard.engineer', { defaultValue: 'Engineer' })}
</th>
<th className="px-4 py-3 text-right font-semibold text-gray-700 dark:text-gray-200">
{t('dashboard.totalHoursSpent', { defaultValue: 'Total Hours Spent' })}
</th>
</tr>
</thead>
<tbody>
{loading ? (
<tr>
<td colSpan={4} className="px-4 py-10 text-center text-gray-500">
{t('dashboard.loadingTechnicianHours', { defaultValue: 'Loading technician hours…' })}
</td>
</tr>
) : rows.length === 0 ? (
<tr>
<td colSpan={4} className="px-4 py-10 text-center text-gray-500">
{t('common.noData', { defaultValue: 'No data' })}
</td>
</tr>
) : (
rows.map((row, index) => (
<tr key={`${row.engineer || getTechnicianHoursLabel(row)}-${index}`} className="border-t border-gray-100 dark:border-gray-700">
<td className="px-4 py-3 text-gray-500">{index + 1}</td>
<td className="px-4 py-3">
<div className="flex items-center gap-2 min-w-0">
<div className="w-8 h-8 rounded-full bg-cyan-50 dark:bg-cyan-900/20 flex items-center justify-center shrink-0">
<FaUserCog className="text-cyan-600 dark:text-cyan-300 text-sm" />
</div>
<span className="truncate text-gray-800 dark:text-gray-100">{getTechnicianHoursLabel(row)}</span>
</div>
</td>
<td className="px-4 py-3">
{row.engineer ? (
<a
href={`/app/user/${encodeURIComponent(row.engineer)}`}
target="_blank"
rel="noopener noreferrer"
className="text-cyan-700 dark:text-cyan-300 hover:underline truncate inline-block max-w-[220px]"
>
{row.engineer}
</a>
) : (
<span className="text-gray-400"></span>
)}
</td>
<td className="px-4 py-3 text-right">
<span className="inline-flex items-center gap-1 rounded-full bg-cyan-50 dark:bg-cyan-900/20 px-3 py-1 text-cyan-700 dark:text-cyan-300 font-semibold">
<FaClock className="text-xs" />
{parseTechnicianHours(row.total_hours)} hrs
</span>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</div>
</div>
<div className="border-t border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-900 px-3 sm:px-4 py-2 flex flex-wrap items-center justify-between gap-2">
<div className="text-xs text-gray-600 dark:text-gray-300">
{t('dashboard.technicianHoursFooter', {
count: rows.length,
total: summary.totalHours,
defaultValue: `Showing ${rows.length} technicians (filtered) • ${summary.totalHours} total hours`,
})}
</div>
<button
type="button"
onClick={onClose}
className="px-3 py-1.5 rounded-md border border-gray-300 dark:border-gray-600 text-xs hover:bg-gray-50 dark:hover:bg-gray-800"
>
{t('common.close', { defaultValue: 'Close' })}
</button>
</div>
</div>
</div>
);
};
const SummaryCard: React.FC<{
icon: React.ReactNode;
label: string;
value: string;
theme: 'blue' | 'cyan' | 'purple';
}> = ({ icon, label, value, theme }) => {
const themeClasses = {
blue: 'bg-blue-50 dark:bg-blue-900/20 border-blue-100 dark:border-blue-800',
cyan: 'bg-cyan-50 dark:bg-cyan-900/20 border-cyan-100 dark:border-cyan-800',
purple: 'bg-purple-50 dark:bg-purple-900/20 border-purple-100 dark:border-purple-800',
}[theme];
return (
<div className={`${compactSummaryCardClass} ${themeClasses}`}>
<div className="flex items-center gap-1.5 mb-1">
{icon}
<div className={compactSummaryLabelClass}>{label}</div>
</div>
<div className={compactSummaryValueClass}>{value}</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,336 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { FaExternalLinkAlt, FaFileExport, FaStar, FaTimes } from 'react-icons/fa';
import { useTranslation } from 'react-i18next';
import LinkField from './LinkField';
import QueryReportExportModal from './QueryReportExportModal';
import woFeedbackReportService, {
type WoFeedbackReportFilters,
} from '../services/woFeedbackReportService';
import { WO_FEEDBACK_RATING_OPTIONS } from '../utils/woFeedbackChartUtils';
import { buildMobileTeamSiteFilters, isSiteEnabledHospital } from '../utils/hospitalUtils';
interface WoFeedbackReportModalProps {
isOpen: boolean;
onClose: () => void;
defaultCompany?: string;
defaultSiteName?: string;
companyLocked?: boolean;
siteLocked?: boolean;
hospitalLinkFilters?: Record<string, unknown>;
}
type AppliedFilters = WoFeedbackReportFilters;
const EMPTY_FILTERS: AppliedFilters = {
work_order: '',
requester_name: '',
rating: '',
company: '',
site_name: '',
};
const compactFilterLabelClass =
'block font-medium text-gray-700 dark:text-gray-300 text-[10px] mb-0.5';
const compactFilterInputClass =
'w-full px-2 py-1 text-xs border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:outline-none focus:ring-1 focus:ring-emerald-500 disabled:bg-gray-100 dark:disabled:bg-gray-700';
const WoFeedbackReportModal: React.FC<WoFeedbackReportModalProps> = ({
isOpen,
onClose,
defaultCompany = '',
defaultSiteName = '',
companyLocked = false,
siteLocked = false,
hospitalLinkFilters,
}) => {
const { t } = useTranslation();
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 [company, setCompany] = useState(defaultCompany);
const [siteName, setSiteName] = useState(defaultSiteName);
const [appliedFilters, setAppliedFilters] = useState<AppliedFilters>(EMPTY_FILTERS);
const [showExportModal, setShowExportModal] = useState(false);
const hasLoadedOnce = useRef(false);
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const showSiteFilter = isSiteEnabledHospital(company);
const mobileTeamSiteFilters = useMemo(
() => buildMobileTeamSiteFilters(company, siteLocked ? siteName : undefined),
[company, siteName, siteLocked]
);
useEffect(() => {
if (!isOpen) {
setWorkOrder('');
setRequesterNameDraft('');
setRating('');
setCompany(defaultCompany);
setSiteName(defaultSiteName);
setAppliedFilters(EMPTY_FILTERS);
setReportData(null);
setLoading(false);
setRefreshing(false);
hasLoadedOnce.current = false;
setShowExportModal(false);
if (debounceRef.current) clearTimeout(debounceRef.current);
}
}, [isOpen, defaultCompany, defaultSiteName]);
useEffect(() => {
if (!isOpen) return;
setCompany(defaultCompany);
setSiteName(defaultSiteName);
}, [isOpen, defaultCompany, defaultSiteName]);
useEffect(() => {
if (!isOpen) return;
if (debounceRef.current) clearTimeout(debounceRef.current);
debounceRef.current = setTimeout(() => {
setAppliedFilters({
work_order: workOrder.trim(),
requester_name: requesterNameDraft.trim(),
rating,
company: company.trim(),
site_name: showSiteFilter ? siteName.trim() : '',
});
}, 450);
return () => {
if (debounceRef.current) clearTimeout(debounceRef.current);
};
}, [isOpen, workOrder, requesterNameDraft, rating, company, siteName, showSiteFilter]);
const fetchReport = useCallback(async (filters: AppliedFilters) => {
const isRefresh = hasLoadedOnce.current;
if (isRefresh) setRefreshing(true);
else setLoading(true);
try {
const data = await woFeedbackReportService.runWoFeedbackReport(filters);
setReportData(data || 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 ||
company.trim() ||
(showSiteFilter && siteName.trim())
);
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">
{t('dashboard.woFeedbackReport', { defaultValue: '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"
>
<FaTimes className="w-5 h-5" />
</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-2 xl:grid-cols-5 gap-3">
<LinkField
label={t('commonFields.hospital', { defaultValue: 'Hospital' })}
doctype="Company"
value={company}
onChange={value => {
setCompany(value);
if (!isSiteEnabledHospital(value)) setSiteName('');
}}
placeholder={t('dashboard.allHospitals', { defaultValue: 'All Hospitals' })}
disabled={companyLocked}
compact
filters={hospitalLinkFilters}
/>
{showSiteFilter && (
<LinkField
label={t('commonFields.siteName', { defaultValue: 'PHCC Site' })}
doctype="Mobile Team Site"
value={siteName}
onChange={setSiteName}
placeholder={t('dashboard.allSites', { defaultValue: 'All Sites' })}
disabled={siteLocked}
compact
filters={mobileTeamSiteFilters}
/>
)}
<LinkField
label={t('commonFields.workOrder', { defaultValue: 'Work Order' })}
doctype="Work_Order"
value={workOrder}
onChange={setWorkOrder}
placeholder="Filter by work order…"
compact
/>
<div className="relative w-full mb-2">
<label className={compactFilterLabelClass}>Requester Name</label>
<input
type="text"
value={requesterNameDraft}
onChange={e => setRequesterNameDraft(e.target.value)}
placeholder="Filter by requester…"
className={compactFilterInputClass}
/>
</div>
<div className="relative w-full mb-2">
<label className={compactFilterLabelClass}>Rating</label>
<select
value={rating}
onChange={e => setRating(e.target.value)}
className={compactFilterInputClass}
>
<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('');
setCompany(defaultCompany);
setSiteName(defaultSiteName);
}}
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} /> {t('dashboard.clearFilters', { defaultValue: '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={t('dashboard.woFeedbackReport', { defaultValue: '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;

View File

@ -0,0 +1,396 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { FaClipboardList, FaSync, FaTimes } from 'react-icons/fa';
import { useTranslation } from 'react-i18next';
import { buildWoUrl } from '../utils/buildWoUrl';
import type { DashboardLocationFilters } from '../utils/hospitalUtils';
import type { AssetDeviceStatusSummaryFilters } from '../services/assetDeviceStatusService';
import {
compactFilterFieldWrapClass,
compactFilterInputClass,
compactFilterLabelClass,
compactSummaryCardClass,
compactSummaryLabelClass,
compactSummaryValueClass,
compactToolbarBtnPrimary,
} from '../utils/reportModalStyles';
type ChartDataset = {
name?: string;
values?: number[];
color?: string;
colors?: string[];
};
type TypeStatusChartData = {
labels?: string[];
datasets?: ChartDataset[];
};
type WorkOrderStatusRefreshResult = {
chart: TypeStatusChartData | null;
total: number;
};
type WorkOrderStatusReportModalProps = {
isOpen: boolean;
onClose: () => void;
initialChartData?: TypeStatusChartData | null;
defaultFromDate?: string;
defaultToDate?: string;
locationFilters?: DashboardLocationFilters;
workOrderType?: string;
onRefresh?: (filters: AssetDeviceStatusSummaryFilters & { work_order_type?: string }) => Promise<WorkOrderStatusRefreshResult>;
};
const APP_BASE = '/asm_app';
const STATUS_HEADER_COLORS: Record<string, string> = {
Open: 'text-amber-700 dark:text-amber-300',
'Work In Progress': 'text-blue-700 dark:text-blue-300',
'Pending Review': 'text-rose-700 dark:text-rose-300',
Completed: 'text-green-700 dark:text-green-400',
Closed: 'text-purple-700 dark:text-purple-300',
Rejected: 'text-red-700 dark:text-red-400',
Cancelled: 'text-gray-600 dark:text-gray-300',
};
const WorkOrderStatusReportModal: React.FC<WorkOrderStatusReportModalProps> = ({
isOpen,
onClose,
initialChartData = null,
defaultFromDate = '',
defaultToDate = '',
locationFilters,
workOrderType = '',
onRefresh,
}) => {
const { t } = useTranslation();
const [fromDate, setFromDate] = useState(defaultFromDate);
const [toDate, setToDate] = useState(defaultToDate);
const [chartData, setChartData] = useState<TypeStatusChartData | null>(initialChartData);
const [totalWorkOrders, setTotalWorkOrders] = useState(0);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (!isOpen) return;
setFromDate(defaultFromDate);
setToDate(defaultToDate);
setChartData(initialChartData);
setError(null);
}, [isOpen, defaultFromDate, defaultToDate, initialChartData]);
useEffect(() => {
if (!isOpen) return;
const previousOverflow = document.body.style.overflow;
document.body.style.overflow = 'hidden';
return () => {
document.body.style.overflow = previousOverflow;
};
}, [isOpen]);
useEffect(() => {
if (!isOpen) return;
const onKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') onClose();
};
window.addEventListener('keydown', onKeyDown);
return () => window.removeEventListener('keydown', onKeyDown);
}, [isOpen, onClose]);
const buildFilters = useCallback(() => {
const filters: AssetDeviceStatusSummaryFilters & { work_order_type?: string } = {};
if (locationFilters?.company) filters.company = locationFilters.company;
if (locationFilters?.site_name) filters.site_name = locationFilters.site_name;
if (fromDate) filters.from_date = fromDate;
if (toDate) filters.to_date = toDate;
if (workOrderType) filters.work_order_type = workOrderType;
return filters;
}, [fromDate, toDate, locationFilters, workOrderType]);
const loadData = useCallback(async () => {
if (!onRefresh) return;
setLoading(true);
setError(null);
try {
const result = await onRefresh(buildFilters());
setChartData(result.chart);
setTotalWorkOrders(result.total);
} catch (err) {
setChartData(null);
setTotalWorkOrders(0);
setError(err instanceof Error ? err.message : 'Failed to load report data');
} finally {
setLoading(false);
}
}, [onRefresh, buildFilters]);
useEffect(() => {
if (!isOpen || !onRefresh) return;
loadData();
}, [isOpen, onRefresh]); // eslint-disable-line react-hooks/exhaustive-deps
const typeRows = useMemo(() => {
const labels = chartData?.labels || [];
const datasets = chartData?.datasets || [];
return labels.map((label, rowIndex) => ({
label,
cells: datasets.map(dataset => Number(dataset.values?.[rowIndex]) || 0),
rowTotal: datasets.reduce((sum, dataset) => sum + (Number(dataset.values?.[rowIndex]) || 0), 0),
}));
}, [chartData]);
const typeColumns = chartData?.datasets?.map(dataset => dataset.name || '') || [];
const columnTotals = useMemo(
() =>
typeColumns.map((_column, columnIndex) =>
typeRows.reduce((sum, row) => sum + (row.cells[columnIndex] || 0), 0)
),
[typeColumns, typeRows]
);
const computedTotal = useMemo(
() => typeRows.reduce((sum, row) => sum + row.rowTotal, 0),
[typeRows]
);
const displayTotal = totalWorkOrders || computedTotal;
const navigateToWorkOrders = (params: { work_order_type?: string; status?: string }) => {
const path = buildWoUrl(
{
work_order_type: params.work_order_type || workOrderType || undefined,
status: params.status,
company: locationFilters?.company,
site_name: locationFilters?.site_name,
},
fromDate || undefined,
toDate || undefined,
locationFilters
);
onClose();
window.location.href = `${APP_BASE}${path}`;
};
const handleTypeClick = (typeLabel: string) => {
navigateToWorkOrders({ work_order_type: typeLabel });
};
const handleCellClick = (typeLabel: string, status: string, value: number) => {
if (value <= 0) return;
navigateToWorkOrders({ work_order_type: typeLabel, status });
};
const handleStatusTotalClick = (status: string, value: number) => {
if (value <= 0) return;
navigateToWorkOrders({ status });
};
if (!isOpen) return null;
return (
<div className="fixed inset-0 z-[80] flex items-center justify-center p-4">
<button type="button" className="absolute inset-0 bg-black/50 backdrop-blur-sm" onClick={onClose} aria-label="Close" />
<div className="relative w-full max-w-5xl max-h-[90vh] overflow-hidden rounded-xl bg-white dark:bg-gray-900 shadow-2xl flex flex-col">
<div className="bg-gradient-to-r from-indigo-600 to-purple-600 text-white px-4 py-3">
<div className="flex items-start justify-between gap-3">
<div className="flex items-center gap-2 min-w-0">
<div className="p-1.5 rounded-lg bg-white/15 shrink-0">
<FaClipboardList className="w-4 h-4" />
</div>
<div className="min-w-0">
<h2 className="text-base font-semibold truncate">
{t('dashboard.workOrderStatus', { defaultValue: 'Work Order Status' })}
</h2>
<p className="text-xs text-white/85 mt-0.5">
{t('dashboard.workOrderTypeStatusHint', {
defaultValue: 'Type vs status counts — click a number to view filtered work orders',
})}
</p>
</div>
</div>
<button type="button" onClick={onClose} className="p-1.5 rounded-lg hover:bg-white/10">
<FaTimes className="text-sm" />
</button>
</div>
</div>
<div className="px-3 py-2 border-b border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800/50">
<div className="flex flex-wrap items-end gap-2">
<div className={`${compactFilterFieldWrapClass} mb-0 w-[8.5rem]`}>
<label className={compactFilterLabelClass}>
{t('dashboard.fromDate', { defaultValue: 'From Date' })}
</label>
<input
type="date"
value={fromDate}
onChange={e => setFromDate(e.target.value)}
className={compactFilterInputClass}
/>
</div>
<div className={`${compactFilterFieldWrapClass} mb-0 w-[8.5rem]`}>
<label className={compactFilterLabelClass}>
{t('dashboard.toDate', { defaultValue: 'To Date' })}
</label>
<input
type="date"
value={toDate}
onChange={e => setToDate(e.target.value)}
className={compactFilterInputClass}
/>
</div>
<button
type="button"
onClick={loadData}
disabled={loading || !onRefresh}
className={`${compactToolbarBtnPrimary('purple')} mb-0.5`}
>
<FaSync className={`text-[10px] ${loading ? 'animate-spin' : ''}`} />
{t('dashboard.refreshReport', { defaultValue: 'Refresh' })}
</button>
{(locationFilters?.company || locationFilters?.site_name) && (
<div className="flex flex-wrap gap-1.5 ml-auto pb-0.5">
{locationFilters?.company && (
<span className="px-2 py-0.5 rounded-full bg-white dark:bg-gray-800 border border-indigo-200 dark:border-indigo-800 text-indigo-800 dark:text-indigo-200 text-[10px]">
{locationFilters.company}
</span>
)}
{locationFilters?.site_name && (
<span className="px-2 py-0.5 rounded-full bg-white dark:bg-gray-800 border border-indigo-200 dark:border-indigo-800 text-indigo-800 dark:text-indigo-200 text-[10px]">
{locationFilters.site_name}
</span>
)}
</div>
)}
</div>
</div>
<div className="px-3 py-2 border-b border-gray-200 dark:border-gray-700">
<div className={`${compactSummaryCardClass} border-indigo-200 dark:border-indigo-800 bg-indigo-50 dark:bg-indigo-900/20 inline-block min-w-[8rem]`}>
<div className={compactSummaryLabelClass}>
{t('dashboard.totalWorkOrders', { defaultValue: 'Total Work Orders' })}
</div>
<div className={`${compactSummaryValueClass} text-indigo-700 dark:text-indigo-300`}>
{displayTotal.toLocaleString()}
</div>
</div>
</div>
<div className="overflow-y-auto flex-1">
{error && (
<div className="mx-3 mt-3 rounded-md border border-red-200 bg-red-50 text-red-700 px-3 py-2 text-xs">
{error}
</div>
)}
<table className="min-w-full text-xs">
<thead className="bg-gray-50 dark:bg-gray-800/80 sticky top-0">
<tr>
<th className="px-3 py-2 text-left font-semibold text-gray-700 dark:text-gray-200">
{t('dashboard.woType', { defaultValue: 'WO Type' })}
</th>
{typeColumns.map(column => (
<th
key={column}
className={`px-3 py-2 text-right font-semibold whitespace-nowrap ${STATUS_HEADER_COLORS[column] || 'text-gray-700 dark:text-gray-200'}`}
>
{column}
</th>
))}
<th className="px-3 py-2 text-right font-semibold text-gray-700 dark:text-gray-200">
{t('dashboard.totalWorkOrders', { defaultValue: 'Total' })}
</th>
</tr>
</thead>
<tbody>
{loading ? (
<tr>
<td colSpan={typeColumns.length + 2} className="px-3 py-8 text-center text-gray-500">
{t('common.loading', { defaultValue: 'Loading...' })}
</td>
</tr>
) : typeRows.length === 0 ? (
<tr>
<td colSpan={typeColumns.length + 2} className="px-3 py-8 text-center text-gray-500">
{t('common.noData', { defaultValue: 'No data' })}
</td>
</tr>
) : (
typeRows.map(row => (
<tr key={row.label} className="border-t border-gray-100 dark:border-gray-800">
<td className="px-3 py-2">
<button
type="button"
onClick={() => handleTypeClick(row.label)}
className="text-left font-medium text-indigo-700 dark:text-indigo-300 hover:underline"
>
{row.label}
</button>
</td>
{row.cells.map((value, index) => (
<td key={`${row.label}-${typeColumns[index]}`} className="px-3 py-2 text-right">
<CountButton
value={value}
onClick={() => handleCellClick(row.label, typeColumns[index], value)}
/>
</td>
))}
<td className="px-3 py-2 text-right font-semibold text-gray-900 dark:text-white">
{row.rowTotal}
</td>
</tr>
))
)}
</tbody>
{!loading && typeRows.length > 0 && (
<tfoot>
<tr className="border-t border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800/80">
<td className="px-3 py-2 font-semibold text-gray-800 dark:text-gray-100">
{t('dashboard.totalWorkOrders', { defaultValue: 'Total' })}
</td>
{columnTotals.map((value, index) => (
<td key={`total-${typeColumns[index]}`} className="px-3 py-2 text-right">
<CountButton
value={value}
onClick={() => handleStatusTotalClick(typeColumns[index], value)}
bold
/>
</td>
))}
<td className="px-3 py-2 text-right font-semibold text-gray-900 dark:text-white">
{computedTotal}
</td>
</tr>
</tfoot>
)}
</table>
</div>
</div>
</div>
);
};
const CountButton: React.FC<{
value: number;
onClick: () => void;
bold?: boolean;
}> = ({ value, onClick, bold = false }) => {
if (value <= 0) {
return <span className="text-gray-400">0</span>;
}
return (
<button
type="button"
onClick={onClick}
className={`${bold ? 'font-semibold' : 'font-medium'} text-gray-900 dark:text-white hover:text-indigo-600 dark:hover:text-indigo-300 hover:underline`}
>
{value.toLocaleString()}
</button>
);
};
export default WorkOrderStatusReportModal;

View File

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

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

@ -0,0 +1,103 @@
// API Configuration Types
interface ApiConfig {
BASE_URL: string;
ENDPOINTS: Record<string, string>;
DEFAULT_HEADERS: Record<string, string>;
TIMEOUT: number;
}
const API_CONFIG: ApiConfig = {
// Use same-origin relative URLs in production so the app works on any domain.
BASE_URL: import.meta.env.VITE_FRAPPE_BASE_URL || '',
// 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_WORK_ORDER_METRICS: '/api/method/asset_lite.api.dashboard_api.get_dashboard_work_order_metrics',
DASHBOARD_ASSET_DEVICE_STATUS: '/api/method/asset_lite.api.dashboard_api.get_asset_device_status_summary',
DASHBOARD_LIST_CHARTS: '/api/method/asset_lite.api.dashboard_api.list_dashboard_charts',
DASHBOARD_CHART_DATA: '/api/method/asset_lite.api.dashboard_api.get_dashboard_chart_data',
DASHBOARD_REPAIR_COST: '/api/method/asset_lite.api.dashboard_api.get_repair_cost_by_item',
// KYC Management
KYC_DETAILS: '/api/method/asset_lite.api.custom_api.get_kyc_details',
// Asset Management
GET_ASSETS: '/api/method/asset_lite.api.asset_api.get_assets',
GET_ASSET_DETAILS: '/api/method/asset_lite.api.asset_api.get_asset_details',
CREATE_ASSET: '/api/method/asset_lite.api.asset_api.create_asset',
UPDATE_ASSET: '/api/method/asset_lite.api.asset_api.update_asset',
DELETE_ASSET: '/api/method/asset_lite.api.asset_api.delete_asset',
GET_ASSET_FILTERS: '/api/method/asset_lite.api.asset_api.get_asset_filters',
GET_ASSET_STATS: '/api/method/asset_lite.api.asset_api.get_asset_stats',
SEARCH_ASSETS: '/api/method/asset_lite.api.asset_api.search_assets',
SUBMIT_ASSET: '/api/method/asset_lite.api.asset_api.submit_asset',
CANCEL_ASSET: '/api/method/asset_lite.api.asset_api.cancel_asset',
// Work Order Management
GET_WORK_ORDERS: '/api/method/asset_lite.api.work_order_api.get_work_orders',
GET_WORK_ORDER_DETAILS: '/api/method/asset_lite.api.work_order_api.get_work_order_details',
CREATE_WORK_ORDER: '/api/method/asset_lite.api.work_order_api.create_work_order',
UPDATE_WORK_ORDER: '/api/method/asset_lite.api.work_order_api.update_work_order',
DELETE_WORK_ORDER: '/api/method/asset_lite.api.work_order_api.delete_work_order',
UPDATE_WORK_ORDER_STATUS: '/api/method/asset_lite.api.work_order_api.update_work_order_status',
// Asset Maintenance Management
GET_ASSET_MAINTENANCE_LOGS: '/api/method/asset_lite.api.asset_maintenance_api.get_asset_maintenance_logs',
GET_ASSET_MAINTENANCE_LOG_DETAILS: '/api/method/asset_lite.api.asset_maintenance_api.get_asset_maintenance_log_details',
CREATE_ASSET_MAINTENANCE_LOG: '/api/method/asset_lite.api.asset_maintenance_api.create_asset_maintenance_log',
UPDATE_ASSET_MAINTENANCE_LOG: '/api/method/asset_lite.api.asset_maintenance_api.update_asset_maintenance_log',
DELETE_ASSET_MAINTENANCE_LOG: '/api/method/asset_lite.api.asset_maintenance_api.delete_asset_maintenance_log',
UPDATE_MAINTENANCE_STATUS: '/api/method/asset_lite.api.asset_maintenance_api.update_maintenance_status',
GET_MAINTENANCE_LOGS_BY_ASSET: '/api/method/asset_lite.api.asset_maintenance_api.get_maintenance_logs_by_asset',
GET_OVERDUE_MAINTENANCE_LOGS: '/api/method/asset_lite.api.asset_maintenance_api.get_overdue_maintenance_logs',
// PPM (Asset Maintenance) Management
GET_ASSET_MAINTENANCES: '/api/method/asset_lite.api.ppm_api.get_asset_maintenances',
GET_ASSET_MAINTENANCE_DETAILS: '/api/method/asset_lite.api.ppm_api.get_asset_maintenance_details',
CREATE_ASSET_MAINTENANCE: '/api/method/asset_lite.api.ppm_api.create_asset_maintenance',
UPDATE_ASSET_MAINTENANCE: '/api/method/asset_lite.api.ppm_api.update_asset_maintenance',
DELETE_ASSET_MAINTENANCE: '/api/method/asset_lite.api.ppm_api.delete_asset_maintenance',
GET_MAINTENANCE_TASKS: '/api/method/asset_lite.api.ppm_api.get_maintenance_tasks',
GET_SERVICE_COVERAGE: '/api/method/asset_lite.api.ppm_api.get_service_coverage',
GET_MAINTENANCES_BY_ASSET: '/api/method/asset_lite.api.ppm_api.get_maintenances_by_asset',
GET_ACTIVE_SERVICE_CONTRACTS: '/api/method/asset_lite.api.ppm_api.get_active_service_contracts',
// Authentication
LOGIN: '/api/method/login',
LOGOUT: '/api/method/logout',
CSRF_TOKEN: '/api/method/asset_lite.api.custom_api.get_user_details',
RESET_PASSWORD: '/api/method/frappe.core.doctype.user.user.reset_password',
// 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,33 @@
import React, { createContext, useCallback, useContext, useMemo, useState } from 'react';
type SidebarLayoutContextValue = {
mobileOpen: boolean;
openMobileSidebar: () => void;
closeMobileSidebar: () => void;
};
const SidebarLayoutContext = createContext<SidebarLayoutContextValue | undefined>(undefined);
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, openMobileSidebar, closeMobileSidebar }),
[mobileOpen, openMobileSidebar, closeMobileSidebar]
);
return <SidebarLayoutContext.Provider value={value}>{children}</SidebarLayoutContext.Provider>;
};
export function useSidebarLayout(): SidebarLayoutContextValue {
const context = useContext(SidebarLayoutContext);
if (!context) {
throw new Error('useSidebarLayout must be used within SidebarLayoutProvider');
}
return context;
}
export default SidebarLayoutContext;

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

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

@ -0,0 +1,210 @@
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;
work_orders_closed: 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(filters?: Record<string, string>) {
return useApi(
() => apiService.getNumberCards(filters),
[JSON.stringify(filters || {})]
);
}
export function useDashboardChart(chartName: string, filters?: Record<string, any>) {
return useApi(
() => apiService.getDashboardChartData(chartName, filters),
[chartName, JSON.stringify(filters || {})]
);
}
export function useChartsList(publicOnly: boolean = true) {
return useApi(() => apiService.listDashboardCharts(publicOnly), [publicOnly]);
}
export function useKycDetails() {
return useApi(() => apiService.getKycDetails());
}
export function useDoctypeRecords(
doctype: string,
filters?: Record<string, any>,
fields?: string[],
limit: number = 20,
offset: number = 0
) {
return useApi(
() => apiService.getDoctypeRecords(doctype, filters, fields, limit, offset),
[doctype, JSON.stringify(filters), JSON.stringify(fields), limit, offset]
);
}
// Authentication hook
export function useAuth() {
const [isAuthenticated, setIsAuthenticated] = useState<boolean>(
apiService.isAuthenticated()
);
const login = async (credentials: { email: string; password: string }) => {
try {
const response = await apiService.login(credentials);
// Check if we have any valid response data
if (response && response.message) {
// Set session ID if available
if (response.message.sid) {
apiService.setSessionId(response.message.sid);
}
setIsAuthenticated(true);
return response;
}
throw new Error('Login failed');
} catch (error) {
setIsAuthenticated(false);
throw error;
}
};
const logout = async () => {
try {
await apiService.logout();
} finally {
apiService.setSessionId('');
setIsAuthenticated(false);
}
};
return {
isAuthenticated,
login,
logout
};
}

View File

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

View File

@ -0,0 +1,288 @@
import { useState, useEffect, useCallback, useRef } from 'react';
import assetMaintenanceService from '../services/assetMaintenanceService';
import type { AssetMaintenanceLog, MaintenanceFilters, CreateMaintenanceData } from '../services/assetMaintenanceService';
/**
* Hook to fetch list of asset maintenance logs with filters and pagination
*/
export function useAssetMaintenanceLogs(
filters?: MaintenanceFilters,
limit: number = 20,
offset: number = 0,
orderBy?: string
) {
const [logs, setLogs] = useState<AssetMaintenanceLog[]>([]);
const [totalCount, setTotalCount] = useState(0);
const [hasMore, setHasMore] = useState(false);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [refetchTrigger, setRefetchTrigger] = useState(0);
const hasAttemptedRef = useRef(false);
const filtersJson = JSON.stringify(filters);
useEffect(() => {
if (hasAttemptedRef.current && error) {
return;
}
let isCancelled = false;
hasAttemptedRef.current = true;
const fetchLogs = async () => {
try {
setLoading(true);
const response = await assetMaintenanceService.getMaintenanceLogs(filters, undefined, limit, offset, orderBy);
if (!isCancelled) {
setLogs(response.asset_maintenance_logs);
setTotalCount(response.total_count);
setHasMore(response.has_more);
setError(null);
}
} catch (err) {
if (!isCancelled) {
const errorMessage = err instanceof Error ? err.message : 'Failed to fetch maintenance logs';
if (errorMessage.includes('417') || errorMessage.includes('Expectation Failed') || errorMessage.includes('has no attribute')) {
setError('API endpoint not deployed. Please deploy asset_maintenance_api.py to your Frappe server.');
} else {
setError(errorMessage);
}
setLogs([]);
setTotalCount(0);
setHasMore(false);
}
} finally {
if (!isCancelled) {
setLoading(false);
}
}
};
fetchLogs();
return () => {
isCancelled = true;
};
}, [filtersJson, limit, offset, orderBy, refetchTrigger]);
const refetch = useCallback(() => {
hasAttemptedRef.current = false;
setRefetchTrigger(prev => prev + 1);
}, []);
return { logs, totalCount, hasMore, loading, error, refetch };
}
/**
* Hook to fetch a single maintenance log by name
*/
export function useMaintenanceLogDetails(logName: string | null) {
const [log, setLog] = useState<AssetMaintenanceLog | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const fetchLog = useCallback(async () => {
if (!logName) {
setLog(null);
setLoading(false);
return;
}
try {
setLoading(true);
setError(null);
const data = await assetMaintenanceService.getMaintenanceLogDetails(logName);
setLog(data);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to fetch maintenance log details');
} finally {
setLoading(false);
}
}, [logName]);
useEffect(() => {
fetchLog();
}, [fetchLog]);
const refetch = useCallback(() => {
fetchLog();
}, [fetchLog]);
return { log, loading, error, refetch };
}
/**
* Hook to manage maintenance log operations
*/
export function useMaintenanceMutations() {
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const createLog = async (logData: CreateMaintenanceData) => {
try {
setLoading(true);
setError(null);
console.log('[useMaintenanceMutations] Creating maintenance log:', logData);
const response = await assetMaintenanceService.createMaintenanceLog(logData);
console.log('[useMaintenanceMutations] Create response:', response);
if (response.success) {
return response.asset_maintenance_log;
} else {
const backendError = (response as any).error || 'Failed to create maintenance log';
throw new Error(backendError);
}
} catch (err) {
console.error('[useMaintenanceMutations] Create error:', err);
const errorMessage = err instanceof Error ? err.message : 'Failed to create maintenance log';
setError(errorMessage);
throw err;
} finally {
setLoading(false);
}
};
const updateLog = async (logName: string, logData: Partial<CreateMaintenanceData>) => {
try {
setLoading(true);
setError(null);
console.log('[useMaintenanceMutations] Updating maintenance log:', logName, logData);
const response = await assetMaintenanceService.updateMaintenanceLog(logName, logData);
console.log('[useMaintenanceMutations] Update response:', response);
if (response.success) {
return response.asset_maintenance_log;
} else {
const backendError = (response as any).error || 'Failed to update maintenance log';
throw new Error(backendError);
}
} catch (err) {
console.error('[useMaintenanceMutations] Update error:', err);
const errorMessage = err instanceof Error ? err.message : 'Failed to update maintenance log';
setError(errorMessage);
throw err;
} finally {
setLoading(false);
}
};
const deleteLog = async (logName: string) => {
try {
setLoading(true);
setError(null);
const response = await assetMaintenanceService.deleteMaintenanceLog(logName);
if (!response.success) {
throw new Error('Failed to delete maintenance log');
}
return response;
} catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Failed to delete maintenance log';
setError(errorMessage);
throw err;
} finally {
setLoading(false);
}
};
const updateStatus = async (logName: string, maintenanceStatus?: string, workflowState?: string) => {
try {
setLoading(true);
setError(null);
const response = await assetMaintenanceService.updateMaintenanceStatus(logName, maintenanceStatus, workflowState);
if (response.success) {
return response.asset_maintenance_log;
} else {
throw new Error('Failed to update maintenance status');
}
} catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Failed to update status';
setError(errorMessage);
throw err;
} finally {
setLoading(false);
}
};
return { createLog, updateLog, deleteLog, updateStatus, loading, error };
}
/**
* Hook to fetch maintenance logs for a specific asset
*/
export function useAssetMaintenanceHistory(assetName: string | null) {
const [logs, setLogs] = useState<AssetMaintenanceLog[]>([]);
const [totalCount, setTotalCount] = useState(0);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const fetchHistory = useCallback(async () => {
if (!assetName) {
setLogs([]);
setLoading(false);
return;
}
try {
setLoading(true);
setError(null);
const response = await assetMaintenanceService.getMaintenanceLogsByAsset(assetName);
setLogs(response.asset_maintenance_logs);
setTotalCount(response.total_count);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to fetch maintenance history');
} finally {
setLoading(false);
}
}, [assetName]);
useEffect(() => {
fetchHistory();
}, [fetchHistory]);
return { logs, totalCount, loading, error, refetch: fetchHistory };
}
/**
* Hook to fetch overdue maintenance logs
*/
export function useOverdueMaintenanceLogs() {
const [logs, setLogs] = useState<AssetMaintenanceLog[]>([]);
const [totalCount, setTotalCount] = useState(0);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const fetchOverdue = useCallback(async () => {
try {
setLoading(true);
setError(null);
const response = await assetMaintenanceService.getOverdueMaintenanceLogs();
setLogs(response.asset_maintenance_logs);
setTotalCount(response.total_count);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to fetch overdue maintenance');
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
fetchOverdue();
}, [fetchOverdue]);
return { logs, totalCount, loading, error, refetch: fetchOverdue };
}

View File

@ -0,0 +1,120 @@
import { useState, useCallback, useEffect } from 'react';
import apiService from '../services/apiService';
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;
}
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: any[];
changed: any[];
removed: any[];
row_changed: any[];
} = { added: [], changed: [], removed: [], row_changed: [] };
try {
parsedData = JSON.parse(version.data || '{}');
} catch (e) {
console.error('Error parsing version data:', e);
}
let changes: VersionChange[] = [];
if (Array.isArray(parsedData.changed)) {
changes = parsedData.changed.map((change: any[]) => ({
field: change[0] || '',
oldValue: change[1],
newValue: change[2],
}));
} else if (parsedData.changed && typeof parsedData.changed === 'object') {
changes = Object.entries(parsedData.changed).map(([field, values]) => {
const [oldValue, newValue] = values as [any, any];
return { field, oldValue, newValue };
});
}
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]);
useEffect(() => {
fetchAuditLogs();
}, [fetchAuditLogs]);
return {
auditLogs,
loading,
error,
refetch: fetchAuditLogs,
};
};
export default useAuditLogs;

View File

@ -0,0 +1,124 @@
import { useState, useEffect, useCallback, useRef } from 'react';
import commentService, { type CommentData, type MentionUser } from '../services/commentService';
interface UseCommentsOptions {
referenceDoctype: string;
referenceName: string | null;
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>;
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('');
const [mentionUsers, setMentionUsers] = useState<MentionUser[]>([]);
const [mentionLoading, setMentionLoading] = useState(false);
const mentionSearchTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
commentService.getCurrentUser().then(setCurrentUser).catch(() => {});
}, []);
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]);
useEffect(() => {
if (!pollInterval || !referenceName) return;
const id = setInterval(fetchComments, pollInterval);
return () => clearInterval(id);
}, [pollInterval, fetchComments, referenceName]);
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;
} finally {
setPosting(false);
}
},
[referenceDoctype, referenceName, fetchComments]
);
const deleteComment = useCallback(async (commentName: string) => {
try {
await commentService.deleteComment(commentName);
setComments((prev) => prev.filter((c) => c.name !== commentName));
} catch (err: any) {
throw err;
}
}, []);
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,56 @@
import { useEffect } from 'react';
import { fetchUserHospitalName } from '../utils/userHospital';
export type HospitalFormField =
| 'company'
| 'custom_hospital_name'
| 'hospital'
| 'asset_owner_company';
interface UseDefaultHospitalOptions {
enabled?: boolean;
fields: HospitalFormField[];
}
export function useDefaultHospital(
setFormData: React.Dispatch<React.SetStateAction<Record<string, any>>>,
{ enabled = true, fields }: UseDefaultHospitalOptions
): void {
const fieldsKey = fields.join('|');
useEffect(() => {
if (!enabled) {
return;
}
let cancelled = false;
fetchUserHospitalName().then((hospitalName) => {
if (cancelled || !hospitalName) {
return;
}
setFormData((prev) => {
const updates: Partial<Record<HospitalFormField, string>> = {};
for (const field of fields) {
if (!prev[field]) {
updates[field] = hospitalName;
}
}
if (Object.keys(updates).length === 0) {
return prev;
}
return { ...prev, ...updates };
});
});
return () => {
cancelled = true;
};
}, [enabled, fieldsKey, setFormData]);
}
export default useDefaultHospital;

View File

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

View File

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

View File

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

View File

@ -0,0 +1,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,228 @@
import { useState, useEffect, useCallback, useRef } from 'react';
import itemService from '../services/itemService';
import type { Item, CreateItemData } from '../services/itemService';
const mergeFilters = (
userFilters: ItemFilters | undefined,
permissionFilters: Record<string, any>
): ItemFilters => {
const merged: ItemFilters = { ...(userFilters || {}) };
for (const [field, value] of Object.entries(permissionFilters)) {
if (!merged[field]) {
(merged as any)[field] = value;
} else if (Array.isArray(value) && value[0] === 'in') {
const permittedValues = value[1] as string[];
const userValue = merged[field];
if (typeof userValue === 'string') {
if (!permittedValues.includes(userValue)) {
(merged as any)[field] = ['in', []];
}
} else if (Array.isArray(userValue) && userValue[0] === 'in') {
const userValues = userValue[1] as string[];
const intersection = userValues.filter((v) => permittedValues.includes(v));
(merged as any)[field] = ['in', intersection];
} else {
(merged as any)[field] = value;
}
}
}
return merged;
};
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,
permissionFilters: Record<string, any> = {}
) {
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);
const permissionFiltersJson = JSON.stringify(permissionFilters);
useEffect(() => {
if (hasAttemptedRef.current && error) {
return;
}
let isCancelled = false;
hasAttemptedRef.current = true;
const fetchItems = async () => {
try {
setLoading(true);
const mergedFilters = mergeFilters(filters, permissionFilters);
const fields = ['name', 'item_code', 'item_name', 'item_group', 'stock_uom', 'disabled', 'is_stock_item','custom_hospital_name', 'opening_stock', 'valuation_rate', 'standard_rate', 'creation', 'modified', 'owner', 'docstatus'];
const response = await itemService.getItems(mergedFilters, fields, limit, offset, orderBy);
if (!isCancelled) {
setItems(response.data);
setTotalCount(response.total);
setHasMore(offset + response.data.length < response.total);
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, permissionFiltersJson, 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,102 @@
import { useCallback, useMemo } from 'react';
import { useSearchParams } from 'react-router-dom';
import type { DateFilterField, SortOption } from '../utils/listFilterUtils';
import { normalizeSortBy } from '../utils/listFilterUtils';
export interface ListSortFilterState {
sortBy: SortOption;
dateFilterBy: DateFilterField;
dateStart: string;
dateEnd: string;
page: number;
setSortBy: (value: string) => void;
setDateFilterBy: (value: DateFilterField) => void;
setDateStart: (value: string) => void;
setDateEnd: (value: string) => void;
setPage: (value: number) => void;
resetPage: () => void;
}
export function useListSortFilters(defaultSort: SortOption = 'creation desc'): ListSortFilterState {
const [searchParams, setSearchParams] = useSearchParams();
const sortBy = normalizeSortBy(searchParams.get('sort_by') || defaultSort, defaultSort);
const dateFilterBy = (searchParams.get('date_filter_by') || '') as DateFilterField;
const dateStart = searchParams.get('date_start') || '';
const dateEnd = searchParams.get('date_end') || '';
const page = Math.max(0, parseInt(searchParams.get('page') || '1', 10) - 1);
const updateParams = useCallback(
(updates: Record<string, string | null>, resetPageOnChange = false) => {
const next = new URLSearchParams(searchParams);
for (const [key, value] of Object.entries(updates)) {
if (value === null || value === '') next.delete(key);
else next.set(key, value);
}
if (resetPageOnChange) {
next.delete('page');
}
setSearchParams(next, { replace: true });
},
[searchParams, setSearchParams]
);
const setSortBy = useCallback(
(value: string) => updateParams({ sort_by: value }, true),
[updateParams]
);
const setDateFilterBy = useCallback(
(value: DateFilterField) => updateParams({ date_filter_by: value || null }, true),
[updateParams]
);
const setDateStart = useCallback(
(value: string) => updateParams({ date_start: value || null }, true),
[updateParams]
);
const setDateEnd = useCallback(
(value: string) => updateParams({ date_end: value || null }, true),
[updateParams]
);
const setPage = useCallback(
(value: number) => updateParams({ page: value <= 0 ? null : String(value + 1) }),
[updateParams]
);
const resetPage = useCallback(() => updateParams({ page: null }), [updateParams]);
return useMemo(
() => ({
sortBy,
dateFilterBy,
dateStart,
dateEnd,
page,
setSortBy,
setDateFilterBy,
setDateStart,
setDateEnd,
setPage,
resetPage,
}),
[
sortBy,
dateFilterBy,
dateStart,
dateEnd,
page,
setSortBy,
setDateFilterBy,
setDateStart,
setDateEnd,
setPage,
resetPage,
]
);
}

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

View File

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

View File

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

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

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

View File

@ -0,0 +1,64 @@
import { useState, useEffect, useCallback } from 'react';
import { fetchCurrentUserProfile } from '../services/userProfileService';
import { getStoredRoleProfile, getStoredUserEmail } from '../utils/roleAccess';
interface UseRoleProfileResult {
roleProfile: string;
userEmail: string;
loading: boolean;
refreshRoleProfile: () => Promise<void>;
}
export function useRoleProfile(): UseRoleProfileResult {
const [roleProfile, setRoleProfile] = useState(getStoredRoleProfile);
const [userEmail, setUserEmail] = useState(getStoredUserEmail);
const [loading, setLoading] = useState(!getStoredRoleProfile());
const refreshRoleProfile = useCallback(async () => {
const storedUser = localStorage.getItem('user');
if (!storedUser) {
setRoleProfile('');
setUserEmail('');
setLoading(false);
return;
}
try {
setLoading(true);
const profile = await fetchCurrentUserProfile();
const parsed = JSON.parse(storedUser);
const updatedUser = {
...parsed,
email: profile.email || profile.name || parsed.email,
role_profile_name: profile.role_profile_name || '',
full_name: profile.full_name || parsed.full_name,
custom_site_name: profile.custom_site_name || '',
custom_phcc_site_name: profile.custom_phcc_site_name || '',
};
localStorage.setItem('user', JSON.stringify(updatedUser));
setRoleProfile(updatedUser.role_profile_name || '');
setUserEmail(updatedUser.email || profile.name || '');
} catch (error) {
console.error('[useRoleProfile] Failed to load role profile:', error);
setRoleProfile(getStoredRoleProfile());
setUserEmail(getStoredUserEmail());
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
if (!getStoredRoleProfile()) {
refreshRoleProfile();
} else {
setRoleProfile(getStoredRoleProfile());
setUserEmail(getStoredUserEmail());
setLoading(false);
}
}, [refreshRoleProfile]);
return { roleProfile, userEmail, loading, refreshRoleProfile };
}
export default useRoleProfile;

View File

@ -0,0 +1,88 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import {
fetchUserLocationFilters,
getStoredHospitalName,
getStoredPhccSiteName,
} from '../utils/userHospital';
import { buildHospitalLinkFilters, isSiteEnabledHospital } from '../utils/hospitalUtils';
export function useUserDashboardFilters() {
const [filterHospital, setFilterHospital] = useState(() => getStoredHospitalName());
const [filterSiteName, setFilterSiteName] = useState(() => getStoredPhccSiteName());
const [hospitalLocked, setHospitalLocked] = useState(false);
const [siteLocked, setSiteLocked] = useState(() => !!getStoredPhccSiteName());
const [allowedHospitals, setAllowedHospitals] = useState<string[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
let cancelled = false;
fetchUserLocationFilters()
.then(({ hospital, siteName, isLocked, allowedHospitals: allowed }) => {
if (cancelled) return;
setAllowedHospitals(allowed);
setHospitalLocked(isLocked);
if (hospital) {
setFilterHospital(hospital);
}
if (siteName) {
setFilterSiteName(siteName);
setSiteLocked(true);
}
})
.finally(() => {
if (!cancelled) {
setLoading(false);
}
});
return () => {
cancelled = true;
};
}, []);
const handleHospitalChange = useCallback(
(value: string) => {
if (hospitalLocked) return;
setFilterHospital(value);
if (!isSiteEnabledHospital(value)) {
setFilterSiteName('');
}
},
[hospitalLocked]
);
const handleSiteChange = useCallback(
(value: string) => {
if (siteLocked) return;
setFilterSiteName(value);
},
[siteLocked]
);
const hospitalLinkFilters = useMemo(
() => buildHospitalLinkFilters(allowedHospitals),
[allowedHospitals]
);
return {
filterHospital,
filterSiteName,
hospitalLocked,
siteLocked,
allowedHospitals,
hospitalLinkFilters,
/** @deprecated use hospitalLocked — hospital filter readonly when user has one permitted hospital */
filtersLocked: hospitalLocked,
loading,
handleHospitalChange,
handleSiteChange,
setFilterSiteName: handleSiteChange,
};
}
export default useUserDashboardFilters;

View File

@ -0,0 +1,62 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import {
fetchUserLocationFilters,
getStoredHospitalName,
} from '../utils/userHospital';
import { buildHospitalLinkFilters } from '../utils/hospitalUtils';
export function useUserHospitalFilter() {
const [filterHospital, setFilterHospital] = useState(() => getStoredHospitalName());
const [filtersLocked, setFiltersLocked] = useState(false);
const [allowedHospitals, setAllowedHospitals] = useState<string[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
let cancelled = false;
fetchUserLocationFilters()
.then(({ hospital, isLocked, allowedHospitals: allowed }) => {
if (cancelled) return;
setAllowedHospitals(allowed);
setFiltersLocked(isLocked);
if (hospital) {
setFilterHospital(hospital);
}
})
.finally(() => {
if (!cancelled) {
setLoading(false);
}
});
return () => {
cancelled = true;
};
}, []);
const handleHospitalChange = useCallback(
(value: string) => {
if (filtersLocked) return;
setFilterHospital(value);
},
[filtersLocked]
);
const hospitalLinkFilters = useMemo(
() => buildHospitalLinkFilters(allowedHospitals),
[allowedHospitals]
);
return {
filterHospital,
filtersLocked,
allowedHospitals,
hospitalLinkFilters,
loading,
handleHospitalChange,
};
}
export default useUserHospitalFilter;

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,37 @@
import { useEffect, useState } from 'react';
import {
fetchUserLocationFilters,
getStoredPhccSiteName,
} from '../utils/userHospital';
export function useUserPhccSiteFilter() {
const [userPhccSiteName, setUserPhccSiteName] = useState(() => getStoredPhccSiteName());
const [loading, setLoading] = useState(!getStoredPhccSiteName());
useEffect(() => {
let cancelled = false;
fetchUserLocationFilters()
.then(({ siteName }) => {
if (cancelled || !siteName) return;
setUserPhccSiteName(siteName);
})
.finally(() => {
if (!cancelled) {
setLoading(false);
}
});
return () => {
cancelled = true;
};
}, []);
return {
userPhccSiteName,
siteLocked: !!userPhccSiteName,
loading,
};
}
export default useUserPhccSiteFilter;

View File

@ -0,0 +1,409 @@
import { useState, useEffect, useCallback, useRef } from 'react';
import workOrderService from '../services/workOrderService';
import type { WorkOrder, WorkOrderFilters, WorkOrderListQuery, CreateWorkOrderData } from '../services/workOrderService';
/**
* Merge user filters with permission filters
* Permission filters take precedence for security
*/
const mergeFilters = (
userQuery: WorkOrderFilters | WorkOrderListQuery | any[] | undefined,
permissionFilters: Record<string, any>
): WorkOrderFilters | WorkOrderListQuery | any[] => {
if (Array.isArray(userQuery)) {
return userQuery;
}
const baseFilters =
userQuery && 'or_filters' in userQuery
? { ...(userQuery.filters as Record<string, any> | undefined) }
: { ...((userQuery as WorkOrderFilters | undefined) || {}) };
const merged: Record<string, any> = { ...baseFilters };
const orFilters =
userQuery && 'or_filters' in userQuery ? userQuery.or_filters : undefined;
// Apply permission filters (they take precedence for security)
for (const [field, value] of Object.entries(permissionFilters)) {
if (!merged[field]) {
merged[field] = value;
} else if (Array.isArray(value) && value[0] === 'in') {
const permittedValues = value[1] as string[];
const userValue = merged[field];
if (typeof userValue === 'string') {
if (!permittedValues.includes(userValue)) {
merged[field] = ['in', []];
}
} else if (Array.isArray(userValue) && userValue[0] === 'in') {
const userValues = userValue[1] as string[];
const intersection = userValues.filter(v => permittedValues.includes(v));
merged[field] = ['in', intersection];
} else {
merged[field] = value;
}
}
}
if (orFilters?.length) {
return { filters: merged, or_filters: orFilters };
}
return merged;
};
/**
* Hook to fetch list of work orders with filters, pagination, and permission-based filtering
*/
export function useWorkOrders(
filters?: WorkOrderFilters | WorkOrderListQuery | any[],
limit: number = 20,
offset: number = 0,
orderBy?: string,
permissionFilters: Record<string, 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); // ← NEW
useEffect(() => {
// Prevent fetching if already attempted and has error
if (hasAttemptedRef.current && error) {
return;
}
let isCancelled = false;
hasAttemptedRef.current = true;
const fetchWorkOrders = async () => {
try {
setLoading(true);
// ✅ NEW: Merge user filters with permission filters
const mergedFilters = Array.isArray(filters)
? filters
: mergeFilters(filters, permissionFilters);
console.log('[useWorkOrders] User filters:', filters);
console.log('[useWorkOrders] Permission filters:', permissionFilters);
console.log('[useWorkOrders] Merged filters:', mergedFilters);
const response = await workOrderService.getWorkOrders(mergedFilters, undefined, limit, offset, orderBy);
if (!isCancelled) {
setWorkOrders(response.work_orders);
setTotalCount(response.total_count);
setHasMore(response.has_more);
setError(null);
}
} catch (err) {
if (!isCancelled) {
const errorMessage = err instanceof Error ? err.message : 'Failed to fetch work orders';
// Check if it's a 417 error (API not deployed)
if (errorMessage.includes('417') || errorMessage.includes('Expectation Failed') || errorMessage.includes('has no attribute')) {
setError('API endpoint not deployed or misconfigured. Please check FIX_417_ERROR.md for solutions.');
} else {
setError(errorMessage);
}
// Set empty arrays
setWorkOrders([]);
setTotalCount(0);
setHasMore(false);
}
} finally {
if (!isCancelled) {
setLoading(false);
}
}
};
fetchWorkOrders();
return () => {
isCancelled = true;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [filtersJson, permissionFiltersJson, limit, offset, orderBy, refetchTrigger]); // ← Added permissionFiltersJson
const refetch = useCallback(() => {
hasAttemptedRef.current = false; // Reset to allow refetch
setRefetchTrigger(prev => prev + 1);
}, []);
return { workOrders, totalCount, hasMore, loading, error, refetch };
}
/**
* Hook to fetch a single work order by name
*/
export function useWorkOrderDetails(workOrderName: string | null) {
const [workOrder, setWorkOrder] = useState<WorkOrder | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const fetchWorkOrder = useCallback(async () => {
if (!workOrderName) {
setWorkOrder(null);
setLoading(false);
return;
}
try {
setLoading(true);
setError(null);
const data = await workOrderService.getWorkOrderDetails(workOrderName);
setWorkOrder(data);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to fetch work order details');
} finally {
setLoading(false);
}
}, [workOrderName]);
useEffect(() => {
fetchWorkOrder();
}, [fetchWorkOrder]);
const refetch = useCallback(() => {
fetchWorkOrder();
}, [fetchWorkOrder]);
return { workOrder, loading, error, refetch };
}
/**
* Hook to manage work order operations (create, update, delete)
*/
export function useWorkOrderMutations() {
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const createWorkOrder = async (workOrderData: CreateWorkOrderData) => {
try {
setLoading(true);
setError(null);
console.log('[useWorkOrderMutations] Creating work order with data:', workOrderData);
const response = await workOrderService.createWorkOrder(workOrderData);
console.log('[useWorkOrderMutations] Create work order response:', response);
if (response.success) {
return response.work_order;
} else {
// Include the backend error message if available
const backendError = (response as any).error || 'Failed to create work order';
throw new Error(backendError);
}
} catch (err) {
console.error('[useWorkOrderMutations] Create work order error:', err);
const errorMessage = err instanceof Error ? err.message : 'Failed to create work order';
setError(errorMessage);
throw err;
} finally {
setLoading(false);
}
};
const updateWorkOrder = async (workOrderName: string, workOrderData: Partial<CreateWorkOrderData>) => {
try {
setLoading(true);
setError(null);
console.log('[useWorkOrderMutations] Updating work order:', workOrderName, 'with data:', workOrderData);
const response = await workOrderService.updateWorkOrder(workOrderName, workOrderData);
console.log('[useWorkOrderMutations] Update work order response:', response);
if (response.success) {
return response.work_order;
} else {
// Include the backend error message if available
const backendError = (response as any).error || 'Failed to update work order';
throw new Error(backendError);
}
} catch (err) {
console.error('[useWorkOrderMutations] Update work order error:', err);
const errorMessage = err instanceof Error ? err.message : 'Failed to update work order';
setError(errorMessage);
throw err;
} finally {
setLoading(false);
}
};
const deleteWorkOrder = async (workOrderName: string) => {
try {
setLoading(true);
setError(null);
const response = await workOrderService.deleteWorkOrder(workOrderName);
if (!response.success) {
throw new Error('Failed to delete work order');
}
return response;
} catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Failed to delete work order';
setError(errorMessage);
throw err;
} finally {
setLoading(false);
}
};
const submitWorkOrder = async (workOrderName: string) => {
try {
setLoading(true);
setError(null);
console.log('[useWorkOrderMutations] Submitting work order:', workOrderName);
const response = await workOrderService.submitWorkOrder(workOrderName);
console.log('[useWorkOrderMutations] Submit work order response:', response);
return response;
} catch (err) {
console.error('[useWorkOrderMutations] Submit work order error:', err);
const errorMessage = err instanceof Error ? err.message : 'Failed to submit work order';
setError(errorMessage);
throw err;
} finally {
setLoading(false);
}
};
const updateStatus = async (workOrderName: string, repairStatus?: string, workflowState?: string) => {
try {
setLoading(true);
setError(null);
const response = await workOrderService.updateWorkOrderStatus(workOrderName, repairStatus, workflowState);
if (response.success) {
return response.work_order;
} else {
throw new Error('Failed to update work order status');
}
} catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Failed to update status';
setError(errorMessage);
throw err;
} finally {
setLoading(false);
}
};
return { createWorkOrder, updateWorkOrder, deleteWorkOrder, submitWorkOrder, updateStatus, loading, error };
}
/**
* Hook to fetch work order filter options
*/
export function useWorkOrderFilters() {
const [filters, setFilters] = useState<any | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const fetchFilters = useCallback(async () => {
try {
setLoading(true);
setError(null);
const data = await workOrderService.getWorkOrderFilters();
setFilters(data);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to fetch filters');
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
fetchFilters();
}, [fetchFilters]);
const refetch = useCallback(() => {
fetchFilters();
}, [fetchFilters]);
return { filters, loading, error, refetch };
}
/**
* Hook to fetch work order statistics
*/
export function useWorkOrderStats() {
const [stats, setStats] = useState<any | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const fetchStats = useCallback(async () => {
try {
setLoading(true);
setError(null);
const data = await workOrderService.getWorkOrderStats();
setStats(data);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to fetch statistics');
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
fetchStats();
}, [fetchStats]);
const refetch = useCallback(() => {
fetchStats();
}, [fetchStats]);
return { stats, loading, error, refetch };
}
/**
* Hook for work order search
*/
export function useWorkOrderSearch() {
const [results, setResults] = useState<WorkOrder[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const search = useCallback(async (searchTerm: string, limit: number = 10) => {
if (!searchTerm.trim()) {
setResults([]);
return;
}
try {
setLoading(true);
setError(null);
const data = await workOrderService.searchWorkOrders(searchTerm, limit);
setResults(data);
} catch (err) {
setError(err instanceof Error ? err.message : 'Search failed');
setResults([]);
} finally {
setLoading(false);
}
}, []);
const clearResults = useCallback(() => {
setResults([]);
setError(null);
}, []);
return { results, loading, error, search, clearResults };
}

View File

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

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

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

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

@ -0,0 +1,100 @@
@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);
}
.scrollbar-hide {
-ms-overflow-style: none;
scrollbar-width: none;
}
.scrollbar-hide::-webkit-scrollbar {
display: none;
}
}
/* Custom Scrollbar Styles */
@layer base {
/* Webkit browsers (Chrome, Safari, Edge) */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: rgb(209, 213, 219); /* gray-300 */
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: rgb(156, 163, 175); /* gray-400 */
}
/* Dark mode scrollbar */
.dark ::-webkit-scrollbar-thumb {
background: rgb(75, 85, 99); /* gray-600 */
}
.dark ::-webkit-scrollbar-thumb:hover {
background: rgb(107, 114, 128); /* gray-500 */
}
/* Firefox */
* {
scrollbar-width: thin;
scrollbar-color: rgb(209, 213, 219) transparent;
}
.dark * {
scrollbar-color: rgb(75, 85, 99) transparent;
}
/* RTL Support */
[dir="rtl"] {
direction: rtl;
text-align: right;
}
[dir="ltr"] {
direction: ltr;
text-align: left;
}
/* RTL spacing utilities */
.rtl .ml-auto {
margin-left: 0;
margin-right: auto;
}
.rtl .mr-auto {
margin-right: 0;
margin-left: auto;
}
/* RTL flex utilities */
.rtl .flex-row-reverse {
flex-direction: row-reverse;
}
}

View File

@ -0,0 +1,276 @@
{
"common": {
"dashboard": "لوحة التحكم",
"assets": "الأصول",
"workOrders": "أوامر العمل",
"maintenance": "صيانة الأصول",
"ppm": "الصيانة الوقائية",
"logout": "تسجيل الخروج",
"login": "تسجيل الدخول",
"email": "البريد الإلكتروني",
"password": "كلمة المرور",
"submit": "إرسال",
"cancel": "إلغاء",
"save": "حفظ",
"delete": "حذف",
"edit": "تعديل",
"create": "إنشاء",
"search": "بحث",
"filter": "تصفية",
"export": "تصدير",
"import": "استيراد",
"loading": "جاري التحميل...",
"noData": "لا توجد بيانات",
"error": "خطأ",
"success": "نجح",
"darkMode": "الوضع الداكن",
"lightMode": "الوضع الفاتح",
"language": "اللغة",
"english": "الإنجليزية",
"arabic": "العربية"
},
"sidebar": {
"title": "أصول سيرا",
"loggedInAs": "تم تسجيل الدخول كـ:",
"version": "أصول سيرا نظام إدارة الأصول الإصدار 2.26"
},
"login": {
"title": "أصول سيرا",
"subtitle": "نظام إدارة الأصول",
"signIn": "قم بتسجيل الدخول للمتابعة",
"emailPlaceholder": "أدخل بريدك الإلكتروني",
"passwordPlaceholder": "أدخل كلمة المرور",
"loginFailed": "فشل تسجيل الدخول. يرجى التحقق من بيانات الاعتماد الخاصة بك.",
"demoLogin": "تسجيل دخول تجريبي",
"forgotPassword": "نسيت كلمة المرور؟",
"forgotPasswordTitle": "إعادة تعيين كلمة المرور",
"forgotPasswordHint": "أدخل بريدك الإلكتروني أو اسم المستخدم. سنرسل لك رابطًا لإعادة تعيين كلمة المرور.",
"forgotPasswordUserRequired": "يرجى إدخال بريدك الإلكتروني أو اسم المستخدم.",
"forgotPasswordUserPlaceholder": "البريد الإلكتروني أو اسم المستخدم",
"forgotPasswordSubmit": "إرسال رابط إعادة التعيين",
"forgotPasswordClose": "إغلاق",
"forgotPasswordSentSuccess": "إذا كان هناك حساب لهذا المستخدم، فقد أُرسلت تعليمات إعادة تعيين كلمة المرور بالبريد الإلكتروني.",
"forgotPasswordNotFound": "لم يتم العثور على حساب بهذا البريد الإلكتروني أو اسم المستخدم.",
"forgotPasswordTimeout": "انتهت مهلة الطلب. يرجى المحاولة مرة أخرى.",
"forgotPasswordCannotReset": "إعادة تعيين كلمة المرور غير متاحة لهذا الحساب.",
"forgotPasswordFailed": "تعذر إرسال رابط إعادة التعيين. يرجى المحاولة لاحقًا.",
"finishingSignOut": "جاري إنهاء تسجيل الخروج…",
"afterPasswordResetSignIn": "تم تحديث كلمة المرور. يرجى تسجيل الدخول بكلمة المرور الجديدة."
},
"dashboard": {
"title": "لوحة التحكم",
"loading": "جاري تحميل لوحة التحكم...",
"totalAssets": "إجمالي عدد الأصول",
"openWorkOrders": "أوامر العمل المفتوحة",
"workOrdersInProgress": "أوامر العمل قيد التنفيذ",
"completedWorkOrders": "أوامر العمل المكتملة",
"closedWorkOrders": "أوامر العمل المغلقة",
"totalWorkOrders": "إجمالي أوامر العمل",
"overdueWorkOrders": "أوامر العمل المتأخرة",
"upTime": "وقت التشغيل",
"downTime": "وقت التوقف",
"workOrderStatus": "حالة أمر العمل",
"workOrderTypeStatusHint": "عدد الحالات حسب النوع — انقر على الرقم لعرض أوامر العمل المفلترة",
"workOrderByType": "أمر العمل حسب النوع",
"maintenanceByAsset": "الصيانة حسب الأصل",
"assigneesStatus": "عدد حالة مكلفي صيانة الأصول",
"assignedTo": "المكلف",
"plannedTasks": "مخطط",
"completedTasks": "مكتمل",
"cancelledTasks": "ملغى",
"overdueTasks": "متأخر",
"totalTasks": "الإجمالي",
"maintenanceFrequency": "تكرار الصيانة",
"maintenanceLogs": "سجلات الصيانة",
"assetUptime": "وقت تشغيل الأصل",
"avgResponseTime": "متوسط وقت الاستجابة",
"maintenanceEfficiency": "كفاءة الصيانة",
"overdueMaintenance": "صيانة متأخرة",
"upDownTimeChart": "مخطط وقت التشغيل والتوقف",
"assetUpDown": "حالة الأصول (تشغيل / توقف)",
"assetStatusOverview": "نظرة عامة على حالة الأصول",
"clickStatusCardHint": "انقر على بطاقة لعرض الأصول المفلترة",
"totalAssetsLabel": "الإجمالي",
"totalAssets": "إجمالي الأصول",
"noAssetStatusData": "لا توجد بيانات لحالة الأصول",
"viewAssetStatusOverview": "عرض نظرة عامة على حالة الأصول",
"viewAll": "عرض الكل",
"viewFiltered": "عرض ↗",
"ofTotal": "من الإجمالي",
"ppmStatus": "حالة الصيانة الوقائية",
"techniciansWorked": "ملخص عمل الفنيين",
"workOrderStatusDistribution": "توزيع حالة أمر العمل",
"workOrders": "أوامر العمل",
"activeDateRange": "نطاق التاريخ: {{from}} إلى {{to}}",
"allDates": "عرض جميع التواريخ",
"applyFilters": "تطبيق",
"clearDates": "مسح التواريخ",
"techniciansWorkedSubtitle": "{{completed}} منجز • {{inProgress}} قيد التنفيذ • {{open}} مفتوح",
"techniciansWorkedEmpty": "لا توجد بيانات للفنيين",
"techniciansWorkedReportHint": "أوامر العمل مجمعة حسب المقاول المعين",
"technicianWorkingHours": "ساعات عمل الفنيين",
"technicianHoursSubtitle": "الإجمالي: {{total}} ساعة",
"technicianHoursEmpty": "لا توجد بيانات لساعات الفنيين",
"loadingTechnicianHours": "جاري تحميل ساعات الفنيين…",
"technicianHoursFound": "تم العثور على {{count}} فني",
"totalTechnicians": "إجمالي الفنيين",
"totalHoursWorked": "إجمالي ساعات العمل",
"avgHoursPerTechnician": "متوسط الساعات/فني",
"technicianName": "اسم الفني",
"engineer": "المهندس",
"totalHoursSpent": "إجمالي الساعات",
"technicianHoursFooter": "عرض {{count}} فني (مفلتر) • {{total}} إجمالي الساعات",
"filters": "الفلاتر",
"clearFilters": "مسح الفلاتر",
"viewFullReport": "عرض التقرير الكامل",
"noChartData": "لا توجد بيانات للمخطط",
"fromDate": "من تاريخ",
"toDate": "إلى تاريخ",
"technicalDepartment": "القسم الفني",
"allDepartments": "جميع الأقسام",
"refreshReport": "تحديث",
"allHospitals": "جميع المستشفيات",
"allSites": "جميع المواقع",
"overallCompletionRate": "معدل الإنجاز الإجمالي",
"overallCompletionByDepartment": "معدل الإنجاز الإجمالي حسب القسم",
"viewCompletionDetails": "عرض تفاصيل الإنجاز",
"clickRowToFilter": "انقر على صف أو قيمة لعرض أوامر العمل المفلترة",
"woType": "نوع أمر العمل",
"woFeedbackReport": "تقرير ملاحظات أمر العمل",
"woFeedbackTotalResponses": "إجمالي الردود: {{total}}"
},
"commonFields": {
"assetId": "معرف الأصل",
"assetName": "اسم الأصل",
"serialNumber": "الرقم التسلسلي",
"company": "الشركة/المستشفى",
"location": "الموقع",
"department": "القسم",
"deviceStatus": "حالة الجهاز",
"modality": "الطريقة",
"manufacturer": "الشركة المصنعة",
"supplier": "المورد",
"assetCategory": "فئة الأصل",
"purchaseDate": "تاريخ الشراء",
"purchaseAmount": "مبلغ الشراء",
"availableForUseDate": "تاريخ التوفر للاستخدام",
"createdOn": "تم الإنشاء في",
"modifiedOn": "تم التعديل في",
"createdBy": "تم الإنشاء بواسطة",
"modifiedBy": "تم التعديل بواسطة",
"workOrderId": "معرف أمر العمل",
"workOrderType": "النوع",
"status": "الحالة",
"priority": "الأولوية",
"description": "الوصف",
"assignedTo": "مكلف إلى",
"scheduledDate": "التاريخ المجدول",
"completedDate": "تاريخ الإكمال"
},
"listPages": {
"addNew": "إضافة جديد",
"searchPlaceholder": "بحث...",
"noResults": "لم يتم العثور على نتائج",
"showing": "عرض",
"of": "من",
"results": "نتائج",
"selectAll": "تحديد الكل",
"deselectAll": "إلغاء تحديد الكل",
"selected": "محدد",
"actions": "الإجراءات",
"view": "عرض",
"edit": "تعديل",
"delete": "حذف",
"duplicate": "نسخ",
"export": "تصدير",
"print": "طباعة",
"filters": "المرشحات",
"clearFilters": "مسح المرشحات",
"applyFilters": "تطبيق المرشحات",
"columns": "الأعمدة",
"exportSelected": "تصدير المحدد",
"exportAllOnPage": "تصدير الكل في الصفحة",
"exportAllWithFilters": "تصدير الكل مع المرشحات",
"exportFormat": "تنسيق التصدير",
"csv": "CSV",
"excel": "Excel",
"exporting": "جاري التصدير...",
"exportComplete": "اكتمل التصدير",
"close": "إغلاق",
"loading": "جاري التحميل...",
"refresh": "تحديث"
},
"assets": {
"title": "الأصول",
"addAsset": "إضافة أصل جديد",
"assetDetails": "تفاصيل الأصل"
},
"workOrders": {
"title": "أوامر العمل",
"addWorkOrder": "إضافة أمر عمل جديد",
"workOrderDetails": "تفاصيل أمر العمل",
"newWorkOrder": "أمر عمل جديد",
"duplicateWorkOrder": "نسخ أمر العمل",
"createFromAsset": "إنشاء أمر عمل من الأصل"
},
"maintenance": {
"title": "صيانة الأصول",
"maintenanceLogs": "سجلات الصيانة",
"maintenanceDetails": "تفاصيل الصيانة",
"addMaintenance": "إضافة صيانة جديدة"
},
"ppm": {
"title": "الصيانة الوقائية",
"ppmDetails": "تفاصيل الصيانة الوقائية",
"addPPM": "إضافة صيانة وقائية جديدة"
},
"exportModal": {
"title": "تصدير",
"whatToExport": "ما الذي سيتم تصديره",
"selectedRows": "الصفوف المحددة",
"currentPage": "الصفحة الحالية",
"allWithFilters": "الكل مع المرشحات",
"exportSelected": "تصدير {count} محدد",
"exportPage": "تصدير {count} في الصفحة الحالية",
"exportAll": "تصدير الكل {count}",
"columnsToExport": "الأعمدة للتصدير",
"selectAll": "تحديد الكل",
"selectDefault": "تحديد الافتراضي",
"exporting": "جاري التصدير...",
"exportingSelected": "جاري تصدير {count} صف(وف) محدد(ة)",
"exportingPage": "جاري تصدير {count} صف(وف) من الصفحة الحالية",
"exportingAll": "جاري تصدير جميع {count} صف(وف)",
"selected": "محدد",
"rows": "صفوف"
},
"filters": {
"filterBy": "تصفية حسب",
"sortBy": "ترتيب حسب",
"createdDate": "تاريخ الإنشاء",
"latestModifiedDate": "تاريخ آخر تعديل",
"startDate": "تاريخ البداية",
"endDate": "تاريخ النهاية",
"sortCreationNewest": "الإنشاء (الأحدث)",
"sortCreationOldest": "الإنشاء (الأقدم)",
"sortModifiedNewest": "التعديل (الأحدث)",
"sortModifiedOldest": "التعديل (الأقدم)",
"sortNameAsc": "الاسم (أ-ي)",
"sortNameDesc": "الاسم (ي-أ)"
},
"pagination": {
"showingToOf": "عرض {{start}} إلى {{end}} من {{total}} {{label}}",
"showingTo": "عرض {{start}} إلى {{end}} {{label}}",
"previous": "السابق",
"next": "التالي",
"goTo": "انتقل إلى",
"go": "انتقل",
"page": "صفحة",
"assets": "أصول",
"workOrders": "أوامر عمل",
"items": "عناصر",
"issues": "مشكلات",
"maintenanceTeams": "فرق صيانة"
}
}

View File

@ -0,0 +1,551 @@
{
"common": {
"dashboard": "Dashboard",
"assets": "Assets",
"workOrders": "Work Orders",
"maintenance": "Asset Maintenance",
"ppm": "PPM",
"logout": "Logout",
"login": "Login",
"email": "Email",
"password": "Password",
"submit": "Submit",
"cancel": "Cancel",
"save": "Save",
"delete": "Delete",
"deleting": "Deleting...",
"edit": "Edit",
"create": "Create",
"search": "Search",
"filter": "Filter",
"export": "Export",
"import": "Import",
"loading": "Loading...",
"noData": "No data available",
"error": "Error",
"success": "Success",
"darkMode": "Dark Mode",
"lightMode": "Light Mode",
"language": "Language",
"english": "English",
"arabic": "Arabic",
"backToDashboard": "Back to Dashboard"
},
"sidebar": {
"title": "Seera-ASM",
"loggedInAs": "Logged in as:",
"version": "Seera-ASM v2.26",
"inventory": "Inventory",
"ppmPlanner": "PPM Planner",
"maintenanceCalendar": "Maintenance Calendar",
"activeMap": "Active Map",
"maintenanceTeam": "Maintenance Team",
"procurement": "Procurement",
"sla": "Service Level Agreement (SLA)",
"support": "Support"
},
"login": {
"title": "Seera-ASM",
"subtitle": "Asset Management System",
"signIn": "Sign in to continue",
"emailPlaceholder": "Enter your email",
"passwordPlaceholder": "Enter your password",
"loginFailed": "Login failed. Please check your credentials.",
"demoLogin": "Demo Login",
"forgotPassword": "Forgot password?",
"forgotPasswordTitle": "Reset your password",
"forgotPasswordHint": "Enter your email address or username. We will send you a link to reset your password.",
"forgotPasswordUserRequired": "Please enter your email or username.",
"forgotPasswordUserPlaceholder": "Email or username",
"forgotPasswordSubmit": "Send reset link",
"forgotPasswordClose": "Close",
"forgotPasswordSentSuccess": "If an account exists for that user, password reset instructions have been sent by email.",
"forgotPasswordNotFound": "No account was found with that email or username.",
"forgotPasswordTimeout": "The request timed out. Please try again.",
"forgotPasswordCannotReset": "Password reset is not available for this account.",
"forgotPasswordFailed": "Could not send the reset link. Please try again later.",
"finishingSignOut": "Finishing sign-out…",
"afterPasswordResetSignIn": "Your password was updated. Please sign in with your new password."
},
"dashboard": {
"title": "Dashboard",
"loading": "Loading dashboard...",
"totalAssets": "TOTAL NO. OF ASSETS",
"openWorkOrders": "OPEN WORK ORDERS",
"workOrdersInProgress": "WORK ORDERS IN PROGRESS",
"completedWorkOrders": "COMPLETED WORK ORDERS",
"closedWorkOrders": "CLOSED WORK ORDERS",
"totalWorkOrders": "TOTAL WORK ORDERS",
"overdueWorkOrders": "OVERDUE WORK ORDERS",
"upTime": "Up Time",
"downTime": "Down Time",
"workOrderStatus": "Work Order Status",
"workOrderTypeStatusHint": "Type vs status counts — click a number to view filtered work orders",
"workOrderByType": "Work Order by Type",
"maintenanceByAsset": "Maintenance by Asset",
"assigneesStatus": "Asset Maintenance Assignees Status Count",
"assignedTo": "Assigned To",
"plannedTasks": "Planned",
"completedTasks": "Completed",
"cancelledTasks": "Cancelled",
"overdueTasks": "Overdue",
"totalTasks": "Total",
"maintenanceFrequency": "Maintenance Frequency",
"maintenanceLogs": "MAINTENANCE LOGS",
"assetUptime": "Asset Uptime",
"avgResponseTime": "Avg Response Time",
"maintenanceEfficiency": "Maintenance Efficiency",
"overdueMaintenance": "Overdue Maintenance",
"upDownTimeChart": "Up & Down Time Chart",
"assetUpDown": "Asset UP & Down",
"assetStatusOverview": "Asset Status Overview",
"clickStatusCardHint": "Click a card to view filtered assets",
"totalAssetsLabel": "Total",
"totalAssets": "Total Assets",
"noAssetStatusData": "No asset status data",
"viewAssetStatusOverview": "View asset status overview",
"viewAll": "View all",
"viewFiltered": "View ↗",
"ofTotal": "of total",
"ppmStatus": "PPM Status",
"techniciansWorked": "Technicians Work Summary",
"workOrderStatusDistribution": "Work Order Status Distribution",
"workOrders": "Work Orders",
"activeDateRange": "Date range: {{from}} to {{to}}",
"allDates": "Showing all dates",
"applyFilters": "Apply",
"clearDates": "Clear dates",
"techniciansWorkedSubtitle": "{{completed}} done • {{inProgress}} wip • {{open}} open",
"techniciansWorkedEmpty": "No technician data",
"techniciansWorkedReportHint": "Work orders grouped by assigned contractor",
"technicianWorkingHours": "Technicians Working Hours",
"technicianHoursSubtitle": "Total: {{total}} hrs",
"technicianHoursEmpty": "No technician hours data",
"loadingTechnicianHours": "Loading technician hours…",
"technicianHoursFound": "{{count}} technicians found",
"totalTechnicians": "Total Technicians",
"totalHoursWorked": "Total Hours Worked",
"avgHoursPerTechnician": "Average Hours/Technician",
"technicianName": "Technician Name",
"engineer": "Engineer",
"totalHoursSpent": "Total Hours Spent",
"technicianHoursFooter": "Showing {{count}} technicians (filtered) • {{total}} total hours",
"filters": "Filters",
"clearFilters": "Clear Filters",
"viewFullReport": "View Full Report",
"noChartData": "No chart data available",
"fromDate": "From Date",
"toDate": "To Date",
"technicalDepartment": "Technical Department",
"allDepartments": "All Departments",
"refreshReport": "Refresh",
"allHospitals": "All Hospitals",
"allSites": "All Sites",
"overallCompletionRate": "Overall Completion Rate",
"overallCompletionByDepartment": "Overall Completion Rate by Department",
"viewCompletionDetails": "View completion details",
"clickRowToFilter": "Click a row or value to view filtered work orders",
"woType": "WO Type",
"woFeedbackReport": "WO Feedback Report",
"woFeedbackTotalResponses": "Total responses: {{total}}"
},
"commonFields": {
"assetId": "Asset ID",
"assetName": "Asset Name",
"serialNumber": "Serial Number",
"company": "Company/Hospital",
"location": "Location",
"department": "Department",
"deviceStatus": "Device Status",
"modality": "Modality",
"manufacturer": "Manufacturer",
"supplier": "Supplier",
"assetCategory": "Asset Category",
"purchaseDate": "Purchase Date",
"purchaseAmount": "Purchase Amount",
"availableForUseDate": "Available For Use Date",
"createdOn": "Created On",
"modifiedOn": "Modified On",
"createdBy": "Created By",
"modifiedBy": "Modified By",
"workOrderId": "Work Order ID",
"workOrderType": "Type",
"status": "Status",
"priority": "Priority",
"description": "Description",
"assignedTo": "Assigned To",
"scheduledDate": "Scheduled Date",
"completedDate": "Completed Date",
"hospital": "Hospital",
"assetType": "Asset Type",
"siteName": "Site Name",
"assignedSupervisor": "Assigned Supervisor",
"assignedContractor": "Assigned Contractor",
"serialNumberShort": "Serial",
"departmentShort": "Dept",
"manufacturerShort": "Mfr",
"workOrderIdShort": "WO ID",
"assetShort": "Asset",
"typeShort": "Type",
"nameShort": "Name",
"pmId": "PM ID",
"name": "Name"
},
"filters": {
"assetId": "Asset ID",
"hospital": "Hospital",
"name": "Name",
"serial": "Serial",
"status": "Status",
"location": "Location",
"dept": "Dept",
"modality": "Modality",
"mfr": "Mfr",
"supplier": "Supplier",
"workOrderId": "WO ID",
"asset": "Asset",
"type": "Type",
"priority": "Priority",
"allManufacturers": "All Manufacturers"
},
"listPages": {
"addNew": "Add New",
"searchPlaceholder": "Search...",
"noResults": "No results found",
"showing": "Showing",
"of": "of",
"results": "results",
"selectAll": "Select All",
"deselectAll": "Deselect All",
"selected": "selected",
"actions": "Actions",
"view": "View",
"edit": "Edit",
"delete": "Delete",
"duplicate": "Duplicate",
"export": "Export",
"print": "Print",
"filters": "Filters",
"clearFilters": "Clear Filters",
"applyFilters": "Apply Filters",
"columns": "Columns",
"exportSelected": "Export Selected",
"exportAllOnPage": "Export All on Page",
"exportAllWithFilters": "Export All with Filters",
"exportFormat": "Export Format",
"csv": "CSV",
"excel": "Excel",
"exporting": "Exporting...",
"exportComplete": "Export Complete",
"close": "Close",
"loading": "Loading...",
"refresh": "Refresh",
"typing": "typing...",
"allStatuses": "All Statuses"
},
"assets": {
"title": "Assets",
"addAsset": "Add New Asset",
"assetDetails": "Asset Details",
"assetInformation": "Asset Information",
"newAsset": "New Asset",
"duplicateAsset": "Duplicate Asset",
"fromAsset": "From Asset",
"creatingFromAsset": "Creating Work Order from Asset",
"assetInfoPrefilled": "Asset information prefilled from",
"pleaseSelectWorkOrderType": "Please select a Work Order type and add any additional details",
"loadingAssetDetails": "Loading asset details...",
"pleaseEnterAssetName": "Please enter an Asset Name",
"pleaseSelectCategory": "Please select a Category",
"assetDuplicatedSuccessfully": "Asset duplicated successfully!",
"assetCreatedSuccessfully": "Asset created successfully!",
"assetUpdatedSuccessfully": "Asset updated successfully!",
"sourceAssetNotFound": "Source Asset Not Found",
"assetNotFoundMessage": "The asset you're trying to duplicate could not be found.",
"backToAssetsList": "Back to Assets List",
"newAssetDetails": "New Asset Details",
"noAssetsFound": "No assets found",
"createFirstAsset": "Create your first asset"
},
"workOrders": {
"title": "Work Orders",
"addWorkOrder": "Add New Work Order",
"workOrderDetails": "Work Order Details",
"newWorkOrder": "New Work Order",
"duplicateWorkOrder": "Duplicate Work Order",
"createFromAsset": "Create Work Order from Asset"
},
"maintenance": {
"title": "Asset Maintenance",
"maintenanceLogs": "Maintenance Logs",
"maintenanceDetails": "Maintenance Details",
"addMaintenance": "Add New Maintenance"
},
"ppm": {
"title": "PPM",
"ppmDetails": "PPM Details",
"addPPM": "Add New PPM",
"periodicity": "Periodicity",
"dueDate": "Due Date",
"manageSchedules": "Manage PM Schedules",
"pmId": "PM ID",
"name": "Name",
"manufacturer": "Manufacturer"
},
"exportModal": {
"title": "Export",
"whatToExport": "What to Export",
"selectedRows": "Selected Rows",
"currentPage": "Current Page",
"allWithFilters": "All with Filters",
"exportSelected": "Export {count} selected",
"exportPage": "Export {count} on current page",
"exportAll": "Export all {count}",
"columnsToExport": "Columns to Export",
"selectAll": "Select All",
"selectDefault": "Select Default",
"exporting": "Exporting...",
"exportingSelected": "Exporting {count} selected row(s)",
"exportingPage": "Exporting {count} row(s) from current page",
"exportingAll": "Exporting all {count} row(s)",
"selected": "selected",
"rows": "rows"
},
"items": {
"title": "Items",
"itemDetails": "Item Details",
"newItem": "New Item",
"addItem": "Add New Item",
"itemId": "Item ID",
"itemCode": "Item Code",
"itemName": "Item Name",
"itemGroup": "Item Group",
"stockUOM": "Stock UOM",
"partDescription": "Part Description",
"brand": "Brand",
"valuationRate": "Valuation Rate",
"openingStock": "Opening Stock",
"lastCalibrationDate": "Last Calibration Date",
"nextCalibrationDate": "Next Calibration Date",
"selectItem": "Select Item",
"selectItemGroup": "Select Item Group",
"selectHospital": "Select Hospital",
"viewDetails": "View Details",
"editItem": "Edit Item",
"duplicateItem": "Duplicate Item",
"deleteItem": "Delete Item",
"basicInformation": "Basic Information",
"stockInformation": "Stock Information",
"isStockItem": "Is Stock Item",
"balanceQty": "Balance Qty",
"calibrationInformation": "Calibration Information",
"additionalInformation": "Additional Information",
"refreshBalanceQty": "Refresh Balance Qty",
"warrantyMonths": "Warranty (Months)"
},
"issues": {
"title": "Issues",
"issueDetails": "Issue Details",
"newIssue": "New Issue",
"addIssue": "Add New Issue",
"issueId": "Issue ID",
"subject": "Subject",
"raisedBy": "Raised By",
"contact": "Contact",
"issueType": "Issue Type",
"openingDate": "Opening Date",
"resolutionDate": "Resolution Date",
"resolvedBy": "Resolved By",
"firstRespondedOn": "First Responded On",
"resolutionDetails": "Resolution Details",
"selectIssue": "Select Issue",
"allPriorities": "All Priorities",
"allCompanies": "All Companies",
"viewDetails": "View Details",
"editIssue": "Edit Issue",
"deleteIssue": "Delete Issue",
"enterSubject": "Enter issue subject",
"selectPriority": "Select priority",
"selectIssueType": "Select issue type",
"describeIssue": "Describe the issue in detail...",
"contactInformation": "Contact Information",
"createNewIssue": "Create a new support issue",
"resolution": "Resolution",
"describeResolution": "Describe how the issue was resolved...",
"selectCompany": "Select company",
"statusInformation": "Status Information",
"currentStatus": "Current Status",
"timeline": "Timeline"
},
"maintenance": {
"title": "Asset Maintenance",
"maintenanceLogs": "Maintenance Logs",
"maintenanceDetails": "Maintenance Details",
"addMaintenance": "Add New Maintenance",
"maintenanceTeam": "Maintenance Team",
"newMaintenanceTeam": "New Maintenance Team",
"teamId": "Team ID",
"teamName": "Team Name",
"managerEmail": "Manager Email",
"managerName": "Manager Name",
"expertise": "Expertise",
"selectTeam": "Select Team",
"viewDetails": "View Details",
"editTeam": "Edit Team",
"duplicateTeam": "Duplicate Team",
"deleteTeam": "Delete Team",
"selectHospital":"Select Hospital",
"selectExpertise":"Select Expertise",
"selectManager":"Select Manager",
"enterTeamName":"Enter Team Name",
"teamInformation":"Team Information",
"selectUser": "Select User",
"selectRole":"Select Role",
"totalMembers": "Total Members",
"teamSummary" : "Team Summary",
"addFirstMember":"Add First Member",
"manager":"Maintenance Manager"
},
"users": {
"title": "Users",
"userDetails": "User Details",
"newUser": "New User",
"addUser": "Add New User"
},
"events": {
"title": "Events",
"eventDetails": "Event Details",
"newEvent": "New Event",
"addEvent": "Add New Event"
},
"listPages": {
"addNew": "Add New",
"searchPlaceholder": "Search...",
"noResults": "No results found",
"showing": "Showing",
"of": "of",
"results": "results",
"selectAll": "Select All",
"deselectAll": "Deselect All",
"selected": "selected",
"actions": "Actions",
"view": "View",
"edit": "Edit",
"delete": "Delete",
"duplicate": "Duplicate",
"export": "Export",
"print": "Print",
"filters": "Filters",
"clearFilters": "Clear Filters",
"applyFilters": "Apply Filters",
"columns": "Columns",
"exportSelected": "Export Selected",
"exportAllOnPage": "Export All on Page",
"exportAllWithFilters": "Export All with Filters",
"exportFormat": "Export Format",
"csv": "CSV",
"excel": "Excel",
"exporting": "Exporting...",
"exportComplete": "Export Complete",
"close": "Close",
"loading": "Loading...",
"refresh": "Refresh",
"deselectAllTitle": "Deselect all",
"selectAllTitle": "Select all",
"typeToSearch": "Type to search...",
"enterFilterName": "Enter filter name",
"enterFilterNameExample": "Enter filter name (e.g., 'Open High Priority')",
"allStatuses": "All Statuses",
"noIssuesFound": "No issues found",
"clearFilters": "Clear filters",
"createFirstIssue": "Create your first issue",
"saveFilterPreset": "Save Filter Preset",
"saveFilter": "Save Filter",
"filtering": "Filtering...",
"noMaintenanceTeamsFound": "No maintenance teams found",
"createFirstTeam": "Create your first team",
"all": "All",
"tryAdjustingFilters": "Try adjusting your search or filters",
"getStartedCreateFirst": "Get started by creating your first PPM Planner",
"noMaintenanceLogsFound": "No maintenance logs found",
"createFirstMaintenanceLog": "Create your first maintenance log",
"total": "Total",
"noPPMSchedulesFound": "No PPM schedules found",
"createFirstPPMSchedule": "Create your first PPM schedule"
},
"filters": {
"assetId": "Asset ID",
"hospital": "Hospital",
"name": "Name",
"serial": "Serial",
"status": "Status",
"location": "Location",
"dept": "Dept",
"modality": "Modality",
"mfr": "Mfr",
"supplier": "Supplier",
"workOrderId": "WO ID",
"asset": "Asset",
"type": "Type",
"priority": "Priority",
"allHospitals": "All Hospitals",
"allModalities": "All Modalities",
"filterByCompany": "Filter by Company",
"allManufacturers": "All Manufacturers"
},
"users": {
"title": "Users",
"userDetails": "User Details",
"newUser": "New User",
"addUser": "Add New User",
"searchUsers": "Search users...",
"manageUsers": "Manage user accounts and permissions",
"noUsersFound": "No users found",
"tryAdjustingSearch": "Try adjusting your search terms.",
"noUsersAvailable": "No users available.",
"backToDashboard": "Back to Dashboard"
},
"events": {
"title": "Events",
"eventDetails": "Event Details",
"newEvent": "New Event",
"addEvent": "Add New Event",
"upcomingEvents": "Upcoming Events",
"eventsFromFrappe": "Events from your Frappe backend",
"noEventsFound": "No events found",
"noEventsScheduled": "No events are currently scheduled.",
"refreshEvents": "Refresh Events"
},
"filters": {
"filterBy": "Filter By",
"sortBy": "Sort By",
"createdDate": "Created Date",
"latestModifiedDate": "Latest Modified Date",
"startDate": "Start Date",
"endDate": "End Date",
"sortCreationNewest": "Created (Newest)",
"sortCreationOldest": "Created (Oldest)",
"sortModifiedNewest": "Modified (Newest)",
"sortModifiedOldest": "Modified (Oldest)",
"sortNameAsc": "Name (A-Z)",
"sortNameDesc": "Name (Z-A)"
},
"pagination": {
"showingToOf": "Showing {{start}} to {{end}} of {{total}} {{label}}",
"showingTo": "Showing {{start}} to {{end}} {{label}}",
"previous": "Previous",
"next": "Next",
"goTo": "Go to",
"go": "Go",
"page": "Page",
"assets": "assets",
"workOrders": "work orders",
"items": "items",
"issues": "issues",
"maintenanceTeams": "maintenance teams"
}
}

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

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

View File

@ -0,0 +1,863 @@
/**
* Active Map Page
*
* Displays hospitals and PHCC locations on an interactive map with markers showing:
* - Asset counts
* - Work Order counts (Normal/Urgent, by status)
* - Maintenance Log counts (Planned/Completed/Overdue)
*
* Supports both Hospital and PHCC location types with different field mappings:
* - Hospital: company field for assets/work orders, custom_hospital_name for maintenance
* - PHCC: custom_site for assets, site_name for work orders, asset-based for maintenance
*/
import React, { useState, useEffect, useRef, useMemo, useCallback } from 'react';
import { useNavigate } from 'react-router-dom';
import { MapContainer, TileLayer, Marker, Popup, Tooltip, useMap } from 'react-leaflet';
import L from 'leaflet';
import 'leaflet/dist/leaflet.css';
import apiService from '../services/apiService';
import LinkField from '../components/LinkField';
import useUserDashboardFilters from '../hooks/useUserDashboardFilters';
import { isSiteEnabledHospital, buildMobileTeamSiteFilters } from '../utils/hospitalUtils';
// Fix for default marker icons in React-Leaflet
import icon from 'leaflet/dist/images/marker-icon.png';
import iconShadow from 'leaflet/dist/images/marker-shadow.png';
const DefaultIcon = L.icon({
iconUrl: icon,
shadowUrl: iconShadow,
iconSize: [25, 41],
iconAnchor: [12, 41],
popupAnchor: [1, -34],
tooltipAnchor: [16, -28],
shadowSize: [41, 41]
});
L.Marker.prototype.options.icon = DefaultIcon;
interface LocationData {
name: string;
latitude: number;
longitude: number;
location_type: 'hospital' | 'phcc';
assets: number;
normal_work_orders: number;
urgent_work_orders: number;
planned_maintenance: number;
completed_maintenance: number;
overdue_maintenance: number;
wo_open: number;
wo_progress: number;
wo_review: number;
wo_completed: number;
wo_closed: number;
phcc_asset_names?: string[];
}
// Component to handle map bounds fitting
const MapBounds: React.FC<{ locations: LocationData[] }> = ({ locations }) => {
const map = useMap();
useEffect(() => {
if (locations.length > 0 && locations.some(l => l.latitude && l.longitude)) {
const bounds = L.latLngBounds(
locations
.filter(l => l.latitude && l.longitude)
.map(l => [l.latitude, l.longitude] as [number, number])
);
map.fitBounds(bounds, { padding: [30, 30], maxZoom: 8 });
} else {
// Fallback: show Saudi Arabia center
map.setView([24.8, 45.5], 6);
}
}, [locations, map]);
return null;
};
const ActiveMap: React.FC = () => {
const navigate = useNavigate();
const [selectedPHCC, setSelectedPHCC] = useState<string>('');
const [locations, setLocations] = useState<LocationData[]>([]);
const [loading, setLoading] = useState(true);
const markersRef = useRef<Record<string, L.Marker>>({});
const {
filterHospital,
filterSiteName,
hospitalLocked,
siteLocked,
allowedHospitals,
loading: filtersLoading,
handleHospitalChange,
handleSiteChange,
} = useUserDashboardFilters();
const showSiteFilter = isSiteEnabledHospital(filterHospital);
const hospitalLocationLinkFilters = useMemo(() => {
const filters: Record<string, unknown> = { custom_is_hospital: 1 };
if (allowedHospitals.length > 0) {
filters.name = ['in', allowedHospitals];
}
return filters;
}, [allowedHospitals]);
const mobileTeamSiteFilters = useMemo(
() => buildMobileTeamSiteFilters(filterHospital, siteLocked ? filterSiteName : undefined),
[filterHospital, filterSiteName, siteLocked]
);
const phccLocationLinkFilters = useMemo(() => {
const filters: Record<string, unknown> = { custom_is_phcc: 1 };
if (siteLocked && filterSiteName) {
filters.name = filterSiteName;
}
return filters;
}, [siteLocked, filterSiteName]);
const applyHospitalSiteFilters = useCallback(
(
filters: Record<string, unknown>,
locationType: 'hospital' | 'phcc',
siteField: 'site_name' | 'custom_site',
hospitalName?: string
) => {
const hospital = hospitalName || filterHospital;
if (
locationType === 'hospital' &&
filterSiteName &&
hospital &&
isSiteEnabledHospital(hospital)
) {
filters[siteField] = filterSiteName;
}
return filters;
},
[filterHospital, filterSiteName]
);
// Fetch location counts based on location type
const fetchLocationCounts = async (
location: { name: string; latitude: string | number; longitude: string | number },
locationType: 'hospital' | 'phcc'
): Promise<LocationData> => {
const isPhcc = locationType === 'phcc';
const assetFilterField = isPhcc ? 'custom_site' : 'company';
const woFilterField = isPhcc ? 'site_name' : 'company';
const counts: Partial<LocationData> = {
assets: 0,
normal_work_orders: 0,
urgent_work_orders: 0,
planned_maintenance: 0,
completed_maintenance: 0,
overdue_maintenance: 0,
wo_open: 0,
wo_progress: 0,
wo_review: 0,
wo_completed: 0,
wo_closed: 0,
phcc_asset_names: []
};
const hospitalName = locationType === 'hospital' ? location.name : undefined;
try {
const assetFilters = applyHospitalSiteFilters(
{ [assetFilterField]: location.name },
locationType,
'custom_site',
hospitalName
);
// Fetch Asset count
const assetsResponse = await apiService.apiCall<any>(
`/api/resource/Asset?filters=${encodeURIComponent(JSON.stringify(assetFilters))}&fields=["name"]&limit_page_length=0`
);
const assetList = assetsResponse?.data || [];
counts.assets = assetList.length;
// Store asset names for PHCC (needed for maintenance log queries)
if (isPhcc) {
counts.phcc_asset_names = assetList.map((a: any) => a.name);
}
const normalWOFilters = applyHospitalSiteFilters(
{
[woFilterField]: location.name,
custom_priority_: 'Normal',
repair_status: ['in', ['Open', 'Work In Progress']],
},
locationType,
'site_name',
hospitalName
);
// Fetch Normal Work Orders
const normalWOResponse = await apiService.apiCall<any>(
`/api/resource/Work_Order?filters=${encodeURIComponent(JSON.stringify(normalWOFilters))}&fields=["name"]`
);
counts.normal_work_orders = normalWOResponse?.data?.length || 0;
const urgentWOFilters = applyHospitalSiteFilters(
{
[woFilterField]: location.name,
custom_priority_: 'Urgent',
repair_status: ['in', ['Open', 'Work In Progress']],
},
locationType,
'site_name',
hospitalName
);
// Fetch Urgent Work Orders
const urgentWOResponse = await apiService.apiCall<any>(
`/api/resource/Work_Order?filters=${encodeURIComponent(JSON.stringify(urgentWOFilters))}&fields=["name"]`
);
counts.urgent_work_orders = urgentWOResponse?.data?.length || 0;
const buildWoStatusFilters = (repairStatus: string) =>
applyHospitalSiteFilters(
{
[woFilterField]: location.name,
repair_status: repairStatus,
},
locationType,
'site_name',
hospitalName
);
// Fetch WO Status counts
const [woOpen, woProgress, woReview, woCompleted, woClosed] = await Promise.all([
apiService.apiCall<any>(
`/api/resource/Work_Order?filters=${encodeURIComponent(JSON.stringify(buildWoStatusFilters('Open')))}&fields=["name"]`
),
apiService.apiCall<any>(
`/api/resource/Work_Order?filters=${encodeURIComponent(JSON.stringify(buildWoStatusFilters('Work In Progress')))}&fields=["name"]`
),
apiService.apiCall<any>(
`/api/resource/Work_Order?filters=${encodeURIComponent(JSON.stringify(buildWoStatusFilters('Pending Review')))}&fields=["name"]`
),
apiService.apiCall<any>(
`/api/resource/Work_Order?filters=${encodeURIComponent(JSON.stringify(buildWoStatusFilters('Completed')))}&fields=["name"]`
),
apiService.apiCall<any>(
`/api/resource/Work_Order?filters=${encodeURIComponent(JSON.stringify(buildWoStatusFilters('Closed')))}&fields=["name"]`
),
]);
counts.wo_open = woOpen?.data?.length || 0;
counts.wo_progress = woProgress?.data?.length || 0;
counts.wo_review = woReview?.data?.length || 0;
counts.wo_completed = woCompleted?.data?.length || 0;
counts.wo_closed = woClosed?.data?.length || 0;
// Fetch Maintenance counts - different logic for PHCC vs Hospital
if (isPhcc && counts.phcc_asset_names && counts.phcc_asset_names.length > 0) {
// For PHCC, filter by asset_name
const [plannedPM, completedPM, overduePM] = await Promise.all([
apiService.apiCall<any>(
`/api/resource/Asset Maintenance Log?filters=${encodeURIComponent(JSON.stringify({
asset_name: ['in', counts.phcc_asset_names],
maintenance_status: 'Planned'
}))}&fields=["name"]`
),
apiService.apiCall<any>(
`/api/resource/Asset Maintenance Log?filters=${encodeURIComponent(JSON.stringify({
asset_name: ['in', counts.phcc_asset_names],
maintenance_status: 'Completed'
}))}&fields=["name"]`
),
apiService.apiCall<any>(
`/api/resource/Asset Maintenance Log?filters=${encodeURIComponent(JSON.stringify({
asset_name: ['in', counts.phcc_asset_names],
maintenance_status: 'Overdue'
}))}&fields=["name"]`
)
]);
counts.planned_maintenance = plannedPM?.data?.length || 0;
counts.completed_maintenance = completedPM?.data?.length || 0;
counts.overdue_maintenance = overduePM?.data?.length || 0;
} else if (!isPhcc) {
const buildMaintenanceFilters = (maintenanceStatus: string) =>
applyHospitalSiteFilters(
{
custom_hospital_name: location.name,
maintenance_status: maintenanceStatus,
},
locationType,
'site_name',
hospitalName
);
// For Hospital, filter by custom_hospital_name
const [plannedPM, completedPM, overduePM] = await Promise.all([
apiService.apiCall<any>(
`/api/resource/Asset Maintenance Log?filters=${encodeURIComponent(JSON.stringify(buildMaintenanceFilters('Planned')))}&fields=["name"]`
),
apiService.apiCall<any>(
`/api/resource/Asset Maintenance Log?filters=${encodeURIComponent(JSON.stringify(buildMaintenanceFilters('Completed')))}&fields=["name"]`
),
apiService.apiCall<any>(
`/api/resource/Asset Maintenance Log?filters=${encodeURIComponent(JSON.stringify(buildMaintenanceFilters('Overdue')))}&fields=["name"]`
),
]);
counts.planned_maintenance = plannedPM?.data?.length || 0;
counts.completed_maintenance = completedPM?.data?.length || 0;
counts.overdue_maintenance = overduePM?.data?.length || 0;
}
} catch (err) {
console.error(`Error fetching counts for ${location.name}:`, err);
}
return {
name: location.name,
latitude: parseFloat(location.latitude),
longitude: parseFloat(location.longitude),
location_type: locationType,
...counts
} as LocationData;
};
// Fetch locations and their counts
const fetchAndRenderData = async () => {
setLoading(true);
try {
let allLocations: LocationData[] = [];
const fetchPromises: Promise<LocationData[]>[] = [];
const effectivePhcc = siteLocked && filterSiteName ? filterSiteName : selectedPHCC;
// Fetch Hospital locations (if no PHCC is specifically selected, or if hospital is selected)
if (!effectivePhcc || filterHospital) {
const hospitalFilters: Record<string, unknown> = {
latitude: ['!=', ''],
longitude: ['!=', ''],
custom_is_hospital: 1,
};
if (filterHospital) {
hospitalFilters.name = filterHospital;
} else if (allowedHospitals.length > 0) {
hospitalFilters.name = ['in', allowedHospitals];
}
fetchPromises.push(
(async () => {
const locationsResponse = await apiService.apiCall<any>(
`/api/resource/Location?filters=${encodeURIComponent(JSON.stringify(hospitalFilters))}&fields=["name","latitude","longitude"]&limit_page_length=0`
);
const locationList = locationsResponse?.data || [];
const locationPromises = locationList.map((loc: { name: string; latitude: string; longitude: string }) =>
fetchLocationCounts(loc, 'hospital')
);
return Promise.all(locationPromises);
})()
);
}
// Fetch PHCC locations (if no hospital is specifically selected, or if PHCC is selected)
if (!filterHospital || effectivePhcc) {
const phccFilters: Record<string, unknown> = {
latitude: ['!=', ''],
longitude: ['!=', ''],
custom_is_phcc: 1,
};
if (effectivePhcc) {
phccFilters.name = effectivePhcc;
}
fetchPromises.push(
(async () => {
const locationsResponse = await apiService.apiCall<any>(
`/api/resource/Location?filters=${encodeURIComponent(JSON.stringify(phccFilters))}&fields=["name","latitude","longitude"]&limit_page_length=0`
);
const locationList = locationsResponse?.data || [];
const locationPromises = locationList.map((loc: { name: string; latitude: string; longitude: string }) =>
fetchLocationCounts(loc, 'phcc')
);
return Promise.all(locationPromises);
})()
);
}
const results = await Promise.all(fetchPromises);
allLocations = results.flat().filter((l) => !isNaN(l.latitude) && !isNaN(l.longitude));
setLocations(allLocations);
} catch (error) {
console.error('Error fetching map data:', error);
} finally {
setLoading(false);
}
};
useEffect(() => {
if (filtersLoading) return;
if (siteLocked && filterSiteName) {
setSelectedPHCC(filterSiteName);
}
fetchAndRenderData();
}, [filterHospital, filterSiteName, selectedPHCC, filtersLoading, siteLocked, allowedHospitals]);
// Navigate to list view with filters
const navigateToWorkOrders = (location: LocationData, priority?: string, status?: string) => {
const params = new URLSearchParams();
if (location.location_type === 'phcc') {
params.set('site_name', location.name);
} else {
params.set('company', location.name);
if (filterSiteName && isSiteEnabledHospital(location.name)) {
params.set('site_name', filterSiteName);
}
}
if (priority) params.set('priority', priority);
if (status) params.set('status', status);
navigate(`/work-orders?${params.toString()}`);
};
const navigateToAssets = (location: LocationData) => {
const params = new URLSearchParams();
const filterField = location.location_type === 'phcc' ? 'custom_site' : 'company';
params.set(filterField, location.name);
navigate(`/assets?${params.toString()}`);
};
const navigateToMaintenanceCalendar = (location: LocationData, status?: string) => {
const params = new URLSearchParams();
if (location.location_type === 'phcc') {
// For PHCC, we need to pass asset names or use a different approach
params.set('phcc', location.name);
} else {
params.set('hospital', location.name);
}
if (status) params.set('status', status);
navigate(`/maintenance-calendar?${params.toString()}`);
};
// Create popup content with modern UI matching the application
const createPopupContent = (location: LocationData) => {
const isPhcc = location.location_type === 'phcc';
const typeBadge = isPhcc ? (
<span className="ml-2 px-2 py-0.5 bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-300 text-xs font-semibold rounded-full">
PHCC
</span>
) : (
<span className="ml-2 px-2 py-0.5 bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300 text-xs font-semibold rounded-full">
Hospital
</span>
);
return (
<div className="p-4 bg-white dark:bg-gray-800 rounded-lg shadow-lg min-w-[280px] max-w-[320px]">
{/* Location Name Header */}
<div className="mb-4 pb-3 border-b border-gray-200 dark:border-gray-700">
<h3 className="text-lg font-bold text-gray-900 dark:text-white flex items-center flex-wrap">
{location.name}
{typeBadge}
</h3>
<p className="text-sm text-gray-600 dark:text-gray-400 mt-1">
Total Assets: <span className="font-semibold text-gray-900 dark:text-white">{location.assets}</span>
</p>
</div>
{/* Work Order Status Section */}
<div className="mb-4">
<h4 className="text-sm font-semibold text-gray-800 dark:text-gray-200 mb-2">
Work Order Status
</h4>
<div className="flex gap-2 mb-3">
<button
onClick={() => navigateToWorkOrders(location, 'Normal')}
className="px-3 py-1.5 bg-blue-100 dark:bg-blue-900/30 hover:bg-blue-200 dark:hover:bg-blue-900/50 text-blue-700 dark:text-blue-300 rounded-lg text-xs font-semibold transition-colors cursor-pointer"
>
Normal: {location.normal_work_orders}
</button>
<button
onClick={() => navigateToWorkOrders(location, 'Urgent')}
className="px-3 py-1.5 bg-red-100 dark:bg-red-900/30 hover:bg-red-200 dark:hover:bg-red-900/50 text-red-700 dark:text-red-300 rounded-lg text-xs font-semibold transition-colors cursor-pointer"
>
Urgent: {location.urgent_work_orders}
</button>
</div>
{/* Status Table */}
<div className="overflow-hidden rounded-lg border border-gray-200 dark:border-gray-700">
<table className="w-full text-xs">
<thead className="bg-gray-50 dark:bg-gray-700">
<tr>
<th className="px-3 py-2 text-left font-semibold text-gray-700 dark:text-gray-300">Status</th>
<th className="px-3 py-2 text-left font-semibold text-gray-700 dark:text-gray-300">Count</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-200 dark:divide-gray-700">
<tr className="bg-red-50 dark:bg-red-900/20 hover:bg-red-100 dark:hover:bg-red-900/30 transition-colors">
<td className="px-3 py-2 text-red-800 dark:text-red-300 font-medium">Open</td>
<td className="px-3 py-2">
<button
onClick={() => navigateToWorkOrders(location, undefined, 'Open')}
className="text-red-700 dark:text-red-400 font-bold hover:underline cursor-pointer"
>
{location.wo_open}
</button>
</td>
</tr>
<tr className="bg-yellow-50 dark:bg-yellow-900/20 hover:bg-yellow-100 dark:hover:bg-yellow-900/30 transition-colors">
<td className="px-3 py-2 text-yellow-800 dark:text-yellow-300 font-medium">Work In Progress</td>
<td className="px-3 py-2">
<button
onClick={() => navigateToWorkOrders(location, undefined, 'Work In Progress')}
className="text-yellow-700 dark:text-yellow-400 font-bold hover:underline cursor-pointer"
>
{location.wo_progress}
</button>
</td>
</tr>
<tr className="bg-blue-50 dark:bg-blue-900/20 hover:bg-blue-100 dark:hover:bg-blue-900/30 transition-colors">
<td className="px-3 py-2 text-blue-800 dark:text-blue-300 font-medium">Pending Review</td>
<td className="px-3 py-2">
<button
onClick={() => navigateToWorkOrders(location, undefined, 'Pending Review')}
className="text-blue-700 dark:text-blue-400 font-bold hover:underline cursor-pointer"
>
{location.wo_review}
</button>
</td>
</tr>
<tr className="bg-green-50 dark:bg-green-900/20 hover:bg-green-100 dark:hover:bg-green-900/30 transition-colors">
<td className="px-3 py-2 text-green-800 dark:text-green-300 font-medium">Completed</td>
<td className="px-3 py-2">
<button
onClick={() => navigateToWorkOrders(location, undefined, 'Completed')}
className="text-green-700 dark:text-green-400 font-bold hover:underline cursor-pointer"
>
{location.wo_completed}
</button>
</td>
</tr>
<tr className="bg-gray-50 dark:bg-gray-700/40 hover:bg-gray-100 dark:hover:bg-gray-700/60 transition-colors">
<td className="px-3 py-2 text-gray-800 dark:text-gray-300 font-medium">Closed</td>
<td className="px-3 py-2">
<button
onClick={() => navigateToWorkOrders(location, undefined, 'Closed')}
className="text-gray-700 dark:text-gray-300 font-bold hover:underline cursor-pointer"
>
{location.wo_closed}
</button>
</td>
</tr>
</tbody>
</table>
</div>
</div>
{/* Preventive Maintenance Section */}
<div className="mb-4">
<h4 className="text-sm font-semibold text-gray-800 dark:text-gray-200 mb-2">
Preventive Maintenance
</h4>
<div className="flex flex-wrap gap-2">
<button
onClick={() => navigateToMaintenanceCalendar(location, 'Planned')}
className="px-3 py-1.5 bg-orange-100 dark:bg-orange-900/30 hover:bg-orange-200 dark:hover:bg-orange-900/50 text-orange-700 dark:text-orange-300 rounded-lg text-xs font-semibold transition-colors cursor-pointer"
>
Planned: {location.planned_maintenance}
</button>
<button
onClick={() => navigateToMaintenanceCalendar(location, 'Completed')}
className="px-3 py-1.5 bg-green-100 dark:bg-green-900/30 hover:bg-green-200 dark:hover:bg-green-900/50 text-green-700 dark:text-green-300 rounded-lg text-xs font-semibold transition-colors cursor-pointer"
>
Completed: {location.completed_maintenance}
</button>
<button
onClick={() => navigateToMaintenanceCalendar(location, 'Overdue')}
className="px-3 py-1.5 bg-red-100 dark:bg-red-900/30 hover:bg-red-200 dark:hover:bg-red-900/50 text-red-700 dark:text-red-300 rounded-lg text-xs font-semibold transition-colors cursor-pointer"
>
Overdue: {location.overdue_maintenance}
</button>
</div>
</div>
{/* Action Buttons */}
<div className="flex gap-2 pt-3 border-t border-gray-200 dark:border-gray-700">
<button
onClick={() => navigateToAssets(location)}
className="flex-1 px-4 py-2 bg-blue-600 hover:bg-blue-700 dark:bg-blue-700 dark:hover:bg-blue-600 text-white rounded-lg text-sm font-medium transition-colors cursor-pointer"
>
View Assets
</button>
<button
onClick={() => navigateToWorkOrders(location)}
className="flex-1 px-4 py-2 bg-purple-600 hover:bg-purple-700 dark:bg-purple-700 dark:hover:bg-purple-600 text-white rounded-lg text-sm font-medium transition-colors cursor-pointer"
>
View Work Orders
</button>
</div>
</div>
);
};
return (
<div className="h-screen flex flex-col bg-gray-50 dark:bg-gray-900">
<div className="flex-shrink-0 bg-white dark:bg-gray-800 shadow-sm border-b border-gray-200 dark:border-gray-700 px-4 py-3">
<h1 className="text-xl font-semibold text-gray-800 dark:text-white">Active Map</h1>
</div>
{/* Filter Container */}
<div className="flex-shrink-0 bg-white dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700 px-4 py-3 relative z-[1000]">
<div className="flex flex-wrap gap-4 relative z-[1000]">
{/* Hospital Filter — Location name matches Company/Hospital name */}
<div className="w-64 relative z-[1000]">
<LinkField
label="Hospital"
doctype="Location"
value={filterHospital}
onChange={handleHospitalChange}
filters={hospitalLocationLinkFilters}
placeholder="All Hospitals"
disabled={hospitalLocked}
/>
</div>
{showSiteFilter && (
<div className="w-64 relative z-[1000]">
<LinkField
label="Site Name"
doctype="Mobile Team Site"
value={filterSiteName}
onChange={handleSiteChange}
filters={mobileTeamSiteFilters}
placeholder="All Sites"
disabled={siteLocked}
/>
</div>
)}
{/* PHCC Filter */}
<div className="w-64 relative z-[1000]">
<LinkField
label="PHCC"
doctype="Location"
value={siteLocked && filterSiteName ? filterSiteName : selectedPHCC}
onChange={setSelectedPHCC}
filters={phccLocationLinkFilters}
placeholder="Select PHCC"
disabled={siteLocked}
/>
</div>
</div>
</div>
{/* Map Container */}
<div className="flex-1 relative" style={{ zIndex: 1 }}>
{loading && (
<div className="absolute inset-0 flex items-center justify-center bg-white bg-opacity-75 dark:bg-gray-900 dark:bg-opacity-75 z-[1000]">
<div className="text-gray-600 dark:text-gray-300">
{filtersLoading ? 'Loading filters...' : 'Loading map data...'}
</div>
</div>
)}
<MapContainer
center={[24.8, 45.5]}
zoom={6}
style={{ height: '100%', width: '100%' }}
zoomControl={true}
>
<TileLayer
attribution='&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
/>
<MapBounds locations={locations} />
{locations.map((location) => {
const urgentIndicator = location.urgent_work_orders > 0 ? '🚨 URGENT! ' : '';
const isPhcc = location.location_type === 'phcc';
const typeIndicator = isPhcc ? '🏥 PHCC' : '🏨 Hospital';
const markerKey = `${location.name}-${location.latitude}-${location.longitude}`;
return (
<Marker
key={markerKey}
position={[location.latitude, location.longitude]}
ref={(ref) => {
if (ref) {
markersRef.current[markerKey] = ref;
// Apply marker styling based on location type and urgency
setTimeout(() => {
const markerElement = ref.getElement();
if (markerElement) {
// Remove all custom classes first
markerElement.classList.remove('urgent-marker', 'red-marker', 'phcc-marker');
if (location.urgent_work_orders > 0) {
// Same red flashing for both Hospital and PHCC urgent markers
markerElement.classList.add('urgent-marker', 'red-marker');
} else if (isPhcc) {
// Green marker for non-urgent PHCC
markerElement.classList.add('phcc-marker');
}
// Non-urgent hospitals use default blue marker
}
}, 100);
}
}}
>
<Tooltip
permanent={false}
direction="right"
className="hospital-tooltip-modern"
>
<div className="p-2 bg-white dark:bg-gray-800 rounded-lg shadow-lg min-w-[200px]">
<div className="mb-2 pb-2 border-b border-gray-200 dark:border-gray-700">
<h4 className="text-sm font-bold text-gray-900 dark:text-white">
{urgentIndicator}{location.name}
</h4>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
{typeIndicator}
</p>
<p className="text-xs text-gray-600 dark:text-gray-400 mt-0.5">
Assets: <span className="font-semibold text-gray-900 dark:text-white">{location.assets}</span>
</p>
</div>
<div className="space-y-1 text-xs">
<div className="flex items-center justify-between">
<span className="text-gray-600 dark:text-gray-400">Normal WOs:</span>
<span className="font-semibold text-blue-700 dark:text-blue-300">{location.normal_work_orders}</span>
</div>
<div className="flex items-center justify-between">
<span className="text-gray-600 dark:text-gray-400">Urgent WOs:</span>
<span className="font-semibold text-red-700 dark:text-red-300">{location.urgent_work_orders}</span>
</div>
<div className="flex items-center justify-between">
<span className="text-gray-600 dark:text-gray-400">Planned PMs:</span>
<span className="font-semibold text-orange-700 dark:text-orange-300">{location.planned_maintenance}</span>
</div>
<div className="flex items-center justify-between">
<span className="text-gray-600 dark:text-gray-400">Completed PMs:</span>
<span className="font-semibold text-green-700 dark:text-green-300">{location.completed_maintenance}</span>
</div>
</div>
</div>
</Tooltip>
<Popup
className={isPhcc ? "phcc-popup-container" : "hospital-popup-container"}
maxWidth={320}
maxHeight={450}
autoPan={true}
keepInView={true}
closeButton={true}
autoClose={false}
>
{createPopupContent(location)}
</Popup>
</Marker>
);
})}
</MapContainer>
</div>
{/* Custom Styles */}
<style>{`
/* Ensure filter container and dropdowns stay above map */
.leaflet-container {
z-index: 1 !important;
}
/* LinkField dropdown z-index - ensure it's above everything */
[data-linkfield-dropdown],
.linkfield-dropdown,
.react-select__menu,
.react-select__menu-portal,
.select2-container,
.select2-dropdown {
z-index: 1050 !important;
}
/* Any dropdown menu from LinkField */
div[role="listbox"],
ul[role="listbox"],
.dropdown-menu,
.autocomplete-dropdown {
z-index: 1050 !important;
}
.hospital-tooltip-modern {
background: transparent !important;
border: none !important;
box-shadow: none !important;
}
.hospital-tooltip-modern .leaflet-tooltip-content-wrapper {
background: transparent !important;
border: none !important;
box-shadow: none !important;
padding: 0 !important;
}
.hospital-tooltip-modern .leaflet-tooltip-content {
margin: 0 !important;
}
.hospital-popup-container .leaflet-popup-content-wrapper {
padding: 0;
border-radius: 8px;
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05);
}
.hospital-popup-container .leaflet-popup-content {
margin: 0;
width: auto !important;
}
/* PHCC Popup Container - with green left border */
.phcc-popup-container .leaflet-popup-content-wrapper {
padding: 0;
border-radius: 8px;
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05);
border-left: 4px solid #28a745;
}
.phcc-popup-container .leaflet-popup-content {
margin: 0;
width: auto !important;
}
/* Red Flashing Animation for Urgent Work Orders (Both Hospital & PHCC) */
/* Fixed: Stays red throughout, just pulses brighter */
.urgent-marker {
animation: urgent-flash 2s infinite;
}
@keyframes urgent-flash {
0%, 50% {
filter: hue-rotate(120deg) saturate(2) brightness(0.8);
}
25%, 75% {
filter: hue-rotate(120deg) saturate(2.5) brightness(1.5) drop-shadow(0 0 10px red);
}
}
/* Red marker style (base state for urgent) */
.red-marker {
filter: hue-rotate(120deg) saturate(2) brightness(0.8);
}
/* Green marker style for PHCC */
.phcc-marker {
filter: hue-rotate(-120deg) saturate(1.3) brightness(1.1);
}
.leaflet-popup {
z-index: 2000 !important;
}
.leaflet-tooltip {
z-index: 2000 !important;
}
`}</style>
</div>
);
};
export default ActiveMap;

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,535 @@
import React, { useState, useEffect, useRef, useMemo } from 'react';
import { useNavigate, useSearchParams } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { useAssetMaintenanceLogs, useMaintenanceMutations } from '../hooks/useAssetMaintenance';
import type { MaintenanceFilters } from '../services/assetMaintenanceService';
import { FaPlus, FaSearch, FaEdit, FaEye, FaTrash, FaCopy, FaEllipsisV, FaDownload, FaPrint, FaFileExport, FaCheckCircle, FaClock, FaExclamationTriangle, FaCalendarCheck } from 'react-icons/fa';
const AssetMaintenanceList: React.FC = () => {
const { t } = useTranslation();
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const [page, setPage] = useState(0);
const [searchTerm, setSearchTerm] = useState('');
const [statusFilter, setStatusFilter] = useState<string>(() => searchParams.get('status') || '');
const [assigneeFilter, setAssigneeFilter] = useState<string>(() => searchParams.get('assignee') || '');
const [dateStart, setDateStart] = useState(() => searchParams.get('date_start') || '');
const [dateEnd, setDateEnd] = useState(() => searchParams.get('date_end') || '');
const [companyFilter, setCompanyFilter] = useState(() => searchParams.get('company') || '');
const [siteFilter, setSiteFilter] = useState(() => searchParams.get('site_name') || '');
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState<string | null>(null);
const [actionMenuOpen, setActionMenuOpen] = useState<string | null>(null);
const dropdownRef = useRef<HTMLDivElement>(null);
const limit = 20;
useEffect(() => {
setStatusFilter(searchParams.get('status') || '');
setAssigneeFilter(searchParams.get('assignee') || '');
setDateStart(searchParams.get('date_start') || '');
setDateEnd(searchParams.get('date_end') || '');
setCompanyFilter(searchParams.get('company') || '');
setSiteFilter(searchParams.get('site_name') || '');
setPage(0);
}, [searchParams]);
const filters = useMemo(() => {
const nextFilters: MaintenanceFilters = {};
if (statusFilter) nextFilters.maintenance_status = statusFilter;
if (assigneeFilter) nextFilters.assign_to_name = assigneeFilter;
if (companyFilter) nextFilters.company = companyFilter;
if (siteFilter) nextFilters.site_name = siteFilter;
if (dateStart && dateEnd) {
nextFilters.due_date = ['between', [dateStart, dateEnd]];
} else if (dateStart) {
nextFilters.due_date = ['>=', dateStart];
} else if (dateEnd) {
nextFilters.due_date = ['<=', dateEnd];
}
return nextFilters;
}, [statusFilter, assigneeFilter, companyFilter, siteFilter, dateStart, dateEnd]);
const { logs, totalCount, hasMore, loading, error, refetch } = useAssetMaintenanceLogs(
filters,
limit,
page * limit,
'due_date asc'
);
const { deleteLog, loading: mutationLoading } = useMaintenanceMutations();
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
setActionMenuOpen(null);
}
};
if (actionMenuOpen) {
document.addEventListener('mousedown', handleClickOutside);
}
return () => {
document.removeEventListener('mousedown', handleClickOutside);
};
}, [actionMenuOpen]);
const handleCreateNew = () => {
navigate('/maintenance/new');
};
const handleView = (logName: string) => {
navigate(`/maintenance/${logName}`);
};
const handleEdit = (logName: string) => {
navigate(`/maintenance/${logName}`);
};
const handleDelete = async (logName: string) => {
try {
await deleteLog(logName);
setDeleteConfirmOpen(null);
refetch();
alert('Maintenance log deleted successfully!');
} catch (err) {
alert(`Failed to delete: ${err instanceof Error ? err.message : 'Unknown error'}`);
}
};
const handleDuplicate = (logName: string) => {
navigate(`/maintenance/new?duplicate=${logName}`);
};
const handleExport = (log: any) => {
const dataStr = JSON.stringify(log, null, 2);
const dataBlob = new Blob([dataStr], { type: 'application/json' });
const url = URL.createObjectURL(dataBlob);
const link = document.createElement('a');
link.href = url;
link.download = `maintenance_${log.name}.json`;
link.click();
URL.revokeObjectURL(url);
};
const handlePrint = (logName: string) => {
window.open(`/maintenance/${logName}?print=true`, '_blank');
};
const handleExportAll = () => {
const headers = ['Log ID', 'Asset', 'Type', 'Status', 'Due Date', 'Assigned To'];
const csvContent = [
headers.join(','),
...logs.map(log => [
log.name,
log.asset_name || '',
log.maintenance_type || '',
log.maintenance_status || '',
log.due_date || '',
log.assign_to_name || ''
].join(','))
].join('\n');
const dataBlob = new Blob([csvContent], { type: 'text/csv' });
const url = URL.createObjectURL(dataBlob);
const link = document.createElement('a');
link.href = url;
link.download = `maintenance_logs_${new Date().toISOString().split('T')[0]}.csv`;
link.click();
URL.revokeObjectURL(url);
};
const getStatusIcon = (status: string) => {
switch (status?.toLowerCase()) {
case 'completed':
return <FaCheckCircle className="text-green-500" />;
case 'planned':
return <FaCalendarCheck className="text-blue-500" />;
case 'overdue':
return <FaExclamationTriangle className="text-red-500" />;
default:
return <FaClock className="text-gray-400" />;
}
};
const getStatusColor = (status: string) => {
switch (status?.toLowerCase()) {
case 'completed':
return 'bg-green-100 dark:bg-green-900/30 text-green-800 dark:text-green-300';
case 'planned':
return 'bg-blue-100 dark:bg-blue-900/30 text-blue-800 dark:text-blue-300';
case 'overdue':
return 'bg-red-100 dark:bg-red-900/30 text-red-800 dark:text-red-300';
case 'cancelled':
return 'bg-gray-100 dark:bg-gray-700 text-gray-800 dark:text-gray-300';
default:
return 'bg-yellow-100 dark:bg-yellow-900/30 text-yellow-800 dark:text-yellow-300';
}
};
const isOverdue = (dueDate: string, status: string) => {
if (!dueDate || status?.toLowerCase() === 'completed') return false;
return new Date(dueDate) < new Date();
};
if (loading && page === 0) {
return (
<div className="flex items-center justify-center h-screen bg-gray-50 dark:bg-gray-900">
<div className="text-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-500 mx-auto"></div>
<p className="mt-4 text-gray-600 dark:text-gray-400">{t('listPages.loading')}</p>
</div>
</div>
);
}
if (error) {
return (
<div className="p-4 sm:p-6 bg-gray-50 dark:bg-gray-900 min-h-screen min-w-0 overflow-x-hidden">
<div className="bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-lg p-6">
<h2 className="text-xl font-bold text-yellow-800 dark:text-yellow-300 mb-4"> Maintenance API Not Available</h2>
<div className="text-yellow-700 dark:text-yellow-400 space-y-3">
<p><strong>The Asset Maintenance API endpoint is not deployed yet.</strong></p>
<div className="mt-4 flex gap-3">
<button
onClick={() => navigate('/maintenance/new')}
className="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded"
>
Try Creating New (Demo)
</button>
<button
onClick={refetch}
className="bg-gray-600 hover:bg-gray-700 text-white px-4 py-2 rounded"
>
Try Again
</button>
</div>
</div>
<div className="mt-4 p-4 bg-white dark:bg-gray-800 rounded border border-yellow-300 dark:border-yellow-700">
<p className="text-sm text-gray-600 dark:text-gray-400">
<strong>Technical Error:</strong> {error}
</p>
</div>
</div>
</div>
);
}
const filteredLogs = logs.filter(log =>
log.name?.toLowerCase().includes(searchTerm.toLowerCase()) ||
log.asset_name?.toLowerCase().includes(searchTerm.toLowerCase()) ||
log.task_name?.toLowerCase().includes(searchTerm.toLowerCase())
);
return (
<div className="p-4 sm:p-6 bg-gray-50 dark:bg-gray-900 min-h-screen min-w-0 overflow-x-hidden">
{/* Header */}
<div className="mb-6 flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between lg:items-center">
<div>
<h1 className="text-2xl sm:text-3xl font-bold text-gray-800 dark:text-white break-words">{t('maintenance.title')}</h1>
<p className="text-gray-600 dark:text-gray-400 mt-1">
{t('listPages.total')}: {totalCount} {t('maintenance.maintenanceLogs')}
</p>
</div>
<div className="flex gap-3">
<button
onClick={handleExportAll}
className="bg-green-600 hover:bg-green-700 text-white px-4 py-3 rounded-lg flex items-center gap-2 shadow transition-all"
disabled={logs.length === 0}
>
<FaFileExport />
<span className="font-medium">{t('listPages.exportAllOnPage')}</span>
</button>
<button
onClick={handleCreateNew}
className="bg-blue-600 hover:bg-blue-700 text-white px-6 py-3 rounded-lg flex items-center gap-2 shadow-lg transition-all hover:shadow-xl"
>
<FaPlus />
<span className="font-medium">{t('maintenance.addMaintenance')}</span>
</button>
</div>
</div>
{/* Filters Bar */}
<div className="mb-6 grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-4">
<div className="flex items-center gap-2 border border-gray-300 dark:border-gray-600 rounded-lg px-4 py-2 bg-white dark:bg-gray-700">
<FaSearch className="text-gray-400 dark:text-gray-500" />
<input
type="text"
placeholder={t('listPages.searchPlaceholder')}
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="flex-1 outline-none text-gray-700 dark:text-gray-200 bg-transparent"
/>
</div>
</div>
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-4">
<select
value={statusFilter}
onChange={(e) => {
setStatusFilter(e.target.value);
setPage(0);
}}
className="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-700 dark:text-gray-200 focus:outline-none focus:ring-2 focus:ring-blue-500"
>
<option value="">{t('listPages.allStatuses')}</option>
<option value="Planned">Planned</option>
<option value="Completed">Completed</option>
<option value="Overdue">Overdue</option>
<option value="Cancelled">Cancelled</option>
</select>
</div>
</div>
{/* Maintenance Logs Table */}
<div className="bg-white dark:bg-gray-800 rounded-lg shadow overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full">
<thead className="bg-gray-100 dark:bg-gray-700 border-b border-gray-200 dark:border-gray-600">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
{t('maintenance.logId')}
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
{t('commonFields.assetShort')}
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
{t('commonFields.typeShort')}
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
{t('ppm.dueDate')}
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
{t('commonFields.status')}
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
{t('listPages.actions')}
</th>
</tr>
</thead>
<tbody className="bg-white dark:bg-gray-800 divide-y divide-gray-200 dark:divide-gray-700">
{filteredLogs.length === 0 ? (
<tr>
<td colSpan={6} className="px-6 py-12 text-center text-gray-500 dark:text-gray-400">
<div className="flex flex-col items-center">
<FaSearch className="text-4xl text-gray-300 dark:text-gray-600 mb-2" />
<p>{t('listPages.noMaintenanceLogsFound')}</p>
<button
onClick={handleCreateNew}
className="mt-4 text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300 underline"
>
{t('listPages.createFirstMaintenanceLog')}
</button>
</div>
</td>
</tr>
) : (
filteredLogs.map((log) => {
const overdue = isOverdue(log.due_date || '', log.maintenance_status || '');
return (
<tr
key={log.name}
className={`hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors cursor-pointer ${
overdue ? 'bg-red-50 dark:bg-red-900/10' : ''
}`}
onClick={() => handleView(log.name)}
>
<td className="px-6 py-4 whitespace-nowrap">
<div className="font-medium text-gray-900 dark:text-white">{log.name}</div>
<div className="text-sm text-gray-500 dark:text-gray-400">
{log.creation ? new Date(log.creation).toLocaleDateString() : ''}
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm text-gray-900 dark:text-white">{log.asset_name || '-'}</div>
<div className="text-xs text-gray-500 dark:text-gray-400">{log.custom_asset_type || ''}</div>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-700 dark:text-gray-300">
{log.maintenance_type || '-'}
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm text-gray-900 dark:text-white">
{log.due_date ? new Date(log.due_date).toLocaleDateString() : '-'}
</div>
{overdue && (
<div className="text-xs text-red-600 dark:text-red-400 font-semibold">
Overdue
</div>
)}
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="flex items-center gap-2">
{getStatusIcon(log.maintenance_status || '')}
<span className={`px-3 py-1 inline-flex text-xs leading-5 font-semibold rounded-full ${getStatusColor(log.maintenance_status || '')}`}>
{log.maintenance_status || 'Unknown'}
</span>
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium">
<div className="flex items-center gap-1" onClick={(e) => e.stopPropagation()}>
<button
onClick={() => handleView(log.name)}
className="text-blue-600 dark:text-blue-400 hover:text-blue-900 dark:hover:text-blue-300 p-2 hover:bg-blue-50 dark:hover:bg-blue-900/30 rounded transition-colors"
title="View Details"
>
<FaEye />
</button>
<button
onClick={() => handleEdit(log.name)}
className="text-green-600 dark:text-green-400 hover:text-green-900 dark:hover:text-green-300 p-2 hover:bg-green-50 dark:hover:bg-green-900/30 rounded transition-colors"
title="Edit Log"
>
<FaEdit />
</button>
<button
onClick={() => handleDuplicate(log.name)}
className="text-purple-600 dark:text-purple-400 hover:text-purple-900 dark:hover:text-purple-300 p-2 hover:bg-purple-50 dark:hover:bg-purple-900/30 rounded transition-colors"
title="Duplicate"
>
<FaCopy />
</button>
<button
onClick={() => setDeleteConfirmOpen(log.name)}
className="text-red-600 dark:text-red-400 hover:text-red-900 dark:hover:text-red-300 p-2 hover:bg-red-50 dark:hover:bg-red-900/30 rounded transition-colors"
title="Delete"
disabled={mutationLoading}
>
<FaTrash />
</button>
<div className="relative" ref={actionMenuOpen === log.name ? dropdownRef : null}>
<button
onClick={() => setActionMenuOpen(actionMenuOpen === log.name ? null : log.name)}
className="text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200 p-2 hover:bg-gray-50 dark:hover:bg-gray-700 rounded transition-colors"
title="More Actions"
>
<FaEllipsisV />
</button>
{actionMenuOpen === log.name && (
<div className="absolute right-0 mt-2 w-48 bg-white dark:bg-gray-800 rounded-lg shadow-lg border border-gray-200 dark:border-gray-700 z-10">
<button
onClick={() => {
handleExport(log);
setActionMenuOpen(null);
}}
className="w-full text-left px-4 py-2 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 flex items-center gap-2 rounded-t-lg"
>
<FaDownload className="text-blue-500" />
Export as JSON
</button>
<button
onClick={() => {
handlePrint(log.name);
setActionMenuOpen(null);
}}
className="w-full text-left px-4 py-2 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 flex items-center gap-2 rounded-b-lg"
>
<FaPrint className="text-purple-500" />
Print Log
</button>
</div>
)}
</div>
</div>
</td>
</tr>
);
})
)}
</tbody>
</table>
</div>
{/* Pagination */}
{filteredLogs.length > 0 && (
<div className="bg-gray-50 dark:bg-gray-700 px-6 py-4 flex items-center justify-between border-t border-gray-200 dark:border-gray-600">
<div className="text-sm text-gray-700 dark:text-gray-300">
Showing <span className="font-medium">{page * limit + 1}</span> to{' '}
<span className="font-medium">
{Math.min((page + 1) * limit, totalCount)}
</span>{' '}
of <span className="font-medium">{totalCount}</span> results
</div>
<div className="flex gap-2">
<button
disabled={page === 0}
onClick={() => setPage(page - 1)}
className="px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-md text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-800 hover:bg-gray-50 dark:hover:bg-gray-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
Previous
</button>
<button
disabled={!hasMore}
onClick={() => setPage(page + 1)}
className="px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-md text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-800 hover:bg-gray-50 dark:hover:bg-gray-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
Next
</button>
</div>
</div>
)}
</div>
{/* Delete Confirmation Modal */}
{deleteConfirmOpen && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white dark:bg-gray-800 rounded-lg p-6 max-w-md w-full mx-4 shadow-2xl">
<div className="flex items-start gap-4">
<div className="flex-shrink-0 w-12 h-12 rounded-full bg-red-100 dark:bg-red-900/30 flex items-center justify-center">
<FaTrash className="text-red-600 dark:text-red-400 text-xl" />
</div>
<div className="flex-1">
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-2">
Delete Maintenance Log
</h3>
<p className="text-sm text-gray-600 dark:text-gray-400 mb-4">
Are you sure you want to delete this maintenance log? This action cannot be undone.
</p>
<div className="bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-md p-3 mb-4">
<p className="text-xs text-yellow-800 dark:text-yellow-300">
<strong>Log ID:</strong> {deleteConfirmOpen}
</p>
</div>
<div className="flex gap-3 justify-end">
<button
onClick={() => setDeleteConfirmOpen(null)}
className="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 rounded-lg transition-colors"
disabled={mutationLoading}
>
Cancel
</button>
<button
onClick={() => handleDelete(deleteConfirmOpen)}
className="px-4 py-2 text-sm font-medium text-white bg-red-600 hover:bg-red-700 rounded-lg transition-colors flex items-center gap-2 disabled:opacity-50"
disabled={mutationLoading}
>
{mutationLoading ? (
<>
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white"></div>
Deleting...
</>
) : (
<>
<FaTrash />
Delete Log
</>
)}
</button>
</div>
</div>
</div>
</div>
</div>
)}
</div>
);
};
export default AssetMaintenanceList;

View File

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

View File

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

View File

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

View File

@ -0,0 +1,700 @@
import React, { useState, useEffect, useCallback } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { useIssueDetails, useIssueMutations } from '../hooks/useIssue';
import {
FaArrowLeft,
FaSave,
FaEdit,
FaTrash,
FaCheckCircle,
FaTimesCircle,
FaExclamationTriangle,
FaClock,
FaUser,
FaBuilding,
FaEnvelope,
FaCalendarAlt,
FaTag,
FaComment
} from 'react-icons/fa';
import { toast, ToastContainer, Bounce } from 'react-toastify';
import 'react-toastify/dist/ReactToastify.css';
import LinkField from '../components/LinkField';
import CommentSection from '../components/CommentSection';
import ActivityLog from '../components/ActivityLog';
import useDefaultHospital from '../hooks/useDefaultHospital';
import type { CreateIssueData } from '../services/issueService';
// Helper to get today's date in YYYY-MM-DD format
const getTodayDate = (): string => {
return new Date().toISOString().split('T')[0];
};
// Helper to get current time in HH:MM:SS format
const getCurrentTime = (): string => {
return new Date().toTimeString().split(' ')[0];
};
// Status badge styles
const getStatusStyle = (status: string) => {
switch (status?.toLowerCase()) {
case 'open':
return { bg: 'bg-blue-100 dark:bg-blue-900/30', text: 'text-blue-800 dark:text-blue-300', border: 'border-blue-200 dark:border-blue-800' };
case 'replied':
return { bg: 'bg-purple-100 dark:bg-purple-900/30', text: 'text-purple-800 dark:text-purple-300', border: 'border-purple-200 dark:border-purple-800' };
case 'on hold':
return { bg: 'bg-yellow-100 dark:bg-yellow-900/30', text: 'text-yellow-800 dark:text-yellow-300', border: 'border-yellow-200 dark:border-yellow-800' };
case 'resolved':
return { bg: 'bg-green-100 dark:bg-green-900/30', text: 'text-green-800 dark:text-green-300', border: 'border-green-200 dark:border-green-800' };
case 'closed':
return { bg: 'bg-gray-100 dark:bg-gray-700', text: 'text-gray-800 dark:text-gray-300', border: 'border-gray-200 dark:border-gray-600' };
default:
return { bg: 'bg-gray-100 dark:bg-gray-700', text: 'text-gray-800 dark:text-gray-300', border: 'border-gray-200 dark:border-gray-600' };
}
};
const IssueDetail: React.FC = () => {
const { t } = useTranslation();
const { issueName } = useParams<{ issueName: string }>();
const navigate = useNavigate();
const isNewIssue = issueName === 'new';
// Form data state
const [formData, setFormData] = useState<CreateIssueData & {
opening_date?: string;
opening_time?: string;
first_responded_on?: string;
resolution_date?: string;
resolution_by?: string;
}>({
subject: '',
raised_by: '',
status: 'Open',
priority: '',
issue_type: '',
description: '',
contact: '',
company: '',
customer: '',
project: '',
resolution_details: '',
opening_date: isNewIssue ? getTodayDate() : '',
opening_time: isNewIssue ? getCurrentTime() : '',
first_responded_on: '',
resolution_date: '',
resolution_by: '',
});
const { issue, loading, error, refetch } = useIssueDetails(isNewIssue ? null : issueName || null);
const { createIssue, updateIssue, deleteIssue, loading: saving } = useIssueMutations();
const [isEditing, setIsEditing] = useState(isNewIssue);
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
useDefaultHospital(setFormData, {
enabled: isNewIssue,
fields: ['company'],
});
// Load issue data when fetched
useEffect(() => {
if (issue && !isNewIssue) {
setFormData({
subject: issue.subject || '',
raised_by: issue.raised_by || '',
status: issue.status || 'Open',
priority: issue.priority || '',
issue_type: issue.issue_type || '',
description: issue.description || '',
contact: issue.contact || '',
company: issue.company || '',
customer: issue.customer || '',
project: issue.project || '',
resolution_details: issue.resolution_details || '',
opening_date: issue.opening_date || '',
opening_time: issue.opening_time || '',
first_responded_on: issue.first_responded_on ? issue.first_responded_on.split(' ')[0] : '',
resolution_date: issue.resolution_date ? issue.resolution_date.split(' ')[0] : '',
resolution_by: issue.resolution_by || '',
});
setIsEditing(false);
}
}, [issue, isNewIssue]);
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement>) => {
const { name, value } = e.target;
setFormData(prev => ({ ...prev, [name]: value }));
};
const handleSave = async () => {
if (!formData.subject) {
toast.error('Please enter a subject', {
position: "top-right",
autoClose: 4000,
icon: <FaTimesCircle />
});
return;
}
try {
if (isNewIssue) {
const newIssue = await createIssue(formData);
toast.success('Issue created successfully!', {
position: "top-right",
autoClose: 3000,
icon: <FaCheckCircle />
});
navigate(`/support/${newIssue.name}`);
} else {
await updateIssue(issueName!, formData);
toast.success('Issue updated successfully!', {
position: "top-right",
autoClose: 3000,
icon: <FaCheckCircle />
});
setIsEditing(false);
refetch();
}
} catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Unknown error';
toast.error(`Failed to save: ${errorMessage}`, {
position: "top-right",
autoClose: 6000,
icon: <FaTimesCircle />
});
}
};
const handleDelete = async () => {
try {
await deleteIssue(issueName!);
toast.success('Issue deleted successfully!', {
position: "top-right",
autoClose: 3000,
icon: <FaCheckCircle />
});
navigate('/support');
} catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Unknown error';
toast.error(`Failed to delete: ${errorMessage}`, {
position: "top-right",
autoClose: 6000,
icon: <FaTimesCircle />
});
}
};
const isFieldDisabled = useCallback((fieldname: string): boolean => {
if (!isEditing) return true;
// Some fields are always read-only
if (['opening_date', 'opening_time'].includes(fieldname) && !isNewIssue) {
return true;
}
return false;
}, [isEditing, isNewIssue]);
// Format datetime
const formatDateTime = (dateStr: string) => {
if (!dateStr) return '-';
return new Date(dateStr).toLocaleString();
};
if (loading) {
return (
<div className="flex items-center justify-center h-screen bg-gray-50 dark:bg-gray-900">
<div className="text-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-500 mx-auto"></div>
<p className="mt-4 text-gray-600 dark:text-gray-400">Loading issue details...</p>
</div>
</div>
);
}
if (error && !isNewIssue) {
return (
<div className="p-4 sm:p-6 bg-gray-50 dark:bg-gray-900 min-h-screen min-w-0 overflow-x-hidden">
<div className="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-6">
<h2 className="text-xl font-bold text-red-800 dark:text-red-300 mb-4">Error Loading Issue</h2>
<p className="text-red-700 dark:text-red-400 mb-4">{error}</p>
<button
onClick={() => navigate('/support')}
className="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded"
>
Back to Issues
</button>
</div>
</div>
);
}
const currentStatus = issue?.status || formData.status || 'Open';
const statusStyle = getStatusStyle(currentStatus);
return (
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 p-4 sm:p-6 min-w-0 overflow-x-hidden">
{/* Toast Container */}
<ToastContainer
position="top-right"
autoClose={4000}
hideProgressBar={false}
newestOnTop
closeOnClick
rtl={false}
pauseOnFocusLoss
draggable
pauseOnHover
theme="colored"
transition={Bounce}
/>
{/* Header */}
<div className="mb-6 flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between lg:items-center flex-wrap">
<div className="flex items-center gap-4">
<button
onClick={() => navigate('/support')}
className="text-gray-600 dark:text-gray-400 hover:text-gray-800 dark:hover:text-gray-200"
>
<FaArrowLeft size={20} />
</button>
<div>
<h1 className="text-2xl font-bold text-gray-800 dark:text-white flex items-center gap-3">
{isNewIssue ? t('issues.newIssue') : issue?.name || t('issues.issueDetails')}
{!isNewIssue && (
<span className={`px-3 py-1 rounded-full text-sm font-medium ${statusStyle.bg} ${statusStyle.text} ${statusStyle.border} border`}>
{currentStatus}
</span>
)}
</h1>
<p className="text-gray-600 dark:text-gray-400 mt-1">
{isNewIssue ? t('issues.createNewIssue') : formData.subject}
</p>
</div>
</div>
<div className="flex gap-3">
{!isNewIssue && !isEditing && (
<>
<button
onClick={() => setIsEditing(true)}
className="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-lg flex items-center gap-2"
>
<FaEdit />
{t('common.edit')}
</button>
<button
onClick={() => setShowDeleteConfirm(true)}
className="bg-red-600 hover:bg-red-700 text-white px-4 py-2 rounded-lg flex items-center gap-2"
>
<FaTrash />
{t('common.delete')}
</button>
</>
)}
{isEditing && (
<>
<button
onClick={() => {
if (isNewIssue) {
navigate('/support');
} else {
setIsEditing(false);
refetch();
}
}}
className="bg-gray-600 hover:bg-gray-700 text-white px-4 py-2 rounded-lg"
>
{t('common.cancel')}
</button>
<button
onClick={handleSave}
disabled={saving}
className="bg-green-600 hover:bg-green-700 text-white px-4 py-2 rounded-lg flex items-center gap-2 disabled:opacity-50"
>
<FaSave />
{saving ? t('common.saving') : t('common.save')}
</button>
</>
)}
</div>
</div>
{/* Delete Confirmation Modal */}
{showDeleteConfirm && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
<div className="bg-white dark:bg-gray-800 rounded-lg p-6 max-w-md w-full mx-4 shadow-xl">
<div className="flex items-start gap-3 mb-4">
<FaExclamationTriangle className="text-red-500 text-xl mt-0.5" />
<div>
<h3 className="text-lg font-semibold text-gray-800 dark:text-white">Delete Issue</h3>
<p className="text-gray-600 dark:text-gray-400 mt-1">
Are you sure you want to delete this issue? This action cannot be undone.
</p>
</div>
</div>
<div className="flex justify-end gap-3">
<button
onClick={() => setShowDeleteConfirm(false)}
className="px-4 py-2 bg-gray-300 hover:bg-gray-400 text-gray-700 rounded-lg"
>
Cancel
</button>
<button
onClick={handleDelete}
disabled={saving}
className="px-4 py-2 bg-red-600 hover:bg-red-700 text-white rounded-lg disabled:opacity-50"
>
{saving ? 'Deleting...' : 'Delete'}
</button>
</div>
</div>
</div>
)}
{/* Form */}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Main Content - Left Column */}
<div className="lg:col-span-2 space-y-6">
{/* Issue Details */}
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-md p-6 border border-gray-200 dark:border-gray-700">
<h2 className="text-lg font-semibold text-gray-800 dark:text-white mb-4 pb-2 border-b border-gray-200 dark:border-gray-700 flex items-center gap-2">
<FaComment className="text-blue-500" />
{t('issues.issueDetails')}
</h2>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
{t('issues.subject')} <span className="text-red-500">*</span>
</label>
<input
type="text"
name="subject"
value={formData.subject}
onChange={handleChange}
disabled={isFieldDisabled('subject')}
placeholder={t('issues.enterSubject')}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-700 text-gray-900 dark:text-white disabled:bg-gray-100 dark:disabled:bg-gray-800 focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
{t('commonFields.status')}
</label>
<select
name="status"
value={formData.status}
onChange={handleChange}
disabled={isFieldDisabled('status')}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-700 text-gray-900 dark:text-white disabled:bg-gray-100 dark:disabled:bg-gray-800 focus:outline-none focus:ring-2 focus:ring-blue-500"
>
<option value="Open">Open</option>
<option value="Replied">Replied</option>
<option value="On Hold">On Hold</option>
<option value="Resolved">Resolved</option>
<option value="Closed">Closed</option>
</select>
</div>
<div>
<LinkField
label={t('commonFields.priority')}
doctype="Issue Priority"
value={formData.priority || ''}
onChange={(val) => setFormData({ ...formData, priority: val })}
disabled={isFieldDisabled('priority')}
placeholder={t('issues.selectPriority')}
/>
</div>
</div>
<div>
<LinkField
label={t('issues.issueType')}
doctype="Issue Type"
value={formData.issue_type || ''}
onChange={(val) => setFormData({ ...formData, issue_type: val })}
disabled={isFieldDisabled('issue_type')}
placeholder={t('issues.selectIssueType')}
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
{t('commonFields.description')}
</label>
<textarea
name="description"
value={formData.description}
onChange={handleChange}
disabled={isFieldDisabled('description')}
placeholder={t('issues.describeIssue')}
rows={5}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-700 text-gray-900 dark:text-white disabled:bg-gray-100 dark:disabled:bg-gray-800 focus:outline-none focus:ring-2 focus:ring-blue-500 resize-none"
/>
</div>
</div>
</div>
{/* Contact Information */}
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-md p-6 border border-gray-200 dark:border-gray-700">
<h2 className="text-lg font-semibold text-gray-800 dark:text-white mb-4 pb-2 border-b border-gray-200 dark:border-gray-700 flex items-center gap-2">
<FaUser className="text-green-500" />
{t('issues.contactInformation')}
</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
{t('issues.raisedBy')}
</label>
<div className="relative">
<FaEnvelope className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400" />
<input
type="email"
name="raised_by"
value={formData.raised_by}
onChange={handleChange}
disabled={isFieldDisabled('raised_by')}
placeholder={t('common.email')}
className="w-full pl-10 pr-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-700 text-gray-900 dark:text-white disabled:bg-gray-100 dark:disabled:bg-gray-800 focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
</div>
{/* <div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Contact Name
</label>
<input
type="text"
name="contact"
value={formData.contact}
onChange={handleChange}
disabled={isFieldDisabled('contact')}
placeholder="Contact person name"
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-700 text-gray-900 dark:text-white disabled:bg-gray-100 dark:disabled:bg-gray-800 focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div> */}
<div>
<LinkField
label={t('commonFields.company')}
doctype="Company"
value={formData.company || ''}
onChange={(val) => setFormData({ ...formData, company: val })}
disabled={isFieldDisabled('company')}
placeholder={t('issues.selectCompany')}
/>
</div>
{/* <div>
<LinkField
label="Customer"
doctype="Customer"
value={formData.customer || ''}
onChange={(val) => setFormData({ ...formData, customer: val })}
disabled={isFieldDisabled('customer')}
placeholder="Select customer"
/>
</div>
<div>
<LinkField
label="Project"
doctype="Project"
value={formData.project || ''}
onChange={(val) => setFormData({ ...formData, project: val })}
disabled={isFieldDisabled('project')}
placeholder="Select project"
/>
</div> */}
</div>
</div>
{/* Resolution */}
{!isNewIssue && (
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-md p-6 border border-gray-200 dark:border-gray-700">
<h2 className="text-lg font-semibold text-gray-800 dark:text-white mb-4 pb-2 border-b border-gray-200 dark:border-gray-700 flex items-center gap-2">
<FaCheckCircle className="text-purple-500" />
{t('issues.resolution')}
</h2>
<div className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
{t('issues.firstRespondedOn')}
</label>
<input
type="date"
name="first_responded_on"
value={formData.first_responded_on || ''}
onChange={handleChange}
disabled={isFieldDisabled('first_responded_on')}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-700 text-gray-900 dark:text-white disabled:bg-gray-100 dark:disabled:bg-gray-800 focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
{t('issues.resolutionDate')}
</label>
<input
type="date"
name="resolution_date"
value={formData.resolution_date || ''}
onChange={handleChange}
disabled={isFieldDisabled('resolution_date')}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-700 text-gray-900 dark:text-white disabled:bg-gray-100 dark:disabled:bg-gray-800 focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
</div>
<div>
<LinkField
label={t('issues.resolvedBy')}
doctype="User"
value={formData.resolution_by || ''}
onChange={(val) => setFormData({ ...formData, resolution_by: val })}
disabled={isFieldDisabled('resolution_by')}
placeholder={t('maintenance.selectUser')}
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
{t('issues.resolutionDetails')}
</label>
<textarea
name="resolution_details"
value={formData.resolution_details}
onChange={handleChange}
disabled={isFieldDisabled('resolution_details')}
placeholder={t('issues.describeResolution')}
rows={4}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-700 text-gray-900 dark:text-white disabled:bg-gray-100 dark:disabled:bg-gray-800 focus:outline-none focus:ring-2 focus:ring-blue-500 resize-none"
/>
</div>
</div>
</div>
)}
</div>
{/* Sidebar - Right Column */}
<div className="space-y-6">
{/* Status Card */}
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-md p-6 border border-gray-200 dark:border-gray-700">
<h2 className="text-lg font-semibold text-gray-800 dark:text-white mb-4 pb-2 border-b border-gray-200 dark:border-gray-700 flex items-center gap-2">
<FaTag className="text-orange-500" />
{t('issues.statusInformation')}
</h2>
<div className="space-y-4">
<div className={`p-4 rounded-lg border ${statusStyle.bg} ${statusStyle.border}`}>
<p className="text-xs text-gray-500 dark:text-gray-400 mb-1">{t('issues.currentStatus')}</p>
<p className={`text-xl font-semibold ${statusStyle.text}`}>
{currentStatus}
</p>
</div>
{formData.priority && (
<div className="p-4 bg-gray-50 dark:bg-gray-700 rounded-lg">
<p className="text-xs text-gray-500 dark:text-gray-400 mb-1">{t('commonFields.priority')}</p>
<p className="text-sm font-medium text-gray-900 dark:text-white">
{formData.priority}
</p>
</div>
)}
{formData.issue_type && (
<div className="p-4 bg-gray-50 dark:bg-gray-700 rounded-lg">
<p className="text-xs text-gray-500 dark:text-gray-400 mb-1">{t('issues.issueType')}</p>
<p className="text-sm font-medium text-gray-900 dark:text-white">
{formData.issue_type}
</p>
</div>
)}
</div>
</div>
{/* Timeline Card */}
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-md p-6 border border-gray-200 dark:border-gray-700">
<h2 className="text-lg font-semibold text-gray-800 dark:text-white mb-4 pb-2 border-b border-gray-200 dark:border-gray-700 flex items-center gap-2">
<FaCalendarAlt className="text-teal-500" />
{t('issues.timeline')}
</h2>
<div className="space-y-4">
<div className="p-4 bg-gray-50 dark:bg-gray-700 rounded-lg">
<p className="text-xs text-gray-500 dark:text-gray-400 mb-1">{t('issues.openingDate')}</p>
<p className="text-sm text-gray-900 dark:text-white">
{formData.opening_date || '-'}
</p>
</div>
<div className="p-4 bg-gray-50 dark:bg-gray-700 rounded-lg">
<p className="text-xs text-gray-500 dark:text-gray-400 mb-1">Opening Time</p>
<p className="text-sm text-gray-900 dark:text-white">
{formData.opening_time || '-'}
</p>
</div>
{!isNewIssue && issue && (
<>
<div className="p-4 bg-gray-50 dark:bg-gray-700 rounded-lg">
<p className="text-xs text-gray-500 dark:text-gray-400 mb-1">Created</p>
<p className="text-sm text-gray-900 dark:text-white">
{formatDateTime(issue.creation)}
</p>
</div>
</>
)}
</div>
</div>
{!isNewIssue && (
<div className="space-y-6">
<CommentSection
referenceDoctype="Issue"
referenceName={issueName || null}
title="Comments & Discussion"
pollInterval={30000}
initialLimit={5}
/>
<ActivityLog
doctype="Issue"
docname={issueName || null}
creationDate={issue?.creation}
createdBy={issue?.owner}
initialVisible={5}
startCollapsed={true}
/>
</div>
)}
{/* Company Info Card */}
{formData.company && !isNewIssue && (
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-md p-6 border border-gray-200 dark:border-gray-700">
<h2 className="text-lg font-semibold text-gray-800 dark:text-white mb-4 pb-2 border-b border-gray-200 dark:border-gray-700 flex items-center gap-2">
<FaBuilding className="text-indigo-500" />
Company
</h2>
<div className="p-4 bg-gray-50 dark:bg-gray-700 rounded-lg">
<p className="text-sm font-medium text-gray-900 dark:text-white">
{formData.company}
</p>
</div>
</div>
)}
</div>
</div>
</div>
);
};
export default IssueDetail;

View File

@ -0,0 +1,669 @@
import React, { useState, useMemo, useEffect, useCallback } from 'react';
import { useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { useIssueList } from '../hooks/useIssue';
import ListPagination from '../components/ListPagination';
import ListFilterSortControls from '../components/ListFilterSortControls';
import { useListSortFilters } from '../hooks/useListSortFilters';
import { applyDateRangeToFilters } from '../utils/listFilterUtils';
import * as XLSX from 'xlsx';
import {
FaPlus,
FaFilter,
FaSync,
FaEye,
FaChevronLeft,
FaChevronRight,
FaExclamationCircle,
FaCheckCircle,
FaClock,
FaTimesCircle,
FaHeadset,
FaTimes,
FaSave,
FaStar,
FaTrash,
FaEdit,
FaCheckSquare,
FaSquare,
FaFileExport,
FaFileExcel,
FaFileCsv,
FaDownload
} from 'react-icons/fa';
import LinkField from '../components/LinkField';
// Export types
type ExportFormat = 'csv' | 'excel';
type ExportScope = 'selected' | 'all_on_page' | 'all_with_filters';
interface ExportModalProps {
isOpen: boolean;
onClose: () => void;
selectedCount: number;
totalCount: number;
pageCount: number;
onExport: (scope: ExportScope, format: ExportFormat, columns: string[]) => void;
isExporting: boolean;
exportColumns: Array<{key: string, label: string, default: boolean}>;
}
const ExportModal: React.FC<ExportModalProps> = ({
isOpen,
onClose,
selectedCount,
totalCount,
pageCount,
onExport,
isExporting,
exportColumns
}) => {
const [scope, setScope] = useState<ExportScope>(selectedCount > 0 ? 'selected' : 'all_with_filters');
const [format, setFormat] = useState<ExportFormat>('csv');
const [selectedColumns, setSelectedColumns] = useState<string[]>(
exportColumns.filter(c => c.default).map(c => c.key)
);
useEffect(() => {
if (selectedCount > 0) {
setScope('selected');
} else {
setScope('all_with_filters');
}
}, [selectedCount]);
const toggleColumn = (key: string) => {
setSelectedColumns(prev =>
prev.includes(key) ? prev.filter(k => k !== key) : [...prev, key]
);
};
const selectAllColumns = () => setSelectedColumns(exportColumns.map(c => c.key));
const selectDefaultColumns = () => setSelectedColumns(exportColumns.filter(c => c.default).map(c => c.key));
if (!isOpen) return null;
return (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-[70] p-4">
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-xl max-w-2xl w-full max-h-[90vh] overflow-hidden animate-scale-in">
<div className="bg-gradient-to-r from-green-500 to-green-600 px-6 py-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<FaFileExport className="text-white text-xl" />
<h3 className="text-lg font-semibold text-white">Export Issues</h3>
</div>
<button onClick={onClose} className="text-white/80 hover:text-white transition-colors" disabled={isExporting}>
<FaTimes size={20} />
</button>
</div>
</div>
<div className="p-6 overflow-y-auto max-h-[calc(90vh-180px)]">
<div className="mb-6">
<h4 className="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-3">Select Data to Export</h4>
<div className="space-y-2">
<label className={`flex items-center gap-3 p-3 rounded-lg border cursor-pointer transition-all ${scope === 'selected' ? 'border-green-500 bg-green-50 dark:bg-green-900/20' : 'border-gray-200 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-700/50'} ${selectedCount === 0 ? 'opacity-50 cursor-not-allowed' : ''}`}>
<input type="radio" name="scope" value="selected" checked={scope === 'selected'} onChange={() => setScope('selected')} disabled={selectedCount === 0} className="text-green-600 focus:ring-green-500" />
<div className="flex-1">
<div className="font-medium text-gray-900 dark:text-white">Selected Rows</div>
<div className="text-sm text-gray-500 dark:text-gray-400">Export {selectedCount} selected issue{selectedCount !== 1 ? 's' : ''}</div>
</div>
{selectedCount > 0 && <span className="bg-green-100 dark:bg-green-900/40 text-green-700 dark:text-green-300 px-2 py-1 rounded text-xs font-medium">{selectedCount} selected</span>}
</label>
<label className={`flex items-center gap-3 p-3 rounded-lg border cursor-pointer transition-all ${scope === 'all_on_page' ? 'border-green-500 bg-green-50 dark:bg-green-900/20' : 'border-gray-200 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-700/50'}`}>
<input type="radio" name="scope" value="all_on_page" checked={scope === 'all_on_page'} onChange={() => setScope('all_on_page')} className="text-green-600 focus:ring-green-500" />
<div className="flex-1">
<div className="font-medium text-gray-900 dark:text-white">Current Page</div>
<div className="text-sm text-gray-500 dark:text-gray-400">Export {pageCount} issue{pageCount !== 1 ? 's' : ''} on current page</div>
</div>
<span className="bg-blue-100 dark:bg-blue-900/40 text-blue-700 dark:text-blue-300 px-2 py-1 rounded text-xs font-medium">{pageCount} rows</span>
</label>
<label className={`flex items-center gap-3 p-3 rounded-lg border cursor-pointer transition-all ${scope === 'all_with_filters' ? 'border-green-500 bg-green-50 dark:bg-green-900/20' : 'border-gray-200 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-700/50'}`}>
<input type="radio" name="scope" value="all_with_filters" checked={scope === 'all_with_filters'} onChange={() => setScope('all_with_filters')} className="text-green-600 focus:ring-green-500" />
<div className="flex-1">
<div className="font-medium text-gray-900 dark:text-white">All Records (with current filters)</div>
<div className="text-sm text-gray-500 dark:text-gray-400">Export all {totalCount} issue{totalCount !== 1 ? 's' : ''} matching current filters</div>
</div>
<span className="bg-purple-100 dark:bg-purple-900/40 text-purple-700 dark:text-purple-300 px-2 py-1 rounded text-xs font-medium">{totalCount} total</span>
</label>
</div>
</div>
<div className="mb-6">
<h4 className="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-3">Export Format</h4>
<div className="flex gap-3">
<label className={`flex-1 flex items-center gap-3 p-3 rounded-lg border cursor-pointer transition-all ${format === 'csv' ? 'border-green-500 bg-green-50 dark:bg-green-900/20' : 'border-gray-200 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-700/50'}`}>
<input type="radio" name="format" value="csv" checked={format === 'csv'} onChange={() => setFormat('csv')} className="text-green-600 focus:ring-green-500" />
<FaFileCsv className="text-green-600 text-xl" />
<div>
<div className="font-medium text-gray-900 dark:text-white">CSV</div>
<div className="text-xs text-gray-500 dark:text-gray-400">Comma-separated values</div>
</div>
</label>
<label className={`flex-1 flex items-center gap-3 p-3 rounded-lg border cursor-pointer transition-all ${format === 'excel' ? 'border-green-500 bg-green-50 dark:bg-green-900/20' : 'border-gray-200 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-700/50'}`}>
<input type="radio" name="format" value="excel" checked={format === 'excel'} onChange={() => setFormat('excel')} className="text-green-600 focus:ring-green-500" />
<FaFileExcel className="text-green-700 text-xl" />
<div>
<div className="font-medium text-gray-900 dark:text-white">Excel</div>
<div className="text-xs text-gray-500 dark:text-gray-400">XLSX spreadsheet</div>
</div>
</label>
</div>
</div>
<div>
<div className="flex items-center justify-between mb-3">
<h4 className="text-sm font-semibold text-gray-700 dark:text-gray-300">Columns to Export</h4>
<div className="flex gap-2">
<button onClick={selectAllColumns} className="text-xs text-blue-600 dark:text-blue-400 hover:underline">Select All</button>
<span className="text-gray-300 dark:text-gray-600">|</span>
<button onClick={selectDefaultColumns} className="text-xs text-blue-600 dark:text-blue-400 hover:underline">Reset to Default</button>
</div>
</div>
<div className="grid grid-cols-2 md:grid-cols-3 gap-2 max-h-48 overflow-y-auto p-3 bg-gray-50 dark:bg-gray-900/50 rounded-lg">
{exportColumns.map((col) => (
<label key={col.key} className={`flex items-center gap-2 p-2 rounded cursor-pointer transition-all ${selectedColumns.includes(col.key) ? 'bg-green-100 dark:bg-green-900/30 text-green-800 dark:text-green-300' : 'hover:bg-gray-100 dark:hover:bg-gray-800 text-gray-700 dark:text-gray-400'}`}>
<input type="checkbox" checked={selectedColumns.includes(col.key)} onChange={() => toggleColumn(col.key)} className="rounded text-green-600 focus:ring-green-500" />
<span className="text-sm truncate">{col.label}</span>
</label>
))}
</div>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-2">{selectedColumns.length} column{selectedColumns.length !== 1 ? 's' : ''} selected</p>
</div>
</div>
<div className="px-6 py-4 bg-gray-50 dark:bg-gray-900/50 border-t border-gray-200 dark:border-gray-700 flex justify-between items-center">
<div className="text-sm text-gray-600 dark:text-gray-400">
{scope === 'selected' && `Exporting ${selectedCount} selected row${selectedCount !== 1 ? 's' : ''}`}
{scope === 'all_on_page' && `Exporting ${pageCount} row${pageCount !== 1 ? 's' : ''} from current page`}
{scope === 'all_with_filters' && `Exporting all ${totalCount} row${totalCount !== 1 ? 's' : ''}`}
</div>
<div className="flex gap-3">
<button onClick={onClose} className="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors" disabled={isExporting}>Cancel</button>
<button onClick={() => onExport(scope, format, selectedColumns)} disabled={selectedColumns.length === 0 || isExporting} className="px-4 py-2 text-sm font-medium text-white bg-green-600 hover:bg-green-700 rounded-lg transition-colors flex items-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed">
{isExporting ? (<><div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white"></div>Exporting...</>) : (<><FaDownload />Export</>)}
</button>
</div>
</div>
</div>
</div>
);
};
// Status badge colors
const getStatusStyle = (status: string) => {
switch (status?.toLowerCase()) {
case 'open': return 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300';
case 'replied': return 'bg-purple-100 text-purple-800 dark:bg-purple-900/30 dark:text-purple-300';
case 'on hold': return 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-300';
case 'resolved': return 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300';
case 'closed': return 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300';
default: return 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300';
}
};
// Priority badge colors
const getPriorityStyle = (priority: string) => {
switch (priority?.toLowerCase()) {
case 'high': return 'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-300';
case 'medium': return 'bg-orange-100 text-orange-800 dark:bg-orange-900/30 dark:text-orange-300';
case 'low': return 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300';
default: return 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300';
}
};
const IssueList: React.FC = () => {
const { t } = useTranslation();
const navigate = useNavigate();
const EXPORT_COLUMNS = [
{ key: 'name', label: t('issues.issueId'), default: true },
{ key: 'subject', label: t('issues.subject'), default: true },
{ key: 'status', label: t('commonFields.status'), default: true },
{ key: 'priority', label: t('commonFields.priority'), default: true },
{ key: 'raised_by', label: t('issues.raisedBy'), default: true },
{ key: 'company', label: t('commonFields.company'), default: true },
{ key: 'contact', label: t('issues.contact'), default: false },
{ key: 'issue_type', label: t('issues.issueType'), default: false },
{ key: 'opening_date', label: t('issues.openingDate'), default: true },
{ key: 'resolution_date', label: t('issues.resolutionDate'), default: false },
{ key: 'resolution_by', label: t('issues.resolvedBy'), default: false },
{ key: 'first_responded_on', label: t('issues.firstRespondedOn'), default: false },
{ key: 'description', label: t('commonFields.description'), default: false },
{ key: 'resolution_details', label: t('issues.resolutionDetails'), default: false },
{ key: 'creation', label: t('commonFields.createdOn'), default: false },
{ key: 'modified', label: t('commonFields.modifiedOn'), default: false },
{ key: 'owner', label: t('commonFields.createdBy'), default: false },
];
const {
sortBy,
dateFilterBy,
dateStart,
dateEnd,
page,
setSortBy,
setDateFilterBy,
setDateStart,
setDateEnd,
setPage,
resetPage,
} = useListSortFilters('creation desc');
const pageSize = 20;
const [initialLoadComplete, setInitialLoadComplete] = useState(false);
const [selectedRows, setSelectedRows] = useState<Set<string>>(new Set());
const [showExportModal, setShowExportModal] = useState(false);
const [isExporting, setIsExporting] = useState(false);
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState<string | null>(null);
const [statusFilter, setStatusFilter] = useState<string>('');
const [priorityFilter, setPriorityFilter] = useState<string>('');
const [companyFilter, setCompanyFilter] = useState<string>('');
const [issueIdFilter, setIssueIdFilter] = useState<string>('');
const [isFilterExpanded, setIsFilterExpanded] = useState(false);
const [activeFilterCount, setActiveFilterCount] = useState(0);
const [savedFilters, setSavedFilters] = useState<any[]>([]);
const [showSaveFilterModal, setShowSaveFilterModal] = useState(false);
const [filterPresetName, setFilterPresetName] = useState('');
useEffect(() => {
const saved = localStorage.getItem('issueFilterPresets');
if (saved) setSavedFilters(JSON.parse(saved));
}, []);
useEffect(() => {
const count = [statusFilter, priorityFilter, companyFilter, issueIdFilter].filter(Boolean).length;
setActiveFilterCount(count);
}, [statusFilter, priorityFilter, companyFilter, issueIdFilter]);
const apiFilters = useMemo(
() =>
applyDateRangeToFilters(
{
...(statusFilter ? { status: statusFilter } : {}),
...(priorityFilter ? { priority: priorityFilter } : {}),
...(companyFilter ? { company: companyFilter } : {}),
...(issueIdFilter ? { name: issueIdFilter } : {}),
},
dateFilterBy,
dateStart,
dateEnd
),
[statusFilter, priorityFilter, companyFilter, issueIdFilter, dateFilterBy, dateStart, dateEnd]
);
const { issues, loading, error, totalCount, refetch } = useIssueList({
filters: apiFilters,
limit_start: page * pageSize,
limit_page_length: pageSize,
order_by: sortBy,
});
useEffect(() => { if (!loading && !initialLoadComplete) setInitialLoadComplete(true); }, [loading, initialLoadComplete]);
useEffect(() => { resetPage(); /* eslint-disable-next-line react-hooks/exhaustive-deps */ }, [statusFilter, priorityFilter, companyFilter, issueIdFilter, sortBy, dateFilterBy, dateStart, dateEnd]);
useEffect(() => { setSelectedRows(new Set()); }, [statusFilter, priorityFilter, companyFilter, issueIdFilter, page]);
const hasMore = (page + 1) * pageSize < totalCount;
const formatDate = (dateStr: string) => dateStr ? new Date(dateStr).toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' }) : '-';
const clearFilters = () => { setStatusFilter(''); setPriorityFilter(''); setCompanyFilter(''); setIssueIdFilter(''); resetPage(); };
const hasActiveFilters = statusFilter || priorityFilter || companyFilter || issueIdFilter;
const handleSaveFilterPreset = () => {
if (!filterPresetName.trim()) { alert('Please enter a filter name'); return; }
const preset = { id: Date.now(), name: filterPresetName, filters: { statusFilter, priorityFilter, companyFilter, issueIdFilter } };
const updated = [...savedFilters, preset];
setSavedFilters(updated);
setFilterPresetName('');
setShowSaveFilterModal(false);
localStorage.setItem('issueFilterPresets', JSON.stringify(updated));
};
const handleLoadFilterPreset = (preset: any) => {
const f = preset.filters;
setStatusFilter(f.statusFilter || ''); setPriorityFilter(f.priorityFilter || '');
setCompanyFilter(f.companyFilter || ''); setIssueIdFilter(f.issueIdFilter || '');
};
const handleDeleteFilterPreset = (id: number) => {
const updated = savedFilters.filter(f => f.id !== id);
setSavedFilters(updated);
localStorage.setItem('issueFilterPresets', JSON.stringify(updated));
};
const handleSelectRow = (issueName: string) => {
setSelectedRows(prev => { const newSet = new Set(prev); newSet.has(issueName) ? newSet.delete(issueName) : newSet.add(issueName); return newSet; });
};
const handleSelectAll = () => { selectedRows.size === issues.length ? setSelectedRows(new Set()) : setSelectedRows(new Set(issues.map(i => i.name))); };
const isAllSelected = issues.length > 0 && selectedRows.size === issues.length;
const isSomeSelected = selectedRows.size > 0 && selectedRows.size < issues.length;
const fetchAllIssuesForExport = useCallback(async (): Promise<any[]> => {
const allIssues: any[] = [];
let currentPageNum = 0;
const pageSizeNum = 100;
let hasMoreData = true;
while (hasMoreData) {
try {
const response = await fetch('/api/method/frappe.client.get_list', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ doctype: 'Issue', filters: apiFilters, fields: ['*'], limit_start: currentPageNum * pageSizeNum, limit_page_length: pageSizeNum, order_by: sortBy })
});
const data = await response.json();
const results = data.message || [];
allIssues.push(...results);
if (results.length < pageSizeNum) hasMoreData = false; else currentPageNum++;
if (currentPageNum > 100) { console.warn('Export safety limit reached'); hasMoreData = false; }
} catch (error) { console.error('Error fetching issues for export:', error); throw error; }
}
return allIssues;
}, [apiFilters]);
const handleExport = async (scope: ExportScope, format: ExportFormat, columns: string[]) => {
setIsExporting(true);
try {
let dataToExport: any[] = [];
switch (scope) {
case 'selected': dataToExport = issues.filter(i => selectedRows.has(i.name)); break;
case 'all_on_page': dataToExport = issues; break;
case 'all_with_filters': dataToExport = await fetchAllIssuesForExport(); break;
}
if (dataToExport.length === 0) { alert('No data to export'); return; }
const columnLabels = columns.map(key => EXPORT_COLUMNS.find(c => c.key === key)?.label || key);
if (format === 'csv') {
const csvContent = [columnLabels.join(','), ...dataToExport.map(issue => columns.map(key => { let value = issue[key] || ''; if (typeof value === 'string' && (value.includes(',') || value.includes('"') || value.includes('\n'))) value = `"${value.replace(/"/g, '""')}"`; return value; }).join(','))].join('\n');
const blob = new Blob(['\ufeff' + csvContent], { type: 'text/csv;charset=utf-8;' });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url; link.download = `issues_export_${new Date().toISOString().split('T')[0]}.csv`; link.click();
URL.revokeObjectURL(url);
} else if (format === 'excel') {
const worksheetData = [columnLabels, ...dataToExport.map(issue => columns.map(key => issue[key] || ''))];
const worksheet = XLSX.utils.aoa_to_sheet(worksheetData);
const workbook = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(workbook, worksheet, 'Issues');
XLSX.writeFile(workbook, `issues_export_${new Date().toISOString().split('T')[0]}.xlsx`);
}
setShowExportModal(false); setSelectedRows(new Set());
} catch (error) { console.error('Export failed:', error); alert(`Export failed: ${error instanceof Error ? error.message : 'Unknown error'}`); }
finally { setIsExporting(false); }
};
const handleDelete = async (issueName: string) => {
try {
const response = await fetch(`/api/resource/Issue/${issueName}`, { method: 'DELETE', headers: { 'Content-Type': 'application/json' } });
if (!response.ok) throw new Error('Failed to delete');
setDeleteConfirmOpen(null); refetch(); alert('Issue deleted successfully!');
} catch (err) { alert(`Failed to delete: ${err instanceof Error ? err.message : 'Unknown error'}`); }
};
if (loading && !initialLoadComplete) {
return (
<div className="flex items-center justify-center h-screen bg-gray-50 dark:bg-gray-900">
<div className="text-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-500 mx-auto"></div>
<p className="mt-4 text-gray-600 dark:text-gray-400">Loading issues...</p>
</div>
</div>
);
}
if (error) {
return (
<div className="p-4 sm:p-6 bg-gray-50 dark:bg-gray-900 min-h-screen min-w-0 overflow-x-hidden">
<div className="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-6">
<h2 className="text-xl font-bold text-red-800 dark:text-red-300 mb-4">Error Loading Issues</h2>
<p className="text-red-700 dark:text-red-400 mb-4">{error}</p>
<button onClick={refetch} className="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded">Try Again</button>
</div>
</div>
);
}
return (
<div className="p-4 sm:p-6 bg-gray-50 dark:bg-gray-900 min-h-screen min-w-0 overflow-x-hidden">
{/* Header */}
<div className="mb-6 flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between lg:items-center">
<div>
<div className="flex items-center gap-3">
<FaHeadset className="text-3xl text-blue-600 dark:text-blue-400" />
<div>
<h1 className="text-2xl font-bold text-gray-800 dark:text-white">Support Issues</h1>
<p className="text-sm text-gray-500 dark:text-gray-400">
Total: {totalCount} issue{totalCount !== 1 ? 's' : ''}
{selectedRows.size > 0 && <span className="ml-2 text-blue-600 dark:text-blue-400"> {selectedRows.size} selected</span>}
{loading && initialLoadComplete && <span className="ml-2 inline-flex items-center gap-1 text-xs text-blue-600 dark:text-blue-400"><div className="animate-spin rounded-full h-3 w-3 border-b-2 border-blue-500"></div>Updating...</span>}
</p>
</div>
</div>
</div>
<div className="flex gap-3">
<button onClick={() => setIsFilterExpanded(!isFilterExpanded)} className={`px-4 py-2 rounded-lg flex items-center gap-2 transition-colors ${isFilterExpanded || hasActiveFilters ? 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-300' : 'bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-600'}`}>
<FaFilter />Filters
{activeFilterCount > 0 && <span className="bg-blue-600 text-white text-xs px-1.5 py-0.5 rounded-full">{activeFilterCount}</span>}
</button>
<button onClick={refetch} disabled={loading} className="px-4 py-2 rounded-lg bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-600 flex items-center gap-2 disabled:opacity-50">
<FaSync className={loading ? 'animate-spin' : ''} />Refresh
</button>
<button onClick={() => setShowExportModal(true)} className="bg-green-600 hover:bg-green-700 text-white px-4 py-2 rounded-lg flex items-center gap-2 shadow transition-all" disabled={totalCount === 0}>
<FaFileExport /><span className="font-medium">Export</span>
{selectedRows.size > 0 && <span className="bg-white/20 px-1.5 py-0.5 rounded text-xs">{selectedRows.size}</span>}
</button>
<button onClick={() => navigate('/support/new')} className="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-lg flex items-center gap-2 shadow-lg transition-all hover:shadow-xl">
<FaPlus /><span className="font-medium">New Issue</span>
</button>
</div>
</div>
{/* Stats Cards */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-4">
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-4 border border-gray-200 dark:border-gray-700">
<div className="flex items-center justify-between"><div><p className="text-sm text-gray-500 dark:text-gray-400">Total Issues</p><p className="text-2xl font-bold text-gray-800 dark:text-white">{totalCount}</p></div><FaExclamationCircle className="text-3xl text-blue-500" /></div>
</div>
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-4 border border-gray-200 dark:border-gray-700">
<div className="flex items-center justify-between"><div><p className="text-sm text-gray-500 dark:text-gray-400">Open</p><p className="text-2xl font-bold text-blue-600">{issues.filter(i => i.status === 'Open').length}</p></div><FaClock className="text-3xl text-blue-500" /></div>
</div>
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-4 border border-gray-200 dark:border-gray-700">
<div className="flex items-center justify-between"><div><p className="text-sm text-gray-500 dark:text-gray-400">Resolved</p><p className="text-2xl font-bold text-green-600">{issues.filter(i => i.status === 'Resolved').length}</p></div><FaCheckCircle className="text-3xl text-green-500" /></div>
</div>
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-4 border border-gray-200 dark:border-gray-700">
<div className="flex items-center justify-between"><div><p className="text-sm text-gray-500 dark:text-gray-400">Closed</p><p className="text-2xl font-bold text-gray-600 dark:text-gray-300">{issues.filter(i => i.status === 'Closed').length}</p></div><FaTimesCircle className="text-3xl text-gray-500" /></div>
</div>
</div>
{/* Expandable Filter Panel */}
{isFilterExpanded && (
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-lg border border-gray-200 dark:border-gray-700 mb-4">
<div className="bg-gradient-to-r from-blue-500 to-blue-600 dark:from-blue-600 dark:to-blue-700 px-4 py-3 rounded-t-lg">
<div className="flex items-center justify-between gap-4">
<div className="flex items-center gap-3">
<FaFilter className="text-white" size={16} /><h3 className="text-white font-semibold text-sm">Filters</h3>
{activeFilterCount > 0 && <span className="bg-white text-blue-600 px-2 py-0.5 rounded-full text-xs font-bold">{activeFilterCount}</span>}
</div>
{hasActiveFilters && (
<div className="flex-1 overflow-x-auto scrollbar-hide mx-2">
<div className="flex items-center gap-2 py-1">
{issueIdFilter && <span className="inline-flex items-center gap-1.5 px-2.5 py-1 bg-white/90 text-blue-700 rounded-full text-[10px] font-medium whitespace-nowrap shadow-sm"><span className="font-semibold">Issue:</span> {issueIdFilter}<button onClick={() => setIssueIdFilter('')} className="hover:text-red-500"><FaTimes className="text-[9px]" /></button></span>}
{statusFilter && <span className="inline-flex items-center gap-1.5 px-2.5 py-1 bg-white/90 text-green-700 rounded-full text-[10px] font-medium whitespace-nowrap shadow-sm"><span className="font-semibold">Status:</span> {statusFilter}<button onClick={() => setStatusFilter('')} className="hover:text-red-500"><FaTimes className="text-[9px]" /></button></span>}
{priorityFilter && <span className="inline-flex items-center gap-1.5 px-2.5 py-1 bg-white/90 text-orange-700 rounded-full text-[10px] font-medium whitespace-nowrap shadow-sm"><span className="font-semibold">Priority:</span> {priorityFilter}<button onClick={() => setPriorityFilter('')} className="hover:text-red-500"><FaTimes className="text-[9px]" /></button></span>}
{companyFilter && <span className="inline-flex items-center gap-1.5 px-2.5 py-1 bg-white/90 text-purple-700 rounded-full text-[10px] font-medium whitespace-nowrap shadow-sm"><span className="font-semibold">Company:</span> {companyFilter}<button onClick={() => setCompanyFilter('')} className="hover:text-red-500"><FaTimes className="text-[9px]" /></button></span>}
</div>
</div>
)}
<div className="flex items-center gap-2 flex-shrink-0">
{activeFilterCount > 0 && <button onClick={() => setShowSaveFilterModal(true)} className="px-3 py-1.5 bg-white text-blue-600 hover:bg-blue-50 rounded-md text-xs font-medium transition-all flex items-center gap-1.5"><FaSave size={12} /><span className="hidden sm:inline">Save</span></button>}
{hasActiveFilters && <button onClick={clearFilters} className="px-3 py-1.5 bg-red-500 hover:bg-red-600 text-white rounded-md text-xs font-medium transition-all flex items-center gap-1.5"><FaTimes size={12} /><span className="hidden sm:inline">Clear</span></button>}
</div>
</div>
</div>
<div className="p-4">
{savedFilters.length > 0 && (
<div className="mb-4 pb-4 border-b border-gray-200 dark:border-gray-700">
<h4 className="text-xs font-semibold text-gray-700 dark:text-gray-300 mb-2 flex items-center gap-2"><FaStar className="text-yellow-500" size={12} />Saved Filters</h4>
<div className="flex flex-wrap gap-2">
{savedFilters.map((preset) => (
<div key={preset.id} className="group relative inline-flex items-center gap-2 px-3 py-1.5 bg-gradient-to-r from-purple-100 to-blue-100 dark:from-purple-900/30 dark:to-blue-900/30 border border-purple-200 dark:border-purple-700 rounded-lg hover:shadow-md transition-all">
<button onClick={() => handleLoadFilterPreset(preset)} className="text-xs font-medium text-purple-700 dark:text-purple-300">{preset.name}</button>
<button onClick={() => handleDeleteFilterPreset(preset.id)} className="opacity-0 group-hover:opacity-100 text-red-500 hover:text-red-700 transition-opacity"><FaTrash size={10} /></button>
</div>
))}
</div>
</div>
)}
<div className="bg-gray-50 dark:bg-gray-900/50 p-3 rounded-lg">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-3">
<ListFilterSortControls
sortBy={sortBy}
dateFilterBy={dateFilterBy}
dateStart={dateStart}
dateEnd={dateEnd}
onSortByChange={setSortBy}
onDateFilterByChange={setDateFilterBy}
onDateStartChange={setDateStart}
onDateEndChange={setDateEnd}
/>
<div className="relative z-[60]">
<LinkField label="Issue" doctype="Issue" value={issueIdFilter} onChange={(val) => { setIssueIdFilter(val); resetPage(); }} placeholder="Select Issue" disabled={false} compact={true} />
{issueIdFilter && <button onClick={() => setIssueIdFilter('')} className="absolute right-2 top-6 text-gray-400 hover:text-red-500 transition-colors z-10"><FaTimes size={10} /></button>}
</div>
<div className="relative">
<label className="block text-[10px] font-medium text-gray-700 dark:text-gray-300 mb-0.5">Status</label>
<select value={statusFilter} onChange={(e) => { setStatusFilter(e.target.value); resetPage(); }} className="w-full px-2 py-1 text-xs border border-gray-300 dark:border-gray-600 rounded focus:outline-none focus:ring-2 focus:ring-blue-500 bg-white dark:bg-gray-700 text-gray-900 dark:text-white">
<option value="">All Statuses</option><option value="Open">Open</option><option value="Replied">Replied</option><option value="On Hold">On Hold</option><option value="Resolved">Resolved</option><option value="Closed">Closed</option>
</select>
</div>
<div className="relative z-[59]">
<LinkField label={t('commonFields.priority')} doctype="Issue Priority" value={priorityFilter} onChange={(val) => { setPriorityFilter(val); resetPage(); }} placeholder={t('issues.allPriorities')} disabled={false} compact={true} />
{priorityFilter && <button onClick={() => setPriorityFilter('')} className="absolute right-2 top-6 text-gray-400 hover:text-red-500 transition-colors z-10"><FaTimes size={10} /></button>}
</div>
<div className="relative z-[58]">
<LinkField label={t('commonFields.company')} doctype="Company" value={companyFilter} onChange={(val) => { setCompanyFilter(val); resetPage(); }} placeholder={t('issues.allCompanies')} disabled={false} compact={true} />
{companyFilter && <button onClick={() => setCompanyFilter('')} className="absolute right-2 top-6 text-gray-400 hover:text-red-500 transition-colors z-10"><FaTimes size={10} /></button>}
</div>
</div>
</div>
</div>
</div>
)}
{/* Save Filter Modal */}
{showSaveFilterModal && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-xl max-w-md w-full p-6 animate-scale-in">
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-4">Save Filter Preset</h3>
<input type="text" value={filterPresetName} onChange={(e) => setFilterPresetName(e.target.value)} onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); handleSaveFilterPreset(); } }} placeholder="Enter filter name (e.g., 'Open High Priority')" className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 bg-white dark:bg-gray-700 text-gray-900 dark:text-white mb-4" autoFocus />
<div className="flex gap-2 justify-end">
<button onClick={() => { setShowSaveFilterModal(false); setFilterPresetName(''); }} className="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-md transition-colors">Cancel</button>
<button onClick={handleSaveFilterPreset} className="px-4 py-2 text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 rounded-md transition-colors flex items-center gap-2"><FaSave size={12} />Save Filter</button>
</div>
</div>
</div>
)}
{/* Export Modal */}
<ExportModal isOpen={showExportModal} onClose={() => setShowExportModal(false)} selectedCount={selectedRows.size} totalCount={totalCount} pageCount={issues.length} onExport={handleExport} isExporting={isExporting} exportColumns={EXPORT_COLUMNS} />
{/* Table */}
<div className="bg-white dark:bg-gray-800 rounded-lg shadow border border-gray-200 dark:border-gray-700 overflow-hidden relative">
{loading && initialLoadComplete && (
<div className="absolute inset-0 bg-white/60 dark:bg-gray-800/60 flex items-center justify-center z-10 backdrop-blur-[1px]">
<div className="flex items-center gap-3 bg-white dark:bg-gray-700 px-4 py-2 rounded-lg shadow-lg"><div className="animate-spin rounded-full h-5 w-5 border-b-2 border-blue-500"></div><span className="text-sm text-gray-600 dark:text-gray-300">Filtering...</span></div>
</div>
)}
<div className="overflow-x-auto">
<table className="w-full">
<thead className="bg-gray-100 dark:bg-gray-700 border-b border-gray-200 dark:border-gray-600">
<tr>
<th className="px-4 py-3 text-left">
<button onClick={handleSelectAll} className="text-gray-500 dark:text-gray-400 hover:text-blue-600 dark:hover:text-blue-400 transition-colors" title={isAllSelected ? t('listPages.deselectAllTitle') : t('listPages.selectAllTitle')}>
{isAllSelected ? <FaCheckSquare className="text-blue-600 dark:text-blue-400" size={18} /> : isSomeSelected ? <div className="relative"><FaSquare size={18} /><div className="absolute inset-0 flex items-center justify-center"><div className="w-2 h-0.5 bg-current"></div></div></div> : <FaSquare size={18} />}
</button>
</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">{t('issues.issueId')}</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">{t('issues.subject')}</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">{t('commonFields.status')}</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">{t('commonFields.priority')}</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">{t('commonFields.company')}</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">{t('issues.openingDate')}</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">{t('listPages.actions')}</th>
</tr>
</thead>
<tbody className="bg-white dark:bg-gray-800 divide-y divide-gray-200 dark:divide-gray-700">
{issues.length === 0 ? (
<tr><td colSpan={8} className="px-4 py-12 text-center text-gray-500 dark:text-gray-400">
<div className="flex flex-col items-center"><FaHeadset className="text-4xl text-gray-300 dark:text-gray-600 mb-2" /><p>No issues found</p>
{hasActiveFilters ? <button onClick={clearFilters} className="mt-4 text-blue-600 dark:text-blue-400 hover:underline">Clear filters</button> : <button onClick={() => navigate('/support/new')} className="mt-4 text-blue-600 dark:text-blue-400 hover:underline">Create your first issue</button>}
</div>
</td></tr>
) : issues.map((issue) => (
<tr key={issue.name} className={`hover:bg-gray-50 dark:hover:bg-gray-700/50 cursor-pointer transition-colors ${selectedRows.has(issue.name) ? 'bg-blue-50 dark:bg-blue-900/20' : ''}`} onClick={() => navigate(`/support/${issue.name}`)}>
<td className="px-4 py-3" onClick={(e) => e.stopPropagation()}>
<button onClick={() => handleSelectRow(issue.name)} className="text-gray-500 dark:text-gray-400 hover:text-blue-600 dark:hover:text-blue-400 transition-colors">
{selectedRows.has(issue.name) ? <FaCheckSquare className="text-blue-600 dark:text-blue-400" size={18} /> : <FaSquare size={18} />}
</button>
</td>
<td className="px-4 py-3"><span className="text-sm font-medium text-blue-600 dark:text-blue-400">{issue.name}</span></td>
<td className="px-4 py-3"><span className="text-sm text-gray-900 dark:text-white line-clamp-1">{issue.subject || '-'}</span></td>
<td className="px-4 py-3"><span className={`inline-flex px-2 py-1 text-xs font-medium rounded-full ${getStatusStyle(issue.status)}`}>{issue.status || '-'}</span></td>
<td className="px-4 py-3">{issue.priority ? <span className={`inline-flex px-2 py-1 text-xs font-medium rounded-full ${getPriorityStyle(issue.priority)}`}>{issue.priority}</span> : <span className="text-gray-400">-</span>}</td>
<td className="px-4 py-3"><span className="text-sm text-gray-600 dark:text-gray-300 line-clamp-1">{issue.company || '-'}</span></td>
<td className="px-4 py-3"><span className="text-sm text-gray-600 dark:text-gray-300">{formatDate(issue.opening_date)}</span></td>
<td className="px-4 py-3">
<div className="flex items-center gap-1" onClick={(e) => e.stopPropagation()}>
<button onClick={() => navigate(`/support/${issue.name}`)} className="text-blue-600 dark:text-blue-400 hover:text-blue-900 dark:hover:text-blue-300 p-2 hover:bg-blue-50 dark:hover:bg-blue-900/30 rounded transition-colors" title={t('issues.viewDetails')}><FaEye /></button>
<button onClick={() => navigate(`/support/${issue.name}`)} className="text-green-600 dark:text-green-400 hover:text-green-900 dark:hover:text-green-300 p-2 hover:bg-green-50 dark:hover:bg-green-900/30 rounded transition-colors" title={t('issues.editIssue')}><FaEdit /></button>
<button onClick={() => setDeleteConfirmOpen(issue.name)} className="text-red-600 dark:text-red-400 hover:text-red-900 dark:hover:text-red-300 p-2 hover:bg-red-50 dark:hover:bg-red-900/30 rounded transition-colors" title={t('issues.deleteIssue')}><FaTrash /></button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
<ListPagination
currentPage={page + 1}
totalCount={totalCount}
pageSize={pageSize}
hasMore={hasMore}
itemLabel={t('pagination.issues')}
onPageChange={(p) => setPage(Math.max(0, p - 1))}
/>
</div>
{/* Delete Confirmation Modal */}
{deleteConfirmOpen && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white dark:bg-gray-800 rounded-lg p-6 max-w-md w-full mx-4 shadow-2xl">
<div className="flex items-start gap-4">
<div className="flex-shrink-0 w-12 h-12 rounded-full bg-red-100 dark:bg-red-900/30 flex items-center justify-center"><FaTrash className="text-red-600 dark:text-red-400 text-xl" /></div>
<div className="flex-1">
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-2">Delete Issue</h3>
<p className="text-sm text-gray-600 dark:text-gray-400 mb-4">Are you sure you want to delete this issue? This action cannot be undone.</p>
<div className="bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-md p-3 mb-4"><p className="text-xs text-yellow-800 dark:text-yellow-300"><strong>Issue ID:</strong> {deleteConfirmOpen}</p></div>
<div className="flex gap-3 justify-end">
<button onClick={() => setDeleteConfirmOpen(null)} className="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 rounded-lg transition-colors">Cancel</button>
<button onClick={() => handleDelete(deleteConfirmOpen)} className="px-4 py-2 text-sm font-medium text-white bg-red-600 hover:bg-red-700 rounded-lg transition-colors flex items-center gap-2"><FaTrash />Delete Issue</button>
</div>
</div>
</div>
</div>
</div>
)}
<style>{`
@keyframes scale-in { from { transform: scale(0.95); opacity: 0; } to { transform: scale(1); opacity: 1; } }
.animate-scale-in { animation: scale-in 0.2s ease-out; }
.scrollbar-hide { -ms-overflow-style: none; scrollbar-width: none; }
.scrollbar-hide::-webkit-scrollbar { display: none; }
`}</style>
</div>
);
};
export default IssueList;

View File

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

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