diff --git a/typescript/frontend-marios2/src/components/DocumentImage.tsx b/typescript/frontend-marios2/src/components/DocumentImage.tsx new file mode 100644 index 000000000..a8001d1ec --- /dev/null +++ b/typescript/frontend-marios2/src/components/DocumentImage.tsx @@ -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(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 ( + + + {alt || 'Image unavailable'} + + ); + } + + if (!blobUrl) { + return ( + + ); + } + + return ( + <> + setOpen(true)} + sx={{ + display: 'block', + maxWidth: '100%', + maxHeight, + borderRadius: 1, + border: '1px solid', + borderColor: 'divider', + cursor: 'zoom-in', + my: 0.5 + }} + /> + setOpen(false)} maxWidth="lg"> + + 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" + > + + + + + + + ); +} + +export default DocumentImage; diff --git a/typescript/frontend-marios2/src/content/dashboards/Tickets/CommentThread.tsx b/typescript/frontend-marios2/src/content/dashboards/Tickets/CommentThread.tsx index 12e0c141b..494317459 100644 --- a/typescript/frontend-marios2/src/content/dashboards/Tickets/CommentThread.tsx +++ b/typescript/frontend-marios2/src/content/dashboards/Tickets/CommentThread.tsx @@ -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, @@ -21,6 +22,7 @@ import { MenuList, Paper, Popper, + Snackbar, TextField, Tooltip, Typography @@ -36,6 +38,7 @@ import { AdminUser, CommentAuthorType, TicketComment } from 'src/interfaces/Tick import DocumentList from 'src/components/DocumentList'; import CommentFormatToolbar from './CommentFormatToolbar'; import { renderCommentBody, handleListEnter } from './commentMarkdown'; +import { usePasteImage, PendingPaste } from 'src/hooks/usePasteImage'; interface CommentThreadProps { ticketId: number; @@ -70,6 +73,37 @@ function CommentThread({ const commentInputRef = useRef(null); const editInputRef = useRef(null); + const [pendingPastes, setPendingPastes] = useState([]); + const [pasteError, setPasteError] = useState<'tooLarge' | 'uploadFailed' | null>(null); + + const pendingPastesRef = useRef([]); + 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 @@ -192,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); @@ -210,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) { @@ -235,6 +306,7 @@ function CommentThread({ setMentionedIds([]); setMentionQuery(null); setSelectedFiles([]); + setPendingPastes([]); setRefreshKey((k) => k + 1); onCommentAdded(); } finally { @@ -342,6 +414,7 @@ function CommentThread({ value={editBody} onChange={(e) => setEditBody(e.target.value)} onKeyDown={(e) => handleListEnter(e, editBody, setEditBody)} + onPaste={handlePasteEditComment} inputRef={editInputRef} /> @@ -402,6 +475,7 @@ function CommentThread({ value={body} onChange={handleBodyChange} onKeyDown={(e) => handleListEnter(e, body, setBody)} + onPaste={handlePasteNewComment} inputRef={commentInputRef} /> + setPasteError(null)} + anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }} + > + setPasteError(null)}> + {pasteError === 'tooLarge' ? ( + + ) : ( + + )} + + ); } diff --git a/typescript/frontend-marios2/src/content/dashboards/Tickets/CreateTicketModal.tsx b/typescript/frontend-marios2/src/content/dashboards/Tickets/CreateTicketModal.tsx index 18df0ca00..d2dcf04f2 100644 --- a/typescript/frontend-marios2/src/content/dashboards/Tickets/CreateTicketModal.tsx +++ b/typescript/frontend-marios2/src/content/dashboards/Tickets/CreateTicketModal.tsx @@ -15,6 +15,7 @@ import { LinearProgress, MenuItem, Select, + Snackbar, TextField, Typography } from '@mui/material'; @@ -23,6 +24,7 @@ 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, @@ -94,6 +96,27 @@ function CreateTicketModal({ open, onClose, onCreated, defaultInstallationId }: const [uploading, setUploading] = useState(false); const [uploadProgress, setUploadProgress] = useState(0); + const [pendingPastes, setPendingPastes] = useState([]); + const [pasteError, setPasteError] = useState<'tooLarge' | 'uploadFailed' | null>(null); + + const pendingPastesRef = useRef([]); + 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; @@ -257,6 +280,8 @@ function CreateTicketModal({ open, onClose, onCreated, defaultInstallationId }: setCustomSubCategory(''); setCustomCategory(''); setSelectedFiles([]); + pendingPastes.forEach((p) => window.URL.revokeObjectURL(p.blobUrl)); + setPendingPastes([]); setError(''); }; @@ -287,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) { @@ -572,6 +635,7 @@ function CreateTicketModal({ open, onClose, onCreated, defaultInstallationId }: value={description} onChange={(e) => setDescription(e.target.value)} onKeyDown={(e) => handleListEnter(e, description, setDescription)} + onPaste={handlePasteDescription} inputRef={descriptionRef} multiline rows={4} @@ -630,6 +694,26 @@ function CreateTicketModal({ open, onClose, onCreated, defaultInstallationId }: + setPasteError(null)} + anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }} + > + setPasteError(null)}> + {pasteError === 'tooLarge' ? ( + + ) : ( + + )} + + ); } diff --git a/typescript/frontend-marios2/src/content/dashboards/Tickets/TicketDetail.tsx b/typescript/frontend-marios2/src/content/dashboards/Tickets/TicketDetail.tsx index 38b112a20..9db193c5d 100644 --- a/typescript/frontend-marios2/src/content/dashboards/Tickets/TicketDetail.tsx +++ b/typescript/frontend-marios2/src/content/dashboards/Tickets/TicketDetail.tsx @@ -22,6 +22,7 @@ import { InputLabel, MenuItem, Select, + Snackbar, TextField, Typography } from '@mui/material'; @@ -52,6 +53,7 @@ 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'; @@ -101,6 +103,19 @@ function TicketDetailPage() { const rootCauseRef = useRef(null); const solutionRef = useRef(null); const descriptionRef = useRef(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(''); @@ -447,6 +462,7 @@ function TicketDetailPage() { setDescriptionSaved(false); }) } + onPaste={handlePasteDescription} inputRef={descriptionRef} /> @@ -1014,6 +1030,26 @@ function TicketDetailPage() { + setPasteError(null)} + anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }} + > + setPasteError(null)}> + {pasteError === 'tooLarge' ? ( + + ) : ( + + )} + +