Resolve merge conflicts in AssetDetail.tsx

This commit is contained in:
Akhib.Shaik 2025-11-14 16:58:25 +05:30
commit a58fc89c46
33 changed files with 6657 additions and 670 deletions

299
GIT_BRANCH_SETUP.md Normal file
View File

@ -0,0 +1,299 @@
# Git Branch Setup Guide
## Creating Branches: akhib and dundu
### Quick Commands
```bash
# Create and push akhib branch
git checkout -b akhib
git push -u origin akhib
# Create and push dundu branch
git checkout -b dundu
git push -u origin dundu
# Go back to main branch
git checkout main
```
## Detailed Step-by-Step
### Step 1: Check Your Current Branch
```bash
git branch
```
This shows which branch you're currently on (marked with *).
### Step 2: Create akhib Branch
```bash
# Create new branch from current position
git checkout -b akhib
# Verify you're on the new branch
git branch
# Push to remote and set upstream
git push -u origin akhib
```
### Step 3: Create dundu Branch
```bash
# Go back to main first
git checkout main
# Create new branch from main
git checkout -b dundu
# Push to remote and set upstream
git push -u origin dundu
```
### Step 4: Verify All Branches Exist
```bash
# List local branches
git branch
# List all branches (local + remote)
git branch -a
```
You should see:
```
* main
akhib
dundu
remotes/origin/akhib
remotes/origin/dundu
remotes/origin/main
```
## Branch Workflow for Team
### For Developer Working on akhib Branch
```bash
# Switch to akhib branch
git checkout akhib
# Make sure it's up to date
git pull origin akhib
# Make changes, then commit
git add .
git commit -m "Your commit message"
# Push changes
git push origin akhib
```
### For Developer Working on dundu Branch
```bash
# Switch to dundu branch
git checkout dundu
# Make sure it's up to date
git pull origin dundu
# Make changes, then commit
git add .
git commit -m "Your commit message"
# Push changes
git push origin dundu
```
## Merging Changes
### Merge akhib into main
```bash
# Switch to main
git checkout main
# Pull latest changes
git pull origin main
# Merge akhib branch
git merge akhib
# Push merged changes
git push origin main
```
### Merge dundu into main
```bash
# Switch to main
git checkout main
# Pull latest changes
git pull origin main
# Merge dundu branch
git merge dundu
# Push merged changes
git push origin main
```
## Branch Protection (Recommended)
After creating branches, consider setting up branch protection rules on GitHub/GitLab:
1. **Protect main branch** - Require pull requests
2. **Require reviews** - At least 1 approval before merging
3. **Require tests** - Ensure CI/CD passes
## Common Branch Commands
```bash
# List all branches
git branch -a
# Switch branches
git checkout branch-name
# Create new branch
git checkout -b new-branch-name
# Delete local branch
git branch -d branch-name
# Delete remote branch
git push origin --delete branch-name
# Rename current branch
git branch -m new-name
# See which branch you're on
git branch
# Update all branches from remote
git fetch --all
# See commits difference between branches
git log main..akhib
```
## Branch Strategy Recommendation
### Main Branch
- **Purpose:** Production-ready code
- **Protection:** Require pull requests
- **Who commits:** No direct commits (only via PR)
### akhib Branch
- **Purpose:** Akhib's development work
- **Protection:** Optional
- **Who commits:** Akhib (and approved team members)
### dundu Branch
- **Purpose:** Dundu's development work
- **Protection:** Optional
- **Who commits:** Dundu (and approved team members)
## Workflow Example
```mermaid
graph LR
A[main] --> B[akhib branch]
A --> C[dundu branch]
B --> D[Pull Request to main]
C --> E[Pull Request to main]
D --> A
E --> A
```
**Recommended Flow:**
1. Developer creates feature on their branch (akhib/dundu)
2. Commits and pushes regularly
3. When ready, creates Pull Request to main
4. Team reviews the PR
5. After approval, merge to main
6. Delete feature branch (optional) or keep for next feature
## Syncing Branches
### Keep akhib up to date with main
```bash
git checkout akhib
git pull origin main
git push origin akhib
```
### Keep dundu up to date with main
```bash
git checkout dundu
git pull origin main
git push origin dundu
```
## Troubleshooting
### Error: "branch already exists"
```bash
# If branch exists locally, just switch to it
git checkout akhib
# If you want to recreate it
git branch -d akhib
git checkout -b akhib
```
### Error: "failed to push some refs"
```bash
# Pull first, then push
git pull origin akhib
git push origin akhib
```
### Merge Conflicts
```bash
# When merge conflict occurs
git status # See conflicted files
# Edit conflicted files manually
# Look for <<<<<<, ======, >>>>>> markers
# After fixing conflicts
git add .
git commit -m "Resolve merge conflicts"
git push
```
## Quick Reference
| Command | Description |
|---------|-------------|
| `git checkout -b akhib` | Create akhib branch |
| `git push -u origin akhib` | Push and track akhib |
| `git checkout akhib` | Switch to akhib |
| `git branch` | List local branches |
| `git branch -a` | List all branches |
| `git merge akhib` | Merge akhib into current |
| `git pull origin akhib` | Update akhib from remote |
## Setting Up Branch Protection (GitHub)
1. Go to: Settings → Branches → Add rule
2. Branch name pattern: `main`
3. Enable:
- ✅ Require pull request reviews before merging
- ✅ Require status checks to pass
- ✅ Require branches to be up to date
4. Save changes
Now your branches are ready for team collaboration! 🎉

220
QUICK_START_FOR_TEAM.md Normal file
View 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! 🚀

View File

@ -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>

322
package-lock.json generated
View File

