Compare commits
11 Commits
ed00b742a1
...
c189a077fb
| Author | SHA1 | Date |
|---|---|---|
|
|
c189a077fb | |
|
|
5586001b79 | |
|
|
99ba1b947c | |
|
|
53f0363da6 | |
|
|
6f1d35016c | |
|
|
c21000e658 | |
|
|
7f902c7271 | |
|
|
90f6c2a5f9 | |
|
|
8d43687829 | |
|
|
a8b371e1da | |
|
|
49ae2b7806 |
|
|
@ -2501,6 +2501,42 @@ public class Controller : ControllerBase
|
|||
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))]
|
||||
public ActionResult<Object> GetTicketDetail(Int64 id, Token authToken)
|
||||
{
|
||||
|
|
@ -2534,15 +2570,33 @@ public class Controller : ControllerBase
|
|||
if (user is null || user.UserType != 2) return Unauthorized();
|
||||
|
||||
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 installation = t.InstallationId.HasValue ? Db.GetInstallationById(t.InstallationId.Value) : null;
|
||||
Installation? installation = null;
|
||||
if (t.InstallationId.HasValue)
|
||||
installationsById.TryGetValue(t.InstallationId.Value, out installation);
|
||||
|
||||
return new
|
||||
{
|
||||
t.Id, t.Subject, t.Status, t.Priority, t.Category, t.SubCategory,
|
||||
t.InstallationId, t.CreatedAt, t.UpdatedAt,
|
||||
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,6 +36,7 @@ public static partial class Db
|
|||
// Ticket system tables
|
||||
public static TableQuery<Ticket> Tickets => Connection.Table<Ticket>();
|
||||
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<TicketTimelineEvent> TicketTimelineEvents => Connection.Table<TicketTimelineEvent>();
|
||||
|
||||
|
|
|
|||
|
|
@ -129,10 +129,13 @@ public static partial class Db
|
|||
.Select(t => t.Id).ToList();
|
||||
foreach (var tid in ticketIds)
|
||||
{
|
||||
// Delete documents attached to ticket comments
|
||||
// Delete documents and mentions attached to ticket comments
|
||||
var tCommentIds = TicketComments.Where(c => c.TicketId == tid).Select(c => c.Id).ToList();
|
||||
foreach (var cid in tCommentIds)
|
||||
{
|
||||
Documents .Delete(d => d.TicketCommentId == cid);
|
||||
TicketCommentMentions .Delete(m => m.CommentId == cid);
|
||||
}
|
||||
|
||||
// Delete documents attached directly to the ticket
|
||||
Documents .Delete(d => d.TicketId == tid);
|
||||
|
|
@ -231,13 +234,16 @@ public static partial class Db
|
|||
|
||||
Boolean DeleteTicketAndChildren()
|
||||
{
|
||||
// Delete documents attached to comments on this ticket
|
||||
// Delete documents and mentions attached to comments on this ticket
|
||||
var commentIds = TicketComments
|
||||
.Where(c => c.TicketId == ticket.Id)
|
||||
.Select(c => c.Id)
|
||||
.ToList();
|
||||
foreach (var cid in commentIds)
|
||||
{
|
||||
Documents .Delete(d => d.TicketCommentId == cid);
|
||||
TicketCommentMentions .Delete(m => m.CommentId == cid);
|
||||
}
|
||||
|
||||
// Delete documents attached directly to the ticket
|
||||
Documents .Delete(d => d.TicketId == ticket.Id);
|
||||
|
|
@ -256,6 +262,21 @@ public static partial class Db
|
|||
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)
|
||||
{
|
||||
// Get documents attached directly to the ticket
|
||||
|
|
|
|||
|
|
@ -0,0 +1,128 @@
|
|||
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,6 +404,7 @@ function InformationSodistorehome(props: InformationSodistorehomeProps) {
|
|||
|
||||
const [networkProviders, setNetworkProviders] = useState<string[]>([]);
|
||||
const [loadingProviders, setLoadingProviders] = useState(false);
|
||||
const [installationDate, setInstallationDate] = useState<string>('');
|
||||
|
||||
useEffect(() => {
|
||||
setLoadingProviders(true);
|
||||
|
|
@ -414,6 +415,23 @@ function InformationSodistorehome(props: InformationSodistorehomeProps) {
|
|||
.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 (
|
||||
<>
|
||||
{openModalDeleteInstallation && (
|
||||
|
|
@ -856,6 +874,18 @@ function InformationSodistorehome(props: InformationSodistorehomeProps) {
|
|||
/>
|
||||
</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>
|
||||
<FormControl sx={{ m: 1, width: '50ch' }}>
|
||||
<InputLabel
|
||||
|
|
|
|||
|
|
@ -327,21 +327,33 @@ const FlatInstallationView = (props: FlatInstallationViewProps) => {
|
|||
<TableCell>
|
||||
<FormattedMessage id="name" defaultMessage="Name" />
|
||||
</TableCell>
|
||||
{product === 4 && (
|
||||
<TableCell>
|
||||
<FormattedMessage id="model" defaultMessage="Model" />
|
||||
</TableCell>
|
||||
)}
|
||||
<TableCell>
|
||||
<FormattedMessage
|
||||
id="location"
|
||||
defaultMessage="Location"
|
||||
/>
|
||||
</TableCell>
|
||||
{product !== 4 && (
|
||||
<TableCell>
|
||||
<FormattedMessage id="country" defaultMessage="Country" />
|
||||
</TableCell>
|
||||
)}
|
||||
<TableCell>
|
||||
<FormattedMessage
|
||||
id="orderNumbers"
|
||||
defaultMessage="Order Numbers"
|
||||
/>
|
||||
</TableCell>
|
||||
{product === 4 && (
|
||||
<TableCell>
|
||||
<FormattedMessage id="city" defaultMessage="City" />
|
||||
</TableCell>
|
||||
)}
|
||||
<TableCell>
|
||||
<FormattedMessage id="status" defaultMessage="Status" />
|
||||
</TableCell>
|
||||
|
|
@ -396,6 +408,21 @@ const FlatInstallationView = (props: FlatInstallationViewProps) => {
|
|||
</Typography>
|
||||
</TableCell>
|
||||
|
||||
{product === 4 && (
|
||||
<TableCell>
|
||||
<Typography
|
||||
variant="body2"
|
||||
fontWeight="bold"
|
||||
color="text.primary"
|
||||
gutterBottom
|
||||
noWrap
|
||||
sx={{ marginTop: '10px', fontSize: 'small' }}
|
||||
>
|
||||
{installation.installationModel || ''}
|
||||
</Typography>
|
||||
</TableCell>
|
||||
)}
|
||||
|
||||
<TableCell>
|
||||
<Typography
|
||||
variant="body2"
|
||||
|
|
@ -409,6 +436,7 @@ const FlatInstallationView = (props: FlatInstallationViewProps) => {
|
|||
</Typography>
|
||||
</TableCell>
|
||||
|
||||
{product !== 4 && (
|
||||
<TableCell>
|
||||
<Typography
|
||||
variant="body2"
|
||||
|
|
@ -421,6 +449,7 @@ const FlatInstallationView = (props: FlatInstallationViewProps) => {
|
|||
{installation.country}
|
||||
</Typography>
|
||||
</TableCell>
|
||||
)}
|
||||
|
||||
<TableCell>
|
||||
<Typography
|
||||
|
|
@ -435,6 +464,21 @@ const FlatInstallationView = (props: FlatInstallationViewProps) => {
|
|||
</Typography>
|
||||
</TableCell>
|
||||
|
||||
{product === 4 && (
|
||||
<TableCell>
|
||||
<Typography
|
||||
variant="body2"
|
||||
fontWeight="bold"
|
||||
color="text.primary"
|
||||
gutterBottom
|
||||
noWrap
|
||||
sx={{ marginTop: '10px', fontSize: 'small' }}
|
||||
>
|
||||
{installation.city || ''}
|
||||
</Typography>
|
||||
</TableCell>
|
||||
)}
|
||||
|
||||
<TableCell>
|
||||
<div
|
||||
style={{
|
||||
|
|
|
|||
|
|
@ -136,16 +136,16 @@ const FlatInstallationView = (props: FlatInstallationViewProps) => {
|
|||
<FormattedMessage id="name" defaultMessage="Name" />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<FormattedMessage id="installationSN" defaultMessage="Installation SN" />
|
||||
<FormattedMessage id="model" defaultMessage="Model" />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<FormattedMessage id="DeviceType" defaultMessage="Device Type" />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<FormattedMessage id="canton" defaultMessage="Canton" />
|
||||
<FormattedMessage id="installationSN" defaultMessage="Installation SN" />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<FormattedMessage id="country" defaultMessage="Country" />
|
||||
<FormattedMessage id="city" defaultMessage="City" />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<FormattedMessage id="status" defaultMessage="Status" />
|
||||
|
|
@ -202,7 +202,7 @@ const FlatInstallationView = (props: FlatInstallationViewProps) => {
|
|||
noWrap
|
||||
sx={{ marginTop: '10px', fontSize: 'small' }}
|
||||
>
|
||||
{installation.serialNumber}
|
||||
{installation.installationModel || ''}
|
||||
</Typography>
|
||||
</TableCell>
|
||||
|
||||
|
|
@ -228,7 +228,7 @@ const FlatInstallationView = (props: FlatInstallationViewProps) => {
|
|||
noWrap
|
||||
sx={{ marginTop: '10px', fontSize: 'small' }}
|
||||
>
|
||||
{installation.canton || ''}
|
||||
{installation.serialNumber}
|
||||
</Typography>
|
||||
</TableCell>
|
||||
|
||||
|
|
@ -241,7 +241,7 @@ const FlatInstallationView = (props: FlatInstallationViewProps) => {
|
|||
noWrap
|
||||
sx={{ marginTop: '10px', fontSize: 'small' }}
|
||||
>
|
||||
{installation.country}
|
||||
{installation.city || ''}
|
||||
</Typography>
|
||||
</TableCell>
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
import React from 'react';
|
||||
import { Box, Button, Tooltip } from '@mui/material';
|
||||
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 { applyFormat, FormatKind } from './commentMarkdown';
|
||||
|
||||
|
|
@ -48,10 +50,17 @@ function CommentFormatToolbar({
|
|||
</Button>
|
||||
</span>
|
||||
</Tooltip>
|
||||
<Tooltip title={intl.formatMessage({ id: 'commentFormatH3', defaultMessage: 'Heading 3' })}>
|
||||
<Tooltip title={intl.formatMessage({ id: 'commentFormatBullet', defaultMessage: 'Bullet list' })}>
|
||||
<span>
|
||||
<Button size="small" variant="outlined" sx={btnSx} onClick={handle('h3')} disabled={disabled}>
|
||||
H3
|
||||
<Button size="small" variant="outlined" sx={btnSx} onClick={handle('bullet')} disabled={disabled}>
|
||||
<FormatListBulletedIcon fontSize="inherit" />
|
||||
</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>
|
||||
</span>
|
||||
</Tooltip>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import React, { useContext, useRef, useState } from 'react';
|
||||
import React, { useContext, useEffect, useRef, useState } from 'react';
|
||||
import { UserContext } from 'src/contexts/userContext';
|
||||
import {
|
||||
Alert,
|
||||
Avatar,
|
||||
Box,
|
||||
Button,
|
||||
|
|
@ -9,24 +10,35 @@ import {
|
|||
CardHeader,
|
||||
Chip,
|
||||
ClickAwayListener,
|
||||
Dialog,
|
||||
DialogActions,
|
||||
DialogContent,
|
||||
DialogContentText,
|
||||
DialogTitle,
|
||||
Divider,
|
||||
IconButton,
|
||||
LinearProgress,
|
||||
MenuItem,
|
||||
MenuList,
|
||||
Paper,
|
||||
Popper,
|
||||
Snackbar,
|
||||
TextField,
|
||||
Tooltip,
|
||||
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 EditIcon from '@mui/icons-material/Edit';
|
||||
import DeleteIcon from '@mui/icons-material/Delete';
|
||||
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';
|
||||
import CommentFormatToolbar from './CommentFormatToolbar';
|
||||
import { renderCommentBody } from './commentMarkdown';
|
||||
import { renderCommentBody, handleListEnter } from './commentMarkdown';
|
||||
import { usePasteImage, PendingPaste } from 'src/hooks/usePasteImage';
|
||||
|
||||
interface CommentThreadProps {
|
||||
ticketId: number;
|
||||
|
|
@ -49,6 +61,8 @@ function CommentThread({
|
|||
const [editingId, setEditingId] = useState<number | null>(null);
|
||||
const [editBody, setEditBody] = useState('');
|
||||
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 [selectedFiles, setSelectedFiles] = useState<File[]>([]);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
|
|
@ -59,6 +73,37 @@ function CommentThread({
|
|||
const commentInputRef = 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 mentionCandidates = mentionQuery === null
|
||||
|
|
@ -147,6 +192,23 @@ function CommentThread({
|
|||
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) => {
|
||||
if (!editBody.trim()) return;
|
||||
setSavingEdit(true);
|
||||
|
|
@ -164,11 +226,12 @@ function CommentThread({
|
|||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!body.trim() && selectedFiles.length === 0) return;
|
||||
if (!body.trim() && selectedFiles.length === 0 && pendingPastes.length === 0) return;
|
||||
setSubmitting(true);
|
||||
|
||||
try {
|
||||
let commentId: number | undefined;
|
||||
let finalBody = body;
|
||||
if (body.trim()) {
|
||||
const activeMentionedIds = mentionedIds.filter((uid) => {
|
||||
const u = adminUsers.find((au) => au.id === uid);
|
||||
|
|
@ -182,6 +245,42 @@ function CommentThread({
|
|||
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) {
|
||||
setUploading(true);
|
||||
for (const file of selectedFiles) {
|
||||
|
|
@ -207,6 +306,7 @@ function CommentThread({
|
|||
setMentionedIds([]);
|
||||
setMentionQuery(null);
|
||||
setSelectedFiles([]);
|
||||
setPendingPastes([]);
|
||||
setRefreshKey((k) => k + 1);
|
||||
onCommentAdded();
|
||||
} finally {
|
||||
|
|
@ -274,14 +374,28 @@ function CommentThread({
|
|||
</Typography>
|
||||
)}
|
||||
{canEdit && !isEditing && (
|
||||
<Button
|
||||
<Box sx={{ ml: 'auto', display: 'flex', gap: 0.5 }}>
|
||||
<Tooltip title={intl.formatMessage({ id: 'edit', defaultMessage: 'Edit' })}>
|
||||
<IconButton
|
||||
size="small"
|
||||
variant="text"
|
||||
sx={{ minWidth: 0, p: 0, ml: 'auto', textTransform: 'none' }}
|
||||
onClick={() => startEdit(comment)}
|
||||
aria-label={intl.formatMessage({ id: 'edit', defaultMessage: 'Edit' })}
|
||||
>
|
||||
<FormattedMessage id="edit" defaultMessage="Edit" />
|
||||
</Button>
|
||||
<EditIcon fontSize="small" />
|
||||
</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>
|
||||
{isEditing ? (
|
||||
|
|
@ -299,6 +413,8 @@ function CommentThread({
|
|||
minRows={2}
|
||||
value={editBody}
|
||||
onChange={(e) => setEditBody(e.target.value)}
|
||||
onKeyDown={(e) => handleListEnter(e, editBody, setEditBody)}
|
||||
onPaste={handlePasteEditComment}
|
||||
inputRef={editInputRef}
|
||||
/>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'flex-end', gap: 1 }}>
|
||||
|
|
@ -341,7 +457,7 @@ function CommentThread({
|
|||
<Typography variant="caption" color="text.disabled">
|
||||
<FormattedMessage
|
||||
id="commentMarkdownHint"
|
||||
defaultMessage="Markdown: **bold**, #, ##, ###"
|
||||
defaultMessage="Markdown: **bold**, #, ##"
|
||||
/>
|
||||
</Typography>
|
||||
</Box>
|
||||
|
|
@ -358,6 +474,8 @@ function CommentThread({
|
|||
})}
|
||||
value={body}
|
||||
onChange={handleBodyChange}
|
||||
onKeyDown={(e) => handleListEnter(e, body, setBody)}
|
||||
onPaste={handlePasteNewComment}
|
||||
inputRef={commentInputRef}
|
||||
/>
|
||||
<Popper
|
||||
|
|
@ -422,6 +540,58 @@ function CommentThread({
|
|||
{uploading && <LinearProgress />}
|
||||
</Box>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,12 +15,16 @@ import {
|
|||
LinearProgress,
|
||||
MenuItem,
|
||||
Select,
|
||||
Snackbar,
|
||||
TextField,
|
||||
Typography
|
||||
} from '@mui/material';
|
||||
import AttachFileIcon from '@mui/icons-material/AttachFile';
|
||||
import { FormattedMessage, useIntl } from 'react-intl';
|
||||
import axiosConfig from 'src/Resources/axiosConfig';
|
||||
import CommentFormatToolbar from './CommentFormatToolbar';
|
||||
import { handleListEnter } from './commentMarkdown';
|
||||
import { usePasteImage, PendingPaste } from 'src/hooks/usePasteImage';
|
||||
import {
|
||||
TicketPriority,
|
||||
TicketCategory,
|
||||
|
|
@ -87,10 +91,32 @@ function CreateTicketModal({ open, onClose, onCreated, defaultInstallationId }:
|
|||
|
||||
// File attachments
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const descriptionRef = useRef<HTMLInputElement | null>(null);
|
||||
const [selectedFiles, setSelectedFiles] = useState<File[]>([]);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
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 MAX_FILE_SIZE = 25 * 1024 * 1024;
|
||||
|
||||
|
|
@ -254,6 +280,8 @@ function CreateTicketModal({ open, onClose, onCreated, defaultInstallationId }:
|
|||
setCustomSubCategory('');
|
||||
setCustomCategory('');
|
||||
setSelectedFiles([]);
|
||||
pendingPastes.forEach((p) => window.URL.revokeObjectURL(p.blobUrl));
|
||||
setPendingPastes([]);
|
||||
setError('');
|
||||
};
|
||||
|
||||
|
|
@ -284,7 +312,45 @@ function CreateTicketModal({ open, onClose, onCreated, defaultInstallationId }:
|
|||
customCategory: isOtherCategory ? customCategory || null : null
|
||||
});
|
||||
|
||||
const newTicketId = res.data?.id;
|
||||
const createdTicket = res.data;
|
||||
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
|
||||
if (selectedFiles.length > 0 && newTicketId) {
|
||||
|
|
@ -547,17 +613,36 @@ function CreateTicketModal({ open, onClose, onCreated, defaultInstallationId }:
|
|||
/>
|
||||
)}
|
||||
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1, mt: 1 }}>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', flexWrap: 'wrap', gap: 1 }}>
|
||||
<CommentFormatToolbar
|
||||
textareaRef={descriptionRef}
|
||||
value={description}
|
||||
onChange={setDescription}
|
||||
disabled={submitting || uploading}
|
||||
/>
|
||||
<Typography variant="caption" color="text.disabled">
|
||||
<FormattedMessage
|
||||
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 */}
|
||||
<Box sx={{ mt: 1 }}>
|
||||
|
|
@ -609,6 +694,26 @@ function CreateTicketModal({ open, onClose, onCreated, defaultInstallationId }:
|
|||
<FormattedMessage id="submit" defaultMessage="Submit" />
|
||||
</Button>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ import {
|
|||
InputLabel,
|
||||
MenuItem,
|
||||
Select,
|
||||
Snackbar,
|
||||
TextField,
|
||||
Typography
|
||||
} from '@mui/material';
|
||||
|
|
@ -50,6 +51,9 @@ import Footer from 'src/components/Footer';
|
|||
import StatusChip from './StatusChip';
|
||||
import AiDiagnosisPanel from './AiDiagnosisPanel';
|
||||
import CommentThread from './CommentThread';
|
||||
import CommentFormatToolbar from './CommentFormatToolbar';
|
||||
import { renderCommentBody, handleListEnter } from './commentMarkdown';
|
||||
import { usePasteImage } from 'src/hooks/usePasteImage';
|
||||
import TimelinePanel from './TimelinePanel';
|
||||
import FileUploadButton from 'src/components/FileUploadButton';
|
||||
import DocumentList from 'src/components/DocumentList';
|
||||
|
|
@ -98,6 +102,20 @@ function TicketDetailPage() {
|
|||
const [solveGateOpen, setSolveGateOpen] = useState(false);
|
||||
const rootCauseRef = 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
|
||||
const [editCustomSub, setEditCustomSub] = useState('');
|
||||
|
|
@ -411,7 +429,24 @@ function TicketDetailPage() {
|
|||
<Divider />
|
||||
<CardContent>
|
||||
{editingDescription ? (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.5 }}>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
|
||||
<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
|
||||
multiline
|
||||
minRows={3}
|
||||
|
|
@ -421,6 +456,14 @@ function TicketDetailPage() {
|
|||
setDescription(e.target.value);
|
||||
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}>
|
||||
{descriptionSaved && (
|
||||
|
|
@ -447,12 +490,9 @@ function TicketDetailPage() {
|
|||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
) : ticket.description ? (
|
||||
renderCommentBody(ticket.description)
|
||||
) : (
|
||||
<Typography
|
||||
variant="body1"
|
||||
sx={{ whiteSpace: 'pre-wrap' }}
|
||||
>
|
||||
{ticket.description || (
|
||||
<Typography
|
||||
component="span"
|
||||
variant="body2"
|
||||
|
|
@ -464,8 +504,6 @@ function TicketDetailPage() {
|
|||
/>
|
||||
</Typography>
|
||||
)}
|
||||
</Typography>
|
||||
)}
|
||||
|
||||
<Box sx={{ mt: 2 }}>
|
||||
<DocumentList
|
||||
|
|
@ -992,6 +1030,26 @@ function TicketDetailPage() {
|
|||
</Dialog>
|
||||
|
||||
</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 />
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -4,9 +4,11 @@ import {
|
|||
Alert,
|
||||
Box,
|
||||
Button,
|
||||
Checkbox,
|
||||
Container,
|
||||
FormControl,
|
||||
InputLabel,
|
||||
ListItemText,
|
||||
MenuItem,
|
||||
Select,
|
||||
Table,
|
||||
|
|
@ -52,7 +54,8 @@ function TicketList() {
|
|||
const intl = useIntl();
|
||||
const [tickets, setTickets] = useState<TicketSummary[]>([]);
|
||||
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 [error, setError] = useState('');
|
||||
|
||||
|
|
@ -67,14 +70,19 @@ function TicketList() {
|
|||
fetchTickets();
|
||||
}, []);
|
||||
|
||||
const partnerOptions = Array.from(
|
||||
new Set(tickets.map((t) => t.distributionPartner).filter((p) => p && p.trim() !== ''))
|
||||
).sort();
|
||||
|
||||
const filtered = tickets
|
||||
.filter((t) => {
|
||||
const matchesSearch =
|
||||
search === '' ||
|
||||
t.subject.toLowerCase().includes(search.toLowerCase()) ||
|
||||
t.installationName.toLowerCase().includes(search.toLowerCase());
|
||||
const matchesStatus = statusFilter === '' || t.status === statusFilter;
|
||||
return matchesSearch && matchesStatus;
|
||||
const matchesStatus = statusFilter.length === 0 || statusFilter.includes(t.status);
|
||||
const matchesPartner = partnerFilter === '' || t.distributionPartner === partnerFilter;
|
||||
return matchesSearch && matchesStatus && matchesPartner;
|
||||
})
|
||||
.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
|
||||
|
||||
|
|
@ -110,26 +118,60 @@ function TicketList() {
|
|||
onChange={(e) => setSearch(e.target.value)}
|
||||
sx={{ minWidth: 250 }}
|
||||
/>
|
||||
<FormControl size="small" sx={{ minWidth: 160 }}>
|
||||
<FormControl size="small" sx={{ minWidth: 200 }}>
|
||||
<InputLabel>
|
||||
<FormattedMessage id="status" defaultMessage="Status" />
|
||||
</InputLabel>
|
||||
<Select
|
||||
multiple
|
||||
value={statusFilter}
|
||||
label="Status"
|
||||
onChange={(e) =>
|
||||
setStatusFilter(e.target.value === '' ? '' : Number(e.target.value))
|
||||
label={intl.formatMessage({ id: 'status', defaultMessage: 'Status' })}
|
||||
onChange={(e) => {
|
||||
const value = 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(', ')
|
||||
}
|
||||
>
|
||||
<MenuItem value="">
|
||||
<FormattedMessage
|
||||
id="allStatuses"
|
||||
defaultMessage="All Statuses"
|
||||
/>
|
||||
{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>
|
||||
{Object.entries(statusKeys).map(([val, msg]) => (
|
||||
<MenuItem key={val} value={Number(val)}>
|
||||
{intl.formatMessage(msg)}
|
||||
);
|
||||
})}
|
||||
</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="">
|
||||
<FormattedMessage id="allPartners" defaultMessage="All Partners" />
|
||||
</MenuItem>
|
||||
{partnerOptions.map((p) => (
|
||||
<MenuItem key={p} value={p}>
|
||||
{p}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
|
|
@ -167,6 +209,18 @@ function TicketList() {
|
|||
defaultMessage="Status"
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<FormattedMessage
|
||||
id="installation"
|
||||
defaultMessage="Installation"
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<FormattedMessage
|
||||
id="distributionPartner"
|
||||
defaultMessage="Distribution Partner"
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<FormattedMessage
|
||||
id="priority"
|
||||
|
|
@ -179,12 +233,6 @@ function TicketList() {
|
|||
defaultMessage="Category"
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<FormattedMessage
|
||||
id="installation"
|
||||
defaultMessage="Installation"
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<FormattedMessage
|
||||
id="createdAt"
|
||||
|
|
@ -213,6 +261,8 @@ function TicketList() {
|
|||
<TableCell>
|
||||
<StatusChip status={ticket.status} />
|
||||
</TableCell>
|
||||
<TableCell>{ticket.installationName}</TableCell>
|
||||
<TableCell>{ticket.distributionPartner}</TableCell>
|
||||
<TableCell>{intl.formatMessage(priorityKeys[ticket.priority] ?? { id: 'unknown', defaultMessage: '-' })}</TableCell>
|
||||
<TableCell>
|
||||
{ticket.customCategory
|
||||
|
|
@ -224,7 +274,6 @@ function TicketList() {
|
|||
? ` — ${ticket.customSubCategory}`
|
||||
: ''}
|
||||
</TableCell>
|
||||
<TableCell>{ticket.installationName}</TableCell>
|
||||
<TableCell>
|
||||
{new Date(ticket.createdAt).toLocaleDateString()}
|
||||
</TableCell>
|
||||
|
|
|
|||
|
|
@ -1,7 +1,20 @@
|
|||
import React from 'react';
|
||||
import { Box, Typography } from '@mui/material';
|
||||
import DocumentImage from 'src/components/DocumentImage';
|
||||
|
||||
export type FormatKind = 'bold' | 'h1' | 'h2' | 'h3';
|
||||
export type FormatKind = 'bold' | 'h1' | 'h2' | 'bullet' | 'numbered';
|
||||
|
||||
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[] {
|
||||
const parts = text.split(/\*\*(.+?)\*\*/g);
|
||||
|
|
@ -10,40 +23,192 @@ function renderInline(text: string): React.ReactNode[] {
|
|||
);
|
||||
}
|
||||
|
||||
type ListBuf = { ordered: boolean; items: string[] };
|
||||
|
||||
export function renderCommentBody(body: string): JSX.Element {
|
||||
const lines = body.split('\n');
|
||||
return (
|
||||
<Box sx={{ '& > *': { mb: 0.5 } }}>
|
||||
{lines.map((line, idx) => {
|
||||
if (line.startsWith('### ')) {
|
||||
return (
|
||||
<Typography key={idx} variant="subtitle1" sx={{ fontWeight: 600, mt: 1 }}>
|
||||
{renderInline(line.slice(4))}
|
||||
</Typography>
|
||||
const blocks: JSX.Element[] = [];
|
||||
let listBuf: ListBuf | null = null;
|
||||
|
||||
const flushList = () => {
|
||||
if (!listBuf) return;
|
||||
const key = `list-${blocks.length}`;
|
||||
const items = listBuf.items.map((item, i) => (
|
||||
<li key={i} style={{ marginBottom: 2 }}>
|
||||
{renderInline(item)}
|
||||
</li>
|
||||
));
|
||||
blocks.push(
|
||||
listBuf.ordered ? (
|
||||
<Box
|
||||
component="ol"
|
||||
key={key}
|
||||
sx={{ pl: 3, my: 0.5, '& li': { typography: 'body2' } }}
|
||||
>
|
||||
{items}
|
||||
</Box>
|
||||
) : (
|
||||
<Box
|
||||
component="ul"
|
||||
key={key}
|
||||
sx={{ pl: 3, my: 0.5, '& li': { typography: 'body2' } }}
|
||||
>
|
||||
{items}
|
||||
</Box>
|
||||
)
|
||||
);
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
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('## ')) {
|
||||
return (
|
||||
<Typography key={idx} variant="h6" sx={{ mt: 1.5 }}>
|
||||
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('# ')) {
|
||||
return (
|
||||
<Typography key={idx} variant="h5" sx={{ mt: 2 }}>
|
||||
blocks.push(
|
||||
<Typography
|
||||
key={idx}
|
||||
variant="h4"
|
||||
sx={{ fontSize: '1.6rem', fontWeight: 700, mt: 2 }}
|
||||
>
|
||||
{renderInline(line.slice(2))}
|
||||
</Typography>
|
||||
);
|
||||
return;
|
||||
}
|
||||
return (
|
||||
blocks.push(
|
||||
<Typography key={idx} variant="body2" sx={{ whiteSpace: 'pre-wrap' }}>
|
||||
{line ? renderInline(line) : '\u00A0'}
|
||||
{line ? renderInline(line) : ' '}
|
||||
</Typography>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
);
|
||||
});
|
||||
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(
|
||||
|
|
@ -68,7 +233,21 @@ export function applyFormat(
|
|||
return;
|
||||
}
|
||||
|
||||
const prefix = kind === 'h1' ? '# ' : kind === 'h2' ? '## ' : '### ';
|
||||
if (kind === 'bullet' || kind === 'numbered') {
|
||||
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 nlAfter = value.indexOf('\n', start);
|
||||
const lineEnd = nlAfter === -1 ? value.length : nlAfter;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,138 @@
|
|||
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,6 +264,7 @@ export type TicketSummary = {
|
|||
createdAt: string;
|
||||
updatedAt: string;
|
||||
installationName: string;
|
||||
distributionPartner: string;
|
||||
customSubCategory: string | null;
|
||||
customCategory: string | null;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -82,6 +82,7 @@
|
|||
"deleteInstallation": "Installation löschen",
|
||||
"confirmDeleteInstallation": "Möchten Sie diese Installation löschen?",
|
||||
"installationModel": "Installationsmodell",
|
||||
"model": "Modell",
|
||||
"externalEms": "Externes EMS",
|
||||
"externalEmsOther": "Externes EMS (angeben)",
|
||||
"emsNo": "Nein",
|
||||
|
|
@ -91,6 +92,7 @@
|
|||
"dataCollectionEnabled": "Datenerfassung",
|
||||
"generalInfo": "Allgemeine Informationen",
|
||||
"installationSetup": "Installationseinrichtung",
|
||||
"installationDate": "Installationsdatum",
|
||||
"couplingType": "AC/DC-Kopplung",
|
||||
"couplingAC": "AC-gekoppelt",
|
||||
"couplingDC": "DC-gekoppelt",
|
||||
|
|
@ -557,6 +559,7 @@
|
|||
"priority": "Priorität",
|
||||
"category": "Kategorie",
|
||||
"allStatuses": "Alle Status",
|
||||
"allPartners": "Alle Partner",
|
||||
"createdAt": "Erstellt",
|
||||
"noTickets": "Keine Tickets gefunden.",
|
||||
"backToTickets": "Zurück zu Tickets",
|
||||
|
|
@ -570,11 +573,16 @@
|
|||
"comments": "Kommentare",
|
||||
"noComments": "Noch keine Kommentare.",
|
||||
"commentEdited": "(bearbeitet {time})",
|
||||
"commentMarkdownHint": "Markdown: **fett**, #, ##, ###",
|
||||
"deleteComment": "Kommentar löschen",
|
||||
"deleteCommentConfirm": "Der Kommentar und alle Anhänge werden dauerhaft gelöscht. Fortfahren?",
|
||||
"commentMarkdownHint": "Markdown: **fett**, #, ##, -, 1.",
|
||||
"commentFormatBold": "Fett",
|
||||
"commentFormatH1": "Überschrift 1",
|
||||
"commentFormatH2": "Überschrift 2",
|
||||
"commentFormatH3": "Überschrift 3",
|
||||
"commentFormatBullet": "Aufzählung",
|
||||
"commentFormatNumbered": "Nummerierte Liste",
|
||||
"pasteImageTooLarge": "Eingefügtes Bild überschreitet das Limit von 100 MB",
|
||||
"pasteImageUploadFailed": "Eingefügtes Bild konnte nicht hochgeladen werden",
|
||||
"addComment": "Hinzufügen",
|
||||
"timeline": "Zeitverlauf",
|
||||
"noTimelineEvents": "Noch keine Ereignisse.",
|
||||
|
|
|
|||
|
|
@ -64,6 +64,7 @@
|
|||
"deleteInstallation": "Delete Installation",
|
||||
"confirmDeleteInstallation": "Do you want to delete this installation?",
|
||||
"installationModel": "Installation Model",
|
||||
"model": "Model",
|
||||
"externalEms": "External EMS",
|
||||
"externalEmsOther": "External EMS (specify)",
|
||||
"emsNo": "No",
|
||||
|
|
@ -73,6 +74,7 @@
|
|||
"dataCollectionEnabled": "Data Collection",
|
||||
"generalInfo": "General Info",
|
||||
"installationSetup": "Installation Setup",
|
||||
"installationDate": "Installation Date",
|
||||
"couplingType": "AC/DC Coupling",
|
||||
"couplingAC": "AC-coupled",
|
||||
"couplingDC": "DC-coupled",
|
||||
|
|
@ -305,6 +307,7 @@
|
|||
"priority": "Priority",
|
||||
"category": "Category",
|
||||
"allStatuses": "All Statuses",
|
||||
"allPartners": "All Partners",
|
||||
"createdAt": "Created",
|
||||
"noTickets": "No tickets found.",
|
||||
"backToTickets": "Back to Tickets",
|
||||
|
|
@ -318,11 +321,16 @@
|
|||
"comments": "Comments",
|
||||
"noComments": "No comments yet.",
|
||||
"commentEdited": "(edited {time})",
|
||||
"commentMarkdownHint": "Markdown: **bold**, #, ##, ###",
|
||||
"deleteComment": "Delete comment",
|
||||
"deleteCommentConfirm": "This will permanently delete the comment and any attachments. Continue?",
|
||||
"commentMarkdownHint": "Markdown: **bold**, #, ##, -, 1.",
|
||||
"commentFormatBold": "Bold",
|
||||
"commentFormatH1": "Heading 1",
|
||||
"commentFormatH2": "Heading 2",
|
||||
"commentFormatH3": "Heading 3",
|
||||
"commentFormatBullet": "Bullet list",
|
||||
"commentFormatNumbered": "Numbered list",
|
||||
"pasteImageTooLarge": "Pasted image exceeds 100 MB limit",
|
||||
"pasteImageUploadFailed": "Failed to upload pasted image",
|
||||
"addComment": "Add",
|
||||
"timeline": "Timeline",
|
||||
"noTimelineEvents": "No events yet.",
|
||||
|
|
|
|||
|
|
@ -76,6 +76,7 @@
|
|||
"deleteInstallation": "Supprimer l'installation",
|
||||
"confirmDeleteInstallation": "Voulez-vous supprimer cette installation ?",
|
||||
"installationModel": "Modèle d'installation",
|
||||
"model": "Modèle",
|
||||
"externalEms": "EMS externe",
|
||||
"externalEmsOther": "EMS externe (préciser)",
|
||||
"emsNo": "Non",
|
||||
|
|
@ -85,6 +86,7 @@
|
|||
"dataCollectionEnabled": "Collecte de données",
|
||||
"generalInfo": "Informations générales",
|
||||
"installationSetup": "Configuration de l'installation",
|
||||
"installationDate": "Date d'installation",
|
||||
"couplingType": "Couplage AC/DC",
|
||||
"couplingAC": "Couplage AC",
|
||||
"couplingDC": "Couplage DC",
|
||||
|
|
@ -557,6 +559,7 @@
|
|||
"priority": "Priorité",
|
||||
"category": "Catégorie",
|
||||
"allStatuses": "Tous les statuts",
|
||||
"allPartners": "Tous les partenaires",
|
||||
"createdAt": "Créé",
|
||||
"noTickets": "Aucun ticket trouvé.",
|
||||
"backToTickets": "Retour aux tickets",
|
||||
|
|
@ -570,11 +573,16 @@
|
|||
"comments": "Commentaires",
|
||||
"noComments": "Aucun commentaire pour le moment.",
|
||||
"commentEdited": "(modifié {time})",
|
||||
"commentMarkdownHint": "Markdown : **gras**, #, ##, ###",
|
||||
"deleteComment": "Supprimer le commentaire",
|
||||
"deleteCommentConfirm": "Le commentaire et toutes les pièces jointes seront définitivement supprimés. Continuer ?",
|
||||
"commentMarkdownHint": "Markdown : **gras**, #, ##, -, 1.",
|
||||
"commentFormatBold": "Gras",
|
||||
"commentFormatH1": "Titre 1",
|
||||
"commentFormatH2": "Titre 2",
|
||||
"commentFormatH3": "Titre 3",
|
||||
"commentFormatBullet": "Liste à puces",
|
||||
"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",
|
||||
"timeline": "Chronologie",
|
||||
"noTimelineEvents": "Aucun événement pour le moment.",
|
||||
|
|
|
|||
|
|
@ -64,6 +64,7 @@
|
|||
"deleteInstallation": "Elimina installazione",
|
||||
"confirmDeleteInstallation": "Vuoi eliminare questa installazione?",
|
||||
"installationModel": "Modello di installazione",
|
||||
"model": "Modello",
|
||||
"externalEms": "EMS esterno",
|
||||
"externalEmsOther": "EMS esterno (specificare)",
|
||||
"emsNo": "No",
|
||||
|
|
@ -73,6 +74,7 @@
|
|||
"dataCollectionEnabled": "Raccolta dati",
|
||||
"generalInfo": "Informazioni generali",
|
||||
"installationSetup": "Configurazione installazione",
|
||||
"installationDate": "Data di installazione",
|
||||
"couplingType": "Accoppiamento AC/DC",
|
||||
"couplingAC": "Accoppiamento AC",
|
||||
"couplingDC": "Accoppiamento DC",
|
||||
|
|
@ -557,6 +559,7 @@
|
|||
"priority": "Priorità",
|
||||
"category": "Categoria",
|
||||
"allStatuses": "Tutti gli stati",
|
||||
"allPartners": "Tutti i partner",
|
||||
"createdAt": "Creato",
|
||||
"noTickets": "Nessun ticket trovato.",
|
||||
"backToTickets": "Torna ai ticket",
|
||||
|
|
@ -570,11 +573,16 @@
|
|||
"comments": "Commenti",
|
||||
"noComments": "Nessun commento ancora.",
|
||||
"commentEdited": "(modificato {time})",
|
||||
"commentMarkdownHint": "Markdown: **grassetto**, #, ##, ###",
|
||||
"deleteComment": "Elimina commento",
|
||||
"deleteCommentConfirm": "Il commento e tutti gli allegati saranno eliminati definitivamente. Continuare?",
|
||||
"commentMarkdownHint": "Markdown: **grassetto**, #, ##, -, 1.",
|
||||
"commentFormatBold": "Grassetto",
|
||||
"commentFormatH1": "Titolo 1",
|
||||
"commentFormatH2": "Titolo 2",
|
||||
"commentFormatH3": "Titolo 3",
|
||||
"commentFormatBullet": "Elenco puntato",
|
||||
"commentFormatNumbered": "Elenco numerato",
|
||||
"pasteImageTooLarge": "L'immagine incollata supera il limite di 100 MB",
|
||||
"pasteImageUploadFailed": "Caricamento dell'immagine incollata non riuscito",
|
||||
"addComment": "Aggiungi",
|
||||
"timeline": "Cronologia",
|
||||
"noTimelineEvents": "Nessun evento ancora.",
|
||||
|
|
|
|||
Loading…
Reference in New Issue