Compare commits
No commits in common. "1ccdcca21ac4a2804a1afe1c9f07a39f8df65aa1" and "c7fd6eedd1868227a2aad365c2791ac71a75a6c7" have entirely different histories.
1ccdcca21a
...
c7fd6eedd1
|
|
@ -9,21 +9,20 @@ jobs:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Check out repository code
|
- name: Check out repository code
|
||||||
uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3
|
uses: actions/checkout@v3
|
||||||
- run: echo " The ${{ gitea.repository }} repository has been cloned to the runner."
|
- run: echo " The ${{ gitea.repository }} repository has been cloned to the runner."
|
||||||
- uses: actions/setup-dotnet@55ec9447dda3d1cf6bd587150f3262f30ee10815 # v3
|
- uses: actions/setup-dotnet@v3
|
||||||
with:
|
with:
|
||||||
dotnet-version: '7.0.x'
|
dotnet-version: '7.0.x'
|
||||||
- run: dotnet publish ${{ gitea.workspace }}/csharp/App/Backend/Backend.csproj -c Release -r linux-x64 --self-contained true -p:PublishTrimmed=false
|
- run: dotnet publish ${{ gitea.workspace }}/csharp/App/Backend/Backend.csproj -c Release -r linux-x64 --self-contained true -p:PublishTrimmed=false
|
||||||
- uses: actions/setup-node@3235b876344d2a9aa001b8d1453c930bba69e610 # v3
|
- uses: actions/setup-node@v3
|
||||||
- run: npm --prefix ${{ gitea.workspace }}/typescript/frontend-marios2 audit --audit-level=moderate || true
|
|
||||||
- run: |
|
- run: |
|
||||||
npm --prefix ${{ gitea.workspace }}/typescript/frontend-marios2 install --ignore-scripts
|
npm --prefix ${{ gitea.workspace }}/typescript/frontend-marios2 install
|
||||||
npm --prefix ${{ gitea.workspace }}/typescript/frontend-marios2 run build
|
npm --prefix ${{ gitea.workspace }}/typescript/frontend-marios2 run build
|
||||||
|
|
||||||
|
|
||||||
- name: stop services
|
- name: stop services
|
||||||
uses: appleboy/ssh-action@1d1b21ca96111b1eb4c03c21c14ebb971d2200f6 # v0.1.4
|
uses: appleboy/ssh-action@v0.1.4
|
||||||
with:
|
with:
|
||||||
host: 194.182.190.208
|
host: 194.182.190.208
|
||||||
username: ubuntu
|
username: ubuntu
|
||||||
|
|
@ -32,7 +31,7 @@ jobs:
|
||||||
sudo systemctl stop backend
|
sudo systemctl stop backend
|
||||||
|
|
||||||
- name: Copy Backend
|
- name: Copy Backend
|
||||||
uses: appleboy/scp-action@8a92fcdb1eb4ffbf538b2fa286739760aac8a95b # v0.1.4
|
uses: appleboy/scp-action@v0.1.4
|
||||||
with:
|
with:
|
||||||
host: 194.182.190.208
|
host: 194.182.190.208
|
||||||
username: ubuntu
|
username: ubuntu
|
||||||
|
|
@ -43,7 +42,7 @@ jobs:
|
||||||
strip_components: 1
|
strip_components: 1
|
||||||
|
|
||||||
- name: Copy Frontend
|
- name: Copy Frontend
|
||||||
uses: appleboy/scp-action@8a92fcdb1eb4ffbf538b2fa286739760aac8a95b # v0.1.4
|
uses: appleboy/scp-action@v0.1.4
|
||||||
with:
|
with:
|
||||||
host: 194.182.190.208
|
host: 194.182.190.208
|
||||||
username: ubuntu
|
username: ubuntu
|
||||||
|
|
@ -54,7 +53,7 @@ jobs:
|
||||||
strip_components: 1
|
strip_components: 1
|
||||||
|
|
||||||
- name: restart services
|
- name: restart services
|
||||||
uses: appleboy/ssh-action@1d1b21ca96111b1eb4c03c21c14ebb971d2200f6 # v0.1.4
|
uses: appleboy/ssh-action@v0.1.4
|
||||||
with:
|
with:
|
||||||
host: 194.182.190.208
|
host: 194.182.190.208
|
||||||
username: ubuntu
|
username: ubuntu
|
||||||
|
|
@ -62,3 +61,4 @@ jobs:
|
||||||
script: |
|
script: |
|
||||||
sudo systemctl restart backend
|
sudo systemctl restart backend
|
||||||
sudo cp -rf ~/frontend/build/* /var/www/html/monitor.innov.energy/html/
|
sudo cp -rf ~/frontend/build/* /var/www/html/monitor.innov.energy/html/
|
||||||
|
sudo npm install -g serve
|
||||||
|
|
@ -9,20 +9,19 @@ jobs:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Check out repository code
|
- name: Check out repository code
|
||||||
uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3
|
uses: actions/checkout@v3
|
||||||
- run: echo " The ${{ gitea.repository }} repository has been cloned to the runner."
|
- run: echo " The ${{ gitea.repository }} repository has been cloned to the runner."
|
||||||
- uses: actions/setup-dotnet@55ec9447dda3d1cf6bd587150f3262f30ee10815 # v3
|
- uses: actions/setup-dotnet@v3
|
||||||
with:
|
with:
|
||||||
dotnet-version: '7.0.x'
|
dotnet-version: '7.0.x'
|
||||||
- run: dotnet publish ${{ gitea.workspace }}/csharp/App/Backend/Backend.csproj -c Release -r linux-x64 --self-contained true -p:PublishTrimmed=false
|
- run: dotnet publish ${{ gitea.workspace }}/csharp/App/Backend/Backend.csproj -c Release -r linux-x64 --self-contained true -p:PublishTrimmed=false
|
||||||
- uses: actions/setup-node@3235b876344d2a9aa001b8d1453c930bba69e610 # v3
|
- uses: actions/setup-node@v3
|
||||||
- run: npm --prefix ${{ gitea.workspace }}/typescript/frontend-marios2 audit --audit-level=moderate || true
|
|
||||||
- run: |
|
- run: |
|
||||||
npm --prefix ${{ gitea.workspace }}/typescript/frontend-marios2 install --ignore-scripts
|
npm --prefix ${{ gitea.workspace }}/typescript/frontend-marios2 install
|
||||||
npm --prefix ${{ gitea.workspace }}/typescript/frontend-marios2 run build
|
npm --prefix ${{ gitea.workspace }}/typescript/frontend-marios2 run build
|
||||||
|
|
||||||
- name: stop services
|
- name: stop services
|
||||||
uses: appleboy/ssh-action@1d1b21ca96111b1eb4c03c21c14ebb971d2200f6 # v0.1.4
|
uses: appleboy/ssh-action@v0.1.4
|
||||||
with:
|
with:
|
||||||
host: 91.92.154.141
|
host: 91.92.154.141
|
||||||
username: ubuntu
|
username: ubuntu
|
||||||
|
|
@ -31,7 +30,7 @@ jobs:
|
||||||
sudo systemctl stop backend
|
sudo systemctl stop backend
|
||||||
|
|
||||||
- name: Copy Backend
|
- name: Copy Backend
|
||||||
uses: appleboy/scp-action@8a92fcdb1eb4ffbf538b2fa286739760aac8a95b # v0.1.4
|
uses: appleboy/scp-action@v0.1.4
|
||||||
with:
|
with:
|
||||||
host: 91.92.154.141
|
host: 91.92.154.141
|
||||||
username: ubuntu
|
username: ubuntu
|
||||||
|
|
@ -42,7 +41,7 @@ jobs:
|
||||||
strip_components: 11
|
strip_components: 11
|
||||||
|
|
||||||
- name: Copy Frontend
|
- name: Copy Frontend
|
||||||
uses: appleboy/scp-action@8a92fcdb1eb4ffbf538b2fa286739760aac8a95b # v0.1.4
|
uses: appleboy/scp-action@v0.1.4
|
||||||
with:
|
with:
|
||||||
host: 91.92.154.141
|
host: 91.92.154.141
|
||||||
username: ubuntu
|
username: ubuntu
|
||||||
|
|
@ -53,7 +52,7 @@ jobs:
|
||||||
strip_components: 5
|
strip_components: 5
|
||||||
|
|
||||||
- name: restart services
|
- name: restart services
|
||||||
uses: appleboy/ssh-action@1d1b21ca96111b1eb4c03c21c14ebb971d2200f6 # v0.1.4
|
uses: appleboy/ssh-action@v0.1.4
|
||||||
with:
|
with:
|
||||||
host: 91.92.154.141
|
host: 91.92.154.141
|
||||||
username: ubuntu
|
username: ubuntu
|
||||||
|
|
@ -61,3 +60,4 @@ jobs:
|
||||||
script: |
|
script: |
|
||||||
sudo systemctl restart backend
|
sudo systemctl restart backend
|
||||||
sudo cp -rf ~/frontend/build/* /var/www/html/stage.innov.energy/html/
|
sudo cp -rf ~/frontend/build/* /var/www/html/stage.innov.energy/html/
|
||||||
|
sudo npm install -g serve
|
||||||
|
|
@ -7,8 +7,6 @@ using InnovEnergy.App.Backend.DataTypes.Methods;
|
||||||
using InnovEnergy.App.Backend.Relations;
|
using InnovEnergy.App.Backend.Relations;
|
||||||
using InnovEnergy.App.Backend.Services;
|
using InnovEnergy.App.Backend.Services;
|
||||||
using InnovEnergy.App.Backend.Websockets;
|
using InnovEnergy.App.Backend.Websockets;
|
||||||
using InnovEnergy.Lib.S3Utils;
|
|
||||||
using InnovEnergy.Lib.S3Utils.DataTypes;
|
|
||||||
using InnovEnergy.Lib.Utils;
|
using InnovEnergy.Lib.Utils;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Newtonsoft.Json;
|
using Newtonsoft.Json;
|
||||||
|
|
@ -2265,7 +2263,7 @@ public class Controller : ControllerBase
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpDelete(nameof(DeleteTicket))]
|
[HttpDelete(nameof(DeleteTicket))]
|
||||||
public async Task<ActionResult> DeleteTicket(Int64 id, Token authToken)
|
public ActionResult DeleteTicket(Int64 id, Token authToken)
|
||||||
{
|
{
|
||||||
var user = Db.GetSession(authToken)?.User;
|
var user = Db.GetSession(authToken)?.User;
|
||||||
if (user is null || user.UserType != 2) return Unauthorized();
|
if (user is null || user.UserType != 2) return Unauthorized();
|
||||||
|
|
@ -2273,14 +2271,6 @@ public class Controller : ControllerBase
|
||||||
var ticket = Db.GetTicketById(id);
|
var ticket = Db.GetTicketById(id);
|
||||||
if (ticket is null) return NotFound();
|
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.");
|
return Db.Delete(ticket) ? Ok() : StatusCode(500, "Delete failed.");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -2457,213 +2447,4 @@ public class Controller : ControllerBase
|
||||||
return Db.Update(user) ? Ok() : StatusCode(500);
|
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();
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,26 +0,0 @@
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
@ -88,9 +88,6 @@ public static partial class Db
|
||||||
public static Boolean Create(TicketAiDiagnosis diagnosis) => Insert(diagnosis);
|
public static Boolean Create(TicketAiDiagnosis diagnosis) => Insert(diagnosis);
|
||||||
public static Boolean Create(TicketTimelineEvent ev) => Insert(ev);
|
public static Boolean Create(TicketTimelineEvent ev) => Insert(ev);
|
||||||
|
|
||||||
// Document storage
|
|
||||||
public static Boolean Create(Document document) => Insert(document);
|
|
||||||
|
|
||||||
public static void HandleAction(UserAction newAction)
|
public static void HandleAction(UserAction newAction)
|
||||||
{
|
{
|
||||||
//Find the total number of actions for this installation
|
//Find the total number of actions for this installation
|
||||||
|
|
|
||||||
|
|
@ -39,9 +39,6 @@ public static partial class Db
|
||||||
public static TableQuery<TicketAiDiagnosis> TicketAiDiagnoses => Connection.Table<TicketAiDiagnosis>();
|
public static TableQuery<TicketAiDiagnosis> TicketAiDiagnoses => Connection.Table<TicketAiDiagnosis>();
|
||||||
public static TableQuery<TicketTimelineEvent> TicketTimelineEvents => Connection.Table<TicketTimelineEvent>();
|
public static TableQuery<TicketTimelineEvent> TicketTimelineEvents => Connection.Table<TicketTimelineEvent>();
|
||||||
|
|
||||||
// Document storage
|
|
||||||
public static TableQuery<Document> Documents => Connection.Table<Document>();
|
|
||||||
|
|
||||||
|
|
||||||
public static void Init()
|
public static void Init()
|
||||||
{
|
{
|
||||||
|
|
@ -80,9 +77,6 @@ public static partial class Db
|
||||||
Connection.CreateTable<TicketComment>();
|
Connection.CreateTable<TicketComment>();
|
||||||
Connection.CreateTable<TicketAiDiagnosis>();
|
Connection.CreateTable<TicketAiDiagnosis>();
|
||||||
Connection.CreateTable<TicketTimelineEvent>();
|
Connection.CreateTable<TicketTimelineEvent>();
|
||||||
|
|
||||||
// Document storage
|
|
||||||
Connection.CreateTable<Document>();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// One-time migration: normalize legacy long-form language values to ISO codes
|
// One-time migration: normalize legacy long-form language values to ISO codes
|
||||||
|
|
@ -141,9 +135,6 @@ public static partial class Db
|
||||||
fileConnection.CreateTable<TicketAiDiagnosis>();
|
fileConnection.CreateTable<TicketAiDiagnosis>();
|
||||||
fileConnection.CreateTable<TicketTimelineEvent>();
|
fileConnection.CreateTable<TicketTimelineEvent>();
|
||||||
|
|
||||||
// Document storage
|
|
||||||
fileConnection.CreateTable<Document>();
|
|
||||||
|
|
||||||
// Migrate new columns: set defaults for existing rows where NULL or empty
|
// 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 ExternalEms = 'No' WHERE ExternalEms IS NULL OR ExternalEms = ''");
|
||||||
fileConnection.Execute("UPDATE Installation SET InstallationModel = '' WHERE InstallationModel IS NULL");
|
fileConnection.Execute("UPDATE Installation SET InstallationModel = '' WHERE InstallationModel IS NULL");
|
||||||
|
|
|
||||||
|
|
@ -129,22 +129,12 @@ public static partial class Db
|
||||||
.Select(t => t.Id).ToList();
|
.Select(t => t.Id).ToList();
|
||||||
foreach (var tid in ticketIds)
|
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);
|
TicketComments .Delete(c => c.TicketId == tid);
|
||||||
TicketAiDiagnoses .Delete(d => d.TicketId == tid);
|
TicketAiDiagnoses .Delete(d => d.TicketId == tid);
|
||||||
TicketTimelineEvents.Delete(e => e.TicketId == tid);
|
TicketTimelineEvents.Delete(e => e.TicketId == tid);
|
||||||
}
|
}
|
||||||
Tickets.Delete(t => t.InstallationId == installation.Id);
|
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;
|
return Installations.Delete(i => i.Id == installation.Id) > 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -228,17 +218,6 @@ public static partial class Db
|
||||||
|
|
||||||
Boolean DeleteTicketAndChildren()
|
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);
|
TicketComments .Delete(c => c.TicketId == ticket.Id);
|
||||||
TicketAiDiagnoses .Delete(d => d.TicketId == ticket.Id);
|
TicketAiDiagnoses .Delete(d => d.TicketId == ticket.Id);
|
||||||
TicketTimelineEvents.Delete(e => e.TicketId == ticket.Id);
|
TicketTimelineEvents.Delete(e => e.TicketId == ticket.Id);
|
||||||
|
|
@ -246,39 +225,6 @@ 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>
|
/// <summary>
|
||||||
/// Deletes all report records older than 1 year. Called annually on Jan 2
|
/// 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
|
/// after yearly reports are created. Uses fetch-then-delete for string-compared
|
||||||
|
|
|
||||||
|
|
@ -210,27 +210,4 @@ public static partial class Db
|
||||||
.Distinct()
|
.Distinct()
|
||||||
.OrderBy(s => s)
|
.OrderBy(s => s)
|
||||||
.ToList();
|
.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,9 +8,6 @@ using InnovEnergy.App.Backend.DeleteOldData;
|
||||||
using Microsoft.AspNetCore.HttpOverrides;
|
using Microsoft.AspNetCore.HttpOverrides;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Microsoft.OpenApi.Models;
|
using Microsoft.OpenApi.Models;
|
||||||
using InnovEnergy.App.Backend.DataTypes.Methods;
|
|
||||||
using InnovEnergy.Lib.S3Utils;
|
|
||||||
using InnovEnergy.Lib.S3Utils.DataTypes;
|
|
||||||
using InnovEnergy.Lib.Utils;
|
using InnovEnergy.Lib.Utils;
|
||||||
|
|
||||||
namespace InnovEnergy.App.Backend;
|
namespace InnovEnergy.App.Backend;
|
||||||
|
|
@ -29,7 +26,6 @@ public static class Program
|
||||||
Watchdog.NotifyReady();
|
Watchdog.NotifyReady();
|
||||||
Db.Init();
|
Db.Init();
|
||||||
LoadEnvFile();
|
LoadEnvFile();
|
||||||
EnsureDocumentBucketExists().SupressAwaitWarning();
|
|
||||||
DiagnosticService.Initialize();
|
DiagnosticService.Initialize();
|
||||||
TicketDiagnosticService.Initialize();
|
TicketDiagnosticService.Initialize();
|
||||||
NetworkProviderService.Initialize();
|
NetworkProviderService.Initialize();
|
||||||
|
|
@ -126,30 +122,6 @@ 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
|
private static OpenApiInfo OpenApiInfo { get; } = new OpenApiInfo
|
||||||
{
|
{
|
||||||
Title = "Inesco Backend API",
|
Title = "Inesco Backend API",
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,5 @@
|
||||||
"detailed_view": "detailed_view/",
|
"detailed_view": "detailed_view/",
|
||||||
"report": "report",
|
"report": "report",
|
||||||
"installationTickets": "installationTickets",
|
"installationTickets": "installationTickets",
|
||||||
"documents": "documents",
|
|
||||||
"tickets": "/tickets/"
|
"tickets": "/tickets/"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,222 +0,0 @@
|
||||||
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;
|
|
||||||
|
|
@ -1,151 +0,0 @@
|
||||||
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;
|
|
||||||
|
|
@ -1,63 +0,0 @@
|
||||||
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;
|
|
||||||
|
|
@ -34,7 +34,7 @@ import { useNavigate } from 'react-router-dom';
|
||||||
import { UserType } from '../../../interfaces/UserTypes';
|
import { UserType } from '../../../interfaces/UserTypes';
|
||||||
import axiosConfig from '../../../Resources/axiosConfig';
|
import axiosConfig from '../../../Resources/axiosConfig';
|
||||||
import {
|
import {
|
||||||
getPresetsForDevice,
|
INSTALLATION_PRESETS,
|
||||||
PresetConfig,
|
PresetConfig,
|
||||||
BatterySnTree,
|
BatterySnTree,
|
||||||
parseBatterySnTree,
|
parseBatterySnTree,
|
||||||
|
|
@ -113,7 +113,7 @@ function InformationSodistorehome(props: InformationSodistorehomeProps) {
|
||||||
? (inverterCount && parseInt(inverterCount, 10) > 0
|
? (inverterCount && parseInt(inverterCount, 10) > 0
|
||||||
? buildSodistoreProPreset(parseInt(inverterCount, 10))
|
? buildSodistoreProPreset(parseInt(inverterCount, 10))
|
||||||
: null)
|
: null)
|
||||||
: (getPresetsForDevice(formValues.device)[selectedPreset] || null);
|
: (INSTALLATION_PRESETS[selectedPreset] || null);
|
||||||
|
|
||||||
const [batterySnTree, setBatterySnTree] = useState<BatterySnTree>(() => {
|
const [batterySnTree, setBatterySnTree] = useState<BatterySnTree>(() => {
|
||||||
if (presetConfig) {
|
if (presetConfig) {
|
||||||
|
|
@ -141,32 +141,8 @@ function InformationSodistorehome(props: InformationSodistorehomeProps) {
|
||||||
return Array.from({ length: invCount }, () => '1');
|
return Array.from({ length: invCount }, () => '1');
|
||||||
});
|
});
|
||||||
|
|
||||||
// When presetConfig is available, ensure flat values (batteryClusterNumber, batteryNumber)
|
|
||||||
// stay in sync with the current preset structure. This handles:
|
|
||||||
// - Legacy installations with device=0 → user sets device type
|
|
||||||
// - Preset structure changed (e.g., Growatt home 9 was [[1,1]] → now [[2]])
|
|
||||||
useEffect(() => {
|
|
||||||
if (!presetConfig) return;
|
|
||||||
|
|
||||||
// Re-parse battery tree if empty but serial numbers exist
|
|
||||||
let tree = batterySnTree;
|
|
||||||
if (tree.length === 0 && props.values.batterySerialNumbers) {
|
|
||||||
tree = parseBatterySnTree(props.values.batterySerialNumbers, presetConfig);
|
|
||||||
setBatterySnTree(tree);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Always recalculate flat values from current preset to keep DB in sync
|
|
||||||
const flat = computeFlatValues(presetConfig, tree);
|
|
||||||
if (
|
|
||||||
flat.batteryClusterNumber !== formValues.batteryClusterNumber ||
|
|
||||||
flat.batteryNumber !== formValues.batteryNumber
|
|
||||||
) {
|
|
||||||
setFormValues((prev) => ({ ...prev, ...flat }));
|
|
||||||
}
|
|
||||||
}, [presetConfig]);
|
|
||||||
|
|
||||||
const handlePresetChange = (newPreset: string) => {
|
const handlePresetChange = (newPreset: string) => {
|
||||||
const newConfig = getPresetsForDevice(formValues.device)[newPreset];
|
const newConfig = INSTALLATION_PRESETS[newPreset];
|
||||||
if (!newConfig) return;
|
if (!newConfig) return;
|
||||||
|
|
||||||
// Check for data loss — either from existing tree or legacy flat data
|
// Check for data loss — either from existing tree or legacy flat data
|
||||||
|
|
@ -195,7 +171,7 @@ function InformationSodistorehome(props: InformationSodistorehomeProps) {
|
||||||
};
|
};
|
||||||
|
|
||||||
const applyPreset = (newPreset: string) => {
|
const applyPreset = (newPreset: string) => {
|
||||||
const newConfig = getPresetsForDevice(formValues.device)[newPreset];
|
const newConfig = INSTALLATION_PRESETS[newPreset];
|
||||||
if (!newConfig) return;
|
if (!newConfig) return;
|
||||||
|
|
||||||
setSelectedPreset(newPreset);
|
setSelectedPreset(newPreset);
|
||||||
|
|
@ -339,28 +315,10 @@ function InformationSodistorehome(props: InformationSodistorehomeProps) {
|
||||||
|
|
||||||
const handleChange = (e) => {
|
const handleChange = (e) => {
|
||||||
const { name, value } = e.target;
|
const { name, value } = e.target;
|
||||||
const updated = { ...formValues, [name]: value };
|
setFormValues({
|
||||||
|
...formValues,
|
||||||
// When device type changes, reset preset if it's not available for the new device
|
[name]: value
|
||||||
if (name === 'device' && !isSodistorePro) {
|
});
|
||||||
const newDevicePresets = getPresetsForDevice(Number(value));
|
|
||||||
if (selectedPreset && !newDevicePresets[selectedPreset]) {
|
|
||||||
setSelectedPreset('');
|
|
||||||
setBatterySnTree([]);
|
|
||||||
setInverterSerialNumbers([]);
|
|
||||||
setDataloggerSerialNumbers([]);
|
|
||||||
setPvStringsPerInverter([]);
|
|
||||||
updated.installationModel = '';
|
|
||||||
updated.batteryNumber = 0;
|
|
||||||
updated.batteryClusterNumber = 0;
|
|
||||||
updated.batterySerialNumbers = '';
|
|
||||||
updated.inverterSN = '';
|
|
||||||
updated.dataloggerSN = '';
|
|
||||||
updated.pvStringsPerInverter = '';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
setFormValues(updated);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSubmit = () => {
|
const handleSubmit = () => {
|
||||||
|
|
@ -891,7 +849,7 @@ function InformationSodistorehome(props: InformationSodistorehomeProps) {
|
||||||
<MenuItem value="" disabled>
|
<MenuItem value="" disabled>
|
||||||
<em><FormattedMessage id="selectModel" defaultMessage="Select model..." /></em>
|
<em><FormattedMessage id="selectModel" defaultMessage="Select model..." /></em>
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
{Object.keys(getPresetsForDevice(formValues.device)).map((name) => (
|
{Object.keys(INSTALLATION_PRESETS).map((name) => (
|
||||||
<MenuItem key={name} value={name}>
|
<MenuItem key={name} value={name}>
|
||||||
{name}
|
{name}
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
|
|
|
||||||
|
|
@ -12,24 +12,13 @@ export type PresetConfig = number[][];
|
||||||
// 3D array: [inverter][cluster][batteryIndex] = serialNumber
|
// 3D array: [inverter][cluster][batteryIndex] = serialNumber
|
||||||
export type BatterySnTree = string[][][];
|
export type BatterySnTree = string[][][];
|
||||||
|
|
||||||
// Device-aware presets: keyed by device ID, then model name
|
export const INSTALLATION_PRESETS: Record<string, PresetConfig> = {
|
||||||
// Device 3 = Growatt, Device 4 = inesco 12K
|
|
||||||
export const INSTALLATION_PRESETS: Record<number, Record<string, PresetConfig>> = {
|
|
||||||
3: {
|
|
||||||
'sodistore home 9': [[2]],
|
|
||||||
'sodistore home 18': [[4]],
|
|
||||||
},
|
|
||||||
4: {
|
|
||||||
'sodistore home 9': [[1, 1]],
|
'sodistore home 9': [[1, 1]],
|
||||||
'sodistore home 18': [[2, 2]],
|
'sodistore home 18': [[2, 2]],
|
||||||
'sodistore home 27': [[2, 2], [1, 1]],
|
'sodistore home 27': [[2, 2], [1, 1]],
|
||||||
'sodistore home 36': [[2, 2], [2, 2]],
|
'sodistore home 36': [[2, 2], [2, 2]],
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getPresetsForDevice = (deviceId: number): Record<string, PresetConfig> =>
|
|
||||||
INSTALLATION_PRESETS[deviceId] ?? {};
|
|
||||||
|
|
||||||
export const buildSodistoreProPreset = (inverterCount: number): PresetConfig =>
|
export const buildSodistoreProPreset = (inverterCount: number): PresetConfig =>
|
||||||
Array.from({ length: inverterCount }, () => [2, 2]);
|
Array.from({ length: inverterCount }, () => [2, 2]);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -29,7 +29,6 @@ import BatteryView from '../BatteryView/BatteryView';
|
||||||
import Configuration from '../Configuration/Configuration';
|
import Configuration from '../Configuration/Configuration';
|
||||||
import PvView from '../PvView/PvView';
|
import PvView from '../PvView/PvView';
|
||||||
import InstallationTicketsTab from '../Tickets/InstallationTicketsTab';
|
import InstallationTicketsTab from '../Tickets/InstallationTicketsTab';
|
||||||
import DocumentsTab from '../Documents/DocumentsTab';
|
|
||||||
|
|
||||||
interface singleInstallationProps {
|
interface singleInstallationProps {
|
||||||
current_installation?: I_Installation;
|
current_installation?: I_Installation;
|
||||||
|
|
@ -565,17 +564,6 @@ function Installation(props: singleInstallationProps) {
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{(currentUser.userType == UserType.admin || currentUser.userType == UserType.partner) && (
|
|
||||||
<Route
|
|
||||||
path={routes.documents}
|
|
||||||
element={
|
|
||||||
<DocumentsTab
|
|
||||||
installationId={props.current_installation.id}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Route
|
<Route
|
||||||
path={'*'}
|
path={'*'}
|
||||||
element={<Navigate to={routes.live}></Navigate>}
|
element={<Navigate to={routes.live}></Navigate>}
|
||||||
|
|
|
||||||
|
|
@ -33,8 +33,7 @@ function InstallationTabs(props: InstallationTabsProps) {
|
||||||
'configuration',
|
'configuration',
|
||||||
'history',
|
'history',
|
||||||
'pvview',
|
'pvview',
|
||||||
'installationTickets',
|
'installationTickets'
|
||||||
'documents'
|
|
||||||
];
|
];
|
||||||
|
|
||||||
const [currentTab, setCurrentTab] = useState<string>(undefined);
|
const [currentTab, setCurrentTab] = useState<string>(undefined);
|
||||||
|
|
@ -171,10 +170,6 @@ function InstallationTabs(props: InstallationTabsProps) {
|
||||||
{
|
{
|
||||||
value: 'installationTickets',
|
value: 'installationTickets',
|
||||||
label: <FormattedMessage id="tickets" defaultMessage="Tickets" />
|
label: <FormattedMessage id="tickets" defaultMessage="Tickets" />
|
||||||
},
|
|
||||||
{
|
|
||||||
value: 'documents',
|
|
||||||
label: <FormattedMessage id="documentsTab" defaultMessage="Documents" />
|
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
: currentUser.userType == UserType.partner
|
: currentUser.userType == UserType.partner
|
||||||
|
|
@ -206,10 +201,6 @@ function InstallationTabs(props: InstallationTabsProps) {
|
||||||
label: (
|
label: (
|
||||||
<FormattedMessage id="information" defaultMessage="Information" />
|
<FormattedMessage id="information" defaultMessage="Information" />
|
||||||
)
|
)
|
||||||
},
|
|
||||||
{
|
|
||||||
value: 'documents',
|
|
||||||
label: <FormattedMessage id="documentsTab" defaultMessage="Documents" />
|
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
: [
|
: [
|
||||||
|
|
@ -312,10 +303,6 @@ function InstallationTabs(props: InstallationTabsProps) {
|
||||||
{
|
{
|
||||||
value: 'installationTickets',
|
value: 'installationTickets',
|
||||||
label: <FormattedMessage id="tickets" defaultMessage="Tickets" />
|
label: <FormattedMessage id="tickets" defaultMessage="Tickets" />
|
||||||
},
|
|
||||||
{
|
|
||||||
value: 'documents',
|
|
||||||
label: <FormattedMessage id="documentsTab" defaultMessage="Documents" />
|
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
: currentUser.userType == UserType.partner
|
: currentUser.userType == UserType.partner
|
||||||
|
|
@ -361,10 +348,6 @@ function InstallationTabs(props: InstallationTabsProps) {
|
||||||
defaultMessage="Information"
|
defaultMessage="Information"
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
},
|
|
||||||
{
|
|
||||||
value: 'documents',
|
|
||||||
label: <FormattedMessage id="documentsTab" defaultMessage="Documents" />
|
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
: [
|
: [
|
||||||
|
|
|
||||||
|
|
@ -6,8 +6,7 @@ export const getChartOptions = (
|
||||||
chartInfo: chartInfoInterface,
|
chartInfo: chartInfoInterface,
|
||||||
type: string,
|
type: string,
|
||||||
dateList: string[],
|
dateList: string[],
|
||||||
stacked: Boolean,
|
stacked: Boolean
|
||||||
voltageInfo?: chartInfoInterface
|
|
||||||
): ApexOptions => {
|
): ApexOptions => {
|
||||||
return type.includes('daily')
|
return type.includes('daily')
|
||||||
? {
|
? {
|
||||||
|
|
@ -166,28 +165,7 @@ export const getChartOptions = (
|
||||||
return Math.round(value).toString();
|
return Math.round(value).toString();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
|
||||||
...(voltageInfo ? [{
|
|
||||||
seriesName: 'Battery Voltage',
|
|
||||||
opposite: true,
|
|
||||||
tickAmount: 5,
|
|
||||||
min: voltageInfo.min > 0 ? Math.floor(voltageInfo.min / 5) * 5 : 0,
|
|
||||||
max: Math.ceil(voltageInfo.max / 5) * 5,
|
|
||||||
title: {
|
|
||||||
text: '(V)',
|
|
||||||
style: {
|
|
||||||
fontSize: '12px'
|
|
||||||
},
|
|
||||||
offsetY: -190,
|
|
||||||
offsetX: -45,
|
|
||||||
rotate: 0
|
|
||||||
},
|
|
||||||
labels: {
|
|
||||||
formatter: function (value: number) {
|
|
||||||
return Math.round(value).toString();
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}] : [])
|
|
||||||
]
|
]
|
||||||
: {
|
: {
|
||||||
tickAmount: chartInfo.unit === '(%)' ? 5 : 6,
|
tickAmount: chartInfo.unit === '(%)' ? 5 : 6,
|
||||||
|
|
|
||||||
|
|
@ -712,8 +712,7 @@ function Overview(props: OverviewProps) {
|
||||||
dailyDataArray[chartState].chartOverview.overview,
|
dailyDataArray[chartState].chartOverview.overview,
|
||||||
'dailyoverview',
|
'dailyoverview',
|
||||||
[],
|
[],
|
||||||
true,
|
true
|
||||||
(product === 2 || product === 5) ? dailyDataArray[chartState].chartOverview.batteryVoltage : undefined
|
|
||||||
),
|
),
|
||||||
chart: {
|
chart: {
|
||||||
events: {
|
events: {
|
||||||
|
|
@ -745,12 +744,7 @@ function Overview(props: OverviewProps) {
|
||||||
...dailyDataArray[chartState].chartData.soc,
|
...dailyDataArray[chartState].chartData.soc,
|
||||||
type: 'line',
|
type: 'line',
|
||||||
color: '#008FFB'
|
color: '#008FFB'
|
||||||
},
|
}
|
||||||
...((product === 2 || product === 5) ? [{
|
|
||||||
...dailyDataArray[chartState].chartData.batteryVoltage,
|
|
||||||
type: 'line' as const,
|
|
||||||
color: '#9b59b6'
|
|
||||||
}] : [])
|
|
||||||
]}
|
]}
|
||||||
height={420}
|
height={420}
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -24,7 +24,6 @@ import { UserType } from '../../../interfaces/UserTypes';
|
||||||
import HistoryOfActions from '../History/History';
|
import HistoryOfActions from '../History/History';
|
||||||
import BuildIcon from '@mui/icons-material/Build';
|
import BuildIcon from '@mui/icons-material/Build';
|
||||||
import InstallationTicketsTab from '../Tickets/InstallationTicketsTab';
|
import InstallationTicketsTab from '../Tickets/InstallationTicketsTab';
|
||||||
import DocumentsTab from '../Documents/DocumentsTab';
|
|
||||||
import AccessContextProvider from '../../../contexts/AccessContextProvider';
|
import AccessContextProvider from '../../../contexts/AccessContextProvider';
|
||||||
import Access from '../ManageAccess/Access';
|
import Access from '../ManageAccess/Access';
|
||||||
|
|
||||||
|
|
@ -443,17 +442,6 @@ function SalidomoInstallation(props: singleInstallationProps) {
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{(currentUser.userType == UserType.admin || currentUser.userType == UserType.partner) && (
|
|
||||||
<Route
|
|
||||||
path={routes.documents}
|
|
||||||
element={
|
|
||||||
<DocumentsTab
|
|
||||||
installationId={props.current_installation.id}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Route
|
<Route
|
||||||
path={'*'}
|
path={'*'}
|
||||||
element={<Navigate to={routes.batteryview}></Navigate>}
|
element={<Navigate to={routes.batteryview}></Navigate>}
|
||||||
|
|
|
||||||
|
|
@ -29,8 +29,7 @@ function SalidomoInstallationTabs(props: InstallationTabsProps) {
|
||||||
'overview',
|
'overview',
|
||||||
'log',
|
'log',
|
||||||
'history',
|
'history',
|
||||||
'installationTickets',
|
'installationTickets'
|
||||||
'documents'
|
|
||||||
];
|
];
|
||||||
|
|
||||||
const [currentTab, setCurrentTab] = useState<string>(undefined);
|
const [currentTab, setCurrentTab] = useState<string>(undefined);
|
||||||
|
|
@ -142,10 +141,6 @@ function SalidomoInstallationTabs(props: InstallationTabsProps) {
|
||||||
{
|
{
|
||||||
value: 'installationTickets',
|
value: 'installationTickets',
|
||||||
label: <FormattedMessage id="tickets" defaultMessage="Tickets" />
|
label: <FormattedMessage id="tickets" defaultMessage="Tickets" />
|
||||||
},
|
|
||||||
{
|
|
||||||
value: 'documents',
|
|
||||||
label: <FormattedMessage id="documentsTab" defaultMessage="Documents" />
|
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
: [
|
: [
|
||||||
|
|
@ -231,10 +226,6 @@ function SalidomoInstallationTabs(props: InstallationTabsProps) {
|
||||||
{
|
{
|
||||||
value: 'installationTickets',
|
value: 'installationTickets',
|
||||||
label: <FormattedMessage id="tickets" defaultMessage="Tickets" />
|
label: <FormattedMessage id="tickets" defaultMessage="Tickets" />
|
||||||
},
|
|
||||||
{
|
|
||||||
value: 'documents',
|
|
||||||
label: <FormattedMessage id="documentsTab" defaultMessage="Documents" />
|
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
: currentTab != 'list' &&
|
: currentTab != 'list' &&
|
||||||
|
|
|
||||||
|
|
@ -29,7 +29,6 @@ import TopologySodistoreHome from '../Topology/TopologySodistoreHome';
|
||||||
import Overview from '../Overview/overview';
|
import Overview from '../Overview/overview';
|
||||||
import WeeklyReport from './WeeklyReport';
|
import WeeklyReport from './WeeklyReport';
|
||||||
import InstallationTicketsTab from '../Tickets/InstallationTicketsTab';
|
import InstallationTicketsTab from '../Tickets/InstallationTicketsTab';
|
||||||
import DocumentsTab from '../Documents/DocumentsTab';
|
|
||||||
|
|
||||||
interface singleInstallationProps {
|
interface singleInstallationProps {
|
||||||
current_installation?: I_Installation;
|
current_installation?: I_Installation;
|
||||||
|
|
@ -638,17 +637,6 @@ function SodioHomeInstallation(props: singleInstallationProps) {
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{(currentUser.userType == UserType.admin || currentUser.userType == UserType.partner) && (
|
|
||||||
<Route
|
|
||||||
path={routes.documents}
|
|
||||||
element={
|
|
||||||
<DocumentsTab
|
|
||||||
installationId={props.current_installation.id}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Route
|
<Route
|
||||||
path={'*'}
|
path={'*'}
|
||||||
element={<Navigate to={routes.live}></Navigate>}
|
element={<Navigate to={routes.live}></Navigate>}
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,7 @@ import { Close as CloseIcon } from '@mui/icons-material';
|
||||||
import { I_Installation } from 'src/interfaces/InstallationTypes';
|
import { I_Installation } from 'src/interfaces/InstallationTypes';
|
||||||
import { InstallationsContext } from 'src/contexts/InstallationsContextProvider';
|
import { InstallationsContext } from 'src/contexts/InstallationsContextProvider';
|
||||||
import { FormattedMessage } from 'react-intl';
|
import { FormattedMessage } from 'react-intl';
|
||||||
import { getPresetsForDevice, SODIOHOME_DEVICE_TYPES } from '../Information/installationSetupUtils';
|
import { INSTALLATION_PRESETS, SODIOHOME_DEVICE_TYPES } from '../Information/installationSetupUtils';
|
||||||
|
|
||||||
interface SodistorehomeInstallationFormPros {
|
interface SodistorehomeInstallationFormPros {
|
||||||
cancel: () => void;
|
cancel: () => void;
|
||||||
|
|
@ -38,7 +38,7 @@ function SodistorehomeInstallationForm(props: SodistorehomeInstallationFormPros)
|
||||||
...(isSodistorePro ? { device: 4 } : {}),
|
...(isSodistorePro ? { device: 4 } : {}),
|
||||||
});
|
});
|
||||||
const [inverterCount, setInverterCount] = useState('');
|
const [inverterCount, setInverterCount] = useState('');
|
||||||
const requiredFields = ['name', 'vpnIp', ...(isSodistorePro ? [] : ['device', 'installationModel'])];
|
const requiredFields = ['name', 'vpnIp', ...(isSodistorePro ? [] : ['installationModel'])];
|
||||||
|
|
||||||
const DeviceTypes = isSodistorePro
|
const DeviceTypes = isSodistorePro
|
||||||
? [{ id: 4, name: 'inesco 12K - WR Hybrid' }]
|
? [{ id: 4, name: 'inesco 12K - WR Hybrid' }]
|
||||||
|
|
@ -49,17 +49,11 @@ function SodistorehomeInstallationForm(props: SodistorehomeInstallationFormPros)
|
||||||
|
|
||||||
const handleChange = (e) => {
|
const handleChange = (e) => {
|
||||||
const { name, value } = e.target;
|
const { name, value } = e.target;
|
||||||
const updated = { ...formValues, [name]: value };
|
|
||||||
|
|
||||||
// Reset preset when device type changes if current preset is invalid
|
setFormValues({
|
||||||
if (name === 'device' && !isSodistorePro) {
|
...formValues,
|
||||||
const newDevicePresets = getPresetsForDevice(Number(value));
|
[name]: value
|
||||||
if (formValues.installationModel && !newDevicePresets[formValues.installationModel]) {
|
});
|
||||||
updated.installationModel = '';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
setFormValues(updated);
|
|
||||||
};
|
};
|
||||||
const handleSubmit = async (e) => {
|
const handleSubmit = async (e) => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
|
|
@ -173,48 +167,10 @@ function SodistorehomeInstallationForm(props: SodistorehomeInstallationFormPros)
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
|
||||||
{/* Device type must be selected before model — it determines available presets */}
|
|
||||||
<div>
|
<div>
|
||||||
<FormControl
|
<FormControl
|
||||||
fullWidth
|
fullWidth
|
||||||
required
|
required
|
||||||
sx={{
|
|
||||||
marginTop: 1,
|
|
||||||
marginBottom: 1,
|
|
||||||
width: 390
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<InputLabel
|
|
||||||
sx={{
|
|
||||||
fontSize: 14,
|
|
||||||
backgroundColor: 'white'
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<FormattedMessage
|
|
||||||
id="DeviceType"
|
|
||||||
defaultMessage="Device Type"
|
|
||||||
/>
|
|
||||||
</InputLabel>
|
|
||||||
<Select
|
|
||||||
name="device"
|
|
||||||
value={formValues.device ?? ''}
|
|
||||||
onChange={handleChange}
|
|
||||||
>
|
|
||||||
{DeviceTypes.map((device) => (
|
|
||||||
<MenuItem key={device.id} value={device.id}>
|
|
||||||
{device.name}
|
|
||||||
</MenuItem>
|
|
||||||
))}
|
|
||||||
</Select>
|
|
||||||
</FormControl>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<FormControl
|
|
||||||
fullWidth
|
|
||||||
required
|
|
||||||
disabled={!formValues.device}
|
|
||||||
error={formValues.installationModel === ''}
|
error={formValues.installationModel === ''}
|
||||||
sx={{
|
sx={{
|
||||||
marginTop: 1,
|
marginTop: 1,
|
||||||
|
|
@ -238,7 +194,7 @@ function SodistorehomeInstallationForm(props: SodistorehomeInstallationFormPros)
|
||||||
value={formValues.installationModel || ''}
|
value={formValues.installationModel || ''}
|
||||||
onChange={handleChange}
|
onChange={handleChange}
|
||||||
>
|
>
|
||||||
{Object.keys(getPresetsForDevice(formValues.device as number)).map((name) => (
|
{Object.keys(INSTALLATION_PRESETS).map((name) => (
|
||||||
<MenuItem key={name} value={name}>
|
<MenuItem key={name} value={name}>
|
||||||
{name}
|
{name}
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
|
|
@ -246,7 +202,42 @@ function SodistorehomeInstallationForm(props: SodistorehomeInstallationFormPros)
|
||||||
</Select>
|
</Select>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
</div>
|
</div>
|
||||||
</>
|
)}
|
||||||
|
|
||||||
|
{!isSodistorePro && (
|
||||||
|
<div>
|
||||||
|
<FormControl
|
||||||
|
fullWidth
|
||||||
|
sx={{
|
||||||
|
marginTop: 1,
|
||||||
|
marginBottom: 1,
|
||||||
|
width: 390
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<InputLabel
|
||||||
|
sx={{
|
||||||
|
fontSize: 14,
|
||||||
|
backgroundColor: 'white'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<FormattedMessage
|
||||||
|
id="DeviceType"
|
||||||
|
defaultMessage="Device Type"
|
||||||
|
/>
|
||||||
|
</InputLabel>
|
||||||
|
<Select
|
||||||
|
name="device"
|
||||||
|
value={formValues.device}
|
||||||
|
onChange={handleChange}
|
||||||
|
>
|
||||||
|
{DeviceTypes.map((device) => (
|
||||||
|
<MenuItem key={device.id} value={device.id}>
|
||||||
|
{device.name}
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
</Box>
|
</Box>
|
||||||
|
|
|
||||||
|
|
@ -52,8 +52,7 @@ function SodioHomeInstallationTabs(props: SodioHomeInstallationTabsProps) {
|
||||||
'history',
|
'history',
|
||||||
'configuration',
|
'configuration',
|
||||||
'report',
|
'report',
|
||||||
'installationTickets',
|
'installationTickets'
|
||||||
'documents'
|
|
||||||
];
|
];
|
||||||
|
|
||||||
const [currentTab, setCurrentTab] = useState<string>(undefined);
|
const [currentTab, setCurrentTab] = useState<string>(undefined);
|
||||||
|
|
@ -188,10 +187,6 @@ function SodioHomeInstallationTabs(props: SodioHomeInstallationTabsProps) {
|
||||||
{
|
{
|
||||||
value: 'installationTickets',
|
value: 'installationTickets',
|
||||||
label: <FormattedMessage id="tickets" defaultMessage="Tickets" />
|
label: <FormattedMessage id="tickets" defaultMessage="Tickets" />
|
||||||
},
|
|
||||||
{
|
|
||||||
value: 'documents',
|
|
||||||
label: <FormattedMessage id="documentsTab" defaultMessage="Documents" />
|
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
: currentUser.userType == UserType.partner
|
: currentUser.userType == UserType.partner
|
||||||
|
|
@ -231,10 +226,6 @@ function SodioHomeInstallationTabs(props: SodioHomeInstallationTabsProps) {
|
||||||
defaultMessage="Report"
|
defaultMessage="Report"
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
},
|
|
||||||
{
|
|
||||||
value: 'documents',
|
|
||||||
label: <FormattedMessage id="documentsTab" defaultMessage="Documents" />
|
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
: [
|
: [
|
||||||
|
|
@ -351,10 +342,6 @@ function SodioHomeInstallationTabs(props: SodioHomeInstallationTabsProps) {
|
||||||
{
|
{
|
||||||
value: 'installationTickets',
|
value: 'installationTickets',
|
||||||
label: <FormattedMessage id="tickets" defaultMessage="Tickets" />
|
label: <FormattedMessage id="tickets" defaultMessage="Tickets" />
|
||||||
},
|
|
||||||
{
|
|
||||||
value: 'documents',
|
|
||||||
label: <FormattedMessage id="documentsTab" defaultMessage="Documents" />
|
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
: inInstallationView && currentUser.userType == UserType.partner
|
: inInstallationView && currentUser.userType == UserType.partner
|
||||||
|
|
@ -402,10 +389,6 @@ function SodioHomeInstallationTabs(props: SodioHomeInstallationTabsProps) {
|
||||||
defaultMessage="Report"
|
defaultMessage="Report"
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
},
|
|
||||||
{
|
|
||||||
value: 'documents',
|
|
||||||
label: <FormattedMessage id="documentsTab" defaultMessage="Documents" />
|
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
: inInstallationView && currentUser.userType == UserType.client
|
: inInstallationView && currentUser.userType == UserType.client
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import React, { useRef, useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import {
|
import {
|
||||||
Avatar,
|
Avatar,
|
||||||
Box,
|
Box,
|
||||||
|
|
@ -6,19 +6,15 @@ import {
|
||||||
Card,
|
Card,
|
||||||
CardContent,
|
CardContent,
|
||||||
CardHeader,
|
CardHeader,
|
||||||
Chip,
|
|
||||||
Divider,
|
Divider,
|
||||||
LinearProgress,
|
|
||||||
TextField,
|
TextField,
|
||||||
Typography
|
Typography
|
||||||
} from '@mui/material';
|
} from '@mui/material';
|
||||||
import PersonIcon from '@mui/icons-material/Person';
|
import PersonIcon from '@mui/icons-material/Person';
|
||||||
import SmartToyIcon from '@mui/icons-material/SmartToy';
|
import SmartToyIcon from '@mui/icons-material/SmartToy';
|
||||||
import AttachFileIcon from '@mui/icons-material/AttachFile';
|
|
||||||
import { FormattedMessage } from 'react-intl';
|
import { FormattedMessage } from 'react-intl';
|
||||||
import axiosConfig from 'src/Resources/axiosConfig';
|
import axiosConfig from 'src/Resources/axiosConfig';
|
||||||
import { AdminUser, CommentAuthorType, TicketComment } from 'src/interfaces/TicketTypes';
|
import { AdminUser, CommentAuthorType, TicketComment } from 'src/interfaces/TicketTypes';
|
||||||
import DocumentList from 'src/components/DocumentList';
|
|
||||||
|
|
||||||
interface CommentThreadProps {
|
interface CommentThreadProps {
|
||||||
ticketId: number;
|
ticketId: number;
|
||||||
|
|
@ -35,67 +31,21 @@ function CommentThread({
|
||||||
}: CommentThreadProps) {
|
}: CommentThreadProps) {
|
||||||
const [body, setBody] = useState('');
|
const [body, setBody] = useState('');
|
||||||
const [submitting, setSubmitting] = useState(false);
|
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(
|
const sorted = [...comments].sort(
|
||||||
(a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime()
|
(a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime()
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleSubmit = async () => {
|
const handleSubmit = () => {
|
||||||
if (!body.trim() && selectedFiles.length === 0) return;
|
if (!body.trim()) return;
|
||||||
setSubmitting(true);
|
setSubmitting(true);
|
||||||
|
axiosConfig
|
||||||
try {
|
.post('/AddTicketComment', { ticketId, body })
|
||||||
let commentId: number | undefined;
|
.then(() => {
|
||||||
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('');
|
setBody('');
|
||||||
setSelectedFiles([]);
|
|
||||||
setRefreshKey((k) => k + 1);
|
|
||||||
onCommentAdded();
|
onCommentAdded();
|
||||||
} finally {
|
})
|
||||||
setSubmitting(false);
|
.finally(() => setSubmitting(false));
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -150,7 +100,6 @@ function CommentThread({
|
||||||
<Typography variant="body2" sx={{ whiteSpace: 'pre-wrap' }}>
|
<Typography variant="body2" sx={{ whiteSpace: 'pre-wrap' }}>
|
||||||
{comment.body}
|
{comment.body}
|
||||||
</Typography>
|
</Typography>
|
||||||
<DocumentList ticketCommentId={comment.id} refreshKey={refreshKey} />
|
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
|
|
@ -158,7 +107,6 @@ function CommentThread({
|
||||||
|
|
||||||
<Divider />
|
<Divider />
|
||||||
|
|
||||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
|
|
||||||
<Box sx={{ display: 'flex', gap: 1 }}>
|
<Box sx={{ display: 'flex', gap: 1 }}>
|
||||||
<TextField
|
<TextField
|
||||||
size="small"
|
size="small"
|
||||||
|
|
@ -170,46 +118,15 @@ function CommentThread({
|
||||||
value={body}
|
value={body}
|
||||||
onChange={(e) => setBody(e.target.value)}
|
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
|
<Button
|
||||||
variant="contained"
|
variant="contained"
|
||||||
onClick={handleSubmit}
|
onClick={handleSubmit}
|
||||||
disabled={submitting || uploading || (!body.trim() && selectedFiles.length === 0)}
|
disabled={submitting || !body.trim()}
|
||||||
|
sx={{ alignSelf: 'flex-end' }}
|
||||||
>
|
>
|
||||||
<FormattedMessage id="addComment" defaultMessage="Add" />
|
<FormattedMessage id="addComment" defaultMessage="Add" />
|
||||||
</Button>
|
</Button>
|
||||||
</Box>
|
</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>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,8 @@
|
||||||
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
import React, { useEffect, useMemo, useState } from 'react';
|
||||||
import {
|
import {
|
||||||
Alert,
|
Alert,
|
||||||
Autocomplete,
|
Autocomplete,
|
||||||
Box,
|
|
||||||
Button,
|
Button,
|
||||||
Chip,
|
|
||||||
CircularProgress,
|
CircularProgress,
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogActions,
|
DialogActions,
|
||||||
|
|
@ -12,13 +10,10 @@ import {
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
FormControl,
|
FormControl,
|
||||||
InputLabel,
|
InputLabel,
|
||||||
LinearProgress,
|
|
||||||
MenuItem,
|
MenuItem,
|
||||||
Select,
|
Select,
|
||||||
TextField,
|
TextField
|
||||||
Typography
|
|
||||||
} from '@mui/material';
|
} from '@mui/material';
|
||||||
import AttachFileIcon from '@mui/icons-material/AttachFile';
|
|
||||||
import { FormattedMessage } from 'react-intl';
|
import { FormattedMessage } from 'react-intl';
|
||||||
import axiosConfig from 'src/Resources/axiosConfig';
|
import axiosConfig from 'src/Resources/axiosConfig';
|
||||||
import {
|
import {
|
||||||
|
|
@ -81,38 +76,6 @@ function CreateTicketModal({ open, onClose, onCreated, defaultInstallationId }:
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
const [submitting, setSubmitting] = useState(false);
|
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
|
// Custom "Other" fields
|
||||||
const [customSubCategory, setCustomSubCategory] = useState('');
|
const [customSubCategory, setCustomSubCategory] = useState('');
|
||||||
const [customCategory, setCustomCategory] = useState('');
|
const [customCategory, setCustomCategory] = useState('');
|
||||||
|
|
@ -238,17 +201,16 @@ function CreateTicketModal({ open, onClose, onCreated, defaultInstallationId }:
|
||||||
setDescription('');
|
setDescription('');
|
||||||
setCustomSubCategory('');
|
setCustomSubCategory('');
|
||||||
setCustomCategory('');
|
setCustomCategory('');
|
||||||
setSelectedFiles([]);
|
|
||||||
setError('');
|
setError('');
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSubmit = async () => {
|
const handleSubmit = () => {
|
||||||
if (!subject.trim()) return;
|
if (!subject.trim()) return;
|
||||||
setSubmitting(true);
|
setSubmitting(true);
|
||||||
setError('');
|
setError('');
|
||||||
|
|
||||||
try {
|
axiosConfig
|
||||||
const res = await axiosConfig.post('/CreateTicket', {
|
.post('/CreateTicket', {
|
||||||
subject,
|
subject,
|
||||||
description,
|
description,
|
||||||
installationId: selectedInstallation?.id ?? null,
|
installationId: selectedInstallation?.id ?? null,
|
||||||
|
|
@ -257,40 +219,14 @@ function CreateTicketModal({ open, onClose, onCreated, defaultInstallationId }:
|
||||||
subCategory: isOtherCategory ? 0 : subCategory,
|
subCategory: isOtherCategory ? 0 : subCategory,
|
||||||
customSubCategory: (isOtherSubCategory || isOtherCategory) ? customSubCategory || null : null,
|
customSubCategory: (isOtherSubCategory || isOtherCategory) ? customSubCategory || null : null,
|
||||||
customCategory: isOtherCategory ? customCategory || null : null
|
customCategory: isOtherCategory ? customCategory || null : null
|
||||||
});
|
})
|
||||||
|
.then(() => {
|
||||||
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();
|
resetForm();
|
||||||
onCreated();
|
onCreated();
|
||||||
onClose();
|
onClose();
|
||||||
} catch {
|
})
|
||||||
setError('Failed to create ticket.');
|
.catch(() => setError('Failed to create ticket.'))
|
||||||
} finally {
|
.finally(() => setSubmitting(false));
|
||||||
setSubmitting(false);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const availableSubCategories = subCategoriesByCategory[category] ?? [];
|
const availableSubCategories = subCategoriesByCategory[category] ?? [];
|
||||||
|
|
@ -506,44 +442,6 @@ function CreateTicketModal({ open, onClose, onCreated, defaultInstallationId }:
|
||||||
fullWidth
|
fullWidth
|
||||||
margin="dense"
|
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>
|
</DialogContent>
|
||||||
<DialogActions>
|
<DialogActions>
|
||||||
<Button onClick={onClose}>
|
<Button onClick={onClose}>
|
||||||
|
|
|
||||||
|
|
@ -50,8 +50,6 @@ import StatusChip from './StatusChip';
|
||||||
import AiDiagnosisPanel from './AiDiagnosisPanel';
|
import AiDiagnosisPanel from './AiDiagnosisPanel';
|
||||||
import CommentThread from './CommentThread';
|
import CommentThread from './CommentThread';
|
||||||
import TimelinePanel from './TimelinePanel';
|
import TimelinePanel from './TimelinePanel';
|
||||||
import FileUploadButton from 'src/components/FileUploadButton';
|
|
||||||
import DocumentList from 'src/components/DocumentList';
|
|
||||||
|
|
||||||
const priorityKeys: Record<number, { id: string; defaultMessage: string }> = {
|
const priorityKeys: Record<number, { id: string; defaultMessage: string }> = {
|
||||||
[TicketPriority.Critical]: { id: 'priorityCritical', defaultMessage: 'Critical' },
|
[TicketPriority.Critical]: { id: 'priorityCritical', defaultMessage: 'Critical' },
|
||||||
|
|
@ -89,7 +87,6 @@ function TicketDetailPage() {
|
||||||
const [editingDescription, setEditingDescription] = useState(false);
|
const [editingDescription, setEditingDescription] = useState(false);
|
||||||
const [savingDescription, setSavingDescription] = useState(false);
|
const [savingDescription, setSavingDescription] = useState(false);
|
||||||
const [descriptionSaved, setDescriptionSaved] = useState(false);
|
const [descriptionSaved, setDescriptionSaved] = useState(false);
|
||||||
const [docRefreshKey, setDocRefreshKey] = useState(0);
|
|
||||||
|
|
||||||
// Custom "Other" editing state
|
// Custom "Other" editing state
|
||||||
const [editCustomSub, setEditCustomSub] = useState('');
|
const [editCustomSub, setEditCustomSub] = useState('');
|
||||||
|
|
@ -384,21 +381,6 @@ function TicketDetailPage() {
|
||||||
)}
|
)}
|
||||||
</Typography>
|
</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>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -30,7 +30,6 @@ export interface overviewInterface {
|
||||||
overview: chartInfoInterface;
|
overview: chartInfoInterface;
|
||||||
ACLoad: chartInfoInterface;
|
ACLoad: chartInfoInterface;
|
||||||
DCLoad: chartInfoInterface;
|
DCLoad: chartInfoInterface;
|
||||||
batteryVoltage: chartInfoInterface;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface chartAggregatedDataInterface {
|
export interface chartAggregatedDataInterface {
|
||||||
|
|
@ -54,7 +53,6 @@ export interface chartDataInterface {
|
||||||
dcBusVoltage: { name: string; data: number[] };
|
dcBusVoltage: { name: string; data: number[] };
|
||||||
ACLoad: { name: string; data: number[] };
|
ACLoad: { name: string; data: number[] };
|
||||||
DCLoad: { name: string; data: number[] };
|
DCLoad: { name: string; data: number[] };
|
||||||
batteryVoltage: { name: string; data: number[] };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface BatteryDataInterface {
|
export interface BatteryDataInterface {
|
||||||
|
|
@ -430,8 +428,7 @@ export const transformInputToDailyDataJson = async (
|
||||||
'SODIOHOME_PV_POWER',
|
'SODIOHOME_PV_POWER',
|
||||||
null, // dcBusVoltage not available for SodioHome
|
null, // dcBusVoltage not available for SodioHome
|
||||||
'SODIOHOME_CONSUMPTION',
|
'SODIOHOME_CONSUMPTION',
|
||||||
null, // DCLoad not available for SodioHome
|
null // DCLoad not available for SodioHome
|
||||||
'SODIOHOME_BATTERY_VOLTAGE'
|
|
||||||
]
|
]
|
||||||
: [
|
: [
|
||||||
'Battery.Soc',
|
'Battery.Soc',
|
||||||
|
|
@ -441,8 +438,7 @@ export const transformInputToDailyDataJson = async (
|
||||||
'PvOnDc',
|
'PvOnDc',
|
||||||
'DcDc.Dc.Link.Voltage',
|
'DcDc.Dc.Link.Voltage',
|
||||||
'LoadOnAcGrid.Power.Active',
|
'LoadOnAcGrid.Power.Active',
|
||||||
'LoadOnDc.Power',
|
'LoadOnDc.Power'
|
||||||
null // batteryVoltage not available for Salimax
|
|
||||||
];
|
];
|
||||||
const categories = [
|
const categories = [
|
||||||
'soc',
|
'soc',
|
||||||
|
|
@ -452,8 +448,7 @@ export const transformInputToDailyDataJson = async (
|
||||||
'pvProduction',
|
'pvProduction',
|
||||||
'dcBusVoltage',
|
'dcBusVoltage',
|
||||||
'ACLoad',
|
'ACLoad',
|
||||||
'DCLoad',
|
'DCLoad'
|
||||||
'batteryVoltage'
|
|
||||||
];
|
];
|
||||||
|
|
||||||
const chartData: chartDataInterface = {
|
const chartData: chartDataInterface = {
|
||||||
|
|
@ -464,8 +459,7 @@ export const transformInputToDailyDataJson = async (
|
||||||
pvProduction: { name: 'PV Power', data: [] },
|
pvProduction: { name: 'PV Power', data: [] },
|
||||||
dcBusVoltage: { name: 'DC Bus Voltage', data: [] },
|
dcBusVoltage: { name: 'DC Bus Voltage', data: [] },
|
||||||
ACLoad: { name: 'AC Load', data: [] },
|
ACLoad: { name: 'AC Load', data: [] },
|
||||||
DCLoad: { name: 'DC Load', data: [] },
|
DCLoad: { name: 'DC Load', data: [] }
|
||||||
batteryVoltage: { name: 'Battery Voltage', data: [] }
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const chartOverview: overviewInterface = {
|
const chartOverview: overviewInterface = {
|
||||||
|
|
@ -478,8 +472,7 @@ export const transformInputToDailyDataJson = async (
|
||||||
dcBusVoltage: { magnitude: 0, unit: '', min: 0, max: 0 },
|
dcBusVoltage: { magnitude: 0, unit: '', min: 0, max: 0 },
|
||||||
overview: { magnitude: 0, unit: '', min: 0, max: 0 },
|
overview: { magnitude: 0, unit: '', min: 0, max: 0 },
|
||||||
ACLoad: { magnitude: 0, unit: '', min: 0, max: 0 },
|
ACLoad: { magnitude: 0, unit: '', min: 0, max: 0 },
|
||||||
DCLoad: { magnitude: 0, unit: '', min: 0, max: 0 },
|
DCLoad: { magnitude: 0, unit: '', min: 0, max: 0 }
|
||||||
batteryVoltage: { magnitude: 0, unit: '', min: 0, max: 0 }
|
|
||||||
};
|
};
|
||||||
|
|
||||||
categories.forEach((category) => {
|
categories.forEach((category) => {
|
||||||
|
|
@ -573,9 +566,6 @@ export const transformInputToDailyDataJson = async (
|
||||||
case 6: // consumption
|
case 6: // consumption
|
||||||
value = inv.TotalLoadPower ?? inv.ConsumptionPower;
|
value = inv.TotalLoadPower ?? inv.ConsumptionPower;
|
||||||
break;
|
break;
|
||||||
case 8: // battery voltage
|
|
||||||
value = inv.AvgBatteryVoltage ?? inv.Battery1Voltage;
|
|
||||||
break;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else if (category_index === 4) {
|
} else if (category_index === 4) {
|
||||||
|
|
@ -646,7 +636,6 @@ export const transformInputToDailyDataJson = async (
|
||||||
'(' + prefixes[chartOverview['ACLoad'].magnitude] + 'W' + ')';
|
'(' + prefixes[chartOverview['ACLoad'].magnitude] + 'W' + ')';
|
||||||
chartOverview.DCLoad.unit =
|
chartOverview.DCLoad.unit =
|
||||||
'(' + prefixes[chartOverview['DCLoad'].magnitude] + 'W' + ')';
|
'(' + prefixes[chartOverview['DCLoad'].magnitude] + 'W' + ')';
|
||||||
chartOverview.batteryVoltage.unit = '(V)';
|
|
||||||
|
|
||||||
chartOverview.overview = {
|
chartOverview.overview = {
|
||||||
magnitude: Math.max(
|
magnitude: Math.max(
|
||||||
|
|
@ -761,8 +750,7 @@ export const transformInputToAggregatedDataJson = async (
|
||||||
dcBusVoltage: { magnitude: 0, unit: '', min: 0, max: 0 },
|
dcBusVoltage: { magnitude: 0, unit: '', min: 0, max: 0 },
|
||||||
overview: { magnitude: 0, unit: '', min: 0, max: 0 },
|
overview: { magnitude: 0, unit: '', min: 0, max: 0 },
|
||||||
ACLoad: { magnitude: 0, unit: '', min: 0, max: 0 },
|
ACLoad: { magnitude: 0, unit: '', min: 0, max: 0 },
|
||||||
DCLoad: { magnitude: 0, unit: '', min: 0, max: 0 },
|
DCLoad: { magnitude: 0, unit: '', min: 0, max: 0 }
|
||||||
batteryVoltage: { magnitude: 0, unit: '', min: 0, max: 0 }
|
|
||||||
};
|
};
|
||||||
|
|
||||||
pathsToSearch.forEach((path) => {
|
pathsToSearch.forEach((path) => {
|
||||||
|
|
|
||||||
|
|
@ -664,17 +664,5 @@
|
||||||
"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_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",
|
"privacy_close_button": "Schliessen",
|
||||||
"sodistorepro": "Sodistore Pro",
|
"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,17 +412,5 @@
|
||||||
"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_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",
|
"privacy_close_button": "Close",
|
||||||
"sodistorepro": "Sodistore Pro",
|
"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,17 +664,5 @@
|
||||||
"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_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",
|
"privacy_close_button": "Fermer",
|
||||||
"sodistorepro": "Sodistore Pro",
|
"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,17 +664,5 @@
|
||||||
"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_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",
|
"privacy_close_button": "Chiudi",
|
||||||
"sodistorepro": "Sodistore Pro",
|
"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