diff --git a/csharp/App/Backend/Controller.cs b/csharp/App/Backend/Controller.cs index ab756e82f..e976e3a48 100644 --- a/csharp/App/Backend/Controller.cs +++ b/csharp/App/Backend/Controller.cs @@ -2740,4 +2740,94 @@ public class Controller : ControllerBase return Ok(); } + // ── Checklist ─────────────────────────────────────────────────────── + + [HttpGet(nameof(GetChecklistForInstallation))] + public ActionResult> GetChecklistForInstallation(Int64 installationId, Token authToken) + { + var user = Db.GetSession(authToken)?.User; + if (user is null || user.UserType != 2) return Unauthorized(); + + var installation = Db.GetInstallationById(installationId); + if (installation is null) return NotFound("Installation not found."); + + if (!Db.ChecklistExistsForInstallation(installationId)) + { + foreach (var def in ChecklistStepDefinitions.Steps) + { + Db.Create(new ChecklistItem + { + InstallationId = installationId, + StepNumber = def.Number, + StepTitle = def.Title, + Subtasks = def.SubtasksJson, + }); + } + } + + return Ok(Db.GetChecklistForInstallation(installationId)); + } + + [HttpPut(nameof(UpdateChecklistItem))] + public ActionResult UpdateChecklistItem( + Int64 checklistItemId, + Int32? status, + String? comments, + Int64? assigneeId, + Boolean clearAssignee, + String? doneAt, + String? subtasks, + Token authToken) + { + var user = Db.GetSession(authToken)?.User; + if (user is null || user.UserType != 2) return Unauthorized(); + + var item = Db.GetChecklistItemById(checklistItemId); + if (item is null) return NotFound("Checklist item not found."); + + if (status.HasValue) item.Status = status.Value; + if (comments is not null) item.Comments = comments; + if (clearAssignee) item.AssigneeId = null; + else if (assigneeId.HasValue) item.AssigneeId = assigneeId.Value; + if (doneAt is not null) item.DoneAt = String.IsNullOrWhiteSpace(doneAt) ? null : doneAt; + if (subtasks is not null) item.Subtasks = subtasks; + + // Auto-fill DoneAt when status transitions to Done and no date provided + if (item.Status == (Int32)ChecklistStatus.Done && String.IsNullOrWhiteSpace(item.DoneAt)) + item.DoneAt = DateTime.UtcNow.ToString("yyyy-MM-dd"); + + item.UpdatedAt = DateTime.UtcNow; + + return Db.Update(item) ? item : StatusCode(500, "Update failed."); + } + + [HttpPost(nameof(NotifyChecklistAssignee))] + public async Task NotifyChecklistAssignee(Int64 checklistItemId, Token authToken) + { + var user = Db.GetSession(authToken)?.User; + if (user is null || user.UserType != 2) return Unauthorized(); + + var item = Db.GetChecklistItemById(checklistItemId); + if (item is null) return NotFound("Checklist item not found."); + + if (item.AssigneeId is null) return BadRequest("No assignee set for this step."); + + var assignee = Db.GetUserById(item.AssigneeId); + if (assignee is null) return BadRequest("Assignee user not found."); + + var installation = Db.GetInstallationById(item.InstallationId); + if (installation is null) return NotFound("Installation not found."); + + try + { + await assignee.SendChecklistAssignedEmail(item, installation, user.Name); + return Ok(); + } + catch (Exception ex) + { + Console.WriteLine($"[Checklist] Failed to send assignee email: {ex}"); + return StatusCode(500, "Failed to send notification email."); + } + } + } diff --git a/csharp/App/Backend/DataTypes/ChecklistItem.cs b/csharp/App/Backend/DataTypes/ChecklistItem.cs new file mode 100644 index 000000000..4591a59ac --- /dev/null +++ b/csharp/App/Backend/DataTypes/ChecklistItem.cs @@ -0,0 +1,23 @@ +using SQLite; + +namespace InnovEnergy.App.Backend.DataTypes; + +public enum ChecklistStatus { NotStarted = 0, InProgress = 1, Done = 2 } + +public class ChecklistItem +{ + [PrimaryKey, AutoIncrement] public Int64 Id { get; set; } + + [Indexed] public Int64 InstallationId { get; set; } + + public Int32 StepNumber { get; set; } + public String StepTitle { get; set; } = ""; + public Int32 Status { get; set; } = (Int32)ChecklistStatus.NotStarted; + public String Comments { get; set; } = ""; + public Int64? AssigneeId { get; set; } + public String? DoneAt { get; set; } + public String? Subtasks { get; set; } + + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + public DateTime UpdatedAt { get; set; } = DateTime.UtcNow; +} diff --git a/csharp/App/Backend/DataTypes/ChecklistStepDefinitions.cs b/csharp/App/Backend/DataTypes/ChecklistStepDefinitions.cs new file mode 100644 index 000000000..f98290541 --- /dev/null +++ b/csharp/App/Backend/DataTypes/ChecklistStepDefinitions.cs @@ -0,0 +1,54 @@ +namespace InnovEnergy.App.Backend.DataTypes; + +public record ChecklistStepDefinition(Int32 Number, String Title, String? SubtasksJson); + +public static class ChecklistStepDefinitions +{ + private const String NoSubtasks = null!; + + public static readonly IReadOnlyList Steps = new List + { + new( 1, "Order created, customer and partner info recorded in CRM", NoSubtasks), + new( 2, "Hardware assembled at Vebo", NoSubtasks), + new( 3, "Installation created on Monitor under correct product and folder", NoSubtasks), + new( 4, "Gateway SD card configured, VPN and gateway name registered", NoSubtasks), + new( 5, "Information tab filled out (customer, serials, VPN)", + """ + [ + {"text":"Customer information (email, address)","checked":false}, + {"text":"Installation information (external EMS, network provider, data collection)","checked":false}, + {"text":"Battery serial number","checked":false}, + {"text":"Inverter serial number","checked":false}, + {"text":"Data logger serial number","checked":false}, + {"text":"VPN details","checked":false} + ] + """), + new( 6, "Installation configured and tested electrically / hardware-wise at Vebo", + """ + [ + {"text":"Inverter firmware and configuration verified","checked":false}, + {"text":"Battery firmware and configuration verified","checked":false}, + {"text":"Internet for gateway configured","checked":false}, + {"text":"Communication cable between gateway and inverter correct","checked":false} + ] + """), + new( 7, "Installation tested software-wise at Vebo", + """ + [ + {"text":"S3 bucket number and key credentials copied from Information tab into config.json","checked":false}, + {"text":"Product ID configured in config.json","checked":false}, + {"text":"USB ID configured in config.json","checked":false}, + {"text":"Inverter data reading from inverter tested","checked":false} + ] + """), + new( 8, "Installation delivered to customer site", NoSubtasks), + new( 9, "Installation connected to grid", NoSubtasks), + new(10, "Hardware verified on site", NoSubtasks), + new(11, "Software verified on site", NoSubtasks), + new(12, "Installation online on Monitor", NoSubtasks), + new(13, "Customer informed about Monitor account and reports", NoSubtasks), + new(14, "User account created with correct folders and access", NoSubtasks), + new(15, "Customer follow-up completed, feedback collected", NoSubtasks), + new(16, "Further issues tracked via Ticket system", NoSubtasks), + }; +} diff --git a/csharp/App/Backend/DataTypes/Methods/User.cs b/csharp/App/Backend/DataTypes/Methods/User.cs index 8dd43ce5e..3eb708713 100644 --- a/csharp/App/Backend/DataTypes/Methods/User.cs +++ b/csharp/App/Backend/DataTypes/Methods/User.cs @@ -518,4 +518,61 @@ public static class UserMethods return user.SendEmail(subject, body); } + public static Task SendChecklistAssignedEmail( + this User user, + ChecklistItem item, + Installation installation, + String notifiedByName) + { + var checklistLink = $"https://monitor.inesco.energy/sodiohome_installations/installation/{installation.Id}/checklist"; + var installationName = String.IsNullOrEmpty(installation.Name) ? $"#{installation.Id}" : installation.Name; + var commentsBlock = String.IsNullOrWhiteSpace(item.Comments) ? "" : item.Comments; + + var (subject, body) = (user.Language ?? "en") switch + { + "de" => ( + "inesco energy – Sie wurden einem Checklisten-Schritt zugewiesen", + $"Sehr geehrte/r {user.Name},\n\n" + + $"{notifiedByName} hat Sie einem Schritt der Installations-Checkliste zugewiesen:\n\n" + + $"Installation: {installationName}\n" + + $"Schritt {item.StepNumber}: {item.StepTitle}\n\n" + + (commentsBlock.Length > 0 ? $"Kommentare:\n{commentsBlock}\n\n" : "") + + $"Checkliste öffnen: {checklistLink}\n\n" + + "Mit freundlichen Grüssen\ninesco energy Monitor" + ), + "fr" => ( + "inesco energy – Une étape de checklist vous a été attribuée", + $"Cher/Chère {user.Name},\n\n" + + $"{notifiedByName} vous a attribué une étape de la checklist d'installation :\n\n" + + $"Installation : {installationName}\n" + + $"Étape {item.StepNumber} : {item.StepTitle}\n\n" + + (commentsBlock.Length > 0 ? $"Commentaires :\n{commentsBlock}\n\n" : "") + + $"Ouvrir la checklist : {checklistLink}\n\n" + + "Cordialement,\ninesco energy Monitor" + ), + "it" => ( + "inesco energy – Le è stato assegnato un passo della checklist", + $"Gentile {user.Name},\n\n" + + $"{notifiedByName} le ha assegnato un passo della checklist di installazione:\n\n" + + $"Installazione: {installationName}\n" + + $"Passo {item.StepNumber}: {item.StepTitle}\n\n" + + (commentsBlock.Length > 0 ? $"Commenti:\n{commentsBlock}\n\n" : "") + + $"Aprire la checklist: {checklistLink}\n\n" + + "Cordiali saluti,\ninesco energy Monitor" + ), + _ => ( + "inesco energy – You have been assigned to a checklist step", + $"Dear {user.Name},\n\n" + + $"{notifiedByName} has assigned you to an installation checklist step:\n\n" + + $"Installation: {installationName}\n" + + $"Step {item.StepNumber}: {item.StepTitle}\n\n" + + (commentsBlock.Length > 0 ? $"Comments:\n{commentsBlock}\n\n" : "") + + $"Open the checklist: {checklistLink}\n\n" + + "Best regards,\ninesco energy Monitor" + ) + }; + + return user.SendEmail(subject, body); + } + } \ No newline at end of file diff --git a/csharp/App/Backend/Database/Create.cs b/csharp/App/Backend/Database/Create.cs index a6759de3a..a94884eae 100644 --- a/csharp/App/Backend/Database/Create.cs +++ b/csharp/App/Backend/Database/Create.cs @@ -91,6 +91,9 @@ public static partial class Db // Document storage public static Boolean Create(Document document) => Insert(document); + + // Checklist + public static Boolean Create(ChecklistItem item) => Insert(item); public static void HandleAction(UserAction newAction) { diff --git a/csharp/App/Backend/Database/Db.cs b/csharp/App/Backend/Database/Db.cs index f909b67eb..5735b88a8 100644 --- a/csharp/App/Backend/Database/Db.cs +++ b/csharp/App/Backend/Database/Db.cs @@ -42,6 +42,9 @@ public static partial class Db // Document storage public static TableQuery Documents => Connection.Table(); + // Checklist + public static TableQuery ChecklistItems => Connection.Table(); + public static void Init() { @@ -83,6 +86,9 @@ public static partial class Db // Document storage Connection.CreateTable(); + + // Checklist + Connection.CreateTable(); }); // One-time migration: normalize legacy long-form language values to ISO codes @@ -150,6 +156,9 @@ public static partial class Db // Document storage fileConnection.CreateTable(); + // Checklist + fileConnection.CreateTable(); + // Migrate new columns: set defaults for existing rows where NULL or empty fileConnection.Execute("UPDATE Installation SET ExternalEms = 'No' WHERE ExternalEms IS NULL OR ExternalEms = ''"); fileConnection.Execute("UPDATE Installation SET InstallationModel = '' WHERE InstallationModel IS NULL"); diff --git a/csharp/App/Backend/Database/Delete.cs b/csharp/App/Backend/Database/Delete.cs index b36b7229d..1bf82931c 100644 --- a/csharp/App/Backend/Database/Delete.cs +++ b/csharp/App/Backend/Database/Delete.cs @@ -145,6 +145,9 @@ public static partial class Db // Clean up installation-level documents Documents.Delete(d => d.InstallationId == installation.Id); + // Clean up checklist items for this installation + ChecklistItems.Delete(c => c.InstallationId == installation.Id); + return Installations.Delete(i => i.Id == installation.Id) > 0; } } diff --git a/csharp/App/Backend/Database/Read.cs b/csharp/App/Backend/Database/Read.cs index a144075d6..9d1813439 100644 --- a/csharp/App/Backend/Database/Read.cs +++ b/csharp/App/Backend/Database/Read.cs @@ -233,4 +233,18 @@ public static partial class Db .Where(d => d.InstallationId == installationId && d.Scope == (Int32)DocumentScope.InstallationDocument) .OrderBy(d => d.CreatedAt) .ToList(); + + // ── Checklist Queries ─────────────────────────────────────────────── + + public static List GetChecklistForInstallation(Int64 installationId) + => ChecklistItems + .Where(c => c.InstallationId == installationId) + .OrderBy(c => c.StepNumber) + .ToList(); + + public static Boolean ChecklistExistsForInstallation(Int64 installationId) + => ChecklistItems.Any(c => c.InstallationId == installationId); + + public static ChecklistItem? GetChecklistItemById(Int64 id) + => ChecklistItems.FirstOrDefault(c => c.Id == id); } \ No newline at end of file diff --git a/csharp/App/Backend/Database/Update.cs b/csharp/App/Backend/Database/Update.cs index ed5b2cf6a..0c694358e 100644 --- a/csharp/App/Backend/Database/Update.cs +++ b/csharp/App/Backend/Database/Update.cs @@ -72,4 +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); + + // Checklist + public static Boolean Update(ChecklistItem item) => Update(obj: item); } \ No newline at end of file diff --git a/typescript/frontend-marios2/src/Resources/routes.json b/typescript/frontend-marios2/src/Resources/routes.json index b2409023a..ec2072a2e 100644 --- a/typescript/frontend-marios2/src/Resources/routes.json +++ b/typescript/frontend-marios2/src/Resources/routes.json @@ -26,5 +26,6 @@ "report": "report", "installationTickets": "installationTickets", "documents": "documents", + "checklist": "checklist", "tickets": "/tickets/" } diff --git a/typescript/frontend-marios2/src/content/dashboards/Checklist/ChecklistStepRow.tsx b/typescript/frontend-marios2/src/content/dashboards/Checklist/ChecklistStepRow.tsx new file mode 100644 index 000000000..a7225224d --- /dev/null +++ b/typescript/frontend-marios2/src/content/dashboards/Checklist/ChecklistStepRow.tsx @@ -0,0 +1,301 @@ +import React, { useEffect, useMemo, useState } from 'react'; +import { + Box, + Checkbox, + Chip, + FormControl, + FormControlLabel, + IconButton, + MenuItem, + Select, + Stack, + TableCell, + TableRow, + TextField, + Tooltip, + Typography +} from '@mui/material'; +import EmailIcon from '@mui/icons-material/Email'; +import CheckIcon from '@mui/icons-material/Check'; +import CloseIcon from '@mui/icons-material/Close'; +import ExpandMoreIcon from '@mui/icons-material/ExpandMore'; +import ExpandLessIcon from '@mui/icons-material/ExpandLess'; +import { FormattedMessage, useIntl } from 'react-intl'; +import { + ChecklistItem, + ChecklistStatus, + ChecklistSubtask, + parseSubtasks, + serializeSubtasks +} from 'src/interfaces/ChecklistTypes'; +import { AdminUser } from 'src/interfaces/TicketTypes'; + +type EmailIconState = 'idle' | 'loading' | 'success' | 'error'; + +interface Props { + item: ChecklistItem; + adminUsers: AdminUser[]; + onUpdate: ( + id: number, + patch: Partial<{ + status: number; + comments: string; + assigneeId: number | null; + doneAt: string | null; + subtasks: string; + }> + ) => Promise; + onNotify: (id: number) => Promise; +} + +const statusColors: Record = { + [ChecklistStatus.NotStarted]: '#9e9e9e', + [ChecklistStatus.InProgress]: '#ed6c02', + [ChecklistStatus.Done]: '#2e7d32' +}; + +function ChecklistStepRow({ item, adminUsers, onUpdate, onNotify }: Props) { + const intl = useIntl(); + const [comments, setComments] = useState(item.comments ?? ''); + const [emailState, setEmailState] = useState('idle'); + const [subtasksOpen, setSubtasksOpen] = useState(false); + const [subtasks, setSubtasks] = useState(() => + parseSubtasks(item.subtasks) + ); + + useEffect(() => { + setComments(item.comments ?? ''); + }, [item.comments]); + + useEffect(() => { + setSubtasks(parseSubtasks(item.subtasks)); + }, [item.subtasks]); + + const subtaskSummary = useMemo(() => { + if (subtasks.length === 0) return null; + const done = subtasks.filter((s) => s.checked).length; + return `${done}/${subtasks.length}`; + }, [subtasks]); + + const handleStatusChange = async (value: number) => { + await onUpdate(item.id, { status: value }); + }; + + const handleAssigneeChange = async (rawValue: string) => { + if (rawValue === '') { + await onUpdate(item.id, { assigneeId: null }); + } else { + await onUpdate(item.id, { assigneeId: Number(rawValue) }); + } + }; + + const handleDoneAtChange = async (value: string) => { + await onUpdate(item.id, { doneAt: value || null }); + }; + + const handleCommentsBlur = async () => { + if (comments !== (item.comments ?? '')) { + await onUpdate(item.id, { comments }); + } + }; + + const handleSubtaskToggle = async (index: number) => { + const updated = subtasks.map((s, i) => + i === index ? { ...s, checked: !s.checked } : s + ); + setSubtasks(updated); + await onUpdate(item.id, { subtasks: serializeSubtasks(updated) }); + }; + + const handleNotifyClick = async () => { + if (!item.assigneeId || emailState === 'loading') return; + setEmailState('loading'); + const ok = await onNotify(item.id); + setEmailState(ok ? 'success' : 'error'); + setTimeout(() => setEmailState('idle'), 2500); + }; + + const renderEmailIcon = () => { + if (emailState === 'success') return ; + if (emailState === 'error') return ; + return ; + }; + + const filteredAdmins = useMemo( + () => + adminUsers.filter((u) => { + const name = (u.name ?? '').toLowerCase(); + return ( + !name.includes('inesco energy master admin') && + !name.includes('paal myhre') + ); + }), + [adminUsers] + ); + + return ( + <> + + + + {item.stepNumber} + + + + + + {item.stepTitle} + {subtasks.length > 0 && ( + <> + s.checked) ? 'success' : 'default' + } + /> + setSubtasksOpen((o) => !o)} + aria-label={intl.formatMessage({ + id: 'checklistToggleSubtasks', + defaultMessage: 'Toggle subtasks' + })} + > + {subtasksOpen ? : } + + + )} + + + + + + + + + + + + + + + + + + {renderEmailIcon()} + + + + + + + + handleDoneAtChange(e.target.value)} + InputLabelProps={{ shrink: true }} + fullWidth + /> + + + + setComments(e.target.value)} + onBlur={handleCommentsBlur} + fullWidth + placeholder={intl.formatMessage({ + id: 'checklistCommentsPlaceholder', + defaultMessage: 'Notes, contact info, observations…' + })} + /> + + + + {subtasks.length > 0 && subtasksOpen && ( + + + + {subtasks.map((s, i) => ( + handleSubtaskToggle(i)} + /> + } + label={{s.text}} + sx={{ display: 'block', ml: 0 }} + /> + ))} + + + + )} + + ); +} + +export default ChecklistStepRow; diff --git a/typescript/frontend-marios2/src/content/dashboards/Checklist/InstallationChecklistTab.tsx b/typescript/frontend-marios2/src/content/dashboards/Checklist/InstallationChecklistTab.tsx new file mode 100644 index 000000000..8637acee7 --- /dev/null +++ b/typescript/frontend-marios2/src/content/dashboards/Checklist/InstallationChecklistTab.tsx @@ -0,0 +1,264 @@ +import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import { + Alert, + Box, + LinearProgress, + Snackbar, + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TableRow, + Typography, + Paper +} from '@mui/material'; +import { FormattedMessage, useIntl } from 'react-intl'; +import axiosConfig from 'src/Resources/axiosConfig'; +import { + ChecklistItem, + ChecklistStatus +} from 'src/interfaces/ChecklistTypes'; +import { AdminUser } from 'src/interfaces/TicketTypes'; +import ChecklistStepRow from './ChecklistStepRow'; + +interface Props { + installationId: number; +} + +type ToastState = { + open: boolean; + severity: 'success' | 'error'; + message: string; +}; + +function InstallationChecklistTab({ installationId }: Props) { + const intl = useIntl(); + const [items, setItems] = useState([]); + const [adminUsers, setAdminUsers] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(''); + const [toast, setToast] = useState({ + open: false, + severity: 'success', + message: '' + }); + + const fetchItems = useCallback(() => { + setLoading(true); + axiosConfig + .get('/GetChecklistForInstallation', { params: { installationId } }) + .then((res) => { + setItems(Array.isArray(res.data) ? res.data : []); + setError(''); + }) + .catch(() => setError('Failed to load checklist.')) + .finally(() => setLoading(false)); + }, [installationId]); + + useEffect(() => { + fetchItems(); + }, [fetchItems]); + + useEffect(() => { + axiosConfig + .get('/GetAdminUsers') + .then((res) => { + if (Array.isArray(res.data)) setAdminUsers(res.data); + }) + .catch(() => setAdminUsers([])); + }, []); + + const progress = useMemo(() => { + const total = items.length; + const done = items.filter( + (i) => i.status === ChecklistStatus.Done + ).length; + const percent = total === 0 ? 0 : Math.round((done / total) * 100); + return { total, done, percent }; + }, [items]); + + const handleUpdate = useCallback( + async ( + id: number, + patch: Partial<{ + status: number; + comments: string; + assigneeId: number | null; + doneAt: string | null; + subtasks: string; + }> + ) => { + const params: Record = { checklistItemId: id }; + if (patch.status !== undefined) params.status = patch.status; + if (patch.comments !== undefined) params.comments = patch.comments; + if (patch.subtasks !== undefined) params.subtasks = patch.subtasks; + + if ('assigneeId' in patch) { + if (patch.assigneeId === null) { + params.clearAssignee = true; + } else if (typeof patch.assigneeId === 'number') { + params.assigneeId = patch.assigneeId; + } + } + + if ('doneAt' in patch) { + params.doneAt = patch.doneAt ?? ''; + } + + try { + const res = await axiosConfig.put('/UpdateChecklistItem', null, { + params + }); + const updated = res.data as ChecklistItem; + setItems((prev) => + prev.map((it) => (it.id === id ? updated : it)) + ); + return true; + } catch { + setToast({ + open: true, + severity: 'error', + message: intl.formatMessage({ + id: 'checklistSaveFailed', + defaultMessage: 'Failed to save change' + }) + }); + return false; + } + }, + [intl] + ); + + const handleNotify = useCallback( + async (id: number) => { + const item = items.find((i) => i.id === id); + if (!item || !item.assigneeId) return false; + const assignee = adminUsers.find((u) => u.id === item.assigneeId); + const assigneeName = assignee?.name ?? ''; + try { + await axiosConfig.post('/NotifyChecklistAssignee', null, { + params: { checklistItemId: id } + }); + setToast({ + open: true, + severity: 'success', + message: intl.formatMessage( + { + id: 'checklistEmailSent', + defaultMessage: 'Email sent to {name}' + }, + { name: assigneeName } + ) + }); + return true; + } catch { + setToast({ + open: true, + severity: 'error', + message: intl.formatMessage({ + id: 'checklistEmailFailed', + defaultMessage: 'Failed to send email — try again' + }) + }); + return false; + } + }, + [items, adminUsers, intl] + ); + + if (loading) { + return ( + + + + ); + } + + if (error) { + return ( + + {error} + + ); + } + + return ( + + + + + + + + + + + + + + + + + + # + + + + + + + + + + + + + + + + + + + {items.map((item) => ( + + ))} + +
+
+ + setToast((t) => ({ ...t, open: false }))} + anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }} + > + setToast((t) => ({ ...t, open: false }))} + > + {toast.message} + + +
+ ); +} + +export default InstallationChecklistTab; diff --git a/typescript/frontend-marios2/src/content/dashboards/Installations/Installation.tsx b/typescript/frontend-marios2/src/content/dashboards/Installations/Installation.tsx index 0439276b5..f7187b695 100644 --- a/typescript/frontend-marios2/src/content/dashboards/Installations/Installation.tsx +++ b/typescript/frontend-marios2/src/content/dashboards/Installations/Installation.tsx @@ -31,6 +31,8 @@ import Configuration from '../Configuration/Configuration'; import PvView from '../PvView/PvView'; import InstallationTicketsTab from '../Tickets/InstallationTicketsTab'; import DocumentsTab from '../Documents/DocumentsTab'; +import InstallationChecklistTab from '../Checklist/InstallationChecklistTab'; +import { CHECKLIST_ENABLED_PRODUCTS } from 'src/interfaces/ChecklistTypes'; interface singleInstallationProps { current_installation?: I_Installation; @@ -569,6 +571,18 @@ function Installation(props: singleInstallationProps) { /> )} + {currentUser.userType == UserType.admin && + CHECKLIST_ENABLED_PRODUCTS.has(props.current_installation.product) && ( + + } + /> + )} + } diff --git a/typescript/frontend-marios2/src/content/dashboards/Installations/index.tsx b/typescript/frontend-marios2/src/content/dashboards/Installations/index.tsx index 1dff83f2e..0c2cfdd84 100644 --- a/typescript/frontend-marios2/src/content/dashboards/Installations/index.tsx +++ b/typescript/frontend-marios2/src/content/dashboards/Installations/index.tsx @@ -34,7 +34,8 @@ function InstallationTabs(props: InstallationTabsProps) { 'history', 'pvview', 'installationTickets', - 'documents' + 'documents', + 'checklist' ]; const [currentTab, setCurrentTab] = useState(undefined); @@ -105,6 +106,10 @@ function InstallationTabs(props: InstallationTabsProps) { // TODO: SodistoreGrid — PV View excluded for product 4, add back when data path is ready const hidePvView = props.product === 4; + // Checklist is only shown for Sodistore Grid (product=4) in the Installations view. + // Salimax (0) / Salidomo (1) / SodistoreMax (3) use different onboarding flows. + const showChecklist = props.product === 4; + const singleInstallationTabs = ( currentUser.userType == UserType.admin ? [ @@ -175,6 +180,10 @@ function InstallationTabs(props: InstallationTabsProps) { { value: 'documents', label: + }, + { + value: 'checklist', + label: } ] : currentUser.userType == UserType.partner @@ -229,7 +238,9 @@ function InstallationTabs(props: InstallationTabsProps) { ) } ] - ).filter((tab) => !(hidePvView && tab.value === 'pvview')); + ) + .filter((tab) => !(hidePvView && tab.value === 'pvview')) + .filter((tab) => !(!showChecklist && tab.value === 'checklist')); const tabs = currentTab != 'list' && @@ -316,6 +327,10 @@ function InstallationTabs(props: InstallationTabsProps) { { value: 'documents', label: + }, + { + value: 'checklist', + label: } ] : currentUser.userType == UserType.partner @@ -410,9 +425,10 @@ function InstallationTabs(props: InstallationTabsProps) { ]; // Filter out PV View for SodistoreGrid - const filteredTabs = hidePvView + const filteredTabs = (hidePvView ? tabs.filter((tab) => tab.value !== 'pvview') - : tabs; + : tabs + ).filter((tab) => !(!showChecklist && tab.value === 'checklist')); return installations.length > 1 ? ( <> diff --git a/typescript/frontend-marios2/src/content/dashboards/SodiohomeInstallations/Installation.tsx b/typescript/frontend-marios2/src/content/dashboards/SodiohomeInstallations/Installation.tsx index 19460ce48..40493171c 100644 --- a/typescript/frontend-marios2/src/content/dashboards/SodiohomeInstallations/Installation.tsx +++ b/typescript/frontend-marios2/src/content/dashboards/SodiohomeInstallations/Installation.tsx @@ -30,6 +30,8 @@ import Overview from '../Overview/overview'; import WeeklyReport from './WeeklyReport'; import InstallationTicketsTab from '../Tickets/InstallationTicketsTab'; import DocumentsTab from '../Documents/DocumentsTab'; +import InstallationChecklistTab from '../Checklist/InstallationChecklistTab'; +import { CHECKLIST_ENABLED_PRODUCTS } from 'src/interfaces/ChecklistTypes'; interface singleInstallationProps { current_installation?: I_Installation; @@ -670,6 +672,18 @@ function SodioHomeInstallation(props: singleInstallationProps) { /> )} + {currentUser.userType == UserType.admin && + CHECKLIST_ENABLED_PRODUCTS.has(props.current_installation.product) && ( + + } + /> + )} + } diff --git a/typescript/frontend-marios2/src/content/dashboards/SodiohomeInstallations/index.tsx b/typescript/frontend-marios2/src/content/dashboards/SodiohomeInstallations/index.tsx index 1b55d00e7..cdf0710d1 100644 --- a/typescript/frontend-marios2/src/content/dashboards/SodiohomeInstallations/index.tsx +++ b/typescript/frontend-marios2/src/content/dashboards/SodiohomeInstallations/index.tsx @@ -53,7 +53,8 @@ function SodioHomeInstallationTabs(props: SodioHomeInstallationTabsProps) { 'configuration', 'report', 'installationTickets', - 'documents' + 'documents', + 'checklist' ]; const [currentTab, setCurrentTab] = useState(undefined); @@ -192,6 +193,10 @@ function SodioHomeInstallationTabs(props: SodioHomeInstallationTabsProps) { { value: 'documents', label: + }, + { + value: 'checklist', + label: } ] : currentUser.userType == UserType.partner @@ -361,6 +366,10 @@ function SodioHomeInstallationTabs(props: SodioHomeInstallationTabsProps) { { value: 'documents', label: + }, + { + value: 'checklist', + label: } ] : inInstallationView && currentUser.userType == UserType.partner diff --git a/typescript/frontend-marios2/src/interfaces/ChecklistTypes.tsx b/typescript/frontend-marios2/src/interfaces/ChecklistTypes.tsx new file mode 100644 index 000000000..7cc733128 --- /dev/null +++ b/typescript/frontend-marios2/src/interfaces/ChecklistTypes.tsx @@ -0,0 +1,43 @@ +export enum ChecklistStatus { + NotStarted = 0, + InProgress = 1, + Done = 2 +} + +export type ChecklistSubtask = { + text: string; + checked: boolean; +}; + +export type ChecklistItem = { + id: number; + installationId: number; + stepNumber: number; + stepTitle: string; + status: number; + comments: string; + assigneeId: number | null; + doneAt: string | null; + subtasks: string | null; + createdAt: string; + updatedAt: string; +}; + +export const CHECKLIST_ENABLED_PRODUCTS: ReadonlySet = new Set([2, 4, 5]); + +export function parseSubtasks(raw: string | null | undefined): ChecklistSubtask[] { + if (!raw) return []; + try { + const parsed = JSON.parse(raw); + if (!Array.isArray(parsed)) return []; + return parsed + .filter((x) => x && typeof x.text === 'string') + .map((x) => ({ text: String(x.text), checked: Boolean(x.checked) })); + } catch { + return []; + } +} + +export function serializeSubtasks(subtasks: ChecklistSubtask[]): string { + return JSON.stringify(subtasks); +} diff --git a/typescript/frontend-marios2/src/lang/de.json b/typescript/frontend-marios2/src/lang/de.json index ecb97852c..8186e07dc 100644 --- a/typescript/frontend-marios2/src/lang/de.json +++ b/typescript/frontend-marios2/src/lang/de.json @@ -685,5 +685,24 @@ "fileTooLarge": "Datei überschreitet die maximale Grösse von 25 MB.", "invalidFileType": "Ungültiger Dateityp.", "uploadFailed": "Hochladen fehlgeschlagen.", - "uploadSuccess": "Hochladen erfolgreich." + "uploadSuccess": "Hochladen erfolgreich.", + "checklist": "Checkliste", + "checklistTitle": "Schritte zur Anbindung der Installation an Monitor", + "checklistProgress": "Fortschritt: {done}/{total} ({percent}%)", + "checklistStep": "Schritt", + "checklistStatus": "Status", + "checklistAssignee": "Zuständig", + "checklistDateDone": "Erledigungsdatum", + "checklistComments": "Kommentare", + "checklistNotStarted": "Nicht gestartet", + "checklistInProgress": "In Bearbeitung", + "checklistDone": "Erledigt", + "checklistNoAssignee": "Nicht zugewiesen", + "checklistNotifyTooltip": "E-Mail-Benachrichtigung an Zuständige senden", + "checklistNotifyDisabledTooltip": "Weisen Sie zuerst jemanden zu, um eine Benachrichtigung zu senden", + "checklistToggleSubtasks": "Unteraufgaben umschalten", + "checklistCommentsPlaceholder": "Notizen, Kontaktinformationen, Beobachtungen…", + "checklistEmailSent": "E-Mail an {name} gesendet", + "checklistEmailFailed": "E-Mail-Versand fehlgeschlagen — bitte erneut versuchen", + "checklistSaveFailed": "Änderung konnte nicht gespeichert werden" } \ No newline at end of file diff --git a/typescript/frontend-marios2/src/lang/en.json b/typescript/frontend-marios2/src/lang/en.json index 5b5e67b91..9e92d0e07 100644 --- a/typescript/frontend-marios2/src/lang/en.json +++ b/typescript/frontend-marios2/src/lang/en.json @@ -433,5 +433,24 @@ "fileTooLarge": "File exceeds maximum size of 25 MB.", "invalidFileType": "Invalid file type.", "uploadFailed": "Upload failed.", - "uploadSuccess": "Upload successful." + "uploadSuccess": "Upload successful.", + "checklist": "Checklist", + "checklistTitle": "Steps to Bring Installation to Monitor", + "checklistProgress": "Progress: {done}/{total} ({percent}%)", + "checklistStep": "Step", + "checklistStatus": "Status", + "checklistAssignee": "Assignee", + "checklistDateDone": "Date Done", + "checklistComments": "Comments", + "checklistNotStarted": "Not Started", + "checklistInProgress": "In Progress", + "checklistDone": "Done", + "checklistNoAssignee": "Unassigned", + "checklistNotifyTooltip": "Send email notification to assignee", + "checklistNotifyDisabledTooltip": "Assign someone first to send a notification", + "checklistToggleSubtasks": "Toggle subtasks", + "checklistCommentsPlaceholder": "Notes, contact info, observations…", + "checklistEmailSent": "Email sent to {name}", + "checklistEmailFailed": "Failed to send email — try again", + "checklistSaveFailed": "Failed to save change" } diff --git a/typescript/frontend-marios2/src/lang/fr.json b/typescript/frontend-marios2/src/lang/fr.json index d6cdc6f62..489848c72 100644 --- a/typescript/frontend-marios2/src/lang/fr.json +++ b/typescript/frontend-marios2/src/lang/fr.json @@ -685,5 +685,24 @@ "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." + "uploadSuccess": "Téléchargement réussi.", + "checklist": "Checklist", + "checklistTitle": "Étapes pour connecter l'installation à Monitor", + "checklistProgress": "Progression : {done}/{total} ({percent}%)", + "checklistStep": "Étape", + "checklistStatus": "Statut", + "checklistAssignee": "Responsable", + "checklistDateDone": "Date de réalisation", + "checklistComments": "Commentaires", + "checklistNotStarted": "Non commencé", + "checklistInProgress": "En cours", + "checklistDone": "Terminé", + "checklistNoAssignee": "Non attribué", + "checklistNotifyTooltip": "Envoyer une notification par e-mail au responsable", + "checklistNotifyDisabledTooltip": "Attribuez d'abord un responsable pour envoyer une notification", + "checklistToggleSubtasks": "Afficher/masquer les sous-tâches", + "checklistCommentsPlaceholder": "Notes, coordonnées, observations…", + "checklistEmailSent": "E-mail envoyé à {name}", + "checklistEmailFailed": "Échec de l'envoi — veuillez réessayer", + "checklistSaveFailed": "Échec de l'enregistrement" } \ No newline at end of file diff --git a/typescript/frontend-marios2/src/lang/it.json b/typescript/frontend-marios2/src/lang/it.json index 59a8c11ce..fc5df8d5f 100644 --- a/typescript/frontend-marios2/src/lang/it.json +++ b/typescript/frontend-marios2/src/lang/it.json @@ -685,5 +685,24 @@ "fileTooLarge": "Il file supera la dimensione massima di 25 MB.", "invalidFileType": "Tipo di file non valido.", "uploadFailed": "Caricamento fallito.", - "uploadSuccess": "Caricamento riuscito." + "uploadSuccess": "Caricamento riuscito.", + "checklist": "Checklist", + "checklistTitle": "Passi per collegare l'installazione a Monitor", + "checklistProgress": "Avanzamento: {done}/{total} ({percent}%)", + "checklistStep": "Passo", + "checklistStatus": "Stato", + "checklistAssignee": "Assegnatario", + "checklistDateDone": "Data completamento", + "checklistComments": "Commenti", + "checklistNotStarted": "Non avviato", + "checklistInProgress": "In corso", + "checklistDone": "Completato", + "checklistNoAssignee": "Non assegnato", + "checklistNotifyTooltip": "Invia notifica e-mail all'assegnatario", + "checklistNotifyDisabledTooltip": "Assegnare prima qualcuno per inviare una notifica", + "checklistToggleSubtasks": "Mostra/nascondi sottoattività", + "checklistCommentsPlaceholder": "Note, contatti, osservazioni…", + "checklistEmailSent": "E-mail inviata a {name}", + "checklistEmailFailed": "Invio e-mail non riuscito — riprovare", + "checklistSaveFailed": "Salvataggio non riuscito" }