71 Commits

Author SHA1 Message Date
Tola Leng 759c3249e1 Merge pull request #22 from operacle/develop
feat: release maintenance and incident management features; apply hotfixes
2025-05-26 21:57:45 +07:00
Tola Leng bff3cd7d91 Fixed: combines new build-based setup with persistent data using Host (volume) directory mapped to container path 2025-05-26 22:54:47 +08:00
Tola Leng 3799be8b23 feat: release maintenance and incident management features; apply hotfixes 2025-05-26 22:45:39 +08:00
Tola Leng 0932acfb61 Fix: Make service table row clickable 2025-05-26 22:02:59 +08:00
Tola Leng 38374acdcd Fix: Apply grid style to SSL overview cards 2025-05-26 21:52:10 +08:00
Tola Leng a58180f252 Updated context language 2025-05-26 20:32:08 +08:00
Tola Leng a307fd006c Removed Dockerfile and Updated API Enpoint 2025-05-26 20:15:10 +08:00
Tola Leng 31738b3a52 Updated language context 2025-05-26 19:58:00 +08:00
Tola Leng e3e7206d91 Resolved merge conflict using 'develop' changes 2025-05-26 19:00:03 +08:00
Tola Leng fe1b94d8e3 Feat: Added schema for new feature (maintenance and incident) include the user management roles. 2025-05-26 18:02:31 +08:00
Tola Leng 4f1c78f8e1 Refactor: Implement permission notice for admin role 2025-05-26 17:50:53 +08:00
Tola Leng 7ccd413973 Feat: added tab dashboard under General Settings (System Setting and Mail Settings). Improved the Setting API to dynamically use the baseURL. 2025-05-26 16:51:05 +08:00
Tola Leng e74bf35264 Fix: Prevent console message loop on maintenance updates
The console was generating excessive messages after schedule maintenance updates.
2025-05-26 16:42:05 +08:00
Tola Leng 23be1fca0f Fix: Reduce maintenance data request frequency.
Fix: The maintenance list should refresh immediately after a new maintenance entry is created.
2025-05-24 18:20:33 +08:00
Tola Leng df047541b9 Fixed Version (HMR-safe). Avoids HMR issues from unstable inline defaults. 2025-05-24 04:45:47 +08:00
Tola Leng c8323e91b0 Updated and Fix UserRole and User Forms 2025-05-24 03:47:32 +08:00
Tola Leng 9da272b464 Fix: Add scrollable content to AddUserForm 2025-05-24 03:01:58 +08:00
Tola Leng 706a01e5aa Updated language for Settings Features and User Management 2025-05-24 03:01:07 +08:00
Tola Leng 8c159f402e feat: Implement tabbed settings dashboard
Implement a tabbed dashboard within the Settings Panel, including System Settings and Mail Setting
2025-05-24 03:00:29 +08:00
Tola Leng f483fbd2ee feat: Implement tabbed settings dashboard (System Settings and Mail Settings) 2025-05-24 02:59:34 +08:00
Tola Leng 616148f794 Expand user form display for both Add and Edit of Users 2025-05-24 02:57:37 +08:00
Tola Leng 7a125bf0b7 Fix: Super admin login API path
The login process for super admins was incorrectly using the regular user authentication path. This commit ensures super admins use the correct authentication path
2025-05-24 02:56:29 +08:00
Tola Leng dfe052aba6 Fix: Email change for both admin and superadmin
The email change functionality was updated to support both admin and superadmin roles. The code was adjusted to correctly handle email change requests for users in both the admin and super-admin
2025-05-24 02:55:29 +08:00
Tola Leng 641e98c001 Updated Language Context 2025-05-23 20:31:30 +07:00
Tola Leng 1ee63ec74a Added PDF Print Function 2025-05-23 20:31:30 +07:00
Tola Leng 0d8b88d9a1 Updated Service Notification 2025-05-23 20:31:30 +07:00
Tola Leng 280c5dcc86 Refactor the Translations language to seperate their own directory, for easy to maintenable and scalable language 2025-05-23 20:31:30 +07:00
Tola Leng 09ce22b4fb Updated UI Component 2025-05-23 20:31:30 +07:00
Tola Leng 1e8962cb11 Build Incident and maintenance service 2025-05-23 20:31:30 +07:00
Tola Leng a3727b5dfd feat: Implement Incident Management and Tab Dashboard 2025-05-23 20:31:30 +07:00
Tola Leng 4c2f65b432 feat: Implement Schedule Maintenance Management and Tab Dashboard 2025-05-23 20:31:30 +07:00
Tola Leng 0692a78576 Added Schedule & Incident Menu Sidebar 2025-05-23 20:31:30 +07:00
Tola Leng 839fcc8771 Add necessary dependencies for jspdf & jspdf-autotable for PDF Print 2025-05-23 20:31:30 +07:00
Tola Leng 686634f0ac Update the word German to Deutsch 2025-05-23 20:31:30 +07:00
Tola Leng 705acfbe1d Add Schedule & Incident menu sidebar 2025-05-23 20:31:30 +07:00
Tola Leng b845e46231 Merge branch 'develop' of https://github.com/operacle/checkcle into develop 2025-05-23 21:22:21 +08:00
Tola Leng 1f13d5c6be Updated: About System Page 2025-05-23 20:06:46 +07:00
Tola Leng 9781a0ad68 Updated Language Context 2025-05-23 20:40:29 +08:00
Tola Leng 1e51c964cf Added PDF Print Function 2025-05-23 20:40:14 +08:00
Tola Leng b282b0b2d5 Updated Service Notification 2025-05-23 20:39:43 +08:00
Tola Leng 8910c1355c Refactor the Translations language to seperate their own directory, for easy to maintenable and scalable language 2025-05-23 20:39:25 +08:00
Tola Leng b4bef67220 Updated UI Component 2025-05-23 20:37:59 +08:00
Tola Leng 462b6d6d40 Build Incident and maintenance service 2025-05-23 20:37:36 +08:00
Tola Leng 747ae5c23f feat: Implement Incident Management and Tab Dashboard 2025-05-23 20:37:00 +08:00
Tola Leng 395a580032 feat: Implement Schedule Maintenance Management and Tab Dashboard 2025-05-23 20:34:18 +08:00
Tola Leng a7a1c9a144 Added Schedule & Incident Menu Sidebar 2025-05-23 20:21:40 +08:00
Tola Leng 11deebe45f Add necessary dependencies for jspdf & jspdf-autotable for PDF Print 2025-05-23 20:20:06 +08:00
Tola Leng 0e705e1309 Update the word German to Deutsch 2025-05-23 20:16:53 +08:00
Tola Leng 4a6cd92661 Add Schedule & Incident menu sidebar 2025-05-23 18:48:03 +08:00
Tola Leng 3f6d606190 Updated: About System Page 2025-05-22 23:10:46 +08:00
Tola Leng 7c382ddb7a Removed Dockerfile 2025-05-20 16:45:51 +08:00
Tola Leng e4b5ba96c1 Updated Pocket API Connection for production 2025-05-20 16:45:31 +08:00
Tola Leng 34e78e5c46 Removed DockerFile 2025-05-20 16:45:06 +08:00
Tola Leng 442e025f7b Merge branch 'develop' into main 2025-05-20 15:35:27 +07:00
Tola Leng 975fb65c3d Updated to clone a specific branch:develop for Local Developement 2025-05-20 15:15:11 +08:00
Tola Leng 48abc618f3 Update API Connection for Local Development 2025-05-20 14:51:12 +08:00
Tola Leng ba318f13a5 Merge pull request #11 from michaelortnerit/main
Update de.ts
2025-05-19 15:50:18 +07:00
Michael Ortner c099f03bbb Update de.ts
Update the word German to Deutsch
2025-05-19 10:15:00 +02:00
Tola Leng 19f13598fb Added a new dropdown menu item for German in the language selector dropdown. Thanks to @michaelortnerit for the contribution. 2025-05-19 16:07:10 +08:00
Tola Leng 918e012082 Merge pull request #9 from michaelortnerit/main
[IMP] Removed the old name and added german language.
2025-05-19 14:19:50 +07:00
Michael Ortner e0c7541a87 [IMP] Removed the old name and added german language. 2025-05-17 21:10:10 +02:00
Tola Leng a1c6c7053e feat(km): Translate SSL & Domain section
Translates the SSL & Domain section, including components and pages, into Khmer. This involves updating the `km.ts` translation file with relevant Khmer translations for all UI elements and strings within the SSL & Domain management feature.
2025-05-17 21:08:44 +08:00
Tola Leng 389eb22737 Updated properties in both translation files 2025-05-17 21:07:31 +08:00
Tola Leng 466ef1cb90 Fix the SSLStatusBadge component to ensure it correctly uses the translations for all SSL status types 2025-05-17 21:06:50 +08:00
Tola Leng caaa32ea13 Updated telegram notification 2025-05-17 20:56:31 +08:00
Tola Leng 78be48ffd0 Fix: Ensure email change request is sent.
The email change request was failing with a 400 error. This commit addresses the issue by ensuring the email change request is correctly sent when a user updates their email in the profile dashboard.
2025-05-17 17:55:59 +08:00
Tola Leng aaee189ace Added Sample Test function to send a direct Telegram message 2025-05-17 17:34:23 +08:00
Tola Leng ce988574e4 Updated Default SMTP Mail gateway 2025-05-17 16:56:12 +08:00
Tola Leng a166938d42 Removes the SSL testSendTelegramMessage function and its associated test credentials from telegramService.ts. 2025-05-17 16:44:45 +08:00
Tola Leng e4d6d84477 Remove test Telegram function 2025-05-17 16:43:33 +08:00
Tola Leng c0cf913935 📜 Updated For New Module: SSL & Domain Monitoring 2025-05-17 00:00:04 +08:00
224 changed files with 12132 additions and 900 deletions
+2
View File
@@ -22,3 +22,5 @@ dist-ssr
*.njsproj *.njsproj
*.sln *.sln
*.sw? *.sw?
application/src/lib/pocketbase.ts
Dockerfile
+1 -1
View File
@@ -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 -18
View File
@@ -24,22 +24,23 @@ CheckCle is an Open Source solution for seamless, real-time monitoring of full-s
## #️⃣ Getting Started ## #️⃣ Getting Started
### Installation with Docker Compose ### Installation with Docker Run and Compose
1. Copy ready docker run command 1. Copy ready docker run command
```bash ```bash
# Create Docker Volume for data persistence docker run -d \
--name checkcle \
docker volume create pb_data --restart unless-stopped \
-p 8090:8090 \
-v /opt/pb_data:/mnt/pb_data \
# Docker Run Command --ulimit nofile=4096:8192 \
operacle/checkcle:latest
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. Docker Compose - Recommended
```bash ```bash
version: '3.9'
services: services:
checkcle: checkcle:
image: operacle/checkcle:latest image: operacle/checkcle:latest
@@ -48,15 +49,12 @@ services:
ports: ports:
- "8090:8090" # Web Application - "8090:8090" # Web Application
volumes: volumes:
- pb_data:/app/pb_data # Ensure persistent data across rebuilds - /opt/pb_data:/mnt/pb_data # Host directory mapped to container path
ulimits: ulimits:
nofile: nofile:
soft: 4096 soft: 4096
hard: 8192 hard: 8192
volumes:
pb_data: # Docker-managed volume for data persistence
``` ```
3. Admin Web Management 3. Admin Web Management
@@ -64,12 +62,12 @@ volumes:
User: admin@example.com User: admin@example.com
Passwd: Admin123456 Passwd: Admin123456
4. Follow the Quick Start Guide at https://docs.checkcle.com (Coming Soon) 4. Follow the Quick Start Guide at https://docs.checkcle.io
### ###
![Uptime Monitoring](https://pub-4a4062303020445f8f289a2fee84f9e8.r2.dev/images/checkcle-collapse-black.png)
![checkcle-collapse-black](https://pub-4a4062303020445f8f289a2fee84f9e8.r2.dev/images/checkcle-black.png) ![checkcle-collapse-black](https://pub-4a4062303020445f8f289a2fee84f9e8.r2.dev/images/checkcle-black.png)
![Service Detail Page](https://pub-4a4062303020445f8f289a2fee84f9e8.r2.dev/images/checkcle-detailpage.png) ![Service Detail Page](https://pub-4a4062303020445f8f289a2fee84f9e8.r2.dev/images/checkcle-detailpage.png)
![Schedule Maintenance](https://pub-4a4062303020445f8f289a2fee84f9e8.r2.dev/images/maintenance-dahboard.png)
## 📝 Development Roadmap ## 📝 Development Roadmap
@@ -80,15 +78,16 @@ volumes:
- [x] Docker containerization - [x] Docker containerization
- [x] CheckCle Website - [x] CheckCle Website
- [x] CheckCle Demo Server - [x] CheckCle Demo Server
- [ ] SSL & Domain Monitoring (Inprogress) - [x] SSL & Domain Monitoring
- [x] Schedule Maintenance
- [x] 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 - [ ] User Permission Roles & Service Group
- [ ] Notifications (Email/Slack/Discord/Signal) - [ ] Notifications (Email/Slack/Discord/Signal)
- [ ] Open-source release with full documentation - [x] 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 +110,7 @@ Here are some ways you can help improve CheckCle:
## 🌍 Stay Connected ## 🌍 Stay Connected
- Website: [checkcle.io](https://checkcle.io) - Website: [checkcle.io](https://checkcle.io)
- Documentation: [docs.checkcle.io](https://docs.checkcle.io)
- GitHub Repository: ⭐ [CheckCle](https://github.com/operacle/checkcle.git) - GitHub Repository: ⭐ [CheckCle](https://github.com/operacle/checkcle.git)
- Community Channels: Engage via discussions and issues! - Community Channels: Engage via discussions and issues!
- Discord: Join our community [@discord](https://discord.gg/xs9gbubGwX) - Discord: Join our community [@discord](https://discord.gg/xs9gbubGwX)
+3 -1
View File
@@ -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",
+9
View File
@@ -16,6 +16,7 @@ import Settings from "./pages/Settings";
import Profile from "./pages/Profile"; import Profile from "./pages/Profile";
import NotFound from "./pages/NotFound"; import NotFound from "./pages/NotFound";
import SslDomain from "./pages/SslDomain"; import SslDomain from "./pages/SslDomain";
import ScheduleIncident from "./pages/ScheduleIncident";
// Create a Protected route component // Create a Protected route component
const ProtectedRoute = ({ children }: { children: React.ReactNode }) => { const ProtectedRoute = ({ children }: { children: React.ReactNode }) => {
@@ -102,6 +103,14 @@ const App = () => {
</ProtectedRoute> </ProtectedRoute>
} }
/> />
<Route
path="/schedule-incident"
element={
<ProtectedRoute>
<ScheduleIncident />
</ProtectedRoute>
}
/>
{/* ADD ALL CUSTOM ROUTES ABOVE THE CATCH-ALL "*" ROUTE */} {/* ADD ALL CUSTOM ROUTES ABOVE THE CATCH-ALL "*" ROUTE */}
<Route path="*" element={<NotFound />} /> <Route path="*" element={<NotFound />} />
</Routes> </Routes>
+52
View File
@@ -1,6 +1,7 @@
// API routes handler // API routes handler
import realtime from './realtime'; import realtime from './realtime';
import settingsApi from './settings';
/** /**
* Simple API router for client-side application * Simple API router for client-side application
@@ -16,6 +17,9 @@ const api = {
if (path === '/api/realtime') { if (path === '/api/realtime') {
console.log("Routing to realtime handler"); console.log("Routing to realtime handler");
return await realtime(body); return await realtime(body);
} else if (path === '/api/settings') {
console.log("Routing to settings handler");
return await settingsApi(body);
} }
// Return 404 for unknown routes // Return 404 for unknown routes
@@ -31,4 +35,52 @@ const api = {
} }
}; };
// 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;
+115
View File
@@ -0,0 +1,115 @@
import { pb, getCurrentEndpoint } from '@/lib/pocketbase';
const settingsApi = async (body: any) => {
try {
const { action, data } = body;
console.log('Settings API called with action:', action, 'data:', data);
const authToken = pb.authStore.token;
const headers: Record<string, string> = {
'Content-Type': 'application/json',
};
if (authToken) {
headers['Authorization'] = `Bearer ${authToken}`;
}
const baseUrl = getCurrentEndpoint();
switch (action) {
case 'getSettings':
try {
const response = await fetch(`${baseUrl}/api/settings`, {
method: 'GET',
headers,
});
if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`);
const settings = await response.json();
return {
status: 200,
json: { success: true, data: settings },
};
} catch (error) {
console.error('Error fetching settings:', error);
return {
status: 500,
json: { success: false, message: 'Failed to fetch settings' },
};
}
case 'updateSettings':
try {
let response = await fetch(`${baseUrl}/api/settings`, {
method: 'PATCH',
headers,
body: JSON.stringify(data),
});
if (!response.ok && (response.status === 404 || response.status === 405)) {
response = await fetch(`${baseUrl}/api/settings`, {
method: 'POST',
headers,
body: JSON.stringify(data),
});
}
if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`);
const updatedSettings = await response.json();
return {
status: 200,
json: { success: true, data: updatedSettings },
};
} catch (error) {
console.error('Error updating settings:', error);
return {
status: 500,
json: { success: false, message: 'Failed to update settings' },
};
}
case 'testEmailConnection':
try {
const response = await fetch(`${baseUrl}/api/settings/test-email`, {
method: 'POST',
headers,
body: JSON.stringify(data),
});
if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`);
const result = await response.json();
return {
status: 200,
json: {
success: result.success || false,
message:
result.message || (result.success ? 'Connection successful' : 'Connection failed'),
},
};
} catch (error) {
console.error('Error testing email connection:', error);
return {
status: 500,
json: { success: false, message: 'Failed to test email connection' },
};
}
default:
return {
status: 400,
json: { success: false, message: 'Invalid action' },
};
}
} catch (error) {
console.error('Unexpected error in settingsApi:', error);
return {
status: 500,
json: { success: false, message: 'Internal server error' },
};
}
};
export default settingsApi;
@@ -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>
@@ -67,10 +67,10 @@ export const Sidebar = ({
<Radar className={`${mainIconSize} text-cyan-400`} /> <Radar className={`${mainIconSize} text-cyan-400`} />
{!collapsed && <span className="ml-2.5 font-medium text-foreground tracking-wide text-[15px]">{t("sslDomain")}</span>} {!collapsed && <span className="ml-2.5 font-medium text-foreground tracking-wide text-[15px]">{t("sslDomain")}</span>}
</Link> </Link>
<div className={`${collapsed ? 'p-3' : 'p-2 pl-3'} mb-1 rounded-lg hover:${theme === 'dark' ? 'bg-gray-800' : 'bg-sidebar-accent'} flex items-center ${collapsed ? 'justify-center' : ''} transition-colors duration-200`}> <Link to="/schedule-incident" className={`${collapsed ? 'p-3' : 'p-2 pl-3'} mb-1 rounded-lg ${location.pathname === '/schedule-incident' ? theme === 'dark' ? 'bg-gray-800' : 'bg-sidebar-accent' : `hover:${theme === 'dark' ? 'bg-gray-800' : 'bg-sidebar-accent'}`} flex items-center ${collapsed ? 'justify-center' : ''} transition-colors duration-200`}>
<Calendar className={`${mainIconSize} text-emerald-400`} /> <Calendar className={`${mainIconSize} text-emerald-400`} />
{!collapsed && <span className="ml-2.5 font-medium text-foreground tracking-wide text-[15px]">{t("scheduleIncident")}</span>} {!collapsed && <span className="ml-2.5 font-medium text-foreground tracking-wide text-[15px]">{t("scheduleIncident")}</span>}
</div> </Link>
<div className={`${collapsed ? 'p-3' : 'p-2 pl-3'} mb-1 rounded-lg hover:${theme === 'dark' ? 'bg-gray-800' : 'bg-sidebar-accent'} flex items-center ${collapsed ? 'justify-center' : ''} transition-colors duration-200`}> <div className={`${collapsed ? 'p-3' : 'p-2 pl-3'} mb-1 rounded-lg hover:${theme === 'dark' ? 'bg-gray-800' : 'bg-sidebar-accent'} flex items-center ${collapsed ? 'justify-center' : ''} transition-colors duration-200`}>
<BarChart2 className={`${mainIconSize} text-amber-400`} /> <BarChart2 className={`${mainIconSize} text-amber-400`} />
{!collapsed && <span className="ml-2.5 font-medium text-foreground tracking-wide text-[15px]">{t("operationalPage")}</span>} {!collapsed && <span className="ml-2.5 font-medium text-foreground tracking-wide text-[15px]">{t("operationalPage")}</span>}
@@ -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>
@@ -9,7 +9,7 @@ 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";
// Profile update form schema // Profile update form schema
@@ -35,6 +35,7 @@ export function UpdateProfileForm({ user }: UpdateProfileFormProps) {
const [isSubmitting, setIsSubmitting] = useState(false); const [isSubmitting, setIsSubmitting] = useState(false);
const [emailChangeRequested, setEmailChangeRequested] = 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();
// Initialize the form with current user data // Initialize the form with current user data
@@ -50,6 +51,7 @@ export function UpdateProfileForm({ user }: UpdateProfileFormProps) {
async function onSubmit(data: ProfileFormValues) { async function onSubmit(data: ProfileFormValues) {
setIsSubmitting(true); setIsSubmitting(true);
setUpdateError(null); setUpdateError(null);
setUpdateSuccess(null);
setEmailChangeRequested(false); setEmailChangeRequested(false);
try { try {
@@ -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
}; };
@@ -78,11 +81,14 @@ export function UpdateProfileForm({ user }: UpdateProfileFormProps) {
// If email was changed, show a specific message // If email was changed, show a specific message
if (isEmailChanged) { if (isEmailChanged) {
setEmailChangeRequested(true); setEmailChangeRequested(true);
setUpdateSuccess("A verification email has been sent to your new email address. Please check your inbox to complete the change.");
toast({ toast({
title: "Email change requested", title: "Email verification sent",
description: "A verification email has been sent to your new email address. Please check your inbox and follow the instructions to complete the change.", description: "A verification email has been sent to your new email address. Please check your inbox and follow the instructions to complete the change.",
variant: "default",
}); });
} else { } else {
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,6 +124,15 @@ export function UpdateProfileForm({ user }: UpdateProfileFormProps) {
</Alert> </Alert>
)} )}
{updateSuccess && (
<Alert className="bg-green-50 border-green-200 text-green-800">
<CheckCircle className="h-4 w-4 text-green-600" />
<AlertDescription>
{updateSuccess}
</AlertDescription>
</Alert>
)}
{emailChangeRequested && ( {emailChangeRequested && (
<Alert className="bg-yellow-50 border-yellow-200 text-yellow-800"> <Alert className="bg-yellow-50 border-yellow-200 text-yellow-800">
<AlertCircle className="h-4 w-4 text-yellow-600" /> <AlertCircle className="h-4 w-4 text-yellow-600" />
@@ -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>
);
};
@@ -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';
@@ -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>
);
};
@@ -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>
);
};
@@ -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>
);
};
@@ -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>
);
};
@@ -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>
);
};
@@ -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>
);
};
@@ -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>
);
};
@@ -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>
);
};
@@ -0,0 +1,4 @@
export * from './PrintButton';
export * from './DownloadPdfButton';
export * from './CloseButton';
@@ -0,0 +1,3 @@
export * from './usePrintIncident';
export * from './useDownloadIncidentPdf';
@@ -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 };
};
@@ -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';
@@ -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>
);
};
@@ -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>
);
};
@@ -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>
);
};
@@ -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>
);
};
@@ -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>
);
};
@@ -0,0 +1,45 @@
import React from 'react';
import { useLanguage } from '@/contexts/LanguageContext';
import { IncidentItem } from '@/services/incident';
import { formatDate } from './utils';
interface TimelineSectionProps {
incident: IncidentItem | null;
assignedUser?: any | null;
}
export const TimelineSection: React.FC<TimelineSectionProps> = ({ incident }) => {
const { t } = useLanguage();
if (!incident) return null;
return (
<div className="space-y-2">
<h3 className="font-semibold text-lg">{t('timeline')}</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<h4 className="text-sm font-medium text-muted-foreground">{t('created')}</h4>
<p className="mt-1">{formatDate(incident.created)}</p>
</div>
<div>
<h4 className="text-sm font-medium text-muted-foreground">{t('lastUpdated')}</h4>
<p className="mt-1">{formatDate(incident.updated)}</p>
</div>
<div>
<h4 className="text-sm font-medium text-muted-foreground">{t('incidentTime')}</h4>
<p className="mt-1">{formatDate(incident.timestamp)}</p>
</div>
<div>
<h4 className="text-sm font-medium text-muted-foreground">{t('resolutionTime')}</h4>
<p className="mt-1">{formatDate(incident.resolution_time)}</p>
</div>
</div>
</div>
);
};
@@ -0,0 +1,7 @@
// Export all section components
export * from './BasicInfoSection';
export * from './TimelineSection';
export * from './AffectedSystemsSection';
export * from './ResolutionSection';
export * from './utils';
@@ -0,0 +1,32 @@
import { User } from '@/services/userService';
import { format } from 'date-fns';
// Helper function to get user initials
export const getUserInitials = (user: User): string => {
if (user.full_name) {
const nameParts = user.full_name.split(' ');
if (nameParts.length > 1) {
return `${nameParts[0][0]}${nameParts[1][0]}`.toUpperCase();
}
return user.full_name.substring(0, 2).toUpperCase();
}
return user.username.substring(0, 2).toUpperCase();
};
// Format date helper
export const formatDate = (dateStr?: string) => {
if (!dateStr) return '-';
try {
return format(new Date(dateStr), 'PPp');
} catch (error) {
console.error('Error formatting date:', dateStr, error);
return dateStr;
}
};
// Get affected systems helper
export const getAffectedSystemsArray = (systems?: string) => {
if (!systems) return [];
return systems.split(',').map(system => system.trim()).filter(Boolean);
};
@@ -0,0 +1,56 @@
import React from 'react';
import { useFormContext } from 'react-hook-form';
import { useLanguage } from '@/contexts/LanguageContext';
import { IncidentFormValues } from '../hooks/useIncidentForm';
import {
FormField,
FormItem,
FormLabel,
FormControl,
FormMessage,
} from '@/components/ui/form';
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
export const IncidentAffectedFields: React.FC = () => {
const { t } = useLanguage();
const { control } = useFormContext<IncidentFormValues>();
return (
<div className="space-y-4">
<FormField
control={control}
name="affected_systems"
render={({ field }) => (
<FormItem>
<FormLabel>{t('affectedSystems')}</FormLabel>
<FormControl>
<Input placeholder={t('enterAffectedSystems')} {...field} />
</FormControl>
<FormMessage />
<p className="text-sm text-muted-foreground">{t('separateSystemsWithComma')}</p>
</FormItem>
)}
/>
<FormField
control={control}
name="root_cause"
render={({ field }) => (
<FormItem>
<FormLabel>{t('rootCause')}</FormLabel>
<FormControl>
<Textarea
placeholder={t('enterRootCause')}
className="min-h-[80px]"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
);
};
@@ -0,0 +1,201 @@
import React, { useEffect } from 'react';
import { useFormContext } from 'react-hook-form';
import { useLanguage } from '@/contexts/LanguageContext';
import { IncidentFormValues } from '../hooks/useIncidentForm';
import {
FormField,
FormItem,
FormLabel,
FormControl,
FormMessage,
} from '@/components/ui/form';
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import { useQuery } from '@tanstack/react-query';
import { userService } from '@/services/userService';
import { Badge } from '@/components/ui/badge';
import { X, Users } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
export const IncidentBasicFields: React.FC = () => {
const { t } = useLanguage();
const form = useFormContext<IncidentFormValues>();
const currentAssignedUserId = form.watch('assigned_to');
// For assigned users functionality
const [selectedUserIds, setSelectedUserIds] = React.useState<string[]>([]);
// Update selectedUserIds when form value changes (for edit mode)
useEffect(() => {
if (currentAssignedUserId) {
setSelectedUserIds([currentAssignedUserId]);
}
}, [currentAssignedUserId]);
// Fetch users for assignment
const { data: users = [], isLoading } = useQuery({
queryKey: ['users'],
queryFn: async () => {
try {
const usersList = await userService.getUsers();
console.log("Fetched users for incident assignment:", usersList);
return Array.isArray(usersList) ? usersList : [];
} catch (error) {
console.error("Failed to fetch users:", error);
return [];
}
},
staleTime: 300000 // Cache for 5 minutes
});
// Add user to assigned_to
const addUser = (userId: string) => {
// For now, we're using a single user assignment
console.log("Setting user ID in form:", userId);
form.setValue('assigned_to', userId, { shouldValidate: true, shouldDirty: true });
setSelectedUserIds([userId]);
};
// Remove assigned user
const removeUser = () => {
form.setValue('assigned_to', '', { shouldValidate: true, shouldDirty: true });
setSelectedUserIds([]);
};
// Get selected user data
const selectedUser = users.find(user => user.id === form.getValues('assigned_to'));
// Function to get user initials from name
const getUserInitials = (user: any): string => {
if (user.full_name) {
const nameParts = user.full_name.split(' ');
if (nameParts.length > 1) {
return `${nameParts[0][0]}${nameParts[1][0]}`.toUpperCase();
}
return user.full_name.substring(0, 2).toUpperCase();
}
return user.username.substring(0, 2).toUpperCase();
};
return (
<div className="space-y-4">
<FormField
control={form.control}
name="title"
render={({ field }) => (
<FormItem>
<FormLabel>{t('title')}</FormLabel>
<FormControl>
<Input placeholder={t('enterIncidentTitle')} {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="description"
render={({ field }) => (
<FormItem>
<FormLabel>{t('description')}</FormLabel>
<FormControl>
<Textarea
placeholder={t('enterIncidentDescription')}
className="min-h-[100px]"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="service_id"
render={({ field }) => (
<FormItem>
<FormLabel>{t('serviceId')}</FormLabel>
<FormControl>
<Input placeholder={t('enterServiceId')} {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="assigned_to"
render={({ field }) => (
<FormItem className="flex flex-col">
<FormLabel className="flex items-center gap-1">
<Users className="h-4 w-4" /> {t('assignedTo')}
</FormLabel>
<div className="space-y-3">
<FormControl>
<select
id="assigned-user-select"
className="w-full h-10 rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
value={field.value || ""}
onChange={(e) => {
const selectedValue = e.target.value;
console.log("Selected user ID:", selectedValue);
if (selectedValue) {
addUser(selectedValue);
}
}}
onBlur={field.onBlur}
disabled={field.disabled}
name={field.name}
>
<option value="">{t('selectAssignedUser')}</option>
{users.map(user => (
<option
key={user.id}
value={user.id}
>
{user.full_name || user.username}
</option>
))}
</select>
</FormControl>
{selectedUser ? (
<div className="flex flex-wrap gap-2 mt-2 border p-2 rounded-md bg-muted/50">
<Badge key={selectedUser.id} variant="secondary" className="flex items-center gap-1 py-1 px-2">
<Avatar className="h-5 w-5 mr-1">
<AvatarImage src={selectedUser.avatar} alt={selectedUser.full_name || selectedUser.username} />
<AvatarFallback className="text-[10px]">
{getUserInitials(selectedUser)}
</AvatarFallback>
</Avatar>
<span>{selectedUser.full_name || selectedUser.username}</span>
<Button
variant="ghost"
size="icon"
className="h-4 w-4 p-0 ml-1 hover:bg-transparent hover:opacity-70"
onClick={removeUser}
type="button"
>
<X className="h-3 w-3" />
<span className="sr-only">{t('remove')}</span>
</Button>
</Badge>
</div>
) : (
<div className="text-sm text-muted-foreground italic p-2">
{t('noAssignedUser')}
</div>
)}
</div>
<FormMessage />
</FormItem>
)}
/>
</div>
);
};
@@ -0,0 +1,119 @@
import React from 'react';
import { useFormContext } from 'react-hook-form';
import { useLanguage } from '@/contexts/LanguageContext';
import { IncidentFormValues } from '../hooks/useIncidentForm';
import {
FormField,
FormItem,
FormLabel,
FormControl,
FormMessage,
} from '@/components/ui/form';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group';
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
import { Label } from '@/components/ui/label';
export const IncidentConfigFields: React.FC = () => {
const { t } = useLanguage();
const { control } = useFormContext<IncidentFormValues>();
return (
<div className="grid grid-cols-1 gap-4">
<FormField
control={control}
name="status"
render={({ field }) => (
<FormItem>
<FormLabel>{t('status')}</FormLabel>
<Select onValueChange={field.onChange} defaultValue={field.value}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder={t('selectIncidentStatus')} />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="investigating">{t('investigating')}</SelectItem>
<SelectItem value="identified">{t('identified')}</SelectItem>
<SelectItem value="found_root_cause">{t('foundRootCause')}</SelectItem>
<SelectItem value="in_progress">{t('inProgress')}</SelectItem>
<SelectItem value="monitoring">{t('monitoring')}</SelectItem>
<SelectItem value="resolved">{t('resolved')}</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={control}
name="impact"
render={({ field }) => (
<FormItem className="space-y-2">
<FormLabel>{t('impact')}</FormLabel>
<FormControl>
<RadioGroup
onValueChange={field.onChange}
defaultValue={field.value}
className="flex space-x-1"
>
<div className="flex items-center space-x-2">
<RadioGroupItem value="minor" id="minor" />
<Label htmlFor="minor">{t('minor')}</Label>
</div>
<span></span>
<div className="flex items-center space-x-2">
<RadioGroupItem value="major" id="major" />
<Label htmlFor="major">{t('major')}</Label>
</div>
<span></span>
<div className="flex items-center space-x-2">
<RadioGroupItem value="critical" id="critical" />
<Label htmlFor="critical">{t('critical')}</Label>
</div>
<span></span>
<div className="flex items-center space-x-2">
<RadioGroupItem value="none" id="none" />
<Label htmlFor="none">{t('none')}</Label>
</div>
</RadioGroup>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={control}
name="priority"
render={({ field }) => (
<FormItem>
<FormLabel>{t('priority')}</FormLabel>
<Select onValueChange={field.onChange} defaultValue={field.value}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder={t('selectPriorityLevel')} />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="low">{t('low')}</SelectItem>
<SelectItem value="medium">{t('medium')}</SelectItem>
<SelectItem value="high">{t('high')}</SelectItem>
<SelectItem value="critical">{t('critical')}</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
</div>
);
};
@@ -0,0 +1,59 @@
import React from 'react';
import { useFormContext } from 'react-hook-form';
import { useLanguage } from '@/contexts/LanguageContext';
import { IncidentFormValues } from '../hooks/useIncidentForm';
import {
FormField,
FormItem,
FormLabel,
FormControl,
FormMessage,
} from '@/components/ui/form';
import { Textarea } from '@/components/ui/textarea';
export const IncidentDetailsFields: React.FC = () => {
const { t } = useLanguage();
const { control } = useFormContext<IncidentFormValues>();
return (
<div className="space-y-4">
<FormField
control={control}
name="resolution_steps"
render={({ field }) => (
<FormItem>
<FormLabel>{t('resolutionSteps')}</FormLabel>
<FormControl>
<Textarea
placeholder={t('enterResolutionSteps')}
className="min-h-[80px]"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={control}
name="lessons_learned"
render={({ field }) => (
<FormItem>
<FormLabel>{t('lessonsLearned')}</FormLabel>
<FormControl>
<Textarea
placeholder={t('enterLessonsLearned')}
className="min-h-[80px]"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
);
};
@@ -0,0 +1,5 @@
export * from './IncidentBasicFields';
export * from './IncidentAffectedFields';
export * from './IncidentConfigFields';
export * from './IncidentDetailsFields';
@@ -0,0 +1,84 @@
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { useToast } from '@/hooks/use-toast';
import { incidentService, IncidentItem } from '@/services/incident';
import { useLanguage } from '@/contexts/LanguageContext';
import { incidentFormSchema, IncidentFormValues } from './useIncidentForm';
export const useIncidentEditForm = (
incident: IncidentItem,
onSuccess: () => void,
onClose: () => void
) => {
const { t } = useLanguage();
const { toast } = useToast();
// Initialize form with existing incident data
const form = useForm<IncidentFormValues>({
resolver: zodResolver(incidentFormSchema),
defaultValues: {
title: incident.title || '',
description: incident.description || '',
affected_systems: incident.affected_systems || '',
status: (incident.status?.toLowerCase() || incident.impact_status?.toLowerCase() || 'investigating') as any,
impact: (incident.impact?.toLowerCase() || 'minor') as any,
priority: (incident.priority?.toLowerCase() || 'medium') as any,
service_id: incident.service_id || '',
assigned_to: incident.assigned_to || '',
root_cause: incident.root_cause || '',
resolution_steps: incident.resolution_steps || '',
lessons_learned: incident.lessons_learned || '',
},
});
const onSubmit = async (data: IncidentFormValues) => {
try {
console.log("Form data for update:", data);
console.log("Assigned user ID for update:", data.assigned_to);
await incidentService.updateIncident(incident.id, {
title: data.title,
description: data.description,
status: data.status,
affected_systems: data.affected_systems,
impact: data.impact,
priority: data.priority,
service_id: data.service_id,
assigned_to: data.assigned_to, // This is the user ID from the form
root_cause: data.root_cause,
resolution_steps: data.resolution_steps,
lessons_learned: data.lessons_learned,
});
toast({
title: t('incidentUpdated'),
description: t('incidentUpdatedDesc'),
});
onClose();
onSuccess();
} catch (error) {
console.error('Error updating incident:', error);
if (error instanceof Error) {
toast({
title: t('error'),
description: `${t('errorUpdatingIncident')}: ${error.message}`,
variant: 'destructive',
});
} else {
toast({
title: t('error'),
description: t('errorUpdatingIncident'),
variant: 'destructive',
});
}
}
};
return {
form,
onSubmit: form.handleSubmit(onSubmit),
};
};
@@ -0,0 +1,105 @@
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import * as z from 'zod';
import { pb } from '@/lib/pocketbase';
import { useToast } from '@/hooks/use-toast';
import { incidentService, CreateIncidentInput } from '@/services/incident';
import { useLanguage } from '@/contexts/LanguageContext';
// Define the schema for our incident form
export const incidentFormSchema = z.object({
title: z.string().min(1, { message: 'Title is required' }),
description: z.string().min(1, { message: 'Description is required' }),
affected_systems: z.string().min(1, { message: 'Affected systems are required' }),
status: z.enum(['investigating', 'found_root_cause', 'in_progress', 'monitoring', 'resolved']),
impact: z.enum(['none', 'minor', 'major', 'critical']),
priority: z.enum(['low', 'medium', 'high', 'critical']),
service_id: z.string().optional(),
assigned_to: z.string().optional(),
root_cause: z.string().optional(),
resolution_steps: z.string().optional(),
lessons_learned: z.string().optional(),
});
export type IncidentFormValues = z.infer<typeof incidentFormSchema>;
export const useIncidentForm = (onSuccess: () => void, onClose: () => void) => {
const { t } = useLanguage();
const { toast } = useToast();
const form = useForm<IncidentFormValues>({
resolver: zodResolver(incidentFormSchema),
defaultValues: {
title: '',
description: '',
affected_systems: '',
status: 'investigating',
impact: 'minor',
priority: 'medium',
service_id: '',
assigned_to: '',
root_cause: '',
resolution_steps: '',
lessons_learned: '',
},
});
const onSubmit = async (data: IncidentFormValues) => {
try {
console.log("Form data before submission:", data);
const formattedData: CreateIncidentInput = {
title: data.title,
description: data.description,
status: data.status,
affected_systems: data.affected_systems,
impact: data.impact,
priority: data.priority,
service_id: data.service_id,
assigned_to: data.assigned_to,
root_cause: data.root_cause,
resolution_steps: data.resolution_steps,
lessons_learned: data.lessons_learned,
timestamp: new Date().toISOString(),
created_by: pb.authStore.model?.id || '',
};
console.log("Formatted data for API:", formattedData);
await incidentService.createIncident(formattedData);
toast({
title: t('incidentCreated'),
description: t('incidentCreatedDesc'),
});
console.log("Incident created successfully, about to call onSuccess and onClose");
form.reset();
onClose();
onSuccess();
} catch (error) {
console.error('Error creating incident:', error);
if (error instanceof Error) {
console.error('Error details:', error.message);
toast({
title: t('error'),
description: `${t('errorCreatingIncident')}: ${error.message}`,
variant: 'destructive',
});
} else {
toast({
title: t('error'),
description: t('errorCreatingIncident'),
variant: 'destructive',
});
}
}
};
return {
form,
onSubmit: form.handleSubmit(onSubmit),
};
};
@@ -0,0 +1,9 @@
export * from './IncidentTable';
export * from './IncidentActionsMenu';
export * from './IncidentStatusBadge';
export * from './IncidentStatusDropdown';
export * from './CreateIncidentDialog';
export * from './EditIncidentDialog';
export * from './EmptyIncidentState';
export * from './detail-dialog';
@@ -0,0 +1,150 @@
import React, { useState, useCallback } from 'react';
import {
Table,
TableBody,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import { format } from 'date-fns';
import { IncidentItem } from '@/services/incident/types';
import { IncidentTableRow } from './IncidentTableRow';
import { IncidentDetailDialog } from '../detail-dialog/IncidentDetailDialog';
import { EditIncidentDialog } from '../EditIncidentDialog';
import { useLanguage } from '@/contexts/LanguageContext';
import { IncidentTableSkeleton } from './IncidentTableSkeleton';
interface IncidentTableProps {
data: IncidentItem[];
isLoading: boolean;
onIncidentUpdated: () => void;
onViewDetails?: (incident: IncidentItem) => void;
onEditIncident?: (incident: IncidentItem) => void;
}
export const IncidentTable = ({
data,
isLoading,
onIncidentUpdated,
onViewDetails,
onEditIncident
}: IncidentTableProps) => {
const { t } = useLanguage();
const [selectedIncident, setSelectedIncident] = useState<IncidentItem | null>(null);
const [isDetailOpen, setIsDetailOpen] = useState(false);
const [isEditOpen, setIsEditOpen] = useState(false);
const formatDate = useCallback((dateString: string | undefined) => {
if (!dateString) return '-';
try {
return format(new Date(dateString), 'PPp');
} catch (error) {
console.error('Error formatting date:', dateString, error);
return dateString;
}
}, []);
const getAffectedSystemsArray = useCallback((affectedSystems: string | undefined): string[] => {
if (!affectedSystems) return [];
return affectedSystems.split(',').map(system => system.trim()).filter(Boolean);
}, []);
const handleViewDetails = useCallback((incident: IncidentItem) => {
setSelectedIncident(incident);
setIsDetailOpen(true);
}, []);
const handleEditIncident = useCallback((incident: IncidentItem) => {
setSelectedIncident(incident);
setIsEditOpen(true);
}, []);
// Handle status updates efficiently
const handleIncidentUpdated = useCallback(() => {
console.log("Incident updated in IncidentTable, propagating event");
onIncidentUpdated();
}, [onIncidentUpdated]);
// Handle dialog closing
const handleDetailDialogClose = useCallback((open: boolean) => {
setIsDetailOpen(open);
if (!open) {
onIncidentUpdated();
}
}, [onIncidentUpdated]);
// Handle edit dialog closing
const handleEditDialogClose = useCallback((open: boolean) => {
setIsEditOpen(open);
if (!open) {
onIncidentUpdated();
}
}, [onIncidentUpdated]);
if (isLoading) {
return <IncidentTableSkeleton />;
}
// Add a safety check to prevent map of undefined error
if (!data || !Array.isArray(data)) {
console.error('Data is not an array:', data);
return (
<div className="p-4 text-center">
<p>No incident data available</p>
</div>
);
}
return (
<>
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead>{t('title')}</TableHead>
<TableHead>{t('status')}</TableHead>
<TableHead>{t('priority')}</TableHead>
<TableHead>{t('time')}</TableHead>
<TableHead>{t('affected')}</TableHead>
<TableHead>{t('impact')}</TableHead>
<TableHead>{t('assignedTo')}</TableHead>
<TableHead className="text-right">{t('actions')}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{data.map((item) => (
<IncidentTableRow
key={item.id}
item={item}
formatDate={formatDate}
getAffectedSystemsArray={getAffectedSystemsArray}
onViewDetails={onViewDetails || handleViewDetails}
onEditIncident={onEditIncident || handleEditIncident}
onIncidentUpdated={handleIncidentUpdated}
t={t}
/>
))}
</TableBody>
</Table>
</div>
{/* Incident detail dialog */}
<IncidentDetailDialog
open={isDetailOpen}
onOpenChange={handleDetailDialogClose}
incident={selectedIncident}
/>
{/* Edit incident dialog */}
{selectedIncident && (
<EditIncidentDialog
open={isEditOpen}
onOpenChange={handleEditDialogClose}
incident={selectedIncident}
onIncidentUpdated={onIncidentUpdated}
/>
)}
</>
);
};
@@ -0,0 +1,119 @@
import React, { memo, useState } from 'react';
import { TableRow, TableCell } from '@/components/ui/table';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Eye } from 'lucide-react';
import { IncidentStatusDropdown } from '../IncidentStatusDropdown';
import { IncidentActionsMenu } from '../IncidentActionsMenu';
import { IncidentItem } from '@/services/incident';
import { AssignedUserCell } from './IncidentTableUtils';
interface IncidentTableRowProps {
item: IncidentItem;
formatDate: (date: string | undefined) => string;
getAffectedSystemsArray: (systems: string | undefined) => string[];
onViewDetails?: (incident: IncidentItem) => void;
onEditIncident?: (incident: IncidentItem) => void;
onIncidentUpdated: () => void;
t: (key: string) => string;
}
export const IncidentTableRow = memo(({
item,
formatDate,
getAffectedSystemsArray,
onViewDetails,
onEditIncident,
onIncidentUpdated,
t
}: IncidentTableRowProps) => {
// Use local state for optimistic UI updates
const [localItem, setLocalItem] = useState(item);
// Update local state when props change
React.useEffect(() => {
setLocalItem(item);
}, [item]);
// Handle status updates locally
const handleStatusUpdated = () => {
console.log("Status updated in TableRow, calling onIncidentUpdated");
onIncidentUpdated();
};
return (
<TableRow
key={localItem.id}
className="hover:bg-muted/40 cursor-pointer"
onClick={() => onViewDetails && onViewDetails(localItem)}
>
<TableCell className="font-medium max-w-[200px] truncate">
{localItem.title || localItem.description || '-'}
</TableCell>
<TableCell onClick={(e) => e.stopPropagation()}>
<IncidentStatusDropdown
status={localItem.impact_status || localItem.status || 'investigating'}
id={localItem.id}
onStatusUpdated={handleStatusUpdated}
/>
</TableCell>
<TableCell>
<Badge variant={
localItem.priority?.toLowerCase() === 'critical' ? 'destructive' :
localItem.priority?.toLowerCase() === 'high' ? 'default' :
localItem.priority?.toLowerCase() === 'medium' ? 'secondary' : 'outline'
}>
{t(localItem.priority?.toLowerCase() || 'low')}
</Badge>
</TableCell>
<TableCell>{formatDate(localItem.created)}</TableCell>
<TableCell>
<div className="flex flex-wrap gap-1">
{getAffectedSystemsArray(localItem.affected_systems).map((system, idx) => (
<Badge key={`${localItem.id}-system-${idx}`} variant="outline">{system}</Badge>
))}
{getAffectedSystemsArray(localItem.affected_systems).length === 0 && '-'}
</div>
</TableCell>
<TableCell>
<Badge variant={
localItem.impact?.toLowerCase() === 'critical' ? 'destructive' :
localItem.impact?.toLowerCase() === 'high' ? 'default' :
localItem.impact?.toLowerCase() === 'medium' ? 'secondary' : 'outline'
}>
{t(localItem.impact?.toLowerCase() || 'low')}
</Badge>
</TableCell>
<TableCell>
<AssignedUserCell userId={localItem.assigned_to} />
</TableCell>
<TableCell className="text-right" onClick={(e) => e.stopPropagation()}>
<div className="flex justify-end items-center space-x-2">
{onViewDetails && (
<Button
variant="ghost"
size="sm"
onClick={(e) => {
e.stopPropagation();
onViewDetails(localItem);
}}
className="h-8 w-8 p-0"
>
<Eye className="h-4 w-4" />
<span className="sr-only">{t('view')}</span>
</Button>
)}
<IncidentActionsMenu
item={localItem}
onIncidentUpdated={onIncidentUpdated}
onViewDetails={onViewDetails}
onEditIncident={onEditIncident}
/>
</div>
</TableCell>
</TableRow>
);
});
IncidentTableRow.displayName = 'IncidentTableRow';
@@ -0,0 +1,27 @@
import React from 'react';
import { TableRow, TableCell } from '@/components/ui/table';
import { Skeleton } from '@/components/ui/skeleton';
export const IncidentTableRowSkeleton = () => (
<TableRow>
<TableCell><Skeleton className="h-5 w-[180px]" /></TableCell>
<TableCell><Skeleton className="h-5 w-[100px]" /></TableCell>
<TableCell><Skeleton className="h-5 w-[80px]" /></TableCell>
<TableCell><Skeleton className="h-5 w-[120px]" /></TableCell>
<TableCell><Skeleton className="h-5 w-[150px]" /></TableCell>
<TableCell><Skeleton className="h-5 w-[80px]" /></TableCell>
<TableCell><Skeleton className="h-5 w-[100px]" /></TableCell>
<TableCell><Skeleton className="h-5 w-[60px]" /></TableCell>
</TableRow>
);
export const IncidentTableSkeleton = () => (
<div className="overflow-x-auto">
<TableRow>
{Array(3).fill(0).map((_, index) => (
<IncidentTableRowSkeleton key={`skeleton-${index}`} />
))}
</TableRow>
</div>
);
@@ -0,0 +1,57 @@
import React from 'react';
import { useQuery } from '@tanstack/react-query';
import { userService, User } from '@/services/userService';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { Skeleton } from '@/components/ui/skeleton';
// Helper function to get user initials
export const getUserInitials = (user: User): string => {
if (user.full_name) {
const nameParts = user.full_name.split(' ');
if (nameParts.length > 1) {
return `${nameParts[0][0]}${nameParts[1][0]}`.toUpperCase();
}
return user.full_name.substring(0, 2).toUpperCase();
}
return user.username.substring(0, 2).toUpperCase();
};
interface AssignedUserCellProps {
userId?: string;
}
export const AssignedUserCell: React.FC<AssignedUserCellProps> = ({ userId }) => {
const { data: user, isLoading } = useQuery({
queryKey: ['user', userId],
queryFn: async () => {
if (!userId) return null;
try {
return await userService.getUser(userId);
} catch (error) {
console.error("Failed to fetch assigned user:", error);
return null;
}
},
enabled: !!userId,
staleTime: 300000 // Cache for 5 minutes
});
if (isLoading) {
return <Skeleton className="h-6 w-20" />;
}
if (!user || !userId) {
return <span>-</span>;
}
return (
<div className="flex items-center gap-2">
<Avatar className="h-6 w-6">
<AvatarImage src={user.avatar} alt={user.full_name || user.username} />
<AvatarFallback>{getUserInitials(user)}</AvatarFallback>
</Avatar>
<span className="truncate max-w-[100px]">{user.full_name || user.username}</span>
</div>
);
};
@@ -0,0 +1,5 @@
export * from './IncidentTable';
export * from './IncidentTableRow';
export * from './IncidentTableSkeleton';
export * from './IncidentTableUtils';
@@ -0,0 +1,18 @@
// Export all components for easier imports
export * from './ScheduleIncidentContent';
export * from './ScheduledMaintenanceTab';
export * from './IncidentManagementTab';
export * from './maintenance/MaintenanceTable';
export * from './maintenance/MaintenanceStatusBadge';
export * from './maintenance/MaintenanceActionsMenu';
export * from './maintenance/EmptyMaintenanceState';
export * from './maintenance/CreateMaintenanceDialog';
export * from './incident/IncidentTable';
export * from './incident/IncidentStatusBadge';
export * from './incident/IncidentActionsMenu';
export * from './incident/EmptyIncidentState';
export * from './incident/CreateIncidentDialog';
export * from './incident-management';
export * from './hooks';
@@ -0,0 +1,94 @@
import React from 'react';
import { useLanguage } from '@/contexts/LanguageContext';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter
} from '@/components/ui/dialog';
import { Form } from '@/components/ui/form';
import { Button } from '@/components/ui/button';
import { useMaintenanceForm } from './hooks/useMaintenanceForm';
import {
MaintenanceBasicFields,
MaintenanceTimeFields,
MaintenanceAffectedFields,
MaintenanceConfigFields,
MaintenanceNotificationSettingsField
} from './form';
interface CreateMaintenanceDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
onMaintenanceCreated: () => void;
}
export const CreateMaintenanceDialog = ({
open,
onOpenChange,
onMaintenanceCreated
}: CreateMaintenanceDialogProps) => {
const { t } = useLanguage();
const handleSuccess = () => {
console.log("CreateMaintenanceDialog: maintenance created successfully");
onMaintenanceCreated();
};
const handleClose = () => {
console.log("CreateMaintenanceDialog: closing dialog");
onOpenChange(false);
};
const { form, onSubmit } = useMaintenanceForm(handleSuccess, handleClose);
// Log the form state for debugging
React.useEffect(() => {
const subscription = form.watch((value) => {
console.log("Form values changed:", value);
});
return () => subscription.unsubscribe();
}, [form]);
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[600px] max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>{t('createMaintenanceWindow')}</DialogTitle>
<DialogDescription>
{t('createMaintenanceDesc')}
</DialogDescription>
</DialogHeader>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<MaintenanceBasicFields />
<MaintenanceTimeFields />
<MaintenanceAffectedFields />
<MaintenanceConfigFields />
<MaintenanceNotificationSettingsField />
<DialogFooter className="pt-4">
<Button
type="button"
variant="outline"
onClick={() => onOpenChange(false)}
>
{t('cancel')}
</Button>
<Button
type="submit"
disabled={form.formState.isSubmitting}
>
{form.formState.isSubmitting ? t('creating') : t('createMaintenance')}
</Button>
</DialogFooter>
</form>
</Form>
</DialogContent>
</Dialog>
);
};
@@ -0,0 +1,18 @@
import React from 'react';
import { useLanguage } from '@/contexts/LanguageContext';
import { CalendarClock } from 'lucide-react';
export const EmptyMaintenanceState = () => {
const { t } = useLanguage();
return (
<div className="flex flex-col items-center justify-center py-12 text-gray-500">
<CalendarClock className="w-12 h-12 mb-4" />
<h3 className="text-lg font-medium mb-2">{t('noScheduledMaintenance')}</h3>
<p className="text-sm text-center max-w-md">
{t('noMaintenanceWindows')}
</p>
</div>
);
};
@@ -0,0 +1,170 @@
import React, { useState } from 'react';
import { useLanguage } from '@/contexts/LanguageContext';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { Button } from '@/components/ui/button';
import { MoreHorizontal, Eye, Edit, Trash, Play, CheckCircle, X } from 'lucide-react';
import { useToast } from '@/hooks/use-toast';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { MaintenanceItem } from '@/services/types/maintenance.types';
import { maintenanceService } from '@/services/maintenance';
import { MaintenanceDetailDialog } from './detail-dialog/MaintenanceDetailDialog';
import { EditMaintenanceDialog } from './edit-dialog/EditMaintenanceDialog';
interface MaintenanceActionsMenuProps {
item: MaintenanceItem;
onMaintenanceUpdated: () => void;
}
export const MaintenanceActionsMenu = ({ item, onMaintenanceUpdated }: MaintenanceActionsMenuProps) => {
const { t } = useLanguage();
const { toast } = useToast();
const [detailDialogOpen, setDetailDialogOpen] = useState(false);
const [editDialogOpen, setEditDialogOpen] = useState(false);
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const handleStatusChange = async (newStatus: string) => {
try {
await maintenanceService.updateMaintenanceStatus(item.id, newStatus);
toast({
title: t('statusUpdated'),
description: t('maintenanceStatusUpdated'),
});
onMaintenanceUpdated();
} catch (error) {
console.error('Error updating maintenance status:', error);
toast({
title: t('error'),
description: t('errorUpdatingMaintenanceStatus'),
variant: 'destructive',
});
}
};
const handleDelete = async () => {
try {
await maintenanceService.deleteMaintenance(item.id);
toast({
title: t('maintenanceDeleted'),
description: t('maintenanceDeletedDesc'),
});
onMaintenanceUpdated();
} catch (error) {
console.error('Error deleting maintenance:', error);
toast({
title: t('error'),
description: t('errorDeletingMaintenance'),
variant: 'destructive',
});
} finally {
setDeleteDialogOpen(false);
}
};
// Convert status to lowercase for consistent comparison
const status = item.status.toLowerCase();
return (
<>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="h-8 w-8 p-0">
<span className="sr-only">{t('actions')}</span>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuLabel>{t('actions')}</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => setDetailDialogOpen(true)}>
<Eye className="mr-2 h-4 w-4" />
{t('view')}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setEditDialogOpen(true)}>
<Edit className="mr-2 h-4 w-4" />
{t('edit')}
</DropdownMenuItem>
{status === 'scheduled' && (
<DropdownMenuItem onClick={() => handleStatusChange('in_progress')}>
<Play className="mr-2 h-4 w-4" />
{t('markAsInProgress')}
</DropdownMenuItem>
)}
{(status === 'scheduled' || status === 'in_progress') && (
<DropdownMenuItem onClick={() => handleStatusChange('completed')}>
<CheckCircle className="mr-2 h-4 w-4" />
{t('markAsCompleted')}
</DropdownMenuItem>
)}
{status !== 'cancelled' && (
<DropdownMenuItem onClick={() => handleStatusChange('cancelled')}>
<X className="mr-2 h-4 w-4" />
{t('markAsCancelled')}
</DropdownMenuItem>
)}
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={() => setDeleteDialogOpen(true)}
className="text-red-600 focus:text-red-600"
>
<Trash className="mr-2 h-4 w-4" />
{t('delete')}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<MaintenanceDetailDialog
open={detailDialogOpen}
onOpenChange={setDetailDialogOpen}
maintenance={item}
/>
<EditMaintenanceDialog
open={editDialogOpen}
onOpenChange={setEditDialogOpen}
maintenance={item}
onMaintenanceUpdated={onMaintenanceUpdated}
/>
<AlertDialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{t('confirmDelete')}</AlertDialogTitle>
<AlertDialogDescription>
{t('deleteMaintenanceConfirmation')}
<span className="font-semibold"> {item.title}</span>?
{t('thisActionCannotBeUndone')}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{t('cancel')}</AlertDialogCancel>
<AlertDialogAction onClick={handleDelete} className="bg-red-600 hover:bg-red-700">
{t('delete')}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
);
};
@@ -0,0 +1,71 @@
import React from 'react';
import { Badge } from '@/components/ui/badge';
import { useLanguage } from '@/contexts/LanguageContext';
import { CalendarClock, Clock, CheckCircle, X } from 'lucide-react';
interface MaintenanceStatusBadgeProps {
status: string;
}
export const MaintenanceStatusBadge = ({ status }: MaintenanceStatusBadgeProps) => {
const { t } = useLanguage();
// Ensure we have a string and normalize it
const normalizedStatus = typeof status === 'string' ? status.toLowerCase() : '';
const getStatusColor = () => {
switch (normalizedStatus) {
case 'scheduled':
return 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-300';
case 'in progress':
case 'in_progress':
return 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-300';
case 'completed':
return 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-300';
case 'cancelled':
return 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-300';
default:
return 'bg-gray-100 text-gray-800 dark:bg-gray-800 dark:text-gray-300';
}
};
const getStatusIcon = () => {
switch (normalizedStatus) {
case 'scheduled':
return <CalendarClock className="h-3 w-3" />;
case 'in progress':
case 'in_progress':
return <Clock className="h-3 w-3" />;
case 'completed':
return <CheckCircle className="h-3 w-3" />;
case 'cancelled':
return <X className="h-3 w-3" />;
default:
return <CalendarClock className="h-3 w-3" />;
}
};
const getDisplayStatus = () => {
switch (normalizedStatus) {
case 'scheduled':
return t('scheduled');
case 'in progress':
case 'in_progress':
return t('inProgress');
case 'completed':
return t('completed');
case 'cancelled':
return t('cancelled');
default:
return status || '';
}
};
return (
<Badge className={`${getStatusColor()} flex items-center gap-1 font-medium`} variant="outline">
{getStatusIcon()}
{getDisplayStatus()}
</Badge>
);
};
@@ -0,0 +1,175 @@
import { useEffect, useRef } from 'react';
import { maintenanceService } from '@/services/maintenance';
import { useToast } from '@/hooks/use-toast';
import { useLanguage } from '@/contexts/LanguageContext';
import { MaintenanceItem } from '@/services/types/maintenance.types';
import { maintenanceNotificationService } from '@/services/maintenance/maintenanceNotificationService';
interface MaintenanceStatusCheckerProps {
maintenanceData: MaintenanceItem[];
onStatusUpdated: () => void;
}
export const MaintenanceStatusChecker = ({
maintenanceData,
onStatusUpdated
}: MaintenanceStatusCheckerProps) => {
const { t } = useLanguage();
const { toast } = useToast();
const checkedItemsRef = useRef<Set<string>>(new Set());
const notificationSentRef = useRef<Set<string>>(new Set());
const intervalRef = useRef<number | null>(null);
useEffect(() => {
if (!maintenanceData || maintenanceData.length === 0) return;
const checkAndUpdateStatus = async () => {
const currentTime = new Date();
let hasUpdates = false;
console.log('MaintenanceStatusChecker: Checking status updates at', currentTime.toISOString());
console.log('MaintenanceStatusChecker: Checking', maintenanceData.length, 'maintenance items');
for (const item of maintenanceData) {
try {
const startTime = new Date(item.start_time);
const endTime = new Date(item.end_time);
const status = item.status.toLowerCase();
console.log(`MaintenanceStatusChecker: Item ${item.id} - Status: ${status}, Start: ${startTime.toISOString()}, End: ${endTime.toISOString()}, Current: ${currentTime.toISOString()}`);
// Check if scheduled maintenance should start (become in_progress)
if (status === 'scheduled' && currentTime >= startTime && currentTime <= endTime) {
const checkKey = `${item.id}-started`;
const notificationKey = `${item.id}-start-notification`;
if (!checkedItemsRef.current.has(checkKey)) {
console.log(`MaintenanceStatusChecker: Starting maintenance ${item.id} at ${currentTime.toISOString()}`);
// Update status to in_progress first
await maintenanceService.updateMaintenanceStatus(item.id, 'in_progress');
// Send start notification only once
if (!notificationSentRef.current.has(notificationKey)) {
try {
await maintenanceNotificationService.sendMaintenanceNotification({
maintenance: item,
notificationType: 'start'
});
notificationSentRef.current.add(notificationKey);
console.log(`MaintenanceStatusChecker: Start notification sent for ${item.id}`);
} catch (notificationError) {
console.log('MaintenanceStatusChecker: Start notification failed', notificationError);
}
}
toast({
title: t('maintenanceInProgress'),
description: `${item.title} ${t('isNowInProgress')}`,
});
checkedItemsRef.current.add(checkKey);
hasUpdates = true;
}
}
// Check if in_progress maintenance should be completed
if (status === 'in_progress' && currentTime >= endTime) {
const checkKey = `${item.id}-completed`;
const notificationKey = `${item.id}-end-notification`;
if (!checkedItemsRef.current.has(checkKey)) {
console.log(`MaintenanceStatusChecker: Completing maintenance ${item.id} at ${currentTime.toISOString()}`);
// Update status to completed first
await maintenanceService.updateMaintenanceStatus(item.id, 'completed');
// Send completion notification only once
if (!notificationSentRef.current.has(notificationKey)) {
try {
await maintenanceNotificationService.sendMaintenanceNotification({
maintenance: item,
notificationType: 'end'
});
notificationSentRef.current.add(notificationKey);
console.log(`MaintenanceStatusChecker: Completion notification sent for ${item.id}`);
} catch (notificationError) {
console.log('MaintenanceStatusChecker: Completion notification failed', notificationError);
}
}
toast({
title: t('maintenanceCompleted'),
description: `${item.title} ${t('hasBeenCompleted')}`,
});
checkedItemsRef.current.add(checkKey);
hasUpdates = true;
}
}
} catch (error) {
console.error('MaintenanceStatusChecker: Error updating status for item', item.id, error);
// Clear the check flags after 2 minutes to allow retry
setTimeout(() => {
checkedItemsRef.current.delete(`${item.id}-started`);
checkedItemsRef.current.delete(`${item.id}-completed`);
notificationSentRef.current.delete(`${item.id}-start-notification`);
notificationSentRef.current.delete(`${item.id}-end-notification`);
}, 120000);
}
}
if (hasUpdates) {
console.log('MaintenanceStatusChecker: Status updates detected, triggering refresh');
// Force immediate refresh to update the UI
onStatusUpdated();
}
};
// Clear the interval if it exists
if (intervalRef.current) {
clearInterval(intervalRef.current);
}
// Initial check immediately
checkAndUpdateStatus();
// Check every 5 seconds for immediate status updates
intervalRef.current = window.setInterval(checkAndUpdateStatus, 5000);
return () => {
if (intervalRef.current) {
clearInterval(intervalRef.current);
intervalRef.current = null;
}
};
}, [maintenanceData, onStatusUpdated, t, toast]);
// Clear check flags when maintenance data changes significantly
useEffect(() => {
const currentIds = new Set(maintenanceData.map(item => item.id));
// Clean up check flags for items that no longer exist
const keysToDelete = Array.from(checkedItemsRef.current).filter(key => {
const itemId = key.split('-')[0];
return !currentIds.has(itemId);
});
keysToDelete.forEach(key => {
checkedItemsRef.current.delete(key);
});
// Clean up notification flags for items that no longer exist
const notificationKeysToDelete = Array.from(notificationSentRef.current).filter(key => {
const itemId = key.split('-')[0];
return !currentIds.has(itemId);
});
notificationKeysToDelete.forEach(key => {
notificationSentRef.current.delete(key);
});
}, [maintenanceData]);
return null;
};
@@ -0,0 +1,82 @@
import React from 'react';
import { useLanguage } from '@/contexts/LanguageContext';
import { CalendarClock, Clock, CheckCircle, X } from 'lucide-react';
import { useToast } from '@/hooks/use-toast';
import { maintenanceService } from '@/services/maintenance';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { MaintenanceStatusBadge } from './MaintenanceStatusBadge';
interface MaintenanceStatusDropdownProps {
status: string;
id: string;
onStatusUpdated: () => void;
disabled?: boolean;
}
export const MaintenanceStatusDropdown = ({
status,
id,
onStatusUpdated,
disabled = false
}: MaintenanceStatusDropdownProps) => {
const { t } = useLanguage();
const { toast } = useToast();
const statusOptions = [
{ value: 'scheduled', label: t('scheduled'), icon: <CalendarClock className="h-4 w-4 mr-2" /> },
{ value: 'in_progress', label: t('inProgress'), icon: <Clock className="h-4 w-4 mr-2" /> },
{ value: 'completed', label: t('completed'), icon: <CheckCircle className="h-4 w-4 mr-2" /> },
{ value: 'cancelled', label: t('cancelled'), icon: <X className="h-4 w-4 mr-2" /> },
];
const handleStatusChange = async (newStatus: string) => {
// Don't update if the status is the same
if (status.toLowerCase() === newStatus) return;
try {
await maintenanceService.updateMaintenanceStatus(id, newStatus);
toast({
title: t('statusUpdated'),
description: t('maintenanceStatusUpdated'),
});
onStatusUpdated();
} catch (error) {
console.error('Error updating maintenance status:', error);
toast({
title: t('error'),
description: t('failedToUpdateStatus'),
variant: 'destructive',
});
}
};
return (
<DropdownMenu>
<DropdownMenuTrigger disabled={disabled} className="w-full cursor-pointer">
<MaintenanceStatusBadge status={status} />
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="bg-popover border border-border shadow-md">
{statusOptions.map((option) => (
<DropdownMenuItem
key={option.value}
className="flex items-center cursor-pointer"
onClick={() => handleStatusChange(option.value)}
>
{option.icon}
{option.label}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
);
};
@@ -0,0 +1,152 @@
import React, { useState, useMemo } from 'react';
import { useLanguage } from '@/contexts/LanguageContext';
import { Table, TableHeader, TableBody, TableRow, TableHead, TableCell } from '@/components/ui/table';
import { Badge } from '@/components/ui/badge';
import { format } from 'date-fns';
import { MaintenanceStatusDropdown } from './MaintenanceStatusDropdown';
import { MaintenanceActionsMenu } from './MaintenanceActionsMenu';
import { MaintenanceDetailDialog } from './detail-dialog/MaintenanceDetailDialog';
import { MaintenanceItem } from '@/services/types/maintenance.types';
import { Skeleton } from '@/components/ui/skeleton';
import { EmptyMaintenanceState } from './EmptyMaintenanceState';
interface MaintenanceTableProps {
data: MaintenanceItem[];
isLoading?: boolean;
onMaintenanceUpdated: () => void;
}
export const MaintenanceTable = ({ data, isLoading = false, onMaintenanceUpdated }: MaintenanceTableProps) => {
const { t } = useLanguage();
const [selectedMaintenance, setSelectedMaintenance] = useState<MaintenanceItem | null>(null);
const [detailDialogOpen, setDetailDialogOpen] = useState(false);
// Memoize the formatted date function to avoid recreating it on each render
const formatDate = useMemo(() => (dateString: string) => {
if (!dateString) return '-';
try {
return format(new Date(dateString), 'MMM d, yyyy HH:mm');
} catch (error) {
return dateString || '-';
}
}, []);
const handleViewMaintenance = (maintenance: MaintenanceItem) => {
setSelectedMaintenance(maintenance);
setDetailDialogOpen(true);
};
if (isLoading) {
// Render skeleton loading state with table structure for smoother transitions
return (
<Table>
<TableHeader>
<TableRow>
<TableHead>{t('title')}</TableHead>
<TableHead>{t('status')}</TableHead>
<TableHead>{t('scheduledStart')}</TableHead>
<TableHead>{t('scheduledEnd')}</TableHead>
<TableHead>{t('affectedServices')}</TableHead>
<TableHead>{t('impact')}</TableHead>
<TableHead className="w-[80px]">{t('actions')}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{[1, 2, 3].map(i => (
<TableRow key={`skeleton-${i}`}>
<TableCell><Skeleton className="h-6 w-28" /></TableCell>
<TableCell><Skeleton className="h-6 w-20" /></TableCell>
<TableCell><Skeleton className="h-6 w-32" /></TableCell>
<TableCell><Skeleton className="h-6 w-32" /></TableCell>
<TableCell><Skeleton className="h-6 w-24" /></TableCell>
<TableCell><Skeleton className="h-6 w-16" /></TableCell>
<TableCell><Skeleton className="h-6 w-8" /></TableCell>
</TableRow>
))}
</TableBody>
</Table>
);
}
if (!data || data.length === 0) {
return <EmptyMaintenanceState />;
}
// Memoize the impact badge variant calculation
const getImpactBadgeVariant = (field: string | undefined) => {
const fieldLower = field?.toLowerCase() || '';
if (fieldLower === 'critical') return 'destructive';
if (fieldLower === 'major') return 'default';
if (fieldLower === 'minor') return 'secondary';
return 'outline';
};
return (
<>
<Table>
<TableHeader>
<TableRow>
<TableHead>{t('title')}</TableHead>
<TableHead>{t('status')}</TableHead>
<TableHead>{t('scheduledStart')}</TableHead>
<TableHead>{t('scheduledEnd')}</TableHead>
<TableHead>{t('affectedServices')}</TableHead>
<TableHead>{t('impact')}</TableHead>
<TableHead className="w-[80px]">{t('actions')}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{data.map((item) => (
<TableRow key={item.id} className="cursor-pointer hover:bg-muted/40">
<TableCell className="font-medium" onClick={() => handleViewMaintenance(item)}>
{item.title || '-'}
</TableCell>
<TableCell onClick={(e) => e.stopPropagation()}>
<MaintenanceStatusDropdown
status={item.status}
id={item.id}
onStatusUpdated={onMaintenanceUpdated}
/>
</TableCell>
<TableCell onClick={() => handleViewMaintenance(item)}>
{formatDate(item.start_time)}
</TableCell>
<TableCell onClick={() => handleViewMaintenance(item)}>
{formatDate(item.end_time)}
</TableCell>
<TableCell onClick={() => handleViewMaintenance(item)}>
<div className="flex flex-wrap gap-1">
{item.affected ? item.affected.split(',').slice(0, 2).map((service, index) => (
<Badge key={index} variant="outline">{service.trim()}</Badge>
)) : '-'}
{item.affected && item.affected.split(',').length > 2 && (
<Badge variant="outline">+{item.affected.split(',').length - 2}</Badge>
)}
</div>
</TableCell>
<TableCell onClick={() => handleViewMaintenance(item)}>
<Badge variant={getImpactBadgeVariant(item.field)}>
{item.field ? t(item.field.toLowerCase()) : '-'}
</Badge>
</TableCell>
<TableCell onClick={(e) => e.stopPropagation()}>
<MaintenanceActionsMenu
item={item}
onMaintenanceUpdated={onMaintenanceUpdated}
/>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
<MaintenanceDetailDialog
open={detailDialogOpen}
onOpenChange={setDetailDialogOpen}
maintenance={selectedMaintenance}
/>
</>
);
};
@@ -0,0 +1,150 @@
import React, { useEffect, useState } from 'react';
import { userService, User } from '@/services/userService';
import { useQuery } from '@tanstack/react-query';
import { MaintenanceItem } from '@/services/types/maintenance.types';
import { MaintenanceDetailHeader } from './MaintenanceDetailHeader';
import { MaintenanceDetailSections } from './MaintenanceDetailSections';
import { MaintenanceDetailFooter } from './MaintenanceDetailFooter';
import { alertConfigService } from '@/services/alertConfigService';
interface MaintenanceDetailContentProps {
maintenance: MaintenanceItem;
onClose: () => void;
}
export const MaintenanceDetailContent = ({
maintenance,
onClose
}: MaintenanceDetailContentProps) => {
const [assignedUsers, setAssignedUsers] = useState<User[]>([]);
const [maintenanceWithDetails, setMaintenanceWithDetails] = useState<MaintenanceItem>(maintenance);
// Fetch all users to resolve the assigned users and creator
const { data: users = [] } = useQuery({
queryKey: ['users'],
queryFn: async () => {
const usersList = await userService.getUsers();
return usersList || [];
},
enabled: true
});
// Fetch notification channels for notification channel display
const { data: notificationChannels = [] } = useQuery({
queryKey: ['notificationChannels'],
queryFn: async () => {
const channels = await alertConfigService.getAlertConfigurations();
return channels || [];
},
enabled: true
});
// Process user information when users data is available
useEffect(() => {
if (users.length > 0 && maintenance) {
// Create a copy of the maintenance object for modifications
const enhancedMaintenance = { ...maintenance };
// Process assigned users
if (maintenance.assigned_users) {
let userIds: string[] = [];
// Step 1: Extract user IDs from various formats
if (Array.isArray(maintenance.assigned_users)) {
userIds = maintenance.assigned_users;
} else if (typeof maintenance.assigned_users === 'string') {
// Try parsing the string to extract user IDs
try {
// Try parsing as JSON first
const parsedData = JSON.parse(maintenance.assigned_users);
if (Array.isArray(parsedData)) {
// Direct array format
userIds = parsedData;
} else if (typeof parsedData === 'string') {
// Nested JSON string
try {
const nestedData = JSON.parse(parsedData);
if (Array.isArray(nestedData)) {
userIds = nestedData;
}
} catch (e) {
// If nested parsing fails, treat as single ID
userIds = [parsedData];
}
} else {
// Unknown format, use as is
userIds = [String(parsedData)];
}
} catch (e) {
// If JSON parsing fails, try comma splitting
if (maintenance.assigned_users.includes(',')) {
userIds = maintenance.assigned_users.split(',').map(id => id.trim()).filter(Boolean);
} else {
// Single ID
userIds = [maintenance.assigned_users];
}
}
}
// Step 2: Clean up extracted IDs (remove quotes, brackets, etc.)
userIds = userIds.map(id => {
// Remove surrounding quotes if present
let cleanId = id.replace(/^["']|["']$/g, '');
// Remove any remaining JSON artifacts
cleanId = cleanId.replace(/[\[\]"'\\]/g, '');
return cleanId;
}).filter(Boolean);
// Step 3: Find matching users from the users array
if (userIds.length > 0) {
const matchedUsers = users.filter(user => userIds.includes(user.id));
setAssignedUsers(matchedUsers);
} else {
setAssignedUsers([]);
}
} else {
setAssignedUsers([]);
}
// Process created_by field - replace ID with full name if available
if (maintenance?.created_by) {
const creator = users.find(user => user.id === maintenance.created_by);
if (creator) {
enhancedMaintenance.created_by = creator.full_name || creator.username || maintenance.created_by;
}
}
// Process notification channel if needed
if (maintenance.notify_subscribers === 'yes') {
// Try notification_channel_id first, fall back to notification_id if needed
const channelId = maintenance.notification_channel_id || maintenance.notification_id;
if (channelId && notificationChannels.length > 0) {
const channel = notificationChannels.find(ch => ch.id === channelId);
if (channel) {
enhancedMaintenance.notification_channel_name = `${channel.notify_name} (${channel.notification_type})`;
} else {
enhancedMaintenance.notification_channel_name = `Channel ID: ${channelId}`;
}
}
}
setMaintenanceWithDetails(enhancedMaintenance);
}
}, [maintenance, users, notificationChannels]);
return (
<>
<MaintenanceDetailHeader maintenance={maintenanceWithDetails} />
<MaintenanceDetailSections
maintenance={maintenanceWithDetails}
assignedUsers={assignedUsers}
/>
<MaintenanceDetailFooter
maintenance={maintenanceWithDetails}
onClose={onClose}
/>
</>
);
};
@@ -0,0 +1,37 @@
import React from 'react';
import {
Dialog,
DialogContent
} from '@/components/ui/dialog';
import { MaintenanceItem } from '@/services/types/maintenance.types';
import { MaintenanceDetailContent } from './MaintenanceDetailContent';
interface MaintenanceDetailDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
maintenance: MaintenanceItem | null;
}
export const MaintenanceDetailDialog = ({
open,
onOpenChange,
maintenance
}: MaintenanceDetailDialogProps) => {
if (!maintenance) {
return null;
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent
className="dialog-content sm:max-w-[700px] max-h-[90vh] overflow-y-auto
print:max-w-none print:max-h-none print:overflow-visible
print:shadow-none print:m-0 print:p-0 print:border-none
print:absolute print:left-0 print:top-0 print:w-full print:h-auto"
>
<MaintenanceDetailContent maintenance={maintenance} onClose={() => onOpenChange(false)} />
</DialogContent>
</Dialog>
);
};
@@ -0,0 +1,27 @@
import React from 'react';
import { DialogFooter } from '@/components/ui/dialog';
import { MaintenanceItem } from '@/services/types/maintenance.types';
import {
PrintButton,
DownloadPdfButton,
CloseButton
} from './components';
interface MaintenanceDetailFooterProps {
maintenance: MaintenanceItem;
onClose: () => void;
}
export const MaintenanceDetailFooter = ({
maintenance,
onClose
}: MaintenanceDetailFooterProps) => {
return (
<DialogFooter className="gap-2 sm:gap-2 mt-6 pt-4 border-t print:hidden">
<PrintButton maintenance={maintenance} />
<DownloadPdfButton maintenance={maintenance} />
<CloseButton onClose={onClose} />
</DialogFooter>
);
};
@@ -0,0 +1,82 @@
import React from 'react';
import { useLanguage } from '@/contexts/LanguageContext';
import { CalendarClock } from 'lucide-react';
import {
DialogHeader,
DialogTitle
} from '@/components/ui/dialog';
import { Badge } from '@/components/ui/badge';
import { MaintenanceItem } from '@/services/types/maintenance.types';
interface MaintenanceDetailHeaderProps {
maintenance: MaintenanceItem;
}
export const MaintenanceDetailHeader = ({ maintenance }: MaintenanceDetailHeaderProps) => {
const { t } = useLanguage();
const getStatusColor = (status: string) => {
switch (status.toLowerCase()) {
case 'scheduled':
return 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-300';
case 'in_progress':
case 'in progress':
return 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-300';
case 'completed':
return 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-300';
case 'cancelled':
return 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-300';
default:
return 'bg-gray-100 text-gray-800 dark:bg-gray-800 dark:text-gray-300';
}
};
const getImpactColor = (impact: string) => {
switch (impact.toLowerCase()) {
case 'critical':
return 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-300';
case 'major':
return 'bg-orange-100 text-orange-800 dark:bg-orange-900 dark:text-orange-300';
case 'minor':
return 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-300';
case 'none':
return 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-300';
default:
return 'bg-gray-100 text-gray-800 dark:bg-gray-800 dark:text-gray-300';
}
};
const getPriorityColor = (priority: string) => {
switch (priority.toLowerCase()) {
case 'high':
return 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-300';
case 'medium':
return 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-300';
case 'low':
return 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-300';
default:
return 'bg-gray-100 text-gray-800 dark:bg-gray-800 dark:text-gray-300';
}
};
return (
<DialogHeader className="print:mb-6">
<div className="flex items-center gap-2 mb-1">
<CalendarClock className="h-5 w-5 text-blue-500 print:hidden" />
<DialogTitle className="text-xl">{maintenance.title}</DialogTitle>
</div>
<div className="flex flex-wrap gap-2 mt-2">
<Badge className={getStatusColor(maintenance.status)}>
{maintenance.status}
</Badge>
<Badge className={getImpactColor(maintenance.field)}>
{maintenance.field} {t('impact')}
</Badge>
<Badge className={getPriorityColor(maintenance.priority)}>
{maintenance.priority} {t('priority')}
</Badge>
</div>
</DialogHeader>
);
};
@@ -0,0 +1,198 @@
import React from 'react';
import { useLanguage } from '@/contexts/LanguageContext';
import { format } from 'date-fns';
import { Separator } from '@/components/ui/separator';
import { Badge } from '@/components/ui/badge';
import { MaintenanceItem } from '@/services/types/maintenance.types';
import { User } from '@/services/userService';
interface MaintenanceDetailSectionsProps {
maintenance: MaintenanceItem;
assignedUsers: User[];
}
export const MaintenanceDetailSections = ({
maintenance,
assignedUsers
}: MaintenanceDetailSectionsProps) => {
const { t } = useLanguage();
const formatDateTime = (dateString: string) => {
try {
return format(new Date(dateString), 'PPP p');
} catch (error) {
return dateString;
}
};
const calculateDuration = (start: string, end: string): string => {
try {
const startTime = new Date(start);
const endTime = new Date(end);
const durationMs = endTime.getTime() - startTime.getTime();
// Convert to hours and minutes
const hours = Math.floor(durationMs / (1000 * 60 * 60));
const minutes = Math.floor((durationMs % (1000 * 60 * 60)) / (1000 * 60));
if (hours === 0) {
return `${minutes}m`;
} else if (minutes === 0) {
return `${hours}h`;
} else {
return `${hours}h ${minutes}m`;
}
} catch (error) {
return "N/A";
}
};
const affectedServices = typeof maintenance.affected === 'string'
? maintenance.affected.split(',').map(item => item.trim())
: [];
return (
<div className="space-y-4 print:space-y-1 print-compact-spacing">
{/* Print Header - Only visible when printing */}
<div className="hidden print:block print-section">
<div className="header-print bg-blue-800 text-white p-4 rounded-t-md">
<div className="flex justify-between">
<div>
<h1 className="text-xl font-bold">Scheduled Maintenance Report</h1>
<p className="text-blue-100 mt-1 print-compact-text">{maintenance.title}</p>
</div>
<div className="text-right">
<p className="text-xs text-blue-100">Reference ID: {maintenance.id}</p>
<p className="text-xs text-blue-100">Generated on {format(new Date(), 'PPP')}</p>
</div>
</div>
</div>
</div>
{/* Reference ID - Only visible in UI */}
<div className="flex items-center justify-between text-sm print:hidden">
<span className="text-muted-foreground">{t('referenceID')}</span>
<span className="font-mono bg-muted px-2 py-1 rounded">{maintenance.id}</span>
</div>
{/* Description Section */}
<div className="print:mt-0 print-section">
<h4 className="text-sm font-medium text-muted-foreground mb-2 print:text-xs print:font-bold print:text-blue-800">{t('description')}</h4>
<div className="bg-muted/50 p-3 rounded-md text-sm whitespace-pre-wrap print:bg-transparent print:border print:border-gray-200 print:p-1 print:rounded print:text-xs print:text-black print-description">
{maintenance.description || t('noDescriptionProvided')}
</div>
</div>
<Separator className="print:hidden" />
{/* Time Information */}
<div className="print-section">
<h4 className="text-sm font-medium text-muted-foreground mb-2 print:text-xs print:font-bold print:text-blue-800">{t('timeInformation')}</h4>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 print:grid-cols-2 print:gap-1 print:bg-gray-50 print:border print:border-gray-200 print:p-1 print:rounded print-compact-text">
<div>
<h4 className="text-sm font-medium text-muted-foreground mb-1 print:text-xs print:text-blue-700 print:mb-0">{t('scheduledStart')}</h4>
<p className="text-sm print:text-xs print:text-black print-smaller-font">{formatDateTime(maintenance.start_time)}</p>
</div>
<div>
<h4 className="text-sm font-medium text-muted-foreground mb-1 print:text-xs print:text-blue-700 print:mb-0">{t('scheduledEnd')}</h4>
<p className="text-sm print:text-xs print:text-black print-smaller-font">{formatDateTime(maintenance.end_time)}</p>
</div>
<div>
<h4 className="text-sm font-medium text-muted-foreground mb-1 print:text-xs print:text-blue-700 print:mb-0">{t('duration')}</h4>
<p className="text-sm print:text-xs print:text-black print-smaller-font">{calculateDuration(maintenance.start_time, maintenance.end_time)}</p>
</div>
<div>
<h4 className="text-sm font-medium text-muted-foreground mb-1 print:text-xs print:text-blue-700 print:mb-0">{t('maintenanceType')}</h4>
<p className="text-sm print:text-xs print:text-black print-smaller-font capitalize">{maintenance.field}</p>
</div>
</div>
</div>
<Separator className="print:hidden" />
{/* Affected Services */}
<div className="print-section">
<h4 className="text-sm font-medium text-muted-foreground mb-2 print:text-xs print:font-bold print:text-blue-800">{t('affectedServices')}</h4>
<div className="print:border print:border-gray-200 print:p-1 print:rounded">
{affectedServices.length > 0 ? (
<div className="flex flex-wrap gap-2 print:gap-1">
{affectedServices.map((service, index) => (
<Badge key={index} variant="outline" className="badge-print print:bg-blue-50 print:text-blue-800 print:border print:border-blue-200">
{service}
</Badge>
))}
</div>
) : (
<span className="text-sm text-muted-foreground print:text-xs print:text-black">{t('noAffectedServices')}</span>
)}
</div>
</div>
{/* Assigned Users - Simplified for print */}
<div className="print-section">
<h4 className="text-sm font-medium text-muted-foreground mb-2 print:text-xs print:font-bold print:text-blue-800">{t('assignedPersonnel')}</h4>
<div className="border border-border rounded-md p-3 print:border print:border-gray-200 print:p-1 print:rounded print:bg-gray-50">
{assignedUsers && assignedUsers.length > 0 ? (
<div className="print:grid print:grid-cols-2 print:gap-1">
{assignedUsers.map((user) => (
<div key={user.id} className="flex items-center gap-2 p-2 bg-muted/30 rounded-md print:bg-transparent print:p-0 print:text-xs">
<div className="w-5 h-5 rounded-full bg-primary/10 flex items-center justify-center text-xs font-medium print:bg-blue-100 print:text-blue-800 print:w-4 print:h-4">
{user && (user.full_name?.[0] || user.username?.[0] || 'U').toUpperCase()}
</div>
<span className="print:text-xs print:font-medium print:text-black print-smaller-font">{user.full_name || user.username}</span>
</div>
))}
</div>
) : (
<span className="text-sm text-muted-foreground print:text-xs print:text-black">{t('noAssignedUsers')}</span>
)}
</div>
</div>
<Separator className="print:hidden" />
{/* Metadata - Condensed for print */}
<div className="print-section">
<h4 className="text-sm font-medium text-muted-foreground mb-2 print:text-xs print:font-bold print:text-blue-800">{t('additionalInformation')}</h4>
<div className="grid grid-cols-1 md:grid-cols-2 gap-2 text-sm print:grid-cols-2 print:gap-1 print:bg-gray-50 print:border print:border-gray-200 print:p-1 print:rounded print-compact-text">
<div>
<h4 className="font-medium text-muted-foreground mb-0 print:text-xs print:text-blue-700">{t('createdBy')}</h4>
<p className="print:text-xs print:text-black print-smaller-font">{maintenance.created_by || t('notSpecified')}</p>
</div>
<div>
<h4 className="font-medium text-muted-foreground mb-0 print:text-xs print:text-blue-700">{t('notificationChannel')}</h4>
<p className="print:text-xs print:text-black print-smaller-font">{maintenance.notification_channel_name || t('notSpecified')}</p>
</div>
<div>
<h4 className="font-medium text-muted-foreground mb-0 print:text-xs print:text-blue-700">{t('created')}</h4>
<p className="print:text-xs print:text-black print-smaller-font">{formatDateTime(maintenance.created)}</p>
</div>
<div>
<h4 className="font-medium text-muted-foreground mb-0 print:text-xs print:text-blue-700">{t('lastUpdated')}</h4>
<p className="print:text-xs print:text-black print-smaller-font">{formatDateTime(maintenance.updated)}</p>
</div>
</div>
</div>
{/* Notification Settings - Simplified for print */}
<div className="text-sm bg-muted/50 p-3 rounded-md print:bg-blue-50 print:border print:border-blue-200 print:p-1 print:rounded print-section print-compact-text">
<h4 className="font-medium mb-1 print:text-xs print:font-bold print:text-blue-800 print:mb-0">{t('notificationSettings')}</h4>
<p className="print:text-xs print:text-black print-smaller-font">
{maintenance.notify_subscribers === 'yes'
? t('subscribersWillBeNotified')
: t('noNotifications')}
</p>
</div>
{/* Print Footer - Only visible when printing */}
<div className="hidden print:block footer-print border-t border-gray-200 text-xs text-gray-500">
<div className="flex justify-between">
<span>Maintenance ID: {maintenance.id}</span>
<span>Printed on {format(new Date(), 'PPP')}</span>
</div>
<p className="mt-1 text-center">This document is confidential and intended for internal use only.</p>
</div>
</div>
);
};
@@ -0,0 +1,18 @@
import React from 'react';
import { useLanguage } from '@/contexts/LanguageContext';
import { Button } from '@/components/ui/button';
interface CloseButtonProps {
onClose: () => void;
}
export const CloseButton: React.FC<CloseButtonProps> = ({ onClose }) => {
const { t } = useLanguage();
return (
<Button variant="secondary" onClick={onClose}>
{t('close')}
</Button>
);
};
@@ -0,0 +1,31 @@
import React from 'react';
import { Button } from '@/components/ui/button';
import { Download } from 'lucide-react';
import { useLanguage } from '@/contexts/LanguageContext';
import { useDownloadMaintenancePdf } from '../hooks';
import { MaintenanceItem } from '@/services/maintenance';
interface DownloadPdfButtonProps {
maintenance: MaintenanceItem;
className?: string;
}
export const DownloadPdfButton: React.FC<DownloadPdfButtonProps> = ({
maintenance,
className
}) => {
const { t } = useLanguage();
const { handleDownloadPDF } = useDownloadMaintenancePdf();
return (
<Button
className={`flex items-center gap-2 ${className || ''}`}
onClick={() => handleDownloadPDF(maintenance)}
variant="outline"
>
<Download className="h-4 w-4" />
{t('downloadPdf')}
</Button>
);
};
@@ -0,0 +1,27 @@
import React from 'react';
import { useLanguage } from '@/contexts/LanguageContext';
import { Printer } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { usePrintMaintenance } from '../hooks/usePrintMaintenance';
import { MaintenanceItem } from '@/services/types/maintenance.types';
interface PrintButtonProps {
maintenance: MaintenanceItem;
}
export const PrintButton: React.FC<PrintButtonProps> = ({ maintenance }) => {
const { t } = useLanguage();
const { handlePrint } = usePrintMaintenance();
return (
<Button
className="flex items-center gap-2"
onClick={() => handlePrint(maintenance)}
variant="default"
>
<Printer className="h-4 w-4" />
{t('print')}
</Button>
);
};
@@ -0,0 +1,4 @@
export * from './DownloadPdfButton';
export * from './PrintButton';
export * from './CloseButton';
@@ -0,0 +1,3 @@
export * from './useDownloadMaintenancePdf';
export * from './usePrintMaintenance';
@@ -0,0 +1,47 @@
import { useToast } from '@/hooks/use-toast';
import { useLanguage } from '@/contexts/LanguageContext';
import { MaintenanceItem, generateMaintenancePDF } from '@/services/maintenance';
export const useDownloadMaintenancePdf = () => {
const { toast } = useToast();
const { t } = useLanguage();
const handleDownloadPDF = async (maintenance: MaintenanceItem) => {
try {
// Show loading toast
toast({
title: t('generating'),
description: t('preparingPdf'),
});
// Generate and download the PDF
const filename = await generateMaintenancePDF(maintenance);
// Show success toast after successful generation
toast({
title: t('success'),
description: t('pdfDownloaded'),
});
return filename;
} catch (error) {
console.error("Error generating PDF:", error);
// Provide more detailed error message if available
const errorMessage = error instanceof Error
? error.message
: t('pdfGenerationFailed');
toast({
title: t('error'),
description: errorMessage,
variant: 'destructive',
});
throw error;
}
};
return { handleDownloadPDF };
};
@@ -0,0 +1,173 @@
import { useLanguage } from '@/contexts/LanguageContext';
import { useToast } from '@/hooks/use-toast';
import { MaintenanceItem } from '@/services/types/maintenance.types';
export const usePrintMaintenance = () => {
const { t } = useLanguage();
const { toast } = useToast();
const handlePrint = (maintenance: MaintenanceItem) => {
try {
// Add print-specific stylesheet temporarily
const style = document.createElement('style');
style.id = 'print-style';
style.textContent = `
@page {
size: A4;
margin: 10mm;
}
@media print {
body * {
visibility: hidden;
}
.dialog-content, .dialog-content * {
visibility: visible;
}
.dialog-content {
position: absolute !important;
left: 0;
top: 0;
width: 100%;
height: auto;
padding: 15mm !important;
margin: 0 !important;
background-color: white !important;
box-shadow: none;
overflow: visible !important;
display: block !important;
transform: none !important;
}
/* Remove any black backgrounds */
html, body {
background-color: white !important;
color: black !important;
}
/* Optimize spacing for single page */
.print-section {
margin-bottom: 2mm !important;
page-break-inside: avoid !important;
}
/* Reduce vertical spacing */
h4 {
margin-bottom: 1mm !important;
margin-top: 1mm !important;
color: #1e40af !important; /* blue-800 */
font-weight: bold !important;
}
.print-compact-text {
font-size: 9pt !important;
line-height: 1.2 !important;
}
.print-compact-spacing > * {
margin-top: 1mm !important;
margin-bottom: 1mm !important;
}
.print-grid {
display: grid !important;
grid-template-columns: 1fr 1fr !important;
gap: 1mm !important;
}
.badge-print {
background-color: #dbeafe !important; /* blue-100 */
color: #1e40af !important; /* blue-800 */
border: 1px solid #93c5fd !important; /* blue-300 */
padding: 0.5mm 1mm !important;
margin: 0.5mm !important;
display: inline-block !important;
font-size: 8pt !important;
}
/* More compact spacing */
.print-compact-margin {
margin: 1mm 0 !important;
}
.print-smaller-font {
font-size: 8pt !important;
}
.header-print {
background-color: #1e40af !important; /* blue-800 */
color: #ffffff !important;
padding: 2mm 3mm !important;
margin-bottom: 2mm !important;
}
/* More compact header */
.header-print h1 {
font-size: 12pt !important;
margin-bottom: 0 !important;
}
.header-print p {
font-size: 9pt !important;
margin-top: 1mm !important;
}
/* Footer stays at bottom */
.footer-print {
font-size: 7pt !important;
color: #6b7280 !important; /* gray-500 */
border-top: 1px solid #e5e7eb !important; /* gray-200 */
position: fixed !important;
bottom: 10mm !important;
left: 15mm !important;
right: 15mm !important;
padding-top: 2mm !important;
background-color: white !important;
}
/* Hide any unnecessary elements */
.print-hide {
display: none !important;
}
/* Optimize description text */
.print-description {
max-height: 100px !important;
overflow: hidden !important;
text-overflow: ellipsis !important;
}
}
`;
document.head.appendChild(style);
// Wait a bit to ensure styles are applied
setTimeout(() => {
window.print();
// Remove the style after printing dialog closes
setTimeout(() => {
const printStyle = document.getElementById('print-style');
if (printStyle) {
printStyle.remove();
}
}, 1000);
}, 100);
toast({
title: t('success'),
description: t('printJobStarted'),
});
} catch (error) {
console.error("Error printing:", error);
toast({
title: t('error'),
description: t('printingFailed'),
variant: 'destructive',
});
}
};
return { handlePrint };
};
@@ -0,0 +1,8 @@
export * from './MaintenanceDetailDialog';
export * from './MaintenanceDetailContent';
export * from './MaintenanceDetailHeader';
export * from './MaintenanceDetailSections';
export * from './MaintenanceDetailFooter';
export * from './components';
export * from './hooks';
@@ -0,0 +1,97 @@
import React from 'react';
import { useLanguage } from '@/contexts/LanguageContext';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter
} from '@/components/ui/dialog';
import { Form } from '@/components/ui/form';
import { Button } from '@/components/ui/button';
import { MaintenanceItem } from '@/services/types/maintenance.types';
import { useMaintenanceEditForm } from '../hooks/useMaintenanceEditForm';
import {
MaintenanceBasicFields,
MaintenanceTimeFields,
MaintenanceAffectedFields,
MaintenanceConfigFields,
MaintenanceNotificationSettingsField
} from '../form';
interface EditMaintenanceDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
maintenance: MaintenanceItem;
onMaintenanceUpdated: () => void;
}
export const EditMaintenanceDialog = ({
open,
onOpenChange,
maintenance,
onMaintenanceUpdated
}: EditMaintenanceDialogProps) => {
const { t } = useLanguage();
const handleSuccess = () => {
console.log("EditMaintenanceDialog: maintenance updated successfully");
onMaintenanceUpdated();
};
const handleClose = () => {
console.log("EditMaintenanceDialog: closing dialog");
onOpenChange(false);
};
const { form, onSubmit } = useMaintenanceEditForm(maintenance, handleSuccess, handleClose);
// Log the form state for debugging
React.useEffect(() => {
const subscription = form.watch((value) => {
console.log("Form values in edit dialog:", value);
});
return () => subscription.unsubscribe();
}, [form]);
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[600px] max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>{t('editMaintenanceWindow')}</DialogTitle>
<DialogDescription>
{t('editMaintenanceDesc')}
</DialogDescription>
</DialogHeader>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<MaintenanceBasicFields />
<MaintenanceTimeFields />
<MaintenanceAffectedFields />
<MaintenanceConfigFields />
<MaintenanceNotificationSettingsField />
<DialogFooter className="pt-4">
<Button
type="button"
variant="outline"
onClick={() => onOpenChange(false)}
>
{t('cancel')}
</Button>
<Button
type="submit"
disabled={form.formState.isSubmitting}
>
{form.formState.isSubmitting ? t('updating') : t('updateMaintenance')}
</Button>
</DialogFooter>
</form>
</Form>
</DialogContent>
</Dialog>
);
};
@@ -0,0 +1,2 @@
export * from './EditMaintenanceDialog';
@@ -0,0 +1,35 @@
import React from 'react';
import { useFormContext } from 'react-hook-form';
import { useLanguage } from '@/contexts/LanguageContext';
import { MaintenanceFormValues } from '../hooks/useMaintenanceForm';
import {
FormField,
FormItem,
FormLabel,
FormControl,
FormMessage,
} from '@/components/ui/form';
import { Input } from '@/components/ui/input';
export const MaintenanceAffectedFields: React.FC = () => {
const { t } = useLanguage();
const { control } = useFormContext<MaintenanceFormValues>();
return (
<FormField
control={control}
name="affected"
render={({ field }) => (
<FormItem>
<FormLabel>{t('affectedServices')}</FormLabel>
<FormControl>
<Input placeholder={t('enterAffectedServices')} {...field} />
</FormControl>
<FormMessage />
<p className="text-sm text-muted-foreground">{t('separateServicesWithComma')}</p>
</FormItem>
)}
/>
);
};
@@ -0,0 +1,55 @@
import React from 'react';
import { useFormContext } from 'react-hook-form';
import { useLanguage } from '@/contexts/LanguageContext';
import { MaintenanceFormValues } from '../hooks/useMaintenanceForm';
import {
FormField,
FormItem,
FormLabel,
FormControl,
FormMessage,
} from '@/components/ui/form';
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
export const MaintenanceBasicFields: React.FC = () => {
const { t } = useLanguage();
const { control } = useFormContext<MaintenanceFormValues>();
return (
<>
<FormField
control={control}
name="title"
render={({ field }) => (
<FormItem>
<FormLabel>{t('title')}</FormLabel>
<FormControl>
<Input placeholder={t('enterTitle')} {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={control}
name="description"
render={({ field }) => (
<FormItem>
<FormLabel>{t('description')}</FormLabel>
<FormControl>
<Textarea
placeholder={t('enterDescription')}
className="resize-none"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</>
);
};
@@ -0,0 +1,32 @@
import React from 'react';
import { useLanguage } from '@/contexts/LanguageContext';
import {
PriorityField,
StatusField,
ImpactLevelField,
AssignedUsersField
} from './config';
export const MaintenanceConfigFields = () => {
const { t } = useLanguage();
return (
<div className="space-y-6">
<div className="text-sm font-medium">{t('configurationSettings')}</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<PriorityField />
<StatusField />
</div>
<div className="grid grid-cols-1 gap-4">
<ImpactLevelField />
</div>
<div className="grid grid-cols-1 gap-6 mt-4">
<AssignedUsersField />
</div>
</div>
);
};
@@ -0,0 +1,170 @@
import React, { useEffect, useState } from 'react';
import { useFormContext } from 'react-hook-form';
import { useLanguage } from '@/contexts/LanguageContext';
import { alertConfigService, AlertConfiguration } from '@/services/alertConfigService';
import { MaintenanceFormValues } from '../hooks/useMaintenanceForm';
import { FormField, FormItem, FormLabel, FormControl, FormDescription } from '@/components/ui/form';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Switch } from '@/components/ui/switch';
import { useToast } from '@/hooks/use-toast';
import { Skeleton } from '@/components/ui/skeleton';
import { Bell, BellOff } from 'lucide-react';
export const MaintenanceNotificationSettingsField = () => {
const { t } = useLanguage();
const { toast } = useToast();
const [notificationChannels, setNotificationChannels] = useState<AlertConfiguration[]>([]);
const [isLoading, setIsLoading] = useState(true);
const { control, watch, setValue, getValues } = useFormContext<MaintenanceFormValues>();
// Watch the notification toggle state
const notifySubscribers = watch('notify_subscribers');
const notificationChannelId = watch('notification_channel_id');
useEffect(() => {
const fetchNotificationChannels = async () => {
try {
setIsLoading(true);
const channels = await alertConfigService.getAlertConfigurations();
console.log("Fetched notification channels for form:", channels);
// Only show enabled channels
const enabledChannels = channels.filter(channel => channel.enabled);
setNotificationChannels(enabledChannels);
// If we have notification channels and notification is enabled but no channel is selected,
// select the first one by default
const currentChannel = getValues('notification_channel_id');
const shouldNotify = getValues('notify_subscribers');
console.log("Current notification values:", {
currentChannel,
shouldNotify,
availableChannels: enabledChannels.length
});
if (shouldNotify && (!currentChannel || currentChannel === 'none') && enabledChannels.length > 0) {
console.log("Setting default notification channel:", enabledChannels[0].id);
setValue('notification_channel_id', enabledChannels[0].id);
}
} catch (error) {
console.error('Error fetching notification channels:', error);
toast({
title: t('error'),
description: t('errorFetchingNotificationChannels'),
variant: 'destructive',
});
} finally {
setIsLoading(false);
}
};
fetchNotificationChannels();
}, [t, toast, setValue, getValues]);
// Log value changes for debugging
useEffect(() => {
console.log("Current notification settings:", {
channel_id: getValues('notification_channel_id'),
notify: notifySubscribers
});
}, [notifySubscribers, notificationChannelId, getValues]);
return (
<div className="space-y-6">
<h3 className="font-medium text-lg">{t('notificationSettings')}</h3>
<FormField
control={control}
name="notify_subscribers"
render={({ field }) => (
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-4">
<div className="space-y-0.5">
<div className="flex items-center gap-2">
{field.value ? <Bell className="w-4 h-4" /> : <BellOff className="w-4 h-4" />}
<FormLabel className="text-base mb-0">
{field.value ? t('notifySubscribers') : t('mutedNotifications')}
</FormLabel>
</div>
<FormDescription>
{field.value
? t('notifySubscribersDesc')
: t('notificationsAreMuted')}
</FormDescription>
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={(checked) => {
field.onChange(checked);
console.log("Notification toggle changed to:", checked);
// If notifications are disabled, also clear the notification channel
if (!checked) {
setValue('notification_channel_id', '');
} else if (notificationChannels.length > 0) {
// If enabled and channels available, select the first one by default
setValue('notification_channel_id', notificationChannels[0].id);
}
}}
/>
</FormControl>
</FormItem>
)}
/>
<FormField
control={control}
name="notification_channel_id"
render={({ field }) => {
// Make sure to handle both empty string and "none" as special cases
const displayValue = field.value || "";
console.log("Rendering notification channel field with value:", {
fieldValue: field.value,
displayValue
});
return (
<FormItem>
<FormLabel>{t('notificationChannel')}</FormLabel>
<FormControl>
{isLoading ? (
<Skeleton className="h-10 w-full" />
) : (
<Select
value={displayValue}
onValueChange={(value) => {
console.log("Setting notification channel to:", value);
field.onChange(value === "none" ? "" : value);
}}
disabled={!notifySubscribers}
>
<SelectTrigger className={!notifySubscribers ? 'opacity-50' : ''}>
<SelectValue placeholder={t('selectNotificationChannel')} />
</SelectTrigger>
<SelectContent>
<SelectItem value="none">
{t('none')}
</SelectItem>
{notificationChannels.map((channel) => (
<SelectItem key={channel.id} value={channel.id}>
{channel.notify_name} ({channel.notification_type})
</SelectItem>
))}
</SelectContent>
</Select>
)}
</FormControl>
<FormDescription>
{notifySubscribers
? t('selectChannelForNotifications')
: t('enableNotificationsFirst')}
</FormDescription>
</FormItem>
);
}}
/>
</div>
);
};
@@ -0,0 +1,195 @@
import React from 'react';
import { useFormContext } from 'react-hook-form';
import { format } from 'date-fns';
import { CalendarIcon } from 'lucide-react';
import { useLanguage } from '@/contexts/LanguageContext';
import { MaintenanceFormValues } from '../hooks/useMaintenanceForm';
import { cn } from '@/lib/utils';
import {
FormField,
FormItem,
FormLabel,
FormControl,
FormMessage,
} from '@/components/ui/form';
import { Button } from '@/components/ui/button';
import { Calendar } from '@/components/ui/calendar';
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover';
import { Input } from '@/components/ui/input';
export const MaintenanceTimeFields: React.FC = () => {
const { t } = useLanguage();
const { control } = useFormContext<MaintenanceFormValues>();
// Helper function to safely format dates
const safeFormat = (date: Date | null | undefined, formatStr: string): string => {
if (!date || !(date instanceof Date) || isNaN(date.getTime())) {
return '';
}
return format(date, formatStr);
};
return (
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<FormField
control={control}
name="start_time"
render={({ field }) => (
<FormItem className="flex flex-col">
<FormLabel>{t('startTime')}</FormLabel>
<Popover>
<PopoverTrigger asChild>
<FormControl>
<Button
variant="outline"
className={cn(
"w-full pl-3 text-left font-normal",
!field.value && "text-muted-foreground"
)}
>
{field.value && field.value instanceof Date && !isNaN(field.value.getTime()) ? (
safeFormat(field.value, "PPP HH:mm")
) : (
<span>{t('selectDate')}</span>
)}
<CalendarIcon className="ml-auto h-4 w-4 opacity-50" />
</Button>
</FormControl>
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align="start">
<Calendar
mode="single"
selected={field.value instanceof Date && !isNaN(field.value.getTime()) ? field.value : undefined}
onSelect={(date) => {
if (date) {
// If existing value is a valid date, preserve the time part
if (field.value instanceof Date && !isNaN(field.value.getTime())) {
const newDate = new Date(date);
newDate.setHours(field.value.getHours(), field.value.getMinutes());
field.onChange(newDate);
} else {
// Set default time to current time if no valid time exists
const now = new Date();
date.setHours(now.getHours(), now.getMinutes());
field.onChange(date);
}
} else {
field.onChange(undefined);
}
}}
initialFocus
className={cn("p-3 pointer-events-auto")}
/>
<div className="p-3 border-t border-border">
<Input
type="time"
onChange={(e) => {
const timeValue = e.target.value;
if (!timeValue) return;
const [hours, minutes] = timeValue.split(':').map(Number);
const date = new Date(field.value || new Date());
// Ensure we have a valid date before setting hours/minutes
if (!isNaN(date.getTime())) {
date.setHours(hours, minutes);
field.onChange(date);
}
}}
value={field.value instanceof Date && !isNaN(field.value.getTime())
? safeFormat(field.value, "HH:mm")
: ""}
/>
</div>
</PopoverContent>
</Popover>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={control}
name="end_time"
render={({ field }) => (
<FormItem className="flex flex-col">
<FormLabel>{t('endTime')}</FormLabel>
<Popover>
<PopoverTrigger asChild>
<FormControl>
<Button
variant="outline"
className={cn(
"w-full pl-3 text-left font-normal",
!field.value && "text-muted-foreground"
)}
>
{field.value && field.value instanceof Date && !isNaN(field.value.getTime()) ? (
safeFormat(field.value, "PPP HH:mm")
) : (
<span>{t('selectDate')}</span>
)}
<CalendarIcon className="ml-auto h-4 w-4 opacity-50" />
</Button>
</FormControl>
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align="start">
<Calendar
mode="single"
selected={field.value instanceof Date && !isNaN(field.value.getTime()) ? field.value : undefined}
onSelect={(date) => {
if (date) {
// If existing value is a valid date, preserve the time part
if (field.value instanceof Date && !isNaN(field.value.getTime())) {
const newDate = new Date(date);
newDate.setHours(field.value.getHours(), field.value.getMinutes());
field.onChange(newDate);
} else {
// Set default time to current time if no valid time exists
const now = new Date();
// Default to current time + 1 hour for end time
date.setHours(now.getHours() + 1, now.getMinutes());
field.onChange(date);
}
} else {
field.onChange(undefined);
}
}}
initialFocus
className={cn("p-3 pointer-events-auto")}
/>
<div className="p-3 border-t border-border">
<Input
type="time"
onChange={(e) => {
const timeValue = e.target.value;
if (!timeValue) return;
const [hours, minutes] = timeValue.split(':').map(Number);
const date = new Date(field.value || new Date());
// Ensure we have a valid date before setting hours/minutes
if (!isNaN(date.getTime())) {
date.setHours(hours, minutes);
field.onChange(date);
}
}}
value={field.value instanceof Date && !isNaN(field.value.getTime())
? safeFormat(field.value, "HH:mm")
: ""}
/>
</div>
</PopoverContent>
</Popover>
<FormMessage />
</FormItem>
)}
/>
</div>
);
};
@@ -0,0 +1,185 @@
import React, { useEffect } from 'react';
import { useLanguage } from '@/contexts/LanguageContext';
import { useFormContext } from 'react-hook-form';
import {
FormField,
FormItem,
FormLabel,
FormControl,
FormMessage,
FormDescription
} from '@/components/ui/form';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { useQuery } from '@tanstack/react-query';
import { userService } from '@/services/userService';
import { Badge } from '@/components/ui/badge';
import { X, Users } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
export const AssignedUsersField = () => {
const { t } = useLanguage();
const form = useFormContext();
// Ensure assigned_users is initialized as an array
useEffect(() => {
const currentValue = form.getValues('assigned_users');
console.log("Initial assigned_users value:", currentValue);
// Initialize as empty array if no value or invalid value
if (!currentValue || !Array.isArray(currentValue)) {
console.log("Initializing assigned_users as empty array");
form.setValue('assigned_users', [], { shouldValidate: false, shouldDirty: true });
}
}, [form]);
console.log("Current form values:", form.getValues());
console.log("Current assigned_users:", form.getValues('assigned_users'));
// Fetch users for the assignment dropdown
const { data: users = [], isLoading } = useQuery({
queryKey: ['users'],
queryFn: async () => {
try {
const usersList = await userService.getUsers();
console.log("Fetched users for assignment:", usersList);
return Array.isArray(usersList) ? usersList : [];
} catch (error) {
console.error("Failed to fetch users:", error);
return [];
}
},
staleTime: 300000 // Cache for 5 minutes to reduce API calls
});
// Get the currently selected assigned users from the form
const selectedUserIds = Array.isArray(form.watch('assigned_users'))
? form.watch('assigned_users')
: [];
console.log("Selected user IDs:", selectedUserIds);
// Function to add a user
const addUser = (userId: string) => {
const currentValues = Array.isArray(form.getValues('assigned_users'))
? [...form.getValues('assigned_users')]
: [];
if (!currentValues.includes(userId)) {
console.log("Adding user:", userId);
form.setValue('assigned_users', [...currentValues, userId], { shouldValidate: true, shouldDirty: true });
}
};
// Function to remove a user
const removeUser = (userId: string) => {
const currentValues = Array.isArray(form.getValues('assigned_users'))
? [...form.getValues('assigned_users')]
: [];
console.log("Removing user:", userId);
form.setValue(
'assigned_users',
currentValues.filter(id => id !== userId),
{ shouldValidate: true, shouldDirty: true }
);
};
// Get selected users data
const selectedUsers = users.filter(user => selectedUserIds.includes(user.id));
console.log("Matched selected users:", selectedUsers);
// Function to get user initials from name
const getUserInitials = (user: any): string => {
if (user.full_name) {
const nameParts = user.full_name.split(' ');
if (nameParts.length > 1) {
return `${nameParts[0][0]}${nameParts[1][0]}`.toUpperCase();
}
return user.full_name.substring(0, 2).toUpperCase();
}
return user.username.substring(0, 2).toUpperCase();
};
return (
<FormField
control={form.control}
name="assigned_users"
render={({ field }) => (
<FormItem className="flex flex-col">
<FormLabel className="flex items-center gap-1">
<Users className="h-4 w-4" /> {t('assignedPersonnel')}
</FormLabel>
<div className="space-y-3">
<Select onValueChange={addUser}>
<FormControl>
<SelectTrigger className="w-full">
<SelectValue placeholder={t('selectAssignedUsers')} />
</SelectTrigger>
</FormControl>
<SelectContent>
{isLoading ? (
<div className="py-2 px-2 text-sm">{t('loading')}</div>
) : users.length === 0 ? (
<div className="py-2 px-2 text-sm">{t('noUsersFound')}</div>
) : (
users.map((user) => (
<SelectItem
key={user.id}
value={user.id}
disabled={selectedUserIds.includes(user.id)}
>
{user.full_name || user.username}
</SelectItem>
))
)}
</SelectContent>
</Select>
{selectedUsers.length > 0 ? (
<div className="flex flex-wrap gap-2 mt-2 border p-2 rounded-md bg-muted/50">
{selectedUsers.map((user) => (
<Badge key={user.id} variant="secondary" className="flex items-center gap-1 py-1 px-2">
<Avatar className="h-5 w-5 mr-1">
<AvatarImage src={user.avatar} alt={user.full_name || user.username} />
<AvatarFallback className="text-[10px]">
{getUserInitials(user)}
</AvatarFallback>
</Avatar>
<span>{user.full_name || user.username}</span>
<Button
variant="ghost"
size="icon"
className="h-4 w-4 p-0 ml-1 hover:bg-transparent hover:opacity-70"
onClick={() => removeUser(user.id)}
type="button"
>
<X className="h-3 w-3" />
<span className="sr-only">{t('remove')}</span>
</Button>
</Badge>
))}
</div>
) : (
<div className="text-sm text-muted-foreground italic p-2">
{t('noAssignedUsers')}
</div>
)}
</div>
<FormDescription>
{t('assignedPersonnelDescription')}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
);
};
@@ -0,0 +1,56 @@
import React from 'react';
import { useLanguage } from '@/contexts/LanguageContext';
import { useFormContext } from 'react-hook-form';
import {
FormField,
FormItem,
FormLabel,
FormControl,
FormMessage,
FormDescription
} from '@/components/ui/form';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue
} from '@/components/ui/select';
export const ImpactLevelField = () => {
const { t } = useLanguage();
const form = useFormContext();
return (
<FormField
control={form.control}
name="field"
render={({ field }) => (
<FormItem>
<FormLabel>{t('impactLevel')}</FormLabel>
<Select
onValueChange={field.onChange}
defaultValue={field.value}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder={t('selectImpactLevel')} />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="none">{t('none')}</SelectItem>
<SelectItem value="minor">{t('minor')}</SelectItem>
<SelectItem value="major">{t('major')}</SelectItem>
<SelectItem value="critical">{t('critical')}</SelectItem>
</SelectContent>
</Select>
<FormDescription>
{t('impactLevelDescription')}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
);
};
@@ -0,0 +1,56 @@
import React from 'react';
import { useLanguage } from '@/contexts/LanguageContext';
import { useFormContext } from 'react-hook-form';
import {
FormField,
FormItem,
FormLabel,
FormControl,
FormMessage,
FormDescription
} from '@/components/ui/form';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue
} from '@/components/ui/select';
export const PriorityField = () => {
const { t } = useLanguage();
const form = useFormContext();
return (
<FormField
control={form.control}
name="priority"
render={({ field }) => (
<FormItem>
<FormLabel>{t('priority')}</FormLabel>
<Select
onValueChange={field.onChange}
defaultValue={field.value}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder={t('selectPriority')} />
</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>
<FormDescription>
{t('priorityDescription')}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
);
};
@@ -0,0 +1,56 @@
import React from 'react';
import { useLanguage } from '@/contexts/LanguageContext';
import { useFormContext } from 'react-hook-form';
import {
FormField,
FormItem,
FormLabel,
FormControl,
FormMessage,
FormDescription
} from '@/components/ui/form';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue
} from '@/components/ui/select';
export const StatusField = () => {
const { t } = useLanguage();
const form = useFormContext();
return (
<FormField
control={form.control}
name="status"
render={({ field }) => (
<FormItem>
<FormLabel>{t('status')}</FormLabel>
<Select
onValueChange={field.onChange}
defaultValue={field.value}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder={t('selectStatus')} />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="scheduled">{t('scheduled')}</SelectItem>
<SelectItem value="in_progress">{t('inProgress')}</SelectItem>
<SelectItem value="completed">{t('completed')}</SelectItem>
<SelectItem value="cancelled">{t('cancelled')}</SelectItem>
</SelectContent>
</Select>
<FormDescription>
{t('statusDescription')}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
);
};
@@ -0,0 +1,5 @@
export * from './PriorityField';
export * from './StatusField';
export * from './ImpactLevelField';
export * from './AssignedUsersField';
@@ -0,0 +1,7 @@
export * from './MaintenanceBasicFields';
export * from './MaintenanceTimeFields';
export * from './MaintenanceAffectedFields';
export * from './MaintenanceConfigFields';
export * from './MaintenanceNotificationSettingsField';
export * from './config';
@@ -0,0 +1,201 @@
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { useToast } from '@/hooks/use-toast';
import { maintenanceService, MaintenanceItem } from '@/services/maintenance';
import { useLanguage } from '@/contexts/LanguageContext';
import { maintenanceFormSchema, MaintenanceFormValues } from './useMaintenanceForm';
export const useMaintenanceEditForm = (
maintenance: MaintenanceItem,
onSuccess: () => void,
onClose: () => void
) => {
const { t } = useLanguage();
const { toast } = useToast();
// Parse assigned_users to ensure it's an array
const getAssignedUsers = (): string[] => {
console.log("Original assigned_users:", maintenance.assigned_users);
if (!maintenance.assigned_users) {
console.log("No assigned users, returning empty array");
return [];
}
if (Array.isArray(maintenance.assigned_users)) {
console.log("Assigned users is already an array:", maintenance.assigned_users);
return maintenance.assigned_users;
}
if (typeof maintenance.assigned_users === 'string') {
// Try to parse as JSON first (for array stored as string)
try {
const parsedData = JSON.parse(maintenance.assigned_users);
if (Array.isArray(parsedData)) {
console.log("Parsed assigned_users from JSON string:", parsedData);
return parsedData;
} else if (typeof parsedData === 'string') {
// Handle nested JSON strings
try {
const nestedData = JSON.parse(parsedData);
if (Array.isArray(nestedData)) {
console.log("Parsed assigned_users from nested JSON string:", nestedData);
return nestedData;
}
} catch (e) {
// Not a nested JSON string, continue
}
// Single ID in a JSON string
console.log("Using parsed string as single ID:", [parsedData]);
return [parsedData];
} else {
// Some other type, convert to string and use as single ID
return [String(parsedData)];
}
} catch (e) {
// Not JSON, try comma splitting
if (maintenance.assigned_users.includes(',')) {
const userArray = maintenance.assigned_users.split(',').map(id => id.trim()).filter(Boolean);
console.log("Converted comma-separated string to array:", userArray);
return userArray;
} else {
// Single ID as string
console.log("Using string as single ID:", [maintenance.assigned_users]);
return [maintenance.assigned_users];
}
}
}
console.warn("Unable to parse assigned_users, returning empty array");
return [];
};
// Clean up user IDs by removing quotes, brackets, etc.
const cleanUserIds = (userIds: string[]): string[] => {
return userIds.map(id => {
// Remove surrounding quotes if present
let cleanId = id.replace(/^["']|["']$/g, '');
// Remove any remaining JSON artifacts
cleanId = cleanId.replace(/[\[\]"'\\]/g, '');
return cleanId;
}).filter(Boolean);
};
// Get notification channel ID (from either notification_channel_id or notification_id)
const getNotificationChannelId = (): string => {
console.log("Getting notification channel ID from:", {
notification_channel_id: maintenance.notification_channel_id,
notification_id: maintenance.notification_id,
notify_subscribers: maintenance.notify_subscribers
});
// Try notification_channel_id first
if (maintenance.notification_channel_id) {
console.log("Using notification_channel_id:", maintenance.notification_channel_id);
return maintenance.notification_channel_id;
}
// Fall back to notification_id if notifications are enabled
else if (maintenance.notification_id && maintenance.notify_subscribers === 'yes') {
console.log("Using notification_id:", maintenance.notification_id);
return maintenance.notification_id;
}
console.log("No notification channel ID found");
return '';
};
// Initialize form with existing maintenance data
const form = useForm<MaintenanceFormValues>({
resolver: zodResolver(maintenanceFormSchema),
defaultValues: {
title: maintenance.title || '',
description: maintenance.description || '',
start_time: maintenance.start_time ? new Date(maintenance.start_time) : new Date(),
end_time: maintenance.end_time ? new Date(maintenance.end_time) : new Date(),
affected: maintenance.affected || '',
priority: (maintenance.priority?.toLowerCase() || 'medium') as any,
status: (maintenance.status?.toLowerCase() || 'scheduled') as any,
field: (maintenance.field?.toLowerCase() || 'minor') as any,
assigned_users: cleanUserIds(getAssignedUsers()),
notify_subscribers: maintenance.notify_subscribers === 'yes',
notification_channel_id: getNotificationChannelId(),
},
});
const onSubmit = async (data: MaintenanceFormValues) => {
try {
console.log("Form data before submission:", data);
console.log("Assigned users to be submitted:", data.assigned_users);
console.log("Notification channel to be submitted:", data.notification_channel_id);
// Verify dates are valid
if (!data.start_time || isNaN(data.start_time.getTime())) {
throw new Error("Invalid start time");
}
if (!data.end_time || isNaN(data.end_time.getTime())) {
throw new Error("Invalid end time");
}
// Format the update payload
const updateData = {
title: data.title,
description: data.description,
start_time: data.start_time.toISOString(),
end_time: data.end_time.toISOString(),
affected: data.affected,
priority: data.priority,
status: data.status,
field: data.field,
assigned_users: data.assigned_users || [], // Send as array
notify_subscribers: data.notify_subscribers ? 'yes' : 'no',
notification_channel_id: data.notify_subscribers && data.notification_channel_id && data.notification_channel_id !== 'none'
? data.notification_channel_id
: '',
// Set notification_id to match notification_channel_id when notifications are enabled
notification_id: data.notify_subscribers && data.notification_channel_id && data.notification_channel_id !== 'none'
? data.notification_channel_id
: '',
};
console.log("Updating maintenance with data:", updateData);
console.log("Assigned users being sent for update:", updateData.assigned_users);
console.log("Notification channel being sent for update:", updateData.notification_channel_id);
console.log("Notification ID being sent for update:", updateData.notification_id);
// Update the maintenance record using our service function
await maintenanceService.updateMaintenance(maintenance.id, updateData);
toast({
title: t('maintenanceUpdated'),
description: t('maintenanceUpdatedDesc'),
});
onClose();
onSuccess();
} catch (error) {
console.error('Error updating maintenance:', error);
if (error instanceof Error) {
toast({
title: t('error'),
description: `${t('errorUpdatingMaintenance')}: ${error.message}`,
variant: 'destructive',
});
} else {
toast({
title: t('error'),
description: t('errorUpdatingMaintenance'),
variant: 'destructive',
});
}
}
};
return {
form,
onSubmit,
};
};

Some files were not shown because too many files have changed in this diff Show More