add notificiation system to ticket dashboard
This commit is contained in:
parent
544f9602e1
commit
3fbb2eeee0
|
|
@ -2176,6 +2176,17 @@ public class Controller : ControllerBase
|
|||
CreatedAt = DateTime.UtcNow
|
||||
});
|
||||
|
||||
if (ticket.AssigneeId.HasValue && ticket.AssigneeId.Value != user.Id)
|
||||
{
|
||||
var assignee = Db.GetUserById(ticket.AssigneeId);
|
||||
if (assignee is not null)
|
||||
_ = Task.Run(async () =>
|
||||
{
|
||||
try { await assignee.SendTicketAssignedEmail(ticket); }
|
||||
catch (Exception ex) { Console.WriteLine($"Failed to send ticket assignment email on create: {ex}"); }
|
||||
});
|
||||
}
|
||||
|
||||
// Fire-and-forget AI diagnosis
|
||||
var lang = user.Language ?? "en";
|
||||
TicketDiagnosticService.DiagnoseTicketAsync(ticket.Id, lang).SupressAwaitWarning();
|
||||
|
|
@ -2221,6 +2232,40 @@ public class Controller : ControllerBase
|
|||
ActorId = user.Id,
|
||||
CreatedAt = DateTime.UtcNow
|
||||
});
|
||||
|
||||
var isSolveTransition = ticket.Status == (Int32)TicketStatus.Resolved
|
||||
&& existing.Status != (Int32)TicketStatus.Resolved;
|
||||
var isReopenTransition = existing.Status == (Int32)TicketStatus.Resolved
|
||||
&& (ticket.Status == (Int32)TicketStatus.InProgress
|
||||
|| ticket.Status == (Int32)TicketStatus.Open);
|
||||
|
||||
if (isSolveTransition)
|
||||
{
|
||||
var creator = Db.GetUserById(existing.CreatedByUserId);
|
||||
if (creator is not null && creator.Id != user.Id)
|
||||
{
|
||||
var actorName = user.Name;
|
||||
_ = Task.Run(async () =>
|
||||
{
|
||||
try { await creator.SendTicketSolvedEmail(ticket, actorName); }
|
||||
catch (Exception ex) { Console.WriteLine($"Failed to send ticket solved email: {ex}"); }
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (isReopenTransition && existing.AssigneeId.HasValue)
|
||||
{
|
||||
var assignee = Db.GetUserById(existing.AssigneeId);
|
||||
if (assignee is not null && assignee.Id != user.Id)
|
||||
{
|
||||
var actorName = user.Name;
|
||||
_ = Task.Run(async () =>
|
||||
{
|
||||
try { await assignee.SendTicketReopenedEmail(ticket, actorName); }
|
||||
catch (Exception ex) { Console.WriteLine($"Failed to send ticket reopened email: {ex}"); }
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (resolutionAdded)
|
||||
|
|
@ -2253,7 +2298,7 @@ public class Controller : ControllerBase
|
|||
CreatedAt = DateTime.UtcNow
|
||||
});
|
||||
|
||||
if (assignee is not null)
|
||||
if (assignee is not null && assignee.Id != user.Id)
|
||||
_ = Task.Run(async () =>
|
||||
{
|
||||
try { await assignee.SendTicketAssignedEmail(ticket); }
|
||||
|
|
@ -2321,6 +2366,35 @@ public class Controller : ControllerBase
|
|||
ticket.UpdatedAt = DateTime.UtcNow;
|
||||
Db.Update(ticket);
|
||||
|
||||
var mentioned = (comment.MentionedUserIds ?? new List<Int64>())
|
||||
.Distinct()
|
||||
.Where(uid => uid != user.Id)
|
||||
.ToList();
|
||||
|
||||
foreach (var uid in mentioned)
|
||||
{
|
||||
Db.Create(new TicketCommentMention
|
||||
{
|
||||
CommentId = comment.Id,
|
||||
MentionedUserId = uid,
|
||||
CreatedAt = DateTime.UtcNow
|
||||
});
|
||||
|
||||
var mentionedUser = Db.GetUserById(uid);
|
||||
if (mentionedUser is null) continue;
|
||||
|
||||
var actorName = user.Name;
|
||||
var body = comment.Body ?? "";
|
||||
var excerpt = body.Length > 300 ? body.Substring(0, 300) + "..." : body;
|
||||
var ticketRef = ticket;
|
||||
|
||||
_ = Task.Run(async () =>
|
||||
{
|
||||
try { await mentionedUser.SendTicketMentionEmail(ticketRef, actorName, excerpt); }
|
||||
catch (Exception ex) { Console.WriteLine($"Failed to send mention email: {ex}"); }
|
||||
});
|
||||
}
|
||||
|
||||
return comment;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -359,4 +359,163 @@ public static class UserMethods
|
|||
return user.SendEmail(subject, body);
|
||||
}
|
||||
|
||||
public static Task SendTicketSolvedEmail(this User user, Ticket ticket, String solvedByName)
|
||||
{
|
||||
var ticketLink = $"https://monitor.inesco.energy/tickets/{ticket.Id}";
|
||||
|
||||
var (subject, body) = (user.Language ?? "en") switch
|
||||
{
|
||||
"de" => (
|
||||
$"inesco energy – Ticket #{ticket.Id} wurde gelöst",
|
||||
$"Sehr geehrte/r {user.Name},\n\n" +
|
||||
$"Ihr Ticket wurde als gelöst markiert von {solvedByName}:\n\n" +
|
||||
$"Ticket: #{ticket.Id}\n" +
|
||||
$"Betreff: {ticket.Subject}\n\n" +
|
||||
$"Ursache:\n{ticket.RootCause}\n\n" +
|
||||
$"Lösung:\n{ticket.Solution}\n\n" +
|
||||
$"Falls das Problem weiterhin besteht, öffnen Sie das Ticket erneut: {ticketLink}\n\n" +
|
||||
"Mit freundlichen Grüssen\ninesco energy Monitor"
|
||||
),
|
||||
"fr" => (
|
||||
$"inesco energy – Le ticket #{ticket.Id} a été résolu",
|
||||
$"Cher/Chère {user.Name},\n\n" +
|
||||
$"Votre ticket a été marqué comme résolu par {solvedByName} :\n\n" +
|
||||
$"Ticket : #{ticket.Id}\n" +
|
||||
$"Objet : {ticket.Subject}\n\n" +
|
||||
$"Cause :\n{ticket.RootCause}\n\n" +
|
||||
$"Solution :\n{ticket.Solution}\n\n" +
|
||||
$"Si le problème persiste, rouvrez le ticket : {ticketLink}\n\n" +
|
||||
"Cordialement,\ninesco energy Monitor"
|
||||
),
|
||||
"it" => (
|
||||
$"inesco energy – Il ticket #{ticket.Id} è stato risolto",
|
||||
$"Gentile {user.Name},\n\n" +
|
||||
$"Il suo ticket è stato contrassegnato come risolto da {solvedByName}:\n\n" +
|
||||
$"Ticket: #{ticket.Id}\n" +
|
||||
$"Oggetto: {ticket.Subject}\n\n" +
|
||||
$"Causa:\n{ticket.RootCause}\n\n" +
|
||||
$"Soluzione:\n{ticket.Solution}\n\n" +
|
||||
$"Se il problema persiste, riaprire il ticket: {ticketLink}\n\n" +
|
||||
"Cordiali saluti,\ninesco energy Monitor"
|
||||
),
|
||||
_ => (
|
||||
$"inesco energy – Ticket #{ticket.Id} has been solved",
|
||||
$"Dear {user.Name},\n\n" +
|
||||
$"Your ticket has been marked as solved by {solvedByName}:\n\n" +
|
||||
$"Ticket: #{ticket.Id}\n" +
|
||||
$"Subject: {ticket.Subject}\n\n" +
|
||||
$"Root cause:\n{ticket.RootCause}\n\n" +
|
||||
$"Solution:\n{ticket.Solution}\n\n" +
|
||||
$"If the issue persists, reopen the ticket: {ticketLink}\n\n" +
|
||||
"Best regards,\ninesco energy Monitor"
|
||||
)
|
||||
};
|
||||
|
||||
return user.SendEmail(subject, body);
|
||||
}
|
||||
|
||||
public static Task SendTicketReopenedEmail(this User user, Ticket ticket, String reopenedByName)
|
||||
{
|
||||
var ticketLink = $"https://monitor.inesco.energy/tickets/{ticket.Id}";
|
||||
var priority = (TicketPriority)ticket.Priority;
|
||||
var category = (TicketCategory)ticket.Category;
|
||||
|
||||
var (subject, body) = (user.Language ?? "en") switch
|
||||
{
|
||||
"de" => (
|
||||
$"inesco energy – Ticket #{ticket.Id} wurde wieder geöffnet",
|
||||
$"Sehr geehrte/r {user.Name},\n\n" +
|
||||
$"Ein Ihnen zugewiesenes Ticket wurde von {reopenedByName} wieder geöffnet:\n\n" +
|
||||
$"Ticket: #{ticket.Id}\n" +
|
||||
$"Betreff: {ticket.Subject}\n" +
|
||||
$"Priorität: {priority}\n" +
|
||||
$"Kategorie: {category}\n\n" +
|
||||
$"Öffnen Sie das Ticket hier: {ticketLink}\n\n" +
|
||||
"Mit freundlichen Grüssen\ninesco energy Monitor"
|
||||
),
|
||||
"fr" => (
|
||||
$"inesco energy – Le ticket #{ticket.Id} a été rouvert",
|
||||
$"Cher/Chère {user.Name},\n\n" +
|
||||
$"Un ticket qui vous est attribué a été rouvert par {reopenedByName} :\n\n" +
|
||||
$"Ticket : #{ticket.Id}\n" +
|
||||
$"Objet : {ticket.Subject}\n" +
|
||||
$"Priorité : {priority}\n" +
|
||||
$"Catégorie : {category}\n\n" +
|
||||
$"Ouvrir le ticket : {ticketLink}\n\n" +
|
||||
"Cordialement,\ninesco energy Monitor"
|
||||
),
|
||||
"it" => (
|
||||
$"inesco energy – Il ticket #{ticket.Id} è stato riaperto",
|
||||
$"Gentile {user.Name},\n\n" +
|
||||
$"Un ticket assegnato a lei è stato riaperto da {reopenedByName}:\n\n" +
|
||||
$"Ticket: #{ticket.Id}\n" +
|
||||
$"Oggetto: {ticket.Subject}\n" +
|
||||
$"Priorità: {priority}\n" +
|
||||
$"Categoria: {category}\n\n" +
|
||||
$"Aprire il ticket: {ticketLink}\n\n" +
|
||||
"Cordiali saluti,\ninesco energy Monitor"
|
||||
),
|
||||
_ => (
|
||||
$"inesco energy – Ticket #{ticket.Id} has been reopened",
|
||||
$"Dear {user.Name},\n\n" +
|
||||
$"A ticket assigned to you has been reopened by {reopenedByName}:\n\n" +
|
||||
$"Ticket: #{ticket.Id}\n" +
|
||||
$"Subject: {ticket.Subject}\n" +
|
||||
$"Priority: {priority}\n" +
|
||||
$"Category: {category}\n\n" +
|
||||
$"Open the ticket: {ticketLink}\n\n" +
|
||||
"Best regards,\ninesco energy Monitor"
|
||||
)
|
||||
};
|
||||
|
||||
return user.SendEmail(subject, body);
|
||||
}
|
||||
|
||||
public static Task SendTicketMentionEmail(this User user, Ticket ticket, String mentionedByName, String commentExcerpt)
|
||||
{
|
||||
var ticketLink = $"https://monitor.inesco.energy/tickets/{ticket.Id}";
|
||||
|
||||
var (subject, body) = (user.Language ?? "en") switch
|
||||
{
|
||||
"de" => (
|
||||
$"inesco energy – Sie wurden in Ticket #{ticket.Id} erwähnt",
|
||||
$"Sehr geehrte/r {user.Name},\n\n" +
|
||||
$"{mentionedByName} hat Sie in einem Kommentar zu Ticket #{ticket.Id} erwähnt:\n\n" +
|
||||
$"Betreff: {ticket.Subject}\n\n" +
|
||||
$"Kommentar:\n\"{commentExcerpt}\"\n\n" +
|
||||
$"Ticket öffnen: {ticketLink}\n\n" +
|
||||
"Mit freundlichen Grüssen\ninesco energy Monitor"
|
||||
),
|
||||
"fr" => (
|
||||
$"inesco energy – Vous avez été mentionné dans le ticket #{ticket.Id}",
|
||||
$"Cher/Chère {user.Name},\n\n" +
|
||||
$"{mentionedByName} vous a mentionné dans un commentaire sur le ticket #{ticket.Id} :\n\n" +
|
||||
$"Objet : {ticket.Subject}\n\n" +
|
||||
$"Commentaire :\n« {commentExcerpt} »\n\n" +
|
||||
$"Ouvrir le ticket : {ticketLink}\n\n" +
|
||||
"Cordialement,\ninesco energy Monitor"
|
||||
),
|
||||
"it" => (
|
||||
$"inesco energy – È stato menzionato nel ticket #{ticket.Id}",
|
||||
$"Gentile {user.Name},\n\n" +
|
||||
$"{mentionedByName} l'ha menzionata in un commento sul ticket #{ticket.Id}:\n\n" +
|
||||
$"Oggetto: {ticket.Subject}\n\n" +
|
||||
$"Commento:\n\"{commentExcerpt}\"\n\n" +
|
||||
$"Aprire il ticket: {ticketLink}\n\n" +
|
||||
"Cordiali saluti,\ninesco energy Monitor"
|
||||
),
|
||||
_ => (
|
||||
$"inesco energy – You were mentioned in ticket #{ticket.Id}",
|
||||
$"Dear {user.Name},\n\n" +
|
||||
$"{mentionedByName} mentioned you in a comment on ticket #{ticket.Id}:\n\n" +
|
||||
$"Subject: {ticket.Subject}\n\n" +
|
||||
$"Comment:\n\"{commentExcerpt}\"\n\n" +
|
||||
$"Open the ticket: {ticketLink}\n\n" +
|
||||
"Best regards,\ninesco energy Monitor"
|
||||
)
|
||||
};
|
||||
|
||||
return user.SendEmail(subject, body);
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -13,4 +13,6 @@ public class TicketComment
|
|||
public Int64? AuthorId { get; set; }
|
||||
public String Body { get; set; } = "";
|
||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||
|
||||
[Ignore] public List<Int64> MentionedUserIds { get; set; } = new();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,12 @@
|
|||
using SQLite;
|
||||
|
||||
namespace InnovEnergy.App.Backend.DataTypes;
|
||||
|
||||
public class TicketCommentMention
|
||||
{
|
||||
[PrimaryKey, AutoIncrement] public Int64 Id { get; set; }
|
||||
|
||||
[Indexed] public Int64 CommentId { get; set; }
|
||||
[Indexed] public Int64 MentionedUserId { get; set; }
|
||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||
}
|
||||
|
|
@ -87,6 +87,7 @@ public static partial class Db
|
|||
public static Boolean Create(TicketComment comment) => Insert(comment);
|
||||
public static Boolean Create(TicketAiDiagnosis diagnosis) => Insert(diagnosis);
|
||||
public static Boolean Create(TicketTimelineEvent ev) => Insert(ev);
|
||||
public static Boolean Create(TicketCommentMention mention) => Insert(mention);
|
||||
|
||||
// Document storage
|
||||
public static Boolean Create(Document document) => Insert(document);
|
||||
|
|
|
|||
|
|
@ -143,6 +143,7 @@ public static partial class Db
|
|||
// Ticket system tables
|
||||
fileConnection.CreateTable<Ticket>();
|
||||
fileConnection.CreateTable<TicketComment>();
|
||||
fileConnection.CreateTable<TicketCommentMention>();
|
||||
fileConnection.CreateTable<TicketAiDiagnosis>();
|
||||
fileConnection.CreateTable<TicketTimelineEvent>();
|
||||
|
||||
|
|
|
|||
|
|
@ -7,15 +7,20 @@ import {
|
|||
CardContent,
|
||||
CardHeader,
|
||||
Chip,
|
||||
ClickAwayListener,
|
||||
Divider,
|
||||
LinearProgress,
|
||||
MenuItem,
|
||||
MenuList,
|
||||
Paper,
|
||||
Popper,
|
||||
TextField,
|
||||
Typography
|
||||
} from '@mui/material';
|
||||
import PersonIcon from '@mui/icons-material/Person';
|
||||
import SmartToyIcon from '@mui/icons-material/SmartToy';
|
||||
import AttachFileIcon from '@mui/icons-material/AttachFile';
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
import { FormattedMessage, useIntl } from 'react-intl';
|
||||
import axiosConfig from 'src/Resources/axiosConfig';
|
||||
import { AdminUser, CommentAuthorType, TicketComment } from 'src/interfaces/TicketTypes';
|
||||
import DocumentList from 'src/components/DocumentList';
|
||||
|
|
@ -33,6 +38,7 @@ function CommentThread({
|
|||
onCommentAdded,
|
||||
adminUsers = []
|
||||
}: CommentThreadProps) {
|
||||
const intl = useIntl();
|
||||
const [body, setBody] = useState('');
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
|
@ -40,6 +46,68 @@ function CommentThread({
|
|||
const [uploading, setUploading] = useState(false);
|
||||
const [refreshKey, setRefreshKey] = useState(0);
|
||||
|
||||
const [mentionedIds, setMentionedIds] = useState<number[]>([]);
|
||||
const [mentionQuery, setMentionQuery] = useState<string | null>(null);
|
||||
const commentInputRef = useRef<HTMLInputElement | null>(null);
|
||||
|
||||
const MENTION_EXCLUDED_NAMES = ['inesco energy Master Admin'];
|
||||
|
||||
const mentionCandidates = mentionQuery === null
|
||||
? []
|
||||
: adminUsers
|
||||
.filter((u) =>
|
||||
!MENTION_EXCLUDED_NAMES.includes(u.name) &&
|
||||
u.name.toLowerCase().includes(mentionQuery.toLowerCase()) &&
|
||||
!mentionedIds.includes(u.id)
|
||||
)
|
||||
.slice(0, 8);
|
||||
|
||||
const detectMention = (text: string, cursor: number) => {
|
||||
const upToCursor = text.slice(0, cursor);
|
||||
const atIdx = upToCursor.lastIndexOf('@');
|
||||
if (atIdx === -1) return null;
|
||||
const between = upToCursor.slice(atIdx + 1);
|
||||
if (/\s/.test(between)) return null;
|
||||
const prevChar = atIdx === 0 ? ' ' : upToCursor[atIdx - 1];
|
||||
if (!/\s|^$/.test(prevChar) && atIdx !== 0) return null;
|
||||
return { atIdx, query: between };
|
||||
};
|
||||
|
||||
const handleBodyChange = (e: React.ChangeEvent<HTMLTextAreaElement | HTMLInputElement>) => {
|
||||
const text = e.target.value;
|
||||
const cursor = e.target.selectionStart ?? text.length;
|
||||
setBody(text);
|
||||
const match = detectMention(text, cursor);
|
||||
setMentionQuery(match ? match.query : null);
|
||||
|
||||
// Drop mentioned IDs whose display names no longer appear in the body
|
||||
setMentionedIds((prev) =>
|
||||
prev.filter((uid) => {
|
||||
const u = adminUsers.find((au) => au.id === uid);
|
||||
return u ? text.includes(`@${u.name}`) : false;
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
const handleSelectMention = (userId: number, userName: string) => {
|
||||
const input = commentInputRef.current;
|
||||
const cursor = input?.selectionStart ?? body.length;
|
||||
const match = detectMention(body, cursor);
|
||||
if (!match) return;
|
||||
const before = body.slice(0, match.atIdx);
|
||||
const after = body.slice(cursor);
|
||||
const token = `@${userName} `;
|
||||
const next = `${before}${token}${after}`;
|
||||
setBody(next);
|
||||
setMentionedIds((prev) => (prev.includes(userId) ? prev : [...prev, userId]));
|
||||
setMentionQuery(null);
|
||||
const caret = before.length + token.length;
|
||||
setTimeout(() => {
|
||||
input?.focus();
|
||||
input?.setSelectionRange(caret, caret);
|
||||
}, 0);
|
||||
};
|
||||
|
||||
const ALLOWED_TYPES = ['image/jpeg', 'image/png', 'image/gif', 'image/webp', 'application/pdf'];
|
||||
const MAX_FILE_SIZE = 25 * 1024 * 1024;
|
||||
|
||||
|
|
@ -64,7 +132,15 @@ function CommentThread({
|
|||
try {
|
||||
let commentId: number | undefined;
|
||||
if (body.trim()) {
|
||||
const res = await axiosConfig.post('/AddTicketComment', { ticketId, body });
|
||||
const activeMentionedIds = mentionedIds.filter((uid) => {
|
||||
const u = adminUsers.find((au) => au.id === uid);
|
||||
return u ? body.includes(`@${u.name}`) : false;
|
||||
});
|
||||
const res = await axiosConfig.post('/AddTicketComment', {
|
||||
ticketId,
|
||||
body,
|
||||
mentionedUserIds: activeMentionedIds
|
||||
});
|
||||
commentId = res.data?.id;
|
||||
}
|
||||
|
||||
|
|
@ -90,6 +166,8 @@ function CommentThread({
|
|||
}
|
||||
|
||||
setBody('');
|
||||
setMentionedIds([]);
|
||||
setMentionQuery(null);
|
||||
setSelectedFiles([]);
|
||||
setRefreshKey((k) => k + 1);
|
||||
onCommentAdded();
|
||||
|
|
@ -166,10 +244,35 @@ function CommentThread({
|
|||
multiline
|
||||
minRows={2}
|
||||
maxRows={4}
|
||||
placeholder="Add a comment..."
|
||||
placeholder={intl.formatMessage({
|
||||
id: 'mentionPlaceholder',
|
||||
defaultMessage: 'Type @ to mention a user'
|
||||
})}
|
||||
value={body}
|
||||
onChange={(e) => setBody(e.target.value)}
|
||||
onChange={handleBodyChange}
|
||||
inputRef={commentInputRef}
|
||||
/>
|
||||
<Popper
|
||||
open={mentionQuery !== null && mentionCandidates.length > 0}
|
||||
anchorEl={commentInputRef.current}
|
||||
placement="top-start"
|
||||
style={{ zIndex: 1300 }}
|
||||
>
|
||||
<ClickAwayListener onClickAway={() => setMentionQuery(null)}>
|
||||
<Paper elevation={4} sx={{ minWidth: 200, maxHeight: 240, overflowY: 'auto' }}>
|
||||
<MenuList dense>
|
||||
{mentionCandidates.map((u) => (
|
||||
<MenuItem
|
||||
key={u.id}
|
||||
onClick={() => handleSelectMention(u.id, u.name)}
|
||||
>
|
||||
{u.name}
|
||||
</MenuItem>
|
||||
))}
|
||||
</MenuList>
|
||||
</Paper>
|
||||
</ClickAwayListener>
|
||||
</Popper>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.5, alignSelf: 'flex-end' }}>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
|
|
|
|||
|
|
@ -32,9 +32,7 @@ const statusCountKeys: {
|
|||
}[] = [
|
||||
{ status: TicketStatus.Open, id: 'statusOpen', defaultMessage: 'Open', color: '#d32f2f' },
|
||||
{ status: TicketStatus.InProgress, id: 'statusInProgress', defaultMessage: 'In Progress', color: '#ed6c02' },
|
||||
{ status: TicketStatus.Escalated, id: 'statusEscalated', defaultMessage: 'Escalated', color: '#9c27b0' },
|
||||
{ status: TicketStatus.Resolved, id: 'statusResolved', defaultMessage: 'Resolved', color: '#2e7d32' },
|
||||
{ status: TicketStatus.Closed, id: 'statusClosed', defaultMessage: 'Closed', color: '#757575' }
|
||||
{ status: TicketStatus.Resolved, id: 'statusResolved', defaultMessage: 'Solved', color: '#2e7d32' }
|
||||
];
|
||||
|
||||
function InstallationTicketsTab({ installationId }: Props) {
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ const statusLabels: Record<number, string> = {
|
|||
[TicketStatus.Open]: 'Open',
|
||||
[TicketStatus.InProgress]: 'In Progress',
|
||||
[TicketStatus.Escalated]: 'Escalated',
|
||||
[TicketStatus.Resolved]: 'Resolved',
|
||||
[TicketStatus.Resolved]: 'Solved',
|
||||
[TicketStatus.Closed]: 'Closed'
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import {
|
||||
Alert,
|
||||
|
|
@ -63,9 +63,7 @@ const priorityKeys: Record<number, { id: string; defaultMessage: string }> = {
|
|||
const statusKeys: { value: number; id: string; defaultMessage: string }[] = [
|
||||
{ value: TicketStatus.Open, id: 'statusOpen', defaultMessage: 'Open' },
|
||||
{ value: TicketStatus.InProgress, id: 'statusInProgress', defaultMessage: 'In Progress' },
|
||||
{ value: TicketStatus.Escalated, id: 'statusEscalated', defaultMessage: 'Escalated' },
|
||||
{ value: TicketStatus.Resolved, id: 'statusResolved', defaultMessage: 'Resolved' },
|
||||
{ value: TicketStatus.Closed, id: 'statusClosed', defaultMessage: 'Closed' }
|
||||
{ value: TicketStatus.Resolved, id: 'statusResolved', defaultMessage: 'Solved' }
|
||||
];
|
||||
|
||||
function TicketDetailPage() {
|
||||
|
|
@ -90,6 +88,9 @@ function TicketDetailPage() {
|
|||
const [savingDescription, setSavingDescription] = useState(false);
|
||||
const [descriptionSaved, setDescriptionSaved] = useState(false);
|
||||
const [docRefreshKey, setDocRefreshKey] = useState(0);
|
||||
const [solveGateOpen, setSolveGateOpen] = useState(false);
|
||||
const rootCauseRef = useRef<HTMLInputElement | null>(null);
|
||||
const solutionRef = useRef<HTMLInputElement | null>(null);
|
||||
|
||||
// Custom "Other" editing state
|
||||
const [editCustomSub, setEditCustomSub] = useState('');
|
||||
|
|
@ -153,9 +154,7 @@ function TicketDetailPage() {
|
|||
newStatus === TicketStatus.Resolved &&
|
||||
(!rootCause.trim() || !solution.trim())
|
||||
) {
|
||||
setResolutionError(
|
||||
'Root Cause and Solution are required to resolve a ticket.'
|
||||
);
|
||||
setSolveGateOpen(true);
|
||||
return;
|
||||
}
|
||||
setResolutionError('');
|
||||
|
|
@ -475,6 +474,7 @@ function TicketDetailPage() {
|
|||
error={
|
||||
!!resolutionError && !rootCause.trim()
|
||||
}
|
||||
inputRef={rootCauseRef}
|
||||
/>
|
||||
<TextField
|
||||
label={
|
||||
|
|
@ -491,6 +491,7 @@ function TicketDetailPage() {
|
|||
error={
|
||||
!!resolutionError && !solution.trim()
|
||||
}
|
||||
inputRef={solutionRef}
|
||||
/>
|
||||
<Box display="flex" justifyContent="flex-end" alignItems="center" gap={1}>
|
||||
{resolutionSaved && (
|
||||
|
|
@ -877,6 +878,36 @@ function TicketDetailPage() {
|
|||
</DialogActions>
|
||||
</Dialog>
|
||||
|
||||
{/* Solve-gate reminder dialog */}
|
||||
<Dialog open={solveGateOpen} onClose={() => setSolveGateOpen(false)}>
|
||||
<DialogTitle>
|
||||
<FormattedMessage
|
||||
id="solveGateTitle"
|
||||
defaultMessage="Root Cause and Solution required"
|
||||
/>
|
||||
</DialogTitle>
|
||||
<DialogContent>
|
||||
<DialogContentText>
|
||||
<FormattedMessage
|
||||
id="solveGateBody"
|
||||
defaultMessage="To mark this ticket as Solved, please fill in both Root Cause and Solution before saving."
|
||||
/>
|
||||
</DialogContentText>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button
|
||||
variant="contained"
|
||||
onClick={() => {
|
||||
setSolveGateOpen(false);
|
||||
if (!rootCause.trim()) rootCauseRef.current?.focus();
|
||||
else solutionRef.current?.focus();
|
||||
}}
|
||||
>
|
||||
<FormattedMessage id="solveGateOk" defaultMessage="OK" />
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
|
||||
</Container>
|
||||
<Footer />
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -37,9 +37,7 @@ import StatusChip from './StatusChip';
|
|||
const statusKeys: Record<number, { id: string; defaultMessage: string }> = {
|
||||
[TicketStatus.Open]: { id: 'statusOpen', defaultMessage: 'Open' },
|
||||
[TicketStatus.InProgress]: { id: 'statusInProgress', defaultMessage: 'In Progress' },
|
||||
[TicketStatus.Escalated]: { id: 'statusEscalated', defaultMessage: 'Escalated' },
|
||||
[TicketStatus.Resolved]: { id: 'statusResolved', defaultMessage: 'Resolved' },
|
||||
[TicketStatus.Closed]: { id: 'statusClosed', defaultMessage: 'Closed' }
|
||||
[TicketStatus.Resolved]: { id: 'statusResolved', defaultMessage: 'Solved' }
|
||||
};
|
||||
|
||||
const priorityKeys: Record<number, { id: string; defaultMessage: string }> = {
|
||||
|
|
|
|||
|
|
@ -598,6 +598,11 @@
|
|||
"statusEscalated": "Eskaliert",
|
||||
"statusResolved": "Gelöst",
|
||||
"statusClosed": "Geschlossen",
|
||||
"solveGateTitle": "Ursache und Lösung erforderlich",
|
||||
"solveGateBody": "Um dieses Ticket als gelöst zu markieren, bitte sowohl Ursache als auch Lösung ausfüllen, bevor Sie speichern.",
|
||||
"solveGateOk": "OK",
|
||||
"mentionPlaceholder": "@ eingeben, um einen Benutzer zu erwähnen",
|
||||
"mentionNoResults": "Keine Benutzer gefunden",
|
||||
"priorityCritical": "Kritisch",
|
||||
"priorityHigh": "Hoch",
|
||||
"priorityMedium": "Mittel",
|
||||
|
|
|
|||
|
|
@ -344,8 +344,13 @@
|
|||
"statusOpen": "Open",
|
||||
"statusInProgress": "In Progress",
|
||||
"statusEscalated": "Escalated",
|
||||
"statusResolved": "Resolved",
|
||||
"statusResolved": "Solved",
|
||||
"statusClosed": "Closed",
|
||||
"solveGateTitle": "Root Cause and Solution required",
|
||||
"solveGateBody": "To mark this ticket as Solved, please fill in both Root Cause and Solution before saving.",
|
||||
"solveGateOk": "OK",
|
||||
"mentionPlaceholder": "Type @ to mention a user",
|
||||
"mentionNoResults": "No users found",
|
||||
"priorityCritical": "Critical",
|
||||
"priorityHigh": "High",
|
||||
"priorityMedium": "Medium",
|
||||
|
|
|
|||
|
|
@ -598,6 +598,11 @@
|
|||
"statusEscalated": "Escaladé",
|
||||
"statusResolved": "Résolu",
|
||||
"statusClosed": "Fermé",
|
||||
"solveGateTitle": "Cause et solution requises",
|
||||
"solveGateBody": "Pour marquer ce ticket comme résolu, veuillez renseigner la cause et la solution avant d'enregistrer.",
|
||||
"solveGateOk": "OK",
|
||||
"mentionPlaceholder": "Tapez @ pour mentionner un utilisateur",
|
||||
"mentionNoResults": "Aucun utilisateur trouvé",
|
||||
"priorityCritical": "Critique",
|
||||
"priorityHigh": "Élevée",
|
||||
"priorityMedium": "Moyenne",
|
||||
|
|
|
|||
|
|
@ -598,6 +598,11 @@
|
|||
"statusEscalated": "Escalato",
|
||||
"statusResolved": "Risolto",
|
||||
"statusClosed": "Chiuso",
|
||||
"solveGateTitle": "Causa e soluzione richieste",
|
||||
"solveGateBody": "Per contrassegnare questo ticket come risolto, compilare sia la causa sia la soluzione prima di salvare.",
|
||||
"solveGateOk": "OK",
|
||||
"mentionPlaceholder": "Digita @ per menzionare un utente",
|
||||
"mentionNoResults": "Nessun utente trovato",
|
||||
"priorityCritical": "Critica",
|
||||
"priorityHigh": "Alta",
|
||||
"priorityMedium": "Media",
|
||||
|
|
|
|||
Loading…
Reference in New Issue