@ -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",
@ -77,6 +78,7 @@
"integrity": "sha512-2BCOP7TN8M+gVDj7/ht3hsaO/B/n5oDbiAyyvnRlNOs+u1o+JWNYTQrmpuNp1/Wq2gcFrI01JAW+paEKDMx/CA==", "integrity": "sha512-2BCOP7TN8M+gVDj7/ht3hsaO/B/n5oDbiAyyvnRlNOs+u1o+JWNYTQrmpuNp1/Wq2gcFrI01JAW+paEKDMx/CA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@babel/code-frame": "^7.27.1", "@babel/code-frame": "^7.27.1",
"@babel/generator": "^7.28.3", "@babel/generator": "^7.28.3",
@ -1482,6 +1484,7 @@
"integrity": "sha512-QoiaXANRkSXK6p0Duvt56W208du4P9Uye9hWLWgGMDTEoKPhuenzNcC4vGUmrNkiOKTlIrBoyNQYNpSwfEZXSg==", "integrity": "sha512-QoiaXANRkSXK6p0Duvt56W208du4P9Uye9hWLWgGMDTEoKPhuenzNcC4vGUmrNkiOKTlIrBoyNQYNpSwfEZXSg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"undici-types": "~7.16.0" "undici-types": "~7.16.0"
} }
@ -1491,6 +1494,7 @@
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.2.tgz", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.2.tgz",
"integrity": "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA==", "integrity": "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"csstype": "^3.0.2" "csstype": "^3.0.2"
} }
@ -1572,6 +1576,7 @@
"integrity": "sha512-BnOroVl1SgrPLywqxyqdJ4l3S2MsKVLDVxZvjI1Eoe8ev2r3kGDo+PcMihNmDE+6/KjkTubSJnmqGZZjQSBq/g==", "integrity": "sha512-BnOroVl1SgrPLywqxyqdJ4l3S2MsKVLDVxZvjI1Eoe8ev2r3kGDo+PcMihNmDE+6/KjkTubSJnmqGZZjQSBq/g==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@typescript-eslint/scope-manager": "8.46.2", "@typescript-eslint/scope-manager": "8.46.2",
"@typescript-eslint/types": "8.46.2", "@typescript-eslint/types": "8.46.2",
@ -1824,6 +1829,7 @@
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"bin": { "bin": {
"acorn": "bin/acorn" "acorn": "bin/acorn"
}, },
@ -2051,6 +2057,7 @@
} }
], ],
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"baseline-browser-mapping": "^2.8.9", "baseline-browser-mapping": "^2.8.9",
"caniuse-lite": "^1.0.30001746", "caniuse-lite": "^1.0.30001746",
@ -2307,18 +2314,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",
@ -2484,6 +2479,7 @@
"integrity": "sha512-t5aPOpmtJcZcz5UJyY2GbvpDlsK5E8JqRqoKtfiKE3cNh437KIqfJr3A3AKf5k64NPx6d0G3dno6XDY05PqPtw==", "integrity": "sha512-t5aPOpmtJcZcz5UJyY2GbvpDlsK5E8JqRqoKtfiKE3cNh437KIqfJr3A3AKf5k64NPx6d0G3dno6XDY05PqPtw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/eslint-utils": "^4.8.0",
"@eslint-community/regexpp": "^4.12.1", "@eslint-community/regexpp": "^4.12.1",
@ -3191,18 +3187,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 +3278,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 +3331,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",
@ -3971,6 +3690,7 @@
} }
], ],
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"nanoid": "^3.3.11", "nanoid": "^3.3.11",
"picocolors": "^1.1.1", "picocolors": "^1.1.1",
@ -4166,6 +3886,7 @@
"resolved": "https://registry.npmjs.org/react/-/react-19.2.0.tgz", "resolved": "https://registry.npmjs.org/react/-/react-19.2.0.tgz",
"integrity": "sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==", "integrity": "sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==",
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=0.10.0" "node": ">=0.10.0"
} }
@ -4175,6 +3896,7 @@
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.0.tgz", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.0.tgz",
"integrity": "sha512-UlbRu4cAiGaIewkPyiRGJk0imDN2T3JjieT6spoL2UeSf5od4n5LB/mQ4ejmxhCFT1tYe8IvaFulzynWovsEFQ==", "integrity": "sha512-UlbRu4cAiGaIewkPyiRGJk0imDN2T3JjieT6spoL2UeSf5od4n5LB/mQ4ejmxhCFT1tYe8IvaFulzynWovsEFQ==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"scheduler": "^0.27.0" "scheduler": "^0.27.0"
}, },
@ -4716,6 +4438,7 @@
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=12" "node": ">=12"
}, },
@ -4775,6 +4498,7 @@
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true, "dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"peer": true,
"bin": { "bin": {
"tsc": "bin/tsc", "tsc": "bin/tsc",
"tsserver": "bin/tsserver" "tsserver": "bin/tsserver"
@ -4868,6 +4592,7 @@
"integrity": "sha512-uzcxnSDVjAopEUjljkWh8EIrg6tlzrjFUfMcR1EVsRDGwf/ccef0qQPRyOrROwhrTDaApueq+ja+KLPlzR/zdg==", "integrity": "sha512-uzcxnSDVjAopEUjljkWh8EIrg6tlzrjFUfMcR1EVsRDGwf/ccef0qQPRyOrROwhrTDaApueq+ja+KLPlzR/zdg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"esbuild": "^0.25.0", "esbuild": "^0.25.0",
"fdir": "^6.5.0", "fdir": "^6.5.0",
@ -4961,6 +4686,7 @@
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=12" "node": ">=12"
}, },

View File

@ -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

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

View File

@ -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

View File

