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
|
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
|
// Fire-and-forget AI diagnosis
|
||||||
var lang = user.Language ?? "en";
|
var lang = user.Language ?? "en";
|
||||||
TicketDiagnosticService.DiagnoseTicketAsync(ticket.Id, lang).SupressAwaitWarning();
|
TicketDiagnosticService.DiagnoseTicketAsync(ticket.Id, lang).SupressAwaitWarning();
|
||||||
|
|
@ -2221,6 +2232,40 @@ public class Controller : ControllerBase
|
||||||
ActorId = user.Id,
|
ActorId = user.Id,
|
||||||
CreatedAt = DateTime.UtcNow
|
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)
|
if (resolutionAdded)
|
||||||
|
|
@ -2253,7 +2298,7 @@ public class Controller : ControllerBase
|
||||||
CreatedAt = DateTime.UtcNow
|
CreatedAt = DateTime.UtcNow
|
||||||
});
|
});
|
||||||
|
|
||||||
if (assignee is not null)
|
if (assignee is not null && assignee.Id != user.Id)
|
||||||
_ = Task.Run(async () =>
|
_ = Task.Run(async () =>
|
||||||
{
|
{
|
||||||
try { await assignee.SendTicketAssignedEmail(ticket); }
|
try { await assignee.SendTicketAssignedEmail(ticket); }
|
||||||
|
|
@ -2321,6 +2366,35 @@ public class Controller : ControllerBase
|
||||||
ticket.UpdatedAt = DateTime.UtcNow;
|
ticket.UpdatedAt = DateTime.UtcNow;
|
||||||
Db.Update(ticket);
|
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;
|
return comment;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -359,4 +359,163 @@ public static class UserMethods
|
||||||
return user.SendEmail(subject, body);
|
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 Int64? AuthorId { get; set; }
|
||||||
public String Body { get; set; } = "";
|
public String Body { get; set; } = "";
|
||||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
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(TicketComment comment) => Insert(comment);
|
||||||
public static Boolean Create(TicketAiDiagnosis diagnosis) => Insert(diagnosis);
|
public static Boolean Create(TicketAiDiagnosis diagnosis) => Insert(diagnosis);
|
||||||
public static Boolean Create(TicketTimelineEvent ev) => Insert(ev);
|
public static Boolean Create(TicketTimelineEvent ev) => Insert(ev);
|
||||||
|
public static Boolean Create(TicketCommentMention mention) => Insert(mention);
|
||||||
|
|
||||||
// Document storage
|
// Document storage
|
||||||
public static Boolean Create(Document document) => Insert(document);
|
public static Boolean Create(Document document) => Insert(document);
|
||||||
|
|
|
||||||
|
|
@ -143,6 +143,7 @@ public static partial class Db
|
||||||
// Ticket system tables
|
// Ticket system tables
|
||||||
fileConnection.CreateTable<Ticket>();
|
fileConnection.CreateTable<Ticket>();
|
||||||
fileConnection.CreateTable<TicketComment>();
|
fileConnection.CreateTable<TicketComment>();
|
||||||
|
fileConnection.CreateTable<TicketCommentMention>();
|
||||||
fileConnection.CreateTable<TicketAiDiagnosis>();
|
fileConnection.CreateTable<TicketAiDiagnosis>();
|
||||||
fileConnection.CreateTable<TicketTimelineEvent>();
|
fileConnection.CreateTable<TicketTimelineEvent>();
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -7,15 +7,20 @@ import {
|
||||||
CardContent,
|
CardContent,
|
||||||
CardHeader,
|
CardHeader,
|
||||||
Chip,
|
Chip,
|
||||||
|
ClickAwayListener,
|
||||||
Divider,
|
Divider,
|
||||||
LinearProgress,
|
LinearProgress,
|
||||||
|
MenuItem,
|
||||||
|
MenuList,
|
||||||
|
Paper,
|
||||||
|
Popper,
|
||||||
TextField,
|
TextField,
|
||||||
Typography
|
Typography
|
||||||
} from '@mui/material';
|
} from '@mui/material';
|
||||||
import PersonIcon from '@mui/icons-material/Person';
|
import PersonIcon from '@mui/icons-material/Person';
|
||||||
import SmartToyIcon from '@mui/icons-material/SmartToy';
|
import SmartToyIcon from '@mui/icons-material/SmartToy';
|
||||||
import AttachFileIcon from '@mui/icons-material/AttachFile';
|
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 axiosConfig from 'src/Resources/axiosConfig';
|
||||||
import { AdminUser, CommentAuthorType, TicketComment } from 'src/interfaces/TicketTypes';
|
import { AdminUser, CommentAuthorType, TicketComment } from 'src/interfaces/TicketTypes';
|
||||||
import DocumentList from 'src/components/DocumentList';
|
import DocumentList from 'src/components/DocumentList';
|
||||||
|
|
@ -33,6 +38,7 @@ function CommentThread({
|
||||||
onCommentAdded,
|
onCommentAdded,
|
||||||
adminUsers = []
|
adminUsers = []
|
||||||
}: CommentThreadProps) {
|
}: CommentThreadProps) {
|
||||||
|
const intl = useIntl();
|
||||||
const [body, setBody] = useState('');
|
const [body, setBody] = useState('');
|
||||||
const [submitting, setSubmitting] = useState(false);
|
const [submitting, setSubmitting] = useState(false);
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
@ -40,6 +46,68 @@ function CommentThread({
|
||||||
const [uploading, setUploading] = useState(false);
|
const [uploading, setUploading] = useState(false);
|
||||||
const [refreshKey, setRefreshKey] = useState(0);
|
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 ALLOWED_TYPES = ['image/jpeg', 'image/png', 'image/gif', 'image/webp', 'application/pdf'];
|
||||||
const MAX_FILE_SIZE = 25 * 1024 * 1024;
|
const MAX_FILE_SIZE = 25 * 1024 * 1024;
|
||||||
|
|
||||||
|
|
@ -64,7 +132,15 @@ function CommentThread({
|
||||||
try {
|
try {
|
||||||
let commentId: number | undefined;
|
let commentId: number | undefined;
|
||||||
if (body.trim()) {
|
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;
|
commentId = res.data?.id;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -90,6 +166,8 @@ function CommentThread({
|
||||||
}
|
}
|
||||||
|
|
||||||
setBody('');
|
setBody('');
|
||||||
|
setMentionedIds([]);
|
||||||
|
setMentionQuery(null);
|
||||||
setSelectedFiles([]);
|
setSelectedFiles([]);
|
||||||
setRefreshKey((k) => k + 1);
|
setRefreshKey((k) => k + 1);
|
||||||
onCommentAdded();
|
onCommentAdded();
|
||||||
|
|
@ -166,10 +244,35 @@ function CommentThread({
|
||||||
multiline
|
multiline
|
||||||
minRows={2}
|
minRows={2}
|
||||||
maxRows={4}
|
maxRows={4}
|
||||||
placeholder="Add a comment..."
|
placeholder={intl.formatMessage({
|
||||||
|
id: 'mentionPlaceholder',
|
||||||
|
defaultMessage: 'Type @ to mention a user'
|
||||||
|
})}
|
||||||
value={body}
|
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' }}>
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.5, alignSelf: 'flex-end' }}>
|
||||||
<input
|
<input
|
||||||
ref={fileInputRef}
|
ref={fileInputRef}
|
||||||
|
|
|
||||||
|
|
@ -32,9 +32,7 @@ const statusCountKeys: {
|
||||||
}[] = [
|
}[] = [
|
||||||
{ status: TicketStatus.Open, id: 'statusOpen', defaultMessage: 'Open', color: '#d32f2f' },
|
{ status: TicketStatus.Open, id: 'statusOpen', defaultMessage: 'Open', color: '#d32f2f' },
|
||||||
{ status: TicketStatus.InProgress, id: 'statusInProgress', defaultMessage: 'In Progress', color: '#ed6c02' },
|
{ 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: 'Solved', color: '#2e7d32' }
|
||||||
{ status: TicketStatus.Resolved, id: 'statusResolved', defaultMessage: 'Resolved', color: '#2e7d32' },
|
|
||||||
{ status: TicketStatus.Closed, id: 'statusClosed', defaultMessage: 'Closed', color: '#757575' }
|
|
||||||
];
|
];
|
||||||
|
|
||||||
function InstallationTicketsTab({ installationId }: Props) {
|
function InstallationTicketsTab({ installationId }: Props) {
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@ const statusLabels: Record<number, string> = {
|
||||||
[TicketStatus.Open]: 'Open',
|
[TicketStatus.Open]: 'Open',
|
||||||
[TicketStatus.InProgress]: 'In Progress',
|
[TicketStatus.InProgress]: 'In Progress',
|
||||||
[TicketStatus.Escalated]: 'Escalated',
|
[TicketStatus.Escalated]: 'Escalated',
|
||||||
[TicketStatus.Resolved]: 'Resolved',
|
[TicketStatus.Resolved]: 'Solved',
|
||||||
[TicketStatus.Closed]: 'Closed'
|
[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 { useNavigate, useParams } from 'react-router-dom';
|
||||||
import {
|
import {
|
||||||
Alert,
|
Alert,
|
||||||
|
|
@ -63,9 +63,7 @@ const priorityKeys: Record<number, { id: string; defaultMessage: string }> = {
|
||||||
const statusKeys: { value: number; id: string; defaultMessage: string }[] = [
|
const statusKeys: { value: number; id: string; defaultMessage: string }[] = [
|
||||||
{ value: TicketStatus.Open, id: 'statusOpen', defaultMessage: 'Open' },
|
{ value: TicketStatus.Open, id: 'statusOpen', defaultMessage: 'Open' },
|
||||||
{ value: TicketStatus.InProgress, id: 'statusInProgress', defaultMessage: 'In Progress' },
|
{ value: TicketStatus.InProgress, id: 'statusInProgress', defaultMessage: 'In Progress' },
|
||||||
{ value: TicketStatus.Escalated, id: 'statusEscalated', defaultMessage: 'Escalated' },
|
{ value: TicketStatus.Resolved, id: 'statusResolved', defaultMessage: 'Solved' }
|
||||||
{ value: TicketStatus.Resolved, id: 'statusResolved', defaultMessage: 'Resolved' },
|
|
||||||
{ value: TicketStatus.Closed, id: 'statusClosed', defaultMessage: 'Closed' }
|
|
||||||
];
|
];
|
||||||
|
|
||||||
function TicketDetailPage() {
|
function TicketDetailPage() {
|
||||||
|
|
@ -90,6 +88,9 @@ function TicketDetailPage() {
|
||||||
const [savingDescription, setSavingDescription] = useState(false);
|
const [savingDescription, setSavingDescription] = useState(false);
|
||||||
const [descriptionSaved, setDescriptionSaved] = useState(false);
|
const [descriptionSaved, setDescriptionSaved] = useState(false);
|
||||||
const [docRefreshKey, setDocRefreshKey] = useState(0);
|
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
|
// Custom "Other" editing state
|
||||||
const [editCustomSub, setEditCustomSub] = useState('');
|
const [editCustomSub, setEditCustomSub] = useState('');
|
||||||
|
|
@ -153,9 +154,7 @@ function TicketDetailPage() {
|
||||||
newStatus === TicketStatus.Resolved &&
|
newStatus === TicketStatus.Resolved &&
|
||||||
(!rootCause.trim() || !solution.trim())
|
(!rootCause.trim() || !solution.trim())
|
||||||
) {
|
) {
|
||||||
setResolutionError(
|
setSolveGateOpen(true);
|
||||||
'Root Cause and Solution are required to resolve a ticket.'
|
|
||||||
);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setResolutionError('');
|
setResolutionError('');
|
||||||
|
|
@ -475,6 +474,7 @@ function TicketDetailPage() {
|
||||||
error={
|
error={
|
||||||
!!resolutionError && !rootCause.trim()
|
!!resolutionError && !rootCause.trim()
|
||||||
}
|
}
|
||||||
|
inputRef={rootCauseRef}
|
||||||
/>
|
/>
|
||||||
<TextField
|
<TextField
|
||||||
label={
|
label={
|
||||||
|
|
@ -491,6 +491,7 @@ function TicketDetailPage() {
|
||||||
error={
|
error={
|
||||||
!!resolutionError && !solution.trim()
|
!!resolutionError && !solution.trim()
|
||||||
}
|
}
|
||||||
|
inputRef={solutionRef}
|
||||||
/>
|
/>
|
||||||
<Box display="flex" justifyContent="flex-end" alignItems="center" gap={1}>
|
<Box display="flex" justifyContent="flex-end" alignItems="center" gap={1}>
|
||||||
{resolutionSaved && (
|
{resolutionSaved && (
|
||||||
|
|
@ -877,6 +878,36 @@ function TicketDetailPage() {
|
||||||
</DialogActions>
|
</DialogActions>
|
||||||
</Dialog>
|
</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>
|
</Container>
|
||||||
<Footer />
|
<Footer />
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -37,9 +37,7 @@ import StatusChip from './StatusChip';
|
||||||
const statusKeys: Record<number, { id: string; defaultMessage: string }> = {
|
const statusKeys: Record<number, { id: string; defaultMessage: string }> = {
|
||||||
[TicketStatus.Open]: { id: 'statusOpen', defaultMessage: 'Open' },
|
[TicketStatus.Open]: { id: 'statusOpen', defaultMessage: 'Open' },
|
||||||
[TicketStatus.InProgress]: { id: 'statusInProgress', defaultMessage: 'In Progress' },
|
[TicketStatus.InProgress]: { id: 'statusInProgress', defaultMessage: 'In Progress' },
|
||||||
[TicketStatus.Escalated]: { id: 'statusEscalated', defaultMessage: 'Escalated' },
|
[TicketStatus.Resolved]: { id: 'statusResolved', defaultMessage: 'Solved' }
|
||||||
[TicketStatus.Resolved]: { id: 'statusResolved', defaultMessage: 'Resolved' },
|
|
||||||
[TicketStatus.Closed]: { id: 'statusClosed', defaultMessage: 'Closed' }
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const priorityKeys: Record<number, { id: string; defaultMessage: string }> = {
|
const priorityKeys: Record<number, { id: string; defaultMessage: string }> = {
|
||||||
|
|
|
||||||
|
|
@ -598,6 +598,11 @@
|
||||||
"statusEscalated": "Eskaliert",
|
"statusEscalated": "Eskaliert",
|
||||||
"statusResolved": "Gelöst",
|
"statusResolved": "Gelöst",
|
||||||
"statusClosed": "Geschlossen",
|
"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",
|
"priorityCritical": "Kritisch",
|
||||||
"priorityHigh": "Hoch",
|
"priorityHigh": "Hoch",
|
||||||
"priorityMedium": "Mittel",
|
"priorityMedium": "Mittel",
|
||||||
|
|
|
||||||
|
|
@ -344,8 +344,13 @@
|
||||||
"statusOpen": "Open",
|
"statusOpen": "Open",
|
||||||
"statusInProgress": "In Progress",
|
"statusInProgress": "In Progress",
|
||||||
"statusEscalated": "Escalated",
|
"statusEscalated": "Escalated",
|
||||||
"statusResolved": "Resolved",
|
"statusResolved": "Solved",
|
||||||
"statusClosed": "Closed",
|
"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",
|
"priorityCritical": "Critical",
|
||||||
"priorityHigh": "High",
|
"priorityHigh": "High",
|
||||||
"priorityMedium": "Medium",
|
"priorityMedium": "Medium",
|
||||||
|
|
|
||||||
|
|
@ -598,6 +598,11 @@
|
||||||
"statusEscalated": "Escaladé",
|
"statusEscalated": "Escaladé",
|
||||||
"statusResolved": "Résolu",
|
"statusResolved": "Résolu",
|
||||||
"statusClosed": "Fermé",
|
"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",
|
"priorityCritical": "Critique",
|
||||||
"priorityHigh": "Élevée",
|
"priorityHigh": "Élevée",
|
||||||
"priorityMedium": "Moyenne",
|
"priorityMedium": "Moyenne",
|
||||||
|
|
|
||||||
|
|
@ -598,6 +598,11 @@
|
||||||
"statusEscalated": "Escalato",
|
"statusEscalated": "Escalato",
|
||||||
"statusResolved": "Risolto",
|
"statusResolved": "Risolto",
|
||||||
"statusClosed": "Chiuso",
|
"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",
|
"priorityCritical": "Critica",
|
||||||
"priorityHigh": "Alta",
|
"priorityHigh": "Alta",
|
||||||
"priorityMedium": "Media",
|
"priorityMedium": "Media",
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue