add notificiation system to ticket dashboard

This commit is contained in:
Yinyin Liu 2026-04-15 16:15:03 +02:00
parent 544f9602e1
commit 3fbb2eeee0
15 changed files with 419 additions and 20 deletions

View File

@ -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;
}

View File

@ -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);
}
}

View File

@ -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();
}

View File

@ -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;
}

View File

@ -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);

View File

@ -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>();

View File

@ -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}

View File

@ -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) {

View File

@ -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'
};

View File

@ -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>

View File

@ -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 }> = {

View File

@ -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",

View File

@ -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",

View File

@ -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",

View File

@ -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",