Initial commit of Asm UI app

This commit is contained in:
Duradundi Hadimani 2026-03-23 17:43:17 +05:30
commit 2571aa6996
161 changed files with 75779 additions and 0 deletions

7
.gitignore vendored Normal file
View File

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

1360
ItemList.tsx Normal file

File diff suppressed because it is too large Load Diff

7
README.md Normal file
View File

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

24
asm_app/.gitignore vendored Normal file
View File

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

73
asm_app/README.md Normal file
View File

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

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

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

16
asm_app/index.html Normal file
View File

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

4160
asm_app/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

49
asm_app/package.json Normal file
View File

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

View File

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

13
asm_app/proxyOptions.ts Normal file
View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 395 KiB

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

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

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

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

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

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

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

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

View File

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

View File

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

After

Width:  |  Height:  |  Size: 4.0 KiB

View File

@ -0,0 +1,450 @@
import React, { useState } from 'react';
import {
FaHistory,
FaSync,
FaChevronDown,
FaChevronUp,
FaUser,
FaClock,
FaCheckCircle,
FaSpinner,
} from 'react-icons/fa';
import { useAuditLogs } from '../hooks/useAuditLogs';
import type { AuditLogEntry, VersionChange } from '../hooks/useAuditLogs';
// ============== PROPS ==============
interface ActivityLogProps {
/** Frappe DocType name (e.g. 'Asset', 'Inspection', 'Work_Order') */
doctype: string;
/** Document name / ID */
docname: string | null;
/** Document creation date (for "Created" entry at bottom) */
creationDate?: string;
/** Document owner/creator email */
createdBy?: string;
/** Title shown in header */
title?: string;
/** Max entries to fetch */
limit?: number;
/** Number of entries visible before "Show All" */
initialVisible?: number;
/** Allow collapse/expand */
collapsible?: boolean;
/** Start collapsed */
startCollapsed?: boolean;
/** Compact mode for sidebar placement */
compact?: boolean;
/** Additional CSS class */
className?: string;
/** Callback after refresh */
onRefresh?: () => void;
}
// ============== HELPER FUNCTIONS ==============
const formatFieldName = (fieldName: string): string => {
if (!fieldName) return '';
return fieldName
.replace(/^custom_/, '')
.replace(/_/g, ' ')
.replace(/\b\w/g, (char) => char.toUpperCase());
};
const formatValue = (value: any): string => {
if (value === null || value === undefined) return '(empty)';
if (value === '') return '(empty)';
if (value === 0) return '0';
if (value === 1) return '1';
if (typeof value === 'boolean') return value ? 'Yes' : 'No';
if (typeof value === 'object') return JSON.stringify(value);
return String(value);
};
const formatAuditDate = (dateStr: string): string => {
if (!dateStr) return '';
const date = new Date(dateStr);
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffMins = Math.floor(diffMs / 60000);
const diffHours = Math.floor(diffMs / 3600000);
const diffDays = Math.floor(diffMs / 86400000);
if (diffMins < 1) return 'Just now';
if (diffMins < 60) return `${diffMins} min${diffMins > 1 ? 's' : ''} ago`;
if (diffHours < 24) return `${diffHours} hour${diffHours > 1 ? 's' : ''} ago`;
if (diffDays < 7) return `${diffDays} day${diffDays > 1 ? 's' : ''} ago`;
return date.toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: date.getFullYear() !== now.getFullYear() ? 'numeric' : undefined,
hour: '2-digit',
minute: '2-digit',
});
};
const formatUsername = (email: string): string => {
if (!email) return 'Unknown';
const atIndex = email.indexOf('@');
if (atIndex === -1) return email;
return email.substring(0, atIndex);
};
const getChangeColor = (fieldName: string): string => {
const lower = fieldName.toLowerCase();
if (lower.includes('status') || lower.includes('state') || lower.includes('workflow')) {
return 'text-purple-600 dark:text-purple-400';
}
if (lower.includes('date')) {
return 'text-blue-600 dark:text-blue-400';
}
if (
lower.includes('technician') ||
lower.includes('supervisor') ||
lower.includes('assigned') ||
lower.includes('location') ||
lower.includes('department') ||
lower.includes('building') ||
lower.includes('room')
) {
return 'text-green-600 dark:text-green-400';
}
return 'text-gray-600 dark:text-gray-400';
};
// ============== SUB-COMPONENTS ==============
/** Single timeline entry */
const TimelineEntry: React.FC<{
log: AuditLogEntry;
isLatest: boolean;
compact: boolean;
}> = ({ log, isLatest, compact }) => {
const dotSize = compact ? 'w-2.5 h-2.5' : 'w-3 h-3';
const avatarSize = compact ? 'w-5 h-5' : 'w-6 h-6';
const iconSize = compact ? 8 : 10;
const textSize = compact ? 'text-[10px]' : 'text-xs';
const valueSize = compact ? 'text-[9px]' : 'text-[10px]';
return (
<div className={`relative ${compact ? 'pl-6' : 'pl-8'}`}>
{/* Timeline dot */}
<div
className={`absolute ${compact ? 'left-1' : 'left-1.5'} top-1.5 ${dotSize} rounded-full border-2 border-white dark:border-gray-800 ${
isLatest ? 'bg-blue-500' : 'bg-gray-300 dark:bg-gray-600'
}`}
/>
{/* Entry content */}
<div
className={`${compact ? 'p-2' : 'p-3'} rounded-lg ${
isLatest
? 'bg-blue-50 dark:bg-blue-900/20 border border-blue-100 dark:border-blue-800/50'
: 'bg-gray-50 dark:bg-gray-700/50'
}`}
>
{/* Header */}
<div className="flex items-center justify-between mb-1.5">
<div className="flex items-center gap-1.5">
<div
className={`${avatarSize} rounded-full bg-gray-200 dark:bg-gray-600 flex items-center justify-center`}
>
<FaUser className="text-gray-500 dark:text-gray-400" size={iconSize} />
</div>
<span className={`${textSize} font-medium text-gray-700 dark:text-gray-300`}>
{formatUsername(log.owner)}
</span>
</div>
<div className={`flex items-center gap-1 ${textSize} text-gray-500 dark:text-gray-400`}>
<FaClock size={iconSize} />
<span title={new Date(log.creation).toLocaleString()}>
{formatAuditDate(log.creation)}
</span>
</div>
</div>
{/* Changes */}
<div className="space-y-1">
{log.changes.length > 0 ? (
log.changes.map((change, i) => (
<div key={i} className={textSize}>
<span className={`font-medium ${getChangeColor(change.field)}`}>
{formatFieldName(change.field)}
</span>
<span className="text-gray-500 dark:text-gray-400"> changed from </span>
<span
className={`px-1 py-0.5 bg-red-100 dark:bg-red-900/30 text-red-600 dark:text-red-400 rounded ${valueSize} font-mono`}
>
{formatValue(change.oldValue)}
</span>
<span className="text-gray-500 dark:text-gray-400"> </span>
<span
className={`px-1 py-0.5 bg-green-100 dark:bg-green-900/30 text-green-600 dark:text-green-400 rounded ${valueSize} font-mono`}
>
{formatValue(change.newValue)}
</span>
</div>
))
) : (
<p className={`${textSize} text-gray-500 dark:text-gray-400 italic`}>Document updated</p>
)}
{log.added && log.added.length > 0 && (
<div className={`${textSize} text-green-600 dark:text-green-400`}>
<span className="font-medium">Added:</span> {log.added.length} item(s)
</div>
)}
{log.removed && log.removed.length > 0 && (
<div className={`${textSize} text-red-600 dark:text-red-400`}>
<span className="font-medium">Removed:</span> {log.removed.length} item(s)
</div>
)}
{log.rowChanged && log.rowChanged.length > 0 && (
<div className={`${textSize} text-orange-600 dark:text-orange-400`}>
<span className="font-medium">Modified:</span> {log.rowChanged.length} row(s)
</div>
)}
</div>
</div>
</div>
);
};
/** "Created this document" entry */
const CreatedEntry: React.FC<{
creationDate: string;
createdBy: string;
doctype: string;
compact: boolean;
}> = ({ creationDate, createdBy, doctype, compact }) => {
const dotSize = compact ? 'w-2.5 h-2.5' : 'w-3 h-3';
const avatarSize = compact ? 'w-5 h-5' : 'w-6 h-6';
const iconSize = compact ? 8 : 10;
const textSize = compact ? 'text-[10px]' : 'text-xs';
// Clean doctype for display (e.g. "Work_Order" → "Work Order")
const displayDoctype = doctype.replace(/_/g, ' ');
return (
<div className={`relative ${compact ? 'pl-6' : 'pl-8'}`}>
<div
className={`absolute ${compact ? 'left-1' : 'left-1.5'} top-1.5 ${dotSize} rounded-full border-2 border-white dark:border-gray-800 bg-green-500`}
/>
<div
className={`${compact ? 'p-2' : 'p-3'} rounded-lg bg-green-50 dark:bg-green-900/20 border border-green-100 dark:border-green-800/50`}
>
<div className="flex items-center justify-between mb-1">
<div className="flex items-center gap-1.5">
<div
className={`${avatarSize} rounded-full bg-green-200 dark:bg-green-800 flex items-center justify-center`}
>
<FaUser className="text-green-600 dark:text-green-400" size={iconSize} />
</div>
<span className={`${textSize} font-medium text-gray-700 dark:text-gray-300`}>
{formatUsername(createdBy)}
</span>
</div>
<div className={`flex items-center gap-1 ${textSize} text-gray-500 dark:text-gray-400`}>
<FaClock size={iconSize} />
<span title={new Date(creationDate).toLocaleString()}>
{formatAuditDate(creationDate)}
</span>
</div>
</div>
<span
className={`inline-flex items-center gap-1 px-1.5 py-0.5 bg-green-100 dark:bg-green-800/50 text-green-700 dark:text-green-300 rounded ${textSize} font-medium`}
>
<FaCheckCircle size={iconSize} />
Created this {displayDoctype}
</span>
</div>
</div>
);
};
// ============== MAIN COMPONENT ==============
const ActivityLog: React.FC<ActivityLogProps> = ({
doctype,
docname,
creationDate,
createdBy,
title = 'Activity Log',
limit = 50,
initialVisible = 5,
collapsible = true,
startCollapsed = false,
compact = false,
className = '',
onRefresh,
}) => {
const [isExpanded, setIsExpanded] = useState(!startCollapsed);
const [showAll, setShowAll] = useState(false);
const { auditLogs, loading, refetch } = useAuditLogs({
doctype,
docname,
limit,
enabled: !!docname,
});
const handleRefresh = () => {
refetch();
onRefresh?.();
};
if (!docname) return null;
const headerIconSize = compact ? 14 : 16;
const headerTextClass = compact ? 'text-sm' : 'text-base';
const timelineLineLeft = compact ? 'left-2' : 'left-3';
const showMoreTextSize = compact ? 'text-[10px]' : 'text-xs';
const showMoreIconSize = compact ? 8 : 10;
const visibleLogs = showAll ? auditLogs : auditLogs.slice(0, initialVisible);
return (
<div
className={`bg-white dark:bg-gray-800 rounded-xl shadow-md border border-gray-200 dark:border-gray-700 overflow-hidden ${className}`}
>
{/* Header */}
<div className="flex items-center justify-between p-3 border-b border-gray-200 dark:border-gray-700">
<div
className={`flex items-center gap-2 flex-1 ${collapsible ? 'cursor-pointer' : ''}`}
onClick={() => collapsible && setIsExpanded(!isExpanded)}
>
<FaHistory className="text-blue-500" size={headerIconSize} />
<h2 className={`${headerTextClass} font-semibold text-gray-800 dark:text-white`}>
{title}
</h2>
{auditLogs.length > 0 && (
<span className="px-1.5 py-0.5 bg-blue-100 dark:bg-blue-900/30 text-blue-600 dark:text-blue-400 rounded-full text-[10px] font-medium">
{auditLogs.length}
</span>
)}
</div>
<div className="flex items-center gap-1">
<button
type="button"
onClick={(e) => {
e.stopPropagation();
handleRefresh();
}}
disabled={loading}
className="p-1 text-gray-400 hover:text-blue-500 hover:bg-blue-50 dark:hover:bg-blue-900/20 rounded transition-colors disabled:opacity-50"
title="Refresh activity log"
>
<FaSync className={loading ? 'animate-spin' : ''} size={compact ? 10 : 12} />
</button>
{collapsible && (
<button
type="button"
onClick={() => setIsExpanded(!isExpanded)}
className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors p-1"
>
{isExpanded ? (
<FaChevronUp size={compact ? 12 : 14} />
) : (
<FaChevronDown size={compact ? 12 : 14} />
)}
</button>
)}
</div>
</div>
{/* Content */}
{isExpanded && (
<div className="p-3">
{/* Loading */}
{loading && (
<div className="flex items-center justify-center py-6">
<FaSpinner className="animate-spin text-blue-500 mr-2" size={14} />
<span className="text-xs text-gray-500 dark:text-gray-400">Loading...</span>
</div>
)}
{/* Empty State */}
{!loading && auditLogs.length === 0 && (
<div className="relative">
<div className={`absolute ${timelineLineLeft} top-0 bottom-0 w-0.5 bg-gray-200 dark:bg-gray-700`} />
<div className={`relative ${compact ? 'pl-6' : 'pl-8'} mb-3`}>
<div
className={`absolute ${compact ? 'left-1' : 'left-1.5'} top-1 ${compact ? 'w-2.5 h-2.5' : 'w-3 h-3'} rounded-full border-2 border-white dark:border-gray-800 bg-gray-300 dark:bg-gray-600`}
/>
<div className={`${compact ? 'p-2' : 'p-3'} rounded-lg bg-gray-50 dark:bg-gray-700/50`}>
<p className={`${compact ? 'text-[10px]' : 'text-xs'} text-gray-500 dark:text-gray-400 italic`}>
No changes recorded yet
</p>
</div>
</div>
{creationDate && createdBy && (
<CreatedEntry
creationDate={creationDate}
createdBy={createdBy}
doctype={doctype}
compact={compact}
/>
)}
</div>
)}
{/* Timeline */}
{!loading && auditLogs.length > 0 && (
<div className="relative">
<div className={`absolute ${timelineLineLeft} top-0 bottom-0 w-0.5 bg-gray-200 dark:bg-gray-700`} />
<div className="space-y-3">
{visibleLogs.map((log, index) => (
<TimelineEntry
key={log.name}
log={log}
isLatest={index === 0}
compact={compact}
/>
))}
</div>
{/* Show More/Less */}
{auditLogs.length > initialVisible && (
<div className="mt-3 text-center">
<button
type="button"
onClick={() => setShowAll(!showAll)}
className={`inline-flex items-center gap-1 px-2 py-1 ${showMoreTextSize} font-medium text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300 hover:bg-blue-50 dark:hover:bg-blue-900/20 rounded-md transition-colors`}
>
{showAll ? (
<>
<FaChevronUp size={showMoreIconSize} /> Show Less
</>
) : (
<>
<FaChevronDown size={showMoreIconSize} /> Show All ({auditLogs.length})
</>
)}
</button>
</div>
)}
{/* Created entry at bottom */}
{creationDate && createdBy && (
<div className="mt-3">
<CreatedEntry
creationDate={creationDate}
createdBy={createdBy}
doctype={doctype}
compact={compact}
/>
</div>
)}
</div>
)}
</div>
)}
</div>
);
};
export default ActivityLog;

View File

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

View File

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

View File

@ -0,0 +1,448 @@
import React, { useState, useMemo } from 'react';
import {
FaComments,
FaUser,
FaTrash,
FaClock,
FaSpinner,
FaSync,
FaChevronDown,
FaChevronUp,
FaExclamationTriangle,
FaCheckCircle,
FaTimesCircle,
FaInfoCircle,
FaPaperclip,
FaThumbsUp,
FaEdit,
} from 'react-icons/fa';
import { toast } from 'react-toastify';
import { useComments } from '../hooks/useComments';
import MentionInput from './MentionInput';
import API_CONFIG from '../config/api';
// ============================================================
// CommentSection drop-in comment / discussion panel
//
// Usage:
// <CommentSection
// referenceDoctype="Inspection"
// referenceName={inspectionName}
// />
//
// That's it! Place it at the bottom of any detail page.
// ============================================================
interface CommentSectionProps {
/** ERPNext doctype (e.g. "Inspection", "Asset", "Work Order") */
referenceDoctype: string;
/** Document name / ID. Pass null for unsaved/new docs. */
referenceName: string | null;
/** Heading text (default "Comments & Discussion") */
title?: string;
/** Auto-poll interval in ms. 0 to disable. Default 30000 */
pollInterval?: number;
/** Max comments to show before "Show more". Default 5 */
initialLimit?: number;
/** Collapse-able section? Default true */
collapsible?: boolean;
/** Start collapsed? Default false */
startCollapsed?: boolean;
}
// ── Comment type icon and color ────────────────────────────
const commentTypeMeta: Record<
string,
{ icon: React.ReactNode; color: string; label: string }
> = {
Comment: {
icon: <FaComments size={10} />,
color: 'text-blue-600 dark:text-blue-400',
label: 'Comment',
},
Info: {
icon: <FaInfoCircle size={10} />,
color: 'text-gray-500 dark:text-gray-400',
label: 'Info',
},
Edit: {
icon: <FaEdit size={10} />,
color: 'text-orange-500 dark:text-orange-400',
label: 'Edit',
},
Attachment: {
icon: <FaPaperclip size={10} />,
color: 'text-purple-500 dark:text-purple-400',
label: 'Attachment',
},
Like: {
icon: <FaThumbsUp size={10} />,
color: 'text-pink-500 dark:text-pink-400',
label: 'Like',
},
};
// ── Helpers ────────────────────────────────────────────────
const timeAgo = (dateStr: string): string => {
if (!dateStr) return '';
const date = new Date(dateStr);
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const mins = Math.floor(diffMs / 60000);
const hrs = Math.floor(diffMs / 3600000);
const days = Math.floor(diffMs / 86400000);
if (mins < 1) return 'Just now';
if (mins < 60) return `${mins}m ago`;
if (hrs < 24) return `${hrs}h ago`;
if (days < 7) return `${days}d ago`;
return date.toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: date.getFullYear() !== now.getFullYear() ? 'numeric' : undefined,
});
};
const emailToName = (email: string): string => {
if (!email) return 'Unknown';
const at = email.indexOf('@');
if (at === -1) return email;
// Title-case the part before @
return email
.substring(0, at)
.replace(/[._-]/g, ' ')
.replace(/\b\w/g, (c) => c.toUpperCase());
};
/**
* Render comment HTML content safely.
* We trust content from our own backend (Frappe generates it).
*/
const CommentContent: React.FC<{ html: string }> = ({ html }) => {
const cleaned = html
.replace(/<div class="ql-editor[^"]*">/g, '')
.replace(/<\/div>$/g, '');
return (
<div
className="comment-content text-sm text-gray-800 dark:text-gray-200 leading-relaxed
[&_a]:text-teal-600 [&_a]:dark:text-teal-400 [&_a]:underline [&_a]:font-medium
[&_.mention]:text-teal-700 [&_.mention]:dark:text-teal-300 [&_.mention]:font-semibold
[&_.mention]:bg-teal-50 [&_.mention]:dark:bg-teal-900/30 [&_.mention]:px-1 [&_.mention]:py-0.5
[&_.mention]:rounded [&_.mention]:pointer-events-none [&_.mention]:cursor-default
[&_.mention_a]:no-underline [&_.mention_a]:text-inherit
[&_p]:my-0"
dangerouslySetInnerHTML={{ __html: cleaned }}
/>
);
};
// const CommentContent: React.FC<{ html: string }> = ({ html }) => {
// const cleaned = html
// .replace(/<div class="ql-editor[^"]*">/g, '')
// .replace(/<\/div>$/g, '');
// return (
// <div
// className="comment-content text-sm text-gray-800 dark:text-gray-200 leading-relaxed
// [&_a]:text-teal-600 [&_a]:dark:text-teal-400 [&_a]:underline [&_a]:font-medium
// [&_.mention]:text-teal-700 [&_.mention]:dark:text-teal-300 [&_.mention]:font-semibold
// [&_.mention]:bg-teal-50 [&_.mention]:dark:bg-teal-900/30 [&_.mention]:px-0.5 [&_.mention]:rounded
// [&_p]:my-0"
// dangerouslySetInnerHTML={{ __html: cleaned }}
// />
// );
// };
// ── Main Component ─────────────────────────────────────────
const CommentSection: React.FC<CommentSectionProps> = ({
referenceDoctype,
referenceName,
title = 'Comments & Discussion',
pollInterval = 30000,
initialLimit = 5,
collapsible = true,
startCollapsed = false,
}) => {
const {
comments,
loading,
posting,
error,
currentUser,
refetch,
postComment,
deleteComment,
mentionUsers,
mentionLoading,
searchMentionUsers,
} = useComments({
referenceDoctype,
referenceName,
pollInterval,
});
const [expanded, setExpanded] = useState(!startCollapsed);
const [showAll, setShowAll] = useState(false);
const [draftText, setDraftText] = useState('');
const [deletingId, setDeletingId] = useState<string | null>(null);
// Only user-posted "Comment" type entries (for the count badge)
const userComments = useMemo(
() => comments.filter((c) => c.comment_type === 'Comment'),
[comments]
);
// All activity items sorted chronologically
const visibleComments = useMemo(() => {
if (showAll) return comments;
return comments.slice(-initialLimit); // latest N
}, [comments, showAll, initialLimit]);
// ── Handlers ────────────────────────────────────────────
const handlePost = async (html: string) => {
try {
await postComment(html);
setDraftText('');
toast.success('Comment posted!', {
position: 'top-right',
autoClose: 2000,
icon: <FaCheckCircle />,
});
} catch (err: any) {
toast.error(`Failed to post comment: ${err.message || 'Unknown error'}`, {
position: 'top-right',
autoClose: 5000,
icon: <FaTimesCircle />,
});
}
};
const handleDelete = async (commentName: string) => {
setDeletingId(commentName);
try {
await deleteComment(commentName);
toast.success('Comment deleted', {
position: 'top-right',
autoClose: 2000,
icon: <FaCheckCircle />,
});
} catch (err: any) {
toast.error(`Failed to delete: ${err.message || 'Unknown error'}`, {
position: 'top-right',
autoClose: 5000,
icon: <FaTimesCircle />,
});
} finally {
setDeletingId(null);
}
};
// ── Guard: can't comment on unsaved docs ────────────────
if (!referenceName) {
return (
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-md p-6 border border-gray-200 dark:border-gray-700">
<div className="flex items-center gap-2 text-gray-500 dark:text-gray-400">
<FaComments className="text-gray-400" />
<span className="text-sm">Save the document first to enable comments.</span>
</div>
</div>
);
}
return (
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-md border border-gray-200 dark:border-gray-700 overflow-hidden">
{/* ── Section header ──────────────────────────────────── */}
<div
className={`flex items-center justify-between px-5 py-3 border-b border-gray-200 dark:border-gray-700 ${
collapsible ? 'cursor-pointer select-none' : ''
}`}
onClick={() => collapsible && setExpanded((v) => !v)}
>
<div className="flex items-center gap-2">
<FaComments className="text-teal-500" size={16} />
<h2 className="text-base font-semibold text-gray-800 dark:text-white">
{title}
</h2>
{userComments.length > 0 && (
<span className="px-2 py-0.5 bg-teal-100 dark:bg-teal-900/30 text-teal-700 dark:text-teal-300 rounded-full text-xs font-medium">
{userComments.length}
</span>
)}
</div>
<div className="flex items-center gap-2">
{/* Refresh */}
<button
type="button"
onClick={(e) => {
e.stopPropagation();
refetch();
}}
disabled={loading}
className="p-1.5 text-gray-400 hover:text-teal-500 hover:bg-teal-50 dark:hover:bg-teal-900/20 rounded transition-colors disabled:opacity-50"
title="Refresh comments"
>
<FaSync className={loading ? 'animate-spin' : ''} size={11} />
</button>
{collapsible && (
<span className="text-gray-400 dark:text-gray-500">
{expanded ? <FaChevronUp size={12} /> : <FaChevronDown size={12} />}
</span>
)}
</div>
</div>
{/* ── Body ────────────────────────────────────────────── */}
{expanded && (
<div className="p-5 space-y-5">
{/* Error state */}
{error && (
<div className="flex items-center gap-2 text-red-600 dark:text-red-400 text-sm bg-red-50 dark:bg-red-900/20 rounded-lg p-3">
<FaExclamationTriangle size={12} />
{error}
</div>
)}
{/* Loading */}
{loading && comments.length === 0 && (
<div className="flex items-center justify-center py-8">
<FaSpinner className="animate-spin text-teal-500 mr-2" size={16} />
<span className="text-sm text-gray-500 dark:text-gray-400">Loading comments</span>
</div>
)}
{/* ── Activity timeline ────────────────────────────── */}
{comments.length > 0 && (
<>
{/* Show older button */}
{!showAll && comments.length > initialLimit && (
<button
type="button"
onClick={() => setShowAll(true)}
className="w-full text-center py-2 text-xs text-teal-600 dark:text-teal-400
hover:bg-teal-50 dark:hover:bg-teal-900/20 rounded-lg transition-colors font-medium"
>
Show {comments.length - initialLimit} older comment{comments.length - initialLimit !== 1 ? 's' : ''}
</button>
)}
<div className="space-y-3">
{visibleComments.map((comment) => {
const meta = commentTypeMeta[comment.comment_type] || commentTypeMeta.Comment;
const isOwn = comment.comment_email === currentUser || comment.owner === currentUser;
const isDeleting = deletingId === comment.name;
return (
<div
key={comment.name}
className={`group relative rounded-lg border transition-colors ${
comment.comment_type === 'Comment'
? 'bg-white dark:bg-gray-800 border-gray-200 dark:border-gray-700 hover:border-gray-300 dark:hover:border-gray-600'
: 'bg-gray-50 dark:bg-gray-800/50 border-gray-100 dark:border-gray-700/50'
}`}
>
<div className="px-4 py-3">
{/* Header row */}
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
{/* Avatar */}
<div className="w-7 h-7 rounded-full bg-teal-100 dark:bg-teal-900/40 flex items-center justify-center flex-shrink-0">
<FaUser className="text-teal-600 dark:text-teal-400" size={10} />
</div>
{/* Name & type */}
<div className="min-w-0">
<span className="text-sm font-medium text-gray-800 dark:text-gray-200">
{comment.comment_by || emailToName(comment.comment_email || comment.owner)}
</span>
{comment.comment_type !== 'Comment' && (
<span className={`ml-2 inline-flex items-center gap-1 text-[10px] font-medium ${meta.color}`}>
{meta.icon}
{meta.label}
</span>
)}
</div>
</div>
<div className="flex items-center gap-2">
{/* Time */}
<span
className="text-[11px] text-gray-400 dark:text-gray-500 flex items-center gap-1"
title={new Date(comment.creation).toLocaleString()}
>
<FaClock size={9} />
{timeAgo(comment.creation)}
</span>
{/* Delete (only own comments) */}
{isOwn && comment.comment_type === 'Comment' && (
<button
type="button"
onClick={() => handleDelete(comment.name)}
disabled={isDeleting}
className="opacity-0 group-hover:opacity-100 p-1 text-gray-400 hover:text-red-500
hover:bg-red-50 dark:hover:bg-red-900/20 rounded transition-all disabled:opacity-50"
title="Delete comment"
>
{isDeleting ? (
<FaSpinner className="animate-spin" size={10} />
) : (
<FaTrash size={10} />
)}
</button>
)}
</div>
</div>
{/* Content */}
<div className="ml-9">
<CommentContent html={comment.content} />
</div>
</div>
</div>
);
})}
</div>
{/* Show less */}
{showAll && comments.length > initialLimit && (
<button
type="button"
onClick={() => setShowAll(false)}
className="w-full text-center py-2 text-xs text-teal-600 dark:text-teal-400
hover:bg-teal-50 dark:hover:bg-teal-900/20 rounded-lg transition-colors font-medium"
>
Show fewer comments
</button>
)}
</>
)}
{/* Empty state */}
{!loading && comments.length === 0 && (
<div className="text-center py-8">
<FaComments className="mx-auto text-gray-300 dark:text-gray-600 mb-2" size={28} />
<p className="text-sm text-gray-500 dark:text-gray-400">
No comments yet. Start the discussion!
</p>
</div>
)}
{/* ── New comment input ────────────────────────────── */}
<div className="pt-3 border-t border-gray-200 dark:border-gray-700">
<MentionInput
value={draftText}
onChange={setDraftText}
onSubmit={handlePost}
disabled={!referenceName}
posting={posting}
mentionUsers={mentionUsers}
mentionLoading={mentionLoading}
onMentionSearch={searchMentionUsers}
/>
</div>
</div>
)}
</div>
);
};
export default CommentSection;

View File

@ -0,0 +1,539 @@
// components/DeleteRequestButton.tsx
import React, { useState, useRef, useEffect } from 'react';
import { FaTrash, FaCheckCircle, FaExclamationTriangle, FaSpinner, FaTimes, FaChevronDown, FaBan } from 'react-icons/fa';
import { useDeleteRequest, type DeleteStatus } from '../hooks/useDeleteRequest';
import { useNavigate } from 'react-router-dom';
interface DeleteRequestButtonProps {
doctype: string;
docname: string | null | undefined;
currentDeleteStatus: DeleteStatus;
userRoles: string[];
isSystemManager: boolean;
/** Called after any successful status change — use to refetch your document */
onStatusChange?: (newStatus: DeleteStatus) => void;
/** Extra classes on the button wrapper div */
className?: string;
/** If true, renders buttons inline (row). Default: column (stacked). */
inline?: boolean;
redirectOnDelete?: string;
triggerMode?: boolean; // ← ADD THIS
}
// ─── Confirmation Modal ───────────────────────────────────────────────────────
interface ConfirmModalProps {
title: string;
message: string;
confirmLabel: string;
confirmClass: string;
icon: React.ReactNode;
loading: boolean;
onConfirm: () => void;
onCancel: () => void;
}
const ConfirmModal: React.FC<ConfirmModalProps> = ({
title,
message,
confirmLabel,
confirmClass,
icon,
loading,
onConfirm,
onCancel,
}) => (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-[9999]">
<div className="bg-white dark:bg-gray-800 rounded-xl p-6 max-w-md w-full mx-4 shadow-2xl">
<div className="flex items-start gap-3 mb-4">
<span className="mt-0.5 flex-shrink-0 text-xl">{icon}</span>
<div>
<h3 className="text-base font-semibold text-gray-800 dark:text-white">{title}</h3>
<p className="text-sm text-gray-600 dark:text-gray-400 mt-1">{message}</p>
</div>
</div>
<div className="flex justify-end gap-3">
<button
onClick={onCancel}
disabled={loading}
className="px-4 py-2 bg-gray-200 hover:bg-gray-300 dark:bg-gray-600 dark:hover:bg-gray-500 text-gray-700 dark:text-gray-200 rounded-lg text-sm font-medium disabled:opacity-50"
>
Cancel
</button>
<button
onClick={onConfirm}
disabled={loading}
className={`px-4 py-2 text-white rounded-lg text-sm font-medium flex items-center gap-2 disabled:opacity-50 ${confirmClass}`}
>
{loading ? <FaSpinner className="animate-spin" size={13} /> : null}
{confirmLabel}
</button>
</div>
</div>
</div>
);
// ─── Split Dropdown Button ────────────────────────────────────────────────────
interface SplitDropdownButtonProps {
primaryLabel: string;
primaryClass: string;
primaryIcon: React.ReactNode;
onPrimary: () => void;
secondaryLabel: string;
secondaryClass: string;
secondaryIcon: React.ReactNode;
onSecondary: () => void;
disabled?: boolean;
loading?: boolean;
}
const SplitDropdownButton: React.FC<SplitDropdownButtonProps> = ({
primaryLabel,
primaryClass,
primaryIcon,
onPrimary,
secondaryLabel,
secondaryClass,
secondaryIcon,
onSecondary,
disabled = false,
loading = false,
}) => {
const [open, setOpen] = useState(false);
const ref = useRef<HTMLDivElement>(null);
// Close on outside click
useEffect(() => {
const handler = (e: MouseEvent) => {
if (ref.current && !ref.current.contains(e.target as Node)) {
setOpen(false);
}
};
if (open) document.addEventListener('mousedown', handler);
return () => document.removeEventListener('mousedown', handler);
}, [open]);
return (
<div ref={ref} className="relative inline-flex rounded-lg overflow-visible">
{/* Primary action button */}
<button
type="button"
disabled={disabled}
onClick={onPrimary}
className={`px-4 py-2 text-white text-sm font-medium flex items-center gap-2 disabled:opacity-50 transition-colors rounded-l-lg ${primaryClass}`}
>
{loading ? <FaSpinner className="animate-spin" size={13} /> : primaryIcon}
{primaryLabel}
</button>
{/* Divider */}
<span className="w-px bg-white/30" />
{/* Chevron toggle */}
<button
type="button"
disabled={disabled}
onClick={() => setOpen((v) => !v)}
className={`px-2 py-2 text-white text-sm font-medium flex items-center disabled:opacity-50 transition-colors rounded-r-lg ${primaryClass}`}
aria-label="More options"
>
<FaChevronDown size={11} className={`transition-transform ${open ? 'rotate-180' : ''}`} />
</button>
{/* Dropdown */}
{open && (
<div className="absolute top-full left-0 mt-1 z-50 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg shadow-lg min-w-[160px] overflow-hidden">
<button
type="button"
disabled={disabled}
onClick={() => { setOpen(false); onSecondary(); }}
className={`w-full px-4 py-2.5 text-sm font-medium flex items-center gap-2 transition-colors ${secondaryClass}`}
>
{secondaryIcon}
{secondaryLabel}
</button>
</div>
)}
</div>
);
};
// ─── Status Badge ─────────────────────────────────────────────────────────────
const StatusBadge: React.FC<{ status: DeleteStatus }> = ({ status }) => {
if (!status) return null;
const config: Record<string, { bg: string; text: string; label: string }> = {
'Delete Request With Supervisor': {
bg: 'bg-orange-100 dark:bg-orange-900/30',
text: 'text-orange-700 dark:text-orange-300',
label: '⏳ Delete Request Pending Supervisor',
},
'Delete Request With CM': {
bg: 'bg-yellow-100 dark:bg-yellow-900/30',
text: 'text-yellow-700 dark:text-yellow-300',
label: '⏳ Delete Request Pending CM',
},
Deleted: {
bg: 'bg-red-100 dark:bg-red-900/30',
text: 'text-red-700 dark:text-red-300',
label: '🗑 Marked for Deletion',
},
};
const c = config[status as string];
if (!c) return null;
return (
<span
className={`inline-flex items-center px-2.5 py-1 rounded-full text-xs font-medium ${c.bg} ${c.text}`}
>
{c.label}
</span>
);
};
// ─── Main Component ───────────────────────────────────────────────────────────
type PendingAction =
| 'raise'
| 'supervisor_approve'
| 'supervisor_reject'
| 'cm_approve'
| 'cm_reject'
| 'direct'
| null;
const DeleteRequestButton: React.FC<DeleteRequestButtonProps> = ({
doctype,
docname,
currentDeleteStatus,
userRoles,
isSystemManager,
onStatusChange,
className = '',
inline = false,
redirectOnDelete,
triggerMode = false,
}) => {
const navigate = useNavigate();
const [pendingAction, setPendingAction] = useState<PendingAction>(null);
const [triggerOpen, setTriggerOpen] = useState(false); // ← ADD THIS
const triggerRef = useRef<HTMLDivElement>(null); // ← ADD THIS
const {
showRaiseRequest,
showApproveAsSupervisor,
showApproveAsCM,
showDirectDelete,
deleteStatus,
loading,
error,
raiseRequest,
approveAsSupervisor,
approveAsCM,
directDelete,
rejectRequest, // ← new: resets delete_status to ""
} = useDeleteRequest({
doctype,
docname,
currentDeleteStatus,
userRoles,
isSystemManager,
onSuccess: (newStatus) => {
onStatusChange?.(newStatus);
if (newStatus === 'Deleted' && redirectOnDelete) {
navigate(redirectOnDelete);
}
},
});
// Close trigger dropdown on outside click
useEffect(() => {
if (!triggerOpen) return;
const handler = (e: MouseEvent) => {
if (triggerRef.current && !triggerRef.current.contains(e.target as Node)) {
setTriggerOpen(false);
}
};
document.addEventListener('mousedown', handler);
return () => document.removeEventListener('mousedown', handler);
}, [triggerOpen]);
const handleConfirm = async () => {
if (!pendingAction) return;
const actionMap: Record<NonNullable<PendingAction>, () => Promise<void>> = {
raise: raiseRequest,
supervisor_approve: approveAsSupervisor,
supervisor_reject: rejectRequest,
cm_approve: approveAsCM,
cm_reject: rejectRequest,
direct: directDelete,
};
await actionMap[pendingAction]();
setPendingAction(null);
};
// ── Modal config per action ──────────────────────────────────────────────
const modalConfig: Record<
NonNullable<PendingAction>,
{ title: string; message: string; confirmLabel: string; confirmClass: string; icon: React.ReactNode }
> = {
raise: {
title: 'Request Deletion',
message: 'This will raise a deletion request to the Supervisor for review.',
confirmLabel: 'Raise Request',
confirmClass: 'bg-orange-600 hover:bg-orange-700',
icon: <FaExclamationTriangle className="text-orange-500" />,
},
supervisor_approve: {
title: 'Approve Deletion Request',
message: 'This will forward the deletion request to the Cluster Manager for final approval.',
confirmLabel: 'Approve & Forward',
confirmClass: 'bg-yellow-600 hover:bg-yellow-700',
icon: <FaCheckCircle className="text-yellow-500" />,
},
supervisor_reject: {
title: 'Reject Deletion Request',
message: 'This will reject the deletion request and clear the status. The record will remain active.',
confirmLabel: 'Reject Request',
confirmClass: 'bg-gray-600 hover:bg-gray-700',
icon: <FaBan className="text-gray-500" />,
},
cm_approve: {
title: 'Approve & Mark as Deleted',
message: 'This will mark the record as Deleted.',
confirmLabel: 'Approve & Delete',
confirmClass: 'bg-red-600 hover:bg-red-700',
icon: <FaTrash className="text-red-500" />,
},
cm_reject: {
title: 'Reject Deletion Request',
message: 'This will reject the deletion request and clear the status. The record will remain active.',
confirmLabel: 'Reject Request',
confirmClass: 'bg-gray-600 hover:bg-gray-700',
icon: <FaBan className="text-gray-500" />,
},
direct: {
title: 'Mark as Deleted',
message: 'This will immediately Delete The Record.',
confirmLabel: 'Delete',
confirmClass: 'bg-red-600 hover:bg-red-700',
icon: <FaTrash className="text-red-500" />,
},
};
const statusTooltip: Record<string, string> = {
'Delete Request With Supervisor': 'Delete Request Pending Supervisor',
'Delete Request With CM': 'Delete Request Pending Cluster Manager',
'Deleted': 'Marked for Deletion',
};
if (triggerMode) {
const hasRequest = !!deleteStatus;
const iconColor = hasRequest
? 'text-orange-500 dark:text-orange-400 hover:bg-orange-50 dark:hover:bg-orange-900/20'
: 'text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20';
const nothingToShow =
!showRaiseRequest && !showApproveAsSupervisor && !showApproveAsCM && !showDirectDelete && !deleteStatus;
if (nothingToShow) return null;
return (
<>
{pendingAction && (
<ConfirmModal
{...modalConfig[pendingAction]}
loading={loading}
onConfirm={handleConfirm}
onCancel={() => setPendingAction(null)}
/>
)}
<div className="relative" ref={triggerRef}>
<button
type="button"
onClick={() => setTriggerOpen((v) => !v)}
title={deleteStatus ? statusTooltip[deleteStatus as string] : 'Request Deletion'}
className={`p-2 rounded transition-colors ${iconColor}`}
disabled={loading || !docname}
>
{loading ? <FaSpinner className="animate-spin" size={14} /> : <FaTrash size={14} />}
</button>
{triggerOpen && (
<div className="absolute right-0 mt-1 z-50 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg shadow-xl p-3 min-w-[230px]">
{deleteStatus && (
<div className="mb-3">
<StatusBadge status={deleteStatus} />
</div>
)}
{error && (
<p className="text-xs text-red-500 mb-2 flex items-center gap-1">
<FaExclamationTriangle size={10} /> {error}
</p>
)}
<div className="flex flex-col gap-2">
{showRaiseRequest && (
<button
type="button"
disabled={loading || !docname}
onClick={() => { setTriggerOpen(false); setPendingAction('raise'); }}
className="w-full px-3 py-2 bg-orange-600 hover:bg-orange-700 text-white rounded-lg text-xs font-medium flex items-center gap-2 disabled:opacity-50"
>
<FaTrash size={11} /> Request Deletion
</button>
)}
{showApproveAsSupervisor && (
<>
<button
type="button"
disabled={loading || !docname}
onClick={() => { setTriggerOpen(false); setPendingAction('supervisor_approve'); }}
className="w-full px-3 py-2 bg-yellow-600 hover:bg-yellow-700 text-white rounded-lg text-xs font-medium flex items-center gap-2 disabled:opacity-50"
>
<FaCheckCircle size={11} /> Approve & Forward to CM
</button>
<button
type="button"
disabled={loading || !docname}
onClick={() => { setTriggerOpen(false); setPendingAction('supervisor_reject'); }}
className="w-full px-3 py-2 bg-gray-200 hover:bg-gray-300 dark:bg-gray-600 dark:hover:bg-gray-500 text-gray-700 dark:text-gray-200 rounded-lg text-xs font-medium flex items-center gap-2 disabled:opacity-50"
>
<FaBan size={11} /> Reject Request
</button>
</>
)}
{showApproveAsCM && (
<>
<button
type="button"
disabled={loading || !docname}
onClick={() => { setTriggerOpen(false); setPendingAction('cm_approve'); }}
className="w-full px-3 py-2 bg-red-600 hover:bg-red-700 text-white rounded-lg text-xs font-medium flex items-center gap-2 disabled:opacity-50"
>
<FaCheckCircle size={11} /> Approve & Delete
</button>
<button
type="button"
disabled={loading || !docname}
onClick={() => { setTriggerOpen(false); setPendingAction('cm_reject'); }}
className="w-full px-3 py-2 bg-gray-200 hover:bg-gray-300 dark:bg-gray-600 dark:hover:bg-gray-500 text-gray-700 dark:text-gray-200 rounded-lg text-xs font-medium flex items-center gap-2 disabled:opacity-50"
>
<FaBan size={11} /> Reject Request
</button>
</>
)}
{showDirectDelete && (
<button
type="button"
disabled={loading || !docname}
onClick={() => { setTriggerOpen(false); setPendingAction('direct'); }}
className="w-full px-3 py-2 bg-red-600 hover:bg-red-700 text-white rounded-lg text-xs font-medium flex items-center gap-2 disabled:opacity-50"
>
<FaTrash size={11} /> Delete
</button>
)}
</div>
</div>
)}
</div>
</>
);
}
const layoutClass = inline
? 'flex flex-row flex-wrap items-center gap-2'
: 'flex flex-col gap-2';
return (
<>
{/* Confirmation Modal */}
{pendingAction && (
<ConfirmModal
{...modalConfig[pendingAction]}
loading={loading}
onConfirm={handleConfirm}
onCancel={() => setPendingAction(null)}
/>
)}
<div className={`${layoutClass} ${className}`}>
{/* Status Badge — always shown when a status exists */}
{deleteStatus && <StatusBadge status={deleteStatus} />}
{/* Error */}
{error && (
<p className="text-xs text-red-500 dark:text-red-400 flex items-center gap-1">
<FaExclamationTriangle size={11} /> {error}
</p>
)}
{/* ── Raise Request Button (no reject needed here) ─────────────────── */}
{showRaiseRequest && (
<button
type="button"
disabled={loading || !docname}
onClick={() => setPendingAction('raise')}
className="px-4 py-2 bg-orange-600 hover:bg-orange-700 text-white rounded-lg text-sm font-medium flex items-center gap-2 disabled:opacity-50 transition-colors"
>
{loading ? <FaSpinner className="animate-spin" size={13} /> : <FaTrash size={13} />}
Request Deletion
</button>
)}
{/* ── Supervisor: Approve (split) + Reject ────────────────────────── */}
{showApproveAsSupervisor && (
<SplitDropdownButton
disabled={loading || !docname}
loading={loading}
// Primary — Approve & Forward
primaryLabel="Approve Request"
primaryClass="bg-yellow-600 hover:bg-yellow-700"
primaryIcon={<FaCheckCircle size={13} />}
onPrimary={() => setPendingAction('supervisor_approve')}
// Secondary — Reject
secondaryLabel="Reject Request"
secondaryClass="text-gray-700 dark:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-700"
secondaryIcon={<FaBan size={13} className="text-gray-500" />}
onSecondary={() => setPendingAction('supervisor_reject')}
/>
)}
{/* ── CM: Approve & Delete (split) + Reject ───────────────────────── */}
{showApproveAsCM && (
<SplitDropdownButton
disabled={loading || !docname}
loading={loading}
// Primary — Approve & Delete
primaryLabel="Approve & Delete"
primaryClass="bg-red-600 hover:bg-red-700"
primaryIcon={<FaCheckCircle size={13} />}
onPrimary={() => setPendingAction('cm_approve')}
// Secondary — Reject
secondaryLabel="Reject Request"
secondaryClass="text-gray-700 dark:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-700"
secondaryIcon={<FaBan size={13} className="text-gray-500" />}
onSecondary={() => setPendingAction('cm_reject')}
/>
)}
{/* ── CM / SysManager: Direct Delete (no reject needed) ───────────── */}
{showDirectDelete && (
<button
type="button"
disabled={loading || !docname}
onClick={() => setPendingAction('direct')}
className="px-4 py-2 bg-red-600 hover:bg-red-700 text-white rounded-lg text-sm font-medium flex items-center gap-2 disabled:opacity-50 transition-colors"
>
{loading ? <FaSpinner className="animate-spin" size={13} /> : <FaTrash size={13} />}
Delete
</button>
)}
</div>
</>
);
};
export default DeleteRequestButton;

View File

@ -0,0 +1,519 @@
import React, { useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import * as XLSX from 'xlsx';
import {
FaFileExport, FaTimes, FaFileCsv, FaFileExcel, FaDownload,
FaSearch, FaCheckSquare, FaSquare, FaSpinner,
} from 'react-icons/fa';
import { useDoctypeFields, type DoctypeField } from '../hooks/useDoctypeFields';
// ─────────────────────────────────────────────
// Types
// ─────────────────────────────────────────────
export type ExportFormat = 'csv' | 'excel';
export type ExportScope = 'selected' | 'all_on_page' | 'all_with_filters';
export interface DynamicExportModalProps {
/** Whether the modal is open */
isOpen: boolean;
onClose: () => void;
/** Frappe DocType name, e.g. "Work_Order", "Asset" */
doctype: string;
/** Counts for the three scope options */
selectedCount: number;
pageCount: number;
totalCount: number;
/**
* Called when the user clicks Export.
* `rows` is the flat array of objects to export (already resolved by parent).
* `columns` is the list of chosen column keys.
* `format` is 'csv' | 'excel'.
*
* Alternatively you can pass `onFetchAll` and let the modal handle fetching.
*/
onExport?: (scope: ExportScope, format: ExportFormat, columns: string[]) => Promise<void> | void;
/**
* If provided, the modal will call this to fetch ALL records when
* scope === 'all_with_filters'. The parent just needs to provide page data.
*/
onFetchAll?: () => Promise<any[]>;
/** Current page data (used for 'all_on_page' and 'selected') */
pageData: any[];
/** Set of selected row names/ids */
selectedRows?: Set<string>;
rowKey?: string; // default: 'name'
/** Optional: extra columns to inject (e.g. computed / virtual fields) */
extraColumns?: DoctypeField[];
/** Optional: columns to hide even if they exist in DocType */
hiddenColumns?: string[];
/** Optional: override default-checked columns (fieldnames) */
defaultColumns?: string[];
/** File name prefix, e.g. "work_orders" → "work_orders_2025-01-01.csv" */
fileNamePrefix?: string;
}
// ─────────────────────────────────────────────
// Helpers
// ─────────────────────────────────────────────
function formatValue(value: any): string {
if (value === null || value === undefined) return '';
if (typeof value === 'boolean') return value ? 'Yes' : 'No';
return String(value);
}
function downloadCSV(rows: any[], columns: DoctypeField[], fileName: string) {
const headers = columns.map(c => c.label);
const body = rows.map(row =>
columns.map(c => {
const val = formatValue(row[c.key]);
// Escape CSV
if (val.includes(',') || val.includes('"') || val.includes('\n')) {
return `"${val.replace(/"/g, '""')}"`;
}
return val;
}).join(',')
);
const csv = [headers.join(','), ...body].join('\n');
const blob = new Blob(['\ufeff' + csv], { type: 'text/csv;charset=utf-8;' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = fileName;
a.click();
URL.revokeObjectURL(url);
}
function downloadExcel(rows: any[], columns: DoctypeField[], fileName: string) {
const wsData = [
columns.map(c => c.label),
...rows.map(row => columns.map(c => formatValue(row[c.key]))),
];
const ws = XLSX.utils.aoa_to_sheet(wsData);
const wb = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(wb, ws, 'Export');
XLSX.writeFile(wb, fileName);
}
// ─────────────────────────────────────────────
// Component
// ─────────────────────────────────────────────
const DynamicExportModal: React.FC<DynamicExportModalProps> = ({
isOpen,
onClose,
doctype,
selectedCount,
pageCount,
totalCount,
onExport,
onFetchAll,
pageData,
selectedRows,
rowKey = 'name',
extraColumns = [],
hiddenColumns = [],
defaultColumns,
fileNamePrefix,
}) => {
const { t } = useTranslation();
const { fields, loading: fieldsLoading } = useDoctypeFields(doctype);
// ── Derived column list ──────────────────────────────────────
const allColumns: DoctypeField[] = React.useMemo(() => {
const hidden = new Set(hiddenColumns);
// Merge fetched fields + extra columns, remove hidden
const base = [
...fields.filter(f => !hidden.has(f.key)),
...extraColumns.filter(f => !hidden.has(f.key)),
];
// Apply defaultColumns override if provided
if (defaultColumns) {
const defaultSet = new Set(defaultColumns);
return base.map(f => ({ ...f, default: defaultSet.has(f.key) }));
}
return base;
}, [fields, extraColumns, hiddenColumns, defaultColumns]);
// ── Local state ───────────────────────────────────────────────
const [scope, setScope] = useState<ExportScope>(selectedCount > 0 ? 'selected' : 'all_with_filters');
const [format, setFormat] = useState<ExportFormat>('csv');
const [checkedKeys, setCheckedKeys] = useState<Set<string>>(new Set());
const [search, setSearch] = useState('');
const [isExporting, setIsExporting] = useState(false);
// Track whether we have seeded checkedKeys for this modal open session
const initializedRef = React.useRef(false);
// Sync scope when selectedCount changes
useEffect(() => {
setScope(selectedCount > 0 ? 'selected' : 'all_with_filters');
}, [selectedCount]);
// Seed default checked columns ONCE when allColumns first populates.
// Using a ref guard prevents re-seeding when allColumns recomputes due to
// inline prop arrays (defaultColumns={[...]} creates a new reference every
// parent render), which would silently wipe out the user's All/None/Default selection.
useEffect(() => {
if (allColumns.length === 0) return;
if (initializedRef.current) return;
initializedRef.current = true;
setCheckedKeys(new Set(allColumns.filter(c => c.default).map(c => c.key)));
}, [allColumns]);
// Reset the seed flag when modal closes so next open re-initializes cleanly
useEffect(() => {
if (!isOpen) {
initializedRef.current = false;
setCheckedKeys(new Set());
}
}, [isOpen]);
if (!isOpen) return null;
// ── Column helpers ────────────────────────────────────────────
const filteredColumns = search.trim()
? allColumns.filter(c =>
c.label.toLowerCase().includes(search.toLowerCase()) ||
c.key.toLowerCase().includes(search.toLowerCase())
)
: allColumns;
const toggleColumn = (key: string) => {
setCheckedKeys(prev => {
const next = new Set(prev);
next.has(key) ? next.delete(key) : next.add(key);
return next;
});
};
const selectAll = () => setCheckedKeys(new Set(allColumns.map(c => c.key)));
const selectDefault = () => setCheckedKeys(new Set(allColumns.filter(c => c.default).map(c => c.key)));
const selectNone = () => setCheckedKeys(new Set());
// ── Export handler ────────────────────────────────────────────
const handleExport = async () => {
if (checkedKeys.size === 0) return;
setIsExporting(true);
try {
// If parent handles everything
if (onExport) {
await onExport(scope, format, [...checkedKeys]);
onClose();
return;
}
// Otherwise handle internally
let rows: any[] = [];
if (scope === 'selected') {
const sel = selectedRows ?? new Set<string>();
rows = pageData.filter(r => sel.has(r[rowKey]));
} else if (scope === 'all_on_page') {
rows = pageData;
} else {
if (!onFetchAll) {
alert('onFetchAll not provided for all_with_filters scope');
return;
}
rows = await onFetchAll();
}
if (rows.length === 0) {
alert('No data to export.');
return;
}
const chosenCols = allColumns.filter(c => checkedKeys.has(c.key));
const prefix = fileNamePrefix ?? doctype.toLowerCase().replace(/\s+/g, '_');
const datePart = new Date().toISOString().split('T')[0];
const fileName = `${prefix}_export_${datePart}.${format === 'csv' ? 'csv' : 'xlsx'}`;
if (format === 'csv') {
downloadCSV(rows, chosenCols, fileName);
} else {
downloadExcel(rows, chosenCols, fileName);
}
onClose();
} catch (err) {
console.error('Export failed:', err);
alert(`Export failed: ${err instanceof Error ? err.message : 'Unknown error'}`);
} finally {
setIsExporting(false);
}
};
// ── Render ────────────────────────────────────────────────────
return (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-[70] p-4">
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-xl w-full max-w-2xl max-h-[92vh] flex flex-col animate-scale-in">
{/* ── Header ── */}
<div className="bg-gradient-to-r from-green-500 to-green-600 px-6 py-4 rounded-t-lg flex-shrink-0">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<FaFileExport className="text-white text-xl" />
<div>
<h3 className="text-lg font-semibold text-white">Export {doctype.replace(/_/g, ' ')}</h3>
<p className="text-green-100 text-xs mt-0.5">
{allColumns.length} fields available · {checkedKeys.size} selected
</p>
</div>
</div>
<button onClick={onClose} className="text-white/80 hover:text-white transition-colors" disabled={isExporting}>
<FaTimes size={20} />
</button>
</div>
</div>
{/* ── Body (scrollable) ── */}
<div className="flex-1 overflow-y-auto p-6 space-y-6">
{/* Scope */}
<div>
<h4 className="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-3">What to export</h4>
<div className="space-y-2">
{/* Selected rows */}
<ScopeOption
value="selected"
current={scope}
onChange={setScope}
disabled={selectedCount === 0}
badge={selectedCount}
badgeColor="green"
label="Selected rows"
sub={`${selectedCount} row${selectedCount !== 1 ? 's' : ''} selected`}
/>
{/* Current page */}
<ScopeOption
value="all_on_page"
current={scope}
onChange={setScope}
badge={pageCount}
badgeColor="blue"
label="Current page"
sub={`${pageCount} rows on this page`}
/>
{/* All with filters */}
<ScopeOption
value="all_with_filters"
current={scope}
onChange={setScope}
badge={totalCount}
badgeColor="purple"
label="All records (current filters)"
sub={`${totalCount} total matching records`}
/>
</div>
</div>
{/* Format */}
<div>
<h4 className="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-3">File format</h4>
<div className="flex gap-3">
<FormatOption value="csv" current={format} onChange={setFormat}
icon={<FaFileCsv className="text-green-600 text-xl" />}
label="CSV" sub="Universal, works everywhere" />
<FormatOption value="excel" current={format} onChange={setFormat}
icon={<FaFileExcel className="text-green-700 text-xl" />}
label="Excel (.xlsx)" sub="Native Excel workbook" />
</div>
</div>
{/* Column picker */}
<div>
<div className="flex items-center justify-between mb-2">
<h4 className="text-sm font-semibold text-gray-700 dark:text-gray-300">
Columns to export
{fieldsLoading && <FaSpinner className="inline ml-2 animate-spin text-gray-400" size={12} />}
</h4>
<div className="flex gap-3 text-xs text-blue-600 dark:text-blue-400">
<button onClick={selectAll} className="hover:underline">All</button>
<button onClick={selectDefault} className="hover:underline">Default</button>
<button onClick={selectNone} className="hover:underline">None</button>
</div>
</div>
{/* Search */}
<div className="relative mb-2">
<FaSearch className="absolute left-2.5 top-1/2 -translate-y-1/2 text-gray-400" size={12} />
<input
type="text"
value={search}
onChange={e => setSearch(e.target.value)}
placeholder="Search fields…"
className="w-full pl-8 pr-3 py-1.5 text-xs border border-gray-300 dark:border-gray-600 rounded-md focus:outline-none focus:ring-2 focus:ring-green-500 bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
/>
{search && (
<button onClick={() => setSearch('')} className="absolute right-2 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600">
<FaTimes size={10} />
</button>
)}
</div>
{/* Field grid */}
{fieldsLoading ? (
<div className="flex items-center justify-center h-24 text-gray-400 text-sm gap-2">
<FaSpinner className="animate-spin" /> Loading fields
</div>
) : (
<div className="grid grid-cols-2 md:grid-cols-3 gap-1.5 max-h-52 overflow-y-auto p-2 bg-gray-50 dark:bg-gray-900/50 rounded-lg border border-gray-200 dark:border-gray-700">
{filteredColumns.map(col => {
const checked = checkedKeys.has(col.key);
return (
<div
key={col.key}
onClick={() => toggleColumn(col.key)}
className={`flex items-center gap-2 px-2 py-1.5 rounded cursor-pointer transition-all text-xs select-none ${
checked
? 'bg-green-100 dark:bg-green-900/30 text-green-800 dark:text-green-200'
: 'hover:bg-gray-100 dark:hover:bg-gray-800 text-gray-600 dark:text-gray-400'
}`}
>
<span className="flex-shrink-0">
{checked
? <FaCheckSquare size={13} className="text-green-600" />
: <FaSquare size={13} className="text-gray-300 dark:text-gray-600" />}
</span>
<span className="truncate" title={`${col.label} (${col.key})`}>
{col.label}
</span>
</div>
);
})}
{filteredColumns.length === 0 && (
<p className="col-span-3 text-center text-gray-400 text-xs py-4">
No fields match "{search}"
</p>
)}
</div>
)}
<p className="text-xs text-gray-400 mt-1.5">
{checkedKeys.size} of {allColumns.length} fields selected
{search && ` · showing ${filteredColumns.length} matching`}
</p>
</div>
</div>
{/* ── Footer ── */}
<div className="flex-shrink-0 px-6 py-4 bg-gray-50 dark:bg-gray-900/50 border-t border-gray-200 dark:border-gray-700 rounded-b-lg flex justify-between items-center">
<p className="text-xs text-gray-500 dark:text-gray-400">
{scope === 'selected' && `Exporting ${selectedCount} selected row${selectedCount !== 1 ? 's' : ''}`}
{scope === 'all_on_page' && `Exporting ${pageCount} rows from current page`}
{scope === 'all_with_filters' && `Exporting up to ${totalCount} records`}
</p>
<div className="flex gap-3">
<button
onClick={onClose}
disabled={isExporting}
className="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors"
>
Cancel
</button>
<button
onClick={handleExport}
disabled={checkedKeys.size === 0 || isExporting}
className="px-4 py-2 text-sm font-medium text-white bg-green-600 hover:bg-green-700 rounded-lg transition-colors flex items-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed"
>
{isExporting ? (
<><FaSpinner className="animate-spin" size={14} /> Exporting</>
) : (
<><FaDownload size={14} /> Export</>
)}
</button>
</div>
</div>
</div>
</div>
);
};
// ─────────────────────────────────────────────
// Sub-components
// ─────────────────────────────────────────────
const BADGE_COLORS: Record<string, string> = {
green: 'bg-green-100 dark:bg-green-900/40 text-green-700 dark:text-green-300',
blue: 'bg-blue-100 dark:bg-blue-900/40 text-blue-700 dark:text-blue-300',
purple: 'bg-purple-100 dark:bg-purple-900/40 text-purple-700 dark:text-purple-300',
};
interface ScopeOptionProps {
value: ExportScope;
current: ExportScope;
onChange: (v: ExportScope) => void;
disabled?: boolean;
label: string;
sub: string;
badge: number;
badgeColor: 'green' | 'blue' | 'purple';
}
const ScopeOption: React.FC<ScopeOptionProps> = ({ value, current, onChange, disabled, label, sub, badge, badgeColor }) => (
<label className={`flex items-center gap-3 p-3 rounded-lg border cursor-pointer transition-all ${
current === value
? 'border-green-500 bg-green-50 dark:bg-green-900/20'
: 'border-gray-200 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-700/50'
} ${disabled ? 'opacity-50 cursor-not-allowed' : ''}`}>
<input
type="radio" name="export_scope" value={value}
checked={current === value}
onChange={() => !disabled && onChange(value)}
disabled={disabled}
className="text-green-600 focus:ring-green-500"
/>
<div className="flex-1 min-w-0">
<div className="font-medium text-sm text-gray-900 dark:text-white">{label}</div>
<div className="text-xs text-gray-500 dark:text-gray-400">{sub}</div>
</div>
<span className={`px-2 py-0.5 rounded text-xs font-semibold flex-shrink-0 ${BADGE_COLORS[badgeColor]}`}>
{badge.toLocaleString()}
</span>
</label>
);
interface FormatOptionProps {
value: ExportFormat;
current: ExportFormat;
onChange: (v: ExportFormat) => void;
icon: React.ReactNode;
label: string;
sub: string;
}
const FormatOption: React.FC<FormatOptionProps> = ({ value, current, onChange, icon, label, sub }) => (
<label className={`flex-1 flex items-center gap-3 p-3 rounded-lg border cursor-pointer transition-all ${
current === value
? 'border-green-500 bg-green-50 dark:bg-green-900/20'
: 'border-gray-200 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-700/50'
}`}>
<input
type="radio" name="export_format" value={value}
checked={current === value}
onChange={() => onChange(value)}
className="text-green-600 focus:ring-green-500"
/>
{icon}
<div>
<div className="font-medium text-sm text-gray-900 dark:text-white">{label}</div>
<div className="text-xs text-gray-500 dark:text-gray-400">{sub}</div>
</div>
</label>
);
export default DynamicExportModal;

View File

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

View File

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

View File

@ -0,0 +1,103 @@
import React from 'react';
import { useNavigate } from 'react-router-dom';
import { useTheme } from '../contexts/ThemeContext';
import { useLanguage } from '../contexts/LanguageContext';
import { useTranslation } from 'react-i18next';
import { Moon, Sun, Languages, LogOut } from 'lucide-react';
import NotificationBell from './NotificationBell';
interface HeaderProps {
userEmail?: string;
}
const Header: React.FC<HeaderProps> = ({ userEmail }) => {
const navigate = useNavigate();
const { theme, toggleTheme } = useTheme();
const { language, changeLanguage } = useLanguage();
const { t } = useTranslation();
// const handleLogout = () => {
// localStorage.removeItem('user');
// localStorage.removeItem('sid');
// navigate('/login');
// };
const handleLogout = async () => {
localStorage.removeItem('user');
localStorage.removeItem('sid');
try {
const csrfToken = document.cookie
.split('; ')
.find(row => row.startsWith('X-Frappe-CSRF-Token='))
?.split('=')[1] || '';
// Step 1: Kill server-side session in Redis
await fetch('/api/method/frappe.auth.logout', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Frappe-CSRF-Token': csrfToken,
},
credentials: 'include',
});
// Step 2: Clear Frappe web session cookies fully
await fetch('/?cmd=web_logout', {
credentials: 'include',
});
} catch (err) {
console.error('Logout error:', err);
} finally {
window.location.href = '/asm_app/login';
}
};
return (
<header className="h-14 bg-white dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700 px-4 flex items-center justify-end gap-2 flex-shrink-0">
{/* User Email (optional, can be shown on hover or always) */}
{/* {userEmail && (
<div className="hidden md:block text-sm text-gray-600 dark:text-gray-400 mr-2">
{userEmail}
</div>
)} */}
{/* Notification Bell */}
<div className="relative">
<NotificationBell />
</div>
{/* Language Toggle */}
<button
onClick={() => changeLanguage(language === 'en' ? 'ar' : 'en')}
className="p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors text-gray-700 dark:text-gray-300"
title={t('common.language')}
>
<Languages size={20} />
</button>
{/* Theme Toggle */}
<button
onClick={toggleTheme}
className="p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors text-gray-700 dark:text-gray-300"
title={theme === 'light' ? t('common.darkMode') : t('common.lightMode')}
>
{theme === 'light' ? <Moon size={20} /> : <Sun size={20} />}
</button>
{/* Logout */}
<button
onClick={handleLogout}
className="p-2 rounded-lg hover:bg-red-50 dark:hover:bg-red-900/20 transition-colors text-red-600 dark:text-red-400"
title={t('common.logout')}
>
<LogOut size={20} />
</button>
</header>
);
};
export default Header;

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,469 @@
import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react';
import { createPortal } from 'react-dom';
import { useTranslation } from 'react-i18next';
import { FaPlus } from 'react-icons/fa';
import apiService from '../services/apiService';
import { supportsQuickCreate } from '../components/QuickCreateConfig';
import { hasCreatePermission } from '../services/permissionService';
import QuickCreateModal from '../components/QuickCreateModal';
interface LinkFieldProps {
label: string;
doctype: string;
value: string;
onChange: (value: string) => void;
placeholder?: string;
disabled?: boolean;
filters?: Record<string, any>;
compact?: boolean;
usePortal?: boolean;
// New props for QuickCreate functionality
allowQuickCreate?: boolean; // Enable/disable quick create (default: false)
onQuickCreateSuccess?: (newRecord: any) => void; // Callback after quick create
quickCreateInitialValues?: Record<string, any>; // Initial values for quick create form
query?: string;
}
// Stable empty object to avoid re-renders
const EMPTY_FILTERS: Record<string, any> = {};
const LinkField: React.FC<LinkFieldProps> = ({
label,
doctype,
value,
onChange,
placeholder,
disabled = false,
filters,
compact = false,
usePortal = false,
// QuickCreate props with defaults
allowQuickCreate = false, // Default to false - must explicitly enable per field
onQuickCreateSuccess,
quickCreateInitialValues = {},
query,
}) => {
const { t } = useTranslation();
const [searchResults, setSearchResults] = useState<{ value: string; description?: string }[]>([]);
const [searchText, setSearchText] = useState('');
const [isDropdownOpen, setDropdownOpen] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const [dropdownPosition, setDropdownPosition] = useState<{ top: number; left: number; width: number }>({ top: 0, left: 0, width: 0 });
// QuickCreate modal state
const [showQuickCreate, setShowQuickCreate] = useState(false);
// Permission state for QuickCreate
// null = not checked yet, true = allowed, false = denied
const [hasPermission, setHasPermission] = useState<boolean | null>(null);
const containerRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
const dropdownRef = useRef<HTMLDivElement>(null);
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const lastSearchRef = useRef<string>('');
const hasLoadedRef = useRef<boolean>(false);
// Use stable empty object if filters not provided
const stableFilters = filters || EMPTY_FILTERS;
// Stringify filters for comparison (avoid object reference issues)
const filtersKey = useMemo(() => JSON.stringify(stableFilters), [stableFilters]);
// Check if doctype has QuickCreate config
const hasQuickCreateConfig = useMemo(() => {
const supported = supportsQuickCreate(doctype);
console.log(`[LinkField] ${doctype} hasQuickCreateConfig: ${supported}`);
return supported;
}, [doctype]);
// Check permission ONLY when allowQuickCreate is enabled AND doctype has config
useEffect(() => {
// Reset state when doctype or allowQuickCreate changes
setHasPermission(null);
// Only check permission if allowQuickCreate is true AND doctype has config
if (allowQuickCreate && hasQuickCreateConfig) {
console.log(`[LinkField] Checking permission for ${doctype}...`);
hasCreatePermission(doctype)
.then((result) => {
console.log(`[LinkField] Permission for ${doctype}: ${result}`);
setHasPermission(result);
})
.catch((err) => {
console.error(`[LinkField] Permission check failed for ${doctype}:`, err);
setHasPermission(false);
});
} else {
// If allowQuickCreate is false or no config, don't show button
setHasPermission(false);
if (allowQuickCreate && !hasQuickCreateConfig) {
console.warn(`[LinkField] ${doctype}: allowQuickCreate=true but no config in QuickCreateConfig.ts`);
}
}
}, [allowQuickCreate, doctype, hasQuickCreateConfig]);
// Final check: show button only if ALL conditions are met:
// 1. allowQuickCreate={true} is set on the field
// 2. Doctype has config in QuickCreateConfig.ts
// 3. Permission check passed (hasPermission === true)
const canQuickCreate = useMemo(() => {
const result = allowQuickCreate && hasQuickCreateConfig && hasPermission === true;
console.log(`[LinkField] canQuickCreate for ${doctype}: ${result}`, {
allowQuickCreate,
hasQuickCreateConfig,
hasPermission
});
return result;
}, [allowQuickCreate, hasQuickCreateConfig, hasPermission, doctype]);
/// Fetch link options from ERPNext with filters
const searchLink = useCallback(async (text: string = '', force: boolean = false) => {
// Prevent duplicate calls for the same search text
const searchKey = `${text}-${filtersKey}-${query || ''}`;
if (!force && lastSearchRef.current === searchKey) {
return;
}
lastSearchRef.current = searchKey;
setIsLoading(true);
try {
let response: { value: string; description?: string }[] | null = null;
if (query) {
// Use custom query method
const params = new URLSearchParams({
txt: text,
doctype: doctype,
searchfield: 'name',
start: '0',
page_len: '50',
});
// Add filters if provided
if (stableFilters && Object.keys(stableFilters).length > 0) {
params.append('filters', JSON.stringify(stableFilters));
}
const customResponse = await apiService.apiCall<any>(
`/api/method/${query}?${params.toString()}`
);
// Custom query returns array of arrays: [[value, description], ...]
// Convert to expected format
if (Array.isArray(customResponse)) {
response = customResponse.map((item: any) => {
if (Array.isArray(item)) {
return { value: item[0], description: item[1] || undefined };
}
return { value: item.value || item.name || item, description: item.description };
});
} else {
response = [];
}
} else {
// Use standard Frappe search_link
const params = new URLSearchParams({
doctype,
txt: text,
page_length: '50',
});
// Add filters if provided
if (stableFilters && Object.keys(stableFilters).length > 0) {
params.append('filters', JSON.stringify(stableFilters));
}
response = await apiService.apiCall<{ value: string; description?: string }[]>(
`/api/method/frappe.desk.search.search_link?${params.toString()}`
);
}
setSearchResults(response || []);
} catch (error) {
console.error(`Error fetching ${doctype} links:`, error);
setSearchResults([]);
} finally {
setIsLoading(false);
}
}, [doctype, filtersKey, stableFilters, query]);
// Debounced search for typing
const debouncedSearch = useCallback((text: string) => {
if (debounceRef.current) {
clearTimeout(debounceRef.current);
}
debounceRef.current = setTimeout(() => {
searchLink(text);
}, 300);
}, [searchLink]);
// Fetch default options ONLY when dropdown first opens
useEffect(() => {
if (isDropdownOpen && !hasLoadedRef.current) {
hasLoadedRef.current = true;
searchLink(searchText || '', true);
}
// Reset the loaded flag when dropdown closes
if (!isDropdownOpen) {
hasLoadedRef.current = false;
lastSearchRef.current = '';
}
}, [isDropdownOpen]); // Only depend on isDropdownOpen
// Cleanup debounce on unmount
useEffect(() => {
return () => {
if (debounceRef.current) {
clearTimeout(debounceRef.current);
}
};
}, []);
// Calculate dropdown position for portal rendering
const updateDropdownPosition = useCallback(() => {
if (usePortal && inputRef.current) {
const rect = inputRef.current.getBoundingClientRect();
setDropdownPosition({
top: rect.bottom + window.scrollY,
left: rect.left + window.scrollX,
width: rect.width
});
}
}, [usePortal]);
// Update position when dropdown opens or on scroll/resize
useEffect(() => {
if (isDropdownOpen && usePortal) {
updateDropdownPosition();
const handleUpdate = () => updateDropdownPosition();
window.addEventListener('scroll', handleUpdate, true);
window.addEventListener('resize', handleUpdate);
return () => {
window.removeEventListener('scroll', handleUpdate, true);
window.removeEventListener('resize', handleUpdate);
};
}
}, [isDropdownOpen, usePortal, updateDropdownPosition]);
// Close dropdown when clicking outside
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
const target = event.target as Node;
const clickedOutsideContainer = containerRef.current && !containerRef.current.contains(target);
const clickedOutsideDropdown = usePortal && dropdownRef.current && !dropdownRef.current.contains(target);
// Close if clicked outside both container and dropdown (when using portal)
if (usePortal) {
if (clickedOutsideContainer && clickedOutsideDropdown) {
setDropdownOpen(false);
setSearchText('');
}
} else {
if (clickedOutsideContainer) {
setDropdownOpen(false);
setSearchText('');
}
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, [usePortal]);
// Handle selecting an item from dropdown
const handleSelect = (selectedValue: string) => {
onChange(selectedValue);
setSearchText('');
setDropdownOpen(false);
};
// Handle clearing the field
const handleClear = () => {
onChange('');
setSearchText('');
setDropdownOpen(false);
};
// Handle opening QuickCreate modal
const handleOpenQuickCreate = () => {
setDropdownOpen(false);
setSearchText('');
setShowQuickCreate(true);
};
// Handle QuickCreate success
const handleQuickCreateSuccess = (newRecord: any) => {
// Get the name/value from the new record
const newValue = newRecord.name || newRecord[Object.keys(newRecord)[0]];
handleSelect(newValue);
// Call external callback if provided
if (onQuickCreateSuccess) {
onQuickCreateSuccess(newRecord);
}
};
// Render dropdown content
const renderDropdown = () => {
const dropdownClasses = `bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600
rounded-md w-full shadow-lg ${compact ? 'mt-0.5' : 'mt-1'}`;
const positionStyle = usePortal ? {
position: 'fixed' as const,
top: `${dropdownPosition.top}px`,
left: `${dropdownPosition.left}px`,
width: `${dropdownPosition.width}px`,
zIndex: 1050,
marginTop: compact ? '2px' : '4px'
} : {};
if (!isDropdownOpen || disabled) return null;
const dropdownContent = (
<div ref={dropdownRef}>
{/* Loading indicator */}
{isLoading && (
<div className={`${usePortal ? '' : 'absolute z-[1050]'} ${dropdownClasses} text-center text-gray-500 dark:text-gray-400
${compact ? 'p-1.5 text-[10px]' : 'p-3 text-sm'}`}
style={positionStyle}>
<span className="inline-block animate-spin mr-2"></span>
{t('linkField.loading')}
</div>
)}
{/* Results list with QuickCreate option */}
{!isLoading && (
<div className={`${usePortal ? '' : 'absolute z-[1050]'} ${dropdownClasses} overflow-hidden`}
style={positionStyle}>
{/* Results */}
{searchResults.length > 0 ? (
<ul className={`overflow-auto ${compact ? 'max-h-36' : 'max-h-48'}`}>
{searchResults.map((item, idx) => (
<li
key={idx}
onClick={() => handleSelect(item.value)}
className={`cursor-pointer text-gray-900 dark:text-gray-100
hover:bg-blue-500 dark:hover:bg-blue-600 hover:text-white
${compact ? 'px-2 py-1 text-xs' : 'px-3 py-2 text-sm'}
${value === item.value ? 'bg-blue-50 dark:bg-blue-700 font-semibold' : ''}`}
>
{item.value}
{item.description && (
<span className={`text-gray-600 dark:text-gray-300 ml-2
${compact ? 'text-[9px] ml-1' : 'text-xs ml-2'}`}>
{item.description}
</span>
)}
</li>
))}
</ul>
) : (
<div className={`text-center text-gray-500 dark:text-gray-400
${compact ? 'p-1.5 text-[10px]' : 'p-3 text-sm'}`}>
{t('linkField.noResultsFound')}
</div>
)}
{/* QuickCreate Button - Only shows if all conditions are met */}
{canQuickCreate && (
<>
<div className="border-t border-gray-200 dark:border-gray-700" />
<div
onClick={handleOpenQuickCreate}
className={`cursor-pointer flex items-center gap-2
text-green-600 dark:text-green-400
hover:bg-green-50 dark:hover:bg-green-900/20
hover:text-green-700 dark:hover:text-green-300
transition-colors
${compact ? 'px-2 py-1.5 text-xs' : 'px-3 py-2.5 text-sm'}`}
>
<FaPlus size={compact ? 10 : 12} />
<span className="font-medium">
{t('linkField.createNewDoctype', { doctype: doctype.replace(/_/g, ' ') })}
</span>
</div>
</>
)}
</div>
)}
</div>
);
return usePortal ? createPortal(dropdownContent, document.body) : dropdownContent;
};
return (
<>
<div ref={containerRef} className={`relative w-full ${compact ? 'mb-2' : 'mb-4'}`}>
<label className={`block font-medium text-gray-700 dark:text-gray-300 ${compact ? 'text-[10px] mb-0.5' : 'text-sm mb-1'}`}>
{label}
</label>
<div className="relative">
<input
ref={inputRef}
type="text"
value={isDropdownOpen ? searchText : value}
placeholder={placeholder || t('linkField.selectLabel', { label })}
disabled={disabled}
className={`w-full border border-gray-300 dark:border-gray-600 rounded-md
focus:outline-none disabled:bg-gray-100 dark:disabled:bg-gray-700
bg-white dark:bg-gray-700 text-gray-900 dark:text-white
${compact
? 'px-2 py-1 text-xs focus:ring-1 focus:ring-blue-500 rounded'
: 'px-3 py-2 text-sm focus:ring-2 focus:ring-blue-500'
}
${value ? (compact ? 'pr-5' : 'pr-8') : ''}`}
onFocus={() => {
if (!disabled) {
setDropdownOpen(true);
setSearchText('');
if (usePortal) {
updateDropdownPosition();
}
}
}}
onChange={(e) => {
const text = e.target.value;
setSearchText(text);
debouncedSearch(text);
}}
/>
{/* Clear button */}
{value && !disabled && !isDropdownOpen && (
<button
type="button"
onClick={handleClear}
className={`absolute top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300
${compact ? 'right-1 text-xs' : 'right-2 text-sm'}`}
>
</button>
)}
</div>
{/* Render dropdown */}
{renderDropdown()}
</div>
{/* QuickCreate Modal */}
<QuickCreateModal
doctype={doctype}
isOpen={showQuickCreate}
onClose={() => setShowQuickCreate(false)}
onSuccess={handleQuickCreateSuccess}
initialValues={quickCreateInitialValues}
parentFilters={stableFilters}
/>
</>
);
};
export default LinkField;

View File

@ -0,0 +1,151 @@
import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
export interface ListPaginationProps {
/** Current page (1-based for display) */
currentPage: number;
/** Total number of items (optional - when missing we don't show "of N" or page numbers) */
totalCount?: number;
/** Page size (limit per page) */
pageSize: number;
/** Whether there is a next page (when totalCount is not available) */
hasMore?: boolean;
/** Label for the list, e.g. "items", "issues" */
itemLabel?: string;
/** Callback when user changes page. Receives 1-based page number. */
onPageChange: (page: number) => void;
/** Optional class for the container */
className?: string;
}
/**
* Reusable list pagination: Previous/Next, optional page number buttons,
* "Go to page" input, and "Showing X to Y of Z" text.
* Page is 1-based in this component (URLs and display).
*/
const ListPagination: React.FC<ListPaginationProps> = ({
currentPage,
totalCount = 0,
pageSize,
hasMore = false,
itemLabel,
onPageChange,
className = '',
}) => {
const { t } = useTranslation();
const displayLabel = itemLabel ?? t('listPages.results');
const totalPages = totalCount > 0 ? Math.max(1, Math.ceil(totalCount / pageSize)) : 0;
const hasTotal = totalCount > 0;
const start = (currentPage - 1) * pageSize + 1;
const end = hasTotal
? Math.min(currentPage * pageSize, totalCount)
: currentPage * pageSize;
const [goToInput, setGoToInput] = useState('');
const handleGoToSubmit = (e: React.FormEvent) => {
e.preventDefault();
const num = parseInt(goToInput.trim(), 10);
if (!Number.isNaN(num) && num >= 1) {
const target = hasTotal ? Math.min(num, totalPages) : num;
onPageChange(target);
setGoToInput('');
}
};
// Page numbers to show: first, last, and window around current (e.g. 1 ... 4 5 6 ... 20)
const getPageNumbers = (): (number | 'ellipsis')[] => {
if (totalPages <= 7) {
return Array.from({ length: totalPages }, (_, i) => i + 1);
}
const pages: (number | 'ellipsis')[] = [];
pages.push(1);
if (currentPage > 3) pages.push('ellipsis');
for (let p = Math.max(2, currentPage - 1); p <= Math.min(totalPages - 1, currentPage + 1); p++) {
if (!pages.includes(p)) pages.push(p);
}
if (currentPage < totalPages - 2) pages.push('ellipsis');
if (totalPages > 1) pages.push(totalPages);
return pages;
};
const showPagination = hasMore || currentPage > 1 || (hasTotal && totalPages > 1);
if (!showPagination) return null;
return (
<div className={`flex flex-wrap items-center justify-between gap-3 px-6 py-4 border-t border-gray-200 dark:border-gray-700 ${className}`}>
<div className="text-sm text-gray-700 dark:text-gray-300">
{hasTotal
? t('pagination.showingToOf', { start, end, total: totalCount, label: displayLabel })
: t('pagination.showingTo', { start, end, label: displayLabel })}
</div>
<div className="flex flex-wrap items-center gap-2">
<button
type="button"
onClick={() => onPageChange(currentPage - 1)}
disabled={currentPage <= 1}
className="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-600 disabled:opacity-50 disabled:cursor-not-allowed"
>
{t('pagination.previous')}
</button>
{hasTotal && totalPages > 1 && (
<div className="flex items-center gap-1">
{getPageNumbers().map((p, i) =>
p === 'ellipsis' ? (
<span key={`e-${i}`} className="px-2 text-gray-500 dark:text-gray-400">
</span>
) : (
<button
key={p}
type="button"
onClick={() => onPageChange(p)}
className={`min-w-[2rem] px-2 py-1 text-sm font-medium rounded-lg transition-colors ${
p === currentPage
? 'bg-blue-600 text-white border border-blue-600'
: 'text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-600'
}`}
>
{p}
</button>
)
)}
</div>
)}
<button
type="button"
onClick={() => onPageChange(currentPage + 1)}
disabled={hasTotal ? currentPage >= totalPages : !hasMore}
className="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-600 disabled:opacity-50 disabled:cursor-not-allowed"
>
{t('pagination.next')}
</button>
<form onSubmit={handleGoToSubmit} className="flex items-center gap-1 ml-2">
<span className="text-sm text-gray-600 dark:text-gray-400 whitespace-nowrap">{t('pagination.goTo')}</span>
<input
type="number"
min={1}
max={hasTotal ? totalPages : undefined}
value={goToInput}
onChange={(e) => setGoToInput(e.target.value)}
placeholder={hasTotal ? `1-${totalPages}` : t('pagination.page')}
className="w-14 px-2 py-1 text-sm border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-blue-500 focus:border-transparent"
/>
<button
type="submit"
className="px-2 py-1 text-sm font-medium text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300 border border-gray-300 dark:border-gray-600 rounded hover:bg-gray-50 dark:hover:bg-gray-700"
>
{t('pagination.go')}
</button>
</form>
</div>
</div>
);
};
export default ListPagination;

View File

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

View File

@ -0,0 +1,361 @@
import React, { useState, useRef, useEffect, useCallback, type KeyboardEvent } from 'react';
import { FaSpinner, FaUser } from 'react-icons/fa';
import type { MentionUser } from '../services/commentService';
import API_CONFIG from '../config/api';
// ============================================================
// MentionInput a textarea that shows a dropdown when user
// types '@' and lets them pick a user to @mention.
//
// The final output is an HTML string with Frappe-style mention
// markup so ERPNext recognises it exactly like its own editor.
// ============================================================
interface MentionInputProps {
value: string; // plain-text draft
onChange: (text: string) => void;
onSubmit: (html: string) => void; // returns formatted HTML
placeholder?: string;
disabled?: boolean;
mentionUsers: MentionUser[];
mentionLoading: boolean;
onMentionSearch: (query: string) => void;
posting?: boolean;
}
/** Stores an inserted mention so we can convert to HTML later */
interface InsertedMention {
startIndex: number;
displayText: string;
userId: string; // email
fullName: string;
}
const MentionInput: React.FC<MentionInputProps> = ({
value,
onChange,
onSubmit,
placeholder = 'Type a comment… Use @ to mention someone',
disabled = false,
mentionUsers,
mentionLoading,
onMentionSearch,
posting = false,
}) => {
const textareaRef = useRef<HTMLTextAreaElement>(null);
const dropdownRef = useRef<HTMLDivElement>(null);
// Mention trigger state
const [showMentionDropdown, setShowMentionDropdown] = useState(false);
const [mentionQuery, setMentionQuery] = useState('');
const [mentionStartPos, setMentionStartPos] = useState<number | null>(null);
const [selectedIndex, setSelectedIndex] = useState(0);
const [insertedMentions, setInsertedMentions] = useState<InsertedMention[]>([]);
// Dropdown position
const [dropdownPos, setDropdownPos] = useState({ top: 0, left: 0 });
// ── Calculate dropdown position relative to textarea ────
const updateDropdownPosition = useCallback(() => {
const ta = textareaRef.current;
if (!ta) return;
// Place the dropdown above the textarea bottom
const rect = ta.getBoundingClientRect();
const parentRect = ta.offsetParent?.getBoundingClientRect() ?? rect;
setDropdownPos({
top: ta.offsetTop - 4, // above textarea
left: ta.offsetLeft,
});
}, []);
// ── Handle text changes ─────────────────────────────────
const handleChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
const newValue = e.target.value;
const cursorPos = e.target.selectionStart ?? 0;
onChange(newValue);
// Check for active mention trigger
const textBeforeCursor = newValue.substring(0, cursorPos);
const lastAtIndex = textBeforeCursor.lastIndexOf('@');
if (lastAtIndex !== -1) {
// Check that @ is at start or preceded by a space/newline
const charBefore = lastAtIndex > 0 ? newValue[lastAtIndex - 1] : ' ';
if (charBefore === ' ' || charBefore === '\n' || lastAtIndex === 0) {
const query = textBeforeCursor.substring(lastAtIndex + 1);
// Only activate if query doesn't contain spaces (single-word search)
if (!query.includes(' ') || query.length <= 30) {
setShowMentionDropdown(true);
setMentionQuery(query);
setMentionStartPos(lastAtIndex);
setSelectedIndex(0);
onMentionSearch(query);
updateDropdownPosition();
return;
}
}
}
// No active mention
setShowMentionDropdown(false);
setMentionStartPos(null);
};
// ── Insert a mention into the text ──────────────────────
const insertMention = useCallback(
(user: MentionUser) => {
if (mentionStartPos === null) return;
const ta = textareaRef.current;
const before = value.substring(0, mentionStartPos);
const cursorPos = ta?.selectionStart ?? mentionStartPos + mentionQuery.length + 1;
const after = value.substring(cursorPos);
const displayText = user.full_name || user.name;
const newText = `${before}@${displayText} ${after}`;
// Track the mention
setInsertedMentions((prev) => [
...prev,
{
startIndex: mentionStartPos,
displayText,
userId: user.name,
fullName: user.full_name || user.name,
},
]);
onChange(newText);
setShowMentionDropdown(false);
setMentionStartPos(null);
setMentionQuery('');
// Refocus and place cursor after mention
setTimeout(() => {
if (ta) {
ta.focus();
const newPos = before.length + displayText.length + 2; // +2 for @ and space
ta.selectionStart = newPos;
ta.selectionEnd = newPos;
}
}, 0);
},
[mentionStartPos, mentionQuery, value, onChange]
);
// ── Keyboard navigation inside dropdown ─────────────────
const handleKeyDown = (e: KeyboardEvent<HTMLTextAreaElement>) => {
if (showMentionDropdown && mentionUsers.length > 0) {
switch (e.key) {
case 'ArrowDown':
e.preventDefault();
setSelectedIndex((prev) => Math.min(prev + 1, mentionUsers.length - 1));
return;
case 'ArrowUp':
e.preventDefault();
setSelectedIndex((prev) => Math.max(prev - 1, 0));
return;
case 'Enter':
e.preventDefault();
insertMention(mentionUsers[selectedIndex]);
return;
case 'Escape':
e.preventDefault();
setShowMentionDropdown(false);
return;
}
}
// Ctrl/Cmd + Enter to submit
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) {
e.preventDefault();
handleSubmit();
}
};
// ── Build Frappe-compatible HTML from plain text + mentions ─
const buildHtml = useCallback(
(text: string): string => {
let html = text;
const baseUrl = API_CONFIG.BASE_URL || window.location.origin;
// Replace each tracked @mention with the Frappe mention markup
// Process in reverse order of startIndex so positions don't shift
const sorted = [...insertedMentions].sort((a, b) => b.startIndex - a.startIndex);
for (const m of sorted) {
const mentionText = `@${m.displayText}`;
const idx = html.indexOf(mentionText);
if (idx === -1) continue;
const profileUrl = `${baseUrl}/app/user-profile/${encodeURIComponent(m.userId)}`;
const mentionHtml =
`<span class="mention" ` +
`data-id="${m.userId}" ` +
`data-value="&lt;a href=&quot;${profileUrl}&quot; target=&quot;_blank&quot;&gt;${m.fullName}" ` +
`data-denotation-char="@" ` +
`data-is-group="false" ` +
`data-link="${profileUrl}">` +
`\uFEFF<span contenteditable="false">` +
`<span class="ql-mention-denotation-char">@</span>` +
`<a href="${profileUrl}" target="_blank">${m.fullName}</a>` +
`</span>\uFEFF</span>`;
html = html.substring(0, idx) + mentionHtml + html.substring(idx + mentionText.length);
}
// Escape remaining HTML chars (basic), then wrap newlines
// We do NOT escape the mention markup we just inserted
// Instead, split by mention spans, escape non-mention parts, and rejoin
// Simple approach: since mentions are already HTML, just convert newlines to <br>
html = html.replace(/\n/g, '<br>');
return `<div class="ql-editor read-mode"><p>${html}</p></div>`;
},
[insertedMentions]
);
// ── Submit handler ──────────────────────────────────────
const handleSubmit = () => {
const trimmed = value.trim();
if (!trimmed || posting) return;
const html = buildHtml(trimmed);
onSubmit(html);
setInsertedMentions([]);
};
// ── Close dropdown on outside click ─────────────────────
useEffect(() => {
const handleClickOutside = (e: MouseEvent) => {
if (
dropdownRef.current &&
!dropdownRef.current.contains(e.target as Node) &&
textareaRef.current &&
!textareaRef.current.contains(e.target as Node)
) {
setShowMentionDropdown(false);
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, []);
// ── Scroll selected item into view ──────────────────────
useEffect(() => {
if (!dropdownRef.current) return;
const item = dropdownRef.current.querySelector(`[data-idx="${selectedIndex}"]`);
item?.scrollIntoView({ block: 'nearest' });
}, [selectedIndex]);
const baseUrl = API_CONFIG.BASE_URL || '';
return (
<div className="relative">
{/* ── Mention dropdown ──────────────────────────────── */}
{showMentionDropdown && (
<div
ref={dropdownRef}
className="absolute z-50 bottom-full mb-1 w-72 max-h-52 overflow-y-auto
bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-600
rounded-lg shadow-lg"
style={{ left: 0 }}
>
{mentionLoading && mentionUsers.length === 0 ? (
<div className="flex items-center gap-2 px-3 py-4 text-sm text-gray-500 dark:text-gray-400">
<FaSpinner className="animate-spin" size={12} />
Searching users
</div>
) : mentionUsers.length === 0 ? (
<div className="px-3 py-4 text-sm text-gray-500 dark:text-gray-400">
No users found
</div>
) : (
mentionUsers.map((user, idx) => (
<button
key={user.name}
data-idx={idx}
type="button"
className={`w-full flex items-center gap-2.5 px-3 py-2 text-left transition-colors
${idx === selectedIndex
? 'bg-teal-50 dark:bg-teal-900/30 text-teal-800 dark:text-teal-200'
: 'text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700'
}`}
onMouseEnter={() => setSelectedIndex(idx)}
onMouseDown={(e) => {
e.preventDefault(); // keep focus on textarea
insertMention(user);
}}
>
{/* Avatar */}
{user.user_image ? (
<img
src={`${baseUrl}${user.user_image}`}
alt=""
className="w-7 h-7 rounded-full object-cover flex-shrink-0"
/>
) : (
<div className="w-7 h-7 rounded-full bg-gray-200 dark:bg-gray-600 flex items-center justify-center flex-shrink-0">
<FaUser className="text-gray-500 dark:text-gray-400" size={10} />
</div>
)}
<div className="min-w-0 flex-1">
<p className="text-sm font-medium truncate">
{user.full_name || user.name}
</p>
<p className="text-xs text-gray-500 dark:text-gray-400 truncate">
{user.name}
</p>
</div>
</button>
))
)}
</div>
)}
{/* ── Textarea + submit ──────────────────────────────── */}
<div className="flex gap-2 items-end">
<textarea
ref={textareaRef}
value={value}
onChange={handleChange}
onKeyDown={handleKeyDown}
placeholder={placeholder}
disabled={disabled || posting}
rows={3}
className="flex-1 px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg
bg-white dark:bg-gray-700 text-gray-900 dark:text-white text-sm
disabled:bg-gray-100 dark:disabled:bg-gray-800
focus:outline-none focus:ring-2 focus:ring-teal-500 resize-none
placeholder:text-gray-400 dark:placeholder:text-gray-500"
/>
<button
type="button"
onClick={handleSubmit}
disabled={disabled || posting || !value.trim()}
className="px-4 py-2 bg-teal-600 hover:bg-teal-700 disabled:bg-teal-600/50
text-white text-sm font-medium rounded-lg transition-colors
disabled:cursor-not-allowed flex items-center gap-1.5 h-10 flex-shrink-0"
>
{posting ? (
<>
<FaSpinner className="animate-spin" size={12} />
<span>Posting</span>
</>
) : (
<span>Comment</span>
)}
</button>
</div>
{/* Hint */}
<p className="mt-1 text-[10px] text-gray-400 dark:text-gray-500">
<kbd className="px-1 py-0.5 bg-gray-100 dark:bg-gray-700 rounded text-[9px]">@</kbd> to mention
&nbsp;·&nbsp;
<kbd className="px-1 py-0.5 bg-gray-100 dark:bg-gray-700 rounded text-[9px]">Ctrl+Enter</kbd> to post
</p>
</div>
);
};
export default MentionInput;

View File

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

View File

@ -0,0 +1,455 @@
/**
* QuickCreate Configuration
*
* This file defines the configuration for quick-creating new records
* from LinkField dropdowns. Add new doctypes as needed.
*/
export interface QuickCreateFieldConfig {
fieldname: string;
label: string;
fieldtype: 'Data' | 'Text' | 'Select' | 'Link' | 'Check' | 'Int' | 'Float' | 'Date' | 'Datetime';
required?: boolean;
placeholder?: string;
options?: string[] | { label: string; value: string }[]; // For Select fieldtype
linkDoctype?: string; // For Link fieldtype
linkFilters?: Record<string, any>; // Filters for Link fieldtype
defaultValue?: any;
description?: string;
hidden?: boolean;
readOnly?: boolean;
dependsOn?: string; // Field name this field depends on
}
export interface QuickCreateDoctypeConfig {
doctype: string;
title: string; // Display title for the modal
fields: QuickCreateFieldConfig[];
titleField?: string; // The main field that represents the record name (defaults to 'name')
afterCreate?: (newRecord: any) => void; // Callback after successful creation
validateBeforeCreate?: (data: Record<string, any>) => string | null; // Return error message or null
}
/**
* Configuration for all doctypes that support quick creation
* Add new doctypes here as needed
*/
export const QUICK_CREATE_CONFIG: Record<string, QuickCreateDoctypeConfig> = {
// Location Doctype
'Location': {
doctype: 'Location',
title: 'Create New Location',
titleField: 'location_name',
fields: [
{
fieldname: 'location_name',
label: 'Location Name',
fieldtype: 'Data',
required: true,
placeholder: 'Enter location name',
},
{
fieldname: 'parent_location',
label: 'Parent Location',
fieldtype: 'Link',
linkDoctype: 'Location',
placeholder: 'Select parent location (optional)',
},
{
fieldname: 'is_group',
label: 'Is Group',
fieldtype: 'Check',
defaultValue: 0,
description: 'Check if this location contains sub-locations',
},
{
fieldname: 'latitude',
label: 'Latitude',
fieldtype: 'Float',
placeholder: 'e.g., 24.7136',
},
{
fieldname: 'longitude',
label: 'Longitude',
fieldtype: 'Float',
placeholder: 'e.g., 46.6753',
},
],
},
// Room Doctype
'Room': {
doctype: 'Room',
title: 'Create New Room',
titleField: 'name',
fields: [
{
fieldname: 'room',
label: 'Room Name/Number',
fieldtype: 'Data',
required: true,
placeholder: 'Enter room name or number',
},
{
fieldname: 'building',
label: 'Building',
fieldtype: 'Link',
linkDoctype: 'Building',
placeholder: 'Select building',
},
{
fieldname: 'location_name',
label: 'Location Name',
fieldtype: 'Link',
linkDoctype: 'Location',
placeholder: 'Select Location',
},
{
fieldname: 'department',
label: 'Department',
fieldtype: 'Link',
linkDoctype: 'Department',
placeholder: 'Select department',
},
],
},
// Building Doctype
'Building': {
doctype: 'Building',
title: 'Create New Building',
titleField: 'name',
fields: [
{
fieldname: 'building',
label: 'Building Name',
fieldtype: 'Data',
required: true,
placeholder: 'Enter building name',
},
],
},
// Extension Directory Doctype
'Extension Directory': {
doctype: 'Extension Directory',
title: 'Create New Extension',
titleField: 'name',
fields: [
{
fieldname: 'extension_number',
label: 'Extension Number',
fieldtype: 'Data',
required: true,
placeholder: 'Enter extension number',
},
],
},
// Department Doctype
'Department': {
doctype: 'Department',
title: 'Create New Department',
titleField: 'department_name',
fields: [
{
fieldname: 'department_name',
label: 'Department Name',
fieldtype: 'Data',
required: true,
placeholder: 'Enter department name',
},
{
fieldname: 'company',
label: 'Company',
fieldtype: 'Link',
linkDoctype: 'Company',
required: true,
placeholder: 'Select company',
},
{
fieldname: 'parent_department',
label: 'Parent Department',
fieldtype: 'Link',
linkDoctype: 'Department',
placeholder: 'Select parent department',
},
{
fieldname: 'is_group',
label: 'Is Group',
fieldtype: 'Check',
defaultValue: 0,
},
],
},
// Issue Type (Work Order Type)
'Issue Type': {
doctype: 'Issue Type',
title: 'Create New Issue Type',
titleField: 'name',
fields: [
{
fieldname: '__newname',
label: 'Issue Type Name',
fieldtype: 'Data',
required: true,
placeholder: 'Enter issue type name',
},
{
fieldname: 'description',
label: 'Description',
fieldtype: 'Text',
placeholder: 'Enter description',
},
],
},
// Manufacturer
'Manufacturer': {
doctype: 'Manufacturer',
title: 'Create New Manufacturer',
titleField: 'name',
fields: [
{
fieldname: 'short_name',
label: 'Manufacturer Name',
fieldtype: 'Data',
required: true,
placeholder: 'Enter manufacturer name',
},
{
fieldname: 'full_name',
label: 'Full Name',
fieldtype: 'Data',
placeholder: 'Enter full company name',
},
{
fieldname: 'website',
label: 'Website',
fieldtype: 'Data',
placeholder: 'https://example.com',
},
{
fieldname: 'country',
label: 'Country',
fieldtype: 'Link',
linkDoctype: 'Country',
placeholder: 'Select country',
},
],
},
// Supplier
'Supplier': {
doctype: 'Supplier',
title: 'Create New Supplier',
titleField: 'supplier_name',
fields: [
{
fieldname: 'supplier_name',
label: 'Supplier Name',
fieldtype: 'Data',
required: true,
placeholder: 'Enter supplier name',
},
{
fieldname: 'supplier_group',
label: 'Supplier Group',
fieldtype: 'Link',
linkDoctype: 'Supplier Group',
placeholder: 'Select supplier group',
},
{
fieldname: 'supplier_type',
label: 'Supplier Type',
fieldtype: 'Select',
options: ['Company', 'Individual'],
defaultValue: 'Company',
},
{
fieldname: 'country',
label: 'Country',
fieldtype: 'Link',
linkDoctype: 'Country',
placeholder: 'Select country',
},
],
},
// Warehouse
'Warehouse': {
doctype: 'Warehouse',
title: 'Create New Warehouse',
titleField: 'warehouse_name',
fields: [
{
fieldname: 'warehouse_name',
label: 'Warehouse Name',
fieldtype: 'Data',
required: true,
placeholder: 'Enter warehouse name',
},
{
fieldname: 'company',
label: 'Company',
fieldtype: 'Link',
linkDoctype: 'Company',
required: true,
placeholder: 'Select company',
},
{
fieldname: 'parent_warehouse',
label: 'Parent Warehouse',
fieldtype: 'Link',
linkDoctype: 'Warehouse',
placeholder: 'Select parent warehouse',
},
{
fieldname: 'is_group',
label: 'Is Group',
fieldtype: 'Check',
defaultValue: 0,
},
],
},
// Item
'Item': {
doctype: 'Item',
title: 'Create New Item',
titleField: 'item_name',
fields: [
{
fieldname: 'item_code',
label: 'Item Code',
fieldtype: 'Data',
required: true,
placeholder: 'Enter item code',
},
{
fieldname: 'item_name',
label: 'Item Name',
fieldtype: 'Data',
required: true,
placeholder: 'Enter item name',
},
{
fieldname: 'item_group',
label: 'Item Group',
fieldtype: 'Link',
linkDoctype: 'Item Group',
required: true,
placeholder: 'Select item group',
},
{
fieldname: 'stock_uom',
label: 'Default Unit of Measure',
fieldtype: 'Link',
linkDoctype: 'UOM',
required: true,
defaultValue: 'Nos',
placeholder: 'Select UOM',
},
{
fieldname: 'is_stock_item',
label: 'Maintain Stock',
fieldtype: 'Check',
defaultValue: 1,
},
{
fieldname: 'description',
label: 'Description',
fieldtype: 'Text',
placeholder: 'Enter item description',
},
],
},
// Asset
'Asset': {
doctype: 'Asset',
title: 'Create New Asset',
titleField: 'asset_name',
fields: [
{
fieldname: 'asset_name',
label: 'Asset Name',
fieldtype: 'Data',
required: true,
placeholder: 'Enter asset name',
},
{
fieldname: 'item_code',
label: 'Item Code',
fieldtype: 'Link',
linkDoctype: 'Item',
required: true,
linkFilters: { is_fixed_asset: 1 },
placeholder: 'Select item',
},
{
fieldname: 'company',
label: 'Company',
fieldtype: 'Link',
linkDoctype: 'Company',
required: true,
placeholder: 'Select company',
},
{
fieldname: 'location',
label: 'Location',
fieldtype: 'Link',
linkDoctype: 'Location',
placeholder: 'Select location',
},
{
fieldname: 'custodian',
label: 'Custodian',
fieldtype: 'Link',
linkDoctype: 'Employee',
placeholder: 'Select custodian',
},
],
},
// Technical Department
'Technical Department': {
doctype: 'Technical Department',
title: 'Create New Technical Department',
titleField: 'name',
fields: [
{
fieldname: 'department',
label: 'Department Name',
fieldtype: 'Data',
required: true,
placeholder: 'Enter technical department name',
}
],
},
};
/**
* Helper function to get configuration for a doctype
*/
export const getQuickCreateConfig = (doctype: string): QuickCreateDoctypeConfig | null => {
return QUICK_CREATE_CONFIG[doctype] || null;
};
/**
* Check if a doctype supports quick creation
*/
export const supportsQuickCreate = (doctype: string): boolean => {
return doctype in QUICK_CREATE_CONFIG;
};
/**
* Add or update a doctype configuration at runtime
*/
export const registerQuickCreateConfig = (config: QuickCreateDoctypeConfig): void => {
QUICK_CREATE_CONFIG[config.doctype] = config;
};
export default QUICK_CREATE_CONFIG;

View File

@ -0,0 +1,602 @@
import React, { useState, useEffect, useCallback, useRef } from 'react';
import {
FaTimes,
FaPlus,
FaSpinner,
FaCheckCircle,
FaTimesCircle,
FaExclamationTriangle,
FaSearch
} from 'react-icons/fa';
import { toast } from 'react-toastify';
import {
type QuickCreateDoctypeConfig,
type QuickCreateFieldConfig,
getQuickCreateConfig
} from './QuickCreateConfig';
import apiService from '../services/apiService';
// Simple Link Input component for use inside modal (avoids circular dependency)
interface SimpleLinkInputProps {
doctype: string;
value: string;
onChange: (value: string) => void;
disabled?: boolean;
placeholder?: string;
filters?: Record<string, any>;
}
const SimpleLinkInput: React.FC<SimpleLinkInputProps> = ({
doctype,
value,
onChange,
disabled = false,
placeholder = 'Search...',
filters = {},
}) => {
const [searchText, setSearchText] = useState('');
const [results, setResults] = useState<{ value: string; description?: string }[]>([]);
const [isOpen, setIsOpen] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const containerRef = useRef<HTMLDivElement>(null);
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
// Search function
const searchLink = useCallback(async (text: string = '') => {
if (!doctype) return;
setIsLoading(true);
try {
const params = new URLSearchParams({
doctype,
txt: text,
page_length: '20',
});
if (filters && Object.keys(filters).length > 0) {
params.append('filters', JSON.stringify(filters));
}
const response = await apiService.apiCall<{ value: string; description?: string }[]>(
`/api/method/frappe.desk.search.search_link?${params.toString()}`
);
setResults(response || []);
} catch (error) {
console.error(`Error fetching ${doctype} links:`, error);
setResults([]);
} finally {
setIsLoading(false);
}
}, [doctype, filters]);
// Debounced search
useEffect(() => {
if (!isOpen) return;
if (debounceRef.current) clearTimeout(debounceRef.current);
debounceRef.current = setTimeout(() => searchLink(searchText), 300);
return () => { if (debounceRef.current) clearTimeout(debounceRef.current); };
}, [searchText, isOpen, searchLink]);
// Load on open
useEffect(() => {
if (isOpen) searchLink(searchText);
}, [isOpen]);
// Close on outside click
useEffect(() => {
const handleClickOutside = (e: MouseEvent) => {
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
setIsOpen(false);
setSearchText('');
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, []);
const handleSelect = (val: string) => {
onChange(val);
setSearchText('');
setIsOpen(false);
};
const handleClear = () => {
onChange('');
setSearchText('');
};
return (
<div ref={containerRef} className="relative">
<div className="relative">
<FaSearch className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400" size={12} />
<input
type="text"
value={isOpen ? searchText : value}
placeholder={placeholder}
disabled={disabled}
className={`w-full pl-9 pr-8 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-md
focus:outline-none focus:ring-2 focus:ring-blue-500
disabled:bg-gray-100 dark:disabled:bg-gray-700
bg-white dark:bg-gray-700 text-gray-900 dark:text-white`}
onFocus={() => !disabled && setIsOpen(true)}
onChange={(e) => {
setSearchText(e.target.value);
setIsOpen(true);
}}
/>
{value && !disabled && !isOpen && (
<button
type="button"
onClick={handleClear}
className="absolute right-2 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600"
>
<FaTimes size={12} />
</button>
)}
</div>
{isOpen && !disabled && (
<div className="absolute z-50 w-full mt-1 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-md shadow-lg max-h-40 overflow-auto">
{isLoading ? (
<div className="p-3 text-center text-gray-500 dark:text-gray-400 text-sm">
<FaSpinner className="animate-spin inline mr-2" size={12} />
Loading...
</div>
) : results.length > 0 ? (
<ul>
{results.map((item, idx) => (
<li
key={idx}
onClick={() => handleSelect(item.value)}
className={`px-3 py-2 cursor-pointer text-sm hover:bg-blue-500 hover:text-white
${value === item.value ? 'bg-blue-50 dark:bg-blue-900/30' : ''}`}
>
{item.value}
{item.description && (
<span className="text-xs text-gray-500 ml-2">{item.description}</span>
)}
</li>
))}
</ul>
) : (
<div className="p-3 text-center text-gray-500 dark:text-gray-400 text-sm">
No results found
</div>
)}
</div>
)}
</div>
);
};
interface QuickCreateModalProps {
doctype: string;
isOpen: boolean;
onClose: () => void;
onSuccess: (newRecord: any) => void;
initialValues?: Record<string, any>;
parentFilters?: Record<string, any>; // Filters to pass down to link fields
customConfig?: QuickCreateDoctypeConfig; // Override default config
}
const QuickCreateModal: React.FC<QuickCreateModalProps> = ({
doctype,
isOpen,
onClose,
onSuccess,
initialValues = {},
parentFilters = {},
customConfig,
}) => {
const [formData, setFormData] = useState<Record<string, any>>({});
const [errors, setErrors] = useState<Record<string, string>>({});
const [isSubmitting, setIsSubmitting] = useState(false);
const [config, setConfig] = useState<QuickCreateDoctypeConfig | null>(null);
// Get configuration for the doctype
useEffect(() => {
const doctypeConfig = customConfig || getQuickCreateConfig(doctype);
setConfig(doctypeConfig);
if (doctypeConfig) {
// Initialize form data with default values
const defaultData: Record<string, any> = {};
doctypeConfig.fields.forEach((field) => {
if (field.defaultValue !== undefined) {
defaultData[field.fieldname] = field.defaultValue;
} else if (field.fieldtype === 'Check') {
defaultData[field.fieldname] = 0;
} else {
defaultData[field.fieldname] = '';
}
});
// Merge with initial values
setFormData({ ...defaultData, ...initialValues });
}
}, [doctype, customConfig, initialValues]);
// Reset form when modal opens
useEffect(() => {
if (isOpen && config) {
const defaultData: Record<string, any> = {};
config.fields.forEach((field) => {
if (field.defaultValue !== undefined) {
defaultData[field.fieldname] = field.defaultValue;
} else if (field.fieldtype === 'Check') {
defaultData[field.fieldname] = 0;
} else {
defaultData[field.fieldname] = '';
}
});
setFormData({ ...defaultData, ...initialValues });
setErrors({});
}
}, [isOpen, config, initialValues]);
// Handle field change
const handleFieldChange = useCallback((fieldname: string, value: any) => {
setFormData((prev) => ({ ...prev, [fieldname]: value }));
// Clear error for this field
setErrors((prev) => {
const newErrors = { ...prev };
delete newErrors[fieldname];
return newErrors;
});
}, []);
// Validate form
const validateForm = useCallback((): boolean => {
if (!config) return false;
const newErrors: Record<string, string> = {};
config.fields.forEach((field) => {
if (field.required && !field.hidden) {
const value = formData[field.fieldname];
if (value === undefined || value === null || value === '') {
newErrors[field.fieldname] = `${field.label} is required`;
}
}
});
// Run custom validation if provided
if (config.validateBeforeCreate) {
const customError = config.validateBeforeCreate(formData);
if (customError) {
toast.error(customError, {
position: 'top-right',
autoClose: 4000,
icon: <FaExclamationTriangle />,
});
return false;
}
}
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
}, [config, formData]);
// Handle form submission
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!validateForm() || !config) {
return;
}
setIsSubmitting(true);
try {
// Prepare data for API - only include non-empty fields
const dataToSubmit: Record<string, any> = {};
Object.entries(formData).forEach(([key, value]) => {
if (value !== '' && value !== null && value !== undefined) {
dataToSubmit[key] = value;
}
});
// Make API call to create the record
const response = await apiService.apiCall<any>(
`/api/resource/${config.doctype}`,
{
method: 'POST',
body: JSON.stringify(dataToSubmit),
}
);
if (response?.data) {
const newRecord = response.data;
toast.success(`${config.title.replace('Create New ', '')} created successfully!`, {
position: 'top-right',
autoClose: 3000,
icon: <FaCheckCircle />,
});
// Call afterCreate callback if provided
if (config.afterCreate) {
config.afterCreate(newRecord);
}
// Call onSuccess callback with the new record
onSuccess(newRecord);
onClose();
} else {
throw new Error('Failed to create record');
}
} catch (err) {
console.error('Error creating record:', err);
const errorMessage = err instanceof Error ? err.message : 'Unknown error occurred';
// Check for duplicate entry error
if (errorMessage.includes('Duplicate') || errorMessage.includes('already exists')) {
toast.error(`A record with this name already exists. Please use a different name.`, {
position: 'top-right',
autoClose: 5000,
icon: <FaTimesCircle />,
});
} else {
toast.error(`Failed to create: ${errorMessage}`, {
position: 'top-right',
autoClose: 5000,
icon: <FaTimesCircle />,
});
}
} finally {
setIsSubmitting(false);
}
};
// Render a single field based on its type
const renderField = (field: QuickCreateFieldConfig) => {
if (field.hidden) return null;
const value = formData[field.fieldname];
const error = errors[field.fieldname];
const isDisabled = field.readOnly || isSubmitting;
// Check if field should be shown based on depends_on
if (field.dependsOn) {
const dependsOnValue = formData[field.dependsOn];
if (!dependsOnValue) return null;
}
const baseInputClass = `w-full px-3 py-2 text-sm border rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500
${error ? 'border-red-500' : 'border-gray-300 dark:border-gray-600'}
${isDisabled ? 'bg-gray-100 dark:bg-gray-700 cursor-not-allowed' : 'bg-white dark:bg-gray-700'}
text-gray-900 dark:text-white`;
switch (field.fieldtype) {
case 'Data':
return (
<input
type="text"
value={value || ''}
onChange={(e) => handleFieldChange(field.fieldname, e.target.value)}
placeholder={field.placeholder}
disabled={isDisabled}
className={baseInputClass}
/>
);
case 'Text':
return (
<textarea
value={value || ''}
onChange={(e) => handleFieldChange(field.fieldname, e.target.value)}
placeholder={field.placeholder}
disabled={isDisabled}
rows={3}
className={`${baseInputClass} resize-none`}
/>
);
case 'Select':
return (
<select
value={value || ''}
onChange={(e) => handleFieldChange(field.fieldname, e.target.value)}
disabled={isDisabled}
className={baseInputClass}
>
<option value="">Select {field.label}</option>
{(field.options || []).map((option) => {
const optionValue = typeof option === 'string' ? option : option.value;
const optionLabel = typeof option === 'string' ? option : option.label;
return (
<option key={optionValue} value={optionValue}>
{optionLabel}
</option>
);
})}
</select>
);
case 'Link':
return (
<SimpleLinkInput
doctype={field.linkDoctype || ''}
value={value || ''}
onChange={(val) => handleFieldChange(field.fieldname, val)}
disabled={isDisabled}
placeholder={field.placeholder}
// filters={{ ...field.linkFilters, ...parentFilters }}
filters={field.linkFilters || {}}
/>
);
case 'Check':
return (
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={value === 1 || value === true}
onChange={(e) => handleFieldChange(field.fieldname, e.target.checked ? 1 : 0)}
disabled={isDisabled}
className="w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 rounded focus:ring-blue-500
dark:focus:ring-blue-600 dark:ring-offset-gray-800 focus:ring-2
dark:bg-gray-700 dark:border-gray-600"
/>
<span className="text-sm text-gray-700 dark:text-gray-300">
{field.description || field.label}
</span>
</label>
);
case 'Int':
return (
<input
type="number"
value={value || ''}
onChange={(e) => handleFieldChange(field.fieldname, parseInt(e.target.value) || '')}
placeholder={field.placeholder}
disabled={isDisabled}
step="1"
className={baseInputClass}
/>
);
case 'Float':
return (
<input
type="number"
value={value || ''}
onChange={(e) => handleFieldChange(field.fieldname, parseFloat(e.target.value) || '')}
placeholder={field.placeholder}
disabled={isDisabled}
step="0.01"
className={baseInputClass}
/>
);
case 'Date':
return (
<input
type="date"
value={value || ''}
onChange={(e) => handleFieldChange(field.fieldname, e.target.value)}
disabled={isDisabled}
className={baseInputClass}
/>
);
case 'Datetime':
return (
<input
type="datetime-local"
value={value || ''}
onChange={(e) => handleFieldChange(field.fieldname, e.target.value)}
disabled={isDisabled}
className={baseInputClass}
/>
);
default:
return (
<input
type="text"
value={value || ''}
onChange={(e) => handleFieldChange(field.fieldname, e.target.value)}
placeholder={field.placeholder}
disabled={isDisabled}
className={baseInputClass}
/>
);
}
};
if (!isOpen || !config) return null;
return (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-[9999]">
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-xl w-full max-w-lg mx-4 max-h-[90vh] flex flex-col">
{/* Header */}
<div className="flex items-center justify-between p-4 border-b border-gray-200 dark:border-gray-700">
<h3 className="text-lg font-semibold text-gray-800 dark:text-white flex items-center gap-2">
<FaPlus className="text-blue-500" size={16} />
{config.title}
</h3>
<button
type="button"
onClick={onClose}
disabled={isSubmitting}
className="p-1 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 rounded transition-colors disabled:opacity-50"
>
<FaTimes size={18} />
</button>
</div>
{/* Form */}
<form onSubmit={handleSubmit} className="flex-1 overflow-y-auto p-4">
<div className="space-y-4">
{config.fields.map((field) => {
if (field.hidden) return null;
// Check depends_on
if (field.dependsOn) {
const dependsOnValue = formData[field.dependsOn];
if (!dependsOnValue) return null;
}
return (
<div key={field.fieldname}>
{field.fieldtype !== 'Check' && (
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">
{field.label}
{field.required && <span className="text-red-500 ml-1">*</span>}
</label>
)}
{renderField(field)}
{field.description && field.fieldtype !== 'Check' && (
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
{field.description}
</p>
)}
{errors[field.fieldname] && (
<p className="mt-1 text-xs text-red-500 flex items-center gap-1">
<FaExclamationTriangle size={10} />
{errors[field.fieldname]}
</p>
)}
</div>
);
})}
</div>
</form>
{/* Footer */}
<div className="flex justify-end gap-3 p-4 border-t border-gray-200 dark:border-gray-700">
<button
type="button"
onClick={onClose}
disabled={isSubmitting}
className="px-4 py-2 text-sm font-medium text-gray-700 bg-gray-200 hover:bg-gray-300 rounded-lg transition-colors disabled:opacity-50"
>
Cancel
</button>
<button
type="submit"
onClick={handleSubmit}
disabled={isSubmitting}
className="px-4 py-2 text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 rounded-lg transition-colors disabled:opacity-50 flex items-center gap-2"
>
{isSubmitting ? (
<>
<FaSpinner className="animate-spin" size={14} />
Creating...
</>
) : (
<>
<FaPlus size={14} />
Create
</>
)}
</button>
</div>
</div>
</div>
);
};
export default QuickCreateModal;

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,642 @@
import React, { useState, useEffect } from 'react';
import { Link, useLocation, useNavigate } from 'react-router-dom';
import { useLanguage } from '../contexts/LanguageContext';
import { useTranslation } from 'react-i18next';
import {
LayoutDashboard,
Package,
Box,
Menu,
X,
ClipboardList,
Calendar,
CalendarCheck,
Map,
Users,
ShoppingCart,
FileText,
HelpCircle,
UserCircle,
Trash2
} from 'lucide-react';
import { FaClipboardCheck } from 'react-icons/fa';
interface SidebarLink {
id: string;
title: string;
icon: React.ReactNode;
path: string;
visible: boolean;
}
interface SidebarProps {
userEmail?: string;
}
// ✅ Role definitions
const ADMIN_ROLES = [
'System Manager',
'Contractor Supervisor',
'Contractor Manager',
'Work Control',
'Contractor Engineer'
];
const TECHNICIAN_ROLE = 'Technician';
const END_USER_ROLE = 'End User';
const Sidebar: React.FC<SidebarProps> = ({ userEmail }) => {
const [isCollapsed, setIsCollapsed] = useState(false);
const location = useLocation();
const navigate = useNavigate();
const { isRTL } = useLanguage();
const { t } = useTranslation();
// ✅ Role-based state
const [userRoles, setUserRoles] = useState<{
isAdmin: boolean;
isTechnician: boolean;
isEndUser: boolean;
isLoading: boolean;
}>({
isAdmin: false,
isTechnician: false,
isEndUser: false,
isLoading: true
});
const [userFullName, setUserFullName] = useState<string>('');
// Get base URL for assets (handles both dev and production)
// BASE_URL in Vite already includes trailing slash in production, but not in dev
const baseUrl = import.meta.env.BASE_URL || '/';
// Add cache-busting query parameter to force browser to reload updated images
// Version is automatically updated by build script based on file modification time
const imageVersion = import.meta.env.DEV
? `?v=${Date.now()}`
: `?v=1768316563`; // Auto-updated by build script
const logoVersion = import.meta.env.DEV
? `?v=${Date.now()}`
: `?v=1768316563`; // Auto-updated by build script
const backgroundImageUrl = baseUrl.endsWith('/')
? `${baseUrl}sidebar-background.jpg${imageVersion}`
: `${baseUrl}/sidebar-background.jpg${imageVersion}`;
// ✅ Fetch user roles on mount
useEffect(() => {
const fetchUserRoles = async () => {
try {
// Check for admin roles
const adminResponse = await fetch('/api/method/asset_lite.api.user_roles.check_has_role', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
roles: ADMIN_ROLES.join(',')
})
});
const adminData = await adminResponse.json();
const isAdmin = adminData.message?.has_role || false;
// Check for technician role
const techResponse = await fetch('/api/method/asset_lite.api.user_roles.check_has_role', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
roles: TECHNICIAN_ROLE
})
});
const techData = await techResponse.json();
const isTechnician = techData.message?.has_role || false;
// Check for end user role
const endUserResponse = await fetch('/api/method/asset_lite.api.user_roles.check_has_role', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
roles: END_USER_ROLE
})
});
const endUserData = await endUserResponse.json();
const isEndUser = endUserData.message?.has_role || false;
setUserRoles({
isAdmin,
isTechnician,
isEndUser,
isLoading: false
});
console.log('User roles:', { isAdmin, isTechnician, isEndUser });
} catch (error) {
console.error('Error fetching user roles:', error);
// Default to showing minimal items on error
setUserRoles({
isAdmin: false,
isTechnician: false,
isEndUser: true, // Default to end user (minimal access)
isLoading: false
});
}
};
fetchUserRoles();
}, []);
// ✅ Fetch user full name on mount
useEffect(() => {
const fetchUserFullName = async () => {
try {
// First get the logged-in user
const userResponse = await fetch('/api/method/frappe.auth.get_logged_user', {
method: 'GET',
headers: {
'Content-Type': 'application/json',
},
credentials: 'include'
});
const userData = await userResponse.json();
const userEmail = userData.message;
if (userEmail) {
// Then fetch the user's full name
const fullNameResponse = await fetch(`/api/resource/User/${encodeURIComponent(userEmail)}?fields=["full_name"]`, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
},
credentials: 'include'
});
const fullNameData = await fullNameResponse.json();
if (fullNameData.data?.full_name) {
setUserFullName(fullNameData.data.full_name);
} else {
// Fallback to email if full name not found
setUserFullName(userEmail);
}
}
} catch (error) {
console.error('Error fetching user full name:', error);
// Fallback to email prop if API fails
if (userEmail) {
setUserFullName(userEmail);
}
}
};
fetchUserFullName();
}, [userEmail]);
// ✅ Visibility logic based on roles
// Admin: sees everything
// Technician: Work Order, Inspection, Procurement, Support, Active Map
// End User: Work Order, Support
// If user has multiple roles, they see the union of all permissions
const getVisibility = (linkId: string): boolean => {
const { isAdmin, isTechnician, isEndUser, isLoading } = userRoles;
// While loading, show nothing or minimal
if (isLoading) {
return false;
}
// Admin sees everything
if (isAdmin) {
return true;
}
// Define what each role can see
const endUserLinks = ['work-orders', 'support', 'assets', 'inventory'];
const technicianLinks = ['work-orders', 'inspections', 'procurement', 'support', 'active-map', 'assets', 'inventory'];
// Check visibility based on roles (union of permissions)
let canSee = false;
if (isEndUser && endUserLinks.includes(linkId)) {
canSee = true;
}
if (isTechnician && technicianLinks.includes(linkId)) {
canSee = true;
}
// If user has no recognized role, show minimal access (end user)
if (!isAdmin && !isTechnician && !isEndUser) {
canSee = endUserLinks.includes(linkId);
}
return canSee;
};
// Role-based visibility logic (keeping old code commented)
// const isMaintenanceManagerKASH = userEmail === 'maintenancemanager-kash@gmail.com';
// const isMaintenanceManagerTH = userEmail === 'maintenancemanager-th@gmail.com';
// const isMaintenanceManagerDAJH = userEmail === 'maintenancemanager-dajh@gmail.com';
// const isFinanceManager = userEmail === 'financemanager@gmail.com';
// const isEndUser = userEmail && (
// userEmail.startsWith('enduser1-kash') ||
// userEmail.startsWith('enduser1-dajh') ||
// userEmail.startsWith('enduser1-th')
// );
// const isTechnician = userEmail && (
// userEmail.startsWith('technician1-kash') ||
// userEmail.startsWith('technician1-dajh') ||
// userEmail.startsWith('technician1-th')
// );
// const showAsset = !isFinanceManager && !isEndUser;
// const showInventory = !isFinanceManager && !isEndUser;
// const showPreventiveMaintenance = !isFinanceManager && !isEndUser;
// const showGeneralWO = !isFinanceManager && !isEndUser;
// const showAMTeam = !isFinanceManager && !isEndUser;
// const showProjectDashboard = !isMaintenanceManagerKASH && !isMaintenanceManagerTH && !isMaintenanceManagerDAJH && !isFinanceManager && !isEndUser && !isTechnician;
// const showSiteDashboards = !isFinanceManager && !isEndUser;
// const showSupplierDashboard = !isFinanceManager && !isEndUser;
// const showSLA = !isFinanceManager && !isEndUser && !isTechnician;
// const showSiteInfo = !isFinanceManager && !isEndUser;
const links: SidebarLink[] = [
{
id: 'dashboard',
title: t('common.dashboard'),
icon: <LayoutDashboard size={20} />,
path: '/dashboard',
visible: userRoles.isAdmin // Only admin sees dashboard
},
{
id: 'assets',
title: t('common.assets'),
icon: <Package size={20} />,
path: '/assets',
visible: getVisibility('assets')
},
{
id: 'inventory',
title: t('sidebar.inventory'),
icon: <Box size={20} />,
path: '/inventory',
visible: getVisibility('inventory')
},
{
id: 'work-orders',
title: t('common.workOrders'),
icon: <ClipboardList size={20} />,
path: '/work-orders',
visible: getVisibility('work-orders')
},
{
id: 'inspections',
title: t('sidebar.inspection'),
icon: <FaClipboardCheck size={20} />,
path: '/inspections',
visible: getVisibility('inspections')
},
// {
// id: 'maintenance',
// title: t('common.maintenance'),
// icon: <Wrench size={20} />,
// path: '/maintenance',
// visible: showPreventiveMaintenance
// },
// {
// id: 'ppm',
// title: t('common.ppm'),
// icon: <Calendar size={20} />,
// path: '/ppm',
// visible: showPreventiveMaintenance
// },
{
id: 'ppm-planner',
title: t('sidebar.ppmPlanner'),
icon: <CalendarCheck size={20} />,
path: '/ppm-planner',
visible: userRoles.isAdmin
},
{
id: 'maintenance-calendar',
title: t('sidebar.maintenanceCalendar'),
icon: <Calendar size={20} />,
path: '/maintenance-calendar',
visible: userRoles.isAdmin
},
{
id: 'active-map',
title: t('sidebar.activeMap'),
icon: <Map size={20} />,
path: '/active-map',
visible: getVisibility('active-map')
},
{
id: 'maintenance-teams',
title: t('sidebar.maintenanceTeam'),
icon: <Users size={20} />,
path: '/maintenance-teams',
visible: userRoles.isAdmin // Only admin
},
{
id: 'procurement',
title: t('sidebar.procurement'),
icon: <ShoppingCart size={20} />,
path: '/procurement',
visible: getVisibility('procurement')
},
{
id: 'sla',
title: t('sidebar.sla'),
icon: <FileText size={20} />,
path: '/sla',
visible: userRoles.isAdmin // Only admin
},
{
id: 'support',
title: t('sidebar.support'),
icon: <HelpCircle size={20} />,
path: '/support',
visible: getVisibility('support')
},
// {
// id: 'delete-requests',
// title: t('sidebar.deleteRequests'),
// icon: <Trash2 size={20} />,
// path: '/delete-requests',
// visible: userRoles.isAdmin // Only admin sees delete requests
// },
// {
// id: 'vendors',
// title: 'Vendors',
// icon: <Truck size={20} />,
// path: '/vendors',
// visible: showSupplierDashboard
// },
// {
// id: 'dashboard-view',
// title: 'Dashboard',
// icon: <BarChart3 size={20} />,
// path: '/dashboard-view',
// visible: showProjectDashboard
// },
// {
// id: 'sites',
// title: 'Sites',
// icon: <Building2 size={20} />,
// path: '/sites',
// visible: showSiteDashboards
// },
// {
// id: 'active-map',
// title: 'Active Map',
// icon: <MapPin size={20} />,
// path: '/active-map',
// visible: showSiteInfo
// },
// {
// id: 'users',
// title: 'Users',
// icon: <Users size={20} />,
// path: '/users',
// visible: showAMTeam
// },
// {
// id: 'account',
// title: 'Account',
// icon: <FileText size={20} />,
// path: '/account',
// visible: showSLA
// }
];
const visibleLinks = links.filter(link => link.visible);
const isActive = (path: string) => {
return location.pathname === path;
};
// ✅ Handle User Profile click
const handleUserProfileClick = () => {
navigate('/user-profile');
};
// ✅ Show loading state while fetching roles
if (userRoles.isLoading) {
return (
<div
className={`
relative
h-screen
w-64
flex
flex-col
items-center
justify-center
shadow-xl
border-r border-gray-200 dark:border-gray-700
`}
style={{
backgroundImage: `url(${backgroundImageUrl})`,
backgroundSize: 'cover',
backgroundPosition: 'center',
backgroundRepeat: 'no-repeat'
}}
>
<div className="absolute inset-0 bg-black/60 dark:bg-black/70 z-0"></div>
<div className="relative z-10 text-white">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-white mx-auto"></div>
<p className="mt-2 text-sm">{t('common.loading')}</p>
</div>
</div>
);
}
return (
<div
className={`
relative
h-screen
transition-all
duration-300
ease-in-out
flex
flex-col
shadow-xl
border-r border-gray-200 dark:border-gray-700
${isCollapsed ? 'w-16' : 'w-64'}
`}
style={{
backgroundImage: `url(${backgroundImageUrl})`,
backgroundSize: 'cover',
backgroundPosition: 'center',
backgroundRepeat: 'no-repeat'
}}
>
{/* Black Overlay */}
<div className="absolute inset-0 bg-black/60 dark:bg-black/70 z-0"></div>
{/* Content Container - Above Overlay */}
<div className="relative z-10 flex flex-col h-full bg-white/0 dark:bg-white/0">
{/* Sidebar Header */}
<div className="flex items-center justify-between p-4 border-b border-gray-200/30 dark:border-gray-700/30">
{!isCollapsed && (
<div className="flex items-center space-x-3">
<div className="w-10 h-10 flex items-center justify-center bg-white/20 dark:bg-white/20 rounded-lg p-1 backdrop-blur-sm">
{/* Seera Arabia Logo */}
<img
src={`${baseUrl}${baseUrl.endsWith('/') ? '' : '/'}seera-logo.png${logoVersion}`}
alt="SEERA-ASM"
className="w-full h-full object-contain"
onError={(e) => {
// Fallback to SVG if image not found
e.currentTarget.style.display = 'none';
e.currentTarget.nextElementSibling?.classList.remove('hidden');
}}
/>
<svg className="w-6 h-6 hidden" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M12 2L2 7L12 12L22 7L12 2Z" fill="#6366F1" fillOpacity="0.9"/>
<path d="M2 17L12 22L22 17V12L12 17L2 12V17Z" fill="#8B5CF6" fillOpacity="0.7"/>
<path d="M12 12V17" stroke="#A855F7" strokeWidth="2" strokeLinecap="round"/>
</svg>
</div>
<h1 className="text-white dark:text-white text-lg font-semibold drop-shadow-lg">{t('sidebar.title')}</h1>
</div>
)}
{isCollapsed && (
<div className="w-8 h-8 flex items-center justify-center bg-white dark:bg-gray-700 rounded-lg p-1">
<img
src={`${baseUrl}${baseUrl.endsWith('/') ? '' : '/'}seera-logo.png?v=1765198405${logoVersion}`}
alt="SEERA-ASM"
className="w-full h-full object-contain"
onError={(e) => {
e.currentTarget.style.display = 'none';
e.currentTarget.nextElementSibling?.classList.remove('hidden');
}}
/>
<svg className="w-5 h-5 hidden" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M12 2L2 7L12 12L22 7L12 2Z" fill="#6366F1" fillOpacity="0.9"/>
<path d="M2 17L12 22L22 17V12L12 17L2 12V17Z" fill="#8B5CF6" fillOpacity="0.7"/>
<path d="M12 12V17" stroke="#A855F7" strokeWidth="2" strokeLinecap="round"/>
</svg>
</div>
)}
<button
onClick={() => setIsCollapsed(!isCollapsed)}
className="text-white dark:text-white hover:bg-white/20 dark:hover:bg-white/20 p-2 rounded-lg transition-colors"
>
{isCollapsed ? <Menu size={20} /> : <X size={20} />}
</button>
</div>
{/* Navigation Links */}
<nav className="flex-1 overflow-y-auto py-4">
{visibleLinks.map((link) => (
<Link
key={link.id}
to={link.path}
className={`
flex
items-center
px-4
py-3
text-white dark:text-white
hover:bg-white/20 dark:hover:bg-white/20
hover:text-white dark:hover:text-white
transition-all
duration-200
${isActive(link.path) ? 'bg-white/30 dark:bg-white/30 text-white dark:text-white border-l-4 border-white' : ''}
${isCollapsed ? 'justify-center' : ''}
`}
title={isCollapsed ? link.title : ''}
>
<span>{link.icon}</span>
{!isCollapsed && (
<span className={`${isRTL ? 'mr-4' : 'ml-4'} font-medium`}>{link.title}</span>
)}
</Link>
))}
</nav>
{/* User Info & Version (Bottom) */}
<div className={`${isCollapsed ? 'p-2' : 'p-4'} border-t border-white/10 backdrop-blur-sm bg-white/5 space-y-3 relative z-10`}>
{/* {!isCollapsed && userEmail && (
<div>
<div className="text-white/80 dark:text-white/80 text-xs truncate">
{t('sidebar.loggedInAs')}
</div>
<div className="text-white dark:text-white text-sm font-medium truncate">
{userEmail}
</div> */}
{!isCollapsed && (userFullName || userEmail) && (
<div>
<div className="text-white/80 dark:text-white/80 text-xs truncate">
{t('sidebar.loggedInAs')}
</div>
<div className="text-white dark:text-white text-sm font-medium truncate">
{userFullName || userEmail}
</div>
{/* ✅ User Profile Button */}
<button
onClick={handleUserProfileClick}
className={`
mt-3 w-full flex items-center justify-center gap-2
px-3 py-2
bg-white/20 hover:bg-white/30
text-white
rounded-lg
transition-all duration-200
text-sm font-medium
${isActive('/user-profile') ? 'bg-white/40 border border-white/50' : ''}
`}
>
<UserCircle size={18} />
<span>{t('sidebar.userProfile')}</span>
</button>
</div>
)}
{/* Collapsed state - just show icon button */}
{isCollapsed && (
<button
onClick={handleUserProfileClick}
className={`
w-full flex items-center justify-center
p-2
bg-white/20 hover:bg-white/30
text-white
rounded-lg
transition-all duration-200
${isActive('/user-profile') ? 'bg-white/40 border border-white/50' : ''}
`}
title={t('sidebar.userProfile')}
>
<UserCircle size={20} />
</button>
)}
{!isCollapsed && (
<div className="text-xs text-white/70 dark:text-white/70 text-center">
{t('sidebar.version')}
</div>
)}
</div>
</div>
</div>
);
};
export default Sidebar;

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,829 @@
import React, { useState, useEffect, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import * as XLSX from 'xlsx';
import {
FaTimes,
FaFileExcel,
FaFileCsv,
FaFilePdf,
FaPrint,
FaSpinner,
FaFilter,
FaChevronDown,
FaChevronUp,
FaSearch,
FaSync,
FaExternalLinkAlt,
FaHardHat,
FaUsers,
FaCheckCircle,
FaClock,
FaFolderOpen,
FaArrowLeft
} from 'react-icons/fa';
interface ReportColumn {
label: string;
fieldname: string;
fieldtype: string;
options?: string;
width?: number;
}
// ── CHANGE 1: Added permittedIssueTypes and isAdmin to props interface ──
interface TechnicianWorkOrderSummaryReportModalProps {
isOpen: boolean;
onClose: () => void;
permittedIssueTypes?: string[];
isAdmin?: boolean;
defaultWorkOrderType?: string;
}
// ── CHANGE 2: Destructure new props with safe defaults ──
const TechnicianWorkOrderSummaryReportModal: React.FC<TechnicianWorkOrderSummaryReportModalProps> = ({
isOpen,
onClose,
permittedIssueTypes = [],
isAdmin = true,
defaultWorkOrderType = ''
}) => {
const { t } = useTranslation();
// State
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [reportData, setReportData] = useState<any[]>([]);
const [columns, setColumns] = useState<ReportColumn[]>([]);
const [filtersExpanded, setFiltersExpanded] = useState(false);
// Export states
const [isExporting, setIsExporting] = useState(false);
// All issue types for admin dropdown
const [allIssueTypes, setAllIssueTypes] = useState<string[]>([]);
// Fetch all Issue Types for admin dropdown
useEffect(() => {
if (!isAdmin) return;
const fetchIssueTypes = async () => {
try {
const response = await fetch(
'/api/resource/Issue Type?fields=["name"]&limit_page_length=0&order_by=name asc',
{ headers: { Accept: 'application/json' }, credentials: 'include' }
);
const data = await response.json();
if (data.data) {
setAllIssueTypes(data.data.map((d: any) => d.name));
}
} catch (error) {
console.error('Error fetching issue types:', error);
}
};
fetchIssueTypes();
}, [isAdmin]);
// ── CHANGE 3: Added filterWorkOrderType state ──
const [filterWorkOrderType, setFilterWorkOrderType] = useState('');
// ── CHANGE 4: Auto-apply permitted Issue Type or dashboard global filter ──
useEffect(() => {
if (!isAdmin && permittedIssueTypes.length === 1) {
// Single permitted type — lock it in automatically
setFilterWorkOrderType(permittedIssueTypes[0]);
} else if (defaultWorkOrderType) {
setFilterWorkOrderType(defaultWorkOrderType);
} else {
setFilterWorkOrderType('');
}
}, [permittedIssueTypes, isAdmin, defaultWorkOrderType]);
// Report name constant
const REPORT_NAME = 'Technician Work Order Summary';
// ── CHANGE 5: fetchReportData now builds filters from permissions ──
const fetchReportData = useCallback(async () => {
setLoading(true);
setError(null);
try {
// Build filters based on user permissions
const filters: Record<string, any> = {};
// if (filterWorkOrderType) {
// // Explicit filter selected (or auto-locked for single-permitted users)
// filters.work_order_type = filterWorkOrderType;
// } else if (!isAdmin && permittedIssueTypes.length > 0) {
// filters.work_order_type = permittedIssueTypes.length === 1
// ? permittedIssueTypes[0]
// : ['in', permittedIssueTypes];
// }
// isAdmin with no filterWorkOrderType → no filter = sees everything
if (filterWorkOrderType) {
filters.work_order_type = filterWorkOrderType;
} else if (!isAdmin && permittedIssueTypes.length === 1) {
filters.work_order_type = permittedIssueTypes[0];
}
// Multiple permitted types: no filter sent, client-side filtering below
const response = await fetch('/api/method/frappe.desk.query_report.run', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
credentials: 'include',
body: JSON.stringify({
report_name: REPORT_NAME,
filters,
ignore_prepared_report: 1
}),
});
const result = await response.json();
if (result.exc) {
throw new Error(result.exc);
}
if (result.message) {
if (result.message.columns && result.message.columns.length > 0) {
setColumns(result.message.columns);
}
// if (result.message.result) {
// setReportData(result.message.result);
// } else {
// setReportData([]);
// }
if (result.message.result) {
let rows = result.message.result;
if (!isAdmin && permittedIssueTypes.length > 1 && !filterWorkOrderType) {
rows = rows.filter((r: any) =>
permittedIssueTypes.includes(r.work_order_type)
);
}
setReportData(rows);
} else {
setReportData([]);
}
}
} catch (err) {
console.error('Error fetching report:', err);
setError(err instanceof Error ? err.message : 'Failed to fetch report data');
setReportData([]);
} finally {
setLoading(false);
}
// ── CHANGE 5 cont: Added filterWorkOrderType, isAdmin, permittedIssueTypes to deps ──
}, [filterWorkOrderType, isAdmin, permittedIssueTypes]);
// Fetch data when modal opens or filters change
useEffect(() => {
if (isOpen) {
fetchReportData();
}
}, [isOpen, fetchReportData]);
// Handle escape key to close
useEffect(() => {
const handleEscape = (e: KeyboardEvent) => {
if (e.key === 'Escape' && isOpen) {
onClose();
}
};
document.addEventListener('keydown', handleEscape);
return () => document.removeEventListener('keydown', handleEscape);
}, [isOpen, onClose]);
// Prevent body scroll when modal is open
useEffect(() => {
if (isOpen) {
document.body.style.overflow = 'hidden';
} else {
document.body.style.overflow = 'unset';
}
return () => {
document.body.style.overflow = 'unset';
};
}, [isOpen]);
/**
* Calculate summary statistics
*/
const getSummaryStats = () => {
const totalTechnicians = reportData.length;
const totalWorkOrders = reportData.reduce((sum, row) => sum + (parseInt(row.total) || 0), 0);
const totalCompleted = reportData.reduce((sum, row) => sum + (parseInt(row.completed) || 0), 0);
const totalInProgress = reportData.reduce((sum, row) => sum + (parseInt(row.in_progress) || 0), 0);
const totalOpen = reportData.reduce((sum, row) => sum + (parseInt(row.open) || 0), 0);
const completionRate = totalWorkOrders > 0 ? ((totalCompleted / totalWorkOrders) * 100).toFixed(1) : '0';
return { totalTechnicians, totalWorkOrders, totalCompleted, totalInProgress, totalOpen, completionRate };
};
/**
* Export to CSV
*/
const handleExportCSV = () => {
if (reportData.length === 0) return;
setIsExporting(true);
try {
const headers = columns.map(col => col.label);
const csvContent = [
headers.join(','),
...reportData.map(row =>
columns.map(col => {
let value = row[col.fieldname] || '';
if (typeof value === 'string' && (value.includes(',') || value.includes('"') || value.includes('\n'))) {
value = `"${value.replace(/"/g, '""')}"`;
}
return value;
}).join(',')
)
].join('\n');
const blob = new Blob(['\ufeff' + csvContent], { type: 'text/csv;charset=utf-8;' });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = `technician_work_order_summary_${new Date().toISOString().split('T')[0]}.csv`;
link.click();
URL.revokeObjectURL(url);
} finally {
setIsExporting(false);
}
};
/**
* Export to Excel
*/
const handleExportExcel = () => {
if (reportData.length === 0) return;
setIsExporting(true);
try {
const headers = columns.map(col => col.label);
const worksheetData = [
headers,
...reportData.map(row => columns.map(col => row[col.fieldname] || ''))
];
const worksheet = XLSX.utils.aoa_to_sheet(worksheetData);
const colWidths = columns.map(col => ({ wch: col.width ? Math.floor(col.width / 7) : 20 }));
worksheet['!cols'] = colWidths;
const workbook = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(workbook, worksheet, 'Work Order Summary');
XLSX.writeFile(workbook, `technician_work_order_summary_${new Date().toISOString().split('T')[0]}.xlsx`);
} finally {
setIsExporting(false);
}
};
/**
* Print report
*/
const handlePrint = () => {
const stats = getSummaryStats();
const printWindow = window.open('', '_blank');
if (!printWindow) {
alert('Please allow popups for this site to print the report.');
return;
}
const tableHTML = `
<!DOCTYPE html>
<html>
<head>
<title>Technician Work Order Summary Report</title>
<style>
body { font-family: Arial, sans-serif; margin: 20px; }
h1 { text-align: center; color: #333; margin-bottom: 20px; }
.meta { text-align: center; color: #666; margin-bottom: 20px; font-size: 12px; }
.summary { display: flex; justify-content: center; gap: 20px; margin-bottom: 20px; padding: 15px; background: #f8f9fa; border-radius: 8px; flex-wrap: wrap; }
.summary-item { text-align: center; min-width: 100px; }
.summary-label { font-size: 11px; color: #666; }
.summary-value { font-size: 20px; font-weight: bold; color: #333; }
.summary-value.completed { color: #10B981; }
.summary-value.in-progress { color: #3B82F6; }
.summary-value.open { color: #F59E0B; }
table { width: 100%; border-collapse: collapse; font-size: 12px; }
th { background-color: #8B5CF6; color: white; padding: 10px 8px; text-align: left; font-weight: 600; }
td { padding: 10px 8px; border-bottom: 1px solid #ddd; }
tr:nth-child(even) { background-color: #f9f9f9; }
tr:hover { background-color: #f5f5f5; }
.count-cell { font-weight: 600; text-align: center; }
.completed { color: #10B981; }
.in-progress { color: #3B82F6; }
.open { color: #F59E0B; }
@media print {
body { margin: 0; }
table { page-break-inside: auto; }
tr { page-break-inside: avoid; page-break-after: auto; }
}
</style>
</head>
<body>
<h1>Technician Work Order Summary Report</h1>
<div class="meta">
Generated on: ${new Date().toLocaleString()}
${filterWorkOrderType ? ` | Department: ${filterWorkOrderType}` : ''}
</div>
<div class="summary">
<div class="summary-item">
<div class="summary-label">Total Technicians</div>
<div class="summary-value">${stats.totalTechnicians}</div>
</div>
<div class="summary-item">
<div class="summary-label">Total Work Orders</div>
<div class="summary-value">${stats.totalWorkOrders}</div>
</div>
<div class="summary-item">
<div class="summary-label">Completed</div>
<div class="summary-value completed">${stats.totalCompleted}</div>
</div>
<div class="summary-item">
<div class="summary-label">In Progress</div>
<div class="summary-value in-progress">${stats.totalInProgress}</div>
</div>
<div class="summary-item">
<div class="summary-label">Open</div>
<div class="summary-value open">${stats.totalOpen}</div>
</div>
<div class="summary-item">
<div class="summary-label">Completion Rate</div>
<div class="summary-value completed">${stats.completionRate}%</div>
</div>
</div>
<table>
<thead>
<tr>
<th>#</th>
${columns.map(col => `<th>${col.label}</th>`).join('')}
</tr>
</thead>
<tbody>
${reportData.map((row, index) => `
<tr>
<td>${index + 1}</td>
${columns.map(col => {
let value = row[col.fieldname] || '-';
let className = '';
if (col.fieldname === 'completed') className = 'count-cell completed';
else if (col.fieldname === 'in_progress') className = 'count-cell in-progress';
else if (col.fieldname === 'open') className = 'count-cell open';
else if (col.fieldname === 'total') className = 'count-cell';
return `<td class="${className}">${value}</td>`;
}).join('')}
</tr>
`).join('')}
</tbody>
</table>
<script>
window.onload = function() { window.print(); }
</script>
</body>
</html>
`;
printWindow.document.write(tableHTML);
printWindow.document.close();
};
const handleExportPDF = () => {
handlePrint();
};
const handleOpenInERPNext = () => {
const baseUrl = window.location.origin;
const url = `${baseUrl}/app/query-report/${encodeURIComponent(REPORT_NAME)}`;
window.open(url, '_blank');
};
const formatCellValue = (value: any, column: ReportColumn) => {
if (value === null || value === undefined || value === '') return '-';
switch (column.fieldtype) {
case 'Int':
return parseInt(value) || 0;
case 'Float':
return typeof value === 'number' ? value.toFixed(2) : value;
default:
return String(value);
}
};
if (!isOpen) return null;
const stats = getSummaryStats();
// ── CHANGE 6: Derived values for filter badge count ──
const hasActiveFilters = !!filterWorkOrderType;
return (
<div className="fixed inset-0 z-[80] bg-gray-50 dark:bg-gray-900 flex flex-col">
{/* Header */}
<div className="bg-gradient-to-r from-purple-600 to-indigo-600 px-6 py-4 flex-shrink-0 shadow-lg">
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<button
onClick={onClose}
className="p-2 text-white/80 hover:text-white hover:bg-white/20 rounded-lg transition-colors"
title="Go Back"
>
<FaArrowLeft size={18} />
</button>
<div className="flex items-center gap-3">
<FaHardHat className="text-white text-xl" />
<div>
<h2 className="text-xl font-bold text-white">Technician Work Order Summary</h2>
<p className="text-white/70 text-sm">
{reportData.length} technician{reportData.length !== 1 ? 's' : ''} found
{/* ── CHANGE 6 cont: Show active department in header ── */}
{filterWorkOrderType && (
<span className="ml-2 bg-white/20 px-2 py-0.5 rounded-full text-xs">
{filterWorkOrderType}
</span>
)}
</p>
</div>
</div>
</div>
<div className="flex items-center gap-2">
<button
onClick={onClose}
className="p-2 text-white/80 hover:text-white hover:bg-white/20 rounded-lg transition-colors"
title="Close (Esc)"
>
<FaTimes size={20} />
</button>
</div>
</div>
</div>
{/* Summary Cards */}
{!loading && reportData.length > 0 && (
<div className="bg-white dark:bg-gray-800 px-6 py-4 border-b border-gray-200 dark:border-gray-700 flex-shrink-0">
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-4">
<div className="bg-indigo-50 dark:bg-indigo-900/20 rounded-lg p-4 border border-indigo-200 dark:border-indigo-800">
<div className="flex items-center gap-3">
<FaUsers className="text-indigo-500 text-xl" />
<div>
<p className="text-xs text-indigo-600 dark:text-indigo-400 font-medium">Technicians</p>
<p className="text-xl font-bold text-indigo-700 dark:text-indigo-300">{stats.totalTechnicians}</p>
</div>
</div>
</div>
{/* <div className="bg-purple-50 dark:bg-purple-900/20 rounded-lg p-4 border border-purple-200 dark:border-purple-800">
<div className="flex items-center gap-3">
<FaHardHat className="text-purple-500 text-xl" />
<div>
<p className="text-xs text-purple-600 dark:text-purple-400 font-medium">Total Work Orders</p>
<p className="text-xl font-bold text-purple-700 dark:text-purple-300">{stats.totalWorkOrders}</p>
</div>
</div>
</div>
<div className="bg-green-50 dark:bg-green-900/20 rounded-lg p-4 border border-green-200 dark:border-green-800">
<div className="flex items-center gap-3">
<FaCheckCircle className="text-green-500 text-xl" />
<div>
<p className="text-xs text-green-600 dark:text-green-400 font-medium">Completed</p>
<p className="text-xl font-bold text-green-700 dark:text-green-300">{stats.totalCompleted}</p>
</div>
</div>
</div>
<div className="bg-blue-50 dark:bg-blue-900/20 rounded-lg p-4 border border-blue-200 dark:border-blue-800">
<div className="flex items-center gap-3">
<FaClock className="text-blue-500 text-xl" />
<div>
<p className="text-xs text-blue-600 dark:text-blue-400 font-medium">In Progress</p>
<p className="text-xl font-bold text-blue-700 dark:text-blue-300">{stats.totalInProgress}</p>
</div>
</div>
</div>
<div className="bg-amber-50 dark:bg-amber-900/20 rounded-lg p-4 border border-amber-200 dark:border-amber-800">
<div className="flex items-center gap-3">
<FaFolderOpen className="text-amber-500 text-xl" />
<div>
<p className="text-xs text-amber-600 dark:text-amber-400 font-medium">Open</p>
<p className="text-xl font-bold text-amber-700 dark:text-amber-300">{stats.totalOpen}</p>
</div>
</div>
</div>
<div className="bg-emerald-50 dark:bg-emerald-900/20 rounded-lg p-4 border border-emerald-200 dark:border-emerald-800">
<div className="flex items-center gap-3">
<FaCheckCircle className="text-emerald-500 text-xl" />
<div>
<p className="text-xs text-emerald-600 dark:text-emerald-400 font-medium">Completion Rate</p>
<p className="text-xl font-bold text-emerald-700 dark:text-emerald-300">{stats.completionRate}%</p>
</div>
</div>
</div> */}
</div>
</div>
)}
{/* Toolbar */}
<div className="bg-white dark:bg-gray-800 px-6 py-3 border-b border-gray-200 dark:border-gray-700 flex-shrink-0 shadow-sm">
<div className="flex items-center justify-between gap-4 flex-wrap">
{/* Filter Toggle */}
<button
onClick={() => setFiltersExpanded(!filtersExpanded)}
className="flex items-center gap-2 px-4 py-2 bg-gray-100 dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg text-sm font-medium text-gray-700 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors"
>
<FaFilter size={12} />
Filters
{/* ── CHANGE 6 cont: Show badge when filter is active ── */}
{hasActiveFilters && (
<span className="bg-purple-100 dark:bg-purple-900/50 text-purple-600 dark:text-purple-400 px-2 py-0.5 rounded-full text-xs font-bold">
1
</span>
)}
{filtersExpanded ? <FaChevronUp size={10} /> : <FaChevronDown size={10} />}
</button>
{/* Action Buttons */}
<div className="flex items-center gap-2">
<button
onClick={fetchReportData}
disabled={loading}
className="p-2.5 bg-gray-100 dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-600 dark:text-gray-400 hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors disabled:opacity-50"
title="Refresh"
>
<FaSync className={loading ? 'animate-spin' : ''} size={14} />
</button>
<div className="flex items-center gap-1 bg-gray-100 dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg overflow-hidden">
<button
onClick={handleExportCSV}
disabled={reportData.length === 0 || isExporting}
className="px-4 py-2.5 text-sm font-medium text-gray-700 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors flex items-center gap-2 disabled:opacity-50"
title="Export as CSV"
>
<FaFileCsv className="text-green-600" size={14} />
<span>CSV</span>
</button>
<div className="w-px h-6 bg-gray-300 dark:bg-gray-600"></div>
<button
onClick={handleExportExcel}
disabled={reportData.length === 0 || isExporting}
className="px-4 py-2.5 text-sm font-medium text-gray-700 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors flex items-center gap-2 disabled:opacity-50"
title="Export as Excel"
>
<FaFileExcel className="text-green-700" size={14} />
<span>Excel</span>
</button>
<div className="w-px h-6 bg-gray-300 dark:bg-gray-600"></div>
<button
onClick={handleExportPDF}
disabled={reportData.length === 0}
className="px-4 py-2.5 text-sm font-medium text-gray-700 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors flex items-center gap-2 disabled:opacity-50"
title="Export as PDF"
>
<FaFilePdf className="text-red-600" size={14} />
<span>PDF</span>
</button>
<div className="w-px h-6 bg-gray-300 dark:bg-gray-600"></div>
<button
onClick={handlePrint}
disabled={reportData.length === 0}
className="px-4 py-2.5 text-sm font-medium text-gray-700 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors flex items-center gap-2 disabled:opacity-50"
title="Print"
>
<FaPrint className="text-purple-600" size={14} />
<span>Print</span>
</button>
</div>
</div>
</div>
{/* ── CHANGE 7: Expandable Filters — replaced placeholder with real filter UI ── */}
{filtersExpanded && (
<div className="mt-4 p-4 bg-gray-50 dark:bg-gray-900/50 rounded-lg border border-gray-200 dark:border-gray-700">
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
{/* Technical Department Filter */}
<div>
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">
Technical Department
</label>
{/* Non-admin with exactly 1 permitted type → show locked/read-only field */}
{!isAdmin && permittedIssueTypes.length === 1 ? (
<div className="w-full px-3 py-2 text-sm border border-gray-200 dark:border-gray-600 rounded-lg bg-gray-50 dark:bg-gray-600 text-gray-700 dark:text-gray-300 flex items-center gap-2">
<span className="w-2 h-2 rounded-full bg-purple-400 flex-shrink-0"></span>
{filterWorkOrderType}
<span className="ml-auto text-[10px] text-gray-400 dark:text-gray-500 uppercase tracking-wide">
Restricted
</span>
</div>
) : (
/* Admin or non-admin with multiple permitted types → show dropdown */
<select
value={filterWorkOrderType}
onChange={(e) => setFilterWorkOrderType(e.target.value)}
className="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-purple-500 bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
>
<option value="">
{isAdmin ? 'All Departments' : 'Select Department'}
</option>
{/* Admin sees all options (passed from parent); non-admin sees only permitted ones */}
{/* {permittedIssueTypes.map(type => ( */}
{(isAdmin ? allIssueTypes : permittedIssueTypes).map(type => (
<option key={type} value={type}>{type}</option>
))}
</select>
)}
</div>
{/* Spacer columns */}
<div></div>
<div></div>
{/* Clear filter button — only shown to admins (non-admins can't clear permission filter) */}
<div className="flex items-end">
{isAdmin && (
<button
onClick={() => setFilterWorkOrderType('')}
disabled={!hasActiveFilters}
className="px-4 py-2 text-sm font-medium text-gray-600 dark:text-gray-400 hover:text-red-600 dark:hover:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20 rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2"
>
<FaTimes size={12} />
Clear Filters
</button>
)}
</div>
</div>
</div>
)}
</div>
{/* Content */}
<div className="flex-1 overflow-auto p-6">
{/* Loading State */}
{loading && (
<div className="flex items-center justify-center py-20">
<div className="text-center">
<FaSpinner className="animate-spin text-purple-500 text-5xl mx-auto mb-4" />
<p className="text-gray-600 dark:text-gray-400 text-lg">Loading report data...</p>
</div>
</div>
)}
{/* Error State */}
{error && !loading && (
<div className="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-8 text-center max-w-lg mx-auto">
<p className="text-red-600 dark:text-red-400 mb-4 text-lg">{error}</p>
<button
onClick={fetchReportData}
className="px-6 py-2 bg-red-600 hover:bg-red-700 text-white rounded-lg text-sm font-medium transition-colors"
>
Try Again
</button>
</div>
)}
{/* Empty State */}
{!loading && !error && reportData.length === 0 && (
<div className="text-center py-20">
<FaSearch className="text-gray-300 dark:text-gray-600 text-6xl mx-auto mb-4" />
<p className="text-gray-500 dark:text-gray-400 text-xl">No data found</p>
<p className="text-gray-400 dark:text-gray-500 text-sm mt-2">
No technicians have been assigned to work orders yet.
</p>
</div>
)}
{/* Data Table */}
{!loading && !error && reportData.length > 0 && (
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 overflow-hidden shadow-sm">
<div className="overflow-x-auto">
<table className="w-full">
<thead className="bg-gray-100 dark:bg-gray-700 sticky top-0">
<tr>
<th className="px-4 py-3 text-left text-xs font-semibold text-gray-600 dark:text-gray-300 uppercase tracking-wider w-16">
#
</th>
{columns.map((col, index) => (
<th
key={index}
className={`px-4 py-3 text-xs font-semibold text-gray-600 dark:text-gray-300 uppercase tracking-wider whitespace-nowrap ${
col.fieldtype === 'Int' ? 'text-center' : 'text-left'
}`}
style={{ minWidth: col.width || 120 }}
>
{col.label}
</th>
))}
</tr>
</thead>
<tbody className="divide-y divide-gray-200 dark:divide-gray-700">
{reportData.map((row, rowIndex) => (
<tr
key={rowIndex}
className="hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-colors"
>
<td className="px-4 py-3 text-sm text-gray-500 dark:text-gray-400 font-medium">
{rowIndex + 1}
</td>
{columns.map((col, colIndex) => {
const value = row[col.fieldname];
const formattedValue = formatCellValue(value, col);
if (col.fieldname === 'assigned_technician') {
return (
<td key={colIndex} className="px-4 py-3 whitespace-nowrap">
<div className="flex items-center gap-2">
<div className="w-8 h-8 rounded-full bg-purple-100 dark:bg-purple-900/30 flex items-center justify-center">
<FaHardHat className="text-purple-500 dark:text-purple-400" size={14} />
</div>
<span className="text-sm font-medium text-gray-800 dark:text-gray-200">
{formattedValue}
</span>
</div>
</td>
);
}
if (col.fieldname === 'total') {
return (
<td key={colIndex} className="px-4 py-3 text-center whitespace-nowrap">
<span className="inline-flex items-center justify-center min-w-[40px] px-3 py-1 rounded-full text-sm font-bold bg-indigo-100 dark:bg-indigo-900/30 text-indigo-700 dark:text-indigo-300">
{formattedValue}
</span>
</td>
);
}
if (col.fieldname === 'completed') {
return (
<td key={colIndex} className="px-4 py-3 text-center whitespace-nowrap">
<span className="inline-flex items-center justify-center min-w-[40px] px-3 py-1 rounded-full text-sm font-bold bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-300">
{formattedValue}
</span>
</td>
);
}
if (col.fieldname === 'in_progress') {
return (
<td key={colIndex} className="px-4 py-3 text-center whitespace-nowrap">
<span className="inline-flex items-center justify-center min-w-[40px] px-3 py-1 rounded-full text-sm font-bold bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300">
{formattedValue}
</span>
</td>
);
}
if (col.fieldname === 'open') {
return (
<td key={colIndex} className="px-4 py-3 text-center whitespace-nowrap">
<span className="inline-flex items-center justify-center min-w-[40px] px-3 py-1 rounded-full text-sm font-bold bg-amber-100 dark:bg-amber-900/30 text-amber-700 dark:text-amber-300">
{formattedValue}
</span>
</td>
);
}
return (
<td
key={colIndex}
className="px-4 py-3 text-sm text-gray-700 dark:text-gray-300"
>
{formattedValue}
</td>
);
})}
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
</div>
{/* Footer */}
<div className="bg-white dark:bg-gray-800 px-6 py-4 border-t border-gray-200 dark:border-gray-700 flex-shrink-0 shadow-inner">
<div className="flex items-center justify-between">
<p className="text-sm text-gray-600 dark:text-gray-400">
Showing {reportData.length} technician{reportData.length !== 1 ? 's' : ''}
{/* ── CHANGE 6 cont: Show active department in footer ── */}
{filterWorkOrderType && (
<span className="ml-2 text-purple-600 dark:text-purple-400">
· {filterWorkOrderType}
</span>
)}
{stats.totalWorkOrders > 0 && (
<span className="ml-2 text-purple-600 dark:text-purple-400">
Total: {stats.totalWorkOrders} work orders
<span className="text-green-600">{stats.totalCompleted} completed</span>
<span className="text-blue-600">{stats.totalInProgress} in progress</span>
<span className="text-amber-600">{stats.totalOpen} open</span>
</span>
)}
</p>
<button
onClick={onClose}
className="px-6 py-2 bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 text-gray-700 dark:text-gray-300 rounded-lg text-sm font-medium transition-colors"
>
Close
</button>
</div>
</div>
</div>
);
};
export default TechnicianWorkOrderSummaryReportModal;

View File

@ -0,0 +1,847 @@
import React, { useState, useEffect, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import * as XLSX from 'xlsx';
import {
FaTimes,
FaFileExcel,
FaFileCsv,
FaFilePdf,
FaPrint,
FaSpinner,
FaFilter,
FaChevronDown,
FaChevronUp,
FaSearch,
FaSync,
FaExternalLinkAlt,
FaClock,
FaArrowLeft,
FaUserCog,
FaUsers
} from 'react-icons/fa';
interface ReportColumn {
label: string;
fieldname: string;
fieldtype: string;
options?: string;
width?: number;
}
interface TechnicianWorkingHoursReportModalProps {
isOpen: boolean;
onClose: () => void;
permittedIssueTypes?: string[];
isAdmin?: boolean;
defaultWorkOrderType?: string;
}
const TechnicianWorkingHoursReportModal: React.FC<TechnicianWorkingHoursReportModalProps> = ({
isOpen,
onClose,
permittedIssueTypes = [],
isAdmin = true,
defaultWorkOrderType = ''
}) => {
const { t } = useTranslation();
// State
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [reportData, setReportData] = useState<any[]>([]);
const [columns, setColumns] = useState<ReportColumn[]>([]);
const [filtersExpanded, setFiltersExpanded] = useState(true);
// Filter states
const [filterFromDate, setFilterFromDate] = useState('');
const [filterToDate, setFilterToDate] = useState('');
const [filterWorkOrderType, setFilterWorkOrderType] = useState('');
// Export states
const [isExporting, setIsExporting] = useState(false);
// All issue types for admin dropdown
const [allIssueTypes, setAllIssueTypes] = useState<string[]>([]);
// Fetch all Issue Types for admin dropdown
useEffect(() => {
if (!isAdmin) return;
const fetchIssueTypes = async () => {
try {
const response = await fetch(
'/api/resource/Issue Type?fields=["name"]&limit_page_length=0&order_by=name asc',
{ headers: { Accept: 'application/json' }, credentials: 'include' }
);
const data = await response.json();
if (data.data) {
setAllIssueTypes(data.data.map((d: any) => d.name));
}
} catch (error) {
console.error('Error fetching issue types:', error);
}
};
fetchIssueTypes();
}, [isAdmin]);
// Auto-apply permitted Issue Type or dashboard global filter as default
useEffect(() => {
if (!isAdmin && permittedIssueTypes.length === 1) {
setFilterWorkOrderType(permittedIssueTypes[0]);
} else if (defaultWorkOrderType) {
setFilterWorkOrderType(defaultWorkOrderType);
} else {
setFilterWorkOrderType('');
}
}, [permittedIssueTypes, isAdmin, defaultWorkOrderType]);
// Report name constant
const REPORT_NAME = 'Technicians working Hours';
/**
* Fetch report data from Frappe API
*/
const fetchReportData = useCallback(async () => {
setLoading(true);
setError(null);
try {
// Build filters object
const filters: Record<string, any> = {};
if (filterFromDate) filters.from_date = filterFromDate;
if (filterToDate) filters.to_date = filterToDate;
// if (filterWorkOrderType) {
// filters.work_order_type = filterWorkOrderType;
// } else if (!isAdmin && permittedIssueTypes.length > 0) {
// filters.work_order_type = permittedIssueTypes.length === 1
// ? permittedIssueTypes[0]
// : ['in', permittedIssueTypes];
// }
if (filterWorkOrderType) {
filters.work_order_type = filterWorkOrderType;
} else if (!isAdmin && permittedIssueTypes.length > 0) {
if (permittedIssueTypes.length === 1) {
filters.work_order_type = permittedIssueTypes[0];
}
// For multiple permitted types: don't send filter at all —
// the report will return all types, then we filter client-side below
}
const response = await fetch('/api/method/frappe.desk.query_report.run', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
credentials: 'include',
body: JSON.stringify({
report_name: REPORT_NAME,
filters: filters,
ignore_prepared_report: 1
}),
});
const result = await response.json();
if (result.exc) {
throw new Error(result.exc);
}
if (result.message) {
// Set columns from report
if (result.message.columns && result.message.columns.length > 0) {
setColumns(result.message.columns);
}
// Set data
// if (result.message.result) {
// setReportData(result.message.result);
// } else {
// setReportData([]);
// }
if (result.message.result) {
let rows = result.message.result;
// Client-side filter when user has multiple permitted types
if (!isAdmin && permittedIssueTypes.length > 1 && !filterWorkOrderType) {
rows = rows.filter((r: any) =>
permittedIssueTypes.includes(r.work_order_type)
);
}
setReportData(rows);
} else {
setReportData([]);
}
}
} catch (err) {
console.error('Error fetching report:', err);
setError(err instanceof Error ? err.message : 'Failed to fetch report data');
setReportData([]);
} finally {
setLoading(false);
}
}, [filterFromDate, filterToDate, filterWorkOrderType, isAdmin, permittedIssueTypes]);
// Fetch data when modal opens or filters change
useEffect(() => {
if (isOpen) {
fetchReportData();
}
}, [isOpen, fetchReportData]);
// Handle escape key to close
useEffect(() => {
const handleEscape = (e: KeyboardEvent) => {
if (e.key === 'Escape' && isOpen) {
onClose();
}
};
document.addEventListener('keydown', handleEscape);
return () => document.removeEventListener('keydown', handleEscape);
}, [isOpen, onClose]);
// Prevent body scroll when modal is open
useEffect(() => {
if (isOpen) {
document.body.style.overflow = 'hidden';
} else {
document.body.style.overflow = 'unset';
}
return () => {
document.body.style.overflow = 'unset';
};
}, [isOpen]);
/**
* Clear all filters
*/
const handleClearFilters = () => {
setFilterFromDate('');
setFilterToDate('');
if (isAdmin) setFilterWorkOrderType('');
};
/**
* Calculate summary statistics
*/
const getSummaryStats = () => {
const totalTechnicians = reportData.length;
const totalHours = reportData.reduce((sum, row) => sum + (parseFloat(row.total_hours) || 0), 0);
const avgHours = totalTechnicians > 0 ? totalHours / totalTechnicians : 0;
return { totalTechnicians, totalHours, avgHours };
};
/**
* Export to CSV
*/
const handleExportCSV = () => {
if (reportData.length === 0) return;
setIsExporting(true);
try {
const headers = columns.map(col => col.label);
const csvContent = [
headers.join(','),
...reportData.map(row =>
columns.map(col => {
let value = row[col.fieldname] || '';
// Escape commas and quotes
if (typeof value === 'string' && (value.includes(',') || value.includes('"') || value.includes('\n'))) {
value = `"${value.replace(/"/g, '""')}"`;
}
return value;
}).join(',')
)
].join('\n');
const blob = new Blob(['\ufeff' + csvContent], { type: 'text/csv;charset=utf-8;' });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = `technician_working_hours_${new Date().toISOString().split('T')[0]}.csv`;
link.click();
URL.revokeObjectURL(url);
} finally {
setIsExporting(false);
}
};
/**
* Export to Excel
*/
const handleExportExcel = () => {
if (reportData.length === 0) return;
setIsExporting(true);
try {
const headers = columns.map(col => col.label);
const worksheetData = [
headers,
...reportData.map(row => columns.map(col => row[col.fieldname] || ''))
];
const worksheet = XLSX.utils.aoa_to_sheet(worksheetData);
// Set column widths
const colWidths = columns.map(col => ({ wch: col.width ? Math.floor(col.width / 7) : 20 }));
worksheet['!cols'] = colWidths;
const workbook = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(workbook, worksheet, 'Technician Hours');
XLSX.writeFile(workbook, `technician_working_hours_${new Date().toISOString().split('T')[0]}.xlsx`);
} finally {
setIsExporting(false);
}
};
/**
* Print report
*/
const handlePrint = () => {
const stats = getSummaryStats();
const printWindow = window.open('', '_blank');
if (!printWindow) {
alert('Please allow popups for this site to print the report.');
return;
}
const tableHTML = `
<!DOCTYPE html>
<html>
<head>
<title>Technicians Working Hours Report</title>
<style>
body { font-family: Arial, sans-serif; margin: 20px; }
h1 { text-align: center; color: #333; margin-bottom: 20px; }
.meta { text-align: center; color: #666; margin-bottom: 20px; font-size: 12px; }
.summary { display: flex; justify-content: center; gap: 30px; margin-bottom: 20px; padding: 15px; background: #f8f9fa; border-radius: 8px; }
.summary-item { text-align: center; }
.summary-label { font-size: 11px; color: #666; }
.summary-value { font-size: 20px; font-weight: bold; color: #333; }
.summary-value.hours { color: #0891B2; }
table { width: 100%; border-collapse: collapse; font-size: 12px; }
th { background-color: #0891B2; color: white; padding: 10px 8px; text-align: left; font-weight: 600; }
td { padding: 10px 8px; border-bottom: 1px solid #ddd; }
tr:nth-child(even) { background-color: #f9f9f9; }
tr:hover { background-color: #f5f5f5; }
.hours-cell { font-weight: 600; color: #0891B2; }
@media print {
body { margin: 0; }
table { page-break-inside: auto; }
tr { page-break-inside: avoid; page-break-after: auto; }
}
</style>
</head>
<body>
<h1>Technicians Working Hours Report</h1>
<div class="meta">
Generated on: ${new Date().toLocaleString()}
${filterFromDate ? ` | From: ${filterFromDate}` : ''}
${filterToDate ? ` | To: ${filterToDate}` : ''}
</div>
<div class="summary">
<div class="summary-item">
<div class="summary-label">Total Technicians</div>
<div class="summary-value">${stats.totalTechnicians}</div>
</div>
<div class="summary-item">
<div class="summary-label">Total Hours</div>
<div class="summary-value hours">${stats.totalHours.toFixed(2)}</div>
</div>
<div class="summary-item">
<div class="summary-label">Average Hours</div>
<div class="summary-value hours">${stats.avgHours.toFixed(2)}</div>
</div>
</div>
<table>
<thead>
<tr>
<th>#</th>
${columns.map(col => `<th>${col.label}</th>`).join('')}
</tr>
</thead>
<tbody>
${reportData.map((row, index) => `
<tr>
<td>${index + 1}</td>
${columns.map(col => {
let value = row[col.fieldname] || '-';
let className = '';
// Add class for hours column
if (col.fieldname === 'total_hours') {
className = 'hours-cell';
value = typeof value === 'number' ? value.toFixed(2) : value;
}
return `<td class="${className}">${value}</td>`;
}).join('')}
</tr>
`).join('')}
</tbody>
</table>
<script>
window.onload = function() { window.print(); }
</script>
</body>
</html>
`;
printWindow.document.write(tableHTML);
printWindow.document.close();
};
/**
* Export to PDF (using browser print)
*/
const handleExportPDF = () => {
handlePrint(); // Uses print dialog which can save as PDF
};
/**
* Open in ERPNext
*/
const handleOpenInERPNext = () => {
const baseUrl = window.location.origin;
let url = `${baseUrl}/app/query-report/${encodeURIComponent(REPORT_NAME)}`;
// Add filters to URL
const params = new URLSearchParams();
if (filterFromDate) params.append('from_date', filterFromDate);
if (filterToDate) params.append('to_date', filterToDate);
if (params.toString()) {
url += `?${params.toString()}`;
}
window.open(url, '_blank');
};
/**
* Format cell value based on fieldtype
*/
const formatCellValue = (value: any, column: ReportColumn) => {
if (value === null || value === undefined || value === '') return '-';
switch (column.fieldtype) {
case 'Float':
return typeof value === 'number' ? value.toFixed(2) : value;
case 'Date':
return new Date(value).toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric'
});
case 'Datetime':
return new Date(value).toLocaleString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
});
default:
return String(value);
}
};
if (!isOpen) return null;
const stats = getSummaryStats();
const hasActiveFilters = filterFromDate || filterToDate || filterWorkOrderType;
return (
<div className="fixed inset-0 z-[80] bg-gray-50 dark:bg-gray-900 flex flex-col">
{/* Header */}
<div className="bg-gradient-to-r from-cyan-600 to-blue-600 px-6 py-4 flex-shrink-0 shadow-lg">
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
{/* Back Button */}
<button
onClick={onClose}
className="p-2 text-white/80 hover:text-white hover:bg-white/20 rounded-lg transition-colors"
title="Go Back"
>
<FaArrowLeft size={18} />
</button>
<div className="flex items-center gap-3">
<FaClock className="text-white text-xl" />
<div>
<h2 className="text-xl font-bold text-white">Technicians Working Hours</h2>
<p className="text-white/70 text-sm">
{reportData.length} technician{reportData.length !== 1 ? 's' : ''} found
</p>
</div>
</div>
</div>
<div className="flex items-center gap-2">
{/* Open in ERPNext Button */}
{/* <button
onClick={handleOpenInERPNext}
className="px-4 py-2 bg-white/20 hover:bg-white/30 text-white rounded-lg text-sm font-medium transition-all flex items-center gap-2"
title="Open in ERPNext"
>
<FaExternalLinkAlt size={12} />
<span>Open in ERPNext</span>
</button> */}
{/* Close Button */}
<button
onClick={onClose}
className="p-2 text-white/80 hover:text-white hover:bg-white/20 rounded-lg transition-colors"
title="Close (Esc)"
>
<FaTimes size={20} />
</button>
</div>
</div>
</div>
{/* Summary Cards */}
{!loading && reportData.length > 0 && (
<div className="bg-white dark:bg-gray-800 px-6 py-4 border-b border-gray-200 dark:border-gray-700 flex-shrink-0">
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div className="bg-blue-50 dark:bg-blue-900/20 rounded-lg p-4 border border-blue-200 dark:border-blue-800">
<div className="flex items-center gap-3">
<FaUsers className="text-blue-500 text-2xl" />
<div>
<p className="text-xs text-blue-600 dark:text-blue-400 font-medium">Total Technicians</p>
<p className="text-2xl font-bold text-blue-700 dark:text-blue-300">{stats.totalTechnicians}</p>
</div>
</div>
</div>
<div className="bg-cyan-50 dark:bg-cyan-900/20 rounded-lg p-4 border border-cyan-200 dark:border-cyan-800">
<div className="flex items-center gap-3">
<FaClock className="text-cyan-500 text-2xl" />
<div>
<p className="text-xs text-cyan-600 dark:text-cyan-400 font-medium">Total Hours Worked</p>
<p className="text-2xl font-bold text-cyan-700 dark:text-cyan-300">{stats.totalHours.toFixed(2)}</p>
</div>
</div>
</div>
<div className="bg-purple-50 dark:bg-purple-900/20 rounded-lg p-4 border border-purple-200 dark:border-purple-800">
<div className="flex items-center gap-3">
<FaUserCog className="text-purple-500 text-2xl" />
<div>
<p className="text-xs text-purple-600 dark:text-purple-400 font-medium">Average Hours/Technician</p>
<p className="text-2xl font-bold text-purple-700 dark:text-purple-300">{stats.avgHours.toFixed(2)}</p>
</div>
</div>
</div>
</div>
</div>
)}
{/* Toolbar */}
<div className="bg-white dark:bg-gray-800 px-6 py-3 border-b border-gray-200 dark:border-gray-700 flex-shrink-0 shadow-sm">
<div className="flex items-center justify-between gap-4 flex-wrap">
{/* Filter Toggle */}
<button
onClick={() => setFiltersExpanded(!filtersExpanded)}
className="flex items-center gap-2 px-4 py-2 bg-gray-100 dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg text-sm font-medium text-gray-700 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors"
>
<FaFilter size={12} />
Filters
{hasActiveFilters && (
<span className="bg-cyan-100 dark:bg-cyan-900/50 text-cyan-600 dark:text-cyan-400 px-2 py-0.5 rounded-full text-xs font-bold">
{[filterFromDate, filterToDate, filterWorkOrderType].filter(Boolean).length}
</span>
)}
{filtersExpanded ? <FaChevronUp size={10} /> : <FaChevronDown size={10} />}
</button>
{/* Action Buttons */}
<div className="flex items-center gap-2">
{/* Refresh */}
<button
onClick={fetchReportData}
disabled={loading}
className="p-2.5 bg-gray-100 dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-600 dark:text-gray-400 hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors disabled:opacity-50"
title="Refresh"
>
<FaSync className={loading ? 'animate-spin' : ''} size={14} />
</button>
{/* Export Buttons */}
<div className="flex items-center gap-1 bg-gray-100 dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg overflow-hidden">
<button
onClick={handleExportCSV}
disabled={reportData.length === 0 || isExporting}
className="px-4 py-2.5 text-sm font-medium text-gray-700 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors flex items-center gap-2 disabled:opacity-50"
title="Export as CSV"
>
<FaFileCsv className="text-green-600" size={14} />
<span>CSV</span>
</button>
<div className="w-px h-6 bg-gray-300 dark:bg-gray-600"></div>
<button
onClick={handleExportExcel}
disabled={reportData.length === 0 || isExporting}
className="px-4 py-2.5 text-sm font-medium text-gray-700 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors flex items-center gap-2 disabled:opacity-50"
title="Export as Excel"
>
<FaFileExcel className="text-green-700" size={14} />
<span>Excel</span>
</button>
<div className="w-px h-6 bg-gray-300 dark:bg-gray-600"></div>
<button
onClick={handleExportPDF}
disabled={reportData.length === 0}
className="px-4 py-2.5 text-sm font-medium text-gray-700 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors flex items-center gap-2 disabled:opacity-50"
title="Export as PDF"
>
<FaFilePdf className="text-red-600" size={14} />
<span>PDF</span>
</button>
<div className="w-px h-6 bg-gray-300 dark:bg-gray-600"></div>
<button
onClick={handlePrint}
disabled={reportData.length === 0}
className="px-4 py-2.5 text-sm font-medium text-gray-700 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors flex items-center gap-2 disabled:opacity-50"
title="Print"
>
<FaPrint className="text-purple-600" size={14} />
<span>Print</span>
</button>
</div>
</div>
</div>
{/* Expandable Filters */}
{filtersExpanded && (
<div className="mt-4 p-4 bg-gray-50 dark:bg-gray-900/50 rounded-lg border border-gray-200 dark:border-gray-700">
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
{/* From Date Filter */}
<div>
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">
From Date
</label>
<input
type="date"
value={filterFromDate}
onChange={(e) => setFilterFromDate(e.target.value)}
className="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-cyan-500 bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
/>
</div>
{/* To Date Filter */}
<div>
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">
To Date
</label>
<input
type="date"
value={filterToDate}
onChange={(e) => setFilterToDate(e.target.value)}
className="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-cyan-500 bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
/>
</div>
{/* Technical Department Filter */}
<div>
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">
Technical Department
</label>
{!isAdmin && permittedIssueTypes.length === 1 ? (
<div className="w-full px-3 py-2 text-sm border border-gray-200 dark:border-gray-600 rounded-lg bg-gray-50 dark:bg-gray-600 text-gray-700 dark:text-gray-300 flex items-center gap-2">
<span className="w-2 h-2 rounded-full bg-indigo-400 flex-shrink-0"></span>
{filterWorkOrderType}
<span className="ml-auto text-[10px] text-gray-400 dark:text-gray-500 uppercase">Restricted</span>
</div>
) : (
<select
value={filterWorkOrderType}
onChange={(e) => setFilterWorkOrderType(e.target.value)}
className="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-cyan-500 bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
>
<option value="">All Departments</option>
{/*{(!isAdmin && permittedIssueTypes.length > 0 ? permittedIssueTypes : []).map(type => ( */}
{(isAdmin ? allIssueTypes : permittedIssueTypes).map(type => (
<option key={type} value={type}>{type}</option>
))}
</select>
)}
</div>
{/* Clear Filters Button */}
<div className="flex items-end">
<button
onClick={handleClearFilters}
disabled={!hasActiveFilters}
className="px-4 py-2 text-sm font-medium text-gray-600 dark:text-gray-400 hover:text-red-600 dark:hover:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20 rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2"
>
<FaTimes size={12} />
Clear Filters
</button>
</div>
</div>
</div>
)}
</div>
{/* Content - Full height scrollable area */}
<div className="flex-1 overflow-auto p-6">
{/* Loading State */}
{loading && (
<div className="flex items-center justify-center py-20">
<div className="text-center">
<FaSpinner className="animate-spin text-cyan-500 text-5xl mx-auto mb-4" />
<p className="text-gray-600 dark:text-gray-400 text-lg">Loading report data...</p>
</div>
</div>
)}
{/* Error State */}
{error && !loading && (
<div className="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-8 text-center max-w-lg mx-auto">
<p className="text-red-600 dark:text-red-400 mb-4 text-lg">{error}</p>
<button
onClick={fetchReportData}
className="px-6 py-2 bg-red-600 hover:bg-red-700 text-white rounded-lg text-sm font-medium transition-colors"
>
Try Again
</button>
</div>
)}
{/* Empty State */}
{!loading && !error && reportData.length === 0 && (
<div className="text-center py-20">
<FaSearch className="text-gray-300 dark:text-gray-600 text-6xl mx-auto mb-4" />
<p className="text-gray-500 dark:text-gray-400 text-xl">No data found</p>
<p className="text-gray-400 dark:text-gray-500 text-sm mt-2">
Try adjusting your date range filters
</p>
</div>
)}
{/* Data Table */}
{!loading && !error && reportData.length > 0 && (
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 overflow-hidden shadow-sm">
<div className="overflow-x-auto">
<table className="w-full">
<thead className="bg-gray-100 dark:bg-gray-700 sticky top-0">
<tr>
<th className="px-4 py-3 text-left text-xs font-semibold text-gray-600 dark:text-gray-300 uppercase tracking-wider w-16">
#
</th>
{columns.map((col, index) => (
<th
key={index}
className="px-4 py-3 text-left text-xs font-semibold text-gray-600 dark:text-gray-300 uppercase tracking-wider whitespace-nowrap"
style={{ minWidth: col.width || 150 }}
>
{col.label}
</th>
))}
</tr>
</thead>
<tbody className="divide-y divide-gray-200 dark:divide-gray-700">
{reportData.map((row, rowIndex) => (
<tr
key={rowIndex}
className="hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-colors"
>
<td className="px-4 py-3 text-sm text-gray-500 dark:text-gray-400 font-medium">
{rowIndex + 1}
</td>
{columns.map((col, colIndex) => {
const value = row[col.fieldname];
const formattedValue = formatCellValue(value, col);
// Special rendering for total_hours
if (col.fieldname === 'total_hours') {
return (
<td key={colIndex} className="px-4 py-3 whitespace-nowrap">
<span className="inline-flex items-center gap-1 px-3 py-1 rounded-full text-sm font-semibold bg-cyan-100 dark:bg-cyan-900/30 text-cyan-700 dark:text-cyan-300">
<FaClock size={12} />
{formattedValue} hrs
</span>
</td>
);
}
// Link to user for engineer field
if (col.fieldname === 'engineer' && value) {
return (
<td key={colIndex} className="px-4 py-3 whitespace-nowrap">
<a
href={`/app/user/${value}`}
target="_blank"
rel="noopener noreferrer"
className="text-cyan-600 dark:text-cyan-400 hover:underline font-medium text-sm"
>
{formattedValue}
</a>
</td>
);
}
// Technician name with icon
if (col.fieldname === 'technician_name') {
return (
<td key={colIndex} className="px-4 py-3 whitespace-nowrap">
<div className="flex items-center gap-2">
<div className="w-8 h-8 rounded-full bg-gray-200 dark:bg-gray-600 flex items-center justify-center">
<FaUserCog className="text-gray-500 dark:text-gray-400" size={14} />
</div>
<span className="text-sm font-medium text-gray-800 dark:text-gray-200">
{formattedValue}
</span>
</div>
</td>
);
}
// Default rendering
return (
<td
key={colIndex}
className="px-4 py-3 text-sm text-gray-700 dark:text-gray-300"
title={typeof value === 'string' && value.length > 50 ? value : undefined}
>
<div className="max-w-xs truncate">
{formattedValue}
</div>
</td>
);
})}
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
</div>
{/* Footer */}
<div className="bg-white dark:bg-gray-800 px-6 py-4 border-t border-gray-200 dark:border-gray-700 flex-shrink-0 shadow-inner">
<div className="flex items-center justify-between">
<p className="text-sm text-gray-600 dark:text-gray-400">
Showing {reportData.length} technician{reportData.length !== 1 ? 's' : ''}
{hasActiveFilters && ' (filtered)'}
{stats.totalHours > 0 && (
<span className="ml-2 text-cyan-600 dark:text-cyan-400">
Total: {stats.totalHours.toFixed(2)} hours
</span>
)}
</p>
<button
onClick={onClose}
className="px-6 py-2 bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 text-gray-700 dark:text-gray-300 rounded-lg text-sm font-medium transition-colors"
>
Close
</button>
</div>
</div>
</div>
);
};
export default TechnicianWorkingHoursReportModal;

View File

@ -0,0 +1,731 @@
import React, { useState, useEffect, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import * as XLSX from 'xlsx';
import {
FaTimes,
FaFileExcel,
FaFileCsv,
FaFilePdf,
FaPrint,
FaSpinner,
FaFilter,
FaChevronDown,
FaChevronUp,
FaSearch,
FaSync,
FaExternalLinkAlt,
FaTable,
FaArrowLeft
} from 'react-icons/fa';
import LinkField from './LinkField';
interface ReportColumn {
label: string;
fieldname: string;
fieldtype: string;
options?: string;
width?: number;
}
interface ReportFilter {
fieldname: string;
label: string;
fieldtype: string;
options?: string;
mandatory?: number;
}
interface WorkOrderReportModalProps {
isOpen: boolean;
onClose: () => void;
}
const WorkOrderReportModal: React.FC<WorkOrderReportModalProps> = ({ isOpen, onClose }) => {
const { t } = useTranslation();
// State
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [reportData, setReportData] = useState<any[]>([]);
const [columns, setColumns] = useState<ReportColumn[]>([]);
const [filtersExpanded, setFiltersExpanded] = useState(true);
// Filter states
const [filterWorkOrderType, setFilterWorkOrderType] = useState('');
const [filterStatus, setFilterStatus] = useState('');
// Export states
const [isExporting, setIsExporting] = useState(false);
// Report name constant
const REPORT_NAME = 'Work Order Data';
/**
* Fetch report data from Frappe API
*/
const fetchReportData = useCallback(async () => {
setLoading(true);
setError(null);
try {
// Build filters object
const filters: Record<string, any> = {};
if (filterWorkOrderType) filters.work_order_type = filterWorkOrderType;
if (filterStatus) filters.repair_status = filterStatus;
const response = await fetch('/api/method/frappe.desk.query_report.run', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
credentials: 'include',
body: JSON.stringify({
report_name: REPORT_NAME,
filters: filters,
ignore_prepared_report: 1
}),
});
const result = await response.json();
if (result.exc) {
throw new Error(result.exc);
}
if (result.message) {
// Set columns from report
if (result.message.columns && result.message.columns.length > 0) {
setColumns(result.message.columns);
}
// Set data
if (result.message.result) {
setReportData(result.message.result);
} else {
setReportData([]);
}
}
} catch (err) {
console.error('Error fetching report:', err);
setError(err instanceof Error ? err.message : 'Failed to fetch report data');
setReportData([]);
} finally {
setLoading(false);
}
}, [filterWorkOrderType, filterStatus]);
// Fetch data when modal opens or filters change
useEffect(() => {
if (isOpen) {
fetchReportData();
}
}, [isOpen, fetchReportData]);
// Handle escape key to close
useEffect(() => {
const handleEscape = (e: KeyboardEvent) => {
if (e.key === 'Escape' && isOpen) {
onClose();
}
};
document.addEventListener('keydown', handleEscape);
return () => document.removeEventListener('keydown', handleEscape);
}, [isOpen, onClose]);
// Prevent body scroll when modal is open
useEffect(() => {
if (isOpen) {
document.body.style.overflow = 'hidden';
} else {
document.body.style.overflow = 'unset';
}
return () => {
document.body.style.overflow = 'unset';
};
}, [isOpen]);
/**
* Clear all filters
*/
const handleClearFilters = () => {
setFilterWorkOrderType('');
setFilterStatus('');
};
/**
* Export to CSV
*/
const handleExportCSV = () => {
if (reportData.length === 0) return;
setIsExporting(true);
try {
const headers = columns.map(col => col.label);
const csvContent = [
headers.join(','),
...reportData.map(row =>
columns.map(col => {
let value = row[col.fieldname] || '';
// Escape commas and quotes
if (typeof value === 'string' && (value.includes(',') || value.includes('"') || value.includes('\n'))) {
value = `"${value.replace(/"/g, '""')}"`;
}
return value;
}).join(',')
)
].join('\n');
const blob = new Blob(['\ufeff' + csvContent], { type: 'text/csv;charset=utf-8;' });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = `work_order_report_${new Date().toISOString().split('T')[0]}.csv`;
link.click();
URL.revokeObjectURL(url);
} finally {
setIsExporting(false);
}
};
/**
* Export to Excel
*/
const handleExportExcel = () => {
if (reportData.length === 0) return;
setIsExporting(true);
try {
const headers = columns.map(col => col.label);
const worksheetData = [
headers,
...reportData.map(row => columns.map(col => row[col.fieldname] || ''))
];
const worksheet = XLSX.utils.aoa_to_sheet(worksheetData);
// Set column widths
const colWidths = columns.map(col => ({ wch: col.width ? Math.floor(col.width / 7) : 15 }));
worksheet['!cols'] = colWidths;
const workbook = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(workbook, worksheet, 'Work Order Report');
XLSX.writeFile(workbook, `work_order_report_${new Date().toISOString().split('T')[0]}.xlsx`);
} finally {
setIsExporting(false);
}
};
/**
* Print report
*/
const handlePrint = () => {
const printWindow = window.open('', '_blank');
if (!printWindow) {
alert('Please allow popups for this site to print the report.');
return;
}
const tableHTML = `
<!DOCTYPE html>
<html>
<head>
<title>Work Order Report</title>
<style>
body { font-family: Arial, sans-serif; margin: 20px; }
h1 { text-align: center; color: #333; margin-bottom: 20px; }
.meta { text-align: center; color: #666; margin-bottom: 20px; font-size: 12px; }
table { width: 100%; border-collapse: collapse; font-size: 11px; }
th { background-color: #4A90D9; color: white; padding: 10px 8px; text-align: left; font-weight: 600; }
td { padding: 8px; border-bottom: 1px solid #ddd; }
tr:nth-child(even) { background-color: #f9f9f9; }
tr:hover { background-color: #f5f5f5; }
.status-open { color: #D97706; font-weight: 500; }
.status-completed { color: #059669; font-weight: 500; }
.status-inprogress { color: #2563EB; font-weight: 500; }
.priority-urgent { color: #DC2626; font-weight: 600; }
.priority-normal { color: #6B7280; }
@media print {
body { margin: 0; }
table { page-break-inside: auto; }
tr { page-break-inside: avoid; page-break-after: auto; }
}
</style>
</head>
<body>
<h1>Work Order Report</h1>
<div class="meta">
Generated on: ${new Date().toLocaleString()} | Total Records: ${reportData.length}
${filterWorkOrderType ? ` | Type: ${filterWorkOrderType}` : ''}
${filterStatus ? ` | Status: ${filterStatus}` : ''}
</div>
<table>
<thead>
<tr>
${columns.map(col => `<th>${col.label}</th>`).join('')}
</tr>
</thead>
<tbody>
${reportData.map(row => `
<tr>
${columns.map(col => {
let value = row[col.fieldname] || '-';
let className = '';
// Add status classes
if (col.fieldname === 'repair_status') {
if (value.toLowerCase().includes('open')) className = 'status-open';
else if (value.toLowerCase().includes('completed')) className = 'status-completed';
else if (value.toLowerCase().includes('progress')) className = 'status-inprogress';
}
// Add priority classes
if (col.fieldname === 'custom_priority_') {
if (value.toLowerCase() === 'urgent') className = 'priority-urgent';
else className = 'priority-normal';
}
return `<td class="${className}">${value}</td>`;
}).join('')}
</tr>
`).join('')}
</tbody>
</table>
<script>
window.onload = function() { window.print(); }
</script>
</body>
</html>
`;
printWindow.document.write(tableHTML);
printWindow.document.close();
};
/**
* Export to PDF (using browser print)
*/
const handleExportPDF = () => {
handlePrint(); // Uses print dialog which can save as PDF
};
/**
* Open in ERPNext
*/
const handleOpenInERPNext = () => {
const baseUrl = window.location.origin;
let url = `${baseUrl}/app/query-report/${encodeURIComponent(REPORT_NAME)}`;
// Add filters to URL
const params = new URLSearchParams();
if (filterWorkOrderType) params.append('work_order_type', filterWorkOrderType);
if (filterStatus) params.append('repair_status', filterStatus);
if (params.toString()) {
url += `?${params.toString()}`;
}
window.open(url, '_blank');
};
/**
* Get status badge color
*/
const getStatusColor = (status: string) => {
switch (status?.toLowerCase()) {
case 'completed':
case 'closed':
return 'bg-green-100 dark:bg-green-900/30 text-green-800 dark:text-green-300';
case 'work in progress':
return 'bg-blue-100 dark:bg-blue-900/30 text-blue-800 dark:text-blue-300';
case 'open':
case 'pending review':
return 'bg-yellow-100 dark:bg-yellow-900/30 text-yellow-800 dark:text-yellow-300';
case 'cancelled':
return 'bg-red-100 dark:bg-red-900/30 text-red-800 dark:text-red-300';
default:
return 'bg-gray-100 dark:bg-gray-700 text-gray-800 dark:text-gray-300';
}
};
/**
* Get priority badge color
*/
const getPriorityColor = (priority: string) => {
switch (priority?.toLowerCase()) {
case 'urgent':
return 'bg-red-100 dark:bg-red-900/30 text-red-800 dark:text-red-300';
case 'medium':
return 'bg-orange-100 dark:bg-orange-900/30 text-orange-800 dark:text-orange-300';
default:
return 'bg-gray-100 dark:bg-gray-700 text-gray-800 dark:text-gray-300';
}
};
/**
* Format cell value based on fieldtype
*/
const formatCellValue = (value: any, column: ReportColumn) => {
if (value === null || value === undefined || value === '') return '-';
switch (column.fieldtype) {
case 'Date':
return new Date(value).toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric'
});
case 'Datetime':
return new Date(value).toLocaleString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
});
case 'Currency':
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'SAR'
}).format(value);
case 'Link':
return value;
default:
return String(value);
}
};
if (!isOpen) return null;
return (
<div className="fixed inset-0 z-[80] bg-gray-50 dark:bg-gray-900 flex flex-col">
{/* Header */}
<div className="bg-gradient-to-r from-indigo-600 to-purple-600 px-6 py-4 flex-shrink-0 shadow-lg">
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
{/* Back Button */}
<button
onClick={onClose}
className="p-2 text-white/80 hover:text-white hover:bg-white/20 rounded-lg transition-colors"
title="Go Back"
>
<FaArrowLeft size={18} />
</button>
<div className="flex items-center gap-3">
<FaTable className="text-white text-xl" />
<div>
<h2 className="text-xl font-bold text-white">Work Order Report</h2>
<p className="text-white/70 text-sm">
{reportData.length} record{reportData.length !== 1 ? 's' : ''} found
</p>
</div>
</div>
</div>
<div className="flex items-center gap-2">
{/* Open in ERPNext Button */}
{/* <button
onClick={handleOpenInERPNext}
className="px-4 py-2 bg-white/20 hover:bg-white/30 text-white rounded-lg text-sm font-medium transition-all flex items-center gap-2"
title="Open in ERPNext"
>
<FaExternalLinkAlt size={12} />
<span>Open in ERPNext</span>
</button> */}
{/* Close Button */}
<button
onClick={onClose}
className="p-2 text-white/80 hover:text-white hover:bg-white/20 rounded-lg transition-colors"
title="Close (Esc)"
>
<FaTimes size={20} />
</button>
</div>
</div>
</div>
{/* Toolbar */}
<div className="bg-white dark:bg-gray-800 px-6 py-3 border-b border-gray-200 dark:border-gray-700 flex-shrink-0 shadow-sm">
<div className="flex items-center justify-between gap-4 flex-wrap">
{/* Filter Toggle */}
<button
onClick={() => setFiltersExpanded(!filtersExpanded)}
className="flex items-center gap-2 px-4 py-2 bg-gray-100 dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg text-sm font-medium text-gray-700 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors"
>
<FaFilter size={12} />
Filters
{(filterWorkOrderType || filterStatus) && (
<span className="bg-indigo-100 dark:bg-indigo-900/50 text-indigo-600 dark:text-indigo-400 px-2 py-0.5 rounded-full text-xs font-bold">
{[filterWorkOrderType, filterStatus].filter(Boolean).length}
</span>
)}
{filtersExpanded ? <FaChevronUp size={10} /> : <FaChevronDown size={10} />}
</button>
{/* Action Buttons */}
<div className="flex items-center gap-2">
{/* Refresh */}
<button
onClick={fetchReportData}
disabled={loading}
className="p-2.5 bg-gray-100 dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-600 dark:text-gray-400 hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors disabled:opacity-50"
title="Refresh"
>
<FaSync className={loading ? 'animate-spin' : ''} size={14} />
</button>
{/* Export Buttons */}
<div className="flex items-center gap-1 bg-gray-100 dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg overflow-hidden">
<button
onClick={handleExportCSV}
disabled={reportData.length === 0 || isExporting}
className="px-4 py-2.5 text-sm font-medium text-gray-700 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors flex items-center gap-2 disabled:opacity-50"
title="Export as CSV"
>
<FaFileCsv className="text-green-600" size={14} />
<span>CSV</span>
</button>
<div className="w-px h-6 bg-gray-300 dark:bg-gray-600"></div>
<button
onClick={handleExportExcel}
disabled={reportData.length === 0 || isExporting}
className="px-4 py-2.5 text-sm font-medium text-gray-700 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors flex items-center gap-2 disabled:opacity-50"
title="Export as Excel"
>
<FaFileExcel className="text-green-700" size={14} />
<span>Excel</span>
</button>
<div className="w-px h-6 bg-gray-300 dark:bg-gray-600"></div>
<button
onClick={handleExportPDF}
disabled={reportData.length === 0}
className="px-4 py-2.5 text-sm font-medium text-gray-700 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors flex items-center gap-2 disabled:opacity-50"
title="Export as PDF"
>
<FaFilePdf className="text-red-600" size={14} />
<span>PDF</span>
</button>
<div className="w-px h-6 bg-gray-300 dark:bg-gray-600"></div>
<button
onClick={handlePrint}
disabled={reportData.length === 0}
className="px-4 py-2.5 text-sm font-medium text-gray-700 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors flex items-center gap-2 disabled:opacity-50"
title="Print"
>
<FaPrint className="text-purple-600" size={14} />
<span>Print</span>
</button>
</div>
</div>
</div>
{/* Expandable Filters */}
{filtersExpanded && (
<div className="mt-4 p-4 bg-gray-50 dark:bg-gray-900/50 rounded-lg border border-gray-200 dark:border-gray-700">
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
{/* Work Order Type Filter */}
<div className="relative z-[70]">
<LinkField
label="Work Order Type"
doctype="Issue Type"
value={filterWorkOrderType}
onChange={(val) => setFilterWorkOrderType(val)}
placeholder="All Types"
disabled={false}
compact={false}
/>
</div>
{/* Status Filter */}
<div>
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">
Work Order Status
</label>
<select
value={filterStatus}
onChange={(e) => setFilterStatus(e.target.value)}
className="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-indigo-500 bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
>
<option value="">All Status</option>
<option value="Open">Open</option>
<option value="Work In Progress">Work In Progress</option>
<option value="Pending Review">Pending Review</option>
<option value="Completed">Completed</option>
<option value="Executed">Executed</option>
<option value="Cancelled">Cancelled</option>
<option value="Closed">Closed</option>
</select>
</div>
{/* Spacer */}
<div></div>
{/* Clear Filters Button */}
<div className="flex items-end">
<button
onClick={handleClearFilters}
disabled={!filterWorkOrderType && !filterStatus}
className="px-4 py-2 text-sm font-medium text-gray-600 dark:text-gray-400 hover:text-red-600 dark:hover:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20 rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2"
>
<FaTimes size={12} />
Clear Filters
</button>
</div>
</div>
</div>
)}
</div>
{/* Content - Full height scrollable area */}
<div className="flex-1 overflow-auto p-6">
{/* Loading State */}
{loading && (
<div className="flex items-center justify-center py-20">
<div className="text-center">
<FaSpinner className="animate-spin text-indigo-500 text-5xl mx-auto mb-4" />
<p className="text-gray-600 dark:text-gray-400 text-lg">Loading report data...</p>
</div>
</div>
)}
{/* Error State */}
{error && !loading && (
<div className="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-8 text-center max-w-lg mx-auto">
<p className="text-red-600 dark:text-red-400 mb-4 text-lg">{error}</p>
<button
onClick={fetchReportData}
className="px-6 py-2 bg-red-600 hover:bg-red-700 text-white rounded-lg text-sm font-medium transition-colors"
>
Try Again
</button>
</div>
)}
{/* Empty State */}
{!loading && !error && reportData.length === 0 && (
<div className="text-center py-20">
<FaSearch className="text-gray-300 dark:text-gray-600 text-6xl mx-auto mb-4" />
<p className="text-gray-500 dark:text-gray-400 text-xl">No data found</p>
<p className="text-gray-400 dark:text-gray-500 text-sm mt-2">
Try adjusting your filters
</p>
</div>
)}
{/* Data Table */}
{!loading && !error && reportData.length > 0 && (
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 overflow-hidden shadow-sm">
<div className="overflow-x-auto">
<table className="w-full">
<thead className="bg-gray-100 dark:bg-gray-700 sticky top-0">
<tr>
<th className="px-4 py-3 text-left text-xs font-semibold text-gray-600 dark:text-gray-300 uppercase tracking-wider w-16">
#
</th>
{columns.map((col, index) => (
<th
key={index}
className="px-4 py-3 text-left text-xs font-semibold text-gray-600 dark:text-gray-300 uppercase tracking-wider whitespace-nowrap"
style={{ minWidth: col.width || 120 }}
>
{col.label}
</th>
))}
</tr>
</thead>
<tbody className="divide-y divide-gray-200 dark:divide-gray-700">
{reportData.map((row, rowIndex) => (
<tr
key={rowIndex}
className="hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-colors"
>
<td className="px-4 py-3 text-sm text-gray-500 dark:text-gray-400 font-medium">
{rowIndex + 1}
</td>
{columns.map((col, colIndex) => {
const value = row[col.fieldname];
const formattedValue = formatCellValue(value, col);
// Special rendering for status
if (col.fieldname === 'repair_status') {
return (
<td key={colIndex} className="px-4 py-3 whitespace-nowrap">
<span className={`px-3 py-1 rounded-full text-xs font-medium ${getStatusColor(value)}`}>
{formattedValue}
</span>
</td>
);
}
// Special rendering for priority
if (col.fieldname === 'custom_priority_') {
return (
<td key={colIndex} className="px-4 py-3 whitespace-nowrap">
<span className={`px-3 py-1 rounded-full text-xs font-medium ${getPriorityColor(value)}`}>
{formattedValue}
</span>
</td>
);
}
// Link fields - make clickable
if (col.fieldtype === 'Link' && col.fieldname === 'name') {
return (
<td key={colIndex} className="px-4 py-3 whitespace-nowrap">
<a
href={`/asm_app/work-orders/${value}`}
target="_blank"
rel="noopener noreferrer"
className="text-indigo-600 dark:text-indigo-400 hover:underline font-medium text-sm"
>
{formattedValue}
</a>
</td>
);
}
// Default rendering
return (
<td
key={colIndex}
className="px-4 py-3 text-sm text-gray-700 dark:text-gray-300"
title={typeof value === 'string' && value.length > 50 ? value : undefined}
>
<div className="max-w-xs truncate">
{formattedValue}
</div>
</td>
);
})}
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
</div>
{/* Footer */}
<div className="bg-white dark:bg-gray-800 px-6 py-4 border-t border-gray-200 dark:border-gray-700 flex-shrink-0 shadow-inner">
<div className="flex items-center justify-between">
<p className="text-sm text-gray-600 dark:text-gray-400">
Showing {reportData.length} record{reportData.length !== 1 ? 's' : ''}
{(filterWorkOrderType || filterStatus) && ' (filtered)'}
</p>
<button
onClick={onClose}
className="px-6 py-2 bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 text-gray-700 dark:text-gray-300 rounded-lg text-sm font-medium transition-colors"
>
Close
</button>
</div>
</div>
</div>
);
};
export default WorkOrderReportModal;

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;

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

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

View File

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

View File

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

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

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

View File

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

View File

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

View File

@ -0,0 +1,111 @@
import { useState, useCallback, useEffect } from 'react';
import apiService from '../services/apiService';
// ============== INTERFACES ==============
export interface VersionChange {
field: string;
oldValue: any;
newValue: any;
}
export interface AuditLogEntry {
name: string;
owner: string;
creation: string;
changes: VersionChange[];
added: any[];
removed: any[];
rowChanged: any[];
}
interface UseAuditLogsOptions {
doctype: string;
docname: string | null;
limit?: number;
enabled?: boolean;
}
interface UseAuditLogsReturn {
auditLogs: AuditLogEntry[];
loading: boolean;
error: string | null;
refetch: () => void;
}
// ============== HOOK ==============
export const useAuditLogs = ({
doctype,
docname,
limit = 50,
enabled = true,
}: UseAuditLogsOptions): UseAuditLogsReturn => {
const [auditLogs, setAuditLogs] = useState<AuditLogEntry[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const fetchAuditLogs = useCallback(async () => {
if (!enabled || !doctype || !docname) return;
setLoading(true);
setError(null);
try {
const response = await apiService.apiCall<any>(
`/api/resource/Version?filters=[["ref_doctype","=","${encodeURIComponent(doctype)}"],["docname","=","${encodeURIComponent(docname)}"]]&fields=["name","owner","creation","data"]&order_by=creation desc&limit=${limit}`
);
if (response?.data && response.data.length > 0) {
const parsedLogs: AuditLogEntry[] = response.data.map((version: any) => {
let parsedData = { added: [], changed: [], removed: [], row_changed: [] };
try {
parsedData = JSON.parse(version.data || '{}');
} catch (e) {
console.error('Error parsing version data:', e);
}
const changes: VersionChange[] = (parsedData.changed || []).map((change: any[]) => ({
field: change[0] || '',
oldValue: change[1],
newValue: change[2],
}));
return {
name: version.name,
owner: version.owner,
creation: version.creation,
changes,
added: parsedData.added || [],
removed: parsedData.removed || [],
rowChanged: parsedData.row_changed || [],
};
});
setAuditLogs(parsedLogs);
} else {
setAuditLogs([]);
}
} catch (err) {
console.error(`Error fetching audit logs for ${doctype}/${docname}:`, err);
setError(err instanceof Error ? err.message : 'Failed to load activity log');
setAuditLogs([]);
} finally {
setLoading(false);
}
}, [doctype, docname, limit, enabled]);
// Initial fetch
useEffect(() => {
fetchAuditLogs();
}, [fetchAuditLogs]);
return {
auditLogs,
loading,
error,
refetch: fetchAuditLogs,
};
};
export default useAuditLogs;

View File

@ -0,0 +1,143 @@
import { useState, useEffect, useCallback, useRef } from 'react';
import commentService, { type CommentData, type MentionUser } from '../services/commentService';
// ============================================================
// useComments reusable hook for any doctype's comment section
// ============================================================
interface UseCommentsOptions {
referenceDoctype: string;
referenceName: string | null;
/** Auto-refresh interval in ms (0 = off). Default 30 000 */
pollInterval?: number;
}
interface UseCommentsReturn {
comments: CommentData[];
loading: boolean;
posting: boolean;
error: string | null;
currentUser: string;
refetch: () => Promise<void>;
postComment: (content: string) => Promise<void>;
deleteComment: (commentName: string) => Promise<void>;
// Mention helpers
mentionUsers: MentionUser[];
mentionLoading: boolean;
searchMentionUsers: (query: string) => Promise<void>;
}
export function useComments({
referenceDoctype,
referenceName,
pollInterval = 30000,
}: UseCommentsOptions): UseCommentsReturn {
const [comments, setComments] = useState<CommentData[]>([]);
const [loading, setLoading] = useState(true);
const [posting, setPosting] = useState(false);
const [error, setError] = useState<string | null>(null);
const [currentUser, setCurrentUser] = useState('');
// Mention state
const [mentionUsers, setMentionUsers] = useState<MentionUser[]>([]);
const [mentionLoading, setMentionLoading] = useState(false);
const mentionSearchTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
// ── Fetch current user once ─────────────────────────────
useEffect(() => {
commentService.getCurrentUser().then(setCurrentUser).catch(() => {});
}, []);
// ── Fetch comments ──────────────────────────────────────
const fetchComments = useCallback(async () => {
if (!referenceName) {
setComments([]);
setLoading(false);
return;
}
try {
const data = await commentService.getComments(referenceDoctype, referenceName);
setComments(data);
setError(null);
} catch (err: any) {
console.error('Error fetching comments:', err);
setError(err.message || 'Failed to load comments');
} finally {
setLoading(false);
}
}, [referenceDoctype, referenceName]);
useEffect(() => {
setLoading(true);
fetchComments();
}, [fetchComments]);
// ── Optional polling ────────────────────────────────────
useEffect(() => {
if (!pollInterval || !referenceName) return;
const id = setInterval(fetchComments, pollInterval);
return () => clearInterval(id);
}, [pollInterval, fetchComments, referenceName]);
// ── Post comment ────────────────────────────────────────
const postComment = useCallback(
async (content: string) => {
if (!referenceName) return;
setPosting(true);
try {
await commentService.postComment(referenceDoctype, referenceName, content);
await fetchComments();
} catch (err: any) {
throw err; // let caller handle toast
} finally {
setPosting(false);
}
},
[referenceDoctype, referenceName, fetchComments]
);
// ── Delete comment ──────────────────────────────────────
const deleteComment = useCallback(
async (commentName: string) => {
try {
await commentService.deleteComment(commentName);
setComments((prev) => prev.filter((c) => c.name !== commentName));
} catch (err: any) {
throw err;
}
},
[]
);
// ── Mention user search (debounced) ─────────────────────
const searchMentionUsers = useCallback(async (query: string) => {
if (mentionSearchTimer.current) clearTimeout(mentionSearchTimer.current);
setMentionLoading(true);
mentionSearchTimer.current = setTimeout(async () => {
try {
const users = await commentService.searchUsers(query);
setMentionUsers(users);
} catch {
setMentionUsers([]);
} finally {
setMentionLoading(false);
}
}, 250);
}, []);
return {
comments,
loading,
posting,
error,
currentUser,
refetch: fetchComments,
postComment,
deleteComment,
mentionUsers,
mentionLoading,
searchMentionUsers,
};
}

View File

@ -0,0 +1,218 @@
// hooks/useDeleteRequest.ts
import { useState, useCallback, useMemo } from 'react';
import {
updateDeleteStatus,
type DeleteStatus,
} from '../services/deleteRequestService';
export type UserRoleContext = {
userRoles: string[];
isSystemManager: boolean;
};
export type DeleteRequestAction =
| 'raise_request' // End user cannot; roles below Supervisor raise to Supervisor
| 'approve_supervisor' // Contractor Supervisor → set to "Delete Request With CM"
| 'approve_cm' // Cluster Manager → set to "Deleted"
| 'direct_delete' // CM or System Manager can skip straight to "Deleted"
| null;
export interface DeleteRequestState {
/** What button(s) to show */
showRaiseRequest: boolean;
showApproveAsSupervisor: boolean; // "Approve Request" (Supervisor view)
showApproveAsCM: boolean; // "Approve & Delete" (CM view)
showDirectDelete: boolean; // CM / System Manager direct delete
/** Current status */
deleteStatus: DeleteStatus;
/** Loading / error */
loading: boolean;
error: string | null;
}
/**
* Role resolution order (highest first):
* System Manager > Cluster Manager > Contractor Supervisor > everyone else (non-End-user)
*
* "End user" role: cannot raise a request at all.
*/
function resolveHighestRole(userRoles: string[], isSystemManager: boolean): string {
console.log('[DeleteRequest] userRoles:', userRoles, '| isSystemManager:', isSystemManager);
if (isSystemManager || userRoles.includes('System Manager')) return 'System Manager';
if (userRoles.includes('Cluster Manager')) return 'Cluster Manager';
// if (userRoles.includes('Contractor Supervisor')) return 'Contractor Supervisor';
if (userRoles.includes('Contractor Supervisor') || userRoles.includes('DR Approver'))
return 'Contractor Supervisor';
if (userRoles.includes('End user')) return 'End user';
// Any other authenticated role (Work Control, Technician, etc.) can raise to Supervisor
return 'Other';
}
/**
* Derive which buttons should be visible given the current delete status and user role.
*
* Rules (from your spec):
*
* No status set yet (null / ''):
* End user nothing (no delete rights at all)
* Other / Work Ctrl show "Request Deletion" sets to "Delete Request With Supervisor"
* Contractor Sup show "Request Deletion" sets to "Delete Request With CM"
* Cluster Manager show "Delete" (direct) sets to "Deleted"
* System Manager show "Delete" (direct) sets to "Deleted"
*
* status = "Delete Request With Supervisor":
* Contractor Sup show "Approve Request" sets to "Delete Request With CM"
* CM / Sys Mgr show "Approve & Delete" sets to "Deleted"
* Others nothing new (request already raised)
*
* status = "Delete Request With CM":
* CM / Sys Mgr show "Approve & Delete" sets to "Deleted"
* Others nothing
*
* status = "Deleted":
* nothing (entry already marked deleted)
*
*/
function computeVisibility(
role: string,
deleteStatus: string | null | undefined,
): Pick<
DeleteRequestState,
'showRaiseRequest' | 'showApproveAsSupervisor' | 'showApproveAsCM' | 'showDirectDelete'
> {
const none = {
showRaiseRequest: false,
showApproveAsSupervisor: false,
showApproveAsCM: false,
showDirectDelete: false,
};
// Already deleted — nothing to show
if (deleteStatus === 'Deleted') return none;
if (!deleteStatus || deleteStatus === '') {
// Fresh document — no delete request raised yet
if (role === 'End user') return none;
if (role === 'System Manager' || role === 'Cluster Manager') {
return { ...none, showDirectDelete: true };
}
// Contractor Supervisor: raise directly to CM level
if (role === 'Contractor Supervisor') {
return { ...none, showRaiseRequest: true };
}
// Work Control / Other authenticated roles
return { ...none, showRaiseRequest: true };
}
if (deleteStatus === 'Delete Request With Supervisor') {
if (role === 'Contractor Supervisor') {
return { ...none, showApproveAsSupervisor: true };
}
if (role === 'System Manager' || role === 'Cluster Manager') {
return { ...none, showApproveAsCM: true };
}
return none; // request already in-flight for others
}
if (deleteStatus === 'Delete Request With CM') {
if (role === 'System Manager' || role === 'Cluster Manager') {
return { ...none, showApproveAsCM: true };
}
return none;
}
return none;
}
/**
* Determine the next status to set when a button is clicked.
* Reject always resets to '' (empty) regardless of role clears the request entirely.
*/
function nextStatus(
action: 'raise' | 'supervisor_approve' | 'cm_approve' | 'direct' | 'reject',
role: string
): DeleteStatus {
if (action === 'reject') return '' as DeleteStatus;
if (action === 'direct' || action === 'cm_approve') return 'Deleted';
if (action === 'supervisor_approve') return 'Delete Request With CM';
// raise_request
if (role === 'Contractor Supervisor') return 'Delete Request With CM';
return 'Delete Request With Supervisor';
}
// ─────────────────────────────────────────────────────────────────────────────
interface UseDeleteRequestOptions {
doctype: string;
docname: string | null | undefined;
currentDeleteStatus: DeleteStatus;
userRoles: string[];
isSystemManager: boolean;
onSuccess?: (newStatus: DeleteStatus) => void;
}
export function useDeleteRequest({
doctype,
docname,
currentDeleteStatus,
userRoles,
isSystemManager,
onSuccess,
}: UseDeleteRequestOptions) {
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [localStatus, setLocalStatus] = useState<DeleteStatus>(currentDeleteStatus);
// Keep local status in sync when prop changes (e.g. after refetch)
const effectiveStatus: DeleteStatus =
localStatus !== currentDeleteStatus ? localStatus : currentDeleteStatus;
const highestRole = useMemo(
() => resolveHighestRole(userRoles, isSystemManager),
[userRoles, isSystemManager]
);
const visibility = useMemo(
() => computeVisibility(highestRole, effectiveStatus),
[highestRole, effectiveStatus]
);
const execute = useCallback(
async (action: 'raise' | 'supervisor_approve' | 'cm_approve' | 'direct' | 'reject') => {
if (!docname) return;
setLoading(true);
setError(null);
const targetStatus = nextStatus(action, highestRole);
const result = await updateDeleteStatus(doctype, docname, targetStatus);
setLoading(false);
if (result.success) {
setLocalStatus(targetStatus);
onSuccess?.(targetStatus);
} else {
setError(result.error || 'Failed to update delete status');
}
},
[doctype, docname, highestRole, onSuccess]
);
return {
...visibility,
deleteStatus: effectiveStatus,
loading,
error,
highestRole,
/** Actions */
raiseRequest: () => execute('raise'),
approveAsSupervisor: () => execute('supervisor_approve'),
approveAsCM: () => execute('cm_approve'),
directDelete: () => execute('direct'),
rejectRequest: () => execute('reject'), // ← resets status to '' (empty)
};
}
export type { DeleteStatus };

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,153 @@
/**
* useDoctypeFields.ts v6 (server-side field resolution)
*
* ROOT CAUSE OF ALL PREVIOUS VERSIONS:
*
* Frappe's meta APIs (/api/resource/DocType and /api/resource/Custom Field)
* are ROLE-FILTERED by design. Frappe strips fields the current user's role
* cannot read BEFORE returning the response. No client-side cache strategy
* can fix this because the *source data* is already wrong.
*
* Example:
* Contractor Engineer /api/resource/DocType/Work_Order 6 fields
* Administrator /api/resource/DocType/Work_Order 45 fields
*
* THE REAL FIX:
*
* Call a whitelisted Python function (`asset_lite.api.doctype_fields.get_export_fields`)
* that uses `frappe.get_meta()` and `frappe.get_all(..., ignore_permissions=True)`.
* These bypass field-level role filtering and always return the complete list.
*
* The in-memory cache below is fine to key by doctype only (no user suffix
* needed) because the server now returns the same field list for everyone.
* sessionStorage is intentionally NOT used a hard refresh always gets fresh
* data, avoiding the "need Ctrl+Shift+R" problem entirely.
*/
import { useState, useEffect, useRef } from 'react';
export interface DoctypeField {
key: string;
label: string;
fieldtype: string;
default: boolean;
}
// ── Default fields per DocType ────────────────────────────────────────────────
const DEFAULT_FIELDS: Record<string, Set<string>> = {
Work_Order: new Set([
'name', 'asset', 'asset_name', 'work_order_type', 'company',
'department', 'repair_status', 'workflow_state', 'custom_priority_',
'creation', 'modified',
]),
Asset: new Set([
'name', 'asset_name', 'custom_serial_number', 'company',
'location', 'custom_device_status', 'modified',
]),
};
// ── In-memory cache (tab lifetime only, keyed by doctype) ────────────────────
// Safe to key by doctype only now because the server returns role-independent
// results. Clears automatically on page refresh — no stale data ever.
const memCache = new Map<string, DoctypeField[]>();
// ── Hook ──────────────────────────────────────────────────────────────────────
export function useDoctypeFields(doctype: string) {
const [fields, setFields] = useState<DoctypeField[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const fetchingRef = useRef(false);
useEffect(() => {
if (!doctype) return;
let cancelled = false;
const run = async () => {
if (fetchingRef.current) return;
fetchingRef.current = true;
// ── In-memory cache hit ───────────────────────────────────────────────
if (memCache.has(doctype)) {
if (!cancelled) setFields(memCache.get(doctype)!);
fetchingRef.current = false;
return;
}
if (!cancelled) { setLoading(true); setError(null); }
try {
// ── Call the server-side whitelisted function ─────────────────────
// This uses frappe.get_meta() + ignore_permissions=True internally,
// so it returns ALL fields regardless of the current user's role.
const res = await fetch(
'/api/method/asset_lite.api.doctype_fields.get_export_fields',
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ doctype }),
}
);
if (!res.ok) {
throw new Error(`Server returned ${res.status}`);
}
const data = await res.json();
if (data.exc) {
throw new Error(data.exc);
}
// Server returns: [{ fieldname, label, fieldtype }, ...]
const raw: { fieldname: string; label: string; fieldtype: string }[] =
data.message || [];
const defaultSet = DEFAULT_FIELDS[doctype];
const normalized: DoctypeField[] = raw.map((f, idx) => ({
key: f.fieldname,
label: f.label || f.fieldname,
fieldtype: f.fieldtype || 'Data',
default: defaultSet ? defaultSet.has(f.fieldname) : idx < 8,
}));
console.log(
`[useDoctypeFields] ✅ "${doctype}": ${normalized.length} fields from server`
);
memCache.set(doctype, normalized);
if (!cancelled) setFields(normalized);
} catch (err) {
console.error('[useDoctypeFields] ❌', err);
if (!cancelled) {
setError(err instanceof Error ? err.message : 'Failed to fetch fields');
// Minimal fallback so the export modal isn't completely broken
setFields([
{ key: 'name', label: 'ID', fieldtype: 'Data', default: true },
{ key: 'creation', label: 'Created On', fieldtype: 'Datetime', default: false },
{ key: 'modified', label: 'Modified On', fieldtype: 'Datetime', default: true },
]);
}
} finally {
fetchingRef.current = false;
if (!cancelled) setLoading(false);
}
};
run();
return () => { cancelled = true; };
}, [doctype]);
/** Force a re-fetch from the server (e.g. after DocType schema changes). */
const refetchFields = () => {
memCache.delete(doctype);
fetchingRef.current = false;
setFields([]);
setError(null);
};
return { fields, loading, error, refetchFields };
}

View File

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

View File

@ -0,0 +1,167 @@
import { useState, useEffect, useCallback } from 'react';
import inspectionService, {
type Inspection,
type InspectionListParams,
type CreateInspectionData,
} from '../services/inspectionService';
interface UseInspectionListResult {
inspections: Inspection[];
loading: boolean;
error: string | null;
totalCount: number;
refetch: () => void;
}
interface UseInspectionDetailsResult {
inspection: Inspection | null;
loading: boolean;
error: string | null;
refetch: () => void;
}
interface UseInspectionMutationsResult {
createInspection: (data: CreateInspectionData) => Promise<Inspection>;
updateInspection: (name: string, data: Partial<CreateInspectionData>) => Promise<Inspection>;
deleteInspection: (name: string) => Promise<void>;
loading: boolean;
error: string | null;
}
// Hook for fetching inspection list
export function useInspectionList(params: InspectionListParams = {}): UseInspectionListResult {
const [inspections, setInspections] = useState<Inspection[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [totalCount, setTotalCount] = useState(0);
const fetchInspections = useCallback(async () => {
try {
setLoading(true);
setError(null);
// Fetch inspections and count in parallel
const [listResponse, count] = await Promise.all([
inspectionService.getInspections(params),
inspectionService.getInspectionCount(params.filters || {})
]);
setInspections(listResponse.data);
setTotalCount(count);
} catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Failed to fetch inspections';
setError(errorMessage);
console.error('Error fetching inspections:', err);
} finally {
setLoading(false);
}
}, [JSON.stringify(params)]);
useEffect(() => {
fetchInspections();
}, [fetchInspections]);
return {
inspections,
loading,
error,
totalCount,
refetch: fetchInspections
};
}
// Hook for fetching single inspection details
export function useInspectionDetails(name: string | null): UseInspectionDetailsResult {
const [inspection, setInspection] = useState<Inspection | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const fetchInspection = useCallback(async () => {
if (!name) {
setLoading(false);
return;
}
try {
setLoading(true);
setError(null);
const data = await inspectionService.getInspection(name);
setInspection(data);
} catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Failed to fetch inspection';
setError(errorMessage);
console.error('Error fetching inspection:', err);
} finally {
setLoading(false);
}
}, [name]);
useEffect(() => {
fetchInspection();
}, [fetchInspection]);
return {
inspection,
loading,
error,
refetch: fetchInspection
};
}
// Hook for inspection mutations (create, update, delete)
export function useInspectionMutations(): UseInspectionMutationsResult {
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const createInspection = async (data: CreateInspectionData): Promise<Inspection> => {
try {
setLoading(true);
setError(null);
const result = await inspectionService.createInspection(data);
return result;
} catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Failed to create inspection';
setError(errorMessage);
throw err;
} finally {
setLoading(false);
}
};
const updateInspection = async (name: string, data: Partial<CreateInspectionData>): Promise<Inspection> => {
try {
setLoading(true);
setError(null);
const result = await inspectionService.updateInspection(name, data);
return result;
} catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Failed to update inspection';
setError(errorMessage);
throw err;
} finally {
setLoading(false);
}
};
const deleteInspection = async (name: string): Promise<void> => {
try {
setLoading(true);
setError(null);
await inspectionService.deleteInspection(name);
} catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Failed to delete inspection';
setError(errorMessage);
throw err;
} finally {
setLoading(false);
}
};
return {
createInspection,
updateInspection,
deleteInspection,
loading,
error
};
}

View File

@ -0,0 +1,133 @@
import { useState, useEffect, useCallback } from 'react';
import issueService, { type Issue, type CreateIssueData, type IssueListParams } from '../services/issueService';
// Hook for fetching issue list
export const useIssueList = (params: IssueListParams = {}) => {
const [issues, setIssues] = useState<Issue[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [totalCount, setTotalCount] = useState(0);
const fetchIssues = useCallback(async () => {
try {
setLoading(true);
setError(null);
const response = await issueService.getIssues(params);
setIssues(response.data);
// Get total count for pagination
const count = await issueService.getIssueCount(params.filters);
setTotalCount(count);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to fetch issues');
} finally {
setLoading(false);
}
}, [JSON.stringify(params)]);
useEffect(() => {
fetchIssues();
}, [fetchIssues]);
return {
issues,
loading,
error,
totalCount,
refetch: fetchIssues,
};
};
// Hook for fetching single issue details
export const useIssueDetails = (issueName: string | null) => {
const [issue, setIssue] = useState<Issue | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const fetchIssue = useCallback(async () => {
if (!issueName) {
setIssue(null);
return;
}
try {
setLoading(true);
setError(null);
const data = await issueService.getIssue(issueName);
setIssue(data);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to fetch issue details');
} finally {
setLoading(false);
}
}, [issueName]);
useEffect(() => {
fetchIssue();
}, [fetchIssue]);
return {
issue,
loading,
error,
refetch: fetchIssue,
};
};
// Hook for issue mutations (create, update, delete)
export const useIssueMutations = () => {
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const createIssue = async (data: CreateIssueData): Promise<Issue> => {
try {
setLoading(true);
setError(null);
const result = await issueService.createIssue(data);
return result;
} catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Failed to create issue';
setError(errorMessage);
throw err;
} finally {
setLoading(false);
}
};
const updateIssue = async (name: string, data: Partial<CreateIssueData>): Promise<Issue> => {
try {
setLoading(true);
setError(null);
const result = await issueService.updateIssue(name, data);
return result;
} catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Failed to update issue';
setError(errorMessage);
throw err;
} finally {
setLoading(false);
}
};
const deleteIssue = async (name: string): Promise<void> => {
try {
setLoading(true);
setError(null);
await issueService.deleteIssue(name);
} catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Failed to delete issue';
setError(errorMessage);
throw err;
} finally {
setLoading(false);
}
};
return {
createIssue,
updateIssue,
deleteIssue,
loading,
error,
};
};

View File

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

View File

@ -0,0 +1,142 @@
import { useState, useEffect, useCallback } from 'react';
import maintenanceTeamService, {
type MaintenanceTeam,
type CreateMaintenanceTeamData,
type MaintenanceTeamListParams
} from '../services/maintenanceTeamService';
// Hook for fetching maintenance team list
export const useMaintenanceTeamList = (params: MaintenanceTeamListParams = {}) => {
const [teams, setTeams] = useState<MaintenanceTeam[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [totalCount, setTotalCount] = useState(0);
const fetchTeams = useCallback(async () => {
try {
setLoading(true);
setError(null);
const response = await maintenanceTeamService.getMaintenanceTeams(params);
setTeams(response.data);
// Get total count for pagination
const count = await maintenanceTeamService.getMaintenanceTeamCount(params.filters);
setTotalCount(count);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to fetch maintenance teams');
} finally {
setLoading(false);
}
}, [JSON.stringify(params)]);
useEffect(() => {
fetchTeams();
}, [fetchTeams]);
return {
teams,
loading,
error,
totalCount,
refetch: fetchTeams,
};
};
// Hook for fetching single maintenance team details
export const useMaintenanceTeamDetails = (teamName: string | null) => {
const [team, setTeam] = useState<MaintenanceTeam | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const fetchTeam = useCallback(async () => {
if (!teamName) {
setTeam(null);
return;
}
try {
setLoading(true);
setError(null);
const data = await maintenanceTeamService.getMaintenanceTeam(teamName);
setTeam(data);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to fetch maintenance team details');
} finally {
setLoading(false);
}
}, [teamName]);
useEffect(() => {
fetchTeam();
}, [fetchTeam]);
return {
team,
loading,
error,
refetch: fetchTeam,
};
};
// Hook for maintenance team mutations (create, update, delete)
export const useMaintenanceTeamMutations = () => {
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const createTeam = async (data: CreateMaintenanceTeamData): Promise<MaintenanceTeam> => {
try {
setLoading(true);
setError(null);
const result = await maintenanceTeamService.createMaintenanceTeam(data);
return result;
} catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Failed to create maintenance team';
setError(errorMessage);
throw err;
} finally {
setLoading(false);
}
};
const updateTeam = async (name: string, data: Partial<CreateMaintenanceTeamData>): Promise<MaintenanceTeam> => {
try {
setLoading(true);
setError(null);
const result = await maintenanceTeamService.updateMaintenanceTeam(name, data);
return result;
} catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Failed to update maintenance team';
setError(errorMessage);
throw err;
} finally {
setLoading(false);
}
};
const deleteTeam = async (name: string): Promise<void> => {
try {
setLoading(true);
setError(null);
await maintenanceTeamService.deleteMaintenanceTeam(name);
} catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Failed to delete maintenance team';
setError(errorMessage);
throw err;
} finally {
setLoading(false);
}
};
const getUserFullName = async (email: string): Promise<string> => {
return await maintenanceTeamService.getUserFullName(email);
};
return {
createTeam,
updateTeam,
deleteTeam,
getUserFullName,
loading,
error,
};
};

View File

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

View File

@ -0,0 +1,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,164 @@
import { useState, useEffect, useCallback } from 'react';
import supportPlanService, {
type SupportPlan,
type SupportPlanListParams,
type CreateSupportPlanData
} from '../services/supportPlanService';
// Hook for fetching support plan list
export const useSupportPlanList = (params: SupportPlanListParams = {}) => {
const [supportPlans, setSupportPlans] = useState<SupportPlan[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [totalCount, setTotalCount] = useState(0);
const fetchSupportPlans = useCallback(async () => {
try {
setLoading(true);
setError(null);
// Fetch list
const response = await supportPlanService.getSupportPlans(params);
setSupportPlans(response.data);
// Fetch count separately for accurate pagination
const countResponse = await fetch('/api/method/frappe.client.get_count', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
doctype: 'Support Plans',
filters: params.filters || {}
})
});
const countData = await countResponse.json();
setTotalCount(countData.message || response.data.length);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to fetch support plans');
setSupportPlans([]);
} finally {
setLoading(false);
}
}, [
params.filters,
params.limit_start,
params.limit_page_length,
params.order_by
]);
useEffect(() => {
fetchSupportPlans();
}, [fetchSupportPlans]);
return {
supportPlans,
loading,
error,
totalCount,
refetch: fetchSupportPlans
};
};
// Hook for fetching single support plan details
export const useSupportPlanDetails = (name: string | null) => {
const [supportPlan, setSupportPlan] = useState<SupportPlan | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const fetchSupportPlan = useCallback(async () => {
if (!name) {
setLoading(false);
return;
}
try {
setLoading(true);
setError(null);
const data = await supportPlanService.getSupportPlan(name);
setSupportPlan(data);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to fetch support plan');
setSupportPlan(null);
} finally {
setLoading(false);
}
}, [name]);
useEffect(() => {
fetchSupportPlan();
}, [fetchSupportPlan]);
return {
supportPlan,
loading,
error,
refetch: fetchSupportPlan
};
};
// Hook for support plan mutations (create, update, delete)
export const useSupportPlanMutations = () => {
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const createSupportPlan = useCallback(async (data: CreateSupportPlanData): Promise<SupportPlan> => {
try {
setLoading(true);
setError(null);
const result = await supportPlanService.createSupportPlan(data);
return result;
} catch (err) {
const message = err instanceof Error ? err.message : 'Failed to create support plan';
setError(message);
throw err;
} finally {
setLoading(false);
}
}, []);
const updateSupportPlan = useCallback(async (
name: string,
data: Partial<CreateSupportPlanData>
): Promise<SupportPlan> => {
try {
setLoading(true);
setError(null);
const result = await supportPlanService.updateSupportPlan(name, data);
return result;
} catch (err) {
const message = err instanceof Error ? err.message : 'Failed to update support plan';
setError(message);
throw err;
} finally {
setLoading(false);
}
}, []);
const deleteSupportPlan = useCallback(async (name: string): Promise<void> => {
try {
setLoading(true);
setError(null);
await supportPlanService.deleteSupportPlan(name);
} catch (err) {
const message = err instanceof Error ? err.message : 'Failed to delete support plan';
setError(message);
throw err;
} finally {
setLoading(false);
}
}, []);
return {
createSupportPlan,
updateSupportPlan,
deleteSupportPlan,
loading,
error
};
};
export default {
useSupportPlanList,
useSupportPlanDetails,
useSupportPlanMutations
};

View File

@ -0,0 +1,135 @@
import { useState, useEffect, useMemo } from 'react';
/**
* Roles that have full access to all work orders
* Users with ONLY "Technician" role (and none of these) will have restricted view
*/
const FULL_ACCESS_ROLES = [
'System Manager',
'Administrator',
'Contractor Supervisor',
'Contractor Manager',
'Work Control',
'End user'
];
interface TechnicianFilterResult {
currentUser: string;
isTechnicianOnly: boolean;
technicianOrFilters: any[][] | undefined;
loading: boolean;
error: string | null;
}
/**
* Hook to determine if current user is a Technician-only user
* and build appropriate OR filters for work order visibility
*
* Logic:
* - If user has ONLY "Technician" role (no higher roles), they see only:
* - Work orders they own (owner = user)
* - Work orders assigned to them (assigned_technician = user)
* - Work orders where they're in additional technicians (custom_add_technicians contains user)
* - If user has Technician + any higher role, they see all work orders
* - If user has only higher roles (no Technician), they see all work orders
*/
export function useTechnicianFilter(): TechnicianFilterResult {
const [currentUser, setCurrentUser] = useState<string>('');
const [isTechnicianOnly, setIsTechnicianOnly] = useState(false);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
const checkTechnicianRole = async () => {
try {
setLoading(true);
// Fetch user info with roles from the API
const response = await fetch('/api/method/asset_lite.api.user_roles.get_user_info_with_roles', {
method: 'GET',
headers: { 'Content-Type': 'application/json' }
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const data = await response.json();
if (data.message) {
const { user, roles } = data.message;
setCurrentUser(user || '');
if (!user || user === 'Guest') {
setIsTechnicianOnly(false);
return;
}
// Check if user has Technician role
const hasTechnician = roles.includes('Technician');
// Check if user has any full access role
const hasFullAccess = roles.some((r: string) => FULL_ACCESS_ROLES.includes(r));
// User is Technician-only if they have Technician but NO full access roles
const technicianOnly = hasTechnician && !hasFullAccess;
console.log('[useTechnicianFilter] User:', user);
console.log('[useTechnicianFilter] Roles:', roles);
console.log('[useTechnicianFilter] Has Technician:', hasTechnician);
console.log('[useTechnicianFilter] Has Full Access:', hasFullAccess);
console.log('[useTechnicianFilter] Is Technician Only:', technicianOnly);
setIsTechnicianOnly(technicianOnly);
}
setError(null);
} catch (err) {
console.error('[useTechnicianFilter] Error checking technician role:', err);
setError(err instanceof Error ? err.message : 'Failed to check user roles');
// Default to showing nothing if we can't verify permissions
setIsTechnicianOnly(false);
} finally {
setLoading(false);
}
};
checkTechnicianRole();
}, []);
/**
* Build OR filters for Technician-only users
* These filters ensure technicians only see work orders they're associated with
*
* Frappe or_filters format: [["field", "operator", "value"], ...]
* Records matching ANY of these conditions will be included
*/
const technicianOrFilters = useMemo(() => {
// If not Technician-only or no user, don't apply OR filters
if (!isTechnicianOnly || !currentUser) {
return undefined;
}
console.log('[useTechnicianFilter] Building OR filters for user:', currentUser);
// Return OR filters - user sees work orders where they are:
// 1. The owner (creator) of the work order
// 2. The assigned technician
// 3. Listed in additional technicians field (using LIKE for comma-separated values)
return [
['owner', '=', currentUser],
['assigned_technician', '=', currentUser],
['custom_add_technicians', 'like', `%${currentUser}%`]
];
}, [isTechnicianOnly, currentUser]);
return {
currentUser,
isTechnicianOnly,
technicianOrFilters,
loading,
error
};
}
export default useTechnicianFilter;

View File

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

View File

@ -0,0 +1,420 @@
import { useState, useEffect, useCallback, useRef } from 'react';
import workOrderService from '../services/workOrderService';
import type { WorkOrder, WorkOrderFilters, CreateWorkOrderData } from '../services/workOrderService';
/**
* Merge user filters with permission filters
* Permission filters take precedence for security
*/
const mergeFilters = (
userFilters: WorkOrderFilters | undefined,
permissionFilters: Record<string, any>
): WorkOrderFilters => {
const merged: WorkOrderFilters = { ...(userFilters || {}) };
// Apply permission filters (they take precedence for security)
for (const [field, value] of Object.entries(permissionFilters)) {
if (!merged[field as keyof WorkOrderFilters]) {
// No user filter on this field, apply permission filter directly
(merged as any)[field] = value;
} else if (Array.isArray(value) && value[0] === 'in') {
// Permission filter is ["in", [...values]]
const permittedValues = value[1] as string[];
const userValue = merged[field as keyof WorkOrderFilters];
if (typeof userValue === 'string') {
// User selected a specific value, check if it's permitted
if (!permittedValues.includes(userValue)) {
// User selected a value they don't have permission for
// Set to empty array to return no results
(merged as any)[field] = ['in', []];
}
// If permitted, keep the user's specific selection
} else if (Array.isArray(userValue) && userValue[0] === 'in') {
// Both are ["in", [...]] format, intersect them
const userValues = userValue[1] as string[];
const intersection = userValues.filter(v => permittedValues.includes(v));
(merged as any)[field] = ['in', intersection];
} else {
// Other filter types, apply permission filter
(merged as any)[field] = value;
}
}
}
return merged;
};
/**
* Hook to fetch list of work orders with filters, pagination, and permission-based filtering
*
* @param filters - User-defined filters
* @param limit - Number of records per page
* @param offset - Starting position for pagination
* @param orderBy - Sort order
* @param permissionFilters - Permission-based filters (AND logic)
* @param orFilters - OR filters for technician-only filtering (shows records matching ANY condition)
* Format: [["field", "operator", "value"], ...]
* Example: [["owner", "=", "user@email.com"], ["assigned_technician", "=", "user@email.com"]]
*/
export function useWorkOrders(
filters?: WorkOrderFilters,
limit: number = 20,
offset: number = 0,
orderBy?: string,
permissionFilters: Record<string, any> = {},
orFilters?: any[][] // ✅ NEW: OR filters for technician filtering
) {
const [workOrders, setWorkOrders] = useState<WorkOrder[]>([]);
const [totalCount, setTotalCount] = useState(0);
const [hasMore, setHasMore] = useState(false);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [refetchTrigger, setRefetchTrigger] = useState(0);
const hasAttemptedRef = useRef(false);
// Stringify filters to prevent object reference changes from causing re-renders
const filtersJson = JSON.stringify(filters);
const permissionFiltersJson = JSON.stringify(permissionFilters);
const orFiltersJson = JSON.stringify(orFilters); // ✅ NEW
useEffect(() => {
// Prevent fetching if already attempted and has error
if (hasAttemptedRef.current && error) {
return;
}
let isCancelled = false;
hasAttemptedRef.current = true;
const fetchWorkOrders = async () => {
try {
setLoading(true);
// ✅ Merge user filters with permission filters
const mergedFilters = mergeFilters(filters, permissionFilters);
console.log('[useWorkOrders] User filters:', filters);
console.log('[useWorkOrders] Permission filters:', permissionFilters);
console.log('[useWorkOrders] OR filters:', orFilters);
console.log('[useWorkOrders] Merged filters:', mergedFilters);
// ✅ Pass orFilters to the service
const response = await workOrderService.getWorkOrders(
mergedFilters,
undefined,
limit,
offset,
orderBy,
orFilters // ✅ NEW parameter
);
if (!isCancelled) {
setWorkOrders(response.work_orders);
setTotalCount(response.total_count);
setHasMore(response.has_more);
setError(null);
}
} catch (err) {
if (!isCancelled) {
const errorMessage = err instanceof Error ? err.message : 'Failed to fetch work orders';
// Check if it's a 417 error (API not deployed)
if (errorMessage.includes('417') || errorMessage.includes('Expectation Failed') || errorMessage.includes('has no attribute')) {
setError('API endpoint not deployed or misconfigured. Please check FIX_417_ERROR.md for solutions.');
} else {
setError(errorMessage);
}
// Set empty arrays
setWorkOrders([]);
setTotalCount(0);
setHasMore(false);
}
} finally {
if (!isCancelled) {
setLoading(false);
}
}
};
fetchWorkOrders();
return () => {
isCancelled = true;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [filtersJson, permissionFiltersJson, orFiltersJson, limit, offset, orderBy, refetchTrigger]); // ✅ Added orFiltersJson
const refetch = useCallback(() => {
hasAttemptedRef.current = false; // Reset to allow refetch
setRefetchTrigger(prev => prev + 1);
}, []);
return { workOrders, totalCount, hasMore, loading, error, refetch };
}
/**
* Hook to fetch a single work order by name
*/
export function useWorkOrderDetails(workOrderName: string | null) {
const [workOrder, setWorkOrder] = useState<WorkOrder | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const fetchWorkOrder = useCallback(async () => {
if (!workOrderName) {
setWorkOrder(null);
setLoading(false);
return;
}
try {
setLoading(true);
setError(null);
const data = await workOrderService.getWorkOrderDetails(workOrderName);
setWorkOrder(data);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to fetch work order details');
} finally {
setLoading(false);
}
}, [workOrderName]);
useEffect(() => {
fetchWorkOrder();
}, [fetchWorkOrder]);
const refetch = useCallback(() => {
fetchWorkOrder();
}, [fetchWorkOrder]);
return { workOrder, loading, error, refetch };
}
/**
* Hook to manage work order operations (create, update, delete)
*/
export function useWorkOrderMutations() {
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const createWorkOrder = async (workOrderData: CreateWorkOrderData) => {
try {
setLoading(true);
setError(null);
console.log('[useWorkOrderMutations] Creating work order with data:', workOrderData);
const response = await workOrderService.createWorkOrder(workOrderData);
console.log('[useWorkOrderMutations] Create work order response:', response);
if (response.success) {
return response.work_order;
} else {
// Include the backend error message if available
const backendError = (response as any).error || 'Failed to create work order';
throw new Error(backendError);
}
} catch (err) {
console.error('[useWorkOrderMutations] Create work order error:', err);
const errorMessage = err instanceof Error ? err.message : 'Failed to create work order';
setError(errorMessage);
throw err;
} finally {
setLoading(false);
}
};
const updateWorkOrder = async (workOrderName: string, workOrderData: Partial<CreateWorkOrderData>) => {
try {
setLoading(true);
setError(null);
console.log('[useWorkOrderMutations] Updating work order:', workOrderName, 'with data:', workOrderData);
const response = await workOrderService.updateWorkOrder(workOrderName, workOrderData);
console.log('[useWorkOrderMutations] Update work order response:', response);
if (response.success) {
return response.work_order;
} else {
// Include the backend error message if available
const backendError = (response as any).error || 'Failed to update work order';
throw new Error(backendError);
}
} catch (err) {
console.error('[useWorkOrderMutations] Update work order error:', err);
const errorMessage = err instanceof Error ? err.message : 'Failed to update work order';
setError(errorMessage);
throw err;
} finally {
setLoading(false);
}
};
const deleteWorkOrder = async (workOrderName: string) => {
try {
setLoading(true);
setError(null);
const response = await workOrderService.deleteWorkOrder(workOrderName);
if (!response.success) {
throw new Error('Failed to delete work order');
}
return response;
} catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Failed to delete work order';
setError(errorMessage);
throw err;
} finally {
setLoading(false);
}
};
const submitWorkOrder = async (workOrderName: string) => {
try {
setLoading(true);
setError(null);
console.log('[useWorkOrderMutations] Submitting work order:', workOrderName);
const response = await workOrderService.submitWorkOrder(workOrderName);
console.log('[useWorkOrderMutations] Submit work order response:', response);
return response;
} catch (err) {
console.error('[useWorkOrderMutations] Submit work order error:', err);
const errorMessage = err instanceof Error ? err.message : 'Failed to submit work order';
setError(errorMessage);
throw err;
} finally {
setLoading(false);
}
};
const updateStatus = async (workOrderName: string, repairStatus?: string, workflowState?: string) => {
try {
setLoading(true);
setError(null);
const response = await workOrderService.updateWorkOrderStatus(workOrderName, repairStatus, workflowState);
if (response.success) {
return response.work_order;
} else {
throw new Error('Failed to update work order status');
}
} catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Failed to update status';
setError(errorMessage);
throw err;
} finally {
setLoading(false);
}
};
return { createWorkOrder, updateWorkOrder, deleteWorkOrder, submitWorkOrder, updateStatus, loading, error };
}
/**
* Hook to fetch work order filter options
*/
export function useWorkOrderFilters() {
const [filters, setFilters] = useState<any | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const fetchFilters = useCallback(async () => {
try {
setLoading(true);
setError(null);
const data = await workOrderService.getWorkOrderFilters();
setFilters(data);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to fetch filters');
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
fetchFilters();
}, [fetchFilters]);
const refetch = useCallback(() => {
fetchFilters();
}, [fetchFilters]);
return { filters, loading, error, refetch };
}
/**
* Hook to fetch work order statistics
*/
export function useWorkOrderStats() {
const [stats, setStats] = useState<any | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const fetchStats = useCallback(async () => {
try {
setLoading(true);
setError(null);
const data = await workOrderService.getWorkOrderStats();
setStats(data);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to fetch statistics');
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
fetchStats();
}, [fetchStats]);
const refetch = useCallback(() => {
fetchStats();
}, [fetchStats]);
return { stats, loading, error, refetch };
}
/**
* Hook for work order search
*/
export function useWorkOrderSearch() {
const [results, setResults] = useState<WorkOrder[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const search = useCallback(async (searchTerm: string, limit: number = 10) => {
if (!searchTerm.trim()) {
setResults([]);
return;
}
try {
setLoading(true);
setError(null);
const data = await workOrderService.searchWorkOrders(searchTerm, limit);
setResults(data);
} catch (err) {
setError(err instanceof Error ? err.message : 'Search failed');
setResults([]);
} finally {
setLoading(false);
}
}, []);
const clearResults = useCallback(() => {
setResults([]);
setError(null);
}, []);
return { results, loading, error, search, clearResults };
}

View File

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

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

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

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

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

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

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

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

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

@ -0,0 +1,255 @@
import React, { useState, useEffect } from 'react';
import { FaTrash, FaCheckCircle, FaTimesCircle, FaClock, FaChevronRight } from 'react-icons/fa';
interface DeleteRequest {
name: string;
target_doctype: string;
target_name: string;
target_display: string;
reason: string;
department: string;
requested_by: string;
workflow_state: string;
creation: string;
}
const STATE_COLORS: Record<string, string> = {
'Pending Supervisor': 'bg-yellow-100 dark:bg-yellow-900/30 text-yellow-800 dark:text-yellow-300',
'Pending Cluster Manager': 'bg-orange-100 dark:bg-orange-900/30 text-orange-800 dark:text-orange-300',
'Approved': 'bg-green-100 dark:bg-green-900/30 text-green-800 dark:text-green-300',
'Rejected': 'bg-red-100 dark:bg-red-900/30 text-red-800 dark:text-red-300',
};
const DOCTYPE_LABELS: Record<string, string> = {
Work_Order: 'Work Order',
Asset: 'Asset',
'Maintenance Schedule': 'PPM',
'Stock Entry': 'Inventory',
};
const DeleteRequestsPage: React.FC = () => {
const [requests, setRequests] = useState<DeleteRequest[]>([]);
const [loading, setLoading] = useState(true);
const [actionLoading, setActionLoading] = useState<string | null>(null);
const [filterState, setFilterState] = useState('');
const [userRole, setUserRole] = useState<'supervisor' | 'cluster_manager' | 'other'>('other');
// Detect user role to show relevant actions
useEffect(() => {
const detectRole = async () => {
const cmRes = await fetch('/api/method/asset_lite.api.user_roles.check_has_role', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ roles: 'Cluster Manager' })
});
const cmData = await cmRes.json();
if (cmData.message?.has_role) { setUserRole('cluster_manager'); return; }
const supRes = await fetch('/api/method/asset_lite.api.user_roles.check_has_role', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ roles: 'Contractor Supervisor' })
});
const supData = await supRes.json();
if (supData.message?.has_role) setUserRole('supervisor');
};
detectRole();
}, []);
const fetchRequests = async () => {
setLoading(true);
try {
const filters: any = {};
if (filterState) filters['workflow_state'] = filterState;
const res = await fetch('/api/method/frappe.client.get_list', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
doctype: 'Delete Request',
filters,
fields: [
'name', 'target_doctype', 'target_name', 'target_display',
'reason', 'department', 'requested_by', 'workflow_state', 'creation'
],
order_by: 'creation desc',
limit_page_length: 100
})
});
const data = await res.json();
setRequests(data.message || []);
} catch (err) {
console.error('Failed to fetch delete requests', err);
} finally {
setLoading(false);
}
};
useEffect(() => { fetchRequests(); }, [filterState]);
const applyAction = async (requestName: string, action: string) => {
setActionLoading(requestName + action);
try {
// First get the full doc
const docRes = await fetch('/api/method/frappe.client.get', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ doctype: 'Delete Request', name: requestName })
});
const docData = await docRes.json();
// Apply workflow action
const res = await fetch('/api/method/frappe.model.workflow.apply_workflow', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ doc: docData.message, action })
});
const result = await res.json();
if (result.exc) throw new Error(result.exc);
alert(`Action "${action}" applied successfully.`);
fetchRequests();
} catch (err) {
alert(`Failed: ${err instanceof Error ? err.message : 'Unknown error'}`);
} finally {
setActionLoading(null);
}
};
// What actions can the current user take on a given request
const getAvailableActions = (req: DeleteRequest) => {
if (userRole === 'supervisor' && req.workflow_state === 'Pending Supervisor') {
return [
{ label: 'Forward to Cluster Manager', action: 'Send For Approval', color: 'bg-blue-600 hover:bg-blue-700' },
];
}
if (userRole === 'cluster_manager' && req.workflow_state === 'Pending Cluster Manager') {
return [
{ label: 'Approve & Delete', action: 'Approve', color: 'bg-green-600 hover:bg-green-700' },
{ label: 'Reject', action: 'Reject', color: 'bg-red-600 hover:bg-red-700' },
];
}
return [];
};
const pendingCount = requests.filter(r =>
r.workflow_state === 'Pending Supervisor' || r.workflow_state === 'Pending Cluster Manager'
).length;
return (
<div className="p-6 bg-gray-50 dark:bg-gray-900 min-h-screen">
{/* Header */}
<div className="mb-6 flex justify-between items-center">
<div>
<h1 className="text-3xl font-bold text-gray-800 dark:text-white flex items-center gap-3">
<FaTrash className="text-red-500" />
Deletion Requests
</h1>
<p className="text-gray-500 dark:text-gray-400 mt-1">
{pendingCount > 0
? <span className="text-orange-600 font-medium">{pendingCount} pending your review</span>
: 'No pending requests'}
</p>
</div>
{/* Filter */}
<select
value={filterState}
onChange={(e) => setFilterState(e.target.value)}
className="px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
>
<option value="">All Statuses</option>
<option value="Pending Supervisor">Pending Supervisor</option>
<option value="Pending Cluster Manager">Pending Cluster Manager</option>
<option value="Approved">Approved</option>
<option value="Rejected">Rejected</option>
</select>
</div>
{/* Table */}
<div className="bg-white dark:bg-gray-800 rounded-lg shadow overflow-hidden">
{loading ? (
<div className="flex items-center justify-center py-20">
<div className="animate-spin rounded-full h-10 w-10 border-b-2 border-red-500"></div>
</div>
) : requests.length === 0 ? (
<div className="text-center py-20 text-gray-400">
<FaTrash className="text-5xl mx-auto mb-3 opacity-30" />
<p>No deletion requests found</p>
</div>
) : (
<table className="w-full">
<thead className="bg-gray-100 dark:bg-gray-700">
<tr>
{['Request ID', 'Type', 'Record', 'Department', 'Requested By', 'Reason', 'Status', 'Date', 'Actions'].map(h => (
<th key={h} className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">
{h}
</th>
))}
</tr>
</thead>
<tbody className="divide-y divide-gray-200 dark:divide-gray-700">
{requests.map((req) => {
const actions = getAvailableActions(req);
const isActing = actionLoading?.startsWith(req.name);
return (
<tr key={req.name} className="hover:bg-gray-50 dark:hover:bg-gray-700/50">
<td className="px-4 py-3 text-xs font-mono text-gray-600 dark:text-gray-400">
{req.name}
</td>
<td className="px-4 py-3">
<span className="px-2 py-1 bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300 rounded text-xs font-medium">
{DOCTYPE_LABELS[req.target_doctype] || req.target_doctype}
</span>
</td>
<td className="px-4 py-3 text-sm font-medium text-gray-900 dark:text-white">
{req.target_display || req.target_name}
</td>
<td className="px-4 py-3 text-sm text-gray-600 dark:text-gray-400">
{req.department || '-'}
</td>
<td className="px-4 py-3 text-sm text-gray-600 dark:text-gray-400">
{req.requested_by}
</td>
<td className="px-4 py-3 max-w-xs">
<p className="text-sm text-gray-700 dark:text-gray-300 truncate" title={req.reason}>
{req.reason}
</p>
</td>
<td className="px-4 py-3">
<span className={`px-2 py-1 rounded-full text-xs font-semibold ${STATE_COLORS[req.workflow_state] || 'bg-gray-100 text-gray-700'}`}>
{req.workflow_state}
</span>
</td>
<td className="px-4 py-3 text-xs text-gray-500 dark:text-gray-400 whitespace-nowrap">
{new Date(req.creation).toLocaleDateString()}
</td>
<td className="px-4 py-3">
<div className="flex gap-2">
{actions.length > 0 ? actions.map(({ label, action, color }) => (
<button
key={action}
onClick={() => applyAction(req.name, action)}
disabled={!!isActing}
className={`px-3 py-1.5 text-xs font-medium text-white rounded-lg transition-colors ${color} disabled:opacity-50 disabled:cursor-not-allowed`}
>
{isActing ? '...' : label}
</button>
)) : (
<span className="text-xs text-gray-400 italic">No actions</span>
)}
</div>
</td>
</tr>
);
})}
</tbody>
</table>
)}
</div>
</div>
);
};
export default DeleteRequestsPage;

View File

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

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,919 @@
import React, { useState, useMemo, useEffect, useCallback, useRef } from 'react';
import { useNavigate, useSearchParams } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { useInspectionList } from '../hooks/useInspection';
import { useUserPermissions } from '../hooks/useUserPermissions'; // ← ADDED
import InspectionReportModal from '../components/InspectionReportModal';
import ListPagination from '../components/ListPagination';
import * as XLSX from 'xlsx';
import {
FaPlus,
FaFilter,
FaSync,
FaEye,
FaChevronLeft,
FaChevronRight,
FaClipboardCheck,
FaTimes,
FaSave,
FaStar,
FaTrash,
FaEdit,
FaCheckSquare,
FaSquare,
FaFileExport,
FaFileExcel,
FaFileCsv,
FaDownload,
FaExternalLinkAlt
} from 'react-icons/fa';
import LinkField from '../components/LinkField';
import { buildDateRangeFilters, toFrappeFilterArray } from '../utils/listFilterUtils';
import DeleteRequestButton from '../components/DeleteRequestButton';
import type { DeleteStatus } from '../services/deleteRequestService';
// Export types
type ExportFormat = 'csv' | 'excel';
type ExportScope = 'selected' | 'all_on_page' | 'all_with_filters';
interface ExportModalProps {
isOpen: boolean;
onClose: () => void;
selectedCount: number;
totalCount: number;
pageCount: number;
onExport: (scope: ExportScope, format: ExportFormat, columns: string[]) => void;
isExporting: boolean;
exportColumns: Array<{key: string, labelKey: string, default: boolean}>;
}
const ExportModal: React.FC<ExportModalProps> = ({
isOpen,
onClose,
selectedCount,
totalCount,
pageCount,
onExport,
isExporting,
exportColumns
}) => {
const { t } = useTranslation();
const [scope, setScope] = useState<ExportScope>(selectedCount > 0 ? 'selected' : 'all_with_filters');
const [format, setFormat] = useState<ExportFormat>('csv');
const [selectedColumns, setSelectedColumns] = useState<string[]>(
exportColumns.filter(c => c.default).map(c => c.key)
);
useEffect(() => {
if (selectedCount > 0) {
setScope('selected');
} else {
setScope('all_with_filters');
}
}, [selectedCount]);
const toggleColumn = (key: string) => {
setSelectedColumns(prev =>
prev.includes(key) ? prev.filter(k => k !== key) : [...prev, key]
);
};
const selectAllColumns = () => setSelectedColumns(exportColumns.map(c => c.key));
const selectDefaultColumns = () => setSelectedColumns(exportColumns.filter(c => c.default).map(c => c.key));
if (!isOpen) return null;
return (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-[70] p-4">
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-xl max-w-2xl w-full max-h-[90vh] overflow-hidden animate-scale-in">
<div className="bg-gradient-to-r from-teal-500 to-teal-600 px-6 py-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<FaFileExport className="text-white text-xl" />
<h3 className="text-lg font-semibold text-white">{t('inspections.export.title')}</h3>
</div>
<button onClick={onClose} className="text-white/80 hover:text-white transition-colors" disabled={isExporting}>
<FaTimes size={20} />
</button>
</div>
</div>
<div className="p-6 overflow-y-auto max-h-[calc(90vh-180px)]">
{/* Scope Selection */}
<div className="mb-6">
<h4 className="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-3">{t('inspections.export.selectData')}</h4>
<div className="space-y-2">
<label className={`flex items-center gap-3 p-3 rounded-lg border cursor-pointer transition-all ${scope === 'selected' ? 'border-teal-500 bg-teal-50 dark:bg-teal-900/20' : 'border-gray-200 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-700/50'} ${selectedCount === 0 ? 'opacity-50 cursor-not-allowed' : ''}`}>
<input type="radio" name="scope" value="selected" checked={scope === 'selected'} onChange={() => setScope('selected')} disabled={selectedCount === 0} className="text-teal-600 focus:ring-teal-500" />
<div className="flex-1">
<div className="font-medium text-gray-900 dark:text-white">{t('inspections.export.selectedRows')}</div>
<div className="text-sm text-gray-500 dark:text-gray-400">{t('inspections.export.selectedCount', { count: selectedCount })}</div>
</div>
{selectedCount > 0 && <span className="bg-teal-100 dark:bg-teal-900/40 text-teal-700 dark:text-teal-300 px-2 py-1 rounded text-xs font-medium">{selectedCount} selected</span>}
</label>
<label className={`flex items-center gap-3 p-3 rounded-lg border cursor-pointer transition-all ${scope === 'all_on_page' ? 'border-teal-500 bg-teal-50 dark:bg-teal-900/20' : 'border-gray-200 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-700/50'}`}>
<input type="radio" name="scope" value="all_on_page" checked={scope === 'all_on_page'} onChange={() => setScope('all_on_page')} className="text-teal-600 focus:ring-teal-500" />
<div className="flex-1">
<div className="font-medium text-gray-900 dark:text-white">{t('inspections.export.currentPage')}</div>
<div className="text-sm text-gray-500 dark:text-gray-400">{t('inspections.export.currentPageCount', { count: pageCount })}</div>
</div>
<span className="bg-blue-100 dark:bg-blue-900/40 text-blue-700 dark:text-blue-300 px-2 py-1 rounded text-xs font-medium">{pageCount} rows</span>
</label>
<label className={`flex items-center gap-3 p-3 rounded-lg border cursor-pointer transition-all ${scope === 'all_with_filters' ? 'border-teal-500 bg-teal-50 dark:bg-teal-900/20' : 'border-gray-200 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-700/50'}`}>
<input type="radio" name="scope" value="all_with_filters" checked={scope === 'all_with_filters'} onChange={() => setScope('all_with_filters')} className="text-teal-600 focus:ring-teal-500" />
<div className="flex-1">
<div className="font-medium text-gray-900 dark:text-white">{t('inspections.export.allWithFilters')}</div>
<div className="text-sm text-gray-500 dark:text-gray-400">{t('inspections.export.allWithFiltersCount', { count: totalCount })}</div>
</div>
<span className="bg-purple-100 dark:bg-purple-900/40 text-purple-700 dark:text-purple-300 px-2 py-1 rounded text-xs font-medium">{totalCount} total</span>
</label>
</div>
</div>
{/* Format Selection */}
<div className="mb-6">
<h4 className="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-3">{t('inspections.export.exportFormat')}</h4>
<div className="flex gap-3">
<label className={`flex-1 flex items-center gap-3 p-3 rounded-lg border cursor-pointer transition-all ${format === 'csv' ? 'border-teal-500 bg-teal-50 dark:bg-teal-900/20' : 'border-gray-200 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-700/50'}`}>
<input type="radio" name="format" value="csv" checked={format === 'csv'} onChange={() => setFormat('csv')} className="text-teal-600 focus:ring-teal-500" />
<FaFileCsv className="text-teal-600 text-xl" />
<div>
<div className="font-medium text-gray-900 dark:text-white">{t('inspections.export.csv')}</div>
<div className="text-xs text-gray-500 dark:text-gray-400">{t('inspections.export.csvDesc')}</div>
</div>
</label>
<label className={`flex-1 flex items-center gap-3 p-3 rounded-lg border cursor-pointer transition-all ${format === 'excel' ? 'border-teal-500 bg-teal-50 dark:bg-teal-900/20' : 'border-gray-200 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-700/50'}`}>
<input type="radio" name="format" value="excel" checked={format === 'excel'} onChange={() => setFormat('excel')} className="text-teal-600 focus:ring-teal-500" />
<FaFileExcel className="text-green-700 text-xl" />
<div>
<div className="font-medium text-gray-900 dark:text-white">{t('inspections.export.excel')}</div>
<div className="text-xs text-gray-500 dark:text-gray-400">{t('inspections.export.excelDesc')}</div>
</div>
</label>
</div>
</div>
{/* Column Selection */}
<div>
<div className="flex items-center justify-between mb-3">
<h4 className="text-sm font-semibold text-gray-700 dark:text-gray-300">{t('inspections.export.columnsToExport')}</h4>
<div className="flex gap-2">
<button onClick={selectAllColumns} className="text-xs text-blue-600 dark:text-blue-400 hover:underline">{t('inspections.export.selectAll')}</button>
<span className="text-gray-300 dark:text-gray-600">|</span>
<button onClick={selectDefaultColumns} className="text-xs text-blue-600 dark:text-blue-400 hover:underline">{t('inspections.export.resetToDefault')}</button>
</div>
</div>
<div className="grid grid-cols-2 md:grid-cols-3 gap-2 max-h-48 overflow-y-auto p-3 bg-gray-50 dark:bg-gray-900/50 rounded-lg">
{exportColumns.map((col) => (
<label key={col.key} className={`flex items-center gap-2 p-2 rounded cursor-pointer transition-all ${selectedColumns.includes(col.key) ? 'bg-teal-100 dark:bg-teal-900/30 text-teal-800 dark:text-teal-300' : 'hover:bg-gray-100 dark:hover:bg-gray-800 text-gray-700 dark:text-gray-400'}`}>
<input type="checkbox" checked={selectedColumns.includes(col.key)} onChange={() => toggleColumn(col.key)} className="rounded text-teal-600 focus:ring-teal-500" />
<span className="text-sm truncate">{t(col.labelKey)}</span>
</label>
))}
</div>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-2">{t('inspections.export.columnsSelected', { count: selectedColumns.length })}</p>
</div>
</div>
{/* Footer */}
<div className="px-6 py-4 bg-gray-50 dark:bg-gray-900/50 border-t border-gray-200 dark:border-gray-700 flex justify-between items-center">
<div className="text-sm text-gray-600 dark:text-gray-400">
{scope === 'selected' && t('inspections.export.exportingSelected', { count: selectedCount })}
{scope === 'all_on_page' && t('inspections.export.exportingPage', { count: pageCount })}
{scope === 'all_with_filters' && t('inspections.export.exportingAll', { count: totalCount })}
</div>
<div className="flex gap-3">
<button onClick={onClose} className="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors" disabled={isExporting}>{t('common.cancel')}</button>
<button onClick={() => onExport(scope, format, selectedColumns)} disabled={selectedColumns.length === 0 || isExporting} className="px-4 py-2 text-sm font-medium text-white bg-teal-600 hover:bg-teal-700 rounded-lg transition-colors flex items-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed">
{isExporting ? (<><div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white"></div>{t('inspections.export.exporting')}</>) : (<><FaDownload />{t('inspections.export.exportButton')}</>)}
</button>
</div>
</div>
</div>
</div>
);
};
// Status badge styles
const getStatusStyle = (status: string) => {
switch (status?.toLowerCase()) {
case 'open': return 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300';
case 'in progress': return 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-300';
case 'closed': return 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300';
case 'pending review': return 'bg-purple-100 text-purple-800 dark:bg-purple-900/30 dark:text-purple-300';
default: return 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300';
}
};
// Workflow state badge styles
const getWorkflowStateStyle = (state: string) => {
switch (state?.toLowerCase()) {
case 'draft': return 'bg-orange-100 text-orange-800 dark:bg-orange-900/30 dark:text-orange-300';
case 'sent to supervisor': return 'bg-purple-100 text-purple-800 dark:bg-purple-900/30 dark:text-purple-300';
case 'closed': return 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300';
default: return 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300';
}
};
// Inspection type badge styles
const getInspectionTypeStyle = (type: string) => {
switch (type?.toLowerCase()) {
case 'inspection': return 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300';
case 'safety inspection': return 'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-300';
default: return 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300';
}
};
const InspectionList: React.FC = () => {
const { t } = useTranslation();
const navigate = useNavigate();
const [searchParams, setSearchParams] = useSearchParams();
// ── Permission hook — same pattern as ModernDashboard ────────────────────
// useUserPermissions('Issue Type') calls apiService.getPermissionFilters('Issue Type')
// which hits the correct endpoint internally — no manual fetch needed.
const [permittedIssueTypes, setPermittedIssueTypes] = useState<string[]>([]);
const [isWoAdmin, setIsWoAdmin] = useState(true);
const currentPage = useMemo(() => {
const p = parseInt(searchParams.get('page') || '1', 10);
return Number.isNaN(p) || p < 1 ? 1 : p;
}, [searchParams]);
const setCurrentPage = useCallback((pageOrUpdater: number | ((p: number) => number)) => {
const next = typeof pageOrUpdater === 'function' ? pageOrUpdater(currentPage) : pageOrUpdater;
setSearchParams((prev) => {
const nextParams = new URLSearchParams(prev);
nextParams.set('page', String(next));
return nextParams;
});
}, [currentPage, setSearchParams]);
const EXPORT_COLUMNS = [
{ key: 'name', labelKey: 'inspections.export.inspectionId', default: true },
{ key: 'inspection_type', labelKey: 'inspections.export.inspectionType', default: true },
{ key: 'status', labelKey: 'inspections.export.status', default: true },
{ key: 'workflow_state', labelKey: 'inspections.export.workflowState', default: true },
{ key: 'inspection_date', labelKey: 'inspections.export.inspectionDate', default: true },
{ key: 'target_closure_date', labelKey: 'inspections.export.targetClosureDate', default: true },
{ key: 'requested_by', labelKey: 'inspections.export.requestedBy', default: true },
{ key: 'work_order_type', labelKey: 'inspections.export.technicalDepartment', default: false },
{ key: 'extension_no', labelKey: 'inspections.export.extensionNo', default: false },
{ key: 'department', labelKey: 'inspections.export.department', default: false },
{ key: 'location', labelKey: 'inspections.export.location', default: false },
{ key: 'assigned_technician', labelKey: 'inspections.export.assignedTechnician', default: false },
{ key: 'linked_corrective_wo_no', labelKey: 'inspections.export.linkedWorkOrder', default: true },
{ key: 'observation_note', labelKey: 'inspections.export.observationNote', default: false },
{ key: 'technical_response', labelKey: 'inspections.export.technicalResponse', default: false },
{ key: 'creation', labelKey: 'inspections.export.createdOn', default: false },
{ key: 'modified', labelKey: 'inspections.export.modifiedOn', default: false },
{ key: 'owner', labelKey: 'inspections.export.createdBy', default: false },
];
const [pageSize] = useState(20);
const [initialLoadComplete, setInitialLoadComplete] = useState(false);
const [selectedRows, setSelectedRows] = useState<Set<string>>(new Set());
const [showExportModal, setShowExportModal] = useState(false);
const [isExporting, setIsExporting] = useState(false);
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState<string | null>(null);
const [showReportModal, setShowReportModal] = useState(false);
// ✅ Roles allowed to delete inspections and add new inspections
const INSPECTION_ALLOWED_ROLES = [
'System Manager',
'Contractor Supervisor',
'Contractor Manager',
'Work Control'
];
// ✅ State for role-based permissions
const [canDelete, setCanDelete] = useState(false);
const [canAddInspection, setCanAddInspection] = useState(false);
const [userRoles, setUserRoles] = useState<string[]>([]);
const [listIsSystemManager, setListIsSystemManager] = useState(false);
// ✅ Check user permissions based on roles
useEffect(() => {
const checkPermissions = async () => {
try {
const response = await fetch('/api/method/asset_lite.api.user_roles.check_has_role', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ roles: INSPECTION_ALLOWED_ROLES.join(',') })
});
const data = await response.json();
if (data.message) {
const hasPermission = data.message.has_role;
setCanDelete(hasPermission);
setCanAddInspection(hasPermission);
}
// Fetch full roles array for DeleteRequestButton
const rolesListResponse = await fetch('/api/method/asset_lite.api.user_roles.get_user_roles', {
credentials: 'include'
});
const rolesListData = await rolesListResponse.json();
const rolesList = Array.isArray(rolesListData.message) ? rolesListData.message : [];
setUserRoles(rolesList);
setListIsSystemManager(rolesList.includes('System Manager'));
// Fetch Issue Type permissions via Work_Order mapping (confirmed working endpoint)
try {
const permRes = await fetch(
'/api/method/asset_lite.api.userperm_api.get_permission_filters?target_doctype=Work_Order',
{ credentials: 'include' }
);
const permData = await permRes.json();
const msg = permData.message || {};
setIsWoAdmin(msg.is_admin ?? true);
setPermittedIssueTypes(msg.restrictions?.['Issue Type']?.values || []);
} catch (e) {
console.error('Error fetching issue type permissions:', e);
setIsWoAdmin(true);
setPermittedIssueTypes([]);
}
} catch (error) {
console.error('Error checking permissions:', error);
setCanDelete(false);
setCanAddInspection(false);
}
};
checkPermissions();
}, []);
// Filters
const [dateFilterBy, setDateFilterBy] = useState<'' | 'creation' | 'modified'>(() => (searchParams.get('date_filter_by') as '' | 'creation' | 'modified') || '');
const [dateStart, setDateStart] = useState<string>(() => searchParams.get('date_start') || '');
const [dateEnd, setDateEnd] = useState<string>(() => searchParams.get('date_end') || '');
const [statusFilter, setStatusFilter] = useState<string>(() => searchParams.get('status') || '');
const [workflowStateFilter, setWorkflowStateFilter] = useState<string>(() => searchParams.get('workflow_state') || '');
const [inspectionTypeFilter, setInspectionTypeFilter] = useState<string>(() => searchParams.get('inspection_type') || '');
const [workOrderFilter, setWorkOrderFilter] = useState<string>(() => searchParams.get('work_order') || '');
const [departmentFilter, setDepartmentFilter] = useState<string>(() => searchParams.get('department') || '');
const [sortBy, setSortBy] = useState<string>(() => searchParams.get('sort_by') || 'creation desc');
const [isFilterExpanded, setIsFilterExpanded] = useState(false);
const [activeFilterCount, setActiveFilterCount] = useState(0);
const [savedFilters, setSavedFilters] = useState<any[]>([]);
const [showSaveFilterModal, setShowSaveFilterModal] = useState(false);
const [filterPresetName, setFilterPresetName] = useState('');
useEffect(() => {
const saved = localStorage.getItem('inspectionFilterPresets');
if (saved) setSavedFilters(JSON.parse(saved));
}, []);
const hasDateFilter = dateFilterBy && (dateStart || dateEnd);
useEffect(() => {
const count = [statusFilter, workflowStateFilter, inspectionTypeFilter, workOrderFilter, departmentFilter].filter(Boolean).length + (hasDateFilter ? 1 : 0);
setActiveFilterCount(count);
}, [statusFilter, workflowStateFilter, inspectionTypeFilter, workOrderFilter, departmentFilter, hasDateFilter]);
const apiFilters = useMemo(() => {
const filters: Record<string, any> = {};
if (statusFilter) filters['status'] = statusFilter;
if (workflowStateFilter) filters['workflow_state'] = workflowStateFilter;
if (inspectionTypeFilter) filters['inspection_type'] = inspectionTypeFilter;
if (workOrderFilter) filters['linked_corrective_wo_no'] = workOrderFilter;
if (departmentFilter) filters['work_order_type'] = departmentFilter;
Object.assign(filters, buildDateRangeFilters(dateFilterBy, dateStart, dateEnd));
filters['custom_delete_status'] = ['!=', 'Deleted'];
return filters;
}, [statusFilter, workflowStateFilter, inspectionTypeFilter, workOrderFilter, departmentFilter, dateFilterBy, dateStart, dateEnd]);
const orderBy = ['creation desc', 'creation asc', 'modified desc', 'modified asc', 'name asc', 'name desc'].includes(sortBy) ? sortBy : 'creation desc';
const { inspections, loading, error, totalCount, refetch } = useInspectionList({
filters: apiFilters,
limit_start: (currentPage - 1) * pageSize,
limit_page_length: pageSize,
order_by: orderBy,
});
useEffect(() => { if (!loading && !initialLoadComplete) setInitialLoadComplete(true); }, [loading, initialLoadComplete]);
const filtersChangedOnce = useRef(false);
useEffect(() => {
if (!filtersChangedOnce.current) {
filtersChangedOnce.current = true;
return;
}
setSearchParams((prev) => {
const next = new URLSearchParams(prev);
if (dateFilterBy) next.set('date_filter_by', dateFilterBy); else next.delete('date_filter_by');
if (dateStart) next.set('date_start', dateStart); else next.delete('date_start');
if (dateEnd) next.set('date_end', dateEnd); else next.delete('date_end');
if (statusFilter) next.set('status', statusFilter); else next.delete('status');
if (workflowStateFilter) next.set('workflow_state', workflowStateFilter); else next.delete('workflow_state');
if (inspectionTypeFilter) next.set('inspection_type', inspectionTypeFilter); else next.delete('inspection_type');
if (workOrderFilter) next.set('work_order', workOrderFilter); else next.delete('work_order');
if (departmentFilter) next.set('department', departmentFilter); else next.delete('department');
if (sortBy && sortBy !== 'creation desc') next.set('sort_by', sortBy); else next.delete('sort_by');
next.set('page', '1');
return next;
});
}, [dateFilterBy, dateStart, dateEnd, statusFilter, workflowStateFilter, inspectionTypeFilter, workOrderFilter, departmentFilter, sortBy]);
useEffect(() => { setSelectedRows(new Set()); }, [dateFilterBy, dateStart, dateEnd, statusFilter, workflowStateFilter, inspectionTypeFilter, workOrderFilter, departmentFilter, currentPage]);
const getDeleteStatusRowClass = (deleteStatus: string | undefined): string => {
switch (deleteStatus) {
case 'Delete Request With Supervisor': return 'bg-orange-50 dark:bg-orange-900/10';
case 'Delete Request With CM': return 'bg-yellow-50 dark:bg-yellow-900/10';
case 'Deleted': return 'bg-red-50 dark:bg-red-900/10';
default: return '';
}
};
const totalPages = Math.ceil(totalCount / pageSize);
const formatDate = (dateStr: string) => dateStr ? new Date(dateStr).toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' }) : '-';
const clearFilters = () => {
setDateFilterBy(''); setDateStart(''); setDateEnd('');
setSortBy('creation desc');
setStatusFilter(''); setWorkflowStateFilter(''); setInspectionTypeFilter(''); setWorkOrderFilter(''); setDepartmentFilter('');
setSearchParams((prev) => {
const next = new URLSearchParams(prev);
next.delete('date_filter_by'); next.delete('date_start'); next.delete('date_end');
next.delete('sort_by');
next.delete('status'); next.delete('workflow_state'); next.delete('inspection_type'); next.delete('work_order'); next.delete('department');
next.set('page', '1');
return next;
});
};
const hasActiveFilters = hasDateFilter || !!statusFilter || !!workflowStateFilter || !!inspectionTypeFilter || !!workOrderFilter || !!departmentFilter;
const handleSaveFilterPreset = () => {
if (!filterPresetName.trim()) { alert(t('common.enterFilterName')); return; }
const preset = { id: Date.now(), name: filterPresetName, filters: { dateFilterBy, dateStart, dateEnd, sortBy, statusFilter, workflowStateFilter, inspectionTypeFilter, workOrderFilter, departmentFilter } };
const updated = [...savedFilters, preset];
setSavedFilters(updated);
setFilterPresetName('');
setShowSaveFilterModal(false);
localStorage.setItem('inspectionFilterPresets', JSON.stringify(updated));
};
const handleLoadFilterPreset = (preset: any) => {
const f = preset.filters;
setDateFilterBy(f.dateFilterBy || ''); setDateStart(f.dateStart || ''); setDateEnd(f.dateEnd || '');
setSortBy(f.sortBy || 'creation desc');
setStatusFilter(f.statusFilter || '');
setWorkflowStateFilter(f.workflowStateFilter || '');
setInspectionTypeFilter(f.inspectionTypeFilter || '');
setWorkOrderFilter(f.workOrderFilter || '');
setDepartmentFilter(f.departmentFilter || '');
};
const handleDeleteFilterPreset = (id: number) => {
const updated = savedFilters.filter(f => f.id !== id);
setSavedFilters(updated);
localStorage.setItem('inspectionFilterPresets', JSON.stringify(updated));
};
const handleSelectRow = (name: string) => {
setSelectedRows(prev => { const newSet = new Set(prev); newSet.has(name) ? newSet.delete(name) : newSet.add(name); return newSet; });
};
const handleSelectAll = () => { selectedRows.size === inspections.length ? setSelectedRows(new Set()) : setSelectedRows(new Set(inspections.map(i => i.name))); };
const isAllSelected = inspections.length > 0 && selectedRows.size === inspections.length;
const isSomeSelected = selectedRows.size > 0 && selectedRows.size < inspections.length;
const fetchAllInspectionsForExport = useCallback(async (): Promise<any[]> => {
const allInspections: any[] = [];
let currentPageNum = 0;
const pageSizeNum = 100;
let hasMoreData = true;
const filterArrays = toFrappeFilterArray(apiFilters);
while (hasMoreData) {
try {
const response = await fetch('/api/method/frappe.client.get_list', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ doctype: 'Inspection', filters: filterArrays.length > 0 ? filterArrays : {}, fields: ['*'], limit_start: currentPageNum * pageSizeNum, limit_page_length: pageSizeNum, order_by: orderBy })
});
const data = await response.json();
const results = data.message || [];
allInspections.push(...results);
if (results.length < pageSizeNum) hasMoreData = false; else currentPageNum++;
if (currentPageNum > 100) { console.warn('Export safety limit reached'); hasMoreData = false; }
} catch (error) { console.error('Error fetching inspections for export:', error); throw error; }
}
return allInspections;
}, [apiFilters, orderBy]);
const handleExport = async (scope: ExportScope, format: ExportFormat, columns: string[]) => {
setIsExporting(true);
try {
let dataToExport: any[] = [];
switch (scope) {
case 'selected': dataToExport = inspections.filter(i => selectedRows.has(i.name)); break;
case 'all_on_page': dataToExport = inspections; break;
case 'all_with_filters': dataToExport = await fetchAllInspectionsForExport(); break;
}
if (dataToExport.length === 0) { alert(t('assets.noDataToExport')); return; }
const columnLabels = columns.map(key => t(EXPORT_COLUMNS.find(c => c.key === key)?.labelKey || key));
if (format === 'csv') {
const csvContent = [columnLabels.join(','), ...dataToExport.map(item => columns.map(key => { let value = item[key] || ''; if (typeof value === 'string' && (value.includes(',') || value.includes('"') || value.includes('\n'))) value = `"${value.replace(/"/g, '""')}"`; return value; }).join(','))].join('\n');
const blob = new Blob(['\ufeff' + csvContent], { type: 'text/csv;charset=utf-8;' });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url; link.download = `inspections_export_${new Date().toISOString().split('T')[0]}.csv`; link.click();
URL.revokeObjectURL(url);
} else if (format === 'excel') {
const worksheetData = [columnLabels, ...dataToExport.map(item => columns.map(key => item[key] || ''))];
const worksheet = XLSX.utils.aoa_to_sheet(worksheetData);
const workbook = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(workbook, worksheet, 'Inspections');
XLSX.writeFile(workbook, `inspections_export_${new Date().toISOString().split('T')[0]}.xlsx`);
}
setShowExportModal(false); setSelectedRows(new Set());
} catch (error) { console.error('Export failed:', error); alert(`Export failed: ${error instanceof Error ? error.message : 'Unknown error'}`); }
finally { setIsExporting(false); }
};
const handleDelete = async (name: string) => {
try {
const response = await fetch(`/api/resource/Inspection/${name}`, { method: 'DELETE', headers: { 'Content-Type': 'application/json' } });
if (!response.ok) throw new Error('Failed to delete');
setDeleteConfirmOpen(null); refetch(); alert(t('inspections.deletedSuccessfully'));
} catch (err) { alert(`Failed to delete: ${err instanceof Error ? err.message : 'Unknown error'}`); }
};
if (loading && !initialLoadComplete) {
return (
<div className="flex items-center justify-center h-screen bg-gray-50 dark:bg-gray-900">
<div className="text-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-teal-500 mx-auto"></div>
<p className="mt-4 text-gray-600 dark:text-gray-400">{t('inspections.loadingInspections')}</p>
</div>
</div>
);
}
if (error) {
return (
<div className="p-6 bg-gray-50 dark:bg-gray-900 min-h-screen">
<div className="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-6">
<h2 className="text-xl font-bold text-red-800 dark:text-red-300 mb-4">{t('inspections.errorLoadingInspections')}</h2>
<p className="text-red-700 dark:text-red-400 mb-4">{error}</p>
<button onClick={refetch} className="bg-teal-600 hover:bg-teal-700 text-white px-4 py-2 rounded">{t('common.tryAgain')}</button>
</div>
</div>
);
}
return (
<div className="p-6 bg-gray-50 dark:bg-gray-900 min-h-screen">
{/* Header */}
<div className="mb-6 flex justify-between items-center">
<div>
<div className="flex items-center gap-3">
<FaClipboardCheck className="text-3xl text-teal-600 dark:text-teal-400" />
<div>
<h1 className="text-2xl font-bold text-gray-800 dark:text-white">{t('inspections.title')}</h1>
<p className="text-sm text-gray-500 dark:text-gray-400">
{t('common.total')}: {totalCount}
{selectedRows.size > 0 && <span className="ml-2 text-teal-600 dark:text-teal-400"> {selectedRows.size} selected</span>}
{loading && initialLoadComplete && <span className="ml-2 inline-flex items-center gap-1 text-xs text-teal-600 dark:text-teal-400"><div className="animate-spin rounded-full h-3 w-3 border-b-2 border-teal-500"></div>Updating...</span>}
</p>
</div>
</div>
</div>
<div className="flex gap-3">
<button onClick={() => setIsFilterExpanded(!isFilterExpanded)} className={`px-4 py-2 rounded-lg flex items-center gap-2 transition-colors ${isFilterExpanded || hasActiveFilters ? 'bg-teal-100 text-teal-700 dark:bg-teal-900/30 dark:text-teal-300' : 'bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-600'}`}>
<FaFilter />{t('listPages.filters')}
{activeFilterCount > 0 && <span className="bg-teal-600 text-white text-xs px-1.5 py-0.5 rounded-full">{activeFilterCount}</span>}
</button>
<button onClick={refetch} disabled={loading} className="px-4 py-2 rounded-lg bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-600 flex items-center gap-2 disabled:opacity-50">
<FaSync className={loading ? 'animate-spin' : ''} />{t('listPages.refresh')}
</button>
{/* Inspection Report Button */}
<button
onClick={() => setShowReportModal(true)}
className="bg-cyan-600 hover:bg-cyan-700 text-white px-4 py-2 rounded-lg flex items-center gap-2 shadow transition-all"
>
<FaClipboardCheck />
<span className="font-medium">{t('inspections.inspectionReport')}</span>
</button>
<button onClick={() => setShowExportModal(true)} className="bg-green-600 hover:bg-green-700 text-white px-4 py-2 rounded-lg flex items-center gap-2 shadow transition-all" disabled={totalCount === 0}>
<FaFileExport /><span className="font-medium">{t('common.export')}</span>
{selectedRows.size > 0 && <span className="bg-white/20 px-1.5 py-0.5 rounded text-xs">{selectedRows.size}</span>}
</button>
{canAddInspection && (
<button onClick={() => navigate('/inspections/new')} className="bg-teal-600 hover:bg-teal-700 text-white px-4 py-2 rounded-lg flex items-center gap-2 shadow-lg transition-all hover:shadow-xl">
<FaPlus /><span className="font-medium">{t('inspections.newInspection')}</span>
</button>
)}
</div>
</div>
{/* Stats Cards */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-4">
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-4 border border-gray-200 dark:border-gray-700">
<div className="flex items-center justify-between">
<div><p className="text-sm text-gray-500 dark:text-gray-400">{t('common.total')}</p><p className="text-2xl font-bold text-gray-800 dark:text-white">{totalCount}</p></div>
<FaClipboardCheck className="text-3xl text-teal-500" />
</div>
</div>
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-4 border border-gray-200 dark:border-gray-700">
<div className="flex items-center justify-between">
<div><p className="text-sm text-gray-500 dark:text-gray-400">{t('inspections.stats.draft')}</p><p className="text-2xl font-bold text-orange-600">{inspections.filter(i => i.workflow_state === 'Draft').length}</p></div>
<div className="w-10 h-10 rounded-full bg-orange-100 dark:bg-orange-900/30 flex items-center justify-center"><span className="text-orange-600 font-bold">D</span></div>
</div>
</div>
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-4 border border-gray-200 dark:border-gray-700">
<div className="flex items-center justify-between">
<div><p className="text-sm text-gray-500 dark:text-gray-400">{t('inspections.stats.pendingApproval')}</p><p className="text-2xl font-bold text-purple-600">{inspections.filter(i => i.workflow_state === 'Sent to Supervisor').length}</p></div>
<div className="w-10 h-10 rounded-full bg-purple-100 dark:bg-purple-900/30 flex items-center justify-center"><span className="text-purple-600 font-bold">P</span></div>
</div>
</div>
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-4 border border-gray-200 dark:border-gray-700">
<div className="flex items-center justify-between">
<div><p className="text-sm text-gray-500 dark:text-gray-400">{t('inspections.stats.closed')}</p><p className="text-2xl font-bold text-green-600">{inspections.filter(i => i.workflow_state === 'Closed').length}</p></div>
<div className="w-10 h-10 rounded-full bg-green-100 dark:bg-green-900/30 flex items-center justify-center"><span className="text-green-600 font-bold">C</span></div>
</div>
</div>
</div>
{/* Expandable Filter Panel */}
{isFilterExpanded && (
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-lg border border-gray-200 dark:border-gray-700 mb-4">
<div className="bg-gradient-to-r from-teal-500 to-teal-600 dark:from-teal-600 dark:to-teal-700 px-4 py-3 rounded-t-lg">
<div className="flex items-center justify-between gap-4">
<div className="flex items-center gap-3">
<FaFilter className="text-white" size={16} /><h3 className="text-white font-semibold text-sm">{t('listPages.filters')}</h3>
{activeFilterCount > 0 && <span className="bg-white text-teal-600 px-2 py-0.5 rounded-full text-xs font-bold">{activeFilterCount}</span>}
</div>
{hasActiveFilters && (
<div className="flex-1 overflow-x-auto scrollbar-hide mx-2">
<div className="flex items-center gap-2 py-1">
{statusFilter && <span className="inline-flex items-center gap-1.5 px-2.5 py-1 bg-white/90 text-blue-700 rounded-full text-[10px] font-medium whitespace-nowrap shadow-sm"><span className="font-semibold">{t('inspections.filterStatus')}:</span> {t(`inspections.status.${(statusFilter || '').toLowerCase().replace(/\s+/g, '_')}`, { defaultValue: statusFilter })}<button onClick={() => setStatusFilter('')} className="hover:text-red-500"><FaTimes className="text-[9px]" /></button></span>}
{workflowStateFilter && <span className="inline-flex items-center gap-1.5 px-2.5 py-1 bg-white/90 text-purple-700 rounded-full text-[10px] font-medium whitespace-nowrap shadow-sm"><span className="font-semibold">{t('inspections.filterWorkflow')}:</span> {t(`inspections.workflowState.${(workflowStateFilter || '').toLowerCase().replace(/\s+/g, '_')}`, { defaultValue: workflowStateFilter })}<button onClick={() => setWorkflowStateFilter('')} className="hover:text-red-500"><FaTimes className="text-[9px]" /></button></span>}
{inspectionTypeFilter && <span className="inline-flex items-center gap-1.5 px-2.5 py-1 bg-white/90 text-teal-700 rounded-full text-[10px] font-medium whitespace-nowrap shadow-sm"><span className="font-semibold">{t('inspections.filterType')}:</span> {t(`inspections.typeMap.${(inspectionTypeFilter || '').toLowerCase().replace(/\s+/g, '_')}`, { defaultValue: inspectionTypeFilter })}<button onClick={() => setInspectionTypeFilter('')} className="hover:text-red-500"><FaTimes className="text-[9px]" /></button></span>}
{workOrderFilter && <span className="inline-flex items-center gap-1.5 px-2.5 py-1 bg-white/90 text-orange-700 rounded-full text-[10px] font-medium whitespace-nowrap shadow-sm"><span className="font-semibold">{t('inspections.filterWorkOrder')}:</span> {workOrderFilter}<button onClick={() => setWorkOrderFilter('')} className="hover:text-red-500"><FaTimes className="text-[9px]" /></button></span>}
{departmentFilter && <span className="inline-flex items-center gap-1.5 px-2.5 py-1 bg-white/90 text-green-700 rounded-full text-[10px] font-medium whitespace-nowrap shadow-sm"><span className="font-semibold">{t('inspections.filterDepartment')}:</span> {departmentFilter}<button onClick={() => setDepartmentFilter('')} className="hover:text-red-500"><FaTimes className="text-[9px]" /></button></span>}
{hasDateFilter && <span className="inline-flex items-center gap-1.5 px-2.5 py-1 bg-white/90 text-indigo-700 rounded-full text-[10px] font-medium whitespace-nowrap shadow-sm"><span className="font-semibold">{t('filters.filterBy')}:</span> {dateFilterBy === 'creation' ? t('filters.createdDate') : t('filters.latestModifiedDate')} {dateStart && ` ${dateStart}`} {dateEnd && ` - ${dateEnd}`}<button onClick={() => { setDateFilterBy(''); setDateStart(''); setDateEnd(''); }} className="hover:text-red-500"><FaTimes className="text-[9px]" /></button></span>}
</div>
</div>
)}
<div className="flex items-center gap-2 flex-shrink-0">
{activeFilterCount > 0 && <button onClick={() => setShowSaveFilterModal(true)} className="px-3 py-1.5 bg-white text-teal-600 hover:bg-teal-50 rounded-md text-xs font-medium transition-all flex items-center gap-1.5"><FaSave size={12} /><span className="hidden sm:inline">{t('common.save')}</span></button>}
{hasActiveFilters && <button onClick={clearFilters} className="px-3 py-1.5 bg-red-500 hover:bg-red-600 text-white rounded-md text-xs font-medium transition-all flex items-center gap-1.5"><FaTimes size={12} /><span className="hidden sm:inline">{t('common.clearFilters')}</span></button>}
</div>
</div>
</div>
<div className="p-4">
{savedFilters.length > 0 && (
<div className="mb-4 pb-4 border-b border-gray-200 dark:border-gray-700">
<h4 className="text-xs font-semibold text-gray-700 dark:text-gray-300 mb-2 flex items-center gap-2"><FaStar className="text-yellow-500" size={12} />{t('inspections.savedFilters')}</h4>
<div className="flex flex-wrap gap-2">
{savedFilters.map((preset) => (
<div key={preset.id} className="group relative inline-flex items-center gap-2 px-3 py-1.5 bg-gradient-to-r from-teal-100 to-blue-100 dark:from-teal-900/30 dark:to-blue-900/30 border border-teal-200 dark:border-teal-700 rounded-lg hover:shadow-md transition-all">
<button onClick={() => handleLoadFilterPreset(preset)} className="text-xs font-medium text-teal-700 dark:text-teal-300">{preset.name}</button>
<button onClick={() => handleDeleteFilterPreset(preset.id)} className="opacity-0 group-hover:opacity-100 text-red-500 hover:text-red-700 transition-opacity"><FaTrash size={10} /></button>
</div>
))}
</div>
</div>
)}
<div className="bg-gray-50 dark:bg-gray-900/50 p-3 rounded-lg">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-5 gap-3">
{/* Sort By */}
<div className="relative">
<label className="block text-[10px] font-medium text-gray-700 dark:text-gray-300 mb-0.5">{t('filters.sortBy')}</label>
<select value={sortBy} onChange={(e) => { setSortBy(e.target.value); setCurrentPage(1); }} className="w-full px-2 py-1 text-xs border border-gray-300 dark:border-gray-600 rounded focus:outline-none focus:ring-2 focus:ring-teal-500 bg-white dark:bg-gray-700 text-gray-900 dark:text-white">
<option value="creation desc">{t('filters.sortCreationNewest')}</option>
<option value="creation asc">{t('filters.sortCreationOldest')}</option>
<option value="modified desc">{t('filters.sortModifiedNewest')}</option>
<option value="modified asc">{t('filters.sortModifiedOldest')}</option>
<option value="name asc">{t('filters.sortNameAsc')}</option>
<option value="name desc">{t('filters.sortNameDesc')}</option>
</select>
</div>
{/* Date range */}
<div className="relative">
<label className="block text-[10px] font-medium text-gray-700 dark:text-gray-300 mb-0.5">{t('filters.filterBy')}</label>
<select value={dateFilterBy} onChange={(e) => { const v = e.target.value as '' | 'creation' | 'modified'; setDateFilterBy(v); setCurrentPage(1); }} className="w-full px-2 py-1 text-xs border border-gray-300 dark:border-gray-600 rounded focus:outline-none focus:ring-2 focus:ring-teal-500 bg-white dark:bg-gray-700 text-gray-900 dark:text-white">
<option value="">{t('filters.filterBy')}</option>
<option value="creation">{t('filters.createdDate')}</option>
<option value="modified">{t('filters.latestModifiedDate')}</option>
</select>
</div>
{dateFilterBy && (
<>
<div className="relative">
<label className="block text-[10px] font-medium text-gray-700 dark:text-gray-300 mb-0.5">{t('filters.startDate')}</label>
<input type="date" value={dateStart} onChange={(e) => { const v = e.target.value; setDateStart(v); if (dateEnd && v > dateEnd) setDateEnd(v); setCurrentPage(1); }} className="w-full px-2 py-1 text-xs border border-gray-300 dark:border-gray-600 rounded focus:outline-none focus:ring-2 focus:ring-teal-500 bg-white dark:bg-gray-700 text-gray-900 dark:text-white" />
</div>
<div className="relative">
<label className="block text-[10px] font-medium text-gray-700 dark:text-gray-300 mb-0.5">{t('filters.endDate')}</label>
<input type="date" value={dateEnd} onChange={(e) => { setDateEnd(e.target.value); setCurrentPage(1); }} min={dateStart || undefined} className="w-full px-2 py-1 text-xs border border-gray-300 dark:border-gray-600 rounded focus:outline-none focus:ring-2 focus:ring-teal-500 bg-white dark:bg-gray-700 text-gray-900 dark:text-white" />
</div>
</>
)}
{/* Status Filter */}
<div className="relative">
<label className="block text-[10px] font-medium text-gray-700 dark:text-gray-300 mb-0.5">{t('filters.status')}</label>
<select value={statusFilter} onChange={(e) => { setStatusFilter(e.target.value); setCurrentPage(1); }} className="w-full px-2 py-1 text-xs border border-gray-300 dark:border-gray-600 rounded focus:outline-none focus:ring-2 focus:ring-teal-500 bg-white dark:bg-gray-700 text-gray-900 dark:text-white">
<option value="">{t('filters.allStatuses')}</option>
<option value="Open">{t('inspections.status.open')}</option>
<option value="In Progress">{t('inspections.status.in_progress')}</option>
<option value="Pending Review">{t('inspections.status.pending_review')}</option>
<option value="Closed">{t('inspections.status.closed')}</option>
</select>
</div>
{/* Workflow State Filter */}
<div className="relative">
<label className="block text-[10px] font-medium text-gray-700 dark:text-gray-300 mb-0.5">{t('filters.workflowState')}</label>
<select value={workflowStateFilter} onChange={(e) => { setWorkflowStateFilter(e.target.value); setCurrentPage(1); }} className="w-full px-2 py-1 text-xs border border-gray-300 dark:border-gray-600 rounded focus:outline-none focus:ring-2 focus:ring-teal-500 bg-white dark:bg-gray-700 text-gray-900 dark:text-white">
<option value="">{t('filters.allStates')}</option>
<option value="Draft">{t('inspections.workflowState.draft')}</option>
<option value="Sent to Work Control">{t('inspections.workflowState.sent_to_work_control')}</option>
<option value="Sent to technician">{t('inspections.workflowState.sent_to_technician')}</option>
<option value="Sent to Supervisor">{t('inspections.workflowState.sent_to_supervisor')}</option>
<option value="Closed">{t('inspections.workflowState.closed')}</option>
</select>
</div>
{/* Inspection Type Filter */}
<div className="relative">
<label className="block text-[10px] font-medium text-gray-700 dark:text-gray-300 mb-0.5">{t('inspections.type')}</label>
<select value={inspectionTypeFilter} onChange={(e) => { setInspectionTypeFilter(e.target.value); setCurrentPage(1); }} className="w-full px-2 py-1 text-xs border border-gray-300 dark:border-gray-600 rounded focus:outline-none focus:ring-2 focus:ring-teal-500 bg-white dark:bg-gray-700 text-gray-900 dark:text-white">
<option value="">{t('filters.allTypes')}</option>
<option value="Inspection">{t('inspections.typeMap.inspection')}</option>
<option value="Safety Inspection">{t('inspections.typeMap.safety_inspection')}</option>
</select>
</div>
{/* Work Order Filter */}
<div className="relative z-[60]">
<LinkField label={t('inspections.filterWorkOrder')}
doctype="Work_Order" value={workOrderFilter} onChange={(val) => { setWorkOrderFilter(val); setCurrentPage(1); }}
placeholder={t('inspections.selectWorkOrder')} disabled={false}
compact={true}
filters={{ custom_delete_status: ['!=', 'Deleted'] }}
/>
{workOrderFilter && <button onClick={() => setWorkOrderFilter('')} className="absolute right-2 top-6 text-gray-400 hover:text-red-500 transition-colors z-10"><FaTimes size={10} /></button>}
</div>
{/* Department Filter */}
<div className="relative z-[59]">
<LinkField label={t('inspections.technicalDepartment')} doctype="Issue Type" value={departmentFilter} onChange={(val) => { setDepartmentFilter(val); setCurrentPage(1); }} placeholder={t('inspections.selectDepartment')} disabled={false} compact={true} />
{departmentFilter && <button onClick={() => setDepartmentFilter('')} className="absolute right-2 top-6 text-gray-400 hover:text-red-500 transition-colors z-10"><FaTimes size={10} /></button>}
</div>
</div>
</div>
</div>
</div>
)}
{/* Save Filter Modal */}
{showSaveFilterModal && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-xl max-w-md w-full p-6 animate-scale-in">
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-4">{t('common.saveFilterPreset')}</h3>
<input type="text" value={filterPresetName} onChange={(e) => setFilterPresetName(e.target.value)} onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); handleSaveFilterPreset(); } }} placeholder={t('common.enterFilterName')} className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md focus:outline-none focus:ring-2 focus:ring-teal-500 bg-white dark:bg-gray-700 text-gray-900 dark:text-white mb-4" autoFocus />
<div className="flex gap-2 justify-end">
<button onClick={() => { setShowSaveFilterModal(false); setFilterPresetName(''); }} className="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-md transition-colors">{t('common.cancel')}</button>
<button onClick={handleSaveFilterPreset} className="px-4 py-2 text-sm font-medium text-white bg-teal-600 hover:bg-teal-700 rounded-md transition-colors flex items-center gap-2"><FaSave size={12} />{t('common.saveFilter')}</button>
</div>
</div>
</div>
)}
{/* Export Modal */}
<ExportModal isOpen={showExportModal} onClose={() => setShowExportModal(false)} selectedCount={selectedRows.size} totalCount={totalCount} pageCount={inspections.length} onExport={handleExport} isExporting={isExporting} exportColumns={EXPORT_COLUMNS} />
{/* Table */}
<div className="bg-white dark:bg-gray-800 rounded-lg shadow border border-gray-200 dark:border-gray-700 overflow-hidden relative">
{loading && initialLoadComplete && (
<div className="absolute inset-0 bg-white/60 dark:bg-gray-800/60 flex items-center justify-center z-10 backdrop-blur-[1px]">
<div className="flex items-center gap-3 bg-white dark:bg-gray-700 px-4 py-2 rounded-lg shadow-lg"><div className="animate-spin rounded-full h-5 w-5 border-b-2 border-teal-500"></div><span className="text-sm text-gray-600 dark:text-gray-300">{t('common.filtering')}</span></div>
</div>
)}
<div className="overflow-x-auto">
<table className="w-full">
<thead className="bg-gray-100 dark:bg-gray-700 border-b border-gray-200 dark:border-gray-600">
<tr>
<th className="px-4 py-3 text-left">
<button onClick={handleSelectAll} className="text-gray-500 dark:text-gray-400 hover:text-teal-600 dark:hover:text-teal-400 transition-colors" title={isAllSelected ? t('common.deselectAllTitle') : t('common.selectAllTitle')}>
{isAllSelected ? <FaCheckSquare className="text-teal-600 dark:text-teal-400" size={18} /> : isSomeSelected ? <div className="relative"><FaSquare size={18} /><div className="absolute inset-0 flex items-center justify-center"><div className="w-2 h-0.5 bg-current"></div></div></div> : <FaSquare size={18} />}
</button>
</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">{t('inspections.inspectionId')}</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">{t('inspections.type')}</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">{t('filters.status')}</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">{t('inspections.workflowStateHeader')}</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">{t('inspections.targetDate')}</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">{t('inspections.requestedBy')}</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">{t('inspections.linkedWorkOrder')}</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">{t('listPages.actions')}</th>
</tr>
</thead>
<tbody className="bg-white dark:bg-gray-800 divide-y divide-gray-200 dark:divide-gray-700">
{inspections.length === 0 ? (
<tr><td colSpan={9} className="px-4 py-12 text-center text-gray-500 dark:text-gray-400">
<div className="flex flex-col items-center"><FaClipboardCheck className="text-4xl text-gray-300 dark:text-gray-600 mb-2" /><p>{t('inspections.noInspectionsFound')}</p>
{hasActiveFilters ? (
<button onClick={clearFilters} className="mt-4 text-teal-600 dark:text-teal-400 hover:underline">{t('common.clearFilters')}</button>
) : canAddInspection ? (
<button onClick={() => navigate('/inspections/new')} className="mt-4 text-teal-600 dark:text-teal-400 hover:underline">{t('inspections.createFirstInspection')}</button>
) : null}
</div>
</td></tr>
) : inspections.map((inspection) => (
<tr key={inspection.name} className={`hover:bg-gray-50 dark:hover:bg-gray-700/50 cursor-pointer transition-colors ${selectedRows.has(inspection.name) ? 'bg-teal-50 dark:bg-teal-900/20' : getDeleteStatusRowClass(inspection.custom_delete_status)}`} title={inspection.custom_delete_status ? `Delete Status: ${inspection.custom_delete_status}` : undefined} onClick={() => navigate(`/inspections/${inspection.name}`)}>
<td className="px-4 py-3" onClick={(e) => e.stopPropagation()}>
<button onClick={() => handleSelectRow(inspection.name)} className="text-gray-500 dark:text-gray-400 hover:text-teal-600 dark:hover:text-teal-400 transition-colors">
{selectedRows.has(inspection.name) ? <FaCheckSquare className="text-teal-600 dark:text-teal-400" size={18} /> : <FaSquare size={18} />}
</button>
</td>
<td className="px-4 py-3"><span className="text-sm font-medium text-teal-600 dark:text-teal-400">{inspection.name}</span></td>
<td className="px-4 py-3">{inspection.inspection_type ? <span className={`inline-flex px-2 py-1 text-xs font-medium rounded-full ${getInspectionTypeStyle(inspection.inspection_type)}`}>{t(`inspections.typeMap.${(inspection.inspection_type || '').toLowerCase().replace(/\s+/g, '_')}`, { defaultValue: inspection.inspection_type })}</span> : <span className="text-gray-400">-</span>}</td>
<td className="px-4 py-3">
<span className={`inline-flex px-2 py-1 text-xs font-medium rounded-full ${getStatusStyle(inspection.status)}`}>
{inspection.status ? t(`inspections.status.${(inspection.status || '').toLowerCase().replace(/\s+/g, '_')}`, { defaultValue: inspection.status }) : '-'}
</span>
</td>
<td className="px-4 py-3"><span className={`inline-flex px-2 py-1 text-xs font-medium rounded-full ${getWorkflowStateStyle(inspection.workflow_state)}`}>{inspection.workflow_state ? t(`inspections.workflowState.${(inspection.workflow_state || '').toLowerCase().replace(/\s+/g, '_')}`, { defaultValue: inspection.workflow_state }) : '-'}</span></td>
<td className="px-4 py-3"><span className="text-sm text-gray-600 dark:text-gray-300">{formatDate(inspection.target_closure_date || '')}</span></td>
<td className="px-4 py-3"><span className="text-sm text-gray-600 dark:text-gray-300 line-clamp-1">{inspection.requested_by || '-'}</span></td>
<td className="px-4 py-3" onClick={(e) => e.stopPropagation()}>
{inspection.linked_corrective_wo_no ? (
<button onClick={() => navigate(`/work-orders/${inspection.linked_corrective_wo_no}`)} className="text-sm text-blue-600 dark:text-blue-400 hover:underline flex items-center gap-1">
{inspection.linked_corrective_wo_no}
<FaExternalLinkAlt size={10} />
</button>
) : <span className="text-gray-400">-</span>}
</td>
<td className="px-4 py-3">
<div className="flex items-center gap-1" onClick={(e) => e.stopPropagation()}>
<button onClick={() => navigate(`/inspections/${inspection.name}`)} className="text-blue-600 dark:text-blue-400 hover:text-blue-900 dark:hover:text-blue-300 p-2 hover:bg-blue-50 dark:hover:bg-blue-900/30 rounded transition-colors" title={t('inspections.viewDetails')}><FaEye /></button>
<button onClick={() => navigate(`/inspections/${inspection.name}?edit=true`)} className="text-green-600 dark:text-green-400 hover:text-green-900 dark:hover:text-green-300 p-2 hover:bg-green-50 dark:hover:bg-green-900/30 rounded transition-colors" title={t('inspections.editInspection')}><FaEdit /></button>
<div onClick={(e) => e.stopPropagation()}>
<DeleteRequestButton
doctype="Inspection"
docname={inspection.name}
currentDeleteStatus={(inspection.custom_delete_status ?? null) as DeleteStatus}
userRoles={userRoles}
isSystemManager={listIsSystemManager}
triggerMode
redirectOnDelete="/inspections"
onStatusChange={() => refetch()}
/>
</div>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
<ListPagination
currentPage={currentPage}
totalCount={totalCount}
pageSize={pageSize}
itemLabel={t('pagination.inspections')}
onPageChange={(p) => setCurrentPage(p)}
/>
</div>
{/* Delete Confirmation Modal */}
{deleteConfirmOpen && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white dark:bg-gray-800 rounded-lg p-6 max-w-md w-full mx-4 shadow-2xl">
<div className="flex items-start gap-4">
<div className="flex-shrink-0 w-12 h-12 rounded-full bg-red-100 dark:bg-red-900/30 flex items-center justify-center"><FaTrash className="text-red-600 dark:text-red-400 text-xl" /></div>
<div className="flex-1">
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-2">{t('inspections.deleteInspection')}</h3>
<p className="text-sm text-gray-600 dark:text-gray-400 mb-4">{t('inspections.deleteConfirmMessage')}</p>
<div className="bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-md p-3 mb-4"><p className="text-xs text-yellow-800 dark:text-yellow-300"><strong>{t('inspections.inspectionId')}:</strong> {deleteConfirmOpen}</p></div>
<div className="flex gap-3 justify-end">
<button onClick={() => setDeleteConfirmOpen(null)} className="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 rounded-lg transition-colors">{t('common.cancel')}</button>
<button onClick={() => handleDelete(deleteConfirmOpen)} className="px-4 py-2 text-sm font-medium text-white bg-red-600 hover:bg-red-700 rounded-lg transition-colors flex items-center gap-2"><FaTrash />{t('common.delete')}</button>
</div>
</div>
</div>
</div>
</div>
)}
{/* Inspection Report Modal */}
<InspectionReportModal
isOpen={showReportModal}
onClose={() => setShowReportModal(false)}
permittedIssueTypes={permittedIssueTypes}
isAdmin={isWoAdmin}
/>
<style>{`
@keyframes scale-in { from { transform: scale(0.95); opacity: 0; } to { transform: scale(1); opacity: 1; } }
.animate-scale-in { animation: scale-in 0.2s ease-out; }
.scrollbar-hide { -ms-overflow-style: none; scrollbar-width: none; }
.scrollbar-hide::-webkit-scrollbar { display: none; }
`}</style>
</div>
);
};
export default InspectionList;

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

View File

@ -0,0 +1,779 @@
import React, { useState, useMemo, useEffect, useCallback, useRef } from 'react';
import { useNavigate, useSearchParams } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { useIssueList } from '../hooks/useIssue';
import ListPagination from '../components/ListPagination';
import * as XLSX from 'xlsx';
import {
FaPlus,
FaFilter,
FaSync,
FaEye,
FaChevronLeft,
FaChevronRight,
FaExclamationCircle,
FaCheckCircle,
FaClock,
FaTimesCircle,
FaHeadset,
FaTimes,
FaSave,
FaStar,
FaTrash,
FaEdit,
FaCheckSquare,
FaSquare,
FaFileExport,
FaFileExcel,
FaFileCsv,
FaDownload
} from 'react-icons/fa';
import LinkField from '../components/LinkField';
import { buildDateRangeFilters, toFrappeFilterArray } from '../utils/listFilterUtils';
// Export types
type ExportFormat = 'csv' | 'excel';
type ExportScope = 'selected' | 'all_on_page' | 'all_with_filters';
interface ExportModalProps {
isOpen: boolean;
onClose: () => void;
selectedCount: number;
totalCount: number;
pageCount: number;
onExport: (scope: ExportScope, format: ExportFormat, columns: string[]) => void;
isExporting: boolean;
exportColumns: Array<{key: string, label: string, default: boolean}>;
}
const ExportModal: React.FC<ExportModalProps> = ({
isOpen,
onClose,
selectedCount,
totalCount,
pageCount,
onExport,
isExporting,
exportColumns
}) => {
const { t } = useTranslation();
const [scope, setScope] = useState<ExportScope>(selectedCount > 0 ? 'selected' : 'all_with_filters');
const [format, setFormat] = useState<ExportFormat>('csv');
const [selectedColumns, setSelectedColumns] = useState<string[]>(
exportColumns.filter(c => c.default).map(c => c.key)
);
useEffect(() => {
if (selectedCount > 0) {
setScope('selected');
} else {
setScope('all_with_filters');
}
}, [selectedCount]);
const toggleColumn = (key: string) => {
setSelectedColumns(prev =>
prev.includes(key) ? prev.filter(k => k !== key) : [...prev, key]
);
};
const selectAllColumns = () => setSelectedColumns(exportColumns.map(c => c.key));
const selectDefaultColumns = () => setSelectedColumns(exportColumns.filter(c => c.default).map(c => c.key));
if (!isOpen) return null;
return (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-[70] p-4">
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-xl max-w-2xl w-full max-h-[90vh] overflow-hidden animate-scale-in">
<div className="bg-gradient-to-r from-green-500 to-green-600 px-6 py-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<FaFileExport className="text-white text-xl" />
<h3 className="text-lg font-semibold text-white">{t('issues.export.title')}</h3>
</div>
<button onClick={onClose} className="text-white/80 hover:text-white transition-colors" disabled={isExporting}>
<FaTimes size={20} />
</button>
</div>
</div>
<div className="p-6 overflow-y-auto max-h-[calc(90vh-180px)]">
<div className="mb-6">
<h4 className="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-3">{t('issues.export.selectData')}</h4>
<div className="space-y-2">
<label className={`flex items-center gap-3 p-3 rounded-lg border cursor-pointer transition-all ${scope === 'selected' ? 'border-green-500 bg-green-50 dark:bg-green-900/20' : 'border-gray-200 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-700/50'} ${selectedCount === 0 ? 'opacity-50 cursor-not-allowed' : ''}`}>
<input type="radio" name="scope" value="selected" checked={scope === 'selected'} onChange={() => setScope('selected')} disabled={selectedCount === 0} className="text-green-600 focus:ring-green-500" />
<div className="flex-1">
<div className="font-medium text-gray-900 dark:text-white">{t('issues.export.selectedRows')}</div>
<div className="text-sm text-gray-500 dark:text-gray-400">{t('issues.export.selectedCount', { count: selectedCount })}</div>
</div>
{selectedCount > 0 && <span className="bg-green-100 dark:bg-green-900/40 text-green-700 dark:text-green-300 px-2 py-1 rounded text-xs font-medium">{selectedCount} selected</span>}
</label>
<label className={`flex items-center gap-3 p-3 rounded-lg border cursor-pointer transition-all ${scope === 'all_on_page' ? 'border-green-500 bg-green-50 dark:bg-green-900/20' : 'border-gray-200 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-700/50'}`}>
<input type="radio" name="scope" value="all_on_page" checked={scope === 'all_on_page'} onChange={() => setScope('all_on_page')} className="text-green-600 focus:ring-green-500" />
<div className="flex-1">
<div className="font-medium text-gray-900 dark:text-white">{t('issues.export.currentPage')}</div>
<div className="text-sm text-gray-500 dark:text-gray-400">{t('issues.export.currentPageCount', { count: pageCount })}</div>
</div>
<span className="bg-blue-100 dark:bg-blue-900/40 text-blue-700 dark:text-blue-300 px-2 py-1 rounded text-xs font-medium">{pageCount} rows</span>
</label>
<label className={`flex items-center gap-3 p-3 rounded-lg border cursor-pointer transition-all ${scope === 'all_with_filters' ? 'border-green-500 bg-green-50 dark:bg-green-900/20' : 'border-gray-200 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-700/50'}`}>
<input type="radio" name="scope" value="all_with_filters" checked={scope === 'all_with_filters'} onChange={() => setScope('all_with_filters')} className="text-green-600 focus:ring-green-500" />
<div className="flex-1">
<div className="font-medium text-gray-900 dark:text-white">{t('issues.export.allWithFilters')}</div>
<div className="text-sm text-gray-500 dark:text-gray-400">{t('issues.export.allWithFiltersCount', { count: totalCount })}</div>
</div>
<span className="bg-purple-100 dark:bg-purple-900/40 text-purple-700 dark:text-purple-300 px-2 py-1 rounded text-xs font-medium">{totalCount} total</span>
</label>
</div>
</div>
<div className="mb-6">
<h4 className="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-3">{t('issues.export.exportFormat')}</h4>
<div className="flex gap-3">
<label className={`flex-1 flex items-center gap-3 p-3 rounded-lg border cursor-pointer transition-all ${format === 'csv' ? 'border-green-500 bg-green-50 dark:bg-green-900/20' : 'border-gray-200 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-700/50'}`}>
<input type="radio" name="format" value="csv" checked={format === 'csv'} onChange={() => setFormat('csv')} className="text-green-600 focus:ring-green-500" />
<FaFileCsv className="text-green-600 text-xl" />
<div>
<div className="font-medium text-gray-900 dark:text-white">{t('issues.export.csv')}</div>
<div className="text-xs text-gray-500 dark:text-gray-400">{t('issues.export.csvDesc')}</div>
</div>
</label>
<label className={`flex-1 flex items-center gap-3 p-3 rounded-lg border cursor-pointer transition-all ${format === 'excel' ? 'border-green-500 bg-green-50 dark:bg-green-900/20' : 'border-gray-200 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-700/50'}`}>
<input type="radio" name="format" value="excel" checked={format === 'excel'} onChange={() => setFormat('excel')} className="text-green-600 focus:ring-green-500" />
<FaFileExcel className="text-green-700 text-xl" />
<div>
<div className="font-medium text-gray-900 dark:text-white">{t('issues.export.excel')}</div>
<div className="text-xs text-gray-500 dark:text-gray-400">{t('issues.export.excelDesc')}</div>
</div>
</label>
</div>
</div>
<div>
<div className="flex items-center justify-between mb-3">
<h4 className="text-sm font-semibold text-gray-700 dark:text-gray-300">{t('issues.export.columnsToExport')}</h4>
<div className="flex gap-2">
<button onClick={selectAllColumns} className="text-xs text-blue-600 dark:text-blue-400 hover:underline">{t('issues.export.selectAll')}</button>
<span className="text-gray-300 dark:text-gray-600">|</span>
<button onClick={selectDefaultColumns} className="text-xs text-blue-600 dark:text-blue-400 hover:underline">{t('issues.export.resetToDefault')}</button>
</div>
</div>
<div className="grid grid-cols-2 md:grid-cols-3 gap-2 max-h-48 overflow-y-auto p-3 bg-gray-50 dark:bg-gray-900/50 rounded-lg">
{exportColumns.map((col) => (
<label key={col.key} className={`flex items-center gap-2 p-2 rounded cursor-pointer transition-all ${selectedColumns.includes(col.key) ? 'bg-green-100 dark:bg-green-900/30 text-green-800 dark:text-green-300' : 'hover:bg-gray-100 dark:hover:bg-gray-800 text-gray-700 dark:text-gray-400'}`}>
<input type="checkbox" checked={selectedColumns.includes(col.key)} onChange={() => toggleColumn(col.key)} className="rounded text-green-600 focus:ring-green-500" />
<span className="text-sm truncate">{col.label}</span>
</label>
))}
</div>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-2">{t('issues.export.columnsSelected', { count: selectedColumns.length })}</p>
</div>
</div>
<div className="px-6 py-4 bg-gray-50 dark:bg-gray-900/50 border-t border-gray-200 dark:border-gray-700 flex justify-between items-center">
<div className="text-sm text-gray-600 dark:text-gray-400">
{scope === 'selected' && t('issues.export.exportingSelected', { count: selectedCount })}
{scope === 'all_on_page' && t('issues.export.exportingPage', { count: pageCount })}
{scope === 'all_with_filters' && t('issues.export.exportingAll', { count: totalCount })}
</div>
<div className="flex gap-3">
<button onClick={onClose} className="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors" disabled={isExporting}>{t('common.cancel')}</button>
<button onClick={() => onExport(scope, format, selectedColumns)} disabled={selectedColumns.length === 0 || isExporting} className="px-4 py-2 text-sm font-medium text-white bg-green-600 hover:bg-green-700 rounded-lg transition-colors flex items-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed">
{isExporting ? (<><div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white"></div>{t('issues.export.exporting')}</>) : (<><FaDownload />{t('issues.export.exportButton')}</>)}
</button>
</div>
</div>
</div>
</div>
);
};
// Status badge colors
const getStatusStyle = (status: string) => {
switch (status?.toLowerCase()) {
case 'open': return 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300';
case 'replied': return 'bg-purple-100 text-purple-800 dark:bg-purple-900/30 dark:text-purple-300';
case 'on hold': return 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-300';
case 'resolved': return 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300';
case 'closed': return 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300';
default: return 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300';
}
};
// Priority badge colors
const getPriorityStyle = (priority: string) => {
switch (priority?.toLowerCase()) {
case 'high': return 'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-300';
case 'medium': return 'bg-orange-100 text-orange-800 dark:bg-orange-900/30 dark:text-orange-300';
case 'low': return 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300';
default: return 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300';
}
};
const IssueList: React.FC = () => {
const { t } = useTranslation();
const navigate = useNavigate();
const [searchParams, setSearchParams] = useSearchParams();
const currentPage = useMemo(() => {
const p = parseInt(searchParams.get('page') || '1', 10);
return Number.isNaN(p) || p < 1 ? 1 : p;
}, [searchParams]);
const setCurrentPage = useCallback((pageOrUpdater: number | ((p: number) => number)) => {
const next = typeof pageOrUpdater === 'function' ? pageOrUpdater(currentPage) : pageOrUpdater;
setSearchParams((prev) => {
const nextParams = new URLSearchParams(prev);
nextParams.set('page', String(next));
return nextParams;
});
}, [currentPage, setSearchParams]);
const EXPORT_COLUMNS = [
{ key: 'name', label: t('issues.issueId'), default: true },
{ key: 'subject', label: t('issues.subject'), default: true },
{ key: 'status', label: t('commonFields.status'), default: true },
{ key: 'priority', label: t('commonFields.priority'), default: true },
{ key: 'raised_by', label: t('issues.raisedBy'), default: true },
{ key: 'company', label: t('commonFields.company'), default: true },
{ key: 'contact', label: t('issues.contact'), default: false },
{ key: 'issue_type', label: t('issues.issueType'), default: false },
{ key: 'opening_date', label: t('issues.openingDate'), default: true },
{ key: 'sla_resolution_date', label: t('issues.resolutionDate'), default: false },
{ key: 'sla_resolution_by', label: t('issues.resolvedBy'), default: false },
{ key: 'first_responded_on', label: t('issues.firstRespondedOn'), default: false },
{ key: 'description', label: t('commonFields.description'), default: false },
{ key: 'resolution_details', label: t('issues.resolutionDetails'), default: false },
{ key: 'creation', label: t('commonFields.createdOn'), default: false },
{ key: 'modified', label: t('commonFields.modifiedOn'), default: false },
{ key: 'owner', label: t('commonFields.createdBy'), default: false },
];
const [pageSize] = useState(20);
const [initialLoadComplete, setInitialLoadComplete] = useState(false);
const [selectedRows, setSelectedRows] = useState<Set<string>>(new Set());
const [showExportModal, setShowExportModal] = useState(false);
const [isExporting, setIsExporting] = useState(false);
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState<string | null>(null);
const [dateFilterBy, setDateFilterBy] = useState<'' | 'creation' | 'modified'>(() => (searchParams.get('date_filter_by') as '' | 'creation' | 'modified') || '');
const [dateStart, setDateStart] = useState<string>(() => searchParams.get('date_start') || '');
const [dateEnd, setDateEnd] = useState<string>(() => searchParams.get('date_end') || '');
const [statusFilter, setStatusFilter] = useState<string>(() => searchParams.get('status') || '');
const [priorityFilter, setPriorityFilter] = useState<string>(() => searchParams.get('priority') || '');
const [companyFilter, setCompanyFilter] = useState<string>(() => searchParams.get('company') || '');
const [issueIdFilter, setIssueIdFilter] = useState<string>(() => searchParams.get('issue_id') || '');
const [sortBy, setSortBy] = useState<string>(() => searchParams.get('sort_by') || 'creation desc');
const [isFilterExpanded, setIsFilterExpanded] = useState(false);
const [activeFilterCount, setActiveFilterCount] = useState(0);
const [savedFilters, setSavedFilters] = useState<any[]>([]);
const [showSaveFilterModal, setShowSaveFilterModal] = useState(false);
const [filterPresetName, setFilterPresetName] = useState('');
useEffect(() => {
const saved = localStorage.getItem('issueFilterPresets');
if (saved) setSavedFilters(JSON.parse(saved));
}, []);
const hasDateFilter = dateFilterBy && (dateStart || dateEnd);
useEffect(() => {
const count = [statusFilter, priorityFilter, companyFilter, issueIdFilter].filter(Boolean).length + (hasDateFilter ? 1 : 0);
setActiveFilterCount(count);
}, [statusFilter, priorityFilter, companyFilter, issueIdFilter, hasDateFilter]);
const apiFilters = useMemo(() => {
const filters: Record<string, any> = {};
if (statusFilter) filters['status'] = statusFilter;
if (priorityFilter) filters['priority'] = priorityFilter;
if (companyFilter) filters['company'] = companyFilter;
if (issueIdFilter) filters['name'] = issueIdFilter;
Object.assign(filters, buildDateRangeFilters(dateFilterBy, dateStart, dateEnd));
return filters;
}, [statusFilter, priorityFilter, companyFilter, issueIdFilter, dateFilterBy, dateStart, dateEnd]);
const orderBy = ['creation desc', 'creation asc', 'modified desc', 'modified asc', 'name asc', 'name desc'].includes(sortBy) ? sortBy : 'creation desc';
const { issues, loading, error, totalCount, refetch } = useIssueList({
filters: apiFilters,
limit_start: (currentPage - 1) * pageSize,
limit_page_length: pageSize,
order_by: orderBy,
});
useEffect(() => { if (!loading && !initialLoadComplete) setInitialLoadComplete(true); }, [loading, initialLoadComplete]);
const filtersChangedOnce = useRef(false);
useEffect(() => {
if (!filtersChangedOnce.current) {
filtersChangedOnce.current = true;
return;
}
setSearchParams((prev) => {
const next = new URLSearchParams(prev);
if (dateFilterBy) next.set('date_filter_by', dateFilterBy); else next.delete('date_filter_by');
if (dateStart) next.set('date_start', dateStart); else next.delete('date_start');
if (dateEnd) next.set('date_end', dateEnd); else next.delete('date_end');
if (statusFilter) next.set('status', statusFilter); else next.delete('status');
if (priorityFilter) next.set('priority', priorityFilter); else next.delete('priority');
if (companyFilter) next.set('company', companyFilter); else next.delete('company');
if (issueIdFilter) next.set('issue_id', issueIdFilter); else next.delete('issue_id');
if (sortBy && sortBy !== 'creation desc') next.set('sort_by', sortBy); else next.delete('sort_by');
next.set('page', '1');
return next;
});
}, [dateFilterBy, dateStart, dateEnd, statusFilter, priorityFilter, companyFilter, issueIdFilter, sortBy]);
useEffect(() => { setSelectedRows(new Set()); }, [dateFilterBy, dateStart, dateEnd, statusFilter, priorityFilter, companyFilter, issueIdFilter, currentPage]);
const totalPages = Math.ceil(totalCount / pageSize);
const formatDate = (dateStr: string) => dateStr ? new Date(dateStr).toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' }) : '-';
const clearFilters = () => {
setDateFilterBy(''); setDateStart(''); setDateEnd('');
setSortBy('creation desc');
setStatusFilter(''); setPriorityFilter(''); setCompanyFilter(''); setIssueIdFilter('');
setSearchParams((prev) => {
const next = new URLSearchParams(prev);
next.delete('date_filter_by'); next.delete('date_start'); next.delete('date_end');
next.delete('sort_by');
next.delete('status'); next.delete('priority'); next.delete('company'); next.delete('issue_id');
next.set('page', '1');
return next;
});
};
const hasActiveFilters = hasDateFilter || !!statusFilter || !!priorityFilter || !!companyFilter || !!issueIdFilter;
const handleSaveFilterPreset = () => {
if (!filterPresetName.trim()) { alert('Please enter a filter name'); return; }
const preset = { id: Date.now(), name: filterPresetName, filters: { dateFilterBy, dateStart, dateEnd, sortBy, statusFilter, priorityFilter, companyFilter, issueIdFilter } };
const updated = [...savedFilters, preset];
setSavedFilters(updated);
setFilterPresetName('');
setShowSaveFilterModal(false);
localStorage.setItem('issueFilterPresets', JSON.stringify(updated));
};
const handleLoadFilterPreset = (preset: any) => {
const f = preset.filters;
setDateFilterBy(f.dateFilterBy || ''); setDateStart(f.dateStart || ''); setDateEnd(f.dateEnd || '');
setSortBy(f.sortBy || 'creation desc');
setStatusFilter(f.statusFilter || ''); setPriorityFilter(f.priorityFilter || '');
setCompanyFilter(f.companyFilter || ''); setIssueIdFilter(f.issueIdFilter || '');
};
const handleDeleteFilterPreset = (id: number) => {
const updated = savedFilters.filter(f => f.id !== id);
setSavedFilters(updated);
localStorage.setItem('issueFilterPresets', JSON.stringify(updated));
};
const handleSelectRow = (issueName: string) => {
setSelectedRows(prev => { const newSet = new Set(prev); newSet.has(issueName) ? newSet.delete(issueName) : newSet.add(issueName); return newSet; });
};
const handleSelectAll = () => { selectedRows.size === issues.length ? setSelectedRows(new Set()) : setSelectedRows(new Set(issues.map(i => i.name))); };
const isAllSelected = issues.length > 0 && selectedRows.size === issues.length;
const isSomeSelected = selectedRows.size > 0 && selectedRows.size < issues.length;
const fetchAllIssuesForExport = useCallback(async (): Promise<any[]> => {
const allIssues: any[] = [];
let currentPageNum = 0;
const pageSizeNum = 100;
let hasMoreData = true;
const filterArrays = toFrappeFilterArray(apiFilters);
while (hasMoreData) {
try {
const response = await fetch('/api/method/frappe.client.get_list', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ doctype: 'Issue', filters: filterArrays.length > 0 ? filterArrays : {}, fields: ['*'], limit_start: currentPageNum * pageSizeNum, limit_page_length: pageSizeNum, order_by: orderBy })
});
const data = await response.json();
const results = data.message || [];
allIssues.push(...results);
if (results.length < pageSizeNum) hasMoreData = false; else currentPageNum++;
if (currentPageNum > 100) { console.warn('Export safety limit reached'); hasMoreData = false; }
} catch (error) { console.error('Error fetching issues for export:', error); throw error; }
}
return allIssues;
}, [apiFilters, orderBy]);
const handleExport = async (scope: ExportScope, format: ExportFormat, columns: string[]) => {
setIsExporting(true);
try {
let dataToExport: any[] = [];
switch (scope) {
case 'selected': dataToExport = issues.filter(i => selectedRows.has(i.name)); break;
case 'all_on_page': dataToExport = issues; break;
case 'all_with_filters': dataToExport = await fetchAllIssuesForExport(); break;
}
if (dataToExport.length === 0) { alert(t('assets.noDataToExport')); return; }
const columnLabels = columns.map(key => EXPORT_COLUMNS.find(c => c.key === key)?.label || key);
if (format === 'csv') {
const csvContent = [columnLabels.join(','), ...dataToExport.map(issue => columns.map(key => { let value = issue[key] || ''; if (typeof value === 'string' && (value.includes(',') || value.includes('"') || value.includes('\n'))) value = `"${value.replace(/"/g, '""')}"`; return value; }).join(','))].join('\n');
const blob = new Blob(['\ufeff' + csvContent], { type: 'text/csv;charset=utf-8;' });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url; link.download = `issues_export_${new Date().toISOString().split('T')[0]}.csv`; link.click();
URL.revokeObjectURL(url);
} else if (format === 'excel') {
const worksheetData = [columnLabels, ...dataToExport.map(issue => columns.map(key => issue[key] || ''))];
const worksheet = XLSX.utils.aoa_to_sheet(worksheetData);
const workbook = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(workbook, worksheet, 'Issues');
XLSX.writeFile(workbook, `issues_export_${new Date().toISOString().split('T')[0]}.xlsx`);
}
setShowExportModal(false); setSelectedRows(new Set());
} catch (error) { console.error('Export failed:', error); alert(`Export failed: ${error instanceof Error ? error.message : 'Unknown error'}`); }
finally { setIsExporting(false); }
};
const handleDelete = async (issueName: string) => {
try {
const response = await fetch(`/api/resource/Issue/${issueName}`, { method: 'DELETE', headers: { 'Content-Type': 'application/json' } });
if (!response.ok) throw new Error('Failed to delete');
setDeleteConfirmOpen(null); refetch(); alert(t('issues.deletedSuccessfully'));
} catch (err) { alert(`Failed to delete: ${err instanceof Error ? err.message : 'Unknown error'}`); }
};
if (loading && !initialLoadComplete) {
return (
<div className="flex items-center justify-center h-screen bg-gray-50 dark:bg-gray-900">
<div className="text-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-500 mx-auto"></div>
<p className="mt-4 text-gray-600 dark:text-gray-400">{t('issues.loadingIssues')}</p>
</div>
</div>
);
}
if (error) {
return (
<div className="p-6 bg-gray-50 dark:bg-gray-900 min-h-screen">
<div className="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-6">
<h2 className="text-xl font-bold text-red-800 dark:text-red-300 mb-4">{t('issues.errorLoadingIssues')}</h2>
<p className="text-red-700 dark:text-red-400 mb-4">{error}</p>
<button onClick={refetch} className="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded">{t('common.tryAgain')}</button>
</div>
</div>
);
}
return (
<div className="p-6 bg-gray-50 dark:bg-gray-900 min-h-screen">
{/* Header */}
<div className="mb-6 flex justify-between items-center">
<div>
<div className="flex items-center gap-3">
<FaHeadset className="text-3xl text-blue-600 dark:text-blue-400" />
<div>
<h1 className="text-2xl font-bold text-gray-800 dark:text-white">{t('issues.listTitle')}</h1>
<p className="text-sm text-gray-500 dark:text-gray-400">
{t('issues.listTotal')}: {totalCount}
{selectedRows.size > 0 && <span className="ml-2 text-blue-600 dark:text-blue-400"> {selectedRows.size} {t('issues.listSelected')}</span>}
{loading && initialLoadComplete && <span className="ml-2 inline-flex items-center gap-1 text-xs text-blue-600 dark:text-blue-400"><div className="animate-spin rounded-full h-3 w-3 border-b-2 border-blue-500"></div>{t('common.filtering')}</span>}
</p>
</div>
</div>
</div>
<div className="flex gap-3">
<button onClick={() => setIsFilterExpanded(!isFilterExpanded)} className={`px-4 py-2 rounded-lg flex items-center gap-2 transition-colors ${isFilterExpanded || hasActiveFilters ? 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-300' : 'bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-600'}`}>
<FaFilter />{t('listPages.filters')}
{activeFilterCount > 0 && <span className="bg-blue-600 text-white text-xs px-1.5 py-0.5 rounded-full">{activeFilterCount}</span>}
</button>
<button onClick={refetch} disabled={loading} className="px-4 py-2 rounded-lg bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-600 flex items-center gap-2 disabled:opacity-50">
<FaSync className={loading ? 'animate-spin' : ''} />{t('listPages.refresh')}
</button>
<button onClick={() => setShowExportModal(true)} className="bg-green-600 hover:bg-green-700 text-white px-4 py-2 rounded-lg flex items-center gap-2 shadow transition-all" disabled={totalCount === 0}>
<FaFileExport /><span className="font-medium">{t('listPages.export')}</span>
{selectedRows.size > 0 && <span className="bg-white/20 px-1.5 py-0.5 rounded text-xs">{selectedRows.size}</span>}
</button>
<button onClick={() => navigate('/support/new')} className="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-lg flex items-center gap-2 shadow-lg transition-all hover:shadow-xl">
<FaPlus /><span className="font-medium">{t('issues.newIssue')}</span>
</button>
</div>
</div>
{/* Stats Cards */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-4">
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-4 border border-gray-200 dark:border-gray-700">
<div className="flex items-center justify-between"><div><p className="text-sm text-gray-500 dark:text-gray-400">{t('issues.statsTotalIssues')}</p><p className="text-2xl font-bold text-gray-800 dark:text-white">{totalCount}</p></div><FaExclamationCircle className="text-3xl text-blue-500" /></div>
</div>
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-4 border border-gray-200 dark:border-gray-700">
<div className="flex items-center justify-between"><div><p className="text-sm text-gray-500 dark:text-gray-400">{t('issues.statsOpen')}</p><p className="text-2xl font-bold text-blue-600">{issues.filter(i => i.status === 'Open').length}</p></div><FaClock className="text-3xl text-blue-500" /></div>
</div>
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-4 border border-gray-200 dark:border-gray-700">
<div className="flex items-center justify-between"><div><p className="text-sm text-gray-500 dark:text-gray-400">{t('issues.statsResolved')}</p><p className="text-2xl font-bold text-green-600">{issues.filter(i => i.status === 'Resolved').length}</p></div><FaCheckCircle className="text-3xl text-green-500" /></div>
</div>
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-4 border border-gray-200 dark:border-gray-700">
<div className="flex items-center justify-between"><div><p className="text-sm text-gray-500 dark:text-gray-400">{t('issues.statsClosed')}</p><p className="text-2xl font-bold text-gray-600 dark:text-gray-300">{issues.filter(i => i.status === 'Closed').length}</p></div><FaTimesCircle className="text-3xl text-gray-500" /></div>
</div>
</div>
{/* Expandable Filter Panel */}
{isFilterExpanded && (
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-lg border border-gray-200 dark:border-gray-700 mb-4">
<div className="bg-gradient-to-r from-blue-500 to-blue-600 dark:from-blue-600 dark:to-blue-700 px-4 py-3 rounded-t-lg">
<div className="flex items-center justify-between gap-4">
<div className="flex items-center gap-3">
<FaFilter className="text-white" size={16} /><h3 className="text-white font-semibold text-sm">{t('listPages.filters')}</h3>
{activeFilterCount > 0 && <span className="bg-white text-blue-600 px-2 py-0.5 rounded-full text-xs font-bold">{activeFilterCount}</span>}
</div>
{hasActiveFilters && (
<div className="flex-1 overflow-x-auto scrollbar-hide mx-2">
<div className="flex items-center gap-2 py-1">
{issueIdFilter && <span className="inline-flex items-center gap-1.5 px-2.5 py-1 bg-white/90 text-blue-700 rounded-full text-[10px] font-medium whitespace-nowrap shadow-sm"><span className="font-semibold">{t('issues.issueId')}:</span> {issueIdFilter}<button onClick={() => setIssueIdFilter('')} className="hover:text-red-500"><FaTimes className="text-[9px]" /></button></span>}
{statusFilter && <span className="inline-flex items-center gap-1.5 px-2.5 py-1 bg-white/90 text-green-700 rounded-full text-[10px] font-medium whitespace-nowrap shadow-sm"><span className="font-semibold">{t('filters.status')}:</span> {statusFilter}<button onClick={() => setStatusFilter('')} className="hover:text-red-500"><FaTimes className="text-[9px]" /></button></span>}
{priorityFilter && <span className="inline-flex items-center gap-1.5 px-2.5 py-1 bg-white/90 text-orange-700 rounded-full text-[10px] font-medium whitespace-nowrap shadow-sm"><span className="font-semibold">{t('filters.priority')}:</span> {priorityFilter}<button onClick={() => setPriorityFilter('')} className="hover:text-red-500"><FaTimes className="text-[9px]" /></button></span>}
{companyFilter && <span className="inline-flex items-center gap-1.5 px-2.5 py-1 bg-white/90 text-purple-700 rounded-full text-[10px] font-medium whitespace-nowrap shadow-sm"><span className="font-semibold">{t('filters.filterByCompany')}:</span> {companyFilter}<button onClick={() => setCompanyFilter('')} className="hover:text-red-500"><FaTimes className="text-[9px]" /></button></span>}
{hasDateFilter && <span className="inline-flex items-center gap-1.5 px-2.5 py-1 bg-white/90 text-indigo-700 rounded-full text-[10px] font-medium whitespace-nowrap shadow-sm"><span className="font-semibold">{t('filters.filterBy')}:</span> {dateFilterBy === 'creation' ? t('filters.createdDate') : t('filters.latestModifiedDate')} {dateStart && ` ${dateStart}`} {dateEnd && ` - ${dateEnd}`}<button onClick={() => { setDateFilterBy(''); setDateStart(''); setDateEnd(''); }} className="hover:text-red-500"><FaTimes className="text-[9px]" /></button></span>}
</div>
</div>
)}
<div className="flex items-center gap-2 flex-shrink-0">
{activeFilterCount > 0 && <button onClick={() => setShowSaveFilterModal(true)} className="px-3 py-1.5 bg-white text-blue-600 hover:bg-blue-50 rounded-md text-xs font-medium transition-all flex items-center gap-1.5"><FaSave size={12} /><span className="hidden sm:inline">{t('listPages.saveFilterPreset')}</span></button>}
{hasActiveFilters && <button onClick={clearFilters} className="px-3 py-1.5 bg-red-500 hover:bg-red-600 text-white rounded-md text-xs font-medium transition-all flex items-center gap-1.5"><FaTimes size={12} /><span className="hidden sm:inline">{t('listPages.clearFilters')}</span></button>}
</div>
</div>
</div>
<div className="p-4">
{savedFilters.length > 0 && (
<div className="mb-4 pb-4 border-b border-gray-200 dark:border-gray-700">
<h4 className="text-xs font-semibold text-gray-700 dark:text-gray-300 mb-2 flex items-center gap-2"><FaStar className="text-yellow-500" size={12} />{t('inspections.savedFilters')}</h4>
<div className="flex flex-wrap gap-2">
{savedFilters.map((preset) => (
<div key={preset.id} className="group relative inline-flex items-center gap-2 px-3 py-1.5 bg-gradient-to-r from-purple-100 to-blue-100 dark:from-purple-900/30 dark:to-blue-900/30 border border-purple-200 dark:border-purple-700 rounded-lg hover:shadow-md transition-all">
<button onClick={() => handleLoadFilterPreset(preset)} className="text-xs font-medium text-purple-700 dark:text-purple-300">{preset.name}</button>
<button onClick={() => handleDeleteFilterPreset(preset.id)} className="opacity-0 group-hover:opacity-100 text-red-500 hover:text-red-700 transition-opacity"><FaTrash size={10} /></button>
</div>
))}
</div>
</div>
)}
<div className="bg-gray-50 dark:bg-gray-900/50 p-3 rounded-lg">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-3">
<div className="relative">
<label className="block text-[10px] font-medium text-gray-700 dark:text-gray-300 mb-0.5">{t('filters.sortBy')}</label>
<select value={sortBy} onChange={(e) => { setSortBy(e.target.value); setCurrentPage(1); }} className="w-full px-2 py-1 text-xs border border-gray-300 dark:border-gray-600 rounded focus:outline-none focus:ring-2 focus:ring-blue-500 bg-white dark:bg-gray-700 text-gray-900 dark:text-white">
<option value="creation desc">{t('filters.sortCreationNewest')}</option>
<option value="creation asc">{t('filters.sortCreationOldest')}</option>
<option value="modified desc">{t('filters.sortModifiedNewest')}</option>
<option value="modified asc">{t('filters.sortModifiedOldest')}</option>
<option value="name asc">{t('filters.sortNameAsc')}</option>
<option value="name desc">{t('filters.sortNameDesc')}</option>
</select>
</div>
<div className="relative">
<label className="block text-[10px] font-medium text-gray-700 dark:text-gray-300 mb-0.5">{t('filters.filterBy')}</label>
<select value={dateFilterBy} onChange={(e) => { const v = e.target.value as '' | 'creation' | 'modified'; setDateFilterBy(v); setCurrentPage(1); }} className="w-full px-2 py-1 text-xs border border-gray-300 dark:border-gray-600 rounded focus:outline-none focus:ring-2 focus:ring-blue-500 bg-white dark:bg-gray-700 text-gray-900 dark:text-white">
<option value="">{t('filters.filterBy')}</option>
<option value="creation">{t('filters.createdDate')}</option>
<option value="modified">{t('filters.latestModifiedDate')}</option>
</select>
</div>
{dateFilterBy && (
<>
<div className="relative">
<label className="block text-[10px] font-medium text-gray-700 dark:text-gray-300 mb-0.5">{t('filters.startDate')}</label>
<input type="date" value={dateStart} onChange={(e) => { const v = e.target.value; setDateStart(v); if (dateEnd && v > dateEnd) setDateEnd(v); setCurrentPage(1); }} className="w-full px-2 py-1 text-xs border border-gray-300 dark:border-gray-600 rounded focus:outline-none focus:ring-2 focus:ring-blue-500 bg-white dark:bg-gray-700 text-gray-900 dark:text-white" />
</div>
<div className="relative">
<label className="block text-[10px] font-medium text-gray-700 dark:text-gray-300 mb-0.5">{t('filters.endDate')}</label>
<input type="date" value={dateEnd} onChange={(e) => { setDateEnd(e.target.value); setCurrentPage(1); }} min={dateStart || undefined} className="w-full px-2 py-1 text-xs border border-gray-300 dark:border-gray-600 rounded focus:outline-none focus:ring-2 focus:ring-blue-500 bg-white dark:bg-gray-700 text-gray-900 dark:text-white" />
</div>
</>
)}
<div className="relative z-[60]">
<LinkField
label={t('issues.issueId')}
doctype="Issue"
value={issueIdFilter}
onChange={(val) => { setIssueIdFilter(val); setCurrentPage(1); }}
placeholder={t('linkField.selectLabel', { label: t('issues.issueId') })}
disabled={false}
compact={true}
/>
{issueIdFilter && <button onClick={() => setIssueIdFilter('')} className="absolute right-2 top-6 text-gray-400 hover:text-red-500 transition-colors z-10"><FaTimes size={10} /></button>}
</div>
<div className="relative">
<label className="block text-[10px] font-medium text-gray-700 dark:text-gray-300 mb-0.5">
{t('filters.status')}
</label>
<select
value={statusFilter}
onChange={(e) => { setStatusFilter(e.target.value); setCurrentPage(1); }}
className="w-full px-2 py-1 text-xs border border-gray-300 dark:border-gray-600 rounded focus:outline-none focus:ring-2 focus:ring-blue-500 bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
>
<option value="">{t('filters.allStatuses')}</option>
<option value="Open">{t('issues.status.open')}</option>
<option value="Replied">{t('issues.status.replied')}</option>
<option value="On Hold">{t('issues.status.on_hold')}</option>
<option value="Resolved">{t('issues.status.resolved')}</option>
<option value="Closed">{t('issues.status.closed')}</option>
</select>
</div>
<div className="relative z-[59]">
<LinkField label={t('commonFields.priority')} doctype="Issue Priority" value={priorityFilter} onChange={(val) => { setPriorityFilter(val); setCurrentPage(1); }} placeholder={t('issues.allPriorities')} disabled={false} compact={true} />
{priorityFilter && <button onClick={() => setPriorityFilter('')} className="absolute right-2 top-6 text-gray-400 hover:text-red-500 transition-colors z-10"><FaTimes size={10} /></button>}
</div>
<div className="relative z-[58]">
<LinkField label={t('commonFields.company')} doctype="Company" value={companyFilter} onChange={(val) => { setCompanyFilter(val); setCurrentPage(1); }} placeholder={t('issues.allCompanies')} disabled={false} compact={true} />
{companyFilter && <button onClick={() => setCompanyFilter('')} className="absolute right-2 top-6 text-gray-400 hover:text-red-500 transition-colors z-10"><FaTimes size={10} /></button>}
</div>
</div>
</div>
</div>
</div>
)}
{/* Save Filter Modal */}
{showSaveFilterModal && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-xl max-w-md w-full p-6 animate-scale-in">
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-4">Save Filter Preset</h3>
<input type="text" value={filterPresetName} onChange={(e) => setFilterPresetName(e.target.value)} onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); handleSaveFilterPreset(); } }} placeholder="Enter filter name (e.g., 'Open High Priority')" className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 bg-white dark:bg-gray-700 text-gray-900 dark:text-white mb-4" autoFocus />
<div className="flex gap-2 justify-end">
<button onClick={() => { setShowSaveFilterModal(false); setFilterPresetName(''); }} className="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-md transition-colors">Cancel</button>
<button onClick={handleSaveFilterPreset} className="px-4 py-2 text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 rounded-md transition-colors flex items-center gap-2"><FaSave size={12} />Save Filter</button>
</div>
</div>
</div>
)}
{/* Export Modal */}
<ExportModal isOpen={showExportModal} onClose={() => setShowExportModal(false)} selectedCount={selectedRows.size} totalCount={totalCount} pageCount={issues.length} onExport={handleExport} isExporting={isExporting} exportColumns={EXPORT_COLUMNS} />
{/* Table */}
<div className="bg-white dark:bg-gray-800 rounded-lg shadow border border-gray-200 dark:border-gray-700 overflow-hidden relative">
{loading && initialLoadComplete && (
<div className="absolute inset-0 bg-white/60 dark:bg-gray-800/60 flex items-center justify-center z-10 backdrop-blur-[1px]">
<div className="flex items-center gap-3 bg-white dark:bg-gray-700 px-4 py-2 rounded-lg shadow-lg">
<div className="animate-spin rounded-full h-5 w-5 border-b-2 border-blue-500"></div>
<span className="text-sm text-gray-600 dark:text-gray-300">
{t('common.filtering')}
</span>
</div>
</div>
)}
<div className="overflow-x-auto">
<table className="w-full">
<thead className="bg-gray-100 dark:bg-gray-700 border-b border-gray-200 dark:border-gray-600">
<tr>
<th className="px-4 py-3 text-left">
<button onClick={handleSelectAll} className="text-gray-500 dark:text-gray-400 hover:text-blue-600 dark:hover:text-blue-400 transition-colors" title={isAllSelected ? t('listPages.deselectAllTitle') : t('listPages.selectAllTitle')}>
{isAllSelected ? <FaCheckSquare className="text-blue-600 dark:text-blue-400" size={18} /> : isSomeSelected ? <div className="relative"><FaSquare size={18} /><div className="absolute inset-0 flex items-center justify-center"><div className="w-2 h-0.5 bg-current"></div></div></div> : <FaSquare size={18} />}
</button>
</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">{t('issues.issueId')}</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">{t('issues.subject')}</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">{t('commonFields.status')}</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">{t('commonFields.priority')}</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">{t('commonFields.company')}</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">{t('issues.openingDate')}</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">{t('listPages.actions')}</th>
</tr>
</thead>
<tbody className="bg-white dark:bg-gray-800 divide-y divide-gray-200 dark:divide-gray-700">
{issues.length === 0 ? (
<tr>
<td colSpan={8} className="px-4 py-12 text-center text-gray-500 dark:text-gray-400">
<div className="flex flex-col items-center">
<FaHeadset className="text-4xl text-gray-300 dark:text-gray-600 mb-2" />
<p>{t('issues.noIssuesFound')}</p>
{hasActiveFilters ? (
<button
onClick={clearFilters}
className="mt-4 text-blue-600 dark:text-blue-400 hover:underline"
>
{t('common.clearFilters')}
</button>
) : (
<button
onClick={() => navigate('/support/new')}
className="mt-4 text-blue-600 dark:text-blue-400 hover:underline"
>
{t('issues.createFirstIssue')}
</button>
)}
</div>
</td>
</tr>
) : issues.map((issue) => (
<tr key={issue.name} className={`hover:bg-gray-50 dark:hover:bg-gray-700/50 cursor-pointer transition-colors ${selectedRows.has(issue.name) ? 'bg-blue-50 dark:bg-blue-900/20' : ''}`} onClick={() => navigate(`/support/${issue.name}`)}>
<td className="px-4 py-3" onClick={(e) => e.stopPropagation()}>
<button onClick={() => handleSelectRow(issue.name)} className="text-gray-500 dark:text-gray-400 hover:text-blue-600 dark:hover:text-blue-400 transition-colors">
{selectedRows.has(issue.name) ? <FaCheckSquare className="text-blue-600 dark:text-blue-400" size={18} /> : <FaSquare size={18} />}
</button>
</td>
<td className="px-4 py-3"><span className="text-sm font-medium text-blue-600 dark:text-blue-400">{issue.name}</span></td>
<td className="px-4 py-3"><span className="text-sm text-gray-900 dark:text-white line-clamp-1">{issue.subject || '-'}</span></td>
<td className="px-4 py-3">
<span className={`inline-flex px-2 py-1 text-xs font-medium rounded-full ${getStatusStyle(issue.status)}`}>
{issue.status
? t(`issues.status.${(issue.status as string).toLowerCase().replace(/\\s+/g, '_')}`, issue.status)
: '-'}
</span>
</td>
<td className="px-4 py-3">
{issue.priority ? (
<span className={`inline-flex px-2 py-1 text-xs font-medium rounded-full ${getPriorityStyle(issue.priority)}`}>
{t(`issues.priority.${(issue.priority as string).toLowerCase()}`, issue.priority)}
</span>
) : (
<span className="text-gray-400">-</span>
)}
</td>
<td className="px-4 py-3"><span className="text-sm text-gray-600 dark:text-gray-300 line-clamp-1">{issue.company || '-'}</span></td>
<td className="px-4 py-3"><span className="text-sm text-gray-600 dark:text-gray-300">{formatDate(issue.opening_date)}</span></td>
<td className="px-4 py-3">
<div className="flex items-center gap-1" onClick={(e) => e.stopPropagation()}>
<button onClick={() => navigate(`/support/${issue.name}`)} className="text-blue-600 dark:text-blue-400 hover:text-blue-900 dark:hover:text-blue-300 p-2 hover:bg-blue-50 dark:hover:bg-blue-900/30 rounded transition-colors" title={t('issues.viewDetails')}><FaEye /></button>
<button onClick={() => navigate(`/support/${issue.name}`)} className="text-green-600 dark:text-green-400 hover:text-green-900 dark:hover:text-green-300 p-2 hover:bg-green-50 dark:hover:bg-green-900/30 rounded transition-colors" title={t('issues.editIssue')}><FaEdit /></button>
<button onClick={() => setDeleteConfirmOpen(issue.name)} className="text-red-600 dark:text-red-400 hover:text-red-900 dark:hover:text-red-300 p-2 hover:bg-red-50 dark:hover:bg-red-900/30 rounded transition-colors" title={t('issues.deleteIssue')}><FaTrash /></button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
<ListPagination
currentPage={currentPage}
totalCount={totalCount}
pageSize={pageSize}
itemLabel={t('pagination.issues')}
onPageChange={(p) => setCurrentPage(p)}
/>
</div>
{/* Delete Confirmation Modal */}
{deleteConfirmOpen && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white dark:bg-gray-800 rounded-lg p-6 max-w-md w-full mx-4 shadow-2xl">
<div className="flex items-start gap-4">
<div className="flex-shrink-0 w-12 h-12 rounded-full bg-red-100 dark:bg-red-900/30 flex items-center justify-center"><FaTrash className="text-red-600 dark:text-red-400 text-xl" /></div>
<div className="flex-1">
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-2">{t('issues.deleteIssue')}</h3>
<p className="text-sm text-gray-600 dark:text-gray-400 mb-4">{t('issues.deleteConfirmMessage')}</p>
<div className="bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-md p-3 mb-4"><p className="text-xs text-yellow-800 dark:text-yellow-300"><strong>{t('issues.issueId')}:</strong> {deleteConfirmOpen}</p></div>
<div className="flex gap-3 justify-end">
<button onClick={() => setDeleteConfirmOpen(null)} className="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 rounded-lg transition-colors">{t('common.cancel')}</button>
<button onClick={() => handleDelete(deleteConfirmOpen)} className="px-4 py-2 text-sm font-medium text-white bg-red-600 hover:bg-red-700 rounded-lg transition-colors flex items-center gap-2"><FaTrash />{t('issues.deleteIssue')}</button>
</div>
</div>
</div>
</div>
</div>
)}
<style>{`
@keyframes scale-in { from { transform: scale(0.95); opacity: 0; } to { transform: scale(1); opacity: 1; } }
.animate-scale-in { animation: scale-in 0.2s ease-out; }
.scrollbar-hide { -ms-overflow-style: none; scrollbar-width: none; }
.scrollbar-hide::-webkit-scrollbar { display: none; }
`}</style>
</div>
);
};
export default IssueList;

View File

@ -0,0 +1,662 @@
import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react';
import { useParams, useNavigate, useSearchParams, useLocation } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { useItemDetails, useItemMutations } from '../hooks/useItem';
import { FaArrowLeft, FaSave, FaEdit, FaCheck, FaTrashAlt, FaSync } from 'react-icons/fa';
import type { CreateItemData } from '../services/itemService';
import LinkField from '../components/LinkField';
import API_CONFIG from '../config/api';
import CommentSection from '../components/CommentSection';
import ActivityLog from '../components/ActivityLog';
import DeleteRequestButton from '../components/DeleteRequestButton';
import type { DeleteStatus } from '../services/deleteRequestService';
import apiService from '../services/apiService';
const ItemDetail: React.FC = () => {
const { t } = useTranslation();
// const { itemName } = useParams<{ itemName: string }>();
// const navigate = useNavigate();
// const [searchParams] = useSearchParams();
// const duplicateFromItem = searchParams.get('duplicate');
// const isNewItem = itemName === 'new';
// const isDuplicating = isNewItem && !!duplicateFromItem;
const { itemName: rawItemName } = useParams<{ itemName: string }>();
const navigate = useNavigate();
const location = useLocation();
const [searchParams] = useSearchParams();
const duplicateFromItem = searchParams.get('duplicate');
// Extract item name from pathname directly to preserve # characters
// which browsers strip from useParams as URL fragments
// const itemName = useMemo(() => {
// const prefix = '/inventory/';
// const idx = location.pathname.indexOf(prefix);
// if (idx !== -1) {
// const encoded = location.pathname.slice(idx + prefix.length);
// const decoded = decodeURIComponent(encoded);
// return decoded;
// }
// return rawItemName || '';
// }, [location.pathname, rawItemName]);
const itemName = useMemo(() => {
if (rawItemName === 'new') return 'new';
// Use the raw encoded pathname and decode it ourselves
// to avoid React Router's automatic decoding losing # info
const prefix = '/inventory/';
const fullPath = window.location.pathname; // e.g. /asm_app/inventory/DELUGE%20VALVE%20%20NO%23%201
const idx = fullPath.indexOf(prefix);
if (idx !== -1) {
const encoded = fullPath.slice(idx + prefix.length);
try {
return decodeURIComponent(encoded);
} catch {
return encoded;
}
}
return rawItemName || '';
}, [rawItemName, location.pathname]);
const isNewItem = itemName === 'new';
const isDuplicating = isNewItem && !!duplicateFromItem;
// Balance Qty state (fetched from Bin doctype)
const [balanceQty, setBalanceQty] = useState<number>(0);
const [balanceQtyLoading, setBalanceQtyLoading] = useState<boolean>(false);
const [userRoles, setUserRoles] = useState<string[]>([]);
const [isSystemManager, setIsSystemManager] = useState(false);
const [rolesLoaded, setRolesLoaded] = useState(false);
useEffect(() => {
const fetchRoles = async () => {
try {
const response = await apiService.apiCall<any>(
'/api/method/asset_lite.api.user_roles.get_user_roles'
);
const roles = Array.isArray(response) ? response : (response?.message || []);
setUserRoles(roles);
setIsSystemManager(roles.includes('System Manager'));
} catch (err) {
console.error('Error fetching roles:', err);
} finally {
setRolesLoaded(true);
}
};
fetchRoles();
}, []);
// Form data state
const [formData, setFormData] = useState<CreateItemData>({
item_code: '',
item_name: '',
item_group: '',
custom_technical_department: '',
custom_hospital_name: '',
custom_part_description: '',
stock_uom: 'Nos',
custom_item_cost_per_unit: 0,
disabled: 0,
is_stock_item: 1,
is_fixed_asset: 0,
opening_stock: 0,
valuation_rate: 0,
standard_rate: 0,
custom_last_calibration_date: '',
custom_next_due_calibration_date: '',
description: '',
brand: '',
custom_warranty_in_months: '',
valuation_method: '',
has_batch_no: 0,
has_serial_no: 0,
custom_serial_no: '',
custom_date_in: '',
custom_code: '',
custom_type: '',
custom_volts: undefined as number | undefined,
custom_w: undefined as number | undefined,
is_purchase_item: 1,
is_sales_item: 1,
country_of_origin: 'Saudi Arabia',
});
const { item, loading, error, refetch: refetchItem } = useItemDetails(
isDuplicating ? duplicateFromItem : (isNewItem ? null : itemName || null)
);
const { createItem, updateItem, submitItem, loading: saving } = useItemMutations();
const [isEditing, setIsEditing] = useState(isNewItem);
// Check document status
const docstatus = item?.docstatus ?? 0;
const isSubmitted = docstatus === 1;
const isCancelled = docstatus === 2;
const isDraft = docstatus === 0;
const hasDeleteRequest = !!(item?.custom_delete_status);
// Check if Calibration Information should be shown
const showCalibrationInfo = formData.item_group === 'Tools';
// Fetch Balance Qty from Bin doctype
const fetchBalanceQty = useCallback(async (itemCode: string) => {
if (!itemCode) return;
setBalanceQtyLoading(true);
try {
// Get CSRF token
let csrfToken: string | null = null;
if (typeof window !== 'undefined' && (window as any).csrf_token) {
csrfToken = (window as any).csrf_token;
}
// Build filters and fields for Frappe API
const filters = JSON.stringify([['item_code', '=', itemCode]]);
const fields = JSON.stringify(['actual_qty', 'warehouse']);
const url = `${API_CONFIG.BASE_URL}/api/resource/Bin?filters=${encodeURIComponent(filters)}&fields=${encodeURIComponent(fields)}&limit_page_length=0`;
const headers: Record<string, string> = {
'Accept': 'application/json',
'Content-Type': 'application/json',
};
if (csrfToken) {
headers['X-Frappe-CSRF-Token'] = csrfToken;
}
const response = await fetch(url, {
method: 'GET',
headers,
credentials: 'include', // Include cookies for session auth
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const result = await response.json();
// Sum up actual_qty from all warehouses
const totalQty = result.data?.reduce((sum: number, bin: any) => {
return sum + (bin.actual_qty || 0);
}, 0) || 0;
setBalanceQty(totalQty);
} catch (err) {
console.error('Failed to fetch balance qty:', err);
setBalanceQty(0);
} finally {
setBalanceQtyLoading(false);
}
}, []);
// Fetch balance qty when item is loaded (for existing items)
useEffect(() => {
if (!isNewItem && item?.item_code) {
fetchBalanceQty(item.item_code);
}
}, [isNewItem, item?.item_code, fetchBalanceQty]);
// Load item data when item is fetched
useEffect(() => {
if (item && !isDuplicating) {
setFormData({
item_code: item.item_code || '',
item_name: item.item_name || '',
item_group: item.item_group || '',
custom_technical_department: item.custom_technical_department || '',
custom_hospital_name: item.custom_hospital_name || '',
custom_part_description: item.custom_part_description || '',
stock_uom: item.stock_uom || 'Nos',
custom_item_cost_per_unit: item.custom_item_cost_per_unit || 0,
disabled: item.disabled || 0,
is_stock_item: item.is_stock_item ?? 1,
is_fixed_asset: item.is_fixed_asset ?? 0,
opening_stock: item.opening_stock || 0,
valuation_rate: item.valuation_rate ?? 0,
standard_rate: item.standard_rate || 0,
custom_last_calibration_date: item.custom_last_calibration_date || '',
custom_next_due_calibration_date: item.custom_next_due_calibration_date || '',
description: item.description || '',
brand: item.brand || '',
custom_warranty_in_months: item.custom_warranty_in_months || '',
valuation_method: item.valuation_method || '',
has_batch_no: item.has_batch_no || 0,
has_serial_no: item.has_serial_no || 0,
is_purchase_item: item.is_purchase_item ?? 1,
is_sales_item: item.is_sales_item ?? 1,
country_of_origin: item.country_of_origin || 'Saudi Arabia',
uoms: item.uoms || [],
item_defaults: item.item_defaults || [],
custom_serial_no: item.custom_serial_no || '',
custom_date_in: item.custom_date_in || '',
custom_code: item.custom_code || '',
custom_type: item.custom_type || '',
custom_volts: item.custom_volts,
custom_w: item.custom_w,
});
setIsEditing(false);
} else if (isDuplicating && item) {
// When duplicating, copy data but clear name/code
setFormData({
item_code: '',
item_name: item.item_name || '',
item_group: item.item_group || '',
custom_technical_department: item.custom_technical_department || '',
custom_hospital_name: item.custom_hospital_name || '',
custom_part_description: item.custom_part_description || '',
stock_uom: item.stock_uom || 'Nos',
custom_item_cost_per_unit: item.custom_item_cost_per_unit || 0,
disabled: 0,
is_stock_item: item.is_stock_item ?? 1,
is_fixed_asset: item.is_fixed_asset ?? 0,
opening_stock: item.opening_stock || 0,
valuation_rate: item.valuation_rate ?? 0,
standard_rate: item.standard_rate || 0,
custom_last_calibration_date: item.custom_last_calibration_date || '',
custom_next_due_calibration_date: item.custom_next_due_calibration_date || '',
description: item.description || '',
brand: item.brand || '',
custom_warranty_in_months: item.custom_warranty_in_months || '',
valuation_method: item.valuation_method || '',
has_batch_no: item.has_batch_no || 0,
has_serial_no: item.has_serial_no || 0,
is_purchase_item: item.is_purchase_item ?? 1,
is_sales_item: item.is_sales_item ?? 1,
country_of_origin: item.country_of_origin || 'Saudi Arabia',
uoms: item.uoms || [],
item_defaults: item.item_defaults || [],
custom_serial_no: item.custom_serial_no || '',
custom_date_in: item.custom_date_in || '',
custom_code: item.custom_code || '',
custom_type: item.custom_type || '',
custom_volts: item.custom_volts,
custom_w: item.custom_w,
});
}
}, [item, isDuplicating]);
const handleSave = async () => {
try {
if (isNewItem) {
const newItem = await createItem(formData);
navigate(`/inventory/${newItem.name}`);
} else {
await updateItem(itemName!, formData);
await refetchItem();
// Refresh balance qty after update
if (formData.item_code) {
fetchBalanceQty(formData.item_code);
}
setIsEditing(false);
alert(t('items.itemUpdatedSuccessfully'));
}
} catch (err) {
alert(`${t('items.failedToSave')}: ${err instanceof Error ? err.message : 'Unknown error'}`);
}
};
const handleSubmit = async () => {
if (!itemName || isNewItem) {
alert(t('items.pleaseSaveFirst'));
return;
}
try {
await submitItem(itemName);
await refetchItem();
setIsEditing(false);
alert(t('items.submittedSuccessfully'));
} catch (err) {
alert(`${t('items.failedToSubmit')}: ${err instanceof Error ? err.message : 'Unknown error'}`);
}
};
const isFieldDisabled = useCallback((fieldname: string): boolean => {
if (!isEditing) return true;
if (isCancelled) return true;
if (hasDeleteRequest) return true;
if (isSubmitted) {
// For submitted items, most fields are read-only
// Only allow editing certain fields if needed
return true;
}
return false;
}, [isEditing, isCancelled, isSubmitted, hasDeleteRequest]);
if (loading) {
return (
<div className="flex items-center justify-center h-screen bg-gray-50 dark:bg-gray-900">
<div className="text-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-500 mx-auto"></div>
<p className="mt-4 text-gray-600 dark:text-gray-400">{t('items.loadingItem')}</p>
</div>
</div>
);
}
if (error && !isNewItem) {
return (
<div className="p-6 bg-gray-50 dark:bg-gray-900 min-h-screen">
<div className="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-6">
<h2 className="text-xl font-bold text-red-800 dark:text-red-300 mb-4">{t('items.errorLoadingItem')}</h2>
<p className="text-red-700 dark:text-red-400 mb-4">{error}</p>
<button
onClick={() => navigate(-1)}
className="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded"
>
{t('items.backToInventory')}
</button>
</div>
</div>
);
}
const inputClassName = "w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:bg-gray-100 dark:disabled:bg-gray-700 bg-white dark:bg-gray-700 text-gray-900 dark:text-white";
const labelClassName = "block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1";
const sectionHeaderClassName = "text-base font-semibold text-gray-800 dark:text-white mb-4 pb-2 border-b border-gray-200 dark:border-gray-700";
const cardClassName = "bg-white dark:bg-gray-800 rounded-xl shadow-md p-6 border border-gray-200 dark:border-gray-700";
return (
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 p-6">
{/* Header */}
<div className="mb-6 flex justify-between items-center">
<div className="flex items-center gap-4">
<button
onClick={() => navigate(-1)}
className="text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300 flex items-center gap-2"
>
<FaArrowLeft />
<span className="text-gray-900 dark:text-white font-medium">
{isNewItem ? t('items.newItem') : item?.item_name || item?.item_code || t('items.title')}
</span>
</button>
{!isNewItem && (
<span className="px-3 py-1 rounded-full text-xs font-medium bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300">
{item?.item_code || itemName}
</span>
)}
</div>
<div className="flex gap-3">
{!isNewItem && !isEditing && isDraft && !hasDeleteRequest && (
<button
onClick={() => setIsEditing(true)}
className="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-lg flex items-center gap-2"
>
<FaEdit />
{t('common.edit')}
</button>
)}
{/* {!isNewItem && !isEditing && rolesLoaded && (
<DeleteRequestButton
doctype="Item"
docname={itemName}
currentDeleteStatus={(item?.custom_delete_status ?? null) as DeleteStatus}
userRoles={userRoles}
isSystemManager={isSystemManager}
inline
redirectOnDelete="/inventory"
onStatusChange={() => refetchItem()}
/>
)} */}
{isEditing && (
<>
<button
type="button"
onClick={() => {
if (isNewItem) navigate(-1);
else { setIsEditing(false); refetchItem(); }
}}
className="bg-gray-600 hover:bg-gray-700 text-white px-4 py-2 rounded-lg"
>
{t('common.cancel')}
</button>
<button
type="button"
onClick={handleSave}
disabled={saving}
className="bg-green-600 hover:bg-green-700 text-white px-4 py-2 rounded-lg flex items-center gap-2 disabled:opacity-50"
>
<FaSave />
{saving ? t('common.saving') : t('common.save')}
</button>
{/* {!isNewItem && isDraft && (
<button
onClick={handleSubmit}
disabled={saving}
className="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-lg flex items-center gap-2 disabled:opacity-50"
>
<FaCheck />
{t('common.submit')}
</button>
)} */}
</>
)}
</div>
</div>
{/* Form - Grid Layout matching AssetDetail */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{/* COLUMN 1: Basic Information */}
<div className={cardClassName}>
<h2 className={sectionHeaderClassName}>{t('items.basicInformation')}</h2>
<div className="space-y-4">
<div>
<label className={labelClassName}>{t('items.itemCode')} <span className="text-red-500">*</span></label>
<input
type="text"
value={formData.item_code}
onChange={(e) => setFormData({ ...formData, item_code: e.target.value })}
disabled={isFieldDisabled('item_code') || !isNewItem}
className={inputClassName}
required
/>
</div>
<LinkField
label={t('commonFields.hospital')}
doctype="Company"
value={formData.custom_hospital_name || ''}
onChange={(value) => setFormData({ ...formData, custom_hospital_name: value })}
disabled={isFieldDisabled('custom_hospital_name')}
placeholder={t('items.selectHospital')}
filters={{ domain: 'Healthcare' }}
/>
<LinkField
label={t('items.itemGroup')}
doctype="Item Group"
value={formData.item_group || ''}
onChange={(value) => setFormData({ ...formData, item_group: value })}
disabled={isFieldDisabled('item_group')}
placeholder={t('items.selectItemGroup')}
/>
<LinkField
label={t('items.technicalDepartment')}
doctype="Issue Type"
value={formData.custom_technical_department || ''}
onChange={(value) => setFormData({ ...formData, custom_technical_department: value })}
disabled={isFieldDisabled('custom_technical_department')}
placeholder={t('items.selectTechnicalDepartment')}
/>
<div>
<label className={labelClassName}>{t('items.stockUOM')}</label>
<input
type="text"
value={formData.stock_uom}
onChange={(e) => setFormData({ ...formData, stock_uom: e.target.value })}
disabled={isFieldDisabled('stock_uom')}
className={inputClassName}
/>
</div>
<div>
<label className={labelClassName}>{t('items.partDescription')}</label>
<input
type="text"
value={formData.custom_part_description}
onChange={(e) => setFormData({ ...formData, custom_part_description: e.target.value })}
disabled={isFieldDisabled('custom_part_description')}
className={inputClassName}
/>
</div>
</div>
</div>
{/* COLUMN 2: Inventory Details */}
<div className={cardClassName}>
<h2 className={sectionHeaderClassName}>{t('items.inventoryDetails')}</h2>
<div className="space-y-4">
<div>
<label className={labelClassName}>{t('items.serialNo')}</label>
<input type="text" value={formData.custom_serial_no} onChange={(e) => setFormData({ ...formData, custom_serial_no: e.target.value })} disabled={isFieldDisabled('custom_serial_no')} className={inputClassName} />
</div>
<div>
<label className={labelClassName}>{t('items.dateIn')}</label>
<input type="date" value={formData.custom_date_in} onChange={(e) => setFormData({ ...formData, custom_date_in: e.target.value })} disabled={isFieldDisabled('custom_date_in')} className={inputClassName} />
</div>
<div>
<label className={labelClassName}>{t('items.watts')}</label>
<input type="number" step="0.01" value={formData.custom_w ?? ''} onChange={(e) => { const v = parseFloat(e.target.value); setFormData({ ...formData, custom_w: e.target.value === '' || isNaN(v) ? undefined : v }); }} disabled={isFieldDisabled('custom_w')} className={inputClassName} />
</div>
<div>
<label className={labelClassName}>{t('items.volts')}</label>
<input type="number" step="0.01" value={formData.custom_volts ?? ''} onChange={(e) => { const v = parseFloat(e.target.value); setFormData({ ...formData, custom_volts: e.target.value === '' || isNaN(v) ? undefined : v }); }} disabled={isFieldDisabled('custom_volts')} className={inputClassName} />
</div>
<div>
<label className={labelClassName}>{t('items.type')}</label>
<input type="text" value={formData.custom_type} onChange={(e) => setFormData({ ...formData, custom_type: e.target.value })} disabled={isFieldDisabled('custom_type')} className={inputClassName} />
</div>
<div>
<label className={labelClassName}>{t('items.code')}</label>
<input type="text" value={formData.custom_code} onChange={(e) => setFormData({ ...formData, custom_code: e.target.value })} disabled={isFieldDisabled('custom_code')} className={inputClassName} />
</div>
</div>
</div>
{/* COLUMN 3: Stock & Additional Information */}
<div className={cardClassName}>
<h2 className={sectionHeaderClassName}>{t('items.stockInformation')}</h2>
<div className="space-y-4">
<div className="flex items-center gap-2">
<input type="checkbox" id="is_stock_item" checked={formData.is_stock_item === 1} onChange={(e) => setFormData({ ...formData, is_stock_item: e.target.checked ? 1 : 0 })} disabled={isFieldDisabled('is_stock_item')} className="w-4 h-4" />
<label htmlFor="is_stock_item" className="text-sm font-medium text-gray-700 dark:text-gray-300">{t('items.isStockItem')}</label>
</div>
<div className="flex items-center gap-2">
<input type="checkbox" id="is_fixed_asset" checked={formData.is_fixed_asset === 1} onChange={(e) => setFormData({ ...formData, is_fixed_asset: e.target.checked ? 1 : 0 })} disabled={isFieldDisabled('is_fixed_asset')} className="w-4 h-4" />
<label htmlFor="is_fixed_asset" className="text-sm font-medium text-gray-700 dark:text-gray-300">{t('items.isFixedAsset')}</label>
</div>
{isNewItem && formData.is_stock_item === 1 && (
<div>
<label className={labelClassName}>{t('items.openingStock')}</label>
<input type="number" value={formData.opening_stock} onChange={(e) => setFormData({ ...formData, opening_stock: parseFloat(e.target.value) || 0 })} disabled={isFieldDisabled('opening_stock')} className={inputClassName} />
</div>
)}
{formData.is_stock_item === 1 && (
<div>
<label className={labelClassName}>{t('items.valuationRate')}</label>
<input type="number" step="0.01" value={formData.valuation_rate} onChange={(e) => setFormData({ ...formData, valuation_rate: parseFloat(e.target.value) || 0 })} disabled={isFieldDisabled('valuation_rate')} className={inputClassName} />
</div>
)}
{!isNewItem && formData.is_stock_item === 1 && (
<div>
<label className={labelClassName}>{t('items.balanceQty')}</label>
<div className="flex items-center gap-2">
<input type="number" value={balanceQty} readOnly className={`${inputClassName} bg-gray-100 dark:bg-gray-800 cursor-not-allowed`} />
<button type="button" onClick={() => formData.item_code && fetchBalanceQty(formData.item_code)} disabled={balanceQtyLoading} className="p-2 text-blue-600 hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300 disabled:opacity-50" title={t('items.refreshBalanceQty')}>
<FaSync className={balanceQtyLoading ? 'animate-spin' : ''} />
</button>
</div>
</div>
)}
</div>
{/* Calibration - when Item Group is Tools */}
{showCalibrationInfo && (
<>
<h2 className={`${sectionHeaderClassName} mt-6`}>{t('items.calibrationInformation')}</h2>
<div className="space-y-4 mt-4">
<div>
<label className={labelClassName}>{t('items.lastCalibrationDate')}</label>
<input type="date" value={formData.custom_last_calibration_date} onChange={(e) => setFormData({ ...formData, custom_last_calibration_date: e.target.value })} disabled={isFieldDisabled('custom_last_calibration_date')} className={inputClassName} />
</div>
<div>
<label className={labelClassName}>{t('items.nextCalibrationDate')}</label>
<input type="date" value={formData.custom_next_due_calibration_date} onChange={(e) => setFormData({ ...formData, custom_next_due_calibration_date: e.target.value })} disabled={isFieldDisabled('custom_next_due_calibration_date')} className={inputClassName} />
</div>
</div>
</>
)}
<h2 className={`${sectionHeaderClassName} mt-6`}>{t('items.additionalInformation')}</h2>
<div className="space-y-4 mt-4">
<div>
<label className={labelClassName}>{t('commonFields.description')}</label>
<textarea value={formData.description} onChange={(e) => setFormData({ ...formData, description: e.target.value })} disabled={isFieldDisabled('description')} rows={3} className={inputClassName} />
</div>
<div>
<label className={labelClassName}>{t('items.warrantyMonths')}</label>
<input type="text" value={formData.custom_warranty_in_months} onChange={(e) => setFormData({ ...formData, custom_warranty_in_months: e.target.value })} disabled={isFieldDisabled('custom_warranty_in_months')} className={inputClassName} />
</div>
</div>
</div> {/* ← closes grid */}
{/* Comments Section */}
{!isNewItem && (
<div className="mt-6">
<CommentSection
referenceDoctype="Item"
referenceName={itemName || null}
title="Comments & Discussion"
pollInterval={30000}
initialLimit={5}
collapsible={true}
startCollapsed={false}
/>
</div>
)}
{/* Activity Log */}
{!isNewItem && !isDuplicating && (
<div className="mt-6">
<ActivityLog
doctype="Item"
docname={itemName || null}
creationDate={item?.creation}
createdBy={item?.owner}
compact={false}
initialVisible={5}
collapsible={true}
startCollapsed={true}
/>
</div>
)}
{/* Delete Request */}
{!isNewItem && rolesLoaded && (
<div className="mt-6 max-w-sm">
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-md p-6 border border-gray-200 dark:border-gray-700">
<h2 className="text-base font-semibold text-gray-800 dark:text-white mb-4 pb-2 border-b border-gray-200 dark:border-gray-700">
Delete Request
</h2>
<DeleteRequestButton
doctype="Item"
docname={itemName}
currentDeleteStatus={(item?.custom_delete_status ?? null) as DeleteStatus}
userRoles={userRoles}
isSystemManager={isSystemManager}
redirectOnDelete="/inventory"
onStatusChange={() => refetchItem()}
/>
</div>
</div>
)}
</div> {/* ← closes min-h-screen wrapper */}
</div>
);
};
export default ItemDetail;

File diff suppressed because it is too large Load Diff

View File

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

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

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

View File

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

View File

@ -0,0 +1,637 @@
import React, { useState, useEffect, useCallback } from 'react';
import { useParams, useNavigate, useSearchParams } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { useMaintenanceTeamDetails, useMaintenanceTeamMutations } from '../hooks/useMaintenanceTeam';
import {
FaArrowLeft,
FaSave,
FaEdit,
FaTrash,
FaCheckCircle,
FaTimesCircle,
FaExclamationTriangle,
FaUsers,
FaUserTie,
FaBuilding,
FaPlus,
FaUserPlus,
FaTimes
} from 'react-icons/fa';
import { toast, ToastContainer, Bounce } from 'react-toastify';
import 'react-toastify/dist/ReactToastify.css';
import LinkField from '../components/LinkField';
import type { CreateMaintenanceTeamData, MaintenanceTeamMember } from '../services/maintenanceTeamService';
import CommentSection from '../components/CommentSection';
const MaintenanceTeamDetail: React.FC = () => {
const { t } = useTranslation();
const { teamName } = useParams<{ teamName: string }>();
const [searchParams] = useSearchParams();
const navigate = useNavigate();
const isNewTeam = teamName === 'new';
const duplicateFrom = searchParams.get('duplicate');
const [formData, setFormData] = useState<CreateMaintenanceTeamData>({
maintenance_team_name: '',
maintenance_manager: '',
maintenance_manager_name: '',
company: '',
custom_expertise: '',
maintenance_team_members: [],
});
const { team, loading, error, refetch } = useMaintenanceTeamDetails(
isNewTeam ? (duplicateFrom || null) : (teamName || null)
);
const { createTeam, updateTeam, deleteTeam, getUserFullName, loading: saving } = useMaintenanceTeamMutations();
const [isEditing, setIsEditing] = useState(isNewTeam);
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const [showDeleteMemberConfirm, setShowDeleteMemberConfirm] = useState<number | null>(null);
const [checkingMember, setCheckingMember] = useState<number | null>(null); // Track which row is being checked
// Check if a team member exists in other teams
const checkMemberInOtherTeams = async (memberEmail: string): Promise<{ exists: boolean; teamName?: string }> => {
if (!memberEmail) return { exists: false };
try {
// Method 1: Try querying via run_doc_method or SQL
const response = await fetch('/api/method/frappe.client.get_list', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
},
credentials: 'include',
body: JSON.stringify({
doctype: 'Asset Maintenance Team',
filters: {},
fields: ['name', 'maintenance_team_name', 'maintenance_team_members.team_member'],
limit_page_length: 0, // Get all
})
});
if (!response.ok) {
// Fallback: Query all teams and check manually
return await checkMemberInOtherTeamsFallback(memberEmail);
}
const data = await response.json();
const results = data.message || [];
// Check each team for the member
for (const teamData of results) {
// Skip current team
if (teamData.name === teamName || teamData.name === team?.name) continue;
if (teamData['maintenance_team_members.team_member'] === memberEmail) {
return { exists: true, teamName: teamData.maintenance_team_name || teamData.name };
}
}
// If the above doesn't work, use fallback
return await checkMemberInOtherTeamsFallback(memberEmail);
} catch (error) {
console.error('Error checking member in other teams:', error);
// Try fallback method
return await checkMemberInOtherTeamsFallback(memberEmail);
}
};
// Fallback method: Fetch all teams with their members
const checkMemberInOtherTeamsFallback = async (memberEmail: string): Promise<{ exists: boolean; teamName?: string }> => {
try {
// Get list of all maintenance teams
const listResponse = await fetch('/api/resource/Asset Maintenance Team?fields=["name","maintenance_team_name"]&limit_page_length=0', {
method: 'GET',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
});
if (!listResponse.ok) return { exists: false };
const listData = await listResponse.json();
const teams = listData.data || [];
// Check each team's members
for (const teamInfo of teams) {
// Skip current team
if (teamInfo.name === teamName || teamInfo.name === team?.name) continue;
// Fetch full team details including members
const teamResponse = await fetch(`/api/resource/Asset Maintenance Team/${encodeURIComponent(teamInfo.name)}`, {
method: 'GET',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
});
if (!teamResponse.ok) continue;
const teamData = await teamResponse.json();
const members = teamData.data?.maintenance_team_members || [];
// Check if member exists in this team
const memberExists = members.some((m: any) => m.team_member === memberEmail);
if (memberExists) {
return {
exists: true,
teamName: teamData.data?.maintenance_team_name || teamInfo.name
};
}
}
return { exists: false };
} catch (error) {
console.error('Fallback check failed:', error);
return { exists: false };
}
};
// Load team data when fetched
useEffect(() => {
if (team) {
setFormData({
maintenance_team_name: isNewTeam && duplicateFrom ? `${team.maintenance_team_name} (Copy)` : team.maintenance_team_name || '',
maintenance_manager: team.maintenance_manager || '',
maintenance_manager_name: team.maintenance_manager_name || '',
company: team.company || '',
custom_expertise: team.custom_expertise || '',
maintenance_team_members: team.maintenance_team_members?.map((m, idx) => ({
...m,
idx: idx + 1,
name: isNewTeam ? undefined : m.name, // Clear name for duplicates
})) || [],
});
if (!isNewTeam) setIsEditing(false);
}
}, [team, isNewTeam, duplicateFrom]);
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement>) => {
const { name, value } = e.target;
setFormData(prev => ({ ...prev, [name]: value }));
};
// Handle manager change and fetch full name
const handleManagerChange = async (email: string) => {
setFormData(prev => ({ ...prev, maintenance_manager: email }));
if (email) {
const fullName = await getUserFullName(email);
setFormData(prev => ({ ...prev, maintenance_manager_name: fullName }));
} else {
setFormData(prev => ({ ...prev, maintenance_manager_name: '' }));
}
};
// Handle team member change
const handleMemberChange = async (index: number, field: string, value: string) => {
// If team_member changed, fetch full name and check for duplicates
if (field === 'team_member' && value) {
// Check if member is already in current team (other rows)
const existsInCurrentTeam = formData.maintenance_team_members?.some(
(m, i) => i !== index && m.team_member === value
);
if (existsInCurrentTeam) {
toast.error('This member is already added to this team!', {
position: "top-right",
autoClose: 4000,
icon: <FaTimesCircle />,
});
return; // Don't update if already in current team
}
// Show checking state
setCheckingMember(index);
toast.info('Checking member availability...', {
position: "top-right",
autoClose: 2000,
icon: () => <span>🔍</span>,
});
// Check if member exists in other teams
const { exists, teamName: otherTeamName } = await checkMemberInOtherTeams(value);
setCheckingMember(null);
if (exists) {
toast.error(
<div>
<strong>Cannot add member!</strong>
<br />
<span className="text-sm">This member is already assigned to: <b>{otherTeamName}</b></span>
</div>,
{
position: "top-right",
autoClose: 5000,
icon: <FaTimesCircle />,
}
);
return; // Don't update if already in another team
}
// Fetch full name
const fullName = await getUserFullName(value);
// Update the member data
const updatedMembers = [...(formData.maintenance_team_members || [])];
updatedMembers[index] = {
...updatedMembers[index],
team_member: value,
full_name: fullName
};
setFormData(prev => ({ ...prev, maintenance_team_members: updatedMembers }));
toast.success('Member added successfully!', {
position: "top-right",
autoClose: 2000,
icon: <FaCheckCircle />,
});
} else {
// For other fields (like role), just update directly
const updatedMembers = [...(formData.maintenance_team_members || [])];
updatedMembers[index] = { ...updatedMembers[index], [field]: value };
setFormData(prev => ({ ...prev, maintenance_team_members: updatedMembers }));
}
};
// Add new team member
const handleAddMember = () => {
const newMember: MaintenanceTeamMember = {
team_member: '',
full_name: '',
maintenance_role: '',
idx: (formData.maintenance_team_members?.length || 0) + 1,
};
setFormData(prev => ({
...prev,
maintenance_team_members: [...(prev.maintenance_team_members || []), newMember],
}));
};
// Remove team member
const handleRemoveMember = (index: number) => {
const updatedMembers = formData.maintenance_team_members?.filter((_, i) => i !== index) || [];
// Re-index
updatedMembers.forEach((m, i) => { m.idx = i + 1; });
setFormData(prev => ({ ...prev, maintenance_team_members: updatedMembers }));
setShowDeleteMemberConfirm(null);
};
const handleSave = async () => {
if (!formData.maintenance_team_name) {
toast.error('Please enter a team name', { position: "top-right", autoClose: 4000, icon: <FaTimesCircle /> });
return;
}
try {
// Clean up member data for submission
const cleanedData = {
...formData,
maintenance_team_members: formData.maintenance_team_members?.map(m => ({
team_member: m.team_member,
full_name: m.full_name,
maintenance_role: m.maintenance_role,
})).filter(m => m.team_member), // Only include members with team_member set
};
if (isNewTeam) {
const newTeam = await createTeam(cleanedData);
toast.success('Maintenance Team created successfully!', { position: "top-right", autoClose: 3000, icon: <FaCheckCircle /> });
navigate(`/maintenance-teams/${newTeam.name}`);
} else {
await updateTeam(teamName!, cleanedData);
toast.success('Maintenance Team updated successfully!', { position: "top-right", autoClose: 3000, icon: <FaCheckCircle /> });
setIsEditing(false);
refetch();
}
} catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Unknown error';
toast.error(`Failed to save: ${errorMessage}`, { position: "top-right", autoClose: 6000, icon: <FaTimesCircle /> });
}
};
const handleDelete = async () => {
try {
await deleteTeam(teamName!);
toast.success('Maintenance Team deleted successfully!', { position: "top-right", autoClose: 3000, icon: <FaCheckCircle /> });
navigate(-1);
} catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Unknown error';
toast.error(`Failed to delete: ${errorMessage}`, { position: "top-right", autoClose: 6000, icon: <FaTimesCircle /> });
}
};
const isFieldDisabled = useCallback((fieldname: string): boolean => {
if (!isEditing) return true;
return false;
}, [isEditing]);
const formatDateTime = (dateStr?: string) => dateStr ? new Date(dateStr).toLocaleString() : '-';
if (loading) {
return (
<div className="flex items-center justify-center h-screen bg-gray-50 dark:bg-gray-900">
<div className="text-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-indigo-500 mx-auto"></div>
<p className="mt-4 text-gray-600 dark:text-gray-400">Loading maintenance team...</p>
</div>
</div>
);
}
if (error && !isNewTeam) {
return (
<div className="p-6 bg-gray-50 dark:bg-gray-900 min-h-screen">
<div className="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-6">
<h2 className="text-xl font-bold text-red-800 dark:text-red-300 mb-4">{t('maintenance.errorLoadingTeam')}</h2>
<p className="text-red-700 dark:text-red-400 mb-4">{error}</p>
<button onClick={() => navigate(-1)} className="bg-indigo-600 hover:bg-indigo-700 text-white px-4 py-2 rounded">{t('maintenance.backToTeams')}</button>
</div>
</div>
);
}
return (
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 p-6">
<ToastContainer position="top-right" autoClose={4000} hideProgressBar={false} newestOnTop closeOnClick rtl={false} pauseOnFocusLoss draggable pauseOnHover theme="colored" transition={Bounce} />
{/* Header */}
<div className="mb-6 flex justify-between items-center">
<div className="flex items-center gap-4">
<button onClick={() => navigate(-1)} className="text-gray-600 dark:text-gray-400 hover:text-gray-800 dark:hover:text-gray-200">
<FaArrowLeft size={20} />
</button>
<div>
<h1 className="text-2xl font-bold text-gray-800 dark:text-white flex items-center gap-3">
<FaUsers className="text-indigo-500" />
{isNewTeam ? 'New Maintenance Team' : team?.maintenance_team_name || 'Maintenance Team'}
</h1>
<p className="text-gray-600 dark:text-gray-400 mt-1">
{isNewTeam ? 'Create a new maintenance team' : team?.name}
</p>
</div>
</div>
<div className="flex gap-3">
{!isNewTeam && !isEditing && (
<>
<button onClick={() => setIsEditing(true)} className="bg-indigo-600 hover:bg-indigo-700 text-white px-4 py-2 rounded-lg flex items-center gap-2">
<FaEdit />{t('common.edit')}
</button>
<button onClick={() => setShowDeleteConfirm(true)} className="bg-red-600 hover:bg-red-700 text-white px-4 py-2 rounded-lg flex items-center gap-2">
<FaTrash />{t('common.delete')}
</button>
</>
)}
{isEditing && (
<>
<button onClick={() => { if (isNewTeam) navigate(-1); else { setIsEditing(false); refetch(); } }} className="bg-gray-600 hover:bg-gray-700 text-white px-4 py-2 rounded-lg">
{t('common.cancel')}
</button>
<button onClick={handleSave} disabled={saving} className="bg-green-600 hover:bg-green-700 text-white px-4 py-2 rounded-lg flex items-center gap-2 disabled:opacity-50">
<FaSave />{saving ? t('common.saving') : t('common.save')}
</button>
</>
)}
</div>
</div>
{/* Delete Confirmation Modal */}
{showDeleteConfirm && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
<div className="bg-white dark:bg-gray-800 rounded-lg p-6 max-w-md w-full mx-4 shadow-xl">
<div className="flex items-start gap-3 mb-4">
<FaExclamationTriangle className="text-red-500 text-xl mt-0.5" />
<div>
<h3 className="text-lg font-semibold text-gray-800 dark:text-white">{t('maintenance.deleteTeam')}</h3>
<p className="text-gray-600 dark:text-gray-400 mt-1">{t('confirmations.cannotUndo')}</p>
</div>
</div>
<div className="flex justify-end gap-3">
<button onClick={() => setShowDeleteConfirm(false)} className="px-4 py-2 bg-gray-300 hover:bg-gray-400 text-gray-700 rounded-lg">{t('common.cancel')}</button>
<button onClick={handleDelete} disabled={saving} className="px-4 py-2 bg-red-600 hover:bg-red-700 text-white rounded-lg disabled:opacity-50">{saving ? t('common.deleting') : t('common.delete')}</button>
</div>
</div>
</div>
)}
{/* Form */}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Main Content */}
<div className="lg:col-span-2 space-y-6">
{/* Team Information */}
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-md p-6 border border-gray-200 dark:border-gray-700">
<h2 className="text-lg font-semibold text-gray-800 dark:text-white mb-4 pb-2 border-b border-gray-200 dark:border-gray-700 flex items-center gap-2">
<FaUsers className="text-indigo-500" />{t('maintenance.teamInformation')}
</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="md:col-span-2">
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
{t('maintenance.teamName')} <span className="text-red-500">*</span>
</label>
<input type="text" name="maintenance_team_name" value={formData.maintenance_team_name} onChange={handleChange} disabled={isFieldDisabled('maintenance_team_name')} placeholder={t('maintenance.enterTeamName')} className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-700 text-gray-900 dark:text-white disabled:bg-gray-100 dark:disabled:bg-gray-800 focus:outline-none focus:ring-2 focus:ring-indigo-500" />
</div>
<div>
<LinkField label={t('commonFields.hospital')} doctype="Company" value={formData.company || ''} onChange={(val) => setFormData({ ...formData, company: val })} disabled={isFieldDisabled('company')} placeholder={t('maintenance.selectHospital')} />
</div>
<div>
<LinkField label={t('maintenance.expertise')} doctype="Issue Type" value={formData.custom_expertise || ''} onChange={(val) => setFormData({ ...formData, custom_expertise: val })} disabled={isFieldDisabled('custom_expertise')} placeholder={t('maintenance.selectExpertise')} allowQuickCreate={true}/>
</div>
<div>
<LinkField label={t('maintenance.manager')} doctype="User" value={formData.maintenance_manager || ''} onChange={handleManagerChange} disabled={isFieldDisabled('maintenance_manager')} placeholder={t('maintenance.selectManager')} />
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">{t('maintenance.managerName')}</label>
<input type="text" value={formData.maintenance_manager_name || ''} disabled className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-400" />
</div>
</div>
</div>
{/* Team Members */}
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-md p-6 border border-gray-200 dark:border-gray-700">
<div className="flex items-center justify-between mb-4 pb-2 border-b border-gray-200 dark:border-gray-700">
<h2 className="text-lg font-semibold text-gray-800 dark:text-white flex items-center gap-2">
<FaUserPlus className="text-green-500" />Team Members
</h2>
{isEditing && (
<button onClick={handleAddMember} disabled={checkingMember !== null} className="bg-green-600 hover:bg-green-700 text-white px-3 py-1.5 rounded-lg flex items-center gap-2 text-sm disabled:opacity-50 disabled:cursor-not-allowed">
<FaPlus size={12} />Add Member
</button>
)}
</div>
{formData.maintenance_team_members && formData.maintenance_team_members.length > 0 ? (
<div className="overflow-x-auto">
<table className="w-full">
<thead className="bg-gray-50 dark:bg-gray-700">
<tr>
<th className="px-3 py-2 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">#</th>
<th className="px-3 py-2 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">Team Member<span className="text-red-500">*</span></th>
<th className="px-3 py-2 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">Full Name</th>
<th className="px-3 py-2 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">Role<span className="text-red-500">*</span></th>
{isEditing && <th className="px-3 py-2 text-center text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">Action</th>}
</tr>
</thead>
<tbody className="divide-y divide-gray-200 dark:divide-gray-700">
{formData.maintenance_team_members.map((member, index) => (
<tr key={index} className={`hover:bg-gray-50 dark:hover:bg-gray-700/50 ${checkingMember === index ? 'opacity-70' : ''}`}>
<td className="px-3 py-2 text-sm text-gray-600 dark:text-gray-400">{index + 1}</td>
<td className="px-3 py-2">
{isEditing ? (
<div className="relative">
<LinkField label="" doctype="User" value={member.team_member || ''} onChange={(val) => handleMemberChange(index, 'team_member', val)} disabled={checkingMember !== null} placeholder={t('maintenance.selectUser')} compact={true} />
{checkingMember === index && (
<div className="absolute right-2 top-1/2 -translate-y-1/2">
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-indigo-500"></div>
</div>
)}
</div>
) : (
<span className="text-sm text-gray-900 dark:text-white">{member.team_member || '-'}</span>
)}
</td>
<td className="px-3 py-2 text-sm text-gray-600 dark:text-gray-300">{member.full_name || '-'}</td>
<td className="px-3 py-2">
{isEditing ? (
<LinkField label="" doctype="Role" value={member.maintenance_role || ''} onChange={(val) => handleMemberChange(index, 'maintenance_role', val)} disabled={checkingMember !== null} placeholder={t('maintenance.selectRole')} compact={true} />
) : (
<span className="text-sm text-gray-600 dark:text-gray-300">{member.maintenance_role || '-'}</span>
)}
</td>
{isEditing && (
<td className="px-3 py-2 text-center">
<button onClick={() => setShowDeleteMemberConfirm(index)} className="text-red-600 dark:text-red-400 hover:text-red-800 dark:hover:text-red-300 p-1.5 hover:bg-red-50 dark:hover:bg-red-900/30 rounded transition-colors" title={t('maintenance.removeMember')}>
<FaTrash size={14} />
</button>
</td>
)}
</tr>
))}
</tbody>
</table>
</div>
) : (
<div className="text-center py-8 text-gray-500 dark:text-gray-400">
<FaUsers className="text-4xl mx-auto mb-2 text-gray-300 dark:text-gray-600" />
<p>{t('maintenance.noTeamMembersYet')}</p>
{isEditing && (
<button onClick={handleAddMember} className="mt-3 text-indigo-600 dark:text-indigo-400 hover:underline">
+ {t('maintenance.addFirstMember')}
</button>
)}
</div>
)}
</div>
{/* ✅ ADD THIS — Comments Section */}
{!isNewTeam && (
<CommentSection
referenceDoctype="Asset Maintenance Team"
referenceName={teamName || null}
title="Comments & Discussion" // optional, default shown
pollInterval={30000} // optional, auto-refresh every 30s (0 = off)
initialLimit={5} // optional, comments shown before "show more"
collapsible={true} // optional, allow collapse/expand
startCollapsed={false} // optional, start collapsed
/>
)}
</div>
{/* Sidebar */}
<div className="space-y-6">
{/* Team Summary Card */}
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-md p-6 border border-gray-200 dark:border-gray-700">
<h2 className="text-lg font-semibold text-gray-800 dark:text-white mb-4 pb-2 border-b border-gray-200 dark:border-gray-700 flex items-center gap-2">
<FaUserTie className="text-blue-500" />{t('maintenance.teamSummary')}
</h2>
<div className="space-y-4">
<div className="p-4 bg-indigo-50 dark:bg-indigo-900/30 rounded-lg border border-indigo-200 dark:border-indigo-800">
<p className="text-xs text-gray-500 dark:text-gray-400 mb-1">{t('maintenance.totalMembers')}</p>
<p className="text-2xl font-bold text-indigo-600 dark:text-indigo-300">
{formData.maintenance_team_members?.filter(m => m.team_member).length || 0}
</p>
</div>
{formData.maintenance_manager_name && (
<div className="p-4 bg-gray-50 dark:bg-gray-700 rounded-lg">
<p className="text-xs text-gray-500 dark:text-gray-400 mb-1">{t('maintenance.manager')}</p>
<p className="text-sm font-medium text-gray-900 dark:text-white">{formData.maintenance_manager_name}</p>
</div>
)}
{formData.company && (
<div className="p-4 bg-gray-50 dark:bg-gray-700 rounded-lg">
<p className="text-xs text-gray-500 dark:text-gray-400 mb-1">Hospital</p>
<p className="text-sm font-medium text-gray-900 dark:text-white">{formData.company}</p>
</div>
)}
{formData.custom_expertise && (
<div className="p-4 bg-gray-50 dark:bg-gray-700 rounded-lg">
<p className="text-xs text-gray-500 dark:text-gray-400 mb-1">Expertise</p>
<span className="inline-flex px-2 py-1 text-xs font-medium rounded-full bg-indigo-100 text-indigo-800 dark:bg-indigo-900/30 dark:text-indigo-300">
{formData.custom_expertise}
</span>
</div>
)}
</div>
</div>
{/* Timeline Card */}
{!isNewTeam && team && (
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-md p-6 border border-gray-200 dark:border-gray-700">
<h2 className="text-lg font-semibold text-gray-800 dark:text-white mb-4 pb-2 border-b border-gray-200 dark:border-gray-700 flex items-center gap-2">
<FaBuilding className="text-teal-500" />Details
</h2>
<div className="space-y-4">
<div className="p-4 bg-gray-50 dark:bg-gray-700 rounded-lg">
<p className="text-xs text-gray-500 dark:text-gray-400 mb-1">Created</p>
<p className="text-sm text-gray-900 dark:text-white">{formatDateTime(team.creation)}</p>
</div>
<div className="p-4 bg-gray-50 dark:bg-gray-700 rounded-lg">
<p className="text-xs text-gray-500 dark:text-gray-400 mb-1">Last Modified</p>
<p className="text-sm text-gray-900 dark:text-white">{formatDateTime(team.modified)}</p>
</div>
<div className="p-4 bg-gray-50 dark:bg-gray-700 rounded-lg">
<p className="text-xs text-gray-500 dark:text-gray-400 mb-1">Modified By</p>
<p className="text-sm text-gray-900 dark:text-white">{team.modified_by || '-'}</p>
</div>
</div>
</div>
)}
</div>
</div>
{/* Delete Member Confirmation Modal */}
{showDeleteMemberConfirm !== null && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
<div className="bg-white dark:bg-gray-800 rounded-lg p-6 max-w-sm w-full mx-4 shadow-xl">
<div className="flex items-start gap-3 mb-4">
<FaExclamationTriangle className="text-orange-500 text-xl mt-0.5" />
<div>
<h3 className="text-lg font-semibold text-gray-800 dark:text-white">Remove Team Member</h3>
<p className="text-gray-600 dark:text-gray-400 mt-1">Are you sure you want to remove this team member?</p>
</div>
</div>
<div className="flex justify-end gap-3">
<button onClick={() => setShowDeleteMemberConfirm(null)} className="px-4 py-2 bg-gray-300 hover:bg-gray-400 text-gray-700 rounded-lg">Cancel</button>
<button onClick={() => handleRemoveMember(showDeleteMemberConfirm)} className="px-4 py-2 bg-red-600 hover:bg-red-700 text-white rounded-lg">Remove</button>
</div>
</div>
</div>
)}
</div>
);
};
export default MaintenanceTeamDetail;

View File

@ -0,0 +1,674 @@
import React, { useState, useMemo, useEffect, useCallback, useRef } from 'react';
import { useNavigate, useSearchParams } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { useMaintenanceTeamList } from '../hooks/useMaintenanceTeam';
import * as XLSX from 'xlsx';
import {
FaPlus,
FaFilter,
FaSync,
FaEye,
FaChevronLeft,
FaChevronRight,
FaTimes,
FaSave,
FaStar,
FaTrash,
FaEdit,
FaCheckSquare,
FaSquare,
FaFileExport,
FaFileExcel,
FaFileCsv,
FaDownload,
FaUsers,
FaUserTie,
FaBuilding,
FaCopy
} from 'react-icons/fa';
import LinkField from '../components/LinkField';
import ListPagination from '../components/ListPagination';
import { buildDateRangeFilters, toFrappeFilterArray } from '../utils/listFilterUtils';
// Export types
type ExportFormat = 'csv' | 'excel';
type ExportScope = 'selected' | 'all_on_page' | 'all_with_filters';
interface ExportModalProps {
isOpen: boolean;
onClose: () => void;
selectedCount: number;
totalCount: number;
pageCount: number;
onExport: (scope: ExportScope, format: ExportFormat, columns: string[]) => void;
isExporting: boolean;
exportColumns: Array<{key: string, label: string, default: boolean}>;
}
const ExportModal: React.FC<ExportModalProps> = ({
isOpen, onClose, selectedCount, totalCount, pageCount, onExport, isExporting, exportColumns
}) => {
const { t } = useTranslation();
const [scope, setScope] = useState<ExportScope>(selectedCount > 0 ? 'selected' : 'all_with_filters');
const [format, setFormat] = useState<ExportFormat>('csv');
const [selectedColumns, setSelectedColumns] = useState<string[]>(exportColumns.filter(c => c.default).map(c => c.key));
useEffect(() => { setScope(selectedCount > 0 ? 'selected' : 'all_with_filters'); }, [selectedCount]);
const toggleColumn = (key: string) => setSelectedColumns(prev => prev.includes(key) ? prev.filter(k => k !== key) : [...prev, key]);
const selectAllColumns = () => setSelectedColumns(exportColumns.map(c => c.key));
const selectDefaultColumns = () => setSelectedColumns(exportColumns.filter(c => c.default).map(c => c.key));
if (!isOpen) return null;
return (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-[70] p-4">
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-xl max-w-2xl w-full max-h-[90vh] overflow-hidden animate-scale-in">
<div className="bg-gradient-to-r from-green-500 to-green-600 px-6 py-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<FaFileExport className="text-white text-xl" />
<h3 className="text-lg font-semibold text-white">{t('maintenance.export.title')}</h3>
</div>
<button onClick={onClose} className="text-white/80 hover:text-white transition-colors" disabled={isExporting}>
<FaTimes size={20} />
</button>
</div>
</div>
<div className="p-6 overflow-y-auto max-h-[calc(90vh-180px)]">
<div className="mb-6">
<h4 className="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-3">{t('maintenance.export.selectData')}</h4>
<div className="space-y-2">
<label className={`flex items-center gap-3 p-3 rounded-lg border cursor-pointer transition-all ${scope === 'selected' ? 'border-green-500 bg-green-50 dark:bg-green-900/20' : 'border-gray-200 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-700/50'} ${selectedCount === 0 ? 'opacity-50 cursor-not-allowed' : ''}`}>
<input type="radio" name="scope" value="selected" checked={scope === 'selected'} onChange={() => setScope('selected')} disabled={selectedCount === 0} className="text-green-600 focus:ring-green-500" />
<div className="flex-1">
<div className="font-medium text-gray-900 dark:text-white">{t('maintenance.export.selectedRows')}</div>
<div className="text-sm text-gray-500 dark:text-gray-400">{t('maintenance.export.selectedCount', { count: selectedCount })}</div>
</div>
{selectedCount > 0 && <span className="bg-green-100 dark:bg-green-900/40 text-green-700 dark:text-green-300 px-2 py-1 rounded text-xs font-medium">{selectedCount} selected</span>}
</label>
<label className={`flex items-center gap-3 p-3 rounded-lg border cursor-pointer transition-all ${scope === 'all_on_page' ? 'border-green-500 bg-green-50 dark:bg-green-900/20' : 'border-gray-200 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-700/50'}`}>
<input type="radio" name="scope" value="all_on_page" checked={scope === 'all_on_page'} onChange={() => setScope('all_on_page')} className="text-green-600 focus:ring-green-500" />
<div className="flex-1">
<div className="font-medium text-gray-900 dark:text-white">{t('maintenance.export.currentPage')}</div>
<div className="text-sm text-gray-500 dark:text-gray-400">{t('maintenance.export.currentPageCount', { count: pageCount })}</div>
</div>
<span className="bg-blue-100 dark:bg-blue-900/40 text-blue-700 dark:text-blue-300 px-2 py-1 rounded text-xs font-medium">{pageCount} rows</span>
</label>
<label className={`flex items-center gap-3 p-3 rounded-lg border cursor-pointer transition-all ${scope === 'all_with_filters' ? 'border-green-500 bg-green-50 dark:bg-green-900/20' : 'border-gray-200 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-700/50'}`}>
<input type="radio" name="scope" value="all_with_filters" checked={scope === 'all_with_filters'} onChange={() => setScope('all_with_filters')} className="text-green-600 focus:ring-green-500" />
<div className="flex-1">
<div className="font-medium text-gray-900 dark:text-white">{t('maintenance.export.allWithFilters')}</div>
<div className="text-sm text-gray-500 dark:text-gray-400">{t('maintenance.export.allWithFiltersCount', { count: totalCount })}</div>
</div>
<span className="bg-purple-100 dark:bg-purple-900/40 text-purple-700 dark:text-purple-300 px-2 py-1 rounded text-xs font-medium">{totalCount} total</span>
</label>
</div>
</div>
<div className="mb-6">
<h4 className="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-3">{t('maintenance.export.exportFormat')}</h4>
<div className="flex gap-3">
<label className={`flex-1 flex items-center gap-3 p-3 rounded-lg border cursor-pointer transition-all ${format === 'csv' ? 'border-green-500 bg-green-50 dark:bg-green-900/20' : 'border-gray-200 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-700/50'}`}>
<input type="radio" name="format" value="csv" checked={format === 'csv'} onChange={() => setFormat('csv')} className="text-green-600 focus:ring-green-500" />
<FaFileCsv className="text-green-600 text-xl" />
<div><div className="font-medium text-gray-900 dark:text-white">{t('maintenance.export.csv')}</div><div className="text-xs text-gray-500 dark:text-gray-400">{t('maintenance.export.csvDesc')}</div></div>
</label>
<label className={`flex-1 flex items-center gap-3 p-3 rounded-lg border cursor-pointer transition-all ${format === 'excel' ? 'border-green-500 bg-green-50 dark:bg-green-900/20' : 'border-gray-200 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-700/50'}`}>
<input type="radio" name="format" value="excel" checked={format === 'excel'} onChange={() => setFormat('excel')} className="text-green-600 focus:ring-green-500" />
<FaFileExcel className="text-green-700 text-xl" />
<div><div className="font-medium text-gray-900 dark:text-white">{t('maintenance.export.excel')}</div><div className="text-xs text-gray-500 dark:text-gray-400">{t('maintenance.export.excelDesc')}</div></div>
</label>
</div>
</div>
<div>
<div className="flex items-center justify-between mb-3">
<h4 className="text-sm font-semibold text-gray-700 dark:text-gray-300">{t('maintenance.export.columnsToExport')}</h4>
<div className="flex gap-2">
<button onClick={selectAllColumns} className="text-xs text-blue-600 dark:text-blue-400 hover:underline">{t('maintenance.export.selectAll')}</button>
<span className="text-gray-300 dark:text-gray-600">|</span>
<button onClick={selectDefaultColumns} className="text-xs text-blue-600 dark:text-blue-400 hover:underline">{t('maintenance.export.resetToDefault')}</button>
</div>
</div>
<div className="grid grid-cols-2 md:grid-cols-3 gap-2 max-h-48 overflow-y-auto p-3 bg-gray-50 dark:bg-gray-900/50 rounded-lg">
{exportColumns.map((col) => (
<label key={col.key} className={`flex items-center gap-2 p-2 rounded cursor-pointer transition-all ${selectedColumns.includes(col.key) ? 'bg-green-100 dark:bg-green-900/30 text-green-800 dark:text-green-300' : 'hover:bg-gray-100 dark:hover:bg-gray-800 text-gray-700 dark:text-gray-400'}`}>
<input type="checkbox" checked={selectedColumns.includes(col.key)} onChange={() => toggleColumn(col.key)} className="rounded text-green-600 focus:ring-green-500" />
<span className="text-sm truncate">{col.label}</span>
</label>
))}
</div>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-2">{t('maintenance.export.columnsSelected', { count: selectedColumns.length })}</p>
</div>
</div>
<div className="px-6 py-4 bg-gray-50 dark:bg-gray-900/50 border-t border-gray-200 dark:border-gray-700 flex justify-between items-center">
<div className="text-sm text-gray-600 dark:text-gray-400">
{scope === 'selected' && t('maintenance.export.exportingSelected', { count: selectedCount })}
{scope === 'all_on_page' && t('maintenance.export.exportingPage', { count: pageCount })}
{scope === 'all_with_filters' && t('maintenance.export.exportingAll', { count: totalCount })}
</div>
<div className="flex gap-3">
<button onClick={onClose} className="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors" disabled={isExporting}>{t('common.cancel')}</button>
<button onClick={() => onExport(scope, format, selectedColumns)} disabled={selectedColumns.length === 0 || isExporting} className="px-4 py-2 text-sm font-medium text-white bg-green-600 hover:bg-green-700 rounded-lg transition-colors flex items-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed">
{isExporting ? (<><div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white"></div>{t('maintenance.export.exporting')}</>) : (<><FaDownload />{t('maintenance.export.exportButton')}</>)}
</button>
</div>
</div>
</div>
</div>
);
};
const MaintenanceTeamList: React.FC = () => {
const { t } = useTranslation();
const navigate = useNavigate();
const EXPORT_COLUMNS = [
{ key: 'name', label: t('maintenance.teamId'), default: true },
{ key: 'maintenance_team_name', label: t('maintenance.teamName'), default: true },
{ key: 'maintenance_manager', label: t('maintenance.managerEmail'), default: true },
{ key: 'maintenance_manager_name', label: t('maintenance.managerName'), default: true },
{ key: 'company', label: t('commonFields.hospital'), default: true },
{ key: 'custom_expertise', label: t('maintenance.expertise'), default: true },
{ key: 'creation', label: t('commonFields.createdOn'), default: false },
{ key: 'modified', label: t('commonFields.modifiedOn'), default: false },
];
const [searchParams, setSearchParams] = useSearchParams();
const currentPage = useMemo(() => {
const p = parseInt(searchParams.get('page') || '1', 10);
return Number.isNaN(p) || p < 1 ? 1 : p;
}, [searchParams]);
const setCurrentPage = useCallback((pageOrUpdater: number | ((p: number) => number)) => {
const next = typeof pageOrUpdater === 'function' ? pageOrUpdater(currentPage) : pageOrUpdater;
setSearchParams((prev) => {
const nextParams = new URLSearchParams(prev);
nextParams.set('page', String(next));
return nextParams;
});
}, [currentPage, setSearchParams]);
const [pageSize] = useState(20);
const [initialLoadComplete, setInitialLoadComplete] = useState(false);
const [selectedRows, setSelectedRows] = useState<Set<string>>(new Set());
const [showExportModal, setShowExportModal] = useState(false);
const [isExporting, setIsExporting] = useState(false);
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState<string | null>(null);
const [dateFilterBy, setDateFilterBy] = useState<'' | 'creation' | 'modified'>(() => (searchParams.get('date_filter_by') as '' | 'creation' | 'modified') || '');
const [dateStart, setDateStart] = useState<string>(() => searchParams.get('date_start') || '');
const [dateEnd, setDateEnd] = useState<string>(() => searchParams.get('date_end') || '');
const [companyFilter, setCompanyFilter] = useState<string>(() => searchParams.get('company') || '');
const [teamNameFilter, setTeamNameFilter] = useState<string>(() => searchParams.get('team_name') || '');
const [sortBy, setSortBy] = useState<string>(() => searchParams.get('sort_by') || 'creation desc');
const [isFilterExpanded, setIsFilterExpanded] = useState(false);
const [activeFilterCount, setActiveFilterCount] = useState(0);
const [savedFilters, setSavedFilters] = useState<any[]>([]);
const [showSaveFilterModal, setShowSaveFilterModal] = useState(false);
const [filterPresetName, setFilterPresetName] = useState('');
useEffect(() => {
const saved = localStorage.getItem('maintenanceTeamFilterPresets');
if (saved) setSavedFilters(JSON.parse(saved));
}, []);
const hasDateFilter = dateFilterBy && (dateStart || dateEnd);
useEffect(() => {
const count = [companyFilter, teamNameFilter].filter(Boolean).length + (hasDateFilter ? 1 : 0);
setActiveFilterCount(count);
}, [companyFilter, teamNameFilter, hasDateFilter]);
const apiFilters = useMemo(() => {
const filters: Record<string, any> = {};
if (companyFilter) filters['company'] = companyFilter;
if (teamNameFilter) filters['name'] = teamNameFilter;
Object.assign(filters, buildDateRangeFilters(dateFilterBy, dateStart, dateEnd));
return filters;
}, [companyFilter, teamNameFilter, dateFilterBy, dateStart, dateEnd]);
const orderBy = ['creation desc', 'creation asc', 'modified desc', 'modified asc', 'name asc', 'name desc', 'maintenance_team_name asc', 'maintenance_team_name desc'].includes(sortBy) ? sortBy : 'creation desc';
const { teams, loading, error, totalCount, refetch } = useMaintenanceTeamList({
filters: apiFilters,
limit_start: (currentPage - 1) * pageSize,
limit_page_length: pageSize,
order_by: orderBy,
});
useEffect(() => { if (!loading && !initialLoadComplete) setInitialLoadComplete(true); }, [loading, initialLoadComplete]);
const filtersChangedOnce = useRef(false);
useEffect(() => {
if (!filtersChangedOnce.current) {
filtersChangedOnce.current = true;
return;
}
setSearchParams((prev) => {
const next = new URLSearchParams(prev);
if (dateFilterBy) next.set('date_filter_by', dateFilterBy); else next.delete('date_filter_by');
if (dateStart) next.set('date_start', dateStart); else next.delete('date_start');
if (dateEnd) next.set('date_end', dateEnd); else next.delete('date_end');
if (companyFilter) next.set('company', companyFilter); else next.delete('company');
if (teamNameFilter) next.set('team_name', teamNameFilter); else next.delete('team_name');
if (sortBy && sortBy !== 'creation desc') next.set('sort_by', sortBy); else next.delete('sort_by');
next.set('page', '1');
return next;
});
}, [dateFilterBy, dateStart, dateEnd, companyFilter, teamNameFilter, sortBy]);
useEffect(() => { setSelectedRows(new Set()); }, [dateFilterBy, dateStart, dateEnd, companyFilter, teamNameFilter, currentPage]);
const totalPages = Math.ceil(totalCount / pageSize);
const formatDate = (dateStr?: string) => dateStr ? new Date(dateStr).toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' }) : '-';
const clearFilters = () => {
setDateFilterBy(''); setDateStart(''); setDateEnd('');
setSortBy('creation desc');
setCompanyFilter(''); setTeamNameFilter('');
setSearchParams((prev) => {
const next = new URLSearchParams(prev);
next.delete('date_filter_by'); next.delete('date_start'); next.delete('date_end');
next.delete('sort_by');
next.delete('company'); next.delete('team_name');
next.set('page', '1');
return next;
});
};
const hasActiveFilters = hasDateFilter || !!companyFilter || !!teamNameFilter;
const handleSaveFilterPreset = () => {
if (!filterPresetName.trim()) { alert('Please enter a filter name'); return; }
const preset = { id: Date.now(), name: filterPresetName, filters: { dateFilterBy, dateStart, dateEnd, sortBy, companyFilter, teamNameFilter } };
const updated = [...savedFilters, preset];
setSavedFilters(updated);
setFilterPresetName('');
setShowSaveFilterModal(false);
localStorage.setItem('maintenanceTeamFilterPresets', JSON.stringify(updated));
};
const handleLoadFilterPreset = (preset: any) => {
const f = preset.filters;
setDateFilterBy(f.dateFilterBy || ''); setDateStart(f.dateStart || ''); setDateEnd(f.dateEnd || '');
setSortBy(f.sortBy || 'creation desc');
setCompanyFilter(f.companyFilter || '');
setTeamNameFilter(f.teamNameFilter || '');
};
const handleDeleteFilterPreset = (id: number) => {
const updated = savedFilters.filter(f => f.id !== id);
setSavedFilters(updated);
localStorage.setItem('maintenanceTeamFilterPresets', JSON.stringify(updated));
};
const handleSelectRow = (teamName: string) => {
setSelectedRows(prev => { const newSet = new Set(prev); newSet.has(teamName) ? newSet.delete(teamName) : newSet.add(teamName); return newSet; });
};
const handleSelectAll = () => { selectedRows.size === teams.length ? setSelectedRows(new Set()) : setSelectedRows(new Set(teams.map(t => t.name))); };
const isAllSelected = teams.length > 0 && selectedRows.size === teams.length;
const isSomeSelected = selectedRows.size > 0 && selectedRows.size < teams.length;
const fetchAllTeamsForExport = useCallback(async (): Promise<any[]> => {
const allTeams: any[] = [];
let currentPageNum = 0;
const pageSizeNum = 100;
let hasMoreData = true;
const filterArrays = toFrappeFilterArray(apiFilters);
while (hasMoreData) {
try {
const response = await fetch('/api/method/frappe.client.get_list', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ doctype: 'Asset Maintenance Team', filters: filterArrays.length > 0 ? filterArrays : {}, fields: ['*'], limit_start: currentPageNum * pageSizeNum, limit_page_length: pageSizeNum, order_by: orderBy })
});
const data = await response.json();
const results = data.message || [];
allTeams.push(...results);
if (results.length < pageSizeNum) hasMoreData = false; else currentPageNum++;
if (currentPageNum > 100) { console.warn('Export safety limit reached'); hasMoreData = false; }
} catch (error) { console.error('Error fetching teams for export:', error); throw error; }
}
return allTeams;
}, [apiFilters]);
const handleExport = async (scope: ExportScope, format: ExportFormat, columns: string[]) => {
setIsExporting(true);
try {
let dataToExport: any[] = [];
switch (scope) {
case 'selected': dataToExport = teams.filter(t => selectedRows.has(t.name)); break;
case 'all_on_page': dataToExport = teams; break;
case 'all_with_filters': dataToExport = await fetchAllTeamsForExport(); break;
}
if (dataToExport.length === 0) { alert(t('assets.noDataToExport')); return; }
const columnLabels = columns.map(key => EXPORT_COLUMNS.find(c => c.key === key)?.label || key);
if (format === 'csv') {
const csvContent = [columnLabels.join(','), ...dataToExport.map(team => columns.map(key => { let value = team[key] || ''; if (typeof value === 'string' && (value.includes(',') || value.includes('"') || value.includes('\n'))) value = `"${value.replace(/"/g, '""')}"`; return value; }).join(','))].join('\n');
const blob = new Blob(['\ufeff' + csvContent], { type: 'text/csv;charset=utf-8;' });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url; link.download = `maintenance_teams_export_${new Date().toISOString().split('T')[0]}.csv`; link.click();
URL.revokeObjectURL(url);
} else if (format === 'excel') {
const worksheetData = [columnLabels, ...dataToExport.map(team => columns.map(key => team[key] || ''))];
const worksheet = XLSX.utils.aoa_to_sheet(worksheetData);
const workbook = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(workbook, worksheet, 'Maintenance Teams');
XLSX.writeFile(workbook, `maintenance_teams_export_${new Date().toISOString().split('T')[0]}.xlsx`);
}
setShowExportModal(false); setSelectedRows(new Set());
} catch (error) { console.error('Export failed:', error); alert(`Export failed: ${error instanceof Error ? error.message : 'Unknown error'}`); }
finally { setIsExporting(false); }
};
const handleDelete = async (teamName: string) => {
try {
const response = await fetch(`/api/resource/Asset Maintenance Team/${encodeURIComponent(teamName)}`, { method: 'DELETE', headers: { 'Content-Type': 'application/json' } });
if (!response.ok) throw new Error('Failed to delete');
setDeleteConfirmOpen(null); refetch(); alert(t('maintenance.deletedSuccessfully'));
} catch (err) { alert(`Failed to delete: ${err instanceof Error ? err.message : 'Unknown error'}`); }
};
if (loading && !initialLoadComplete) {
return (
<div className="flex items-center justify-center h-screen bg-gray-50 dark:bg-gray-900">
<div className="text-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-500 mx-auto"></div>
<p className="mt-4 text-gray-600 dark:text-gray-400">{t('maintenance.loadingTeams')}</p>
</div>
</div>
);
}
if (error) {
return (
<div className="p-6 bg-gray-50 dark:bg-gray-900 min-h-screen">
<div className="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-6">
<h2 className="text-xl font-bold text-red-800 dark:text-red-300 mb-4">{t('maintenance.errorLoadingTeams')}</h2>
<p className="text-red-700 dark:text-red-400 mb-4">{error}</p>
<button onClick={refetch} className="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded">{t('common.tryAgain')}</button>
</div>
</div>
);
}
return (
<div className="p-6 bg-gray-50 dark:bg-gray-900 min-h-screen">
{/* Header */}
<div className="mb-6 flex justify-between items-center">
<div>
<div className="flex items-center gap-3">
<FaUsers className="text-3xl text-indigo-600 dark:text-indigo-400" />
<div>
<h1 className="text-2xl font-bold text-gray-800 dark:text-white">{t('maintenance.listTitle')}</h1>
<p className="text-sm text-gray-500 dark:text-gray-400">
{t('issues.listTotal')}: {totalCount}
{selectedRows.size > 0 && <span className="ml-2 text-blue-600 dark:text-blue-400"> {selectedRows.size} {t('issues.listSelected')}</span>}
{loading && initialLoadComplete && <span className="ml-2 inline-flex items-center gap-1 text-xs text-blue-600 dark:text-blue-400"><div className="animate-spin rounded-full h-3 w-3 border-b-2 border-blue-500"></div>{t('common.filtering')}</span>}
</p>
</div>
</div>
</div>
<div className="flex gap-3">
<button onClick={() => setIsFilterExpanded(!isFilterExpanded)} className={`px-4 py-2 rounded-lg flex items-center gap-2 transition-colors ${isFilterExpanded || hasActiveFilters ? 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-300' : 'bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-600'}`}>
<FaFilter />{t('listPages.filters')}
{activeFilterCount > 0 && <span className="bg-blue-600 text-white text-xs px-1.5 py-0.5 rounded-full">{activeFilterCount}</span>}
</button>
<button onClick={refetch} disabled={loading} className="px-4 py-2 rounded-lg bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-600 flex items-center gap-2 disabled:opacity-50">
<FaSync className={loading ? 'animate-spin' : ''} />{t('listPages.refresh')}
</button>
<button onClick={() => setShowExportModal(true)} className="bg-green-600 hover:bg-green-700 text-white px-4 py-2 rounded-lg flex items-center gap-2 shadow transition-all" disabled={totalCount === 0}>
<FaFileExport /><span className="font-medium">{t('listPages.export')}</span>
{selectedRows.size > 0 && <span className="bg-white/20 px-1.5 py-0.5 rounded text-xs">{selectedRows.size}</span>}
</button>
<button onClick={() => navigate('/maintenance-teams/new')} className="bg-indigo-600 hover:bg-indigo-700 text-white px-4 py-2 rounded-lg flex items-center gap-2 shadow-lg transition-all hover:shadow-xl">
<FaPlus /><span className="font-medium">{t('maintenance.newMaintenanceTeam')}</span>
</button>
</div>
</div>
{/* Stats Cards */}
{/* <div className="grid grid-cols-1 md:grid-cols-3 gap-4 mb-4">
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-4 border border-gray-200 dark:border-gray-700">
<div className="flex items-center justify-between"><div><p className="text-sm text-gray-500 dark:text-gray-400">Total Teams</p><p className="text-2xl font-bold text-gray-800 dark:text-white">{totalCount}</p></div><FaUsers className="text-3xl text-indigo-500" /></div>
</div>
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-4 border border-gray-200 dark:border-gray-700">
<div className="flex items-center justify-between"><div><p className="text-sm text-gray-500 dark:text-gray-400">Managers</p><p className="text-2xl font-bold text-blue-600">{new Set(teams.map(t => t.maintenance_manager).filter(Boolean)).size}</p></div><FaUserTie className="text-3xl text-blue-500" /></div>
</div>
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-4 border border-gray-200 dark:border-gray-700">
<div className="flex items-center justify-between"><div><p className="text-sm text-gray-500 dark:text-gray-400">Hospitals</p><p className="text-2xl font-bold text-green-600">{new Set(teams.map(t => t.company).filter(Boolean)).size}</p></div><FaBuilding className="text-3xl text-green-500" /></div>
</div>
</div> */}
{/* Expandable Filter Panel */}
{isFilterExpanded && (
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-lg border border-gray-200 dark:border-gray-700 mb-4">
<div className="bg-gradient-to-r from-indigo-500 to-indigo-600 dark:from-indigo-600 dark:to-indigo-700 px-4 py-3 rounded-t-lg">
<div className="flex items-center justify-between gap-4">
<div className="flex items-center gap-3">
<FaFilter className="text-white" size={16} /><h3 className="text-white font-semibold text-sm">{t('listPages.filters')}</h3>
{activeFilterCount > 0 && <span className="bg-white text-indigo-600 px-2 py-0.5 rounded-full text-xs font-bold">{activeFilterCount}</span>}
</div>
{hasActiveFilters && (
<div className="flex-1 overflow-x-auto scrollbar-hide mx-2">
<div className="flex items-center gap-2 py-1">
{hasDateFilter && <span className="inline-flex items-center gap-1.5 px-2.5 py-1 bg-white/90 text-amber-700 rounded-full text-[10px] font-medium whitespace-nowrap shadow-sm"><span className="font-semibold">{dateFilterBy === 'creation' ? t('filters.createdDate') : t('filters.latestModifiedDate')}:</span> {[dateStart, dateEnd].filter(Boolean).join(' ')}<button onClick={() => { setDateFilterBy(''); setDateStart(''); setDateEnd(''); }} className="hover:text-red-500"><FaTimes className="text-[9px]" /></button></span>}
{companyFilter && <span className="inline-flex items-center gap-1.5 px-2.5 py-1 bg-white/90 text-green-700 rounded-full text-[10px] font-medium whitespace-nowrap shadow-sm"><span className="font-semibold">{t('filters.hospital')}:</span> {companyFilter}<button onClick={() => setCompanyFilter('')} className="hover:text-red-500"><FaTimes className="text-[9px]" /></button></span>}
{teamNameFilter && <span className="inline-flex items-center gap-1.5 px-2.5 py-1 bg-white/90 text-indigo-700 rounded-full text-[10px] font-medium whitespace-nowrap shadow-sm"><span className="font-semibold">{t('maintenance.teamName')}:</span> {teamNameFilter}<button onClick={() => setTeamNameFilter('')} className="hover:text-red-500"><FaTimes className="text-[9px]" /></button></span>}
</div>
</div>
)}
<div className="flex items-center gap-2 flex-shrink-0">
{activeFilterCount > 0 && <button onClick={() => setShowSaveFilterModal(true)} className="px-3 py-1.5 bg-white text-indigo-600 hover:bg-indigo-50 rounded-md text-xs font-medium transition-all flex items-center gap-1.5"><FaSave size={12} /><span className="hidden sm:inline">{t('listPages.saveFilterPreset')}</span></button>}
{hasActiveFilters && <button onClick={clearFilters} className="px-3 py-1.5 bg-red-500 hover:bg-red-600 text-white rounded-md text-xs font-medium transition-all flex items-center gap-1.5"><FaTimes size={12} /><span className="hidden sm:inline">{t('listPages.clearFilters')}</span></button>}
</div>
</div>
</div>
<div className="p-4">
{savedFilters.length > 0 && (
<div className="mb-4 pb-4 border-b border-gray-200 dark:border-gray-700">
<h4 className="text-xs font-semibold text-gray-700 dark:text-gray-300 mb-2 flex items-center gap-2"><FaStar className="text-yellow-500" size={12} />{t('inspections.savedFilters')}</h4>
<div className="flex flex-wrap gap-2">
{savedFilters.map((preset) => (
<div key={preset.id} className="group relative inline-flex items-center gap-2 px-3 py-1.5 bg-gradient-to-r from-purple-100 to-indigo-100 dark:from-purple-900/30 dark:to-indigo-900/30 border border-purple-200 dark:border-purple-700 rounded-lg hover:shadow-md transition-all">
<button onClick={() => handleLoadFilterPreset(preset)} className="text-xs font-medium text-purple-700 dark:text-purple-300">{preset.name}</button>
<button onClick={() => handleDeleteFilterPreset(preset.id)} className="opacity-0 group-hover:opacity-100 text-red-500 hover:text-red-700 transition-opacity"><FaTrash size={10} /></button>
</div>
))}
</div>
</div>
)}
<div className="bg-gray-50 dark:bg-gray-900/50 p-3 rounded-lg">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-3 mb-3">
{/* Sort By */}
<div className="relative">
<label className="block text-[10px] font-medium text-gray-700 dark:text-gray-300 mb-0.5">{t('filters.sortBy')}</label>
<select
value={sortBy}
onChange={(e) => { setSortBy(e.target.value); setCurrentPage(1); }}
className="w-full px-2 py-1 text-xs border border-gray-300 dark:border-gray-600 rounded focus:outline-none focus:ring-2 focus:ring-indigo-500 bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
>
<option value="creation desc">{t('filters.sortCreationNewest')}</option>
<option value="creation asc">{t('filters.sortCreationOldest')}</option>
<option value="modified desc">{t('filters.sortModifiedNewest')}</option>
<option value="modified asc">{t('filters.sortModifiedOldest')}</option>
<option value="name asc">{t('filters.sortNameAsc')}</option>
<option value="name desc">{t('filters.sortNameDesc')}</option>
<option value="maintenance_team_name asc">{t('filters.sortTeamNameAsc')}</option>
<option value="maintenance_team_name desc">{t('filters.sortTeamNameDesc')}</option>
</select>
</div>
<div className="relative">
<label className="block text-[10px] font-medium text-gray-700 dark:text-gray-300 mb-0.5">{t('filters.filterBy')}</label>
<select value={dateFilterBy} onChange={(e) => { const v = e.target.value as '' | 'creation' | 'modified'; setDateFilterBy(v); setCurrentPage(1); }} className="w-full px-2 py-1 text-xs border border-gray-300 dark:border-gray-600 rounded focus:outline-none focus:ring-2 focus:ring-indigo-500 bg-white dark:bg-gray-700 text-gray-900 dark:text-white">
<option value="">{t('filters.filterBy')}</option>
<option value="creation">{t('filters.createdDate')}</option>
<option value="modified">{t('filters.latestModifiedDate')}</option>
</select>
</div>
{dateFilterBy && (
<>
<div className="relative">
<label className="block text-[10px] font-medium text-gray-700 dark:text-gray-300 mb-0.5">{t('filters.startDate')}</label>
<input type="date" value={dateStart} onChange={(e) => { const v = e.target.value; setDateStart(v); if (dateEnd && v > dateEnd) setDateEnd(v); setCurrentPage(1); }} className="w-full px-2 py-1 text-xs border border-gray-300 dark:border-gray-600 rounded focus:outline-none focus:ring-2 focus:ring-indigo-500 bg-white dark:bg-gray-700 text-gray-900 dark:text-white" />
</div>
<div className="relative">
<label className="block text-[10px] font-medium text-gray-700 dark:text-gray-300 mb-0.5">{t('filters.endDate')}</label>
<input type="date" value={dateEnd} onChange={(e) => { setDateEnd(e.target.value); setCurrentPage(1); }} min={dateStart || undefined} className="w-full px-2 py-1 text-xs border border-gray-300 dark:border-gray-600 rounded focus:outline-none focus:ring-2 focus:ring-indigo-500 bg-white dark:bg-gray-700 text-gray-900 dark:text-white" />
</div>
</>
)}
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<div className="relative z-[60]">
<LinkField label="Hospital" doctype="Company" value={companyFilter} onChange={(val) => { setCompanyFilter(val); setCurrentPage(1); }} placeholder="Select Hospital" disabled={false} compact={true} />
{companyFilter && <button onClick={() => setCompanyFilter('')} className="absolute right-2 top-6 text-gray-400 hover:text-red-500 transition-colors z-10"><FaTimes size={10} /></button>}
</div>
<div className="relative z-[59]">
<LinkField label={t('maintenance.teamName')} doctype="Asset Maintenance Team" value={teamNameFilter} onChange={(val) => { setTeamNameFilter(val); setCurrentPage(1); }} placeholder={t('maintenance.selectTeam')} disabled={false} compact={true} />
{teamNameFilter && <button onClick={() => setTeamNameFilter('')} className="absolute right-2 top-6 text-gray-400 hover:text-red-500 transition-colors z-10"><FaTimes size={10} /></button>}
</div>
</div>
</div>
</div>
</div>
)}
{/* Save Filter Modal */}
{showSaveFilterModal && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-xl max-w-md w-full p-6 animate-scale-in">
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-4">{t('listPages.saveFilterPreset')}</h3>
<input type="text" value={filterPresetName} onChange={(e) => setFilterPresetName(e.target.value)} onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); handleSaveFilterPreset(); } }} placeholder={t('listPages.enterFilterName')} className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md focus:outline-none focus:ring-2 focus:ring-indigo-500 bg-white dark:bg-gray-700 text-gray-900 dark:text-white mb-4" autoFocus />
<div className="flex gap-2 justify-end">
<button onClick={() => { setShowSaveFilterModal(false); setFilterPresetName(''); }} className="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-md transition-colors">{t('common.cancel')}</button>
<button onClick={handleSaveFilterPreset} className="px-4 py-2 text-sm font-medium text-white bg-indigo-600 hover:bg-indigo-700 rounded-md transition-colors flex items-center gap-2"><FaSave size={12} />{t('listPages.saveFilter')}</button>
</div>
</div>
</div>
)}
{/* Export Modal */}
<ExportModal isOpen={showExportModal} onClose={() => setShowExportModal(false)} selectedCount={selectedRows.size} totalCount={totalCount} pageCount={teams.length} onExport={handleExport} isExporting={isExporting} exportColumns={EXPORT_COLUMNS} />
{/* Table */}
<div className="bg-white dark:bg-gray-800 rounded-lg shadow border border-gray-200 dark:border-gray-700 overflow-hidden relative">
{loading && initialLoadComplete && (
<div className="absolute inset-0 bg-white/60 dark:bg-gray-800/60 flex items-center justify-center z-10 backdrop-blur-[1px]">
<div className="flex items-center gap-3 bg-white dark:bg-gray-700 px-4 py-2 rounded-lg shadow-lg"><div className="animate-spin rounded-full h-5 w-5 border-b-2 border-indigo-500"></div><span className="text-sm text-gray-600 dark:text-gray-300">{t('listPages.filtering')}</span></div>
</div>
)}
<div className="overflow-x-auto">
<table className="w-full">
<thead className="bg-gray-100 dark:bg-gray-700 border-b border-gray-200 dark:border-gray-600">
<tr>
<th className="px-4 py-3 text-left">
<button onClick={handleSelectAll} className="text-gray-500 dark:text-gray-400 hover:text-indigo-600 dark:hover:text-indigo-400 transition-colors" title={isAllSelected ? t('listPages.deselectAllTitle') : t('listPages.selectAllTitle')}>
{isAllSelected ? <FaCheckSquare className="text-indigo-600 dark:text-indigo-400" size={18} /> : isSomeSelected ? <div className="relative"><FaSquare size={18} /><div className="absolute inset-0 flex items-center justify-center"><div className="w-2 h-0.5 bg-current"></div></div></div> : <FaSquare size={18} />}
</button>
</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">{t('maintenance.teamName')}</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">{t('maintenance.managerName')}</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">{t('commonFields.hospital')}</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">{t('maintenance.expertise')}</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">{t('commonFields.createdOn')}</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">{t('listPages.actions')}</th>
</tr>
</thead>
<tbody className="bg-white dark:bg-gray-800 divide-y divide-gray-200 dark:divide-gray-700">
{teams.length === 0 ? (
<tr>
<td colSpan={7} className="px-4 py-12 text-center text-gray-500 dark:text-gray-400">
<div className="flex flex-col items-center">
<FaUsers className="text-4xl text-gray-300 dark:text-gray-600 mb-2" />
<p>{t('listPages.noMaintenanceTeamsFound')}</p>
{hasActiveFilters ? (
<button onClick={clearFilters} className="mt-4 text-indigo-600 dark:text-indigo-400 hover:underline">
{t('common.clearFilters')}
</button>
) : (
<button onClick={() => navigate('/maintenance-teams/new')} className="mt-4 text-indigo-600 dark:text-indigo-400 hover:underline">
{t('listPages.createFirstTeam')}
</button>
)}
</div>
</td>
</tr>
) : teams.map((team) => (
<tr key={team.name} className={`hover:bg-gray-50 dark:hover:bg-gray-700/50 cursor-pointer transition-colors ${selectedRows.has(team.name) ? 'bg-indigo-50 dark:bg-indigo-900/20' : ''}`} onClick={() => navigate(`/maintenance-teams/${team.name}`)}>
<td className="px-4 py-3" onClick={(e) => e.stopPropagation()}>
<button onClick={() => handleSelectRow(team.name)} className="text-gray-500 dark:text-gray-400 hover:text-indigo-600 dark:hover:text-indigo-400 transition-colors">
{selectedRows.has(team.name) ? <FaCheckSquare className="text-indigo-600 dark:text-indigo-400" size={18} /> : <FaSquare size={18} />}
</button>
</td>
<td className="px-4 py-3">
<div className="text-sm font-medium text-gray-900 dark:text-white">{team.maintenance_team_name}</div>
<div className="text-xs text-gray-500 dark:text-gray-400">{team.name}</div>
</td>
<td className="px-4 py-3">
<div className="text-sm text-gray-900 dark:text-white">{team.maintenance_manager_name || '-'}</div>
<div className="text-xs text-gray-500 dark:text-gray-400">{team.maintenance_manager || '-'}</div>
</td>
<td className="px-4 py-3"><span className="text-sm text-gray-600 dark:text-gray-300">{team.company || '-'}</span></td>
<td className="px-4 py-3">
{team.custom_expertise ? (
<span className="inline-flex px-2 py-1 text-xs font-medium rounded-full bg-indigo-100 text-indigo-800 dark:bg-indigo-900/30 dark:text-indigo-300">{team.custom_expertise}</span>
) : <span className="text-gray-400">-</span>}
</td>
<td className="px-4 py-3"><span className="text-sm text-gray-600 dark:text-gray-300">{formatDate(team.creation)}</span></td>
<td className="px-4 py-3">
<div className="flex items-center gap-1" onClick={(e) => e.stopPropagation()}>
<button onClick={() => navigate(`/maintenance-teams/${team.name}`)} className="text-blue-600 dark:text-blue-400 hover:text-blue-900 dark:hover:text-blue-300 p-2 hover:bg-blue-50 dark:hover:bg-blue-900/30 rounded transition-colors" title={t('maintenance.viewDetails')}><FaEye /></button>
<button onClick={() => navigate(`/maintenance-teams/${team.name}`)} className="text-green-600 dark:text-green-400 hover:text-green-900 dark:hover:text-green-300 p-2 hover:bg-green-50 dark:hover:bg-green-900/30 rounded transition-colors" title={t('maintenance.editTeam')}><FaEdit /></button>
<button onClick={() => navigate(`/maintenance-teams/new?duplicate=${team.name}`)} className="text-purple-600 dark:text-purple-400 hover:text-purple-900 dark:hover:text-purple-300 p-2 hover:bg-purple-50 dark:hover:bg-purple-900/30 rounded transition-colors" title={t('maintenance.duplicateTeam')}><FaCopy /></button>
<button onClick={() => setDeleteConfirmOpen(team.name)} className="text-red-600 dark:text-red-400 hover:text-red-900 dark:hover:text-red-300 p-2 hover:bg-red-50 dark:hover:bg-red-900/30 rounded transition-colors" title={t('maintenance.deleteTeam')}><FaTrash /></button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
<ListPagination
currentPage={currentPage}
totalCount={totalCount}
pageSize={pageSize}
itemLabel={t('pagination.teams')}
onPageChange={(p) => setCurrentPage(p)}
/>
</div>
{/* Delete Confirmation Modal */}
{deleteConfirmOpen && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white dark:bg-gray-800 rounded-lg p-6 max-w-md w-full mx-4 shadow-2xl">
<div className="flex items-start gap-4">
<div className="flex-shrink-0 w-12 h-12 rounded-full bg-red-100 dark:bg-red-900/30 flex items-center justify-center"><FaTrash className="text-red-600 dark:text-red-400 text-xl" /></div>
<div className="flex-1">
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-2">{t('maintenance.deleteTeam')}</h3>
<p className="text-sm text-gray-600 dark:text-gray-400 mb-4">{t('maintenance.deleteConfirmMessage')}</p>
<div className="bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-md p-3 mb-4"><p className="text-xs text-yellow-800 dark:text-yellow-300"><strong>{t('maintenance.team')}:</strong> {deleteConfirmOpen}</p></div>
<div className="flex gap-3 justify-end">
<button onClick={() => setDeleteConfirmOpen(null)} className="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 rounded-lg transition-colors">{t('common.cancel')}</button>
<button onClick={() => handleDelete(deleteConfirmOpen)} className="px-4 py-2 text-sm font-medium text-white bg-red-600 hover:bg-red-700 rounded-lg transition-colors flex items-center gap-2"><FaTrash />{t('maintenance.deleteTeamButton')}</button>
</div>
</div>
</div>
</div>
</div>
)}
<style>{`
@keyframes scale-in { from { transform: scale(0.95); opacity: 0; } to { transform: scale(1); opacity: 1; } }
.animate-scale-in { animation: scale-in 0.2s ease-out; }
.scrollbar-hide { -ms-overflow-style: none; scrollbar-width: none; }
.scrollbar-hide::-webkit-scrollbar { display: none; }
`}</style>
</div>
);
};
export default MaintenanceTeamList;

File diff suppressed because it is too large Load Diff

View File

View File

@ -0,0 +1,408 @@
import React, { useState, useEffect } from 'react';
import { useParams, useNavigate, useSearchParams } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { usePPMDetails, usePPMMutations } from '../hooks/usePPM';
import { FaArrowLeft, FaSave, FaEdit, FaTools } from 'react-icons/fa';
import type { CreatePPMData } from '../services/ppmService';
const PPMDetail: React.FC = () => {
const { t } = useTranslation();
const { ppmName } = useParams<{ ppmName: string }>();
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const duplicateFromPPM = searchParams.get('duplicate');
const isNewPPM = ppmName === 'new';
const isDuplicating = isNewPPM && !!duplicateFromPPM;
const { ppm, loading, error, refetch } = usePPMDetails(
isDuplicating ? duplicateFromPPM : (isNewPPM ? null : ppmName || null)
);
const { createPPM, updatePPM, loading: saving } = usePPMMutations();
const [isEditing, setIsEditing] = useState(isNewPPM);
const [formData, setFormData] = useState<CreatePPMData>({
company: '',
asset_name: '',
custom_asset_type: '',
maintenance_team: '',
custom_frequency: '',
custom_total_amount: 0,
custom_no_of_pms: 0,
custom_price_per_pm: 0,
});
useEffect(() => {
if (ppm) {
setFormData({
company: ppm.company || '',
asset_name: ppm.asset_name || '',
custom_asset_type: ppm.custom_asset_type || '',
maintenance_team: ppm.maintenance_team || '',
custom_frequency: ppm.custom_frequency || '',
custom_total_amount: ppm.custom_total_amount || 0,
custom_no_of_pms: ppm.custom_no_of_pms || 0,
custom_price_per_pm: ppm.custom_price_per_pm || 0,
});
}
}, [ppm, isDuplicating]);
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement>) => {
const { name, value } = e.target;
setFormData(prev => ({
...prev,
[name]: name.includes('amount') || name.includes('pms') || name.includes('price')
? parseFloat(value) || 0
: value
}));
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!formData.asset_name) {
alert(t('ppm.detail.pleaseEnterAssetName'));
return;
}
try {
if (isNewPPM || isDuplicating) {
const result = await createPPM(formData);
const successMessage = isDuplicating
? t('ppm.detail.duplicatedSuccessfully')
: t('ppm.detail.createdSuccessfully');
alert(successMessage);
if (result.asset_maintenance?.name) {
navigate(`/ppm/${result.asset_maintenance.name}`);
} else {
refetch();
navigate(-1);
}
} else if (ppmName) {
await updatePPM(ppmName, formData);
alert(t('ppm.detail.updatedSuccessfully'));
setIsEditing(false);
refetch();
}
} catch (err) {
console.error('PPM save error:', err);
alert(t('ppm.detail.failedToSave') + ': ' + (err instanceof Error ? err.message : 'Unknown error'));
}
};
if (loading) {
return (
<div className="flex items-center justify-center h-screen bg-gray-50 dark:bg-gray-900">
<div className="text-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-500 mx-auto"></div>
<p className="mt-4 text-gray-600 dark:text-gray-400">{t('ppm.detail.loadingSchedule')}</p>
</div>
</div>
);
}
if (error && !isNewPPM && !isDuplicating) {
return (
<div className="p-6 bg-gray-50 dark:bg-gray-900 min-h-screen">
<div className="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-4">
<p className="text-red-600 dark:text-red-400">{t('ppm.detail.errorLoading')}: {error}</p>
<button
onClick={() => navigate(-1)}
className="mt-2 text-red-700 dark:text-red-400 underline hover:text-red-800 dark:hover:text-red-300"
>
{t('ppm.detail.backToSchedules')}
</button>
</div>
</div>
);
}
return (
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 p-6">
{/* Header */}
<div className="mb-6 flex justify-between items-center">
<div className="flex items-center gap-4">
<button
onClick={() => navigate(-1)}
className="text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300 flex items-center gap-2"
>
<FaArrowLeft />
<span className="text-gray-900 dark:text-white">
{isDuplicating ? t('ppm.detail.duplicateSchedule') : (isNewPPM ? t('ppm.detail.newSchedule') : t('ppm.detail.scheduleDetails'))}
</span>
</button>
</div>
<div className="flex items-center gap-3">
{!isNewPPM && !isEditing && (
<button
onClick={() => setIsEditing(true)}
className="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-lg flex items-center gap-2"
>
<FaEdit />
{t('common.edit')}
</button>
)}
</div>
</div>
<form onSubmit={handleSubmit}>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Main Form */}
<div className="lg:col-span-2 space-y-6">
{/* Basic Information */}
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
<h2 className="text-xl font-semibold text-gray-900 dark:text-white mb-4">{t('ppm.detail.basicInformation')}</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
{t('ppm.company')} *
</label>
{isEditing ? (
<input
type="text"
name="company"
value={formData.company}
onChange={handleChange}
className="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
required
/>
) : (
<div className="p-4 bg-gray-50 dark:bg-gray-700 rounded-lg">
<p className="text-gray-900 dark:text-white">{ppm?.company || '-'}</p>
</div>
)}
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
{t('ppm.detail.assetName')} *
</label>
{isEditing ? (
<input
type="text"
name="asset_name"
value={formData.asset_name}
onChange={handleChange}
className="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
required
/>
) : (
<div className="p-4 bg-gray-50 dark:bg-gray-700 rounded-lg">
<p className="text-gray-900 dark:text-white">{ppm?.asset_name || '-'}</p>
</div>
)}
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
{t('ppm.assetType')}
</label>
{isEditing ? (
<input
type="text"
name="custom_asset_type"
value={formData.custom_asset_type}
onChange={handleChange}
className="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
) : (
<div className="p-4 bg-gray-50 dark:bg-gray-700 rounded-lg">
<p className="text-gray-900 dark:text-white">{ppm?.custom_asset_type || '-'}</p>
</div>
)}
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
{t('ppm.detail.maintenanceTeam')}
</label>
{isEditing ? (
<input
type="text"
name="maintenance_team"
value={formData.maintenance_team}
onChange={handleChange}
className="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
) : (
<div className="p-4 bg-gray-50 dark:bg-gray-700 rounded-lg">
<p className="text-gray-900 dark:text-white">{ppm?.maintenance_team || '-'}</p>
</div>
)}
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
{t('ppm.frequency')}
</label>
{isEditing ? (
<input
type="text"
name="custom_frequency"
value={formData.custom_frequency}
onChange={handleChange}
className="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder={t('ppm.detail.frequencyPlaceholder')}
/>
) : (
<div className="p-4 bg-gray-50 dark:bg-gray-700 rounded-lg">
<p className="text-gray-900 dark:text-white">{ppm?.custom_frequency || '-'}</p>
</div>
)}
</div>
</div>
</div>
{/* Financial Information */}
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
<h2 className="text-xl font-semibold text-gray-900 dark:text-white mb-4">{t('ppm.detail.financialInformation')}</h2>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
{t('ppm.detail.numberOfPMs')}
</label>
{isEditing ? (
<input
type="number"
name="custom_no_of_pms"
value={formData.custom_no_of_pms}
onChange={handleChange}
className="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
min="0"
/>
) : (
<div className="p-4 bg-gray-50 dark:bg-gray-700 rounded-lg">
<p className="text-gray-900 dark:text-white">{ppm?.custom_no_of_pms || '-'}</p>
</div>
)}
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
{t('ppm.detail.pricePerPM')}
</label>
{isEditing ? (
<input
type="number"
name="custom_price_per_pm"
value={formData.custom_price_per_pm}
onChange={handleChange}
className="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
min="0"
step="0.01"
/>
) : (
<div className="p-4 bg-gray-50 dark:bg-gray-700 rounded-lg">
<p className="text-gray-900 dark:text-white">
{ppm?.custom_price_per_pm ? `$${ppm.custom_price_per_pm.toLocaleString()}` : '-'}
</p>
</div>
)}
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
{t('ppm.totalAmount')}
</label>
{isEditing ? (
<input
type="number"
name="custom_total_amount"
value={formData.custom_total_amount}
onChange={handleChange}
className="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
min="0"
step="0.01"
/>
) : (
<div className="p-4 bg-gray-50 dark:bg-gray-700 rounded-lg">
<p className="text-gray-900 dark:text-white font-semibold">
{ppm?.custom_total_amount ? `$${ppm.custom_total_amount.toLocaleString()}` : '-'}
</p>
</div>
)}
</div>
</div>
</div>
</div>
{/* Sidebar Info */}
<div className="lg:col-span-1">
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-6 space-y-4">
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-4">{t('ppm.detail.scheduleInformation')}</h3>
{!isNewPPM && ppm && (
<>
<div className="p-4 bg-gray-50 dark:bg-gray-700 rounded-lg">
<p className="text-xs text-gray-500 dark:text-gray-400 mb-1">{t('ppm.pmId')}</p>
<p className="text-sm font-medium text-gray-900 dark:text-white">{ppm.name}</p>
</div>
<div className="p-4 bg-gray-50 dark:bg-gray-700 rounded-lg">
<p className="text-xs text-gray-500 dark:text-gray-400 mb-1">{t('users.created')}</p>
<p className="text-xs text-gray-900 dark:text-white">
{ppm.creation ? new Date(ppm.creation).toLocaleString() : '-'}
</p>
</div>
</>
)}
{isNewPPM && (
<div className="text-center py-8">
<FaTools className="text-4xl text-gray-400 dark:text-gray-500 mx-auto mb-2" />
<p className="text-sm text-gray-500 dark:text-gray-400">
{t('ppm.detail.scheduleInfoAfterCreation')}
</p>
</div>
)}
</div>
</div>
</div>
{/* Action Buttons */}
{isEditing && (
<div className="mt-6 flex justify-end gap-3">
<button
type="button"
onClick={() => {
if (isNewPPM) {
navigate(-1);
} else {
setIsEditing(false);
if (ppm) {
setFormData({
company: ppm.company || '',
asset_name: ppm.asset_name || '',
custom_asset_type: ppm.custom_asset_type || '',
maintenance_team: ppm.maintenance_team || '',
custom_frequency: ppm.custom_frequency || '',
custom_total_amount: ppm.custom_total_amount || 0,
custom_no_of_pms: ppm.custom_no_of_pms || 0,
custom_price_per_pm: ppm.custom_price_per_pm || 0,
});
}
}
}}
className="px-6 py-2 border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700"
>
{t('common.cancel')}
</button>
<button
type="submit"
disabled={saving}
className="px-6 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg flex items-center gap-2 disabled:opacity-50"
>
<FaSave />
{saving ? t('common.saving') : (isNewPPM ? t('common.create') : t('ppm.detail.saveChanges'))}
</button>
</div>
)}
</form>
</div>
);
};
export default PPMDetail;

View File

@ -0,0 +1,443 @@
import React, { useState, useEffect, useRef } from 'react';
import { useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { usePPMs, usePPMMutations } from '../hooks/usePPM';
import { FaPlus, FaSearch, FaEdit, FaEye, FaTrash, FaCopy, FaEllipsisV, FaFileExport, FaCalendarCheck, FaBuilding } from 'react-icons/fa';
const PPMList: React.FC = () => {
const { t } = useTranslation();
const navigate = useNavigate();
const [page, setPage] = useState(0);
const [searchTerm, setSearchTerm] = useState('');
const [companyFilter, setCompanyFilter] = useState<string>('');
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState<string | null>(null);
const [actionMenuOpen, setActionMenuOpen] = useState<string | null>(null);
const dropdownRef = useRef<HTMLDivElement>(null);
const limit = 20;
const filters = companyFilter ? { company: companyFilter } : {};
const { ppms, totalCount, hasMore, loading, error, refetch } = usePPMs(
filters,
limit,
page * limit,
'creation desc'
);
const { deletePPM, loading: mutationLoading } = usePPMMutations();
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
setActionMenuOpen(null);
}
};
if (actionMenuOpen) {
document.addEventListener('mousedown', handleClickOutside);
}
return () => {
document.removeEventListener('mousedown', handleClickOutside);
};
}, [actionMenuOpen]);
const handleCreateNew = () => {
navigate('/ppm/new');
};
const handleView = (ppmName: string) => {
navigate(`/ppm/${ppmName}`);
};
const handleEdit = (ppmName: string) => {
navigate(`/ppm/${ppmName}`);
};
const handleDelete = async (ppmName: string) => {
try {
await deletePPM(ppmName);
setDeleteConfirmOpen(null);
refetch();
alert(t('ppm.deletedSuccessfully'));
} catch (err) {
alert(`Failed to delete: ${err instanceof Error ? err.message : 'Unknown error'}`);
}
};
const handleDuplicate = (ppmName: string) => {
navigate(`/ppm/new?duplicate=${ppmName}`);
};
const handleExport = (ppm: any) => {
const dataStr = JSON.stringify(ppm, null, 2);
const dataBlob = new Blob([dataStr], { type: 'application/json' });
const url = URL.createObjectURL(dataBlob);
const link = document.createElement('a');
link.href = url;
link.download = `ppm_${ppm.name}.json`;
link.click();
URL.revokeObjectURL(url);
};
const handleExportAll = () => {
const headers = ['PPM ID', 'Company', 'Asset', 'Asset Type', 'Frequency', 'No. of PMs', 'Total Amount'];
const csvContent = [
headers.join(','),
...ppms.map(ppm => [
ppm.name,
ppm.company || '',
ppm.asset_name || '',
ppm.custom_asset_type || '',
ppm.custom_frequency || '',
ppm.custom_no_of_pms || '',
ppm.custom_total_amount || ''
].join(','))
].join('\n');
const dataBlob = new Blob([csvContent], { type: 'text/csv' });
const url = URL.createObjectURL(dataBlob);
const link = document.createElement('a');
link.href = url;
link.download = `ppm_schedules_${new Date().toISOString().split('T')[0]}.csv`;
link.click();
URL.revokeObjectURL(url);
};
if (loading && page === 0) {
return (
<div className="flex items-center justify-center h-screen bg-gray-50 dark:bg-gray-900">
<div className="text-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-500 mx-auto"></div>
<p className="mt-4 text-gray-600 dark:text-gray-400">{t('listPages.loading')}</p>
</div>
</div>
);
}
if (error) {
return (
<div className="p-6 bg-gray-50 dark:bg-gray-900 min-h-screen">
<div className="bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-lg p-6">
<h2 className="text-xl font-bold text-yellow-800 dark:text-yellow-300 mb-4"> {t('ppm.apiNotAvailable')}</h2>
<div className="text-yellow-700 dark:text-yellow-400 space-y-3">
<p><strong>{t('ppm.apiNotDeployed')}</strong></p>
<div className="mt-4 flex gap-3">
<button
onClick={() => navigate('/ppm/new')}
className="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded"
>
{t('ppm.tryCreatingNew')}
</button>
<button
onClick={refetch}
className="bg-gray-600 hover:bg-gray-700 text-white px-4 py-2 rounded"
>
{t('common.tryAgain')}
</button>
</div>
</div>
<div className="mt-4 p-4 bg-white dark:bg-gray-800 rounded border border-yellow-300 dark:border-yellow-700">
<p className="text-sm text-gray-600 dark:text-gray-400">
<strong>Technical Error:</strong> {error}
</p>
</div>
</div>
</div>
);
}
const filteredPPMs = ppms.filter(ppm =>
ppm.name?.toLowerCase().includes(searchTerm.toLowerCase()) ||
ppm.asset_name?.toLowerCase().includes(searchTerm.toLowerCase()) ||
ppm.company?.toLowerCase().includes(searchTerm.toLowerCase()) ||
ppm.custom_asset_type?.toLowerCase().includes(searchTerm.toLowerCase())
);
return (
<div className="p-6 bg-gray-50 dark:bg-gray-900 min-h-screen">
{/* Header */}
<div className="mb-6 flex justify-between items-center">
<div>
<h1 className="text-3xl font-bold text-gray-800 dark:text-white">{t('ppm.title')}</h1>
<p className="text-gray-600 dark:text-gray-400 mt-1">
{t('ppm.listTotal', { count: totalCount })}
</p>
</div>
<div className="flex gap-3">
<button
onClick={handleExportAll}
className="bg-green-600 hover:bg-green-700 text-white px-4 py-3 rounded-lg flex items-center gap-2 shadow transition-all"
disabled={ppms.length === 0}
>
<FaFileExport />
<span className="font-medium">{t('listPages.export')}</span>
</button>
<button
onClick={handleCreateNew}
className="bg-blue-600 hover:bg-blue-700 text-white px-6 py-3 rounded-lg flex items-center gap-2 shadow-lg transition-all hover:shadow-xl"
>
<FaPlus />
<span className="font-medium">{t('ppm.addPPM')}</span>
</button>
</div>
</div>
{/* Filters Bar */}
<div className="mb-6 grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-4">
<div className="flex items-center gap-2 border border-gray-300 dark:border-gray-600 rounded-lg px-4 py-2 bg-white dark:bg-gray-700">
<FaSearch className="text-gray-400 dark:text-gray-500" />
<input
type="text"
placeholder={t('ppm.searchPlaceholder')}
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="flex-1 outline-none text-gray-700 dark:text-gray-200 bg-transparent"
/>
</div>
</div>
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-4">
<input
type="text"
placeholder={t('ppm.filterByCompany')}
value={companyFilter}
onChange={(e) => {
setCompanyFilter(e.target.value);
setPage(0);
}}
className="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-700 dark:text-gray-200 focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
</div>
{/* PPM Schedules Table */}
<div className="bg-white dark:bg-gray-800 rounded-lg shadow overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full">
<thead className="bg-gray-100 dark:bg-gray-700 border-b border-gray-200 dark:border-gray-600">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
{t('ppm.pmId')}
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
{t('ppm.company')}
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
{t('ppm.asset')}
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
{t('ppm.assetType')}
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
{t('ppm.frequency')}
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
{t('ppm.noOfPMs')}
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
{t('ppm.totalAmount')}
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
{t('listPages.actions')}
</th>
</tr>
</thead>
<tbody className="bg-white dark:bg-gray-800 divide-y divide-gray-200 dark:divide-gray-700">
{filteredPPMs.length === 0 ? (
<tr>
<td colSpan={8} className="px-6 py-12 text-center text-gray-500 dark:text-gray-400">
<div className="flex flex-col items-center">
<FaSearch className="text-4xl text-gray-300 dark:text-gray-600 mb-2" />
<p>{t('ppm.noSchedulesFound')}</p>
<button
onClick={handleCreateNew}
className="mt-4 text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300 underline"
>
{t('ppm.createFirstSchedule')}
</button>
</div>
</td>
</tr>
) : (
filteredPPMs.map((ppm) => (
<tr
key={ppm.name}
className="hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-colors cursor-pointer"
onClick={() => handleView(ppm.name)}
>
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm font-medium text-gray-900 dark:text-white">
{ppm.name}
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="flex items-center gap-2">
<FaBuilding className="text-gray-400" />
<span className="text-sm text-gray-700 dark:text-gray-300">
{ppm.company || '-'}
</span>
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm text-gray-700 dark:text-gray-300">
{ppm.asset_name || '-'}
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm text-gray-700 dark:text-gray-300">
{ppm.custom_asset_type || '-'}
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="flex items-center gap-2">
<FaCalendarCheck className="text-blue-500" />
<span className="text-sm text-gray-700 dark:text-gray-300">
{ppm.custom_frequency || '-'}
</span>
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm text-gray-700 dark:text-gray-300">
{ppm.custom_no_of_pms || '-'}
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm font-medium text-gray-900 dark:text-white">
{ppm.custom_total_amount ? `$${ppm.custom_total_amount.toLocaleString()}` : '-'}
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium">
<div className="relative" onClick={(e) => e.stopPropagation()}>
<button
onClick={() => setActionMenuOpen(actionMenuOpen === ppm.name ? null : ppm.name)}
className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700"
>
<FaEllipsisV />
</button>
{actionMenuOpen === ppm.name && (
<div
ref={dropdownRef}
className="absolute right-0 mt-2 w-48 bg-white dark:bg-gray-800 rounded-lg shadow-lg z-10 border border-gray-200 dark:border-gray-700"
>
<button
onClick={() => {
handleView(ppm.name);
setActionMenuOpen(null);
}}
className="w-full text-left px-4 py-2 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 flex items-center gap-2"
>
<FaEye />
{t('listPages.view')}
</button>
<button
onClick={() => {
handleEdit(ppm.name);
setActionMenuOpen(null);
}}
className="w-full text-left px-4 py-2 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 flex items-center gap-2"
>
<FaEdit />
{t('listPages.edit')}
</button>
<button
onClick={() => {
handleDuplicate(ppm.name);
setActionMenuOpen(null);
}}
className="w-full text-left px-4 py-2 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 flex items-center gap-2"
>
<FaCopy />
{t('listPages.duplicate')}
</button>
<button
onClick={() => {
handleExport(ppm);
setActionMenuOpen(null);
}}
className="w-full text-left px-4 py-2 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 flex items-center gap-2"
>
<FaFileExport />
{t('listPages.export')}
</button>
<div className="border-t border-gray-200 dark:border-gray-700"></div>
<button
onClick={() => {
setDeleteConfirmOpen(ppm.name);
setActionMenuOpen(null);
}}
className="w-full text-left px-4 py-2 text-sm text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20 flex items-center gap-2"
>
<FaTrash />
{t('listPages.delete')}
</button>
</div>
)}
</div>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
{/* Pagination */}
{(hasMore || page > 0) && (
<div className="px-6 py-4 border-t border-gray-200 dark:border-gray-700 flex items-center justify-between">
<div className="text-sm text-gray-700 dark:text-gray-300">
{t('pagination.showingToOf', { start: page * limit + 1, end: Math.min((page + 1) * limit, totalCount), total: totalCount, label: t('listPages.results') })}
</div>
<div className="flex gap-2">
<button
onClick={() => setPage(Math.max(0, page - 1))}
disabled={page === 0}
className="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-600 disabled:opacity-50 disabled:cursor-not-allowed"
>
{t('pagination.previous')}
</button>
<button
onClick={() => setPage(page + 1)}
disabled={!hasMore}
className="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-600 disabled:opacity-50 disabled:cursor-not-allowed"
>
{t('pagination.next')}
</button>
</div>
</div>
)}
</div>
{/* Delete Confirmation Modal */}
{deleteConfirmOpen && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white dark:bg-gray-800 rounded-lg p-6 max-w-md w-full mx-4">
<h3 className="text-lg font-bold text-gray-900 dark:text-white mb-4">{t('ppm.confirmDelete')}</h3>
<p className="text-gray-600 dark:text-gray-400 mb-6">
{t('ppm.deleteConfirmMessage')}
</p>
<div className="flex gap-3 justify-end">
<button
onClick={() => setDeleteConfirmOpen(null)}
className="px-4 py-2 text-gray-700 dark:text-gray-300 bg-gray-200 dark:bg-gray-700 rounded-lg hover:bg-gray-300 dark:hover:bg-gray-600"
>
{t('common.cancel')}
</button>
<button
onClick={() => handleDelete(deleteConfirmOpen)}
disabled={mutationLoading}
className="px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 disabled:opacity-50"
>
{mutationLoading ? t('common.deleting') : t('common.delete')}
</button>
</div>
</div>
</div>
)}
</div>
);
};
export default PPMList;

View File

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

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