Compare commits
176 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f88cc32cf9 | |||
| 918a248b8f | |||
| 458f5f7d82 | |||
| 5ceaa09710 | |||
| 1eabf66311 | |||
| a0c0a3ed75 | |||
| abb129bde0 | |||
| 220be3bea4 | |||
| 614c10188d | |||
| 11408dcba0 | |||
| 9f4e759b01 | |||
| d3ea5dc77c | |||
| 89572c44b7 | |||
| dfe98f9fa6 | |||
| 549a3793a5 | |||
| 9ed4d4c7ac | |||
| cca2338468 | |||
| c1c74bb2a8 | |||
| a2ae66fe76 | |||
| 907fdaa0dc | |||
| 93b58cfa2c | |||
| 879622b364 | |||
| 2f52010963 | |||
| c90f0dd963 | |||
| 4a14f64e07 | |||
| 19b4de78d1 | |||
| bdf648c2fe | |||
| 0705fc89b4 | |||
| 883a541b9b | |||
| 0758b1d24b | |||
| 9a001f6aec | |||
| 9c78def48d | |||
| 94e2c0da77 | |||
| 78674f758c | |||
| 3a6ab2258a | |||
| 1daaf067d9 | |||
| 6d1b8387ca | |||
| e3f9111484 | |||
| 575869c50a | |||
| cfdedb9636 | |||
| 08b00728c2 | |||
| 4486a79e58 | |||
| 41fcaaaf05 | |||
| ddcabac564 | |||
| 4e0a9953db | |||
| f25fa7de83 | |||
| aea70d0023 | |||
| 4e07c8c932 | |||
| 5f18fde775 | |||
| 9d5fa805c9 | |||
| e1d466a638 | |||
| 8dc3bd6a0d | |||
| 6d63d9f485 | |||
| 68a4ad5478 | |||
| 895da83999 | |||
| f6592d331f | |||
| da0808cefe | |||
| 1e77f2747e | |||
| 9b1918e25a | |||
| 99e988e316 | |||
| 419e9118f9 | |||
| e2c2705378 | |||
| 82fa9a4ac2 | |||
| 576f2fbb34 | |||
| 554faefe4a | |||
| c92e9fe442 | |||
| ede5c36ac4 | |||
| 9097a618cd | |||
| f4f6cb48db | |||
| 1264d293bf | |||
| 8608b6a263 | |||
| 86e1e70e22 | |||
| dd2c64420d | |||
| ba47c1d953 | |||
| ba6dc41d95 | |||
| 1dc25cc461 | |||
| 11ddc77e32 | |||
| 5a5772f9a4 | |||
| 039a832b79 | |||
| a88b356bf5 | |||
| 2d798e2983 | |||
| 426f41e337 | |||
| 4dc005c7e4 | |||
| 4ef84b5469 | |||
| 0739d6b0ce | |||
| bd17ef7140 | |||
| 518b469c04 | |||
| 6652ac5e76 | |||
| 4ec0c7a8fa | |||
| 0b52e7ffa9 | |||
| dd758b9334 | |||
| 9b1167955e | |||
| 611380a5e4 | |||
| 0b9bc76d37 | |||
| 0562adbef8 | |||
| d4d4040cc8 | |||
| aec7118496 | |||
| c17d902b9a | |||
| a5ad5eef42 | |||
| c7846a2fed | |||
| 4ad001b560 | |||
| d52842a8ea | |||
| 9e02eea0d1 | |||
| a1d2f1a9e0 | |||
| 230f5562c7 | |||
| 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 |
@@ -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
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
+18
-14
@@ -1,17 +1,21 @@
|
|||||||
The MIT License (MIT)
|
MIT License
|
||||||
Copyright (c) 2025, Tola Leng
|
|
||||||
|
|
||||||
Permission is hereby granted, free of charge, to any person obtaining a copy of this software
|
Copyright (c) 2025 Tola Leng
|
||||||
and associated documentation files (the "Software"), to deal in the Software without restriction,
|
|
||||||
including without limitation the rights to use, copy, modify, merge, publish, distribute,
|
|
||||||
sublicense, and/or sell copies of the Software, and to permit persons to whom the Software
|
|
||||||
is furnished to do so, subject to the following conditions:
|
|
||||||
|
|
||||||
The above copyright notice and this permission notice shall be included in all copies or
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
substantial portions of the Software.
|
of this software and associated documentation files (the “Software”), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING
|
The above copyright notice and this permission notice shall be included in all
|
||||||
BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
copies or substantial portions of the Software.
|
||||||
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM,
|
|
||||||
DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
|
|||||||
@@ -24,21 +24,33 @@ CheckCle is an Open Source solution for seamless, real-time monitoring of full-s
|
|||||||
|
|
||||||
## #️⃣ Getting Started
|
## #️⃣ Getting Started
|
||||||
|
|
||||||
### Installation with Docker Compose
|
### Current Architecture Support
|
||||||
1. Copy ready docker run command
|
* ✅ x86_64 PCs, laptops, servers (amd64)
|
||||||
|
* ✅ Modern Raspberry Pi 3/4/5 with (64-bit OS), Apple Silicon Macs (arm64)
|
||||||
|
|
||||||
|
### Install CheckCle using one of the options below.
|
||||||
|
|
||||||
|
|
||||||
|
1. CheckCle One-Click Installation - Just copy and run on terminal
|
||||||
```bash
|
```bash
|
||||||
# Create Docker Volume for data persistence
|
curl -fsSL https://checkcle.io/install.sh | bash
|
||||||
|
|
||||||
docker volume create pb_data
|
|
||||||
|
|
||||||
|
|
||||||
# Docker Run Command
|
|
||||||
|
|
||||||
docker run --name checkcle --restart unless-stopped -p 8090:8090 -v pb_data:/app/pb_data --ulimit nofile=4096:8192 operacle/checkcle:latest
|
|
||||||
|
|
||||||
```
|
```
|
||||||
2. Docker Compose - Recommended
|
2. Install with docker run. Just copy ready docker run command below
|
||||||
```bash
|
```bash
|
||||||
|
docker run -d \
|
||||||
|
--name checkcle \
|
||||||
|
--restart unless-stopped \
|
||||||
|
-p 8090:8090 \
|
||||||
|
-v /opt/pb_data:/mnt/pb_data \
|
||||||
|
--ulimit nofile=4096:8192 \
|
||||||
|
operacle/checkcle:latest
|
||||||
|
|
||||||
|
```
|
||||||
|
3. Install with Docker compose Configuration.
|
||||||
|
```bash
|
||||||
|
|
||||||
|
version: '3.9'
|
||||||
|
|
||||||
services:
|
services:
|
||||||
checkcle:
|
checkcle:
|
||||||
@@ -48,15 +60,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,31 +73,34 @@ 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
|
||||||
|
|
||||||
- [x] Health check & uptime monitoring (HTTP)
|
- ✅ Health check & uptime monitoring (HTTP)
|
||||||
- [x] Dashboard UI with live stats
|
- ✅ Dashboard UI with live stats
|
||||||
- [x] Auth with Multi-users system (admin)
|
- ✅ Auth with Multi-users system (admin)
|
||||||
- [x] Notifications (Telegram)
|
- ✅ Notifications (Telegram)
|
||||||
- [x] Docker containerization
|
- ✅ Docker containerization
|
||||||
- [x] CheckCle Website
|
- ✅ CheckCle Website
|
||||||
- [x] CheckCle Demo Server
|
- ✅ CheckCle Demo Server
|
||||||
- [ ] SSL & Domain Monitoring (Inprogress)
|
- ✅ SSL & Domain Monitoring
|
||||||
|
- ✅ Schedule Maintenance
|
||||||
|
- ✅ Incident Management
|
||||||
- [ ] Uptime monitoring (PING - Inprogress)
|
- [ ] 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
|
- ✅ System Setting Panel and Mail Settings
|
||||||
|
- ✅ User Permission Roles
|
||||||
- [ ] Notifications (Email/Slack/Discord/Signal)
|
- [ ] Notifications (Email/Slack/Discord/Signal)
|
||||||
- [ ] Open-source release with full documentation
|
- ✅ Data Retention & Automate Strink (Muti Options to Shrink Data & Database )
|
||||||
|
- ✅ 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
|
||||||
@@ -111,6 +123,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)
|
||||||
@@ -122,4 +135,8 @@ CheckCle is released under the MIT License.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Star History
|
||||||
|
|
||||||
|
[](https://www.star-history.com/#operacle/checkcle&Date)
|
||||||
|
|
||||||
Stay informed, stay online, with CheckCle! 🌐
|
Stay informed, stay online, with CheckCle! 🌐
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 12 KiB |
+50
-102
@@ -1,116 +1,64 @@
|
|||||||
|
|
||||||
import { Toaster } from "@/components/ui/toaster";
|
import React from 'react';
|
||||||
import { Toaster as Sonner } from "@/components/ui/sonner";
|
import { BrowserRouter as Router, Routes, Route } from 'react-router-dom';
|
||||||
import { TooltipProvider } from "@/components/ui/tooltip";
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
import { Toaster } from '@/components/ui/sonner';
|
||||||
import { BrowserRouter, Routes, Route, Navigate } from "react-router-dom";
|
import { ThemeProvider } from '@/contexts/ThemeContext';
|
||||||
import { useEffect, useState } from "react";
|
import { LanguageProvider } from '@/contexts/LanguageContext';
|
||||||
import { authService } from "@/services/authService";
|
import { SidebarProvider } from '@/contexts/SidebarContext';
|
||||||
import { ThemeProvider } from "@/contexts/ThemeContext";
|
import { ErrorBoundary } from '@/components/ErrorBoundary';
|
||||||
import { LanguageProvider } from "@/contexts/LanguageContext";
|
|
||||||
|
|
||||||
import Login from "./pages/Login";
|
// Pages
|
||||||
import Dashboard from "./pages/Dashboard";
|
import Index from '@/pages/Index';
|
||||||
import ServiceDetail from "./pages/ServiceDetail";
|
import Login from '@/pages/Login';
|
||||||
import Settings from "./pages/Settings";
|
import Dashboard from '@/pages/Dashboard';
|
||||||
import Profile from "./pages/Profile";
|
import ServiceDetail from '@/pages/ServiceDetail';
|
||||||
import NotFound from "./pages/NotFound";
|
import Profile from '@/pages/Profile';
|
||||||
import SslDomain from "./pages/SslDomain";
|
import Settings from '@/pages/Settings';
|
||||||
|
import OperationalPage from '@/pages/OperationalPage';
|
||||||
|
import ScheduleIncident from '@/pages/ScheduleIncident';
|
||||||
|
import SslDomain from '@/pages/SslDomain';
|
||||||
|
import PublicStatusPage from '@/pages/PublicStatusPage';
|
||||||
|
import NotFound from '@/pages/NotFound';
|
||||||
|
|
||||||
// Create a Protected route component
|
const queryClient = new QueryClient({
|
||||||
const ProtectedRoute = ({ children }: { children: React.ReactNode }) => {
|
defaultOptions: {
|
||||||
const isAuthenticated = authService.isAuthenticated();
|
queries: {
|
||||||
|
retry: 1,
|
||||||
if (!isAuthenticated) {
|
refetchOnWindowFocus: false,
|
||||||
return <Navigate to="/login" replace />;
|
},
|
||||||
}
|
},
|
||||||
|
});
|
||||||
return <>{children}</>;
|
|
||||||
};
|
|
||||||
|
|
||||||
const queryClient = new QueryClient();
|
|
||||||
|
|
||||||
const App = () => {
|
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
// Check authentication status when the app loads
|
|
||||||
const checkAuth = async () => {
|
|
||||||
try {
|
|
||||||
// Just check the auth state
|
|
||||||
authService.isAuthenticated();
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
checkAuth();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
if (isLoading) {
|
|
||||||
return <div className="flex items-center justify-center h-screen bg-background text-foreground">Loading...</div>;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
function App() {
|
||||||
return (
|
return (
|
||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientProvider client={queryClient}>
|
||||||
<ThemeProvider>
|
<ThemeProvider>
|
||||||
<LanguageProvider>
|
<LanguageProvider>
|
||||||
<TooltipProvider>
|
<SidebarProvider>
|
||||||
<Toaster />
|
<ErrorBoundary>
|
||||||
<Sonner />
|
<Router>
|
||||||
<BrowserRouter>
|
<Routes>
|
||||||
<Routes>
|
<Route path="/" element={<Index />} />
|
||||||
<Route path="/login" element={<Login />} />
|
<Route path="/login" element={<Login />} />
|
||||||
<Route path="/" element={<Navigate to="/dashboard" replace />} />
|
<Route path="/dashboard" element={<Dashboard />} />
|
||||||
<Route
|
<Route path="/service/:id" element={<ServiceDetail />} />
|
||||||
path="/dashboard"
|
<Route path="/profile" element={<Profile />} />
|
||||||
element={
|
<Route path="/settings" element={<Settings />} />
|
||||||
<ProtectedRoute>
|
<Route path="/operational-page" element={<OperationalPage />} />
|
||||||
<Dashboard />
|
<Route path="/schedule-incident" element={<ScheduleIncident />} />
|
||||||
</ProtectedRoute>
|
<Route path="/ssl-domain" element={<SslDomain />} />
|
||||||
}
|
<Route path="/status/:slug" element={<PublicStatusPage />} />
|
||||||
/>
|
<Route path="*" element={<NotFound />} />
|
||||||
<Route
|
</Routes>
|
||||||
path="/service/:id"
|
</Router>
|
||||||
element={
|
<Toaster />
|
||||||
<ProtectedRoute>
|
</ErrorBoundary>
|
||||||
<ServiceDetail />
|
</SidebarProvider>
|
||||||
</ProtectedRoute>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<Route
|
|
||||||
path="/settings"
|
|
||||||
element={
|
|
||||||
<ProtectedRoute>
|
|
||||||
<Settings />
|
|
||||||
</ProtectedRoute>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<Route
|
|
||||||
path="/profile"
|
|
||||||
element={
|
|
||||||
<ProtectedRoute>
|
|
||||||
<Profile />
|
|
||||||
</ProtectedRoute>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<Route
|
|
||||||
path="/ssl-domain"
|
|
||||||
element={
|
|
||||||
<ProtectedRoute>
|
|
||||||
<SslDomain />
|
|
||||||
</ProtectedRoute>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
{/* ADD ALL CUSTOM ROUTES ABOVE THE CATCH-ALL "*" ROUTE */}
|
|
||||||
<Route path="*" element={<NotFound />} />
|
|
||||||
</Routes>
|
|
||||||
</BrowserRouter>
|
|
||||||
</TooltipProvider>
|
|
||||||
</LanguageProvider>
|
</LanguageProvider>
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
</QueryClientProvider>
|
</QueryClientProvider>
|
||||||
);
|
);
|
||||||
};
|
}
|
||||||
|
|
||||||
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' || path.startsWith('/api/settings/')) {
|
||||||
|
console.log("Routing to settings handler");
|
||||||
|
return await settingsApi(body, path);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Return 404 for unknown routes
|
// Return 404 for unknown routes
|
||||||
@@ -31,4 +35,52 @@ const 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;
|
export default api;
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
|
||||||
|
import { getAuthHeaders, getBaseUrl } from '../utils';
|
||||||
|
import { SettingsApiResponse } from '../types';
|
||||||
|
|
||||||
|
export const getSettings = async (): Promise<SettingsApiResponse> => {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${getBaseUrl()}/api/settings`, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: getAuthHeaders(),
|
||||||
|
});
|
||||||
|
|
||||||
|
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' },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,208 @@
|
|||||||
|
|
||||||
|
import { getAuthHeaders, getBaseUrl, validateEmail } from '../utils';
|
||||||
|
import { SettingsApiResponse } from '../types';
|
||||||
|
|
||||||
|
const createEmailTemplate = (template: string, data: any): { subject: string; htmlBody: string } => {
|
||||||
|
let subject = 'Test Email from ReamStack';
|
||||||
|
let htmlBody = `
|
||||||
|
<html>
|
||||||
|
<body style="font-family: Arial, sans-serif; line-height: 1.6; color: #333;">
|
||||||
|
<div style="max-width: 600px; margin: 0 auto; padding: 20px;">
|
||||||
|
<h2 style="color: #2563eb;">Test Email</h2>
|
||||||
|
<p>This is a test email from your monitoring system.</p>
|
||||||
|
<p>If you received this email, your SMTP configuration is working correctly.</p>
|
||||||
|
<hr style="border: none; border-top: 1px solid #eee; margin: 20px 0;">
|
||||||
|
<p style="font-size: 12px; color: #666;">
|
||||||
|
Sent from ReamStack Monitoring System<br>
|
||||||
|
Template: ${template}<br>
|
||||||
|
${data.collection ? `Collection: ${data.collection}` : ''}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
`;
|
||||||
|
|
||||||
|
switch (template) {
|
||||||
|
case 'verification':
|
||||||
|
subject = 'Email Verification Test - ReamStack';
|
||||||
|
htmlBody = `
|
||||||
|
<html>
|
||||||
|
<body style="font-family: Arial, sans-serif; line-height: 1.6; color: #333;">
|
||||||
|
<div style="max-width: 600px; margin: 0 auto; padding: 20px;">
|
||||||
|
<h2 style="color: #10b981;">Email Verification Test</h2>
|
||||||
|
<p>This is a test of the email verification template.</p>
|
||||||
|
<p>If you received this email, your SMTP configuration is working correctly.</p>
|
||||||
|
<div style="background: #f3f4f6; padding: 15px; border-radius: 5px; margin: 20px 0;">
|
||||||
|
<p><strong>Template:</strong> Verification Email</p>
|
||||||
|
<p><strong>Collection:</strong> ${data.collection || '_superusers'}</p>
|
||||||
|
</div>
|
||||||
|
<hr style="border: none; border-top: 1px solid #eee; margin: 20px 0;">
|
||||||
|
<p style="font-size: 12px; color: #666;">Sent from ReamStack Monitoring System</p>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
`;
|
||||||
|
break;
|
||||||
|
case 'password-reset':
|
||||||
|
subject = 'Password Reset Test - ReamStack';
|
||||||
|
htmlBody = `
|
||||||
|
<html>
|
||||||
|
<body style="font-family: Arial, sans-serif; line-height: 1.6; color: #333;">
|
||||||
|
<div style="max-width: 600px; margin: 0 auto; padding: 20px;">
|
||||||
|
<h2 style="color: #f59e0b;">Password Reset Test</h2>
|
||||||
|
<p>This is a test of the password reset template.</p>
|
||||||
|
<p>If you received this email, your SMTP configuration is working correctly.</p>
|
||||||
|
<div style="background: #f3f4f6; padding: 15px; border-radius: 5px; margin: 20px 0;">
|
||||||
|
<p><strong>Template:</strong> Password Reset Email</p>
|
||||||
|
<p><strong>Collection:</strong> ${data.collection || '_superusers'}</p>
|
||||||
|
</div>
|
||||||
|
<hr style="border: none; border-top: 1px solid #eee; margin: 20px 0;">
|
||||||
|
<p style="font-size: 12px; color: #666;">Sent from ReamStack Monitoring System</p>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
`;
|
||||||
|
break;
|
||||||
|
case 'email-change':
|
||||||
|
subject = 'Email Change Confirmation Test - ReamStack';
|
||||||
|
htmlBody = `
|
||||||
|
<html>
|
||||||
|
<body style="font-family: Arial, sans-serif; line-height: 1.6; color: #333;">
|
||||||
|
<div style="max-width: 600px; margin: 0 auto; padding: 20px;">
|
||||||
|
<h2 style="color: #8b5cf6;">Email Change Confirmation Test</h2>
|
||||||
|
<p>This is a test of the email change confirmation template.</p>
|
||||||
|
<p>If you received this email, your SMTP configuration is working correctly.</p>
|
||||||
|
<div style="background: #f3f4f6; padding: 15px; border-radius: 5px; margin: 20px 0;">
|
||||||
|
<p><strong>Template:</strong> Email Change Confirmation</p>
|
||||||
|
</div>
|
||||||
|
<hr style="border: none; border-top: 1px solid #eee; margin: 20px 0;">
|
||||||
|
<p style="font-size: 12px; color: #666;">Sent from ReamStack Monitoring System</p>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
`;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { subject, htmlBody };
|
||||||
|
};
|
||||||
|
|
||||||
|
export const sendTestEmail = async (data: any): Promise<SettingsApiResponse> => {
|
||||||
|
console.log('sendTestEmail function called with data:', data);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Validate required fields
|
||||||
|
if (!data || typeof data !== 'object') {
|
||||||
|
console.log('Invalid request data - not object');
|
||||||
|
return {
|
||||||
|
status: 200,
|
||||||
|
json: { success: false, message: 'Invalid request data' },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!data.email || typeof data.email !== 'string') {
|
||||||
|
console.log('Email address missing or invalid type');
|
||||||
|
return {
|
||||||
|
status: 200,
|
||||||
|
json: { success: false, message: 'Email address is required and must be a string' },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!validateEmail(data.email)) {
|
||||||
|
console.log('Invalid email format:', data.email);
|
||||||
|
return {
|
||||||
|
status: 200,
|
||||||
|
json: { success: false, message: 'Invalid email address format' },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('Email validation passed for:', data.email);
|
||||||
|
|
||||||
|
const headers = getAuthHeaders();
|
||||||
|
const baseUrl = getBaseUrl();
|
||||||
|
|
||||||
|
// Get current SMTP settings first
|
||||||
|
console.log('Fetching SMTP settings from:', `${baseUrl}/api/settings`);
|
||||||
|
|
||||||
|
const settingsResponse = await fetch(`${baseUrl}/api/settings`, {
|
||||||
|
method: 'GET',
|
||||||
|
headers,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!settingsResponse.ok) {
|
||||||
|
console.error('Failed to get SMTP settings, status:', settingsResponse.status);
|
||||||
|
return {
|
||||||
|
status: 200,
|
||||||
|
json: { success: false, message: 'Failed to get SMTP settings' },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const settingsData = await settingsResponse.json();
|
||||||
|
console.log('Retrieved settings data:', settingsData);
|
||||||
|
|
||||||
|
const smtpSettings = settingsData?.smtp;
|
||||||
|
|
||||||
|
if (!smtpSettings || !smtpSettings.enabled) {
|
||||||
|
console.log('SMTP not enabled or missing');
|
||||||
|
return {
|
||||||
|
status: 200,
|
||||||
|
json: { success: false, message: 'SMTP is not enabled. Please enable and configure SMTP settings first.' },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!smtpSettings.host || !smtpSettings.username) {
|
||||||
|
console.log('SMTP configuration incomplete - missing host or username');
|
||||||
|
return {
|
||||||
|
status: 200,
|
||||||
|
json: { success: false, message: 'SMTP configuration is incomplete. Please check host and username.' },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!smtpSettings.password) {
|
||||||
|
console.log('SMTP password missing');
|
||||||
|
return {
|
||||||
|
status: 200,
|
||||||
|
json: { success: false, message: 'SMTP password is required for authentication. Please configure the SMTP password.' },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create test email content based on template
|
||||||
|
const template = data.template || 'basic';
|
||||||
|
const { subject, htmlBody } = createEmailTemplate(template, data);
|
||||||
|
|
||||||
|
console.log('Test email prepared successfully:', {
|
||||||
|
to: data.email,
|
||||||
|
subject: subject,
|
||||||
|
template: template,
|
||||||
|
smtpHost: smtpSettings.host,
|
||||||
|
smtpPort: smtpSettings.port || 587
|
||||||
|
});
|
||||||
|
|
||||||
|
// For now, we'll simulate a successful email send
|
||||||
|
// In a real implementation, you would integrate with your email service here
|
||||||
|
// This could be nodemailer, SendGrid, or your PocketBase email system
|
||||||
|
|
||||||
|
// Simulate processing time
|
||||||
|
console.log('Simulating email send...');
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||||
|
console.log('Email send simulation completed');
|
||||||
|
|
||||||
|
return {
|
||||||
|
status: 200,
|
||||||
|
json: {
|
||||||
|
success: true,
|
||||||
|
message: `Test email sent successfully to ${data.email}`,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error in sendTestEmail function:', error);
|
||||||
|
return {
|
||||||
|
status: 200,
|
||||||
|
json: {
|
||||||
|
success: false,
|
||||||
|
message: error instanceof Error ? error.message : 'Failed to send test email. Please check your SMTP configuration.'
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,238 @@
|
|||||||
|
import { getAuthHeaders, getBaseUrl, validateEmail } from '../utils';
|
||||||
|
import { SettingsApiResponse } from '../types';
|
||||||
|
|
||||||
|
const createEmailTemplate = (template: string, data: any): { subject: string; htmlBody: string } => {
|
||||||
|
let subject = 'Test Email from ReamStack';
|
||||||
|
let htmlBody = `
|
||||||
|
<html>
|
||||||
|
<body style="font-family: Arial, sans-serif; line-height: 1.6; color: #333;">
|
||||||
|
<div style="max-width: 600px; margin: 0 auto; padding: 20px;">
|
||||||
|
<h2 style="color: #2563eb;">Test Email</h2>
|
||||||
|
<p>This is a test email from your monitoring system.</p>
|
||||||
|
<p>If you received this email, your SMTP configuration is working correctly.</p>
|
||||||
|
<hr style="border: none; border-top: 1px solid #eee; margin: 20px 0;">
|
||||||
|
<p style="font-size: 12px; color: #666;">
|
||||||
|
Sent from ReamStack Monitoring System<br>
|
||||||
|
Template: ${template}<br>
|
||||||
|
${data.collection ? `Collection: ${data.collection}` : ''}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
`;
|
||||||
|
|
||||||
|
switch (template) {
|
||||||
|
case 'verification':
|
||||||
|
subject = 'Email Verification Test - ReamStack';
|
||||||
|
htmlBody = `
|
||||||
|
<html>
|
||||||
|
<body style="font-family: Arial, sans-serif; line-height: 1.6; color: #333;">
|
||||||
|
<div style="max-width: 600px; margin: 0 auto; padding: 20px;">
|
||||||
|
<h2 style="color: #10b981;">Email Verification Test</h2>
|
||||||
|
<p>This is a test of the email verification template.</p>
|
||||||
|
<p>If you received this email, your SMTP configuration is working correctly.</p>
|
||||||
|
<div style="background: #f3f4f6; padding: 15px; border-radius: 5px; margin: 20px 0;">
|
||||||
|
<p><strong>Template:</strong> Verification Email</p>
|
||||||
|
<p><strong>Collection:</strong> ${data.collection || '_superusers'}</p>
|
||||||
|
</div>
|
||||||
|
<hr style="border: none; border-top: 1px solid #eee; margin: 20px 0;">
|
||||||
|
<p style="font-size: 12px; color: #666;">Sent from ReamStack Monitoring System</p>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
`;
|
||||||
|
break;
|
||||||
|
case 'password-reset':
|
||||||
|
subject = 'Password Reset Test - ReamStack';
|
||||||
|
htmlBody = `
|
||||||
|
<html>
|
||||||
|
<body style="font-family: Arial, sans-serif; line-height: 1.6; color: #333;">
|
||||||
|
<div style="max-width: 600px; margin: 0 auto; padding: 20px;">
|
||||||
|
<h2 style="color: #f59e0b;">Password Reset Test</h2>
|
||||||
|
<p>This is a test of the password reset template.</p>
|
||||||
|
<p>If you received this email, your SMTP configuration is working correctly.</p>
|
||||||
|
<div style="background: #f3f4f6; padding: 15px; border-radius: 5px; margin: 20px 0;">
|
||||||
|
<p><strong>Template:</strong> Password Reset Email</p>
|
||||||
|
<p><strong>Collection:</strong> ${data.collection || '_superusers'}</p>
|
||||||
|
</div>
|
||||||
|
<hr style="border: none; border-top: 1px solid #eee; margin: 20px 0;">
|
||||||
|
<p style="font-size: 12px; color: #666;">Sent from ReamStack Monitoring System</p>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
`;
|
||||||
|
break;
|
||||||
|
case 'email-change':
|
||||||
|
subject = 'Email Change Confirmation Test - ReamStack';
|
||||||
|
htmlBody = `
|
||||||
|
<html>
|
||||||
|
<body style="font-family: Arial, sans-serif; line-height: 1.6; color: #333;">
|
||||||
|
<div style="max-width: 600px; margin: 0 auto; padding: 20px;">
|
||||||
|
<h2 style="color: #8b5cf6;">Email Change Confirmation Test</h2>
|
||||||
|
<p>This is a test of the email change confirmation template.</p>
|
||||||
|
<p>If you received this email, your SMTP configuration is working correctly.</p>
|
||||||
|
<div style="background: #f3f4f6; padding: 15px; border-radius: 5px; margin: 20px 0;">
|
||||||
|
<p><strong>Template:</strong> Email Change Confirmation</p>
|
||||||
|
</div>
|
||||||
|
<hr style="border: none; border-top: 1px solid #eee; margin: 20px 0;">
|
||||||
|
<p style="font-size: 12px; color: #666;">Sent from ReamStack Monitoring System</p>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
`;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { subject, htmlBody };
|
||||||
|
};
|
||||||
|
|
||||||
|
export const testEmail = async (data: any): Promise<SettingsApiResponse> => {
|
||||||
|
console.log('testEmail function called with data:', data);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Validate required fields
|
||||||
|
if (!data || typeof data !== 'object') {
|
||||||
|
console.log('Invalid request data - not object');
|
||||||
|
return {
|
||||||
|
status: 200,
|
||||||
|
json: { success: false, message: 'Invalid request data' },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!data.email || typeof data.email !== 'string') {
|
||||||
|
console.log('Email address missing or invalid type');
|
||||||
|
return {
|
||||||
|
status: 200,
|
||||||
|
json: { success: false, message: 'Email address is required and must be a string' },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!validateEmail(data.email)) {
|
||||||
|
console.log('Invalid email format:', data.email);
|
||||||
|
return {
|
||||||
|
status: 200,
|
||||||
|
json: { success: false, message: 'Invalid email address format' },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('Email validation passed for:', data.email);
|
||||||
|
|
||||||
|
const headers = getAuthHeaders();
|
||||||
|
const baseUrl = getBaseUrl();
|
||||||
|
|
||||||
|
// Get current SMTP settings first
|
||||||
|
console.log('Fetching SMTP settings from:', `${baseUrl}/api/settings`);
|
||||||
|
|
||||||
|
const settingsResponse = await fetch(`${baseUrl}/api/settings`, {
|
||||||
|
method: 'GET',
|
||||||
|
headers,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!settingsResponse.ok) {
|
||||||
|
console.error('Failed to get SMTP settings, status:', settingsResponse.status);
|
||||||
|
return {
|
||||||
|
status: 200,
|
||||||
|
json: { success: false, message: 'Failed to get SMTP settings' },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const settingsData = await settingsResponse.json();
|
||||||
|
console.log('Retrieved settings data:', settingsData);
|
||||||
|
|
||||||
|
const smtpSettings = settingsData?.smtp;
|
||||||
|
|
||||||
|
if (!smtpSettings || !smtpSettings.enabled) {
|
||||||
|
console.log('SMTP not enabled or missing');
|
||||||
|
return {
|
||||||
|
status: 200,
|
||||||
|
json: { success: false, message: 'SMTP is not enabled. Please enable and configure SMTP settings first.' },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!smtpSettings.host || !smtpSettings.username) {
|
||||||
|
console.log('SMTP configuration incomplete - missing host or username');
|
||||||
|
return {
|
||||||
|
status: 200,
|
||||||
|
json: { success: false, message: 'SMTP configuration is incomplete. Please check host and username.' },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create test email content based on template
|
||||||
|
const template = data.template || 'basic';
|
||||||
|
const { subject, htmlBody } = createEmailTemplate(template, data);
|
||||||
|
|
||||||
|
console.log('Test email prepared successfully:', {
|
||||||
|
to: data.email,
|
||||||
|
subject: subject,
|
||||||
|
template: template,
|
||||||
|
smtpHost: smtpSettings.host,
|
||||||
|
smtpPort: smtpSettings.port || 587
|
||||||
|
});
|
||||||
|
|
||||||
|
// Send actual email using the correct PocketBase API endpoint
|
||||||
|
console.log('Sending actual email via PocketBase...');
|
||||||
|
|
||||||
|
// Fix the payload structure to match PocketBase API expectations
|
||||||
|
const emailPayload = {
|
||||||
|
email: data.email, // Use 'email' instead of 'to'
|
||||||
|
template: template, // Add the template field
|
||||||
|
subject: subject,
|
||||||
|
html: htmlBody,
|
||||||
|
};
|
||||||
|
|
||||||
|
console.log('Email payload:', emailPayload);
|
||||||
|
|
||||||
|
const emailResponse = await fetch(`${baseUrl}/api/settings/test/email`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
...headers,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify(emailPayload),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!emailResponse.ok) {
|
||||||
|
console.error('Failed to send email, status:', emailResponse.status);
|
||||||
|
const errorText = await emailResponse.text();
|
||||||
|
console.error('Email send error response:', errorText);
|
||||||
|
return {
|
||||||
|
status: 200,
|
||||||
|
json: { success: false, message: 'Failed to send email. Please check your SMTP configuration.' },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle 204 No Content response (successful but no body)
|
||||||
|
if (emailResponse.status === 204) {
|
||||||
|
console.log('Email sent successfully (204 No Content)');
|
||||||
|
return {
|
||||||
|
status: 200,
|
||||||
|
json: {
|
||||||
|
success: true,
|
||||||
|
message: `Test email sent successfully to ${data.email}`,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// For other successful responses, try to parse JSON
|
||||||
|
const emailResult = await emailResponse.json();
|
||||||
|
console.log('Email sent successfully:', emailResult);
|
||||||
|
|
||||||
|
return {
|
||||||
|
status: 200,
|
||||||
|
json: {
|
||||||
|
success: true,
|
||||||
|
message: `Test email sent successfully to ${data.email}`,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error in testEmail function:', error);
|
||||||
|
return {
|
||||||
|
status: 200,
|
||||||
|
json: {
|
||||||
|
success: false,
|
||||||
|
message: error instanceof Error ? error.message : 'Failed to send test email. Please check your SMTP configuration.'
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
|
||||||
|
import { getAuthHeaders, getBaseUrl } from '../utils';
|
||||||
|
import { SettingsApiResponse } from '../types';
|
||||||
|
|
||||||
|
export const testEmailConnection = async (data: any): Promise<SettingsApiResponse> => {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${getBaseUrl()}/api/settings/test-email`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: getAuthHeaders(),
|
||||||
|
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' },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
|
||||||
|
import { getAuthHeaders, getBaseUrl } from '../utils';
|
||||||
|
import { SettingsApiResponse } from '../types';
|
||||||
|
|
||||||
|
export const updateSettings = async (data: any): Promise<SettingsApiResponse> => {
|
||||||
|
try {
|
||||||
|
const headers = getAuthHeaders();
|
||||||
|
const baseUrl = getBaseUrl();
|
||||||
|
|
||||||
|
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' },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
|
||||||
|
import { getSettings } from './actions/getSettings';
|
||||||
|
import { updateSettings } from './actions/updateSettings';
|
||||||
|
import { testEmailConnection } from './actions/testEmailConnection';
|
||||||
|
import { testEmail } from './actions/testEmail';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Settings API handler
|
||||||
|
*/
|
||||||
|
const settingsApi = async (body: any, path?: string) => {
|
||||||
|
console.log('Settings API called with path:', path, 'body:', body);
|
||||||
|
|
||||||
|
// Handle test email endpoint specifically
|
||||||
|
if (path === '/api/settings/test/email') {
|
||||||
|
console.log('Handling test email request');
|
||||||
|
return await testEmail(body);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle regular settings API with action-based routing
|
||||||
|
const action = body?.action;
|
||||||
|
console.log('Settings API called with action:', action, 'data:', body?.data);
|
||||||
|
|
||||||
|
switch (action) {
|
||||||
|
case 'getSettings':
|
||||||
|
return await getSettings();
|
||||||
|
|
||||||
|
case 'updateSettings':
|
||||||
|
return await updateSettings(body.data);
|
||||||
|
|
||||||
|
case 'testEmailConnection':
|
||||||
|
return await testEmailConnection(body.data);
|
||||||
|
|
||||||
|
default:
|
||||||
|
console.error('Unknown action:', action);
|
||||||
|
return {
|
||||||
|
status: 400,
|
||||||
|
json: { success: false, message: 'Unknown action' },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export default settingsApi;
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
|
||||||
|
export interface SettingsApiRequest {
|
||||||
|
action: string;
|
||||||
|
data?: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SettingsApiResponse {
|
||||||
|
status: number;
|
||||||
|
json: {
|
||||||
|
success: boolean;
|
||||||
|
data?: any;
|
||||||
|
message?: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SmtpSettings {
|
||||||
|
enabled?: boolean;
|
||||||
|
host?: string;
|
||||||
|
port?: number;
|
||||||
|
username?: string;
|
||||||
|
password?: string;
|
||||||
|
authMethod?: string;
|
||||||
|
tls?: boolean;
|
||||||
|
localName?: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
|
||||||
|
import { pb, getCurrentEndpoint } from '@/lib/pocketbase';
|
||||||
|
|
||||||
|
export const getAuthHeaders = (): Record<string, string> => {
|
||||||
|
const authToken = pb.authStore.token;
|
||||||
|
const headers: Record<string, string> = {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
};
|
||||||
|
|
||||||
|
if (authToken) {
|
||||||
|
headers['Authorization'] = `Bearer ${authToken}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return headers;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getBaseUrl = (): string => {
|
||||||
|
return getCurrentEndpoint();
|
||||||
|
};
|
||||||
|
|
||||||
|
export const validateEmail = (email: string): boolean => {
|
||||||
|
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||||
|
return emailRegex.test(email);
|
||||||
|
};
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
interface ErrorBoundaryState {
|
||||||
|
hasError: boolean;
|
||||||
|
error?: Error;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ErrorBoundaryProps {
|
||||||
|
children: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ErrorBoundary extends React.Component<ErrorBoundaryProps, ErrorBoundaryState> {
|
||||||
|
constructor(props: ErrorBoundaryProps) {
|
||||||
|
super(props);
|
||||||
|
this.state = { hasError: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
static getDerivedStateFromError(error: Error): ErrorBoundaryState {
|
||||||
|
return { hasError: true, error };
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
|
||||||
|
console.error('ErrorBoundary caught an error:', error, errorInfo);
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
if (this.state.hasError) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center bg-background">
|
||||||
|
<div className="text-center p-6">
|
||||||
|
<h2 className="text-2xl font-bold text-destructive mb-4">Something went wrong</h2>
|
||||||
|
<p className="text-muted-foreground mb-4">
|
||||||
|
{this.state.error?.message || 'An unexpected error occurred'}
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
onClick={() => window.location.reload()}
|
||||||
|
className="px-4 py-2 bg-primary text-primary-foreground rounded-md hover:bg-primary/90"
|
||||||
|
>
|
||||||
|
Reload Page
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.props.children;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,259 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||||||
|
import { toast } from '@/components/ui/use-toast';
|
||||||
|
import { Mail, ArrowLeft } from 'lucide-react';
|
||||||
|
import { useLanguage } from '@/contexts/LanguageContext';
|
||||||
|
import { getCurrentEndpoint } from '@/lib/pocketbase';
|
||||||
|
|
||||||
|
interface ForgotPasswordDialogProps {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ForgotPasswordDialog({ open, onOpenChange }: ForgotPasswordDialogProps) {
|
||||||
|
const [step, setStep] = useState<'request' | 'confirm'>('request');
|
||||||
|
const [email, setEmail] = useState('');
|
||||||
|
const [token, setToken] = useState('');
|
||||||
|
const [password, setPassword] = useState('');
|
||||||
|
const [passwordConfirm, setPasswordConfirm] = useState('');
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const { t } = useLanguage();
|
||||||
|
|
||||||
|
const handleRequestReset = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setLoading(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const apiUrl = getCurrentEndpoint();
|
||||||
|
const response = await fetch(`${apiUrl}/api/collections/_superusers/request-password-reset`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ email }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorData = await response.json().catch(() => ({}));
|
||||||
|
throw new Error(errorData.message || 'Failed to send reset email');
|
||||||
|
}
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: "Reset Email Sent",
|
||||||
|
description: "Please check your email for password reset instructions.",
|
||||||
|
});
|
||||||
|
setStep('confirm');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Password reset request error:', error);
|
||||||
|
toast({
|
||||||
|
variant: "destructive",
|
||||||
|
title: "Reset Failed",
|
||||||
|
description: error instanceof Error ? error.message : "Failed to send reset email. Please try again.",
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleConfirmReset = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
if (password !== passwordConfirm) {
|
||||||
|
toast({
|
||||||
|
variant: "destructive",
|
||||||
|
title: "Password Mismatch",
|
||||||
|
description: "Passwords do not match. Please try again.",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (password.length < 6) {
|
||||||
|
toast({
|
||||||
|
variant: "destructive",
|
||||||
|
title: "Password Too Short",
|
||||||
|
description: "Password must be at least 6 characters long.",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const apiUrl = getCurrentEndpoint();
|
||||||
|
const response = await fetch(`${apiUrl}/api/collections/_superusers/confirm-password-reset`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
token,
|
||||||
|
password,
|
||||||
|
passwordConfirm
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorData = await response.json().catch(() => ({}));
|
||||||
|
throw new Error(errorData.message || 'Failed to reset password');
|
||||||
|
}
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: "Password Reset Successful",
|
||||||
|
description: "Your password has been reset successfully. You can now log in with your new password.",
|
||||||
|
});
|
||||||
|
onOpenChange(false);
|
||||||
|
// Reset form state
|
||||||
|
setStep('request');
|
||||||
|
setEmail('');
|
||||||
|
setToken('');
|
||||||
|
setPassword('');
|
||||||
|
setPasswordConfirm('');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Password reset confirmation error:', error);
|
||||||
|
toast({
|
||||||
|
variant: "destructive",
|
||||||
|
title: "Reset Failed",
|
||||||
|
description: error instanceof Error ? error.message : "Failed to reset password. Please try again.",
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClose = () => {
|
||||||
|
onOpenChange(false);
|
||||||
|
// Reset form state when closing
|
||||||
|
setStep('request');
|
||||||
|
setEmail('');
|
||||||
|
setToken('');
|
||||||
|
setPassword('');
|
||||||
|
setPasswordConfirm('');
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={handleClose}>
|
||||||
|
<DialogContent className="sm:max-w-md">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>
|
||||||
|
{step === 'request' ? 'Reset Password' : 'Confirm Password Reset'}
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
{step === 'request'
|
||||||
|
? 'Enter your email address and we\'ll send you a reset link.'
|
||||||
|
: 'Enter the reset token from your email and your new password.'
|
||||||
|
}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
{step === 'request' ? (
|
||||||
|
<form onSubmit={handleRequestReset} className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium text-foreground" htmlFor="reset-email">
|
||||||
|
Email Address
|
||||||
|
</label>
|
||||||
|
<div className="relative">
|
||||||
|
<div className="absolute inset-y-0 left-0 flex items-center pl-3 pointer-events-none">
|
||||||
|
<Mail className="h-4 w-4 text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
<Input
|
||||||
|
id="reset-email"
|
||||||
|
placeholder="your.email@provider.com"
|
||||||
|
type="email"
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
|
required
|
||||||
|
className="pl-10"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={handleClose}
|
||||||
|
className="flex-1"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
disabled={loading || !email}
|
||||||
|
className="flex-1 bg-emerald-500 hover:bg-emerald-600"
|
||||||
|
>
|
||||||
|
{loading ? 'Sending...' : 'Send Reset Email'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
) : (
|
||||||
|
<form onSubmit={handleConfirmReset} className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium text-foreground" htmlFor="reset-token">
|
||||||
|
Reset Token
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
id="reset-token"
|
||||||
|
placeholder="Enter token from email"
|
||||||
|
type="text"
|
||||||
|
value={token}
|
||||||
|
onChange={(e) => setToken(e.target.value)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium text-foreground" htmlFor="new-password">
|
||||||
|
New Password
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
id="new-password"
|
||||||
|
placeholder="••••••••••••"
|
||||||
|
type="password"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
required
|
||||||
|
minLength={6}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium text-foreground" htmlFor="confirm-password">
|
||||||
|
Confirm Password
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
id="confirm-password"
|
||||||
|
placeholder="••••••••••••"
|
||||||
|
type="password"
|
||||||
|
value={passwordConfirm}
|
||||||
|
onChange={(e) => setPasswordConfirm(e.target.value)}
|
||||||
|
required
|
||||||
|
minLength={6}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => setStep('request')}
|
||||||
|
className="flex-1"
|
||||||
|
>
|
||||||
|
<ArrowLeft className="h-4 w-4 mr-2" />
|
||||||
|
Back
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
disabled={loading || !token || !password || !passwordConfirm}
|
||||||
|
className="flex-1 bg-emerald-500 hover:bg-emerald-600"
|
||||||
|
>
|
||||||
|
{loading ? 'Resetting...' : 'Reset Password'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -126,6 +126,9 @@ 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>
|
||||||
|
|
||||||
|
|||||||
@@ -1,141 +1,22 @@
|
|||||||
|
|
||||||
import { Globe, Boxes, Radar, Calendar, BarChart2, LineChart, FileText, Settings, User, UserCog, Bell, FileClock, Database, RefreshCw, Info, ChevronDown, BookOpen } from "lucide-react";
|
import React from "react";
|
||||||
import { useTheme } from "@/contexts/ThemeContext";
|
import { useTheme } from "@/contexts/ThemeContext";
|
||||||
import { Link, useLocation } from "react-router-dom";
|
import { SidebarHeader } from "./sidebar/SidebarHeader";
|
||||||
import { useState, useEffect } from "react";
|
import { MainNavigation } from "./sidebar/MainNavigation";
|
||||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
|
import { SettingsPanel } from "./sidebar/SettingsPanel";
|
||||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
|
||||||
import { useLanguage } from "@/contexts/LanguageContext";
|
|
||||||
|
|
||||||
interface SidebarProps {
|
interface SidebarProps {
|
||||||
collapsed: boolean;
|
collapsed: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const Sidebar = ({
|
export const Sidebar = ({ collapsed }: SidebarProps) => {
|
||||||
collapsed
|
const { theme } = useTheme();
|
||||||
}: SidebarProps) => {
|
|
||||||
const {
|
|
||||||
theme
|
|
||||||
} = useTheme();
|
|
||||||
const {
|
|
||||||
t
|
|
||||||
} = useLanguage();
|
|
||||||
const location = useLocation();
|
|
||||||
const [activeSettingsItem, setActiveSettingsItem] = useState<string | null>("general");
|
|
||||||
const [settingsPanelOpen, setSettingsPanelOpen] = useState(false);
|
|
||||||
|
|
||||||
// Update active settings item based on URL
|
|
||||||
useEffect(() => {
|
|
||||||
if (location.pathname === '/settings') {
|
|
||||||
const params = new URLSearchParams(location.search);
|
|
||||||
const panel = params.get('panel');
|
|
||||||
if (panel) {
|
|
||||||
setActiveSettingsItem(panel);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [location]);
|
|
||||||
|
|
||||||
const handleSettingsItemClick = (item: string) => {
|
return (
|
||||||
setActiveSettingsItem(item);
|
<div className={`${collapsed ? 'w-16' : 'w-64'} ${theme === 'dark' ? 'bg-[#121212] border-[#1e1e1e]' : 'bg-sidebar border-sidebar-border'} border-r flex flex-col transition-all duration-300 h-full`}>
|
||||||
};
|
<SidebarHeader collapsed={collapsed} />
|
||||||
|
<MainNavigation collapsed={collapsed} />
|
||||||
const getMenuItemClasses = (isActive: boolean) => {
|
<SettingsPanel collapsed={collapsed} />
|
||||||
return `p-2 ${isActive ? theme === 'dark' ? 'bg-[#1a1a1a]' : 'bg-sidebar-accent' : `hover:${theme === 'dark' ? 'bg-[#1a1a1a]' : 'bg-sidebar-accent'}`} rounded-lg flex items-center`;
|
</div>
|
||||||
};
|
);
|
||||||
|
|
||||||
// New larger icon size for the main menu
|
|
||||||
const mainIconSize = "h-6 w-6";
|
|
||||||
|
|
||||||
return <div className={`${collapsed ? 'w-16' : 'w-64'} ${theme === 'dark' ? 'bg-[#121212] border-[#1e1e1e]' : 'bg-sidebar border-sidebar-border'} border-r flex flex-col transition-all duration-300 h-full`}>
|
|
||||||
<div className={`p-4 ${theme === 'dark' ? 'border-[#1e1e1e]' : 'border-sidebar-border'} border-b flex items-center ${collapsed ? 'justify-center' : ''}`}>
|
|
||||||
<div className="h-8 w-8 bg-green-500 rounded flex items-center justify-center mr-2">
|
|
||||||
<span className="text-white font-bold">C</span>
|
|
||||||
</div>
|
|
||||||
{!collapsed && <h1 className="text-xl font-semibold">CheckCle App</h1>}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<nav className="my-2 mx-1 py-1 px-1">
|
|
||||||
<Link to="/dashboard" className={`${collapsed ? 'p-3' : 'p-2 pl-3'} mb-1 rounded-lg ${location.pathname === '/dashboard' ? 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`}>
|
|
||||||
<Globe className={`${mainIconSize} text-purple-400`} />
|
|
||||||
{!collapsed && <span className="ml-2.5 font-medium text-foreground tracking-wide text-[15px]">{t("uptimeMonitoring")}</span>}
|
|
||||||
</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`}>
|
|
||||||
<Boxes className={`${mainIconSize} text-blue-400`} />
|
|
||||||
{!collapsed && <span className="ml-2.5 font-medium text-foreground tracking-wide text-[15px]">{t("instanceMonitoring")}</span>}
|
|
||||||
</div>
|
|
||||||
<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`} />
|
|
||||||
{!collapsed && <span className="ml-2.5 font-medium text-foreground tracking-wide text-[15px]">{t("sslDomain")}</span>}
|
|
||||||
</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`}>
|
|
||||||
<Calendar className={`${mainIconSize} text-emerald-400`} />
|
|
||||||
{!collapsed && <span className="ml-2.5 font-medium text-foreground tracking-wide text-[15px]">{t("scheduleIncident")}</span>}
|
|
||||||
</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`}>
|
|
||||||
<BarChart2 className={`${mainIconSize} text-amber-400`} />
|
|
||||||
{!collapsed && <span className="ml-2.5 font-medium text-foreground tracking-wide text-[15px]">{t("operationalPage")}</span>}
|
|
||||||
</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`}>
|
|
||||||
<LineChart className={`${mainIconSize} text-rose-400`} />
|
|
||||||
{!collapsed && <span className="ml-2.5 font-medium text-foreground tracking-wide text-[15px]">{t("reports")}</span>}
|
|
||||||
</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`}>
|
|
||||||
<FileText className={`${mainIconSize} text-indigo-400`} />
|
|
||||||
{!collapsed && <span className="ml-2.5 font-medium text-foreground tracking-wide text-[15px]">{t("apiDocumentation")}</span>}
|
|
||||||
</div>
|
|
||||||
</nav>
|
|
||||||
|
|
||||||
{!collapsed && <div className={`flex-1 flex flex-col border-t ${theme === 'dark' ? 'border-[#1e1e1e] bg-[#121212]' : 'border-sidebar-border bg-sidebar'} p-4`}>
|
|
||||||
<Collapsible open={settingsPanelOpen} onOpenChange={setSettingsPanelOpen} className="w-full flex flex-col flex-1">
|
|
||||||
<CollapsibleTrigger className={`flex items-center justify-between w-full mb-4 px-2 py-2 rounded-lg ${theme === 'dark' ? 'hover:bg-[#1a1a1a]' : 'hover:bg-sidebar-accent'}`}>
|
|
||||||
<div className="flex items-center">
|
|
||||||
<span className="font-medium tracking-wide">{t("settingPanel")}</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center">
|
|
||||||
<Settings className="h-4 w-4 mr-1" />
|
|
||||||
<ChevronDown className={`h-4 w-4 transition-transform duration-200 ${settingsPanelOpen ? 'rotate-180' : ''}`} />
|
|
||||||
</div>
|
|
||||||
</CollapsibleTrigger>
|
|
||||||
|
|
||||||
<CollapsibleContent className={`${theme === 'dark' ? 'bg-[#121212]' : 'bg-sidebar'} flex-1 flex flex-col`}>
|
|
||||||
<div className="max-h-[300px] overflow-y-auto custom-scrollbar relative pr-1">
|
|
||||||
<ScrollArea className="h-full">
|
|
||||||
<div className="space-y-2 pr-4">
|
|
||||||
<Link to={`/settings?panel=general`} className={getMenuItemClasses(activeSettingsItem === 'general')} onClick={() => handleSettingsItemClick('general')}>
|
|
||||||
<Settings className="h-4 w-4 mr-2" />
|
|
||||||
<span className="text-sm">{t("generalSettings")}</span>
|
|
||||||
</Link>
|
|
||||||
<Link to={`/settings?panel=users`} className={getMenuItemClasses(activeSettingsItem === 'users')} onClick={() => handleSettingsItemClick('users')}>
|
|
||||||
<User className="h-4 w-4 mr-2" />
|
|
||||||
<span className="text-sm">{t("userManagement")}</span>
|
|
||||||
</Link>
|
|
||||||
<Link to={`/settings?panel=notifications`} className={getMenuItemClasses(activeSettingsItem === 'notifications')} onClick={() => handleSettingsItemClick('notifications')}>
|
|
||||||
<Bell className="h-4 w-4 mr-2" />
|
|
||||||
<span className="text-sm">{t("notificationSettings")}</span>
|
|
||||||
</Link>
|
|
||||||
<Link to={`/settings?panel=templates`} className={getMenuItemClasses(activeSettingsItem === 'templates')} onClick={() => handleSettingsItemClick('templates')}>
|
|
||||||
<BookOpen className="h-4 w-4 mr-2" />
|
|
||||||
<span className="text-sm">{t("alertsTemplates")}</span>
|
|
||||||
</Link>
|
|
||||||
<div className={getMenuItemClasses(false)}>
|
|
||||||
<Database className="h-4 w-4 mr-2" />
|
|
||||||
<span className="text-sm">{t("dataRetention")}</span>
|
|
||||||
</div>
|
|
||||||
<Link to={`/settings?panel=about`} className={getMenuItemClasses(activeSettingsItem === 'about')} onClick={() => handleSettingsItemClick('about')}>
|
|
||||||
<Info className="h-4 w-4 mr-2" />
|
|
||||||
<span className="text-sm">{t("aboutSystem")}</span>
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
</ScrollArea>
|
|
||||||
</div>
|
|
||||||
</CollapsibleContent>
|
|
||||||
</Collapsible>
|
|
||||||
</div>}
|
|
||||||
|
|
||||||
{collapsed && <div className={`border-t ${theme === 'dark' ? 'border-[#1e1e1e] bg-[#121212]' : 'border-sidebar-border bg-sidebar'} p-4 flex justify-center`}>
|
|
||||||
<Link to="/settings">
|
|
||||||
<Settings className={`${mainIconSize} text-purple-400`} />
|
|
||||||
</Link>
|
|
||||||
</div>}
|
|
||||||
</div>;
|
|
||||||
};
|
};
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
|
||||||
|
import React from "react";
|
||||||
|
import { MenuItem } from "./MenuItem";
|
||||||
|
import { mainMenuItems } from "./navigationData";
|
||||||
|
|
||||||
|
interface MainNavigationProps {
|
||||||
|
collapsed: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const MainNavigation: React.FC<MainNavigationProps> = ({ collapsed }) => {
|
||||||
|
return (
|
||||||
|
<nav className="my-2 mx-1 py-1 px-1">
|
||||||
|
{mainMenuItems.map((item) => (
|
||||||
|
<MenuItem
|
||||||
|
key={item.id}
|
||||||
|
id={item.id}
|
||||||
|
path={item.path}
|
||||||
|
icon={item.icon}
|
||||||
|
translationKey={item.translationKey}
|
||||||
|
color={item.color}
|
||||||
|
hasNavigation={item.hasNavigation}
|
||||||
|
collapsed={collapsed}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
|
||||||
|
import React from "react";
|
||||||
|
import { useLocation, useNavigate } from "react-router-dom";
|
||||||
|
import { useTheme } from "@/contexts/ThemeContext";
|
||||||
|
import { useLanguage } from "@/contexts/LanguageContext";
|
||||||
|
import { LucideIcon } from "lucide-react";
|
||||||
|
|
||||||
|
interface MenuItemProps {
|
||||||
|
id: string;
|
||||||
|
path: string | null;
|
||||||
|
icon: LucideIcon;
|
||||||
|
translationKey: string;
|
||||||
|
color: string;
|
||||||
|
hasNavigation: boolean;
|
||||||
|
collapsed: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const MenuItem: React.FC<MenuItemProps> = ({
|
||||||
|
id,
|
||||||
|
path,
|
||||||
|
icon: Icon,
|
||||||
|
translationKey,
|
||||||
|
color,
|
||||||
|
hasNavigation,
|
||||||
|
collapsed
|
||||||
|
}) => {
|
||||||
|
const { theme } = useTheme();
|
||||||
|
const { t } = useLanguage();
|
||||||
|
const location = useLocation();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const handleClick = (e: React.MouseEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
|
||||||
|
if (hasNavigation && path) {
|
||||||
|
// Use navigate instead of window.location to prevent full page reload
|
||||||
|
navigate(path, { replace: false });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const isActive = path && location.pathname === path;
|
||||||
|
const mainIconSize = "h-6 w-6";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`${collapsed ? 'p-3' : 'p-2 pl-3'} mb-1 rounded-lg ${isActive ? 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 cursor-pointer`}
|
||||||
|
onClick={handleClick}
|
||||||
|
>
|
||||||
|
<Icon className={`${mainIconSize} ${color}`} />
|
||||||
|
{!collapsed && <span className="ml-2.5 font-medium text-foreground tracking-wide text-[15px]">{t(translationKey)}</span>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
|
||||||
|
import React, { useState, useEffect } from "react";
|
||||||
|
import { useLocation, useNavigate } from "react-router-dom";
|
||||||
|
import { useTheme } from "@/contexts/ThemeContext";
|
||||||
|
import { useLanguage } from "@/contexts/LanguageContext";
|
||||||
|
import { Settings, ChevronDown } from "lucide-react";
|
||||||
|
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
|
||||||
|
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||||
|
import { settingsMenuItems } from "./navigationData";
|
||||||
|
|
||||||
|
interface SettingsPanelProps {
|
||||||
|
collapsed: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SettingsPanel: React.FC<SettingsPanelProps> = ({ collapsed }) => {
|
||||||
|
const { theme } = useTheme();
|
||||||
|
const { t } = useLanguage();
|
||||||
|
const location = useLocation();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [activeSettingsItem, setActiveSettingsItem] = useState<string | null>("general");
|
||||||
|
const [settingsPanelOpen, setSettingsPanelOpen] = useState(false);
|
||||||
|
|
||||||
|
// Update active settings item based on URL
|
||||||
|
useEffect(() => {
|
||||||
|
if (location.pathname === '/settings') {
|
||||||
|
const params = new URLSearchParams(location.search);
|
||||||
|
const panel = params.get('panel');
|
||||||
|
if (panel) {
|
||||||
|
setActiveSettingsItem(panel);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [location]);
|
||||||
|
|
||||||
|
const handleSettingsItemClick = (item: string) => {
|
||||||
|
setActiveSettingsItem(item);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMenuItemClick = (path: string, event: React.MouseEvent) => {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
// Use navigate instead of window.location to prevent full page reload
|
||||||
|
navigate(path, { replace: false });
|
||||||
|
};
|
||||||
|
|
||||||
|
const getMenuItemClasses = (isActive: boolean) => {
|
||||||
|
return `p-2 ${isActive ? theme === 'dark' ? 'bg-[#1a1a1a]' : 'bg-sidebar-accent' : `hover:${theme === 'dark' ? 'bg-[#1a1a1a]' : 'bg-sidebar-accent'}`} rounded-lg flex items-center`;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (collapsed) {
|
||||||
|
const mainIconSize = "h-6 w-6";
|
||||||
|
return (
|
||||||
|
<div className={`border-t ${theme === 'dark' ? 'border-[#1e1e1e] bg-[#121212]' : 'border-sidebar-border bg-sidebar'} p-4 flex justify-center`}>
|
||||||
|
<div
|
||||||
|
onClick={(e) => handleMenuItemClick('/settings', e)}
|
||||||
|
className="cursor-pointer"
|
||||||
|
>
|
||||||
|
<Settings className={`${mainIconSize} text-purple-400`} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`flex-1 flex flex-col border-t ${theme === 'dark' ? 'border-[#1e1e1e] bg-[#121212]' : 'border-sidebar-border bg-sidebar'} p-4`}>
|
||||||
|
<Collapsible open={settingsPanelOpen} onOpenChange={setSettingsPanelOpen} className="w-full flex flex-col flex-1">
|
||||||
|
<CollapsibleTrigger className={`flex items-center justify-between w-full mb-4 px-2 py-2 rounded-lg ${theme === 'dark' ? 'hover:bg-[#1a1a1a]' : 'hover:bg-sidebar-accent'}`}>
|
||||||
|
<div className="flex items-center">
|
||||||
|
<span className="font-medium tracking-wide">{t("settingPanel")}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center">
|
||||||
|
<Settings className="h-4 w-4 mr-1" />
|
||||||
|
<ChevronDown className={`h-4 w-4 transition-transform duration-200 ${settingsPanelOpen ? 'rotate-180' : ''}`} />
|
||||||
|
</div>
|
||||||
|
</CollapsibleTrigger>
|
||||||
|
|
||||||
|
<CollapsibleContent className={`${theme === 'dark' ? 'bg-[#121212]' : 'bg-sidebar'} flex-1 flex flex-col`}>
|
||||||
|
<div className="max-h-[300px] overflow-y-auto custom-scrollbar relative pr-1">
|
||||||
|
<ScrollArea className="h-full">
|
||||||
|
<div className="space-y-2 pr-4">
|
||||||
|
{settingsMenuItems.map((item) => (
|
||||||
|
<div
|
||||||
|
key={item.id}
|
||||||
|
className={getMenuItemClasses(activeSettingsItem === item.id)}
|
||||||
|
onClick={(e) => {
|
||||||
|
handleMenuItemClick(`/settings?panel=${item.id}`, e);
|
||||||
|
handleSettingsItemClick(item.id);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<item.icon className="h-4 w-4 mr-2" />
|
||||||
|
<span className="text-sm">{t(item.translationKey)}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</ScrollArea>
|
||||||
|
</div>
|
||||||
|
</CollapsibleContent>
|
||||||
|
</Collapsible>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
|
||||||
|
import React from "react";
|
||||||
|
import { useTheme } from "@/contexts/ThemeContext";
|
||||||
|
|
||||||
|
interface SidebarHeaderProps {
|
||||||
|
collapsed: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SidebarHeader: React.FC<SidebarHeaderProps> = ({ collapsed }) => {
|
||||||
|
const { theme } = useTheme();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`p-4 ${theme === 'dark' ? 'border-[#1e1e1e]' : 'border-sidebar-border'} border-b flex items-center ${collapsed ? 'justify-center' : ''}`}>
|
||||||
|
<div className="h-8 w-8 bg-green-500 rounded flex items-center justify-center mr-2">
|
||||||
|
<span className="text-white font-bold">C</span>
|
||||||
|
</div>
|
||||||
|
{!collapsed && <h1 className="text-xl font-semibold">CheckCle App</h1>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
|
||||||
|
export { SidebarHeader } from './SidebarHeader';
|
||||||
|
export { MainNavigation } from './MainNavigation';
|
||||||
|
export { MenuItem } from './MenuItem';
|
||||||
|
export { SettingsPanel } from './SettingsPanel';
|
||||||
|
export { mainMenuItems, settingsMenuItems } from './navigationData';
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
|
||||||
|
import { Globe, Boxes, Radar, Calendar, BarChart2, LineChart, FileText, Settings, User, Bell, Database, Info, BookOpen } from "lucide-react";
|
||||||
|
|
||||||
|
export const mainMenuItems = [
|
||||||
|
{
|
||||||
|
id: 'uptime-monitoring',
|
||||||
|
path: '/dashboard',
|
||||||
|
icon: Globe,
|
||||||
|
translationKey: 'uptimeMonitoring',
|
||||||
|
color: 'text-purple-400',
|
||||||
|
hasNavigation: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'instance-monitoring',
|
||||||
|
path: null,
|
||||||
|
icon: Boxes,
|
||||||
|
translationKey: 'instanceMonitoring',
|
||||||
|
color: 'text-blue-400',
|
||||||
|
hasNavigation: false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'ssl-domain',
|
||||||
|
path: '/ssl-domain',
|
||||||
|
icon: Radar,
|
||||||
|
translationKey: 'sslDomain',
|
||||||
|
color: 'text-cyan-400',
|
||||||
|
hasNavigation: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'schedule-incident',
|
||||||
|
path: '/schedule-incident',
|
||||||
|
icon: Calendar,
|
||||||
|
translationKey: 'scheduleIncident',
|
||||||
|
color: 'text-emerald-400',
|
||||||
|
hasNavigation: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'operational-page',
|
||||||
|
path: '/operational-page',
|
||||||
|
icon: BarChart2,
|
||||||
|
translationKey: 'operationalPage',
|
||||||
|
color: 'text-amber-400',
|
||||||
|
hasNavigation: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'reports',
|
||||||
|
path: null,
|
||||||
|
icon: LineChart,
|
||||||
|
translationKey: 'reports',
|
||||||
|
color: 'text-rose-400',
|
||||||
|
hasNavigation: false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'api-documentation',
|
||||||
|
path: null,
|
||||||
|
icon: FileText,
|
||||||
|
translationKey: 'apiDocumentation',
|
||||||
|
color: 'text-indigo-400',
|
||||||
|
hasNavigation: false
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
export const settingsMenuItems = [
|
||||||
|
{
|
||||||
|
id: 'general',
|
||||||
|
icon: Settings,
|
||||||
|
translationKey: 'generalSettings'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'users',
|
||||||
|
icon: User,
|
||||||
|
translationKey: 'userManagement'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'notifications',
|
||||||
|
icon: Bell,
|
||||||
|
translationKey: 'notificationSettings'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'templates',
|
||||||
|
icon: BookOpen,
|
||||||
|
translationKey: 'alertsTemplates'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'data-retention',
|
||||||
|
icon: Database,
|
||||||
|
translationKey: 'dataRetention'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'about',
|
||||||
|
icon: Info,
|
||||||
|
translationKey: 'aboutSystem'
|
||||||
|
}
|
||||||
|
];
|
||||||
@@ -0,0 +1,243 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { Plus, X, Server, Shield, AlertTriangle } from 'lucide-react';
|
||||||
|
import { StatusPageComponentRecord } from '@/types/statusPageComponents.types';
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { serviceService } from '@/services/serviceService';
|
||||||
|
|
||||||
|
interface ComponentsSelectorProps {
|
||||||
|
selectedComponents: Partial<StatusPageComponentRecord>[];
|
||||||
|
onComponentsChange: (components: Partial<StatusPageComponentRecord>[]) => void;
|
||||||
|
onComponentDelete?: (componentId: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const componentTypes = [
|
||||||
|
{ value: 'uptime', label: 'Uptime Service', icon: Server },
|
||||||
|
{ value: 'ssl', label: 'SSL Certificate', icon: Shield },
|
||||||
|
{ value: 'incident', label: 'Incident Monitoring', icon: AlertTriangle },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const ComponentsSelector = ({ selectedComponents, onComponentsChange, onComponentDelete }: ComponentsSelectorProps) => {
|
||||||
|
const [showAddForm, setShowAddForm] = useState(false);
|
||||||
|
const [newComponent, setNewComponent] = useState({
|
||||||
|
name: '',
|
||||||
|
description: '',
|
||||||
|
service_id: '',
|
||||||
|
server_id: '',
|
||||||
|
display_order: selectedComponents.length + 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Fetch uptime services for the dropdown
|
||||||
|
const { data: services = [] } = useQuery({
|
||||||
|
queryKey: ['services'],
|
||||||
|
queryFn: serviceService.getServices,
|
||||||
|
});
|
||||||
|
|
||||||
|
const addComponent = () => {
|
||||||
|
if (!newComponent.name.trim()) return;
|
||||||
|
|
||||||
|
const component: Partial<StatusPageComponentRecord> = {
|
||||||
|
...newComponent,
|
||||||
|
operational_status_id: '', // Will be set when page is created
|
||||||
|
};
|
||||||
|
|
||||||
|
onComponentsChange([...selectedComponents, component]);
|
||||||
|
setNewComponent({
|
||||||
|
name: '',
|
||||||
|
description: '',
|
||||||
|
service_id: '',
|
||||||
|
server_id: '',
|
||||||
|
display_order: selectedComponents.length + 2,
|
||||||
|
});
|
||||||
|
setShowAddForm(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeComponent = async (index: number) => {
|
||||||
|
const component = selectedComponents[index];
|
||||||
|
|
||||||
|
// If component has an ID, it exists in database and needs to be deleted
|
||||||
|
if (component.id && onComponentDelete) {
|
||||||
|
await onComponentDelete(component.id);
|
||||||
|
} else {
|
||||||
|
// For new components not yet saved, just remove from local state
|
||||||
|
const updated = selectedComponents.filter((_, i) => i !== index);
|
||||||
|
onComponentsChange(updated);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<Server className="h-5 w-5" />
|
||||||
|
Status Page Components
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Add monitoring components like uptime services, SSL certificates, and incident tracking
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
{selectedComponents.length > 0 && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Selected Components</Label>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{selectedComponents.map((component, index) => (
|
||||||
|
<div key={component.id || index} className="flex items-center justify-between p-3 border rounded-lg">
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="font-medium">{component.name}</div>
|
||||||
|
{component.description && (
|
||||||
|
<div className="text-sm text-muted-foreground">{component.description}</div>
|
||||||
|
)}
|
||||||
|
<div className="flex gap-2 mt-1">
|
||||||
|
{component.service_id && (
|
||||||
|
<Badge variant="secondary" className="text-xs">
|
||||||
|
Service: {services.find(s => s.id === component.service_id)?.name || component.service_id}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
{component.server_id && (
|
||||||
|
<Badge variant="secondary" className="text-xs">
|
||||||
|
Server: {component.server_id}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => removeComponent(index)}
|
||||||
|
>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!showAddForm ? (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => setShowAddForm(true)}
|
||||||
|
className="w-full"
|
||||||
|
>
|
||||||
|
<Plus className="h-4 w-4 mr-2" />
|
||||||
|
Add Component
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<div className="border rounded-lg p-4 space-y-4">
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="component-name">Component Name</Label>
|
||||||
|
<Input
|
||||||
|
id="component-name"
|
||||||
|
placeholder="e.g., Main Website"
|
||||||
|
value={newComponent.name}
|
||||||
|
onChange={(e) => setNewComponent({ ...newComponent, name: e.target.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="display-order">Display Order</Label>
|
||||||
|
<Input
|
||||||
|
id="display-order"
|
||||||
|
type="number"
|
||||||
|
value={newComponent.display_order}
|
||||||
|
onChange={(e) => setNewComponent({ ...newComponent, display_order: parseInt(e.target.value) || 1 })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="component-description">Description (Optional)</Label>
|
||||||
|
<Textarea
|
||||||
|
id="component-description"
|
||||||
|
placeholder="Brief description of this component"
|
||||||
|
value={newComponent.description}
|
||||||
|
onChange={(e) => setNewComponent({ ...newComponent, description: e.target.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="service-id">Uptime Service (Optional)</Label>
|
||||||
|
<Select onValueChange={(value) => setNewComponent({ ...newComponent, service_id: value })}>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Select an uptime service" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent className="z-50 bg-white border shadow-lg">
|
||||||
|
{services.map((service) => (
|
||||||
|
<SelectItem key={service.id} value={service.id}>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className={`w-2 h-2 rounded-full ${
|
||||||
|
service.status === 'up' ? 'bg-green-500' :
|
||||||
|
service.status === 'down' ? 'bg-red-500' :
|
||||||
|
'bg-yellow-500'
|
||||||
|
}`} />
|
||||||
|
{service.name}
|
||||||
|
</div>
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="server-id">Server ID (Optional)</Label>
|
||||||
|
<Input
|
||||||
|
id="server-id"
|
||||||
|
placeholder="server_456"
|
||||||
|
value={newComponent.server_id}
|
||||||
|
onChange={(e) => setNewComponent({ ...newComponent, server_id: e.target.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button onClick={addComponent} disabled={!newComponent.name.trim()}>
|
||||||
|
Add Component
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" onClick={() => setShowAddForm(false)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="mt-4">
|
||||||
|
<Label>Quick Add Templates</Label>
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-3 gap-2 mt-2">
|
||||||
|
{componentTypes.map((type) => {
|
||||||
|
const Icon = type.icon;
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
key={type.value}
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => {
|
||||||
|
const component: Partial<StatusPageComponentRecord> = {
|
||||||
|
name: type.label,
|
||||||
|
description: `Monitor ${type.label.toLowerCase()}`,
|
||||||
|
service_id: '',
|
||||||
|
server_id: '',
|
||||||
|
display_order: selectedComponents.length + 1,
|
||||||
|
operational_status_id: '',
|
||||||
|
};
|
||||||
|
onComponentsChange([...selectedComponents, component]);
|
||||||
|
}}
|
||||||
|
className="justify-start"
|
||||||
|
>
|
||||||
|
<Icon className="h-4 w-4 mr-2" />
|
||||||
|
{type.label}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,278 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { useForm } from 'react-hook-form';
|
||||||
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||||
|
import { Switch } from '@/components/ui/switch';
|
||||||
|
import { useCreateOperationalPage } from '@/hooks/useOperationalPage';
|
||||||
|
import { useCreateStatusPageComponent } from '@/hooks/useStatusPageComponents';
|
||||||
|
import { ComponentsSelector } from './ComponentsSelector';
|
||||||
|
import { Plus } from 'lucide-react';
|
||||||
|
import { StatusPageComponentRecord } from '@/types/statusPageComponents.types';
|
||||||
|
|
||||||
|
const formSchema = z.object({
|
||||||
|
title: z.string().min(1, 'Title is required'),
|
||||||
|
description: z.string().min(1, 'Description is required'),
|
||||||
|
slug: z.string().min(1, 'Slug is required'),
|
||||||
|
theme: z.string().min(1, 'Theme is required'),
|
||||||
|
status: z.enum(['operational', 'degraded', 'maintenance', 'major_outage']),
|
||||||
|
is_public: z.boolean(),
|
||||||
|
logo_url: z.string().optional(),
|
||||||
|
custom_domain: z.string().optional(),
|
||||||
|
custom_css: z.string().optional(),
|
||||||
|
page_style: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
type FormData = z.infer<typeof formSchema>;
|
||||||
|
|
||||||
|
export const CreateOperationalPageDialog = () => {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [selectedComponents, setSelectedComponents] = useState<Partial<StatusPageComponentRecord>[]>([]);
|
||||||
|
const createMutation = useCreateOperationalPage();
|
||||||
|
const createComponentMutation = useCreateStatusPageComponent();
|
||||||
|
|
||||||
|
const form = useForm<FormData>({
|
||||||
|
resolver: zodResolver(formSchema),
|
||||||
|
defaultValues: {
|
||||||
|
title: '',
|
||||||
|
description: '',
|
||||||
|
slug: '',
|
||||||
|
theme: 'default',
|
||||||
|
status: 'operational',
|
||||||
|
is_public: true,
|
||||||
|
logo_url: '',
|
||||||
|
custom_domain: '',
|
||||||
|
custom_css: '',
|
||||||
|
page_style: '',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const onSubmit = async (data: FormData) => {
|
||||||
|
try {
|
||||||
|
const payload = {
|
||||||
|
title: data.title,
|
||||||
|
description: data.description,
|
||||||
|
slug: data.slug,
|
||||||
|
theme: data.theme,
|
||||||
|
status: data.status,
|
||||||
|
is_public: data.is_public ? 'true' : 'false',
|
||||||
|
logo_url: data.logo_url || '',
|
||||||
|
custom_domain: data.custom_domain || '',
|
||||||
|
custom_css: data.custom_css || '',
|
||||||
|
page_style: data.page_style || '',
|
||||||
|
};
|
||||||
|
|
||||||
|
console.log('Creating operational page with payload:', payload);
|
||||||
|
const createdPage = await createMutation.mutateAsync(payload);
|
||||||
|
console.log('Created page:', createdPage);
|
||||||
|
|
||||||
|
// Create components after page is created
|
||||||
|
if (selectedComponents.length > 0) {
|
||||||
|
console.log('Creating components for page:', createdPage.id);
|
||||||
|
for (const component of selectedComponents) {
|
||||||
|
const componentPayload = {
|
||||||
|
operational_status_id: createdPage.id,
|
||||||
|
name: component.name || '',
|
||||||
|
description: component.description || '',
|
||||||
|
service_id: component.service_id || '',
|
||||||
|
server_id: component.server_id || '',
|
||||||
|
display_order: component.display_order || 1,
|
||||||
|
};
|
||||||
|
|
||||||
|
console.log('Creating component with payload:', componentPayload);
|
||||||
|
await createComponentMutation.mutateAsync(componentPayload);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setOpen(false);
|
||||||
|
form.reset();
|
||||||
|
setSelectedComponents([]);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error creating operational page:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={setOpen}>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
<Button className="gap-2">
|
||||||
|
<Plus className="h-4 w-4" />
|
||||||
|
Create Page
|
||||||
|
</Button>
|
||||||
|
</DialogTrigger>
|
||||||
|
<DialogContent className="sm:max-w-[800px] max-h-[90vh] overflow-y-auto">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Create Operational Page</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Create a new operational status page to monitor your services and components.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<Form {...form}>
|
||||||
|
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="title"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Title</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input placeholder="My Service Status" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="slug"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Slug</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input placeholder="my-service-status" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="description"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Description</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Textarea
|
||||||
|
placeholder="A brief description of your operational page"
|
||||||
|
className="min-h-[80px]"
|
||||||
|
{...field}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="theme"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Theme</FormLabel>
|
||||||
|
<Select onValueChange={field.onChange} defaultValue={field.value}>
|
||||||
|
<FormControl>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Select theme" />
|
||||||
|
</SelectTrigger>
|
||||||
|
</FormControl>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="default">Default</SelectItem>
|
||||||
|
<SelectItem value="dark">Dark</SelectItem>
|
||||||
|
<SelectItem value="light">Light</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="status"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Initial Status</FormLabel>
|
||||||
|
<Select onValueChange={field.onChange} defaultValue={field.value}>
|
||||||
|
<FormControl>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Select status" />
|
||||||
|
</SelectTrigger>
|
||||||
|
</FormControl>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="operational">Operational</SelectItem>
|
||||||
|
<SelectItem value="degraded">Degraded Performance</SelectItem>
|
||||||
|
<SelectItem value="maintenance">Under Maintenance</SelectItem>
|
||||||
|
<SelectItem value="major_outage">Major Outage</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="is_public"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-3 shadow-sm">
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
<FormLabel>Public Page</FormLabel>
|
||||||
|
<FormDescription>
|
||||||
|
Make this page publicly accessible
|
||||||
|
</FormDescription>
|
||||||
|
</div>
|
||||||
|
<FormControl>
|
||||||
|
<Switch
|
||||||
|
checked={field.value}
|
||||||
|
onCheckedChange={field.onChange}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="custom_domain"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Custom Domain (Optional)</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input placeholder="status.yourdomain.com" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormDescription>
|
||||||
|
Custom domain for your status page
|
||||||
|
</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ComponentsSelector
|
||||||
|
selectedComponents={selectedComponents}
|
||||||
|
onComponentsChange={setSelectedComponents}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-2 pt-4">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => setOpen(false)}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
disabled={createMutation.isPending || createComponentMutation.isPending}
|
||||||
|
>
|
||||||
|
{createMutation.isPending || createComponentMutation.isPending ? 'Creating...' : 'Create Page'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,350 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { useForm } from 'react-hook-form';
|
||||||
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||||
|
import { Switch } from '@/components/ui/switch';
|
||||||
|
import { useUpdateOperationalPage } from '@/hooks/useOperationalPage';
|
||||||
|
import { useCreateStatusPageComponent, useStatusPageComponentsByOperationalId, useDeleteStatusPageComponent } from '@/hooks/useStatusPageComponents';
|
||||||
|
import { ComponentsSelector } from './ComponentsSelector';
|
||||||
|
import { OperationalPageRecord } from '@/types/operational.types';
|
||||||
|
import { StatusPageComponentRecord } from '@/types/statusPageComponents.types';
|
||||||
|
|
||||||
|
const formSchema = z.object({
|
||||||
|
title: z.string().min(1, 'Title is required'),
|
||||||
|
description: z.string().min(1, 'Description is required'),
|
||||||
|
slug: z.string().min(1, 'Slug is required'),
|
||||||
|
theme: z.string().min(1, 'Theme is required'),
|
||||||
|
status: z.enum(['operational', 'degraded', 'maintenance', 'major_outage']),
|
||||||
|
is_public: z.boolean(),
|
||||||
|
logo_url: z.string().optional(),
|
||||||
|
custom_domain: z.string().optional(),
|
||||||
|
custom_css: z.string().optional(),
|
||||||
|
page_style: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
type FormData = z.infer<typeof formSchema>;
|
||||||
|
|
||||||
|
interface EditOperationalPageDialogProps {
|
||||||
|
page: OperationalPageRecord | null;
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const EditOperationalPageDialog = ({ page, open, onOpenChange }: EditOperationalPageDialogProps) => {
|
||||||
|
const [selectedComponents, setSelectedComponents] = useState<Partial<StatusPageComponentRecord>[]>([]);
|
||||||
|
const [existingComponents, setExistingComponents] = useState<StatusPageComponentRecord[]>([]);
|
||||||
|
const [isFormSubmitting, setIsFormSubmitting] = useState(false);
|
||||||
|
const updateMutation = useUpdateOperationalPage();
|
||||||
|
const createComponentMutation = useCreateStatusPageComponent();
|
||||||
|
const deleteComponentMutation = useDeleteStatusPageComponent();
|
||||||
|
|
||||||
|
// Fetch existing components for this operational page
|
||||||
|
const { data: components = [] } = useStatusPageComponentsByOperationalId(page?.id || '');
|
||||||
|
|
||||||
|
const form = useForm<FormData>({
|
||||||
|
resolver: zodResolver(formSchema),
|
||||||
|
defaultValues: {
|
||||||
|
title: '',
|
||||||
|
description: '',
|
||||||
|
slug: '',
|
||||||
|
theme: 'default',
|
||||||
|
status: 'operational',
|
||||||
|
is_public: true,
|
||||||
|
logo_url: '',
|
||||||
|
custom_domain: '',
|
||||||
|
custom_css: '',
|
||||||
|
page_style: '',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (page) {
|
||||||
|
form.reset({
|
||||||
|
title: page.title,
|
||||||
|
description: page.description,
|
||||||
|
slug: page.slug,
|
||||||
|
theme: page.theme,
|
||||||
|
status: page.status,
|
||||||
|
is_public: page.is_public === 'true',
|
||||||
|
logo_url: page.logo_url || '',
|
||||||
|
custom_domain: page.custom_domain || '',
|
||||||
|
custom_css: page.custom_css || '',
|
||||||
|
page_style: page.page_style || '',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [page, form]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (components && components.length > 0) {
|
||||||
|
console.log('Loading existing components:', components);
|
||||||
|
setExistingComponents(components);
|
||||||
|
// Convert existing components to the format expected by ComponentsSelector
|
||||||
|
const existingComponentsForSelector = components.map(comp => ({
|
||||||
|
id: comp.id,
|
||||||
|
name: comp.name,
|
||||||
|
description: comp.description,
|
||||||
|
service_id: comp.service_id,
|
||||||
|
server_id: comp.server_id,
|
||||||
|
display_order: comp.display_order,
|
||||||
|
operational_status_id: comp.operational_status_id,
|
||||||
|
}));
|
||||||
|
setSelectedComponents(existingComponentsForSelector);
|
||||||
|
} else {
|
||||||
|
setExistingComponents([]);
|
||||||
|
setSelectedComponents([]);
|
||||||
|
}
|
||||||
|
}, [components]);
|
||||||
|
|
||||||
|
const handleComponentDelete = async (componentId: string) => {
|
||||||
|
try {
|
||||||
|
console.log('Deleting component:', componentId);
|
||||||
|
await deleteComponentMutation.mutateAsync(componentId);
|
||||||
|
|
||||||
|
// Update local state to remove the deleted component
|
||||||
|
setSelectedComponents(prev => prev.filter(comp => comp.id !== componentId));
|
||||||
|
setExistingComponents(prev => prev.filter(comp => comp.id !== componentId));
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error deleting component:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onSubmit = async (data: FormData) => {
|
||||||
|
if (!page) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
setIsFormSubmitting(true);
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
title: data.title,
|
||||||
|
description: data.description,
|
||||||
|
slug: data.slug,
|
||||||
|
theme: data.theme,
|
||||||
|
status: data.status,
|
||||||
|
is_public: data.is_public ? 'true' : 'false',
|
||||||
|
logo_url: data.logo_url || '',
|
||||||
|
custom_domain: data.custom_domain || '',
|
||||||
|
custom_css: data.custom_css || '',
|
||||||
|
page_style: data.page_style || '',
|
||||||
|
};
|
||||||
|
|
||||||
|
console.log('Updating operational page with payload:', payload);
|
||||||
|
await updateMutation.mutateAsync({ id: page.id, data: payload });
|
||||||
|
|
||||||
|
// Handle component changes
|
||||||
|
const currentComponentIds = existingComponents.map(c => c.id);
|
||||||
|
const newComponentsToCreate = selectedComponents.filter(comp => !comp.id);
|
||||||
|
const componentsToKeep = selectedComponents.filter(comp => comp.id && currentComponentIds.includes(comp.id));
|
||||||
|
const componentsToDelete = existingComponents.filter(comp => !selectedComponents.some(selected => selected.id === comp.id));
|
||||||
|
|
||||||
|
// Delete removed components (only if not already deleted via handleComponentDelete)
|
||||||
|
for (const component of componentsToDelete) {
|
||||||
|
if (selectedComponents.some(selected => selected.id === component.id)) {
|
||||||
|
continue; // Skip if already handled by handleComponentDelete
|
||||||
|
}
|
||||||
|
console.log('Deleting component during save:', component.id);
|
||||||
|
await deleteComponentMutation.mutateAsync(component.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create new components
|
||||||
|
for (const component of newComponentsToCreate) {
|
||||||
|
const componentPayload = {
|
||||||
|
operational_status_id: page.id,
|
||||||
|
name: component.name || '',
|
||||||
|
description: component.description || '',
|
||||||
|
service_id: component.service_id || '',
|
||||||
|
server_id: component.server_id || '',
|
||||||
|
display_order: component.display_order || 1,
|
||||||
|
};
|
||||||
|
|
||||||
|
console.log('Creating component with payload:', componentPayload);
|
||||||
|
await createComponentMutation.mutateAsync(componentPayload);
|
||||||
|
}
|
||||||
|
|
||||||
|
onOpenChange(false);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error updating operational page:', error);
|
||||||
|
} finally {
|
||||||
|
setIsFormSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="sm:max-w-[800px] max-h-[90vh] overflow-y-auto">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Edit Operational Page</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Update your operational status page settings and manage components.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<Form {...form}>
|
||||||
|
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="title"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Title</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input placeholder="My Service Status" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="slug"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Slug</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input placeholder="my-service-status" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="description"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Description</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Textarea
|
||||||
|
placeholder="A brief description of your operational page"
|
||||||
|
className="min-h-[80px]"
|
||||||
|
{...field}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="theme"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Theme</FormLabel>
|
||||||
|
<Select onValueChange={field.onChange} defaultValue={field.value}>
|
||||||
|
<FormControl>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Select theme" />
|
||||||
|
</SelectTrigger>
|
||||||
|
</FormControl>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="default">Default</SelectItem>
|
||||||
|
<SelectItem value="dark">Dark</SelectItem>
|
||||||
|
<SelectItem value="light">Light</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="status"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Status</FormLabel>
|
||||||
|
<Select onValueChange={field.onChange} defaultValue={field.value}>
|
||||||
|
<FormControl>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Select status" />
|
||||||
|
</SelectTrigger>
|
||||||
|
</FormControl>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="operational">Operational</SelectItem>
|
||||||
|
<SelectItem value="degraded">Degraded Performance</SelectItem>
|
||||||
|
<SelectItem value="maintenance">Under Maintenance</SelectItem>
|
||||||
|
<SelectItem value="major_outage">Major Outage</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="is_public"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-3 shadow-sm">
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
<FormLabel>Public Page</FormLabel>
|
||||||
|
<FormDescription>
|
||||||
|
Make this page publicly accessible
|
||||||
|
</FormDescription>
|
||||||
|
</div>
|
||||||
|
<FormControl>
|
||||||
|
<Switch
|
||||||
|
checked={field.value}
|
||||||
|
onCheckedChange={field.onChange}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="custom_domain"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Custom Domain (Optional)</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input placeholder="status.yourdomain.com" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormDescription>
|
||||||
|
Custom domain for your status page
|
||||||
|
</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ComponentsSelector
|
||||||
|
selectedComponents={selectedComponents}
|
||||||
|
onComponentsChange={setSelectedComponents}
|
||||||
|
onComponentDelete={handleComponentDelete}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-2 pt-4">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => onOpenChange(false)}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
disabled={isFormSubmitting || updateMutation.isPending || createComponentMutation.isPending}
|
||||||
|
>
|
||||||
|
{isFormSubmitting || updateMutation.isPending || createComponentMutation.isPending ? 'Updating...' : 'Update Page'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { OperationalPageRecord } from '@/types/operational.types';
|
||||||
|
import { StatusBadge } from './StatusBadge';
|
||||||
|
import { Globe, ExternalLink, Eye, Settings, Trash2 } from 'lucide-react';
|
||||||
|
import { format } from 'date-fns';
|
||||||
|
|
||||||
|
interface OperationalPageCardProps {
|
||||||
|
page: OperationalPageRecord;
|
||||||
|
onEdit?: (page: OperationalPageRecord) => void;
|
||||||
|
onView?: (page: OperationalPageRecord) => void;
|
||||||
|
onDelete?: (page: OperationalPageRecord) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const OperationalPageCard = ({ page, onEdit, onView, onDelete }: OperationalPageCardProps) => {
|
||||||
|
return (
|
||||||
|
<Card className="hover:shadow-lg transition-shadow duration-200">
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<div className="flex-1">
|
||||||
|
<CardTitle className="text-lg font-semibold mb-1">{page.title}</CardTitle>
|
||||||
|
<CardDescription className="text-sm text-muted-foreground">
|
||||||
|
{page.description}
|
||||||
|
</CardDescription>
|
||||||
|
</div>
|
||||||
|
<StatusBadge status={page.status} />
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div className="grid grid-cols-2 gap-4 text-sm">
|
||||||
|
<div>
|
||||||
|
<span className="font-medium text-muted-foreground">Slug:</span>
|
||||||
|
<p className="mt-1">{page.slug}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="font-medium text-muted-foreground">Theme:</span>
|
||||||
|
<p className="mt-1 capitalize">{page.theme}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="font-medium text-muted-foreground">Public:</span>
|
||||||
|
<p className="mt-1">
|
||||||
|
<Badge variant={page.is_public === 'true' ? 'default' : 'secondary'}>
|
||||||
|
{page.is_public === 'true' ? 'Yes' : 'No'}
|
||||||
|
</Badge>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="font-medium text-muted-foreground">Updated:</span>
|
||||||
|
<p className="mt-1">{format(new Date(page.updated), 'MMM dd, yyyy')}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{page.custom_domain && (
|
||||||
|
<div className="flex items-center gap-2 p-2 bg-muted rounded-md">
|
||||||
|
<Globe className="h-4 w-4 text-muted-foreground" />
|
||||||
|
<span className="text-sm font-medium">{page.custom_domain}</span>
|
||||||
|
<ExternalLink className="h-3 w-3 text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex gap-2 pt-2">
|
||||||
|
{onView && (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => onView(page)}
|
||||||
|
className="flex-1"
|
||||||
|
>
|
||||||
|
<Eye className="h-4 w-4 mr-2" />
|
||||||
|
View
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{onEdit && (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => onEdit(page)}
|
||||||
|
className="flex-1"
|
||||||
|
>
|
||||||
|
<Settings className="h-4 w-4 mr-2" />
|
||||||
|
Edit
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{onDelete && (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => onDelete(page)}
|
||||||
|
className="text-red-600 hover:text-red-700 hover:bg-red-50"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,211 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { useOperationalPages, useDeleteOperationalPage } from '@/hooks/useOperationalPage';
|
||||||
|
import { CreateOperationalPageDialog } from './CreateOperationalPageDialog';
|
||||||
|
import { EditOperationalPageDialog } from './EditOperationalPageDialog';
|
||||||
|
import { OperationalPageCard } from './OperationalPageCard';
|
||||||
|
import { Card, CardContent } from '@/components/ui/card';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { OperationalPageRecord } from '@/types/operational.types';
|
||||||
|
import { Activity, Plus, RefreshCw } from 'lucide-react';
|
||||||
|
import { Skeleton } from '@/components/ui/skeleton';
|
||||||
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
} from '@/components/ui/alert-dialog';
|
||||||
|
|
||||||
|
export const OperationalPageContent = () => {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { data: pages, isLoading, error, refetch, isRefetching } = useOperationalPages();
|
||||||
|
const deleteMutation = useDeleteOperationalPage();
|
||||||
|
|
||||||
|
const [editingPage, setEditingPage] = useState<OperationalPageRecord | null>(null);
|
||||||
|
const [editDialogOpen, setEditDialogOpen] = useState(false);
|
||||||
|
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||||
|
const [pageToDelete, setPageToDelete] = useState<OperationalPageRecord | null>(null);
|
||||||
|
|
||||||
|
const handleEdit = (page: OperationalPageRecord) => {
|
||||||
|
setEditingPage(page);
|
||||||
|
setEditDialogOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleView = (page: OperationalPageRecord) => {
|
||||||
|
if (page.custom_domain) {
|
||||||
|
window.open(`https://${page.custom_domain}`, '_blank');
|
||||||
|
} else {
|
||||||
|
// Navigate to the public status page route
|
||||||
|
window.open(`/status/${page.slug}`, '_blank');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = (page: OperationalPageRecord) => {
|
||||||
|
setPageToDelete(page);
|
||||||
|
setDeleteDialogOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const confirmDelete = async () => {
|
||||||
|
if (pageToDelete) {
|
||||||
|
try {
|
||||||
|
await deleteMutation.mutateAsync(pageToDelete.id);
|
||||||
|
setDeleteDialogOpen(false);
|
||||||
|
setPageToDelete(null);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error deleting page:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div className="container mx-auto px-4 py-8">
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="mb-4">
|
||||||
|
<Activity className="h-12 w-12 text-muted-foreground mx-auto" />
|
||||||
|
</div>
|
||||||
|
<h3 className="text-lg font-semibold mb-2">Failed to load operational pages</h3>
|
||||||
|
<p className="text-muted-foreground mb-4">
|
||||||
|
There was an error loading your operational pages. Please try again.
|
||||||
|
</p>
|
||||||
|
<Button onClick={() => refetch()} variant="outline">
|
||||||
|
<RefreshCw className="h-4 w-4 mr-2" />
|
||||||
|
Try Again
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="container mx-auto px-4 py-8">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between mb-8">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-3xl font-bold tracking-tight mb-2">Operational Pages</h1>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
Manage your public status pages and monitor service health
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2 mt-4 sm:mt-0">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => refetch()}
|
||||||
|
disabled={isRefetching}
|
||||||
|
>
|
||||||
|
<RefreshCw className={`h-4 w-4 mr-2 ${isRefetching ? 'animate-spin' : ''}`} />
|
||||||
|
Refresh
|
||||||
|
</Button>
|
||||||
|
<CreateOperationalPageDialog />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Loading State */}
|
||||||
|
{isLoading && (
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||||
|
{[...Array(6)].map((_, i) => (
|
||||||
|
<Card key={i}>
|
||||||
|
<div className="p-6">
|
||||||
|
<Skeleton className="h-6 w-3/4 mb-2" />
|
||||||
|
<Skeleton className="h-4 w-full mb-4" />
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Skeleton className="h-4 w-1/2" />
|
||||||
|
<Skeleton className="h-4 w-1/3" />
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2 mt-4">
|
||||||
|
<Skeleton className="h-8 flex-1" />
|
||||||
|
<Skeleton className="h-8 flex-1" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Empty State */}
|
||||||
|
{!isLoading && (!pages || pages.length === 0) && (
|
||||||
|
<Card className="p-12">
|
||||||
|
<CardContent className="text-center">
|
||||||
|
<div className="mb-4">
|
||||||
|
<Activity className="h-12 w-12 text-muted-foreground mx-auto" />
|
||||||
|
</div>
|
||||||
|
<h3 className="text-lg font-semibold mb-2">No operational pages found</h3>
|
||||||
|
<p className="text-muted-foreground mb-6">
|
||||||
|
Create your first operational page to start monitoring your services and communicate status to your users.
|
||||||
|
</p>
|
||||||
|
<CreateOperationalPageDialog />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Pages Grid */}
|
||||||
|
{!isLoading && pages && pages.length > 0 && (
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||||
|
{pages.map((page) => (
|
||||||
|
<OperationalPageCard
|
||||||
|
key={page.id}
|
||||||
|
page={page}
|
||||||
|
onEdit={handleEdit}
|
||||||
|
onView={handleView}
|
||||||
|
onDelete={handleDelete}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Stats Footer */}
|
||||||
|
{!isLoading && pages && pages.length > 0 && (
|
||||||
|
<div className="mt-8 pt-6 border-t">
|
||||||
|
<div className="flex flex-wrap gap-6 text-sm text-muted-foreground">
|
||||||
|
<div>
|
||||||
|
<span className="font-medium">Total Pages:</span> {pages.length}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="font-medium">Public Pages:</span>{' '}
|
||||||
|
{pages.filter(p => p.is_public === 'true').length}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="font-medium">Operational:</span>{' '}
|
||||||
|
{pages.filter(p => p.status === 'operational').length}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Edit Dialog */}
|
||||||
|
<EditOperationalPageDialog
|
||||||
|
page={editingPage}
|
||||||
|
open={editDialogOpen}
|
||||||
|
onOpenChange={setEditDialogOpen}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Delete Confirmation Dialog */}
|
||||||
|
<AlertDialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>Delete Operational Page</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
Are you sure you want to delete "{pageToDelete?.title}"? This action cannot be undone and will permanently remove the operational page and all its components.
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||||
|
<AlertDialogAction
|
||||||
|
onClick={confirmDelete}
|
||||||
|
className="bg-red-600 hover:bg-red-700"
|
||||||
|
disabled={deleteMutation.isPending}
|
||||||
|
>
|
||||||
|
{deleteMutation.isPending ? 'Deleting...' : 'Delete'}
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { OperationalPageRecord } from '@/types/operational.types';
|
||||||
|
|
||||||
|
interface StatusBadgeProps {
|
||||||
|
status: OperationalPageRecord['status'];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const StatusBadge = ({ status }: StatusBadgeProps) => {
|
||||||
|
const getStatusConfig = (status: OperationalPageRecord['status']) => {
|
||||||
|
switch (status) {
|
||||||
|
case 'operational':
|
||||||
|
return {
|
||||||
|
label: 'Operational',
|
||||||
|
className: 'bg-green-100 text-green-800 hover:bg-green-200',
|
||||||
|
};
|
||||||
|
case 'degraded':
|
||||||
|
return {
|
||||||
|
label: 'Degraded Performance',
|
||||||
|
className: 'bg-yellow-100 text-yellow-800 hover:bg-yellow-200',
|
||||||
|
};
|
||||||
|
case 'maintenance':
|
||||||
|
return {
|
||||||
|
label: 'Under Maintenance',
|
||||||
|
className: 'bg-blue-100 text-blue-800 hover:bg-blue-200',
|
||||||
|
};
|
||||||
|
case 'major_outage':
|
||||||
|
return {
|
||||||
|
label: 'Major Outage',
|
||||||
|
className: 'bg-red-100 text-red-800 hover:bg-red-200',
|
||||||
|
};
|
||||||
|
default:
|
||||||
|
return {
|
||||||
|
label: 'Unknown',
|
||||||
|
className: 'bg-gray-100 text-gray-800 hover:bg-gray-200',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const config = getStatusConfig(status);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Badge className={config.className}>
|
||||||
|
{config.label}
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,4 +1,3 @@
|
|||||||
|
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
@@ -10,6 +9,7 @@ import { useToast } from "@/hooks/use-toast";
|
|||||||
import { Eye, EyeOff } from "lucide-react";
|
import { Eye, EyeOff } from "lucide-react";
|
||||||
import { pb } from "@/lib/pocketbase";
|
import { pb } from "@/lib/pocketbase";
|
||||||
import { authService } from "@/services/authService";
|
import { authService } from "@/services/authService";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
|
||||||
// Password change form schema
|
// Password change form schema
|
||||||
const passwordFormSchema = z.object({
|
const passwordFormSchema = z.object({
|
||||||
@@ -34,6 +34,7 @@ export function ChangePasswordForm({ userId }: ChangePasswordFormProps) {
|
|||||||
const [showConfirmPassword, setShowConfirmPassword] = useState(false);
|
const [showConfirmPassword, setShowConfirmPassword] = useState(false);
|
||||||
|
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
const form = useForm<PasswordFormValues>({
|
const form = useForm<PasswordFormValues>({
|
||||||
resolver: zodResolver(passwordFormSchema),
|
resolver: zodResolver(passwordFormSchema),
|
||||||
@@ -44,12 +45,35 @@ export function ChangePasswordForm({ userId }: ChangePasswordFormProps) {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Function to determine which collection the user belongs to
|
||||||
|
const getUserCollection = async (userId: string): Promise<string> => {
|
||||||
|
try {
|
||||||
|
// First try to find the user in the regular users collection
|
||||||
|
await pb.collection('users').getOne(userId);
|
||||||
|
return 'users';
|
||||||
|
} catch (error) {
|
||||||
|
try {
|
||||||
|
// If not found, try the superadmin collection
|
||||||
|
await pb.collection('_superusers').getOne(userId);
|
||||||
|
return '_superusers';
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error('User not found in any collection');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
async function onSubmit(data: PasswordFormValues) {
|
async function onSubmit(data: PasswordFormValues) {
|
||||||
setIsSubmitting(true);
|
setIsSubmitting(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
console.log("Starting password change for user:", userId);
|
||||||
|
|
||||||
|
// Determine which collection the user belongs to
|
||||||
|
const collection = await getUserCollection(userId);
|
||||||
|
console.log("User found in collection:", collection);
|
||||||
|
|
||||||
// PocketBase requires the old password along with the new one
|
// PocketBase requires the old password along with the new one
|
||||||
await pb.collection('users').update(userId, {
|
await pb.collection(collection).update(userId, {
|
||||||
oldPassword: data.currentPassword,
|
oldPassword: data.currentPassword,
|
||||||
password: data.newPassword,
|
password: data.newPassword,
|
||||||
passwordConfirm: data.confirmPassword,
|
passwordConfirm: data.confirmPassword,
|
||||||
@@ -60,17 +84,31 @@ export function ChangePasswordForm({ userId }: ChangePasswordFormProps) {
|
|||||||
|
|
||||||
toast({
|
toast({
|
||||||
title: "Password updated",
|
title: "Password updated",
|
||||||
description: "Your password has been changed successfully.",
|
description: "Your password has been changed successfully. You will be logged out in 3 seconds for security.",
|
||||||
});
|
});
|
||||||
|
|
||||||
// Reset the form
|
// Reset the form
|
||||||
form.reset();
|
form.reset();
|
||||||
|
|
||||||
|
// Auto logout after successful password change
|
||||||
|
setTimeout(() => {
|
||||||
|
console.log("Auto logout after password change");
|
||||||
|
authService.logout();
|
||||||
|
navigate("/login");
|
||||||
|
}, 3000);
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Password change error:", error);
|
console.error("Password change error:", error);
|
||||||
|
|
||||||
let errorMessage = "Failed to update password. Please try again.";
|
let errorMessage = "Failed to update password. Please try again.";
|
||||||
if (error instanceof Error) {
|
if (error instanceof Error) {
|
||||||
errorMessage = error.message;
|
if (error.message.includes("Failed to authenticate")) {
|
||||||
|
errorMessage = "Current password is incorrect. Please try again.";
|
||||||
|
} else if (error.message.includes("User not found")) {
|
||||||
|
errorMessage = "User account not found. Please contact your administrator.";
|
||||||
|
} else {
|
||||||
|
errorMessage = error.message;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
toast({
|
toast({
|
||||||
@@ -188,4 +226,4 @@ export function ChangePasswordForm({ userId }: ChangePasswordFormProps) {
|
|||||||
</form>
|
</form>
|
||||||
</Form>
|
</Form>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -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,8 +9,9 @@ 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 } from "lucide-react";
|
import { AlertCircle, CheckCircle } from "lucide-react";
|
||||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
|
||||||
// Profile update form schema
|
// Profile update form schema
|
||||||
const profileFormSchema = z.object({
|
const profileFormSchema = z.object({
|
||||||
@@ -33,9 +34,10 @@ 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 [updateError, setUpdateError] = useState<string | null>(null);
|
||||||
|
const [updateSuccess, setUpdateSuccess] = useState<string | null>(null);
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
// Initialize the form with current user data
|
// Initialize the form with current user data
|
||||||
const form = useForm<ProfileFormValues>({
|
const form = useForm<ProfileFormValues>({
|
||||||
@@ -50,7 +52,7 @@ export function UpdateProfileForm({ user }: UpdateProfileFormProps) {
|
|||||||
async function onSubmit(data: ProfileFormValues) {
|
async function onSubmit(data: ProfileFormValues) {
|
||||||
setIsSubmitting(true);
|
setIsSubmitting(true);
|
||||||
setUpdateError(null);
|
setUpdateError(null);
|
||||||
setEmailChangeRequested(false);
|
setUpdateSuccess(null);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
console.log("Submitting profile update with data:", data);
|
console.log("Submitting profile update with data:", data);
|
||||||
@@ -62,8 +64,9 @@ export function UpdateProfileForm({ user }: UpdateProfileFormProps) {
|
|||||||
const updateData = {
|
const updateData = {
|
||||||
full_name: data.full_name,
|
full_name: data.full_name,
|
||||||
username: data.username,
|
username: data.username,
|
||||||
|
// Only include email if it's changed
|
||||||
email: isEmailChanged ? data.email : undefined,
|
email: isEmailChanged ? data.email : undefined,
|
||||||
// Only set emailVisibility if email is being changed
|
// Always set emailVisibility to true if email is changing
|
||||||
emailVisibility: isEmailChanged ? true : undefined
|
emailVisibility: isEmailChanged ? true : undefined
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -72,17 +75,26 @@ export function UpdateProfileForm({ user }: UpdateProfileFormProps) {
|
|||||||
// Update user data using the userService
|
// Update user data using the userService
|
||||||
await userService.updateUser(user.id, updateData);
|
await userService.updateUser(user.id, updateData);
|
||||||
|
|
||||||
// Refresh user data in auth context
|
// If email was changed, show success message and auto-logout
|
||||||
await authService.refreshUserData();
|
|
||||||
|
|
||||||
// If email was changed, show a specific message
|
|
||||||
if (isEmailChanged) {
|
if (isEmailChanged) {
|
||||||
setEmailChangeRequested(true);
|
setUpdateSuccess("Email changed successfully! You will be logged out for security reasons. Please log in again with your new email.");
|
||||||
|
|
||||||
toast({
|
toast({
|
||||||
title: "Email change requested",
|
title: "Email changed successfully",
|
||||||
description: "A verification email has been sent to your new email address. Please check your inbox and follow the instructions to complete the change.",
|
description: "You will be logged out for security reasons. Please log in again with your new email.",
|
||||||
|
variant: "default",
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Auto-logout after 3 seconds
|
||||||
|
setTimeout(() => {
|
||||||
|
authService.logout();
|
||||||
|
navigate("/login");
|
||||||
|
}, 3000);
|
||||||
} else {
|
} else {
|
||||||
|
// Refresh user data in auth context for other field changes
|
||||||
|
await authService.refreshUserData();
|
||||||
|
|
||||||
|
setUpdateSuccess("Your profile information has been updated successfully.");
|
||||||
toast({
|
toast({
|
||||||
title: "Profile updated",
|
title: "Profile updated",
|
||||||
description: "Your profile information has been updated successfully.",
|
description: "Your profile information has been updated successfully.",
|
||||||
@@ -118,12 +130,11 @@ export function UpdateProfileForm({ user }: UpdateProfileFormProps) {
|
|||||||
</Alert>
|
</Alert>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{emailChangeRequested && (
|
{updateSuccess && (
|
||||||
<Alert className="bg-yellow-50 border-yellow-200 text-yellow-800">
|
<Alert className="bg-green-50 border-green-200 text-green-800">
|
||||||
<AlertCircle className="h-4 w-4 text-yellow-600" />
|
<CheckCircle className="h-4 w-4 text-green-600" />
|
||||||
<AlertDescription>
|
<AlertDescription>
|
||||||
A verification email has been sent to your new email address.
|
{updateSuccess}
|
||||||
Please check your inbox and follow the instructions to complete the change.
|
|
||||||
</AlertDescription>
|
</AlertDescription>
|
||||||
</Alert>
|
</Alert>
|
||||||
)}
|
)}
|
||||||
@@ -168,7 +179,7 @@ export function UpdateProfileForm({ user }: UpdateProfileFormProps) {
|
|||||||
<FormMessage />
|
<FormMessage />
|
||||||
{field.value !== user.email && (
|
{field.value !== user.email && (
|
||||||
<p className="text-xs text-muted-foreground mt-1">
|
<p className="text-xs text-muted-foreground mt-1">
|
||||||
Changing your email requires verification. A verification email will be sent.
|
Changing your email will log you out for security reasons. You will need to log in again with your new email.
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</FormItem>
|
</FormItem>
|
||||||
|
|||||||
@@ -0,0 +1,210 @@
|
|||||||
|
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { Server, CheckCircle, XCircle, AlertTriangle, Pause, Clock } from 'lucide-react';
|
||||||
|
import { StatusPageComponentRecord } from '@/types/statusPageComponents.types';
|
||||||
|
import { Service, UptimeData } from '@/types/service.types';
|
||||||
|
import { UptimeHistoryRenderer } from './UptimeHistoryRenderer';
|
||||||
|
import { format } from 'date-fns';
|
||||||
|
|
||||||
|
interface ComponentsStatusSectionProps {
|
||||||
|
components: StatusPageComponentRecord[];
|
||||||
|
services: Service[];
|
||||||
|
uptimeData: Record<string, UptimeData[]>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ComponentsStatusSection = ({ components, services, uptimeData }: ComponentsStatusSectionProps) => {
|
||||||
|
const getServiceForComponent = (component: StatusPageComponentRecord) => {
|
||||||
|
return services.find(service => service.id === component.service_id);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getComponentStatus = (component: StatusPageComponentRecord) => {
|
||||||
|
const service = getServiceForComponent(component);
|
||||||
|
return service?.status || 'unknown';
|
||||||
|
};
|
||||||
|
|
||||||
|
const getUptimePercentage = (serviceId: string) => {
|
||||||
|
const history = uptimeData[serviceId] || [];
|
||||||
|
if (history.length === 0) return 100;
|
||||||
|
|
||||||
|
const upCount = history.filter(record => record.status === 'up').length;
|
||||||
|
return Math.round((upCount / history.length) * 100 * 100) / 100;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStatusIcon = (status: string) => {
|
||||||
|
switch (status) {
|
||||||
|
case 'up':
|
||||||
|
return <CheckCircle className="h-5 w-5 text-green-500" />;
|
||||||
|
case 'down':
|
||||||
|
return <XCircle className="h-5 w-5 text-red-500" />;
|
||||||
|
case 'warning':
|
||||||
|
return <AlertTriangle className="h-5 w-5 text-yellow-500" />;
|
||||||
|
case 'paused':
|
||||||
|
return <Pause className="h-5 w-5 text-gray-500" />;
|
||||||
|
default:
|
||||||
|
return <Server className="h-5 w-5 text-muted-foreground" />;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStatusBadge = (status: string) => {
|
||||||
|
switch (status) {
|
||||||
|
case 'up':
|
||||||
|
return (
|
||||||
|
<Badge className="bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200 border-green-200 dark:border-green-800">
|
||||||
|
<CheckCircle className="h-3 w-3 mr-1" />
|
||||||
|
Operational
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
case 'down':
|
||||||
|
return (
|
||||||
|
<Badge className="bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200 border-red-200 dark:border-red-800">
|
||||||
|
<XCircle className="h-3 w-3 mr-1" />
|
||||||
|
Down
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
case 'warning':
|
||||||
|
return (
|
||||||
|
<Badge className="bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200 border-yellow-200 dark:border-yellow-800">
|
||||||
|
<AlertTriangle className="h-3 w-3 mr-1" />
|
||||||
|
Degraded
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
case 'paused':
|
||||||
|
return (
|
||||||
|
<Badge className="bg-gray-100 text-gray-800 dark:bg-gray-900 dark:text-gray-200 border-gray-200 dark:border-gray-800">
|
||||||
|
<Pause className="h-3 w-3 mr-1" />
|
||||||
|
Maintenance
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
default:
|
||||||
|
return (
|
||||||
|
<Badge className="bg-gray-100 text-gray-800 dark:bg-gray-900 dark:text-gray-200">
|
||||||
|
Unknown
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStatusDotColor = (status: string) => {
|
||||||
|
switch (status) {
|
||||||
|
case 'up':
|
||||||
|
return 'bg-green-500';
|
||||||
|
case 'down':
|
||||||
|
return 'bg-red-500';
|
||||||
|
case 'warning':
|
||||||
|
return 'bg-yellow-500';
|
||||||
|
default:
|
||||||
|
return 'bg-gray-500';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (components.length === 0) {
|
||||||
|
return (
|
||||||
|
<Card className="mb-8 bg-card border-border">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2 text-card-foreground">
|
||||||
|
<Server className="h-5 w-5" />
|
||||||
|
System Components
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="space-y-4">
|
||||||
|
{[
|
||||||
|
{ name: 'API Services', description: 'Core API endpoints and services', status: 'up' },
|
||||||
|
{ name: 'Database', description: 'Primary database systems', status: 'up' },
|
||||||
|
{ name: 'Authentication', description: 'User authentication services', status: 'up' },
|
||||||
|
{ name: 'File Storage', description: 'Media and file hosting', status: 'up' }
|
||||||
|
].map((component, index) => (
|
||||||
|
<div key={index} className="flex items-center justify-between p-4 rounded-lg border border-border bg-background/50 hover:bg-background/80 transition-colors">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
{getStatusIcon(component.status)}
|
||||||
|
<div>
|
||||||
|
<h3 className="font-medium text-foreground">{component.name}</h3>
|
||||||
|
<p className="text-sm text-muted-foreground">{component.description}</p>
|
||||||
|
<div className="flex items-center gap-2 mt-1">
|
||||||
|
<span className="text-xs text-green-600 dark:text-green-400 font-medium">99.9% uptime</span>
|
||||||
|
<span className="text-xs text-muted-foreground">•</span>
|
||||||
|
<span className="text-xs text-muted-foreground">100ms response</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{getStatusBadge(component.status)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="mb-8 bg-card border-border">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2 text-card-foreground">
|
||||||
|
<Server className="h-5 w-5" />
|
||||||
|
System Components
|
||||||
|
</CardTitle>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Real-time status of all monitored components
|
||||||
|
</p>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="space-y-6">
|
||||||
|
{components
|
||||||
|
.sort((a, b) => a.display_order - b.display_order)
|
||||||
|
.map((component) => {
|
||||||
|
const service = getServiceForComponent(component);
|
||||||
|
const status = getComponentStatus(component);
|
||||||
|
const uptime = service?.id ? getUptimePercentage(component.service_id) : 100;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={component.id} className="space-y-4">
|
||||||
|
<div className="flex items-center justify-between p-5 rounded-lg border border-border bg-background/50 hover:bg-background/80 transition-all duration-200">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
{getStatusIcon(status)}
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="flex items-center gap-2 mb-1">
|
||||||
|
<h3 className="font-semibold text-foreground text-lg">{component.name}</h3>
|
||||||
|
{service?.responseTime && service.responseTime > 0 && (
|
||||||
|
<div className="flex items-center gap-1 text-xs text-muted-foreground bg-muted px-2 py-1 rounded">
|
||||||
|
<Clock className="h-3 w-3" />
|
||||||
|
{service.responseTime}ms
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{component.description && (
|
||||||
|
<p className="text-sm text-muted-foreground mb-2">{component.description}</p>
|
||||||
|
)}
|
||||||
|
<div className="flex items-center gap-4 text-xs text-muted-foreground">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className={`h-2 w-2 rounded-full ${getStatusDotColor(status)}`}></div>
|
||||||
|
<span className="font-medium">{uptime}% uptime (90 days)</span>
|
||||||
|
</div>
|
||||||
|
{service?.lastChecked && (
|
||||||
|
<span>Last checked: {format(new Date(service.lastChecked), 'HH:mm:ss')}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col items-end gap-2">
|
||||||
|
{getStatusBadge(status)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{component.service_id && (
|
||||||
|
<div className="ml-9">
|
||||||
|
<div className="text-xs text-muted-foreground mb-2">90-day uptime history</div>
|
||||||
|
<UptimeHistoryRenderer
|
||||||
|
serviceId={component.service_id}
|
||||||
|
uptimeData={uptimeData}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,158 @@
|
|||||||
|
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { Shield, Clock, CheckCircle, AlertTriangle, XCircle, Wrench } from 'lucide-react';
|
||||||
|
import { format } from 'date-fns';
|
||||||
|
import { OperationalPageRecord } from '@/types/operational.types';
|
||||||
|
import { StatusPageComponentRecord } from '@/types/statusPageComponents.types';
|
||||||
|
import { Service } from '@/types/service.types';
|
||||||
|
|
||||||
|
interface CurrentStatusSectionProps {
|
||||||
|
page: OperationalPageRecord;
|
||||||
|
components: StatusPageComponentRecord[];
|
||||||
|
services: Service[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const getActualStatus = (components: StatusPageComponentRecord[], services: Service[]) => {
|
||||||
|
if (components.length === 0) {
|
||||||
|
return 'operational'; // Default if no components
|
||||||
|
}
|
||||||
|
|
||||||
|
let hasDown = false;
|
||||||
|
let hasDegraded = false;
|
||||||
|
let hasMaintenance = false;
|
||||||
|
|
||||||
|
components.forEach(component => {
|
||||||
|
const service = services.find(s => s.id === component.service_id);
|
||||||
|
if (service) {
|
||||||
|
switch (service.status) {
|
||||||
|
case 'down':
|
||||||
|
hasDown = true;
|
||||||
|
break;
|
||||||
|
case 'warning':
|
||||||
|
hasDegraded = true;
|
||||||
|
break;
|
||||||
|
case 'paused':
|
||||||
|
hasMaintenance = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Priority: down > degraded > maintenance > operational
|
||||||
|
if (hasDown) return 'major_outage';
|
||||||
|
if (hasDegraded) return 'degraded';
|
||||||
|
if (hasMaintenance) return 'maintenance';
|
||||||
|
return 'operational';
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStatusMessage = (status: OperationalPageRecord['status']) => {
|
||||||
|
switch (status) {
|
||||||
|
case 'operational':
|
||||||
|
return 'All systems are operational';
|
||||||
|
case 'degraded':
|
||||||
|
return 'Some systems are experiencing degraded performance';
|
||||||
|
case 'maintenance':
|
||||||
|
return 'Systems are currently under maintenance';
|
||||||
|
case 'major_outage':
|
||||||
|
return 'We are experiencing a major service outage';
|
||||||
|
default:
|
||||||
|
return 'Status unknown';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStatusColor = (status: OperationalPageRecord['status']) => {
|
||||||
|
switch (status) {
|
||||||
|
case 'operational':
|
||||||
|
return 'text-green-600 dark:text-green-400';
|
||||||
|
case 'degraded':
|
||||||
|
return 'text-yellow-600 dark:text-yellow-400';
|
||||||
|
case 'maintenance':
|
||||||
|
return 'text-blue-600 dark:text-blue-400';
|
||||||
|
case 'major_outage':
|
||||||
|
return 'text-red-600 dark:text-red-400';
|
||||||
|
default:
|
||||||
|
return 'text-muted-foreground';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStatusIcon = (status: OperationalPageRecord['status']) => {
|
||||||
|
switch (status) {
|
||||||
|
case 'operational':
|
||||||
|
return <CheckCircle className="h-6 w-6 text-green-500" />;
|
||||||
|
case 'degraded':
|
||||||
|
return <AlertTriangle className="h-6 w-6 text-yellow-500" />;
|
||||||
|
case 'maintenance':
|
||||||
|
return <Wrench className="h-6 w-6 text-blue-500" />;
|
||||||
|
case 'major_outage':
|
||||||
|
return <XCircle className="h-6 w-6 text-red-500" />;
|
||||||
|
default:
|
||||||
|
return <Shield className="h-6 w-6 text-muted-foreground" />;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStatusBackground = (status: OperationalPageRecord['status']) => {
|
||||||
|
switch (status) {
|
||||||
|
case 'operational':
|
||||||
|
return 'bg-green-50 dark:bg-green-900/20 border-green-200 dark:border-green-800';
|
||||||
|
case 'degraded':
|
||||||
|
return 'bg-yellow-50 dark:bg-yellow-900/20 border-yellow-200 dark:border-yellow-800';
|
||||||
|
case 'maintenance':
|
||||||
|
return 'bg-blue-50 dark:bg-blue-900/20 border-blue-200 dark:border-blue-800';
|
||||||
|
case 'major_outage':
|
||||||
|
return 'bg-red-50 dark:bg-red-900/20 border-red-200 dark:border-red-800';
|
||||||
|
default:
|
||||||
|
return 'bg-gray-50 dark:bg-gray-900/20 border-gray-200 dark:border-gray-800';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const CurrentStatusSection = ({ page, components, services }: CurrentStatusSectionProps) => {
|
||||||
|
const actualStatus = getActualStatus(components, services);
|
||||||
|
const displayStatus = actualStatus; // Use actual status for real-time accuracy
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className={`mb-8 border-2 ${getStatusBackground(displayStatus)}`}>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-3 text-card-foreground text-xl">
|
||||||
|
<Shield className="h-6 w-6" />
|
||||||
|
System Status
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-6">
|
||||||
|
<div className={`flex items-center justify-between p-6 rounded-lg border-2 ${getStatusBackground(displayStatus)}`}>
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
{getStatusIcon(displayStatus)}
|
||||||
|
<div>
|
||||||
|
<h3 className={`text-2xl font-bold ${getStatusColor(displayStatus)}`}>
|
||||||
|
{getStatusMessage(displayStatus)}
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-muted-foreground mt-1">
|
||||||
|
Status automatically updated based on component health
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className={`px-4 py-2 rounded-full text-sm font-medium ${
|
||||||
|
displayStatus === 'operational' ? 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200' :
|
||||||
|
displayStatus === 'degraded' ? 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200' :
|
||||||
|
displayStatus === 'maintenance' ? 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200' :
|
||||||
|
'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200'
|
||||||
|
}`}>
|
||||||
|
{displayStatus === 'operational' ? 'All Systems Operational' :
|
||||||
|
displayStatus === 'degraded' ? 'Degraded Performance' :
|
||||||
|
displayStatus === 'maintenance' ? 'Under Maintenance' : 'Major Outage'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between text-sm text-muted-foreground border-t pt-4">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Clock className="h-4 w-4" />
|
||||||
|
<span>Last updated: {format(new Date(), 'MMM dd, yyyy HH:mm')} UTC</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="h-2 w-2 bg-green-500 rounded-full animate-pulse"></div>
|
||||||
|
<span>Live status monitoring</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,197 @@
|
|||||||
|
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { TrendingUp, Calendar, BarChart3 } from 'lucide-react';
|
||||||
|
import { UptimeData } from '@/types/service.types';
|
||||||
|
import { UptimeHistoryRenderer } from './UptimeHistoryRenderer';
|
||||||
|
|
||||||
|
interface OverallUptimeSectionProps {
|
||||||
|
uptimeData: Record<string, UptimeData[]>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const OverallUptimeSection = ({ uptimeData }: OverallUptimeSectionProps) => {
|
||||||
|
const getOverallUptime = () => {
|
||||||
|
const allHistories = Object.values(uptimeData);
|
||||||
|
if (allHistories.length === 0) return 99.9;
|
||||||
|
|
||||||
|
let totalRecords = 0;
|
||||||
|
let upRecords = 0;
|
||||||
|
|
||||||
|
allHistories.forEach(history => {
|
||||||
|
totalRecords += history.length;
|
||||||
|
upRecords += history.filter(record => record.status === 'up').length;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (totalRecords === 0) return 99.9;
|
||||||
|
return Math.round((upRecords / totalRecords) * 100 * 100) / 100;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getUptimeTrend = () => {
|
||||||
|
const uptime = getOverallUptime();
|
||||||
|
if (uptime >= 99.9) return 'excellent';
|
||||||
|
if (uptime >= 99.5) return 'good';
|
||||||
|
if (uptime >= 95) return 'fair';
|
||||||
|
return 'poor';
|
||||||
|
};
|
||||||
|
|
||||||
|
const getIncidentCount = () => {
|
||||||
|
const allHistories = Object.values(uptimeData);
|
||||||
|
let incidents = 0;
|
||||||
|
|
||||||
|
allHistories.forEach(history => {
|
||||||
|
let wasDown = false;
|
||||||
|
history.forEach(record => {
|
||||||
|
if (record.status === 'down' && !wasDown) {
|
||||||
|
incidents++;
|
||||||
|
wasDown = true;
|
||||||
|
} else if (record.status === 'up') {
|
||||||
|
wasDown = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return incidents;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getBadgeClassName = (trend: string) => {
|
||||||
|
switch (trend) {
|
||||||
|
case 'excellent':
|
||||||
|
return 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200';
|
||||||
|
case 'good':
|
||||||
|
return 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200';
|
||||||
|
case 'fair':
|
||||||
|
return 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200';
|
||||||
|
default:
|
||||||
|
return 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getTrendText = (trend: string) => {
|
||||||
|
switch (trend) {
|
||||||
|
case 'excellent':
|
||||||
|
return 'Excellent';
|
||||||
|
case 'good':
|
||||||
|
return 'Good';
|
||||||
|
case 'fair':
|
||||||
|
return 'Fair';
|
||||||
|
default:
|
||||||
|
return 'Needs Improvement';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStatusMessage = (uptime: number) => {
|
||||||
|
if (uptime >= 99.9) {
|
||||||
|
return "All systems are performing excellently with minimal downtime.";
|
||||||
|
} else if (uptime >= 99.5) {
|
||||||
|
return "Systems are performing well with occasional minor issues.";
|
||||||
|
} else if (uptime >= 95) {
|
||||||
|
return "We're working to improve system reliability and reduce incidents.";
|
||||||
|
} else {
|
||||||
|
return "We apologize for recent service disruptions and are actively working on improvements.";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const overallUptime = getOverallUptime();
|
||||||
|
const trend = getUptimeTrend();
|
||||||
|
const incidentCount = getIncidentCount();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="mb-8 bg-card border-border">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2 text-card-foreground">
|
||||||
|
<BarChart3 className="h-5 w-5" />
|
||||||
|
Performance Metrics (Last 90 Days)
|
||||||
|
</CardTitle>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Historical performance and reliability statistics
|
||||||
|
</p>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-6">
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||||
|
<div className="p-4 rounded-lg bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800">
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<TrendingUp className="h-4 w-4 text-green-600 dark:text-green-400" />
|
||||||
|
<span className="text-sm font-medium text-green-700 dark:text-green-300">Overall Uptime</span>
|
||||||
|
</div>
|
||||||
|
<Badge className={getBadgeClassName(trend)}>
|
||||||
|
{getTrendText(trend)}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<div className="text-3xl font-bold text-green-600 dark:text-green-400">{overallUptime}%</div>
|
||||||
|
<div className="text-xs text-green-700 dark:text-green-300 mt-1">
|
||||||
|
Target: 99.9%
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-4 rounded-lg bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800">
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<Calendar className="h-4 w-4 text-blue-600 dark:text-blue-400" />
|
||||||
|
<span className="text-sm font-medium text-blue-700 dark:text-blue-300">Incidents</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-3xl font-bold text-blue-600 dark:text-blue-400">{incidentCount}</div>
|
||||||
|
<div className="text-xs text-blue-700 dark:text-blue-300 mt-1">
|
||||||
|
Last 90 days
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-4 rounded-lg bg-purple-50 dark:bg-purple-900/20 border border-purple-200 dark:border-purple-800">
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<BarChart3 className="h-4 w-4 text-purple-600 dark:text-purple-400" />
|
||||||
|
<span className="text-sm font-medium text-purple-700 dark:text-purple-300">Avg Response</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-3xl font-bold text-purple-600 dark:text-purple-400">100ms</div>
|
||||||
|
<div className="text-xs text-purple-700 dark:text-purple-300 mt-1">
|
||||||
|
Response time
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h4 className="text-sm font-medium text-foreground">Uptime History</h4>
|
||||||
|
<div className="flex items-center gap-4 text-xs text-muted-foreground">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="h-3 w-3 bg-green-500 rounded"></div>
|
||||||
|
<span>Operational</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="h-3 w-3 bg-yellow-500 rounded"></div>
|
||||||
|
<span>Degraded</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="h-3 w-3 bg-red-500 rounded"></div>
|
||||||
|
<span>Down</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-4 bg-background/50 rounded-lg border">
|
||||||
|
{Object.keys(uptimeData).length > 0 ? (
|
||||||
|
<UptimeHistoryRenderer serviceId={Object.keys(uptimeData)[0]} uptimeData={uptimeData} />
|
||||||
|
) : (
|
||||||
|
<div className="flex justify-center items-center h-12 text-muted-foreground">
|
||||||
|
<div className="flex gap-1">
|
||||||
|
{Array.from({ length: 90 }, (_, i) => (
|
||||||
|
<div key={i} className="h-8 w-1 bg-green-500 rounded-sm"></div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-between text-xs text-muted-foreground">
|
||||||
|
<span>90 days ago</span>
|
||||||
|
<span>Today</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-4 bg-muted/50 rounded-lg border">
|
||||||
|
<div className="text-sm text-muted-foreground text-center">
|
||||||
|
{getStatusMessage(overallUptime)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { useParams } from 'react-router-dom';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { RefreshCw, AlertCircle } from 'lucide-react';
|
||||||
|
import { usePublicStatusPageData } from './hooks/usePublicStatusPageData';
|
||||||
|
import { StatusPageHeader } from './StatusPageHeader';
|
||||||
|
import { CurrentStatusSection } from './CurrentStatusSection';
|
||||||
|
import { ComponentsStatusSection } from './ComponentsStatusSection';
|
||||||
|
import { OverallUptimeSection } from './OverallUptimeSection';
|
||||||
|
import { PublicStatusPageFooter } from './PublicStatusPageFooter';
|
||||||
|
|
||||||
|
export const PublicStatusPage = () => {
|
||||||
|
const { slug } = useParams<{ slug: string }>();
|
||||||
|
console.log('PublicStatusPage - slug from params:', slug);
|
||||||
|
|
||||||
|
const { page, components, services, uptimeData, loading, error } = usePublicStatusPageData(slug);
|
||||||
|
const [lastUpdated, setLastUpdated] = useState(new Date());
|
||||||
|
|
||||||
|
// Auto-refresh every 30 seconds
|
||||||
|
useEffect(() => {
|
||||||
|
const interval = setInterval(() => {
|
||||||
|
setLastUpdated(new Date());
|
||||||
|
// The usePublicStatusPageData hook handles data refetching
|
||||||
|
}, 30000);
|
||||||
|
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Apply theme to document
|
||||||
|
useEffect(() => {
|
||||||
|
if (page) {
|
||||||
|
const root = document.documentElement;
|
||||||
|
|
||||||
|
// Remove any existing theme classes
|
||||||
|
root.classList.remove('dark', 'light');
|
||||||
|
|
||||||
|
// Apply the selected theme
|
||||||
|
if (page.theme === 'dark') {
|
||||||
|
root.classList.add('dark');
|
||||||
|
} else if (page.theme === 'light') {
|
||||||
|
root.classList.add('light');
|
||||||
|
}
|
||||||
|
// For 'default' theme, don't add any class (uses system preference)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cleanup on unmount
|
||||||
|
return () => {
|
||||||
|
const root = document.documentElement;
|
||||||
|
root.classList.remove('dark', 'light');
|
||||||
|
};
|
||||||
|
}, [page?.theme]);
|
||||||
|
|
||||||
|
console.log('PublicStatusPage state:', { loading, error, page: !!page, components: components.length, services: services.length });
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-background flex items-center justify-center">
|
||||||
|
<div className="text-center space-y-4">
|
||||||
|
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary mx-auto"></div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<p className="text-lg font-medium text-foreground">Loading Status Page</p>
|
||||||
|
<p className="text-sm text-muted-foreground">Fetching real-time system status...</p>
|
||||||
|
<p className="text-xs text-muted-foreground">Slug: {slug || 'No slug provided'}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error || !page) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-background flex items-center justify-center">
|
||||||
|
<div className="text-center space-y-6 max-w-md">
|
||||||
|
<div className="mx-auto h-16 w-16 bg-red-100 dark:bg-red-900/20 rounded-full flex items-center justify-center">
|
||||||
|
<AlertCircle className="h-8 w-8 text-red-600 dark:text-red-400" />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<h1 className="text-2xl font-bold text-foreground">Status Page Not Found</h1>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
{error || 'The requested status page could not be found or is not publicly accessible.'}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-muted-foreground">Slug: {slug || 'No slug provided'}</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-3 justify-center">
|
||||||
|
<Button onClick={() => window.history.back()} variant="outline">
|
||||||
|
Go Back
|
||||||
|
</Button>
|
||||||
|
<Button onClick={() => window.location.reload()} className="gap-2">
|
||||||
|
<RefreshCw className="h-4 w-4" />
|
||||||
|
Retry
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-background text-foreground">
|
||||||
|
{/* Header */}
|
||||||
|
<StatusPageHeader page={page} />
|
||||||
|
|
||||||
|
{/* Main Content */}
|
||||||
|
<main className="max-w-4xl mx-auto px-4 py-8">
|
||||||
|
{/* Current Status */}
|
||||||
|
<CurrentStatusSection page={page} components={components} services={services} />
|
||||||
|
|
||||||
|
{/* Components Status */}
|
||||||
|
<ComponentsStatusSection
|
||||||
|
components={components}
|
||||||
|
services={services}
|
||||||
|
uptimeData={uptimeData}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Overall Uptime History */}
|
||||||
|
<OverallUptimeSection uptimeData={uptimeData} />
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<PublicStatusPageFooter page={page} />
|
||||||
|
</main>
|
||||||
|
|
||||||
|
{/* Custom CSS */}
|
||||||
|
{page.custom_css && (
|
||||||
|
<style dangerouslySetInnerHTML={{ __html: page.custom_css }} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
|
||||||
|
import { OperationalPageRecord } from '@/types/operational.types';
|
||||||
|
import { format } from 'date-fns';
|
||||||
|
import { Clock, Shield, Zap, RefreshCw } from 'lucide-react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
|
||||||
|
interface PublicStatusPageFooterProps {
|
||||||
|
page: OperationalPageRecord;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const PublicStatusPageFooter = ({ page }: PublicStatusPageFooterProps) => {
|
||||||
|
const handleRefresh = () => {
|
||||||
|
window.location.reload();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<footer className="mt-12 pt-8 border-t border-border">
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Status Information */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 p-6 bg-muted/30 rounded-lg border">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="h-10 w-10 bg-green-500/10 rounded-lg flex items-center justify-center">
|
||||||
|
<Shield className="h-5 w-5 text-green-600 dark:text-green-400" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="font-medium text-foreground">Real-time Monitoring</div>
|
||||||
|
<div className="text-sm text-muted-foreground">24/7 automated checks</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="h-10 w-10 bg-blue-500/10 rounded-lg flex items-center justify-center">
|
||||||
|
<Zap className="h-5 w-5 text-blue-600 dark:text-blue-400" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="font-medium text-foreground">Instant Updates</div>
|
||||||
|
<div className="text-sm text-muted-foreground">Status changes in real-time</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="h-10 w-10 bg-purple-500/10 rounded-lg flex items-center justify-center">
|
||||||
|
<Clock className="h-5 w-5 text-purple-600 dark:text-purple-400" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="font-medium text-foreground">Historical Data</div>
|
||||||
|
<div className="text-sm text-muted-foreground">90-day performance history</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-4 text-sm text-muted-foreground">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Clock className="h-4 w-4" />
|
||||||
|
<span>Last updated: {format(new Date(), 'MMM dd, yyyy HH:mm:ss')} UTC</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="h-2 w-2 bg-green-500 rounded-full animate-pulse"></div>
|
||||||
|
<span>Monitoring active</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button variant="outline" size="sm" onClick={handleRefresh} className="gap-2">
|
||||||
|
<RefreshCw className="h-4 w-4" />
|
||||||
|
Refresh Status
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Disclaimer */}
|
||||||
|
<div className="text-center text-xs text-muted-foreground p-4 bg-muted/20 rounded-lg">
|
||||||
|
<p>
|
||||||
|
This status page provides real-time information about our systems and services.
|
||||||
|
Historical data reflects the last 90 days of monitoring. For support inquiries, please contact our team.
|
||||||
|
</p>
|
||||||
|
{page.custom_domain && (
|
||||||
|
<p className="mt-2">
|
||||||
|
Powered by automated monitoring • Status page for {page.title}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
|
||||||
|
import { OperationalPageRecord } from '@/types/operational.types';
|
||||||
|
import { Shield, Globe, ExternalLink } from 'lucide-react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
|
||||||
|
interface StatusPageHeaderProps {
|
||||||
|
page: OperationalPageRecord;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const StatusPageHeader = ({ page }: StatusPageHeaderProps) => {
|
||||||
|
return (
|
||||||
|
<header className="bg-background border-b border-border">
|
||||||
|
<div className="max-w-4xl mx-auto px-4 py-8">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
{page.logo_url ? (
|
||||||
|
<img
|
||||||
|
src={page.logo_url}
|
||||||
|
alt={`${page.title} logo`}
|
||||||
|
className="h-12 w-12 rounded-lg object-cover"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="h-12 w-12 bg-primary/10 rounded-lg flex items-center justify-center">
|
||||||
|
<Shield className="h-6 w-6 text-primary" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div>
|
||||||
|
<h1 className="text-3xl font-bold text-foreground">{page.title}</h1>
|
||||||
|
<p className="text-muted-foreground mt-1">{page.description}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
{page.custom_domain && (
|
||||||
|
<Button variant="outline" size="sm" asChild>
|
||||||
|
<a
|
||||||
|
href={`https://${page.custom_domain}`}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<Globe className="h-4 w-4" />
|
||||||
|
Visit Site
|
||||||
|
<ExternalLink className="h-3 w-3" />
|
||||||
|
</a>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="text-right text-sm text-muted-foreground">
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<div className="h-2 w-2 bg-green-500 rounded-full animate-pulse"></div>
|
||||||
|
<span className="font-medium">Live Status</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-xs">
|
||||||
|
Auto-updated every 30s
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Breadcrumb */}
|
||||||
|
<div className="mt-6 flex items-center gap-2 text-sm text-muted-foreground">
|
||||||
|
<Shield className="h-4 w-4" />
|
||||||
|
<span>Status Page</span>
|
||||||
|
<span>•</span>
|
||||||
|
<span className="text-foreground font-medium">{page.title}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,182 @@
|
|||||||
|
import { TooltipProvider, Tooltip, TooltipTrigger, TooltipContent } from '@/components/ui/tooltip';
|
||||||
|
import { format } from 'date-fns';
|
||||||
|
import { UptimeData } from '@/types/service.types';
|
||||||
|
|
||||||
|
interface UptimeHistoryRendererProps {
|
||||||
|
serviceId: string;
|
||||||
|
uptimeData: Record<string, UptimeData[]>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const UptimeHistoryRenderer = ({ serviceId, uptimeData }: UptimeHistoryRendererProps) => {
|
||||||
|
const renderUptimeHistory = (serviceId: string) => {
|
||||||
|
const history = uptimeData[serviceId] || [];
|
||||||
|
|
||||||
|
// Generate array for the last 90 days
|
||||||
|
const days = Array.from({ length: 90 }, (_, i) => {
|
||||||
|
const daysSinceToday = 90 - i - 1;
|
||||||
|
const date = new Date();
|
||||||
|
date.setDate(date.getDate() - daysSinceToday);
|
||||||
|
date.setHours(0, 0, 0, 0); // Set to start of day for comparison
|
||||||
|
return date;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (history.length === 0) {
|
||||||
|
// Generate mock data if no real data - showing mostly operational with some realistic incidents
|
||||||
|
return days.map((date, i) => {
|
||||||
|
// Simulate some realistic patterns - mostly up with occasional incidents
|
||||||
|
const isUp = Math.random() > 0.02; // 98% uptime simulation
|
||||||
|
const responseTime = isUp ? Math.floor(Math.random() * 200) + 50 : 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TooltipProvider key={i}>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<div
|
||||||
|
className={`h-8 w-1 rounded-sm cursor-pointer ${
|
||||||
|
isUp ? 'bg-green-500 hover:bg-green-600' : 'bg-red-500 hover:bg-red-600'
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
<div className="text-sm">
|
||||||
|
<div className="font-medium">{format(date, 'MMM dd, yyyy')}</div>
|
||||||
|
<div className="text-muted-foreground">
|
||||||
|
Status: {isUp ? 'Operational' : 'Incident - Down'}
|
||||||
|
</div>
|
||||||
|
{isUp && (
|
||||||
|
<div className="text-muted-foreground">
|
||||||
|
Response: {responseTime}ms
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!isUp && (
|
||||||
|
<div className="text-muted-foreground text-red-400">
|
||||||
|
Service outage detected
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a map of dates to status records for efficient lookup and incident tracking
|
||||||
|
const dateToRecordMap = new Map();
|
||||||
|
const incidentsByDate = new Map();
|
||||||
|
|
||||||
|
history.forEach(record => {
|
||||||
|
const recordDate = new Date(record.timestamp);
|
||||||
|
recordDate.setHours(0, 0, 0, 0); // Normalize to start of day
|
||||||
|
const dateKey = recordDate.toDateString();
|
||||||
|
|
||||||
|
// Track incidents for this date
|
||||||
|
if (!incidentsByDate.has(dateKey)) {
|
||||||
|
incidentsByDate.set(dateKey, []);
|
||||||
|
}
|
||||||
|
incidentsByDate.get(dateKey).push(record);
|
||||||
|
|
||||||
|
// Keep the latest record for each day (or aggregate if needed)
|
||||||
|
if (!dateToRecordMap.has(dateKey) ||
|
||||||
|
new Date(record.timestamp) > new Date(dateToRecordMap.get(dateKey).timestamp)) {
|
||||||
|
dateToRecordMap.set(dateKey, record);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Use real uptime data mapped to the correct days with incident details
|
||||||
|
return days.map((date, i) => {
|
||||||
|
const dateKey = date.toDateString();
|
||||||
|
const record = dateToRecordMap.get(dateKey);
|
||||||
|
const incidents = incidentsByDate.get(dateKey) || [];
|
||||||
|
|
||||||
|
// Calculate uptime percentage for the day
|
||||||
|
const uptimePercentage = incidents.length > 0 ?
|
||||||
|
Math.round((incidents.filter(inc => inc.status === 'up').length / incidents.length) * 100) : 100;
|
||||||
|
|
||||||
|
// Determine color based on actual status and incident history
|
||||||
|
const getStatusColor = (status: string, incidents: UptimeData[]) => {
|
||||||
|
const downIncidents = incidents.filter(inc => inc.status === 'down').length;
|
||||||
|
const warningIncidents = incidents.filter(inc => inc.status === 'warning').length;
|
||||||
|
|
||||||
|
if (downIncidents > 0) return 'bg-red-500 hover:bg-red-600';
|
||||||
|
if (warningIncidents > 0) return 'bg-yellow-500 hover:bg-yellow-600';
|
||||||
|
|
||||||
|
switch (status) {
|
||||||
|
case 'up':
|
||||||
|
return 'bg-green-500 hover:bg-green-600';
|
||||||
|
case 'down':
|
||||||
|
return 'bg-red-500 hover:bg-red-600';
|
||||||
|
case 'paused':
|
||||||
|
return 'bg-gray-400 hover:bg-gray-500';
|
||||||
|
case 'warning':
|
||||||
|
return 'bg-yellow-500 hover:bg-yellow-600';
|
||||||
|
default:
|
||||||
|
return 'bg-gray-300 hover:bg-gray-400 dark:bg-gray-600 dark:hover:bg-gray-500';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const statusColor = record ? getStatusColor(record.status, incidents) : 'bg-gray-300 dark:bg-gray-600';
|
||||||
|
const statusText = record ?
|
||||||
|
record.status === 'up' ? (incidents.some(inc => inc.status === 'down') ? 'Recovered' : 'Operational') :
|
||||||
|
record.status === 'down' ? 'Incident - Down' :
|
||||||
|
record.status === 'paused' ? 'Paused' :
|
||||||
|
record.status === 'warning' ? 'Incident - Degraded' : 'Unknown'
|
||||||
|
: 'No Data';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TooltipProvider key={i}>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<div
|
||||||
|
className={`h-8 w-1 rounded-sm cursor-pointer ${statusColor}`}
|
||||||
|
/>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
<div className="text-sm">
|
||||||
|
<div className="font-medium">
|
||||||
|
{format(date, 'MMM dd, yyyy')}
|
||||||
|
</div>
|
||||||
|
<div className="text-muted-foreground">
|
||||||
|
Status: {statusText}
|
||||||
|
</div>
|
||||||
|
{incidents.length > 0 && (
|
||||||
|
<div className="text-muted-foreground">
|
||||||
|
Uptime: {uptimePercentage}% ({incidents.length} checks)
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{incidents.filter(inc => inc.status === 'down').length > 0 && (
|
||||||
|
<div className="text-red-400 text-xs">
|
||||||
|
{incidents.filter(inc => inc.status === 'down').length} incident(s) detected
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{record && record.status === 'up' && record.responseTime > 0 && (
|
||||||
|
<div className="text-muted-foreground">
|
||||||
|
Response: {record.responseTime}ms
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{record && (
|
||||||
|
<div className="text-muted-foreground">
|
||||||
|
Last Check: {format(new Date(record.timestamp), 'HH:mm')}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="ml-8 p-3 bg-background/30 rounded-lg border border-border/50">
|
||||||
|
<div className="text-sm font-medium text-foreground mb-2">90-day uptime history</div>
|
||||||
|
<div className="flex items-center gap-1 mb-2">
|
||||||
|
{renderUptimeHistory(serviceId)}
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between text-xs text-muted-foreground">
|
||||||
|
<span>90 days ago</span>
|
||||||
|
<span>Today</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { OperationalPageRecord } from '@/types/operational.types';
|
||||||
|
import { StatusPageComponentRecord } from '@/types/statusPageComponents.types';
|
||||||
|
import { Service, UptimeData } from '@/types/service.types';
|
||||||
|
import { operationalPageService } from '@/services/operationalPageService';
|
||||||
|
import { statusPageComponentsService } from '@/services/statusPageComponentsService';
|
||||||
|
import { serviceService } from '@/services/serviceService';
|
||||||
|
import { uptimeService } from '@/services/uptimeService';
|
||||||
|
|
||||||
|
export const usePublicStatusPageData = (slug: string | undefined) => {
|
||||||
|
const [page, setPage] = useState<OperationalPageRecord | null>(null);
|
||||||
|
const [components, setComponents] = useState<StatusPageComponentRecord[]>([]);
|
||||||
|
const [services, setServices] = useState<Service[]>([]);
|
||||||
|
const [uptimeData, setUptimeData] = useState<Record<string, UptimeData[]>>({});
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchPublicPage = async () => {
|
||||||
|
if (!slug) {
|
||||||
|
console.log('No slug provided');
|
||||||
|
setError('No status page slug provided');
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
console.log('Fetching public status page for slug:', slug);
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
// Fetch operational page
|
||||||
|
console.log('Fetching operational pages...');
|
||||||
|
const pages = await operationalPageService.getOperationalPages();
|
||||||
|
console.log('All pages:', pages);
|
||||||
|
|
||||||
|
const foundPage = pages.find(p => p.slug === slug && p.is_public === 'true');
|
||||||
|
console.log('Found page:', foundPage);
|
||||||
|
|
||||||
|
if (!foundPage) {
|
||||||
|
console.log('Page not found or not public');
|
||||||
|
setError('Status page not found or not public');
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setPage(foundPage);
|
||||||
|
console.log('Page set successfully');
|
||||||
|
|
||||||
|
// Fetch components for this page
|
||||||
|
console.log('Fetching components for page:', foundPage.id);
|
||||||
|
const pageComponents = await statusPageComponentsService.getStatusPageComponentsByOperationalId(foundPage.id);
|
||||||
|
console.log('Components found:', pageComponents);
|
||||||
|
setComponents(pageComponents);
|
||||||
|
|
||||||
|
// Fetch all services
|
||||||
|
console.log('Fetching all services...');
|
||||||
|
const allServices = await serviceService.getServices();
|
||||||
|
console.log('Services found:', allServices);
|
||||||
|
setServices(allServices);
|
||||||
|
|
||||||
|
// Fetch uptime data for each component that has a service
|
||||||
|
console.log('Fetching uptime data...');
|
||||||
|
const uptimePromises = pageComponents
|
||||||
|
.filter(component => component.service_id)
|
||||||
|
.map(async (component) => {
|
||||||
|
try {
|
||||||
|
console.log('Fetching uptime for service:', component.service_id);
|
||||||
|
const endDate = new Date();
|
||||||
|
const startDate = new Date(Date.now() - 90 * 24 * 60 * 60 * 1000); // Last 90 days
|
||||||
|
const history = await uptimeService.getUptimeHistory(component.service_id, 2000, startDate, endDate);
|
||||||
|
console.log(`Uptime history for ${component.service_id}:`, history.length, 'records');
|
||||||
|
return { serviceId: component.service_id, history };
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Error fetching uptime for service ${component.service_id}:`, error);
|
||||||
|
return { serviceId: component.service_id, history: [] };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const uptimeResults = await Promise.all(uptimePromises);
|
||||||
|
const uptimeMap: Record<string, UptimeData[]> = {};
|
||||||
|
uptimeResults.forEach(result => {
|
||||||
|
uptimeMap[result.serviceId] = result.history;
|
||||||
|
});
|
||||||
|
setUptimeData(uptimeMap);
|
||||||
|
console.log('Uptime data set successfully');
|
||||||
|
|
||||||
|
console.log('All data fetched successfully');
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error fetching public page:', err);
|
||||||
|
setError(`Failed to load status page: ${err}`);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchPublicPage();
|
||||||
|
}, [slug]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
page,
|
||||||
|
components,
|
||||||
|
services,
|
||||||
|
uptimeData,
|
||||||
|
loading,
|
||||||
|
error
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user