allow to upload pictures and pdf in ticket and specific tab
This commit is contained in:
parent
a8db23cadf
commit
5d6c9a886c
|
|
@ -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<ActionResult> 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<String> 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<String> 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<ActionResult<Document>> 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<ActionResult> 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<IEnumerable<Document>> 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<ActionResult> 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();
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -39,6 +39,9 @@ public static partial class Db
|
|||
public static TableQuery<TicketAiDiagnosis> TicketAiDiagnoses => Connection.Table<TicketAiDiagnosis>();
|
||||
public static TableQuery<TicketTimelineEvent> TicketTimelineEvents => Connection.Table<TicketTimelineEvent>();
|
||||
|
||||
// Document storage
|
||||
public static TableQuery<Document> Documents => Connection.Table<Document>();
|
||||
|
||||
|
||||
public static void Init()
|
||||
{
|
||||
|
|
@ -77,6 +80,9 @@ public static partial class Db
|
|||
Connection.CreateTable<TicketComment>();
|
||||
Connection.CreateTable<TicketAiDiagnosis>();
|
||||
Connection.CreateTable<TicketTimelineEvent>();
|
||||
|
||||
// Document storage
|
||||
Connection.CreateTable<Document>();
|
||||
});
|
||||
|
||||
// One-time migration: normalize legacy long-form language values to ISO codes
|
||||
|
|
@ -135,6 +141,9 @@ public static partial class Db
|
|||
fileConnection.CreateTable<TicketAiDiagnosis>();
|
||||
fileConnection.CreateTable<TicketTimelineEvent>();
|
||||
|
||||
// Document storage
|
||||
fileConnection.CreateTable<Document>();
|
||||
|
||||
// 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");
|
||||
|
|
|
|||
|
|
@ -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<String> 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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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
|
||||
|
|
|
|||
|
|
@ -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<Document> GetDocumentsForTicket(Int64 ticketId)
|
||||
=> Documents
|
||||
.Where(d => d.TicketId == ticketId)
|
||||
.OrderBy(d => d.CreatedAt)
|
||||
.ToList();
|
||||
|
||||
public static List<Document> GetDocumentsForComment(Int64 commentId)
|
||||
=> Documents
|
||||
.Where(d => d.TicketCommentId == commentId)
|
||||
.OrderBy(d => d.CreatedAt)
|
||||
.ToList();
|
||||
|
||||
public static List<Document> GetDocumentsForInstallation(Int64 installationId)
|
||||
=> Documents
|
||||
.Where(d => d.InstallationId == installationId && d.Scope == (Int32)DocumentScope.InstallationDocument)
|
||||
.OrderBy(d => d.CreatedAt)
|
||||
.ToList();
|
||||
}
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -25,5 +25,6 @@
|
|||
"detailed_view": "detailed_view/",
|
||||
"report": "report",
|
||||
"installationTickets": "installationTickets",
|
||||
"documents": "documents",
|
||||
"tickets": "/tickets/"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 <PictureAsPdfIcon fontSize="small" color="error" />;
|
||||
return <InsertDriveFileIcon fontSize="small" />;
|
||||
}
|
||||
|
||||
function DocumentList({
|
||||
ticketId,
|
||||
ticketCommentId,
|
||||
installationId,
|
||||
refreshKey = 0,
|
||||
canDelete = false
|
||||
}: DocumentListProps) {
|
||||
const [documents, setDocuments] = useState<DocumentItem[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [previews, setPreviews] = useState<Record<number, string>>({});
|
||||
const [expandedImage, setExpandedImage] = useState<string | null>(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 (
|
||||
<Box>
|
||||
<Typography variant="subtitle2" sx={{ mb: 0.5 }}>
|
||||
<FormattedMessage id="attachments" defaultMessage="Attachments" />
|
||||
<Chip label={documents.length} size="small" sx={{ ml: 1 }} />
|
||||
</Typography>
|
||||
<List dense disablePadding>
|
||||
{documents.map((doc) => (
|
||||
<ListItem
|
||||
key={doc.id}
|
||||
disableGutters
|
||||
sx={{ alignItems: 'flex-start', flexDirection: 'column', gap: 0.5 }}
|
||||
>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', width: '100%' }}>
|
||||
{!isImage(doc.contentType) && (
|
||||
<Box sx={{ mr: 1, display: 'flex', alignItems: 'center' }}>
|
||||
{getFileIcon(doc.contentType)}
|
||||
</Box>
|
||||
)}
|
||||
<ListItemText
|
||||
primary={doc.originalName}
|
||||
secondary={`${formatFileSize(doc.sizeBytes)} — ${new Date(doc.createdAt).toLocaleDateString()}`}
|
||||
/>
|
||||
<Box sx={{ ml: 'auto', flexShrink: 0 }}>
|
||||
<IconButton size="small" onClick={() => handleDownload(doc)}>
|
||||
<DownloadIcon fontSize="small" />
|
||||
</IconButton>
|
||||
{canDelete && (
|
||||
<IconButton size="small" onClick={() => handleDelete(doc)}>
|
||||
<DeleteIcon fontSize="small" />
|
||||
</IconButton>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
{isImage(doc.contentType) && previews[doc.id] && (
|
||||
<Box
|
||||
component="img"
|
||||
src={previews[doc.id]}
|
||||
alt={doc.originalName}
|
||||
onClick={() => setExpandedImage(previews[doc.id])}
|
||||
sx={{
|
||||
maxWidth: 200,
|
||||
maxHeight: 150,
|
||||
borderRadius: 1,
|
||||
cursor: 'pointer',
|
||||
border: '1px solid',
|
||||
borderColor: 'divider',
|
||||
'&:hover': { opacity: 0.85 }
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</ListItem>
|
||||
))}
|
||||
</List>
|
||||
|
||||
{/* Full-size image preview dialog */}
|
||||
<Dialog
|
||||
open={!!expandedImage}
|
||||
onClose={() => setExpandedImage(null)}
|
||||
maxWidth="lg"
|
||||
>
|
||||
{expandedImage && (
|
||||
<Box
|
||||
component="img"
|
||||
src={expandedImage}
|
||||
onClick={() => setExpandedImage(null)}
|
||||
sx={{ maxWidth: '90vw', maxHeight: '90vh', cursor: 'pointer' }}
|
||||
/>
|
||||
)}
|
||||
</Dialog>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
export default DocumentList;
|
||||
|
|
@ -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<HTMLInputElement>(null);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [progress, setProgress] = useState(0);
|
||||
const [error, setError] = useState('');
|
||||
const [pendingFiles, setPendingFiles] = useState<File[]>([]);
|
||||
|
||||
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
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 (
|
||||
<Box>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="file"
|
||||
accept={ALLOWED_TYPES.join(',')}
|
||||
multiple
|
||||
style={{ display: 'none' }}
|
||||
onChange={handleFileSelect}
|
||||
/>
|
||||
<Button
|
||||
variant="outlined"
|
||||
size="small"
|
||||
startIcon={<AttachFileIcon />}
|
||||
onClick={() => inputRef.current?.click()}
|
||||
disabled={disabled || uploading}
|
||||
>
|
||||
<FormattedMessage id="attachFiles" defaultMessage="Attach Files" />
|
||||
</Button>
|
||||
|
||||
{uploading && (
|
||||
<Box sx={{ mt: 1, width: '100%' }}>
|
||||
<LinearProgress variant="determinate" value={progress} />
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{pendingFiles.length > 0 && (
|
||||
<Box sx={{ mt: 1, display: 'flex', flexWrap: 'wrap', gap: 0.5 }}>
|
||||
{pendingFiles.map((f, i) => (
|
||||
<Chip key={i} label={f.name} size="small" variant="outlined" />
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<Typography color="error" variant="caption" sx={{ mt: 0.5, display: 'block' }}>
|
||||
{error}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
export default FileUploadButton;
|
||||
|
|
@ -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 (
|
||||
<Container maxWidth="lg" sx={{ mt: 3, mb: 3 }}>
|
||||
<Card>
|
||||
<CardHeader
|
||||
title={
|
||||
<FormattedMessage
|
||||
id="installationDocuments"
|
||||
defaultMessage="Installation Documents"
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Divider />
|
||||
<CardContent>
|
||||
<DocumentList
|
||||
installationId={installationId}
|
||||
refreshKey={refreshKey}
|
||||
canDelete={canDelete}
|
||||
/>
|
||||
<Box sx={{ mt: 2 }}>
|
||||
<FileUploadButton
|
||||
scope={1}
|
||||
installationId={installationId}
|
||||
onUploaded={() => setRefreshKey((k) => k + 1)}
|
||||
/>
|
||||
</Box>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ mt: 2, display: 'block' }}>
|
||||
<FormattedMessage
|
||||
id="documentsHint"
|
||||
defaultMessage="Accepted formats: JPEG, PNG, GIF, WebP, PDF. Maximum file size: 25 MB."
|
||||
/>
|
||||
</Typography>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
export default DocumentsTab;
|
||||
|
|
@ -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) && (
|
||||
<Route
|
||||
path={routes.documents}
|
||||
element={
|
||||
<DocumentsTab
|
||||
installationId={props.current_installation.id}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Route
|
||||
path={'*'}
|
||||
element={<Navigate to={routes.live}></Navigate>}
|
||||
|
|
|
|||
|
|
@ -33,7 +33,8 @@ function InstallationTabs(props: InstallationTabsProps) {
|
|||
'configuration',
|
||||
'history',
|
||||
'pvview',
|
||||
'installationTickets'
|
||||
'installationTickets',
|
||||
'documents'
|
||||
];
|
||||
|
||||
const [currentTab, setCurrentTab] = useState<string>(undefined);
|
||||
|
|
@ -170,6 +171,10 @@ function InstallationTabs(props: InstallationTabsProps) {
|
|||
{
|
||||
value: 'installationTickets',
|
||||
label: <FormattedMessage id="tickets" defaultMessage="Tickets" />
|
||||
},
|
||||
{
|
||||
value: 'documents',
|
||||
label: <FormattedMessage id="documentsTab" defaultMessage="Documents" />
|
||||
}
|
||||
]
|
||||
: currentUser.userType == UserType.partner
|
||||
|
|
@ -201,6 +206,10 @@ function InstallationTabs(props: InstallationTabsProps) {
|
|||
label: (
|
||||
<FormattedMessage id="information" defaultMessage="Information" />
|
||||
)
|
||||
},
|
||||
{
|
||||
value: 'documents',
|
||||
label: <FormattedMessage id="documentsTab" defaultMessage="Documents" />
|
||||
}
|
||||
]
|
||||
: [
|
||||
|
|
@ -303,6 +312,10 @@ function InstallationTabs(props: InstallationTabsProps) {
|
|||
{
|
||||
value: 'installationTickets',
|
||||
label: <FormattedMessage id="tickets" defaultMessage="Tickets" />
|
||||
},
|
||||
{
|
||||
value: 'documents',
|
||||
label: <FormattedMessage id="documentsTab" defaultMessage="Documents" />
|
||||
}
|
||||
]
|
||||
: currentUser.userType == UserType.partner
|
||||
|
|
@ -348,6 +361,10 @@ function InstallationTabs(props: InstallationTabsProps) {
|
|||
defaultMessage="Information"
|
||||
/>
|
||||
)
|
||||
},
|
||||
{
|
||||
value: 'documents',
|
||||
label: <FormattedMessage id="documentsTab" defaultMessage="Documents" />
|
||||
}
|
||||
]
|
||||
: [
|
||||
|
|
|
|||
|
|
@ -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) && (
|
||||
<Route
|
||||
path={routes.documents}
|
||||
element={
|
||||
<DocumentsTab
|
||||
installationId={props.current_installation.id}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Route
|
||||
path={'*'}
|
||||
element={<Navigate to={routes.batteryview}></Navigate>}
|
||||
|
|
|
|||
|
|
@ -29,7 +29,8 @@ function SalidomoInstallationTabs(props: InstallationTabsProps) {
|
|||
'overview',
|
||||
'log',
|
||||
'history',
|
||||
'installationTickets'
|
||||
'installationTickets',
|
||||
'documents'
|
||||
];
|
||||
|
||||
const [currentTab, setCurrentTab] = useState<string>(undefined);
|
||||
|
|
@ -141,6 +142,10 @@ function SalidomoInstallationTabs(props: InstallationTabsProps) {
|
|||
{
|
||||
value: 'installationTickets',
|
||||
label: <FormattedMessage id="tickets" defaultMessage="Tickets" />
|
||||
},
|
||||
{
|
||||
value: 'documents',
|
||||
label: <FormattedMessage id="documentsTab" defaultMessage="Documents" />
|
||||
}
|
||||
]
|
||||
: [
|
||||
|
|
@ -226,6 +231,10 @@ function SalidomoInstallationTabs(props: InstallationTabsProps) {
|
|||
{
|
||||
value: 'installationTickets',
|
||||
label: <FormattedMessage id="tickets" defaultMessage="Tickets" />
|
||||
},
|
||||
{
|
||||
value: 'documents',
|
||||
label: <FormattedMessage id="documentsTab" defaultMessage="Documents" />
|
||||
}
|
||||
]
|
||||
: currentTab != 'list' &&
|
||||
|
|
|
|||
|
|
@ -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) && (
|
||||
<Route
|
||||
path={routes.documents}
|
||||
element={
|
||||
<DocumentsTab
|
||||
installationId={props.current_installation.id}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Route
|
||||
path={'*'}
|
||||
element={<Navigate to={routes.live}></Navigate>}
|
||||
|
|
|
|||
|
|
@ -52,7 +52,8 @@ function SodioHomeInstallationTabs(props: SodioHomeInstallationTabsProps) {
|
|||
'history',
|
||||
'configuration',
|
||||
'report',
|
||||
'installationTickets'
|
||||
'installationTickets',
|
||||
'documents'
|
||||
];
|
||||
|
||||
const [currentTab, setCurrentTab] = useState<string>(undefined);
|
||||
|
|
@ -187,6 +188,10 @@ function SodioHomeInstallationTabs(props: SodioHomeInstallationTabsProps) {
|
|||
{
|
||||
value: 'installationTickets',
|
||||
label: <FormattedMessage id="tickets" defaultMessage="Tickets" />
|
||||
},
|
||||
{
|
||||
value: 'documents',
|
||||
label: <FormattedMessage id="documentsTab" defaultMessage="Documents" />
|
||||
}
|
||||
]
|
||||
: currentUser.userType == UserType.partner
|
||||
|
|
@ -226,6 +231,10 @@ function SodioHomeInstallationTabs(props: SodioHomeInstallationTabsProps) {
|
|||
defaultMessage="Report"
|
||||
/>
|
||||
)
|
||||
},
|
||||
{
|
||||
value: 'documents',
|
||||
label: <FormattedMessage id="documentsTab" defaultMessage="Documents" />
|
||||
}
|
||||
]
|
||||
: [
|
||||
|
|
@ -342,6 +351,10 @@ function SodioHomeInstallationTabs(props: SodioHomeInstallationTabsProps) {
|
|||
{
|
||||
value: 'installationTickets',
|
||||
label: <FormattedMessage id="tickets" defaultMessage="Tickets" />
|
||||
},
|
||||
{
|
||||
value: 'documents',
|
||||
label: <FormattedMessage id="documentsTab" defaultMessage="Documents" />
|
||||
}
|
||||
]
|
||||
: inInstallationView && currentUser.userType == UserType.partner
|
||||
|
|
@ -389,6 +402,10 @@ function SodioHomeInstallationTabs(props: SodioHomeInstallationTabsProps) {
|
|||
defaultMessage="Report"
|
||||
/>
|
||||
)
|
||||
},
|
||||
{
|
||||
value: 'documents',
|
||||
label: <FormattedMessage id="documentsTab" defaultMessage="Documents" />
|
||||
}
|
||||
]
|
||||
: inInstallationView && currentUser.userType == UserType.client
|
||||
|
|
|
|||
|
|
@ -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<HTMLInputElement>(null);
|
||||
const [selectedFiles, setSelectedFiles] = useState<File[]>([]);
|
||||
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<HTMLInputElement>) => {
|
||||
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({
|
|||
<Typography variant="body2" sx={{ whiteSpace: 'pre-wrap' }}>
|
||||
{comment.body}
|
||||
</Typography>
|
||||
<DocumentList ticketCommentId={comment.id} refreshKey={refreshKey} />
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
|
|
@ -107,25 +158,57 @@ function CommentThread({
|
|||
|
||||
<Divider />
|
||||
|
||||
<Box sx={{ display: 'flex', gap: 1 }}>
|
||||
<TextField
|
||||
size="small"
|
||||
fullWidth
|
||||
multiline
|
||||
minRows={2}
|
||||
maxRows={4}
|
||||
placeholder="Add a comment..."
|
||||
value={body}
|
||||
onChange={(e) => setBody(e.target.value)}
|
||||
/>
|
||||
<Button
|
||||
variant="contained"
|
||||
onClick={handleSubmit}
|
||||
disabled={submitting || !body.trim()}
|
||||
sx={{ alignSelf: 'flex-end' }}
|
||||
>
|
||||
<FormattedMessage id="addComment" defaultMessage="Add" />
|
||||
</Button>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
|
||||
<Box sx={{ display: 'flex', gap: 1 }}>
|
||||
<TextField
|
||||
size="small"
|
||||
fullWidth
|
||||
multiline
|
||||
minRows={2}
|
||||
maxRows={4}
|
||||
placeholder="Add a comment..."
|
||||
value={body}
|
||||
onChange={(e) => setBody(e.target.value)}
|
||||
/>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.5, alignSelf: 'flex-end' }}>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/jpeg,image/png,image/gif,image/webp,application/pdf"
|
||||
multiple
|
||||
style={{ display: 'none' }}
|
||||
onChange={handleFileSelect}
|
||||
/>
|
||||
<Button
|
||||
variant="outlined"
|
||||
size="small"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
disabled={submitting || uploading}
|
||||
>
|
||||
<AttachFileIcon fontSize="small" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="contained"
|
||||
onClick={handleSubmit}
|
||||
disabled={submitting || uploading || (!body.trim() && selectedFiles.length === 0)}
|
||||
>
|
||||
<FormattedMessage id="addComment" defaultMessage="Add" />
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
{selectedFiles.length > 0 && (
|
||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5 }}>
|
||||
{selectedFiles.map((f, i) => (
|
||||
<Chip
|
||||
key={i}
|
||||
label={f.name}
|
||||
size="small"
|
||||
onDelete={() => setSelectedFiles((prev) => prev.filter((_, idx) => idx !== i))}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
{uploading && <LinearProgress />}
|
||||
</Box>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
|
|
|||
|
|
@ -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<HTMLInputElement>(null);
|
||||
const [selectedFiles, setSelectedFiles] = useState<File[]>([]);
|
||||
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<HTMLInputElement>) => {
|
||||
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 */}
|
||||
<Box sx={{ mt: 1 }}>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/jpeg,image/png,image/gif,image/webp,application/pdf"
|
||||
multiple
|
||||
style={{ display: 'none' }}
|
||||
onChange={handleFileSelect}
|
||||
/>
|
||||
<Button
|
||||
variant="outlined"
|
||||
size="small"
|
||||
startIcon={<AttachFileIcon />}
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
disabled={submitting || uploading}
|
||||
>
|
||||
<FormattedMessage id="attachFiles" defaultMessage="Attach Files" />
|
||||
</Button>
|
||||
{selectedFiles.length > 0 && (
|
||||
<Box sx={{ mt: 1, display: 'flex', flexWrap: 'wrap', gap: 0.5 }}>
|
||||
{selectedFiles.map((f, i) => (
|
||||
<Chip
|
||||
key={i}
|
||||
label={f.name}
|
||||
size="small"
|
||||
onDelete={() => removeFile(i)}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
{uploading && (
|
||||
<Box sx={{ mt: 1 }}>
|
||||
<LinearProgress variant="determinate" value={uploadProgress} />
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={onClose}>
|
||||
|
|
|
|||
|
|
@ -50,6 +50,8 @@ import StatusChip from './StatusChip';
|
|||
import AiDiagnosisPanel from './AiDiagnosisPanel';
|
||||
import CommentThread from './CommentThread';
|
||||
import TimelinePanel from './TimelinePanel';
|
||||
import FileUploadButton from 'src/components/FileUploadButton';
|
||||
import DocumentList from 'src/components/DocumentList';
|
||||
|
||||
const priorityKeys: Record<number, { id: string; defaultMessage: string }> = {
|
||||
[TicketPriority.Critical]: { id: 'priorityCritical', defaultMessage: 'Critical' },
|
||||
|
|
@ -87,6 +89,7 @@ function TicketDetailPage() {
|
|||
const [editingDescription, setEditingDescription] = useState(false);
|
||||
const [savingDescription, setSavingDescription] = useState(false);
|
||||
const [descriptionSaved, setDescriptionSaved] = useState(false);
|
||||
const [docRefreshKey, setDocRefreshKey] = useState(0);
|
||||
|
||||
// Custom "Other" editing state
|
||||
const [editCustomSub, setEditCustomSub] = useState('');
|
||||
|
|
@ -381,6 +384,21 @@ function TicketDetailPage() {
|
|||
)}
|
||||
</Typography>
|
||||
)}
|
||||
|
||||
<Box sx={{ mt: 2 }}>
|
||||
<DocumentList
|
||||
ticketId={ticket.id}
|
||||
refreshKey={docRefreshKey}
|
||||
canDelete={true}
|
||||
/>
|
||||
<Box sx={{ mt: 1 }}>
|
||||
<FileUploadButton
|
||||
scope={0}
|
||||
ticketId={ticket.id}
|
||||
onUploaded={() => setDocRefreshKey((k) => k + 1)}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
|
|
|
|||
|
|
@ -664,5 +664,17 @@
|
|||
"privacy_access_body": "Ihre Daten werden nicht an Dritte weitergegeben. Sie werden ausschliesslich für den Betrieb der Plattform und zur Bereitstellung von Einblicken in Ihre Installationen verwendet.",
|
||||
"privacy_close_button": "Schliessen",
|
||||
"sodistorepro": "Sodistore Pro",
|
||||
"numberOfInverters": "Anzahl der Wechselrichter"
|
||||
"numberOfInverters": "Anzahl der Wechselrichter",
|
||||
"documentsTab": "Dokumente",
|
||||
"documentsHint": "Akzeptierte Formate: JPEG, PNG, GIF, WebP, PDF. Maximale Dateigrösse: 25 MB.",
|
||||
"attachFiles": "Dateien anhängen",
|
||||
"attachments": "Anhänge",
|
||||
"documents": "Dokumente",
|
||||
"installationDocuments": "Installationsdokumente",
|
||||
"uploadDocument": "Dokument hochladen",
|
||||
"noDocuments": "Noch keine Dokumente.",
|
||||
"fileTooLarge": "Datei überschreitet die maximale Grösse von 25 MB.",
|
||||
"invalidFileType": "Ungültiger Dateityp.",
|
||||
"uploadFailed": "Hochladen fehlgeschlagen.",
|
||||
"uploadSuccess": "Hochladen erfolgreich."
|
||||
}
|
||||
|
|
@ -412,5 +412,17 @@
|
|||
"privacy_access_body": "Your data is not shared with third parties. It is used solely to operate the platform and provide you with insights about your installations.",
|
||||
"privacy_close_button": "Close",
|
||||
"sodistorepro": "Sodistore Pro",
|
||||
"numberOfInverters": "Number of Inverters"
|
||||
"numberOfInverters": "Number of Inverters",
|
||||
"documentsTab": "Documents",
|
||||
"documentsHint": "Accepted formats: JPEG, PNG, GIF, WebP, PDF. Maximum file size: 25 MB.",
|
||||
"attachFiles": "Attach Files",
|
||||
"attachments": "Attachments",
|
||||
"documents": "Documents",
|
||||
"installationDocuments": "Installation Documents",
|
||||
"uploadDocument": "Upload Document",
|
||||
"noDocuments": "No documents yet.",
|
||||
"fileTooLarge": "File exceeds maximum size of 25 MB.",
|
||||
"invalidFileType": "Invalid file type.",
|
||||
"uploadFailed": "Upload failed.",
|
||||
"uploadSuccess": "Upload successful."
|
||||
}
|
||||
|
|
|
|||
|
|
@ -664,5 +664,17 @@
|
|||
"privacy_access_body": "Vos données ne sont pas partagées avec des tiers. Elles sont utilisées uniquement pour le fonctionnement de la plateforme et pour vous fournir des informations sur vos installations.",
|
||||
"privacy_close_button": "Fermer",
|
||||
"sodistorepro": "Sodistore Pro",
|
||||
"numberOfInverters": "Nombre d'onduleurs"
|
||||
"numberOfInverters": "Nombre d'onduleurs",
|
||||
"documentsTab": "Documents",
|
||||
"documentsHint": "Formats acceptés : JPEG, PNG, GIF, WebP, PDF. Taille maximale : 25 Mo.",
|
||||
"attachFiles": "Joindre des fichiers",
|
||||
"attachments": "Pièces jointes",
|
||||
"documents": "Documents",
|
||||
"installationDocuments": "Documents d'installation",
|
||||
"uploadDocument": "Télécharger un document",
|
||||
"noDocuments": "Aucun document pour le moment.",
|
||||
"fileTooLarge": "Le fichier dépasse la taille maximale de 25 Mo.",
|
||||
"invalidFileType": "Type de fichier non valide.",
|
||||
"uploadFailed": "Échec du téléchargement.",
|
||||
"uploadSuccess": "Téléchargement réussi."
|
||||
}
|
||||
|
|
@ -664,5 +664,17 @@
|
|||
"privacy_access_body": "I tuoi dati non vengono condivisi con terze parti. Vengono utilizzati esclusivamente per il funzionamento della piattaforma e per fornirti informazioni sulle tue installazioni.",
|
||||
"privacy_close_button": "Chiudi",
|
||||
"sodistorepro": "Sodistore Pro",
|
||||
"numberOfInverters": "Numero di inverter"
|
||||
"numberOfInverters": "Numero di inverter",
|
||||
"documentsTab": "Documenti",
|
||||
"documentsHint": "Formati accettati: JPEG, PNG, GIF, WebP, PDF. Dimensione massima: 25 MB.",
|
||||
"attachFiles": "Allega file",
|
||||
"attachments": "Allegati",
|
||||
"documents": "Documenti",
|
||||
"installationDocuments": "Documenti dell'installazione",
|
||||
"uploadDocument": "Carica documento",
|
||||
"noDocuments": "Nessun documento ancora.",
|
||||
"fileTooLarge": "Il file supera la dimensione massima di 25 MB.",
|
||||
"invalidFileType": "Tipo di file non valido.",
|
||||
"uploadFailed": "Caricamento fallito.",
|
||||
"uploadSuccess": "Caricamento riuscito."
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue