Compare commits
No commits in common. "c189a077fb9df1df98f9d68d72a4d5a3c7685cfc" and "ed00b742a1ff7a27fe537cc7f8b43b4e8dd75e05" have entirely different histories.
c189a077fb
...
ed00b742a1
|
|
@ -2501,42 +2501,6 @@ public class Controller : ControllerBase
|
||||||
return comment;
|
return comment;
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpDelete(nameof(DeleteTicketComment))]
|
|
||||||
public async Task<ActionResult> DeleteTicketComment(Int64 id, Token authToken)
|
|
||||||
{
|
|
||||||
var user = Db.GetSession(authToken)?.User;
|
|
||||||
if (user is null || user.UserType != 2) return Unauthorized();
|
|
||||||
|
|
||||||
var comment = Db.TicketComments.FirstOrDefault(c => c.Id == id);
|
|
||||||
if (comment is null) return NotFound();
|
|
||||||
|
|
||||||
// Author-only; AI comments cannot be deleted via this endpoint.
|
|
||||||
if (comment.AuthorType != (Int32)CommentAuthorType.Human) return Unauthorized();
|
|
||||||
if (comment.AuthorId != user.Id) return Unauthorized();
|
|
||||||
|
|
||||||
// Clean up S3 objects for documents attached to this comment before DB delete.
|
|
||||||
var s3Keys = Db.Documents
|
|
||||||
.Where(d => d.TicketCommentId == comment.Id)
|
|
||||||
.Select(d => d.S3Key)
|
|
||||||
.ToList();
|
|
||||||
if (s3Keys.Count > 0)
|
|
||||||
{
|
|
||||||
try { await DocumentBucket.DeleteObjects(s3Keys); }
|
|
||||||
catch (Exception ex) { Console.WriteLine($"[Documents] S3 cleanup on comment delete failed: {ex.Message}"); }
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!Db.Delete(comment)) return StatusCode(500, "Failed to delete comment.");
|
|
||||||
|
|
||||||
var ticket = Db.GetTicketById(comment.TicketId);
|
|
||||||
if (ticket is not null)
|
|
||||||
{
|
|
||||||
ticket.UpdatedAt = DateTime.UtcNow;
|
|
||||||
Db.Update(ticket);
|
|
||||||
}
|
|
||||||
|
|
||||||
return Ok();
|
|
||||||
}
|
|
||||||
|
|
||||||
[HttpGet(nameof(GetTicketDetail))]
|
[HttpGet(nameof(GetTicketDetail))]
|
||||||
public ActionResult<Object> GetTicketDetail(Int64 id, Token authToken)
|
public ActionResult<Object> GetTicketDetail(Int64 id, Token authToken)
|
||||||
{
|
{
|
||||||
|
|
@ -2570,33 +2534,15 @@ public class Controller : ControllerBase
|
||||||
if (user is null || user.UserType != 2) return Unauthorized();
|
if (user is null || user.UserType != 2) return Unauthorized();
|
||||||
|
|
||||||
var tickets = Db.GetAllTickets();
|
var tickets = Db.GetAllTickets();
|
||||||
|
|
||||||
var installationIds = tickets
|
|
||||||
.Where(t => t.InstallationId.HasValue)
|
|
||||||
.Select(t => t.InstallationId!.Value)
|
|
||||||
.Distinct()
|
|
||||||
.ToList();
|
|
||||||
|
|
||||||
var installationsById = installationIds.Count == 0
|
|
||||||
? new Dictionary<Int64, Installation>()
|
|
||||||
: Db.Installations
|
|
||||||
.Where(i => installationIds.Contains(i.Id))
|
|
||||||
.ToList()
|
|
||||||
.ToDictionary(i => i.Id);
|
|
||||||
|
|
||||||
var summaries = tickets.Select(t =>
|
var summaries = tickets.Select(t =>
|
||||||
{
|
{
|
||||||
Installation? installation = null;
|
var installation = t.InstallationId.HasValue ? Db.GetInstallationById(t.InstallationId.Value) : null;
|
||||||
if (t.InstallationId.HasValue)
|
|
||||||
installationsById.TryGetValue(t.InstallationId.Value, out installation);
|
|
||||||
|
|
||||||
return new
|
return new
|
||||||
{
|
{
|
||||||
t.Id, t.Subject, t.Status, t.Priority, t.Category, t.SubCategory,
|
t.Id, t.Subject, t.Status, t.Priority, t.Category, t.SubCategory,
|
||||||
t.InstallationId, t.CreatedAt, t.UpdatedAt,
|
t.InstallationId, t.CreatedAt, t.UpdatedAt,
|
||||||
t.CustomSubCategory, t.CustomCategory,
|
t.CustomSubCategory, t.CustomCategory,
|
||||||
installationName = installation?.Name ?? (t.InstallationId.HasValue ? $"#{t.InstallationId}" : "No installation"),
|
installationName = installation?.Name ?? (t.InstallationId.HasValue ? $"#{t.InstallationId}" : "No installation")
|
||||||
distributionPartner = installation?.DistributionPartner ?? ""
|
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -36,7 +36,6 @@ public static partial class Db
|
||||||
// Ticket system tables
|
// Ticket system tables
|
||||||
public static TableQuery<Ticket> Tickets => Connection.Table<Ticket>();
|
public static TableQuery<Ticket> Tickets => Connection.Table<Ticket>();
|
||||||
public static TableQuery<TicketComment> TicketComments => Connection.Table<TicketComment>();
|
public static TableQuery<TicketComment> TicketComments => Connection.Table<TicketComment>();
|
||||||
public static TableQuery<TicketCommentMention> TicketCommentMentions => Connection.Table<TicketCommentMention>();
|
|
||||||
public static TableQuery<TicketAiDiagnosis> TicketAiDiagnoses => Connection.Table<TicketAiDiagnosis>();
|
public static TableQuery<TicketAiDiagnosis> TicketAiDiagnoses => Connection.Table<TicketAiDiagnosis>();
|
||||||
public static TableQuery<TicketTimelineEvent> TicketTimelineEvents => Connection.Table<TicketTimelineEvent>();
|
public static TableQuery<TicketTimelineEvent> TicketTimelineEvents => Connection.Table<TicketTimelineEvent>();
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -129,13 +129,10 @@ public static partial class Db
|
||||||
.Select(t => t.Id).ToList();
|
.Select(t => t.Id).ToList();
|
||||||
foreach (var tid in ticketIds)
|
foreach (var tid in ticketIds)
|
||||||
{
|
{
|
||||||
// Delete documents and mentions attached to ticket comments
|
// Delete documents attached to ticket comments
|
||||||
var tCommentIds = TicketComments.Where(c => c.TicketId == tid).Select(c => c.Id).ToList();
|
var tCommentIds = TicketComments.Where(c => c.TicketId == tid).Select(c => c.Id).ToList();
|
||||||
foreach (var cid in tCommentIds)
|
foreach (var cid in tCommentIds)
|
||||||
{
|
Documents.Delete(d => d.TicketCommentId == cid);
|
||||||
Documents .Delete(d => d.TicketCommentId == cid);
|
|
||||||
TicketCommentMentions .Delete(m => m.CommentId == cid);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Delete documents attached directly to the ticket
|
// Delete documents attached directly to the ticket
|
||||||
Documents .Delete(d => d.TicketId == tid);
|
Documents .Delete(d => d.TicketId == tid);
|
||||||
|
|
@ -234,16 +231,13 @@ public static partial class Db
|
||||||
|
|
||||||
Boolean DeleteTicketAndChildren()
|
Boolean DeleteTicketAndChildren()
|
||||||
{
|
{
|
||||||
// Delete documents and mentions attached to comments on this ticket
|
// Delete documents attached to comments on this ticket
|
||||||
var commentIds = TicketComments
|
var commentIds = TicketComments
|
||||||
.Where(c => c.TicketId == ticket.Id)
|
.Where(c => c.TicketId == ticket.Id)
|
||||||
.Select(c => c.Id)
|
.Select(c => c.Id)
|
||||||
.ToList();
|
.ToList();
|
||||||
foreach (var cid in commentIds)
|
foreach (var cid in commentIds)
|
||||||
{
|
Documents.Delete(d => d.TicketCommentId == cid);
|
||||||
Documents .Delete(d => d.TicketCommentId == cid);
|
|
||||||
TicketCommentMentions .Delete(m => m.CommentId == cid);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Delete documents attached directly to the ticket
|
// Delete documents attached directly to the ticket
|
||||||
Documents .Delete(d => d.TicketId == ticket.Id);
|
Documents .Delete(d => d.TicketId == ticket.Id);
|
||||||
|
|
@ -262,21 +256,6 @@ public static partial class Db
|
||||||
return success;
|
return success;
|
||||||
}
|
}
|
||||||
|
|
||||||
public static Boolean Delete(TicketComment comment)
|
|
||||||
{
|
|
||||||
var deleteSuccess = RunTransaction(DeleteCommentAndChildren);
|
|
||||||
if (deleteSuccess) Backup();
|
|
||||||
return deleteSuccess;
|
|
||||||
|
|
||||||
Boolean DeleteCommentAndChildren()
|
|
||||||
{
|
|
||||||
// Document rows attached to this comment (S3 cleanup happens in the controller)
|
|
||||||
Documents .Delete(d => d.TicketCommentId == comment.Id);
|
|
||||||
TicketCommentMentions .Delete(m => m.CommentId == comment.Id);
|
|
||||||
return TicketComments .Delete(c => c.Id == comment.Id) > 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public static List<String> GetS3KeysForTicketDocuments(Int64 ticketId)
|
public static List<String> GetS3KeysForTicketDocuments(Int64 ticketId)
|
||||||
{
|
{
|
||||||
// Get documents attached directly to the ticket
|
// Get documents attached directly to the ticket
|
||||||
|
|
|
||||||
|
|
@ -1,128 +0,0 @@
|
||||||
import React, { useEffect, useState } from 'react';
|
|
||||||
import { Box, Dialog, DialogContent, IconButton } from '@mui/material';
|
|
||||||
import CloseIcon from '@mui/icons-material/Close';
|
|
||||||
import BrokenImageIcon from '@mui/icons-material/BrokenImage';
|
|
||||||
import axiosConfig from 'src/Resources/axiosConfig';
|
|
||||||
|
|
||||||
interface DocumentImageProps {
|
|
||||||
docId?: number;
|
|
||||||
src?: string;
|
|
||||||
alt?: string;
|
|
||||||
maxHeight?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
function DocumentImage({ docId, src, alt = '', maxHeight = 480 }: DocumentImageProps) {
|
|
||||||
const [blobUrl, setBlobUrl] = useState<string | null>(null);
|
|
||||||
const [failed, setFailed] = useState(false);
|
|
||||||
const [open, setOpen] = useState(false);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (src) {
|
|
||||||
setBlobUrl(src);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!docId) return;
|
|
||||||
let cancelled = false;
|
|
||||||
let createdUrl: string | null = null;
|
|
||||||
axiosConfig
|
|
||||||
.get('/DownloadDocument', { params: { id: docId }, responseType: 'blob' })
|
|
||||||
.then((res) => {
|
|
||||||
if (cancelled) return;
|
|
||||||
const url = window.URL.createObjectURL(new Blob([res.data]));
|
|
||||||
createdUrl = url;
|
|
||||||
setBlobUrl(url);
|
|
||||||
})
|
|
||||||
.catch(() => {
|
|
||||||
if (!cancelled) setFailed(true);
|
|
||||||
});
|
|
||||||
return () => {
|
|
||||||
cancelled = true;
|
|
||||||
if (createdUrl) window.URL.revokeObjectURL(createdUrl);
|
|
||||||
};
|
|
||||||
}, [docId, src]);
|
|
||||||
|
|
||||||
if (failed) {
|
|
||||||
return (
|
|
||||||
<Box
|
|
||||||
sx={{
|
|
||||||
display: 'inline-flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
gap: 0.5,
|
|
||||||
color: 'text.disabled',
|
|
||||||
fontSize: '0.85rem',
|
|
||||||
my: 0.5
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<BrokenImageIcon fontSize="small" />
|
|
||||||
{alt || 'Image unavailable'}
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!blobUrl) {
|
|
||||||
return (
|
|
||||||
<Box
|
|
||||||
sx={{
|
|
||||||
display: 'inline-block',
|
|
||||||
width: 120,
|
|
||||||
height: 80,
|
|
||||||
bgcolor: 'action.hover',
|
|
||||||
borderRadius: 1,
|
|
||||||
my: 0.5
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<Box
|
|
||||||
component="img"
|
|
||||||
src={blobUrl}
|
|
||||||
alt={alt}
|
|
||||||
onClick={() => setOpen(true)}
|
|
||||||
sx={{
|
|
||||||
display: 'block',
|
|
||||||
maxWidth: '100%',
|
|
||||||
maxHeight,
|
|
||||||
borderRadius: 1,
|
|
||||||
border: '1px solid',
|
|
||||||
borderColor: 'divider',
|
|
||||||
cursor: 'zoom-in',
|
|
||||||
my: 0.5
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Dialog open={open} onClose={() => setOpen(false)} maxWidth="lg">
|
|
||||||
<DialogContent sx={{ p: 0, position: 'relative', bgcolor: 'common.black' }}>
|
|
||||||
<IconButton
|
|
||||||
onClick={() => setOpen(false)}
|
|
||||||
sx={{
|
|
||||||
position: 'absolute',
|
|
||||||
top: 8,
|
|
||||||
right: 8,
|
|
||||||
bgcolor: 'rgba(0,0,0,0.5)',
|
|
||||||
color: 'common.white',
|
|
||||||
'&:hover': { bgcolor: 'rgba(0,0,0,0.7)' }
|
|
||||||
}}
|
|
||||||
aria-label="Close"
|
|
||||||
>
|
|
||||||
<CloseIcon />
|
|
||||||
</IconButton>
|
|
||||||
<Box
|
|
||||||
component="img"
|
|
||||||
src={blobUrl}
|
|
||||||
alt={alt}
|
|
||||||
sx={{
|
|
||||||
display: 'block',
|
|
||||||
maxWidth: '90vw',
|
|
||||||
maxHeight: '90vh',
|
|
||||||
objectFit: 'contain'
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default DocumentImage;
|
|
||||||
|
|
@ -404,7 +404,6 @@ function InformationSodistorehome(props: InformationSodistorehomeProps) {
|
||||||
|
|
||||||
const [networkProviders, setNetworkProviders] = useState<string[]>([]);
|
const [networkProviders, setNetworkProviders] = useState<string[]>([]);
|
||||||
const [loadingProviders, setLoadingProviders] = useState(false);
|
const [loadingProviders, setLoadingProviders] = useState(false);
|
||||||
const [installationDate, setInstallationDate] = useState<string>('');
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setLoadingProviders(true);
|
setLoadingProviders(true);
|
||||||
|
|
@ -415,23 +414,6 @@ function InformationSodistorehome(props: InformationSodistorehomeProps) {
|
||||||
.finally(() => setLoadingProviders(false));
|
.finally(() => setLoadingProviders(false));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!props.values.id) return;
|
|
||||||
axiosConfig
|
|
||||||
.get('/GetChecklistForInstallation', {
|
|
||||||
params: { installationId: props.values.id }
|
|
||||||
})
|
|
||||||
.then((res) => {
|
|
||||||
if (Array.isArray(res.data)) {
|
|
||||||
const step10 = res.data.find(
|
|
||||||
(i: { stepNumber?: number }) => i.stepNumber === 10
|
|
||||||
);
|
|
||||||
setInstallationDate(step10?.doneAt ?? '');
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.catch(() => setInstallationDate(''));
|
|
||||||
}, [props.values.id]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{openModalDeleteInstallation && (
|
{openModalDeleteInstallation && (
|
||||||
|
|
@ -874,18 +856,6 @@ function InformationSodistorehome(props: InformationSodistorehomeProps) {
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
|
||||||
<TextField
|
|
||||||
label={<FormattedMessage id="installationDate" defaultMessage="Installation Date" />}
|
|
||||||
type="date"
|
|
||||||
value={installationDate}
|
|
||||||
variant="outlined"
|
|
||||||
fullWidth
|
|
||||||
InputLabelProps={{ shrink: true }}
|
|
||||||
inputProps={{ readOnly: true }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<FormControl sx={{ m: 1, width: '50ch' }}>
|
<FormControl sx={{ m: 1, width: '50ch' }}>
|
||||||
<InputLabel
|
<InputLabel
|
||||||
|
|
|
||||||
|
|
@ -327,33 +327,21 @@ const FlatInstallationView = (props: FlatInstallationViewProps) => {
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<FormattedMessage id="name" defaultMessage="Name" />
|
<FormattedMessage id="name" defaultMessage="Name" />
|
||||||
</TableCell>
|
</TableCell>
|
||||||
{product === 4 && (
|
|
||||||
<TableCell>
|
|
||||||
<FormattedMessage id="model" defaultMessage="Model" />
|
|
||||||
</TableCell>
|
|
||||||
)}
|
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<FormattedMessage
|
<FormattedMessage
|
||||||
id="location"
|
id="location"
|
||||||
defaultMessage="Location"
|
defaultMessage="Location"
|
||||||
/>
|
/>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
{product !== 4 && (
|
<TableCell>
|
||||||
<TableCell>
|
<FormattedMessage id="country" defaultMessage="Country" />
|
||||||
<FormattedMessage id="country" defaultMessage="Country" />
|
</TableCell>
|
||||||
</TableCell>
|
|
||||||
)}
|
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<FormattedMessage
|
<FormattedMessage
|
||||||
id="orderNumbers"
|
id="orderNumbers"
|
||||||
defaultMessage="Order Numbers"
|
defaultMessage="Order Numbers"
|
||||||
/>
|
/>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
{product === 4 && (
|
|
||||||
<TableCell>
|
|
||||||
<FormattedMessage id="city" defaultMessage="City" />
|
|
||||||
</TableCell>
|
|
||||||
)}
|
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<FormattedMessage id="status" defaultMessage="Status" />
|
<FormattedMessage id="status" defaultMessage="Status" />
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
|
@ -408,21 +396,6 @@ const FlatInstallationView = (props: FlatInstallationViewProps) => {
|
||||||
</Typography>
|
</Typography>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
|
||||||
{product === 4 && (
|
|
||||||
<TableCell>
|
|
||||||
<Typography
|
|
||||||
variant="body2"
|
|
||||||
fontWeight="bold"
|
|
||||||
color="text.primary"
|
|
||||||
gutterBottom
|
|
||||||
noWrap
|
|
||||||
sx={{ marginTop: '10px', fontSize: 'small' }}
|
|
||||||
>
|
|
||||||
{installation.installationModel || ''}
|
|
||||||
</Typography>
|
|
||||||
</TableCell>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<Typography
|
<Typography
|
||||||
variant="body2"
|
variant="body2"
|
||||||
|
|
@ -436,20 +409,18 @@ const FlatInstallationView = (props: FlatInstallationViewProps) => {
|
||||||
</Typography>
|
</Typography>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
|
||||||
{product !== 4 && (
|
<TableCell>
|
||||||
<TableCell>
|
<Typography
|
||||||
<Typography
|
variant="body2"
|
||||||
variant="body2"
|
fontWeight="bold"
|
||||||
fontWeight="bold"
|
color="text.primary"
|
||||||
color="text.primary"
|
gutterBottom
|
||||||
gutterBottom
|
noWrap
|
||||||
noWrap
|
sx={{ marginTop: '10px', fontSize: 'small' }}
|
||||||
sx={{ marginTop: '10px', fontSize: 'small' }}
|
>
|
||||||
>
|
{installation.country}
|
||||||
{installation.country}
|
</Typography>
|
||||||
</Typography>
|
</TableCell>
|
||||||
</TableCell>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<Typography
|
<Typography
|
||||||
|
|
@ -464,21 +435,6 @@ const FlatInstallationView = (props: FlatInstallationViewProps) => {
|
||||||
</Typography>
|
</Typography>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
|
||||||
{product === 4 && (
|
|
||||||
<TableCell>
|
|
||||||
<Typography
|
|
||||||
variant="body2"
|
|
||||||
fontWeight="bold"
|
|
||||||
color="text.primary"
|
|
||||||
gutterBottom
|
|
||||||
noWrap
|
|
||||||
sx={{ marginTop: '10px', fontSize: 'small' }}
|
|
||||||
>
|
|
||||||
{installation.city || ''}
|
|
||||||
</Typography>
|
|
||||||
</TableCell>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
|
|
|
||||||
|
|
@ -136,16 +136,16 @@ const FlatInstallationView = (props: FlatInstallationViewProps) => {
|
||||||
<FormattedMessage id="name" defaultMessage="Name" />
|
<FormattedMessage id="name" defaultMessage="Name" />
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<FormattedMessage id="model" defaultMessage="Model" />
|
<FormattedMessage id="installationSN" defaultMessage="Installation SN" />
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<FormattedMessage id="DeviceType" defaultMessage="Device Type" />
|
<FormattedMessage id="DeviceType" defaultMessage="Device Type" />
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<FormattedMessage id="installationSN" defaultMessage="Installation SN" />
|
<FormattedMessage id="canton" defaultMessage="Canton" />
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<FormattedMessage id="city" defaultMessage="City" />
|
<FormattedMessage id="country" defaultMessage="Country" />
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<FormattedMessage id="status" defaultMessage="Status" />
|
<FormattedMessage id="status" defaultMessage="Status" />
|
||||||
|
|
@ -202,7 +202,7 @@ const FlatInstallationView = (props: FlatInstallationViewProps) => {
|
||||||
noWrap
|
noWrap
|
||||||
sx={{ marginTop: '10px', fontSize: 'small' }}
|
sx={{ marginTop: '10px', fontSize: 'small' }}
|
||||||
>
|
>
|
||||||
{installation.installationModel || ''}
|
{installation.serialNumber}
|
||||||
</Typography>
|
</Typography>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
|
||||||
|
|
@ -228,7 +228,7 @@ const FlatInstallationView = (props: FlatInstallationViewProps) => {
|
||||||
noWrap
|
noWrap
|
||||||
sx={{ marginTop: '10px', fontSize: 'small' }}
|
sx={{ marginTop: '10px', fontSize: 'small' }}
|
||||||
>
|
>
|
||||||
{installation.serialNumber}
|
{installation.canton || ''}
|
||||||
</Typography>
|
</Typography>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
|
||||||
|
|
@ -241,7 +241,7 @@ const FlatInstallationView = (props: FlatInstallationViewProps) => {
|
||||||
noWrap
|
noWrap
|
||||||
sx={{ marginTop: '10px', fontSize: 'small' }}
|
sx={{ marginTop: '10px', fontSize: 'small' }}
|
||||||
>
|
>
|
||||||
{installation.city || ''}
|
{installation.country}
|
||||||
</Typography>
|
</Typography>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,6 @@
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Box, Button, Tooltip } from '@mui/material';
|
import { Box, Button, Tooltip } from '@mui/material';
|
||||||
import FormatBoldIcon from '@mui/icons-material/FormatBold';
|
import FormatBoldIcon from '@mui/icons-material/FormatBold';
|
||||||
import FormatListBulletedIcon from '@mui/icons-material/FormatListBulleted';
|
|
||||||
import FormatListNumberedIcon from '@mui/icons-material/FormatListNumbered';
|
|
||||||
import { useIntl } from 'react-intl';
|
import { useIntl } from 'react-intl';
|
||||||
import { applyFormat, FormatKind } from './commentMarkdown';
|
import { applyFormat, FormatKind } from './commentMarkdown';
|
||||||
|
|
||||||
|
|
@ -50,17 +48,10 @@ function CommentFormatToolbar({
|
||||||
</Button>
|
</Button>
|
||||||
</span>
|
</span>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
<Tooltip title={intl.formatMessage({ id: 'commentFormatBullet', defaultMessage: 'Bullet list' })}>
|
<Tooltip title={intl.formatMessage({ id: 'commentFormatH3', defaultMessage: 'Heading 3' })}>
|
||||||
<span>
|
<span>
|
||||||
<Button size="small" variant="outlined" sx={btnSx} onClick={handle('bullet')} disabled={disabled}>
|
<Button size="small" variant="outlined" sx={btnSx} onClick={handle('h3')} disabled={disabled}>
|
||||||
<FormatListBulletedIcon fontSize="inherit" />
|
H3
|
||||||
</Button>
|
|
||||||
</span>
|
|
||||||
</Tooltip>
|
|
||||||
<Tooltip title={intl.formatMessage({ id: 'commentFormatNumbered', defaultMessage: 'Numbered list' })}>
|
|
||||||
<span>
|
|
||||||
<Button size="small" variant="outlined" sx={btnSx} onClick={handle('numbered')} disabled={disabled}>
|
|
||||||
<FormatListNumberedIcon fontSize="inherit" />
|
|
||||||
</Button>
|
</Button>
|
||||||
</span>
|
</span>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
import React, { useContext, useEffect, useRef, useState } from 'react';
|
import React, { useContext, useRef, useState } from 'react';
|
||||||
import { UserContext } from 'src/contexts/userContext';
|
import { UserContext } from 'src/contexts/userContext';
|
||||||
import {
|
import {
|
||||||
Alert,
|
|
||||||
Avatar,
|
Avatar,
|
||||||
Box,
|
Box,
|
||||||
Button,
|
Button,
|
||||||
|
|
@ -10,35 +9,24 @@ import {
|
||||||
CardHeader,
|
CardHeader,
|
||||||
Chip,
|
Chip,
|
||||||
ClickAwayListener,
|
ClickAwayListener,
|
||||||
Dialog,
|
|
||||||
DialogActions,
|
|
||||||
DialogContent,
|
|
||||||
DialogContentText,
|
|
||||||
DialogTitle,
|
|
||||||
Divider,
|
Divider,
|
||||||
IconButton,
|
|
||||||
LinearProgress,
|
LinearProgress,
|
||||||
MenuItem,
|
MenuItem,
|
||||||
MenuList,
|
MenuList,
|
||||||
Paper,
|
Paper,
|
||||||
Popper,
|
Popper,
|
||||||
Snackbar,
|
|
||||||
TextField,
|
TextField,
|
||||||
Tooltip,
|
|
||||||
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 EditIcon from '@mui/icons-material/Edit';
|
|
||||||
import DeleteIcon from '@mui/icons-material/Delete';
|
|
||||||
import { FormattedMessage, useIntl } 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';
|
||||||
import CommentFormatToolbar from './CommentFormatToolbar';
|
import CommentFormatToolbar from './CommentFormatToolbar';
|
||||||
import { renderCommentBody, handleListEnter } from './commentMarkdown';
|
import { renderCommentBody } from './commentMarkdown';
|
||||||
import { usePasteImage, PendingPaste } from 'src/hooks/usePasteImage';
|
|
||||||
|
|
||||||
interface CommentThreadProps {
|
interface CommentThreadProps {
|
||||||
ticketId: number;
|
ticketId: number;
|
||||||
|
|
@ -61,8 +49,6 @@ function CommentThread({
|
||||||
const [editingId, setEditingId] = useState<number | null>(null);
|
const [editingId, setEditingId] = useState<number | null>(null);
|
||||||
const [editBody, setEditBody] = useState('');
|
const [editBody, setEditBody] = useState('');
|
||||||
const [savingEdit, setSavingEdit] = useState(false);
|
const [savingEdit, setSavingEdit] = useState(false);
|
||||||
const [deleteCandidateId, setDeleteCandidateId] = useState<number | null>(null);
|
|
||||||
const [deletingId, setDeletingId] = useState<number | null>(null);
|
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
const [selectedFiles, setSelectedFiles] = useState<File[]>([]);
|
const [selectedFiles, setSelectedFiles] = useState<File[]>([]);
|
||||||
const [uploading, setUploading] = useState(false);
|
const [uploading, setUploading] = useState(false);
|
||||||
|
|
@ -73,37 +59,6 @@ function CommentThread({
|
||||||
const commentInputRef = useRef<HTMLInputElement | null>(null);
|
const commentInputRef = useRef<HTMLInputElement | null>(null);
|
||||||
const editInputRef = useRef<HTMLInputElement | null>(null);
|
const editInputRef = useRef<HTMLInputElement | null>(null);
|
||||||
|
|
||||||
const [pendingPastes, setPendingPastes] = useState<PendingPaste[]>([]);
|
|
||||||
const [pasteError, setPasteError] = useState<'tooLarge' | 'uploadFailed' | null>(null);
|
|
||||||
|
|
||||||
const pendingPastesRef = useRef<PendingPaste[]>([]);
|
|
||||||
pendingPastesRef.current = pendingPastes;
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
return () => {
|
|
||||||
pendingPastesRef.current.forEach((p) => window.URL.revokeObjectURL(p.blobUrl));
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handlePasteNewComment = usePasteImage({
|
|
||||||
mode: 'deferred',
|
|
||||||
textareaRef: commentInputRef,
|
|
||||||
value: body,
|
|
||||||
onChange: setBody,
|
|
||||||
onError: setPasteError,
|
|
||||||
onBufferedPaste: (p) => setPendingPastes((prev) => [...prev, p])
|
|
||||||
});
|
|
||||||
|
|
||||||
const handlePasteEditComment = usePasteImage({
|
|
||||||
mode: 'immediate',
|
|
||||||
textareaRef: editInputRef,
|
|
||||||
value: editBody,
|
|
||||||
onChange: setEditBody,
|
|
||||||
onError: setPasteError,
|
|
||||||
ticketId,
|
|
||||||
ticketCommentId: editingId ?? undefined
|
|
||||||
});
|
|
||||||
|
|
||||||
const MENTION_EXCLUDED_NAMES = ['inesco energy Master Admin'];
|
const MENTION_EXCLUDED_NAMES = ['inesco energy Master Admin'];
|
||||||
|
|
||||||
const mentionCandidates = mentionQuery === null
|
const mentionCandidates = mentionQuery === null
|
||||||
|
|
@ -192,23 +147,6 @@ function CommentThread({
|
||||||
setEditBody('');
|
setEditBody('');
|
||||||
};
|
};
|
||||||
|
|
||||||
const confirmDelete = async () => {
|
|
||||||
if (deleteCandidateId == null) return;
|
|
||||||
const id = deleteCandidateId;
|
|
||||||
setDeletingId(id);
|
|
||||||
try {
|
|
||||||
await axiosConfig.delete('/DeleteTicketComment', { params: { id } });
|
|
||||||
setDeleteCandidateId(null);
|
|
||||||
if (editingId === id) {
|
|
||||||
setEditingId(null);
|
|
||||||
setEditBody('');
|
|
||||||
}
|
|
||||||
onCommentAdded();
|
|
||||||
} finally {
|
|
||||||
setDeletingId(null);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const saveEdit = async (commentId: number) => {
|
const saveEdit = async (commentId: number) => {
|
||||||
if (!editBody.trim()) return;
|
if (!editBody.trim()) return;
|
||||||
setSavingEdit(true);
|
setSavingEdit(true);
|
||||||
|
|
@ -226,12 +164,11 @@ function CommentThread({
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSubmit = async () => {
|
const handleSubmit = async () => {
|
||||||
if (!body.trim() && selectedFiles.length === 0 && pendingPastes.length === 0) return;
|
if (!body.trim() && selectedFiles.length === 0) return;
|
||||||
setSubmitting(true);
|
setSubmitting(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
let commentId: number | undefined;
|
let commentId: number | undefined;
|
||||||
let finalBody = body;
|
|
||||||
if (body.trim()) {
|
if (body.trim()) {
|
||||||
const activeMentionedIds = mentionedIds.filter((uid) => {
|
const activeMentionedIds = mentionedIds.filter((uid) => {
|
||||||
const u = adminUsers.find((au) => au.id === uid);
|
const u = adminUsers.find((au) => au.id === uid);
|
||||||
|
|
@ -245,42 +182,6 @@ function CommentThread({
|
||||||
commentId = res.data?.id;
|
commentId = res.data?.id;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Upload buffered pasted images, then rewrite blob: URLs to /DownloadDocument URLs
|
|
||||||
if (pendingPastes.length > 0 && commentId) {
|
|
||||||
setUploading(true);
|
|
||||||
let bodyChanged = false;
|
|
||||||
for (const paste of pendingPastes) {
|
|
||||||
const formData = new FormData();
|
|
||||||
formData.append('file', paste.blob);
|
|
||||||
try {
|
|
||||||
const res = await axiosConfig.post('/UploadDocument', formData, {
|
|
||||||
params: { scope: 0, ticketId, ticketCommentId: commentId },
|
|
||||||
headers: { 'Content-Type': 'multipart/form-data' }
|
|
||||||
});
|
|
||||||
const docId = res.data?.id;
|
|
||||||
if (docId) {
|
|
||||||
const realUrl = `/DownloadDocument?id=${docId}`;
|
|
||||||
if (finalBody.includes(paste.blobUrl)) {
|
|
||||||
finalBody = finalBody.split(paste.blobUrl).join(realUrl);
|
|
||||||
bodyChanged = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (err: any) {
|
|
||||||
console.warn('[Paste] Upload failed:', err?.response?.data || err?.message);
|
|
||||||
setPasteError('uploadFailed');
|
|
||||||
}
|
|
||||||
window.URL.revokeObjectURL(paste.blobUrl);
|
|
||||||
}
|
|
||||||
if (bodyChanged) {
|
|
||||||
try {
|
|
||||||
await axiosConfig.post('/UpdateTicketComment', { id: commentId, body: finalBody });
|
|
||||||
} catch (err: any) {
|
|
||||||
console.warn('[Paste] Body rewrite failed:', err?.response?.data || err?.message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
setUploading(false);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (selectedFiles.length > 0) {
|
if (selectedFiles.length > 0) {
|
||||||
setUploading(true);
|
setUploading(true);
|
||||||
for (const file of selectedFiles) {
|
for (const file of selectedFiles) {
|
||||||
|
|
@ -306,7 +207,6 @@ function CommentThread({
|
||||||
setMentionedIds([]);
|
setMentionedIds([]);
|
||||||
setMentionQuery(null);
|
setMentionQuery(null);
|
||||||
setSelectedFiles([]);
|
setSelectedFiles([]);
|
||||||
setPendingPastes([]);
|
|
||||||
setRefreshKey((k) => k + 1);
|
setRefreshKey((k) => k + 1);
|
||||||
onCommentAdded();
|
onCommentAdded();
|
||||||
} finally {
|
} finally {
|
||||||
|
|
@ -374,28 +274,14 @@ function CommentThread({
|
||||||
</Typography>
|
</Typography>
|
||||||
)}
|
)}
|
||||||
{canEdit && !isEditing && (
|
{canEdit && !isEditing && (
|
||||||
<Box sx={{ ml: 'auto', display: 'flex', gap: 0.5 }}>
|
<Button
|
||||||
<Tooltip title={intl.formatMessage({ id: 'edit', defaultMessage: 'Edit' })}>
|
size="small"
|
||||||
<IconButton
|
variant="text"
|
||||||
size="small"
|
sx={{ minWidth: 0, p: 0, ml: 'auto', textTransform: 'none' }}
|
||||||
onClick={() => startEdit(comment)}
|
onClick={() => startEdit(comment)}
|
||||||
aria-label={intl.formatMessage({ id: 'edit', defaultMessage: 'Edit' })}
|
>
|
||||||
>
|
<FormattedMessage id="edit" defaultMessage="Edit" />
|
||||||
<EditIcon fontSize="small" />
|
</Button>
|
||||||
</IconButton>
|
|
||||||
</Tooltip>
|
|
||||||
<Tooltip title={intl.formatMessage({ id: 'delete', defaultMessage: 'Delete' })}>
|
|
||||||
<IconButton
|
|
||||||
size="small"
|
|
||||||
color="error"
|
|
||||||
onClick={() => setDeleteCandidateId(comment.id)}
|
|
||||||
disabled={deletingId === comment.id}
|
|
||||||
aria-label={intl.formatMessage({ id: 'delete', defaultMessage: 'Delete' })}
|
|
||||||
>
|
|
||||||
<DeleteIcon fontSize="small" />
|
|
||||||
</IconButton>
|
|
||||||
</Tooltip>
|
|
||||||
</Box>
|
|
||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
{isEditing ? (
|
{isEditing ? (
|
||||||
|
|
@ -413,8 +299,6 @@ function CommentThread({
|
||||||
minRows={2}
|
minRows={2}
|
||||||
value={editBody}
|
value={editBody}
|
||||||
onChange={(e) => setEditBody(e.target.value)}
|
onChange={(e) => setEditBody(e.target.value)}
|
||||||
onKeyDown={(e) => handleListEnter(e, editBody, setEditBody)}
|
|
||||||
onPaste={handlePasteEditComment}
|
|
||||||
inputRef={editInputRef}
|
inputRef={editInputRef}
|
||||||
/>
|
/>
|
||||||
<Box sx={{ display: 'flex', justifyContent: 'flex-end', gap: 1 }}>
|
<Box sx={{ display: 'flex', justifyContent: 'flex-end', gap: 1 }}>
|
||||||
|
|
@ -457,7 +341,7 @@ function CommentThread({
|
||||||
<Typography variant="caption" color="text.disabled">
|
<Typography variant="caption" color="text.disabled">
|
||||||
<FormattedMessage
|
<FormattedMessage
|
||||||
id="commentMarkdownHint"
|
id="commentMarkdownHint"
|
||||||
defaultMessage="Markdown: **bold**, #, ##"
|
defaultMessage="Markdown: **bold**, #, ##, ###"
|
||||||
/>
|
/>
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
@ -474,8 +358,6 @@ function CommentThread({
|
||||||
})}
|
})}
|
||||||
value={body}
|
value={body}
|
||||||
onChange={handleBodyChange}
|
onChange={handleBodyChange}
|
||||||
onKeyDown={(e) => handleListEnter(e, body, setBody)}
|
|
||||||
onPaste={handlePasteNewComment}
|
|
||||||
inputRef={commentInputRef}
|
inputRef={commentInputRef}
|
||||||
/>
|
/>
|
||||||
<Popper
|
<Popper
|
||||||
|
|
@ -540,58 +422,6 @@ function CommentThread({
|
||||||
{uploading && <LinearProgress />}
|
{uploading && <LinearProgress />}
|
||||||
</Box>
|
</Box>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
<Dialog
|
|
||||||
open={deleteCandidateId !== null}
|
|
||||||
onClose={() => deletingId === null && setDeleteCandidateId(null)}
|
|
||||||
>
|
|
||||||
<DialogTitle>
|
|
||||||
<FormattedMessage id="deleteComment" defaultMessage="Delete comment" />
|
|
||||||
</DialogTitle>
|
|
||||||
<DialogContent>
|
|
||||||
<DialogContentText>
|
|
||||||
<FormattedMessage
|
|
||||||
id="deleteCommentConfirm"
|
|
||||||
defaultMessage="This will permanently delete the comment and any attachments. Continue?"
|
|
||||||
/>
|
|
||||||
</DialogContentText>
|
|
||||||
</DialogContent>
|
|
||||||
<DialogActions>
|
|
||||||
<Button
|
|
||||||
onClick={() => setDeleteCandidateId(null)}
|
|
||||||
disabled={deletingId !== null}
|
|
||||||
>
|
|
||||||
<FormattedMessage id="cancel" defaultMessage="Cancel" />
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
onClick={confirmDelete}
|
|
||||||
color="error"
|
|
||||||
variant="contained"
|
|
||||||
disabled={deletingId !== null}
|
|
||||||
>
|
|
||||||
<FormattedMessage id="delete" defaultMessage="Delete" />
|
|
||||||
</Button>
|
|
||||||
</DialogActions>
|
|
||||||
</Dialog>
|
|
||||||
<Snackbar
|
|
||||||
open={pasteError !== null}
|
|
||||||
autoHideDuration={4000}
|
|
||||||
onClose={() => setPasteError(null)}
|
|
||||||
anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }}
|
|
||||||
>
|
|
||||||
<Alert severity="error" onClose={() => setPasteError(null)}>
|
|
||||||
{pasteError === 'tooLarge' ? (
|
|
||||||
<FormattedMessage
|
|
||||||
id="pasteImageTooLarge"
|
|
||||||
defaultMessage="Pasted image exceeds 100 MB limit"
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<FormattedMessage
|
|
||||||
id="pasteImageUploadFailed"
|
|
||||||
defaultMessage="Failed to upload pasted image"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</Alert>
|
|
||||||
</Snackbar>
|
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -15,16 +15,12 @@ import {
|
||||||
LinearProgress,
|
LinearProgress,
|
||||||
MenuItem,
|
MenuItem,
|
||||||
Select,
|
Select,
|
||||||
Snackbar,
|
|
||||||
TextField,
|
TextField,
|
||||||
Typography
|
Typography
|
||||||
} from '@mui/material';
|
} from '@mui/material';
|
||||||
import AttachFileIcon from '@mui/icons-material/AttachFile';
|
import AttachFileIcon from '@mui/icons-material/AttachFile';
|
||||||
import { FormattedMessage, useIntl } from 'react-intl';
|
import { FormattedMessage, useIntl } from 'react-intl';
|
||||||
import axiosConfig from 'src/Resources/axiosConfig';
|
import axiosConfig from 'src/Resources/axiosConfig';
|
||||||
import CommentFormatToolbar from './CommentFormatToolbar';
|
|
||||||
import { handleListEnter } from './commentMarkdown';
|
|
||||||
import { usePasteImage, PendingPaste } from 'src/hooks/usePasteImage';
|
|
||||||
import {
|
import {
|
||||||
TicketPriority,
|
TicketPriority,
|
||||||
TicketCategory,
|
TicketCategory,
|
||||||
|
|
@ -91,32 +87,10 @@ function CreateTicketModal({ open, onClose, onCreated, defaultInstallationId }:
|
||||||
|
|
||||||
// File attachments
|
// File attachments
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
const descriptionRef = useRef<HTMLInputElement | null>(null);
|
|
||||||
const [selectedFiles, setSelectedFiles] = useState<File[]>([]);
|
const [selectedFiles, setSelectedFiles] = useState<File[]>([]);
|
||||||
const [uploading, setUploading] = useState(false);
|
const [uploading, setUploading] = useState(false);
|
||||||
const [uploadProgress, setUploadProgress] = useState(0);
|
const [uploadProgress, setUploadProgress] = useState(0);
|
||||||
|
|
||||||
const [pendingPastes, setPendingPastes] = useState<PendingPaste[]>([]);
|
|
||||||
const [pasteError, setPasteError] = useState<'tooLarge' | 'uploadFailed' | null>(null);
|
|
||||||
|
|
||||||
const pendingPastesRef = useRef<PendingPaste[]>([]);
|
|
||||||
pendingPastesRef.current = pendingPastes;
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
return () => {
|
|
||||||
pendingPastesRef.current.forEach((p) => window.URL.revokeObjectURL(p.blobUrl));
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handlePasteDescription = usePasteImage({
|
|
||||||
mode: 'deferred',
|
|
||||||
textareaRef: descriptionRef,
|
|
||||||
value: description,
|
|
||||||
onChange: setDescription,
|
|
||||||
onError: setPasteError,
|
|
||||||
onBufferedPaste: (p) => setPendingPastes((prev) => [...prev, p])
|
|
||||||
});
|
|
||||||
|
|
||||||
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;
|
||||||
|
|
||||||
|
|
@ -280,8 +254,6 @@ function CreateTicketModal({ open, onClose, onCreated, defaultInstallationId }:
|
||||||
setCustomSubCategory('');
|
setCustomSubCategory('');
|
||||||
setCustomCategory('');
|
setCustomCategory('');
|
||||||
setSelectedFiles([]);
|
setSelectedFiles([]);
|
||||||
pendingPastes.forEach((p) => window.URL.revokeObjectURL(p.blobUrl));
|
|
||||||
setPendingPastes([]);
|
|
||||||
setError('');
|
setError('');
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -312,45 +284,7 @@ function CreateTicketModal({ open, onClose, onCreated, defaultInstallationId }:
|
||||||
customCategory: isOtherCategory ? customCategory || null : null
|
customCategory: isOtherCategory ? customCategory || null : null
|
||||||
});
|
});
|
||||||
|
|
||||||
const createdTicket = res.data;
|
const newTicketId = res.data?.id;
|
||||||
const newTicketId = createdTicket?.id;
|
|
||||||
|
|
||||||
// Upload buffered pasted images, then rewrite blob: URLs in description
|
|
||||||
if (pendingPastes.length > 0 && newTicketId) {
|
|
||||||
setUploading(true);
|
|
||||||
let finalDescription = description;
|
|
||||||
let descChanged = false;
|
|
||||||
for (const paste of pendingPastes) {
|
|
||||||
const formData = new FormData();
|
|
||||||
formData.append('file', paste.blob);
|
|
||||||
try {
|
|
||||||
const upRes = await axiosConfig.post('/UploadDocument', formData, {
|
|
||||||
params: { scope: 0, ticketId: newTicketId },
|
|
||||||
headers: { 'Content-Type': 'multipart/form-data' }
|
|
||||||
});
|
|
||||||
const docId = upRes.data?.id;
|
|
||||||
if (docId && finalDescription.includes(paste.blobUrl)) {
|
|
||||||
finalDescription = finalDescription.split(paste.blobUrl).join(`/DownloadDocument?id=${docId}`);
|
|
||||||
descChanged = true;
|
|
||||||
}
|
|
||||||
} catch (err: any) {
|
|
||||||
console.warn('[Paste] Upload failed:', err?.response?.data || err?.message);
|
|
||||||
setPasteError('uploadFailed');
|
|
||||||
}
|
|
||||||
window.URL.revokeObjectURL(paste.blobUrl);
|
|
||||||
}
|
|
||||||
if (descChanged && createdTicket) {
|
|
||||||
try {
|
|
||||||
await axiosConfig.put('/UpdateTicket', {
|
|
||||||
...createdTicket,
|
|
||||||
description: finalDescription
|
|
||||||
});
|
|
||||||
} catch (err: any) {
|
|
||||||
console.warn('[Paste] Description rewrite failed:', err?.response?.data || err?.message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
setUploading(false);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Upload attached files if any
|
// Upload attached files if any
|
||||||
if (selectedFiles.length > 0 && newTicketId) {
|
if (selectedFiles.length > 0 && newTicketId) {
|
||||||
|
|
@ -613,36 +547,17 @@ function CreateTicketModal({ open, onClose, onCreated, defaultInstallationId }:
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1, mt: 1 }}>
|
<TextField
|
||||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', flexWrap: 'wrap', gap: 1 }}>
|
label={
|
||||||
<CommentFormatToolbar
|
<FormattedMessage id="description" defaultMessage="Description" />
|
||||||
textareaRef={descriptionRef}
|
}
|
||||||
value={description}
|
value={description}
|
||||||
onChange={setDescription}
|
onChange={(e) => setDescription(e.target.value)}
|
||||||
disabled={submitting || uploading}
|
multiline
|
||||||
/>
|
rows={4}
|
||||||
<Typography variant="caption" color="text.disabled">
|
fullWidth
|
||||||
<FormattedMessage
|
margin="dense"
|
||||||
id="commentMarkdownHint"
|
/>
|
||||||
defaultMessage="Markdown: **bold**, #, ##"
|
|
||||||
/>
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
<TextField
|
|
||||||
label={
|
|
||||||
<FormattedMessage id="description" defaultMessage="Description" />
|
|
||||||
}
|
|
||||||
value={description}
|
|
||||||
onChange={(e) => setDescription(e.target.value)}
|
|
||||||
onKeyDown={(e) => handleListEnter(e, description, setDescription)}
|
|
||||||
onPaste={handlePasteDescription}
|
|
||||||
inputRef={descriptionRef}
|
|
||||||
multiline
|
|
||||||
rows={4}
|
|
||||||
fullWidth
|
|
||||||
margin="dense"
|
|
||||||
/>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
{/* File attachments */}
|
{/* File attachments */}
|
||||||
<Box sx={{ mt: 1 }}>
|
<Box sx={{ mt: 1 }}>
|
||||||
|
|
@ -694,26 +609,6 @@ function CreateTicketModal({ open, onClose, onCreated, defaultInstallationId }:
|
||||||
<FormattedMessage id="submit" defaultMessage="Submit" />
|
<FormattedMessage id="submit" defaultMessage="Submit" />
|
||||||
</Button>
|
</Button>
|
||||||
</DialogActions>
|
</DialogActions>
|
||||||
<Snackbar
|
|
||||||
open={pasteError !== null}
|
|
||||||
autoHideDuration={4000}
|
|
||||||
onClose={() => setPasteError(null)}
|
|
||||||
anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }}
|
|
||||||
>
|
|
||||||
<Alert severity="error" onClose={() => setPasteError(null)}>
|
|
||||||
{pasteError === 'tooLarge' ? (
|
|
||||||
<FormattedMessage
|
|
||||||
id="pasteImageTooLarge"
|
|
||||||
defaultMessage="Pasted image exceeds 100 MB limit"
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<FormattedMessage
|
|
||||||
id="pasteImageUploadFailed"
|
|
||||||
defaultMessage="Failed to upload pasted image"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</Alert>
|
|
||||||
</Snackbar>
|
|
||||||
</Dialog>
|
</Dialog>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -22,7 +22,6 @@ import {
|
||||||
InputLabel,
|
InputLabel,
|
||||||
MenuItem,
|
MenuItem,
|
||||||
Select,
|
Select,
|
||||||
Snackbar,
|
|
||||||
TextField,
|
TextField,
|
||||||
Typography
|
Typography
|
||||||
} from '@mui/material';
|
} from '@mui/material';
|
||||||
|
|
@ -51,9 +50,6 @@ import Footer from 'src/components/Footer';
|
||||||
import StatusChip from './StatusChip';
|
import StatusChip from './StatusChip';
|
||||||
import AiDiagnosisPanel from './AiDiagnosisPanel';
|
import AiDiagnosisPanel from './AiDiagnosisPanel';
|
||||||
import CommentThread from './CommentThread';
|
import CommentThread from './CommentThread';
|
||||||
import CommentFormatToolbar from './CommentFormatToolbar';
|
|
||||||
import { renderCommentBody, handleListEnter } from './commentMarkdown';
|
|
||||||
import { usePasteImage } from 'src/hooks/usePasteImage';
|
|
||||||
import TimelinePanel from './TimelinePanel';
|
import TimelinePanel from './TimelinePanel';
|
||||||
import FileUploadButton from 'src/components/FileUploadButton';
|
import FileUploadButton from 'src/components/FileUploadButton';
|
||||||
import DocumentList from 'src/components/DocumentList';
|
import DocumentList from 'src/components/DocumentList';
|
||||||
|
|
@ -102,20 +98,6 @@ function TicketDetailPage() {
|
||||||
const [solveGateOpen, setSolveGateOpen] = useState(false);
|
const [solveGateOpen, setSolveGateOpen] = useState(false);
|
||||||
const rootCauseRef = useRef<HTMLInputElement | null>(null);
|
const rootCauseRef = useRef<HTMLInputElement | null>(null);
|
||||||
const solutionRef = useRef<HTMLInputElement | null>(null);
|
const solutionRef = useRef<HTMLInputElement | null>(null);
|
||||||
const descriptionRef = useRef<HTMLInputElement | null>(null);
|
|
||||||
const [pasteError, setPasteError] = useState<'tooLarge' | 'uploadFailed' | null>(null);
|
|
||||||
|
|
||||||
const handlePasteDescription = usePasteImage({
|
|
||||||
mode: 'immediate',
|
|
||||||
textareaRef: descriptionRef,
|
|
||||||
value: description,
|
|
||||||
onChange: (next) => {
|
|
||||||
setDescription(next);
|
|
||||||
setDescriptionSaved(false);
|
|
||||||
},
|
|
||||||
onError: setPasteError,
|
|
||||||
ticketId: detail?.ticket?.id
|
|
||||||
});
|
|
||||||
|
|
||||||
// Custom "Other" editing state
|
// Custom "Other" editing state
|
||||||
const [editCustomSub, setEditCustomSub] = useState('');
|
const [editCustomSub, setEditCustomSub] = useState('');
|
||||||
|
|
@ -429,24 +411,7 @@ function TicketDetailPage() {
|
||||||
<Divider />
|
<Divider />
|
||||||
<CardContent>
|
<CardContent>
|
||||||
{editingDescription ? (
|
{editingDescription ? (
|
||||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.5 }}>
|
||||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', flexWrap: 'wrap', gap: 1 }}>
|
|
||||||
<CommentFormatToolbar
|
|
||||||
textareaRef={descriptionRef}
|
|
||||||
value={description}
|
|
||||||
onChange={(next) => {
|
|
||||||
setDescription(next);
|
|
||||||
setDescriptionSaved(false);
|
|
||||||
}}
|
|
||||||
disabled={savingDescription}
|
|
||||||
/>
|
|
||||||
<Typography variant="caption" color="text.disabled">
|
|
||||||
<FormattedMessage
|
|
||||||
id="commentMarkdownHint"
|
|
||||||
defaultMessage="Markdown: **bold**, #, ##"
|
|
||||||
/>
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
<TextField
|
<TextField
|
||||||
multiline
|
multiline
|
||||||
minRows={3}
|
minRows={3}
|
||||||
|
|
@ -456,14 +421,6 @@ function TicketDetailPage() {
|
||||||
setDescription(e.target.value);
|
setDescription(e.target.value);
|
||||||
setDescriptionSaved(false);
|
setDescriptionSaved(false);
|
||||||
}}
|
}}
|
||||||
onKeyDown={(e) =>
|
|
||||||
handleListEnter(e, description, (next) => {
|
|
||||||
setDescription(next);
|
|
||||||
setDescriptionSaved(false);
|
|
||||||
})
|
|
||||||
}
|
|
||||||
onPaste={handlePasteDescription}
|
|
||||||
inputRef={descriptionRef}
|
|
||||||
/>
|
/>
|
||||||
<Box display="flex" justifyContent="flex-end" alignItems="center" gap={1}>
|
<Box display="flex" justifyContent="flex-end" alignItems="center" gap={1}>
|
||||||
{descriptionSaved && (
|
{descriptionSaved && (
|
||||||
|
|
@ -490,18 +447,23 @@ function TicketDetailPage() {
|
||||||
</Button>
|
</Button>
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
) : ticket.description ? (
|
|
||||||
renderCommentBody(ticket.description)
|
|
||||||
) : (
|
) : (
|
||||||
<Typography
|
<Typography
|
||||||
component="span"
|
variant="body1"
|
||||||
variant="body2"
|
sx={{ whiteSpace: 'pre-wrap' }}
|
||||||
color="text.secondary"
|
|
||||||
>
|
>
|
||||||
<FormattedMessage
|
{ticket.description || (
|
||||||
id="noDescription"
|
<Typography
|
||||||
defaultMessage="No description provided."
|
component="span"
|
||||||
/>
|
variant="body2"
|
||||||
|
color="text.secondary"
|
||||||
|
>
|
||||||
|
<FormattedMessage
|
||||||
|
id="noDescription"
|
||||||
|
defaultMessage="No description provided."
|
||||||
|
/>
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
</Typography>
|
</Typography>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
@ -1030,26 +992,6 @@ function TicketDetailPage() {
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
</Container>
|
</Container>
|
||||||
<Snackbar
|
|
||||||
open={pasteError !== null}
|
|
||||||
autoHideDuration={4000}
|
|
||||||
onClose={() => setPasteError(null)}
|
|
||||||
anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }}
|
|
||||||
>
|
|
||||||
<Alert severity="error" onClose={() => setPasteError(null)}>
|
|
||||||
{pasteError === 'tooLarge' ? (
|
|
||||||
<FormattedMessage
|
|
||||||
id="pasteImageTooLarge"
|
|
||||||
defaultMessage="Pasted image exceeds 100 MB limit"
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<FormattedMessage
|
|
||||||
id="pasteImageUploadFailed"
|
|
||||||
defaultMessage="Failed to upload pasted image"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</Alert>
|
|
||||||
</Snackbar>
|
|
||||||
<Footer />
|
<Footer />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -4,11 +4,9 @@ import {
|
||||||
Alert,
|
Alert,
|
||||||
Box,
|
Box,
|
||||||
Button,
|
Button,
|
||||||
Checkbox,
|
|
||||||
Container,
|
Container,
|
||||||
FormControl,
|
FormControl,
|
||||||
InputLabel,
|
InputLabel,
|
||||||
ListItemText,
|
|
||||||
MenuItem,
|
MenuItem,
|
||||||
Select,
|
Select,
|
||||||
Table,
|
Table,
|
||||||
|
|
@ -54,8 +52,7 @@ function TicketList() {
|
||||||
const intl = useIntl();
|
const intl = useIntl();
|
||||||
const [tickets, setTickets] = useState<TicketSummary[]>([]);
|
const [tickets, setTickets] = useState<TicketSummary[]>([]);
|
||||||
const [search, setSearch] = useState('');
|
const [search, setSearch] = useState('');
|
||||||
const [statusFilter, setStatusFilter] = useState<number[]>([]);
|
const [statusFilter, setStatusFilter] = useState<number | ''>('');
|
||||||
const [partnerFilter, setPartnerFilter] = useState<string>('');
|
|
||||||
const [createOpen, setCreateOpen] = useState(false);
|
const [createOpen, setCreateOpen] = useState(false);
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
|
|
||||||
|
|
@ -70,19 +67,14 @@ function TicketList() {
|
||||||
fetchTickets();
|
fetchTickets();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const partnerOptions = Array.from(
|
|
||||||
new Set(tickets.map((t) => t.distributionPartner).filter((p) => p && p.trim() !== ''))
|
|
||||||
).sort();
|
|
||||||
|
|
||||||
const filtered = tickets
|
const filtered = tickets
|
||||||
.filter((t) => {
|
.filter((t) => {
|
||||||
const matchesSearch =
|
const matchesSearch =
|
||||||
search === '' ||
|
search === '' ||
|
||||||
t.subject.toLowerCase().includes(search.toLowerCase()) ||
|
t.subject.toLowerCase().includes(search.toLowerCase()) ||
|
||||||
t.installationName.toLowerCase().includes(search.toLowerCase());
|
t.installationName.toLowerCase().includes(search.toLowerCase());
|
||||||
const matchesStatus = statusFilter.length === 0 || statusFilter.includes(t.status);
|
const matchesStatus = statusFilter === '' || t.status === statusFilter;
|
||||||
const matchesPartner = partnerFilter === '' || t.distributionPartner === partnerFilter;
|
return matchesSearch && matchesStatus;
|
||||||
return matchesSearch && matchesStatus && matchesPartner;
|
|
||||||
})
|
})
|
||||||
.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
|
.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
|
||||||
|
|
||||||
|
|
@ -118,60 +110,26 @@ function TicketList() {
|
||||||
onChange={(e) => setSearch(e.target.value)}
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
sx={{ minWidth: 250 }}
|
sx={{ minWidth: 250 }}
|
||||||
/>
|
/>
|
||||||
<FormControl size="small" sx={{ minWidth: 200 }}>
|
<FormControl size="small" sx={{ minWidth: 160 }}>
|
||||||
<InputLabel>
|
<InputLabel>
|
||||||
<FormattedMessage id="status" defaultMessage="Status" />
|
<FormattedMessage id="status" defaultMessage="Status" />
|
||||||
</InputLabel>
|
</InputLabel>
|
||||||
<Select
|
<Select
|
||||||
multiple
|
|
||||||
value={statusFilter}
|
value={statusFilter}
|
||||||
label={intl.formatMessage({ id: 'status', defaultMessage: 'Status' })}
|
label="Status"
|
||||||
onChange={(e) => {
|
onChange={(e) =>
|
||||||
const value = e.target.value;
|
setStatusFilter(e.target.value === '' ? '' : Number(e.target.value))
|
||||||
setStatusFilter(
|
|
||||||
typeof value === 'string'
|
|
||||||
? value.split(',').map(Number)
|
|
||||||
: (value as number[])
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
renderValue={(selected) =>
|
|
||||||
(selected as number[])
|
|
||||||
.map((s) => intl.formatMessage(statusKeys[s]))
|
|
||||||
.join(', ')
|
|
||||||
}
|
}
|
||||||
>
|
|
||||||
{Object.entries(statusKeys).map(([val, msg]) => {
|
|
||||||
const num = Number(val);
|
|
||||||
return (
|
|
||||||
<MenuItem key={val} value={num}>
|
|
||||||
<Checkbox checked={statusFilter.indexOf(num) > -1} />
|
|
||||||
<ListItemText primary={intl.formatMessage(msg)} />
|
|
||||||
</MenuItem>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</Select>
|
|
||||||
</FormControl>
|
|
||||||
<FormControl size="small" sx={{ minWidth: 200 }}>
|
|
||||||
<InputLabel>
|
|
||||||
<FormattedMessage
|
|
||||||
id="distributionPartner"
|
|
||||||
defaultMessage="Distribution Partner"
|
|
||||||
/>
|
|
||||||
</InputLabel>
|
|
||||||
<Select
|
|
||||||
value={partnerFilter}
|
|
||||||
label={intl.formatMessage({
|
|
||||||
id: 'distributionPartner',
|
|
||||||
defaultMessage: 'Distribution Partner'
|
|
||||||
})}
|
|
||||||
onChange={(e) => setPartnerFilter(e.target.value as string)}
|
|
||||||
>
|
>
|
||||||
<MenuItem value="">
|
<MenuItem value="">
|
||||||
<FormattedMessage id="allPartners" defaultMessage="All Partners" />
|
<FormattedMessage
|
||||||
|
id="allStatuses"
|
||||||
|
defaultMessage="All Statuses"
|
||||||
|
/>
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
{partnerOptions.map((p) => (
|
{Object.entries(statusKeys).map(([val, msg]) => (
|
||||||
<MenuItem key={p} value={p}>
|
<MenuItem key={val} value={Number(val)}>
|
||||||
{p}
|
{intl.formatMessage(msg)}
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
))}
|
))}
|
||||||
</Select>
|
</Select>
|
||||||
|
|
@ -209,18 +167,6 @@ function TicketList() {
|
||||||
defaultMessage="Status"
|
defaultMessage="Status"
|
||||||
/>
|
/>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
|
||||||
<FormattedMessage
|
|
||||||
id="installation"
|
|
||||||
defaultMessage="Installation"
|
|
||||||
/>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<FormattedMessage
|
|
||||||
id="distributionPartner"
|
|
||||||
defaultMessage="Distribution Partner"
|
|
||||||
/>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<FormattedMessage
|
<FormattedMessage
|
||||||
id="priority"
|
id="priority"
|
||||||
|
|
@ -233,6 +179,12 @@ function TicketList() {
|
||||||
defaultMessage="Category"
|
defaultMessage="Category"
|
||||||
/>
|
/>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<FormattedMessage
|
||||||
|
id="installation"
|
||||||
|
defaultMessage="Installation"
|
||||||
|
/>
|
||||||
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<FormattedMessage
|
<FormattedMessage
|
||||||
id="createdAt"
|
id="createdAt"
|
||||||
|
|
@ -261,8 +213,6 @@ function TicketList() {
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<StatusChip status={ticket.status} />
|
<StatusChip status={ticket.status} />
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>{ticket.installationName}</TableCell>
|
|
||||||
<TableCell>{ticket.distributionPartner}</TableCell>
|
|
||||||
<TableCell>{intl.formatMessage(priorityKeys[ticket.priority] ?? { id: 'unknown', defaultMessage: '-' })}</TableCell>
|
<TableCell>{intl.formatMessage(priorityKeys[ticket.priority] ?? { id: 'unknown', defaultMessage: '-' })}</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
{ticket.customCategory
|
{ticket.customCategory
|
||||||
|
|
@ -274,6 +224,7 @@ function TicketList() {
|
||||||
? ` — ${ticket.customSubCategory}`
|
? ` — ${ticket.customSubCategory}`
|
||||||
: ''}
|
: ''}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
<TableCell>{ticket.installationName}</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
{new Date(ticket.createdAt).toLocaleDateString()}
|
{new Date(ticket.createdAt).toLocaleDateString()}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
|
|
||||||
|
|
@ -1,20 +1,7 @@
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Box, Typography } from '@mui/material';
|
import { Box, Typography } from '@mui/material';
|
||||||
import DocumentImage from 'src/components/DocumentImage';
|
|
||||||
|
|
||||||
export type FormatKind = 'bold' | 'h1' | 'h2' | 'bullet' | 'numbered';
|
export type FormatKind = 'bold' | 'h1' | 'h2' | 'h3';
|
||||||
|
|
||||||
const BULLET_RE = /^- /;
|
|
||||||
const NUMBERED_RE = /^\d+\.\s/;
|
|
||||||
const IMAGE_RE = /^!\[([^\]]*)\]\((.+)\)\s*$/;
|
|
||||||
const DOC_URL_RE = /\/DownloadDocument\?(?:[^&]*&)*id=(\d+)/;
|
|
||||||
|
|
||||||
function parseImageUrl(url: string): { docId?: number; src?: string } | null {
|
|
||||||
const docMatch = url.match(DOC_URL_RE);
|
|
||||||
if (docMatch) return { docId: parseInt(docMatch[1], 10) };
|
|
||||||
if (url.startsWith('blob:')) return { src: url };
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderInline(text: string): React.ReactNode[] {
|
function renderInline(text: string): React.ReactNode[] {
|
||||||
const parts = text.split(/\*\*(.+?)\*\*/g);
|
const parts = text.split(/\*\*(.+?)\*\*/g);
|
||||||
|
|
@ -23,192 +10,40 @@ function renderInline(text: string): React.ReactNode[] {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
type ListBuf = { ordered: boolean; items: string[] };
|
|
||||||
|
|
||||||
export function renderCommentBody(body: string): JSX.Element {
|
export function renderCommentBody(body: string): JSX.Element {
|
||||||
const lines = body.split('\n');
|
const lines = body.split('\n');
|
||||||
const blocks: JSX.Element[] = [];
|
return (
|
||||||
let listBuf: ListBuf | null = null;
|
<Box sx={{ '& > *': { mb: 0.5 } }}>
|
||||||
|
{lines.map((line, idx) => {
|
||||||
const flushList = () => {
|
if (line.startsWith('### ')) {
|
||||||
if (!listBuf) return;
|
return (
|
||||||
const key = `list-${blocks.length}`;
|
<Typography key={idx} variant="subtitle1" sx={{ fontWeight: 600, mt: 1 }}>
|
||||||
const items = listBuf.items.map((item, i) => (
|
{renderInline(line.slice(4))}
|
||||||
<li key={i} style={{ marginBottom: 2 }}>
|
</Typography>
|
||||||
{renderInline(item)}
|
);
|
||||||
</li>
|
}
|
||||||
));
|
if (line.startsWith('## ')) {
|
||||||
blocks.push(
|
return (
|
||||||
listBuf.ordered ? (
|
<Typography key={idx} variant="h6" sx={{ mt: 1.5 }}>
|
||||||
<Box
|
{renderInline(line.slice(3))}
|
||||||
component="ol"
|
</Typography>
|
||||||
key={key}
|
);
|
||||||
sx={{ pl: 3, my: 0.5, '& li': { typography: 'body2' } }}
|
}
|
||||||
>
|
if (line.startsWith('# ')) {
|
||||||
{items}
|
return (
|
||||||
</Box>
|
<Typography key={idx} variant="h5" sx={{ mt: 2 }}>
|
||||||
) : (
|
{renderInline(line.slice(2))}
|
||||||
<Box
|
</Typography>
|
||||||
component="ul"
|
);
|
||||||
key={key}
|
}
|
||||||
sx={{ pl: 3, my: 0.5, '& li': { typography: 'body2' } }}
|
return (
|
||||||
>
|
<Typography key={idx} variant="body2" sx={{ whiteSpace: 'pre-wrap' }}>
|
||||||
{items}
|
{line ? renderInline(line) : '\u00A0'}
|
||||||
</Box>
|
</Typography>
|
||||||
)
|
|
||||||
);
|
|
||||||
listBuf = null;
|
|
||||||
};
|
|
||||||
|
|
||||||
lines.forEach((line, idx) => {
|
|
||||||
const imageMatch = line.match(IMAGE_RE);
|
|
||||||
if (imageMatch) {
|
|
||||||
const parsed = parseImageUrl(imageMatch[2]);
|
|
||||||
if (parsed) {
|
|
||||||
flushList();
|
|
||||||
blocks.push(
|
|
||||||
<DocumentImage
|
|
||||||
key={idx}
|
|
||||||
docId={parsed.docId}
|
|
||||||
src={parsed.src}
|
|
||||||
alt={imageMatch[1]}
|
|
||||||
/>
|
|
||||||
);
|
);
|
||||||
return;
|
})}
|
||||||
}
|
</Box>
|
||||||
}
|
);
|
||||||
|
|
||||||
if (BULLET_RE.test(line)) {
|
|
||||||
const item = line.replace(BULLET_RE, '');
|
|
||||||
if (listBuf && !listBuf.ordered) {
|
|
||||||
listBuf.items.push(item);
|
|
||||||
} else {
|
|
||||||
flushList();
|
|
||||||
listBuf = { ordered: false, items: [item] };
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (NUMBERED_RE.test(line)) {
|
|
||||||
const item = line.replace(NUMBERED_RE, '');
|
|
||||||
if (listBuf && listBuf.ordered) {
|
|
||||||
listBuf.items.push(item);
|
|
||||||
} else {
|
|
||||||
flushList();
|
|
||||||
listBuf = { ordered: true, items: [item] };
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
flushList();
|
|
||||||
|
|
||||||
if (line.startsWith('## ')) {
|
|
||||||
blocks.push(
|
|
||||||
<Typography
|
|
||||||
key={idx}
|
|
||||||
variant="h6"
|
|
||||||
sx={{ fontSize: '1.15rem', fontWeight: 700, mt: 1.25 }}
|
|
||||||
>
|
|
||||||
{renderInline(line.slice(3))}
|
|
||||||
</Typography>
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (line.startsWith('# ')) {
|
|
||||||
blocks.push(
|
|
||||||
<Typography
|
|
||||||
key={idx}
|
|
||||||
variant="h4"
|
|
||||||
sx={{ fontSize: '1.6rem', fontWeight: 700, mt: 2 }}
|
|
||||||
>
|
|
||||||
{renderInline(line.slice(2))}
|
|
||||||
</Typography>
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
blocks.push(
|
|
||||||
<Typography key={idx} variant="body2" sx={{ whiteSpace: 'pre-wrap' }}>
|
|
||||||
{line ? renderInline(line) : ' '}
|
|
||||||
</Typography>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
flushList();
|
|
||||||
|
|
||||||
return <Box sx={{ '& > *': { mb: 0.5 } }}>{blocks}</Box>;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getLineRange(value: string, start: number, end: number) {
|
|
||||||
const lineStart = value.lastIndexOf('\n', start - 1) + 1;
|
|
||||||
const tailIdx = end > start ? end - 1 : end;
|
|
||||||
const nlAfter = value.indexOf('\n', tailIdx);
|
|
||||||
const lineEnd = nlAfter === -1 ? value.length : nlAfter;
|
|
||||||
return { lineStart, lineEnd };
|
|
||||||
}
|
|
||||||
|
|
||||||
function toggleListLines(block: string, ordered: boolean): string {
|
|
||||||
const lines = block.split('\n');
|
|
||||||
const re = ordered ? NUMBERED_RE : BULLET_RE;
|
|
||||||
const allMatch = lines.every((l) => re.test(l));
|
|
||||||
|
|
||||||
if (allMatch) {
|
|
||||||
return lines.map((l) => l.replace(re, '')).join('\n');
|
|
||||||
}
|
|
||||||
|
|
||||||
let n = 1;
|
|
||||||
return lines
|
|
||||||
.map((l) => {
|
|
||||||
const stripped = l.replace(BULLET_RE, '').replace(NUMBERED_RE, '');
|
|
||||||
if (ordered) {
|
|
||||||
const out = `${n}. ${stripped}`;
|
|
||||||
n += 1;
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
return `- ${stripped}`;
|
|
||||||
})
|
|
||||||
.join('\n');
|
|
||||||
}
|
|
||||||
|
|
||||||
export function handleListEnter(
|
|
||||||
e: React.KeyboardEvent,
|
|
||||||
value: string,
|
|
||||||
onChange: (next: string) => void
|
|
||||||
): void {
|
|
||||||
if (e.key !== 'Enter' || e.shiftKey || e.ctrlKey || e.metaKey || e.altKey) return;
|
|
||||||
const el = e.target as HTMLTextAreaElement | HTMLInputElement;
|
|
||||||
if (!el || (el.tagName !== 'TEXTAREA' && el.tagName !== 'INPUT')) return;
|
|
||||||
const start = el.selectionStart ?? value.length;
|
|
||||||
const end = el.selectionEnd ?? value.length;
|
|
||||||
if (start !== end) return;
|
|
||||||
|
|
||||||
const lineStart = value.lastIndexOf('\n', start - 1) + 1;
|
|
||||||
const line = value.slice(lineStart, start);
|
|
||||||
|
|
||||||
const bulletMatch = line.match(/^- (.*)$/);
|
|
||||||
const numberedMatch = line.match(/^(\d+)\. (.*)$/);
|
|
||||||
if (!bulletMatch && !numberedMatch) return;
|
|
||||||
|
|
||||||
e.preventDefault();
|
|
||||||
|
|
||||||
const content = bulletMatch ? bulletMatch[1] : numberedMatch![2];
|
|
||||||
if (content.length === 0) {
|
|
||||||
const next = value.slice(0, lineStart) + value.slice(start);
|
|
||||||
onChange(next);
|
|
||||||
requestAnimationFrame(() => {
|
|
||||||
el.focus();
|
|
||||||
el.setSelectionRange(lineStart, lineStart);
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const prefix = bulletMatch
|
|
||||||
? '- '
|
|
||||||
: `${parseInt(numberedMatch![1], 10) + 1}. `;
|
|
||||||
const insertion = `\n${prefix}`;
|
|
||||||
const next = value.slice(0, start) + insertion + value.slice(end);
|
|
||||||
onChange(next);
|
|
||||||
const caret = start + insertion.length;
|
|
||||||
requestAnimationFrame(() => {
|
|
||||||
el.focus();
|
|
||||||
el.setSelectionRange(caret, caret);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function applyFormat(
|
export function applyFormat(
|
||||||
|
|
@ -233,21 +68,7 @@ export function applyFormat(
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (kind === 'bullet' || kind === 'numbered') {
|
const prefix = kind === 'h1' ? '# ' : kind === 'h2' ? '## ' : '### ';
|
||||||
const { lineStart, lineEnd } = getLineRange(value, start, end);
|
|
||||||
const block = value.slice(lineStart, lineEnd);
|
|
||||||
const newBlock = toggleListLines(block, kind === 'numbered');
|
|
||||||
const next = value.slice(0, lineStart) + newBlock + value.slice(lineEnd);
|
|
||||||
onChange(next);
|
|
||||||
const caret = lineStart + newBlock.length;
|
|
||||||
requestAnimationFrame(() => {
|
|
||||||
el?.focus();
|
|
||||||
el?.setSelectionRange(caret, caret);
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const prefix = kind === 'h1' ? '# ' : '## ';
|
|
||||||
const lineStart = value.lastIndexOf('\n', start - 1) + 1;
|
const lineStart = value.lastIndexOf('\n', start - 1) + 1;
|
||||||
const nlAfter = value.indexOf('\n', start);
|
const nlAfter = value.indexOf('\n', start);
|
||||||
const lineEnd = nlAfter === -1 ? value.length : nlAfter;
|
const lineEnd = nlAfter === -1 ? value.length : nlAfter;
|
||||||
|
|
|
||||||
|
|
@ -1,138 +0,0 @@
|
||||||
import { ClipboardEvent, RefObject } from 'react';
|
|
||||||
import axiosConfig from 'src/Resources/axiosConfig';
|
|
||||||
|
|
||||||
export interface PendingPaste {
|
|
||||||
placeholderId: string;
|
|
||||||
blob: File;
|
|
||||||
blobUrl: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ImmediateOptions {
|
|
||||||
mode: 'immediate';
|
|
||||||
ticketId?: number;
|
|
||||||
ticketCommentId?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface DeferredOptions {
|
|
||||||
mode: 'deferred';
|
|
||||||
onBufferedPaste: (paste: PendingPaste) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
type Options = (ImmediateOptions | DeferredOptions) & {
|
|
||||||
textareaRef: RefObject<HTMLInputElement | HTMLTextAreaElement | null>;
|
|
||||||
value: string;
|
|
||||||
onChange: (next: string) => void;
|
|
||||||
onError?: (kind: 'tooLarge' | 'uploadFailed') => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
const MAX_BYTES = 100 * 1024 * 1024;
|
|
||||||
|
|
||||||
function extensionFor(type: string): string {
|
|
||||||
if (type === 'image/png') return 'png';
|
|
||||||
if (type === 'image/jpeg') return 'jpg';
|
|
||||||
if (type === 'image/gif') return 'gif';
|
|
||||||
if (type === 'image/webp') return 'webp';
|
|
||||||
return 'png';
|
|
||||||
}
|
|
||||||
|
|
||||||
function makeFilename(type: string): string {
|
|
||||||
const stamp = new Date().toISOString().replace(/[:.]/g, '-');
|
|
||||||
return `pasted-${stamp}.${extensionFor(type)}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function spliceAtCursor(
|
|
||||||
el: HTMLInputElement | HTMLTextAreaElement | null,
|
|
||||||
value: string,
|
|
||||||
insertion: string
|
|
||||||
): { next: string; caret: number } {
|
|
||||||
const start = el?.selectionStart ?? value.length;
|
|
||||||
const end = el?.selectionEnd ?? value.length;
|
|
||||||
const before = value.slice(0, start);
|
|
||||||
const after = value.slice(end);
|
|
||||||
const needsLeadingNl = before.length > 0 && !before.endsWith('\n');
|
|
||||||
const needsTrailingNl = after.length > 0 && !after.startsWith('\n');
|
|
||||||
const wrapped =
|
|
||||||
(needsLeadingNl ? '\n' : '') + insertion + (needsTrailingNl ? '\n' : '');
|
|
||||||
const next = before + wrapped + after;
|
|
||||||
const caret = before.length + wrapped.length;
|
|
||||||
return { next, caret };
|
|
||||||
}
|
|
||||||
|
|
||||||
export function usePasteImage(opts: Options) {
|
|
||||||
const { textareaRef, value, onChange, onError } = opts;
|
|
||||||
|
|
||||||
return async (e: ClipboardEvent<HTMLDivElement | HTMLInputElement | HTMLTextAreaElement>) => {
|
|
||||||
const items = e.clipboardData?.items;
|
|
||||||
if (!items) return;
|
|
||||||
|
|
||||||
let imageFile: File | null = null;
|
|
||||||
for (let i = 0; i < items.length; i++) {
|
|
||||||
const item = items[i];
|
|
||||||
if (item.kind === 'file' && item.type.startsWith('image/')) {
|
|
||||||
const f = item.getAsFile();
|
|
||||||
if (f) {
|
|
||||||
imageFile = f;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (!imageFile) return;
|
|
||||||
|
|
||||||
e.preventDefault();
|
|
||||||
|
|
||||||
if (imageFile.size > MAX_BYTES) {
|
|
||||||
onError?.('tooLarge');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const filename = makeFilename(imageFile.type);
|
|
||||||
const renamed = new File([imageFile], filename, { type: imageFile.type });
|
|
||||||
|
|
||||||
if (opts.mode === 'deferred') {
|
|
||||||
const blobUrl = window.URL.createObjectURL(renamed);
|
|
||||||
const placeholderId = `paste-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
||||||
opts.onBufferedPaste({ placeholderId, blob: renamed, blobUrl });
|
|
||||||
const insertion = ``;
|
|
||||||
const { next, caret } = spliceAtCursor(textareaRef.current, value, insertion);
|
|
||||||
onChange(next);
|
|
||||||
requestAnimationFrame(() => {
|
|
||||||
const el = textareaRef.current;
|
|
||||||
if (!el) return;
|
|
||||||
el.focus();
|
|
||||||
el.setSelectionRange(caret, caret);
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Immediate mode: upload now and splice the real /DownloadDocument URL
|
|
||||||
try {
|
|
||||||
const formData = new FormData();
|
|
||||||
formData.append('file', renamed);
|
|
||||||
const res = await axiosConfig.post('/UploadDocument', formData, {
|
|
||||||
params: {
|
|
||||||
scope: 0,
|
|
||||||
ticketId: opts.ticketId,
|
|
||||||
ticketCommentId: opts.ticketCommentId
|
|
||||||
},
|
|
||||||
headers: { 'Content-Type': 'multipart/form-data' }
|
|
||||||
});
|
|
||||||
const docId = res.data?.id;
|
|
||||||
if (!docId) {
|
|
||||||
onError?.('uploadFailed');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const insertion = ``;
|
|
||||||
const { next, caret } = spliceAtCursor(textareaRef.current, value, insertion);
|
|
||||||
onChange(next);
|
|
||||||
requestAnimationFrame(() => {
|
|
||||||
const el = textareaRef.current;
|
|
||||||
if (!el) return;
|
|
||||||
el.focus();
|
|
||||||
el.setSelectionRange(caret, caret);
|
|
||||||
});
|
|
||||||
} catch (err: any) {
|
|
||||||
console.warn('[Paste] Upload failed:', err?.response?.data || err?.message);
|
|
||||||
onError?.('uploadFailed');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
@ -264,7 +264,6 @@ export type TicketSummary = {
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
installationName: string;
|
installationName: string;
|
||||||
distributionPartner: string;
|
|
||||||
customSubCategory: string | null;
|
customSubCategory: string | null;
|
||||||
customCategory: string | null;
|
customCategory: string | null;
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -82,7 +82,6 @@
|
||||||
"deleteInstallation": "Installation löschen",
|
"deleteInstallation": "Installation löschen",
|
||||||
"confirmDeleteInstallation": "Möchten Sie diese Installation löschen?",
|
"confirmDeleteInstallation": "Möchten Sie diese Installation löschen?",
|
||||||
"installationModel": "Installationsmodell",
|
"installationModel": "Installationsmodell",
|
||||||
"model": "Modell",
|
|
||||||
"externalEms": "Externes EMS",
|
"externalEms": "Externes EMS",
|
||||||
"externalEmsOther": "Externes EMS (angeben)",
|
"externalEmsOther": "Externes EMS (angeben)",
|
||||||
"emsNo": "Nein",
|
"emsNo": "Nein",
|
||||||
|
|
@ -92,7 +91,6 @@
|
||||||
"dataCollectionEnabled": "Datenerfassung",
|
"dataCollectionEnabled": "Datenerfassung",
|
||||||
"generalInfo": "Allgemeine Informationen",
|
"generalInfo": "Allgemeine Informationen",
|
||||||
"installationSetup": "Installationseinrichtung",
|
"installationSetup": "Installationseinrichtung",
|
||||||
"installationDate": "Installationsdatum",
|
|
||||||
"couplingType": "AC/DC-Kopplung",
|
"couplingType": "AC/DC-Kopplung",
|
||||||
"couplingAC": "AC-gekoppelt",
|
"couplingAC": "AC-gekoppelt",
|
||||||
"couplingDC": "DC-gekoppelt",
|
"couplingDC": "DC-gekoppelt",
|
||||||
|
|
@ -559,7 +557,6 @@
|
||||||
"priority": "Priorität",
|
"priority": "Priorität",
|
||||||
"category": "Kategorie",
|
"category": "Kategorie",
|
||||||
"allStatuses": "Alle Status",
|
"allStatuses": "Alle Status",
|
||||||
"allPartners": "Alle Partner",
|
|
||||||
"createdAt": "Erstellt",
|
"createdAt": "Erstellt",
|
||||||
"noTickets": "Keine Tickets gefunden.",
|
"noTickets": "Keine Tickets gefunden.",
|
||||||
"backToTickets": "Zurück zu Tickets",
|
"backToTickets": "Zurück zu Tickets",
|
||||||
|
|
@ -573,16 +570,11 @@
|
||||||
"comments": "Kommentare",
|
"comments": "Kommentare",
|
||||||
"noComments": "Noch keine Kommentare.",
|
"noComments": "Noch keine Kommentare.",
|
||||||
"commentEdited": "(bearbeitet {time})",
|
"commentEdited": "(bearbeitet {time})",
|
||||||
"deleteComment": "Kommentar löschen",
|
"commentMarkdownHint": "Markdown: **fett**, #, ##, ###",
|
||||||
"deleteCommentConfirm": "Der Kommentar und alle Anhänge werden dauerhaft gelöscht. Fortfahren?",
|
|
||||||
"commentMarkdownHint": "Markdown: **fett**, #, ##, -, 1.",
|
|
||||||
"commentFormatBold": "Fett",
|
"commentFormatBold": "Fett",
|
||||||
"commentFormatH1": "Überschrift 1",
|
"commentFormatH1": "Überschrift 1",
|
||||||
"commentFormatH2": "Überschrift 2",
|
"commentFormatH2": "Überschrift 2",
|
||||||
"commentFormatBullet": "Aufzählung",
|
"commentFormatH3": "Überschrift 3",
|
||||||
"commentFormatNumbered": "Nummerierte Liste",
|
|
||||||
"pasteImageTooLarge": "Eingefügtes Bild überschreitet das Limit von 100 MB",
|
|
||||||
"pasteImageUploadFailed": "Eingefügtes Bild konnte nicht hochgeladen werden",
|
|
||||||
"addComment": "Hinzufügen",
|
"addComment": "Hinzufügen",
|
||||||
"timeline": "Zeitverlauf",
|
"timeline": "Zeitverlauf",
|
||||||
"noTimelineEvents": "Noch keine Ereignisse.",
|
"noTimelineEvents": "Noch keine Ereignisse.",
|
||||||
|
|
|
||||||
|
|
@ -64,7 +64,6 @@
|
||||||
"deleteInstallation": "Delete Installation",
|
"deleteInstallation": "Delete Installation",
|
||||||
"confirmDeleteInstallation": "Do you want to delete this installation?",
|
"confirmDeleteInstallation": "Do you want to delete this installation?",
|
||||||
"installationModel": "Installation Model",
|
"installationModel": "Installation Model",
|
||||||
"model": "Model",
|
|
||||||
"externalEms": "External EMS",
|
"externalEms": "External EMS",
|
||||||
"externalEmsOther": "External EMS (specify)",
|
"externalEmsOther": "External EMS (specify)",
|
||||||
"emsNo": "No",
|
"emsNo": "No",
|
||||||
|
|
@ -74,7 +73,6 @@
|
||||||
"dataCollectionEnabled": "Data Collection",
|
"dataCollectionEnabled": "Data Collection",
|
||||||
"generalInfo": "General Info",
|
"generalInfo": "General Info",
|
||||||
"installationSetup": "Installation Setup",
|
"installationSetup": "Installation Setup",
|
||||||
"installationDate": "Installation Date",
|
|
||||||
"couplingType": "AC/DC Coupling",
|
"couplingType": "AC/DC Coupling",
|
||||||
"couplingAC": "AC-coupled",
|
"couplingAC": "AC-coupled",
|
||||||
"couplingDC": "DC-coupled",
|
"couplingDC": "DC-coupled",
|
||||||
|
|
@ -307,7 +305,6 @@
|
||||||
"priority": "Priority",
|
"priority": "Priority",
|
||||||
"category": "Category",
|
"category": "Category",
|
||||||
"allStatuses": "All Statuses",
|
"allStatuses": "All Statuses",
|
||||||
"allPartners": "All Partners",
|
|
||||||
"createdAt": "Created",
|
"createdAt": "Created",
|
||||||
"noTickets": "No tickets found.",
|
"noTickets": "No tickets found.",
|
||||||
"backToTickets": "Back to Tickets",
|
"backToTickets": "Back to Tickets",
|
||||||
|
|
@ -321,16 +318,11 @@
|
||||||
"comments": "Comments",
|
"comments": "Comments",
|
||||||
"noComments": "No comments yet.",
|
"noComments": "No comments yet.",
|
||||||
"commentEdited": "(edited {time})",
|
"commentEdited": "(edited {time})",
|
||||||
"deleteComment": "Delete comment",
|
"commentMarkdownHint": "Markdown: **bold**, #, ##, ###",
|
||||||
"deleteCommentConfirm": "This will permanently delete the comment and any attachments. Continue?",
|
|
||||||
"commentMarkdownHint": "Markdown: **bold**, #, ##, -, 1.",
|
|
||||||
"commentFormatBold": "Bold",
|
"commentFormatBold": "Bold",
|
||||||
"commentFormatH1": "Heading 1",
|
"commentFormatH1": "Heading 1",
|
||||||
"commentFormatH2": "Heading 2",
|
"commentFormatH2": "Heading 2",
|
||||||
"commentFormatBullet": "Bullet list",
|
"commentFormatH3": "Heading 3",
|
||||||
"commentFormatNumbered": "Numbered list",
|
|
||||||
"pasteImageTooLarge": "Pasted image exceeds 100 MB limit",
|
|
||||||
"pasteImageUploadFailed": "Failed to upload pasted image",
|
|
||||||
"addComment": "Add",
|
"addComment": "Add",
|
||||||
"timeline": "Timeline",
|
"timeline": "Timeline",
|
||||||
"noTimelineEvents": "No events yet.",
|
"noTimelineEvents": "No events yet.",
|
||||||
|
|
|
||||||
|
|
@ -76,7 +76,6 @@
|
||||||
"deleteInstallation": "Supprimer l'installation",
|
"deleteInstallation": "Supprimer l'installation",
|
||||||
"confirmDeleteInstallation": "Voulez-vous supprimer cette installation ?",
|
"confirmDeleteInstallation": "Voulez-vous supprimer cette installation ?",
|
||||||
"installationModel": "Modèle d'installation",
|
"installationModel": "Modèle d'installation",
|
||||||
"model": "Modèle",
|
|
||||||
"externalEms": "EMS externe",
|
"externalEms": "EMS externe",
|
||||||
"externalEmsOther": "EMS externe (préciser)",
|
"externalEmsOther": "EMS externe (préciser)",
|
||||||
"emsNo": "Non",
|
"emsNo": "Non",
|
||||||
|
|
@ -86,7 +85,6 @@
|
||||||
"dataCollectionEnabled": "Collecte de données",
|
"dataCollectionEnabled": "Collecte de données",
|
||||||
"generalInfo": "Informations générales",
|
"generalInfo": "Informations générales",
|
||||||
"installationSetup": "Configuration de l'installation",
|
"installationSetup": "Configuration de l'installation",
|
||||||
"installationDate": "Date d'installation",
|
|
||||||
"couplingType": "Couplage AC/DC",
|
"couplingType": "Couplage AC/DC",
|
||||||
"couplingAC": "Couplage AC",
|
"couplingAC": "Couplage AC",
|
||||||
"couplingDC": "Couplage DC",
|
"couplingDC": "Couplage DC",
|
||||||
|
|
@ -559,7 +557,6 @@
|
||||||
"priority": "Priorité",
|
"priority": "Priorité",
|
||||||
"category": "Catégorie",
|
"category": "Catégorie",
|
||||||
"allStatuses": "Tous les statuts",
|
"allStatuses": "Tous les statuts",
|
||||||
"allPartners": "Tous les partenaires",
|
|
||||||
"createdAt": "Créé",
|
"createdAt": "Créé",
|
||||||
"noTickets": "Aucun ticket trouvé.",
|
"noTickets": "Aucun ticket trouvé.",
|
||||||
"backToTickets": "Retour aux tickets",
|
"backToTickets": "Retour aux tickets",
|
||||||
|
|
@ -573,16 +570,11 @@
|
||||||
"comments": "Commentaires",
|
"comments": "Commentaires",
|
||||||
"noComments": "Aucun commentaire pour le moment.",
|
"noComments": "Aucun commentaire pour le moment.",
|
||||||
"commentEdited": "(modifié {time})",
|
"commentEdited": "(modifié {time})",
|
||||||
"deleteComment": "Supprimer le commentaire",
|
"commentMarkdownHint": "Markdown : **gras**, #, ##, ###",
|
||||||
"deleteCommentConfirm": "Le commentaire et toutes les pièces jointes seront définitivement supprimés. Continuer ?",
|
|
||||||
"commentMarkdownHint": "Markdown : **gras**, #, ##, -, 1.",
|
|
||||||
"commentFormatBold": "Gras",
|
"commentFormatBold": "Gras",
|
||||||
"commentFormatH1": "Titre 1",
|
"commentFormatH1": "Titre 1",
|
||||||
"commentFormatH2": "Titre 2",
|
"commentFormatH2": "Titre 2",
|
||||||
"commentFormatBullet": "Liste à puces",
|
"commentFormatH3": "Titre 3",
|
||||||
"commentFormatNumbered": "Liste numérotée",
|
|
||||||
"pasteImageTooLarge": "L'image collée dépasse la limite de 100 Mo",
|
|
||||||
"pasteImageUploadFailed": "Échec du téléversement de l'image collée",
|
|
||||||
"addComment": "Ajouter",
|
"addComment": "Ajouter",
|
||||||
"timeline": "Chronologie",
|
"timeline": "Chronologie",
|
||||||
"noTimelineEvents": "Aucun événement pour le moment.",
|
"noTimelineEvents": "Aucun événement pour le moment.",
|
||||||
|
|
|
||||||
|
|
@ -64,7 +64,6 @@
|
||||||
"deleteInstallation": "Elimina installazione",
|
"deleteInstallation": "Elimina installazione",
|
||||||
"confirmDeleteInstallation": "Vuoi eliminare questa installazione?",
|
"confirmDeleteInstallation": "Vuoi eliminare questa installazione?",
|
||||||
"installationModel": "Modello di installazione",
|
"installationModel": "Modello di installazione",
|
||||||
"model": "Modello",
|
|
||||||
"externalEms": "EMS esterno",
|
"externalEms": "EMS esterno",
|
||||||
"externalEmsOther": "EMS esterno (specificare)",
|
"externalEmsOther": "EMS esterno (specificare)",
|
||||||
"emsNo": "No",
|
"emsNo": "No",
|
||||||
|
|
@ -74,7 +73,6 @@
|
||||||
"dataCollectionEnabled": "Raccolta dati",
|
"dataCollectionEnabled": "Raccolta dati",
|
||||||
"generalInfo": "Informazioni generali",
|
"generalInfo": "Informazioni generali",
|
||||||
"installationSetup": "Configurazione installazione",
|
"installationSetup": "Configurazione installazione",
|
||||||
"installationDate": "Data di installazione",
|
|
||||||
"couplingType": "Accoppiamento AC/DC",
|
"couplingType": "Accoppiamento AC/DC",
|
||||||
"couplingAC": "Accoppiamento AC",
|
"couplingAC": "Accoppiamento AC",
|
||||||
"couplingDC": "Accoppiamento DC",
|
"couplingDC": "Accoppiamento DC",
|
||||||
|
|
@ -559,7 +557,6 @@
|
||||||
"priority": "Priorità",
|
"priority": "Priorità",
|
||||||
"category": "Categoria",
|
"category": "Categoria",
|
||||||
"allStatuses": "Tutti gli stati",
|
"allStatuses": "Tutti gli stati",
|
||||||
"allPartners": "Tutti i partner",
|
|
||||||
"createdAt": "Creato",
|
"createdAt": "Creato",
|
||||||
"noTickets": "Nessun ticket trovato.",
|
"noTickets": "Nessun ticket trovato.",
|
||||||
"backToTickets": "Torna ai ticket",
|
"backToTickets": "Torna ai ticket",
|
||||||
|
|
@ -573,16 +570,11 @@
|
||||||
"comments": "Commenti",
|
"comments": "Commenti",
|
||||||
"noComments": "Nessun commento ancora.",
|
"noComments": "Nessun commento ancora.",
|
||||||
"commentEdited": "(modificato {time})",
|
"commentEdited": "(modificato {time})",
|
||||||
"deleteComment": "Elimina commento",
|
"commentMarkdownHint": "Markdown: **grassetto**, #, ##, ###",
|
||||||
"deleteCommentConfirm": "Il commento e tutti gli allegati saranno eliminati definitivamente. Continuare?",
|
|
||||||
"commentMarkdownHint": "Markdown: **grassetto**, #, ##, -, 1.",
|
|
||||||
"commentFormatBold": "Grassetto",
|
"commentFormatBold": "Grassetto",
|
||||||
"commentFormatH1": "Titolo 1",
|
"commentFormatH1": "Titolo 1",
|
||||||
"commentFormatH2": "Titolo 2",
|
"commentFormatH2": "Titolo 2",
|
||||||
"commentFormatBullet": "Elenco puntato",
|
"commentFormatH3": "Titolo 3",
|
||||||
"commentFormatNumbered": "Elenco numerato",
|
|
||||||
"pasteImageTooLarge": "L'immagine incollata supera il limite di 100 MB",
|
|
||||||
"pasteImageUploadFailed": "Caricamento dell'immagine incollata non riuscito",
|
|
||||||
"addComment": "Aggiungi",
|
"addComment": "Aggiungi",
|
||||||
"timeline": "Cronologia",
|
"timeline": "Cronologia",
|
||||||
"noTimelineEvents": "Nessun evento ancora.",
|
"noTimelineEvents": "Nessun evento ancora.",
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue