diff --git a/csharp/App/Backend/Controller.cs b/csharp/App/Backend/Controller.cs index e976e3a48..9b027c78d 100644 --- a/csharp/App/Backend/Controller.cs +++ b/csharp/App/Backend/Controller.cs @@ -2398,6 +2398,40 @@ public class Controller : ControllerBase return comment; } + public class UpdateTicketCommentRequest + { + public Int64 Id { get; set; } + public String Body { get; set; } = ""; + } + + [HttpPost(nameof(UpdateTicketComment))] + public ActionResult UpdateTicketComment([FromBody] UpdateTicketCommentRequest req, Token authToken) + { + var user = Db.GetSession(authToken)?.User; + if (user is null || user.UserType != 2) return Unauthorized(); + + var comment = Db.TicketComments.FirstOrDefault(c => c.Id == req.Id); + if (comment is null) return NotFound(); + + if (comment.AuthorType != (Int32)CommentAuthorType.Human) return Unauthorized(); + if (comment.AuthorId != user.Id) return Unauthorized(); + + if (String.IsNullOrWhiteSpace(req.Body)) return BadRequest("Body required."); + + comment.Body = req.Body; + comment.EditedAt = DateTime.UtcNow; + if (!Db.Update(comment)) return StatusCode(500, "Failed to update comment."); + + var ticket = Db.GetTicketById(comment.TicketId); + if (ticket is not null) + { + ticket.UpdatedAt = DateTime.UtcNow; + Db.Update(ticket); + } + + return comment; + } + [HttpGet(nameof(GetTicketDetail))] public ActionResult GetTicketDetail(Int64 id, Token authToken) { diff --git a/csharp/App/Backend/DataTypes/Methods/Installation.cs b/csharp/App/Backend/DataTypes/Methods/Installation.cs index 074b62638..fd01696cc 100644 --- a/csharp/App/Backend/DataTypes/Methods/Installation.cs +++ b/csharp/App/Backend/DataTypes/Methods/Installation.cs @@ -1,5 +1,6 @@ using InnovEnergy.App.Backend.Database; using InnovEnergy.App.Backend.Relations; +using InnovEnergy.Lib.Mailer; using InnovEnergy.Lib.Utils; namespace InnovEnergy.App.Backend.DataTypes.Methods; @@ -171,5 +172,39 @@ public static class InstallationMethods return true; } - + + private const String SupportEmail = "support@inesco.energy"; + private const String SupportName = "inesco energy Support Team"; + + public static Task SendAlarmNotificationToSupport(this Installation installation, Int32 prevStatus) + { + var productName = ProductName(installation.Product); + var fromStatus = StatusName(prevStatus); + + var subject = $"[inesco energy] Alarm: {installation.Name}"; + var body = + $"Installation \"{installation.Name}\" (ID {installation.Id}, {productName})\n" + + $"status changed from {fromStatus} to Alarm.\n\n" + + "Please check the Log tab on the Monitor to see detailed errors and warnings.\n"; + + return Mailer.Send(SupportName, SupportEmail, subject, body); + } + + private static String StatusName(Int32 status) => status switch + { + -1 => "Offline", + 0 => "Green", + 1 => "Warning", + 2 => "Alarm", + _ => "Unknown" + }; + + private static String ProductName(Int32 product) => product switch + { + 2 => "Sodistore Home", + 3 => "Sodistore Max", + 4 => "Sodistore Grid", + 5 => "Sodistore Pro", + _ => $"Product {product}" + }; } \ No newline at end of file diff --git a/csharp/App/Backend/DataTypes/TicketComment.cs b/csharp/App/Backend/DataTypes/TicketComment.cs index df0df19a6..6f08d97e5 100644 --- a/csharp/App/Backend/DataTypes/TicketComment.cs +++ b/csharp/App/Backend/DataTypes/TicketComment.cs @@ -13,6 +13,7 @@ public class TicketComment public Int64? AuthorId { get; set; } public String Body { get; set; } = ""; public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + public DateTime? EditedAt { get; set; } [Ignore] public List MentionedUserIds { get; set; } = new(); } diff --git a/csharp/App/Backend/Database/Update.cs b/csharp/App/Backend/Database/Update.cs index 0c694358e..0e5e7fc59 100644 --- a/csharp/App/Backend/Database/Update.cs +++ b/csharp/App/Backend/Database/Update.cs @@ -72,6 +72,7 @@ public static partial class Db // Ticket system public static Boolean Update(Ticket ticket) => Update(obj: ticket); public static Boolean Update(TicketAiDiagnosis diagnosis) => Update(obj: diagnosis); + public static Boolean Update(TicketComment comment) => Update(obj: comment); // Checklist public static Boolean Update(ChecklistItem item) => Update(obj: item); diff --git a/csharp/App/Backend/Websockets/RabbitMQManager.cs b/csharp/App/Backend/Websockets/RabbitMQManager.cs index 5d4328224..39f67a6e4 100644 --- a/csharp/App/Backend/Websockets/RabbitMQManager.cs +++ b/csharp/App/Backend/Websockets/RabbitMQManager.cs @@ -2,6 +2,7 @@ using System.Text; using System.Text.Json; using InnovEnergy.App.Backend.Database; using InnovEnergy.App.Backend.DataTypes; +using InnovEnergy.App.Backend.DataTypes.Methods; using InnovEnergy.Lib.Utils; using RabbitMQ.Client; using RabbitMQ.Client.Events; @@ -186,6 +187,20 @@ public static class RabbitMqManager Db.UpdateInstallationStatus(installationId, receivedStatusMessage.Status); + const int AlarmStatus = 2; + var isSodistore = installation.Product is 2 or 3 or 4 or 5; + if (isSodistore + && prevStatus != AlarmStatus + && receivedStatusMessage.Status == AlarmStatus) + { + var prev = prevStatus; + _ = Task.Run(async () => + { + try { await installation.SendAlarmNotificationToSupport(prev); } + catch (Exception ex) { Console.WriteLine($"[AlarmNotify] failed for {installationId}: {ex.Message}"); } + }); + } + //Console.WriteLine("----------------------------------------------"); //If the status has changed, update all the connected front-ends regarding this installation if(prevStatus != receivedStatusMessage.Status && WebsocketManager.InstallationConnections[installationId].Connections.Count > 0) diff --git a/typescript/frontend-marios2/src/content/dashboards/BatteryView/BatteryViewSodioHome.tsx b/typescript/frontend-marios2/src/content/dashboards/BatteryView/BatteryViewSodioHome.tsx index 4283a8bd6..9d6d74cd9 100644 --- a/typescript/frontend-marios2/src/content/dashboards/BatteryView/BatteryViewSodioHome.tsx +++ b/typescript/frontend-marios2/src/content/dashboards/BatteryView/BatteryViewSodioHome.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useState } from 'react'; +import React, { useEffect, useMemo, useState } from 'react'; import { Container, Grid, @@ -14,12 +14,16 @@ import { import { JSONRecordData } from '../Log/graph.util'; import { Link, Route, Routes, useLocation, useNavigate } from 'react-router-dom'; import Button from '@mui/material/Button'; -import { FormattedMessage } from 'react-intl'; +import { FormattedMessage, useIntl } from 'react-intl'; import { I_S3Credentials } from '../../../interfaces/S3Types'; import routes from '../../../Resources/routes.json'; import CircularProgress from '@mui/material/CircularProgress'; import { I_Installation } from 'src/interfaces/InstallationTypes'; import MainStatsSodioHome from './MainStatsSodioHome'; +import { + ActiveCluster, + getActiveClusters +} from '../Information/installationSetupUtils'; interface BatteryViewSodioHomeProps { values: JSONRecordData; @@ -36,42 +40,78 @@ function BatteryViewSodioHome(props: BatteryViewSodioHomeProps) { const currentLocation = useLocation(); const navigate = useNavigate(); + const intl = useIntl(); const inverter = (props.values as any)?.InverterRecord; const batteryClusterNumber = props.installation.batteryClusterNumber; + const batterySerialNumbers = props.installation.batterySerialNumbers; const hasDevices = !!inverter?.Devices; - const sortedBatteryView = inverter - ? Array.from({ length: batteryClusterNumber }, (_, i) => { - if (hasDevices) { - // Sinexcel: map across devices — 0→D1/B1, 1→D1/B2, 2→D2/B1, 3→D2/B2 - const deviceId = String(Math.floor(i / 2) + 1); - const batteryIndex = (i % 2) + 1; - const device = inverter.Devices[deviceId]; + const activeClusters: ActiveCluster[] = useMemo(() => { + const parsed = getActiveClusters(batterySerialNumbers || ''); + if (parsed.length > 0) return parsed; + // Legacy/empty fallback: assume 2 clusters per inverter (all current Sinexcel + // presets), which matches the previous floor(i/2)+1 / (i%2)+1 mapping. + // For Growatt (batteryClusterNumber = 1) this collapses to a single row. + return Array.from({ length: batteryClusterNumber }, (_, i) => ({ + invIdx: Math.floor(i / 2), + clIdx: i % 2, + flatIdx: i + })); + }, [batterySerialNumbers, batteryClusterNumber]); + const inverterCount = activeClusters.reduce( + (max, c) => Math.max(max, c.invIdx + 1), + 0 + ); + const showInverterLabel = hasDevices && inverterCount > 1; + + const sortedBatteryView = inverter + ? activeClusters.map(({ invIdx, clIdx, flatIdx }) => { + const label = showInverterLabel + ? intl.formatMessage( + { + id: 'batteryClusterInInverter', + defaultMessage: 'Battery Cluster {cl} in Inverter {inv}' + }, + { cl: clIdx + 1, inv: invIdx + 1 } + ) + : intl.formatMessage( + { id: 'batteryClusterN', defaultMessage: 'Battery Cluster {n}' }, + { n: clIdx + 1 } + ); + + if (hasDevices) { + // Sinexcel: Devices keyed by "1","2",... (1-based dict keys) + const device = inverter.Devices[String(invIdx + 1)]; + const bi = clIdx + 1; + return { + BatteryId: String(flatIdx + 1), + label, + battery: { + Voltage: device?.[`Battery${bi}PackTotalVoltage`] ?? 0, + Current: device?.[`Battery${bi}PackTotalCurrent`] ?? 0, + Power: device?.[`Battery${bi}Power`] ?? 0, + Soc: + device?.[`Battery${bi}Soc`] ?? + device?.[`Battery${bi}SocSecondvalue`] ?? + 0 + } + }; + } + // Growatt: flat Battery1, Battery2, ... on InverterRecord + const index = clIdx + 1; return { - BatteryId: String(i + 1), - battery: { - Voltage: device?.[`Battery${batteryIndex}PackTotalVoltage`] ?? 0, - Current: device?.[`Battery${batteryIndex}PackTotalCurrent`] ?? 0, - Power: device?.[`Battery${batteryIndex}Power`] ?? 0, - Soc: device?.[`Battery${batteryIndex}Soc`] ?? device?.[`Battery${batteryIndex}SocSecondvalue`] ?? 0, - } - }; - } else { - // Growatt: flat Battery1, Battery2, ... - const index = i + 1; - return { - BatteryId: String(index), + BatteryId: String(flatIdx + 1), + label, battery: { Voltage: inverter[`Battery${index}Voltage`] ?? 0, Current: inverter[`Battery${index}Current`] ?? 0, Power: inverter[`Battery${index}Power`] ?? 0, - Soc: inverter[`Battery${index}Soc`] ?? 0, + Soc: inverter[`Battery${index}Soc`] ?? 0 } }; - } - }) + }) : []; const [loading, setLoading] = useState(sortedBatteryView.length == 0); @@ -193,6 +233,8 @@ function BatteryViewSodioHome(props: BatteryViewSodioHomeProps) { s3Credentials={props.s3Credentials} id={props.installationId} batteryClusterNumber={props.installation.batteryClusterNumber} + activeClusters={activeClusters} + showInverterLabel={showInverterLabel} > } /> @@ -225,7 +267,7 @@ function BatteryViewSodioHome(props: BatteryViewSodioHomeProps) { - {sortedBatteryView.map(({ BatteryId, battery }) => ( + {sortedBatteryView.map(({ BatteryId, label, battery }) => ( */} - {'Battery Cluster ' + BatteryId} + {label} {/**/} 0) { + props.activeClusters.forEach((c) => { + pathsToSearch.push('Node' + c.flatIdx); + }); + } else { + for (let i = 0; i < props.batteryClusterNumber; i++) { + pathsToSearch.push('Node' + i); + } } const total = pathsToSearch.length; @@ -207,7 +217,8 @@ function MainStatsSodioHome(props: MainStatsSodioHomeProps) { 2, UnixTime.fromTicks(startDate.unix()), UnixTime.fromTicks(endDate.unix()), - props.batteryClusterNumber + props.batteryClusterNumber, + props.activeClusters ); resultPromise @@ -270,7 +281,8 @@ function MainStatsSodioHome(props: MainStatsSodioHomeProps) { 2, UnixTime.fromTicks(startX).earlier(TimeSpan.fromHours(2)), UnixTime.fromTicks(endX).earlier(TimeSpan.fromHours(2)), - props.batteryClusterNumber + props.batteryClusterNumber, + props.activeClusters ); resultPromise diff --git a/typescript/frontend-marios2/src/content/dashboards/Information/installationSetupUtils.ts b/typescript/frontend-marios2/src/content/dashboards/Information/installationSetupUtils.ts index 2756f3d97..e5e65e0ec 100644 --- a/typescript/frontend-marios2/src/content/dashboards/Information/installationSetupUtils.ts +++ b/typescript/frontend-marios2/src/content/dashboards/Information/installationSetupUtils.ts @@ -115,6 +115,32 @@ export const computeFlatValues = ( }; }; +export interface ActiveCluster { + invIdx: number; + clIdx: number; + flatIdx: number; +} + +export const getActiveClusters = (raw: string): ActiveCluster[] => { + if (!raw || raw.trim() === '') return []; + if (!raw.includes('/') && !raw.includes('|')) return []; + + const result: ActiveCluster[] = []; + let flatIdx = 0; + raw.split('/').forEach((invStr, invIdx) => { + invStr.split('|').forEach((clStr, clIdx) => { + const hasSn = clStr + .split(',') + .some((s) => s.trim() !== ''); + if (hasSn) { + result.push({ invIdx, clIdx, flatIdx }); + } + flatIdx += 1; + }); + }); + return result; +}; + export const wouldLoseData = ( oldTree: BatterySnTree, newPreset: PresetConfig diff --git a/typescript/frontend-marios2/src/content/dashboards/Tickets/CommentFormatToolbar.tsx b/typescript/frontend-marios2/src/content/dashboards/Tickets/CommentFormatToolbar.tsx new file mode 100644 index 000000000..af1b62fa7 --- /dev/null +++ b/typescript/frontend-marios2/src/content/dashboards/Tickets/CommentFormatToolbar.tsx @@ -0,0 +1,62 @@ +import React from 'react'; +import { Box, Button, Tooltip } from '@mui/material'; +import FormatBoldIcon from '@mui/icons-material/FormatBold'; +import { useIntl } from 'react-intl'; +import { applyFormat, FormatKind } from './commentMarkdown'; + +interface CommentFormatToolbarProps { + textareaRef: React.RefObject; + value: string; + onChange: (next: string) => void; + disabled?: boolean; +} + +function CommentFormatToolbar({ + textareaRef, + value, + onChange, + disabled +}: CommentFormatToolbarProps) { + const intl = useIntl(); + + const handle = (kind: FormatKind) => () => { + applyFormat(textareaRef.current, value, kind, onChange); + }; + + const btnSx = { minWidth: 32, px: 1, py: 0.25, fontSize: 12, textTransform: 'none' as const }; + + return ( + + + + + + + + + + + + + + + + + + + + + + + ); +} + +export default CommentFormatToolbar; diff --git a/typescript/frontend-marios2/src/content/dashboards/Tickets/CommentThread.tsx b/typescript/frontend-marios2/src/content/dashboards/Tickets/CommentThread.tsx index 6bc3ccb64..8802dc883 100644 --- a/typescript/frontend-marios2/src/content/dashboards/Tickets/CommentThread.tsx +++ b/typescript/frontend-marios2/src/content/dashboards/Tickets/CommentThread.tsx @@ -1,4 +1,5 @@ -import React, { useRef, useState } from 'react'; +import React, { useContext, useRef, useState } from 'react'; +import { UserContext } from 'src/contexts/userContext'; import { Avatar, Box, @@ -24,6 +25,8 @@ import { FormattedMessage, useIntl } from 'react-intl'; import axiosConfig from 'src/Resources/axiosConfig'; import { AdminUser, CommentAuthorType, TicketComment } from 'src/interfaces/TicketTypes'; import DocumentList from 'src/components/DocumentList'; +import CommentFormatToolbar from './CommentFormatToolbar'; +import { renderCommentBody } from './commentMarkdown'; interface CommentThreadProps { ticketId: number; @@ -39,8 +42,13 @@ function CommentThread({ adminUsers = [] }: CommentThreadProps) { const intl = useIntl(); + const userCtx = useContext(UserContext); + const currentUserId = userCtx?.currentUser?.id; const [body, setBody] = useState(''); const [submitting, setSubmitting] = useState(false); + const [editingId, setEditingId] = useState(null); + const [editBody, setEditBody] = useState(''); + const [savingEdit, setSavingEdit] = useState(false); const fileInputRef = useRef(null); const [selectedFiles, setSelectedFiles] = useState([]); const [uploading, setUploading] = useState(false); @@ -49,6 +57,7 @@ function CommentThread({ const [mentionedIds, setMentionedIds] = useState([]); const [mentionQuery, setMentionQuery] = useState(null); const commentInputRef = useRef(null); + const editInputRef = useRef(null); const MENTION_EXCLUDED_NAMES = ['inesco energy Master Admin']; @@ -125,6 +134,32 @@ function CommentThread({ (a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime() ); + const startEdit = (comment: TicketComment) => { + setEditingId(comment.id); + setEditBody(comment.body); + }; + + const cancelEdit = () => { + setEditingId(null); + setEditBody(''); + }; + + const saveEdit = async (commentId: number) => { + if (!editBody.trim()) return; + setSavingEdit(true); + try { + await axiosConfig.post('/UpdateTicketComment', { + id: commentId, + body: editBody + }); + setEditingId(null); + setEditBody(''); + onCommentAdded(); + } finally { + setSavingEdit(false); + } + }; + const handleSubmit = async () => { if (!body.trim() && selectedFiles.length === 0) return; setSubmitting(true); @@ -196,6 +231,8 @@ function CommentThread({ {sorted.map((comment) => { const isAi = comment.authorType === CommentAuthorType.AiAgent; + const canEdit = !isAi && currentUserId != null && comment.authorId === currentUserId; + const isEditing = editingId === comment.id; return ( {isAi ? 'AI' : (adminUsers.find(u => u.id === comment.authorId)?.name ?? `User #${comment.authorId ?? '?'}`)} @@ -224,10 +261,64 @@ function CommentThread({ {new Date(comment.createdAt).toLocaleString()} + {comment.editedAt && ( + + + + )} + {canEdit && !isEditing && ( + + )} - - {comment.body} - + {isEditing ? ( + + + setEditBody(e.target.value)} + inputRef={editInputRef} + /> + + + + + + ) : ( + renderCommentBody(comment.body) + )} @@ -237,6 +328,20 @@ function CommentThread({ + + + + + + + i % 2 === 1 ? {p} : {p} + ); +} + +export function renderCommentBody(body: string): JSX.Element { + const lines = body.split('\n'); + return ( + *': { mb: 0.5 } }}> + {lines.map((line, idx) => { + if (line.startsWith('### ')) { + return ( + + {renderInline(line.slice(4))} + + ); + } + if (line.startsWith('## ')) { + return ( + + {renderInline(line.slice(3))} + + ); + } + if (line.startsWith('# ')) { + return ( + + {renderInline(line.slice(2))} + + ); + } + return ( + + {line ? renderInline(line) : '\u00A0'} + + ); + })} + + ); +} + +export function applyFormat( + el: HTMLTextAreaElement | HTMLInputElement | null, + value: string, + kind: FormatKind, + onChange: (next: string) => void +): void { + const start = el?.selectionStart ?? value.length; + const end = el?.selectionEnd ?? value.length; + + if (kind === 'bold') { + const selected = value.slice(start, end); + const wrapped = `**${selected}**`; + const next = value.slice(0, start) + wrapped + value.slice(end); + onChange(next); + const caret = selected.length > 0 ? start + wrapped.length : start + 2; + requestAnimationFrame(() => { + el?.focus(); + el?.setSelectionRange(caret, caret); + }); + return; + } + + const prefix = kind === 'h1' ? '# ' : kind === 'h2' ? '## ' : '### '; + const lineStart = value.lastIndexOf('\n', start - 1) + 1; + const nlAfter = value.indexOf('\n', start); + const lineEnd = nlAfter === -1 ? value.length : nlAfter; + const line = value.slice(lineStart, lineEnd); + const stripped = line.replace(/^#{1,3}\s/, ''); + const newLine = prefix + stripped; + const next = value.slice(0, lineStart) + newLine + value.slice(lineEnd); + onChange(next); + const caret = lineStart + newLine.length; + requestAnimationFrame(() => { + el?.focus(); + el?.setSelectionRange(caret, caret); + }); +} diff --git a/typescript/frontend-marios2/src/interfaces/Chart.tsx b/typescript/frontend-marios2/src/interfaces/Chart.tsx index 3b9a8cd2a..012d45d0d 100644 --- a/typescript/frontend-marios2/src/interfaces/Chart.tsx +++ b/typescript/frontend-marios2/src/interfaces/Chart.tsx @@ -81,11 +81,13 @@ export const transformInputToBatteryViewDataJson = async ( product: number, start_time?: UnixTime, end_time?: UnixTime, - batteryClusterNumber?: number + batteryClusterNumber?: number, + activeClusters?: Array<{ invIdx: number; clIdx: number; flatIdx: number }> ): Promise<{ chartData: BatteryDataInterface; chartOverview: BatteryOverviewInterface; }> => { + const useActive = !!activeClusters && activeClusters.length > 0; const prefixes = ['', 'k', 'M', 'G', 'T']; const MAX_NUMBER = 9999999; const isSodioHome = product === 2 || product === 5; @@ -199,17 +201,34 @@ export const transformInputToBatteryViewDataJson = async ( const inv = (result as any)?.InverterRecord; if (!inv) continue; - const numBatteries = batteryClusterNumber || 1; + // Iteration order: either active-cluster list (skips empty slots, + // preserves flat hardware indices) or a contiguous 0..N-1 fallback. + const iter = useActive + ? activeClusters!.map((c) => ({ + flatIdx: c.flatIdx, + invIdx: c.invIdx, + clIdx: c.clIdx + })) + : Array.from({ length: batteryClusterNumber || 1 }, (_, k) => ({ + flatIdx: k, + invIdx: Math.floor(k / 2), + clIdx: k % 2 + })); + + const inverterCount = iter.reduce( + (max, c) => Math.max(max, c.invIdx + 1), + 0 + ); + const showInverterLabel = !!inv?.Devices && inverterCount > 1; + let old_length = pathsToSave.length; - if (numBatteries > old_length) { - for (let b = old_length; b < numBatteries; b++) { - const nodeName = 'Node' + b; - if (!pathsToSave.includes(nodeName)) { - pathsToSave.push(nodeName); - } + iter.forEach((c) => { + const nodeName = 'Node' + c.flatIdx; + if (!pathsToSave.includes(nodeName)) { + pathsToSave.push(nodeName); } - } + }); if (initialiation) { initialiation = false; @@ -224,12 +243,15 @@ export const transformInputToBatteryViewDataJson = async ( }); } - if (numBatteries > old_length) { + if (pathsToSave.length > old_length) { categories.forEach((category) => { - pathsToSave.forEach((path) => { + iter.forEach((c) => { + const path = 'Node' + c.flatIdx; if (pathsToSave.indexOf(path) >= old_length) { - const displayIndex = pathsToSave.indexOf(path); - chartData[category].data[path] = { name: 'Battery Cluster ' + (displayIndex + 1), data: [] }; + const name = showInverterLabel + ? `Battery Cluster ${c.clIdx + 1} in Inverter ${c.invIdx + 1}` + : `Battery Cluster ${c.clIdx + 1}`; + chartData[category].data[path] = { name, data: [] }; } }); }); @@ -253,24 +275,23 @@ export const transformInputToBatteryViewDataJson = async ( Soh: 'Soh' }; - for (let j = 0; j < pathsToSave.length; j++) { + iter.forEach((c) => { + const path = 'Node' + c.flatIdx; categories.forEach((category) => { let value: number | undefined; if (hasDevices) { - // Sinexcel: nested under Devices — 0→D1/B1, 1→D1/B2, 2→D2/B1, ... - const deviceId = String(Math.floor(j / 2) + 1); - const bi = (j % 2) + 1; - const device = inv.Devices[deviceId]; + // Sinexcel: Devices keyed by "1","2",... (1-based dict keys) + const device = inv.Devices[String(c.invIdx + 1)]; + const bi = c.clIdx + 1; const fieldName = `Battery${bi}${categoryFieldMapSinexcel[category]}`; value = device?.[fieldName]; - // Fallback for Soc if ((value === undefined || value === null) && category === 'Soc') { value = device?.[`Battery${bi}SocSecondvalue`]; } } else { - // Growatt: flat Battery1Soc, Battery2Voltage, ... - const batteryIndex = j + 1; + // Growatt: flat Battery1Soc, Battery2Voltage, ... on InverterRecord + const batteryIndex = c.clIdx + 1; const fieldName = `Battery${batteryIndex}${categoryFieldMapGrowatt[category]}`; value = inv[fieldName]; } @@ -282,13 +303,13 @@ export const transformInputToBatteryViewDataJson = async ( if (value > chartOverview[category].max) { chartOverview[category].max = value; } - chartData[category].data[pathsToSave[j]].data.push([ + chartData[category].data[path].data.push([ adjustedTimestampArray[i], value ]); } }); - } + }); } else { // SaliMax, Salidomo, SodistoreMax: existing logic const battery_nodes = diff --git a/typescript/frontend-marios2/src/interfaces/TicketTypes.tsx b/typescript/frontend-marios2/src/interfaces/TicketTypes.tsx index e0e986e41..888dc58ee 100644 --- a/typescript/frontend-marios2/src/interfaces/TicketTypes.tsx +++ b/typescript/frontend-marios2/src/interfaces/TicketTypes.tsx @@ -214,6 +214,7 @@ export type TicketComment = { authorId: number | null; body: string; createdAt: string; + editedAt?: string | null; }; export type TicketAiDiagnosis = { diff --git a/typescript/frontend-marios2/src/lang/de.json b/typescript/frontend-marios2/src/lang/de.json index 3a74bfc1c..dffa34395 100644 --- a/typescript/frontend-marios2/src/lang/de.json +++ b/typescript/frontend-marios2/src/lang/de.json @@ -97,6 +97,8 @@ "selectModel": "Modell auswählen...", "inverterN": "Wechselrichter {n}", "clusterN": "Cluster {n}", + "batteryClusterN": "Batterie-Cluster {n}", + "batteryClusterInInverter": "Batterie-Cluster {cl} an Wechselrichter {inv}", "clustersBatteriesSummary": "{filledClusters}/{totalClusters} Cluster, {filledBat}/{totalBat} Batterien", "batteriesSummary": "{filled}/{total} Batterien", "inverterNSerialNumber": "Wechselrichter {n} Seriennummer", @@ -567,6 +569,12 @@ "noDiagnosis": "Keine KI-Diagnose verfügbar.", "comments": "Kommentare", "noComments": "Noch keine Kommentare.", + "commentEdited": "(bearbeitet {time})", + "commentMarkdownHint": "Markdown: **fett**, #, ##, ###", + "commentFormatBold": "Fett", + "commentFormatH1": "Überschrift 1", + "commentFormatH2": "Überschrift 2", + "commentFormatH3": "Überschrift 3", "addComment": "Hinzufügen", "timeline": "Zeitverlauf", "noTimelineEvents": "Noch keine Ereignisse.", diff --git a/typescript/frontend-marios2/src/lang/en.json b/typescript/frontend-marios2/src/lang/en.json index 7a1b1c9bd..4fe5e7d2d 100644 --- a/typescript/frontend-marios2/src/lang/en.json +++ b/typescript/frontend-marios2/src/lang/en.json @@ -79,6 +79,8 @@ "selectModel": "Select model...", "inverterN": "Inverter {n}", "clusterN": "Cluster {n}", + "batteryClusterN": "Battery Cluster {n}", + "batteryClusterInInverter": "Battery Cluster {cl} in Inverter {inv}", "clustersBatteriesSummary": "{filledClusters}/{totalClusters} clusters, {filledBat}/{totalBat} batteries", "batteriesSummary": "{filled}/{total} batteries", "inverterNSerialNumber": "Inverter {n} Serial Number", @@ -315,6 +317,12 @@ "noDiagnosis": "No AI diagnosis available.", "comments": "Comments", "noComments": "No comments yet.", + "commentEdited": "(edited {time})", + "commentMarkdownHint": "Markdown: **bold**, #, ##, ###", + "commentFormatBold": "Bold", + "commentFormatH1": "Heading 1", + "commentFormatH2": "Heading 2", + "commentFormatH3": "Heading 3", "addComment": "Add", "timeline": "Timeline", "noTimelineEvents": "No events yet.", diff --git a/typescript/frontend-marios2/src/lang/fr.json b/typescript/frontend-marios2/src/lang/fr.json index d37ff5ac3..90fbd10b4 100644 --- a/typescript/frontend-marios2/src/lang/fr.json +++ b/typescript/frontend-marios2/src/lang/fr.json @@ -91,6 +91,8 @@ "selectModel": "Sélectionner le modèle...", "inverterN": "Onduleur {n}", "clusterN": "Cluster {n}", + "batteryClusterN": "Cluster de batteries {n}", + "batteryClusterInInverter": "Cluster de batteries {cl} sur onduleur {inv}", "clustersBatteriesSummary": "{filledClusters}/{totalClusters} clusters, {filledBat}/{totalBat} batteries", "batteriesSummary": "{filled}/{total} batteries", "inverterNSerialNumber": "Numéro de série onduleur {n}", @@ -567,6 +569,12 @@ "noDiagnosis": "Aucun diagnostic IA disponible.", "comments": "Commentaires", "noComments": "Aucun commentaire pour le moment.", + "commentEdited": "(modifié {time})", + "commentMarkdownHint": "Markdown : **gras**, #, ##, ###", + "commentFormatBold": "Gras", + "commentFormatH1": "Titre 1", + "commentFormatH2": "Titre 2", + "commentFormatH3": "Titre 3", "addComment": "Ajouter", "timeline": "Chronologie", "noTimelineEvents": "Aucun événement pour le moment.", diff --git a/typescript/frontend-marios2/src/lang/it.json b/typescript/frontend-marios2/src/lang/it.json index 40e7baf77..93bf6f2cb 100644 --- a/typescript/frontend-marios2/src/lang/it.json +++ b/typescript/frontend-marios2/src/lang/it.json @@ -79,6 +79,8 @@ "selectModel": "Seleziona modello...", "inverterN": "Inverter {n}", "clusterN": "Cluster {n}", + "batteryClusterN": "Cluster batteria {n}", + "batteryClusterInInverter": "Cluster batteria {cl} su inverter {inv}", "clustersBatteriesSummary": "{filledClusters}/{totalClusters} cluster, {filledBat}/{totalBat} batterie", "batteriesSummary": "{filled}/{total} batterie", "inverterNSerialNumber": "Numero di serie inverter {n}", @@ -567,6 +569,12 @@ "noDiagnosis": "Nessuna diagnosi IA disponibile.", "comments": "Commenti", "noComments": "Nessun commento ancora.", + "commentEdited": "(modificato {time})", + "commentMarkdownHint": "Markdown: **grassetto**, #, ##, ###", + "commentFormatBold": "Grassetto", + "commentFormatH1": "Titolo 1", + "commentFormatH2": "Titolo 2", + "commentFormatH3": "Titolo 3", "addComment": "Aggiungi", "timeline": "Cronologia", "noTimelineEvents": "Nessun evento ancora.",