allow to upload pictures and pdf in ticket and specific tab

This commit is contained in:
Yinyin Liu 2026-04-08 15:58:57 +02:00
parent a8db23cadf
commit 5d6c9a886c
24 changed files with 1179 additions and 50 deletions

View File

@ -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();
}
}

View File

@ -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;
}

View File

@ -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)
{

View File

@ -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");

View File

@ -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

View File

@ -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();
}

View File

@ -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",

View File

@ -25,5 +25,6 @@
"detailed_view": "detailed_view/",
"report": "report",
"installationTickets": "installationTickets",
"documents": "documents",
"tickets": "/tickets/"
}

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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>}

View File

@ -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" />
}
]
: [

View File

@ -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>}

View File

@ -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' &&

View File

@ -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>}

View File

@ -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

View File

@ -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>

View File

@ -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}>

View File

@ -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>

View File

@ -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."
}

View File

@ -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."
}

View File

@ -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."
}

View File

@ -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."
}