@ -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={

View File

@ -0,0 +1,236 @@
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>;
}
const LinkField: React.FC<LinkFieldProps> = ({
label,
doctype,
value,
onChange,
placeholder,
disabled = false,
filters = {},
}) => {
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) {
// Convert filters to JSON string for Frappe API
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]); // Re-fetch when filters change
// Close dropdown when clicking outside
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (containerRef.current && !containerRef.current.contains(event.target as Node)) {
setDropdownOpen(false);
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, []);
return (
<div ref={containerRef} className="relative w-full mb-4">
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
{label}
</label>
<input
type="text"
value={value}
placeholder={placeholder || `Select ${label}`}
disabled={disabled}
className={`w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md
focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:bg-gray-100 dark:disabled:bg-gray-700
bg-white dark:bg-gray-700 text-gray-900 dark:text-white`}
onFocus={() => !disabled && setDropdownOpen(true)}
onChange={(e) => {
const text = e.target.value;
setSearchText(text);
searchLink(text);
onChange(text);
}}
/>
{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 mt-1 max-h-48 overflow-auto w-full shadow-lg">
{searchResults.map((item, idx) => (
<li
key={idx}
onClick={() => {
onChange(item.value);
setDropdownOpen(false);
}}
className={`px-3 py-2 cursor-pointer
text-gray-900 dark:text-gray-100
hover:bg-blue-500 dark:hover:bg-blue-600 hover:text-white
${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 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 mt-1 w-full shadow-lg p-3 text-center text-gray-500 dark:text-gray-400 text-sm">
No results found
</div>
)}
</div>
);
};
export default LinkField;
// import React, { useState, useEffect, useRef } from 'react';
// import apiService from '../services/apiService'; // ✅ your ApiService
// interface LinkFieldProps {
// label: string;
// doctype: string;
// value: string;
// onChange: (value: string) => void;
// placeholder?: string;
// disabled?: boolean;
// filters?: Record<string, any>
// }
// const LinkField: React.FC<LinkFieldProps> = ({
// label,
// doctype,
// value,
// onChange,
// placeholder,
// disabled = false,
// }) => {
// const [searchResults, setSearchResults] = useState<{ value: string; description?: string }[]>([]);
// const [searchText, setSearchText] = useState('');
// const [isDropdownOpen, setDropdownOpen] = useState(false);
// const containerRef = useRef<HTMLDivElement>(null);
// // Fetch link options from ERPNext
// const searchLink = async (text: string = '') => {
// try {
// const params = new URLSearchParams({ doctype, txt: text });
// const response = await apiService.apiCall<{ value: string; description?: string }[]>(
// `/api/method/frappe.desk.search.search_link?${params.toString()}`
// );
// setSearchResults(response || []);
// } catch (error) {
// console.error(`Error fetching ${doctype} links:`, error);
// }
// };
// // Fetch default options when dropdown opens
// useEffect(() => {
// if (isDropdownOpen) searchLink('');
// }, [isDropdownOpen]);
// // Close dropdown when clicking outside
// useEffect(() => {
// const handleClickOutside = (event: MouseEvent) => {
// if (containerRef.current && !containerRef.current.contains(event.target as Node)) {
// setDropdownOpen(false);
// }
// };
// document.addEventListener('mousedown', handleClickOutside);
// return () => document.removeEventListener('mousedown', handleClickOutside);
// }, []);
// return (
// <div ref={containerRef} className="relative w-full mb-4">
// <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">{label}</label>
// <input
// type="text"
// value={value}
// placeholder={placeholder || `Select ${label}`}
// disabled={disabled}
// className={`w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md
// focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:bg-gray-100 dark:disabled:bg-gray-700
// bg-white dark:bg-gray-700 text-gray-900 dark:text-white`}
// onFocus={() => !disabled && setDropdownOpen(true)}
// onChange={(e) => {
// const text = e.target.value;
// setSearchText(text);
// searchLink(text);
// onChange(text);
// }}
// />
// {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 mt-1 max-h-48 overflow-auto w-full shadow-lg">
// {searchResults.map((item, idx) => (
// <li
// key={idx}
// onClick={() => {
// onChange(item.value);
// setDropdownOpen(false);
// }}
// className={`px-3 py-2 cursor-pointer
// text-gray-900 dark:text-gray-100
// hover:bg-blue-500 dark:hover:bg-blue-600
// ${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 text-xs ml-2">{item.description}</span>
// )}
// </li>
// ))}
// </ul>
// )}
// </div>
// );
// };
// export default LinkField;

View File

@ -2,23 +2,23 @@ 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, Users,
FaUsers, BarChart3,
FaChartBar, Building2,
FaBuilding, Truck,
FaTruck, FileText,
FaFileContract, MapPin,
FaInfoCircle, Menu,
FaBars, X,
FaTimes, Moon,
FaHome, Sun,
FaMoon, LogOut,
FaSun, ClipboardList,
FaSignOutAlt Calendar
} from 'react-icons/fa'; } from 'lucide-react';
interface SidebarLink { interface SidebarLink {
id: string; id: string;
@ -75,80 +75,87 @@ const Sidebar: React.FC<SidebarProps> = ({ userEmail }) => {
{ {
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 +182,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 +251,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 +267,7 @@ const Sidebar: React.FC<SidebarProps> = ({ userEmail }) => {
className="w-full flex items-center justify-center px-4 py-2 rounded-lg bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors text-gray-700 dark:text-gray-300" 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 +281,7 @@ const Sidebar: React.FC<SidebarProps> = ({ userEmail }) => {
className="w-full flex items-center justify-center px-4 py-2 rounded-lg bg-red-50 dark:bg-red-900/20 hover:bg-red-100 dark:hover:bg-red-900/30 transition-colors text-red-600 dark:text-red-400" 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 +300,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>

View File

@ -40,6 +40,35 @@ 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',

View File

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

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

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

220
src/hooks/useWorkOrder.ts Normal file
View 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 };
}

View File

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

View File

@ -4,6 +4,9 @@ import { useAssetDetails, useAssetMutations } from '../hooks/useAsset';
import { FaArrowLeft, FaSave, FaEdit, FaQrcode } from 'react-icons/fa'; import { FaArrowLeft, FaSave, FaEdit, FaQrcode } from 'react-icons/fa';
import type { CreateAssetData } from '../services/assetService'; import type { CreateAssetData } from '../services/assetService';
import LinkField from '../components/LinkField';
import apiService from '../services/apiService'; // ✅ your ApiService
// Helper function to get the base URL for files // Helper function to get the base URL for files
const getFileBaseUrl = () => { const getFileBaseUrl = () => {
// Always use the full URL to avoid proxy path duplication issues // Always use the full URL to avoid proxy path duplication issues
@ -24,8 +27,12 @@ const AssetDetail: React.FC = () => {
isDuplicating ? duplicateFromAsset : (isNewAsset ? null : assetName || null) isDuplicating ? duplicateFromAsset : (isNewAsset ? null : assetName || null)
); );
const { createAsset, updateAsset, loading: saving } = useAssetMutations(); const { createAsset, updateAsset, loading: saving } = useAssetMutations();
const [isEditing, setIsEditing] = useState(isNewAsset); const [isEditing, setIsEditing] = useState(isNewAsset);
const [userSiteName, setUserSiteName] = useState('');
const [departmentFilters, setDepartmentFilters] = useState<Record<string, any>>({});
const [formData, setFormData] = useState<CreateAssetData>({ const [formData, setFormData] = useState<CreateAssetData>({
asset_name: '', asset_name: '',
company: '', company: '',
@ -44,9 +51,53 @@ const AssetDetail: React.FC = () => {
custom_modality: '', custom_modality: '',
custom_attach_image: '', custom_attach_image: '',
custom_site_contractor: '', custom_site_contractor: '',
custom_total_amount: 0 custom_total_amount: 0,
calculate_depreciation: false,
available_for_use_date: isNewAsset ? new Date().toISOString().split('T')[0] : undefined
}); });
// Load user details on mount
useEffect(() => {
async function loadUserDetails() {
try {
const user = await apiService.getUserDetails();
setUserSiteName(user.custom_site_name || '');
} catch (err) {
console.error('Error loading user details', err);
}
}
loadUserDetails();
}, []);
// Update department filters when company or userSiteName changes
useEffect(() => {
const filters: Record<string, any> = {};
// Base filter: company must match
if (formData.company) {
filters['company'] = formData.company;
}
// Apply department name filters based on site name and company
const isMobileSite =
(userSiteName && userSiteName.startsWith('Mobile')) ||
(formData.company && formData.company.startsWith('Mobile'));
if (isMobileSite) {
// For Mobile sites, exclude Non Bio departments (show Bio departments)
// Frappe filter format: ['not like', 'pattern']
filters['department_name'] = ['not like', 'Non Bio%'];
} else if (userSiteName || formData.company) {
// For non-Mobile sites, exclude Bio departments (show Non-Bio departments)
filters['department_name'] = ['not like', 'Bio%'];
}
console.log('Department filters updated:', filters); // Debug log
setDepartmentFilters(filters);
}, [formData.company, userSiteName]);
// Load asset data for editing or duplicating // Load asset data for editing or duplicating
useEffect(() => { useEffect(() => {
if (asset) { if (asset) {
@ -71,11 +122,52 @@ const AssetDetail: React.FC = () => {
custom_modality: asset.custom_modality || '', custom_modality: asset.custom_modality || '',
custom_attach_image: asset.custom_attach_image || '', custom_attach_image: asset.custom_attach_image || '',
custom_site_contractor: asset.custom_site_contractor || '', custom_site_contractor: asset.custom_site_contractor || '',
custom_total_amount: asset.custom_total_amount || 0 custom_total_amount: asset.custom_total_amount || 0,
gross_purchase_amount:asset.gross_purchase_amount || 0,
available_for_use_date: asset.available_for_use_date || '',
calculate_depreciation: asset.calculate_depreciation || false
}); });
} }
}, [asset, isDuplicating]); }, [asset, isDuplicating]);
const [qrCodeUrl, setQrCodeUrl] = useState<string | null>(null);
useEffect(() => {
if (!assetName || assetName === "new") return;
const fetchQRCode = async () => {
try {
// Try fixed predictable URL
const directUrl = `/files/${assetName}-qr.png`;
console.log(directUrl)
// Quickly test if file exists
const response = await fetch(directUrl, { method: "HEAD" });
console.log(response)
if (response.ok) {
setQrCodeUrl(directUrl);
return;
}
// If not available, fallback to File doctype API
const fileRes = await apiService.apiCall<any>(
`/api/resource/File?filters=[["File","attached_to_name","=","${assetName}"]]`
);
if (fileRes?.data?.length > 0) {
setQrCodeUrl(fileRes.data[0].file_url);
}
} catch (error) {
console.error("Error loading QR code:", error);
}
};
fetchQRCode();
}, [assetName, asset]);
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement>) => { const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement>) => {
const { name, value } = e.target; const { name, value } = e.target;
setFormData(prev => ({ setFormData(prev => ({
@ -104,6 +196,10 @@ const AssetDetail: React.FC = () => {
try { try {
if (isNewAsset || isDuplicating) { if (isNewAsset || isDuplicating) {
const newAsset = await createAsset(formData); const newAsset = await createAsset(formData);
if (newAsset.name) {
const qrUrl = `/files/${newAsset.name}-qr.png`;
setQrCodeUrl(qrUrl);
}
const successMessage = isDuplicating const successMessage = isDuplicating
? 'Asset duplicated successfully!' ? 'Asset duplicated successfully!'
: 'Asset created successfully!'; : 'Asset created successfully!';
@ -273,7 +369,7 @@ const AssetDetail: React.FC = () => {
/> />
</div> </div>
<div> {/* <div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"> <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Category <span className="text-red-500">*</span> Category <span className="text-red-500">*</span>
</label> </label>
@ -291,9 +387,18 @@ const AssetDetail: React.FC = () => {
<option value="IT Equipment">IT Equipment</option> <option value="IT Equipment">IT Equipment</option>
<option value="Furniture">Furniture</option> <option value="Furniture">Furniture</option>
</select> </select>
</div> </div> */}
<div> <LinkField
label="Category"
doctype="Asset Type"
value={formData.custom_asset_type || ''}
onChange={(val) => setFormData({ ...formData, custom_asset_type: val })}
disabled={!isEditing}
/>
{/* <div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"> <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Modality <span className="text-red-500">*</span> Modality <span className="text-red-500">*</span>
</label> </label>
@ -311,7 +416,15 @@ const AssetDetail: React.FC = () => {
<option value="Ultrasound">Ultrasound</option> <option value="Ultrasound">Ultrasound</option>
<option value="Other">Other</option> <option value="Other">Other</option>
</select> </select>
</div> </div> */}
<LinkField
label="Modality"
doctype="Modality"
value={formData.custom_modality || ''}
onChange={(val) => setFormData({ ...formData, custom_modality: val })}
disabled={!isEditing}
/>
<div> <div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"> <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
@ -393,7 +506,7 @@ const AssetDetail: React.FC = () => {
/> />
</div> </div>
<div> {/* <div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"> <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Manufacturer Manufacturer
</label> </label>
@ -406,7 +519,15 @@ const AssetDetail: React.FC = () => {
disabled={!isEditing} disabled={!isEditing}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:bg-gray-100 dark:disabled:bg-gray-700 bg-white dark:bg-gray-700 text-gray-900 dark:text-white" className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:bg-gray-100 dark:disabled:bg-gray-700 bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
/> />
</div> </div> */}
<LinkField
label="Manufacturer"
doctype="Manufacturer"
value={formData.custom_manufacturer || ''}
onChange={(val) => setFormData({ ...formData, custom_manufacturer: val })}
disabled={!isEditing}
/>
<div> <div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"> <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
@ -441,7 +562,7 @@ const AssetDetail: React.FC = () => {
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-6"> <div className="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
<h2 className="text-lg font-semibold text-gray-800 dark:text-white mb-4">Location</h2> <h2 className="text-lg font-semibold text-gray-800 dark:text-white mb-4">Location</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4"> <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div> {/* <div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"> <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Company Company
</label> </label>
@ -456,9 +577,22 @@ const AssetDetail: React.FC = () => {
<option value="ABC Hospital">ABC Hospital</option> <option value="ABC Hospital">ABC Hospital</option>
<option value="XYZ Clinic">XYZ Clinic</option> <option value="XYZ Clinic">XYZ Clinic</option>
</select> </select>
</div> </div> */}
<div> <LinkField
label="Hospital"
doctype="Company"
value={formData.company || ''}
onChange={(val) => {
setFormData({ ...formData, company: val, department: '' }); // Clear department when company changes
}}
disabled={!isEditing}
filters={{ domain: 'Healthcare' }}
// onChange={(val) => setFormData({ ...formData, company: val })}
// disabled={!isEditing}
/>
{/* <div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"> <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Department Department
</label> </label>
@ -474,7 +608,24 @@ const AssetDetail: React.FC = () => {
<option value="Cardiology">Cardiology</option> <option value="Cardiology">Cardiology</option>
<option value="IT">IT</option> <option value="IT">IT</option>
</select> </select>
</div> </div> */}
<LinkField
label="Department"
doctype="Department"
value={formData.department || ''}
onChange={(val) => setFormData({ ...formData, department: val })}
disabled={!isEditing}
filters={departmentFilters}
/>
<LinkField
label="Location"
doctype="Location"
value={formData.location || ''}
onChange={(val) => setFormData({ ...formData, location: val })}
disabled={!isEditing}
/>
<div> <div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"> <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
@ -574,10 +725,19 @@ const AssetDetail: React.FC = () => {
Service Agreement Service Agreement
</label> </label>
<select <select
name="custom_service_agreement"
value={formData.custom_service_agreement}
onChange={handleChange}
disabled={!isEditing} disabled={!isEditing}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:bg-gray-100 dark:disabled:bg-gray-700 bg-white dark:bg-gray-700 text-gray-900 dark:text-white" className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:bg-gray-100 dark:disabled:bg-gray-700 bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
> >
<option value="">Select</option> <option value="">Select Service Agreement</option>
<option value="Warranty">Warranty</option>
<option value="Contract">Contract</option>
<option value="Frame Work">Frame Work</option>
<option value="Out of warranty">Out of warranty</option>
<option value="Under Dismantle">Under Dismantle</option>
<option value="Under Installation">Under Installation</option>
</select> </select>
</div> </div>
@ -586,10 +746,17 @@ const AssetDetail: React.FC = () => {
Service Coverage Service Coverage
</label> </label>
<select <select
name="custom_service_coverage"
value={formData.custom_service_coverage}
onChange={handleChange}
disabled={!isEditing} disabled={!isEditing}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:bg-gray-100 dark:disabled:bg-gray-700 bg-white dark:bg-gray-700 text-gray-900 dark:text-white" className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:bg-gray-100 dark:disabled:bg-gray-700 bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
> >
<option value="">Select</option> <option value="">Select Service Coverage</option>
<option value="PM Only">PM Only</option>
<option value="Labour">Labour</option>
<option value="Labour & Parts">Labour & Parts</option>
<option value="Comprehensive">Comprehensive</option>
</select> </select>
</div> </div>
@ -658,7 +825,7 @@ const AssetDetail: React.FC = () => {
/> />
</div> </div>
<div> {/* <div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"> <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Supplier/Vendor Supplier/Vendor
</label> </label>
@ -668,18 +835,27 @@ const AssetDetail: React.FC = () => {
> >
<option value="">Select</option> <option value="">Select</option>
</select> </select>
</div> </div> */}
<LinkField
label="Supplier/Vendor"
doctype="Supplier"
value={formData.supplier || ''}
onChange={(val) => setFormData({ ...formData, supplier: val })}
disabled={!isEditing}
/>
<div> <div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"> <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Gross Purchase Amount Gross Purchase Amount
</label> </label>
<select <input
type="number"
name="gross_purchase_amount"
value={formData.gross_purchase_amount}
onChange={handleChange}
disabled={!isEditing} disabled={!isEditing}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:bg-gray-100 dark:disabled:bg-gray-700 bg-white dark:bg-gray-700 text-gray-900 dark:text-white" className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:bg-gray-100 dark:disabled:bg-gray-700 bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
> />
<option value="">Price</option>
</select>
</div> </div>
<div> <div>
@ -710,6 +886,11 @@ const AssetDetail: React.FC = () => {
</label> </label>
<input <input
type="date" type="date"
name="available_for_use_date"
value={formData.available_for_use_date || ''}
onChange={(e) =>
setFormData((prev) => ({ ...prev, available_for_use_date: e.target.value }))
}
disabled={!isEditing} disabled={!isEditing}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:bg-gray-100 dark:disabled:bg-gray-700 bg-white dark:bg-gray-700 text-gray-900 dark:text-white" className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:bg-gray-100 dark:disabled:bg-gray-700 bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
/> />
@ -779,6 +960,29 @@ const AssetDetail: React.FC = () => {
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:bg-gray-100 dark:disabled:bg-gray-700 bg-white dark:bg-gray-700 text-gray-900 dark:text-white" className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:bg-gray-100 dark:disabled:bg-gray-700 bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
/> />
</div> </div>
<div className="flex items-center mt-4">
<input
id="calculate_depreciation"
type="checkbox"
checked={formData.calculate_depreciation}
onChange={(e) =>
setFormData({
...formData,
calculate_depreciation: e.target.checked,
})
}
disabled={!isEditing}
className="h-4 w-4 text-blue-600 border-gray-300 rounded focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700"
/>
<label
htmlFor="calculate_depreciation"
className="ml-2 text-sm font-medium text-gray-700 dark:text-gray-300"
>
Calculate Depreciation
</label>
</div>
</div> </div>
</div> </div>
@ -839,12 +1043,12 @@ const AssetDetail: React.FC = () => {
{/* QR Code */} {/* QR Code */}
<div className="flex flex-col items-center my-6"> <div className="flex flex-col items-center my-6">
<div className="border-2 border-gray-300 dark:border-gray-600 p-4 rounded-lg bg-white"> <div className="border-2 border-gray-300 dark:border-gray-600 p-4 rounded-lg bg-white dark:bg-gray-700">
{asset?.name ? ( {qrCodeUrl ? (
<> <>
<img <img
src={`${getFileBaseUrl()}/files/${asset.name}-qr.png`} src={qrCodeUrl}
alt={`QR Code for ${asset.name}`} alt={`QR Code for ${asset?.name || 'Asset'}`}
className="w-[120px] h-[120px] object-contain" className="w-[120px] h-[120px] object-contain"
onError={(e) => { onError={(e) => {
// Hide image and show fallback icon if QR code doesn't exist // Hide image and show fallback icon if QR code doesn't exist
@ -878,6 +1082,7 @@ const AssetDetail: React.FC = () => {
)} )}
</div> </div>
<div className="mb-4"> <div className="mb-4">
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"> <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Description Description

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

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

View File

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

406
src/pages/PPMDetail.tsx Normal file
View File

@ -0,0 +1,406 @@
import React, { useState, useEffect } from 'react';
import { useParams, useNavigate, useSearchParams } from 'react-router-dom';
import { usePPMDetails, usePPMMutations } from '../hooks/usePPM';
import { FaArrowLeft, FaSave, FaEdit, FaBuilding, FaTools, FaCalendarCheck, FaDollarSign } from 'react-icons/fa';
import type { CreatePPMData } from '../services/ppmService';
const PPMDetail: React.FC = () => {
const { ppmName } = useParams<{ ppmName: string }>();
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const duplicateFromPPM = searchParams.get('duplicate');
const isNewPPM = ppmName === 'new';
const isDuplicating = isNewPPM && !!duplicateFromPPM;
const { ppm, loading, error, refetch } = usePPMDetails(
isDuplicating ? duplicateFromPPM : (isNewPPM ? null : ppmName || null)
);
const { createPPM, updatePPM, loading: saving } = usePPMMutations();
const [isEditing, setIsEditing] = useState(isNewPPM);
const [formData, setFormData] = useState<CreatePPMData>({
company: '',
asset_name: '',
custom_asset_type: '',
maintenance_team: '',
custom_frequency: '',
custom_total_amount: 0,
custom_no_of_pms: 0,
custom_price_per_pm: 0,
});
useEffect(() => {
if (ppm) {
setFormData({
company: ppm.company || '',
asset_name: ppm.asset_name || '',
custom_asset_type: ppm.custom_asset_type || '',
maintenance_team: ppm.maintenance_team || '',
custom_frequency: ppm.custom_frequency || '',
custom_total_amount: ppm.custom_total_amount || 0,
custom_no_of_pms: ppm.custom_no_of_pms || 0,
custom_price_per_pm: ppm.custom_price_per_pm || 0,
});
}
}, [ppm, isDuplicating]);
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement>) => {
const { name, value } = e.target;
setFormData(prev => ({
...prev,
[name]: name.includes('amount') || name.includes('pms') || name.includes('price')
? parseFloat(value) || 0
: value
}));
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!formData.asset_name) {
alert('Please enter Asset Name');
return;
}
try {
if (isNewPPM || isDuplicating) {
const result = await createPPM(formData);
const successMessage = isDuplicating
? 'PPM schedule duplicated successfully!'
: 'PPM schedule created successfully!';
alert(successMessage);
if (result.asset_maintenance?.name) {
navigate(`/ppm/${result.asset_maintenance.name}`);
} else {
refetch();
navigate('/ppm');
}
} else if (ppmName) {
await updatePPM(ppmName, formData);
alert('PPM schedule updated successfully!');
setIsEditing(false);
refetch();
}
} catch (err) {
console.error('PPM save error:', err);
alert('Failed to save: ' + (err instanceof Error ? err.message : 'Unknown error'));
}
};
if (loading) {
return (
<div className="flex items-center justify-center h-screen bg-gray-50 dark:bg-gray-900">
<div className="text-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-500 mx-auto"></div>
<p className="mt-4 text-gray-600 dark:text-gray-400">Loading PPM schedule...</p>
</div>
</div>
);
}
if (error && !isNewPPM && !isDuplicating) {
return (
<div className="p-6 bg-gray-50 dark:bg-gray-900 min-h-screen">
<div className="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-4">
<p className="text-red-600 dark:text-red-400">Error: {error}</p>
<button
onClick={() => navigate('/ppm')}
className="mt-2 text-red-700 dark:text-red-400 underline hover:text-red-800 dark:hover:text-red-300"
>
Back to PPM schedules
</button>
</div>
</div>
);
}
return (
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 p-6">
{/* Header */}
<div className="mb-6 flex justify-between items-center">
<div className="flex items-center gap-4">
<button
onClick={() => navigate('/ppm')}
className="text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300 flex items-center gap-2"
>
<FaArrowLeft />
<span className="text-gray-900 dark:text-white">
{isDuplicating ? 'Duplicate PPM Schedule' : (isNewPPM ? 'New PPM Schedule' : 'PPM Schedule Details')}
</span>
</button>
</div>
<div className="flex items-center gap-3">
{!isNewPPM && !isEditing && (
<button
onClick={() => setIsEditing(true)}
className="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-lg flex items-center gap-2"
>
<FaEdit />
Edit
</button>
)}
</div>
</div>
<form onSubmit={handleSubmit}>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Main Form */}
<div className="lg:col-span-2 space-y-6">
{/* Basic Information */}
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
<h2 className="text-xl font-semibold text-gray-900 dark:text-white mb-4">Basic Information</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Company *
</label>
{isEditing ? (
<input
type="text"
name="company"
value={formData.company}
onChange={handleChange}
className="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
required
/>
) : (
<div className="p-4 bg-gray-50 dark:bg-gray-700 rounded-lg">
<p className="text-gray-900 dark:text-white">{ppm?.company || '-'}</p>
</div>
)}
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Asset Name *
</label>
{isEditing ? (
<input
type="text"
name="asset_name"
value={formData.asset_name}
onChange={handleChange}
className="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
required
/>
) : (
<div className="p-4 bg-gray-50 dark:bg-gray-700 rounded-lg">
<p className="text-gray-900 dark:text-white">{ppm?.asset_name || '-'}</p>
</div>
)}
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Asset Type
</label>
{isEditing ? (
<input
type="text"
name="custom_asset_type"
value={formData.custom_asset_type}
onChange={handleChange}
className="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
) : (
<div className="p-4 bg-gray-50 dark:bg-gray-700 rounded-lg">
<p className="text-gray-900 dark:text-white">{ppm?.custom_asset_type || '-'}</p>
</div>
)}
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Maintenance Team
</label>
{isEditing ? (
<input
type="text"
name="maintenance_team"
value={formData.maintenance_team}
onChange={handleChange}
className="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
) : (
<div className="p-4 bg-gray-50 dark:bg-gray-700 rounded-lg">
<p className="text-gray-900 dark:text-white">{ppm?.maintenance_team || '-'}</p>
</div>
)}
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Frequency
</label>
{isEditing ? (
<input
type="text"
name="custom_frequency"
value={formData.custom_frequency}
onChange={handleChange}
className="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder="e.g., Monthly, Quarterly, Yearly"
/>
) : (
<div className="p-4 bg-gray-50 dark:bg-gray-700 rounded-lg">
<p className="text-gray-900 dark:text-white">{ppm?.custom_frequency || '-'}</p>
</div>
)}
</div>
</div>
</div>
{/* Financial Information */}
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
<h2 className="text-xl font-semibold text-gray-900 dark:text-white mb-4">Financial Information</h2>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Number of PMs
</label>
{isEditing ? (
<input
type="number"
name="custom_no_of_pms"
value={formData.custom_no_of_pms}
onChange={handleChange}
className="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
min="0"
/>
) : (
<div className="p-4 bg-gray-50 dark:bg-gray-700 rounded-lg">
<p className="text-gray-900 dark:text-white">{ppm?.custom_no_of_pms || '-'}</p>
</div>
)}
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Price per PM
</label>
{isEditing ? (
<input
type="number"
name="custom_price_per_pm"
value={formData.custom_price_per_pm}
onChange={handleChange}
className="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
min="0"
step="0.01"
/>
) : (
<div className="p-4 bg-gray-50 dark:bg-gray-700 rounded-lg">
<p className="text-gray-900 dark:text-white">
{ppm?.custom_price_per_pm ? `$${ppm.custom_price_per_pm.toLocaleString()}` : '-'}
</p>
</div>
)}
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Total Amount
</label>
{isEditing ? (
<input
type="number"
name="custom_total_amount"
value={formData.custom_total_amount}
onChange={handleChange}
className="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
min="0"
step="0.01"
/>
) : (
<div className="p-4 bg-gray-50 dark:bg-gray-700 rounded-lg">
<p className="text-gray-900 dark:text-white font-semibold">
{ppm?.custom_total_amount ? `$${ppm.custom_total_amount.toLocaleString()}` : '-'}
</p>
</div>
)}
</div>
</div>
</div>
</div>
{/* Sidebar Info */}
<div className="lg:col-span-1">
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-6 space-y-4">
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-4">Schedule Information</h3>
{!isNewPPM && ppm && (
<>
<div className="p-4 bg-gray-50 dark:bg-gray-700 rounded-lg">
<p className="text-xs text-gray-500 dark:text-gray-400 mb-1">PPM ID</p>
<p className="text-sm font-medium text-gray-900 dark:text-white">{ppm.name}</p>
</div>
<div className="p-4 bg-gray-50 dark:bg-gray-700 rounded-lg">
<p className="text-xs text-gray-500 dark:text-gray-400 mb-1">Created</p>
<p className="text-xs text-gray-900 dark:text-white">
{ppm.creation ? new Date(ppm.creation).toLocaleString() : '-'}
</p>
</div>
</>
)}
{isNewPPM && (
<div className="text-center py-8">
<FaTools className="text-4xl text-gray-400 dark:text-gray-500 mx-auto mb-2" />
<p className="text-sm text-gray-500 dark:text-gray-400">
Schedule information will appear after creation
</p>
</div>
)}
</div>
</div>
</div>
{/* Action Buttons */}
{isEditing && (
<div className="mt-6 flex justify-end gap-3">
<button
type="button"
onClick={() => {
if (isNewPPM) {
navigate('/ppm');
} else {
setIsEditing(false);
if (ppm) {
setFormData({
company: ppm.company || '',
asset_name: ppm.asset_name || '',
custom_asset_type: ppm.custom_asset_type || '',
maintenance_team: ppm.maintenance_team || '',
custom_frequency: ppm.custom_frequency || '',
custom_total_amount: ppm.custom_total_amount || 0,
custom_no_of_pms: ppm.custom_no_of_pms || 0,
custom_price_per_pm: ppm.custom_price_per_pm || 0,
});
}
}
}}
className="px-6 py-2 border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700"
>
Cancel
</button>
<button
type="submit"
disabled={saving}
className="px-6 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg flex items-center gap-2 disabled:opacity-50"
>
<FaSave />
{saving ? 'Saving...' : (isNewPPM ? 'Create' : 'Save Changes')}
</button>
</div>
)}
</form>
</div>
);
};
export default PPMDetail;

441
src/pages/PPMList.tsx Normal file
View File

@ -0,0 +1,441 @@
import React, { useState, useEffect, useRef } from 'react';
import { useNavigate } from 'react-router-dom';
import { usePPMs, usePPMMutations } from '../hooks/usePPM';
import { FaPlus, FaSearch, FaEdit, FaEye, FaTrash, FaCopy, FaEllipsisV, FaFileExport, FaCalendarCheck, FaTools, FaBuilding } from 'react-icons/fa';
const PPMList: React.FC = () => {
const navigate = useNavigate();
const [page, setPage] = useState(0);
const [searchTerm, setSearchTerm] = useState('');
const [companyFilter, setCompanyFilter] = useState<string>('');
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState<string | null>(null);
const [actionMenuOpen, setActionMenuOpen] = useState<string | null>(null);
const dropdownRef = useRef<HTMLDivElement>(null);
const limit = 20;
const filters = companyFilter ? { company: companyFilter } : {};
const { ppms, totalCount, hasMore, loading, error, refetch } = usePPMs(
filters,
limit,
page * limit,
'creation desc'
);
const { deletePPM, loading: mutationLoading } = usePPMMutations();
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
setActionMenuOpen(null);
}
};
if (actionMenuOpen) {
document.addEventListener('mousedown', handleClickOutside);
}
return () => {
document.removeEventListener('mousedown', handleClickOutside);
};
}, [actionMenuOpen]);
const handleCreateNew = () => {
navigate('/ppm/new');
};
const handleView = (ppmName: string) => {
navigate(`/ppm/${ppmName}`);
};
const handleEdit = (ppmName: string) => {
navigate(`/ppm/${ppmName}`);
};
const handleDelete = async (ppmName: string) => {
try {
await deletePPM(ppmName);
setDeleteConfirmOpen(null);
refetch();
alert('PPM schedule deleted successfully!');
} catch (err) {
alert(`Failed to delete: ${err instanceof Error ? err.message : 'Unknown error'}`);
}
};
const handleDuplicate = (ppmName: string) => {
navigate(`/ppm/new?duplicate=${ppmName}`);
};
const handleExport = (ppm: any) => {
const dataStr = JSON.stringify(ppm, null, 2);
const dataBlob = new Blob([dataStr], { type: 'application/json' });
const url = URL.createObjectURL(dataBlob);
const link = document.createElement('a');
link.href = url;
link.download = `ppm_${ppm.name}.json`;
link.click();
URL.revokeObjectURL(url);
};
const handleExportAll = () => {
const headers = ['PPM ID', 'Company', 'Asset', 'Asset Type', 'Frequency', 'No. of PMs', 'Total Amount'];
const csvContent = [
headers.join(','),
...ppms.map(ppm => [
ppm.name,
ppm.company || '',
ppm.asset_name || '',
ppm.custom_asset_type || '',
ppm.custom_frequency || '',
ppm.custom_no_of_pms || '',
ppm.custom_total_amount || ''
].join(','))
].join('\n');
const dataBlob = new Blob([csvContent], { type: 'text/csv' });
const url = URL.createObjectURL(dataBlob);
const link = document.createElement('a');
link.href = url;
link.download = `ppm_schedules_${new Date().toISOString().split('T')[0]}.csv`;
link.click();
URL.revokeObjectURL(url);
};
if (loading && page === 0) {
return (
<div className="flex items-center justify-center h-screen bg-gray-50 dark:bg-gray-900">
<div className="text-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-500 mx-auto"></div>
<p className="mt-4 text-gray-600 dark:text-gray-400">Loading PPM schedules...</p>
</div>
</div>
);
}
if (error) {
return (
<div className="p-6 bg-gray-50 dark:bg-gray-900 min-h-screen">
<div className="bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-lg p-6">
<h2 className="text-xl font-bold text-yellow-800 dark:text-yellow-300 mb-4"> PPM API Not Available</h2>
<div className="text-yellow-700 dark:text-yellow-400 space-y-3">
<p><strong>The PPM API endpoint is not deployed yet.</strong></p>
<div className="mt-4 flex gap-3">
<button
onClick={() => navigate('/ppm/new')}
className="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded"
>
Try Creating New (Demo)
</button>
<button
onClick={refetch}
className="bg-gray-600 hover:bg-gray-700 text-white px-4 py-2 rounded"
>
Try Again
</button>
</div>
</div>
<div className="mt-4 p-4 bg-white dark:bg-gray-800 rounded border border-yellow-300 dark:border-yellow-700">
<p className="text-sm text-gray-600 dark:text-gray-400">
<strong>Technical Error:</strong> {error}
</p>
</div>
</div>
</div>
);
}
const filteredPPMs = ppms.filter(ppm =>
ppm.name?.toLowerCase().includes(searchTerm.toLowerCase()) ||
ppm.asset_name?.toLowerCase().includes(searchTerm.toLowerCase()) ||
ppm.company?.toLowerCase().includes(searchTerm.toLowerCase()) ||
ppm.custom_asset_type?.toLowerCase().includes(searchTerm.toLowerCase())
);
return (
<div className="p-6 bg-gray-50 dark:bg-gray-900 min-h-screen">
{/* Header */}
<div className="mb-6 flex justify-between items-center">
<div>
<h1 className="text-3xl font-bold text-gray-800 dark:text-white">PPM Schedules</h1>
<p className="text-gray-600 dark:text-gray-400 mt-1">
Total: {totalCount} PPM schedule{totalCount !== 1 ? 's' : ''}
</p>
</div>
<div className="flex gap-3">
<button
onClick={handleExportAll}
className="bg-green-600 hover:bg-green-700 text-white px-4 py-3 rounded-lg flex items-center gap-2 shadow transition-all"
disabled={ppms.length === 0}
>
<FaFileExport />
<span className="font-medium">Export All</span>
</button>
<button
onClick={handleCreateNew}
className="bg-blue-600 hover:bg-blue-700 text-white px-6 py-3 rounded-lg flex items-center gap-2 shadow-lg transition-all hover:shadow-xl"
>
<FaPlus />
<span className="font-medium">New PPM Schedule</span>
</button>
</div>
</div>
{/* Filters Bar */}
<div className="mb-6 grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-4">
<div className="flex items-center gap-2 border border-gray-300 dark:border-gray-600 rounded-lg px-4 py-2 bg-white dark:bg-gray-700">
<FaSearch className="text-gray-400 dark:text-gray-500" />
<input
type="text"
placeholder="Search by ID, asset, company..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="flex-1 outline-none text-gray-700 dark:text-gray-200 bg-transparent"
/>
</div>
</div>
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-4">
<input
type="text"
placeholder="Filter by Company"
value={companyFilter}
onChange={(e) => {
setCompanyFilter(e.target.value);
setPage(0);
}}
className="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-700 dark:text-gray-200 focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
</div>
{/* PPM Schedules Table */}
<div className="bg-white dark:bg-gray-800 rounded-lg shadow overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full">
<thead className="bg-gray-100 dark:bg-gray-700 border-b border-gray-200 dark:border-gray-600">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
PPM ID
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
Company
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
Asset
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
Asset Type
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
Frequency
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
No. of PMs
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
Total Amount
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
Actions
</th>
</tr>
</thead>
<tbody className="bg-white dark:bg-gray-800 divide-y divide-gray-200 dark:divide-gray-700">
{filteredPPMs.length === 0 ? (
<tr>
<td colSpan={8} className="px-6 py-12 text-center text-gray-500 dark:text-gray-400">
<div className="flex flex-col items-center">
<FaSearch className="text-4xl text-gray-300 dark:text-gray-600 mb-2" />
<p>No PPM schedules found</p>
<button
onClick={handleCreateNew}
className="mt-4 text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300 underline"
>
Create your first PPM schedule
</button>
</div>
</td>
</tr>
) : (
filteredPPMs.map((ppm) => (
<tr
key={ppm.name}
className="hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-colors cursor-pointer"
onClick={() => handleView(ppm.name)}
>
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm font-medium text-gray-900 dark:text-white">
{ppm.name}
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="flex items-center gap-2">
<FaBuilding className="text-gray-400" />
<span className="text-sm text-gray-700 dark:text-gray-300">
{ppm.company || '-'}
</span>
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm text-gray-700 dark:text-gray-300">
{ppm.asset_name || '-'}
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm text-gray-700 dark:text-gray-300">
{ppm.custom_asset_type || '-'}
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="flex items-center gap-2">
<FaCalendarCheck className="text-blue-500" />
<span className="text-sm text-gray-700 dark:text-gray-300">
{ppm.custom_frequency || '-'}
</span>
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm text-gray-700 dark:text-gray-300">
{ppm.custom_no_of_pms || '-'}
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm font-medium text-gray-900 dark:text-white">
{ppm.custom_total_amount ? `$${ppm.custom_total_amount.toLocaleString()}` : '-'}
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium">
<div className="relative" onClick={(e) => e.stopPropagation()}>
<button
onClick={() => setActionMenuOpen(actionMenuOpen === ppm.name ? null : ppm.name)}
className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700"
>
<FaEllipsisV />
</button>
{actionMenuOpen === ppm.name && (
<div
ref={dropdownRef}
className="absolute right-0 mt-2 w-48 bg-white dark:bg-gray-800 rounded-lg shadow-lg z-10 border border-gray-200 dark:border-gray-700"
>
<button
onClick={() => {
handleView(ppm.name);
setActionMenuOpen(null);
}}
className="w-full text-left px-4 py-2 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 flex items-center gap-2"
>
<FaEye />
View
</button>
<button
onClick={() => {
handleEdit(ppm.name);
setActionMenuOpen(null);
}}
className="w-full text-left px-4 py-2 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 flex items-center gap-2"
>
<FaEdit />
Edit
</button>
<button
onClick={() => {
handleDuplicate(ppm.name);
setActionMenuOpen(null);
}}
className="w-full text-left px-4 py-2 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 flex items-center gap-2"
>
<FaCopy />
Duplicate
</button>
<button
onClick={() => {
handleExport(ppm);
setActionMenuOpen(null);
}}
className="w-full text-left px-4 py-2 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 flex items-center gap-2"
>
<FaFileExport />
Export
</button>
<div className="border-t border-gray-200 dark:border-gray-700"></div>
<button
onClick={() => {
setDeleteConfirmOpen(ppm.name);
setActionMenuOpen(null);
}}
className="w-full text-left px-4 py-2 text-sm text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20 flex items-center gap-2"
>
<FaTrash />
Delete
</button>
</div>
)}
</div>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
{/* Pagination */}
{(hasMore || page > 0) && (
<div className="px-6 py-4 border-t border-gray-200 dark:border-gray-700 flex items-center justify-between">
<div className="text-sm text-gray-700 dark:text-gray-300">
Showing {page * limit + 1} to {Math.min((page + 1) * limit, totalCount)} of {totalCount} results
</div>
<div className="flex gap-2">
<button
onClick={() => setPage(Math.max(0, page - 1))}
disabled={page === 0}
className="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-600 disabled:opacity-50 disabled:cursor-not-allowed"
>
Previous
</button>
<button
onClick={() => setPage(page + 1)}
disabled={!hasMore}
className="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-600 disabled:opacity-50 disabled:cursor-not-allowed"
>
Next
</button>
</div>
</div>
)}
</div>
{/* Delete Confirmation Modal */}
{deleteConfirmOpen && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white dark:bg-gray-800 rounded-lg p-6 max-w-md w-full mx-4">
<h3 className="text-lg font-bold text-gray-900 dark:text-white mb-4">Confirm Delete</h3>
<p className="text-gray-600 dark:text-gray-400 mb-6">
Are you sure you want to delete this PPM schedule? This action cannot be undone.
</p>
<div className="flex gap-3 justify-end">
<button
onClick={() => setDeleteConfirmOpen(null)}
className="px-4 py-2 text-gray-700 dark:text-gray-300 bg-gray-200 dark:bg-gray-700 rounded-lg hover:bg-gray-300 dark:hover:bg-gray-600"
>
Cancel
</button>
<button
onClick={() => handleDelete(deleteConfirmOpen)}
disabled={mutationLoading}
className="px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 disabled:opacity-50"
>
{mutationLoading ? 'Deleting...' : 'Delete'}
</button>
</div>
</div>
</div>
)}
</div>
);
};
export default PPMList;

0
src/pages/WorkOrder.tsx Normal file
View File

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

View File

@ -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 {

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

View File

@ -26,6 +26,10 @@ export interface Asset {
modified?: string; modified?: string;
owner?: string; owner?: string;
modified_by?: string; modified_by?: string;
calculate_depreciation?: boolean;
gross_purchase_amount?: number;
available_for_use_date?:string;
} }
export interface AssetListResponse { export interface AssetListResponse {
@ -82,6 +86,7 @@ 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;
[key: string]: any; [key: string]: any;
} }

242
src/services/ppmService.ts Normal file
View 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;

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

View File

@ -6,6 +6,9 @@ export default defineConfig({
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': {