Compare commits
5 Commits
7747e144d8
...
e5ba9ebd0f
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e5ba9ebd0f | ||
|
|
225d63e384 | ||
| 037c11f06a | |||
|
|
b60096686b | ||
| f5a80773cb |
220
QUICK_START_FOR_TEAM.md
Normal file
220
QUICK_START_FOR_TEAM.md
Normal file
@ -0,0 +1,220 @@
|
||||
# Quick Start Guide for Team Members
|
||||
|
||||
## 📋 For Akhib and Dundu
|
||||
|
||||
### Step 1: Accept Invitation ✉️
|
||||
|
||||
Check your email for GitHub invitation and click **Accept invitation**.
|
||||
|
||||
### Step 2: Clone Repository 📥
|
||||
|
||||
```bash
|
||||
# Open terminal/command prompt and run:
|
||||
git clone https://github.com/YOUR_USERNAME/frappe-frontend.git
|
||||
cd frappe-frontend
|
||||
```
|
||||
|
||||
### Step 3: Install Dependencies 📦
|
||||
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
This will take a few minutes. Wait for it to complete.
|
||||
|
||||
### Step 4: Checkout Your Branch 🌿
|
||||
|
||||
**For Akhib:**
|
||||
```bash
|
||||
git checkout akhib
|
||||
```
|
||||
|
||||
**For Dundu:**
|
||||
```bash
|
||||
git checkout dundu
|
||||
```
|
||||
|
||||
### Step 5: Start Development 🚀
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
Open browser: http://localhost:3000
|
||||
|
||||
---
|
||||
|
||||
## 📝 Daily Workflow
|
||||
|
||||
### Morning (Start Work)
|
||||
|
||||
```bash
|
||||
# Get latest changes
|
||||
git pull origin akhib # or dundu
|
||||
|
||||
# Start dev server
|
||||
npm run dev
|
||||
```
|
||||
|
||||
### During Work (Save Changes)
|
||||
|
||||
```bash
|
||||
# Check what changed
|
||||
git status
|
||||
|
||||
# Add all changes
|
||||
git add .
|
||||
|
||||
# Commit with message
|
||||
git commit -m "Your description here"
|
||||
|
||||
# Push to remote
|
||||
git push origin akhib # or dundu
|
||||
```
|
||||
|
||||
### Evening (End of Day)
|
||||
|
||||
```bash
|
||||
# Make sure everything is saved
|
||||
git status
|
||||
|
||||
# Push if needed
|
||||
git push origin akhib # or dundu
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🆘 Common Issues
|
||||
|
||||
### Issue: "Permission denied"
|
||||
|
||||
**Solution:** Make sure you accepted the GitHub invitation.
|
||||
|
||||
### Issue: "npm install" fails
|
||||
|
||||
**Solution:**
|
||||
```bash
|
||||
# Delete node_modules
|
||||
rm -rf node_modules
|
||||
npm cache clean --force
|
||||
npm install
|
||||
```
|
||||
|
||||
### Issue: Branch doesn't exist
|
||||
|
||||
**Solution:**
|
||||
```bash
|
||||
git fetch origin
|
||||
git checkout akhib # or dundu
|
||||
```
|
||||
|
||||
### Issue: "Cannot push to remote"
|
||||
|
||||
**Solution:**
|
||||
```bash
|
||||
# Pull first, then push
|
||||
git pull origin akhib
|
||||
git push origin akhib
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🎯 Important Commands
|
||||
|
||||
| Command | What it does |
|
||||
|---------|--------------|
|
||||
| `git status` | See what changed |
|
||||
| `git add .` | Stage all changes |
|
||||
| `git commit -m "msg"` | Save changes |
|
||||
| `git push` | Upload to GitHub |
|
||||
| `git pull` | Download from GitHub |
|
||||
| `npm run dev` | Start dev server |
|
||||
| `npm install` | Install packages |
|
||||
|
||||
---
|
||||
|
||||
## 📞 Need Help?
|
||||
|
||||
1. Check if dev server is running: http://localhost:3000
|
||||
2. Check terminal for error messages
|
||||
3. Try restarting: Stop server (Ctrl+C) and run `npm run dev` again
|
||||
4. Contact team lead
|
||||
|
||||
---
|
||||
|
||||
## ✅ Checklist for First Day
|
||||
|
||||
- [ ] Accepted GitHub invitation
|
||||
- [ ] Cloned repository
|
||||
- [ ] Ran `npm install` successfully
|
||||
- [ ] Switched to my branch (akhib or dundu)
|
||||
- [ ] Started dev server (`npm run dev`)
|
||||
- [ ] Saw application in browser
|
||||
- [ ] Made test change
|
||||
- [ ] Committed and pushed test change
|
||||
- [ ] Saw my change on GitHub
|
||||
|
||||
---
|
||||
|
||||
## 🎨 Project Structure
|
||||
|
||||
```
|
||||
frappe-frontend/
|
||||
├── src/
|
||||
│ ├── pages/ # All pages (Login, Dashboard, etc)
|
||||
│ ├── components/ # Reusable components
|
||||
│ ├── services/ # API calls
|
||||
│ ├── hooks/ # Custom React hooks
|
||||
│ └── contexts/ # React contexts (Theme, etc)
|
||||
├── public/ # Static files
|
||||
└── package.json # Project dependencies
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🌟 Best Practices
|
||||
|
||||
1. **Commit often** - Don't wait until end of day
|
||||
2. **Write clear messages** - "Fixed login bug" not "fixed stuff"
|
||||
3. **Pull before push** - Always get latest changes first
|
||||
4. **Test before commit** - Make sure it works
|
||||
5. **Ask questions** - Better to ask than break things!
|
||||
|
||||
---
|
||||
|
||||
## 🔐 Git Config (One-time Setup)
|
||||
|
||||
```bash
|
||||
# Set your name and email
|
||||
git config --global user.name "Your Name"
|
||||
git config --global user.email "your.email@example.com"
|
||||
|
||||
# Check settings
|
||||
git config --list
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 💻 VS Code Extensions (Recommended)
|
||||
|
||||
- **ES7+ React/Redux/React-Native snippets**
|
||||
- **GitLens** - Better Git integration
|
||||
- **Prettier** - Code formatter
|
||||
- **ESLint** - Code quality
|
||||
- **Auto Rename Tag** - HTML/JSX helper
|
||||
|
||||
---
|
||||
|
||||
## 🎓 Learning Resources
|
||||
|
||||
- **React:** https://react.dev/learn
|
||||
- **TypeScript:** https://www.typescriptlang.org/docs/
|
||||
- **Git Basics:** https://git-scm.com/book/en/v2
|
||||
- **Tailwind CSS:** https://tailwindcss.com/docs
|
||||
|
||||
---
|
||||
|
||||
**Remember:** Your branch (akhib/dundu) is YOUR workspace. Feel free to experiment!
|
||||
|
||||
Good luck! 🚀
|
||||
|
||||
@ -2,7 +2,7 @@
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<link rel="icon" type="image/svg+xml" href="/seera-logo.png" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>frappe-frontend</title>
|
||||
</head>
|
||||
|
||||
BIN
public/seera-logo.png
Normal file
BIN
public/seera-logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 37 KiB |
@ -1 +0,0 @@
|
||||
<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>
|
||||
|
Before Width: | Height: | Size: 1.5 KiB |
72
src/App.tsx
72
src/App.tsx
@ -7,6 +7,12 @@ 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 Sidebar from './components/Sidebar';
|
||||
|
||||
// Layout with Sidebar
|
||||
@ -71,6 +77,72 @@ const App: React.FC = () => {
|
||||
}
|
||||
/>
|
||||
|
||||
<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="/old-dashboard"
|
||||
element={
|
||||
|
||||
201
src/components/LinkField.tsx
Normal file
201
src/components/LinkField.tsx
Normal file
@ -0,0 +1,201 @@
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import apiService from '../services/apiService'; // ✅ your ApiService
|
||||
|
||||
interface LinkFieldProps {
|
||||
label: string;
|
||||
doctype: string;
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
placeholder?: string;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
const LinkField: React.FC<LinkFieldProps> = ({
|
||||
label,
|
||||
doctype,
|
||||
value,
|
||||
onChange,
|
||||
placeholder,
|
||||
disabled = false,
|
||||
}) => {
|
||||
const [searchResults, setSearchResults] = useState<{ value: string; description?: string }[]>([]);
|
||||
const [searchText, setSearchText] = useState('');
|
||||
const [isDropdownOpen, setDropdownOpen] = useState(false);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Fetch link options from ERPNext
|
||||
const searchLink = async (text: string = '') => {
|
||||
try {
|
||||
const params = new URLSearchParams({ doctype, txt: text });
|
||||
const response = await apiService.apiCall<{ value: string; description?: string }[]>(
|
||||
`/api/method/frappe.desk.search.search_link?${params.toString()}`
|
||||
);
|
||||
setSearchResults(response || []);
|
||||
} catch (error) {
|
||||
console.error(`Error fetching ${doctype} links:`, error);
|
||||
}
|
||||
};
|
||||
|
||||
// Fetch default options when dropdown opens
|
||||
useEffect(() => {
|
||||
if (isDropdownOpen) searchLink('');
|
||||
}, [isDropdownOpen]);
|
||||
|
||||
// Close dropdown when clicking outside
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (containerRef.current && !containerRef.current.contains(event.target as Node)) {
|
||||
setDropdownOpen(false);
|
||||
}
|
||||
};
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className="relative w-full mb-4">
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">{label}</label>
|
||||
|
||||
<input
|
||||
type="text"
|
||||
value={value}
|
||||
placeholder={placeholder || `Select ${label}`}
|
||||
disabled={disabled}
|
||||
onFocus={() => !disabled && setDropdownOpen(true)}
|
||||
onChange={(e) => {
|
||||
const text = e.target.value;
|
||||
setSearchText(text);
|
||||
searchLink(text);
|
||||
onChange(text);
|
||||
}}
|
||||
className={`w-full px-3 py-2 border rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500
|
||||
disabled:bg-gray-100 dark:disabled:bg-gray-700
|
||||
border-gray-300 dark:border-gray-600
|
||||
bg-white dark:bg-gray-700 text-gray-900 dark:text-white`}
|
||||
/>
|
||||
|
||||
{isDropdownOpen && searchResults.length > 0 && (
|
||||
<ul
|
||||
className="absolute z-50 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600
|
||||
rounded-md mt-1 max-h-48 overflow-auto w-full shadow-lg"
|
||||
>
|
||||
{searchResults.map((item, idx) => (
|
||||
<li
|
||||
key={idx}
|
||||
onClick={() => {
|
||||
onChange(item.value);
|
||||
setDropdownOpen(false);
|
||||
}}
|
||||
className={`px-3 py-2 hover:bg-blue-100 dark:hover:bg-gray-700 cursor-pointer
|
||||
${value === item.value ? 'bg-blue-50 dark:bg-gray-600 font-semibold' : ''}`}
|
||||
>
|
||||
<div>{item.value}</div>
|
||||
{item.description && (
|
||||
<div className="text-gray-500 dark:text-gray-400 text-xs">{item.description}</div>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default LinkField;
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
// import React, { useState, useEffect, useRef } from 'react';
|
||||
// import apiService from '../services/apiService';// ✅ uses your existing ApiService
|
||||
|
||||
// interface LinkFieldProps {
|
||||
// label: string;
|
||||
// doctype: string;
|
||||
// value: string;
|
||||
// onChange: (value: string) => void;
|
||||
// placeholder?: string;
|
||||
// }
|
||||
|
||||
// const LinkField: React.FC<LinkFieldProps> = ({ label, doctype, value, onChange, placeholder }) => {
|
||||
// const [searchResults, setSearchResults] = useState<{ value: string; description?: string }[]>([]);
|
||||
// const [searchText, setSearchText] = useState('');
|
||||
// const [isDropdownOpen, setDropdownOpen] = useState(false);
|
||||
// const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// // Function to call ERPNext link search API
|
||||
// const searchLink = async (text: string = '') => {
|
||||
// try {
|
||||
// const params = new URLSearchParams({
|
||||
// doctype,
|
||||
// txt: text,
|
||||
// });
|
||||
// const response = await apiService.apiCall<{ value: string; description?: string }[]>(
|
||||
// `/api/method/frappe.desk.search.search_link?${params.toString()}`
|
||||
// );
|
||||
// setSearchResults(response || []);
|
||||
// } catch (error) {
|
||||
// console.error(`Error fetching ${doctype} links:`, error);
|
||||
// }
|
||||
// };
|
||||
|
||||
// // Load default results when dropdown opens
|
||||
// useEffect(() => {
|
||||
// if (isDropdownOpen) {
|
||||
// searchLink('');
|
||||
// }
|
||||
// }, [isDropdownOpen]);
|
||||
|
||||
// // Close dropdown when clicking outside
|
||||
// useEffect(() => {
|
||||
// const handleClickOutside = (event: MouseEvent) => {
|
||||
// if (containerRef.current && !containerRef.current.contains(event.target as Node)) {
|
||||
// setDropdownOpen(false);
|
||||
// }
|
||||
// };
|
||||
// document.addEventListener('mousedown', handleClickOutside);
|
||||
// return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||
// }, []);
|
||||
|
||||
// return (
|
||||
// <div ref={containerRef} className="relative w-full mb-4">
|
||||
// {/* <label className="block text-gray-700 text-sm font-medium mb-1">{label}</label> */}
|
||||
// <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">{label}</label>
|
||||
// <input
|
||||
// type="text"
|
||||
// value={value}
|
||||
// placeholder={placeholder || `Select ${label}`}
|
||||
// className="border border-gray-300 rounded-md p-2 w-full focus:ring focus:ring-blue-200"
|
||||
// onFocus={() => setDropdownOpen(true)}
|
||||
// onChange={(e) => {
|
||||
// const text = e.target.value;
|
||||
// setSearchText(text);
|
||||
// searchLink(text);
|
||||
// onChange(text);
|
||||
// }}
|
||||
// />
|
||||
// {isDropdownOpen && searchResults.length > 0 && (
|
||||
// <ul className="absolute z-50 bg-white border border-gray-300 rounded-md mt-1 max-h-48 overflow-auto w-full shadow-lg">
|
||||
// {searchResults.map((item, idx) => (
|
||||
// <li
|
||||
// key={idx}
|
||||
// onClick={() => {
|
||||
// onChange(item.value);
|
||||
// setDropdownOpen(false);
|
||||
// }}
|
||||
// className={`p-2 hover:bg-blue-100 cursor-pointer ${
|
||||
// value === item.value ? 'bg-blue-50 font-semibold' : ''
|
||||
// }`}
|
||||
// >
|
||||
// {item.value}
|
||||
// {item.description && <span className="text-gray-500 text-xs ml-2">{item.description}</span>}
|
||||
// </li>
|
||||
// ))}
|
||||
// </ul>
|
||||
// )}
|
||||
// </div>
|
||||
// );
|
||||
// };
|
||||
|
||||
// export default LinkField;
|
||||
@ -2,23 +2,23 @@ import React, { useState } from 'react';
|
||||
import { Link, useLocation, useNavigate } from 'react-router-dom';
|
||||
import { useTheme } from '../contexts/ThemeContext';
|
||||
import {
|
||||
FaTools,
|
||||
FaBox,
|
||||
FaWrench,
|
||||
FaCog,
|
||||
FaUsers,
|
||||
FaChartBar,
|
||||
FaBuilding,
|
||||
FaTruck,
|
||||
FaFileContract,
|
||||
FaInfoCircle,
|
||||
FaBars,
|
||||
FaTimes,
|
||||
FaHome,
|
||||
FaMoon,
|
||||
FaSun,
|
||||
FaSignOutAlt
|
||||
} from 'react-icons/fa';
|
||||
LayoutDashboard,
|
||||
Package,
|
||||
Wrench,
|
||||
Users,
|
||||
BarChart3,
|
||||
Building2,
|
||||
Truck,
|
||||
FileText,
|
||||
MapPin,
|
||||
Menu,
|
||||
X,
|
||||
Moon,
|
||||
Sun,
|
||||
LogOut,
|
||||
ClipboardList,
|
||||
Calendar
|
||||
} from 'lucide-react';
|
||||
|
||||
interface SidebarLink {
|
||||
id: string;
|
||||
@ -75,80 +75,87 @@ const Sidebar: React.FC<SidebarProps> = ({ userEmail }) => {
|
||||
{
|
||||
id: 'dashboard',
|
||||
title: 'Dashboard',
|
||||
icon: <FaHome />,
|
||||
icon: <LayoutDashboard size={20} />,
|
||||
path: '/dashboard',
|
||||
visible: true
|
||||
},
|
||||
{
|
||||
id: 'assets',
|
||||
title: 'Assets',
|
||||
icon: <FaTools />,
|
||||
icon: <Package size={20} />,
|
||||
path: '/assets',
|
||||
visible: showAsset
|
||||
},
|
||||
{
|
||||
id: 'work-orders',
|
||||
title: 'Work Orders',
|
||||
icon: <FaCog />,
|
||||
icon: <ClipboardList size={20} />,
|
||||
path: '/work-orders',
|
||||
visible: showGeneralWO
|
||||
},
|
||||
{
|
||||
id: 'ppm',
|
||||
title: 'PPM',
|
||||
icon: <FaWrench />,
|
||||
path: '/ppm',
|
||||
id: 'maintenance',
|
||||
title: 'Asset Maintenance',
|
||||
icon: <Wrench size={20} />,
|
||||
path: '/maintenance',
|
||||
visible: showPreventiveMaintenance
|
||||
},
|
||||
{
|
||||
id: 'inventory',
|
||||
title: 'Inventory',
|
||||
icon: <FaBox />,
|
||||
path: '/inventory',
|
||||
visible: showInventory
|
||||
id: 'ppm',
|
||||
title: 'PPM',
|
||||
icon: <Calendar size={20} />,
|
||||
path: '/ppm',
|
||||
visible: showPreventiveMaintenance
|
||||
},
|
||||
{
|
||||
id: 'vendors',
|
||||
title: 'Vendors',
|
||||
icon: <FaTruck />,
|
||||
path: '/vendors',
|
||||
visible: showSupplierDashboard
|
||||
},
|
||||
{
|
||||
id: 'dashboard-view',
|
||||
title: 'Dashboard',
|
||||
icon: <FaChartBar />,
|
||||
path: '/dashboard-view',
|
||||
visible: showProjectDashboard
|
||||
},
|
||||
{
|
||||
id: 'sites',
|
||||
title: 'Sites',
|
||||
icon: <FaBuilding />,
|
||||
path: '/sites',
|
||||
visible: showSiteDashboards
|
||||
},
|
||||
{
|
||||
id: 'active-map',
|
||||
title: 'Active Map',
|
||||
icon: <FaInfoCircle />,
|
||||
path: '/active-map',
|
||||
visible: showSiteInfo
|
||||
},
|
||||
{
|
||||
id: 'users',
|
||||
title: 'Users',
|
||||
icon: <FaUsers />,
|
||||
path: '/users',
|
||||
visible: showAMTeam
|
||||
},
|
||||
{
|
||||
id: 'account',
|
||||
title: 'Account',
|
||||
icon: <FaFileContract />,
|
||||
path: '/account',
|
||||
visible: showSLA
|
||||
}
|
||||
// {
|
||||
// id: 'inventory',
|
||||
// title: 'Inventory',
|
||||
// icon: <Package size={20} />,
|
||||
// path: '/inventory',
|
||||
// visible: showInventory
|
||||
// },
|
||||
// {
|
||||
// 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);
|
||||
@ -175,18 +182,51 @@ const Sidebar: React.FC<SidebarProps> = ({ userEmail }) => {
|
||||
{/* Sidebar Header */}
|
||||
<div className="flex items-center justify-between p-4 border-b border-gray-200 dark:border-gray-700">
|
||||
{!isCollapsed && (
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="w-8 h-8 bg-gradient-to-br from-indigo-600 to-purple-600 rounded-lg flex items-center justify-center">
|
||||
<span className="text-white font-bold text-sm">AL</span>
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="w-10 h-10 flex items-center justify-center bg-white dark:bg-gray-700 rounded-lg p-1">
|
||||
{/* Seera Arabia Logo */}
|
||||
<img
|
||||
src="/seera-logo.png"
|
||||
alt="Seera Arabia"
|
||||
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-gray-900 dark:text-white text-xl font-bold">Asset Lite</h1>
|
||||
<h1 className="text-gray-900 dark:text-white text-lg font-semibold">Seera Arabia</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="/seera-logo.png"
|
||||
alt="Seera Arabia"
|
||||
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-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 p-2 rounded-lg transition-colors"
|
||||
>
|
||||
{isCollapsed ? <FaBars size={20} /> : <FaTimes size={20} />}
|
||||
{isCollapsed ? <Menu size={20} /> : <X size={20} />}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@ -211,7 +251,7 @@ const Sidebar: React.FC<SidebarProps> = ({ userEmail }) => {
|
||||
`}
|
||||
title={isCollapsed ? link.title : ''}
|
||||
>
|
||||
<span className="text-xl">{link.icon}</span>
|
||||
<span>{link.icon}</span>
|
||||
{!isCollapsed && (
|
||||
<span className="ml-4 font-medium">{link.title}</span>
|
||||
)}
|
||||
@ -227,7 +267,7 @@ const Sidebar: React.FC<SidebarProps> = ({ userEmail }) => {
|
||||
className="w-full flex items-center justify-center px-4 py-2 rounded-lg bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors text-gray-700 dark:text-gray-300"
|
||||
title={isCollapsed ? (theme === 'light' ? 'Dark Mode' : 'Light Mode') : ''}
|
||||
>
|
||||
{theme === 'light' ? <FaMoon size={16} /> : <FaSun size={16} />}
|
||||
{theme === 'light' ? <Moon size={18} /> : <Sun size={18} />}
|
||||
{!isCollapsed && (
|
||||
<span className="ml-2 text-sm font-medium">
|
||||
{theme === 'light' ? 'Dark Mode' : 'Light Mode'}
|
||||
@ -241,7 +281,7 @@ const Sidebar: React.FC<SidebarProps> = ({ userEmail }) => {
|
||||
className="w-full flex items-center justify-center px-4 py-2 rounded-lg bg-red-50 dark:bg-red-900/20 hover:bg-red-100 dark:hover:bg-red-900/30 transition-colors text-red-600 dark:text-red-400"
|
||||
title={isCollapsed ? 'Logout' : ''}
|
||||
>
|
||||
<FaSignOutAlt size={16} />
|
||||
<LogOut size={18} />
|
||||
{!isCollapsed && (
|
||||
<span className="ml-2 text-sm font-medium">Logout</span>
|
||||
)}
|
||||
@ -260,7 +300,7 @@ const Sidebar: React.FC<SidebarProps> = ({ userEmail }) => {
|
||||
|
||||
{!isCollapsed && (
|
||||
<div className="text-xs text-gray-400 dark:text-gray-500 text-center">
|
||||
Asset Lite v1.0
|
||||
Seera Arabia AMS v1.0
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@ -40,6 +40,35 @@ const API_CONFIG: ApiConfig = {
|
||||
GET_ASSET_STATS: '/api/method/asset_lite.api.asset_api.get_asset_stats',
|
||||
SEARCH_ASSETS: '/api/method/asset_lite.api.asset_api.search_assets',
|
||||
|
||||
// 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',
|
||||
|
||||
288
src/hooks/useAssetMaintenance.ts
Normal file
288
src/hooks/useAssetMaintenance.ts
Normal file
@ -0,0 +1,288 @@
|
||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import assetMaintenanceService from '../services/assetMaintenanceService';
|
||||
import type { AssetMaintenanceLog, MaintenanceFilters, CreateMaintenanceData } from '../services/assetMaintenanceService';
|
||||
|
||||
/**
|
||||
* Hook to fetch list of asset maintenance logs with filters and pagination
|
||||
*/
|
||||
export function useAssetMaintenanceLogs(
|
||||
filters?: MaintenanceFilters,
|
||||
limit: number = 20,
|
||||
offset: number = 0,
|
||||
orderBy?: string
|
||||
) {
|
||||
const [logs, setLogs] = useState<AssetMaintenanceLog[]>([]);
|
||||
const [totalCount, setTotalCount] = useState(0);
|
||||
const [hasMore, setHasMore] = useState(false);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [refetchTrigger, setRefetchTrigger] = useState(0);
|
||||
const hasAttemptedRef = useRef(false);
|
||||
|
||||
const filtersJson = JSON.stringify(filters);
|
||||
|
||||
useEffect(() => {
|
||||
if (hasAttemptedRef.current && error) {
|
||||
return;
|
||||
}
|
||||
|
||||
let isCancelled = false;
|
||||
hasAttemptedRef.current = true;
|
||||
|
||||
const fetchLogs = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
|
||||
const response = await assetMaintenanceService.getMaintenanceLogs(filters, undefined, limit, offset, orderBy);
|
||||
|
||||
if (!isCancelled) {
|
||||
setLogs(response.asset_maintenance_logs);
|
||||
setTotalCount(response.total_count);
|
||||
setHasMore(response.has_more);
|
||||
setError(null);
|
||||
}
|
||||
} catch (err) {
|
||||
if (!isCancelled) {
|
||||
const errorMessage = err instanceof Error ? err.message : 'Failed to fetch maintenance logs';
|
||||
|
||||
if (errorMessage.includes('417') || errorMessage.includes('Expectation Failed') || errorMessage.includes('has no attribute')) {
|
||||
setError('API endpoint not deployed. Please deploy asset_maintenance_api.py to your Frappe server.');
|
||||
} else {
|
||||
setError(errorMessage);
|
||||
}
|
||||
|
||||
setLogs([]);
|
||||
setTotalCount(0);
|
||||
setHasMore(false);
|
||||
}
|
||||
} finally {
|
||||
if (!isCancelled) {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
fetchLogs();
|
||||
|
||||
return () => {
|
||||
isCancelled = true;
|
||||
};
|
||||
}, [filtersJson, limit, offset, orderBy, refetchTrigger]);
|
||||
|
||||
const refetch = useCallback(() => {
|
||||
hasAttemptedRef.current = false;
|
||||
setRefetchTrigger(prev => prev + 1);
|
||||
}, []);
|
||||
|
||||
return { logs, totalCount, hasMore, loading, error, refetch };
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to fetch a single maintenance log by name
|
||||
*/
|
||||
export function useMaintenanceLogDetails(logName: string | null) {
|
||||
const [log, setLog] = useState<AssetMaintenanceLog | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const fetchLog = useCallback(async () => {
|
||||
if (!logName) {
|
||||
setLog(null);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
const data = await assetMaintenanceService.getMaintenanceLogDetails(logName);
|
||||
setLog(data);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to fetch maintenance log details');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [logName]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchLog();
|
||||
}, [fetchLog]);
|
||||
|
||||
const refetch = useCallback(() => {
|
||||
fetchLog();
|
||||
}, [fetchLog]);
|
||||
|
||||
return { log, loading, error, refetch };
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to manage maintenance log operations
|
||||
*/
|
||||
export function useMaintenanceMutations() {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const createLog = async (logData: CreateMaintenanceData) => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
console.log('[useMaintenanceMutations] Creating maintenance log:', logData);
|
||||
const response = await assetMaintenanceService.createMaintenanceLog(logData);
|
||||
console.log('[useMaintenanceMutations] Create response:', response);
|
||||
|
||||
if (response.success) {
|
||||
return response.asset_maintenance_log;
|
||||
} else {
|
||||
const backendError = (response as any).error || 'Failed to create maintenance log';
|
||||
throw new Error(backendError);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[useMaintenanceMutations] Create error:', err);
|
||||
const errorMessage = err instanceof Error ? err.message : 'Failed to create maintenance log';
|
||||
setError(errorMessage);
|
||||
throw err;
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const updateLog = async (logName: string, logData: Partial<CreateMaintenanceData>) => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
console.log('[useMaintenanceMutations] Updating maintenance log:', logName, logData);
|
||||
const response = await assetMaintenanceService.updateMaintenanceLog(logName, logData);
|
||||
console.log('[useMaintenanceMutations] Update response:', response);
|
||||
|
||||
if (response.success) {
|
||||
return response.asset_maintenance_log;
|
||||
} else {
|
||||
const backendError = (response as any).error || 'Failed to update maintenance log';
|
||||
throw new Error(backendError);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[useMaintenanceMutations] Update error:', err);
|
||||
const errorMessage = err instanceof Error ? err.message : 'Failed to update maintenance log';
|
||||
setError(errorMessage);
|
||||
throw err;
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const deleteLog = async (logName: string) => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
const response = await assetMaintenanceService.deleteMaintenanceLog(logName);
|
||||
|
||||
if (!response.success) {
|
||||
throw new Error('Failed to delete maintenance log');
|
||||
}
|
||||
|
||||
return response;
|
||||
} catch (err) {
|
||||
const errorMessage = err instanceof Error ? err.message : 'Failed to delete maintenance log';
|
||||
setError(errorMessage);
|
||||
throw err;
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const updateStatus = async (logName: string, maintenanceStatus?: string, workflowState?: string) => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
const response = await assetMaintenanceService.updateMaintenanceStatus(logName, maintenanceStatus, workflowState);
|
||||
|
||||
if (response.success) {
|
||||
return response.asset_maintenance_log;
|
||||
} else {
|
||||
throw new Error('Failed to update maintenance status');
|
||||
}
|
||||
} catch (err) {
|
||||
const errorMessage = err instanceof Error ? err.message : 'Failed to update status';
|
||||
setError(errorMessage);
|
||||
throw err;
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return { createLog, updateLog, deleteLog, updateStatus, loading, error };
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to fetch maintenance logs for a specific asset
|
||||
*/
|
||||
export function useAssetMaintenanceHistory(assetName: string | null) {
|
||||
const [logs, setLogs] = useState<AssetMaintenanceLog[]>([]);
|
||||
const [totalCount, setTotalCount] = useState(0);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const fetchHistory = useCallback(async () => {
|
||||
if (!assetName) {
|
||||
setLogs([]);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
const response = await assetMaintenanceService.getMaintenanceLogsByAsset(assetName);
|
||||
setLogs(response.asset_maintenance_logs);
|
||||
setTotalCount(response.total_count);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to fetch maintenance history');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [assetName]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchHistory();
|
||||
}, [fetchHistory]);
|
||||
|
||||
return { logs, totalCount, loading, error, refetch: fetchHistory };
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to fetch overdue maintenance logs
|
||||
*/
|
||||
export function useOverdueMaintenanceLogs() {
|
||||
const [logs, setLogs] = useState<AssetMaintenanceLog[]>([]);
|
||||
const [totalCount, setTotalCount] = useState(0);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const fetchOverdue = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
const response = await assetMaintenanceService.getOverdueMaintenanceLogs();
|
||||
setLogs(response.asset_maintenance_logs);
|
||||
setTotalCount(response.total_count);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to fetch overdue maintenance');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchOverdue();
|
||||
}, [fetchOverdue]);
|
||||
|
||||
return { logs, totalCount, loading, error, refetch: fetchOverdue };
|
||||
}
|
||||
|
||||
174
src/hooks/usePPM.ts
Normal file
174
src/hooks/usePPM.ts
Normal file
@ -0,0 +1,174 @@
|
||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import ppmService from '../services/ppmService';
|
||||
import type { AssetMaintenance, PPMFilters, CreatePPMData } from '../services/ppmService';
|
||||
|
||||
/**
|
||||
* Hook to fetch list of asset maintenances (PPM schedules) with filters and pagination
|
||||
*/
|
||||
export function usePPMs(
|
||||
filters?: PPMFilters,
|
||||
limit: number = 20,
|
||||
offset: number = 0,
|
||||
orderBy?: string
|
||||
) {
|
||||
const [ppms, setPPMs] = useState<AssetMaintenance[]>([]);
|
||||
const [totalCount, setTotalCount] = useState(0);
|
||||
const [hasMore, setHasMore] = useState(false);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [refetchTrigger, setRefetchTrigger] = useState(0);
|
||||
const hasAttemptedRef = useRef(false);
|
||||
|
||||
const filtersJson = JSON.stringify(filters);
|
||||
|
||||
useEffect(() => {
|
||||
if (hasAttemptedRef.current && error) {
|
||||
return;
|
||||
}
|
||||
|
||||
let isCancelled = false;
|
||||
hasAttemptedRef.current = true;
|
||||
|
||||
const fetchPPMs = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
|
||||
const response = await ppmService.getAssetMaintenances(filters, undefined, limit, offset, orderBy);
|
||||
|
||||
if (!isCancelled) {
|
||||
setPPMs(response.asset_maintenances);
|
||||
setTotalCount(response.total_count);
|
||||
setHasMore(response.has_more);
|
||||
setError(null);
|
||||
}
|
||||
} catch (err) {
|
||||
if (!isCancelled) {
|
||||
const errorMessage = err instanceof Error ? err.message : 'Failed to fetch PPM schedules';
|
||||
|
||||
if (errorMessage.includes('417') || errorMessage.includes('Expectation Failed') || errorMessage.includes('has no attribute')) {
|
||||
setError('API endpoint not deployed. Please deploy ppm_api.py to your Frappe server.');
|
||||
} else {
|
||||
setError(errorMessage);
|
||||
}
|
||||
|
||||
setPPMs([]);
|
||||
setTotalCount(0);
|
||||
setHasMore(false);
|
||||
}
|
||||
} finally {
|
||||
if (!isCancelled) {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
fetchPPMs();
|
||||
|
||||
return () => {
|
||||
isCancelled = true;
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [filtersJson, limit, offset, orderBy, refetchTrigger]);
|
||||
|
||||
const refetch = useCallback(() => {
|
||||
hasAttemptedRef.current = false;
|
||||
setRefetchTrigger(prev => prev + 1);
|
||||
}, []);
|
||||
|
||||
return { ppms, totalCount, hasMore, loading, error, refetch };
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to fetch a single PPM schedule by name
|
||||
*/
|
||||
export function usePPMDetails(ppmName: string | null) {
|
||||
const [ppm, setPPM] = useState<AssetMaintenance | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const fetchPPM = useCallback(async () => {
|
||||
if (!ppmName) {
|
||||
setPPM(null);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
const data = await ppmService.getAssetMaintenanceDetails(ppmName);
|
||||
setPPM(data);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to fetch PPM details');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [ppmName]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchPPM();
|
||||
}, [fetchPPM]);
|
||||
|
||||
const refetch = useCallback(() => {
|
||||
fetchPPM();
|
||||
}, [fetchPPM]);
|
||||
|
||||
return { ppm, loading, error, refetch };
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to manage PPM operations (create, update, delete)
|
||||
*/
|
||||
export function usePPMMutations() {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const createPPM = useCallback(async (data: CreatePPMData) => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const result = await ppmService.createAssetMaintenance(data);
|
||||
return result;
|
||||
} catch (err) {
|
||||
const errorMessage = err instanceof Error ? err.message : 'Failed to create PPM schedule';
|
||||
setError(errorMessage);
|
||||
throw err;
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const updatePPM = useCallback(async (ppmName: string, data: Partial<CreatePPMData>) => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const result = await ppmService.updateAssetMaintenance(ppmName, data);
|
||||
return result;
|
||||
} catch (err) {
|
||||
const errorMessage = err instanceof Error ? err.message : 'Failed to update PPM schedule';
|
||||
setError(errorMessage);
|
||||
throw err;
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const deletePPM = useCallback(async (ppmName: string) => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const result = await ppmService.deleteAssetMaintenance(ppmName);
|
||||
return result;
|
||||
} catch (err) {
|
||||
const errorMessage = err instanceof Error ? err.message : 'Failed to delete PPM schedule';
|
||||
setError(errorMessage);
|
||||
throw err;
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
return { createPPM, updatePPM, deletePPM, loading, error };
|
||||
}
|
||||
|
||||
220
src/hooks/useWorkOrder.ts
Normal file
220
src/hooks/useWorkOrder.ts
Normal file
@ -0,0 +1,220 @@
|
||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import workOrderService from '../services/workOrderService';
|
||||
import type { WorkOrder, WorkOrderFilters, CreateWorkOrderData } from '../services/workOrderService';
|
||||
|
||||
/**
|
||||
* Hook to fetch list of work orders with filters and pagination
|
||||
*/
|
||||
export function useWorkOrders(
|
||||
filters?: WorkOrderFilters,
|
||||
limit: number = 20,
|
||||
offset: number = 0,
|
||||
orderBy?: string
|
||||
) {
|
||||
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);
|
||||
|
||||
const filtersJson = JSON.stringify(filters);
|
||||
|
||||
useEffect(() => {
|
||||
if (hasAttemptedRef.current && error) {
|
||||
return;
|
||||
}
|
||||
|
||||
let isCancelled = false;
|
||||
hasAttemptedRef.current = true;
|
||||
|
||||
const fetchWorkOrders = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
|
||||
const response = await workOrderService.getWorkOrders(filters, undefined, limit, offset, orderBy);
|
||||
|
||||
if (!isCancelled) {
|
||||
setWorkOrders(response.work_orders);
|
||||
setTotalCount(response.total_count);
|
||||
setHasMore(response.has_more);
|
||||
setError(null);
|
||||
}
|
||||
} catch (err) {
|
||||
if (!isCancelled) {
|
||||
const errorMessage = err instanceof Error ? err.message : 'Failed to fetch work orders';
|
||||
|
||||
if (errorMessage.includes('417') || errorMessage.includes('Expectation Failed') || errorMessage.includes('has no attribute')) {
|
||||
setError('API endpoint not deployed. Please deploy work_order_api.py to your Frappe server.');
|
||||
} else {
|
||||
setError(errorMessage);
|
||||
}
|
||||
|
||||
setWorkOrders([]);
|
||||
setTotalCount(0);
|
||||
setHasMore(false);
|
||||
}
|
||||
} finally {
|
||||
if (!isCancelled) {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
fetchWorkOrders();
|
||||
|
||||
return () => {
|
||||
isCancelled = true;
|
||||
};
|
||||
}, [filtersJson, limit, offset, orderBy, refetchTrigger]);
|
||||
|
||||
const refetch = useCallback(() => {
|
||||
hasAttemptedRef.current = false;
|
||||
setRefetchTrigger(prev => prev + 1);
|
||||
}, []);
|
||||
|
||||
return { workOrders, totalCount, hasMore, loading, error, refetch };
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to fetch a single work order by name
|
||||
*/
|
||||
export function useWorkOrderDetails(workOrderName: string | null) {
|
||||
const [workOrder, setWorkOrder] = useState<WorkOrder | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const fetchWorkOrder = useCallback(async () => {
|
||||
if (!workOrderName) {
|
||||
setWorkOrder(null);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
const data = await workOrderService.getWorkOrderDetails(workOrderName);
|
||||
setWorkOrder(data);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to fetch work order details');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [workOrderName]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchWorkOrder();
|
||||
}, [fetchWorkOrder]);
|
||||
|
||||
const refetch = useCallback(() => {
|
||||
fetchWorkOrder();
|
||||
}, [fetchWorkOrder]);
|
||||
|
||||
return { workOrder, loading, error, refetch };
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to manage work order operations (create, update, delete)
|
||||
*/
|
||||
export function useWorkOrderMutations() {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const createWorkOrder = async (workOrderData: CreateWorkOrderData) => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
console.log('[useWorkOrderMutations] Creating work order with data:', workOrderData);
|
||||
const response = await workOrderService.createWorkOrder(workOrderData);
|
||||
console.log('[useWorkOrderMutations] Create work order response:', response);
|
||||
|
||||
if (response.success) {
|
||||
return response.work_order;
|
||||
} else {
|
||||
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 {
|
||||
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 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, updateStatus, loading, error };
|
||||
}
|
||||
|
||||
@ -1,3 +1,5 @@
|
||||
@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;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
423
src/pages/AssetMaintenanceDetail.tsx
Normal file
423
src/pages/AssetMaintenanceDetail.tsx
Normal file
@ -0,0 +1,423 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useParams, useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import { useMaintenanceLogDetails, useMaintenanceMutations } from '../hooks/useAssetMaintenance';
|
||||
import { FaArrowLeft, FaSave, FaEdit, FaCheckCircle, FaClock } from 'react-icons/fa';
|
||||
import type { CreateMaintenanceData } from '../services/assetMaintenanceService';
|
||||
|
||||
const AssetMaintenanceDetail: React.FC = () => {
|
||||
const { logName } = useParams<{ logName: string }>();
|
||||
const navigate = useNavigate();
|
||||
const [searchParams] = useSearchParams();
|
||||
const duplicateFromLog = searchParams.get('duplicate');
|
||||
|
||||
const isNewLog = logName === 'new';
|
||||
const isDuplicating = isNewLog && !!duplicateFromLog;
|
||||
|
||||
const { log, loading, error } = useMaintenanceLogDetails(
|
||||
isDuplicating ? duplicateFromLog : (isNewLog ? null : logName || null)
|
||||
);
|
||||
const { createLog, updateLog, updateStatus, loading: saving } = useMaintenanceMutations();
|
||||
|
||||
const [isEditing, setIsEditing] = useState(isNewLog);
|
||||
const [formData, setFormData] = useState<CreateMaintenanceData>({
|
||||
asset_name: '',
|
||||
task: '',
|
||||
task_name: '',
|
||||
maintenance_type: 'Preventive',
|
||||
periodicity: '',
|
||||
maintenance_status: 'Planned',
|
||||
due_date: '',
|
||||
assign_to_name: '',
|
||||
description: '',
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (log) {
|
||||
setFormData({
|
||||
asset_name: log.asset_name || '',
|
||||
task: log.task || '',
|
||||
task_name: log.task_name || '',
|
||||
maintenance_type: log.maintenance_type || 'Preventive',
|
||||
periodicity: log.periodicity || '',
|
||||
maintenance_status: isDuplicating ? 'Planned' : (log.maintenance_status || 'Planned'),
|
||||
due_date: log.due_date || '',
|
||||
assign_to_name: log.assign_to_name || '',
|
||||
description: '',
|
||||
});
|
||||
}
|
||||
}, [log, isDuplicating]);
|
||||
|
||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement>) => {
|
||||
const { name, value } = e.target;
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
[name]: value
|
||||
}));
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!formData.asset_name) {
|
||||
alert('Please enter Asset Name');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!formData.maintenance_type) {
|
||||
alert('Please select Maintenance Type');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('Submitting maintenance log data:', formData);
|
||||
|
||||
try {
|
||||
if (isNewLog || isDuplicating) {
|
||||
const newLog = await createLog(formData);
|
||||
const successMessage = isDuplicating
|
||||
? 'Maintenance log duplicated successfully!'
|
||||
: 'Maintenance log created successfully!';
|
||||
alert(successMessage);
|
||||
navigate(`/maintenance/${newLog.name}`);
|
||||
} else if (logName) {
|
||||
await updateLog(logName, formData);
|
||||
alert('Maintenance log updated successfully!');
|
||||
setIsEditing(false);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Maintenance log save error:', err);
|
||||
alert('Failed to save: ' + (err instanceof Error ? err.message : 'Unknown error'));
|
||||
}
|
||||
};
|
||||
|
||||
const handleStatusUpdate = async (newStatus: string) => {
|
||||
if (!logName || isNewLog) return;
|
||||
|
||||
try {
|
||||
await updateStatus(logName, newStatus);
|
||||
alert('Status updated successfully!');
|
||||
window.location.reload();
|
||||
} catch (err) {
|
||||
alert('Failed to update status: ' + (err instanceof Error ? err.message : 'Unknown error'));
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-screen bg-gray-50 dark:bg-gray-900">
|
||||
<div className="text-center">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-500 mx-auto"></div>
|
||||
<p className="mt-4 text-gray-600 dark:text-gray-400">Loading maintenance log...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error && !isNewLog && !isDuplicating) {
|
||||
return (
|
||||
<div className="p-6 bg-gray-50 dark:bg-gray-900 min-h-screen">
|
||||
<div className="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-4">
|
||||
<p className="text-red-600 dark:text-red-400">Error: {error}</p>
|
||||
<button
|
||||
onClick={() => navigate('/maintenance')}
|
||||
className="mt-2 text-red-700 dark:text-red-400 underline hover:text-red-800 dark:hover:text-red-300"
|
||||
>
|
||||
Back to maintenance logs
|
||||
</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('/maintenance')}
|
||||
className="text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300 flex items-center gap-2"
|
||||
>
|
||||
<FaArrowLeft />
|
||||
<span className="text-gray-900 dark:text-white">
|
||||
{isDuplicating ? 'Duplicate Maintenance Log' : (isNewLog ? 'New Maintenance Log' : 'Maintenance Log Details')}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
{!isNewLog && !isEditing && (
|
||||
<>
|
||||
<button
|
||||
onClick={() => setIsEditing(true)}
|
||||
className="bg-blue-600 hover:bg-blue-700 text-white px-6 py-2 rounded-lg flex items-center gap-2"
|
||||
>
|
||||
<FaEdit />
|
||||
Edit
|
||||
</button>
|
||||
{log?.maintenance_status !== 'Completed' && (
|
||||
<button
|
||||
onClick={() => handleStatusUpdate('Completed')}
|
||||
className="bg-green-600 hover:bg-green-700 text-white px-4 py-2 rounded-lg flex items-center gap-2"
|
||||
disabled={saving}
|
||||
>
|
||||
<FaCheckCircle />
|
||||
Mark Complete
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{isEditing && (
|
||||
<>
|
||||
<button
|
||||
onClick={() => {
|
||||
if (isNewLog) {
|
||||
navigate('/maintenance');
|
||||
} else {
|
||||
setIsEditing(false);
|
||||
}
|
||||
}}
|
||||
className="bg-gray-300 hover:bg-gray-400 text-gray-700 px-6 py-2 rounded-lg"
|
||||
disabled={saving}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
disabled={saving}
|
||||
className="bg-green-600 hover:bg-green-700 text-white px-6 py-2 rounded-lg flex items-center gap-2 disabled:opacity-50"
|
||||
>
|
||||
<FaSave />
|
||||
{saving ? 'Saving...' : 'Save Changes'}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
{/* Left Column */}
|
||||
<div className="lg:col-span-2 space-y-6">
|
||||
{/* Maintenance 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-base font-semibold text-gray-800 dark:text-white mb-4 pb-2 border-b border-gray-200 dark:border-gray-700">Maintenance Information</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="md:col-span-2">
|
||||
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Log ID
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={isNewLog || isDuplicating ? 'Auto-generated' : log?.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-700 text-gray-900 dark:text-white"
|
||||
/>
|
||||
{isDuplicating && (
|
||||
<p className="mt-1 text-xs text-blue-600 dark:text-blue-400">
|
||||
💡 Duplicating from: {duplicateFromLog}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Asset Name <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
name="asset_name"
|
||||
value={formData.asset_name}
|
||||
onChange={handleChange}
|
||||
required
|
||||
disabled={!isEditing}
|
||||
placeholder="Asset name or ID"
|
||||
className="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"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Maintenance Type <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<select
|
||||
name="maintenance_type"
|
||||
value={formData.maintenance_type}
|
||||
onChange={handleChange}
|
||||
required
|
||||
disabled={!isEditing}
|
||||
className="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"
|
||||
>
|
||||
<option value="Preventive">Preventive</option>
|
||||
<option value="Corrective">Corrective</option>
|
||||
<option value="Calibration">Calibration</option>
|
||||
<option value="Inspection">Inspection</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Task Name
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
name="task_name"
|
||||
value={formData.task_name}
|
||||
onChange={handleChange}
|
||||
disabled={!isEditing}
|
||||
placeholder="Maintenance task name"
|
||||
className="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"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Periodicity
|
||||
</label>
|
||||
<select
|
||||
name="periodicity"
|
||||
value={formData.periodicity}
|
||||
onChange={handleChange}
|
||||
disabled={!isEditing}
|
||||
className="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"
|
||||
>
|
||||
<option value="">Select periodicity</option>
|
||||
<option value="Daily">Daily</option>
|
||||
<option value="Weekly">Weekly</option>
|
||||
<option value="Monthly">Monthly</option>
|
||||
<option value="Quarterly">Quarterly</option>
|
||||
<option value="Half-yearly">Half-yearly</option>
|
||||
<option value="Yearly">Yearly</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Status
|
||||
</label>
|
||||
<select
|
||||
name="maintenance_status"
|
||||
value={formData.maintenance_status}
|
||||
onChange={handleChange}
|
||||
disabled={!isEditing}
|
||||
className="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"
|
||||
>
|
||||
<option value="Planned">Planned</option>
|
||||
<option value="Completed">Completed</option>
|
||||
<option value="Overdue">Overdue</option>
|
||||
<option value="Cancelled">Cancelled</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Due Date
|
||||
</label>
|
||||
<input
|
||||
type="date"
|
||||
name="due_date"
|
||||
value={formData.due_date}
|
||||
onChange={handleChange}
|
||||
disabled={!isEditing}
|
||||
className="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"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Assigned To
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
name="assign_to_name"
|
||||
value={formData.assign_to_name}
|
||||
onChange={handleChange}
|
||||
disabled={!isEditing}
|
||||
placeholder="Technician name"
|
||||
className="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"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="md:col-span-2">
|
||||
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Description / Notes
|
||||
</label>
|
||||
<textarea
|
||||
name="description"
|
||||
value={formData.description}
|
||||
onChange={handleChange}
|
||||
rows={4}
|
||||
disabled={!isEditing}
|
||||
placeholder="Maintenance notes and details..."
|
||||
className="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"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right Column - Status Summary */}
|
||||
<div className="space-y-6">
|
||||
<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">Status Summary</h2>
|
||||
|
||||
{!isNewLog && log && (
|
||||
<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">Current Status</p>
|
||||
<p className="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
{log.maintenance_status || 'Planned'}
|
||||
</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">Due Date</p>
|
||||
<p className="text-sm text-gray-900 dark:text-white">
|
||||
{log.due_date ? new Date(log.due_date).toLocaleDateString() : 'Not set'}
|
||||
</p>
|
||||
{log.due_date && new Date(log.due_date) < new Date() && log.maintenance_status !== 'Completed' && (
|
||||
<p className="text-xs text-red-600 dark:text-red-400 font-semibold mt-1">
|
||||
⚠️ Overdue
|
||||
</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">Type</p>
|
||||
<p className="text-sm text-gray-900 dark:text-white">
|
||||
{log.maintenance_type || 'N/A'}
|
||||
</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">Assigned To</p>
|
||||
<p className="text-sm text-gray-900 dark:text-white">
|
||||
{log.assign_to_name || 'Unassigned'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="p-4 bg-gray-50 dark:bg-gray-700 rounded-lg">
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mb-1">Created</p>
|
||||
<p className="text-xs text-gray-900 dark:text-white">
|
||||
{log.creation ? new Date(log.creation).toLocaleString() : '-'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isNewLog && (
|
||||
<div className="text-center py-8">
|
||||
<FaClock 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">
|
||||
Status information will appear after creation
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AssetMaintenanceDetail;
|
||||
|
||||
499
src/pages/AssetMaintenanceList.tsx
Normal file
499
src/pages/AssetMaintenanceList.tsx
Normal file
@ -0,0 +1,499 @@
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
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 navigate = useNavigate();
|
||||
const [page, setPage] = useState(0);
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [statusFilter, setStatusFilter] = useState<string>('');
|
||||
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState<string | null>(null);
|
||||
const [actionMenuOpen, setActionMenuOpen] = useState<string | null>(null);
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
const limit = 20;
|
||||
|
||||
const filters = statusFilter ? { maintenance_status: statusFilter } : {};
|
||||
|
||||
const { logs, totalCount, hasMore, loading, error, refetch } = useAssetMaintenanceLogs(
|
||||
filters,
|
||||
limit,
|
||||
page * limit,
|
||||
'due_date asc'
|
||||
);
|
||||
|
||||
const { deleteLog, loading: mutationLoading } = useMaintenanceMutations();
|
||||
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
|
||||
setActionMenuOpen(null);
|
||||
}
|
||||
};
|
||||
|
||||
if (actionMenuOpen) {
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
}
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClickOutside);
|
||||
};
|
||||
}, [actionMenuOpen]);
|
||||
|
||||
const handleCreateNew = () => {
|
||||
navigate('/maintenance/new');
|
||||
};
|
||||
|
||||
const handleView = (logName: string) => {
|
||||
navigate(`/maintenance/${logName}`);
|
||||
};
|
||||
|
||||
const handleEdit = (logName: string) => {
|
||||
navigate(`/maintenance/${logName}`);
|
||||
};
|
||||
|
||||
const handleDelete = async (logName: string) => {
|
||||
try {
|
||||
await deleteLog(logName);
|
||||
setDeleteConfirmOpen(null);
|
||||
refetch();
|
||||
alert('Maintenance log deleted successfully!');
|
||||
} catch (err) {
|
||||
alert(`Failed to delete: ${err instanceof Error ? err.message : 'Unknown error'}`);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDuplicate = (logName: string) => {
|
||||
navigate(`/maintenance/new?duplicate=${logName}`);
|
||||
};
|
||||
|
||||
const handleExport = (log: any) => {
|
||||
const dataStr = JSON.stringify(log, null, 2);
|
||||
const dataBlob = new Blob([dataStr], { type: 'application/json' });
|
||||
const url = URL.createObjectURL(dataBlob);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = `maintenance_${log.name}.json`;
|
||||
link.click();
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
const handlePrint = (logName: string) => {
|
||||
window.open(`/maintenance/${logName}?print=true`, '_blank');
|
||||
};
|
||||
|
||||
const handleExportAll = () => {
|
||||
const headers = ['Log ID', 'Asset', 'Type', 'Status', 'Due Date', 'Assigned To'];
|
||||
const csvContent = [
|
||||
headers.join(','),
|
||||
...logs.map(log => [
|
||||
log.name,
|
||||
log.asset_name || '',
|
||||
log.maintenance_type || '',
|
||||
log.maintenance_status || '',
|
||||
log.due_date || '',
|
||||
log.assign_to_name || ''
|
||||
].join(','))
|
||||
].join('\n');
|
||||
|
||||
const dataBlob = new Blob([csvContent], { type: 'text/csv' });
|
||||
const url = URL.createObjectURL(dataBlob);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = `maintenance_logs_${new Date().toISOString().split('T')[0]}.csv`;
|
||||
link.click();
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
const getStatusIcon = (status: string) => {
|
||||
switch (status?.toLowerCase()) {
|
||||
case 'completed':
|
||||
return <FaCheckCircle className="text-green-500" />;
|
||||
case 'planned':
|
||||
return <FaCalendarCheck className="text-blue-500" />;
|
||||
case 'overdue':
|
||||
return <FaExclamationTriangle className="text-red-500" />;
|
||||
default:
|
||||
return <FaClock className="text-gray-400" />;
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusColor = (status: string) => {
|
||||
switch (status?.toLowerCase()) {
|
||||
case 'completed':
|
||||
return 'bg-green-100 dark:bg-green-900/30 text-green-800 dark:text-green-300';
|
||||
case 'planned':
|
||||
return 'bg-blue-100 dark:bg-blue-900/30 text-blue-800 dark:text-blue-300';
|
||||
case 'overdue':
|
||||
return 'bg-red-100 dark:bg-red-900/30 text-red-800 dark:text-red-300';
|
||||
case 'cancelled':
|
||||
return 'bg-gray-100 dark:bg-gray-700 text-gray-800 dark:text-gray-300';
|
||||
default:
|
||||
return 'bg-yellow-100 dark:bg-yellow-900/30 text-yellow-800 dark:text-yellow-300';
|
||||
}
|
||||
};
|
||||
|
||||
const isOverdue = (dueDate: string, status: string) => {
|
||||
if (!dueDate || status?.toLowerCase() === 'completed') return false;
|
||||
return new Date(dueDate) < new Date();
|
||||
};
|
||||
|
||||
if (loading && page === 0) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-screen bg-gray-50 dark:bg-gray-900">
|
||||
<div className="text-center">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-500 mx-auto"></div>
|
||||
<p className="mt-4 text-gray-600 dark:text-gray-400">Loading maintenance logs...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="p-6 bg-gray-50 dark:bg-gray-900 min-h-screen">
|
||||
<div className="bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-lg p-6">
|
||||
<h2 className="text-xl font-bold text-yellow-800 dark:text-yellow-300 mb-4">⚠️ Maintenance API Not Available</h2>
|
||||
<div className="text-yellow-700 dark:text-yellow-400 space-y-3">
|
||||
<p><strong>The Asset Maintenance API endpoint is not deployed yet.</strong></p>
|
||||
<div className="mt-4 flex gap-3">
|
||||
<button
|
||||
onClick={() => navigate('/maintenance/new')}
|
||||
className="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded"
|
||||
>
|
||||
Try Creating New (Demo)
|
||||
</button>
|
||||
<button
|
||||
onClick={refetch}
|
||||
className="bg-gray-600 hover:bg-gray-700 text-white px-4 py-2 rounded"
|
||||
>
|
||||
Try Again
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4 p-4 bg-white dark:bg-gray-800 rounded border border-yellow-300 dark:border-yellow-700">
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
<strong>Technical Error:</strong> {error}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const filteredLogs = logs.filter(log =>
|
||||
log.name?.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
log.asset_name?.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
log.task_name?.toLowerCase().includes(searchTerm.toLowerCase())
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="p-6 bg-gray-50 dark:bg-gray-900 min-h-screen">
|
||||
{/* Header */}
|
||||
<div className="mb-6 flex justify-between items-center">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-gray-800 dark:text-white">Asset Maintenance</h1>
|
||||
<p className="text-gray-600 dark:text-gray-400 mt-1">
|
||||
Total: {totalCount} maintenance log{totalCount !== 1 ? 's' : ''}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
onClick={handleExportAll}
|
||||
className="bg-green-600 hover:bg-green-700 text-white px-4 py-3 rounded-lg flex items-center gap-2 shadow transition-all"
|
||||
disabled={logs.length === 0}
|
||||
>
|
||||
<FaFileExport />
|
||||
<span className="font-medium">Export All</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={handleCreateNew}
|
||||
className="bg-blue-600 hover:bg-blue-700 text-white px-6 py-3 rounded-lg flex items-center gap-2 shadow-lg transition-all hover:shadow-xl"
|
||||
>
|
||||
<FaPlus />
|
||||
<span className="font-medium">New Maintenance Log</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filters Bar */}
|
||||
<div className="mb-6 grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-4">
|
||||
<div className="flex items-center gap-2 border border-gray-300 dark:border-gray-600 rounded-lg px-4 py-2 bg-white dark:bg-gray-700">
|
||||
<FaSearch className="text-gray-400 dark:text-gray-500" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search by ID, asset, task..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="flex-1 outline-none text-gray-700 dark:text-gray-200 bg-transparent"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-4">
|
||||
<select
|
||||
value={statusFilter}
|
||||
onChange={(e) => {
|
||||
setStatusFilter(e.target.value);
|
||||
setPage(0);
|
||||
}}
|
||||
className="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-700 dark:text-gray-200 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
>
|
||||
<option value="">All Statuses</option>
|
||||
<option value="Planned">Planned</option>
|
||||
<option value="Completed">Completed</option>
|
||||
<option value="Overdue">Overdue</option>
|
||||
<option value="Cancelled">Cancelled</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Maintenance Logs Table */}
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow overflow-hidden">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead className="bg-gray-100 dark:bg-gray-700 border-b border-gray-200 dark:border-gray-600">
|
||||
<tr>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
Log ID
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
Asset
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
Type
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
Due Date
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
Status
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
Actions
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white dark:bg-gray-800 divide-y divide-gray-200 dark:divide-gray-700">
|
||||
{filteredLogs.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={6} className="px-6 py-12 text-center text-gray-500 dark:text-gray-400">
|
||||
<div className="flex flex-col items-center">
|
||||
<FaSearch className="text-4xl text-gray-300 dark:text-gray-600 mb-2" />
|
||||
<p>No maintenance logs found</p>
|
||||
<button
|
||||
onClick={handleCreateNew}
|
||||
className="mt-4 text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300 underline"
|
||||
>
|
||||
Create your first maintenance log
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
filteredLogs.map((log) => {
|
||||
const overdue = isOverdue(log.due_date || '', log.maintenance_status || '');
|
||||
|
||||
return (
|
||||
<tr
|
||||
key={log.name}
|
||||
className={`hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors cursor-pointer ${
|
||||
overdue ? 'bg-red-50 dark:bg-red-900/10' : ''
|
||||
}`}
|
||||
onClick={() => handleView(log.name)}
|
||||
>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="font-medium text-gray-900 dark:text-white">{log.name}</div>
|
||||
<div className="text-sm text-gray-500 dark:text-gray-400">
|
||||
{log.creation ? new Date(log.creation).toLocaleDateString() : ''}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="text-sm text-gray-900 dark:text-white">{log.asset_name || '-'}</div>
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400">{log.custom_asset_type || ''}</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-700 dark:text-gray-300">
|
||||
{log.maintenance_type || '-'}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="text-sm text-gray-900 dark:text-white">
|
||||
{log.due_date ? new Date(log.due_date).toLocaleDateString() : '-'}
|
||||
</div>
|
||||
{overdue && (
|
||||
<div className="text-xs text-red-600 dark:text-red-400 font-semibold">
|
||||
Overdue
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="flex items-center gap-2">
|
||||
{getStatusIcon(log.maintenance_status || '')}
|
||||
<span className={`px-3 py-1 inline-flex text-xs leading-5 font-semibold rounded-full ${getStatusColor(log.maintenance_status || '')}`}>
|
||||
{log.maintenance_status || 'Unknown'}
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium">
|
||||
<div className="flex items-center gap-1" onClick={(e) => e.stopPropagation()}>
|
||||
<button
|
||||
onClick={() => handleView(log.name)}
|
||||
className="text-blue-600 dark:text-blue-400 hover:text-blue-900 dark:hover:text-blue-300 p-2 hover:bg-blue-50 dark:hover:bg-blue-900/30 rounded transition-colors"
|
||||
title="View Details"
|
||||
>
|
||||
<FaEye />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleEdit(log.name)}
|
||||
className="text-green-600 dark:text-green-400 hover:text-green-900 dark:hover:text-green-300 p-2 hover:bg-green-50 dark:hover:bg-green-900/30 rounded transition-colors"
|
||||
title="Edit Log"
|
||||
>
|
||||
<FaEdit />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDuplicate(log.name)}
|
||||
className="text-purple-600 dark:text-purple-400 hover:text-purple-900 dark:hover:text-purple-300 p-2 hover:bg-purple-50 dark:hover:bg-purple-900/30 rounded transition-colors"
|
||||
title="Duplicate"
|
||||
>
|
||||
<FaCopy />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setDeleteConfirmOpen(log.name)}
|
||||
className="text-red-600 dark:text-red-400 hover:text-red-900 dark:hover:text-red-300 p-2 hover:bg-red-50 dark:hover:bg-red-900/30 rounded transition-colors"
|
||||
title="Delete"
|
||||
disabled={mutationLoading}
|
||||
>
|
||||
<FaTrash />
|
||||
</button>
|
||||
|
||||
<div className="relative" ref={actionMenuOpen === log.name ? dropdownRef : null}>
|
||||
<button
|
||||
onClick={() => setActionMenuOpen(actionMenuOpen === log.name ? null : log.name)}
|
||||
className="text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200 p-2 hover:bg-gray-50 dark:hover:bg-gray-700 rounded transition-colors"
|
||||
title="More Actions"
|
||||
>
|
||||
<FaEllipsisV />
|
||||
</button>
|
||||
|
||||
{actionMenuOpen === log.name && (
|
||||
<div className="absolute right-0 mt-2 w-48 bg-white dark:bg-gray-800 rounded-lg shadow-lg border border-gray-200 dark:border-gray-700 z-10">
|
||||
<button
|
||||
onClick={() => {
|
||||
handleExport(log);
|
||||
setActionMenuOpen(null);
|
||||
}}
|
||||
className="w-full text-left px-4 py-2 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 flex items-center gap-2 rounded-t-lg"
|
||||
>
|
||||
<FaDownload className="text-blue-500" />
|
||||
Export as JSON
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
handlePrint(log.name);
|
||||
setActionMenuOpen(null);
|
||||
}}
|
||||
className="w-full text-left px-4 py-2 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 flex items-center gap-2 rounded-b-lg"
|
||||
>
|
||||
<FaPrint className="text-purple-500" />
|
||||
Print Log
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Pagination */}
|
||||
{filteredLogs.length > 0 && (
|
||||
<div className="bg-gray-50 dark:bg-gray-700 px-6 py-4 flex items-center justify-between border-t border-gray-200 dark:border-gray-600">
|
||||
<div className="text-sm text-gray-700 dark:text-gray-300">
|
||||
Showing <span className="font-medium">{page * limit + 1}</span> to{' '}
|
||||
<span className="font-medium">
|
||||
{Math.min((page + 1) * limit, totalCount)}
|
||||
</span>{' '}
|
||||
of <span className="font-medium">{totalCount}</span> results
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
disabled={page === 0}
|
||||
onClick={() => setPage(page - 1)}
|
||||
className="px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-md text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-800 hover:bg-gray-50 dark:hover:bg-gray-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
Previous
|
||||
</button>
|
||||
<button
|
||||
disabled={!hasMore}
|
||||
onClick={() => setPage(page + 1)}
|
||||
className="px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-md text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-800 hover:bg-gray-50 dark:hover:bg-gray-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Delete Confirmation Modal */}
|
||||
{deleteConfirmOpen && (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg p-6 max-w-md w-full mx-4 shadow-2xl">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="flex-shrink-0 w-12 h-12 rounded-full bg-red-100 dark:bg-red-900/30 flex items-center justify-center">
|
||||
<FaTrash className="text-red-600 dark:text-red-400 text-xl" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-2">
|
||||
Delete Maintenance Log
|
||||
</h3>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400 mb-4">
|
||||
Are you sure you want to delete this maintenance log? This action cannot be undone.
|
||||
</p>
|
||||
<div className="bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-md p-3 mb-4">
|
||||
<p className="text-xs text-yellow-800 dark:text-yellow-300">
|
||||
<strong>Log ID:</strong> {deleteConfirmOpen}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-3 justify-end">
|
||||
<button
|
||||
onClick={() => setDeleteConfirmOpen(null)}
|
||||
className="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 rounded-lg transition-colors"
|
||||
disabled={mutationLoading}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDelete(deleteConfirmOpen)}
|
||||
className="px-4 py-2 text-sm font-medium text-white bg-red-600 hover:bg-red-700 rounded-lg transition-colors flex items-center gap-2 disabled:opacity-50"
|
||||
disabled={mutationLoading}
|
||||
>
|
||||
{mutationLoading ? (
|
||||
<>
|
||||
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white"></div>
|
||||
Deleting...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<FaTrash />
|
||||
Delete Log
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AssetMaintenanceList;
|
||||
|
||||
@ -70,11 +70,38 @@ const Login: React.FC = () => {
|
||||
<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>
|
||||
<h2 className="mt-6 text-center text-3xl font-extrabold text-gray-900 dark:text-white">
|
||||
Sign in to your account
|
||||
<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"
|
||||
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 text-gray-600 dark:text-gray-400">
|
||||
Connect to your Frappe backend
|
||||
<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>
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
0
src/pages/PPM.tsx
Normal file
0
src/pages/PPM.tsx
Normal file
406
src/pages/PPMDetail.tsx
Normal file
406
src/pages/PPMDetail.tsx
Normal file
@ -0,0 +1,406 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useParams, useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import { usePPMDetails, usePPMMutations } from '../hooks/usePPM';
|
||||
import { FaArrowLeft, FaSave, FaEdit, FaBuilding, FaTools, FaCalendarCheck, FaDollarSign } from 'react-icons/fa';
|
||||
import type { CreatePPMData } from '../services/ppmService';
|
||||
|
||||
const PPMDetail: React.FC = () => {
|
||||
const { ppmName } = useParams<{ ppmName: string }>();
|
||||
const navigate = useNavigate();
|
||||
const [searchParams] = useSearchParams();
|
||||
const duplicateFromPPM = searchParams.get('duplicate');
|
||||
|
||||
const isNewPPM = ppmName === 'new';
|
||||
const isDuplicating = isNewPPM && !!duplicateFromPPM;
|
||||
|
||||
const { ppm, loading, error, refetch } = usePPMDetails(
|
||||
isDuplicating ? duplicateFromPPM : (isNewPPM ? null : ppmName || null)
|
||||
);
|
||||
const { createPPM, updatePPM, loading: saving } = usePPMMutations();
|
||||
|
||||
const [isEditing, setIsEditing] = useState(isNewPPM);
|
||||
const [formData, setFormData] = useState<CreatePPMData>({
|
||||
company: '',
|
||||
asset_name: '',
|
||||
custom_asset_type: '',
|
||||
maintenance_team: '',
|
||||
custom_frequency: '',
|
||||
custom_total_amount: 0,
|
||||
custom_no_of_pms: 0,
|
||||
custom_price_per_pm: 0,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (ppm) {
|
||||
setFormData({
|
||||
company: ppm.company || '',
|
||||
asset_name: ppm.asset_name || '',
|
||||
custom_asset_type: ppm.custom_asset_type || '',
|
||||
maintenance_team: ppm.maintenance_team || '',
|
||||
custom_frequency: ppm.custom_frequency || '',
|
||||
custom_total_amount: ppm.custom_total_amount || 0,
|
||||
custom_no_of_pms: ppm.custom_no_of_pms || 0,
|
||||
custom_price_per_pm: ppm.custom_price_per_pm || 0,
|
||||
});
|
||||
}
|
||||
}, [ppm, isDuplicating]);
|
||||
|
||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement>) => {
|
||||
const { name, value } = e.target;
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
[name]: name.includes('amount') || name.includes('pms') || name.includes('price')
|
||||
? parseFloat(value) || 0
|
||||
: value
|
||||
}));
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!formData.asset_name) {
|
||||
alert('Please enter Asset Name');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (isNewPPM || isDuplicating) {
|
||||
const result = await createPPM(formData);
|
||||
const successMessage = isDuplicating
|
||||
? 'PPM schedule duplicated successfully!'
|
||||
: 'PPM schedule created successfully!';
|
||||
alert(successMessage);
|
||||
if (result.asset_maintenance?.name) {
|
||||
navigate(`/ppm/${result.asset_maintenance.name}`);
|
||||
} else {
|
||||
refetch();
|
||||
navigate('/ppm');
|
||||
}
|
||||
} else if (ppmName) {
|
||||
await updatePPM(ppmName, formData);
|
||||
alert('PPM schedule updated successfully!');
|
||||
setIsEditing(false);
|
||||
refetch();
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('PPM save error:', err);
|
||||
alert('Failed to save: ' + (err instanceof Error ? err.message : 'Unknown error'));
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-screen bg-gray-50 dark:bg-gray-900">
|
||||
<div className="text-center">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-500 mx-auto"></div>
|
||||
<p className="mt-4 text-gray-600 dark:text-gray-400">Loading PPM schedule...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error && !isNewPPM && !isDuplicating) {
|
||||
return (
|
||||
<div className="p-6 bg-gray-50 dark:bg-gray-900 min-h-screen">
|
||||
<div className="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-4">
|
||||
<p className="text-red-600 dark:text-red-400">Error: {error}</p>
|
||||
<button
|
||||
onClick={() => navigate('/ppm')}
|
||||
className="mt-2 text-red-700 dark:text-red-400 underline hover:text-red-800 dark:hover:text-red-300"
|
||||
>
|
||||
Back to PPM schedules
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 p-6">
|
||||
{/* Header */}
|
||||
<div className="mb-6 flex justify-between items-center">
|
||||
<div className="flex items-center gap-4">
|
||||
<button
|
||||
onClick={() => navigate('/ppm')}
|
||||
className="text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300 flex items-center gap-2"
|
||||
>
|
||||
<FaArrowLeft />
|
||||
<span className="text-gray-900 dark:text-white">
|
||||
{isDuplicating ? 'Duplicate PPM Schedule' : (isNewPPM ? 'New PPM Schedule' : 'PPM Schedule Details')}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
{!isNewPPM && !isEditing && (
|
||||
<button
|
||||
onClick={() => setIsEditing(true)}
|
||||
className="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-lg flex items-center gap-2"
|
||||
>
|
||||
<FaEdit />
|
||||
Edit
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
{/* Main Form */}
|
||||
<div className="lg:col-span-2 space-y-6">
|
||||
{/* Basic Information */}
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
|
||||
<h2 className="text-xl font-semibold text-gray-900 dark:text-white mb-4">Basic Information</h2>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Company *
|
||||
</label>
|
||||
{isEditing ? (
|
||||
<input
|
||||
type="text"
|
||||
name="company"
|
||||
value={formData.company}
|
||||
onChange={handleChange}
|
||||
className="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
required
|
||||
/>
|
||||
) : (
|
||||
<div className="p-4 bg-gray-50 dark:bg-gray-700 rounded-lg">
|
||||
<p className="text-gray-900 dark:text-white">{ppm?.company || '-'}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Asset Name *
|
||||
</label>
|
||||
{isEditing ? (
|
||||
<input
|
||||
type="text"
|
||||
name="asset_name"
|
||||
value={formData.asset_name}
|
||||
onChange={handleChange}
|
||||
className="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
required
|
||||
/>
|
||||
) : (
|
||||
<div className="p-4 bg-gray-50 dark:bg-gray-700 rounded-lg">
|
||||
<p className="text-gray-900 dark:text-white">{ppm?.asset_name || '-'}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Asset Type
|
||||
</label>
|
||||
{isEditing ? (
|
||||
<input
|
||||
type="text"
|
||||
name="custom_asset_type"
|
||||
value={formData.custom_asset_type}
|
||||
onChange={handleChange}
|
||||
className="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
) : (
|
||||
<div className="p-4 bg-gray-50 dark:bg-gray-700 rounded-lg">
|
||||
<p className="text-gray-900 dark:text-white">{ppm?.custom_asset_type || '-'}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Maintenance Team
|
||||
</label>
|
||||
{isEditing ? (
|
||||
<input
|
||||
type="text"
|
||||
name="maintenance_team"
|
||||
value={formData.maintenance_team}
|
||||
onChange={handleChange}
|
||||
className="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
) : (
|
||||
<div className="p-4 bg-gray-50 dark:bg-gray-700 rounded-lg">
|
||||
<p className="text-gray-900 dark:text-white">{ppm?.maintenance_team || '-'}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Frequency
|
||||
</label>
|
||||
{isEditing ? (
|
||||
<input
|
||||
type="text"
|
||||
name="custom_frequency"
|
||||
value={formData.custom_frequency}
|
||||
onChange={handleChange}
|
||||
className="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
placeholder="e.g., Monthly, Quarterly, Yearly"
|
||||
/>
|
||||
) : (
|
||||
<div className="p-4 bg-gray-50 dark:bg-gray-700 rounded-lg">
|
||||
<p className="text-gray-900 dark:text-white">{ppm?.custom_frequency || '-'}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Financial Information */}
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
|
||||
<h2 className="text-xl font-semibold text-gray-900 dark:text-white mb-4">Financial Information</h2>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Number of PMs
|
||||
</label>
|
||||
{isEditing ? (
|
||||
<input
|
||||
type="number"
|
||||
name="custom_no_of_pms"
|
||||
value={formData.custom_no_of_pms}
|
||||
onChange={handleChange}
|
||||
className="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
min="0"
|
||||
/>
|
||||
) : (
|
||||
<div className="p-4 bg-gray-50 dark:bg-gray-700 rounded-lg">
|
||||
<p className="text-gray-900 dark:text-white">{ppm?.custom_no_of_pms || '-'}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Price per PM
|
||||
</label>
|
||||
{isEditing ? (
|
||||
<input
|
||||
type="number"
|
||||
name="custom_price_per_pm"
|
||||
value={formData.custom_price_per_pm}
|
||||
onChange={handleChange}
|
||||
className="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
min="0"
|
||||
step="0.01"
|
||||
/>
|
||||
) : (
|
||||
<div className="p-4 bg-gray-50 dark:bg-gray-700 rounded-lg">
|
||||
<p className="text-gray-900 dark:text-white">
|
||||
{ppm?.custom_price_per_pm ? `$${ppm.custom_price_per_pm.toLocaleString()}` : '-'}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Total Amount
|
||||
</label>
|
||||
{isEditing ? (
|
||||
<input
|
||||
type="number"
|
||||
name="custom_total_amount"
|
||||
value={formData.custom_total_amount}
|
||||
onChange={handleChange}
|
||||
className="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
min="0"
|
||||
step="0.01"
|
||||
/>
|
||||
) : (
|
||||
<div className="p-4 bg-gray-50 dark:bg-gray-700 rounded-lg">
|
||||
<p className="text-gray-900 dark:text-white font-semibold">
|
||||
{ppm?.custom_total_amount ? `$${ppm.custom_total_amount.toLocaleString()}` : '-'}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Sidebar Info */}
|
||||
<div className="lg:col-span-1">
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-6 space-y-4">
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-4">Schedule Information</h3>
|
||||
|
||||
{!isNewPPM && ppm && (
|
||||
<>
|
||||
<div className="p-4 bg-gray-50 dark:bg-gray-700 rounded-lg">
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mb-1">PPM ID</p>
|
||||
<p className="text-sm font-medium text-gray-900 dark:text-white">{ppm.name}</p>
|
||||
</div>
|
||||
|
||||
<div className="p-4 bg-gray-50 dark:bg-gray-700 rounded-lg">
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mb-1">Created</p>
|
||||
<p className="text-xs text-gray-900 dark:text-white">
|
||||
{ppm.creation ? new Date(ppm.creation).toLocaleString() : '-'}
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{isNewPPM && (
|
||||
<div className="text-center py-8">
|
||||
<FaTools className="text-4xl text-gray-400 dark:text-gray-500 mx-auto mb-2" />
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
Schedule information will appear after creation
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Action Buttons */}
|
||||
{isEditing && (
|
||||
<div className="mt-6 flex justify-end gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (isNewPPM) {
|
||||
navigate('/ppm');
|
||||
} else {
|
||||
setIsEditing(false);
|
||||
if (ppm) {
|
||||
setFormData({
|
||||
company: ppm.company || '',
|
||||
asset_name: ppm.asset_name || '',
|
||||
custom_asset_type: ppm.custom_asset_type || '',
|
||||
maintenance_team: ppm.maintenance_team || '',
|
||||
custom_frequency: ppm.custom_frequency || '',
|
||||
custom_total_amount: ppm.custom_total_amount || 0,
|
||||
custom_no_of_pms: ppm.custom_no_of_pms || 0,
|
||||
custom_price_per_pm: ppm.custom_price_per_pm || 0,
|
||||
});
|
||||
}
|
||||
}
|
||||
}}
|
||||
className="px-6 py-2 border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={saving}
|
||||
className="px-6 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg flex items-center gap-2 disabled:opacity-50"
|
||||
>
|
||||
<FaSave />
|
||||
{saving ? 'Saving...' : (isNewPPM ? 'Create' : 'Save Changes')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PPMDetail;
|
||||
|
||||
441
src/pages/PPMList.tsx
Normal file
441
src/pages/PPMList.tsx
Normal file
@ -0,0 +1,441 @@
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { usePPMs, usePPMMutations } from '../hooks/usePPM';
|
||||
import { FaPlus, FaSearch, FaEdit, FaEye, FaTrash, FaCopy, FaEllipsisV, FaFileExport, FaCalendarCheck, FaTools, FaBuilding } from 'react-icons/fa';
|
||||
|
||||
const PPMList: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const [page, setPage] = useState(0);
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [companyFilter, setCompanyFilter] = useState<string>('');
|
||||
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState<string | null>(null);
|
||||
const [actionMenuOpen, setActionMenuOpen] = useState<string | null>(null);
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
const limit = 20;
|
||||
|
||||
const filters = companyFilter ? { company: companyFilter } : {};
|
||||
|
||||
const { ppms, totalCount, hasMore, loading, error, refetch } = usePPMs(
|
||||
filters,
|
||||
limit,
|
||||
page * limit,
|
||||
'creation desc'
|
||||
);
|
||||
|
||||
const { deletePPM, loading: mutationLoading } = usePPMMutations();
|
||||
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
|
||||
setActionMenuOpen(null);
|
||||
}
|
||||
};
|
||||
|
||||
if (actionMenuOpen) {
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
}
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClickOutside);
|
||||
};
|
||||
}, [actionMenuOpen]);
|
||||
|
||||
const handleCreateNew = () => {
|
||||
navigate('/ppm/new');
|
||||
};
|
||||
|
||||
const handleView = (ppmName: string) => {
|
||||
navigate(`/ppm/${ppmName}`);
|
||||
};
|
||||
|
||||
const handleEdit = (ppmName: string) => {
|
||||
navigate(`/ppm/${ppmName}`);
|
||||
};
|
||||
|
||||
const handleDelete = async (ppmName: string) => {
|
||||
try {
|
||||
await deletePPM(ppmName);
|
||||
setDeleteConfirmOpen(null);
|
||||
refetch();
|
||||
alert('PPM schedule deleted successfully!');
|
||||
} catch (err) {
|
||||
alert(`Failed to delete: ${err instanceof Error ? err.message : 'Unknown error'}`);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDuplicate = (ppmName: string) => {
|
||||
navigate(`/ppm/new?duplicate=${ppmName}`);
|
||||
};
|
||||
|
||||
const handleExport = (ppm: any) => {
|
||||
const dataStr = JSON.stringify(ppm, null, 2);
|
||||
const dataBlob = new Blob([dataStr], { type: 'application/json' });
|
||||
const url = URL.createObjectURL(dataBlob);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = `ppm_${ppm.name}.json`;
|
||||
link.click();
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
const handleExportAll = () => {
|
||||
const headers = ['PPM ID', 'Company', 'Asset', 'Asset Type', 'Frequency', 'No. of PMs', 'Total Amount'];
|
||||
const csvContent = [
|
||||
headers.join(','),
|
||||
...ppms.map(ppm => [
|
||||
ppm.name,
|
||||
ppm.company || '',
|
||||
ppm.asset_name || '',
|
||||
ppm.custom_asset_type || '',
|
||||
ppm.custom_frequency || '',
|
||||
ppm.custom_no_of_pms || '',
|
||||
ppm.custom_total_amount || ''
|
||||
].join(','))
|
||||
].join('\n');
|
||||
|
||||
const dataBlob = new Blob([csvContent], { type: 'text/csv' });
|
||||
const url = URL.createObjectURL(dataBlob);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = `ppm_schedules_${new Date().toISOString().split('T')[0]}.csv`;
|
||||
link.click();
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
if (loading && page === 0) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-screen bg-gray-50 dark:bg-gray-900">
|
||||
<div className="text-center">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-500 mx-auto"></div>
|
||||
<p className="mt-4 text-gray-600 dark:text-gray-400">Loading PPM schedules...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="p-6 bg-gray-50 dark:bg-gray-900 min-h-screen">
|
||||
<div className="bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-lg p-6">
|
||||
<h2 className="text-xl font-bold text-yellow-800 dark:text-yellow-300 mb-4">⚠️ PPM API Not Available</h2>
|
||||
<div className="text-yellow-700 dark:text-yellow-400 space-y-3">
|
||||
<p><strong>The PPM API endpoint is not deployed yet.</strong></p>
|
||||
<div className="mt-4 flex gap-3">
|
||||
<button
|
||||
onClick={() => navigate('/ppm/new')}
|
||||
className="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded"
|
||||
>
|
||||
Try Creating New (Demo)
|
||||
</button>
|
||||
<button
|
||||
onClick={refetch}
|
||||
className="bg-gray-600 hover:bg-gray-700 text-white px-4 py-2 rounded"
|
||||
>
|
||||
Try Again
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4 p-4 bg-white dark:bg-gray-800 rounded border border-yellow-300 dark:border-yellow-700">
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
<strong>Technical Error:</strong> {error}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const filteredPPMs = ppms.filter(ppm =>
|
||||
ppm.name?.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
ppm.asset_name?.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
ppm.company?.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
ppm.custom_asset_type?.toLowerCase().includes(searchTerm.toLowerCase())
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="p-6 bg-gray-50 dark:bg-gray-900 min-h-screen">
|
||||
{/* Header */}
|
||||
<div className="mb-6 flex justify-between items-center">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-gray-800 dark:text-white">PPM Schedules</h1>
|
||||
<p className="text-gray-600 dark:text-gray-400 mt-1">
|
||||
Total: {totalCount} PPM schedule{totalCount !== 1 ? 's' : ''}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
onClick={handleExportAll}
|
||||
className="bg-green-600 hover:bg-green-700 text-white px-4 py-3 rounded-lg flex items-center gap-2 shadow transition-all"
|
||||
disabled={ppms.length === 0}
|
||||
>
|
||||
<FaFileExport />
|
||||
<span className="font-medium">Export All</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={handleCreateNew}
|
||||
className="bg-blue-600 hover:bg-blue-700 text-white px-6 py-3 rounded-lg flex items-center gap-2 shadow-lg transition-all hover:shadow-xl"
|
||||
>
|
||||
<FaPlus />
|
||||
<span className="font-medium">New PPM Schedule</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filters Bar */}
|
||||
<div className="mb-6 grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-4">
|
||||
<div className="flex items-center gap-2 border border-gray-300 dark:border-gray-600 rounded-lg px-4 py-2 bg-white dark:bg-gray-700">
|
||||
<FaSearch className="text-gray-400 dark:text-gray-500" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search by ID, asset, company..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="flex-1 outline-none text-gray-700 dark:text-gray-200 bg-transparent"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-4">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Filter by Company"
|
||||
value={companyFilter}
|
||||
onChange={(e) => {
|
||||
setCompanyFilter(e.target.value);
|
||||
setPage(0);
|
||||
}}
|
||||
className="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-700 dark:text-gray-200 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* PPM Schedules Table */}
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow overflow-hidden">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead className="bg-gray-100 dark:bg-gray-700 border-b border-gray-200 dark:border-gray-600">
|
||||
<tr>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
PPM ID
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
Company
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
Asset
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
Asset Type
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
Frequency
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
No. of PMs
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
Total Amount
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
Actions
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white dark:bg-gray-800 divide-y divide-gray-200 dark:divide-gray-700">
|
||||
{filteredPPMs.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={8} className="px-6 py-12 text-center text-gray-500 dark:text-gray-400">
|
||||
<div className="flex flex-col items-center">
|
||||
<FaSearch className="text-4xl text-gray-300 dark:text-gray-600 mb-2" />
|
||||
<p>No PPM schedules found</p>
|
||||
<button
|
||||
onClick={handleCreateNew}
|
||||
className="mt-4 text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300 underline"
|
||||
>
|
||||
Create your first PPM schedule
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
filteredPPMs.map((ppm) => (
|
||||
<tr
|
||||
key={ppm.name}
|
||||
className="hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-colors cursor-pointer"
|
||||
onClick={() => handleView(ppm.name)}
|
||||
>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="text-sm font-medium text-gray-900 dark:text-white">
|
||||
{ppm.name}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="flex items-center gap-2">
|
||||
<FaBuilding className="text-gray-400" />
|
||||
<span className="text-sm text-gray-700 dark:text-gray-300">
|
||||
{ppm.company || '-'}
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="text-sm text-gray-700 dark:text-gray-300">
|
||||
{ppm.asset_name || '-'}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="text-sm text-gray-700 dark:text-gray-300">
|
||||
{ppm.custom_asset_type || '-'}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="flex items-center gap-2">
|
||||
<FaCalendarCheck className="text-blue-500" />
|
||||
<span className="text-sm text-gray-700 dark:text-gray-300">
|
||||
{ppm.custom_frequency || '-'}
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="text-sm text-gray-700 dark:text-gray-300">
|
||||
{ppm.custom_no_of_pms || '-'}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="text-sm font-medium text-gray-900 dark:text-white">
|
||||
{ppm.custom_total_amount ? `$${ppm.custom_total_amount.toLocaleString()}` : '-'}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium">
|
||||
<div className="relative" onClick={(e) => e.stopPropagation()}>
|
||||
<button
|
||||
onClick={() => setActionMenuOpen(actionMenuOpen === ppm.name ? null : ppm.name)}
|
||||
className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700"
|
||||
>
|
||||
<FaEllipsisV />
|
||||
</button>
|
||||
{actionMenuOpen === ppm.name && (
|
||||
<div
|
||||
ref={dropdownRef}
|
||||
className="absolute right-0 mt-2 w-48 bg-white dark:bg-gray-800 rounded-lg shadow-lg z-10 border border-gray-200 dark:border-gray-700"
|
||||
>
|
||||
<button
|
||||
onClick={() => {
|
||||
handleView(ppm.name);
|
||||
setActionMenuOpen(null);
|
||||
}}
|
||||
className="w-full text-left px-4 py-2 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 flex items-center gap-2"
|
||||
>
|
||||
<FaEye />
|
||||
View
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
handleEdit(ppm.name);
|
||||
setActionMenuOpen(null);
|
||||
}}
|
||||
className="w-full text-left px-4 py-2 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 flex items-center gap-2"
|
||||
>
|
||||
<FaEdit />
|
||||
Edit
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
handleDuplicate(ppm.name);
|
||||
setActionMenuOpen(null);
|
||||
}}
|
||||
className="w-full text-left px-4 py-2 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 flex items-center gap-2"
|
||||
>
|
||||
<FaCopy />
|
||||
Duplicate
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
handleExport(ppm);
|
||||
setActionMenuOpen(null);
|
||||
}}
|
||||
className="w-full text-left px-4 py-2 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 flex items-center gap-2"
|
||||
>
|
||||
<FaFileExport />
|
||||
Export
|
||||
</button>
|
||||
<div className="border-t border-gray-200 dark:border-gray-700"></div>
|
||||
<button
|
||||
onClick={() => {
|
||||
setDeleteConfirmOpen(ppm.name);
|
||||
setActionMenuOpen(null);
|
||||
}}
|
||||
className="w-full text-left px-4 py-2 text-sm text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20 flex items-center gap-2"
|
||||
>
|
||||
<FaTrash />
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Pagination */}
|
||||
{(hasMore || page > 0) && (
|
||||
<div className="px-6 py-4 border-t border-gray-200 dark:border-gray-700 flex items-center justify-between">
|
||||
<div className="text-sm text-gray-700 dark:text-gray-300">
|
||||
Showing {page * limit + 1} to {Math.min((page + 1) * limit, totalCount)} of {totalCount} results
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => setPage(Math.max(0, page - 1))}
|
||||
disabled={page === 0}
|
||||
className="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-600 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
Previous
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setPage(page + 1)}
|
||||
disabled={!hasMore}
|
||||
className="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-600 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Delete Confirmation Modal */}
|
||||
{deleteConfirmOpen && (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg p-6 max-w-md w-full mx-4">
|
||||
<h3 className="text-lg font-bold text-gray-900 dark:text-white mb-4">Confirm Delete</h3>
|
||||
<p className="text-gray-600 dark:text-gray-400 mb-6">
|
||||
Are you sure you want to delete this PPM schedule? This action cannot be undone.
|
||||
</p>
|
||||
<div className="flex gap-3 justify-end">
|
||||
<button
|
||||
onClick={() => setDeleteConfirmOpen(null)}
|
||||
className="px-4 py-2 text-gray-700 dark:text-gray-300 bg-gray-200 dark:bg-gray-700 rounded-lg hover:bg-gray-300 dark:hover:bg-gray-600"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDelete(deleteConfirmOpen)}
|
||||
disabled={mutationLoading}
|
||||
className="px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 disabled:opacity-50"
|
||||
>
|
||||
{mutationLoading ? 'Deleting...' : 'Delete'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PPMList;
|
||||
|
||||
571
src/pages/WorkOrderDetail.tsx
Normal file
571
src/pages/WorkOrderDetail.tsx
Normal file
@ -0,0 +1,571 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useParams, useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import { useWorkOrderDetails, useWorkOrderMutations } from '../hooks/useWorkOrder';
|
||||
import { FaArrowLeft, FaSave, FaEdit, FaCheckCircle, FaClock } from 'react-icons/fa';
|
||||
import type { CreateWorkOrderData } from '../services/workOrderService';
|
||||
|
||||
const WorkOrderDetail: React.FC = () => {
|
||||
const { workOrderName } = useParams<{ workOrderName: string }>();
|
||||
const navigate = useNavigate();
|
||||
const [searchParams] = useSearchParams();
|
||||
const duplicateFromWorkOrder = searchParams.get('duplicate');
|
||||
|
||||
const isNewWorkOrder = workOrderName === 'new';
|
||||
const isDuplicating = isNewWorkOrder && !!duplicateFromWorkOrder;
|
||||
|
||||
const { workOrder, loading, error } = useWorkOrderDetails(
|
||||
isDuplicating ? duplicateFromWorkOrder : (isNewWorkOrder ? null : workOrderName || null)
|
||||
);
|
||||
const { createWorkOrder, updateWorkOrder, updateStatus, loading: saving } = useWorkOrderMutations();
|
||||
|
||||
const [isEditing, setIsEditing] = useState(isNewWorkOrder);
|
||||
const [formData, setFormData] = useState<CreateWorkOrderData>({
|
||||
company: '',
|
||||
work_order_type: '',
|
||||
asset: '',
|
||||
asset_name: '',
|
||||
description: '',
|
||||
repair_status: 'Open',
|
||||
workflow_state: '',
|
||||
department: '',
|
||||
custom_priority_: 'Normal',
|
||||
asset_type: '',
|
||||
manufacturer: '',
|
||||
serial_number: '',
|
||||
model: '',
|
||||
custom_site_contractor: '',
|
||||
custom_subcontractor: '',
|
||||
failure_date: '',
|
||||
custom_deadline_date: '',
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (workOrder) {
|
||||
setFormData({
|
||||
company: workOrder.company || '',
|
||||
work_order_type: workOrder.work_order_type || '',
|
||||
asset: workOrder.asset || '',
|
||||
asset_name: isDuplicating ? `${workOrder.asset_name} (Copy)` : (workOrder.asset_name || ''),
|
||||
description: workOrder.description || '',
|
||||
repair_status: isDuplicating ? 'Open' : (workOrder.repair_status || 'Open'),
|
||||
workflow_state: workOrder.workflow_state || '',
|
||||
department: workOrder.department || '',
|
||||
custom_priority_: workOrder.custom_priority_ || 'Normal',
|
||||
asset_type: workOrder.asset_type || '',
|
||||
manufacturer: workOrder.manufacturer || '',
|
||||
serial_number: workOrder.serial_number || '',
|
||||
model: workOrder.model || '',
|
||||
custom_site_contractor: workOrder.custom_site_contractor || '',
|
||||
custom_subcontractor: workOrder.custom_subcontractor || '',
|
||||
failure_date: workOrder.failure_date || '',
|
||||
custom_deadline_date: workOrder.custom_deadline_date || '',
|
||||
});
|
||||
}
|
||||
}, [workOrder, isDuplicating]);
|
||||
|
||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement>) => {
|
||||
const { name, value } = e.target;
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
[name]: value
|
||||
}));
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!formData.work_order_type) {
|
||||
alert('Please select a Work Order Type');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('Submitting work order data:', formData);
|
||||
|
||||
try {
|
||||
if (isNewWorkOrder || isDuplicating) {
|
||||
const newWorkOrder = await createWorkOrder(formData);
|
||||
const successMessage = isDuplicating
|
||||
? 'Work order duplicated successfully!'
|
||||
: 'Work order created successfully!';
|
||||
alert(successMessage);
|
||||
navigate(`/work-orders/${newWorkOrder.name}`);
|
||||
} else if (workOrderName) {
|
||||
await updateWorkOrder(workOrderName, formData);
|
||||
alert('Work order updated successfully!');
|
||||
setIsEditing(false);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Work order save error:', err);
|
||||
const errorMessage = err instanceof Error ? err.message : 'Unknown error';
|
||||
|
||||
if (errorMessage.includes('404') || errorMessage.includes('not found') ||
|
||||
errorMessage.includes('has no attribute') || errorMessage.includes('417')) {
|
||||
alert(
|
||||
'⚠️ Work Order API Not Deployed\n\n' +
|
||||
'The Work Order API endpoint is not deployed on your Frappe server yet.\n\n' +
|
||||
'Deploy work_order_api.py to: frappe-bench/apps/asset_lite/asset_lite/api/\n\n' +
|
||||
'Error: ' + errorMessage
|
||||
);
|
||||
} else {
|
||||
alert('Failed to save work order:\n\n' + errorMessage);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleStatusUpdate = async (newStatus: string) => {
|
||||
if (!workOrderName || isNewWorkOrder) return;
|
||||
|
||||
try {
|
||||
await updateStatus(workOrderName, newStatus);
|
||||
alert('Status updated successfully!');
|
||||
window.location.reload();
|
||||
} catch (err) {
|
||||
alert('Failed to update status: ' + (err instanceof Error ? err.message : 'Unknown error'));
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-screen bg-gray-50 dark:bg-gray-900">
|
||||
<div className="text-center">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-500 mx-auto"></div>
|
||||
<p className="mt-4 text-gray-600 dark:text-gray-400">Loading work order details...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error && !isNewWorkOrder && !isDuplicating) {
|
||||
return (
|
||||
<div className="p-6 bg-gray-50 dark:bg-gray-900 min-h-screen">
|
||||
<div className="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-4">
|
||||
<p className="text-red-600 dark:text-red-400">Error: {error}</p>
|
||||
<button
|
||||
onClick={() => navigate('/work-orders')}
|
||||
className="mt-2 text-red-700 dark:text-red-400 underline hover:text-red-800 dark:hover:text-red-300"
|
||||
>
|
||||
Back to work orders list
|
||||
</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('/work-orders')}
|
||||
className="text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300 flex items-center gap-2"
|
||||
>
|
||||
<FaArrowLeft />
|
||||
<span className="text-gray-900 dark:text-white">
|
||||
{isDuplicating ? 'Duplicate Work Order' : (isNewWorkOrder ? 'New Work Order' : 'Work Order Details')}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
{!isNewWorkOrder && !isEditing && (
|
||||
<>
|
||||
<button
|
||||
onClick={() => setIsEditing(true)}
|
||||
className="bg-blue-600 hover:bg-blue-700 text-white px-6 py-2 rounded-lg flex items-center gap-2"
|
||||
>
|
||||
<FaEdit />
|
||||
Edit
|
||||
</button>
|
||||
{/* Quick Status Update Buttons */}
|
||||
{workOrder?.repair_status !== 'Completed' && (
|
||||
<button
|
||||
onClick={() => handleStatusUpdate('Completed')}
|
||||
className="bg-green-600 hover:bg-green-700 text-white px-4 py-2 rounded-lg flex items-center gap-2"
|
||||
disabled={saving}
|
||||
>
|
||||
<FaCheckCircle />
|
||||
Mark Complete
|
||||
</button>
|
||||
)}
|
||||
{workOrder?.repair_status !== 'In Progress' && workOrder?.repair_status !== 'Completed' && (
|
||||
<button
|
||||
onClick={() => handleStatusUpdate('In Progress')}
|
||||
className="bg-yellow-600 hover:bg-yellow-700 text-white px-4 py-2 rounded-lg flex items-center gap-2"
|
||||
disabled={saving}
|
||||
>
|
||||
<FaClock />
|
||||
Start Work
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{isEditing && (
|
||||
<>
|
||||
<button
|
||||
onClick={() => {
|
||||
if (isNewWorkOrder) {
|
||||
navigate('/work-orders');
|
||||
} else {
|
||||
setIsEditing(false);
|
||||
}
|
||||
}}
|
||||
className="bg-gray-300 hover:bg-gray-400 text-gray-700 px-6 py-2 rounded-lg"
|
||||
disabled={saving}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
disabled={saving}
|
||||
className="bg-green-600 hover:bg-green-700 text-white px-6 py-2 rounded-lg flex items-center gap-2 disabled:opacity-50"
|
||||
>
|
||||
<FaSave />
|
||||
{saving ? 'Saving...' : 'Save Changes'}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
{/* Left Column - Main Info */}
|
||||
<div className="lg:col-span-2 space-y-6">
|
||||
{/* Work Order 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-base font-semibold text-gray-800 dark:text-white mb-4 pb-2 border-b border-gray-200 dark:border-gray-700">Work Order Information</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Work Order ID
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={isNewWorkOrder || isDuplicating ? 'Auto-generated' : workOrder?.name}
|
||||
disabled
|
||||
className="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-md bg-gray-100 dark:bg-gray-700 text-gray-500 dark:text-gray-400"
|
||||
/>
|
||||
{isDuplicating && (
|
||||
<p className="mt-1 text-xs text-blue-600 dark:text-blue-400">
|
||||
💡 Duplicating from: {duplicateFromWorkOrder}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Work Order Type <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<select
|
||||
name="work_order_type"
|
||||
value={formData.work_order_type}
|
||||
onChange={handleChange}
|
||||
required
|
||||
disabled={!isEditing}
|
||||
className="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"
|
||||
>
|
||||
<option value="">Select type</option>
|
||||
<option value="Preventive Maintenance">Preventive Maintenance</option>
|
||||
<option value="Corrective Maintenance">Corrective Maintenance</option>
|
||||
<option value="Breakdown Maintenance">Breakdown Maintenance</option>
|
||||
<option value="Calibration">Calibration</option>
|
||||
<option value="Inspection">Inspection</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Priority
|
||||
</label>
|
||||
<select
|
||||
name="custom_priority_"
|
||||
value={formData.custom_priority_}
|
||||
onChange={handleChange}
|
||||
disabled={!isEditing}
|
||||
className="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"
|
||||
>
|
||||
<option value="Low">Low</option>
|
||||
<option value="Normal">Normal</option>
|
||||
<option value="Medium">Medium</option>
|
||||
<option value="High">High</option>
|
||||
<option value="Urgent">Urgent</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Status
|
||||
</label>
|
||||
<select
|
||||
name="repair_status"
|
||||
value={formData.repair_status}
|
||||
onChange={handleChange}
|
||||
disabled={!isEditing}
|
||||
className="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"
|
||||
>
|
||||
<option value="Open">Open</option>
|
||||
<option value="In Progress">In Progress</option>
|
||||
<option value="Pending">Pending</option>
|
||||
<option value="Completed">Completed</option>
|
||||
<option value="Cancelled">Cancelled</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Company
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
name="company"
|
||||
value={formData.company}
|
||||
onChange={handleChange}
|
||||
disabled={!isEditing}
|
||||
className="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"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Description
|
||||
</label>
|
||||
<textarea
|
||||
name="description"
|
||||
value={formData.description}
|
||||
onChange={handleChange}
|
||||
rows={3}
|
||||
disabled={!isEditing}
|
||||
placeholder="Describe the issue or maintenance task..."
|
||||
className="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"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* COLUMN 2: Asset 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-base font-semibold text-gray-800 dark:text-white mb-4 pb-2 border-b border-gray-200 dark:border-gray-700">
|
||||
Asset Information
|
||||
</h2>
|
||||
<div className="space-y-4">
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Asset ID
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
name="asset"
|
||||
value={formData.asset}
|
||||
onChange={handleChange}
|
||||
disabled={!isEditing}
|
||||
placeholder="e.g. ACC-ASS-2025-00001"
|
||||
className="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"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Asset Name
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
name="asset_name"
|
||||
value={formData.asset_name}
|
||||
onChange={handleChange}
|
||||
disabled={!isEditing}
|
||||
placeholder="Asset name"
|
||||
className="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"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Asset Type
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
name="asset_type"
|
||||
value={formData.asset_type}
|
||||
onChange={handleChange}
|
||||
disabled={!isEditing}
|
||||
className="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"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Manufacturer
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
name="manufacturer"
|
||||
value={formData.manufacturer}
|
||||
onChange={handleChange}
|
||||
disabled={!isEditing}
|
||||
className="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"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Serial Number
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
name="serial_number"
|
||||
value={formData.serial_number}
|
||||
onChange={handleChange}
|
||||
disabled={!isEditing}
|
||||
className="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"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Model
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
name="model"
|
||||
value={formData.model}
|
||||
onChange={handleChange}
|
||||
disabled={!isEditing}
|
||||
className="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"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Location & Assignment */}
|
||||
<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">Location & Assignment</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Department
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
name="department"
|
||||
value={formData.department}
|
||||
onChange={handleChange}
|
||||
disabled={!isEditing}
|
||||
className="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"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Site Contractor
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
name="custom_site_contractor"
|
||||
value={formData.custom_site_contractor}
|
||||
onChange={handleChange}
|
||||
disabled={!isEditing}
|
||||
className="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"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Subcontractor
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
name="custom_subcontractor"
|
||||
value={formData.custom_subcontractor}
|
||||
onChange={handleChange}
|
||||
disabled={!isEditing}
|
||||
className="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"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Failure Date
|
||||
</label>
|
||||
<input
|
||||
type="date"
|
||||
name="failure_date"
|
||||
value={formData.failure_date}
|
||||
onChange={handleChange}
|
||||
disabled={!isEditing}
|
||||
className="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"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Deadline Date
|
||||
</label>
|
||||
<input
|
||||
type="date"
|
||||
name="custom_deadline_date"
|
||||
value={formData.custom_deadline_date}
|
||||
onChange={handleChange}
|
||||
disabled={!isEditing}
|
||||
className="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"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right Column - Status & Summary */}
|
||||
<div className="space-y-6">
|
||||
<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">Status Summary</h2>
|
||||
|
||||
{!isNewWorkOrder && workOrder && (
|
||||
<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">Current Status</p>
|
||||
<p className="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
{workOrder.repair_status || 'Open'}
|
||||
</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">Priority</p>
|
||||
<p className="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
{workOrder.custom_priority_ || 'Normal'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="p-4 bg-gray-50 dark:bg-gray-700 rounded-lg">
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mb-1">Created</p>
|
||||
<p className="text-sm text-gray-900 dark:text-white">
|
||||
{workOrder.creation ? new Date(workOrder.creation).toLocaleString() : '-'}
|
||||
</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">
|
||||
{workOrder.modified ? new Date(workOrder.modified).toLocaleString() : '-'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isNewWorkOrder && (
|
||||
<div className="text-center py-8">
|
||||
<FaClock 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">
|
||||
Status information will appear after creation
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default WorkOrderDetail;
|
||||
|
||||
513
src/pages/WorkOrderList.tsx
Normal file
513
src/pages/WorkOrderList.tsx
Normal file
@ -0,0 +1,513 @@
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useWorkOrders, useWorkOrderMutations } from '../hooks/useWorkOrder';
|
||||
import { FaPlus, FaSearch, FaEdit, FaEye, FaTrash, FaCopy, FaEllipsisV, FaDownload, FaPrint, FaFileExport, FaCheckCircle, FaClock, FaExclamationTriangle } from 'react-icons/fa';
|
||||
|
||||
const WorkOrderList: React.FC = () => {
|
||||
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 ? { repair_status: statusFilter } : {};
|
||||
|
||||
const { workOrders, totalCount, hasMore, loading, error, refetch } = useWorkOrders(
|
||||
filters,
|
||||
limit,
|
||||
page * limit,
|
||||
'creation desc'
|
||||
);
|
||||
|
||||
const { deleteWorkOrder, loading: mutationLoading } = useWorkOrderMutations();
|
||||
|
||||
// Close dropdown when clicking outside
|
||||
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('/work-orders/new');
|
||||
};
|
||||
|
||||
const handleView = (workOrderName: string) => {
|
||||
navigate(`/work-orders/${workOrderName}`);
|
||||
};
|
||||
|
||||
const handleEdit = (workOrderName: string) => {
|
||||
navigate(`/work-orders/${workOrderName}`);
|
||||
};
|
||||
|
||||
const handleDelete = async (workOrderName: string) => {
|
||||
try {
|
||||
await deleteWorkOrder(workOrderName);
|
||||
setDeleteConfirmOpen(null);
|
||||
refetch();
|
||||
alert('Work order deleted successfully!');
|
||||
} catch (err) {
|
||||
alert(`Failed to delete work order: ${err instanceof Error ? err.message : 'Unknown error'}`);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDuplicate = (workOrderName: string) => {
|
||||
navigate(`/work-orders/new?duplicate=${workOrderName}`);
|
||||
};
|
||||
|
||||
const handleExport = (workOrder: any) => {
|
||||
const dataStr = JSON.stringify(workOrder, 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 = `work_order_${workOrder.name}.json`;
|
||||
link.click();
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
const handlePrint = (workOrderName: string) => {
|
||||
window.open(`/work-orders/${workOrderName}?print=true`, '_blank');
|
||||
};
|
||||
|
||||
const handleExportAll = () => {
|
||||
const headers = ['Work Order ID', 'Asset', 'Type', 'Status', 'Department', 'Priority', 'Created'];
|
||||
const csvContent = [
|
||||
headers.join(','),
|
||||
...workOrders.map(wo => [
|
||||
wo.name,
|
||||
wo.asset_name || wo.asset || '',
|
||||
wo.work_order_type || '',
|
||||
wo.repair_status || '',
|
||||
wo.department || '',
|
||||
wo.custom_priority_ || '',
|
||||
wo.creation ? new Date(wo.creation).toLocaleDateString() : ''
|
||||
].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 = `work_orders_export_${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 'in progress':
|
||||
return <FaClock className="text-blue-500" />;
|
||||
case 'pending':
|
||||
return <FaExclamationTriangle className="text-yellow-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 'in progress':
|
||||
return 'bg-blue-100 dark:bg-blue-900/30 text-blue-800 dark:text-blue-300';
|
||||
case 'pending':
|
||||
case 'open':
|
||||
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';
|
||||
}
|
||||
};
|
||||
|
||||
const getPriorityColor = (priority: string) => {
|
||||
switch (priority?.toLowerCase()) {
|
||||
case 'high':
|
||||
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';
|
||||
case 'low':
|
||||
return 'bg-green-100 dark:bg-green-900/30 text-green-800 dark:text-green-300';
|
||||
default:
|
||||
return 'bg-gray-100 dark:bg-gray-700 text-gray-800 dark:text-gray-300';
|
||||
}
|
||||
};
|
||||
|
||||
if (loading && page === 0) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-screen bg-gray-50 dark:bg-gray-900">
|
||||
<div className="text-center">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-500 mx-auto"></div>
|
||||
<p className="mt-4 text-gray-600 dark:text-gray-400">Loading work orders...</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">⚠️ Work Order API Not Available</h2>
|
||||
<div className="text-yellow-700 dark:text-yellow-400 space-y-3">
|
||||
<p><strong>The Work Order API endpoint is not deployed yet.</strong></p>
|
||||
<p>To fix this, deploy the work_order_api.py file to your Frappe server.</p>
|
||||
<div className="mt-4 flex gap-3">
|
||||
<button
|
||||
onClick={() => navigate('/work-orders/new')}
|
||||
className="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded"
|
||||
>
|
||||
Try Creating New (Demo)
|
||||
</button>
|
||||
<button
|
||||
onClick={refetch}
|
||||
className="bg-gray-600 hover:bg-gray-700 text-white px-4 py-2 rounded"
|
||||
>
|
||||
Try Again
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4 p-4 bg-white dark:bg-gray-800 rounded border border-yellow-300 dark:border-yellow-700">
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
<strong>Technical Error:</strong> {error}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Filter work orders by search term
|
||||
const filteredWorkOrders = workOrders.filter(wo =>
|
||||
wo.name?.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
wo.asset_name?.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
wo.description?.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
wo.asset?.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">Work Orders</h1>
|
||||
<p className="text-gray-600 dark:text-gray-400 mt-1">
|
||||
Total: {totalCount} work order{totalCount !== 1 ? 's' : ''}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
onClick={handleExportAll}
|
||||
className="bg-green-600 hover:bg-green-700 text-white px-4 py-3 rounded-lg flex items-center gap-2 shadow transition-all"
|
||||
disabled={workOrders.length === 0}
|
||||
>
|
||||
<FaFileExport />
|
||||
<span className="font-medium">Export All</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={handleCreateNew}
|
||||
className="bg-blue-600 hover:bg-blue-700 text-white px-6 py-3 rounded-lg flex items-center gap-2 shadow-lg transition-all hover:shadow-xl"
|
||||
>
|
||||
<FaPlus />
|
||||
<span className="font-medium">New Work Order</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filters Bar */}
|
||||
<div className="mb-6 grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{/* Search Bar */}
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-4">
|
||||
<div className="flex items-center gap-2 border border-gray-300 dark:border-gray-600 rounded-lg px-4 py-2 bg-white dark:bg-gray-700">
|
||||
<FaSearch className="text-gray-400 dark:text-gray-500" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search by ID, asset name, description..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="flex-1 outline-none text-gray-700 dark:text-gray-200 bg-transparent"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Status Filter */}
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-4">
|
||||
<select
|
||||
value={statusFilter}
|
||||
onChange={(e) => {
|
||||
setStatusFilter(e.target.value);
|
||||
setPage(0);
|
||||
}}
|
||||
className="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-700 dark:text-gray-200 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
>
|
||||
<option value="">All Statuses</option>
|
||||
<option value="Open">Open</option>
|
||||
<option value="In Progress">In Progress</option>
|
||||
<option value="Pending">Pending</option>
|
||||
<option value="Completed">Completed</option>
|
||||
<option value="Cancelled">Cancelled</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Work Orders 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">
|
||||
Work Order ID
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
Asset
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
Type
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
Department
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
Status
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
Priority
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
Actions
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white dark:bg-gray-800 divide-y divide-gray-200 dark:divide-gray-700">
|
||||
{filteredWorkOrders.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={7} className="px-6 py-12 text-center text-gray-500 dark:text-gray-400">
|
||||
<div className="flex flex-col items-center">
|
||||
<FaSearch className="text-4xl text-gray-300 dark:text-gray-600 mb-2" />
|
||||
<p>No work orders found</p>
|
||||
<button
|
||||
onClick={handleCreateNew}
|
||||
className="mt-4 text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300 underline"
|
||||
>
|
||||
Create your first work order
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
filteredWorkOrders.map((workOrder) => (
|
||||
<tr
|
||||
key={workOrder.name}
|
||||
className="hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors cursor-pointer"
|
||||
onClick={() => handleView(workOrder.name)}
|
||||
>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="font-medium text-gray-900 dark:text-white">{workOrder.name}</div>
|
||||
<div className="text-sm text-gray-500 dark:text-gray-400">
|
||||
{workOrder.creation ? new Date(workOrder.creation).toLocaleDateString() : ''}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="text-sm text-gray-900 dark:text-white">{workOrder.asset_name || '-'}</div>
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400">{workOrder.asset || ''}</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-700 dark:text-gray-300">
|
||||
{workOrder.work_order_type || '-'}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-700 dark:text-gray-300">
|
||||
{workOrder.department || '-'}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="flex items-center gap-2">
|
||||
{getStatusIcon(workOrder.repair_status || '')}
|
||||
<span className={`px-3 py-1 inline-flex text-xs leading-5 font-semibold rounded-full ${getStatusColor(workOrder.repair_status || '')}`}>
|
||||
{workOrder.repair_status || 'Unknown'}
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<span className={`px-3 py-1 inline-flex text-xs leading-5 font-semibold rounded-full ${getPriorityColor(workOrder.custom_priority_ || '')}`}>
|
||||
{workOrder.custom_priority_ || 'Normal'}
|
||||
</span>
|
||||
</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(workOrder.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(workOrder.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 Work Order"
|
||||
>
|
||||
<FaEdit />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDuplicate(workOrder.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 Work Order"
|
||||
>
|
||||
<FaCopy />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setDeleteConfirmOpen(workOrder.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 Work Order"
|
||||
disabled={mutationLoading}
|
||||
>
|
||||
<FaTrash />
|
||||
</button>
|
||||
|
||||
{/* More Actions Dropdown */}
|
||||
<div className="relative" ref={actionMenuOpen === workOrder.name ? dropdownRef : null}>
|
||||
<button
|
||||
onClick={() => setActionMenuOpen(actionMenuOpen === workOrder.name ? null : workOrder.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 === workOrder.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(workOrder);
|
||||
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(workOrder.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 Work Order
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Pagination */}
|
||||
{filteredWorkOrders.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 Work Order
|
||||
</h3>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400 mb-4">
|
||||
Are you sure you want to delete this work order? 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>Work Order 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 Work Order
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default WorkOrderList;
|
||||
|
||||
220
src/services/assetMaintenanceService.ts
Normal file
220
src/services/assetMaintenanceService.ts
Normal file
@ -0,0 +1,220 @@
|
||||
import apiService from './apiService';
|
||||
import API_CONFIG from '../config/api';
|
||||
|
||||
// Asset Maintenance Interfaces
|
||||
export interface AssetMaintenanceLog {
|
||||
name: string;
|
||||
asset_maintenance?: string;
|
||||
naming_series?: string;
|
||||
asset_name?: string;
|
||||
custom_asset_type?: string;
|
||||
item_code?: string;
|
||||
item_name?: string;
|
||||
custom_asset_names?: string;
|
||||
custom_hospital_name?: string;
|
||||
task?: string;
|
||||
task_name?: string;
|
||||
maintenance_type?: string;
|
||||
periodicity?: string;
|
||||
has_certificate?: number;
|
||||
custom_early_completion?: number;
|
||||
maintenance_status?: string;
|
||||
custom_pm_overdue_reason?: string;
|
||||
custom_accepted_by_moh?: string;
|
||||
assign_to_name?: string;
|
||||
due_date?: string;
|
||||
custom_accepted_by_moh_?: string;
|
||||
custom_template?: string;
|
||||
workflow_state?: string;
|
||||
creation?: string;
|
||||
modified?: string;
|
||||
owner?: string;
|
||||
modified_by?: string;
|
||||
docstatus?: number;
|
||||
idx?: number;
|
||||
}
|
||||
|
||||
export interface AssetMaintenanceListResponse {
|
||||
asset_maintenance_logs: AssetMaintenanceLog[];
|
||||
total_count: number;
|
||||
limit: number;
|
||||
offset: number;
|
||||
has_more: boolean;
|
||||
}
|
||||
|
||||
export interface MaintenanceFilters {
|
||||
maintenance_status?: string;
|
||||
asset_name?: string;
|
||||
custom_hospital_name?: string;
|
||||
maintenance_type?: string;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
export interface CreateMaintenanceData {
|
||||
asset_name?: string;
|
||||
task?: string;
|
||||
task_name?: string;
|
||||
maintenance_type?: string;
|
||||
periodicity?: string;
|
||||
maintenance_status?: string;
|
||||
due_date?: string;
|
||||
assign_to_name?: string;
|
||||
description?: string;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
class AssetMaintenanceService {
|
||||
/**
|
||||
* Get list of asset maintenance logs with optional filters and pagination
|
||||
*/
|
||||
async getMaintenanceLogs(
|
||||
filters?: MaintenanceFilters,
|
||||
fields?: string[],
|
||||
limit: number = 20,
|
||||
offset: number = 0,
|
||||
orderBy?: string
|
||||
): Promise<AssetMaintenanceListResponse> {
|
||||
const params = new URLSearchParams();
|
||||
|
||||
if (filters) {
|
||||
params.append('filters', JSON.stringify(filters));
|
||||
}
|
||||
|
||||
if (fields && fields.length > 0) {
|
||||
params.append('fields', JSON.stringify(fields));
|
||||
}
|
||||
|
||||
params.append('limit', limit.toString());
|
||||
params.append('offset', offset.toString());
|
||||
|
||||
if (orderBy) {
|
||||
params.append('order_by', orderBy);
|
||||
}
|
||||
|
||||
const endpoint = `${API_CONFIG.ENDPOINTS.GET_ASSET_MAINTENANCE_LOGS}?${params.toString()}`;
|
||||
return apiService.apiCall<AssetMaintenanceListResponse>(endpoint);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get detailed information about a specific maintenance log
|
||||
*/
|
||||
async getMaintenanceLogDetails(logName: string): Promise<AssetMaintenanceLog> {
|
||||
const endpoint = `${API_CONFIG.ENDPOINTS.GET_ASSET_MAINTENANCE_LOG_DETAILS}?log_name=${encodeURIComponent(logName)}`;
|
||||
return apiService.apiCall<AssetMaintenanceLog>(endpoint);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new maintenance log
|
||||
*/
|
||||
async createMaintenanceLog(logData: CreateMaintenanceData): Promise<{ success: boolean; asset_maintenance_log: AssetMaintenanceLog; message: string }> {
|
||||
return apiService.apiCall(API_CONFIG.ENDPOINTS.CREATE_ASSET_MAINTENANCE_LOG, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ log_data: logData })
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Update an existing maintenance log
|
||||
*/
|
||||
async updateMaintenanceLog(
|
||||
logName: string,
|
||||
logData: Partial<CreateMaintenanceData>
|
||||
): Promise<{ success: boolean; asset_maintenance_log: AssetMaintenanceLog; message: string }> {
|
||||
return apiService.apiCall(API_CONFIG.ENDPOINTS.UPDATE_ASSET_MAINTENANCE_LOG, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
log_name: logName,
|
||||
log_data: logData
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a maintenance log
|
||||
*/
|
||||
async deleteMaintenanceLog(logName: string): Promise<{ success: boolean; message: string }> {
|
||||
return apiService.apiCall(API_CONFIG.ENDPOINTS.DELETE_ASSET_MAINTENANCE_LOG, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ log_name: logName })
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Update maintenance log status
|
||||
*/
|
||||
async updateMaintenanceStatus(
|
||||
logName: string,
|
||||
maintenanceStatus?: string,
|
||||
workflowState?: string
|
||||
): Promise<{ success: boolean; asset_maintenance_log: AssetMaintenanceLog; message: string }> {
|
||||
return apiService.apiCall(API_CONFIG.ENDPOINTS.UPDATE_MAINTENANCE_STATUS, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
log_name: logName,
|
||||
maintenance_status: maintenanceStatus,
|
||||
workflow_state: workflowState
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get maintenance logs for a specific asset
|
||||
*/
|
||||
async getMaintenanceLogsByAsset(
|
||||
assetName: string,
|
||||
filters?: MaintenanceFilters,
|
||||
limit: number = 20,
|
||||
offset: number = 0
|
||||
): Promise<AssetMaintenanceListResponse> {
|
||||
const params = new URLSearchParams();
|
||||
params.append('asset_name', assetName);
|
||||
|
||||
if (filters) {
|
||||
params.append('filters', JSON.stringify(filters));
|
||||
}
|
||||
|
||||
params.append('limit', limit.toString());
|
||||
params.append('offset', offset.toString());
|
||||
|
||||
const endpoint = `${API_CONFIG.ENDPOINTS.GET_MAINTENANCE_LOGS_BY_ASSET}?${params.toString()}`;
|
||||
return apiService.apiCall<AssetMaintenanceListResponse>(endpoint);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get overdue maintenance logs
|
||||
*/
|
||||
async getOverdueMaintenanceLogs(
|
||||
filters?: MaintenanceFilters,
|
||||
limit: number = 20,
|
||||
offset: number = 0
|
||||
): Promise<AssetMaintenanceListResponse> {
|
||||
const params = new URLSearchParams();
|
||||
|
||||
if (filters) {
|
||||
params.append('filters', JSON.stringify(filters));
|
||||
}
|
||||
|
||||
params.append('limit', limit.toString());
|
||||
params.append('offset', offset.toString());
|
||||
|
||||
const endpoint = `${API_CONFIG.ENDPOINTS.GET_OVERDUE_MAINTENANCE_LOGS}?${params.toString()}`;
|
||||
return apiService.apiCall<AssetMaintenanceListResponse>(endpoint);
|
||||
}
|
||||
}
|
||||
|
||||
// Create and export singleton instance
|
||||
const assetMaintenanceService = new AssetMaintenanceService();
|
||||
export default assetMaintenanceService;
|
||||
|
||||
242
src/services/ppmService.ts
Normal file
242
src/services/ppmService.ts
Normal file
@ -0,0 +1,242 @@
|
||||
import apiService from './apiService';
|
||||
import API_CONFIG from '../config/api';
|
||||
|
||||
// PPM (Asset Maintenance) Interfaces
|
||||
export interface AssetMaintenance {
|
||||
name: string;
|
||||
company?: string;
|
||||
asset_name?: string;
|
||||
custom_asset_type?: string;
|
||||
asset_category?: string;
|
||||
custom_type_of_maintenance?: string;
|
||||
custom_asset_name?: string;
|
||||
item_code?: string;
|
||||
item_name?: string;
|
||||
maintenance_team?: string;
|
||||
custom_pm_schedule?: string;
|
||||
maintenance_manager?: string;
|
||||
maintenance_manager_name?: string;
|
||||
custom_warranty?: string;
|
||||
custom_warranty_status?: string;
|
||||
custom_service_contract?: number;
|
||||
custom_service_contract_status?: string;
|
||||
custom_frequency?: string;
|
||||
custom_total_amount?: number;
|
||||
custom_no_of_pms?: number;
|
||||
custom_price_per_pm?: number;
|
||||
creation?: string;
|
||||
modified?: string;
|
||||
owner?: string;
|
||||
modified_by?: string;
|
||||
docstatus?: number;
|
||||
idx?: number;
|
||||
}
|
||||
|
||||
export interface AssetMaintenanceListResponse {
|
||||
asset_maintenances: AssetMaintenance[];
|
||||
total_count: number;
|
||||
limit: number;
|
||||
offset: number;
|
||||
has_more: boolean;
|
||||
}
|
||||
|
||||
export interface MaintenanceTask {
|
||||
name: string;
|
||||
parent?: string;
|
||||
task?: string;
|
||||
task_name?: string;
|
||||
start_date?: string;
|
||||
end_date?: string;
|
||||
periodicity?: string;
|
||||
maintenance_type?: string;
|
||||
maintenance_status?: string;
|
||||
assign_to?: string;
|
||||
assign_to_name?: string;
|
||||
next_due_date?: string;
|
||||
last_completion_date?: string;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
export interface ServiceCoverage {
|
||||
name: string;
|
||||
parent?: string;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
export interface PPMFilters {
|
||||
company?: string;
|
||||
asset_name?: string;
|
||||
custom_asset_type?: string;
|
||||
maintenance_team?: string;
|
||||
custom_service_contract?: number;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
export interface CreatePPMData {
|
||||
company?: string;
|
||||
asset_name?: string;
|
||||
custom_asset_type?: string;
|
||||
maintenance_team?: string;
|
||||
custom_frequency?: string;
|
||||
custom_total_amount?: number;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
class PPMService {
|
||||
/**
|
||||
* Get list of asset maintenances (PPM schedules) with optional filters and pagination
|
||||
*/
|
||||
async getAssetMaintenances(
|
||||
filters?: PPMFilters,
|
||||
fields?: string[],
|
||||
limit: number = 20,
|
||||
offset: number = 0,
|
||||
orderBy?: string
|
||||
): Promise<AssetMaintenanceListResponse> {
|
||||
const params = new URLSearchParams();
|
||||
|
||||
if (filters) {
|
||||
params.append('filters', JSON.stringify(filters));
|
||||
}
|
||||
|
||||
if (fields && fields.length > 0) {
|
||||
params.append('fields', JSON.stringify(fields));
|
||||
}
|
||||
|
||||
params.append('limit', limit.toString());
|
||||
params.append('offset', offset.toString());
|
||||
|
||||
if (orderBy) {
|
||||
params.append('order_by', orderBy);
|
||||
}
|
||||
|
||||
const endpoint = `${API_CONFIG.ENDPOINTS.GET_ASSET_MAINTENANCES}?${params.toString()}`;
|
||||
return apiService.apiCall<AssetMaintenanceListResponse>(endpoint);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get detailed information about a specific asset maintenance
|
||||
*/
|
||||
async getAssetMaintenanceDetails(maintenanceName: string): Promise<AssetMaintenance> {
|
||||
const params = new URLSearchParams();
|
||||
params.append('maintenance_name', maintenanceName);
|
||||
const endpoint = `${API_CONFIG.ENDPOINTS.GET_ASSET_MAINTENANCE_DETAILS}?${params.toString()}`;
|
||||
return apiService.apiCall<AssetMaintenance>(endpoint);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new asset maintenance (PPM schedule)
|
||||
*/
|
||||
async createAssetMaintenance(data: CreatePPMData): Promise<{ success: boolean; asset_maintenance: AssetMaintenance; message?: string }> {
|
||||
const endpoint = `${API_CONFIG.ENDPOINTS.CREATE_ASSET_MAINTENANCE}`;
|
||||
return apiService.apiCall<{ success: boolean; asset_maintenance: AssetMaintenance; message?: string }>(
|
||||
endpoint,
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ maintenance_data: JSON.stringify(data) })
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update an existing asset maintenance
|
||||
*/
|
||||
async updateAssetMaintenance(
|
||||
maintenanceName: string,
|
||||
data: Partial<CreatePPMData>
|
||||
): Promise<{ success: boolean; asset_maintenance: AssetMaintenance; message?: string }> {
|
||||
const endpoint = `${API_CONFIG.ENDPOINTS.UPDATE_ASSET_MAINTENANCE}`;
|
||||
return apiService.apiCall<{ success: boolean; asset_maintenance: AssetMaintenance; message?: string }>(
|
||||
endpoint,
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
maintenance_name: maintenanceName,
|
||||
maintenance_data: JSON.stringify(data)
|
||||
})
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete an asset maintenance
|
||||
*/
|
||||
async deleteAssetMaintenance(maintenanceName: string): Promise<{ success: boolean; message?: string }> {
|
||||
const endpoint = `${API_CONFIG.ENDPOINTS.DELETE_ASSET_MAINTENANCE}`;
|
||||
return apiService.apiCall<{ success: boolean; message?: string }>(
|
||||
endpoint,
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ maintenance_name: maintenanceName })
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all maintenance tasks for a specific asset maintenance
|
||||
*/
|
||||
async getMaintenanceTasks(maintenanceName: string): Promise<{ maintenance_tasks: MaintenanceTask[]; total_count: number }> {
|
||||
const params = new URLSearchParams();
|
||||
params.append('maintenance_name', maintenanceName);
|
||||
const endpoint = `${API_CONFIG.ENDPOINTS.GET_MAINTENANCE_TASKS}?${params.toString()}`;
|
||||
return apiService.apiCall<{ maintenance_tasks: MaintenanceTask[]; total_count: number }>(endpoint);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get service coverage for a specific asset maintenance
|
||||
*/
|
||||
async getServiceCoverage(maintenanceName: string): Promise<{ service_coverage: ServiceCoverage[]; total_count: number }> {
|
||||
const params = new URLSearchParams();
|
||||
params.append('maintenance_name', maintenanceName);
|
||||
const endpoint = `${API_CONFIG.ENDPOINTS.GET_SERVICE_COVERAGE}?${params.toString()}`;
|
||||
return apiService.apiCall<{ service_coverage: ServiceCoverage[]; total_count: number }>(endpoint);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all maintenance schedules for a specific asset
|
||||
*/
|
||||
async getMaintenancesByAsset(
|
||||
assetName: string,
|
||||
filters?: PPMFilters,
|
||||
limit: number = 20,
|
||||
offset: number = 0
|
||||
): Promise<AssetMaintenanceListResponse> {
|
||||
const params = new URLSearchParams();
|
||||
params.append('asset_name', assetName);
|
||||
|
||||
if (filters) {
|
||||
params.append('filters', JSON.stringify(filters));
|
||||
}
|
||||
|
||||
params.append('limit', limit.toString());
|
||||
params.append('offset', offset.toString());
|
||||
|
||||
const endpoint = `${API_CONFIG.ENDPOINTS.GET_MAINTENANCES_BY_ASSET}?${params.toString()}`;
|
||||
return apiService.apiCall<AssetMaintenanceListResponse>(endpoint);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all asset maintenances with active service contracts
|
||||
*/
|
||||
async getActiveServiceContracts(
|
||||
filters?: PPMFilters,
|
||||
limit: number = 20,
|
||||
offset: number = 0
|
||||
): Promise<AssetMaintenanceListResponse> {
|
||||
const params = new URLSearchParams();
|
||||
|
||||
if (filters) {
|
||||
params.append('filters', JSON.stringify(filters));
|
||||
}
|
||||
|
||||
params.append('limit', limit.toString());
|
||||
params.append('offset', offset.toString());
|
||||
|
||||
const endpoint = `${API_CONFIG.ENDPOINTS.GET_ACTIVE_SERVICE_CONTRACTS}?${params.toString()}`;
|
||||
return apiService.apiCall<AssetMaintenanceListResponse>(endpoint);
|
||||
}
|
||||
}
|
||||
|
||||
const ppmService = new PPMService();
|
||||
export default ppmService;
|
||||
|
||||
205
src/services/workOrderService.ts
Normal file
205
src/services/workOrderService.ts
Normal file
@ -0,0 +1,205 @@
|
||||
import apiService from './apiService';
|
||||
import API_CONFIG from '../config/api';
|
||||
|
||||
// Work Order Interfaces
|
||||
export interface WorkOrder {
|
||||
name: string;
|
||||
company?: string;
|
||||
naming_series?: string;
|
||||
work_order_type?: string;
|
||||
asset_type?: string;
|
||||
manufacturer?: string;
|
||||
serial_number?: string;
|
||||
custom_priority_?: string;
|
||||
asset?: string;
|
||||
custom_maintenance_manager?: string;
|
||||
department?: string;
|
||||
repair_status?: string;
|
||||
asset_name?: string;
|
||||
supplier?: string;
|
||||
custom_pending_reason?: string;
|
||||
model?: string;
|
||||
custom_site_contractor?: string;
|
||||
custom_subcontractor?: string;
|
||||
custom_service_agreement?: string;
|
||||
custom_service_coverage?: string;
|
||||
custom_start_date?: string;
|
||||
custom_end_date?: string;
|
||||
custom_total_amount?: number;
|
||||
warranty?: string;
|
||||
service_contract?: string;
|
||||
covering_spare_parts?: string;
|
||||
spare_parts_labour?: string;
|
||||
covering_labour?: string;
|
||||
ppm_only?: number;
|
||||
failure_date?: string;
|
||||
total_hours_spent?: number;
|
||||
job_completed?: string;
|
||||
custom_difference?: number;
|
||||
custom_vendors_hrs?: number;
|
||||
custom_deadline_date?: string;
|
||||
custom_diffrence?: number;
|
||||
feedback_rating?: number;
|
||||
first_responded_on?: string;
|
||||
penalty?: number;
|
||||
custom_assigned_supervisor?: string;
|
||||
stock_consumption?: number;
|
||||
need_procurement?: number;
|
||||
repair_cost?: number;
|
||||
total_repair_cost?: number;
|
||||
capitalize_repair_cost?: number;
|
||||
increase_in_asset_life?: number;
|
||||
description?: string;
|
||||
actions_performed?: string;
|
||||
bio_med_dept?: string;
|
||||
workflow_state?: string;
|
||||
creation?: string;
|
||||
modified?: string;
|
||||
owner?: string;
|
||||
modified_by?: string;
|
||||
docstatus?: number;
|
||||
idx?: number;
|
||||
}
|
||||
|
||||
export interface WorkOrderListResponse {
|
||||
work_orders: WorkOrder[];
|
||||
total_count: number;
|
||||
limit: number;
|
||||
offset: number;
|
||||
has_more: boolean;
|
||||
}
|
||||
|
||||
export interface WorkOrderFilters {
|
||||
company?: string;
|
||||
department?: string;
|
||||
work_order_type?: string;
|
||||
repair_status?: string;
|
||||
workflow_state?: string;
|
||||
asset?: string;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
export interface CreateWorkOrderData {
|
||||
company?: string;
|
||||
work_order_type?: string;
|
||||
asset?: string;
|
||||
asset_name?: string;
|
||||
description?: string;
|
||||
repair_status?: string;
|
||||
workflow_state?: string;
|
||||
department?: string;
|
||||
custom_priority_?: string;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
class WorkOrderService {
|
||||
/**
|
||||
* Get list of work orders with optional filters and pagination
|
||||
*/
|
||||
async getWorkOrders(
|
||||
filters?: WorkOrderFilters,
|
||||
fields?: string[],
|
||||
limit: number = 20,
|
||||
offset: number = 0,
|
||||
orderBy?: string
|
||||
): Promise<WorkOrderListResponse> {
|
||||
const params = new URLSearchParams();
|
||||
|
||||
if (filters) {
|
||||
params.append('filters', JSON.stringify(filters));
|
||||
}
|
||||
|
||||
if (fields && fields.length > 0) {
|
||||
params.append('fields', JSON.stringify(fields));
|
||||
}
|
||||
|
||||
params.append('limit', limit.toString());
|
||||
params.append('offset', offset.toString());
|
||||
|
||||
if (orderBy) {
|
||||
params.append('order_by', orderBy);
|
||||
}
|
||||
|
||||
const endpoint = `${API_CONFIG.ENDPOINTS.GET_WORK_ORDERS}?${params.toString()}`;
|
||||
return apiService.apiCall<WorkOrderListResponse>(endpoint);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get detailed information about a specific work order
|
||||
*/
|
||||
async getWorkOrderDetails(workOrderName: string): Promise<WorkOrder> {
|
||||
const endpoint = `${API_CONFIG.ENDPOINTS.GET_WORK_ORDER_DETAILS}?work_order_name=${encodeURIComponent(workOrderName)}`;
|
||||
return apiService.apiCall<WorkOrder>(endpoint);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new work order
|
||||
*/
|
||||
async createWorkOrder(workOrderData: CreateWorkOrderData): Promise<{ success: boolean; work_order: WorkOrder; message: string }> {
|
||||
return apiService.apiCall(API_CONFIG.ENDPOINTS.CREATE_WORK_ORDER, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ work_order_data: workOrderData })
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Update an existing work order
|
||||
*/
|
||||
async updateWorkOrder(
|
||||
workOrderName: string,
|
||||
workOrderData: Partial<CreateWorkOrderData>
|
||||
): Promise<{ success: boolean; work_order: WorkOrder; message: string }> {
|
||||
return apiService.apiCall(API_CONFIG.ENDPOINTS.UPDATE_WORK_ORDER, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
work_order_name: workOrderName,
|
||||
work_order_data: workOrderData
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a work order
|
||||
*/
|
||||
async deleteWorkOrder(workOrderName: string): Promise<{ success: boolean; message: string }> {
|
||||
return apiService.apiCall(API_CONFIG.ENDPOINTS.DELETE_WORK_ORDER, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ work_order_name: workOrderName })
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Update work order status
|
||||
*/
|
||||
async updateWorkOrderStatus(
|
||||
workOrderName: string,
|
||||
repairStatus?: string,
|
||||
workflowState?: string
|
||||
): Promise<{ success: boolean; work_order: WorkOrder; message: string }> {
|
||||
return apiService.apiCall(API_CONFIG.ENDPOINTS.UPDATE_WORK_ORDER_STATUS, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
work_order_name: workOrderName,
|
||||
repair_status: repairStatus,
|
||||
workflow_state: workflowState
|
||||
})
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Create and export singleton instance
|
||||
const workOrderService = new WorkOrderService();
|
||||
export default workOrderService;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user