Compare commits
25 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 811f102adb | |||
|
|
0b60478cbf | ||
|
|
a0124f24f6 | ||
|
|
39e49543e3 | ||
|
|
276abc37a4 | ||
|
|
d4478bdacc | ||
|
|
9cf1f3201b | ||
| c3a42d67b5 | |||
| 0a9f589c05 | |||
|
|
29ded9f6a6 | ||
|
|
fc091eb666 | ||
|
|
34f8581b29 | ||
|
|
03cf1cf98d | ||
| a58fc89c46 | |||
| dd9f790490 | |||
|
|
c11db48dbf | ||
|
|
18ed88bff0 | ||
|
|
23b58de1f9 | ||
|
|
e62a7be9fa | ||
|
|
a2d3f48fca | ||
|
|
5d92f1bac9 | ||
|
|
af5c2d6019 | ||
| de987a9fc5 | |||
| 037c11f06a | |||
| 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">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<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" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>frappe-frontend</title>
|
<title>frappe-frontend</title>
|
||||||
</head>
|
</head>
|
||||||
|
|||||||
308
package-lock.json
generated
308
package-lock.json
generated
@ -10,6 +10,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/react-router-dom": "^5.3.3",
|
"@types/react-router-dom": "^5.3.3",
|
||||||
"axios": "^1.12.2",
|
"axios": "^1.12.2",
|
||||||
|
"lucide-react": "^0.553.0",
|
||||||
"react": "^19.1.1",
|
"react": "^19.1.1",
|
||||||
"react-dom": "^19.1.1",
|
"react-dom": "^19.1.1",
|
||||||
"react-icons": "^5.5.0",
|
"react-icons": "^5.5.0",
|
||||||
@ -2307,18 +2308,6 @@
|
|||||||
"node": ">=0.4.0"
|
"node": ">=0.4.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/detect-libc": {
|
|
||||||
"version": "2.1.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
|
|
||||||
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "Apache-2.0",
|
|
||||||
"optional": true,
|
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
|
||||||
"node": ">=8"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/didyoumean": {
|
"node_modules/didyoumean": {
|
||||||
"version": "1.2.2",
|
"version": "1.2.2",
|
||||||
"resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz",
|
"resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz",
|
||||||
@ -3191,18 +3180,6 @@
|
|||||||
"@pkgjs/parseargs": "^0.11.0"
|
"@pkgjs/parseargs": "^0.11.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/jiti": {
|
|
||||||
"version": "2.6.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz",
|
|
||||||
"integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"peer": true,
|
|
||||||
"bin": {
|
|
||||||
"jiti": "lib/jiti-cli.mjs"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/js-tokens": {
|
"node_modules/js-tokens": {
|
||||||
"version": "4.0.0",
|
"version": "4.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
|
||||||
@ -3294,280 +3271,6 @@
|
|||||||
"node": ">= 0.8.0"
|
"node": ">= 0.8.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/lightningcss": {
|
|
||||||
"version": "1.30.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.30.2.tgz",
|
|
||||||
"integrity": "sha512-utfs7Pr5uJyyvDETitgsaqSyjCb2qNRAtuqUeWIAKztsOYdcACf2KtARYXg2pSvhkt+9NfoaNY7fxjl6nuMjIQ==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MPL-2.0",
|
|
||||||
"optional": true,
|
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
|
||||||
"detect-libc": "^2.0.3"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 12.0.0"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"type": "opencollective",
|
|
||||||
"url": "https://opencollective.com/parcel"
|
|
||||||
},
|
|
||||||
"optionalDependencies": {
|
|
||||||
"lightningcss-android-arm64": "1.30.2",
|
|
||||||
"lightningcss-darwin-arm64": "1.30.2",
|
|
||||||
"lightningcss-darwin-x64": "1.30.2",
|
|
||||||
"lightningcss-freebsd-x64": "1.30.2",
|
|
||||||
"lightningcss-linux-arm-gnueabihf": "1.30.2",
|
|
||||||
"lightningcss-linux-arm64-gnu": "1.30.2",
|
|
||||||
"lightningcss-linux-arm64-musl": "1.30.2",
|
|
||||||
"lightningcss-linux-x64-gnu": "1.30.2",
|
|
||||||
"lightningcss-linux-x64-musl": "1.30.2",
|
|
||||||
"lightningcss-win32-arm64-msvc": "1.30.2",
|
|
||||||
"lightningcss-win32-x64-msvc": "1.30.2"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/lightningcss-android-arm64": {
|
|
||||||
"version": "1.30.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.30.2.tgz",
|
|
||||||
"integrity": "sha512-BH9sEdOCahSgmkVhBLeU7Hc9DWeZ1Eb6wNS6Da8igvUwAe0sqROHddIlvU06q3WyXVEOYDZ6ykBZQnjTbmo4+A==",
|
|
||||||
"cpu": [
|
|
||||||
"arm64"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "MPL-2.0",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"android"
|
|
||||||
],
|
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 12.0.0"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"type": "opencollective",
|
|
||||||
"url": "https://opencollective.com/parcel"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/lightningcss-darwin-arm64": {
|
|
||||||
"version": "1.30.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.30.2.tgz",
|
|
||||||
"integrity": "sha512-ylTcDJBN3Hp21TdhRT5zBOIi73P6/W0qwvlFEk22fkdXchtNTOU4Qc37SkzV+EKYxLouZ6M4LG9NfZ1qkhhBWA==",
|
|
||||||
"cpu": [
|
|
||||||
"arm64"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "MPL-2.0",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"darwin"
|
|
||||||
],
|
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 12.0.0"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"type": "opencollective",
|
|
||||||
"url": "https://opencollective.com/parcel"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/lightningcss-darwin-x64": {
|
|
||||||
"version": "1.30.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.30.2.tgz",
|
|
||||||
"integrity": "sha512-oBZgKchomuDYxr7ilwLcyms6BCyLn0z8J0+ZZmfpjwg9fRVZIR5/GMXd7r9RH94iDhld3UmSjBM6nXWM2TfZTQ==",
|
|
||||||
"cpu": [
|
|
||||||
"x64"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "MPL-2.0",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"darwin"
|
|
||||||
],
|
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 12.0.0"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"type": "opencollective",
|
|
||||||
"url": "https://opencollective.com/parcel"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/lightningcss-freebsd-x64": {
|
|
||||||
"version": "1.30.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.30.2.tgz",
|
|
||||||
"integrity": "sha512-c2bH6xTrf4BDpK8MoGG4Bd6zAMZDAXS569UxCAGcA7IKbHNMlhGQ89eRmvpIUGfKWNVdbhSbkQaWhEoMGmGslA==",
|
|
||||||
"cpu": [
|
|
||||||
"x64"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "MPL-2.0",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"freebsd"
|
|
||||||
],
|
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 12.0.0"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"type": "opencollective",
|
|
||||||
"url": "https://opencollective.com/parcel"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/lightningcss-linux-arm-gnueabihf": {
|
|
||||||
"version": "1.30.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.30.2.tgz",
|
|
||||||
"integrity": "sha512-eVdpxh4wYcm0PofJIZVuYuLiqBIakQ9uFZmipf6LF/HRj5Bgm0eb3qL/mr1smyXIS1twwOxNWndd8z0E374hiA==",
|
|
||||||
"cpu": [
|
|
||||||
"arm"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "MPL-2.0",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"linux"
|
|
||||||
],
|
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 12.0.0"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"type": "opencollective",
|
|
||||||
"url": "https://opencollective.com/parcel"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/lightningcss-linux-arm64-gnu": {
|
|
||||||
"version": "1.30.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.30.2.tgz",
|
|
||||||
"integrity": "sha512-UK65WJAbwIJbiBFXpxrbTNArtfuznvxAJw4Q2ZGlU8kPeDIWEX1dg3rn2veBVUylA2Ezg89ktszWbaQnxD/e3A==",
|
|
||||||
"cpu": [
|
|
||||||
"arm64"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "MPL-2.0",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"linux"
|
|
||||||
],
|
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 12.0.0"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"type": "opencollective",
|
|
||||||
"url": "https://opencollective.com/parcel"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/lightningcss-linux-arm64-musl": {
|
|
||||||
"version": "1.30.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.30.2.tgz",
|
|
||||||
"integrity": "sha512-5Vh9dGeblpTxWHpOx8iauV02popZDsCYMPIgiuw97OJ5uaDsL86cnqSFs5LZkG3ghHoX5isLgWzMs+eD1YzrnA==",
|
|
||||||
"cpu": [
|
|
||||||
"arm64"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "MPL-2.0",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"linux"
|
|
||||||
],
|
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 12.0.0"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"type": "opencollective",
|
|
||||||
"url": "https://opencollective.com/parcel"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/lightningcss-linux-x64-gnu": {
|
|
||||||
"version": "1.30.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.30.2.tgz",
|
|
||||||
"integrity": "sha512-Cfd46gdmj1vQ+lR6VRTTadNHu6ALuw2pKR9lYq4FnhvgBc4zWY1EtZcAc6EffShbb1MFrIPfLDXD6Xprbnni4w==",
|
|
||||||
"cpu": [
|
|
||||||
"x64"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "MPL-2.0",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"linux"
|
|
||||||
],
|
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 12.0.0"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"type": "opencollective",
|
|
||||||
"url": "https://opencollective.com/parcel"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/lightningcss-linux-x64-musl": {
|
|
||||||
"version": "1.30.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.30.2.tgz",
|
|
||||||
"integrity": "sha512-XJaLUUFXb6/QG2lGIW6aIk6jKdtjtcffUT0NKvIqhSBY3hh9Ch+1LCeH80dR9q9LBjG3ewbDjnumefsLsP6aiA==",
|
|
||||||
"cpu": [
|
|
||||||
"x64"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "MPL-2.0",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"linux"
|
|
||||||
],
|
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 12.0.0"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"type": "opencollective",
|
|
||||||
"url": "https://opencollective.com/parcel"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/lightningcss-win32-arm64-msvc": {
|
|
||||||
"version": "1.30.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.30.2.tgz",
|
|
||||||
"integrity": "sha512-FZn+vaj7zLv//D/192WFFVA0RgHawIcHqLX9xuWiQt7P0PtdFEVaxgF9rjM/IRYHQXNnk61/H/gb2Ei+kUQ4xQ==",
|
|
||||||
"cpu": [
|
|
||||||
"arm64"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "MPL-2.0",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"win32"
|
|
||||||
],
|
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 12.0.0"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"type": "opencollective",
|
|
||||||
"url": "https://opencollective.com/parcel"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/lightningcss-win32-x64-msvc": {
|
|
||||||
"version": "1.30.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.30.2.tgz",
|
|
||||||
"integrity": "sha512-5g1yc73p+iAkid5phb4oVFMB45417DkRevRbt/El/gKXJk4jid+vPFF/AXbxn05Aky8PapwzZrdJShv5C0avjw==",
|
|
||||||
"cpu": [
|
|
||||||
"x64"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "MPL-2.0",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"win32"
|
|
||||||
],
|
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 12.0.0"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"type": "opencollective",
|
|
||||||
"url": "https://opencollective.com/parcel"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/lilconfig": {
|
"node_modules/lilconfig": {
|
||||||
"version": "3.1.3",
|
"version": "3.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz",
|
||||||
@ -3621,6 +3324,15 @@
|
|||||||
"yallist": "^3.0.2"
|
"yallist": "^3.0.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/lucide-react": {
|
||||||
|
"version": "0.553.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.553.0.tgz",
|
||||||
|
"integrity": "sha512-BRgX5zrWmNy/lkVAe0dXBgd7XQdZ3HTf+Hwe3c9WK6dqgnj9h+hxV+MDncM88xDWlCq27+TKvHGE70ViODNILw==",
|
||||||
|
"license": "ISC",
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/math-intrinsics": {
|
"node_modules/math-intrinsics": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
|
||||||
|
|||||||
@ -12,6 +12,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/react-router-dom": "^5.3.3",
|
"@types/react-router-dom": "^5.3.3",
|
||||||
"axios": "^1.12.2",
|
"axios": "^1.12.2",
|
||||||
|
"lucide-react": "^0.553.0",
|
||||||
"react": "^19.1.1",
|
"react": "^19.1.1",
|
||||||
"react-dom": "^19.1.1",
|
"react-dom": "^19.1.1",
|
||||||
"react-icons": "^5.5.0",
|
"react-icons": "^5.5.0",
|
||||||
|
|||||||
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 EventsList from './pages/EventsList';
|
||||||
import AssetList from './pages/AssetList';
|
import AssetList from './pages/AssetList';
|
||||||
import AssetDetail from './pages/AssetDetail';
|
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';
|
import Sidebar from './components/Sidebar';
|
||||||
|
|
||||||
// Layout with 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
|
<Route
|
||||||
path="/old-dashboard"
|
path="/old-dashboard"
|
||||||
element={
|
element={
|
||||||
|
|||||||
169
src/components/LinkField.tsx
Normal file
169
src/components/LinkField.tsx
Normal file
@ -0,0 +1,169 @@
|
|||||||
|
import React, { useState, useEffect, useRef } from 'react';
|
||||||
|
import apiService from '../services/apiService';
|
||||||
|
|
||||||
|
interface LinkFieldProps {
|
||||||
|
label: string;
|
||||||
|
doctype: string;
|
||||||
|
value: string;
|
||||||
|
onChange: (value: string) => void;
|
||||||
|
placeholder?: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
filters?: Record<string, any>;
|
||||||
|
compact?: boolean; // ✅ Add this prop
|
||||||
|
}
|
||||||
|
|
||||||
|
const LinkField: React.FC<LinkFieldProps> = ({
|
||||||
|
label,
|
||||||
|
doctype,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
placeholder,
|
||||||
|
disabled = false,
|
||||||
|
filters = {},
|
||||||
|
compact = false, // ✅ Default to 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 with filters
|
||||||
|
const searchLink = async (text: string = '') => {
|
||||||
|
try {
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
doctype,
|
||||||
|
txt: text,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add filters if provided
|
||||||
|
if (filters && Object.keys(filters).length > 0) {
|
||||||
|
params.append('filters', JSON.stringify(filters));
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await apiService.apiCall<{ value: string; description?: string }[]>(
|
||||||
|
`/api/method/frappe.desk.search.search_link?${params.toString()}`
|
||||||
|
);
|
||||||
|
setSearchResults(response || []);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Error fetching ${doctype} links:`, error);
|
||||||
|
setSearchResults([]);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Fetch default options when dropdown opens or filters change
|
||||||
|
useEffect(() => {
|
||||||
|
if (isDropdownOpen) {
|
||||||
|
searchLink(searchText || '');
|
||||||
|
}
|
||||||
|
}, [isDropdownOpen, filters]);
|
||||||
|
|
||||||
|
// Close dropdown when clicking outside
|
||||||
|
useEffect(() => {
|
||||||
|
const handleClickOutside = (event: MouseEvent) => {
|
||||||
|
if (containerRef.current && !containerRef.current.contains(event.target as Node)) {
|
||||||
|
setDropdownOpen(false);
|
||||||
|
setSearchText('');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
document.addEventListener('mousedown', handleClickOutside);
|
||||||
|
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Handle selecting an item from dropdown
|
||||||
|
const handleSelect = (selectedValue: string) => {
|
||||||
|
onChange(selectedValue);
|
||||||
|
setSearchText('');
|
||||||
|
setDropdownOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle clearing the field
|
||||||
|
const handleClear = () => {
|
||||||
|
onChange('');
|
||||||
|
setSearchText('');
|
||||||
|
setDropdownOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={containerRef} className={`relative w-full ${compact ? 'mb-2' : 'mb-4'}`}>
|
||||||
|
<label className={`block font-medium text-gray-700 dark:text-gray-300 ${compact ? 'text-[10px] mb-0.5' : 'text-sm mb-1'}`}>
|
||||||
|
{label}
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div className="relative">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={isDropdownOpen ? searchText : value}
|
||||||
|
placeholder={placeholder || `Select ${label}`}
|
||||||
|
disabled={disabled}
|
||||||
|
className={`w-full border border-gray-300 dark:border-gray-600 rounded-md
|
||||||
|
focus:outline-none disabled:bg-gray-100 dark:disabled:bg-gray-700
|
||||||
|
bg-white dark:bg-gray-700 text-gray-900 dark:text-white
|
||||||
|
${compact
|
||||||
|
? 'px-2 py-1 text-xs focus:ring-1 focus:ring-blue-500 rounded'
|
||||||
|
: 'px-3 py-2 text-sm focus:ring-2 focus:ring-blue-500'
|
||||||
|
}
|
||||||
|
${value ? (compact ? 'pr-5' : 'pr-8') : ''}`}
|
||||||
|
onFocus={() => {
|
||||||
|
if (!disabled) {
|
||||||
|
setDropdownOpen(true);
|
||||||
|
setSearchText('');
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onChange={(e) => {
|
||||||
|
const text = e.target.value;
|
||||||
|
setSearchText(text);
|
||||||
|
searchLink(text);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Clear button */}
|
||||||
|
{value && !disabled && !isDropdownOpen && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleClear}
|
||||||
|
className={`absolute top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300
|
||||||
|
${compact ? 'right-1 text-xs' : 'right-2 text-sm'}`}
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isDropdownOpen && searchResults.length > 0 && !disabled && (
|
||||||
|
<ul className={`absolute z-50 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600
|
||||||
|
rounded-md overflow-auto w-full shadow-lg
|
||||||
|
${compact ? 'mt-0.5 max-h-36' : 'mt-1 max-h-48'}`}>
|
||||||
|
{searchResults.map((item, idx) => (
|
||||||
|
<li
|
||||||
|
key={idx}
|
||||||
|
onClick={() => handleSelect(item.value)}
|
||||||
|
className={`cursor-pointer text-gray-900 dark:text-gray-100
|
||||||
|
hover:bg-blue-500 dark:hover:bg-blue-600 hover:text-white
|
||||||
|
${compact ? 'px-2 py-1 text-xs' : 'px-3 py-2 text-sm'}
|
||||||
|
${value === item.value ? 'bg-blue-50 dark:bg-blue-700 font-semibold' : ''}`}
|
||||||
|
>
|
||||||
|
{item.value}
|
||||||
|
{item.description && (
|
||||||
|
<span className={`text-gray-600 dark:text-gray-300 ml-2
|
||||||
|
${compact ? 'text-[9px] ml-1' : 'text-xs ml-2'}`}>
|
||||||
|
{item.description}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Show message when no results found */}
|
||||||
|
{isDropdownOpen && searchResults.length === 0 && !disabled && (
|
||||||
|
<div className={`absolute z-50 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600
|
||||||
|
rounded-md w-full shadow-lg text-center text-gray-500 dark:text-gray-400
|
||||||
|
${compact ? 'mt-0.5 p-1.5 text-[10px]' : 'mt-1 p-3 text-sm'}`}>
|
||||||
|
No results found
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default LinkField;
|
||||||
@ -2,23 +2,17 @@ import React, { useState } from 'react';
|
|||||||
import { Link, useLocation, useNavigate } from 'react-router-dom';
|
import { Link, useLocation, useNavigate } from 'react-router-dom';
|
||||||
import { useTheme } from '../contexts/ThemeContext';
|
import { useTheme } from '../contexts/ThemeContext';
|
||||||
import {
|
import {
|
||||||
FaTools,
|
LayoutDashboard,
|
||||||
FaBox,
|
Package,
|
||||||
FaWrench,
|
Wrench,
|
||||||
FaCog,
|
Menu,
|
||||||
FaUsers,
|
X,
|
||||||
FaChartBar,
|
Moon,
|
||||||
FaBuilding,
|
Sun,
|
||||||
FaTruck,
|
LogOut,
|
||||||
FaFileContract,
|
ClipboardList,
|
||||||
FaInfoCircle,
|
Calendar
|
||||||
FaBars,
|
} from 'lucide-react';
|
||||||
FaTimes,
|
|
||||||
FaHome,
|
|
||||||
FaMoon,
|
|
||||||
FaSun,
|
|
||||||
FaSignOutAlt
|
|
||||||
} from 'react-icons/fa';
|
|
||||||
|
|
||||||
interface SidebarLink {
|
interface SidebarLink {
|
||||||
id: string;
|
id: string;
|
||||||
@ -45,110 +39,117 @@ const Sidebar: React.FC<SidebarProps> = ({ userEmail }) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Role-based visibility logic
|
// Role-based visibility logic
|
||||||
const isMaintenanceManagerKASH = userEmail === 'maintenancemanager-kash@gmail.com';
|
// const isMaintenanceManagerKASH = userEmail === 'maintenancemanager-kash@gmail.com';
|
||||||
const isMaintenanceManagerTH = userEmail === 'maintenancemanager-th@gmail.com';
|
// const isMaintenanceManagerTH = userEmail === 'maintenancemanager-th@gmail.com';
|
||||||
const isMaintenanceManagerDAJH = userEmail === 'maintenancemanager-dajh@gmail.com';
|
// const isMaintenanceManagerDAJH = userEmail === 'maintenancemanager-dajh@gmail.com';
|
||||||
const isFinanceManager = userEmail === 'financemanager@gmail.com';
|
const isFinanceManager = userEmail === 'financemanager@gmail.com';
|
||||||
const isEndUser = userEmail && (
|
const isEndUser = userEmail && (
|
||||||
userEmail.startsWith('enduser1-kash') ||
|
userEmail.startsWith('enduser1-kash') ||
|
||||||
userEmail.startsWith('enduser1-dajh') ||
|
userEmail.startsWith('enduser1-dajh') ||
|
||||||
userEmail.startsWith('enduser1-th')
|
userEmail.startsWith('enduser1-th')
|
||||||
);
|
);
|
||||||
const isTechnician = userEmail && (
|
// const isTechnician = userEmail && (
|
||||||
userEmail.startsWith('technician1-kash') ||
|
// userEmail.startsWith('technician1-kash') ||
|
||||||
userEmail.startsWith('technician1-dajh') ||
|
// userEmail.startsWith('technician1-dajh') ||
|
||||||
userEmail.startsWith('technician1-th')
|
// userEmail.startsWith('technician1-th')
|
||||||
);
|
// );
|
||||||
|
|
||||||
const showAsset = !isFinanceManager && !isEndUser;
|
const showAsset = !isFinanceManager && !isEndUser;
|
||||||
const showInventory = !isFinanceManager && !isEndUser;
|
// const showInventory = !isFinanceManager && !isEndUser;
|
||||||
const showPreventiveMaintenance = !isFinanceManager && !isEndUser;
|
const showPreventiveMaintenance = !isFinanceManager && !isEndUser;
|
||||||
const showGeneralWO = !isFinanceManager && !isEndUser;
|
const showGeneralWO = !isFinanceManager && !isEndUser;
|
||||||
const showAMTeam = !isFinanceManager && !isEndUser;
|
// const showAMTeam = !isFinanceManager && !isEndUser;
|
||||||
const showProjectDashboard = !isMaintenanceManagerKASH && !isMaintenanceManagerTH && !isMaintenanceManagerDAJH && !isFinanceManager && !isEndUser && !isTechnician;
|
// const showProjectDashboard = !isMaintenanceManagerKASH && !isMaintenanceManagerTH && !isMaintenanceManagerDAJH && !isFinanceManager && !isEndUser && !isTechnician;
|
||||||
const showSiteDashboards = !isFinanceManager && !isEndUser;
|
// const showSiteDashboards = !isFinanceManager && !isEndUser;
|
||||||
const showSupplierDashboard = !isFinanceManager && !isEndUser;
|
// const showSupplierDashboard = !isFinanceManager && !isEndUser;
|
||||||
const showSLA = !isFinanceManager && !isEndUser && !isTechnician;
|
// const showSLA = !isFinanceManager && !isEndUser && !isTechnician;
|
||||||
const showSiteInfo = !isFinanceManager && !isEndUser;
|
// const showSiteInfo = !isFinanceManager && !isEndUser;
|
||||||
|
|
||||||
const links: SidebarLink[] = [
|
const links: SidebarLink[] = [
|
||||||
{
|
{
|
||||||
id: 'dashboard',
|
id: 'dashboard',
|
||||||
title: 'Dashboard',
|
title: 'Dashboard',
|
||||||
icon: <FaHome />,
|
icon: <LayoutDashboard size={20} />,
|
||||||
path: '/dashboard',
|
path: '/dashboard',
|
||||||
visible: true
|
visible: true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'assets',
|
id: 'assets',
|
||||||
title: 'Assets',
|
title: 'Assets',
|
||||||
icon: <FaTools />,
|
icon: <Package size={20} />,
|
||||||
path: '/assets',
|
path: '/assets',
|
||||||
visible: showAsset
|
visible: showAsset
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'work-orders',
|
id: 'work-orders',
|
||||||
title: 'Work Orders',
|
title: 'Work Orders',
|
||||||
icon: <FaCog />,
|
icon: <ClipboardList size={20} />,
|
||||||
path: '/work-orders',
|
path: '/work-orders',
|
||||||
visible: showGeneralWO
|
visible: showGeneralWO
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'ppm',
|
id: 'maintenance',
|
||||||
title: 'PPM',
|
title: 'Asset Maintenance',
|
||||||
icon: <FaWrench />,
|
icon: <Wrench size={20} />,
|
||||||
path: '/ppm',
|
path: '/maintenance',
|
||||||
visible: showPreventiveMaintenance
|
visible: showPreventiveMaintenance
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'inventory',
|
id: 'ppm',
|
||||||
title: 'Inventory',
|
title: 'PPM',
|
||||||
icon: <FaBox />,
|
icon: <Calendar size={20} />,
|
||||||
path: '/inventory',
|
path: '/ppm',
|
||||||
visible: showInventory
|
visible: showPreventiveMaintenance
|
||||||
},
|
},
|
||||||
{
|
// {
|
||||||
id: 'vendors',
|
// id: 'inventory',
|
||||||
title: 'Vendors',
|
// title: 'Inventory',
|
||||||
icon: <FaTruck />,
|
// icon: <Package size={20} />,
|
||||||
path: '/vendors',
|
// path: '/inventory',
|
||||||
visible: showSupplierDashboard
|
// visible: showInventory
|
||||||
},
|
// },
|
||||||
{
|
// {
|
||||||
id: 'dashboard-view',
|
// id: 'vendors',
|
||||||
title: 'Dashboard',
|
// title: 'Vendors',
|
||||||
icon: <FaChartBar />,
|
// icon: <Truck size={20} />,
|
||||||
path: '/dashboard-view',
|
// path: '/vendors',
|
||||||
visible: showProjectDashboard
|
// visible: showSupplierDashboard
|
||||||
},
|
// },
|
||||||
{
|
// {
|
||||||
id: 'sites',
|
// id: 'dashboard-view',
|
||||||
title: 'Sites',
|
// title: 'Dashboard',
|
||||||
icon: <FaBuilding />,
|
// icon: <BarChart3 size={20} />,
|
||||||
path: '/sites',
|
// path: '/dashboard-view',
|
||||||
visible: showSiteDashboards
|
// visible: showProjectDashboard
|
||||||
},
|
// },
|
||||||
{
|
// {
|
||||||
id: 'active-map',
|
// id: 'sites',
|
||||||
title: 'Active Map',
|
// title: 'Sites',
|
||||||
icon: <FaInfoCircle />,
|
// icon: <Building2 size={20} />,
|
||||||
path: '/active-map',
|
// path: '/sites',
|
||||||
visible: showSiteInfo
|
// visible: showSiteDashboards
|
||||||
},
|
// },
|
||||||
{
|
// {
|
||||||
id: 'users',
|
// id: 'active-map',
|
||||||
title: 'Users',
|
// title: 'Active Map',
|
||||||
icon: <FaUsers />,
|
// icon: <MapPin size={20} />,
|
||||||
path: '/users',
|
// path: '/active-map',
|
||||||
visible: showAMTeam
|
// visible: showSiteInfo
|
||||||
},
|
// },
|
||||||
{
|
// {
|
||||||
id: 'account',
|
// id: 'users',
|
||||||
title: 'Account',
|
// title: 'Users',
|
||||||
icon: <FaFileContract />,
|
// icon: <Users size={20} />,
|
||||||
path: '/account',
|
// path: '/users',
|
||||||
visible: showSLA
|
// visible: showAMTeam
|
||||||
}
|
// },
|
||||||
|
// {
|
||||||
|
// id: 'account',
|
||||||
|
// title: 'Account',
|
||||||
|
// icon: <FileText size={20} />,
|
||||||
|
// path: '/account',
|
||||||
|
// visible: showSLA
|
||||||
|
// }
|
||||||
];
|
];
|
||||||
|
|
||||||
const visibleLinks = links.filter(link => link.visible);
|
const visibleLinks = links.filter(link => link.visible);
|
||||||
@ -175,18 +176,51 @@ const Sidebar: React.FC<SidebarProps> = ({ userEmail }) => {
|
|||||||
{/* Sidebar Header */}
|
{/* Sidebar Header */}
|
||||||
<div className="flex items-center justify-between p-4 border-b border-gray-200 dark:border-gray-700">
|
<div className="flex items-center justify-between p-4 border-b border-gray-200 dark:border-gray-700">
|
||||||
{!isCollapsed && (
|
{!isCollapsed && (
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-3">
|
||||||
<div className="w-8 h-8 bg-gradient-to-br from-indigo-600 to-purple-600 rounded-lg flex items-center justify-center">
|
<div className="w-10 h-10 flex items-center justify-center bg-white dark:bg-gray-700 rounded-lg p-1">
|
||||||
<span className="text-white font-bold text-sm">AL</span>
|
{/* 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>
|
</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>
|
</div>
|
||||||
)}
|
)}
|
||||||
<button
|
<button
|
||||||
onClick={() => setIsCollapsed(!isCollapsed)}
|
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"
|
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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -211,7 +245,7 @@ const Sidebar: React.FC<SidebarProps> = ({ userEmail }) => {
|
|||||||
`}
|
`}
|
||||||
title={isCollapsed ? link.title : ''}
|
title={isCollapsed ? link.title : ''}
|
||||||
>
|
>
|
||||||
<span className="text-xl">{link.icon}</span>
|
<span>{link.icon}</span>
|
||||||
{!isCollapsed && (
|
{!isCollapsed && (
|
||||||
<span className="ml-4 font-medium">{link.title}</span>
|
<span className="ml-4 font-medium">{link.title}</span>
|
||||||
)}
|
)}
|
||||||
@ -227,7 +261,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"
|
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') : ''}
|
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 && (
|
{!isCollapsed && (
|
||||||
<span className="ml-2 text-sm font-medium">
|
<span className="ml-2 text-sm font-medium">
|
||||||
{theme === 'light' ? 'Dark Mode' : 'Light Mode'}
|
{theme === 'light' ? 'Dark Mode' : 'Light Mode'}
|
||||||
@ -241,7 +275,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"
|
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' : ''}
|
title={isCollapsed ? 'Logout' : ''}
|
||||||
>
|
>
|
||||||
<FaSignOutAlt size={16} />
|
<LogOut size={18} />
|
||||||
{!isCollapsed && (
|
{!isCollapsed && (
|
||||||
<span className="ml-2 text-sm font-medium">Logout</span>
|
<span className="ml-2 text-sm font-medium">Logout</span>
|
||||||
)}
|
)}
|
||||||
@ -260,7 +294,7 @@ const Sidebar: React.FC<SidebarProps> = ({ userEmail }) => {
|
|||||||
|
|
||||||
{!isCollapsed && (
|
{!isCollapsed && (
|
||||||
<div className="text-xs text-gray-400 dark:text-gray-500 text-center">
|
<div className="text-xs text-gray-400 dark:text-gray-500 text-center">
|
||||||
Asset Lite v1.0
|
Seera Arabia AMS v1.0
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -40,13 +40,51 @@ const API_CONFIG: ApiConfig = {
|
|||||||
GET_ASSET_STATS: '/api/method/asset_lite.api.asset_api.get_asset_stats',
|
GET_ASSET_STATS: '/api/method/asset_lite.api.asset_api.get_asset_stats',
|
||||||
SEARCH_ASSETS: '/api/method/asset_lite.api.asset_api.search_assets',
|
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
|
// Authentication
|
||||||
LOGIN: '/api/method/login',
|
LOGIN: '/api/method/login',
|
||||||
LOGOUT: '/api/method/logout',
|
LOGOUT: '/api/method/logout',
|
||||||
CSRF_TOKEN: '/api/method/frappe.sessions.get_csrf_token',
|
CSRF_TOKEN: '/api/method/frappe.sessions.get_csrf_token',
|
||||||
|
|
||||||
// File Upload
|
// File Upload
|
||||||
UPLOAD_FILE: '/api/method/upload_file'
|
UPLOAD_FILE: '/api/method/upload_file',
|
||||||
|
|
||||||
|
// User Permission Management - Generic (only these are needed!)
|
||||||
|
GET_USER_PERMISSIONS: '/api/method/asset_lite.api.userperm_api.get_user_permissions',
|
||||||
|
GET_PERMISSION_FILTERS: '/api/method/asset_lite.api.userperm_api.get_permission_filters',
|
||||||
|
GET_ALLOWED_VALUES: '/api/method/asset_lite.api.userperm_api.get_allowed_values',
|
||||||
|
CHECK_DOCUMENT_ACCESS: '/api/method/asset_lite.api.userperm_api.check_document_access',
|
||||||
|
GET_CONFIGURED_DOCTYPES: '/api/method/asset_lite.api.userperm_api.get_configured_doctypes',
|
||||||
|
GET_USER_DEFAULTS: '/api/method/asset_lite.api.userperm_api.get_user_defaults',
|
||||||
|
|
||||||
},
|
},
|
||||||
|
|
||||||
// Request Configuration
|
// Request Configuration
|
||||||
|
|||||||
@ -3,13 +3,57 @@ import assetService from '../services/assetService';
|
|||||||
import type { Asset, AssetFilters, AssetFilterOptions, AssetStats, CreateAssetData } from '../services/assetService';
|
import type { Asset, AssetFilters, AssetFilterOptions, AssetStats, CreateAssetData } from '../services/assetService';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Hook to fetch list of assets with filters and pagination
|
* Merge user filters with permission filters
|
||||||
|
* Permission filters take precedence for security
|
||||||
|
*/
|
||||||
|
const mergeFilters = (
|
||||||
|
userFilters: AssetFilters | undefined,
|
||||||
|
permissionFilters: Record<string, any>
|
||||||
|
): AssetFilters => {
|
||||||
|
const merged: AssetFilters = { ...(userFilters || {}) };
|
||||||
|
|
||||||
|
// Apply permission filters (they take precedence for security)
|
||||||
|
for (const [field, value] of Object.entries(permissionFilters)) {
|
||||||
|
if (!merged[field as keyof AssetFilters]) {
|
||||||
|
// No user filter on this field, apply permission filter directly
|
||||||
|
(merged as any)[field] = value;
|
||||||
|
} else if (Array.isArray(value) && value[0] === 'in') {
|
||||||
|
// Permission filter is ["in", [...values]]
|
||||||
|
const permittedValues = value[1] as string[];
|
||||||
|
const userValue = merged[field as keyof AssetFilters];
|
||||||
|
|
||||||
|
if (typeof userValue === 'string') {
|
||||||
|
// User selected a specific value, check if it's permitted
|
||||||
|
if (!permittedValues.includes(userValue)) {
|
||||||
|
// User selected a value they don't have permission for
|
||||||
|
// Set to empty array to return no results
|
||||||
|
(merged as any)[field] = ['in', []];
|
||||||
|
}
|
||||||
|
// If permitted, keep the user's specific selection
|
||||||
|
} else if (Array.isArray(userValue) && userValue[0] === 'in') {
|
||||||
|
// Both are ["in", [...]] format, intersect them
|
||||||
|
const userValues = userValue[1] as string[];
|
||||||
|
const intersection = userValues.filter(v => permittedValues.includes(v));
|
||||||
|
(merged as any)[field] = ['in', intersection];
|
||||||
|
} else {
|
||||||
|
// Other filter types, apply permission filter
|
||||||
|
(merged as any)[field] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return merged;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook to fetch list of assets with filters, pagination, and permission-based filtering
|
||||||
*/
|
*/
|
||||||
export function useAssets(
|
export function useAssets(
|
||||||
filters?: AssetFilters,
|
filters?: AssetFilters,
|
||||||
limit: number = 20,
|
limit: number = 20,
|
||||||
offset: number = 0,
|
offset: number = 0,
|
||||||
orderBy?: string
|
orderBy?: string,
|
||||||
|
permissionFilters: Record<string, any> = {} // ← NEW: Permission filters parameter
|
||||||
) {
|
) {
|
||||||
const [assets, setAssets] = useState<Asset[]>([]);
|
const [assets, setAssets] = useState<Asset[]>([]);
|
||||||
const [totalCount, setTotalCount] = useState(0);
|
const [totalCount, setTotalCount] = useState(0);
|
||||||
@ -21,6 +65,7 @@ export function useAssets(
|
|||||||
|
|
||||||
// Stringify filters to prevent object reference changes from causing re-renders
|
// Stringify filters to prevent object reference changes from causing re-renders
|
||||||
const filtersJson = JSON.stringify(filters);
|
const filtersJson = JSON.stringify(filters);
|
||||||
|
const permissionFiltersJson = JSON.stringify(permissionFilters); // ← NEW
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Prevent fetching if already attempted and has error
|
// Prevent fetching if already attempted and has error
|
||||||
@ -35,7 +80,14 @@ export function useAssets(
|
|||||||
try {
|
try {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
|
|
||||||
const response = await assetService.getAssets(filters, undefined, limit, offset, orderBy);
|
// ✅ NEW: Merge user filters with permission filters
|
||||||
|
const mergedFilters = mergeFilters(filters, permissionFilters);
|
||||||
|
|
||||||
|
console.log('[useAssets] User filters:', filters);
|
||||||
|
console.log('[useAssets] Permission filters:', permissionFilters);
|
||||||
|
console.log('[useAssets] Merged filters:', mergedFilters);
|
||||||
|
|
||||||
|
const response = await assetService.getAssets(mergedFilters, undefined, limit, offset, orderBy);
|
||||||
|
|
||||||
if (!isCancelled) {
|
if (!isCancelled) {
|
||||||
setAssets(response.assets);
|
setAssets(response.assets);
|
||||||
@ -72,7 +124,7 @@ export function useAssets(
|
|||||||
isCancelled = true;
|
isCancelled = true;
|
||||||
};
|
};
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [filtersJson, limit, offset, orderBy, refetchTrigger]);
|
}, [filtersJson, permissionFiltersJson, limit, offset, orderBy, refetchTrigger]); // ← Added permissionFiltersJson
|
||||||
|
|
||||||
const refetch = useCallback(() => {
|
const refetch = useCallback(() => {
|
||||||
hasAttemptedRef.current = false; // Reset to allow refetch
|
hasAttemptedRef.current = false; // Reset to allow refetch
|
||||||
@ -201,7 +253,27 @@ export function useAssetMutations() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return { createAsset, updateAsset, deleteAsset, loading, error };
|
const submitAsset = async (assetName: string) => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
console.log('[useAssetMutations] Submitting asset:', assetName);
|
||||||
|
const response = await assetService.submitAsset(assetName);
|
||||||
|
console.log('[useAssetMutations] Submit asset response:', response);
|
||||||
|
|
||||||
|
return response;
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[useAssetMutations] Submit asset error:', err);
|
||||||
|
const errorMessage = err instanceof Error ? err.message : 'Failed to submit asset';
|
||||||
|
setError(errorMessage);
|
||||||
|
throw err;
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return { createAsset, updateAsset, deleteAsset, submitAsset, loading, error };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -304,5 +376,4 @@ export function useAssetSearch() {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return { results, loading, error, search, clearResults };
|
return { results, loading, error, search, clearResults };
|
||||||
}
|
}
|
||||||
|
|
||||||
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 };
|
||||||
|
}
|
||||||
|
|
||||||
77
src/hooks/useDocTypeMeta.ts
Normal file
77
src/hooks/useDocTypeMeta.ts
Normal file
@ -0,0 +1,77 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import apiService from '../services/apiService';
|
||||||
|
|
||||||
|
export interface DocTypeField {
|
||||||
|
fieldname: string;
|
||||||
|
fieldtype: string;
|
||||||
|
label: string;
|
||||||
|
allow_on_submit: number; // 0 or 1
|
||||||
|
reqd: number; // 0 or 1 for required
|
||||||
|
read_only: number; // 0 or 1
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useDocTypeMeta = (doctype: string) => {
|
||||||
|
const [fields, setFields] = useState<DocTypeField[]>([]);
|
||||||
|
const [allowOnSubmitFields, setAllowOnSubmitFields] = useState<Set<string>>(new Set());
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchDocTypeMeta = async () => {
|
||||||
|
if (!doctype) {
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
const response = await apiService.apiCall<any>(
|
||||||
|
`/api/resource/DocType/${doctype}`
|
||||||
|
);
|
||||||
|
|
||||||
|
// Handle different response structures from Frappe API
|
||||||
|
// Response can be: { data: {...} } or directly {...}
|
||||||
|
const docTypeData = response.data || response;
|
||||||
|
const fieldsList: DocTypeField[] = docTypeData.fields || [];
|
||||||
|
|
||||||
|
// Extract fields that allow editing on submit
|
||||||
|
const allowOnSubmitSet = new Set<string>();
|
||||||
|
fieldsList.forEach((field: DocTypeField) => {
|
||||||
|
// Check both number (1) and boolean (true) formats
|
||||||
|
// if (field.allow_on_submit === 1 || field.allow_on_submit === true) {
|
||||||
|
if (field.allow_on_submit === 1){
|
||||||
|
allowOnSubmitSet.add(field.fieldname);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Debug logging (development only)
|
||||||
|
if (import.meta.env.DEV) {
|
||||||
|
console.log(`[DocTypeMeta] Loaded ${fieldsList.length} fields for ${doctype}`);
|
||||||
|
console.log(`[DocTypeMeta] Fields with allow_on_submit:`, Array.from(allowOnSubmitSet));
|
||||||
|
}
|
||||||
|
|
||||||
|
setFields(fieldsList);
|
||||||
|
setAllowOnSubmitFields(allowOnSubmitSet);
|
||||||
|
setError(null);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`[DocTypeMeta] Error fetching DocType meta for ${doctype}:`, err);
|
||||||
|
setError(err instanceof Error ? err.message : 'Unknown error');
|
||||||
|
// Don't block the UI if metadata fetch fails - allow all fields to be editable
|
||||||
|
// This is a graceful degradation
|
||||||
|
setFields([]);
|
||||||
|
setAllowOnSubmitFields(new Set());
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchDocTypeMeta();
|
||||||
|
}, [doctype]);
|
||||||
|
|
||||||
|
const isAllowedOnSubmit = (fieldname: string): boolean => {
|
||||||
|
return allowOnSubmitFields.has(fieldname);
|
||||||
|
};
|
||||||
|
|
||||||
|
return { fields, allowOnSubmitFields, isAllowedOnSubmit, loading, error };
|
||||||
|
};
|
||||||
|
|
||||||
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 };
|
||||||
|
}
|
||||||
|
|
||||||
188
src/hooks/useUserPermissions.ts
Normal file
188
src/hooks/useUserPermissions.ts
Normal file
@ -0,0 +1,188 @@
|
|||||||
|
import { useState, useEffect, useCallback, useMemo } from 'react';
|
||||||
|
import apiService from '../services/apiService';
|
||||||
|
|
||||||
|
interface RestrictionInfo {
|
||||||
|
field: string;
|
||||||
|
values: string[];
|
||||||
|
count: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PermissionsState {
|
||||||
|
isAdmin: boolean;
|
||||||
|
restrictions: Record<string, RestrictionInfo>;
|
||||||
|
permissionFilters: Record<string, any>;
|
||||||
|
targetDoctype: string;
|
||||||
|
loading: boolean;
|
||||||
|
error: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generic hook for user permissions - works with ANY doctype
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* const { permissionFilters, restrictions } = useUserPermissions('Asset');
|
||||||
|
* const { permissionFilters, restrictions } = useUserPermissions('Work Order');
|
||||||
|
* const { permissionFilters, restrictions } = useUserPermissions('Project');
|
||||||
|
*/
|
||||||
|
export const useUserPermissions = (targetDoctype: string = 'Asset') => {
|
||||||
|
const [state, setState] = useState<PermissionsState>({
|
||||||
|
isAdmin: false,
|
||||||
|
restrictions: {},
|
||||||
|
permissionFilters: {},
|
||||||
|
targetDoctype,
|
||||||
|
loading: true,
|
||||||
|
error: null
|
||||||
|
});
|
||||||
|
|
||||||
|
const fetchPermissions = useCallback(async (doctype?: string) => {
|
||||||
|
const dt = doctype || targetDoctype;
|
||||||
|
|
||||||
|
try {
|
||||||
|
setState(prev => ({ ...prev, loading: true, error: null, targetDoctype: dt }));
|
||||||
|
|
||||||
|
const response = await apiService.getPermissionFilters(dt);
|
||||||
|
|
||||||
|
setState({
|
||||||
|
isAdmin: response.is_admin,
|
||||||
|
restrictions: response.restrictions || {},
|
||||||
|
permissionFilters: response.filters || {},
|
||||||
|
targetDoctype: dt,
|
||||||
|
loading: false,
|
||||||
|
error: null
|
||||||
|
});
|
||||||
|
|
||||||
|
return response;
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`Error fetching permissions for ${dt}:`, err);
|
||||||
|
setState(prev => ({
|
||||||
|
...prev,
|
||||||
|
loading: false,
|
||||||
|
error: err instanceof Error ? err.message : 'Failed to fetch permissions'
|
||||||
|
}));
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}, [targetDoctype]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchPermissions();
|
||||||
|
}, [fetchPermissions]);
|
||||||
|
|
||||||
|
// Get allowed values for a permission type (e.g., "Company", "Location")
|
||||||
|
const getAllowedValues = useCallback((permissionType: string): string[] => {
|
||||||
|
return state.restrictions[permissionType]?.values || [];
|
||||||
|
}, [state.restrictions]);
|
||||||
|
|
||||||
|
// Check if user has restriction on a permission type
|
||||||
|
const hasRestriction = useCallback((permissionType: string): boolean => {
|
||||||
|
if (state.isAdmin) return false;
|
||||||
|
return !!state.restrictions[permissionType];
|
||||||
|
}, [state.isAdmin, state.restrictions]);
|
||||||
|
|
||||||
|
// Check if any restrictions exist
|
||||||
|
const hasAnyRestrictions = useMemo(() => {
|
||||||
|
return !state.isAdmin && Object.keys(state.restrictions).length > 0;
|
||||||
|
}, [state.isAdmin, state.restrictions]);
|
||||||
|
|
||||||
|
// Merge user filters with permission filters
|
||||||
|
const mergeFilters = useCallback((userFilters: Record<string, any>): Record<string, any> => {
|
||||||
|
if (state.isAdmin) return userFilters;
|
||||||
|
|
||||||
|
const merged = { ...userFilters };
|
||||||
|
|
||||||
|
for (const [field, value] of Object.entries(state.permissionFilters)) {
|
||||||
|
if (!merged[field]) {
|
||||||
|
merged[field] = value;
|
||||||
|
} else if (Array.isArray(value) && value[0] === 'in') {
|
||||||
|
const permittedValues = value[1] as string[];
|
||||||
|
if (typeof merged[field] === 'string' && !permittedValues.includes(merged[field])) {
|
||||||
|
merged[field] = ['in', []]; // Return empty - value not permitted
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return merged;
|
||||||
|
}, [state.isAdmin, state.permissionFilters]);
|
||||||
|
|
||||||
|
// Get summary of restrictions for display
|
||||||
|
const restrictionsList = useMemo(() => {
|
||||||
|
return Object.entries(state.restrictions).map(([type, info]) => ({
|
||||||
|
type,
|
||||||
|
field: info.field,
|
||||||
|
values: info.values,
|
||||||
|
count: info.count
|
||||||
|
}));
|
||||||
|
}, [state.restrictions]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
refetch: fetchPermissions,
|
||||||
|
switchDoctype: fetchPermissions,
|
||||||
|
getAllowedValues,
|
||||||
|
hasRestriction,
|
||||||
|
hasAnyRestrictions,
|
||||||
|
mergeFilters,
|
||||||
|
restrictionsList
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook to check access to a specific document
|
||||||
|
*/
|
||||||
|
export const useDocumentAccess = (doctype: string | null, docname: string | null) => {
|
||||||
|
const [hasAccess, setHasAccess] = useState<boolean | null>(null);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!doctype || !docname) {
|
||||||
|
setHasAccess(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const check = async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
const response = await apiService.checkDocumentAccess(doctype, docname);
|
||||||
|
setHasAccess(response.has_access);
|
||||||
|
if (!response.has_access && response.error) {
|
||||||
|
setError(response.error);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Failed to check access');
|
||||||
|
setHasAccess(false);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
check();
|
||||||
|
}, [doctype, docname]);
|
||||||
|
|
||||||
|
return { hasAccess, loading, error };
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook to get user's default values
|
||||||
|
*/
|
||||||
|
export const useUserDefaults = () => {
|
||||||
|
const [defaults, setDefaults] = useState<Record<string, string>>({});
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fetch = async () => {
|
||||||
|
try {
|
||||||
|
const response = await apiService.getUserDefaults();
|
||||||
|
setDefaults(response.defaults || {});
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to fetch user defaults:', err);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
fetch();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return { defaults, loading, getDefault: (type: string) => defaults[type] };
|
||||||
|
};
|
||||||
|
|
||||||
|
export default useUserPermissions;
|
||||||
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 base;
|
||||||
@tailwind components;
|
@tailwind components;
|
||||||
@tailwind utilities;
|
@tailwind utilities;
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
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;
|
||||||
|
|
||||||
0
src/pages/AssetMaintenanceLog.tsx
Normal file
0
src/pages/AssetMaintenanceLog.tsx
Normal file
@ -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="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 className="max-w-md w-full space-y-8">
|
||||||
<div>
|
<div>
|
||||||
<h2 className="mt-6 text-center text-3xl font-extrabold text-gray-900 dark:text-white">
|
<div className="flex justify-center mb-6">
|
||||||
Sign in to your account
|
<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>
|
</h2>
|
||||||
<p className="mt-2 text-center text-sm text-gray-600 dark:text-gray-400">
|
<p className="mt-2 text-center text-sm font-medium text-indigo-600 dark:text-indigo-400">
|
||||||
Connect to your Frappe backend
|
Asset Management System
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 text-center text-xs text-gray-600 dark:text-gray-400">
|
||||||
|
Sign in to continue
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</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, FaTools } 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, 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;
|
||||||
|
|
||||||
0
src/pages/WorkOrder.tsx
Normal file
0
src/pages/WorkOrder.tsx
Normal file
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;
|
||||||
|
|
||||||
@ -24,6 +24,7 @@ interface UserDetails {
|
|||||||
creation: string;
|
creation: string;
|
||||||
modified: string;
|
modified: string;
|
||||||
language: string;
|
language: string;
|
||||||
|
custom_site_name:string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface DocTypeRecord {
|
interface DocTypeRecord {
|
||||||
@ -120,6 +121,47 @@ interface RequestOptions {
|
|||||||
body?: any;
|
body?: any;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// USER PERMISSION INTERFACES
|
||||||
|
interface RestrictionInfo {
|
||||||
|
field: string;
|
||||||
|
values: string[];
|
||||||
|
count: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PermissionFiltersResponse {
|
||||||
|
is_admin: boolean;
|
||||||
|
filters: Record<string, any>;
|
||||||
|
restrictions: Record<string, RestrictionInfo>;
|
||||||
|
target_doctype: string;
|
||||||
|
user?: string;
|
||||||
|
total_restrictions?: number;
|
||||||
|
warning?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AllowedValuesResponse {
|
||||||
|
is_admin: boolean;
|
||||||
|
allowed_values: string[];
|
||||||
|
default_value?: string | null;
|
||||||
|
has_restriction: boolean;
|
||||||
|
allow_doctype: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DocumentAccessResponse {
|
||||||
|
has_access: boolean;
|
||||||
|
is_admin?: boolean;
|
||||||
|
no_restrictions?: boolean;
|
||||||
|
error?: string;
|
||||||
|
denied_by?: string;
|
||||||
|
field?: string;
|
||||||
|
document_value?: string;
|
||||||
|
allowed_values?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UserDefaultsResponse {
|
||||||
|
is_admin: boolean;
|
||||||
|
defaults: Record<string, string>;
|
||||||
|
}
|
||||||
|
|
||||||
class ApiService {
|
class ApiService {
|
||||||
private baseURL: string;
|
private baseURL: string;
|
||||||
private endpoints: Record<string, string>;
|
private endpoints: Record<string, string>;
|
||||||
@ -380,6 +422,39 @@ class ApiService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// USER PERMISSION METHODS
|
||||||
|
async getUserPermissions(userId?: string): Promise<any> {
|
||||||
|
const params = userId ? `?user=${encodeURIComponent(userId)}` : '';
|
||||||
|
return this.apiCall(`${this.endpoints.GET_USER_PERMISSIONS}${params}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getPermissionFilters(targetDoctype: string, userId?: string): Promise<PermissionFiltersResponse> {
|
||||||
|
const params = new URLSearchParams({ target_doctype: targetDoctype });
|
||||||
|
if (userId) params.append('user', userId);
|
||||||
|
return this.apiCall<PermissionFiltersResponse>(`${this.endpoints.GET_PERMISSION_FILTERS}?${params}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getAllowedValues(allowDoctype: string, userId?: string): Promise<AllowedValuesResponse> {
|
||||||
|
const params = new URLSearchParams({ allow_doctype: allowDoctype });
|
||||||
|
if (userId) params.append('user', userId);
|
||||||
|
return this.apiCall<AllowedValuesResponse>(`${this.endpoints.GET_ALLOWED_VALUES}?${params}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async checkDocumentAccess(doctype: string, docname: string, userId?: string): Promise<DocumentAccessResponse> {
|
||||||
|
const params = new URLSearchParams({ doctype, docname });
|
||||||
|
if (userId) params.append('user', userId);
|
||||||
|
return this.apiCall<DocumentAccessResponse>(`${this.endpoints.CHECK_DOCUMENT_ACCESS}?${params}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getConfiguredDoctypes(): Promise<any> {
|
||||||
|
return this.apiCall(this.endpoints.GET_CONFIGURED_DOCTYPES);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getUserDefaults(userId?: string): Promise<UserDefaultsResponse> {
|
||||||
|
const params = userId ? `?user=${encodeURIComponent(userId)}` : '';
|
||||||
|
return this.apiCall<UserDefaultsResponse>(`${this.endpoints.GET_USER_DEFAULTS}${params}`);
|
||||||
|
}
|
||||||
|
|
||||||
// Utility Methods
|
// Utility Methods
|
||||||
isAuthenticated(): boolean {
|
isAuthenticated(): boolean {
|
||||||
// Check if user is authenticated (implement based on your auth strategy)
|
// Check if user is authenticated (implement based on your auth strategy)
|
||||||
|
|||||||
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;
|
||||||
|
|
||||||
@ -6,6 +6,7 @@ export interface Asset {
|
|||||||
name: string;
|
name: string;
|
||||||
asset_name: string;
|
asset_name: string;
|
||||||
company: string;
|
company: string;
|
||||||
|
docstatus?: number; // 0 = Draft, 1 = Submitted, 2 = Cancelled
|
||||||
custom_serial_number?: string;
|
custom_serial_number?: string;
|
||||||
location?: string;
|
location?: string;
|
||||||
custom_manufacturer?: string;
|
custom_manufacturer?: string;
|
||||||
@ -26,6 +27,21 @@ export interface Asset {
|
|||||||
modified?: string;
|
modified?: string;
|
||||||
owner?: string;
|
owner?: string;
|
||||||
modified_by?: string;
|
modified_by?: string;
|
||||||
|
status?: string;
|
||||||
|
|
||||||
|
calculate_depreciation?: boolean;
|
||||||
|
gross_purchase_amount?: number;
|
||||||
|
available_for_use_date?:string;
|
||||||
|
finance_books?: AssetFinanceBookRow[];
|
||||||
|
custom_spare_parts?: Array<{
|
||||||
|
item_code?: string;
|
||||||
|
item_name?: string;
|
||||||
|
qty?: number;
|
||||||
|
rate?: number;
|
||||||
|
amount?: number;
|
||||||
|
uom?: string;
|
||||||
|
work_order?: string;
|
||||||
|
}>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AssetListResponse {
|
export interface AssetListResponse {
|
||||||
@ -63,6 +79,15 @@ export interface AssetStats {
|
|||||||
total_amount: number;
|
total_amount: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Add child row type
|
||||||
|
export interface AssetFinanceBookRow {
|
||||||
|
finance_book?: string;
|
||||||
|
depreciation_method?: string;
|
||||||
|
total_number_of_depreciations?: number;
|
||||||
|
frequency_of_depreciation?: number;
|
||||||
|
depreciation_start_date?: string; // YYYY-MM-DD
|
||||||
|
}
|
||||||
|
|
||||||
export interface CreateAssetData {
|
export interface CreateAssetData {
|
||||||
asset_name: string;
|
asset_name: string;
|
||||||
company: string;
|
company: string;
|
||||||
@ -82,6 +107,8 @@ export interface CreateAssetData {
|
|||||||
custom_attach_image?: string;
|
custom_attach_image?: string;
|
||||||
custom_site_contractor?: string;
|
custom_site_contractor?: string;
|
||||||
custom_total_amount?: number;
|
custom_total_amount?: number;
|
||||||
|
calculate_depreciation?: boolean;
|
||||||
|
finance_books?: AssetFinanceBookRow[];
|
||||||
[key: string]: any;
|
[key: string]: any;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -191,6 +218,22 @@ class AssetService {
|
|||||||
const endpoint = `${API_CONFIG.ENDPOINTS.SEARCH_ASSETS}?search_term=${encodeURIComponent(searchTerm)}&limit=${limit}`;
|
const endpoint = `${API_CONFIG.ENDPOINTS.SEARCH_ASSETS}?search_term=${encodeURIComponent(searchTerm)}&limit=${limit}`;
|
||||||
return apiService.apiCall<Asset[]>(endpoint);
|
return apiService.apiCall<Asset[]>(endpoint);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Submit an asset document (changes docstatus from 0 to 1)
|
||||||
|
*/
|
||||||
|
async submitAsset(assetName: string): Promise<{ message: string }> {
|
||||||
|
return apiService.apiCall('/api/method/frappe.client.submit', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
doctype: 'Asset',
|
||||||
|
name: assetName
|
||||||
|
})
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create and export singleton instance
|
// Create and export singleton instance
|
||||||
|
|||||||
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;
|
||||||
|
|
||||||
@ -3,9 +3,15 @@ import react from '@vitejs/plugin-react'
|
|||||||
|
|
||||||
// https://vite.dev/config/
|
// https://vite.dev/config/
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
|
base: '/seera-app/',
|
||||||
|
|
||||||
plugins: [react()],
|
plugins: [react()],
|
||||||
|
|
||||||
server: {
|
server: {
|
||||||
port: 3000,
|
port: 3000,
|
||||||
|
allowedHosts: [
|
||||||
|
'monotriglyphic-uniformless-rema.ngrok-free.dev'
|
||||||
|
],
|
||||||
proxy: {
|
proxy: {
|
||||||
// Proxy API requests to Frappe backend
|
// Proxy API requests to Frappe backend
|
||||||
'/api': {
|
'/api': {
|
||||||
@ -32,9 +38,24 @@ export default defineConfig({
|
|||||||
proxyRes.headers['Access-Control-Allow-Headers'] = 'Content-Type, Authorization, X-Frappe-CSRF-Token';
|
proxyRes.headers['Access-Control-Allow-Headers'] = 'Content-Type, Authorization, X-Frappe-CSRF-Token';
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// Proxy file requests to Frappe backend
|
||||||
|
'/files': {
|
||||||
|
target: process.env.VITE_FRAPPE_BASE_URL || 'https://seeraasm-med.seeraarabia.com',
|
||||||
|
changeOrigin: true,
|
||||||
|
secure: true,
|
||||||
|
configure: (proxy, _options) => {
|
||||||
|
proxy.on('proxyReq', (proxyReq, req, _res) => {
|
||||||
|
if (process.env.DEV) {
|
||||||
|
console.log('Proxying file request:', req.url, 'to', proxyReq.path);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
build: {
|
build: {
|
||||||
outDir: 'dist',
|
outDir: 'dist',
|
||||||
assetsDir: 'assets',
|
assetsDir: 'assets',
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user