From a40c168f1a5cf18f979613f944dfc31f5f68bf57 Mon Sep 17 00:00:00 2001 From: Yinyin Liu Date: Fri, 6 Mar 2026 10:43:31 +0100 Subject: [PATCH] added ticket dashboard frontend: List & Create --- .claude/settings.json | 8 + csharp/App/Backend/DataTypes/Ticket.cs | 20 +- typescript/frontend-marios2/src/App.tsx | 2 + .../src/Resources/routes.json | 3 +- .../dashboards/Tickets/CreateTicketModal.tsx | 348 ++++++++++++++++++ .../content/dashboards/Tickets/TicketList.tsx | 241 ++++++++++++ .../src/content/dashboards/Tickets/index.tsx | 13 + .../src/interfaces/TicketTypes.tsx | 149 ++++++++ typescript/frontend-marios2/src/lang/de.json | 11 +- typescript/frontend-marios2/src/lang/en.json | 11 +- typescript/frontend-marios2/src/lang/fr.json | 11 +- typescript/frontend-marios2/src/lang/it.json | 11 +- .../Sidebar/SidebarMenu/index.tsx | 14 + 13 files changed, 836 insertions(+), 6 deletions(-) create mode 100644 .claude/settings.json create mode 100644 typescript/frontend-marios2/src/content/dashboards/Tickets/CreateTicketModal.tsx create mode 100644 typescript/frontend-marios2/src/content/dashboards/Tickets/TicketList.tsx create mode 100644 typescript/frontend-marios2/src/content/dashboards/Tickets/index.tsx create mode 100644 typescript/frontend-marios2/src/interfaces/TicketTypes.tsx diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 000000000..1b997d844 --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,8 @@ +{ + "permissions": { + "allow": [ + "Bash(git checkout feature/ticket-dashboard)", + "Bash(npx react-scripts build)" + ] + } +} diff --git a/csharp/App/Backend/DataTypes/Ticket.cs b/csharp/App/Backend/DataTypes/Ticket.cs index 53659e3f0..ef3ca3658 100644 --- a/csharp/App/Backend/DataTypes/Ticket.cs +++ b/csharp/App/Backend/DataTypes/Ticket.cs @@ -4,7 +4,24 @@ namespace InnovEnergy.App.Backend.DataTypes; public enum TicketStatus { Open = 0, InProgress = 1, Escalated = 2, Resolved = 3, Closed = 4 } public enum TicketPriority { Critical = 0, High = 1, Medium = 2, Low = 3 } -public enum TicketCategory { Hardware = 0, Software = 1, DataApi = 2, UserAccess = 3 } +public enum TicketCategory { Hardware = 0, Software = 1, Network = 2, UserAccess = 3, Firmware = 4 } + +public enum TicketSubCategory +{ + General = 0, + Other = 99, + // Hardware (1xx) + Battery = 100, Inverter = 101, Cable = 102, Gateway = 103, + Metering = 104, Cooling = 105, PvSolar = 106, Safety = 107, + // Software (2xx) + Backend = 200, Frontend = 201, Database = 202, Api = 203, + // Network (3xx) + Connectivity = 300, VpnAccess = 301, S3Storage = 302, + // UserAccess (4xx) + Permissions = 400, Login = 401, + // Firmware (5xx) + BatteryFirmware = 500, InverterFirmware = 501, ControllerFirmware = 502 +} public enum TicketSource { Manual = 0, AutoAlert = 1, Email = 2, Api = 3 } public class Ticket @@ -17,6 +34,7 @@ public class Ticket [Indexed] public Int32 Status { get; set; } = (Int32)TicketStatus.Open; public Int32 Priority { get; set; } = (Int32)TicketPriority.Medium; public Int32 Category { get; set; } = (Int32)TicketCategory.Hardware; + public Int32 SubCategory { get; set; } = (Int32)TicketSubCategory.General; public Int32 Source { get; set; } = (Int32)TicketSource.Manual; [Indexed] public Int64 InstallationId { get; set; } diff --git a/typescript/frontend-marios2/src/App.tsx b/typescript/frontend-marios2/src/App.tsx index 3ff590030..19e054b97 100644 --- a/typescript/frontend-marios2/src/App.tsx +++ b/typescript/frontend-marios2/src/App.tsx @@ -23,6 +23,7 @@ import SalidomoInstallationTabs from './content/dashboards/SalidomoInstallations import SodioHomeInstallationTabs from './content/dashboards/SodiohomeInstallations'; import { ProductIdContext } from './contexts/ProductIdContextProvider'; import { TourProvider } from './contexts/TourContext'; +import Tickets from './content/dashboards/Tickets'; function App() { const context = useContext(UserContext); @@ -236,6 +237,7 @@ function App() { } /> + } /> } /> = { + 1: [ + { value: 1, label: 'Cerbo' }, + { value: 2, label: 'Venus' } + ], + 2: [ + { value: 3, label: 'Growatt' }, + { value: 4, label: 'Sinexcel' } + ] +}; + +const categoryLabels: Record = { + [TicketCategory.Hardware]: 'Hardware', + [TicketCategory.Software]: 'Software', + [TicketCategory.Network]: 'Network', + [TicketCategory.UserAccess]: 'User Access', + [TicketCategory.Firmware]: 'Firmware' +}; + +interface Props { + open: boolean; + onClose: () => void; + onCreated: () => void; +} + +function CreateTicketModal({ open, onClose, onCreated }: Props) { + const [subject, setSubject] = useState(''); + const [selectedProduct, setSelectedProduct] = useState(''); + const [selectedDevice, setSelectedDevice] = useState(''); + const [allInstallations, setAllInstallations] = useState([]); + const [selectedInstallation, setSelectedInstallation] = + useState(null); + const [loadingInstallations, setLoadingInstallations] = useState(false); + const [priority, setPriority] = useState(TicketPriority.Medium); + const [category, setCategory] = useState(TicketCategory.Hardware); + const [subCategory, setSubCategory] = useState( + TicketSubCategory.General + ); + const [description, setDescription] = useState(''); + const [error, setError] = useState(''); + const [submitting, setSubmitting] = useState(false); + + const hasDeviceOptions = + selectedProduct !== '' && selectedProduct in deviceOptionsByProduct; + + useEffect(() => { + if (selectedProduct === '') { + setAllInstallations([]); + setSelectedInstallation(null); + setSelectedDevice(''); + return; + } + setLoadingInstallations(true); + setSelectedInstallation(null); + setSelectedDevice(''); + axiosConfig + .get('/GetAllInstallationsFromProduct', { + params: { product: selectedProduct } + }) + .then((res) => { + const data = res.data; + if (Array.isArray(data)) { + setAllInstallations( + data.map((item: any) => ({ + id: item.id, + name: item.name, + device: item.device + })) + ); + } + }) + .catch(() => setAllInstallations([])) + .finally(() => setLoadingInstallations(false)); + }, [selectedProduct]); + + useEffect(() => { + setSelectedInstallation(null); + }, [selectedDevice]); + + useEffect(() => { + setSubCategory(TicketSubCategory.General); + }, [category]); + + const filteredInstallations = useMemo(() => { + if (!hasDeviceOptions || selectedDevice === '') return allInstallations; + return allInstallations.filter((inst) => inst.device === selectedDevice); + }, [allInstallations, selectedDevice, hasDeviceOptions]); + + const installationReady = + selectedProduct !== '' && (!hasDeviceOptions || selectedDevice !== ''); + + const resetForm = () => { + setSubject(''); + setSelectedProduct(''); + setSelectedDevice(''); + setAllInstallations([]); + setSelectedInstallation(null); + setPriority(TicketPriority.Medium); + setCategory(TicketCategory.Hardware); + setSubCategory(TicketSubCategory.General); + setDescription(''); + setError(''); + }; + + const handleSubmit = () => { + if (!subject.trim() || !selectedInstallation) return; + setSubmitting(true); + setError(''); + + axiosConfig + .post('/CreateTicket', { + subject, + description, + installationId: selectedInstallation.id, + priority, + category, + subCategory + }) + .then(() => { + resetForm(); + onCreated(); + onClose(); + }) + .catch(() => setError('Failed to create ticket.')) + .finally(() => setSubmitting(false)); + }; + + const availableSubCategories = subCategoriesByCategory[category] ?? [0]; + + return ( + + + + + + {error && {error}} + + } + value={subject} + onChange={(e) => setSubject(e.target.value)} + required + fullWidth + margin="dense" + /> + + + + + + + + + {hasDeviceOptions && ( + + + + + + + )} + + + options={filteredInstallations} + getOptionLabel={(opt) => opt.name} + value={selectedInstallation} + onChange={(_e, val) => setSelectedInstallation(val)} + disabled={!installationReady} + loading={loadingInstallations} + renderInput={(params) => ( + + } + margin="dense" + InputProps={{ + ...params.InputProps, + endAdornment: ( + <> + {loadingInstallations ? ( + + ) : null} + {params.InputProps.endAdornment} + + ) + }} + /> + )} + /> + + + + + + + + + + + + + + + + + + + + + + + + } + value={description} + onChange={(e) => setDescription(e.target.value)} + multiline + rows={4} + fullWidth + margin="dense" + /> + + + + + + + ); +} + +export default CreateTicketModal; diff --git a/typescript/frontend-marios2/src/content/dashboards/Tickets/TicketList.tsx b/typescript/frontend-marios2/src/content/dashboards/Tickets/TicketList.tsx new file mode 100644 index 000000000..1c4a85361 --- /dev/null +++ b/typescript/frontend-marios2/src/content/dashboards/Tickets/TicketList.tsx @@ -0,0 +1,241 @@ +import React, { useEffect, useState } from 'react'; +import { + Alert, + Box, + Button, + Chip, + Container, + FormControl, + InputLabel, + MenuItem, + Select, + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TableRow, + TextField, + Typography +} from '@mui/material'; +import AddIcon from '@mui/icons-material/Add'; +import { FormattedMessage } from 'react-intl'; +import axiosConfig from 'src/Resources/axiosConfig'; +import { + Ticket, + TicketStatus, + TicketPriority, + TicketCategory, + TicketSubCategory, + subCategoryLabels +} from 'src/interfaces/TicketTypes'; +import Footer from 'src/components/Footer'; +import CreateTicketModal from './CreateTicketModal'; + +const statusLabels: Record = { + [TicketStatus.Open]: 'Open', + [TicketStatus.InProgress]: 'In Progress', + [TicketStatus.Escalated]: 'Escalated', + [TicketStatus.Resolved]: 'Resolved', + [TicketStatus.Closed]: 'Closed' +}; + +const statusColors: Record = { + [TicketStatus.Open]: 'error', + [TicketStatus.InProgress]: 'warning', + [TicketStatus.Escalated]: 'error', + [TicketStatus.Resolved]: 'success', + [TicketStatus.Closed]: 'default' +}; + +const priorityLabels: Record = { + [TicketPriority.Critical]: 'Critical', + [TicketPriority.High]: 'High', + [TicketPriority.Medium]: 'Medium', + [TicketPriority.Low]: 'Low' +}; + +const categoryLabels: Record = { + [TicketCategory.Hardware]: 'Hardware', + [TicketCategory.Software]: 'Software', + [TicketCategory.Network]: 'Network', + [TicketCategory.UserAccess]: 'User Access', + [TicketCategory.Firmware]: 'Firmware' +}; + +function TicketList() { + const [tickets, setTickets] = useState([]); + const [search, setSearch] = useState(''); + const [statusFilter, setStatusFilter] = useState(''); + const [createOpen, setCreateOpen] = useState(false); + const [error, setError] = useState(''); + + const fetchTickets = () => { + axiosConfig + .get('/GetAllTickets') + .then((res) => setTickets(res.data)) + .catch(() => setError('Failed to load tickets')); + }; + + useEffect(() => { + fetchTickets(); + }, []); + + const filtered = tickets.filter((t) => { + const matchesSearch = + search === '' || + t.subject.toLowerCase().includes(search.toLowerCase()) || + t.description.toLowerCase().includes(search.toLowerCase()); + const matchesStatus = statusFilter === '' || t.status === statusFilter; + return matchesSearch && matchesStatus; + }); + + return ( +
+ + + + + + + + + + } + value={search} + onChange={(e) => setSearch(e.target.value)} + sx={{ minWidth: 250 }} + /> + + + + + + + + + {error && ( + + {error} + + )} + + {filtered.length === 0 && !error ? ( + + + + ) : ( + + + + + # + + + + + + + + + + + + + + + + + + + {filtered.map((ticket) => ( + + {ticket.id} + {ticket.subject} + + + + {priorityLabels[ticket.priority] ?? '-'} + + {categoryLabels[ticket.category] ?? '-'} + {ticket.subCategory !== TicketSubCategory.General && + ` — ${subCategoryLabels[ticket.subCategory] ?? ''}`} + + + {new Date(ticket.createdAt).toLocaleDateString()} + + + ))} + +
+
+ )} + + setCreateOpen(false)} + onCreated={fetchTickets} + /> +
+
+
+ ); +} + +export default TicketList; diff --git a/typescript/frontend-marios2/src/content/dashboards/Tickets/index.tsx b/typescript/frontend-marios2/src/content/dashboards/Tickets/index.tsx new file mode 100644 index 000000000..57d325748 --- /dev/null +++ b/typescript/frontend-marios2/src/content/dashboards/Tickets/index.tsx @@ -0,0 +1,13 @@ +import React from 'react'; +import { Route, Routes } from 'react-router-dom'; +import TicketList from './TicketList'; + +function Tickets() { + return ( + + } /> + + ); +} + +export default Tickets; diff --git a/typescript/frontend-marios2/src/interfaces/TicketTypes.tsx b/typescript/frontend-marios2/src/interfaces/TicketTypes.tsx new file mode 100644 index 000000000..8107d2ba8 --- /dev/null +++ b/typescript/frontend-marios2/src/interfaces/TicketTypes.tsx @@ -0,0 +1,149 @@ +export enum TicketStatus { + Open = 0, + InProgress = 1, + Escalated = 2, + Resolved = 3, + Closed = 4 +} + +export enum TicketPriority { + Critical = 0, + High = 1, + Medium = 2, + Low = 3 +} + +export enum TicketCategory { + Hardware = 0, + Software = 1, + Network = 2, + UserAccess = 3, + Firmware = 4 +} + +export enum TicketSubCategory { + General = 0, + Other = 99, + // Hardware (1xx) + Battery = 100, Inverter = 101, Cable = 102, Gateway = 103, + Metering = 104, Cooling = 105, PvSolar = 106, Safety = 107, + // Software (2xx) + Backend = 200, Frontend = 201, Database = 202, Api = 203, + // Network (3xx) + Connectivity = 300, VpnAccess = 301, S3Storage = 302, + // UserAccess (4xx) + Permissions = 400, Login = 401, + // Firmware (5xx) + BatteryFirmware = 500, InverterFirmware = 501, ControllerFirmware = 502 +} + +export const subCategoryLabels: Record = { + [TicketSubCategory.General]: 'General', + [TicketSubCategory.Other]: 'Other', + [TicketSubCategory.Battery]: 'Battery', [TicketSubCategory.Inverter]: 'Inverter', + [TicketSubCategory.Cable]: 'Cable', [TicketSubCategory.Gateway]: 'Gateway', + [TicketSubCategory.Metering]: 'Metering', [TicketSubCategory.Cooling]: 'Cooling', + [TicketSubCategory.PvSolar]: 'PV / Solar', [TicketSubCategory.Safety]: 'Safety', + [TicketSubCategory.Backend]: 'Backend', [TicketSubCategory.Frontend]: 'Frontend', + [TicketSubCategory.Database]: 'Database', [TicketSubCategory.Api]: 'API', + [TicketSubCategory.Connectivity]: 'Connectivity', [TicketSubCategory.VpnAccess]: 'VPN Access', + [TicketSubCategory.S3Storage]: 'S3 Storage', + [TicketSubCategory.Permissions]: 'Permissions', [TicketSubCategory.Login]: 'Login', + [TicketSubCategory.BatteryFirmware]: 'Battery Firmware', + [TicketSubCategory.InverterFirmware]: 'Inverter Firmware', + [TicketSubCategory.ControllerFirmware]: 'Controller Firmware' +}; + +export const subCategoriesByCategory: Record = { + [TicketCategory.Hardware]: [0, 100, 101, 102, 103, 104, 105, 106, 107, 99], + [TicketCategory.Software]: [0, 200, 201, 202, 203, 99], + [TicketCategory.Network]: [0, 300, 301, 302, 99], + [TicketCategory.UserAccess]: [0, 400, 401, 99], + [TicketCategory.Firmware]: [0, 500, 501, 502, 99] +}; + +export enum TicketSource { + Manual = 0, + AutoAlert = 1, + Email = 2, + Api = 3 +} + +export enum CommentAuthorType { + Human = 0, + AiAgent = 1 +} + +export enum DiagnosisStatus { + Pending = 0, + Analyzing = 1, + Completed = 2, + Failed = 3 +} + +export enum TimelineEventType { + Created = 0, + StatusChanged = 1, + Assigned = 2, + CommentAdded = 3, + AiDiagnosisAttached = 4, + Escalated = 5 +} + +export type Ticket = { + id: number; + subject: string; + description: string; + status: number; + priority: number; + category: number; + subCategory: number; + source: number; + installationId: number; + assigneeId: number | null; + createdByUserId: number; + tags: string; + createdAt: string; + updatedAt: string; + resolvedAt: string | null; +}; + +export type TicketComment = { + id: number; + ticketId: number; + authorType: number; + authorId: number | null; + body: string; + createdAt: string; +}; + +export type TicketAiDiagnosis = { + id: number; + ticketId: number; + status: number; + rootCause: string | null; + confidence: number | null; + recommendedActions: string | null; + similarTicketIds: string | null; + feedback: number | null; + overrideText: string | null; + createdAt: string; + completedAt: string | null; +}; + +export type TicketTimelineEvent = { + id: number; + ticketId: number; + eventType: number; + description: string; + actorType: number; + actorId: number | null; + createdAt: string; +}; + +export type TicketDetail = { + ticket: Ticket; + comments: TicketComment[]; + diagnosis: TicketAiDiagnosis | null; + timeline: TicketTimelineEvent[]; +}; diff --git a/typescript/frontend-marios2/src/lang/de.json b/typescript/frontend-marios2/src/lang/de.json index caaa984ce..de571333b 100644 --- a/typescript/frontend-marios2/src/lang/de.json +++ b/typescript/frontend-marios2/src/lang/de.json @@ -515,5 +515,14 @@ "tourConfigurationTitle": "Konfiguration", "tourConfigurationContent": "Geräteeinstellungen für diese Installation anzeigen und ändern.", "tourHistoryTitle": "Verlauf", - "tourHistoryContent": "Protokoll der Aktionen an dieser Installation — wer hat was und wann geändert." + "tourHistoryContent": "Protokoll der Aktionen an dieser Installation — wer hat was und wann geändert.", + "tickets": "Tickets", + "createTicket": "Ticket erstellen", + "subject": "Betreff", + "description": "Beschreibung", + "priority": "Priorität", + "category": "Kategorie", + "allStatuses": "Alle Status", + "createdAt": "Erstellt", + "noTickets": "Keine Tickets gefunden." } \ No newline at end of file diff --git a/typescript/frontend-marios2/src/lang/en.json b/typescript/frontend-marios2/src/lang/en.json index 50d0db465..52cd08915 100644 --- a/typescript/frontend-marios2/src/lang/en.json +++ b/typescript/frontend-marios2/src/lang/en.json @@ -263,5 +263,14 @@ "tourConfigurationTitle": "Configuration", "tourConfigurationContent": "View and modify device settings for this installation.", "tourHistoryTitle": "History", - "tourHistoryContent": "Audit trail of actions performed on this installation — who changed what and when." + "tourHistoryContent": "Audit trail of actions performed on this installation — who changed what and when.", + "tickets": "Tickets", + "createTicket": "Create Ticket", + "subject": "Subject", + "description": "Description", + "priority": "Priority", + "category": "Category", + "allStatuses": "All Statuses", + "createdAt": "Created", + "noTickets": "No tickets found." } diff --git a/typescript/frontend-marios2/src/lang/fr.json b/typescript/frontend-marios2/src/lang/fr.json index 022079478..b84d6c028 100644 --- a/typescript/frontend-marios2/src/lang/fr.json +++ b/typescript/frontend-marios2/src/lang/fr.json @@ -515,5 +515,14 @@ "tourConfigurationTitle": "Configuration", "tourConfigurationContent": "Afficher et modifier les paramètres de l'appareil pour cette installation.", "tourHistoryTitle": "Historique", - "tourHistoryContent": "Journal des actions effectuées sur cette installation — qui a changé quoi et quand." + "tourHistoryContent": "Journal des actions effectuées sur cette installation — qui a changé quoi et quand.", + "tickets": "Tickets", + "createTicket": "Créer un ticket", + "subject": "Objet", + "description": "Description", + "priority": "Priorité", + "category": "Catégorie", + "allStatuses": "Tous les statuts", + "createdAt": "Créé", + "noTickets": "Aucun ticket trouvé." } \ No newline at end of file diff --git a/typescript/frontend-marios2/src/lang/it.json b/typescript/frontend-marios2/src/lang/it.json index 9c3a47e28..d4bca6997 100644 --- a/typescript/frontend-marios2/src/lang/it.json +++ b/typescript/frontend-marios2/src/lang/it.json @@ -515,5 +515,14 @@ "tourConfigurationTitle": "Configurazione", "tourConfigurationContent": "Visualizza e modifica le impostazioni del dispositivo per questa installazione.", "tourHistoryTitle": "Cronologia", - "tourHistoryContent": "Registro delle azioni eseguite su questa installazione — chi ha cambiato cosa e quando." + "tourHistoryContent": "Registro delle azioni eseguite su questa installazione — chi ha cambiato cosa e quando.", + "tickets": "Ticket", + "createTicket": "Crea ticket", + "subject": "Oggetto", + "description": "Descrizione", + "priority": "Priorità", + "category": "Categoria", + "allStatuses": "Tutti gli stati", + "createdAt": "Creato", + "noTickets": "Nessun ticket trovato." } diff --git a/typescript/frontend-marios2/src/layouts/SidebarLayout/Sidebar/SidebarMenu/index.tsx b/typescript/frontend-marios2/src/layouts/SidebarLayout/Sidebar/SidebarMenu/index.tsx index fdc8d54e9..3a25cf5ef 100644 --- a/typescript/frontend-marios2/src/layouts/SidebarLayout/Sidebar/SidebarMenu/index.tsx +++ b/typescript/frontend-marios2/src/layouts/SidebarLayout/Sidebar/SidebarMenu/index.tsx @@ -13,6 +13,7 @@ import { NavLink as RouterLink } from 'react-router-dom'; import { SidebarContext } from 'src/contexts/SidebarContext'; import BrightnessLowTwoToneIcon from '@mui/icons-material/BrightnessLowTwoTone'; import TableChartTwoToneIcon from '@mui/icons-material/TableChartTwoTone'; +import ConfirmationNumberTwoToneIcon from '@mui/icons-material/ConfirmationNumberTwoTone'; import { FormattedMessage } from 'react-intl'; import { UserContext } from '../../../../contexts/userContext'; import { UserType } from '../../../../interfaces/UserTypes'; @@ -310,6 +311,19 @@ function SidebarMenu() { + + + + + )}