From 5d6c9a886c3ebfd916466e3cf56d59c3d7233c2b Mon Sep 17 00:00:00 2001 From: Yinyin Liu Date: Wed, 8 Apr 2026 15:58:57 +0200 Subject: [PATCH] allow to upload pictures and pdf in ticket and specific tab --- csharp/App/Backend/Controller.cs | 221 ++++++++++++++++- csharp/App/Backend/DataTypes/Document.cs | 26 ++ csharp/App/Backend/Database/Create.cs | 3 + csharp/App/Backend/Database/Db.cs | 9 + csharp/App/Backend/Database/Delete.cs | 54 +++++ csharp/App/Backend/Database/Read.cs | 23 ++ csharp/App/Backend/Program.cs | 28 +++ .../src/Resources/routes.json | 1 + .../src/components/DocumentList.tsx | 222 ++++++++++++++++++ .../src/components/FileUploadButton.tsx | 151 ++++++++++++ .../dashboards/Documents/DocumentsTab.tsx | 63 +++++ .../dashboards/Installations/Installation.tsx | 12 + .../dashboards/Installations/index.tsx | 19 +- .../SalidomoInstallations/Installation.tsx | 12 + .../SalidomoInstallations/index.tsx | 11 +- .../SodiohomeInstallations/Installation.tsx | 12 + .../SodiohomeInstallations/index.tsx | 19 +- .../dashboards/Tickets/CommentThread.tsx | 141 ++++++++--- .../dashboards/Tickets/CreateTicketModal.tsx | 128 +++++++++- .../dashboards/Tickets/TicketDetail.tsx | 18 ++ typescript/frontend-marios2/src/lang/de.json | 14 +- typescript/frontend-marios2/src/lang/en.json | 14 +- typescript/frontend-marios2/src/lang/fr.json | 14 +- typescript/frontend-marios2/src/lang/it.json | 14 +- 24 files changed, 1179 insertions(+), 50 deletions(-) create mode 100644 csharp/App/Backend/DataTypes/Document.cs create mode 100644 typescript/frontend-marios2/src/components/DocumentList.tsx create mode 100644 typescript/frontend-marios2/src/components/FileUploadButton.tsx create mode 100644 typescript/frontend-marios2/src/content/dashboards/Documents/DocumentsTab.tsx diff --git a/csharp/App/Backend/Controller.cs b/csharp/App/Backend/Controller.cs index ca7015c11..19fcb4d3c 100644 --- a/csharp/App/Backend/Controller.cs +++ b/csharp/App/Backend/Controller.cs @@ -7,6 +7,8 @@ using InnovEnergy.App.Backend.DataTypes.Methods; using InnovEnergy.App.Backend.Relations; using InnovEnergy.App.Backend.Services; using InnovEnergy.App.Backend.Websockets; +using InnovEnergy.Lib.S3Utils; +using InnovEnergy.Lib.S3Utils.DataTypes; using InnovEnergy.Lib.Utils; using Microsoft.AspNetCore.Mvc; using Newtonsoft.Json; @@ -2263,7 +2265,7 @@ public class Controller : ControllerBase } [HttpDelete(nameof(DeleteTicket))] - public ActionResult DeleteTicket(Int64 id, Token authToken) + public async Task DeleteTicket(Int64 id, Token authToken) { var user = Db.GetSession(authToken)?.User; if (user is null || user.UserType != 2) return Unauthorized(); @@ -2271,6 +2273,14 @@ public class Controller : ControllerBase var ticket = Db.GetTicketById(id); if (ticket is null) return NotFound(); + // Clean up S3 objects for ticket documents before DB delete + var s3Keys = Db.GetS3KeysForTicketDocuments(id); + if (s3Keys.Count > 0) + { + try { await DocumentBucket.DeleteObjects(s3Keys); } + catch (Exception ex) { Console.WriteLine($"[Documents] S3 cleanup on ticket delete failed: {ex.Message}"); } + } + return Db.Delete(ticket) ? Ok() : StatusCode(500, "Delete failed."); } @@ -2447,4 +2457,213 @@ public class Controller : ControllerBase return Db.Update(user) ? Ok() : StatusCode(500); } + // ── Document Upload/Download ──────────────────────────────────────── + + private static readonly HashSet AllowedMimeTypes = new(StringComparer.OrdinalIgnoreCase) + { + "image/jpeg", "image/png", "image/gif", "image/webp", + "application/pdf", "application/x-pdf" + }; + + // Some browsers send generic MIME types — allow them if the file extension is valid + private static readonly HashSet AllowedExtensions = new(StringComparer.OrdinalIgnoreCase) + { + ".jpg", ".jpeg", ".png", ".gif", ".webp", ".pdf" + }; + + private const Int64 MaxFileSizeBytes = 25 * 1024 * 1024; // 25 MB + + private static S3Bucket DocumentBucket + { + get + { + var region = new S3Region("https://sos-ch-dk-2.exo.io", ExoCmd.S3Credentials); + return region.Bucket(Program.DocumentBucketName); + } + } + + [HttpPost(nameof(UploadDocument))] + [RequestSizeLimit(26_214_400)] + public async Task> UploadDocument( + IFormFile file, + [FromQuery] Int32 scope, + [FromQuery] Int64? ticketId, + [FromQuery] Int64? ticketCommentId, + [FromQuery] Int64? installationId, + [FromQuery] Token authToken) + { + var user = Db.GetSession(authToken)?.User; + if (user is null) return Unauthorized(); + + if (file.Length == 0) + return BadRequest("File is empty."); + + if (file.Length > MaxFileSizeBytes) + return BadRequest($"File exceeds maximum size of {MaxFileSizeBytes / (1024 * 1024)} MB."); + + var fileExtension = Path.GetExtension(file.FileName); + if (!AllowedMimeTypes.Contains(file.ContentType) && !AllowedExtensions.Contains(fileExtension)) + { + Console.WriteLine($"[Documents] Rejected upload: name={file.FileName}, contentType={file.ContentType}, ext={fileExtension}, size={file.Length}"); + return BadRequest($"File type '{file.ContentType}' ({fileExtension}) is not allowed."); + } + + Console.WriteLine($"[Documents] Accepting upload: name={file.FileName}, contentType={file.ContentType}, size={file.Length}"); + + // Validate parent entity exists + var docScope = (DocumentScope)scope; + String s3Prefix; + + switch (docScope) + { + case DocumentScope.TicketAttachment: + if (ticketId.HasValue) + { + if (Db.GetTicketById(ticketId.Value) is null) return NotFound("Ticket not found."); + s3Prefix = $"tickets/{ticketId.Value}"; + } + else if (ticketCommentId.HasValue) + { + s3Prefix = $"comments/{ticketCommentId.Value}"; + } + else + { + return BadRequest("Ticket attachment requires ticketId or ticketCommentId."); + } + break; + + case DocumentScope.InstallationDocument: + if (!installationId.HasValue) + return BadRequest("Installation document requires installationId."); + if (Db.GetInstallationById(installationId.Value) is null) + return NotFound("Installation not found."); + s3Prefix = $"installations/{installationId.Value}"; + break; + + default: + return BadRequest("Invalid scope."); + } + + var guid = Guid.NewGuid().ToString("N"); + var safeFileName = Path.GetFileName(file.FileName); + var s3Key = $"{s3Prefix}/{guid}/{safeFileName}"; + + try + { + await using var stream = file.OpenReadStream(); + var s3Url = DocumentBucket.Path(s3Key); + var success = await s3Url.PutObject(stream); + + if (!success) + return StatusCode(500, "Failed to upload file to storage."); + } + catch (Exception ex) + { + Console.WriteLine($"[Documents] Upload failed: {ex.Message}"); + return StatusCode(500, "Failed to upload file to storage."); + } + + var document = new Document + { + TicketId = ticketId, + TicketCommentId = ticketCommentId, + InstallationId = installationId, + Scope = scope, + S3Key = s3Key, + OriginalName = safeFileName, + ContentType = file.ContentType, + SizeBytes = file.Length, + UploadedByUserId = user.Id, + CreatedAt = DateTime.UtcNow + }; + + if (!Db.Create(document)) + return StatusCode(500, "Failed to save document metadata."); + + return Ok(document); + } + + [HttpGet(nameof(DownloadDocument))] + public async Task DownloadDocument(Int64 id, Token authToken) + { + var user = Db.GetSession(authToken)?.User; + if (user is null) return Unauthorized(); + + var document = Db.GetDocumentById(id); + if (document is null) return NotFound("Document not found."); + + // Access control: admin can access all; others need installation access + if (user.UserType != 2 && document.InstallationId.HasValue) + { + var inst = Db.GetInstallationById(document.InstallationId.Value); + if (inst is null || !user.HasAccessTo(inst)) return Unauthorized(); + } + + try + { + var s3Url = DocumentBucket.Path(document.S3Key); + var data = await s3Url.GetObject(); + return File(data.ToArray(), document.ContentType, document.OriginalName); + } + catch (Exception ex) + { + Console.WriteLine($"[Documents] Download failed for {document.S3Key}: {ex.Message}"); + return StatusCode(500, "Failed to download file from storage."); + } + } + + [HttpGet(nameof(GetDocuments))] + public ActionResult> GetDocuments( + [FromQuery] Int64? ticketId, + [FromQuery] Int64? ticketCommentId, + [FromQuery] Int64? installationId, + [FromQuery] Token authToken) + { + var user = Db.GetSession(authToken)?.User; + if (user is null) return Unauthorized(); + + if (ticketId.HasValue) + return Ok(Db.GetDocumentsForTicket(ticketId.Value)); + + if (ticketCommentId.HasValue) + return Ok(Db.GetDocumentsForComment(ticketCommentId.Value)); + + if (installationId.HasValue) + { + // Access control: admin can list all; others need installation access + if (user.UserType != 2) + { + var inst = Db.GetInstallationById(installationId.Value); + if (inst is null || !user.HasAccessTo(inst)) return Unauthorized(); + } + return Ok(Db.GetDocumentsForInstallation(installationId.Value)); + } + + return BadRequest("Provide ticketId, ticketCommentId, or installationId."); + } + + [HttpDelete(nameof(DeleteDocument))] + public async Task DeleteDocument(Int64 id, Token authToken) + { + var user = Db.GetSession(authToken)?.User; + if (user is null || user.UserType != 2) return Unauthorized(); + + var document = Db.GetDocumentById(id); + if (document is null) return NotFound("Document not found."); + + try + { + await DocumentBucket.DeleteObjects(new[] { document.S3Key }); + } + catch (Exception ex) + { + Console.WriteLine($"[Documents] S3 delete failed for {document.S3Key}: {ex.Message}"); + } + + if (!Db.Delete(document)) + return StatusCode(500, "Failed to delete document metadata."); + + return Ok(); + } + } diff --git a/csharp/App/Backend/DataTypes/Document.cs b/csharp/App/Backend/DataTypes/Document.cs new file mode 100644 index 000000000..798742cd9 --- /dev/null +++ b/csharp/App/Backend/DataTypes/Document.cs @@ -0,0 +1,26 @@ +using SQLite; + +namespace InnovEnergy.App.Backend.DataTypes; + +public enum DocumentScope +{ + TicketAttachment = 0, + InstallationDocument = 1 +} + +public class Document +{ + [PrimaryKey, AutoIncrement] public Int64 Id { get; set; } + + [Indexed] public Int64? TicketId { get; set; } + [Indexed] public Int64? TicketCommentId { get; set; } + [Indexed] public Int64? InstallationId { get; set; } + + public Int32 Scope { get; set; } = (Int32)DocumentScope.TicketAttachment; + public String S3Key { get; set; } = ""; + public String OriginalName { get; set; } = ""; + public String ContentType { get; set; } = ""; + public Int64 SizeBytes { get; set; } + public Int64 UploadedByUserId { get; set; } + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; +} diff --git a/csharp/App/Backend/Database/Create.cs b/csharp/App/Backend/Database/Create.cs index cef03a470..c677ae8c0 100644 --- a/csharp/App/Backend/Database/Create.cs +++ b/csharp/App/Backend/Database/Create.cs @@ -87,6 +87,9 @@ public static partial class Db public static Boolean Create(TicketComment comment) => Insert(comment); public static Boolean Create(TicketAiDiagnosis diagnosis) => Insert(diagnosis); public static Boolean Create(TicketTimelineEvent ev) => Insert(ev); + + // Document storage + public static Boolean Create(Document document) => Insert(document); public static void HandleAction(UserAction newAction) { diff --git a/csharp/App/Backend/Database/Db.cs b/csharp/App/Backend/Database/Db.cs index 511f8aa80..3ee45c6bb 100644 --- a/csharp/App/Backend/Database/Db.cs +++ b/csharp/App/Backend/Database/Db.cs @@ -39,6 +39,9 @@ public static partial class Db public static TableQuery TicketAiDiagnoses => Connection.Table(); public static TableQuery TicketTimelineEvents => Connection.Table(); + // Document storage + public static TableQuery Documents => Connection.Table(); + public static void Init() { @@ -77,6 +80,9 @@ public static partial class Db Connection.CreateTable(); Connection.CreateTable(); Connection.CreateTable(); + + // Document storage + Connection.CreateTable(); }); // One-time migration: normalize legacy long-form language values to ISO codes @@ -135,6 +141,9 @@ public static partial class Db fileConnection.CreateTable(); fileConnection.CreateTable(); + // Document storage + fileConnection.CreateTable(); + // Migrate new columns: set defaults for existing rows where NULL or empty fileConnection.Execute("UPDATE Installation SET ExternalEms = 'No' WHERE ExternalEms IS NULL OR ExternalEms = ''"); fileConnection.Execute("UPDATE Installation SET InstallationModel = '' WHERE InstallationModel IS NULL"); diff --git a/csharp/App/Backend/Database/Delete.cs b/csharp/App/Backend/Database/Delete.cs index 0eed3ef03..b36b7229d 100644 --- a/csharp/App/Backend/Database/Delete.cs +++ b/csharp/App/Backend/Database/Delete.cs @@ -129,12 +129,22 @@ public static partial class Db .Select(t => t.Id).ToList(); foreach (var tid in ticketIds) { + // Delete documents 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); + + // Delete documents attached directly to the ticket + Documents .Delete(d => d.TicketId == tid); TicketComments .Delete(c => c.TicketId == tid); TicketAiDiagnoses .Delete(d => d.TicketId == tid); TicketTimelineEvents.Delete(e => e.TicketId == tid); } Tickets.Delete(t => t.InstallationId == installation.Id); + // Clean up installation-level documents + Documents.Delete(d => d.InstallationId == installation.Id); + return Installations.Delete(i => i.Id == installation.Id) > 0; } } @@ -218,6 +228,17 @@ public static partial class Db Boolean DeleteTicketAndChildren() { + // Delete documents 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); + + // Delete documents attached directly to the ticket + Documents .Delete(d => d.TicketId == ticket.Id); + TicketComments .Delete(c => c.TicketId == ticket.Id); TicketAiDiagnoses .Delete(d => d.TicketId == ticket.Id); TicketTimelineEvents.Delete(e => e.TicketId == ticket.Id); @@ -225,6 +246,39 @@ public static partial class Db } } + public static Boolean Delete(Document document) + { + var success = Documents.Delete(d => d.Id == document.Id) > 0; + if (success) Backup(); + return success; + } + + public static List GetS3KeysForTicketDocuments(Int64 ticketId) + { + // Get documents attached directly to the ticket + var keys = Documents + .Where(d => d.TicketId == ticketId) + .Select(d => d.S3Key) + .ToList(); + + // Also get documents attached to comments on this ticket + var commentIds = TicketComments + .Where(c => c.TicketId == ticketId) + .Select(c => c.Id) + .ToList(); + + foreach (var cid in commentIds) + { + var commentKeys = Documents + .Where(d => d.TicketCommentId == cid) + .Select(d => d.S3Key) + .ToList(); + keys.AddRange(commentKeys); + } + + return keys; + } + /// /// Deletes all report records older than 1 year. Called annually on Jan 2 /// after yearly reports are created. Uses fetch-then-delete for string-compared diff --git a/csharp/App/Backend/Database/Read.cs b/csharp/App/Backend/Database/Read.cs index 2e14e363b..a144075d6 100644 --- a/csharp/App/Backend/Database/Read.cs +++ b/csharp/App/Backend/Database/Read.cs @@ -210,4 +210,27 @@ public static partial class Db .Distinct() .OrderBy(s => s) .ToList(); + + // ── Document Queries ──────────────────────────────────────────────── + + public static Document? GetDocumentById(Int64 id) + => Documents.FirstOrDefault(d => d.Id == id); + + public static List GetDocumentsForTicket(Int64 ticketId) + => Documents + .Where(d => d.TicketId == ticketId) + .OrderBy(d => d.CreatedAt) + .ToList(); + + public static List GetDocumentsForComment(Int64 commentId) + => Documents + .Where(d => d.TicketCommentId == commentId) + .OrderBy(d => d.CreatedAt) + .ToList(); + + public static List GetDocumentsForInstallation(Int64 installationId) + => Documents + .Where(d => d.InstallationId == installationId && d.Scope == (Int32)DocumentScope.InstallationDocument) + .OrderBy(d => d.CreatedAt) + .ToList(); } \ No newline at end of file diff --git a/csharp/App/Backend/Program.cs b/csharp/App/Backend/Program.cs index 3034c1166..c090c0aac 100644 --- a/csharp/App/Backend/Program.cs +++ b/csharp/App/Backend/Program.cs @@ -8,6 +8,9 @@ using InnovEnergy.App.Backend.DeleteOldData; using Microsoft.AspNetCore.HttpOverrides; using Microsoft.AspNetCore.Mvc; using Microsoft.OpenApi.Models; +using InnovEnergy.App.Backend.DataTypes.Methods; +using InnovEnergy.Lib.S3Utils; +using InnovEnergy.Lib.S3Utils.DataTypes; using InnovEnergy.Lib.Utils; namespace InnovEnergy.App.Backend; @@ -26,6 +29,7 @@ public static class Program Watchdog.NotifyReady(); Db.Init(); LoadEnvFile(); + EnsureDocumentBucketExists().SupressAwaitWarning(); DiagnosticService.Initialize(); TicketDiagnosticService.Initialize(); NetworkProviderService.Initialize(); @@ -122,6 +126,30 @@ public static class Program } } + public const String DocumentBucketName = "inesco-documents"; + + private static async Task EnsureDocumentBucketExists() + { + try + { + var region = new S3Region("https://sos-ch-dk-2.exo.io", ExoCmd.S3Credentials); + var buckets = await region.ListAllBuckets(); + if (buckets.Buckets.All(b => b.BucketName != DocumentBucketName)) + { + await region.PutBucket(DocumentBucketName); + Console.WriteLine($"[Documents] Created S3 bucket: {DocumentBucketName}"); + } + else + { + Console.WriteLine($"[Documents] S3 bucket already exists: {DocumentBucketName}"); + } + } + catch (Exception ex) + { + Console.WriteLine($"[Documents] Warning: Could not ensure bucket exists: {ex.Message}"); + } + } + private static OpenApiInfo OpenApiInfo { get; } = new OpenApiInfo { Title = "Inesco Backend API", diff --git a/typescript/frontend-marios2/src/Resources/routes.json b/typescript/frontend-marios2/src/Resources/routes.json index 5b876eb80..b2409023a 100644 --- a/typescript/frontend-marios2/src/Resources/routes.json +++ b/typescript/frontend-marios2/src/Resources/routes.json @@ -25,5 +25,6 @@ "detailed_view": "detailed_view/", "report": "report", "installationTickets": "installationTickets", + "documents": "documents", "tickets": "/tickets/" } diff --git a/typescript/frontend-marios2/src/components/DocumentList.tsx b/typescript/frontend-marios2/src/components/DocumentList.tsx new file mode 100644 index 000000000..41f3ad042 --- /dev/null +++ b/typescript/frontend-marios2/src/components/DocumentList.tsx @@ -0,0 +1,222 @@ +import React, { useEffect, useState } from 'react'; +import { + Box, + Chip, + Dialog, + IconButton, + List, + ListItem, + ListItemText, + Typography +} from '@mui/material'; +import DownloadIcon from '@mui/icons-material/Download'; +import DeleteIcon from '@mui/icons-material/Delete'; +import InsertDriveFileIcon from '@mui/icons-material/InsertDriveFile'; +import PictureAsPdfIcon from '@mui/icons-material/PictureAsPdf'; +import { FormattedMessage } from 'react-intl'; +import axiosConfig from 'src/Resources/axiosConfig'; + +export interface DocumentItem { + id: number; + ticketId?: number; + ticketCommentId?: number; + installationId?: number; + scope: number; + s3Key: string; + originalName: string; + contentType: string; + sizeBytes: number; + uploadedByUserId: number; + createdAt: string; +} + +interface DocumentListProps { + ticketId?: number; + ticketCommentId?: number; + installationId?: number; + refreshKey?: number; + canDelete?: boolean; +} + +function formatFileSize(bytes: number): string { + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; + return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; +} + +function isImage(contentType: string): boolean { + return contentType.startsWith('image/'); +} + +function getFileIcon(contentType: string) { + if (contentType === 'application/pdf') return ; + return ; +} + +function DocumentList({ + ticketId, + ticketCommentId, + installationId, + refreshKey = 0, + canDelete = false +}: DocumentListProps) { + const [documents, setDocuments] = useState([]); + const [loading, setLoading] = useState(true); + const [previews, setPreviews] = useState>({}); + const [expandedImage, setExpandedImage] = useState(null); + + const fetchDocuments = () => { + setLoading(true); + axiosConfig + .get('/GetDocuments', { + params: { ticketId, ticketCommentId, installationId } + }) + .then((res) => { + if (Array.isArray(res.data)) setDocuments(res.data); + }) + .catch(() => setDocuments([])) + .finally(() => setLoading(false)); + }; + + useEffect(() => { + fetchDocuments(); + }, [ticketId, ticketCommentId, installationId, refreshKey]); + + // Load image thumbnails + useEffect(() => { + documents.forEach((doc) => { + if (isImage(doc.contentType) && !previews[doc.id]) { + axiosConfig + .get('/DownloadDocument', { + params: { id: doc.id }, + responseType: 'blob' + }) + .then((res) => { + const url = window.URL.createObjectURL(new Blob([res.data], { type: doc.contentType })); + setPreviews((prev) => ({ ...prev, [doc.id]: url })); + }) + .catch(() => {}); + } + }); + }, [documents]); + + // Clean up blob URLs on unmount + useEffect(() => { + return () => { + Object.values(previews).forEach((url) => window.URL.revokeObjectURL(url)); + }; + }, []); + + const handleDownload = (doc: DocumentItem) => { + if (previews[doc.id]) { + const link = document.createElement('a'); + link.href = previews[doc.id]; + link.setAttribute('download', doc.originalName); + document.body.appendChild(link); + link.click(); + link.remove(); + return; + } + axiosConfig + .get('/DownloadDocument', { + params: { id: doc.id }, + responseType: 'blob' + }) + .then((res) => { + const url = window.URL.createObjectURL(new Blob([res.data])); + const link = document.createElement('a'); + link.href = url; + link.setAttribute('download', doc.originalName); + document.body.appendChild(link); + link.click(); + link.remove(); + window.URL.revokeObjectURL(url); + }) + .catch(() => {}); + }; + + const handleDelete = (doc: DocumentItem) => { + axiosConfig + .delete('/DeleteDocument', { params: { id: doc.id } }) + .then(() => fetchDocuments()) + .catch(() => {}); + }; + + if (loading) return null; + if (documents.length === 0) return null; + + return ( + + + + + + + {documents.map((doc) => ( + + + {!isImage(doc.contentType) && ( + + {getFileIcon(doc.contentType)} + + )} + + + handleDownload(doc)}> + + + {canDelete && ( + handleDelete(doc)}> + + + )} + + + {isImage(doc.contentType) && previews[doc.id] && ( + setExpandedImage(previews[doc.id])} + sx={{ + maxWidth: 200, + maxHeight: 150, + borderRadius: 1, + cursor: 'pointer', + border: '1px solid', + borderColor: 'divider', + '&:hover': { opacity: 0.85 } + }} + /> + )} + + ))} + + + {/* Full-size image preview dialog */} + setExpandedImage(null)} + maxWidth="lg" + > + {expandedImage && ( + setExpandedImage(null)} + sx={{ maxWidth: '90vw', maxHeight: '90vh', cursor: 'pointer' }} + /> + )} + + + ); +} + +export default DocumentList; diff --git a/typescript/frontend-marios2/src/components/FileUploadButton.tsx b/typescript/frontend-marios2/src/components/FileUploadButton.tsx new file mode 100644 index 000000000..6886e8c52 --- /dev/null +++ b/typescript/frontend-marios2/src/components/FileUploadButton.tsx @@ -0,0 +1,151 @@ +import React, { useRef, useState } from 'react'; +import { + Box, + Button, + Chip, + LinearProgress, + Typography +} from '@mui/material'; +import AttachFileIcon from '@mui/icons-material/AttachFile'; +import { FormattedMessage } from 'react-intl'; +import axiosConfig from 'src/Resources/axiosConfig'; + +const ALLOWED_TYPES = [ + 'image/jpeg', + 'image/png', + 'image/gif', + 'image/webp', + 'application/pdf' +]; +const MAX_FILE_SIZE = 25 * 1024 * 1024; // 25 MB + +export interface UploadedDocument { + id: number; + originalName: string; + contentType: string; + sizeBytes: number; + createdAt: string; +} + +interface FileUploadButtonProps { + scope: number; // 0 = TicketAttachment, 1 = InstallationDocument + ticketId?: number; + ticketCommentId?: number; + installationId?: number; + onUploaded?: (doc: UploadedDocument) => void; + disabled?: boolean; +} + +function FileUploadButton({ + scope, + ticketId, + ticketCommentId, + installationId, + onUploaded, + disabled = false +}: FileUploadButtonProps) { + const inputRef = useRef(null); + const [uploading, setUploading] = useState(false); + const [progress, setProgress] = useState(0); + const [error, setError] = useState(''); + const [pendingFiles, setPendingFiles] = useState([]); + + const handleFileSelect = (e: React.ChangeEvent) => { + const files = e.target.files; + if (!files) return; + + const validFiles: File[] = []; + for (let i = 0; i < files.length; i++) { + const file = files[i]; + if (!ALLOWED_TYPES.includes(file.type)) { + setError(`Invalid file type: ${file.name}`); + return; + } + if (file.size > MAX_FILE_SIZE) { + setError(`File too large: ${file.name} (max 25 MB)`); + return; + } + validFiles.push(file); + } + + setError(''); + setPendingFiles((prev) => [...prev, ...validFiles]); + + // Reset input so the same file can be selected again + if (inputRef.current) inputRef.current.value = ''; + + // Upload files sequentially to avoid race conditions + uploadFilesSequentially(validFiles); + }; + + const uploadFilesSequentially = async (files: File[]) => { + setUploading(true); + for (const file of files) { + setProgress(0); + const formData = new FormData(); + formData.append('file', file); + + try { + const res = await axiosConfig.post('/UploadDocument', formData, { + params: { scope, ticketId, ticketCommentId, installationId }, + headers: { 'Content-Type': 'multipart/form-data' }, + onUploadProgress: (e) => { + if (e.total) setProgress(Math.round((e.loaded * 100) / e.total)); + } + }); + setPendingFiles((prev) => prev.filter((f) => f !== file)); + if (onUploaded) onUploaded(res.data); + } catch (err: any) { + const serverMsg = err?.response?.data || err?.message || 'Unknown error'; + setError(`Failed to upload ${file.name}: ${serverMsg}`); + setPendingFiles((prev) => prev.filter((f) => f !== file)); + } + } + setUploading(false); + setProgress(0); + }; + + return ( + + + + + {uploading && ( + + + + )} + + {pendingFiles.length > 0 && ( + + {pendingFiles.map((f, i) => ( + + ))} + + )} + + {error && ( + + {error} + + )} + + ); +} + +export default FileUploadButton; diff --git a/typescript/frontend-marios2/src/content/dashboards/Documents/DocumentsTab.tsx b/typescript/frontend-marios2/src/content/dashboards/Documents/DocumentsTab.tsx new file mode 100644 index 000000000..7d9cc6407 --- /dev/null +++ b/typescript/frontend-marios2/src/content/dashboards/Documents/DocumentsTab.tsx @@ -0,0 +1,63 @@ +import React, { useContext, useState } from 'react'; +import { + Box, + Card, + CardContent, + CardHeader, + Container, + Divider, + Typography +} from '@mui/material'; +import { FormattedMessage } from 'react-intl'; +import { UserContext } from 'src/contexts/userContext'; +import { UserType } from 'src/interfaces/UserTypes'; +import FileUploadButton from 'src/components/FileUploadButton'; +import DocumentList from 'src/components/DocumentList'; + +interface DocumentsTabProps { + installationId: number; +} + +function DocumentsTab({ installationId }: DocumentsTabProps) { + const { currentUser } = useContext(UserContext); + const [refreshKey, setRefreshKey] = useState(0); + const canDelete = currentUser?.userType === UserType.admin; + + return ( + + + + } + /> + + + + + setRefreshKey((k) => k + 1)} + /> + + + + + + + + ); +} + +export default DocumentsTab; diff --git a/typescript/frontend-marios2/src/content/dashboards/Installations/Installation.tsx b/typescript/frontend-marios2/src/content/dashboards/Installations/Installation.tsx index 56f8c8f99..dcb80784c 100644 --- a/typescript/frontend-marios2/src/content/dashboards/Installations/Installation.tsx +++ b/typescript/frontend-marios2/src/content/dashboards/Installations/Installation.tsx @@ -29,6 +29,7 @@ import BatteryView from '../BatteryView/BatteryView'; import Configuration from '../Configuration/Configuration'; import PvView from '../PvView/PvView'; import InstallationTicketsTab from '../Tickets/InstallationTicketsTab'; +import DocumentsTab from '../Documents/DocumentsTab'; interface singleInstallationProps { current_installation?: I_Installation; @@ -564,6 +565,17 @@ function Installation(props: singleInstallationProps) { /> )} + {(currentUser.userType == UserType.admin || currentUser.userType == UserType.partner) && ( + + } + /> + )} + } diff --git a/typescript/frontend-marios2/src/content/dashboards/Installations/index.tsx b/typescript/frontend-marios2/src/content/dashboards/Installations/index.tsx index 02b3d97f2..1dff83f2e 100644 --- a/typescript/frontend-marios2/src/content/dashboards/Installations/index.tsx +++ b/typescript/frontend-marios2/src/content/dashboards/Installations/index.tsx @@ -33,7 +33,8 @@ function InstallationTabs(props: InstallationTabsProps) { 'configuration', 'history', 'pvview', - 'installationTickets' + 'installationTickets', + 'documents' ]; const [currentTab, setCurrentTab] = useState(undefined); @@ -170,6 +171,10 @@ function InstallationTabs(props: InstallationTabsProps) { { value: 'installationTickets', label: + }, + { + value: 'documents', + label: } ] : currentUser.userType == UserType.partner @@ -201,6 +206,10 @@ function InstallationTabs(props: InstallationTabsProps) { label: ( ) + }, + { + value: 'documents', + label: } ] : [ @@ -303,6 +312,10 @@ function InstallationTabs(props: InstallationTabsProps) { { value: 'installationTickets', label: + }, + { + value: 'documents', + label: } ] : currentUser.userType == UserType.partner @@ -348,6 +361,10 @@ function InstallationTabs(props: InstallationTabsProps) { defaultMessage="Information" /> ) + }, + { + value: 'documents', + label: } ] : [ diff --git a/typescript/frontend-marios2/src/content/dashboards/SalidomoInstallations/Installation.tsx b/typescript/frontend-marios2/src/content/dashboards/SalidomoInstallations/Installation.tsx index ca3b6f621..05e4b2298 100644 --- a/typescript/frontend-marios2/src/content/dashboards/SalidomoInstallations/Installation.tsx +++ b/typescript/frontend-marios2/src/content/dashboards/SalidomoInstallations/Installation.tsx @@ -24,6 +24,7 @@ import { UserType } from '../../../interfaces/UserTypes'; import HistoryOfActions from '../History/History'; import BuildIcon from '@mui/icons-material/Build'; import InstallationTicketsTab from '../Tickets/InstallationTicketsTab'; +import DocumentsTab from '../Documents/DocumentsTab'; import AccessContextProvider from '../../../contexts/AccessContextProvider'; import Access from '../ManageAccess/Access'; @@ -442,6 +443,17 @@ function SalidomoInstallation(props: singleInstallationProps) { /> )} + {(currentUser.userType == UserType.admin || currentUser.userType == UserType.partner) && ( + + } + /> + )} + } diff --git a/typescript/frontend-marios2/src/content/dashboards/SalidomoInstallations/index.tsx b/typescript/frontend-marios2/src/content/dashboards/SalidomoInstallations/index.tsx index de169d4af..1c9235dc5 100644 --- a/typescript/frontend-marios2/src/content/dashboards/SalidomoInstallations/index.tsx +++ b/typescript/frontend-marios2/src/content/dashboards/SalidomoInstallations/index.tsx @@ -29,7 +29,8 @@ function SalidomoInstallationTabs(props: InstallationTabsProps) { 'overview', 'log', 'history', - 'installationTickets' + 'installationTickets', + 'documents' ]; const [currentTab, setCurrentTab] = useState(undefined); @@ -141,6 +142,10 @@ function SalidomoInstallationTabs(props: InstallationTabsProps) { { value: 'installationTickets', label: + }, + { + value: 'documents', + label: } ] : [ @@ -226,6 +231,10 @@ function SalidomoInstallationTabs(props: InstallationTabsProps) { { value: 'installationTickets', label: + }, + { + value: 'documents', + label: } ] : currentTab != 'list' && diff --git a/typescript/frontend-marios2/src/content/dashboards/SodiohomeInstallations/Installation.tsx b/typescript/frontend-marios2/src/content/dashboards/SodiohomeInstallations/Installation.tsx index 80ea47d89..dc8f7ad93 100644 --- a/typescript/frontend-marios2/src/content/dashboards/SodiohomeInstallations/Installation.tsx +++ b/typescript/frontend-marios2/src/content/dashboards/SodiohomeInstallations/Installation.tsx @@ -29,6 +29,7 @@ import TopologySodistoreHome from '../Topology/TopologySodistoreHome'; import Overview from '../Overview/overview'; import WeeklyReport from './WeeklyReport'; import InstallationTicketsTab from '../Tickets/InstallationTicketsTab'; +import DocumentsTab from '../Documents/DocumentsTab'; interface singleInstallationProps { current_installation?: I_Installation; @@ -637,6 +638,17 @@ function SodioHomeInstallation(props: singleInstallationProps) { /> )} + {(currentUser.userType == UserType.admin || currentUser.userType == UserType.partner) && ( + + } + /> + )} + } diff --git a/typescript/frontend-marios2/src/content/dashboards/SodiohomeInstallations/index.tsx b/typescript/frontend-marios2/src/content/dashboards/SodiohomeInstallations/index.tsx index beb0e2db3..228f9dcef 100644 --- a/typescript/frontend-marios2/src/content/dashboards/SodiohomeInstallations/index.tsx +++ b/typescript/frontend-marios2/src/content/dashboards/SodiohomeInstallations/index.tsx @@ -52,7 +52,8 @@ function SodioHomeInstallationTabs(props: SodioHomeInstallationTabsProps) { 'history', 'configuration', 'report', - 'installationTickets' + 'installationTickets', + 'documents' ]; const [currentTab, setCurrentTab] = useState(undefined); @@ -187,6 +188,10 @@ function SodioHomeInstallationTabs(props: SodioHomeInstallationTabsProps) { { value: 'installationTickets', label: + }, + { + value: 'documents', + label: } ] : currentUser.userType == UserType.partner @@ -226,6 +231,10 @@ function SodioHomeInstallationTabs(props: SodioHomeInstallationTabsProps) { defaultMessage="Report" /> ) + }, + { + value: 'documents', + label: } ] : [ @@ -342,6 +351,10 @@ function SodioHomeInstallationTabs(props: SodioHomeInstallationTabsProps) { { value: 'installationTickets', label: + }, + { + value: 'documents', + label: } ] : inInstallationView && currentUser.userType == UserType.partner @@ -389,6 +402,10 @@ function SodioHomeInstallationTabs(props: SodioHomeInstallationTabsProps) { defaultMessage="Report" /> ) + }, + { + value: 'documents', + label: } ] : inInstallationView && currentUser.userType == UserType.client diff --git a/typescript/frontend-marios2/src/content/dashboards/Tickets/CommentThread.tsx b/typescript/frontend-marios2/src/content/dashboards/Tickets/CommentThread.tsx index 8475d1607..792038882 100644 --- a/typescript/frontend-marios2/src/content/dashboards/Tickets/CommentThread.tsx +++ b/typescript/frontend-marios2/src/content/dashboards/Tickets/CommentThread.tsx @@ -1,4 +1,4 @@ -import React, { useState } from 'react'; +import React, { useRef, useState } from 'react'; import { Avatar, Box, @@ -6,15 +6,19 @@ import { Card, CardContent, CardHeader, + Chip, Divider, + LinearProgress, TextField, Typography } from '@mui/material'; import PersonIcon from '@mui/icons-material/Person'; import SmartToyIcon from '@mui/icons-material/SmartToy'; +import AttachFileIcon from '@mui/icons-material/AttachFile'; import { FormattedMessage } from 'react-intl'; import axiosConfig from 'src/Resources/axiosConfig'; import { AdminUser, CommentAuthorType, TicketComment } from 'src/interfaces/TicketTypes'; +import DocumentList from 'src/components/DocumentList'; interface CommentThreadProps { ticketId: number; @@ -31,21 +35,67 @@ function CommentThread({ }: CommentThreadProps) { const [body, setBody] = useState(''); const [submitting, setSubmitting] = useState(false); + const fileInputRef = useRef(null); + const [selectedFiles, setSelectedFiles] = useState([]); + const [uploading, setUploading] = useState(false); + const [refreshKey, setRefreshKey] = useState(0); + + const ALLOWED_TYPES = ['image/jpeg', 'image/png', 'image/gif', 'image/webp', 'application/pdf']; + const MAX_FILE_SIZE = 25 * 1024 * 1024; + + const handleFileSelect = (e: React.ChangeEvent) => { + const files = e.target.files; + if (!files) return; + for (let i = 0; i < files.length; i++) { + if (!ALLOWED_TYPES.includes(files[i].type) || files[i].size > MAX_FILE_SIZE) return; + } + setSelectedFiles((prev) => [...prev, ...Array.from(files)]); + if (fileInputRef.current) fileInputRef.current.value = ''; + }; const sorted = [...comments].sort( (a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime() ); - const handleSubmit = () => { - if (!body.trim()) return; + const handleSubmit = async () => { + if (!body.trim() && selectedFiles.length === 0) return; setSubmitting(true); - axiosConfig - .post('/AddTicketComment', { ticketId, body }) - .then(() => { - setBody(''); - onCommentAdded(); - }) - .finally(() => setSubmitting(false)); + + try { + let commentId: number | undefined; + if (body.trim()) { + const res = await axiosConfig.post('/AddTicketComment', { ticketId, body }); + commentId = res.data?.id; + } + + if (selectedFiles.length > 0) { + setUploading(true); + for (const file of selectedFiles) { + const formData = new FormData(); + formData.append('file', file); + try { + await axiosConfig.post('/UploadDocument', formData, { + params: { + scope: 0, + ticketId, + ticketCommentId: commentId + }, + headers: { 'Content-Type': 'multipart/form-data' } + }); + } catch (err: any) { + console.warn(`[Documents] Upload failed for ${file.name}:`, err?.response?.data || err?.message); + } + } + setUploading(false); + } + + setBody(''); + setSelectedFiles([]); + setRefreshKey((k) => k + 1); + onCommentAdded(); + } finally { + setSubmitting(false); + } }; return ( @@ -100,6 +150,7 @@ function CommentThread({ {comment.body} + ); @@ -107,25 +158,57 @@ function CommentThread({ - - setBody(e.target.value)} - /> - + + + setBody(e.target.value)} + /> + + + + + + + {selectedFiles.length > 0 && ( + + {selectedFiles.map((f, i) => ( + setSelectedFiles((prev) => prev.filter((_, idx) => idx !== i))} + /> + ))} + + )} + {uploading && } diff --git a/typescript/frontend-marios2/src/content/dashboards/Tickets/CreateTicketModal.tsx b/typescript/frontend-marios2/src/content/dashboards/Tickets/CreateTicketModal.tsx index d10927ea3..7d6f64087 100644 --- a/typescript/frontend-marios2/src/content/dashboards/Tickets/CreateTicketModal.tsx +++ b/typescript/frontend-marios2/src/content/dashboards/Tickets/CreateTicketModal.tsx @@ -1,8 +1,10 @@ -import React, { useEffect, useMemo, useState } from 'react'; +import React, { useEffect, useMemo, useRef, useState } from 'react'; import { Alert, Autocomplete, + Box, Button, + Chip, CircularProgress, Dialog, DialogActions, @@ -10,10 +12,13 @@ import { DialogTitle, FormControl, InputLabel, + LinearProgress, MenuItem, Select, - TextField + TextField, + Typography } from '@mui/material'; +import AttachFileIcon from '@mui/icons-material/AttachFile'; import { FormattedMessage } from 'react-intl'; import axiosConfig from 'src/Resources/axiosConfig'; import { @@ -76,6 +81,38 @@ function CreateTicketModal({ open, onClose, onCreated, defaultInstallationId }: const [error, setError] = useState(''); const [submitting, setSubmitting] = useState(false); + // File attachments + const fileInputRef = useRef(null); + const [selectedFiles, setSelectedFiles] = useState([]); + const [uploading, setUploading] = useState(false); + const [uploadProgress, setUploadProgress] = useState(0); + + const ALLOWED_TYPES = ['image/jpeg', 'image/png', 'image/gif', 'image/webp', 'application/pdf']; + const MAX_FILE_SIZE = 25 * 1024 * 1024; + + const handleFileSelect = (e: React.ChangeEvent) => { + const files = e.target.files; + if (!files) return; + for (let i = 0; i < files.length; i++) { + const file = files[i]; + if (!ALLOWED_TYPES.includes(file.type)) { + setError(`Invalid file type: ${file.name}`); + return; + } + if (file.size > MAX_FILE_SIZE) { + setError(`File too large: ${file.name} (max 25 MB)`); + return; + } + } + setError(''); + setSelectedFiles((prev) => [...prev, ...Array.from(files)]); + if (fileInputRef.current) fileInputRef.current.value = ''; + }; + + const removeFile = (index: number) => { + setSelectedFiles((prev) => prev.filter((_, i) => i !== index)); + }; + // Custom "Other" fields const [customSubCategory, setCustomSubCategory] = useState(''); const [customCategory, setCustomCategory] = useState(''); @@ -201,16 +238,17 @@ function CreateTicketModal({ open, onClose, onCreated, defaultInstallationId }: setDescription(''); setCustomSubCategory(''); setCustomCategory(''); + setSelectedFiles([]); setError(''); }; - const handleSubmit = () => { + const handleSubmit = async () => { if (!subject.trim()) return; setSubmitting(true); setError(''); - axiosConfig - .post('/CreateTicket', { + try { + const res = await axiosConfig.post('/CreateTicket', { subject, description, installationId: selectedInstallation?.id ?? null, @@ -219,14 +257,40 @@ function CreateTicketModal({ open, onClose, onCreated, defaultInstallationId }: subCategory: isOtherCategory ? 0 : subCategory, customSubCategory: (isOtherSubCategory || isOtherCategory) ? customSubCategory || null : null, customCategory: isOtherCategory ? customCategory || null : null - }) - .then(() => { - resetForm(); - onCreated(); - onClose(); - }) - .catch(() => setError('Failed to create ticket.')) - .finally(() => setSubmitting(false)); + }); + + const newTicketId = res.data?.id; + + // Upload attached files if any + if (selectedFiles.length > 0 && newTicketId) { + setUploading(true); + for (const file of selectedFiles) { + const formData = new FormData(); + formData.append('file', file); + try { + await axiosConfig.post('/UploadDocument', formData, { + params: { scope: 0, ticketId: newTicketId }, + headers: { 'Content-Type': 'multipart/form-data' }, + onUploadProgress: (e) => { + if (e.total) setUploadProgress(Math.round((e.loaded * 100) / e.total)); + } + }); + } catch (err: any) { + console.warn(`[Documents] Upload failed for ${file.name}:`, err?.response?.data || err?.message); + } + } + setUploading(false); + setUploadProgress(0); + } + + resetForm(); + onCreated(); + onClose(); + } catch { + setError('Failed to create ticket.'); + } finally { + setSubmitting(false); + } }; const availableSubCategories = subCategoriesByCategory[category] ?? []; @@ -442,6 +506,44 @@ function CreateTicketModal({ open, onClose, onCreated, defaultInstallationId }: fullWidth margin="dense" /> + + {/* File attachments */} + + + + {selectedFiles.length > 0 && ( + + {selectedFiles.map((f, i) => ( + removeFile(i)} + /> + ))} + + )} + {uploading && ( + + + + )} +