Compare commits
104 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 759c3249e1 | |||
| bff3cd7d91 | |||
| 3799be8b23 | |||
| 0932acfb61 | |||
| 38374acdcd | |||
| a58180f252 | |||
| a307fd006c | |||
| 31738b3a52 | |||
| e3e7206d91 | |||
| fe1b94d8e3 | |||
| 4f1c78f8e1 | |||
| 7ccd413973 | |||
| e74bf35264 | |||
| 23be1fca0f | |||
| df047541b9 | |||
| c8323e91b0 | |||
| 9da272b464 | |||
| 706a01e5aa | |||
| 8c159f402e | |||
| f483fbd2ee | |||
| 616148f794 | |||
| 7a125bf0b7 | |||
| dfe052aba6 | |||
| 641e98c001 | |||
| 1ee63ec74a | |||
| 0d8b88d9a1 | |||
| 280c5dcc86 | |||
| 09ce22b4fb | |||
| 1e8962cb11 | |||
| a3727b5dfd | |||
| 4c2f65b432 | |||
| 0692a78576 | |||
| 839fcc8771 | |||
| 686634f0ac | |||
| 705acfbe1d | |||
| b845e46231 | |||
| 1f13d5c6be | |||
| 9781a0ad68 | |||
| 1e51c964cf | |||
| b282b0b2d5 | |||
| 8910c1355c | |||
| b4bef67220 | |||
| 462b6d6d40 | |||
| 747ae5c23f | |||
| 395a580032 | |||
| a7a1c9a144 | |||
| 11deebe45f | |||
| 0e705e1309 | |||
| 4a6cd92661 | |||
| 3f6d606190 | |||
| 7c382ddb7a | |||
| e4b5ba96c1 | |||
| 34e78e5c46 | |||
| 442e025f7b | |||
| 975fb65c3d | |||
| 48abc618f3 | |||
| ba318f13a5 | |||
| c099f03bbb | |||
| 19f13598fb | |||
| 918e012082 | |||
| e0c7541a87 | |||
| a1c6c7053e | |||
| 389eb22737 | |||
| 466ef1cb90 | |||
| caaa32ea13 | |||
| 78be48ffd0 | |||
| aaee189ace | |||
| ce988574e4 | |||
| a166938d42 | |||
| e4d6d84477 | |||
| c0cf913935 | |||
| c8d640ebb5 | |||
| 9f38e23b22 | |||
| 1ea81a23b2 | |||
| 8ee933563e | |||
| 687619ce0d | |||
| 9e931ad439 | |||
| c7fd7f4fbe | |||
| eafc4c33ab | |||
| 7644480782 | |||
| 1515ae56af | |||
| caf3e73dc3 | |||
| 98c86ff5dd | |||
| 42441b615a | |||
| e5651c152a | |||
| fc84922959 | |||
| 093300c38a | |||
| 2dcedf7b30 | |||
| 38e06b9bec | |||
| 9f2ab1a79b | |||
| 54ebe0604d | |||
| cf28eb41d6 | |||
| 8ef3614908 | |||
| 7773c1bc66 | |||
| 580dbc424c | |||
| 981bc972f0 | |||
| a9043e8088 | |||
| 1ae961c5d9 | |||
| 51226a7afe | |||
| dfd57a6b62 | |||
| 12f4d18521 | |||
| aea32f10b6 | |||
| a16b1d228a | |||
| 930622b5be |
@@ -21,4 +21,6 @@ dist-ssr
|
|||||||
*.ntvs*
|
*.ntvs*
|
||||||
*.njsproj
|
*.njsproj
|
||||||
*.sln
|
*.sln
|
||||||
*.sw?
|
*.sw?
|
||||||
|
application/src/lib/pocketbase.ts
|
||||||
|
Dockerfile
|
||||||
+1
-1
@@ -46,7 +46,7 @@ Click "Fork" on [GitHub](https://github.com/operacle/checkcle) to create your ow
|
|||||||
|
|
||||||
### 2. Clone Your Fork
|
### 2. Clone Your Fork
|
||||||
```bash
|
```bash
|
||||||
git clone https://github.com/operacle/checkcle.git
|
git clone --branch develop https://github.com/operacle/checkcle.git
|
||||||
cd checkcle
|
cd checkcle
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
-44
@@ -1,44 +0,0 @@
|
|||||||
# Stage 1: Build the frontend
|
|
||||||
FROM node:18-alpine AS frontend-builder
|
|
||||||
WORKDIR /app
|
|
||||||
COPY application/package*.json ./
|
|
||||||
RUN npm install
|
|
||||||
COPY application/ ./
|
|
||||||
RUN npm run build
|
|
||||||
|
|
||||||
# Stage 2: Download PocketBase binary
|
|
||||||
FROM alpine:3.17 AS pb-builder
|
|
||||||
WORKDIR /pb
|
|
||||||
ARG PB_VERSION=0.27.2
|
|
||||||
RUN apk add --no-cache wget unzip \
|
|
||||||
&& wget https://github.com/pocketbase/pocketbase/releases/download/v${PB_VERSION}/pocketbase_${PB_VERSION}_linux_amd64.zip \
|
|
||||||
&& unzip pocketbase_${PB_VERSION}_linux_amd64.zip \
|
|
||||||
&& chmod +x /pb/pocketbase
|
|
||||||
|
|
||||||
# Stage 3: Final runtime image
|
|
||||||
FROM alpine:3.17
|
|
||||||
WORKDIR /app
|
|
||||||
|
|
||||||
# Copy PocketBase binary
|
|
||||||
COPY --from=pb-builder /pb/pocketbase /app/pocketbase
|
|
||||||
|
|
||||||
# Copy frontend build to PocketBase public directory
|
|
||||||
COPY --from=frontend-builder /app/dist /app/pb_public
|
|
||||||
|
|
||||||
# Copy backend files
|
|
||||||
COPY server/pb_migrations /app/pb_migrations
|
|
||||||
COPY server/pb_hooks /app/pb_hooks
|
|
||||||
COPY server/pb_data /app/pb_data
|
|
||||||
|
|
||||||
# Mark pb_data as a volume for runtime persistence
|
|
||||||
VOLUME /app/pb_data
|
|
||||||
|
|
||||||
# Expose default PocketBase port
|
|
||||||
EXPOSE 8090
|
|
||||||
|
|
||||||
# Launch PocketBase
|
|
||||||
#CMD ["/app/pocketbase", "serve", "--dir", "/app/pb_data"]
|
|
||||||
|
|
||||||
|
|
||||||
# Launch PocketBase and bind to 0.0.0.0 for external access
|
|
||||||
CMD ["/app/pocketbase", "serve", "--http=0.0.0.0:8090", "--dir", "/app/pb_data"]
|
|
||||||
+1
-1
@@ -1,5 +1,5 @@
|
|||||||
The MIT License (MIT)
|
The MIT License (MIT)
|
||||||
Copyright (c) 2022 - Present, Tola Leng
|
Copyright (c) 2025, Tola Leng
|
||||||
|
|
||||||
Permission is hereby granted, free of charge, to any person obtaining a copy of this software
|
Permission is hereby granted, free of charge, to any person obtaining a copy of this software
|
||||||
and associated documentation files (the "Software"), to deal in the Software without restriction,
|
and associated documentation files (the "Software"), to deal in the Software without restriction,
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
CheckCle is an Open Source solution for seamless, real-time monitoring of full-stack systems, applications, and infrastructure. It provides developers, sysadmins, and DevOps teams with deep insights and actionable data across every layer of their environment—whether it's servers, applications, or services. With CheckCle, you gain visibility, control, and the ability to ensure optimal performance throughout your entire technology stack.
|
CheckCle is an Open Source solution for seamless, real-time monitoring of full-stack systems, applications, and infrastructure. It provides developers, sysadmins, and DevOps teams with deep insights and actionable data across every layer of their environment—whether it's servers, applications, or services. With CheckCle, you gain visibility, control, and the ability to ensure optimal performance throughout your entire technology stack.
|
||||||
|
|
||||||
## 🎯 Live Demo
|
## 🎯 Live Demo
|
||||||
👉 **Try it now:** [CheckCle Live Demo](https://demo.checkcle.io) Coming Soon!
|
👉 **Try it now:** [CheckCle Live Demo](https://demo.checkcle.io)
|
||||||
|
|
||||||
## 🌟 Core Features
|
## 🌟 Core Features
|
||||||
|
|
||||||
@@ -24,22 +24,23 @@ CheckCle is an Open Source solution for seamless, real-time monitoring of full-s
|
|||||||
|
|
||||||
## #️⃣ Getting Started
|
## #️⃣ Getting Started
|
||||||
|
|
||||||
### Installation with Docker Compose
|
### Installation with Docker Run and Compose
|
||||||
1. Clone the repository and run
|
1. Copy ready docker run command
|
||||||
```bash
|
```bash
|
||||||
#Clone the repository
|
docker run -d \
|
||||||
|
--name checkcle \
|
||||||
git clone https://github.com/operacle/checkcle.git
|
--restart unless-stopped \
|
||||||
cd checkcle
|
-p 8090:8090 \
|
||||||
|
-v /opt/pb_data:/mnt/pb_data \
|
||||||
# Run docker compose
|
--ulimit nofile=4096:8192 \
|
||||||
|
operacle/checkcle:latest
|
||||||
docker compose up -d
|
|
||||||
|
|
||||||
```
|
```
|
||||||
2. Docker Compose - Recommended
|
2. Docker Compose - Recommended
|
||||||
```bash
|
```bash
|
||||||
|
|
||||||
|
version: '3.9'
|
||||||
|
|
||||||
services:
|
services:
|
||||||
checkcle:
|
checkcle:
|
||||||
image: operacle/checkcle:latest
|
image: operacle/checkcle:latest
|
||||||
@@ -48,15 +49,12 @@ services:
|
|||||||
ports:
|
ports:
|
||||||
- "8090:8090" # Web Application
|
- "8090:8090" # Web Application
|
||||||
volumes:
|
volumes:
|
||||||
- pb_data:/app/pb_data # Ensure persistent data across rebuilds
|
- /opt/pb_data:/mnt/pb_data # Host directory mapped to container path
|
||||||
ulimits:
|
ulimits:
|
||||||
nofile:
|
nofile:
|
||||||
soft: 4096
|
soft: 4096
|
||||||
hard: 8192
|
hard: 8192
|
||||||
|
|
||||||
volumes:
|
|
||||||
pb_data: # Docker-managed volume for data persistence
|
|
||||||
|
|
||||||
```
|
```
|
||||||
3. Admin Web Management
|
3. Admin Web Management
|
||||||
|
|
||||||
@@ -64,12 +62,12 @@ volumes:
|
|||||||
User: admin@example.com
|
User: admin@example.com
|
||||||
Passwd: Admin123456
|
Passwd: Admin123456
|
||||||
|
|
||||||
4. Follow the Quick Start Guide at https://docs.checkcle.com (Coming Soon)
|
4. Follow the Quick Start Guide at https://docs.checkcle.io
|
||||||
|
|
||||||
###
|
###
|
||||||

|
|
||||||

|

|
||||||

|

|
||||||
|

|
||||||
|
|
||||||
## 📝 Development Roadmap
|
## 📝 Development Roadmap
|
||||||
|
|
||||||
@@ -78,15 +76,18 @@ volumes:
|
|||||||
- [x] Auth with Multi-users system (admin)
|
- [x] Auth with Multi-users system (admin)
|
||||||
- [x] Notifications (Telegram)
|
- [x] Notifications (Telegram)
|
||||||
- [x] Docker containerization
|
- [x] Docker containerization
|
||||||
- [ ] SSL & Domain Monitoring
|
- [x] CheckCle Website
|
||||||
|
- [x] CheckCle Demo Server
|
||||||
|
- [x] SSL & Domain Monitoring
|
||||||
|
- [x] Schedule Maintenance
|
||||||
|
- [x] Incident Management
|
||||||
|
- [ ] Uptime monitoring (PING - Inprogress)
|
||||||
- [ ] Infrastructure Server Monitoring
|
- [ ] Infrastructure Server Monitoring
|
||||||
- [ ] Schedule Maintenance & Incident Management
|
|
||||||
- [ ] Operational Status / Public Status Pages
|
- [ ] Operational Status / Public Status Pages
|
||||||
- [ ] Uptime monitoring (TCP, PING, DNS)
|
- [ ] Uptime monitoring (TCP, PING, DNS)
|
||||||
- [ ] User Permission Roles & Service Group
|
- [ ] User Permission Roles & Service Group
|
||||||
- [ ] Notifications (Email/Slack/Discord/Signal)
|
- [ ] Notifications (Email/Slack/Discord/Signal)
|
||||||
- [ ] Mobile PWA version
|
- [x] Open-source release with full documentation
|
||||||
- [ ] Open-source release with full documentation
|
|
||||||
|
|
||||||
## 🌟 CheckCle for Communities?
|
## 🌟 CheckCle for Communities?
|
||||||
- **Built with Passion**: Created by an open-source enthusiast for the community
|
- **Built with Passion**: Created by an open-source enthusiast for the community
|
||||||
@@ -109,6 +110,7 @@ Here are some ways you can help improve CheckCle:
|
|||||||
|
|
||||||
## 🌍 Stay Connected
|
## 🌍 Stay Connected
|
||||||
- Website: [checkcle.io](https://checkcle.io)
|
- Website: [checkcle.io](https://checkcle.io)
|
||||||
|
- Documentation: [docs.checkcle.io](https://docs.checkcle.io)
|
||||||
- GitHub Repository: ⭐ [CheckCle](https://github.com/operacle/checkcle.git)
|
- GitHub Repository: ⭐ [CheckCle](https://github.com/operacle/checkcle.git)
|
||||||
- Community Channels: Engage via discussions and issues!
|
- Community Channels: Engage via discussions and issues!
|
||||||
- Discord: Join our community [@discord](https://discord.gg/xs9gbubGwX)
|
- Discord: Join our community [@discord](https://discord.gg/xs9gbubGwX)
|
||||||
|
|||||||
+81
@@ -0,0 +1,81 @@
|
|||||||
|
# 🔐 Security Policy
|
||||||
|
|
||||||
|
## 📦 Project: [checkcle](https://github.com/operacle/checkcle)
|
||||||
|
|
||||||
|
**checkcle** is a lightweight, TypeScript-based built for uptime and server monitoring tools.
|
||||||
|
|
||||||
|
We care about the security and privacy of users running this project in production environments.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📣 Reporting a Vulnerability
|
||||||
|
|
||||||
|
If you believe you have found a security vulnerability in this project:
|
||||||
|
|
||||||
|
- **DO NOT** open a public issue to report it.
|
||||||
|
- Please report it responsibly via one of the following methods:
|
||||||
|
|
||||||
|
### 🔐 Preferred: [Report a Vulnerability via GitHub](https://github.com/operacle/checkcle/security/advisories/new)
|
||||||
|
|
||||||
|
- Use the GitHub security advisory form (private and secure).
|
||||||
|
- Attach as much detail as possible:
|
||||||
|
- Description of the issue
|
||||||
|
- Affected version or commit hash
|
||||||
|
- Reproduction steps
|
||||||
|
- Impact and any potential mitigations
|
||||||
|
- Logs or screenshots (if available)
|
||||||
|
|
||||||
|
### 📧 Alternatively: Contact the Maintainer
|
||||||
|
- Email: `security@checkcle.io`
|
||||||
|
- Optionally include a PGP public key for encrypted messages
|
||||||
|
|
||||||
|
We aim to respond within **3–5 business days**.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ✅ Supported Versions
|
||||||
|
|
||||||
|
We support the latest stable release of `checkcle`. Security patches may also be applied to recent versions at our discretion.
|
||||||
|
|
||||||
|
| Version | Supported |
|
||||||
|
|---------|-----------|
|
||||||
|
| `main` (latest) | ✅ Yes |
|
||||||
|
| Older versions | ⚠️ Best-effort |
|
||||||
|
| Pre-release or forks | ❌ No |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔍 Security Practices
|
||||||
|
|
||||||
|
This project adheres to the following practices to enhance security:
|
||||||
|
|
||||||
|
- 🔎 Regular vulnerability checks using `npm audit`
|
||||||
|
- ⛓️ Dependency pinning via `package-lock.json`
|
||||||
|
- ✅ Type-safe code using `TypeScript`
|
||||||
|
- 🧪 Continuous testing and CI
|
||||||
|
- 🔐 No data is stored or transmitted unless explicitly configured by the user
|
||||||
|
- 🧑💻 All contributions are reviewed before being merged
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ⚠️ Known Security Limitations
|
||||||
|
|
||||||
|
- `checkcle` makes outbound HTTPS requests to check certificate details — avoid running in untrusted or high-risk environments without proper network policies.
|
||||||
|
- Output may contain certificate metadata (e.g., CN, SANs, expiry dates) — avoid exposing this to public logs unless sanitized.
|
||||||
|
- The data may be lost upon system restarts or crashes. Always ensure that backup and recovery mechanisms are in place in production environments.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📄 License
|
||||||
|
|
||||||
|
This project is released under the [MIT License](./LICENSE). Use at your own risk. The Creator and contributors are not liable for misuse, data loss, or operational impact resulting from use of the software.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🙌 Acknowledgements
|
||||||
|
|
||||||
|
We appreciate responsible disclosures from the community. Your efforts help us make the open-source ecosystem safer for everyone.
|
||||||
|
|
||||||
|
Thanks & Regards,
|
||||||
|
|
||||||
|
— [Tola Leng](https://github.com/tolaleng)
|
||||||
@@ -30,7 +30,7 @@
|
|||||||
"@radix-ui/react-radio-group": "^1.2.0",
|
"@radix-ui/react-radio-group": "^1.2.0",
|
||||||
"@radix-ui/react-scroll-area": "^1.1.0",
|
"@radix-ui/react-scroll-area": "^1.1.0",
|
||||||
"@radix-ui/react-select": "^2.1.1",
|
"@radix-ui/react-select": "^2.1.1",
|
||||||
"@radix-ui/react-separator": "^1.1.0",
|
"@radix-ui/react-separator": "^1.1.6",
|
||||||
"@radix-ui/react-slider": "^1.2.0",
|
"@radix-ui/react-slider": "^1.2.0",
|
||||||
"@radix-ui/react-slot": "^1.1.0",
|
"@radix-ui/react-slot": "^1.1.0",
|
||||||
"@radix-ui/react-switch": "^1.1.0",
|
"@radix-ui/react-switch": "^1.1.0",
|
||||||
@@ -46,6 +46,8 @@
|
|||||||
"date-fns": "^3.6.0",
|
"date-fns": "^3.6.0",
|
||||||
"embla-carousel-react": "^8.3.0",
|
"embla-carousel-react": "^8.3.0",
|
||||||
"input-otp": "^1.2.4",
|
"input-otp": "^1.2.4",
|
||||||
|
"jspdf": "^3.0.1",
|
||||||
|
"jspdf-autotable": "^5.0.2",
|
||||||
"lucide-react": "^0.462.0",
|
"lucide-react": "^0.462.0",
|
||||||
"next-themes": "^0.3.0",
|
"next-themes": "^0.3.0",
|
||||||
"pocketbase": "^0.19.0",
|
"pocketbase": "^0.19.0",
|
||||||
@@ -80,4 +82,4 @@
|
|||||||
"typescript-eslint": "^8.0.1",
|
"typescript-eslint": "^8.0.1",
|
||||||
"vite": "^5.4.1"
|
"vite": "^5.4.1"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+19
-1
@@ -15,6 +15,8 @@ import ServiceDetail from "./pages/ServiceDetail";
|
|||||||
import Settings from "./pages/Settings";
|
import Settings from "./pages/Settings";
|
||||||
import Profile from "./pages/Profile";
|
import Profile from "./pages/Profile";
|
||||||
import NotFound from "./pages/NotFound";
|
import NotFound from "./pages/NotFound";
|
||||||
|
import SslDomain from "./pages/SslDomain";
|
||||||
|
import ScheduleIncident from "./pages/ScheduleIncident";
|
||||||
|
|
||||||
// Create a Protected route component
|
// Create a Protected route component
|
||||||
const ProtectedRoute = ({ children }: { children: React.ReactNode }) => {
|
const ProtectedRoute = ({ children }: { children: React.ReactNode }) => {
|
||||||
@@ -93,6 +95,22 @@ const App = () => {
|
|||||||
</ProtectedRoute>
|
</ProtectedRoute>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
<Route
|
||||||
|
path="/ssl-domain"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute>
|
||||||
|
<SslDomain />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/schedule-incident"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute>
|
||||||
|
<ScheduleIncident />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
{/* ADD ALL CUSTOM ROUTES ABOVE THE CATCH-ALL "*" ROUTE */}
|
{/* ADD ALL CUSTOM ROUTES ABOVE THE CATCH-ALL "*" ROUTE */}
|
||||||
<Route path="*" element={<NotFound />} />
|
<Route path="*" element={<NotFound />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
@@ -104,4 +122,4 @@ const App = () => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default App;
|
export default App;
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
|
|
||||||
// API routes handler
|
// API routes handler
|
||||||
import realtime from './realtime';
|
import realtime from './realtime';
|
||||||
|
import settingsApi from './settings';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Simple API router for client-side application
|
* Simple API router for client-side application
|
||||||
@@ -16,6 +17,9 @@ const api = {
|
|||||||
if (path === '/api/realtime') {
|
if (path === '/api/realtime') {
|
||||||
console.log("Routing to realtime handler");
|
console.log("Routing to realtime handler");
|
||||||
return await realtime(body);
|
return await realtime(body);
|
||||||
|
} else if (path === '/api/settings') {
|
||||||
|
console.log("Routing to settings handler");
|
||||||
|
return await settingsApi(body);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Return 404 for unknown routes
|
// Return 404 for unknown routes
|
||||||
@@ -31,4 +35,52 @@ const api = {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export default api;
|
// Mock fetch override for development
|
||||||
|
const originalFetch = window.fetch;
|
||||||
|
window.fetch = async (url, options = {}) => {
|
||||||
|
// Check if this is an API request to our mock endpoints
|
||||||
|
if (typeof url === 'string' && url.startsWith('/api/')) {
|
||||||
|
console.log('Intercepting API request:', url, options);
|
||||||
|
|
||||||
|
try {
|
||||||
|
let body = {};
|
||||||
|
|
||||||
|
// Properly handle different body types
|
||||||
|
if (options.body) {
|
||||||
|
if (typeof options.body === 'string') {
|
||||||
|
body = JSON.parse(options.body);
|
||||||
|
} else {
|
||||||
|
// Handle ReadableStream or other BodyInit types
|
||||||
|
const bodyText = await new Response(options.body).text();
|
||||||
|
body = bodyText ? JSON.parse(bodyText) : {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await api.handleRequest(url, options.method || 'GET', body);
|
||||||
|
|
||||||
|
// Create a proper Response object
|
||||||
|
return new Response(JSON.stringify(result.json), {
|
||||||
|
status: result.status,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error in API handler:', error);
|
||||||
|
return new Response(JSON.stringify({
|
||||||
|
success: false,
|
||||||
|
message: 'Internal server error'
|
||||||
|
}), {
|
||||||
|
status: 500,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// For all other requests, use the original fetch
|
||||||
|
return originalFetch(url, options);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default api;
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
import { pb, getCurrentEndpoint } from '@/lib/pocketbase';
|
||||||
|
|
||||||
|
const settingsApi = async (body: any) => {
|
||||||
|
try {
|
||||||
|
const { action, data } = body;
|
||||||
|
console.log('Settings API called with action:', action, 'data:', data);
|
||||||
|
|
||||||
|
const authToken = pb.authStore.token;
|
||||||
|
const headers: Record<string, string> = {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
};
|
||||||
|
|
||||||
|
if (authToken) {
|
||||||
|
headers['Authorization'] = `Bearer ${authToken}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const baseUrl = getCurrentEndpoint();
|
||||||
|
|
||||||
|
switch (action) {
|
||||||
|
case 'getSettings':
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${baseUrl}/api/settings`, {
|
||||||
|
method: 'GET',
|
||||||
|
headers,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`);
|
||||||
|
|
||||||
|
const settings = await response.json();
|
||||||
|
return {
|
||||||
|
status: 200,
|
||||||
|
json: { success: true, data: settings },
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching settings:', error);
|
||||||
|
return {
|
||||||
|
status: 500,
|
||||||
|
json: { success: false, message: 'Failed to fetch settings' },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'updateSettings':
|
||||||
|
try {
|
||||||
|
let response = await fetch(`${baseUrl}/api/settings`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers,
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok && (response.status === 404 || response.status === 405)) {
|
||||||
|
response = await fetch(`${baseUrl}/api/settings`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers,
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`);
|
||||||
|
|
||||||
|
const updatedSettings = await response.json();
|
||||||
|
return {
|
||||||
|
status: 200,
|
||||||
|
json: { success: true, data: updatedSettings },
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error updating settings:', error);
|
||||||
|
return {
|
||||||
|
status: 500,
|
||||||
|
json: { success: false, message: 'Failed to update settings' },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'testEmailConnection':
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${baseUrl}/api/settings/test-email`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers,
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`);
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
return {
|
||||||
|
status: 200,
|
||||||
|
json: {
|
||||||
|
success: result.success || false,
|
||||||
|
message:
|
||||||
|
result.message || (result.success ? 'Connection successful' : 'Connection failed'),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error testing email connection:', error);
|
||||||
|
return {
|
||||||
|
status: 500,
|
||||||
|
json: { success: false, message: 'Failed to test email connection' },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
default:
|
||||||
|
return {
|
||||||
|
status: 400,
|
||||||
|
json: { success: false, message: 'Invalid action' },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Unexpected error in settingsApi:', error);
|
||||||
|
return {
|
||||||
|
status: 500,
|
||||||
|
json: { success: false, message: 'Internal server error' },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export default settingsApi;
|
||||||
@@ -9,6 +9,7 @@ import { useEffect, useState } from "react";
|
|||||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import { useSystemSettings } from "@/hooks/useSystemSettings";
|
import { useSystemSettings } from "@/hooks/useSystemSettings";
|
||||||
|
import QuickActionsDialog from "./QuickActionsDialog";
|
||||||
|
|
||||||
interface HeaderProps {
|
interface HeaderProps {
|
||||||
currentUser: AuthUser | null;
|
currentUser: AuthUser | null;
|
||||||
@@ -28,6 +29,7 @@ export const Header = ({
|
|||||||
const [greeting, setGreeting] = useState<string>("");
|
const [greeting, setGreeting] = useState<string>("");
|
||||||
const { systemName } = useSystemSettings();
|
const { systemName } = useSystemSettings();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const [quickActionsOpen, setQuickActionsOpen] = useState(false);
|
||||||
|
|
||||||
// Set greeting based on time of day
|
// Set greeting based on time of day
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -88,8 +90,18 @@ export const Header = ({
|
|||||||
<Button variant="ghost" size="icon" onClick={toggleSidebar} className="mr-2">
|
<Button variant="ghost" size="icon" onClick={toggleSidebar} className="mr-2">
|
||||||
{sidebarCollapsed ? <PanelLeft className="h-5 w-5" /> : <PanelLeftClose className="h-5 w-5" />}
|
{sidebarCollapsed ? <PanelLeft className="h-5 w-5" /> : <PanelLeftClose className="h-5 w-5" />}
|
||||||
</Button>
|
</Button>
|
||||||
<div className="flex items-center space-x-2">
|
|
||||||
|
{/* Quick Actions Button */}
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => setQuickActionsOpen(true)}
|
||||||
|
className="mr-2"
|
||||||
|
>
|
||||||
<Grid3x3 className="h-5 w-5 text-green-500" />
|
<Grid3x3 className="h-5 w-5 text-green-500" />
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
<h1 className="text-lg font-medium">{greeting}, {currentUser?.name || currentUser?.email?.split('@')[0] || 'User'} 👋 ✨</h1>
|
<h1 className="text-lg font-medium">{greeting}, {currentUser?.name || currentUser?.email?.split('@')[0] || 'User'} 👋 ✨</h1>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -114,29 +126,52 @@ export const Header = ({
|
|||||||
<DropdownMenuItem onClick={() => setLanguage("km")} className={language === "km" ? "bg-accent" : ""}>
|
<DropdownMenuItem onClick={() => setLanguage("km")} className={language === "km" ? "bg-accent" : ""}>
|
||||||
{t("khmer")}
|
{t("khmer")}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem onClick={() => setLanguage("de")} className={language === "de" ? "bg-accent" : ""}>
|
||||||
|
{t("Deutsch")}
|
||||||
|
</DropdownMenuItem>
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
|
|
||||||
{/* Documentation */}
|
{/* Documentation */}
|
||||||
<Button variant="outline" size="icon" className="rounded-full w-8 h-8 border-border">
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="icon"
|
||||||
|
className="rounded-full w-8 h-8 border-border"
|
||||||
|
onClick={() => window.open("https://docs.checkcle.io", "_blank")}
|
||||||
|
>
|
||||||
<span className="sr-only">{t("documentation")}</span>
|
<span className="sr-only">{t("documentation")}</span>
|
||||||
<FileText className="w-4 h-4" />
|
<FileText className="w-4 h-4" />
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
{/* GitHub */}
|
{/* GitHub */}
|
||||||
<Button variant="outline" size="icon" className="rounded-full w-8 h-8 border-border">
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="icon"
|
||||||
|
className="rounded-full w-8 h-8 border-border"
|
||||||
|
onClick={() => window.open("https://github.com/operacle/checkcle", "_blank")}
|
||||||
|
>
|
||||||
<span className="sr-only">GitHub</span>
|
<span className="sr-only">GitHub</span>
|
||||||
<Github className="w-4 h-4" />
|
<Github className="w-4 h-4" />
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
{/* X (Twitter) */}
|
{/* X (Twitter) */}
|
||||||
<Button variant="outline" size="icon" className="rounded-full w-8 h-8 border-border">
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="icon"
|
||||||
|
className="rounded-full w-8 h-8 border-border"
|
||||||
|
onClick={() => window.open("https://x.com/tlengoss", "_blank")}
|
||||||
|
>
|
||||||
<span className="sr-only">X (Twitter)</span>
|
<span className="sr-only">X (Twitter)</span>
|
||||||
<Twitter className="w-4 h-4" />
|
<Twitter className="w-4 h-4" />
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
{/* Discord (replaced with MessageSquare) */}
|
{/* Discord */}
|
||||||
<Button variant="outline" size="icon" className="rounded-full w-8 h-8 border-border">
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="icon"
|
||||||
|
className="rounded-full w-8 h-8 border-border"
|
||||||
|
onClick={() => window.open("https://discord.gg/xs9gbubGwX", "_blank")}
|
||||||
|
>
|
||||||
<span className="sr-only">Discord</span>
|
<span className="sr-only">Discord</span>
|
||||||
<MessageSquare className="w-4 h-4" />
|
<MessageSquare className="w-4 h-4" />
|
||||||
</Button>
|
</Button>
|
||||||
@@ -185,6 +220,9 @@ export const Header = ({
|
|||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Quick Actions Dialog */}
|
||||||
|
<QuickActionsDialog isOpen={quickActionsOpen} setIsOpen={setQuickActionsOpen} />
|
||||||
</header>
|
</header>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -0,0 +1,145 @@
|
|||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogClose
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { X } from "lucide-react";
|
||||||
|
import { useLanguage } from "@/contexts/LanguageContext";
|
||||||
|
|
||||||
|
interface QuickActionsDialogProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
setIsOpen: (open: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const QuickActionsDialog: React.FC<QuickActionsDialogProps> = ({ isOpen, setIsOpen }) => {
|
||||||
|
const { t } = useLanguage();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
||||||
|
<DialogContent className="sm:max-w-[800px] bg-background max-h-[90vh] overflow-y-auto">
|
||||||
|
<DialogClose className="absolute right-4 top-4">
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
<span className="sr-only">Close</span>
|
||||||
|
</DialogClose>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="text-xl font-bold">{t('quickActions')}</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
{t('quickActionsDescription')}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mt-4">
|
||||||
|
{/* Website Monitoring Card */}
|
||||||
|
<div className="border rounded-lg p-4 bg-card hover:shadow-md transition-shadow">
|
||||||
|
<h3 className="font-semibold text-lg mb-2">{t('monitorWebsite')}</h3>
|
||||||
|
<p className="text-sm text-muted-foreground mb-4">
|
||||||
|
{t('monitorWebsiteDesc')}
|
||||||
|
</p>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<p className="text-sm font-medium">{t('quickTips')}:</p>
|
||||||
|
<ul className="list-disc pl-5 text-sm space-y-1">
|
||||||
|
<li>{t('monitorMultipleEndpoints')}</li>
|
||||||
|
<li>{t('trackResponseTimes')}</li>
|
||||||
|
<li>{t('setCustomAlerts')}</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Server Monitoring Card */}
|
||||||
|
<div className="border rounded-lg p-4 bg-card hover:shadow-md transition-shadow">
|
||||||
|
<h3 className="font-semibold text-lg mb-2">{t('monitorServer')}</h3>
|
||||||
|
<p className="text-sm text-muted-foreground mb-4">
|
||||||
|
{t('monitorServerDesc')}
|
||||||
|
</p>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<p className="text-sm font-medium">{t('quickTips')}:</p>
|
||||||
|
<ul className="list-disc pl-5 text-sm space-y-1">
|
||||||
|
<li>{t('monitorCPURam')}</li>
|
||||||
|
<li>{t('trackNetworkMetrics')}</li>
|
||||||
|
<li>{t('viewProcesses')}</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* SSL Certificate Card */}
|
||||||
|
<div className="border rounded-lg p-4 bg-card hover:shadow-md transition-shadow">
|
||||||
|
<h3 className="font-semibold text-lg mb-2">{t('sslCertificate')}</h3>
|
||||||
|
<p className="text-sm text-muted-foreground mb-4">
|
||||||
|
{t('sslCertificateDesc')}
|
||||||
|
</p>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<p className="text-sm font-medium">{t('quickTips')}:</p>
|
||||||
|
<ul className="list-disc pl-5 text-sm space-y-1">
|
||||||
|
<li>{t('trackSSLExpiration')}</li>
|
||||||
|
<li>{t('getEarlyRenewal')}</li>
|
||||||
|
<li>{t('monitorMultipleDomains')}</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Incidents Management Card */}
|
||||||
|
<div className="border rounded-lg p-4 bg-card hover:shadow-md transition-shadow">
|
||||||
|
<h3 className="font-semibold text-lg mb-2">{t('incidentsManagement')}</h3>
|
||||||
|
<p className="text-sm text-muted-foreground mb-4">
|
||||||
|
{t('incidentsManagementDesc')}
|
||||||
|
</p>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<p className="text-sm font-medium">{t('quickTips')}:</p>
|
||||||
|
<ul className="list-disc pl-5 text-sm space-y-1">
|
||||||
|
<li>{t('createTrackIncidents')}</li>
|
||||||
|
<li>{t('assignIncidents')}</li>
|
||||||
|
<li>{t('monitorResolution')}</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Operational Status Card */}
|
||||||
|
<div className="border rounded-lg p-4 bg-card hover:shadow-md transition-shadow">
|
||||||
|
<h3 className="font-semibold text-lg mb-2">{t('operationalStatus')}</h3>
|
||||||
|
<p className="text-sm text-muted-foreground mb-4">
|
||||||
|
{t('operationalStatusDesc')}
|
||||||
|
</p>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<p className="text-sm font-medium">{t('quickTips')}:</p>
|
||||||
|
<ul className="list-disc pl-5 text-sm space-y-1">
|
||||||
|
<li>{t('createCustomStatus')}</li>
|
||||||
|
<li>{t('displayRealTime')}</li>
|
||||||
|
<li>{t('shareIncidentUpdates')}</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Reports & Analytics Card */}
|
||||||
|
<div className="border rounded-lg p-4 bg-card hover:shadow-md transition-shadow">
|
||||||
|
<h3 className="font-semibold text-lg mb-2">{t('reportsAnalytics')}</h3>
|
||||||
|
<p className="text-sm text-muted-foreground mb-4">
|
||||||
|
{t('reportsAnalyticsDesc')}
|
||||||
|
</p>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<p className="text-sm font-medium">{t('quickTips')}:</p>
|
||||||
|
<ul className="list-disc pl-5 text-sm space-y-1">
|
||||||
|
<li>{t('generateUptimeReports')}</li>
|
||||||
|
<li>{t('analyzePerformance')}</li>
|
||||||
|
<li>{t('exportMonitoringData')}</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-end mt-6">
|
||||||
|
<Button variant="outline" onClick={() => setIsOpen(false)}>
|
||||||
|
{t('close')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default QuickActionsDialog;
|
||||||
@@ -63,14 +63,14 @@ export const Sidebar = ({
|
|||||||
<Boxes className={`${mainIconSize} text-blue-400`} />
|
<Boxes className={`${mainIconSize} text-blue-400`} />
|
||||||
{!collapsed && <span className="ml-2.5 font-medium text-foreground tracking-wide text-[15px]">{t("instanceMonitoring")}</span>}
|
{!collapsed && <span className="ml-2.5 font-medium text-foreground tracking-wide text-[15px]">{t("instanceMonitoring")}</span>}
|
||||||
</div>
|
</div>
|
||||||
<div className={`${collapsed ? 'p-3' : 'p-2 pl-3'} mb-1 rounded-lg hover:${theme === 'dark' ? 'bg-gray-800' : 'bg-sidebar-accent'} flex items-center ${collapsed ? 'justify-center' : ''} transition-colors duration-200`}>
|
<Link to="/ssl-domain" className={`${collapsed ? 'p-3' : 'p-2 pl-3'} mb-1 rounded-lg ${location.pathname === '/ssl-domain' ? theme === 'dark' ? 'bg-gray-800' : 'bg-sidebar-accent' : `hover:${theme === 'dark' ? 'bg-gray-800' : 'bg-sidebar-accent'}`} flex items-center ${collapsed ? 'justify-center' : ''} transition-colors duration-200`}>
|
||||||
<Radar className={`${mainIconSize} text-cyan-400`} />
|
<Radar className={`${mainIconSize} text-cyan-400`} />
|
||||||
{!collapsed && <span className="ml-2.5 font-medium text-foreground tracking-wide text-[15px]">{t("sslDomain")}</span>}
|
{!collapsed && <span className="ml-2.5 font-medium text-foreground tracking-wide text-[15px]">{t("sslDomain")}</span>}
|
||||||
</div>
|
</Link>
|
||||||
<div className={`${collapsed ? 'p-3' : 'p-2 pl-3'} mb-1 rounded-lg hover:${theme === 'dark' ? 'bg-gray-800' : 'bg-sidebar-accent'} flex items-center ${collapsed ? 'justify-center' : ''} transition-colors duration-200`}>
|
<Link to="/schedule-incident" className={`${collapsed ? 'p-3' : 'p-2 pl-3'} mb-1 rounded-lg ${location.pathname === '/schedule-incident' ? theme === 'dark' ? 'bg-gray-800' : 'bg-sidebar-accent' : `hover:${theme === 'dark' ? 'bg-gray-800' : 'bg-sidebar-accent'}`} flex items-center ${collapsed ? 'justify-center' : ''} transition-colors duration-200`}>
|
||||||
<Calendar className={`${mainIconSize} text-emerald-400`} />
|
<Calendar className={`${mainIconSize} text-emerald-400`} />
|
||||||
{!collapsed && <span className="ml-2.5 font-medium text-foreground tracking-wide text-[15px]">{t("scheduleIncident")}</span>}
|
{!collapsed && <span className="ml-2.5 font-medium text-foreground tracking-wide text-[15px]">{t("scheduleIncident")}</span>}
|
||||||
</div>
|
</Link>
|
||||||
<div className={`${collapsed ? 'p-3' : 'p-2 pl-3'} mb-1 rounded-lg hover:${theme === 'dark' ? 'bg-gray-800' : 'bg-sidebar-accent'} flex items-center ${collapsed ? 'justify-center' : ''} transition-colors duration-200`}>
|
<div className={`${collapsed ? 'p-3' : 'p-2 pl-3'} mb-1 rounded-lg hover:${theme === 'dark' ? 'bg-gray-800' : 'bg-sidebar-accent'} flex items-center ${collapsed ? 'justify-center' : ''} transition-colors duration-200`}>
|
||||||
<BarChart2 className={`${mainIconSize} text-amber-400`} />
|
<BarChart2 className={`${mainIconSize} text-amber-400`} />
|
||||||
{!collapsed && <span className="ml-2.5 font-medium text-foreground tracking-wide text-[15px]">{t("operationalPage")}</span>}
|
{!collapsed && <span className="ml-2.5 font-medium text-foreground tracking-wide text-[15px]">{t("operationalPage")}</span>}
|
||||||
@@ -138,4 +138,4 @@ export const Sidebar = ({
|
|||||||
</Link>
|
</Link>
|
||||||
</div>}
|
</div>}
|
||||||
</div>;
|
</div>;
|
||||||
};
|
};
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
|
|
||||||
import { useState } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { User } from "@/services/userService";
|
import { User } from "@/services/userService";
|
||||||
import { UserProfileDetails } from "./UserProfileDetails";
|
import { UserProfileDetails } from "./UserProfileDetails";
|
||||||
@@ -9,11 +9,19 @@ import { UpdateProfileForm } from "./UpdateProfileForm";
|
|||||||
|
|
||||||
interface ProfileContentProps {
|
interface ProfileContentProps {
|
||||||
currentUser: User | null;
|
currentUser: User | null;
|
||||||
|
onUserUpdated?: () => Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ProfileContent({ currentUser }: ProfileContentProps) {
|
export function ProfileContent({ currentUser, onUserUpdated }: ProfileContentProps) {
|
||||||
const [activeTab, setActiveTab] = useState("details");
|
const [activeTab, setActiveTab] = useState("details");
|
||||||
|
|
||||||
|
// When active tab changes, refresh user data if needed
|
||||||
|
useEffect(() => {
|
||||||
|
if (activeTab === "details" && onUserUpdated) {
|
||||||
|
onUserUpdated();
|
||||||
|
}
|
||||||
|
}, [activeTab, onUserUpdated]);
|
||||||
|
|
||||||
if (!currentUser) {
|
if (!currentUser) {
|
||||||
return (
|
return (
|
||||||
<Card>
|
<Card>
|
||||||
@@ -71,4 +79,4 @@ export function ProfileContent({ currentUser }: ProfileContentProps) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -9,6 +9,8 @@ import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "
|
|||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { useToast } from "@/hooks/use-toast";
|
import { useToast } from "@/hooks/use-toast";
|
||||||
import { authService } from "@/services/authService";
|
import { authService } from "@/services/authService";
|
||||||
|
import { AlertCircle, CheckCircle } from "lucide-react";
|
||||||
|
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||||
|
|
||||||
// Profile update form schema
|
// Profile update form schema
|
||||||
const profileFormSchema = z.object({
|
const profileFormSchema = z.object({
|
||||||
@@ -31,6 +33,9 @@ interface UpdateProfileFormProps {
|
|||||||
|
|
||||||
export function UpdateProfileForm({ user }: UpdateProfileFormProps) {
|
export function UpdateProfileForm({ user }: UpdateProfileFormProps) {
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
const [emailChangeRequested, setEmailChangeRequested] = useState(false);
|
||||||
|
const [updateError, setUpdateError] = useState<string | null>(null);
|
||||||
|
const [updateSuccess, setUpdateSuccess] = useState<string | null>(null);
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
|
||||||
// Initialize the form with current user data
|
// Initialize the form with current user data
|
||||||
@@ -45,21 +50,50 @@ export function UpdateProfileForm({ user }: UpdateProfileFormProps) {
|
|||||||
|
|
||||||
async function onSubmit(data: ProfileFormValues) {
|
async function onSubmit(data: ProfileFormValues) {
|
||||||
setIsSubmitting(true);
|
setIsSubmitting(true);
|
||||||
|
setUpdateError(null);
|
||||||
|
setUpdateSuccess(null);
|
||||||
|
setEmailChangeRequested(false);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await userService.updateUser(user.id, {
|
console.log("Submitting profile update with data:", data);
|
||||||
|
|
||||||
|
// Detect if email is being changed
|
||||||
|
const isEmailChanged = data.email !== user.email;
|
||||||
|
|
||||||
|
// Create update payload with all fields
|
||||||
|
const updateData = {
|
||||||
full_name: data.full_name,
|
full_name: data.full_name,
|
||||||
username: data.username,
|
username: data.username,
|
||||||
email: data.email,
|
// Only include email if it's changed
|
||||||
});
|
email: isEmailChanged ? data.email : undefined,
|
||||||
|
// Always set emailVisibility to true if email is changing
|
||||||
|
emailVisibility: isEmailChanged ? true : undefined
|
||||||
|
};
|
||||||
|
|
||||||
|
console.log("Sending update payload:", updateData);
|
||||||
|
|
||||||
|
// Update user data using the userService
|
||||||
|
await userService.updateUser(user.id, updateData);
|
||||||
|
|
||||||
// Refresh user data in auth context
|
// Refresh user data in auth context
|
||||||
await authService.refreshUserData();
|
await authService.refreshUserData();
|
||||||
|
|
||||||
toast({
|
// If email was changed, show a specific message
|
||||||
title: "Profile updated",
|
if (isEmailChanged) {
|
||||||
description: "Your profile information has been updated successfully.",
|
setEmailChangeRequested(true);
|
||||||
});
|
setUpdateSuccess("A verification email has been sent to your new email address. Please check your inbox to complete the change.");
|
||||||
|
toast({
|
||||||
|
title: "Email verification sent",
|
||||||
|
description: "A verification email has been sent to your new email address. Please check your inbox and follow the instructions to complete the change.",
|
||||||
|
variant: "default",
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
setUpdateSuccess("Your profile information has been updated successfully.");
|
||||||
|
toast({
|
||||||
|
title: "Profile updated",
|
||||||
|
description: "Your profile information has been updated successfully.",
|
||||||
|
});
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Profile update error:", error);
|
console.error("Profile update error:", error);
|
||||||
|
|
||||||
@@ -68,6 +102,8 @@ export function UpdateProfileForm({ user }: UpdateProfileFormProps) {
|
|||||||
errorMessage = error.message;
|
errorMessage = error.message;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setUpdateError(errorMessage);
|
||||||
|
|
||||||
toast({
|
toast({
|
||||||
title: "Update failed",
|
title: "Update failed",
|
||||||
description: errorMessage,
|
description: errorMessage,
|
||||||
@@ -81,6 +117,32 @@ export function UpdateProfileForm({ user }: UpdateProfileFormProps) {
|
|||||||
return (
|
return (
|
||||||
<Form {...form}>
|
<Form {...form}>
|
||||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
|
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
|
||||||
|
{updateError && (
|
||||||
|
<Alert variant="destructive">
|
||||||
|
<AlertCircle className="h-4 w-4" />
|
||||||
|
<AlertDescription>{updateError}</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{updateSuccess && (
|
||||||
|
<Alert className="bg-green-50 border-green-200 text-green-800">
|
||||||
|
<CheckCircle className="h-4 w-4 text-green-600" />
|
||||||
|
<AlertDescription>
|
||||||
|
{updateSuccess}
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{emailChangeRequested && (
|
||||||
|
<Alert className="bg-yellow-50 border-yellow-200 text-yellow-800">
|
||||||
|
<AlertCircle className="h-4 w-4 text-yellow-600" />
|
||||||
|
<AlertDescription>
|
||||||
|
A verification email has been sent to your new email address.
|
||||||
|
Please check your inbox and follow the instructions to complete the change.
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="full_name"
|
name="full_name"
|
||||||
@@ -119,6 +181,11 @@ export function UpdateProfileForm({ user }: UpdateProfileFormProps) {
|
|||||||
<Input type="email" placeholder="your.email@example.com" {...field} />
|
<Input type="email" placeholder="your.email@example.com" {...field} />
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
|
{field.value !== user.email && (
|
||||||
|
<p className="text-xs text-muted-foreground mt-1">
|
||||||
|
Changing your email requires verification. A verification email will be sent.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
@@ -129,4 +196,4 @@ export function UpdateProfileForm({ user }: UpdateProfileFormProps) {
|
|||||||
</form>
|
</form>
|
||||||
</Form>
|
</Form>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -23,6 +23,9 @@ export function UserProfileDetails({ user }: UserProfileDetailsProps) {
|
|||||||
return user.username[0].toUpperCase();
|
return user.username[0].toUpperCase();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Determine if the user is active based on the status field
|
||||||
|
const isActive = user.status === "Active";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col items-center space-y-4">
|
<div className="flex flex-col items-center space-y-4">
|
||||||
<Avatar className="h-32 w-32">
|
<Avatar className="h-32 w-32">
|
||||||
@@ -54,8 +57,8 @@ export function UserProfileDetails({ user }: UserProfileDetailsProps) {
|
|||||||
{user.role}
|
{user.role}
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
<Badge variant={user.isActive ? "default" : "outline"} className="px-2 py-1">
|
<Badge variant={isActive ? "default" : "outline"} className="px-2 py-1">
|
||||||
{user.isActive ? "Active" : "Inactive"}
|
{user.status || (isActive ? "Active" : "Inactive")}
|
||||||
</Badge>
|
</Badge>
|
||||||
{user.verified && (
|
{user.verified && (
|
||||||
<Badge className="bg-green-600 hover:bg-green-700 px-2 py-1">
|
<Badge className="bg-green-600 hover:bg-green-700 px-2 py-1">
|
||||||
@@ -72,4 +75,4 @@ export function UserProfileDetails({ user }: UserProfileDetailsProps) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import { IncidentManagementContainer } from './incident-management';
|
||||||
|
|
||||||
|
interface IncidentManagementTabProps {
|
||||||
|
refreshTrigger?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const IncidentManagementTab = React.memo(({ refreshTrigger = 0 }: IncidentManagementTabProps) => {
|
||||||
|
return <IncidentManagementContainer refreshTrigger={refreshTrigger} />;
|
||||||
|
});
|
||||||
|
|
||||||
|
IncidentManagementTab.displayName = 'IncidentManagementTab';
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
|
||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Plus, CalendarClock, AlertCircle } from "lucide-react";
|
||||||
|
import { useLanguage } from "@/contexts/LanguageContext";
|
||||||
|
import { ScheduledMaintenanceTab } from "./ScheduledMaintenanceTab";
|
||||||
|
import { IncidentManagementTab } from "./IncidentManagementTab";
|
||||||
|
import { CreateMaintenanceDialog } from './maintenance/CreateMaintenanceDialog';
|
||||||
|
import { CreateIncidentDialog } from './incident/CreateIncidentDialog';
|
||||||
|
import { useToast } from '@/hooks/use-toast';
|
||||||
|
import { initMaintenanceNotifications, stopMaintenanceNotifications } from '@/services/maintenance/maintenanceNotificationService';
|
||||||
|
|
||||||
|
export const ScheduleIncidentContent = () => {
|
||||||
|
const { t } = useLanguage();
|
||||||
|
const { toast } = useToast();
|
||||||
|
const [activeTab, setActiveTab] = useState("maintenance");
|
||||||
|
const [createMaintenanceDialogOpen, setCreateMaintenanceDialogOpen] = useState(false);
|
||||||
|
const [createIncidentDialogOpen, setCreateIncidentDialogOpen] = useState(false);
|
||||||
|
const [refreshTrigger, setRefreshTrigger] = useState(0);
|
||||||
|
const [incidentRefreshTrigger, setIncidentRefreshTrigger] = useState(0);
|
||||||
|
|
||||||
|
// Initialize maintenance notifications when the component mounts
|
||||||
|
useEffect(() => {
|
||||||
|
console.log("Initializing maintenance notifications");
|
||||||
|
initMaintenanceNotifications();
|
||||||
|
|
||||||
|
// Clean up when the component unmounts
|
||||||
|
return () => {
|
||||||
|
console.log("Cleaning up maintenance notifications");
|
||||||
|
stopMaintenanceNotifications();
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleCreateButtonClick = () => {
|
||||||
|
if (activeTab === "maintenance") {
|
||||||
|
setCreateMaintenanceDialogOpen(true);
|
||||||
|
} else {
|
||||||
|
setCreateIncidentDialogOpen(true);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMaintenanceCreated = () => {
|
||||||
|
// Refresh data by incrementing the refresh trigger
|
||||||
|
const newTriggerValue = refreshTrigger + 1;
|
||||||
|
console.log("Maintenance created, refreshing data with new trigger value:", newTriggerValue);
|
||||||
|
setRefreshTrigger(newTriggerValue);
|
||||||
|
|
||||||
|
// Show success toast
|
||||||
|
toast({
|
||||||
|
title: t('success'),
|
||||||
|
description: t('maintenanceCreatedSuccess'),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleIncidentCreated = () => {
|
||||||
|
// Refresh data by incrementing the refresh trigger
|
||||||
|
const newTriggerValue = incidentRefreshTrigger + 1;
|
||||||
|
console.log("Incident created, refreshing data with new trigger value:", newTriggerValue);
|
||||||
|
setIncidentRefreshTrigger(newTriggerValue);
|
||||||
|
|
||||||
|
// Show success toast
|
||||||
|
toast({
|
||||||
|
title: t('success'),
|
||||||
|
description: t('incidentCreatedSuccess'),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="flex-1 flex flex-col overflow-auto bg-background p-6">
|
||||||
|
<div className="flex flex-col gap-6">
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<h2 className="text-2xl font-bold text-foreground">
|
||||||
|
{t('scheduleIncidentManagement')}
|
||||||
|
</h2>
|
||||||
|
<Button
|
||||||
|
className="text-primary-foreground"
|
||||||
|
onClick={handleCreateButtonClick}
|
||||||
|
>
|
||||||
|
<Plus className="w-4 h-4 mr-2" />
|
||||||
|
{activeTab === "maintenance" ? t('createMaintenanceWindow') : t('createIncident')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Tabs
|
||||||
|
defaultValue="maintenance"
|
||||||
|
className="w-full"
|
||||||
|
onValueChange={(value) => setActiveTab(value)}
|
||||||
|
>
|
||||||
|
<TabsList className="grid w-full grid-cols-2 mb-4">
|
||||||
|
<TabsTrigger value="maintenance">
|
||||||
|
<CalendarClock className="w-4 h-4 mr-2" />
|
||||||
|
{t('scheduledMaintenance')}
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="incidents">
|
||||||
|
<AlertCircle className="w-4 h-4 mr-2" />
|
||||||
|
{t('incidentManagement')}
|
||||||
|
</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
|
||||||
|
<TabsContent value="maintenance" className="space-y-4">
|
||||||
|
<ScheduledMaintenanceTab refreshTrigger={refreshTrigger} />
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="incidents" className="space-y-4">
|
||||||
|
<IncidentManagementTab refreshTrigger={incidentRefreshTrigger} />
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Maintenance creation dialog */}
|
||||||
|
<CreateMaintenanceDialog
|
||||||
|
open={createMaintenanceDialogOpen}
|
||||||
|
onOpenChange={setCreateMaintenanceDialogOpen}
|
||||||
|
onMaintenanceCreated={handleMaintenanceCreated}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Incident creation dialog */}
|
||||||
|
<CreateIncidentDialog
|
||||||
|
open={createIncidentDialogOpen}
|
||||||
|
onOpenChange={setCreateIncidentDialogOpen}
|
||||||
|
onIncidentCreated={handleIncidentCreated}
|
||||||
|
/>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,160 @@
|
|||||||
|
|
||||||
|
import React, { useEffect } from 'react';
|
||||||
|
import { useLanguage } from '@/contexts/LanguageContext';
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs';
|
||||||
|
import { MaintenanceTable, MaintenanceStatusChecker } from './maintenance';
|
||||||
|
import { EmptyMaintenanceState } from './maintenance/EmptyMaintenanceState';
|
||||||
|
import { OverviewCard } from './common/OverviewCard';
|
||||||
|
import { Calendar, Clock, CheckCircle } from 'lucide-react';
|
||||||
|
import { useMaintenanceData } from './hooks/useMaintenanceData';
|
||||||
|
import { Skeleton } from '@/components/ui/skeleton';
|
||||||
|
import { useToast } from '@/hooks/use-toast';
|
||||||
|
import { LoadingState } from '@/components/services/LoadingState';
|
||||||
|
|
||||||
|
interface ScheduledMaintenanceTabProps {
|
||||||
|
refreshTrigger?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ScheduledMaintenanceTab = ({ refreshTrigger = 0 }: ScheduledMaintenanceTabProps) => {
|
||||||
|
const { t } = useLanguage();
|
||||||
|
const { toast } = useToast();
|
||||||
|
const [manualRefresh, setManualRefresh] = React.useState(0);
|
||||||
|
|
||||||
|
// Combine the external refresh trigger with our internal one
|
||||||
|
const combinedRefreshTrigger = refreshTrigger + manualRefresh;
|
||||||
|
|
||||||
|
const {
|
||||||
|
loading,
|
||||||
|
filter,
|
||||||
|
setFilter,
|
||||||
|
maintenanceData,
|
||||||
|
overviewStats,
|
||||||
|
fetchMaintenanceData,
|
||||||
|
isEmpty,
|
||||||
|
error,
|
||||||
|
initialized
|
||||||
|
} = useMaintenanceData({ refreshTrigger: combinedRefreshTrigger });
|
||||||
|
|
||||||
|
// Display toast when error occurs
|
||||||
|
useEffect(() => {
|
||||||
|
if (error) {
|
||||||
|
toast({
|
||||||
|
title: t('error'),
|
||||||
|
description: error,
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [error, toast, t]);
|
||||||
|
|
||||||
|
// Force fetch data on initial load
|
||||||
|
useEffect(() => {
|
||||||
|
console.log("ScheduledMaintenanceTab is mounted, fetching data");
|
||||||
|
fetchMaintenanceData(true);
|
||||||
|
}, [fetchMaintenanceData]);
|
||||||
|
|
||||||
|
// Handle maintenance updates
|
||||||
|
const handleMaintenanceUpdated = () => {
|
||||||
|
console.log("Maintenance updated, refreshing data");
|
||||||
|
setManualRefresh(prev => prev + 1);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle tab changes
|
||||||
|
const handleTabChange = (value: string) => {
|
||||||
|
setFilter(value);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Show full-page loading state during initial load
|
||||||
|
if (loading && !initialized) {
|
||||||
|
return <LoadingState />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* Status checker for automatic updates */}
|
||||||
|
<MaintenanceStatusChecker
|
||||||
|
maintenanceData={maintenanceData}
|
||||||
|
onStatusUpdated={handleMaintenanceUpdated}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Overview Cards */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
|
||||||
|
<OverviewCard
|
||||||
|
title={t('upcomingMaintenance')}
|
||||||
|
value={loading ? '...' : overviewStats.upcoming}
|
||||||
|
icon={<Calendar className="h-5 w-5 text-white" />}
|
||||||
|
isLoading={loading}
|
||||||
|
color="blue"
|
||||||
|
/>
|
||||||
|
<OverviewCard
|
||||||
|
title={t('ongoingMaintenance')}
|
||||||
|
value={loading ? '...' : overviewStats.ongoing}
|
||||||
|
icon={<Clock className="h-5 w-5 text-white" />}
|
||||||
|
isLoading={loading}
|
||||||
|
color="amber"
|
||||||
|
/>
|
||||||
|
<OverviewCard
|
||||||
|
title={t('completedMaintenance')}
|
||||||
|
value={loading ? '...' : overviewStats.completed}
|
||||||
|
icon={<CheckCircle className="h-5 w-5 text-white" />}
|
||||||
|
isLoading={loading}
|
||||||
|
color="green"
|
||||||
|
/>
|
||||||
|
<OverviewCard
|
||||||
|
title={t('totalScheduledHours')}
|
||||||
|
value={loading ? '...' : `${overviewStats.totalDuration}h`}
|
||||||
|
icon={<Clock className="h-5 w-5 text-white" />}
|
||||||
|
isLoading={loading}
|
||||||
|
color="purple"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>{t('scheduledMaintenance')}</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
{t('scheduledMaintenanceDesc')}
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<Tabs
|
||||||
|
defaultValue="upcoming"
|
||||||
|
value={filter}
|
||||||
|
className="w-full"
|
||||||
|
onValueChange={handleTabChange}
|
||||||
|
>
|
||||||
|
<TabsList className="mb-6">
|
||||||
|
<TabsTrigger value="upcoming">{t('upcomingMaintenance')}</TabsTrigger>
|
||||||
|
<TabsTrigger value="ongoing">{t('ongoingMaintenance')}</TabsTrigger>
|
||||||
|
<TabsTrigger value="completed">{t('completedMaintenance')}</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
|
||||||
|
<TabsContent value="upcoming" className="space-y-4">
|
||||||
|
<MaintenanceTable
|
||||||
|
data={maintenanceData}
|
||||||
|
isLoading={loading && initialized}
|
||||||
|
onMaintenanceUpdated={handleMaintenanceUpdated}
|
||||||
|
/>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="ongoing" className="space-y-4">
|
||||||
|
<MaintenanceTable
|
||||||
|
data={maintenanceData}
|
||||||
|
isLoading={loading && initialized}
|
||||||
|
onMaintenanceUpdated={handleMaintenanceUpdated}
|
||||||
|
/>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="completed" className="space-y-4">
|
||||||
|
<MaintenanceTable
|
||||||
|
data={maintenanceData}
|
||||||
|
isLoading={loading && initialized}
|
||||||
|
onMaintenanceUpdated={handleMaintenanceUpdated}
|
||||||
|
/>
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
|
||||||
|
import React, { ReactNode } from 'react';
|
||||||
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
|
import { useTheme } from "@/contexts/ThemeContext";
|
||||||
|
|
||||||
|
interface OverviewCardProps {
|
||||||
|
title: string;
|
||||||
|
value: string | number;
|
||||||
|
description?: string;
|
||||||
|
icon: ReactNode;
|
||||||
|
trend?: {
|
||||||
|
value: number;
|
||||||
|
isPositive: boolean;
|
||||||
|
};
|
||||||
|
className?: string;
|
||||||
|
valueClassName?: string;
|
||||||
|
isLoading?: boolean;
|
||||||
|
color?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const OverviewCard = ({
|
||||||
|
title,
|
||||||
|
value,
|
||||||
|
description,
|
||||||
|
icon,
|
||||||
|
trend,
|
||||||
|
className,
|
||||||
|
valueClassName,
|
||||||
|
isLoading = false,
|
||||||
|
color = "blue",
|
||||||
|
}: OverviewCardProps) => {
|
||||||
|
const { theme } = useTheme();
|
||||||
|
|
||||||
|
// Map color prop to gradient colors
|
||||||
|
const getGradientBackground = () => {
|
||||||
|
const colors = {
|
||||||
|
blue: theme === 'dark'
|
||||||
|
? "linear-gradient(135deg, rgba(25, 118, 210, 0.8) 0%, rgba(66, 165, 245, 0.6) 100%)"
|
||||||
|
: "linear-gradient(135deg, #1976d2 0%, #42a5f5 100%)",
|
||||||
|
green: theme === 'dark'
|
||||||
|
? "linear-gradient(135deg, rgba(67, 160, 71, 0.8) 0%, rgba(102, 187, 106, 0.6) 100%)"
|
||||||
|
: "linear-gradient(135deg, #43a047 0%, #66bb6a 100%)",
|
||||||
|
amber: theme === 'dark'
|
||||||
|
? "linear-gradient(135deg, rgba(255, 152, 0, 0.8) 0%, rgba(255, 183, 77, 0.6) 100%)"
|
||||||
|
: "linear-gradient(135deg, #ff9800 0%, #ffb74d 100%)",
|
||||||
|
red: theme === 'dark'
|
||||||
|
? "linear-gradient(135deg, rgba(229, 57, 53, 0.8) 0%, rgba(239, 83, 80, 0.6) 100%)"
|
||||||
|
: "linear-gradient(135deg, #e53935 0%, #ef5350 100%)",
|
||||||
|
purple: theme === 'dark'
|
||||||
|
? "linear-gradient(135deg, rgba(123, 31, 162, 0.8) 0%, rgba(156, 39, 176, 0.6) 100%)"
|
||||||
|
: "linear-gradient(135deg, #7b1fa2 0%, #9c27b0 100%)",
|
||||||
|
orange: theme === 'dark'
|
||||||
|
? "linear-gradient(135deg, rgba(230, 81, 0, 0.8) 0%, rgba(255, 109, 0, 0.6) 100%)"
|
||||||
|
: "linear-gradient(135deg, #e65100 0%, #ff6d00 100%)",
|
||||||
|
};
|
||||||
|
|
||||||
|
return colors[color as keyof typeof colors] || colors.blue;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card
|
||||||
|
className={cn(
|
||||||
|
"border-none rounded-xl shadow-lg overflow-hidden transition-all duration-300 hover:shadow-xl transform hover:-translate-y-1 relative z-10",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
style={{
|
||||||
|
background: getGradientBackground()
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Grid Pattern Overlay */}
|
||||||
|
<div className="absolute inset-0 z-0 opacity-10">
|
||||||
|
<div className="w-full h-full"
|
||||||
|
style={{
|
||||||
|
backgroundImage: `linear-gradient(#fff 1px, transparent 1px),
|
||||||
|
linear-gradient(90deg, #fff 1px, transparent 1px)`,
|
||||||
|
backgroundSize: '20px 20px'
|
||||||
|
}}
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<CardContent className="p-6 relative z-10">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-white/90 mb-1">{title}</p>
|
||||||
|
{isLoading ? (
|
||||||
|
<Skeleton className="h-8 w-16 bg-white/20" />
|
||||||
|
) : (
|
||||||
|
<h3 className={cn("text-3xl font-bold text-white", valueClassName)}>{value}</h3>
|
||||||
|
)}
|
||||||
|
{description && (
|
||||||
|
<p className="text-sm text-white/80 mt-1">{description}</p>
|
||||||
|
)}
|
||||||
|
{trend && (
|
||||||
|
<div className={`flex items-center mt-2 text-sm ${trend.isPositive ? 'text-green-100' : 'text-red-100'}`}>
|
||||||
|
<span className="mr-1">
|
||||||
|
{trend.isPositive ? '↑' : '↓'}
|
||||||
|
</span>
|
||||||
|
<span>{Math.abs(trend.value)}%</span>
|
||||||
|
<span className="ml-1 text-white/70">vs last month</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="rounded-full p-3 bg-white/25 backdrop-blur-sm">
|
||||||
|
{icon}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
|
||||||
|
export * from './useMaintenanceData';
|
||||||
|
export * from './useIncidentData';
|
||||||
@@ -0,0 +1,200 @@
|
|||||||
|
import { useState, useEffect, useCallback, useMemo, useRef } from 'react';
|
||||||
|
import { incidentService, IncidentItem } from '@/services/incident';
|
||||||
|
|
||||||
|
interface UseIncidentDataProps {
|
||||||
|
refreshTrigger?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useIncidentData = ({ refreshTrigger = 0 }: UseIncidentDataProps) => {
|
||||||
|
const [incidents, setIncidents] = useState<IncidentItem[]>([]);
|
||||||
|
const [filter, setFilter] = useState("unresolved");
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [initialized, setInitialized] = useState(false);
|
||||||
|
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||||
|
|
||||||
|
// Use a ref to prevent multiple simultaneous fetch requests
|
||||||
|
const isFetchingRef = useRef(false);
|
||||||
|
// Use a ref to track the refresh trigger
|
||||||
|
const lastRefreshTriggerRef = useRef(refreshTrigger);
|
||||||
|
|
||||||
|
// Simplified fetch function with improved controls to prevent duplicate calls
|
||||||
|
const fetchIncidentData = useCallback(async (force = false) => {
|
||||||
|
// Skip if already fetching
|
||||||
|
if (isFetchingRef.current) {
|
||||||
|
console.log('Already fetching data, skipping additional request');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip if not forced and already initialized
|
||||||
|
if (initialized && !force) {
|
||||||
|
console.log('Data already initialized and no force refresh, skipping fetch');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set fetching flags
|
||||||
|
isFetchingRef.current = true;
|
||||||
|
|
||||||
|
if (!initialized || force) {
|
||||||
|
setLoading(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (force) {
|
||||||
|
setIsRefreshing(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
console.log(`Fetching incident data (force=${force})`);
|
||||||
|
const allIncidents = await incidentService.getAllIncidents(force);
|
||||||
|
|
||||||
|
if (Array.isArray(allIncidents)) {
|
||||||
|
setIncidents(allIncidents);
|
||||||
|
console.log(`Successfully set ${allIncidents.length} incidents to state`);
|
||||||
|
} else {
|
||||||
|
setIncidents([]);
|
||||||
|
console.warn('No incidents returned from service');
|
||||||
|
}
|
||||||
|
|
||||||
|
setInitialized(true);
|
||||||
|
setLoading(false);
|
||||||
|
setIsRefreshing(false);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching incident data:', error);
|
||||||
|
setError('Failed to load incident data. Please try again later.');
|
||||||
|
setIncidents([]);
|
||||||
|
setInitialized(true);
|
||||||
|
setLoading(false);
|
||||||
|
setIsRefreshing(false);
|
||||||
|
} finally {
|
||||||
|
// Reset fetching flag after a slight delay to prevent rapid consecutive calls
|
||||||
|
setTimeout(() => {
|
||||||
|
isFetchingRef.current = false;
|
||||||
|
}, 500);
|
||||||
|
}
|
||||||
|
}, [initialized]);
|
||||||
|
|
||||||
|
// Only fetch when component mounts or refreshTrigger changes
|
||||||
|
useEffect(() => {
|
||||||
|
// Skip if the refresh trigger hasn't changed (prevents duplicate effect calls)
|
||||||
|
if (refreshTrigger === lastRefreshTriggerRef.current && initialized) {
|
||||||
|
console.log('Refresh trigger unchanged, skipping fetch');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update last refresh trigger ref
|
||||||
|
lastRefreshTriggerRef.current = refreshTrigger;
|
||||||
|
|
||||||
|
// Create an abort controller for cleanup
|
||||||
|
const abortController = new AbortController();
|
||||||
|
let isMounted = true;
|
||||||
|
|
||||||
|
console.log(`useIncidentData effect running, refreshTrigger: ${refreshTrigger}`);
|
||||||
|
|
||||||
|
// Use a longer delay to ensure we don't trigger too many API calls
|
||||||
|
const fetchTimer = setTimeout(() => {
|
||||||
|
if (isMounted) {
|
||||||
|
fetchIncidentData(refreshTrigger > 0);
|
||||||
|
}
|
||||||
|
}, 500); // Use a longer delay
|
||||||
|
|
||||||
|
// Cleanup function to abort any in-flight requests and clear timers
|
||||||
|
return () => {
|
||||||
|
console.log('Cleaning up incident data fetch effect');
|
||||||
|
isMounted = false;
|
||||||
|
clearTimeout(fetchTimer);
|
||||||
|
abortController.abort();
|
||||||
|
};
|
||||||
|
}, [fetchIncidentData, refreshTrigger, initialized]);
|
||||||
|
|
||||||
|
// Filter the data based on the current filter
|
||||||
|
const incidentData = useMemo(() => {
|
||||||
|
if (!initialized || incidents.length === 0) return [];
|
||||||
|
|
||||||
|
console.log(`Filtering incidents by: ${filter}`);
|
||||||
|
|
||||||
|
if (filter === "unresolved") {
|
||||||
|
return incidents.filter(item => {
|
||||||
|
const status = (item.status || item.impact_status || '').toLowerCase();
|
||||||
|
return status !== 'resolved';
|
||||||
|
});
|
||||||
|
} else if (filter === "resolved") {
|
||||||
|
return incidents.filter(item => {
|
||||||
|
const status = (item.status || item.impact_status || '').toLowerCase();
|
||||||
|
return status === 'resolved';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return [];
|
||||||
|
}, [filter, incidents, initialized]);
|
||||||
|
|
||||||
|
// Calculate stats for overview cards
|
||||||
|
const overviewStats = useMemo(() => {
|
||||||
|
if (!initialized || incidents.length === 0) {
|
||||||
|
return {
|
||||||
|
unresolved: 0,
|
||||||
|
resolved: 0,
|
||||||
|
critical: 0,
|
||||||
|
highPriority: 0,
|
||||||
|
avgResolutionTime: "0h"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const unresolvedIncidents = incidents.filter(item => {
|
||||||
|
const status = (item.status || item.impact_status || '').toLowerCase();
|
||||||
|
return status !== 'resolved';
|
||||||
|
});
|
||||||
|
|
||||||
|
const resolvedIncidents = incidents.filter(item => {
|
||||||
|
const status = (item.status || item.impact_status || '').toLowerCase();
|
||||||
|
return status === 'resolved';
|
||||||
|
});
|
||||||
|
|
||||||
|
const criticalCount = unresolvedIncidents.filter(item =>
|
||||||
|
(item.priority?.toLowerCase() === 'critical') ||
|
||||||
|
(item.impact?.toLowerCase() === 'critical')
|
||||||
|
).length;
|
||||||
|
|
||||||
|
const highPriorityCount = unresolvedIncidents.filter(item =>
|
||||||
|
(item.priority?.toLowerCase() === 'high')
|
||||||
|
).length;
|
||||||
|
|
||||||
|
let avgResolutionTime = "0h";
|
||||||
|
if (resolvedIncidents.length > 0) {
|
||||||
|
const totalHours = resolvedIncidents.reduce((total, item) => {
|
||||||
|
if (!item.created || !item.resolution_time) return total;
|
||||||
|
|
||||||
|
const createdAt = new Date(item.created).getTime();
|
||||||
|
const resolvedAt = new Date(item.resolution_time).getTime();
|
||||||
|
const durationHours = (resolvedAt - createdAt) / (1000 * 60 * 60);
|
||||||
|
return isNaN(durationHours) ? total : total + durationHours;
|
||||||
|
}, 0);
|
||||||
|
|
||||||
|
avgResolutionTime = `${(totalHours / resolvedIncidents.length).toFixed(1)}h`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
unresolved: unresolvedIncidents.length,
|
||||||
|
resolved: resolvedIncidents.length,
|
||||||
|
critical: criticalCount,
|
||||||
|
highPriority: highPriorityCount,
|
||||||
|
avgResolutionTime
|
||||||
|
};
|
||||||
|
}, [incidents, initialized]);
|
||||||
|
|
||||||
|
const isEmpty = !incidentData.length && initialized && !loading;
|
||||||
|
|
||||||
|
return {
|
||||||
|
filter,
|
||||||
|
setFilter,
|
||||||
|
incidentData,
|
||||||
|
overviewStats,
|
||||||
|
fetchIncidentData,
|
||||||
|
isEmpty,
|
||||||
|
error,
|
||||||
|
loading,
|
||||||
|
initialized,
|
||||||
|
isRefreshing
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -0,0 +1,181 @@
|
|||||||
|
|
||||||
|
import { useState, useEffect, useCallback, useMemo, useRef } from 'react';
|
||||||
|
import { maintenanceService, MaintenanceItem } from '@/services/maintenance';
|
||||||
|
import { useToast } from '@/hooks/use-toast';
|
||||||
|
import { useLanguage } from '@/contexts/LanguageContext';
|
||||||
|
|
||||||
|
interface UseMaintenanceDataProps {
|
||||||
|
refreshTrigger?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useMaintenanceData = ({ refreshTrigger = 0 }: UseMaintenanceDataProps) => {
|
||||||
|
const { t } = useLanguage();
|
||||||
|
const { toast } = useToast();
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [allMaintenanceData, setAllMaintenanceData] = useState<MaintenanceItem[]>([]);
|
||||||
|
const [filter, setFilter] = useState("upcoming");
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [initialized, setInitialized] = useState(false);
|
||||||
|
|
||||||
|
// Refs for cleanup and request management
|
||||||
|
const mountedRef = useRef(true);
|
||||||
|
const currentRequestRef = useRef<Promise<void> | null>(null);
|
||||||
|
|
||||||
|
// Memoize categorized data to prevent unnecessary recalculations
|
||||||
|
const categorizedData = useMemo(() => {
|
||||||
|
if (!allMaintenanceData.length) return { upcoming: [], ongoing: [], completed: [] };
|
||||||
|
|
||||||
|
const currentDate = new Date();
|
||||||
|
const upcoming: MaintenanceItem[] = [];
|
||||||
|
const ongoing: MaintenanceItem[] = [];
|
||||||
|
const completed: MaintenanceItem[] = [];
|
||||||
|
|
||||||
|
allMaintenanceData.forEach(item => {
|
||||||
|
const status = item.status?.toLowerCase() || '';
|
||||||
|
const startTime = new Date(item.start_time);
|
||||||
|
const endTime = new Date(item.end_time);
|
||||||
|
|
||||||
|
if (status === 'completed' || status === 'cancelled') {
|
||||||
|
completed.push(item);
|
||||||
|
} else if (status === 'in_progress' ||
|
||||||
|
(status === 'scheduled' && startTime <= currentDate && endTime >= currentDate)) {
|
||||||
|
ongoing.push(item);
|
||||||
|
} else if (status === 'scheduled' && startTime > currentDate) {
|
||||||
|
upcoming.push(item);
|
||||||
|
} else {
|
||||||
|
// Default case: treat as upcoming if we can't determine
|
||||||
|
upcoming.push(item);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return { upcoming, ongoing, completed };
|
||||||
|
}, [allMaintenanceData]);
|
||||||
|
|
||||||
|
// Simple fetch function - only called when explicitly requested
|
||||||
|
const fetchMaintenanceData = useCallback(async (force = false) => {
|
||||||
|
// Check if component is still mounted
|
||||||
|
if (!mountedRef.current) return;
|
||||||
|
|
||||||
|
// Prevent duplicate requests
|
||||||
|
if (currentRequestRef.current) {
|
||||||
|
await currentRequestRef.current;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only show loading state for initial load or forced refresh
|
||||||
|
if (!initialized || force) {
|
||||||
|
setLoading(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
const requestPromise = (async () => {
|
||||||
|
try {
|
||||||
|
const data = await maintenanceService.getMaintenanceRecords();
|
||||||
|
|
||||||
|
// Check if component is still mounted before updating state
|
||||||
|
if (!mountedRef.current) return;
|
||||||
|
|
||||||
|
// Update state with fetched data
|
||||||
|
setAllMaintenanceData(data);
|
||||||
|
setInitialized(true);
|
||||||
|
|
||||||
|
// Clear any previous error
|
||||||
|
if (error) {
|
||||||
|
setError(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error fetching maintenance data:', err);
|
||||||
|
|
||||||
|
// Only update error state if component is still mounted
|
||||||
|
if (!mountedRef.current) return;
|
||||||
|
|
||||||
|
const errorMessage = 'Failed to load maintenance data. Please try again.';
|
||||||
|
setError(errorMessage);
|
||||||
|
|
||||||
|
// Show toast for errors
|
||||||
|
toast({
|
||||||
|
title: t('error'),
|
||||||
|
description: t('errorFetchingMaintenanceData'),
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
// Only update loading state if component is still mounted
|
||||||
|
if (mountedRef.current) {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
currentRequestRef.current = null;
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
currentRequestRef.current = requestPromise;
|
||||||
|
await requestPromise;
|
||||||
|
}, [t, toast, error, initialized]);
|
||||||
|
|
||||||
|
// Initial fetch on mount - NO AUTOMATIC POLLING
|
||||||
|
useEffect(() => {
|
||||||
|
mountedRef.current = true;
|
||||||
|
|
||||||
|
// Only fetch initial data, no polling
|
||||||
|
fetchMaintenanceData(true);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
mountedRef.current = false;
|
||||||
|
currentRequestRef.current = null;
|
||||||
|
};
|
||||||
|
}, []); // Remove fetchMaintenanceData from dependencies to prevent re-runs
|
||||||
|
|
||||||
|
// Handle refresh trigger changes - ONLY when explicitly triggered
|
||||||
|
useEffect(() => {
|
||||||
|
if (refreshTrigger > 0) {
|
||||||
|
fetchMaintenanceData(true); // Force refresh to bypass cache
|
||||||
|
}
|
||||||
|
}, [refreshTrigger, fetchMaintenanceData]);
|
||||||
|
|
||||||
|
// Get filtered data based on current tab
|
||||||
|
const maintenanceData = useMemo(() => {
|
||||||
|
if (!initialized) return [];
|
||||||
|
return categorizedData[filter as keyof typeof categorizedData] || [];
|
||||||
|
}, [filter, categorizedData, initialized]);
|
||||||
|
|
||||||
|
// Calculate overview stats with memoization
|
||||||
|
const overviewStats = useMemo(() => {
|
||||||
|
const { upcoming, ongoing, completed } = categorizedData;
|
||||||
|
|
||||||
|
// Calculate total hours more efficiently
|
||||||
|
const calculateTotalHours = (items: MaintenanceItem[]) => {
|
||||||
|
return items.reduce((total, item) => {
|
||||||
|
try {
|
||||||
|
const start = new Date(item.start_time).getTime();
|
||||||
|
const end = new Date(item.end_time).getTime();
|
||||||
|
const durationHours = (end - start) / (1000 * 60 * 60);
|
||||||
|
return total + (isNaN(durationHours) ? 0 : durationHours);
|
||||||
|
} catch (e) {
|
||||||
|
return total;
|
||||||
|
}
|
||||||
|
}, 0).toFixed(1);
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
upcoming: upcoming.length,
|
||||||
|
ongoing: ongoing.length,
|
||||||
|
completed: completed.length,
|
||||||
|
totalDuration: calculateTotalHours([...upcoming, ...ongoing]),
|
||||||
|
};
|
||||||
|
}, [categorizedData]);
|
||||||
|
|
||||||
|
const isEmpty = !loading && maintenanceData.length === 0 && initialized;
|
||||||
|
|
||||||
|
return {
|
||||||
|
loading,
|
||||||
|
filter,
|
||||||
|
setFilter,
|
||||||
|
maintenanceData,
|
||||||
|
overviewStats,
|
||||||
|
fetchMaintenanceData,
|
||||||
|
isEmpty,
|
||||||
|
error,
|
||||||
|
initialized,
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import { useLanguage } from '@/contexts/LanguageContext';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { RefreshCw } from 'lucide-react';
|
||||||
|
|
||||||
|
interface ErrorStateProps {
|
||||||
|
error: string | null;
|
||||||
|
onRefresh: () => void;
|
||||||
|
isRefreshing: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ErrorState: React.FC<ErrorStateProps> = ({
|
||||||
|
error,
|
||||||
|
onRefresh,
|
||||||
|
isRefreshing
|
||||||
|
}) => {
|
||||||
|
const { t } = useLanguage();
|
||||||
|
|
||||||
|
if (!error) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="text-center py-8">
|
||||||
|
<p className="text-muted-foreground mb-4">{error}</p>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={onRefresh}
|
||||||
|
className="flex items-center gap-2"
|
||||||
|
disabled={isRefreshing}
|
||||||
|
>
|
||||||
|
<RefreshCw className={`h-4 w-4 ${isRefreshing ? 'animate-spin' : ''}`} />
|
||||||
|
{t('tryAgain')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import { CardDescription, CardTitle } from '@/components/ui/card';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { RefreshCw } from 'lucide-react';
|
||||||
|
import { useLanguage } from '@/contexts/LanguageContext';
|
||||||
|
|
||||||
|
interface HeaderActionsProps {
|
||||||
|
onRefresh: () => void;
|
||||||
|
isRefreshing: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const HeaderActions: React.FC<HeaderActionsProps> = ({ onRefresh, isRefreshing }) => {
|
||||||
|
const { t } = useLanguage();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-row items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<CardTitle>{t('incidentManagement')}</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
{t('incidentsManagementDesc')}
|
||||||
|
</CardDescription>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="icon"
|
||||||
|
onClick={onRefresh}
|
||||||
|
className="ml-auto"
|
||||||
|
title={t('refreshData')}
|
||||||
|
disabled={isRefreshing}
|
||||||
|
>
|
||||||
|
<RefreshCw className={`h-4 w-4 ${isRefreshing ? 'animate-spin' : ''}`} />
|
||||||
|
<span className="sr-only">{t('refresh')}</span>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
+171
@@ -0,0 +1,171 @@
|
|||||||
|
|
||||||
|
import React, { useState, useCallback, useRef } from 'react';
|
||||||
|
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs';
|
||||||
|
import { Card, CardContent, CardHeader } from '@/components/ui/card';
|
||||||
|
import { useLanguage } from '@/contexts/LanguageContext';
|
||||||
|
import { useIncidentData } from '../hooks/useIncidentData';
|
||||||
|
import { IncidentItem } from '@/services/incident';
|
||||||
|
import { useToast } from '@/hooks/use-toast';
|
||||||
|
import { OverviewCards } from './OverviewCards';
|
||||||
|
import { HeaderActions } from './HeaderActions';
|
||||||
|
import { TabContent } from './TabContent';
|
||||||
|
import { LoadingState } from '@/components/services/LoadingState';
|
||||||
|
import { IncidentDetailDialog } from '../incident/detail-dialog/IncidentDetailDialog';
|
||||||
|
|
||||||
|
interface IncidentManagementContainerProps {
|
||||||
|
refreshTrigger?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const IncidentManagementContainer: React.FC<IncidentManagementContainerProps> = ({
|
||||||
|
refreshTrigger = 0
|
||||||
|
}) => {
|
||||||
|
const { t } = useLanguage();
|
||||||
|
const { toast } = useToast();
|
||||||
|
const [selectedIncident, setSelectedIncident] = useState<IncidentItem | null>(null);
|
||||||
|
const [detailDialogOpen, setDetailDialogOpen] = useState(false);
|
||||||
|
const [manualRefresh, setManualRefresh] = useState(0);
|
||||||
|
|
||||||
|
// Use a ref to debounce multiple refresh requests
|
||||||
|
const refreshTimerRef = useRef<number | null>(null);
|
||||||
|
|
||||||
|
// Combine the external refresh trigger with our internal one
|
||||||
|
const combinedRefreshTrigger = refreshTrigger + manualRefresh;
|
||||||
|
|
||||||
|
const {
|
||||||
|
filter,
|
||||||
|
setFilter,
|
||||||
|
incidentData,
|
||||||
|
overviewStats,
|
||||||
|
isEmpty,
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
initialized,
|
||||||
|
isRefreshing
|
||||||
|
} = useIncidentData({ refreshTrigger: combinedRefreshTrigger });
|
||||||
|
|
||||||
|
// Handle incident updates with debouncing
|
||||||
|
const handleIncidentUpdated = useCallback(() => {
|
||||||
|
console.log('Incident updated, triggering refresh');
|
||||||
|
|
||||||
|
// Clear any existing timer
|
||||||
|
if (refreshTimerRef.current !== null) {
|
||||||
|
window.clearTimeout(refreshTimerRef.current);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set a new timer to debounce multiple quick updates
|
||||||
|
refreshTimerRef.current = window.setTimeout(() => {
|
||||||
|
setManualRefresh(prev => prev + 1);
|
||||||
|
refreshTimerRef.current = null;
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: t('incidentUpdated'),
|
||||||
|
description: t('incidentUpdateSuccess'),
|
||||||
|
});
|
||||||
|
}, 300);
|
||||||
|
|
||||||
|
}, [t, toast]);
|
||||||
|
|
||||||
|
// Handle tab changes
|
||||||
|
const handleTabChange = useCallback((value: string) => {
|
||||||
|
console.log(`Tab changed to: ${value}`);
|
||||||
|
setFilter(value);
|
||||||
|
}, [setFilter]);
|
||||||
|
|
||||||
|
// Handle view incident details
|
||||||
|
const handleViewIncidentDetails = useCallback((incident: IncidentItem) => {
|
||||||
|
setSelectedIncident(incident);
|
||||||
|
setDetailDialogOpen(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Handle manual refresh
|
||||||
|
const handleManualRefresh = useCallback(() => {
|
||||||
|
console.log('Manual refresh triggered by user');
|
||||||
|
setManualRefresh(prev => prev + 1);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Handle detail dialog close with refresh
|
||||||
|
const handleDetailDialogClose = useCallback((open: boolean) => {
|
||||||
|
setDetailDialogOpen(open);
|
||||||
|
if (!open) {
|
||||||
|
// When dialog closes, refresh data
|
||||||
|
handleManualRefresh();
|
||||||
|
}
|
||||||
|
}, [handleManualRefresh]);
|
||||||
|
|
||||||
|
// Clean up timer on unmount
|
||||||
|
React.useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (refreshTimerRef.current !== null) {
|
||||||
|
window.clearTimeout(refreshTimerRef.current);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Show full-page loading state during initial load
|
||||||
|
if (loading && !initialized) {
|
||||||
|
return <LoadingState />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* Overview Cards */}
|
||||||
|
<OverviewCards
|
||||||
|
overviewStats={overviewStats}
|
||||||
|
loading={loading}
|
||||||
|
initialized={initialized}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<HeaderActions
|
||||||
|
onRefresh={handleManualRefresh}
|
||||||
|
isRefreshing={isRefreshing}
|
||||||
|
/>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<Tabs value={filter} className="w-full" onValueChange={handleTabChange}>
|
||||||
|
<TabsList className="mb-6">
|
||||||
|
<TabsTrigger value="unresolved">{t('unresolvedIncidents')}</TabsTrigger>
|
||||||
|
<TabsTrigger value="resolved">{t('resolvedIncidents')}</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
|
||||||
|
<TabsContent value="unresolved" className="space-y-4">
|
||||||
|
<TabContent
|
||||||
|
error={error}
|
||||||
|
isEmpty={isEmpty}
|
||||||
|
data={incidentData}
|
||||||
|
loading={loading}
|
||||||
|
initialized={initialized}
|
||||||
|
isRefreshing={isRefreshing}
|
||||||
|
onIncidentUpdated={handleIncidentUpdated}
|
||||||
|
onViewDetails={handleViewIncidentDetails}
|
||||||
|
onRefresh={handleManualRefresh}
|
||||||
|
/>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="resolved" className="space-y-4">
|
||||||
|
<TabContent
|
||||||
|
error={error}
|
||||||
|
isEmpty={isEmpty}
|
||||||
|
data={incidentData}
|
||||||
|
loading={loading}
|
||||||
|
initialized={initialized}
|
||||||
|
isRefreshing={isRefreshing}
|
||||||
|
onIncidentUpdated={handleIncidentUpdated}
|
||||||
|
onViewDetails={handleViewIncidentDetails}
|
||||||
|
onRefresh={handleManualRefresh}
|
||||||
|
/>
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Incident Detail Dialog */}
|
||||||
|
<IncidentDetailDialog
|
||||||
|
open={detailDialogOpen}
|
||||||
|
onOpenChange={handleDetailDialogClose}
|
||||||
|
incident={selectedIncident}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import { AlertCircle, CheckCircle, Clock, AlertTriangle, Flag } from 'lucide-react';
|
||||||
|
import { useLanguage } from '@/contexts/LanguageContext';
|
||||||
|
import { OverviewCard } from '../common/OverviewCard';
|
||||||
|
|
||||||
|
interface OverviewStatsProps {
|
||||||
|
unresolved: number;
|
||||||
|
resolved: number;
|
||||||
|
critical: number;
|
||||||
|
highPriority: number;
|
||||||
|
avgResolutionTime: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface OverviewCardsProps {
|
||||||
|
overviewStats: OverviewStatsProps;
|
||||||
|
loading: boolean;
|
||||||
|
initialized: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const OverviewCards: React.FC<OverviewCardsProps> = ({
|
||||||
|
overviewStats,
|
||||||
|
loading,
|
||||||
|
initialized
|
||||||
|
}) => {
|
||||||
|
const { t } = useLanguage();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-5 gap-4 mb-6">
|
||||||
|
<OverviewCard
|
||||||
|
title={t('activeIncidents')}
|
||||||
|
value={overviewStats.unresolved.toString()}
|
||||||
|
icon={<AlertCircle className="h-5 w-5 text-white" />}
|
||||||
|
isLoading={loading && initialized}
|
||||||
|
color="red"
|
||||||
|
/>
|
||||||
|
<OverviewCard
|
||||||
|
title={t('criticalIssues')}
|
||||||
|
value={overviewStats.critical.toString()}
|
||||||
|
icon={<AlertTriangle className="h-5 w-5 text-white" />}
|
||||||
|
isLoading={loading && initialized}
|
||||||
|
color="amber"
|
||||||
|
/>
|
||||||
|
<OverviewCard
|
||||||
|
title={t('highPriority')}
|
||||||
|
value={overviewStats.highPriority.toString()}
|
||||||
|
icon={<Flag className="h-5 w-5 text-white" />}
|
||||||
|
isLoading={loading && initialized}
|
||||||
|
color="orange"
|
||||||
|
/>
|
||||||
|
<OverviewCard
|
||||||
|
title={t('resolvedIncidents')}
|
||||||
|
value={overviewStats.resolved.toString()}
|
||||||
|
icon={<CheckCircle className="h-5 w-5 text-white" />}
|
||||||
|
isLoading={loading && initialized}
|
||||||
|
color="green"
|
||||||
|
/>
|
||||||
|
<OverviewCard
|
||||||
|
title={t('avgResolutionTime')}
|
||||||
|
value={overviewStats.avgResolutionTime}
|
||||||
|
icon={<Clock className="h-5 w-5 text-white" />}
|
||||||
|
isLoading={loading && initialized}
|
||||||
|
color="blue"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import { IncidentTable } from '../incident/table/IncidentTable';
|
||||||
|
import { EmptyIncidentState } from '../incident/EmptyIncidentState';
|
||||||
|
import { ErrorState } from './ErrorState';
|
||||||
|
import { IncidentItem } from '@/services/incident';
|
||||||
|
|
||||||
|
interface TabContentProps {
|
||||||
|
error: string | null;
|
||||||
|
isEmpty: boolean;
|
||||||
|
data: IncidentItem[];
|
||||||
|
loading: boolean;
|
||||||
|
initialized: boolean;
|
||||||
|
isRefreshing: boolean;
|
||||||
|
onIncidentUpdated: () => void;
|
||||||
|
onViewDetails: (incident: IncidentItem) => void;
|
||||||
|
onRefresh: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const TabContent: React.FC<TabContentProps> = ({
|
||||||
|
error,
|
||||||
|
isEmpty,
|
||||||
|
data,
|
||||||
|
loading,
|
||||||
|
initialized,
|
||||||
|
isRefreshing,
|
||||||
|
onIncidentUpdated,
|
||||||
|
onViewDetails,
|
||||||
|
onRefresh
|
||||||
|
}) => {
|
||||||
|
if (error) {
|
||||||
|
return <ErrorState error={error} onRefresh={onRefresh} isRefreshing={isRefreshing} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isEmpty) {
|
||||||
|
return <EmptyIncidentState />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<IncidentTable
|
||||||
|
data={data}
|
||||||
|
onIncidentUpdated={onIncidentUpdated}
|
||||||
|
onViewDetails={onViewDetails}
|
||||||
|
isLoading={loading && initialized}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
|
||||||
|
export * from './IncidentManagementContainer';
|
||||||
|
export * from './OverviewCards';
|
||||||
|
export * from './TabContent';
|
||||||
|
export * from './ErrorState';
|
||||||
|
export * from './HeaderActions';
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import { useLanguage } from '@/contexts/LanguageContext';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogFooter,
|
||||||
|
} from '@/components/ui/dialog';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Form } from '@/components/ui/form';
|
||||||
|
import { useIncidentForm } from './hooks/useIncidentForm';
|
||||||
|
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||||
|
import {
|
||||||
|
IncidentBasicFields,
|
||||||
|
IncidentAffectedFields,
|
||||||
|
IncidentConfigFields,
|
||||||
|
IncidentDetailsFields,
|
||||||
|
} from './form';
|
||||||
|
|
||||||
|
interface CreateIncidentDialogProps {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
onIncidentCreated: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CreateIncidentDialog: React.FC<CreateIncidentDialogProps> = ({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
onIncidentCreated,
|
||||||
|
}) => {
|
||||||
|
const { t } = useLanguage();
|
||||||
|
const { form, onSubmit } = useIncidentForm(
|
||||||
|
onIncidentCreated,
|
||||||
|
() => onOpenChange(false)
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="sm:max-w-[700px] max-h-[90vh]">
|
||||||
|
<ScrollArea className="h-[80vh]">
|
||||||
|
<div className="px-1 py-2">
|
||||||
|
<DialogHeader className="mb-4">
|
||||||
|
<DialogTitle className="text-xl">{t('createIncident')}</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
{t('createIncidentDesc')}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<Form {...form}>
|
||||||
|
<form onSubmit={onSubmit} className="space-y-6">
|
||||||
|
<div className="space-y-8 pb-4">
|
||||||
|
<div className="space-y-4">
|
||||||
|
<h3 className="text-sm font-medium border-b pb-2">{t('basicInfo')}</h3>
|
||||||
|
<IncidentBasicFields />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<h3 className="text-sm font-medium border-b pb-2">{t('affectedSystems')}</h3>
|
||||||
|
<IncidentAffectedFields />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<h3 className="text-sm font-medium border-b pb-2">{t('configuration')}</h3>
|
||||||
|
<IncidentConfigFields />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<h3 className="text-sm font-medium border-b pb-2">{t('resolutionDetails')}</h3>
|
||||||
|
<IncidentDetailsFields />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter className="pt-4 mt-4 border-t">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => onOpenChange(false)}
|
||||||
|
>
|
||||||
|
{t('cancel')}
|
||||||
|
</Button>
|
||||||
|
<Button type="submit">
|
||||||
|
{form.formState.isSubmitting ? t('creating') : t('create')}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
</div>
|
||||||
|
</ScrollArea>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import { useLanguage } from '@/contexts/LanguageContext';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogFooter,
|
||||||
|
} from '@/components/ui/dialog';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Form } from '@/components/ui/form';
|
||||||
|
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||||
|
import { useIncidentEditForm } from './hooks/useIncidentEditForm';
|
||||||
|
import {
|
||||||
|
IncidentBasicFields,
|
||||||
|
IncidentAffectedFields,
|
||||||
|
IncidentConfigFields,
|
||||||
|
IncidentDetailsFields,
|
||||||
|
} from './form';
|
||||||
|
import { IncidentItem } from '@/services/incident/types';
|
||||||
|
|
||||||
|
interface EditIncidentDialogProps {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
incident: IncidentItem;
|
||||||
|
onIncidentUpdated: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const EditIncidentDialog: React.FC<EditIncidentDialogProps> = ({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
incident,
|
||||||
|
onIncidentUpdated,
|
||||||
|
}) => {
|
||||||
|
const { t } = useLanguage();
|
||||||
|
|
||||||
|
const handleClose = () => {
|
||||||
|
onOpenChange(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const { form, onSubmit } = useIncidentEditForm(
|
||||||
|
incident,
|
||||||
|
onIncidentUpdated,
|
||||||
|
handleClose
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="sm:max-w-[700px] max-h-[90vh]">
|
||||||
|
<ScrollArea className="h-[80vh]">
|
||||||
|
<div className="px-1 py-2">
|
||||||
|
<DialogHeader className="mb-4">
|
||||||
|
<DialogTitle className="text-xl">{t('editIncident')}</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
{t('editIncidentDesc')}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<Form {...form}>
|
||||||
|
<form onSubmit={onSubmit} className="space-y-6">
|
||||||
|
<div className="space-y-8 pb-4">
|
||||||
|
<div className="space-y-4">
|
||||||
|
<h3 className="text-sm font-medium border-b pb-2">{t('basicInfo')}</h3>
|
||||||
|
<IncidentBasicFields />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<h3 className="text-sm font-medium border-b pb-2">{t('affectedSystems')}</h3>
|
||||||
|
<IncidentAffectedFields />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<h3 className="text-sm font-medium border-b pb-2">{t('configuration')}</h3>
|
||||||
|
<IncidentConfigFields />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<h3 className="text-sm font-medium border-b pb-2">{t('resolutionDetails')}</h3>
|
||||||
|
<IncidentDetailsFields />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter className="pt-4 mt-4 border-t">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={handleClose}
|
||||||
|
>
|
||||||
|
{t('cancel')}
|
||||||
|
</Button>
|
||||||
|
<Button type="submit">
|
||||||
|
{form.formState.isSubmitting ? t('updating') : t('update')}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
</div>
|
||||||
|
</ScrollArea>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import { useLanguage } from '@/contexts/LanguageContext';
|
||||||
|
import { AlertCircle } from 'lucide-react';
|
||||||
|
|
||||||
|
export const EmptyIncidentState = () => {
|
||||||
|
const { t } = useLanguage();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center justify-center py-12 text-gray-500">
|
||||||
|
<AlertCircle className="w-12 h-12 mb-4" />
|
||||||
|
<h3 className="text-lg font-medium mb-2">{t('noIncidents')}</h3>
|
||||||
|
<p className="text-sm text-center max-w-md">
|
||||||
|
{t('noServices')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import { useLanguage } from '@/contexts/LanguageContext';
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuLabel,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from '@/components/ui/dropdown-menu';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { MoreHorizontal, Eye, Edit, Trash, Check } from 'lucide-react';
|
||||||
|
import { useToast } from '@/hooks/use-toast';
|
||||||
|
import { updateIncidentStatus, deleteIncident } from '@/services/incident/incidentOperations';
|
||||||
|
import { IncidentItem } from '@/services/incident/types';
|
||||||
|
|
||||||
|
interface IncidentActionsMenuProps {
|
||||||
|
item: IncidentItem;
|
||||||
|
onIncidentUpdated: () => void;
|
||||||
|
onViewDetails?: (incident: IncidentItem) => void;
|
||||||
|
onEditIncident?: (incident: IncidentItem) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const IncidentActionsMenu = ({
|
||||||
|
item,
|
||||||
|
onIncidentUpdated,
|
||||||
|
onViewDetails,
|
||||||
|
onEditIncident
|
||||||
|
}: IncidentActionsMenuProps) => {
|
||||||
|
const { t } = useLanguage();
|
||||||
|
const { toast } = useToast();
|
||||||
|
|
||||||
|
const handleResolveIncident = async () => {
|
||||||
|
try {
|
||||||
|
await updateIncidentStatus(item.id, 'resolved');
|
||||||
|
toast({
|
||||||
|
title: t('success'),
|
||||||
|
description: t('incidentResolved'),
|
||||||
|
});
|
||||||
|
onIncidentUpdated();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error resolving incident:', error);
|
||||||
|
toast({
|
||||||
|
title: t('error'),
|
||||||
|
description: t('errorResolvingIncident'),
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteIncident = async () => {
|
||||||
|
try {
|
||||||
|
await deleteIncident(item.id);
|
||||||
|
toast({
|
||||||
|
title: t('success'),
|
||||||
|
description: t('incidentDeleted'),
|
||||||
|
});
|
||||||
|
onIncidentUpdated();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error deleting incident:', error);
|
||||||
|
toast({
|
||||||
|
title: t('error'),
|
||||||
|
description: t('errorDeletingIncident'),
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEditClick = () => {
|
||||||
|
if (onEditIncident) {
|
||||||
|
onEditIncident(item);
|
||||||
|
} else {
|
||||||
|
console.log(`Edit incident ${item.id}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button variant="ghost" className="h-8 w-8 p-0">
|
||||||
|
<span className="sr-only">{t('actions')}</span>
|
||||||
|
<MoreHorizontal className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end" className="bg-background">
|
||||||
|
<DropdownMenuLabel>{t('actions')}</DropdownMenuLabel>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
{onViewDetails && (
|
||||||
|
<DropdownMenuItem onClick={() => onViewDetails(item)}>
|
||||||
|
<Eye className="mr-2 h-4 w-4" />
|
||||||
|
{t('view')}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
)}
|
||||||
|
<DropdownMenuItem onClick={handleEditClick}>
|
||||||
|
<Edit className="mr-2 h-4 w-4" />
|
||||||
|
{t('edit')}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem onClick={handleResolveIncident}>
|
||||||
|
<Check className="mr-2 h-4 w-4" />
|
||||||
|
{t('resolve')}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={handleDeleteIncident}
|
||||||
|
className="text-red-600 focus:text-red-600"
|
||||||
|
>
|
||||||
|
<Trash className="mr-2 h-4 w-4" />
|
||||||
|
{t('delete')}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { useLanguage } from '@/contexts/LanguageContext';
|
||||||
|
import {
|
||||||
|
AlertCircle,
|
||||||
|
CheckCircle,
|
||||||
|
Gauge,
|
||||||
|
Search,
|
||||||
|
Wrench,
|
||||||
|
LucideIcon
|
||||||
|
} from 'lucide-react';
|
||||||
|
|
||||||
|
interface IncidentStatusBadgeProps {
|
||||||
|
status: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
type StatusConfig = {
|
||||||
|
label: string;
|
||||||
|
variant: 'outline' | 'default' | 'secondary' | 'destructive';
|
||||||
|
icon: LucideIcon;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const IncidentStatusBadge = ({ status }: IncidentStatusBadgeProps) => {
|
||||||
|
const { t } = useLanguage();
|
||||||
|
|
||||||
|
// Normalize the status string
|
||||||
|
const normalizedStatus = (status || '').toLowerCase();
|
||||||
|
|
||||||
|
// Status configuration map
|
||||||
|
const statusConfigs: Record<string, StatusConfig> = {
|
||||||
|
'investigating': {
|
||||||
|
label: t('investigating'),
|
||||||
|
variant: 'destructive',
|
||||||
|
icon: Search,
|
||||||
|
className: 'bg-red-100 border-red-200 text-red-700 hover:bg-red-100',
|
||||||
|
},
|
||||||
|
'identified': {
|
||||||
|
label: t('identified'),
|
||||||
|
variant: 'secondary',
|
||||||
|
icon: AlertCircle,
|
||||||
|
className: 'bg-amber-100 border-amber-200 text-amber-700 hover:bg-amber-100',
|
||||||
|
},
|
||||||
|
'found_root_cause': {
|
||||||
|
label: t('rootCauseFound'),
|
||||||
|
variant: 'secondary',
|
||||||
|
icon: AlertCircle,
|
||||||
|
className: 'bg-amber-100 border-amber-200 text-amber-700 hover:bg-amber-100',
|
||||||
|
},
|
||||||
|
'completed': {
|
||||||
|
label: t('completed'),
|
||||||
|
variant: 'default',
|
||||||
|
icon: CheckCircle,
|
||||||
|
className: 'bg-green-100 border-green-200 text-green-700 hover:bg-green-100',
|
||||||
|
},
|
||||||
|
'in_progress': {
|
||||||
|
label: t('inProgress'),
|
||||||
|
variant: 'default',
|
||||||
|
icon: Wrench,
|
||||||
|
className: 'bg-blue-100 border-blue-200 text-blue-700 hover:bg-blue-100',
|
||||||
|
},
|
||||||
|
'inprogress': {
|
||||||
|
label: t('inProgress'),
|
||||||
|
variant: 'default',
|
||||||
|
icon: Wrench,
|
||||||
|
className: 'bg-blue-100 border-blue-200 text-blue-700 hover:bg-blue-100',
|
||||||
|
},
|
||||||
|
'monitoring': {
|
||||||
|
label: t('monitoring'),
|
||||||
|
variant: 'outline',
|
||||||
|
icon: Gauge,
|
||||||
|
className: 'bg-purple-100 border-purple-200 text-purple-700 hover:bg-purple-100',
|
||||||
|
},
|
||||||
|
'resolved': {
|
||||||
|
label: t('resolved'),
|
||||||
|
variant: 'default',
|
||||||
|
icon: CheckCircle,
|
||||||
|
className: 'bg-green-100 border-green-200 text-green-700 hover:bg-green-100',
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Find the appropriate config, defaulting to investigating if not found
|
||||||
|
const getStatusConfig = (): StatusConfig => {
|
||||||
|
for (const [key, config] of Object.entries(statusConfigs)) {
|
||||||
|
if (normalizedStatus.includes(key)) {
|
||||||
|
return config;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return statusConfigs['investigating'];
|
||||||
|
};
|
||||||
|
|
||||||
|
const config = getStatusConfig();
|
||||||
|
const Icon = config.icon;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Badge
|
||||||
|
variant={config.variant}
|
||||||
|
className={`flex items-center gap-1 ${config.className}`}
|
||||||
|
>
|
||||||
|
<Icon className="h-3 w-3" />
|
||||||
|
<span>{config.label}</span>
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import { useLanguage } from '@/contexts/LanguageContext';
|
||||||
|
import { AlertCircle, CheckCircle, Gauge, Search, Wrench } from 'lucide-react';
|
||||||
|
import { useToast } from '@/hooks/use-toast';
|
||||||
|
import { updateIncidentStatus } from '@/services/incident/incidentOperations';
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from '@/components/ui/dropdown-menu';
|
||||||
|
import { IncidentStatusBadge } from './IncidentStatusBadge';
|
||||||
|
|
||||||
|
interface IncidentStatusDropdownProps {
|
||||||
|
status: string;
|
||||||
|
id: string;
|
||||||
|
onStatusUpdated: () => void;
|
||||||
|
disabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const IncidentStatusDropdown = ({
|
||||||
|
status,
|
||||||
|
id,
|
||||||
|
onStatusUpdated,
|
||||||
|
disabled = false
|
||||||
|
}: IncidentStatusDropdownProps) => {
|
||||||
|
const { t } = useLanguage();
|
||||||
|
const { toast } = useToast();
|
||||||
|
const [localStatus, setLocalStatus] = React.useState(status);
|
||||||
|
|
||||||
|
// Update local status when prop changes
|
||||||
|
React.useEffect(() => {
|
||||||
|
setLocalStatus(status);
|
||||||
|
}, [status]);
|
||||||
|
|
||||||
|
const statusOptions = [
|
||||||
|
{ value: 'investigating', label: t('investigating'), icon: <Search className="h-4 w-4 mr-2" /> },
|
||||||
|
{ value: 'identified', label: t('identified'), icon: <AlertCircle className="h-4 w-4 mr-2" /> },
|
||||||
|
{ value: 'found_root_cause', label: t('foundRootCause'), icon: <AlertCircle className="h-4 w-4 mr-2" /> },
|
||||||
|
{ value: 'in_progress', label: t('inProgress'), icon: <Wrench className="h-4 w-4 mr-2" /> },
|
||||||
|
{ value: 'monitoring', label: t('monitoring'), icon: <Gauge className="h-4 w-4 mr-2" /> },
|
||||||
|
{ value: 'resolved', label: t('resolved'), icon: <CheckCircle className="h-4 w-4 mr-2" /> },
|
||||||
|
];
|
||||||
|
|
||||||
|
const handleStatusChange = async (newStatus: string) => {
|
||||||
|
try {
|
||||||
|
// Don't update if the status is the same
|
||||||
|
if (localStatus === newStatus) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`Changing incident status from ${localStatus} to ${newStatus}`);
|
||||||
|
|
||||||
|
// Optimistically update the UI immediately
|
||||||
|
setLocalStatus(newStatus);
|
||||||
|
|
||||||
|
// Make the API call in the background
|
||||||
|
await updateIncidentStatus(id, newStatus);
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: t('statusUpdated'),
|
||||||
|
description: t('incidentStatusUpdated'),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Notify parent components about the status change
|
||||||
|
onStatusUpdated();
|
||||||
|
console.log('Status update complete, UI refresh triggered');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error updating incident status:', error);
|
||||||
|
|
||||||
|
// Revert to the original status on error
|
||||||
|
setLocalStatus(status);
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: t('error'),
|
||||||
|
description: t('failedToUpdateStatus'),
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger disabled={disabled} className="w-full cursor-pointer">
|
||||||
|
<IncidentStatusBadge status={localStatus} />
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="start" className="bg-background border border-border shadow-md z-50">
|
||||||
|
{statusOptions.map((option) => (
|
||||||
|
<DropdownMenuItem
|
||||||
|
key={option.value}
|
||||||
|
className="flex items-center cursor-pointer"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation(); // Prevent event from bubbling to table row click
|
||||||
|
handleStatusChange(option.value);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{option.icon}
|
||||||
|
{option.label}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
))}
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
|
||||||
|
// Re-export the refactored IncidentTable component
|
||||||
|
export { IncidentTable } from './table/IncidentTable';
|
||||||
+76
@@ -0,0 +1,76 @@
|
|||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import { IncidentItem } from '@/services/incident/types';
|
||||||
|
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||||
|
import { IncidentDetailHeader } from './IncidentDetailHeader';
|
||||||
|
import { Separator } from '@/components/ui/separator';
|
||||||
|
import {
|
||||||
|
BasicInfoSection,
|
||||||
|
TimelineSection,
|
||||||
|
AffectedSystemsSection,
|
||||||
|
ResolutionSection
|
||||||
|
} from './sections';
|
||||||
|
import { IncidentDetailFooter } from './IncidentDetailFooter';
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { userService } from '@/services/userService';
|
||||||
|
|
||||||
|
interface IncidentDetailContentProps {
|
||||||
|
incident: IncidentItem;
|
||||||
|
onClose: () => void;
|
||||||
|
assignedUser: any | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const IncidentDetailContent = ({
|
||||||
|
incident,
|
||||||
|
onClose,
|
||||||
|
assignedUser
|
||||||
|
}: IncidentDetailContentProps) => {
|
||||||
|
// Fetch assigned user details if one wasn't provided and there's an assigned_to field
|
||||||
|
const { data: fetchedUser } = useQuery({
|
||||||
|
queryKey: ['user', incident?.assigned_to],
|
||||||
|
queryFn: async () => {
|
||||||
|
if (!incident?.assigned_to) return null;
|
||||||
|
try {
|
||||||
|
return await userService.getUser(incident.assigned_to);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to fetch assigned user:", error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
enabled: !!incident?.assigned_to && !assignedUser,
|
||||||
|
staleTime: 300000 // Cache for 5 minutes
|
||||||
|
});
|
||||||
|
|
||||||
|
// Use the provided assignedUser or the one we fetched
|
||||||
|
const userToDisplay = assignedUser || fetchedUser;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ScrollArea className="h-[80vh] print:h-auto print:overflow-visible">
|
||||||
|
<div className="px-6 py-6">
|
||||||
|
<div className="print-section header-print">
|
||||||
|
<IncidentDetailHeader incident={incident} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-8 print-compact-spacing">
|
||||||
|
<div className="print-section">
|
||||||
|
<BasicInfoSection incident={incident} assignedUser={userToDisplay} />
|
||||||
|
</div>
|
||||||
|
<Separator className="print:border-blue-200" />
|
||||||
|
<div className="print-section">
|
||||||
|
<TimelineSection incident={incident} assignedUser={userToDisplay} />
|
||||||
|
</div>
|
||||||
|
<Separator className="print:border-blue-200" />
|
||||||
|
<div className="print-section">
|
||||||
|
<AffectedSystemsSection incident={incident} assignedUser={userToDisplay} />
|
||||||
|
</div>
|
||||||
|
<Separator className="print:border-blue-200" />
|
||||||
|
<div className="print-section">
|
||||||
|
<ResolutionSection incident={incident} assignedUser={userToDisplay} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<IncidentDetailFooter onClose={onClose} incident={incident} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</ScrollArea>
|
||||||
|
);
|
||||||
|
};
|
||||||
+54
@@ -0,0 +1,54 @@
|
|||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import { Dialog, DialogContent } from '@/components/ui/dialog';
|
||||||
|
import { IncidentItem } from '@/services/incident/types';
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { userService } from '@/services/userService';
|
||||||
|
import { IncidentDetailContent } from './IncidentDetailContent';
|
||||||
|
|
||||||
|
interface IncidentDetailDialogProps {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
incident: IncidentItem | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const IncidentDetailDialog = ({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
incident
|
||||||
|
}: IncidentDetailDialogProps) => {
|
||||||
|
// Fetch user data for assigned_to field
|
||||||
|
const { data: users = [] } = useQuery({
|
||||||
|
queryKey: ['users'],
|
||||||
|
queryFn: async () => {
|
||||||
|
const usersList = await userService.getUsers();
|
||||||
|
return Array.isArray(usersList) ? usersList : [];
|
||||||
|
},
|
||||||
|
staleTime: 300000, // Cache for 5 minutes
|
||||||
|
enabled: !!incident?.assigned_to && open // Only run query if there's an assigned_to value and dialog is open
|
||||||
|
});
|
||||||
|
|
||||||
|
// Find the assigned user
|
||||||
|
const assignedUser = incident?.assigned_to
|
||||||
|
? users.find(user => user.id === incident.assigned_to)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
if (!incident) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent
|
||||||
|
className="dialog-content sm:max-w-[700px] max-h-[90vh] p-0
|
||||||
|
print:max-w-none print:max-h-none print:overflow-visible
|
||||||
|
print:shadow-none print:m-0 print:p-0 print:border-none
|
||||||
|
print:absolute print:left-0 print:top-0 print:w-full print:h-auto"
|
||||||
|
>
|
||||||
|
<IncidentDetailContent
|
||||||
|
incident={incident}
|
||||||
|
onClose={() => onOpenChange(false)}
|
||||||
|
assignedUser={assignedUser}
|
||||||
|
/>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
+31
@@ -0,0 +1,31 @@
|
|||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import { IncidentItem } from '@/services/incident';
|
||||||
|
import { Separator } from '@/components/ui/separator';
|
||||||
|
import { CloseButton, DownloadPdfButton, PrintButton } from './components';
|
||||||
|
import { useLanguage } from '@/contexts/LanguageContext';
|
||||||
|
|
||||||
|
interface IncidentDetailFooterProps {
|
||||||
|
onClose: () => void;
|
||||||
|
incident: IncidentItem;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const IncidentDetailFooter: React.FC<IncidentDetailFooterProps> = ({
|
||||||
|
onClose,
|
||||||
|
incident,
|
||||||
|
}) => {
|
||||||
|
const { t } = useLanguage();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="print:hidden">
|
||||||
|
<Separator className="my-6" />
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<CloseButton onClose={onClose} />
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<DownloadPdfButton incident={incident} />
|
||||||
|
<PrintButton />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
+19
@@ -0,0 +1,19 @@
|
|||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import { DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||||||
|
import { IncidentItem } from '@/services/incident/types';
|
||||||
|
|
||||||
|
interface IncidentDetailHeaderProps {
|
||||||
|
incident: IncidentItem;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const IncidentDetailHeader = ({ incident }: IncidentDetailHeaderProps) => {
|
||||||
|
return (
|
||||||
|
<DialogHeader className="mb-4">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<DialogTitle className="text-xl">{incident.title || 'Incident Details'}</DialogTitle>
|
||||||
|
<span className="text-sm text-muted-foreground">#{incident.id}</span>
|
||||||
|
</div>
|
||||||
|
</DialogHeader>
|
||||||
|
);
|
||||||
|
};
|
||||||
+55
@@ -0,0 +1,55 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { useLanguage } from '@/contexts/LanguageContext';
|
||||||
|
import { IncidentItem } from '@/services/incident';
|
||||||
|
import { Separator } from '@/components/ui/separator';
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { userService } from '@/services/userService';
|
||||||
|
|
||||||
|
// Import all section components from the new location
|
||||||
|
import {
|
||||||
|
BasicInfoSection,
|
||||||
|
TimelineSection,
|
||||||
|
AffectedSystemsSection,
|
||||||
|
ResolutionSection
|
||||||
|
} from './sections';
|
||||||
|
|
||||||
|
// Re-export all section components for compatibility with existing imports
|
||||||
|
export {
|
||||||
|
BasicInfoSection,
|
||||||
|
TimelineSection,
|
||||||
|
AffectedSystemsSection,
|
||||||
|
ResolutionSection
|
||||||
|
};
|
||||||
|
|
||||||
|
// Legacy component - keeping this for backward compatibility with other imports
|
||||||
|
export const IncidentDetailSections = ({ incident }: { incident: IncidentItem | null }) => {
|
||||||
|
// Fetch assigned user details if there's an assigned_to field
|
||||||
|
const { data: assignedUser } = useQuery({
|
||||||
|
queryKey: ['user', incident?.assigned_to],
|
||||||
|
queryFn: async () => {
|
||||||
|
if (!incident?.assigned_to) return null;
|
||||||
|
try {
|
||||||
|
return await userService.getUser(incident.assigned_to);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to fetch assigned user:", error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
enabled: !!incident?.assigned_to,
|
||||||
|
staleTime: 300000 // Cache for 5 minutes
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!incident) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<BasicInfoSection incident={incident} assignedUser={assignedUser} />
|
||||||
|
<Separator />
|
||||||
|
<TimelineSection incident={incident} assignedUser={assignedUser} />
|
||||||
|
<Separator />
|
||||||
|
<AffectedSystemsSection incident={incident} assignedUser={assignedUser} />
|
||||||
|
<Separator />
|
||||||
|
<ResolutionSection incident={incident} assignedUser={assignedUser} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
+26
@@ -0,0 +1,26 @@
|
|||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { useLanguage } from '@/contexts/LanguageContext';
|
||||||
|
|
||||||
|
interface CloseButtonProps {
|
||||||
|
onClose: () => void;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CloseButton: React.FC<CloseButtonProps> = ({
|
||||||
|
onClose,
|
||||||
|
className
|
||||||
|
}) => {
|
||||||
|
const { t } = useLanguage();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
onClick={onClose}
|
||||||
|
className={className}
|
||||||
|
>
|
||||||
|
{t('close', 'common')}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
};
|
||||||
+31
@@ -0,0 +1,31 @@
|
|||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Download } from 'lucide-react';
|
||||||
|
import { useLanguage } from '@/contexts/LanguageContext';
|
||||||
|
import { useDownloadIncidentPdf } from '../hooks';
|
||||||
|
import { IncidentItem } from '@/services/incident';
|
||||||
|
|
||||||
|
interface DownloadPdfButtonProps {
|
||||||
|
incident: IncidentItem;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DownloadPdfButton: React.FC<DownloadPdfButtonProps> = ({
|
||||||
|
incident,
|
||||||
|
className
|
||||||
|
}) => {
|
||||||
|
const { t } = useLanguage();
|
||||||
|
const { handleDownloadPDF } = useDownloadIncidentPdf();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
className={`flex items-center gap-2 ${className || ''}`}
|
||||||
|
onClick={() => handleDownloadPDF(incident)}
|
||||||
|
variant="outline"
|
||||||
|
>
|
||||||
|
<Download className="h-4 w-4" />
|
||||||
|
{t('downloadPdf', 'incident')}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
};
|
||||||
+26
@@ -0,0 +1,26 @@
|
|||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Printer } from 'lucide-react';
|
||||||
|
import { useLanguage } from '@/contexts/LanguageContext';
|
||||||
|
import { usePrintIncident } from '../hooks';
|
||||||
|
|
||||||
|
interface PrintButtonProps {
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const PrintButton: React.FC<PrintButtonProps> = ({ className }) => {
|
||||||
|
const { t } = useLanguage();
|
||||||
|
const { handlePrint } = usePrintIncident();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
className={`flex items-center gap-2 ${className || ''}`}
|
||||||
|
onClick={handlePrint}
|
||||||
|
variant="default"
|
||||||
|
>
|
||||||
|
<Printer className="h-4 w-4" />
|
||||||
|
{t('print', 'incident')}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
};
|
||||||
+4
@@ -0,0 +1,4 @@
|
|||||||
|
|
||||||
|
export * from './PrintButton';
|
||||||
|
export * from './DownloadPdfButton';
|
||||||
|
export * from './CloseButton';
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
|
||||||
|
export * from './usePrintIncident';
|
||||||
|
export * from './useDownloadIncidentPdf';
|
||||||
+29
@@ -0,0 +1,29 @@
|
|||||||
|
|
||||||
|
import { useToast } from '@/hooks/use-toast';
|
||||||
|
import { useLanguage } from '@/contexts/LanguageContext';
|
||||||
|
import { IncidentItem, incidentService } from '@/services/incident';
|
||||||
|
|
||||||
|
export const useDownloadIncidentPdf = () => {
|
||||||
|
const { toast } = useToast();
|
||||||
|
const { t } = useLanguage();
|
||||||
|
|
||||||
|
const handleDownloadPDF = async (incident: IncidentItem) => {
|
||||||
|
try {
|
||||||
|
await incidentService.generateIncidentPDF(incident);
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: t('success'),
|
||||||
|
description: t('pdfDownloaded'),
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error generating PDF:", error);
|
||||||
|
toast({
|
||||||
|
title: t('error'),
|
||||||
|
description: t('pdfGenerationFailed'),
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return { handleDownloadPDF };
|
||||||
|
};
|
||||||
+173
@@ -0,0 +1,173 @@
|
|||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import { useToast } from '@/hooks/use-toast';
|
||||||
|
import { useLanguage } from '@/contexts/LanguageContext';
|
||||||
|
|
||||||
|
export const usePrintIncident = () => {
|
||||||
|
const { toast } = useToast();
|
||||||
|
const { t } = useLanguage();
|
||||||
|
|
||||||
|
const handlePrint = React.useCallback(() => {
|
||||||
|
try {
|
||||||
|
// Add print-specific stylesheet temporarily
|
||||||
|
const style = document.createElement('style');
|
||||||
|
style.id = 'print-style';
|
||||||
|
style.textContent = `
|
||||||
|
@page {
|
||||||
|
size: A4;
|
||||||
|
margin: 10mm;
|
||||||
|
}
|
||||||
|
@media print {
|
||||||
|
body * {
|
||||||
|
visibility: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog-content, .dialog-content * {
|
||||||
|
visibility: visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog-content {
|
||||||
|
position: absolute !important;
|
||||||
|
left: 0;
|
||||||
|
top: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: auto;
|
||||||
|
padding: 15mm !important;
|
||||||
|
margin: 0 !important;
|
||||||
|
background-color: white !important;
|
||||||
|
box-shadow: none;
|
||||||
|
overflow: visible !important;
|
||||||
|
display: block !important;
|
||||||
|
transform: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Remove any black backgrounds */
|
||||||
|
html, body {
|
||||||
|
background-color: white !important;
|
||||||
|
color: black !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Optimize spacing for single page */
|
||||||
|
.print-section {
|
||||||
|
margin-bottom: 2mm !important;
|
||||||
|
page-break-inside: avoid !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Reduce vertical spacing */
|
||||||
|
h4 {
|
||||||
|
margin-bottom: 1mm !important;
|
||||||
|
margin-top: 1mm !important;
|
||||||
|
color: #1e40af !important; /* blue-800 */
|
||||||
|
font-weight: bold !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.print-compact-text {
|
||||||
|
font-size: 9pt !important;
|
||||||
|
line-height: 1.2 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.print-compact-spacing > * {
|
||||||
|
margin-top: 1mm !important;
|
||||||
|
margin-bottom: 1mm !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.print-grid {
|
||||||
|
display: grid !important;
|
||||||
|
grid-template-columns: 1fr 1fr !important;
|
||||||
|
gap: 1mm !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-print {
|
||||||
|
background-color: #dbeafe !important; /* blue-100 */
|
||||||
|
color: #1e40af !important; /* blue-800 */
|
||||||
|
border: 1px solid #93c5fd !important; /* blue-300 */
|
||||||
|
padding: 0.5mm 1mm !important;
|
||||||
|
margin: 0.5mm !important;
|
||||||
|
display: inline-block !important;
|
||||||
|
font-size: 8pt !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* More compact spacing */
|
||||||
|
.print-compact-margin {
|
||||||
|
margin: 1mm 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.print-smaller-font {
|
||||||
|
font-size: 8pt !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-print {
|
||||||
|
background-color: #1e40af !important; /* blue-800 */
|
||||||
|
color: #ffffff !important;
|
||||||
|
padding: 2mm 3mm !important;
|
||||||
|
margin-bottom: 2mm !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* More compact header */
|
||||||
|
.header-print h1 {
|
||||||
|
font-size: 12pt !important;
|
||||||
|
margin-bottom: 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-print p {
|
||||||
|
font-size: 9pt !important;
|
||||||
|
margin-top: 1mm !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Footer stays at bottom */
|
||||||
|
.footer-print {
|
||||||
|
font-size: 7pt !important;
|
||||||
|
color: #6b7280 !important; /* gray-500 */
|
||||||
|
border-top: 1px solid #e5e7eb !important; /* gray-200 */
|
||||||
|
position: fixed !important;
|
||||||
|
bottom: 10mm !important;
|
||||||
|
left: 15mm !important;
|
||||||
|
right: 15mm !important;
|
||||||
|
padding-top: 2mm !important;
|
||||||
|
background-color: white !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Hide any unnecessary elements */
|
||||||
|
.print-hide {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Optimize description text */
|
||||||
|
.print-description {
|
||||||
|
max-height: 100px !important;
|
||||||
|
overflow: hidden !important;
|
||||||
|
text-overflow: ellipsis !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
document.head.appendChild(style);
|
||||||
|
|
||||||
|
// Wait a bit to ensure styles are applied
|
||||||
|
setTimeout(() => {
|
||||||
|
window.print();
|
||||||
|
|
||||||
|
// Remove the style after printing dialog closes
|
||||||
|
setTimeout(() => {
|
||||||
|
const printStyle = document.getElementById('print-style');
|
||||||
|
if (printStyle) {
|
||||||
|
printStyle.remove();
|
||||||
|
}
|
||||||
|
}, 1000);
|
||||||
|
}, 100);
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: t('success'),
|
||||||
|
description: t('printJobStarted'),
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error printing:", error);
|
||||||
|
toast({
|
||||||
|
title: t('error'),
|
||||||
|
description: t('printingFailed'),
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [t, toast]);
|
||||||
|
|
||||||
|
return { handlePrint };
|
||||||
|
};
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
|
||||||
|
export * from './IncidentDetailDialog';
|
||||||
|
export * from './IncidentDetailHeader';
|
||||||
|
export * from './IncidentDetailContent';
|
||||||
|
export * from './IncidentDetailSections';
|
||||||
|
export * from './IncidentDetailFooter';
|
||||||
+66
@@ -0,0 +1,66 @@
|
|||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import { useLanguage } from '@/contexts/LanguageContext';
|
||||||
|
import { IncidentItem } from '@/services/incident';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { getAffectedSystemsArray } from './utils';
|
||||||
|
|
||||||
|
interface AffectedSystemsSectionProps {
|
||||||
|
incident: IncidentItem | null;
|
||||||
|
assignedUser?: any | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AffectedSystemsSection: React.FC<AffectedSystemsSectionProps> = ({ incident }) => {
|
||||||
|
const { t } = useLanguage();
|
||||||
|
|
||||||
|
if (!incident) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<h3 className="font-semibold text-lg">{t('affectedSystems')}</h3>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<h4 className="text-sm font-medium text-muted-foreground">{t('systems')}</h4>
|
||||||
|
<div className="mt-1 flex flex-wrap gap-1">
|
||||||
|
{getAffectedSystemsArray(incident.affected_systems).map((system, idx) => (
|
||||||
|
<Badge key={idx} variant="outline">{system}</Badge>
|
||||||
|
))}
|
||||||
|
{getAffectedSystemsArray(incident.affected_systems).length === 0 &&
|
||||||
|
<span className="text-muted-foreground italic">{t('noSystems')}</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<h4 className="text-sm font-medium text-muted-foreground">{t('impact')}</h4>
|
||||||
|
<div className="mt-1">
|
||||||
|
<Badge variant={
|
||||||
|
incident.impact?.toLowerCase() === 'critical' ? 'destructive' :
|
||||||
|
incident.impact?.toLowerCase() === 'high' ? 'default' :
|
||||||
|
incident.impact?.toLowerCase() === 'medium' ? 'secondary' : 'outline'
|
||||||
|
}>
|
||||||
|
{t(incident.impact?.toLowerCase() || 'low')}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h4 className="text-sm font-medium text-muted-foreground">{t('priority')}</h4>
|
||||||
|
<div className="mt-1">
|
||||||
|
<Badge variant={
|
||||||
|
incident.priority?.toLowerCase() === 'critical' ? 'destructive' :
|
||||||
|
incident.priority?.toLowerCase() === 'high' ? 'default' :
|
||||||
|
incident.priority?.toLowerCase() === 'medium' ? 'secondary' : 'outline'
|
||||||
|
}>
|
||||||
|
{t(incident.priority?.toLowerCase() || 'low')}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
+44
@@ -0,0 +1,44 @@
|
|||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import { useLanguage } from '@/contexts/LanguageContext';
|
||||||
|
import { IncidentItem } from '@/services/incident';
|
||||||
|
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
||||||
|
import { User } from '@/services/userService';
|
||||||
|
import { getUserInitials } from './utils';
|
||||||
|
|
||||||
|
interface AssignmentSectionProps {
|
||||||
|
incident: IncidentItem | null;
|
||||||
|
assignedUser?: User | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AssignmentSection: React.FC<AssignmentSectionProps> = ({ incident, assignedUser }) => {
|
||||||
|
const { t } = useLanguage();
|
||||||
|
|
||||||
|
if (!incident) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<h3 className="font-semibold text-lg">{t('assignment')}</h3>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h4 className="text-sm font-medium text-muted-foreground">{t('assignedTo')}</h4>
|
||||||
|
<div className="mt-1">
|
||||||
|
{assignedUser ? (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Avatar className="h-6 w-6">
|
||||||
|
<AvatarImage src={assignedUser.avatar} alt={assignedUser.full_name || assignedUser.username} />
|
||||||
|
<AvatarFallback>{getUserInitials(assignedUser)}</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
<span>{assignedUser.full_name || assignedUser.username}</span>
|
||||||
|
</div>
|
||||||
|
) : incident.assigned_to ? (
|
||||||
|
<span>{incident.assigned_to}</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-muted-foreground italic">{t('unassigned')}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
+69
@@ -0,0 +1,69 @@
|
|||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import { useLanguage } from '@/contexts/LanguageContext';
|
||||||
|
import { IncidentItem } from '@/services/incident';
|
||||||
|
import { User } from '@/services/userService';
|
||||||
|
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
||||||
|
import { IncidentStatusBadge } from '../../IncidentStatusBadge';
|
||||||
|
import { getUserInitials } from './utils';
|
||||||
|
|
||||||
|
interface BasicInfoSectionProps {
|
||||||
|
incident: IncidentItem | null;
|
||||||
|
assignedUser?: User | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const BasicInfoSection: React.FC<BasicInfoSectionProps> = ({ incident, assignedUser }) => {
|
||||||
|
const { t } = useLanguage();
|
||||||
|
|
||||||
|
if (!incident) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-2 print-compact-text">
|
||||||
|
<h3 className="font-semibold text-lg">{t('basicInfo')}</h3>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 print-grid">
|
||||||
|
<div>
|
||||||
|
<h4 className="text-sm font-medium text-muted-foreground">{t('title')}</h4>
|
||||||
|
<p className="mt-1">{incident.title || '-'}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h4 className="text-sm font-medium text-muted-foreground">{t('status')}</h4>
|
||||||
|
<div className="mt-1">
|
||||||
|
<IncidentStatusBadge status={incident.status || incident.impact_status || 'investigating'} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h4 className="text-sm font-medium text-muted-foreground">{t('serviceId')}</h4>
|
||||||
|
<p className="mt-1">{incident.service_id || '-'}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h4 className="text-sm font-medium text-muted-foreground">{t('assignedTo')}</h4>
|
||||||
|
<div className="mt-1">
|
||||||
|
{assignedUser ? (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Avatar className="h-6 w-6">
|
||||||
|
<AvatarImage src={assignedUser.avatar} alt={assignedUser.full_name || assignedUser.username} />
|
||||||
|
<AvatarFallback>{getUserInitials(assignedUser)}</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
<span>{assignedUser.full_name || assignedUser.username}</span>
|
||||||
|
</div>
|
||||||
|
) : incident.assigned_to ? (
|
||||||
|
<span>{incident.assigned_to}</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-muted-foreground italic">{t('unassigned')}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-2">
|
||||||
|
<h4 className="text-sm font-medium text-muted-foreground">{t('description')}</h4>
|
||||||
|
<p className="mt-1 whitespace-pre-line print-description">{incident.description || '-'}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
+51
@@ -0,0 +1,51 @@
|
|||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import { useLanguage } from '@/contexts/LanguageContext';
|
||||||
|
import { IncidentItem } from '@/services/incident';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
|
||||||
|
interface ImpactAnalysisSectionProps {
|
||||||
|
incident: IncidentItem | null;
|
||||||
|
assignedUser?: any | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ImpactAnalysisSection: React.FC<ImpactAnalysisSectionProps> = ({ incident }) => {
|
||||||
|
const { t } = useLanguage();
|
||||||
|
|
||||||
|
if (!incident) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<h3 className="font-semibold text-lg">{t('impactAnalysis')}</h3>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<h4 className="text-sm font-medium text-muted-foreground">{t('impact')}</h4>
|
||||||
|
<div className="mt-1">
|
||||||
|
<Badge variant={
|
||||||
|
incident.impact?.toLowerCase() === 'critical' ? 'destructive' :
|
||||||
|
incident.impact?.toLowerCase() === 'high' ? 'default' :
|
||||||
|
incident.impact?.toLowerCase() === 'medium' ? 'secondary' : 'outline'
|
||||||
|
}>
|
||||||
|
{t(incident.impact?.toLowerCase() || 'low')}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h4 className="text-sm font-medium text-muted-foreground">{t('priority')}</h4>
|
||||||
|
<div className="mt-1">
|
||||||
|
<Badge variant={
|
||||||
|
incident.priority?.toLowerCase() === 'critical' ? 'destructive' :
|
||||||
|
incident.priority?.toLowerCase() === 'high' ? 'default' :
|
||||||
|
incident.priority?.toLowerCase() === 'medium' ? 'secondary' : 'outline'
|
||||||
|
}>
|
||||||
|
{t(incident.priority?.toLowerCase() || 'low')}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
+39
@@ -0,0 +1,39 @@
|
|||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import { useLanguage } from '@/contexts/LanguageContext';
|
||||||
|
import { IncidentItem } from '@/services/incident';
|
||||||
|
|
||||||
|
interface ResolutionSectionProps {
|
||||||
|
incident: IncidentItem | null;
|
||||||
|
assignedUser?: any | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ResolutionSection: React.FC<ResolutionSectionProps> = ({ incident }) => {
|
||||||
|
const { t } = useLanguage();
|
||||||
|
|
||||||
|
if (!incident) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<h3 className="font-semibold text-lg">{t('resolutionDetails')}</h3>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<h4 className="text-sm font-medium text-muted-foreground">{t('rootCause')}</h4>
|
||||||
|
<p className="mt-1 whitespace-pre-line">{incident.root_cause || '-'}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h4 className="text-sm font-medium text-muted-foreground">{t('resolutionSteps')}</h4>
|
||||||
|
<p className="mt-1 whitespace-pre-line">{incident.resolution_steps || '-'}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h4 className="text-sm font-medium text-muted-foreground">{t('lessonsLearned')}</h4>
|
||||||
|
<p className="mt-1 whitespace-pre-line">{incident.lessons_learned || '-'}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
+45
@@ -0,0 +1,45 @@
|
|||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import { useLanguage } from '@/contexts/LanguageContext';
|
||||||
|
import { IncidentItem } from '@/services/incident';
|
||||||
|
import { formatDate } from './utils';
|
||||||
|
|
||||||
|
interface TimelineSectionProps {
|
||||||
|
incident: IncidentItem | null;
|
||||||
|
assignedUser?: any | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const TimelineSection: React.FC<TimelineSectionProps> = ({ incident }) => {
|
||||||
|
const { t } = useLanguage();
|
||||||
|
|
||||||
|
if (!incident) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<h3 className="font-semibold text-lg">{t('timeline')}</h3>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<h4 className="text-sm font-medium text-muted-foreground">{t('created')}</h4>
|
||||||
|
<p className="mt-1">{formatDate(incident.created)}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h4 className="text-sm font-medium text-muted-foreground">{t('lastUpdated')}</h4>
|
||||||
|
<p className="mt-1">{formatDate(incident.updated)}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h4 className="text-sm font-medium text-muted-foreground">{t('incidentTime')}</h4>
|
||||||
|
<p className="mt-1">{formatDate(incident.timestamp)}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h4 className="text-sm font-medium text-muted-foreground">{t('resolutionTime')}</h4>
|
||||||
|
<p className="mt-1">{formatDate(incident.resolution_time)}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
|
||||||
|
// Export all section components
|
||||||
|
export * from './BasicInfoSection';
|
||||||
|
export * from './TimelineSection';
|
||||||
|
export * from './AffectedSystemsSection';
|
||||||
|
export * from './ResolutionSection';
|
||||||
|
export * from './utils';
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
|
||||||
|
import { User } from '@/services/userService';
|
||||||
|
import { format } from 'date-fns';
|
||||||
|
|
||||||
|
// Helper function to get user initials
|
||||||
|
export const getUserInitials = (user: User): string => {
|
||||||
|
if (user.full_name) {
|
||||||
|
const nameParts = user.full_name.split(' ');
|
||||||
|
if (nameParts.length > 1) {
|
||||||
|
return `${nameParts[0][0]}${nameParts[1][0]}`.toUpperCase();
|
||||||
|
}
|
||||||
|
return user.full_name.substring(0, 2).toUpperCase();
|
||||||
|
}
|
||||||
|
return user.username.substring(0, 2).toUpperCase();
|
||||||
|
};
|
||||||
|
|
||||||
|
// Format date helper
|
||||||
|
export const formatDate = (dateStr?: string) => {
|
||||||
|
if (!dateStr) return '-';
|
||||||
|
try {
|
||||||
|
return format(new Date(dateStr), 'PPp');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error formatting date:', dateStr, error);
|
||||||
|
return dateStr;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Get affected systems helper
|
||||||
|
export const getAffectedSystemsArray = (systems?: string) => {
|
||||||
|
if (!systems) return [];
|
||||||
|
return systems.split(',').map(system => system.trim()).filter(Boolean);
|
||||||
|
};
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import { useFormContext } from 'react-hook-form';
|
||||||
|
import { useLanguage } from '@/contexts/LanguageContext';
|
||||||
|
import { IncidentFormValues } from '../hooks/useIncidentForm';
|
||||||
|
import {
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormControl,
|
||||||
|
FormMessage,
|
||||||
|
} from '@/components/ui/form';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
|
|
||||||
|
export const IncidentAffectedFields: React.FC = () => {
|
||||||
|
const { t } = useLanguage();
|
||||||
|
const { control } = useFormContext<IncidentFormValues>();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<FormField
|
||||||
|
control={control}
|
||||||
|
name="affected_systems"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>{t('affectedSystems')}</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input placeholder={t('enterAffectedSystems')} {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
<p className="text-sm text-muted-foreground">{t('separateSystemsWithComma')}</p>
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={control}
|
||||||
|
name="root_cause"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>{t('rootCause')}</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Textarea
|
||||||
|
placeholder={t('enterRootCause')}
|
||||||
|
className="min-h-[80px]"
|
||||||
|
{...field}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,201 @@
|
|||||||
|
|
||||||
|
import React, { useEffect } from 'react';
|
||||||
|
import { useFormContext } from 'react-hook-form';
|
||||||
|
import { useLanguage } from '@/contexts/LanguageContext';
|
||||||
|
import { IncidentFormValues } from '../hooks/useIncidentForm';
|
||||||
|
import {
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormControl,
|
||||||
|
FormMessage,
|
||||||
|
} from '@/components/ui/form';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { userService } from '@/services/userService';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { X, Users } from 'lucide-react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
||||||
|
|
||||||
|
export const IncidentBasicFields: React.FC = () => {
|
||||||
|
const { t } = useLanguage();
|
||||||
|
const form = useFormContext<IncidentFormValues>();
|
||||||
|
const currentAssignedUserId = form.watch('assigned_to');
|
||||||
|
|
||||||
|
// For assigned users functionality
|
||||||
|
const [selectedUserIds, setSelectedUserIds] = React.useState<string[]>([]);
|
||||||
|
|
||||||
|
// Update selectedUserIds when form value changes (for edit mode)
|
||||||
|
useEffect(() => {
|
||||||
|
if (currentAssignedUserId) {
|
||||||
|
setSelectedUserIds([currentAssignedUserId]);
|
||||||
|
}
|
||||||
|
}, [currentAssignedUserId]);
|
||||||
|
|
||||||
|
// Fetch users for assignment
|
||||||
|
const { data: users = [], isLoading } = useQuery({
|
||||||
|
queryKey: ['users'],
|
||||||
|
queryFn: async () => {
|
||||||
|
try {
|
||||||
|
const usersList = await userService.getUsers();
|
||||||
|
console.log("Fetched users for incident assignment:", usersList);
|
||||||
|
return Array.isArray(usersList) ? usersList : [];
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to fetch users:", error);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
},
|
||||||
|
staleTime: 300000 // Cache for 5 minutes
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add user to assigned_to
|
||||||
|
const addUser = (userId: string) => {
|
||||||
|
// For now, we're using a single user assignment
|
||||||
|
console.log("Setting user ID in form:", userId);
|
||||||
|
form.setValue('assigned_to', userId, { shouldValidate: true, shouldDirty: true });
|
||||||
|
setSelectedUserIds([userId]);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Remove assigned user
|
||||||
|
const removeUser = () => {
|
||||||
|
form.setValue('assigned_to', '', { shouldValidate: true, shouldDirty: true });
|
||||||
|
setSelectedUserIds([]);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Get selected user data
|
||||||
|
const selectedUser = users.find(user => user.id === form.getValues('assigned_to'));
|
||||||
|
|
||||||
|
// Function to get user initials from name
|
||||||
|
const getUserInitials = (user: any): string => {
|
||||||
|
if (user.full_name) {
|
||||||
|
const nameParts = user.full_name.split(' ');
|
||||||
|
if (nameParts.length > 1) {
|
||||||
|
return `${nameParts[0][0]}${nameParts[1][0]}`.toUpperCase();
|
||||||
|
}
|
||||||
|
return user.full_name.substring(0, 2).toUpperCase();
|
||||||
|
}
|
||||||
|
return user.username.substring(0, 2).toUpperCase();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="title"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>{t('title')}</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input placeholder={t('enterIncidentTitle')} {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="description"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>{t('description')}</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Textarea
|
||||||
|
placeholder={t('enterIncidentDescription')}
|
||||||
|
className="min-h-[100px]"
|
||||||
|
{...field}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="service_id"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>{t('serviceId')}</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input placeholder={t('enterServiceId')} {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="assigned_to"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="flex flex-col">
|
||||||
|
<FormLabel className="flex items-center gap-1">
|
||||||
|
<Users className="h-4 w-4" /> {t('assignedTo')}
|
||||||
|
</FormLabel>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<FormControl>
|
||||||
|
<select
|
||||||
|
id="assigned-user-select"
|
||||||
|
className="w-full h-10 rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
value={field.value || ""}
|
||||||
|
onChange={(e) => {
|
||||||
|
const selectedValue = e.target.value;
|
||||||
|
console.log("Selected user ID:", selectedValue);
|
||||||
|
if (selectedValue) {
|
||||||
|
addUser(selectedValue);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onBlur={field.onBlur}
|
||||||
|
disabled={field.disabled}
|
||||||
|
name={field.name}
|
||||||
|
>
|
||||||
|
<option value="">{t('selectAssignedUser')}</option>
|
||||||
|
{users.map(user => (
|
||||||
|
<option
|
||||||
|
key={user.id}
|
||||||
|
value={user.id}
|
||||||
|
>
|
||||||
|
{user.full_name || user.username}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</FormControl>
|
||||||
|
|
||||||
|
{selectedUser ? (
|
||||||
|
<div className="flex flex-wrap gap-2 mt-2 border p-2 rounded-md bg-muted/50">
|
||||||
|
<Badge key={selectedUser.id} variant="secondary" className="flex items-center gap-1 py-1 px-2">
|
||||||
|
<Avatar className="h-5 w-5 mr-1">
|
||||||
|
<AvatarImage src={selectedUser.avatar} alt={selectedUser.full_name || selectedUser.username} />
|
||||||
|
<AvatarFallback className="text-[10px]">
|
||||||
|
{getUserInitials(selectedUser)}
|
||||||
|
</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
<span>{selectedUser.full_name || selectedUser.username}</span>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-4 w-4 p-0 ml-1 hover:bg-transparent hover:opacity-70"
|
||||||
|
onClick={removeUser}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<X className="h-3 w-3" />
|
||||||
|
<span className="sr-only">{t('remove')}</span>
|
||||||
|
</Button>
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="text-sm text-muted-foreground italic p-2">
|
||||||
|
{t('noAssignedUser')}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import { useFormContext } from 'react-hook-form';
|
||||||
|
import { useLanguage } from '@/contexts/LanguageContext';
|
||||||
|
import { IncidentFormValues } from '../hooks/useIncidentForm';
|
||||||
|
import {
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormControl,
|
||||||
|
FormMessage,
|
||||||
|
} from '@/components/ui/form';
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from '@/components/ui/select';
|
||||||
|
import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group';
|
||||||
|
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
|
||||||
|
export const IncidentConfigFields: React.FC = () => {
|
||||||
|
const { t } = useLanguage();
|
||||||
|
const { control } = useFormContext<IncidentFormValues>();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-1 gap-4">
|
||||||
|
<FormField
|
||||||
|
control={control}
|
||||||
|
name="status"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>{t('status')}</FormLabel>
|
||||||
|
<Select onValueChange={field.onChange} defaultValue={field.value}>
|
||||||
|
<FormControl>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder={t('selectIncidentStatus')} />
|
||||||
|
</SelectTrigger>
|
||||||
|
</FormControl>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="investigating">{t('investigating')}</SelectItem>
|
||||||
|
<SelectItem value="identified">{t('identified')}</SelectItem>
|
||||||
|
<SelectItem value="found_root_cause">{t('foundRootCause')}</SelectItem>
|
||||||
|
<SelectItem value="in_progress">{t('inProgress')}</SelectItem>
|
||||||
|
<SelectItem value="monitoring">{t('monitoring')}</SelectItem>
|
||||||
|
<SelectItem value="resolved">{t('resolved')}</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={control}
|
||||||
|
name="impact"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="space-y-2">
|
||||||
|
<FormLabel>{t('impact')}</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<RadioGroup
|
||||||
|
onValueChange={field.onChange}
|
||||||
|
defaultValue={field.value}
|
||||||
|
className="flex space-x-1"
|
||||||
|
>
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<RadioGroupItem value="minor" id="minor" />
|
||||||
|
<Label htmlFor="minor">{t('minor')}</Label>
|
||||||
|
</div>
|
||||||
|
<span>•</span>
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<RadioGroupItem value="major" id="major" />
|
||||||
|
<Label htmlFor="major">{t('major')}</Label>
|
||||||
|
</div>
|
||||||
|
<span>•</span>
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<RadioGroupItem value="critical" id="critical" />
|
||||||
|
<Label htmlFor="critical">{t('critical')}</Label>
|
||||||
|
</div>
|
||||||
|
<span>•</span>
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<RadioGroupItem value="none" id="none" />
|
||||||
|
<Label htmlFor="none">{t('none')}</Label>
|
||||||
|
</div>
|
||||||
|
</RadioGroup>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={control}
|
||||||
|
name="priority"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>{t('priority')}</FormLabel>
|
||||||
|
<Select onValueChange={field.onChange} defaultValue={field.value}>
|
||||||
|
<FormControl>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder={t('selectPriorityLevel')} />
|
||||||
|
</SelectTrigger>
|
||||||
|
</FormControl>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="low">{t('low')}</SelectItem>
|
||||||
|
<SelectItem value="medium">{t('medium')}</SelectItem>
|
||||||
|
<SelectItem value="high">{t('high')}</SelectItem>
|
||||||
|
<SelectItem value="critical">{t('critical')}</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import { useFormContext } from 'react-hook-form';
|
||||||
|
import { useLanguage } from '@/contexts/LanguageContext';
|
||||||
|
import { IncidentFormValues } from '../hooks/useIncidentForm';
|
||||||
|
import {
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormControl,
|
||||||
|
FormMessage,
|
||||||
|
} from '@/components/ui/form';
|
||||||
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
|
|
||||||
|
export const IncidentDetailsFields: React.FC = () => {
|
||||||
|
const { t } = useLanguage();
|
||||||
|
const { control } = useFormContext<IncidentFormValues>();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<FormField
|
||||||
|
control={control}
|
||||||
|
name="resolution_steps"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>{t('resolutionSteps')}</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Textarea
|
||||||
|
placeholder={t('enterResolutionSteps')}
|
||||||
|
className="min-h-[80px]"
|
||||||
|
{...field}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={control}
|
||||||
|
name="lessons_learned"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>{t('lessonsLearned')}</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Textarea
|
||||||
|
placeholder={t('enterLessonsLearned')}
|
||||||
|
className="min-h-[80px]"
|
||||||
|
{...field}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
|
||||||
|
export * from './IncidentBasicFields';
|
||||||
|
export * from './IncidentAffectedFields';
|
||||||
|
export * from './IncidentConfigFields';
|
||||||
|
export * from './IncidentDetailsFields';
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
|
||||||
|
import { useForm } from 'react-hook-form';
|
||||||
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
|
import { useToast } from '@/hooks/use-toast';
|
||||||
|
import { incidentService, IncidentItem } from '@/services/incident';
|
||||||
|
import { useLanguage } from '@/contexts/LanguageContext';
|
||||||
|
import { incidentFormSchema, IncidentFormValues } from './useIncidentForm';
|
||||||
|
|
||||||
|
export const useIncidentEditForm = (
|
||||||
|
incident: IncidentItem,
|
||||||
|
onSuccess: () => void,
|
||||||
|
onClose: () => void
|
||||||
|
) => {
|
||||||
|
const { t } = useLanguage();
|
||||||
|
const { toast } = useToast();
|
||||||
|
|
||||||
|
// Initialize form with existing incident data
|
||||||
|
const form = useForm<IncidentFormValues>({
|
||||||
|
resolver: zodResolver(incidentFormSchema),
|
||||||
|
defaultValues: {
|
||||||
|
title: incident.title || '',
|
||||||
|
description: incident.description || '',
|
||||||
|
affected_systems: incident.affected_systems || '',
|
||||||
|
status: (incident.status?.toLowerCase() || incident.impact_status?.toLowerCase() || 'investigating') as any,
|
||||||
|
impact: (incident.impact?.toLowerCase() || 'minor') as any,
|
||||||
|
priority: (incident.priority?.toLowerCase() || 'medium') as any,
|
||||||
|
service_id: incident.service_id || '',
|
||||||
|
assigned_to: incident.assigned_to || '',
|
||||||
|
root_cause: incident.root_cause || '',
|
||||||
|
resolution_steps: incident.resolution_steps || '',
|
||||||
|
lessons_learned: incident.lessons_learned || '',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const onSubmit = async (data: IncidentFormValues) => {
|
||||||
|
try {
|
||||||
|
console.log("Form data for update:", data);
|
||||||
|
console.log("Assigned user ID for update:", data.assigned_to);
|
||||||
|
|
||||||
|
await incidentService.updateIncident(incident.id, {
|
||||||
|
title: data.title,
|
||||||
|
description: data.description,
|
||||||
|
status: data.status,
|
||||||
|
affected_systems: data.affected_systems,
|
||||||
|
impact: data.impact,
|
||||||
|
priority: data.priority,
|
||||||
|
service_id: data.service_id,
|
||||||
|
assigned_to: data.assigned_to, // This is the user ID from the form
|
||||||
|
root_cause: data.root_cause,
|
||||||
|
resolution_steps: data.resolution_steps,
|
||||||
|
lessons_learned: data.lessons_learned,
|
||||||
|
});
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: t('incidentUpdated'),
|
||||||
|
description: t('incidentUpdatedDesc'),
|
||||||
|
});
|
||||||
|
|
||||||
|
onClose();
|
||||||
|
onSuccess();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error updating incident:', error);
|
||||||
|
|
||||||
|
if (error instanceof Error) {
|
||||||
|
toast({
|
||||||
|
title: t('error'),
|
||||||
|
description: `${t('errorUpdatingIncident')}: ${error.message}`,
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
toast({
|
||||||
|
title: t('error'),
|
||||||
|
description: t('errorUpdatingIncident'),
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
form,
|
||||||
|
onSubmit: form.handleSubmit(onSubmit),
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
import { useForm } from 'react-hook-form';
|
||||||
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
|
import * as z from 'zod';
|
||||||
|
import { pb } from '@/lib/pocketbase';
|
||||||
|
import { useToast } from '@/hooks/use-toast';
|
||||||
|
import { incidentService, CreateIncidentInput } from '@/services/incident';
|
||||||
|
import { useLanguage } from '@/contexts/LanguageContext';
|
||||||
|
|
||||||
|
// Define the schema for our incident form
|
||||||
|
export const incidentFormSchema = z.object({
|
||||||
|
title: z.string().min(1, { message: 'Title is required' }),
|
||||||
|
description: z.string().min(1, { message: 'Description is required' }),
|
||||||
|
affected_systems: z.string().min(1, { message: 'Affected systems are required' }),
|
||||||
|
status: z.enum(['investigating', 'found_root_cause', 'in_progress', 'monitoring', 'resolved']),
|
||||||
|
impact: z.enum(['none', 'minor', 'major', 'critical']),
|
||||||
|
priority: z.enum(['low', 'medium', 'high', 'critical']),
|
||||||
|
service_id: z.string().optional(),
|
||||||
|
assigned_to: z.string().optional(),
|
||||||
|
root_cause: z.string().optional(),
|
||||||
|
resolution_steps: z.string().optional(),
|
||||||
|
lessons_learned: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type IncidentFormValues = z.infer<typeof incidentFormSchema>;
|
||||||
|
|
||||||
|
export const useIncidentForm = (onSuccess: () => void, onClose: () => void) => {
|
||||||
|
const { t } = useLanguage();
|
||||||
|
const { toast } = useToast();
|
||||||
|
|
||||||
|
const form = useForm<IncidentFormValues>({
|
||||||
|
resolver: zodResolver(incidentFormSchema),
|
||||||
|
defaultValues: {
|
||||||
|
title: '',
|
||||||
|
description: '',
|
||||||
|
affected_systems: '',
|
||||||
|
status: 'investigating',
|
||||||
|
impact: 'minor',
|
||||||
|
priority: 'medium',
|
||||||
|
service_id: '',
|
||||||
|
assigned_to: '',
|
||||||
|
root_cause: '',
|
||||||
|
resolution_steps: '',
|
||||||
|
lessons_learned: '',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const onSubmit = async (data: IncidentFormValues) => {
|
||||||
|
try {
|
||||||
|
console.log("Form data before submission:", data);
|
||||||
|
|
||||||
|
const formattedData: CreateIncidentInput = {
|
||||||
|
title: data.title,
|
||||||
|
description: data.description,
|
||||||
|
status: data.status,
|
||||||
|
affected_systems: data.affected_systems,
|
||||||
|
impact: data.impact,
|
||||||
|
priority: data.priority,
|
||||||
|
service_id: data.service_id,
|
||||||
|
assigned_to: data.assigned_to,
|
||||||
|
root_cause: data.root_cause,
|
||||||
|
resolution_steps: data.resolution_steps,
|
||||||
|
lessons_learned: data.lessons_learned,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
created_by: pb.authStore.model?.id || '',
|
||||||
|
};
|
||||||
|
|
||||||
|
console.log("Formatted data for API:", formattedData);
|
||||||
|
|
||||||
|
await incidentService.createIncident(formattedData);
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: t('incidentCreated'),
|
||||||
|
description: t('incidentCreatedDesc'),
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log("Incident created successfully, about to call onSuccess and onClose");
|
||||||
|
|
||||||
|
form.reset();
|
||||||
|
onClose();
|
||||||
|
onSuccess();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error creating incident:', error);
|
||||||
|
|
||||||
|
if (error instanceof Error) {
|
||||||
|
console.error('Error details:', error.message);
|
||||||
|
toast({
|
||||||
|
title: t('error'),
|
||||||
|
description: `${t('errorCreatingIncident')}: ${error.message}`,
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
toast({
|
||||||
|
title: t('error'),
|
||||||
|
description: t('errorCreatingIncident'),
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
form,
|
||||||
|
onSubmit: form.handleSubmit(onSubmit),
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
|
||||||
|
export * from './IncidentTable';
|
||||||
|
export * from './IncidentActionsMenu';
|
||||||
|
export * from './IncidentStatusBadge';
|
||||||
|
export * from './IncidentStatusDropdown';
|
||||||
|
export * from './CreateIncidentDialog';
|
||||||
|
export * from './EditIncidentDialog';
|
||||||
|
export * from './EmptyIncidentState';
|
||||||
|
export * from './detail-dialog';
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
|
||||||
|
import React, { useState, useCallback } from 'react';
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from '@/components/ui/table';
|
||||||
|
import { format } from 'date-fns';
|
||||||
|
import { IncidentItem } from '@/services/incident/types';
|
||||||
|
import { IncidentTableRow } from './IncidentTableRow';
|
||||||
|
import { IncidentDetailDialog } from '../detail-dialog/IncidentDetailDialog';
|
||||||
|
import { EditIncidentDialog } from '../EditIncidentDialog';
|
||||||
|
import { useLanguage } from '@/contexts/LanguageContext';
|
||||||
|
import { IncidentTableSkeleton } from './IncidentTableSkeleton';
|
||||||
|
|
||||||
|
interface IncidentTableProps {
|
||||||
|
data: IncidentItem[];
|
||||||
|
isLoading: boolean;
|
||||||
|
onIncidentUpdated: () => void;
|
||||||
|
onViewDetails?: (incident: IncidentItem) => void;
|
||||||
|
onEditIncident?: (incident: IncidentItem) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const IncidentTable = ({
|
||||||
|
data,
|
||||||
|
isLoading,
|
||||||
|
onIncidentUpdated,
|
||||||
|
onViewDetails,
|
||||||
|
onEditIncident
|
||||||
|
}: IncidentTableProps) => {
|
||||||
|
const { t } = useLanguage();
|
||||||
|
const [selectedIncident, setSelectedIncident] = useState<IncidentItem | null>(null);
|
||||||
|
const [isDetailOpen, setIsDetailOpen] = useState(false);
|
||||||
|
const [isEditOpen, setIsEditOpen] = useState(false);
|
||||||
|
|
||||||
|
const formatDate = useCallback((dateString: string | undefined) => {
|
||||||
|
if (!dateString) return '-';
|
||||||
|
try {
|
||||||
|
return format(new Date(dateString), 'PPp');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error formatting date:', dateString, error);
|
||||||
|
return dateString;
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const getAffectedSystemsArray = useCallback((affectedSystems: string | undefined): string[] => {
|
||||||
|
if (!affectedSystems) return [];
|
||||||
|
return affectedSystems.split(',').map(system => system.trim()).filter(Boolean);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleViewDetails = useCallback((incident: IncidentItem) => {
|
||||||
|
setSelectedIncident(incident);
|
||||||
|
setIsDetailOpen(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleEditIncident = useCallback((incident: IncidentItem) => {
|
||||||
|
setSelectedIncident(incident);
|
||||||
|
setIsEditOpen(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Handle status updates efficiently
|
||||||
|
const handleIncidentUpdated = useCallback(() => {
|
||||||
|
console.log("Incident updated in IncidentTable, propagating event");
|
||||||
|
onIncidentUpdated();
|
||||||
|
}, [onIncidentUpdated]);
|
||||||
|
|
||||||
|
// Handle dialog closing
|
||||||
|
const handleDetailDialogClose = useCallback((open: boolean) => {
|
||||||
|
setIsDetailOpen(open);
|
||||||
|
if (!open) {
|
||||||
|
onIncidentUpdated();
|
||||||
|
}
|
||||||
|
}, [onIncidentUpdated]);
|
||||||
|
|
||||||
|
// Handle edit dialog closing
|
||||||
|
const handleEditDialogClose = useCallback((open: boolean) => {
|
||||||
|
setIsEditOpen(open);
|
||||||
|
if (!open) {
|
||||||
|
onIncidentUpdated();
|
||||||
|
}
|
||||||
|
}, [onIncidentUpdated]);
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return <IncidentTableSkeleton />;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add a safety check to prevent map of undefined error
|
||||||
|
if (!data || !Array.isArray(data)) {
|
||||||
|
console.error('Data is not an array:', data);
|
||||||
|
return (
|
||||||
|
<div className="p-4 text-center">
|
||||||
|
<p>No incident data available</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="rounded-md border">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>{t('title')}</TableHead>
|
||||||
|
<TableHead>{t('status')}</TableHead>
|
||||||
|
<TableHead>{t('priority')}</TableHead>
|
||||||
|
<TableHead>{t('time')}</TableHead>
|
||||||
|
<TableHead>{t('affected')}</TableHead>
|
||||||
|
<TableHead>{t('impact')}</TableHead>
|
||||||
|
<TableHead>{t('assignedTo')}</TableHead>
|
||||||
|
<TableHead className="text-right">{t('actions')}</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{data.map((item) => (
|
||||||
|
<IncidentTableRow
|
||||||
|
key={item.id}
|
||||||
|
item={item}
|
||||||
|
formatDate={formatDate}
|
||||||
|
getAffectedSystemsArray={getAffectedSystemsArray}
|
||||||
|
onViewDetails={onViewDetails || handleViewDetails}
|
||||||
|
onEditIncident={onEditIncident || handleEditIncident}
|
||||||
|
onIncidentUpdated={handleIncidentUpdated}
|
||||||
|
t={t}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Incident detail dialog */}
|
||||||
|
<IncidentDetailDialog
|
||||||
|
open={isDetailOpen}
|
||||||
|
onOpenChange={handleDetailDialogClose}
|
||||||
|
incident={selectedIncident}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Edit incident dialog */}
|
||||||
|
{selectedIncident && (
|
||||||
|
<EditIncidentDialog
|
||||||
|
open={isEditOpen}
|
||||||
|
onOpenChange={handleEditDialogClose}
|
||||||
|
incident={selectedIncident}
|
||||||
|
onIncidentUpdated={onIncidentUpdated}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
|
||||||
|
import React, { memo, useState } from 'react';
|
||||||
|
import { TableRow, TableCell } from '@/components/ui/table';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Eye } from 'lucide-react';
|
||||||
|
import { IncidentStatusDropdown } from '../IncidentStatusDropdown';
|
||||||
|
import { IncidentActionsMenu } from '../IncidentActionsMenu';
|
||||||
|
import { IncidentItem } from '@/services/incident';
|
||||||
|
import { AssignedUserCell } from './IncidentTableUtils';
|
||||||
|
|
||||||
|
interface IncidentTableRowProps {
|
||||||
|
item: IncidentItem;
|
||||||
|
formatDate: (date: string | undefined) => string;
|
||||||
|
getAffectedSystemsArray: (systems: string | undefined) => string[];
|
||||||
|
onViewDetails?: (incident: IncidentItem) => void;
|
||||||
|
onEditIncident?: (incident: IncidentItem) => void;
|
||||||
|
onIncidentUpdated: () => void;
|
||||||
|
t: (key: string) => string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const IncidentTableRow = memo(({
|
||||||
|
item,
|
||||||
|
formatDate,
|
||||||
|
getAffectedSystemsArray,
|
||||||
|
onViewDetails,
|
||||||
|
onEditIncident,
|
||||||
|
onIncidentUpdated,
|
||||||
|
t
|
||||||
|
}: IncidentTableRowProps) => {
|
||||||
|
// Use local state for optimistic UI updates
|
||||||
|
const [localItem, setLocalItem] = useState(item);
|
||||||
|
|
||||||
|
// Update local state when props change
|
||||||
|
React.useEffect(() => {
|
||||||
|
setLocalItem(item);
|
||||||
|
}, [item]);
|
||||||
|
|
||||||
|
// Handle status updates locally
|
||||||
|
const handleStatusUpdated = () => {
|
||||||
|
console.log("Status updated in TableRow, calling onIncidentUpdated");
|
||||||
|
onIncidentUpdated();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TableRow
|
||||||
|
key={localItem.id}
|
||||||
|
className="hover:bg-muted/40 cursor-pointer"
|
||||||
|
onClick={() => onViewDetails && onViewDetails(localItem)}
|
||||||
|
>
|
||||||
|
<TableCell className="font-medium max-w-[200px] truncate">
|
||||||
|
{localItem.title || localItem.description || '-'}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell onClick={(e) => e.stopPropagation()}>
|
||||||
|
<IncidentStatusDropdown
|
||||||
|
status={localItem.impact_status || localItem.status || 'investigating'}
|
||||||
|
id={localItem.id}
|
||||||
|
onStatusUpdated={handleStatusUpdated}
|
||||||
|
/>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge variant={
|
||||||
|
localItem.priority?.toLowerCase() === 'critical' ? 'destructive' :
|
||||||
|
localItem.priority?.toLowerCase() === 'high' ? 'default' :
|
||||||
|
localItem.priority?.toLowerCase() === 'medium' ? 'secondary' : 'outline'
|
||||||
|
}>
|
||||||
|
{t(localItem.priority?.toLowerCase() || 'low')}
|
||||||
|
</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>{formatDate(localItem.created)}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
{getAffectedSystemsArray(localItem.affected_systems).map((system, idx) => (
|
||||||
|
<Badge key={`${localItem.id}-system-${idx}`} variant="outline">{system}</Badge>
|
||||||
|
))}
|
||||||
|
{getAffectedSystemsArray(localItem.affected_systems).length === 0 && '-'}
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge variant={
|
||||||
|
localItem.impact?.toLowerCase() === 'critical' ? 'destructive' :
|
||||||
|
localItem.impact?.toLowerCase() === 'high' ? 'default' :
|
||||||
|
localItem.impact?.toLowerCase() === 'medium' ? 'secondary' : 'outline'
|
||||||
|
}>
|
||||||
|
{t(localItem.impact?.toLowerCase() || 'low')}
|
||||||
|
</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<AssignedUserCell userId={localItem.assigned_to} />
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<div className="flex justify-end items-center space-x-2">
|
||||||
|
{onViewDetails && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onViewDetails(localItem);
|
||||||
|
}}
|
||||||
|
className="h-8 w-8 p-0"
|
||||||
|
>
|
||||||
|
<Eye className="h-4 w-4" />
|
||||||
|
<span className="sr-only">{t('view')}</span>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<IncidentActionsMenu
|
||||||
|
item={localItem}
|
||||||
|
onIncidentUpdated={onIncidentUpdated}
|
||||||
|
onViewDetails={onViewDetails}
|
||||||
|
onEditIncident={onEditIncident}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
IncidentTableRow.displayName = 'IncidentTableRow';
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import { TableRow, TableCell } from '@/components/ui/table';
|
||||||
|
import { Skeleton } from '@/components/ui/skeleton';
|
||||||
|
|
||||||
|
export const IncidentTableRowSkeleton = () => (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell><Skeleton className="h-5 w-[180px]" /></TableCell>
|
||||||
|
<TableCell><Skeleton className="h-5 w-[100px]" /></TableCell>
|
||||||
|
<TableCell><Skeleton className="h-5 w-[80px]" /></TableCell>
|
||||||
|
<TableCell><Skeleton className="h-5 w-[120px]" /></TableCell>
|
||||||
|
<TableCell><Skeleton className="h-5 w-[150px]" /></TableCell>
|
||||||
|
<TableCell><Skeleton className="h-5 w-[80px]" /></TableCell>
|
||||||
|
<TableCell><Skeleton className="h-5 w-[100px]" /></TableCell>
|
||||||
|
<TableCell><Skeleton className="h-5 w-[60px]" /></TableCell>
|
||||||
|
</TableRow>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const IncidentTableSkeleton = () => (
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<TableRow>
|
||||||
|
{Array(3).fill(0).map((_, index) => (
|
||||||
|
<IncidentTableRowSkeleton key={`skeleton-${index}`} />
|
||||||
|
))}
|
||||||
|
</TableRow>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { userService, User } from '@/services/userService';
|
||||||
|
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
||||||
|
import { Skeleton } from '@/components/ui/skeleton';
|
||||||
|
|
||||||
|
// Helper function to get user initials
|
||||||
|
export const getUserInitials = (user: User): string => {
|
||||||
|
if (user.full_name) {
|
||||||
|
const nameParts = user.full_name.split(' ');
|
||||||
|
if (nameParts.length > 1) {
|
||||||
|
return `${nameParts[0][0]}${nameParts[1][0]}`.toUpperCase();
|
||||||
|
}
|
||||||
|
return user.full_name.substring(0, 2).toUpperCase();
|
||||||
|
}
|
||||||
|
return user.username.substring(0, 2).toUpperCase();
|
||||||
|
};
|
||||||
|
|
||||||
|
interface AssignedUserCellProps {
|
||||||
|
userId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AssignedUserCell: React.FC<AssignedUserCellProps> = ({ userId }) => {
|
||||||
|
const { data: user, isLoading } = useQuery({
|
||||||
|
queryKey: ['user', userId],
|
||||||
|
queryFn: async () => {
|
||||||
|
if (!userId) return null;
|
||||||
|
try {
|
||||||
|
return await userService.getUser(userId);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to fetch assigned user:", error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
enabled: !!userId,
|
||||||
|
staleTime: 300000 // Cache for 5 minutes
|
||||||
|
});
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return <Skeleton className="h-6 w-20" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!user || !userId) {
|
||||||
|
return <span>-</span>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Avatar className="h-6 w-6">
|
||||||
|
<AvatarImage src={user.avatar} alt={user.full_name || user.username} />
|
||||||
|
<AvatarFallback>{getUserInitials(user)}</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
<span className="truncate max-w-[100px]">{user.full_name || user.username}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
|
||||||
|
export * from './IncidentTable';
|
||||||
|
export * from './IncidentTableRow';
|
||||||
|
export * from './IncidentTableSkeleton';
|
||||||
|
export * from './IncidentTableUtils';
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
|
||||||
|
// Export all components for easier imports
|
||||||
|
export * from './ScheduleIncidentContent';
|
||||||
|
export * from './ScheduledMaintenanceTab';
|
||||||
|
export * from './IncidentManagementTab';
|
||||||
|
export * from './maintenance/MaintenanceTable';
|
||||||
|
export * from './maintenance/MaintenanceStatusBadge';
|
||||||
|
export * from './maintenance/MaintenanceActionsMenu';
|
||||||
|
export * from './maintenance/EmptyMaintenanceState';
|
||||||
|
export * from './maintenance/CreateMaintenanceDialog';
|
||||||
|
export * from './incident/IncidentTable';
|
||||||
|
export * from './incident/IncidentStatusBadge';
|
||||||
|
export * from './incident/IncidentActionsMenu';
|
||||||
|
export * from './incident/EmptyIncidentState';
|
||||||
|
export * from './incident/CreateIncidentDialog';
|
||||||
|
export * from './incident-management';
|
||||||
|
export * from './hooks';
|
||||||
|
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import { useLanguage } from '@/contexts/LanguageContext';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter
|
||||||
|
} from '@/components/ui/dialog';
|
||||||
|
import { Form } from '@/components/ui/form';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { useMaintenanceForm } from './hooks/useMaintenanceForm';
|
||||||
|
import {
|
||||||
|
MaintenanceBasicFields,
|
||||||
|
MaintenanceTimeFields,
|
||||||
|
MaintenanceAffectedFields,
|
||||||
|
MaintenanceConfigFields,
|
||||||
|
MaintenanceNotificationSettingsField
|
||||||
|
} from './form';
|
||||||
|
|
||||||
|
interface CreateMaintenanceDialogProps {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
onMaintenanceCreated: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CreateMaintenanceDialog = ({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
onMaintenanceCreated
|
||||||
|
}: CreateMaintenanceDialogProps) => {
|
||||||
|
const { t } = useLanguage();
|
||||||
|
|
||||||
|
const handleSuccess = () => {
|
||||||
|
console.log("CreateMaintenanceDialog: maintenance created successfully");
|
||||||
|
onMaintenanceCreated();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClose = () => {
|
||||||
|
console.log("CreateMaintenanceDialog: closing dialog");
|
||||||
|
onOpenChange(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const { form, onSubmit } = useMaintenanceForm(handleSuccess, handleClose);
|
||||||
|
|
||||||
|
// Log the form state for debugging
|
||||||
|
React.useEffect(() => {
|
||||||
|
const subscription = form.watch((value) => {
|
||||||
|
console.log("Form values changed:", value);
|
||||||
|
});
|
||||||
|
return () => subscription.unsubscribe();
|
||||||
|
}, [form]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="sm:max-w-[600px] max-h-[90vh] overflow-y-auto">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{t('createMaintenanceWindow')}</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
{t('createMaintenanceDesc')}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<Form {...form}>
|
||||||
|
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
||||||
|
<MaintenanceBasicFields />
|
||||||
|
<MaintenanceTimeFields />
|
||||||
|
<MaintenanceAffectedFields />
|
||||||
|
<MaintenanceConfigFields />
|
||||||
|
<MaintenanceNotificationSettingsField />
|
||||||
|
|
||||||
|
<DialogFooter className="pt-4">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => onOpenChange(false)}
|
||||||
|
>
|
||||||
|
{t('cancel')}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
disabled={form.formState.isSubmitting}
|
||||||
|
>
|
||||||
|
{form.formState.isSubmitting ? t('creating') : t('createMaintenance')}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import { useLanguage } from '@/contexts/LanguageContext';
|
||||||
|
import { CalendarClock } from 'lucide-react';
|
||||||
|
|
||||||
|
export const EmptyMaintenanceState = () => {
|
||||||
|
const { t } = useLanguage();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center justify-center py-12 text-gray-500">
|
||||||
|
<CalendarClock className="w-12 h-12 mb-4" />
|
||||||
|
<h3 className="text-lg font-medium mb-2">{t('noScheduledMaintenance')}</h3>
|
||||||
|
<p className="text-sm text-center max-w-md">
|
||||||
|
{t('noMaintenanceWindows')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,170 @@
|
|||||||
|
|
||||||
|
import React, { useState } from 'react';
|
||||||
|
import { useLanguage } from '@/contexts/LanguageContext';
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuLabel,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from '@/components/ui/dropdown-menu';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { MoreHorizontal, Eye, Edit, Trash, Play, CheckCircle, X } from 'lucide-react';
|
||||||
|
import { useToast } from '@/hooks/use-toast';
|
||||||
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
} from "@/components/ui/alert-dialog";
|
||||||
|
import { MaintenanceItem } from '@/services/types/maintenance.types';
|
||||||
|
import { maintenanceService } from '@/services/maintenance';
|
||||||
|
import { MaintenanceDetailDialog } from './detail-dialog/MaintenanceDetailDialog';
|
||||||
|
import { EditMaintenanceDialog } from './edit-dialog/EditMaintenanceDialog';
|
||||||
|
|
||||||
|
interface MaintenanceActionsMenuProps {
|
||||||
|
item: MaintenanceItem;
|
||||||
|
onMaintenanceUpdated: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const MaintenanceActionsMenu = ({ item, onMaintenanceUpdated }: MaintenanceActionsMenuProps) => {
|
||||||
|
const { t } = useLanguage();
|
||||||
|
const { toast } = useToast();
|
||||||
|
const [detailDialogOpen, setDetailDialogOpen] = useState(false);
|
||||||
|
const [editDialogOpen, setEditDialogOpen] = useState(false);
|
||||||
|
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||||
|
|
||||||
|
const handleStatusChange = async (newStatus: string) => {
|
||||||
|
try {
|
||||||
|
await maintenanceService.updateMaintenanceStatus(item.id, newStatus);
|
||||||
|
toast({
|
||||||
|
title: t('statusUpdated'),
|
||||||
|
description: t('maintenanceStatusUpdated'),
|
||||||
|
});
|
||||||
|
onMaintenanceUpdated();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error updating maintenance status:', error);
|
||||||
|
toast({
|
||||||
|
title: t('error'),
|
||||||
|
description: t('errorUpdatingMaintenanceStatus'),
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = async () => {
|
||||||
|
try {
|
||||||
|
await maintenanceService.deleteMaintenance(item.id);
|
||||||
|
toast({
|
||||||
|
title: t('maintenanceDeleted'),
|
||||||
|
description: t('maintenanceDeletedDesc'),
|
||||||
|
});
|
||||||
|
onMaintenanceUpdated();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error deleting maintenance:', error);
|
||||||
|
toast({
|
||||||
|
title: t('error'),
|
||||||
|
description: t('errorDeletingMaintenance'),
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setDeleteDialogOpen(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Convert status to lowercase for consistent comparison
|
||||||
|
const status = item.status.toLowerCase();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button variant="ghost" className="h-8 w-8 p-0">
|
||||||
|
<span className="sr-only">{t('actions')}</span>
|
||||||
|
<MoreHorizontal className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end">
|
||||||
|
<DropdownMenuLabel>{t('actions')}</DropdownMenuLabel>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuItem onClick={() => setDetailDialogOpen(true)}>
|
||||||
|
<Eye className="mr-2 h-4 w-4" />
|
||||||
|
{t('view')}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
|
||||||
|
<DropdownMenuItem onClick={() => setEditDialogOpen(true)}>
|
||||||
|
<Edit className="mr-2 h-4 w-4" />
|
||||||
|
{t('edit')}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
|
||||||
|
{status === 'scheduled' && (
|
||||||
|
<DropdownMenuItem onClick={() => handleStatusChange('in_progress')}>
|
||||||
|
<Play className="mr-2 h-4 w-4" />
|
||||||
|
{t('markAsInProgress')}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{(status === 'scheduled' || status === 'in_progress') && (
|
||||||
|
<DropdownMenuItem onClick={() => handleStatusChange('completed')}>
|
||||||
|
<CheckCircle className="mr-2 h-4 w-4" />
|
||||||
|
{t('markAsCompleted')}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{status !== 'cancelled' && (
|
||||||
|
<DropdownMenuItem onClick={() => handleStatusChange('cancelled')}>
|
||||||
|
<X className="mr-2 h-4 w-4" />
|
||||||
|
{t('markAsCancelled')}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() => setDeleteDialogOpen(true)}
|
||||||
|
className="text-red-600 focus:text-red-600"
|
||||||
|
>
|
||||||
|
<Trash className="mr-2 h-4 w-4" />
|
||||||
|
{t('delete')}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
|
||||||
|
<MaintenanceDetailDialog
|
||||||
|
open={detailDialogOpen}
|
||||||
|
onOpenChange={setDetailDialogOpen}
|
||||||
|
maintenance={item}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<EditMaintenanceDialog
|
||||||
|
open={editDialogOpen}
|
||||||
|
onOpenChange={setEditDialogOpen}
|
||||||
|
maintenance={item}
|
||||||
|
onMaintenanceUpdated={onMaintenanceUpdated}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<AlertDialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>{t('confirmDelete')}</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
{t('deleteMaintenanceConfirmation')}
|
||||||
|
<span className="font-semibold"> {item.title}</span>?
|
||||||
|
{t('thisActionCannotBeUndone')}
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>{t('cancel')}</AlertDialogCancel>
|
||||||
|
<AlertDialogAction onClick={handleDelete} className="bg-red-600 hover:bg-red-700">
|
||||||
|
{t('delete')}
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { useLanguage } from '@/contexts/LanguageContext';
|
||||||
|
import { CalendarClock, Clock, CheckCircle, X } from 'lucide-react';
|
||||||
|
|
||||||
|
interface MaintenanceStatusBadgeProps {
|
||||||
|
status: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const MaintenanceStatusBadge = ({ status }: MaintenanceStatusBadgeProps) => {
|
||||||
|
const { t } = useLanguage();
|
||||||
|
|
||||||
|
// Ensure we have a string and normalize it
|
||||||
|
const normalizedStatus = typeof status === 'string' ? status.toLowerCase() : '';
|
||||||
|
|
||||||
|
const getStatusColor = () => {
|
||||||
|
switch (normalizedStatus) {
|
||||||
|
case 'scheduled':
|
||||||
|
return 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-300';
|
||||||
|
case 'in progress':
|
||||||
|
case 'in_progress':
|
||||||
|
return 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-300';
|
||||||
|
case 'completed':
|
||||||
|
return 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-300';
|
||||||
|
case 'cancelled':
|
||||||
|
return 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-300';
|
||||||
|
default:
|
||||||
|
return 'bg-gray-100 text-gray-800 dark:bg-gray-800 dark:text-gray-300';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStatusIcon = () => {
|
||||||
|
switch (normalizedStatus) {
|
||||||
|
case 'scheduled':
|
||||||
|
return <CalendarClock className="h-3 w-3" />;
|
||||||
|
case 'in progress':
|
||||||
|
case 'in_progress':
|
||||||
|
return <Clock className="h-3 w-3" />;
|
||||||
|
case 'completed':
|
||||||
|
return <CheckCircle className="h-3 w-3" />;
|
||||||
|
case 'cancelled':
|
||||||
|
return <X className="h-3 w-3" />;
|
||||||
|
default:
|
||||||
|
return <CalendarClock className="h-3 w-3" />;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getDisplayStatus = () => {
|
||||||
|
switch (normalizedStatus) {
|
||||||
|
case 'scheduled':
|
||||||
|
return t('scheduled');
|
||||||
|
case 'in progress':
|
||||||
|
case 'in_progress':
|
||||||
|
return t('inProgress');
|
||||||
|
case 'completed':
|
||||||
|
return t('completed');
|
||||||
|
case 'cancelled':
|
||||||
|
return t('cancelled');
|
||||||
|
default:
|
||||||
|
return status || '';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Badge className={`${getStatusColor()} flex items-center gap-1 font-medium`} variant="outline">
|
||||||
|
{getStatusIcon()}
|
||||||
|
{getDisplayStatus()}
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
};
|
||||||
+175
@@ -0,0 +1,175 @@
|
|||||||
|
|
||||||
|
import { useEffect, useRef } from 'react';
|
||||||
|
import { maintenanceService } from '@/services/maintenance';
|
||||||
|
import { useToast } from '@/hooks/use-toast';
|
||||||
|
import { useLanguage } from '@/contexts/LanguageContext';
|
||||||
|
import { MaintenanceItem } from '@/services/types/maintenance.types';
|
||||||
|
import { maintenanceNotificationService } from '@/services/maintenance/maintenanceNotificationService';
|
||||||
|
|
||||||
|
interface MaintenanceStatusCheckerProps {
|
||||||
|
maintenanceData: MaintenanceItem[];
|
||||||
|
onStatusUpdated: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const MaintenanceStatusChecker = ({
|
||||||
|
maintenanceData,
|
||||||
|
onStatusUpdated
|
||||||
|
}: MaintenanceStatusCheckerProps) => {
|
||||||
|
const { t } = useLanguage();
|
||||||
|
const { toast } = useToast();
|
||||||
|
const checkedItemsRef = useRef<Set<string>>(new Set());
|
||||||
|
const notificationSentRef = useRef<Set<string>>(new Set());
|
||||||
|
const intervalRef = useRef<number | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!maintenanceData || maintenanceData.length === 0) return;
|
||||||
|
|
||||||
|
const checkAndUpdateStatus = async () => {
|
||||||
|
const currentTime = new Date();
|
||||||
|
let hasUpdates = false;
|
||||||
|
|
||||||
|
console.log('MaintenanceStatusChecker: Checking status updates at', currentTime.toISOString());
|
||||||
|
console.log('MaintenanceStatusChecker: Checking', maintenanceData.length, 'maintenance items');
|
||||||
|
|
||||||
|
for (const item of maintenanceData) {
|
||||||
|
try {
|
||||||
|
const startTime = new Date(item.start_time);
|
||||||
|
const endTime = new Date(item.end_time);
|
||||||
|
const status = item.status.toLowerCase();
|
||||||
|
|
||||||
|
console.log(`MaintenanceStatusChecker: Item ${item.id} - Status: ${status}, Start: ${startTime.toISOString()}, End: ${endTime.toISOString()}, Current: ${currentTime.toISOString()}`);
|
||||||
|
|
||||||
|
// Check if scheduled maintenance should start (become in_progress)
|
||||||
|
if (status === 'scheduled' && currentTime >= startTime && currentTime <= endTime) {
|
||||||
|
const checkKey = `${item.id}-started`;
|
||||||
|
const notificationKey = `${item.id}-start-notification`;
|
||||||
|
|
||||||
|
if (!checkedItemsRef.current.has(checkKey)) {
|
||||||
|
console.log(`MaintenanceStatusChecker: Starting maintenance ${item.id} at ${currentTime.toISOString()}`);
|
||||||
|
|
||||||
|
// Update status to in_progress first
|
||||||
|
await maintenanceService.updateMaintenanceStatus(item.id, 'in_progress');
|
||||||
|
|
||||||
|
// Send start notification only once
|
||||||
|
if (!notificationSentRef.current.has(notificationKey)) {
|
||||||
|
try {
|
||||||
|
await maintenanceNotificationService.sendMaintenanceNotification({
|
||||||
|
maintenance: item,
|
||||||
|
notificationType: 'start'
|
||||||
|
});
|
||||||
|
notificationSentRef.current.add(notificationKey);
|
||||||
|
console.log(`MaintenanceStatusChecker: Start notification sent for ${item.id}`);
|
||||||
|
} catch (notificationError) {
|
||||||
|
console.log('MaintenanceStatusChecker: Start notification failed', notificationError);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: t('maintenanceInProgress'),
|
||||||
|
description: `${item.title} ${t('isNowInProgress')}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
checkedItemsRef.current.add(checkKey);
|
||||||
|
hasUpdates = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if in_progress maintenance should be completed
|
||||||
|
if (status === 'in_progress' && currentTime >= endTime) {
|
||||||
|
const checkKey = `${item.id}-completed`;
|
||||||
|
const notificationKey = `${item.id}-end-notification`;
|
||||||
|
|
||||||
|
if (!checkedItemsRef.current.has(checkKey)) {
|
||||||
|
console.log(`MaintenanceStatusChecker: Completing maintenance ${item.id} at ${currentTime.toISOString()}`);
|
||||||
|
|
||||||
|
// Update status to completed first
|
||||||
|
await maintenanceService.updateMaintenanceStatus(item.id, 'completed');
|
||||||
|
|
||||||
|
// Send completion notification only once
|
||||||
|
if (!notificationSentRef.current.has(notificationKey)) {
|
||||||
|
try {
|
||||||
|
await maintenanceNotificationService.sendMaintenanceNotification({
|
||||||
|
maintenance: item,
|
||||||
|
notificationType: 'end'
|
||||||
|
});
|
||||||
|
notificationSentRef.current.add(notificationKey);
|
||||||
|
console.log(`MaintenanceStatusChecker: Completion notification sent for ${item.id}`);
|
||||||
|
} catch (notificationError) {
|
||||||
|
console.log('MaintenanceStatusChecker: Completion notification failed', notificationError);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: t('maintenanceCompleted'),
|
||||||
|
description: `${item.title} ${t('hasBeenCompleted')}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
checkedItemsRef.current.add(checkKey);
|
||||||
|
hasUpdates = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('MaintenanceStatusChecker: Error updating status for item', item.id, error);
|
||||||
|
// Clear the check flags after 2 minutes to allow retry
|
||||||
|
setTimeout(() => {
|
||||||
|
checkedItemsRef.current.delete(`${item.id}-started`);
|
||||||
|
checkedItemsRef.current.delete(`${item.id}-completed`);
|
||||||
|
notificationSentRef.current.delete(`${item.id}-start-notification`);
|
||||||
|
notificationSentRef.current.delete(`${item.id}-end-notification`);
|
||||||
|
}, 120000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasUpdates) {
|
||||||
|
console.log('MaintenanceStatusChecker: Status updates detected, triggering refresh');
|
||||||
|
// Force immediate refresh to update the UI
|
||||||
|
onStatusUpdated();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Clear the interval if it exists
|
||||||
|
if (intervalRef.current) {
|
||||||
|
clearInterval(intervalRef.current);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initial check immediately
|
||||||
|
checkAndUpdateStatus();
|
||||||
|
|
||||||
|
// Check every 5 seconds for immediate status updates
|
||||||
|
intervalRef.current = window.setInterval(checkAndUpdateStatus, 5000);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (intervalRef.current) {
|
||||||
|
clearInterval(intervalRef.current);
|
||||||
|
intervalRef.current = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [maintenanceData, onStatusUpdated, t, toast]);
|
||||||
|
|
||||||
|
// Clear check flags when maintenance data changes significantly
|
||||||
|
useEffect(() => {
|
||||||
|
const currentIds = new Set(maintenanceData.map(item => item.id));
|
||||||
|
|
||||||
|
// Clean up check flags for items that no longer exist
|
||||||
|
const keysToDelete = Array.from(checkedItemsRef.current).filter(key => {
|
||||||
|
const itemId = key.split('-')[0];
|
||||||
|
return !currentIds.has(itemId);
|
||||||
|
});
|
||||||
|
|
||||||
|
keysToDelete.forEach(key => {
|
||||||
|
checkedItemsRef.current.delete(key);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Clean up notification flags for items that no longer exist
|
||||||
|
const notificationKeysToDelete = Array.from(notificationSentRef.current).filter(key => {
|
||||||
|
const itemId = key.split('-')[0];
|
||||||
|
return !currentIds.has(itemId);
|
||||||
|
});
|
||||||
|
|
||||||
|
notificationKeysToDelete.forEach(key => {
|
||||||
|
notificationSentRef.current.delete(key);
|
||||||
|
});
|
||||||
|
}, [maintenanceData]);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
};
|
||||||
+82
@@ -0,0 +1,82 @@
|
|||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import { useLanguage } from '@/contexts/LanguageContext';
|
||||||
|
import { CalendarClock, Clock, CheckCircle, X } from 'lucide-react';
|
||||||
|
import { useToast } from '@/hooks/use-toast';
|
||||||
|
import { maintenanceService } from '@/services/maintenance';
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from '@/components/ui/dropdown-menu';
|
||||||
|
import { MaintenanceStatusBadge } from './MaintenanceStatusBadge';
|
||||||
|
|
||||||
|
interface MaintenanceStatusDropdownProps {
|
||||||
|
status: string;
|
||||||
|
id: string;
|
||||||
|
onStatusUpdated: () => void;
|
||||||
|
disabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const MaintenanceStatusDropdown = ({
|
||||||
|
status,
|
||||||
|
id,
|
||||||
|
onStatusUpdated,
|
||||||
|
disabled = false
|
||||||
|
}: MaintenanceStatusDropdownProps) => {
|
||||||
|
const { t } = useLanguage();
|
||||||
|
const { toast } = useToast();
|
||||||
|
|
||||||
|
const statusOptions = [
|
||||||
|
{ value: 'scheduled', label: t('scheduled'), icon: <CalendarClock className="h-4 w-4 mr-2" /> },
|
||||||
|
{ value: 'in_progress', label: t('inProgress'), icon: <Clock className="h-4 w-4 mr-2" /> },
|
||||||
|
{ value: 'completed', label: t('completed'), icon: <CheckCircle className="h-4 w-4 mr-2" /> },
|
||||||
|
{ value: 'cancelled', label: t('cancelled'), icon: <X className="h-4 w-4 mr-2" /> },
|
||||||
|
];
|
||||||
|
|
||||||
|
const handleStatusChange = async (newStatus: string) => {
|
||||||
|
// Don't update if the status is the same
|
||||||
|
if (status.toLowerCase() === newStatus) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await maintenanceService.updateMaintenanceStatus(id, newStatus);
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: t('statusUpdated'),
|
||||||
|
description: t('maintenanceStatusUpdated'),
|
||||||
|
});
|
||||||
|
|
||||||
|
onStatusUpdated();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error updating maintenance status:', error);
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: t('error'),
|
||||||
|
description: t('failedToUpdateStatus'),
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger disabled={disabled} className="w-full cursor-pointer">
|
||||||
|
<MaintenanceStatusBadge status={status} />
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="start" className="bg-popover border border-border shadow-md">
|
||||||
|
{statusOptions.map((option) => (
|
||||||
|
<DropdownMenuItem
|
||||||
|
key={option.value}
|
||||||
|
className="flex items-center cursor-pointer"
|
||||||
|
onClick={() => handleStatusChange(option.value)}
|
||||||
|
>
|
||||||
|
{option.icon}
|
||||||
|
{option.label}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
))}
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
@@ -0,0 +1,152 @@
|
|||||||
|
|
||||||
|
import React, { useState, useMemo } from 'react';
|
||||||
|
import { useLanguage } from '@/contexts/LanguageContext';
|
||||||
|
import { Table, TableHeader, TableBody, TableRow, TableHead, TableCell } from '@/components/ui/table';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { format } from 'date-fns';
|
||||||
|
import { MaintenanceStatusDropdown } from './MaintenanceStatusDropdown';
|
||||||
|
import { MaintenanceActionsMenu } from './MaintenanceActionsMenu';
|
||||||
|
import { MaintenanceDetailDialog } from './detail-dialog/MaintenanceDetailDialog';
|
||||||
|
import { MaintenanceItem } from '@/services/types/maintenance.types';
|
||||||
|
import { Skeleton } from '@/components/ui/skeleton';
|
||||||
|
import { EmptyMaintenanceState } from './EmptyMaintenanceState';
|
||||||
|
|
||||||
|
interface MaintenanceTableProps {
|
||||||
|
data: MaintenanceItem[];
|
||||||
|
isLoading?: boolean;
|
||||||
|
onMaintenanceUpdated: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const MaintenanceTable = ({ data, isLoading = false, onMaintenanceUpdated }: MaintenanceTableProps) => {
|
||||||
|
const { t } = useLanguage();
|
||||||
|
const [selectedMaintenance, setSelectedMaintenance] = useState<MaintenanceItem | null>(null);
|
||||||
|
const [detailDialogOpen, setDetailDialogOpen] = useState(false);
|
||||||
|
|
||||||
|
// Memoize the formatted date function to avoid recreating it on each render
|
||||||
|
const formatDate = useMemo(() => (dateString: string) => {
|
||||||
|
if (!dateString) return '-';
|
||||||
|
|
||||||
|
try {
|
||||||
|
return format(new Date(dateString), 'MMM d, yyyy HH:mm');
|
||||||
|
} catch (error) {
|
||||||
|
return dateString || '-';
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleViewMaintenance = (maintenance: MaintenanceItem) => {
|
||||||
|
setSelectedMaintenance(maintenance);
|
||||||
|
setDetailDialogOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
// Render skeleton loading state with table structure for smoother transitions
|
||||||
|
return (
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>{t('title')}</TableHead>
|
||||||
|
<TableHead>{t('status')}</TableHead>
|
||||||
|
<TableHead>{t('scheduledStart')}</TableHead>
|
||||||
|
<TableHead>{t('scheduledEnd')}</TableHead>
|
||||||
|
<TableHead>{t('affectedServices')}</TableHead>
|
||||||
|
<TableHead>{t('impact')}</TableHead>
|
||||||
|
<TableHead className="w-[80px]">{t('actions')}</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{[1, 2, 3].map(i => (
|
||||||
|
<TableRow key={`skeleton-${i}`}>
|
||||||
|
<TableCell><Skeleton className="h-6 w-28" /></TableCell>
|
||||||
|
<TableCell><Skeleton className="h-6 w-20" /></TableCell>
|
||||||
|
<TableCell><Skeleton className="h-6 w-32" /></TableCell>
|
||||||
|
<TableCell><Skeleton className="h-6 w-32" /></TableCell>
|
||||||
|
<TableCell><Skeleton className="h-6 w-24" /></TableCell>
|
||||||
|
<TableCell><Skeleton className="h-6 w-16" /></TableCell>
|
||||||
|
<TableCell><Skeleton className="h-6 w-8" /></TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!data || data.length === 0) {
|
||||||
|
return <EmptyMaintenanceState />;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Memoize the impact badge variant calculation
|
||||||
|
const getImpactBadgeVariant = (field: string | undefined) => {
|
||||||
|
const fieldLower = field?.toLowerCase() || '';
|
||||||
|
if (fieldLower === 'critical') return 'destructive';
|
||||||
|
if (fieldLower === 'major') return 'default';
|
||||||
|
if (fieldLower === 'minor') return 'secondary';
|
||||||
|
return 'outline';
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>{t('title')}</TableHead>
|
||||||
|
<TableHead>{t('status')}</TableHead>
|
||||||
|
<TableHead>{t('scheduledStart')}</TableHead>
|
||||||
|
<TableHead>{t('scheduledEnd')}</TableHead>
|
||||||
|
<TableHead>{t('affectedServices')}</TableHead>
|
||||||
|
<TableHead>{t('impact')}</TableHead>
|
||||||
|
<TableHead className="w-[80px]">{t('actions')}</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{data.map((item) => (
|
||||||
|
<TableRow key={item.id} className="cursor-pointer hover:bg-muted/40">
|
||||||
|
<TableCell className="font-medium" onClick={() => handleViewMaintenance(item)}>
|
||||||
|
{item.title || '-'}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell onClick={(e) => e.stopPropagation()}>
|
||||||
|
<MaintenanceStatusDropdown
|
||||||
|
status={item.status}
|
||||||
|
id={item.id}
|
||||||
|
onStatusUpdated={onMaintenanceUpdated}
|
||||||
|
/>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell onClick={() => handleViewMaintenance(item)}>
|
||||||
|
{formatDate(item.start_time)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell onClick={() => handleViewMaintenance(item)}>
|
||||||
|
{formatDate(item.end_time)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell onClick={() => handleViewMaintenance(item)}>
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
{item.affected ? item.affected.split(',').slice(0, 2).map((service, index) => (
|
||||||
|
<Badge key={index} variant="outline">{service.trim()}</Badge>
|
||||||
|
)) : '-'}
|
||||||
|
{item.affected && item.affected.split(',').length > 2 && (
|
||||||
|
<Badge variant="outline">+{item.affected.split(',').length - 2}</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell onClick={() => handleViewMaintenance(item)}>
|
||||||
|
<Badge variant={getImpactBadgeVariant(item.field)}>
|
||||||
|
{item.field ? t(item.field.toLowerCase()) : '-'}
|
||||||
|
</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell onClick={(e) => e.stopPropagation()}>
|
||||||
|
<MaintenanceActionsMenu
|
||||||
|
item={item}
|
||||||
|
onMaintenanceUpdated={onMaintenanceUpdated}
|
||||||
|
/>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
|
||||||
|
<MaintenanceDetailDialog
|
||||||
|
open={detailDialogOpen}
|
||||||
|
onOpenChange={setDetailDialogOpen}
|
||||||
|
maintenance={selectedMaintenance}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
+150
@@ -0,0 +1,150 @@
|
|||||||
|
|
||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import { userService, User } from '@/services/userService';
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { MaintenanceItem } from '@/services/types/maintenance.types';
|
||||||
|
import { MaintenanceDetailHeader } from './MaintenanceDetailHeader';
|
||||||
|
import { MaintenanceDetailSections } from './MaintenanceDetailSections';
|
||||||
|
import { MaintenanceDetailFooter } from './MaintenanceDetailFooter';
|
||||||
|
import { alertConfigService } from '@/services/alertConfigService';
|
||||||
|
|
||||||
|
interface MaintenanceDetailContentProps {
|
||||||
|
maintenance: MaintenanceItem;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const MaintenanceDetailContent = ({
|
||||||
|
maintenance,
|
||||||
|
onClose
|
||||||
|
}: MaintenanceDetailContentProps) => {
|
||||||
|
const [assignedUsers, setAssignedUsers] = useState<User[]>([]);
|
||||||
|
const [maintenanceWithDetails, setMaintenanceWithDetails] = useState<MaintenanceItem>(maintenance);
|
||||||
|
|
||||||
|
// Fetch all users to resolve the assigned users and creator
|
||||||
|
const { data: users = [] } = useQuery({
|
||||||
|
queryKey: ['users'],
|
||||||
|
queryFn: async () => {
|
||||||
|
const usersList = await userService.getUsers();
|
||||||
|
return usersList || [];
|
||||||
|
},
|
||||||
|
enabled: true
|
||||||
|
});
|
||||||
|
|
||||||
|
// Fetch notification channels for notification channel display
|
||||||
|
const { data: notificationChannels = [] } = useQuery({
|
||||||
|
queryKey: ['notificationChannels'],
|
||||||
|
queryFn: async () => {
|
||||||
|
const channels = await alertConfigService.getAlertConfigurations();
|
||||||
|
return channels || [];
|
||||||
|
},
|
||||||
|
enabled: true
|
||||||
|
});
|
||||||
|
|
||||||
|
// Process user information when users data is available
|
||||||
|
useEffect(() => {
|
||||||
|
if (users.length > 0 && maintenance) {
|
||||||
|
// Create a copy of the maintenance object for modifications
|
||||||
|
const enhancedMaintenance = { ...maintenance };
|
||||||
|
|
||||||
|
// Process assigned users
|
||||||
|
if (maintenance.assigned_users) {
|
||||||
|
let userIds: string[] = [];
|
||||||
|
|
||||||
|
// Step 1: Extract user IDs from various formats
|
||||||
|
if (Array.isArray(maintenance.assigned_users)) {
|
||||||
|
userIds = maintenance.assigned_users;
|
||||||
|
} else if (typeof maintenance.assigned_users === 'string') {
|
||||||
|
// Try parsing the string to extract user IDs
|
||||||
|
try {
|
||||||
|
// Try parsing as JSON first
|
||||||
|
const parsedData = JSON.parse(maintenance.assigned_users);
|
||||||
|
if (Array.isArray(parsedData)) {
|
||||||
|
// Direct array format
|
||||||
|
userIds = parsedData;
|
||||||
|
} else if (typeof parsedData === 'string') {
|
||||||
|
// Nested JSON string
|
||||||
|
try {
|
||||||
|
const nestedData = JSON.parse(parsedData);
|
||||||
|
if (Array.isArray(nestedData)) {
|
||||||
|
userIds = nestedData;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// If nested parsing fails, treat as single ID
|
||||||
|
userIds = [parsedData];
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Unknown format, use as is
|
||||||
|
userIds = [String(parsedData)];
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// If JSON parsing fails, try comma splitting
|
||||||
|
if (maintenance.assigned_users.includes(',')) {
|
||||||
|
userIds = maintenance.assigned_users.split(',').map(id => id.trim()).filter(Boolean);
|
||||||
|
} else {
|
||||||
|
// Single ID
|
||||||
|
userIds = [maintenance.assigned_users];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 2: Clean up extracted IDs (remove quotes, brackets, etc.)
|
||||||
|
userIds = userIds.map(id => {
|
||||||
|
// Remove surrounding quotes if present
|
||||||
|
let cleanId = id.replace(/^["']|["']$/g, '');
|
||||||
|
// Remove any remaining JSON artifacts
|
||||||
|
cleanId = cleanId.replace(/[\[\]"'\\]/g, '');
|
||||||
|
return cleanId;
|
||||||
|
}).filter(Boolean);
|
||||||
|
|
||||||
|
// Step 3: Find matching users from the users array
|
||||||
|
if (userIds.length > 0) {
|
||||||
|
const matchedUsers = users.filter(user => userIds.includes(user.id));
|
||||||
|
setAssignedUsers(matchedUsers);
|
||||||
|
} else {
|
||||||
|
setAssignedUsers([]);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setAssignedUsers([]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process created_by field - replace ID with full name if available
|
||||||
|
if (maintenance?.created_by) {
|
||||||
|
const creator = users.find(user => user.id === maintenance.created_by);
|
||||||
|
if (creator) {
|
||||||
|
enhancedMaintenance.created_by = creator.full_name || creator.username || maintenance.created_by;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process notification channel if needed
|
||||||
|
if (maintenance.notify_subscribers === 'yes') {
|
||||||
|
// Try notification_channel_id first, fall back to notification_id if needed
|
||||||
|
const channelId = maintenance.notification_channel_id || maintenance.notification_id;
|
||||||
|
|
||||||
|
if (channelId && notificationChannels.length > 0) {
|
||||||
|
const channel = notificationChannels.find(ch => ch.id === channelId);
|
||||||
|
if (channel) {
|
||||||
|
enhancedMaintenance.notification_channel_name = `${channel.notify_name} (${channel.notification_type})`;
|
||||||
|
} else {
|
||||||
|
enhancedMaintenance.notification_channel_name = `Channel ID: ${channelId}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setMaintenanceWithDetails(enhancedMaintenance);
|
||||||
|
}
|
||||||
|
}, [maintenance, users, notificationChannels]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<MaintenanceDetailHeader maintenance={maintenanceWithDetails} />
|
||||||
|
<MaintenanceDetailSections
|
||||||
|
maintenance={maintenanceWithDetails}
|
||||||
|
assignedUsers={assignedUsers}
|
||||||
|
/>
|
||||||
|
<MaintenanceDetailFooter
|
||||||
|
maintenance={maintenanceWithDetails}
|
||||||
|
onClose={onClose}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
+37
@@ -0,0 +1,37 @@
|
|||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent
|
||||||
|
} from '@/components/ui/dialog';
|
||||||
|
import { MaintenanceItem } from '@/services/types/maintenance.types';
|
||||||
|
import { MaintenanceDetailContent } from './MaintenanceDetailContent';
|
||||||
|
|
||||||
|
interface MaintenanceDetailDialogProps {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
maintenance: MaintenanceItem | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const MaintenanceDetailDialog = ({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
maintenance
|
||||||
|
}: MaintenanceDetailDialogProps) => {
|
||||||
|
if (!maintenance) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent
|
||||||
|
className="dialog-content sm:max-w-[700px] max-h-[90vh] overflow-y-auto
|
||||||
|
print:max-w-none print:max-h-none print:overflow-visible
|
||||||
|
print:shadow-none print:m-0 print:p-0 print:border-none
|
||||||
|
print:absolute print:left-0 print:top-0 print:w-full print:h-auto"
|
||||||
|
>
|
||||||
|
<MaintenanceDetailContent maintenance={maintenance} onClose={() => onOpenChange(false)} />
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
+27
@@ -0,0 +1,27 @@
|
|||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import { DialogFooter } from '@/components/ui/dialog';
|
||||||
|
import { MaintenanceItem } from '@/services/types/maintenance.types';
|
||||||
|
import {
|
||||||
|
PrintButton,
|
||||||
|
DownloadPdfButton,
|
||||||
|
CloseButton
|
||||||
|
} from './components';
|
||||||
|
|
||||||
|
interface MaintenanceDetailFooterProps {
|
||||||
|
maintenance: MaintenanceItem;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const MaintenanceDetailFooter = ({
|
||||||
|
maintenance,
|
||||||
|
onClose
|
||||||
|
}: MaintenanceDetailFooterProps) => {
|
||||||
|
return (
|
||||||
|
<DialogFooter className="gap-2 sm:gap-2 mt-6 pt-4 border-t print:hidden">
|
||||||
|
<PrintButton maintenance={maintenance} />
|
||||||
|
<DownloadPdfButton maintenance={maintenance} />
|
||||||
|
<CloseButton onClose={onClose} />
|
||||||
|
</DialogFooter>
|
||||||
|
);
|
||||||
|
};
|
||||||
+82
@@ -0,0 +1,82 @@
|
|||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import { useLanguage } from '@/contexts/LanguageContext';
|
||||||
|
import { CalendarClock } from 'lucide-react';
|
||||||
|
import {
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle
|
||||||
|
} from '@/components/ui/dialog';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { MaintenanceItem } from '@/services/types/maintenance.types';
|
||||||
|
|
||||||
|
interface MaintenanceDetailHeaderProps {
|
||||||
|
maintenance: MaintenanceItem;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const MaintenanceDetailHeader = ({ maintenance }: MaintenanceDetailHeaderProps) => {
|
||||||
|
const { t } = useLanguage();
|
||||||
|
|
||||||
|
const getStatusColor = (status: string) => {
|
||||||
|
switch (status.toLowerCase()) {
|
||||||
|
case 'scheduled':
|
||||||
|
return 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-300';
|
||||||
|
case 'in_progress':
|
||||||
|
case 'in progress':
|
||||||
|
return 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-300';
|
||||||
|
case 'completed':
|
||||||
|
return 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-300';
|
||||||
|
case 'cancelled':
|
||||||
|
return 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-300';
|
||||||
|
default:
|
||||||
|
return 'bg-gray-100 text-gray-800 dark:bg-gray-800 dark:text-gray-300';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getImpactColor = (impact: string) => {
|
||||||
|
switch (impact.toLowerCase()) {
|
||||||
|
case 'critical':
|
||||||
|
return 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-300';
|
||||||
|
case 'major':
|
||||||
|
return 'bg-orange-100 text-orange-800 dark:bg-orange-900 dark:text-orange-300';
|
||||||
|
case 'minor':
|
||||||
|
return 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-300';
|
||||||
|
case 'none':
|
||||||
|
return 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-300';
|
||||||
|
default:
|
||||||
|
return 'bg-gray-100 text-gray-800 dark:bg-gray-800 dark:text-gray-300';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getPriorityColor = (priority: string) => {
|
||||||
|
switch (priority.toLowerCase()) {
|
||||||
|
case 'high':
|
||||||
|
return 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-300';
|
||||||
|
case 'medium':
|
||||||
|
return 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-300';
|
||||||
|
case 'low':
|
||||||
|
return 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-300';
|
||||||
|
default:
|
||||||
|
return 'bg-gray-100 text-gray-800 dark:bg-gray-800 dark:text-gray-300';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DialogHeader className="print:mb-6">
|
||||||
|
<div className="flex items-center gap-2 mb-1">
|
||||||
|
<CalendarClock className="h-5 w-5 text-blue-500 print:hidden" />
|
||||||
|
<DialogTitle className="text-xl">{maintenance.title}</DialogTitle>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-2 mt-2">
|
||||||
|
<Badge className={getStatusColor(maintenance.status)}>
|
||||||
|
{maintenance.status}
|
||||||
|
</Badge>
|
||||||
|
<Badge className={getImpactColor(maintenance.field)}>
|
||||||
|
{maintenance.field} {t('impact')}
|
||||||
|
</Badge>
|
||||||
|
<Badge className={getPriorityColor(maintenance.priority)}>
|
||||||
|
{maintenance.priority} {t('priority')}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</DialogHeader>
|
||||||
|
);
|
||||||
|
};
|
||||||
+198
@@ -0,0 +1,198 @@
|
|||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import { useLanguage } from '@/contexts/LanguageContext';
|
||||||
|
import { format } from 'date-fns';
|
||||||
|
import { Separator } from '@/components/ui/separator';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { MaintenanceItem } from '@/services/types/maintenance.types';
|
||||||
|
import { User } from '@/services/userService';
|
||||||
|
|
||||||
|
interface MaintenanceDetailSectionsProps {
|
||||||
|
maintenance: MaintenanceItem;
|
||||||
|
assignedUsers: User[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const MaintenanceDetailSections = ({
|
||||||
|
maintenance,
|
||||||
|
assignedUsers
|
||||||
|
}: MaintenanceDetailSectionsProps) => {
|
||||||
|
const { t } = useLanguage();
|
||||||
|
|
||||||
|
const formatDateTime = (dateString: string) => {
|
||||||
|
try {
|
||||||
|
return format(new Date(dateString), 'PPP p');
|
||||||
|
} catch (error) {
|
||||||
|
return dateString;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const calculateDuration = (start: string, end: string): string => {
|
||||||
|
try {
|
||||||
|
const startTime = new Date(start);
|
||||||
|
const endTime = new Date(end);
|
||||||
|
const durationMs = endTime.getTime() - startTime.getTime();
|
||||||
|
|
||||||
|
// Convert to hours and minutes
|
||||||
|
const hours = Math.floor(durationMs / (1000 * 60 * 60));
|
||||||
|
const minutes = Math.floor((durationMs % (1000 * 60 * 60)) / (1000 * 60));
|
||||||
|
|
||||||
|
if (hours === 0) {
|
||||||
|
return `${minutes}m`;
|
||||||
|
} else if (minutes === 0) {
|
||||||
|
return `${hours}h`;
|
||||||
|
} else {
|
||||||
|
return `${hours}h ${minutes}m`;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
return "N/A";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const affectedServices = typeof maintenance.affected === 'string'
|
||||||
|
? maintenance.affected.split(',').map(item => item.trim())
|
||||||
|
: [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4 print:space-y-1 print-compact-spacing">
|
||||||
|
{/* Print Header - Only visible when printing */}
|
||||||
|
<div className="hidden print:block print-section">
|
||||||
|
<div className="header-print bg-blue-800 text-white p-4 rounded-t-md">
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-xl font-bold">Scheduled Maintenance Report</h1>
|
||||||
|
<p className="text-blue-100 mt-1 print-compact-text">{maintenance.title}</p>
|
||||||
|
</div>
|
||||||
|
<div className="text-right">
|
||||||
|
<p className="text-xs text-blue-100">Reference ID: {maintenance.id}</p>
|
||||||
|
<p className="text-xs text-blue-100">Generated on {format(new Date(), 'PPP')}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Reference ID - Only visible in UI */}
|
||||||
|
<div className="flex items-center justify-between text-sm print:hidden">
|
||||||
|
<span className="text-muted-foreground">{t('referenceID')}</span>
|
||||||
|
<span className="font-mono bg-muted px-2 py-1 rounded">{maintenance.id}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Description Section */}
|
||||||
|
<div className="print:mt-0 print-section">
|
||||||
|
<h4 className="text-sm font-medium text-muted-foreground mb-2 print:text-xs print:font-bold print:text-blue-800">{t('description')}</h4>
|
||||||
|
<div className="bg-muted/50 p-3 rounded-md text-sm whitespace-pre-wrap print:bg-transparent print:border print:border-gray-200 print:p-1 print:rounded print:text-xs print:text-black print-description">
|
||||||
|
{maintenance.description || t('noDescriptionProvided')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Separator className="print:hidden" />
|
||||||
|
|
||||||
|
{/* Time Information */}
|
||||||
|
<div className="print-section">
|
||||||
|
<h4 className="text-sm font-medium text-muted-foreground mb-2 print:text-xs print:font-bold print:text-blue-800">{t('timeInformation')}</h4>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 print:grid-cols-2 print:gap-1 print:bg-gray-50 print:border print:border-gray-200 print:p-1 print:rounded print-compact-text">
|
||||||
|
<div>
|
||||||
|
<h4 className="text-sm font-medium text-muted-foreground mb-1 print:text-xs print:text-blue-700 print:mb-0">{t('scheduledStart')}</h4>
|
||||||
|
<p className="text-sm print:text-xs print:text-black print-smaller-font">{formatDateTime(maintenance.start_time)}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h4 className="text-sm font-medium text-muted-foreground mb-1 print:text-xs print:text-blue-700 print:mb-0">{t('scheduledEnd')}</h4>
|
||||||
|
<p className="text-sm print:text-xs print:text-black print-smaller-font">{formatDateTime(maintenance.end_time)}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h4 className="text-sm font-medium text-muted-foreground mb-1 print:text-xs print:text-blue-700 print:mb-0">{t('duration')}</h4>
|
||||||
|
<p className="text-sm print:text-xs print:text-black print-smaller-font">{calculateDuration(maintenance.start_time, maintenance.end_time)}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h4 className="text-sm font-medium text-muted-foreground mb-1 print:text-xs print:text-blue-700 print:mb-0">{t('maintenanceType')}</h4>
|
||||||
|
<p className="text-sm print:text-xs print:text-black print-smaller-font capitalize">{maintenance.field}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Separator className="print:hidden" />
|
||||||
|
|
||||||
|
{/* Affected Services */}
|
||||||
|
<div className="print-section">
|
||||||
|
<h4 className="text-sm font-medium text-muted-foreground mb-2 print:text-xs print:font-bold print:text-blue-800">{t('affectedServices')}</h4>
|
||||||
|
<div className="print:border print:border-gray-200 print:p-1 print:rounded">
|
||||||
|
{affectedServices.length > 0 ? (
|
||||||
|
<div className="flex flex-wrap gap-2 print:gap-1">
|
||||||
|
{affectedServices.map((service, index) => (
|
||||||
|
<Badge key={index} variant="outline" className="badge-print print:bg-blue-50 print:text-blue-800 print:border print:border-blue-200">
|
||||||
|
{service}
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<span className="text-sm text-muted-foreground print:text-xs print:text-black">{t('noAffectedServices')}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Assigned Users - Simplified for print */}
|
||||||
|
<div className="print-section">
|
||||||
|
<h4 className="text-sm font-medium text-muted-foreground mb-2 print:text-xs print:font-bold print:text-blue-800">{t('assignedPersonnel')}</h4>
|
||||||
|
<div className="border border-border rounded-md p-3 print:border print:border-gray-200 print:p-1 print:rounded print:bg-gray-50">
|
||||||
|
{assignedUsers && assignedUsers.length > 0 ? (
|
||||||
|
<div className="print:grid print:grid-cols-2 print:gap-1">
|
||||||
|
{assignedUsers.map((user) => (
|
||||||
|
<div key={user.id} className="flex items-center gap-2 p-2 bg-muted/30 rounded-md print:bg-transparent print:p-0 print:text-xs">
|
||||||
|
<div className="w-5 h-5 rounded-full bg-primary/10 flex items-center justify-center text-xs font-medium print:bg-blue-100 print:text-blue-800 print:w-4 print:h-4">
|
||||||
|
{user && (user.full_name?.[0] || user.username?.[0] || 'U').toUpperCase()}
|
||||||
|
</div>
|
||||||
|
<span className="print:text-xs print:font-medium print:text-black print-smaller-font">{user.full_name || user.username}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<span className="text-sm text-muted-foreground print:text-xs print:text-black">{t('noAssignedUsers')}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Separator className="print:hidden" />
|
||||||
|
|
||||||
|
{/* Metadata - Condensed for print */}
|
||||||
|
<div className="print-section">
|
||||||
|
<h4 className="text-sm font-medium text-muted-foreground mb-2 print:text-xs print:font-bold print:text-blue-800">{t('additionalInformation')}</h4>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-2 text-sm print:grid-cols-2 print:gap-1 print:bg-gray-50 print:border print:border-gray-200 print:p-1 print:rounded print-compact-text">
|
||||||
|
<div>
|
||||||
|
<h4 className="font-medium text-muted-foreground mb-0 print:text-xs print:text-blue-700">{t('createdBy')}</h4>
|
||||||
|
<p className="print:text-xs print:text-black print-smaller-font">{maintenance.created_by || t('notSpecified')}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h4 className="font-medium text-muted-foreground mb-0 print:text-xs print:text-blue-700">{t('notificationChannel')}</h4>
|
||||||
|
<p className="print:text-xs print:text-black print-smaller-font">{maintenance.notification_channel_name || t('notSpecified')}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h4 className="font-medium text-muted-foreground mb-0 print:text-xs print:text-blue-700">{t('created')}</h4>
|
||||||
|
<p className="print:text-xs print:text-black print-smaller-font">{formatDateTime(maintenance.created)}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h4 className="font-medium text-muted-foreground mb-0 print:text-xs print:text-blue-700">{t('lastUpdated')}</h4>
|
||||||
|
<p className="print:text-xs print:text-black print-smaller-font">{formatDateTime(maintenance.updated)}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Notification Settings - Simplified for print */}
|
||||||
|
<div className="text-sm bg-muted/50 p-3 rounded-md print:bg-blue-50 print:border print:border-blue-200 print:p-1 print:rounded print-section print-compact-text">
|
||||||
|
<h4 className="font-medium mb-1 print:text-xs print:font-bold print:text-blue-800 print:mb-0">{t('notificationSettings')}</h4>
|
||||||
|
<p className="print:text-xs print:text-black print-smaller-font">
|
||||||
|
{maintenance.notify_subscribers === 'yes'
|
||||||
|
? t('subscribersWillBeNotified')
|
||||||
|
: t('noNotifications')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Print Footer - Only visible when printing */}
|
||||||
|
<div className="hidden print:block footer-print border-t border-gray-200 text-xs text-gray-500">
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span>Maintenance ID: {maintenance.id}</span>
|
||||||
|
<span>Printed on {format(new Date(), 'PPP')}</span>
|
||||||
|
</div>
|
||||||
|
<p className="mt-1 text-center">This document is confidential and intended for internal use only.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
+18
@@ -0,0 +1,18 @@
|
|||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import { useLanguage } from '@/contexts/LanguageContext';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
|
||||||
|
interface CloseButtonProps {
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CloseButton: React.FC<CloseButtonProps> = ({ onClose }) => {
|
||||||
|
const { t } = useLanguage();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button variant="secondary" onClick={onClose}>
|
||||||
|
{t('close')}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
};
|
||||||
+31
@@ -0,0 +1,31 @@
|
|||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Download } from 'lucide-react';
|
||||||
|
import { useLanguage } from '@/contexts/LanguageContext';
|
||||||
|
import { useDownloadMaintenancePdf } from '../hooks';
|
||||||
|
import { MaintenanceItem } from '@/services/maintenance';
|
||||||
|
|
||||||
|
interface DownloadPdfButtonProps {
|
||||||
|
maintenance: MaintenanceItem;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DownloadPdfButton: React.FC<DownloadPdfButtonProps> = ({
|
||||||
|
maintenance,
|
||||||
|
className
|
||||||
|
}) => {
|
||||||
|
const { t } = useLanguage();
|
||||||
|
const { handleDownloadPDF } = useDownloadMaintenancePdf();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
className={`flex items-center gap-2 ${className || ''}`}
|
||||||
|
onClick={() => handleDownloadPDF(maintenance)}
|
||||||
|
variant="outline"
|
||||||
|
>
|
||||||
|
<Download className="h-4 w-4" />
|
||||||
|
{t('downloadPdf')}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
};
|
||||||
+27
@@ -0,0 +1,27 @@
|
|||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import { useLanguage } from '@/contexts/LanguageContext';
|
||||||
|
import { Printer } from 'lucide-react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { usePrintMaintenance } from '../hooks/usePrintMaintenance';
|
||||||
|
import { MaintenanceItem } from '@/services/types/maintenance.types';
|
||||||
|
|
||||||
|
interface PrintButtonProps {
|
||||||
|
maintenance: MaintenanceItem;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const PrintButton: React.FC<PrintButtonProps> = ({ maintenance }) => {
|
||||||
|
const { t } = useLanguage();
|
||||||
|
const { handlePrint } = usePrintMaintenance();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
className="flex items-center gap-2"
|
||||||
|
onClick={() => handlePrint(maintenance)}
|
||||||
|
variant="default"
|
||||||
|
>
|
||||||
|
<Printer className="h-4 w-4" />
|
||||||
|
{t('print')}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
};
|
||||||
+4
@@ -0,0 +1,4 @@
|
|||||||
|
|
||||||
|
export * from './DownloadPdfButton';
|
||||||
|
export * from './PrintButton';
|
||||||
|
export * from './CloseButton';
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
|
||||||
|
export * from './useDownloadMaintenancePdf';
|
||||||
|
export * from './usePrintMaintenance';
|
||||||
+47
@@ -0,0 +1,47 @@
|
|||||||
|
|
||||||
|
import { useToast } from '@/hooks/use-toast';
|
||||||
|
import { useLanguage } from '@/contexts/LanguageContext';
|
||||||
|
import { MaintenanceItem, generateMaintenancePDF } from '@/services/maintenance';
|
||||||
|
|
||||||
|
export const useDownloadMaintenancePdf = () => {
|
||||||
|
const { toast } = useToast();
|
||||||
|
const { t } = useLanguage();
|
||||||
|
|
||||||
|
const handleDownloadPDF = async (maintenance: MaintenanceItem) => {
|
||||||
|
try {
|
||||||
|
// Show loading toast
|
||||||
|
toast({
|
||||||
|
title: t('generating'),
|
||||||
|
description: t('preparingPdf'),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Generate and download the PDF
|
||||||
|
const filename = await generateMaintenancePDF(maintenance);
|
||||||
|
|
||||||
|
// Show success toast after successful generation
|
||||||
|
toast({
|
||||||
|
title: t('success'),
|
||||||
|
description: t('pdfDownloaded'),
|
||||||
|
});
|
||||||
|
|
||||||
|
return filename;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error generating PDF:", error);
|
||||||
|
|
||||||
|
// Provide more detailed error message if available
|
||||||
|
const errorMessage = error instanceof Error
|
||||||
|
? error.message
|
||||||
|
: t('pdfGenerationFailed');
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: t('error'),
|
||||||
|
description: errorMessage,
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return { handleDownloadPDF };
|
||||||
|
};
|
||||||
+173
@@ -0,0 +1,173 @@
|
|||||||
|
|
||||||
|
import { useLanguage } from '@/contexts/LanguageContext';
|
||||||
|
import { useToast } from '@/hooks/use-toast';
|
||||||
|
import { MaintenanceItem } from '@/services/types/maintenance.types';
|
||||||
|
|
||||||
|
export const usePrintMaintenance = () => {
|
||||||
|
const { t } = useLanguage();
|
||||||
|
const { toast } = useToast();
|
||||||
|
|
||||||
|
const handlePrint = (maintenance: MaintenanceItem) => {
|
||||||
|
try {
|
||||||
|
// Add print-specific stylesheet temporarily
|
||||||
|
const style = document.createElement('style');
|
||||||
|
style.id = 'print-style';
|
||||||
|
style.textContent = `
|
||||||
|
@page {
|
||||||
|
size: A4;
|
||||||
|
margin: 10mm;
|
||||||
|
}
|
||||||
|
@media print {
|
||||||
|
body * {
|
||||||
|
visibility: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog-content, .dialog-content * {
|
||||||
|
visibility: visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog-content {
|
||||||
|
position: absolute !important;
|
||||||
|
left: 0;
|
||||||
|
top: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: auto;
|
||||||
|
padding: 15mm !important;
|
||||||
|
margin: 0 !important;
|
||||||
|
background-color: white !important;
|
||||||
|
box-shadow: none;
|
||||||
|
overflow: visible !important;
|
||||||
|
display: block !important;
|
||||||
|
transform: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Remove any black backgrounds */
|
||||||
|
html, body {
|
||||||
|
background-color: white !important;
|
||||||
|
color: black !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Optimize spacing for single page */
|
||||||
|
.print-section {
|
||||||
|
margin-bottom: 2mm !important;
|
||||||
|
page-break-inside: avoid !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Reduce vertical spacing */
|
||||||
|
h4 {
|
||||||
|
margin-bottom: 1mm !important;
|
||||||
|
margin-top: 1mm !important;
|
||||||
|
color: #1e40af !important; /* blue-800 */
|
||||||
|
font-weight: bold !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.print-compact-text {
|
||||||
|
font-size: 9pt !important;
|
||||||
|
line-height: 1.2 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.print-compact-spacing > * {
|
||||||
|
margin-top: 1mm !important;
|
||||||
|
margin-bottom: 1mm !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.print-grid {
|
||||||
|
display: grid !important;
|
||||||
|
grid-template-columns: 1fr 1fr !important;
|
||||||
|
gap: 1mm !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-print {
|
||||||
|
background-color: #dbeafe !important; /* blue-100 */
|
||||||
|
color: #1e40af !important; /* blue-800 */
|
||||||
|
border: 1px solid #93c5fd !important; /* blue-300 */
|
||||||
|
padding: 0.5mm 1mm !important;
|
||||||
|
margin: 0.5mm !important;
|
||||||
|
display: inline-block !important;
|
||||||
|
font-size: 8pt !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* More compact spacing */
|
||||||
|
.print-compact-margin {
|
||||||
|
margin: 1mm 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.print-smaller-font {
|
||||||
|
font-size: 8pt !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-print {
|
||||||
|
background-color: #1e40af !important; /* blue-800 */
|
||||||
|
color: #ffffff !important;
|
||||||
|
padding: 2mm 3mm !important;
|
||||||
|
margin-bottom: 2mm !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* More compact header */
|
||||||
|
.header-print h1 {
|
||||||
|
font-size: 12pt !important;
|
||||||
|
margin-bottom: 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-print p {
|
||||||
|
font-size: 9pt !important;
|
||||||
|
margin-top: 1mm !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Footer stays at bottom */
|
||||||
|
.footer-print {
|
||||||
|
font-size: 7pt !important;
|
||||||
|
color: #6b7280 !important; /* gray-500 */
|
||||||
|
border-top: 1px solid #e5e7eb !important; /* gray-200 */
|
||||||
|
position: fixed !important;
|
||||||
|
bottom: 10mm !important;
|
||||||
|
left: 15mm !important;
|
||||||
|
right: 15mm !important;
|
||||||
|
padding-top: 2mm !important;
|
||||||
|
background-color: white !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Hide any unnecessary elements */
|
||||||
|
.print-hide {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Optimize description text */
|
||||||
|
.print-description {
|
||||||
|
max-height: 100px !important;
|
||||||
|
overflow: hidden !important;
|
||||||
|
text-overflow: ellipsis !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
document.head.appendChild(style);
|
||||||
|
|
||||||
|
// Wait a bit to ensure styles are applied
|
||||||
|
setTimeout(() => {
|
||||||
|
window.print();
|
||||||
|
|
||||||
|
// Remove the style after printing dialog closes
|
||||||
|
setTimeout(() => {
|
||||||
|
const printStyle = document.getElementById('print-style');
|
||||||
|
if (printStyle) {
|
||||||
|
printStyle.remove();
|
||||||
|
}
|
||||||
|
}, 1000);
|
||||||
|
}, 100);
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: t('success'),
|
||||||
|
description: t('printJobStarted'),
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error printing:", error);
|
||||||
|
toast({
|
||||||
|
title: t('error'),
|
||||||
|
description: t('printingFailed'),
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return { handlePrint };
|
||||||
|
};
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
|
||||||
|
export * from './MaintenanceDetailDialog';
|
||||||
|
export * from './MaintenanceDetailContent';
|
||||||
|
export * from './MaintenanceDetailHeader';
|
||||||
|
export * from './MaintenanceDetailSections';
|
||||||
|
export * from './MaintenanceDetailFooter';
|
||||||
|
export * from './components';
|
||||||
|
export * from './hooks';
|
||||||
+97
@@ -0,0 +1,97 @@
|
|||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import { useLanguage } from '@/contexts/LanguageContext';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter
|
||||||
|
} from '@/components/ui/dialog';
|
||||||
|
import { Form } from '@/components/ui/form';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { MaintenanceItem } from '@/services/types/maintenance.types';
|
||||||
|
import { useMaintenanceEditForm } from '../hooks/useMaintenanceEditForm';
|
||||||
|
import {
|
||||||
|
MaintenanceBasicFields,
|
||||||
|
MaintenanceTimeFields,
|
||||||
|
MaintenanceAffectedFields,
|
||||||
|
MaintenanceConfigFields,
|
||||||
|
MaintenanceNotificationSettingsField
|
||||||
|
} from '../form';
|
||||||
|
|
||||||
|
interface EditMaintenanceDialogProps {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
maintenance: MaintenanceItem;
|
||||||
|
onMaintenanceUpdated: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const EditMaintenanceDialog = ({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
maintenance,
|
||||||
|
onMaintenanceUpdated
|
||||||
|
}: EditMaintenanceDialogProps) => {
|
||||||
|
const { t } = useLanguage();
|
||||||
|
|
||||||
|
const handleSuccess = () => {
|
||||||
|
console.log("EditMaintenanceDialog: maintenance updated successfully");
|
||||||
|
onMaintenanceUpdated();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClose = () => {
|
||||||
|
console.log("EditMaintenanceDialog: closing dialog");
|
||||||
|
onOpenChange(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const { form, onSubmit } = useMaintenanceEditForm(maintenance, handleSuccess, handleClose);
|
||||||
|
|
||||||
|
// Log the form state for debugging
|
||||||
|
React.useEffect(() => {
|
||||||
|
const subscription = form.watch((value) => {
|
||||||
|
console.log("Form values in edit dialog:", value);
|
||||||
|
});
|
||||||
|
return () => subscription.unsubscribe();
|
||||||
|
}, [form]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="sm:max-w-[600px] max-h-[90vh] overflow-y-auto">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{t('editMaintenanceWindow')}</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
{t('editMaintenanceDesc')}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<Form {...form}>
|
||||||
|
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
||||||
|
<MaintenanceBasicFields />
|
||||||
|
<MaintenanceTimeFields />
|
||||||
|
<MaintenanceAffectedFields />
|
||||||
|
<MaintenanceConfigFields />
|
||||||
|
<MaintenanceNotificationSettingsField />
|
||||||
|
|
||||||
|
<DialogFooter className="pt-4">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => onOpenChange(false)}
|
||||||
|
>
|
||||||
|
{t('cancel')}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
disabled={form.formState.isSubmitting}
|
||||||
|
>
|
||||||
|
{form.formState.isSubmitting ? t('updating') : t('updateMaintenance')}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
|
||||||
|
export * from './EditMaintenanceDialog';
|
||||||
+35
@@ -0,0 +1,35 @@
|
|||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import { useFormContext } from 'react-hook-form';
|
||||||
|
import { useLanguage } from '@/contexts/LanguageContext';
|
||||||
|
import { MaintenanceFormValues } from '../hooks/useMaintenanceForm';
|
||||||
|
import {
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormControl,
|
||||||
|
FormMessage,
|
||||||
|
} from '@/components/ui/form';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
|
||||||
|
export const MaintenanceAffectedFields: React.FC = () => {
|
||||||
|
const { t } = useLanguage();
|
||||||
|
const { control } = useFormContext<MaintenanceFormValues>();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FormField
|
||||||
|
control={control}
|
||||||
|
name="affected"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>{t('affectedServices')}</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input placeholder={t('enterAffectedServices')} {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
<p className="text-sm text-muted-foreground">{t('separateServicesWithComma')}</p>
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
+55
@@ -0,0 +1,55 @@
|
|||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import { useFormContext } from 'react-hook-form';
|
||||||
|
import { useLanguage } from '@/contexts/LanguageContext';
|
||||||
|
import { MaintenanceFormValues } from '../hooks/useMaintenanceForm';
|
||||||
|
import {
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormControl,
|
||||||
|
FormMessage,
|
||||||
|
} from '@/components/ui/form';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
|
|
||||||
|
export const MaintenanceBasicFields: React.FC = () => {
|
||||||
|
const { t } = useLanguage();
|
||||||
|
const { control } = useFormContext<MaintenanceFormValues>();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<FormField
|
||||||
|
control={control}
|
||||||
|
name="title"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>{t('title')}</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input placeholder={t('enterTitle')} {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={control}
|
||||||
|
name="description"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>{t('description')}</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Textarea
|
||||||
|
placeholder={t('enterDescription')}
|
||||||
|
className="resize-none"
|
||||||
|
{...field}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
+32
@@ -0,0 +1,32 @@
|
|||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import { useLanguage } from '@/contexts/LanguageContext';
|
||||||
|
import {
|
||||||
|
PriorityField,
|
||||||
|
StatusField,
|
||||||
|
ImpactLevelField,
|
||||||
|
AssignedUsersField
|
||||||
|
} from './config';
|
||||||
|
|
||||||
|
export const MaintenanceConfigFields = () => {
|
||||||
|
const { t } = useLanguage();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="text-sm font-medium">{t('configurationSettings')}</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||||
|
<PriorityField />
|
||||||
|
<StatusField />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 gap-4">
|
||||||
|
<ImpactLevelField />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 gap-6 mt-4">
|
||||||
|
<AssignedUsersField />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
+170
@@ -0,0 +1,170 @@
|
|||||||
|
|
||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import { useFormContext } from 'react-hook-form';
|
||||||
|
import { useLanguage } from '@/contexts/LanguageContext';
|
||||||
|
import { alertConfigService, AlertConfiguration } from '@/services/alertConfigService';
|
||||||
|
import { MaintenanceFormValues } from '../hooks/useMaintenanceForm';
|
||||||
|
import { FormField, FormItem, FormLabel, FormControl, FormDescription } from '@/components/ui/form';
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||||
|
import { Switch } from '@/components/ui/switch';
|
||||||
|
import { useToast } from '@/hooks/use-toast';
|
||||||
|
import { Skeleton } from '@/components/ui/skeleton';
|
||||||
|
import { Bell, BellOff } from 'lucide-react';
|
||||||
|
|
||||||
|
export const MaintenanceNotificationSettingsField = () => {
|
||||||
|
const { t } = useLanguage();
|
||||||
|
const { toast } = useToast();
|
||||||
|
const [notificationChannels, setNotificationChannels] = useState<AlertConfiguration[]>([]);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const { control, watch, setValue, getValues } = useFormContext<MaintenanceFormValues>();
|
||||||
|
|
||||||
|
// Watch the notification toggle state
|
||||||
|
const notifySubscribers = watch('notify_subscribers');
|
||||||
|
const notificationChannelId = watch('notification_channel_id');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchNotificationChannels = async () => {
|
||||||
|
try {
|
||||||
|
setIsLoading(true);
|
||||||
|
const channels = await alertConfigService.getAlertConfigurations();
|
||||||
|
console.log("Fetched notification channels for form:", channels);
|
||||||
|
|
||||||
|
// Only show enabled channels
|
||||||
|
const enabledChannels = channels.filter(channel => channel.enabled);
|
||||||
|
setNotificationChannels(enabledChannels);
|
||||||
|
|
||||||
|
// If we have notification channels and notification is enabled but no channel is selected,
|
||||||
|
// select the first one by default
|
||||||
|
const currentChannel = getValues('notification_channel_id');
|
||||||
|
const shouldNotify = getValues('notify_subscribers');
|
||||||
|
|
||||||
|
console.log("Current notification values:", {
|
||||||
|
currentChannel,
|
||||||
|
shouldNotify,
|
||||||
|
availableChannels: enabledChannels.length
|
||||||
|
});
|
||||||
|
|
||||||
|
if (shouldNotify && (!currentChannel || currentChannel === 'none') && enabledChannels.length > 0) {
|
||||||
|
console.log("Setting default notification channel:", enabledChannels[0].id);
|
||||||
|
setValue('notification_channel_id', enabledChannels[0].id);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching notification channels:', error);
|
||||||
|
toast({
|
||||||
|
title: t('error'),
|
||||||
|
description: t('errorFetchingNotificationChannels'),
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchNotificationChannels();
|
||||||
|
}, [t, toast, setValue, getValues]);
|
||||||
|
|
||||||
|
// Log value changes for debugging
|
||||||
|
useEffect(() => {
|
||||||
|
console.log("Current notification settings:", {
|
||||||
|
channel_id: getValues('notification_channel_id'),
|
||||||
|
notify: notifySubscribers
|
||||||
|
});
|
||||||
|
}, [notifySubscribers, notificationChannelId, getValues]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<h3 className="font-medium text-lg">{t('notificationSettings')}</h3>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={control}
|
||||||
|
name="notify_subscribers"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-4">
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{field.value ? <Bell className="w-4 h-4" /> : <BellOff className="w-4 h-4" />}
|
||||||
|
<FormLabel className="text-base mb-0">
|
||||||
|
{field.value ? t('notifySubscribers') : t('mutedNotifications')}
|
||||||
|
</FormLabel>
|
||||||
|
</div>
|
||||||
|
<FormDescription>
|
||||||
|
{field.value
|
||||||
|
? t('notifySubscribersDesc')
|
||||||
|
: t('notificationsAreMuted')}
|
||||||
|
</FormDescription>
|
||||||
|
</div>
|
||||||
|
<FormControl>
|
||||||
|
<Switch
|
||||||
|
checked={field.value}
|
||||||
|
onCheckedChange={(checked) => {
|
||||||
|
field.onChange(checked);
|
||||||
|
console.log("Notification toggle changed to:", checked);
|
||||||
|
// If notifications are disabled, also clear the notification channel
|
||||||
|
if (!checked) {
|
||||||
|
setValue('notification_channel_id', '');
|
||||||
|
} else if (notificationChannels.length > 0) {
|
||||||
|
// If enabled and channels available, select the first one by default
|
||||||
|
setValue('notification_channel_id', notificationChannels[0].id);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={control}
|
||||||
|
name="notification_channel_id"
|
||||||
|
render={({ field }) => {
|
||||||
|
// Make sure to handle both empty string and "none" as special cases
|
||||||
|
const displayValue = field.value || "";
|
||||||
|
|
||||||
|
console.log("Rendering notification channel field with value:", {
|
||||||
|
fieldValue: field.value,
|
||||||
|
displayValue
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>{t('notificationChannel')}</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
{isLoading ? (
|
||||||
|
<Skeleton className="h-10 w-full" />
|
||||||
|
) : (
|
||||||
|
<Select
|
||||||
|
value={displayValue}
|
||||||
|
onValueChange={(value) => {
|
||||||
|
console.log("Setting notification channel to:", value);
|
||||||
|
field.onChange(value === "none" ? "" : value);
|
||||||
|
}}
|
||||||
|
disabled={!notifySubscribers}
|
||||||
|
>
|
||||||
|
<SelectTrigger className={!notifySubscribers ? 'opacity-50' : ''}>
|
||||||
|
<SelectValue placeholder={t('selectNotificationChannel')} />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="none">
|
||||||
|
{t('none')}
|
||||||
|
</SelectItem>
|
||||||
|
{notificationChannels.map((channel) => (
|
||||||
|
<SelectItem key={channel.id} value={channel.id}>
|
||||||
|
{channel.notify_name} ({channel.notification_type})
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
)}
|
||||||
|
</FormControl>
|
||||||
|
<FormDescription>
|
||||||
|
{notifySubscribers
|
||||||
|
? t('selectChannelForNotifications')
|
||||||
|
: t('enableNotificationsFirst')}
|
||||||
|
</FormDescription>
|
||||||
|
</FormItem>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
+195
@@ -0,0 +1,195 @@
|
|||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import { useFormContext } from 'react-hook-form';
|
||||||
|
import { format } from 'date-fns';
|
||||||
|
import { CalendarIcon } from 'lucide-react';
|
||||||
|
import { useLanguage } from '@/contexts/LanguageContext';
|
||||||
|
import { MaintenanceFormValues } from '../hooks/useMaintenanceForm';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import {
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormControl,
|
||||||
|
FormMessage,
|
||||||
|
} from '@/components/ui/form';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Calendar } from '@/components/ui/calendar';
|
||||||
|
import {
|
||||||
|
Popover,
|
||||||
|
PopoverContent,
|
||||||
|
PopoverTrigger,
|
||||||
|
} from '@/components/ui/popover';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
|
||||||
|
export const MaintenanceTimeFields: React.FC = () => {
|
||||||
|
const { t } = useLanguage();
|
||||||
|
const { control } = useFormContext<MaintenanceFormValues>();
|
||||||
|
|
||||||
|
// Helper function to safely format dates
|
||||||
|
const safeFormat = (date: Date | null | undefined, formatStr: string): string => {
|
||||||
|
if (!date || !(date instanceof Date) || isNaN(date.getTime())) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
return format(date, formatStr);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<FormField
|
||||||
|
control={control}
|
||||||
|
name="start_time"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="flex flex-col">
|
||||||
|
<FormLabel>{t('startTime')}</FormLabel>
|
||||||
|
<Popover>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<FormControl>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className={cn(
|
||||||
|
"w-full pl-3 text-left font-normal",
|
||||||
|
!field.value && "text-muted-foreground"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{field.value && field.value instanceof Date && !isNaN(field.value.getTime()) ? (
|
||||||
|
safeFormat(field.value, "PPP HH:mm")
|
||||||
|
) : (
|
||||||
|
<span>{t('selectDate')}</span>
|
||||||
|
)}
|
||||||
|
<CalendarIcon className="ml-auto h-4 w-4 opacity-50" />
|
||||||
|
</Button>
|
||||||
|
</FormControl>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent className="w-auto p-0" align="start">
|
||||||
|
<Calendar
|
||||||
|
mode="single"
|
||||||
|
selected={field.value instanceof Date && !isNaN(field.value.getTime()) ? field.value : undefined}
|
||||||
|
onSelect={(date) => {
|
||||||
|
if (date) {
|
||||||
|
// If existing value is a valid date, preserve the time part
|
||||||
|
if (field.value instanceof Date && !isNaN(field.value.getTime())) {
|
||||||
|
const newDate = new Date(date);
|
||||||
|
newDate.setHours(field.value.getHours(), field.value.getMinutes());
|
||||||
|
field.onChange(newDate);
|
||||||
|
} else {
|
||||||
|
// Set default time to current time if no valid time exists
|
||||||
|
const now = new Date();
|
||||||
|
date.setHours(now.getHours(), now.getMinutes());
|
||||||
|
field.onChange(date);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
field.onChange(undefined);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
initialFocus
|
||||||
|
className={cn("p-3 pointer-events-auto")}
|
||||||
|
/>
|
||||||
|
<div className="p-3 border-t border-border">
|
||||||
|
<Input
|
||||||
|
type="time"
|
||||||
|
onChange={(e) => {
|
||||||
|
const timeValue = e.target.value;
|
||||||
|
if (!timeValue) return;
|
||||||
|
|
||||||
|
const [hours, minutes] = timeValue.split(':').map(Number);
|
||||||
|
const date = new Date(field.value || new Date());
|
||||||
|
|
||||||
|
// Ensure we have a valid date before setting hours/minutes
|
||||||
|
if (!isNaN(date.getTime())) {
|
||||||
|
date.setHours(hours, minutes);
|
||||||
|
field.onChange(date);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
value={field.value instanceof Date && !isNaN(field.value.getTime())
|
||||||
|
? safeFormat(field.value, "HH:mm")
|
||||||
|
: ""}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={control}
|
||||||
|
name="end_time"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="flex flex-col">
|
||||||
|
<FormLabel>{t('endTime')}</FormLabel>
|
||||||
|
<Popover>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<FormControl>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className={cn(
|
||||||
|
"w-full pl-3 text-left font-normal",
|
||||||
|
!field.value && "text-muted-foreground"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{field.value && field.value instanceof Date && !isNaN(field.value.getTime()) ? (
|
||||||
|
safeFormat(field.value, "PPP HH:mm")
|
||||||
|
) : (
|
||||||
|
<span>{t('selectDate')}</span>
|
||||||
|
)}
|
||||||
|
<CalendarIcon className="ml-auto h-4 w-4 opacity-50" />
|
||||||
|
</Button>
|
||||||
|
</FormControl>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent className="w-auto p-0" align="start">
|
||||||
|
<Calendar
|
||||||
|
mode="single"
|
||||||
|
selected={field.value instanceof Date && !isNaN(field.value.getTime()) ? field.value : undefined}
|
||||||
|
onSelect={(date) => {
|
||||||
|
if (date) {
|
||||||
|
// If existing value is a valid date, preserve the time part
|
||||||
|
if (field.value instanceof Date && !isNaN(field.value.getTime())) {
|
||||||
|
const newDate = new Date(date);
|
||||||
|
newDate.setHours(field.value.getHours(), field.value.getMinutes());
|
||||||
|
field.onChange(newDate);
|
||||||
|
} else {
|
||||||
|
// Set default time to current time if no valid time exists
|
||||||
|
const now = new Date();
|
||||||
|
// Default to current time + 1 hour for end time
|
||||||
|
date.setHours(now.getHours() + 1, now.getMinutes());
|
||||||
|
field.onChange(date);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
field.onChange(undefined);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
initialFocus
|
||||||
|
className={cn("p-3 pointer-events-auto")}
|
||||||
|
/>
|
||||||
|
<div className="p-3 border-t border-border">
|
||||||
|
<Input
|
||||||
|
type="time"
|
||||||
|
onChange={(e) => {
|
||||||
|
const timeValue = e.target.value;
|
||||||
|
if (!timeValue) return;
|
||||||
|
|
||||||
|
const [hours, minutes] = timeValue.split(':').map(Number);
|
||||||
|
const date = new Date(field.value || new Date());
|
||||||
|
|
||||||
|
// Ensure we have a valid date before setting hours/minutes
|
||||||
|
if (!isNaN(date.getTime())) {
|
||||||
|
date.setHours(hours, minutes);
|
||||||
|
field.onChange(date);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
value={field.value instanceof Date && !isNaN(field.value.getTime())
|
||||||
|
? safeFormat(field.value, "HH:mm")
|
||||||
|
: ""}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
+185
@@ -0,0 +1,185 @@
|
|||||||
|
|
||||||
|
import React, { useEffect } from 'react';
|
||||||
|
import { useLanguage } from '@/contexts/LanguageContext';
|
||||||
|
import { useFormContext } from 'react-hook-form';
|
||||||
|
import {
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormControl,
|
||||||
|
FormMessage,
|
||||||
|
FormDescription
|
||||||
|
} from '@/components/ui/form';
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { userService } from '@/services/userService';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { X, Users } from 'lucide-react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
||||||
|
|
||||||
|
export const AssignedUsersField = () => {
|
||||||
|
const { t } = useLanguage();
|
||||||
|
const form = useFormContext();
|
||||||
|
|
||||||
|
// Ensure assigned_users is initialized as an array
|
||||||
|
useEffect(() => {
|
||||||
|
const currentValue = form.getValues('assigned_users');
|
||||||
|
console.log("Initial assigned_users value:", currentValue);
|
||||||
|
|
||||||
|
// Initialize as empty array if no value or invalid value
|
||||||
|
if (!currentValue || !Array.isArray(currentValue)) {
|
||||||
|
console.log("Initializing assigned_users as empty array");
|
||||||
|
form.setValue('assigned_users', [], { shouldValidate: false, shouldDirty: true });
|
||||||
|
}
|
||||||
|
}, [form]);
|
||||||
|
|
||||||
|
console.log("Current form values:", form.getValues());
|
||||||
|
console.log("Current assigned_users:", form.getValues('assigned_users'));
|
||||||
|
|
||||||
|
// Fetch users for the assignment dropdown
|
||||||
|
const { data: users = [], isLoading } = useQuery({
|
||||||
|
queryKey: ['users'],
|
||||||
|
queryFn: async () => {
|
||||||
|
try {
|
||||||
|
const usersList = await userService.getUsers();
|
||||||
|
console.log("Fetched users for assignment:", usersList);
|
||||||
|
return Array.isArray(usersList) ? usersList : [];
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to fetch users:", error);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
},
|
||||||
|
staleTime: 300000 // Cache for 5 minutes to reduce API calls
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get the currently selected assigned users from the form
|
||||||
|
const selectedUserIds = Array.isArray(form.watch('assigned_users'))
|
||||||
|
? form.watch('assigned_users')
|
||||||
|
: [];
|
||||||
|
|
||||||
|
console.log("Selected user IDs:", selectedUserIds);
|
||||||
|
|
||||||
|
// Function to add a user
|
||||||
|
const addUser = (userId: string) => {
|
||||||
|
const currentValues = Array.isArray(form.getValues('assigned_users'))
|
||||||
|
? [...form.getValues('assigned_users')]
|
||||||
|
: [];
|
||||||
|
|
||||||
|
if (!currentValues.includes(userId)) {
|
||||||
|
console.log("Adding user:", userId);
|
||||||
|
form.setValue('assigned_users', [...currentValues, userId], { shouldValidate: true, shouldDirty: true });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Function to remove a user
|
||||||
|
const removeUser = (userId: string) => {
|
||||||
|
const currentValues = Array.isArray(form.getValues('assigned_users'))
|
||||||
|
? [...form.getValues('assigned_users')]
|
||||||
|
: [];
|
||||||
|
|
||||||
|
console.log("Removing user:", userId);
|
||||||
|
form.setValue(
|
||||||
|
'assigned_users',
|
||||||
|
currentValues.filter(id => id !== userId),
|
||||||
|
{ shouldValidate: true, shouldDirty: true }
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Get selected users data
|
||||||
|
const selectedUsers = users.filter(user => selectedUserIds.includes(user.id));
|
||||||
|
console.log("Matched selected users:", selectedUsers);
|
||||||
|
|
||||||
|
// Function to get user initials from name
|
||||||
|
const getUserInitials = (user: any): string => {
|
||||||
|
if (user.full_name) {
|
||||||
|
const nameParts = user.full_name.split(' ');
|
||||||
|
if (nameParts.length > 1) {
|
||||||
|
return `${nameParts[0][0]}${nameParts[1][0]}`.toUpperCase();
|
||||||
|
}
|
||||||
|
return user.full_name.substring(0, 2).toUpperCase();
|
||||||
|
}
|
||||||
|
return user.username.substring(0, 2).toUpperCase();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="assigned_users"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="flex flex-col">
|
||||||
|
<FormLabel className="flex items-center gap-1">
|
||||||
|
<Users className="h-4 w-4" /> {t('assignedPersonnel')}
|
||||||
|
</FormLabel>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<Select onValueChange={addUser}>
|
||||||
|
<FormControl>
|
||||||
|
<SelectTrigger className="w-full">
|
||||||
|
<SelectValue placeholder={t('selectAssignedUsers')} />
|
||||||
|
</SelectTrigger>
|
||||||
|
</FormControl>
|
||||||
|
<SelectContent>
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="py-2 px-2 text-sm">{t('loading')}</div>
|
||||||
|
) : users.length === 0 ? (
|
||||||
|
<div className="py-2 px-2 text-sm">{t('noUsersFound')}</div>
|
||||||
|
) : (
|
||||||
|
users.map((user) => (
|
||||||
|
<SelectItem
|
||||||
|
key={user.id}
|
||||||
|
value={user.id}
|
||||||
|
disabled={selectedUserIds.includes(user.id)}
|
||||||
|
>
|
||||||
|
{user.full_name || user.username}
|
||||||
|
</SelectItem>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
|
||||||
|
{selectedUsers.length > 0 ? (
|
||||||
|
<div className="flex flex-wrap gap-2 mt-2 border p-2 rounded-md bg-muted/50">
|
||||||
|
{selectedUsers.map((user) => (
|
||||||
|
<Badge key={user.id} variant="secondary" className="flex items-center gap-1 py-1 px-2">
|
||||||
|
<Avatar className="h-5 w-5 mr-1">
|
||||||
|
<AvatarImage src={user.avatar} alt={user.full_name || user.username} />
|
||||||
|
<AvatarFallback className="text-[10px]">
|
||||||
|
{getUserInitials(user)}
|
||||||
|
</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
<span>{user.full_name || user.username}</span>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-4 w-4 p-0 ml-1 hover:bg-transparent hover:opacity-70"
|
||||||
|
onClick={() => removeUser(user.id)}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<X className="h-3 w-3" />
|
||||||
|
<span className="sr-only">{t('remove')}</span>
|
||||||
|
</Button>
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="text-sm text-muted-foreground italic p-2">
|
||||||
|
{t('noAssignedUsers')}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<FormDescription>
|
||||||
|
{t('assignedPersonnelDescription')}
|
||||||
|
</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user