Merge branch 'main' into feature/sodistore-checklist
This commit is contained in:
commit
30499df329
|
|
@ -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<TicketComment> 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<Object> GetTicketDetail(Int64 id, Token authToken)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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}"
|
||||
};
|
||||
}
|
||||
|
|
@ -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<Int64> MentionedUserIds { get; set; } = new();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
></MainStatsSodioHome>
|
||||
}
|
||||
/>
|
||||
|
|
@ -225,7 +267,7 @@ function BatteryViewSodioHome(props: BatteryViewSodioHomeProps) {
|
|||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{sortedBatteryView.map(({ BatteryId, battery }) => (
|
||||
{sortedBatteryView.map(({ BatteryId, label, battery }) => (
|
||||
<TableRow
|
||||
key={BatteryId}
|
||||
style={{
|
||||
|
|
@ -243,7 +285,7 @@ function BatteryViewSodioHome(props: BatteryViewSodioHomeProps) {
|
|||
style={{ color: 'black' }}
|
||||
to={routes.detailed_view + BatteryId}
|
||||
>*/}
|
||||
{'Battery Cluster ' + BatteryId}
|
||||
{label}
|
||||
{/*</Link>*/}
|
||||
</TableCell>
|
||||
<TableCell
|
||||
|
|
|
|||
|
|
@ -26,11 +26,14 @@ import { AdapterDayjs } from '@mui/x-date-pickers/AdapterDayjs';
|
|||
import CircularProgress from '@mui/material/CircularProgress';
|
||||
import { useLocation, useNavigate } from 'react-router-dom';
|
||||
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
|
||||
import { ActiveCluster } from '../Information/installationSetupUtils';
|
||||
|
||||
interface MainStatsSodioHomeProps {
|
||||
s3Credentials: I_S3Credentials;
|
||||
id: number;
|
||||
batteryClusterNumber: number;
|
||||
activeClusters?: ActiveCluster[];
|
||||
showInverterLabel?: boolean;
|
||||
}
|
||||
|
||||
function MainStatsSodioHome(props: MainStatsSodioHomeProps) {
|
||||
|
|
@ -115,7 +118,8 @@ function MainStatsSodioHome(props: MainStatsSodioHomeProps) {
|
|||
TimeSpan.fromDays(1)
|
||||
),
|
||||
UnixTime.fromTicks(new Date().getTime() / 1000),
|
||||
props.batteryClusterNumber
|
||||
props.batteryClusterNumber,
|
||||
props.activeClusters
|
||||
);
|
||||
|
||||
resultPromise
|
||||
|
|
@ -146,9 +150,15 @@ function MainStatsSodioHome(props: MainStatsSodioHomeProps) {
|
|||
|
||||
function generateSeries(chartData, category, color) {
|
||||
const series = [];
|
||||
const pathsToSearch = [];
|
||||
for (let i = 0; i < props.batteryClusterNumber; i++) {
|
||||
pathsToSearch.push('Node' + i);
|
||||
const pathsToSearch: string[] = [];
|
||||
if (props.activeClusters && props.activeClusters.length > 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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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<HTMLTextAreaElement | HTMLInputElement | null>;
|
||||
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 (
|
||||
<Box sx={{ display: 'flex', gap: 0.5, alignItems: 'center' }}>
|
||||
<Tooltip title={intl.formatMessage({ id: 'commentFormatBold', defaultMessage: 'Bold' })}>
|
||||
<span>
|
||||
<Button size="small" variant="outlined" sx={btnSx} onClick={handle('bold')} disabled={disabled}>
|
||||
<FormatBoldIcon fontSize="inherit" />
|
||||
</Button>
|
||||
</span>
|
||||
</Tooltip>
|
||||
<Tooltip title={intl.formatMessage({ id: 'commentFormatH1', defaultMessage: 'Heading 1' })}>
|
||||
<span>
|
||||
<Button size="small" variant="outlined" sx={btnSx} onClick={handle('h1')} disabled={disabled}>
|
||||
H1
|
||||
</Button>
|
||||
</span>
|
||||
</Tooltip>
|
||||
<Tooltip title={intl.formatMessage({ id: 'commentFormatH2', defaultMessage: 'Heading 2' })}>
|
||||
<span>
|
||||
<Button size="small" variant="outlined" sx={btnSx} onClick={handle('h2')} disabled={disabled}>
|
||||
H2
|
||||
</Button>
|
||||
</span>
|
||||
</Tooltip>
|
||||
<Tooltip title={intl.formatMessage({ id: 'commentFormatH3', defaultMessage: 'Heading 3' })}>
|
||||
<span>
|
||||
<Button size="small" variant="outlined" sx={btnSx} onClick={handle('h3')} disabled={disabled}>
|
||||
H3
|
||||
</Button>
|
||||
</span>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
export default CommentFormatToolbar;
|
||||
|
|
@ -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<number | null>(null);
|
||||
const [editBody, setEditBody] = useState('');
|
||||
const [savingEdit, setSavingEdit] = useState(false);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const [selectedFiles, setSelectedFiles] = useState<File[]>([]);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
|
|
@ -49,6 +57,7 @@ function CommentThread({
|
|||
const [mentionedIds, setMentionedIds] = useState<number[]>([]);
|
||||
const [mentionQuery, setMentionQuery] = useState<string | null>(null);
|
||||
const commentInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const editInputRef = useRef<HTMLInputElement | null>(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 (
|
||||
<Box
|
||||
key={comment.id}
|
||||
|
|
@ -216,7 +253,7 @@ function CommentThread({
|
|||
</Avatar>
|
||||
<Box sx={{ flex: 1 }}>
|
||||
<Box
|
||||
sx={{ display: 'flex', alignItems: 'baseline', gap: 1 }}
|
||||
sx={{ display: 'flex', alignItems: 'baseline', gap: 1, flexWrap: 'wrap' }}
|
||||
>
|
||||
<Typography variant="subtitle2">
|
||||
{isAi ? 'AI' : (adminUsers.find(u => u.id === comment.authorId)?.name ?? `User #${comment.authorId ?? '?'}`)}
|
||||
|
|
@ -224,10 +261,64 @@ function CommentThread({
|
|||
<Typography variant="caption" color="text.disabled">
|
||||
{new Date(comment.createdAt).toLocaleString()}
|
||||
</Typography>
|
||||
{comment.editedAt && (
|
||||
<Typography variant="caption" color="text.disabled" sx={{ fontStyle: 'italic' }}>
|
||||
<FormattedMessage
|
||||
id="commentEdited"
|
||||
defaultMessage="(edited {time})"
|
||||
values={{ time: new Date(comment.editedAt).toLocaleString() }}
|
||||
/>
|
||||
</Typography>
|
||||
)}
|
||||
{canEdit && !isEditing && (
|
||||
<Button
|
||||
size="small"
|
||||
variant="text"
|
||||
sx={{ minWidth: 0, p: 0, ml: 'auto', textTransform: 'none' }}
|
||||
onClick={() => startEdit(comment)}
|
||||
>
|
||||
<FormattedMessage id="edit" defaultMessage="Edit" />
|
||||
</Button>
|
||||
)}
|
||||
</Box>
|
||||
<Typography variant="body2" sx={{ whiteSpace: 'pre-wrap' }}>
|
||||
{comment.body}
|
||||
</Typography>
|
||||
{isEditing ? (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1, mt: 0.5 }}>
|
||||
<CommentFormatToolbar
|
||||
textareaRef={editInputRef}
|
||||
value={editBody}
|
||||
onChange={setEditBody}
|
||||
disabled={savingEdit}
|
||||
/>
|
||||
<TextField
|
||||
size="small"
|
||||
fullWidth
|
||||
multiline
|
||||
minRows={2}
|
||||
value={editBody}
|
||||
onChange={(e) => setEditBody(e.target.value)}
|
||||
inputRef={editInputRef}
|
||||
/>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'flex-end', gap: 1 }}>
|
||||
<Button
|
||||
size="small"
|
||||
onClick={cancelEdit}
|
||||
disabled={savingEdit}
|
||||
>
|
||||
<FormattedMessage id="cancel" defaultMessage="Cancel" />
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
variant="contained"
|
||||
onClick={() => saveEdit(comment.id)}
|
||||
disabled={savingEdit || !editBody.trim()}
|
||||
>
|
||||
<FormattedMessage id="save" defaultMessage="Save" />
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
) : (
|
||||
renderCommentBody(comment.body)
|
||||
)}
|
||||
<DocumentList ticketCommentId={comment.id} refreshKey={refreshKey} />
|
||||
</Box>
|
||||
</Box>
|
||||
|
|
@ -237,6 +328,20 @@ function CommentThread({
|
|||
<Divider />
|
||||
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', flexWrap: 'wrap', gap: 1 }}>
|
||||
<CommentFormatToolbar
|
||||
textareaRef={commentInputRef}
|
||||
value={body}
|
||||
onChange={setBody}
|
||||
disabled={submitting || uploading}
|
||||
/>
|
||||
<Typography variant="caption" color="text.disabled">
|
||||
<FormattedMessage
|
||||
id="commentMarkdownHint"
|
||||
defaultMessage="Markdown: **bold**, #, ##, ###"
|
||||
/>
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box sx={{ display: 'flex', gap: 1 }}>
|
||||
<TextField
|
||||
size="small"
|
||||
|
|
|
|||
|
|
@ -0,0 +1,85 @@
|
|||
import React from 'react';
|
||||
import { Box, Typography } from '@mui/material';
|
||||
|
||||
export type FormatKind = 'bold' | 'h1' | 'h2' | 'h3';
|
||||
|
||||
function renderInline(text: string): React.ReactNode[] {
|
||||
const parts = text.split(/\*\*(.+?)\*\*/g);
|
||||
return parts.map((p, i) =>
|
||||
i % 2 === 1 ? <strong key={i}>{p}</strong> : <React.Fragment key={i}>{p}</React.Fragment>
|
||||
);
|
||||
}
|
||||
|
||||
export function renderCommentBody(body: string): JSX.Element {
|
||||
const lines = body.split('\n');
|
||||
return (
|
||||
<Box sx={{ '& > *': { mb: 0.5 } }}>
|
||||
{lines.map((line, idx) => {
|
||||
if (line.startsWith('### ')) {
|
||||
return (
|
||||
<Typography key={idx} variant="subtitle1" sx={{ fontWeight: 600, mt: 1 }}>
|
||||
{renderInline(line.slice(4))}
|
||||
</Typography>
|
||||
);
|
||||
}
|
||||
if (line.startsWith('## ')) {
|
||||
return (
|
||||
<Typography key={idx} variant="h6" sx={{ mt: 1.5 }}>
|
||||
{renderInline(line.slice(3))}
|
||||
</Typography>
|
||||
);
|
||||
}
|
||||
if (line.startsWith('# ')) {
|
||||
return (
|
||||
<Typography key={idx} variant="h5" sx={{ mt: 2 }}>
|
||||
{renderInline(line.slice(2))}
|
||||
</Typography>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Typography key={idx} variant="body2" sx={{ whiteSpace: 'pre-wrap' }}>
|
||||
{line ? renderInline(line) : '\u00A0'}
|
||||
</Typography>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
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);
|
||||
});
|
||||
}
|
||||
|
|
@ -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 =
|
||||
|
|
|
|||
|
|
@ -214,6 +214,7 @@ export type TicketComment = {
|
|||
authorId: number | null;
|
||||
body: string;
|
||||
createdAt: string;
|
||||
editedAt?: string | null;
|
||||
};
|
||||
|
||||
export type TicketAiDiagnosis = {
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
|
|
|
|||
Loading…
Reference in New Issue