checklist page version1.0

This commit is contained in:
Yinyin Liu 2026-04-21 13:35:56 +02:00
parent 5666191a6b
commit 11940b4684
21 changed files with 1003 additions and 9 deletions

View File

@ -2740,4 +2740,94 @@ public class Controller : ControllerBase
return Ok();
}
// ── Checklist ───────────────────────────────────────────────────────
[HttpGet(nameof(GetChecklistForInstallation))]
public ActionResult<IEnumerable<ChecklistItem>> 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<ChecklistItem> 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<ActionResult> 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.");
}
}
}

View File

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

View File

@ -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<ChecklistStepDefinition> Steps = new List<ChecklistStepDefinition>
{
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),
};
}

View File

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

View File

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

View File

@ -42,6 +42,9 @@ public static partial class Db
// Document storage
public static TableQuery<Document> Documents => Connection.Table<Document>();
// Checklist
public static TableQuery<ChecklistItem> ChecklistItems => Connection.Table<ChecklistItem>();
public static void Init()
{
@ -83,6 +86,9 @@ public static partial class Db
// Document storage
Connection.CreateTable<Document>();
// Checklist
Connection.CreateTable<ChecklistItem>();
});
// 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<Document>();
// Checklist
fileConnection.CreateTable<ChecklistItem>();
// Migrate new columns: set defaults for existing rows where NULL or empty
fileConnection.Execute("UPDATE Installation SET ExternalEms = 'No' WHERE ExternalEms IS NULL OR ExternalEms = ''");
fileConnection.Execute("UPDATE Installation SET InstallationModel = '' WHERE InstallationModel IS NULL");

View File

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

View File

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

View File

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

View File

@ -26,5 +26,6 @@
"report": "report",
"installationTickets": "installationTickets",
"documents": "documents",
"checklist": "checklist",
"tickets": "/tickets/"
}

View File

@ -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<boolean>;
onNotify: (id: number) => Promise<boolean>;
}
const statusColors: Record<number, string> = {
[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<EmailIconState>('idle');
const [subtasksOpen, setSubtasksOpen] = useState(false);
const [subtasks, setSubtasks] = useState<ChecklistSubtask[]>(() =>
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 <CheckIcon sx={{ color: '#2e7d32' }} />;
if (emailState === 'error') return <CloseIcon sx={{ color: '#d32f2f' }} />;
return <EmailIcon />;
};
const filteredAdmins = useMemo(
() =>
adminUsers.filter((u) => {
const name = (u.name ?? '').toLowerCase();
return (
!name.includes('inesco energy master admin') &&
!name.includes('paal myhre')
);
}),
[adminUsers]
);
return (
<>
<TableRow hover>
<TableCell sx={{ width: 48, verticalAlign: 'top' }}>
<Typography variant="body2" fontWeight={600}>
{item.stepNumber}
</Typography>
</TableCell>
<TableCell sx={{ verticalAlign: 'top', minWidth: 240 }}>
<Stack direction="row" spacing={1} alignItems="center">
<Typography variant="body2">{item.stepTitle}</Typography>
{subtasks.length > 0 && (
<>
<Chip
size="small"
label={subtaskSummary}
color={
subtasks.every((s) => s.checked) ? 'success' : 'default'
}
/>
<IconButton
size="small"
onClick={() => setSubtasksOpen((o) => !o)}
aria-label={intl.formatMessage({
id: 'checklistToggleSubtasks',
defaultMessage: 'Toggle subtasks'
})}
>
{subtasksOpen ? <ExpandLessIcon /> : <ExpandMoreIcon />}
</IconButton>
</>
)}
</Stack>
</TableCell>
<TableCell sx={{ verticalAlign: 'top', width: 160 }}>
<FormControl size="small" fullWidth>
<Select
value={item.status}
onChange={(e) => handleStatusChange(Number(e.target.value))}
sx={{
'& .MuiSelect-select': {
color: statusColors[item.status] ?? 'inherit',
fontWeight: 600
}
}}
>
<MenuItem value={ChecklistStatus.NotStarted}>
<FormattedMessage id="checklistNotStarted" defaultMessage="Not Started" />
</MenuItem>
<MenuItem value={ChecklistStatus.InProgress}>
<FormattedMessage id="checklistInProgress" defaultMessage="In Progress" />
</MenuItem>
<MenuItem value={ChecklistStatus.Done}>
<FormattedMessage id="checklistDone" defaultMessage="Done" />
</MenuItem>
</Select>
</FormControl>
</TableCell>
<TableCell sx={{ verticalAlign: 'top', width: 260 }}>
<Stack direction="row" spacing={0.5} alignItems="center">
<FormControl size="small" sx={{ flex: 1 }}>
<Select
value={item.assigneeId ?? ''}
displayEmpty
onChange={(e) => handleAssigneeChange(String(e.target.value))}
>
<MenuItem value="">
<em>
<FormattedMessage
id="checklistNoAssignee"
defaultMessage="Unassigned"
/>
</em>
</MenuItem>
{filteredAdmins.map((u) => (
<MenuItem key={u.id} value={u.id}>
{u.name}
</MenuItem>
))}
</Select>
</FormControl>
<Tooltip
title={
!item.assigneeId
? intl.formatMessage({
id: 'checklistNotifyDisabledTooltip',
defaultMessage: 'Assign someone first to send a notification'
})
: intl.formatMessage({
id: 'checklistNotifyTooltip',
defaultMessage: 'Send email notification to assignee'
})
}
>
<span>
<IconButton
size="small"
disabled={!item.assigneeId || emailState === 'loading'}
onClick={handleNotifyClick}
>
{renderEmailIcon()}
</IconButton>
</span>
</Tooltip>
</Stack>
</TableCell>
<TableCell sx={{ verticalAlign: 'top', width: 160 }}>
<TextField
size="small"
type="date"
value={item.doneAt ?? ''}
onChange={(e) => handleDoneAtChange(e.target.value)}
InputLabelProps={{ shrink: true }}
fullWidth
/>
</TableCell>
<TableCell sx={{ verticalAlign: 'top', minWidth: 280 }}>
<TextField
size="small"
multiline
minRows={1}
maxRows={6}
value={comments}
onChange={(e) => setComments(e.target.value)}
onBlur={handleCommentsBlur}
fullWidth
placeholder={intl.formatMessage({
id: 'checklistCommentsPlaceholder',
defaultMessage: 'Notes, contact info, observations…'
})}
/>
</TableCell>
</TableRow>
{subtasks.length > 0 && subtasksOpen && (
<TableRow>
<TableCell colSpan={6} sx={{ backgroundColor: '#fafafa', py: 1 }}>
<Box pl={6}>
{subtasks.map((s, i) => (
<FormControlLabel
key={i}
control={
<Checkbox
size="small"
checked={s.checked}
onChange={() => handleSubtaskToggle(i)}
/>
}
label={<Typography variant="body2">{s.text}</Typography>}
sx={{ display: 'block', ml: 0 }}
/>
))}
</Box>
</TableCell>
</TableRow>
)}
</>
);
}
export default ChecklistStepRow;

View File

@ -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<ChecklistItem[]>([]);
const [adminUsers, setAdminUsers] = useState<AdminUser[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [toast, setToast] = useState<ToastState>({
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<string, unknown> = { 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 (
<Box p={3}>
<LinearProgress />
</Box>
);
}
if (error) {
return (
<Box p={3}>
<Alert severity="error">{error}</Alert>
</Box>
);
}
return (
<Box p={2}>
<Box mb={2}>
<Box display="flex" justifyContent="space-between" alignItems="center" mb={1}>
<Typography variant="h4">
<FormattedMessage
id="checklistTitle"
defaultMessage="Steps to Bring Installation to Monitor"
/>
</Typography>
<Typography variant="body2" color="text.secondary">
<FormattedMessage
id="checklistProgress"
defaultMessage="Progress: {done}/{total} ({percent}%)"
values={progress}
/>
</Typography>
</Box>
<LinearProgress
variant="determinate"
value={progress.percent}
sx={{ height: 8, borderRadius: 4 }}
/>
</Box>
<TableContainer component={Paper} variant="outlined">
<Table size="small">
<TableHead>
<TableRow>
<TableCell>#</TableCell>
<TableCell>
<FormattedMessage id="checklistStep" defaultMessage="Step" />
</TableCell>
<TableCell>
<FormattedMessage id="checklistStatus" defaultMessage="Status" />
</TableCell>
<TableCell>
<FormattedMessage id="checklistAssignee" defaultMessage="Assignee" />
</TableCell>
<TableCell>
<FormattedMessage id="checklistDateDone" defaultMessage="Date Done" />
</TableCell>
<TableCell>
<FormattedMessage id="checklistComments" defaultMessage="Comments" />
</TableCell>
</TableRow>
</TableHead>
<TableBody>
{items.map((item) => (
<ChecklistStepRow
key={item.id}
item={item}
adminUsers={adminUsers}
onUpdate={handleUpdate}
onNotify={handleNotify}
/>
))}
</TableBody>
</Table>
</TableContainer>
<Snackbar
open={toast.open}
autoHideDuration={4000}
onClose={() => setToast((t) => ({ ...t, open: false }))}
anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }}
>
<Alert
severity={toast.severity}
onClose={() => setToast((t) => ({ ...t, open: false }))}
>
{toast.message}
</Alert>
</Snackbar>
</Box>
);
}
export default InstallationChecklistTab;

View File

@ -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) && (
<Route
path={routes.checklist}
element={
<InstallationChecklistTab
installationId={props.current_installation.id}
/>
}
/>
)}
<Route
path={'*'}
element={<Navigate to={routes.live}></Navigate>}

View File

@ -34,7 +34,8 @@ function InstallationTabs(props: InstallationTabsProps) {
'history',
'pvview',
'installationTickets',
'documents'
'documents',
'checklist'
];
const [currentTab, setCurrentTab] = useState<string>(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: <FormattedMessage id="documentsTab" defaultMessage="Documents" />
},
{
value: 'checklist',
label: <FormattedMessage id="checklist" defaultMessage="Checklist" />
}
]
: 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: <FormattedMessage id="documentsTab" defaultMessage="Documents" />
},
{
value: 'checklist',
label: <FormattedMessage id="checklist" defaultMessage="Checklist" />
}
]
: 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 ? (
<>

View File

@ -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) && (
<Route
path={routes.checklist}
element={
<InstallationChecklistTab
installationId={props.current_installation.id}
/>
}
/>
)}
<Route
path={'*'}
element={<Navigate to={dataCollectionDisabled ? routes.information : routes.live}></Navigate>}

View File

@ -53,7 +53,8 @@ function SodioHomeInstallationTabs(props: SodioHomeInstallationTabsProps) {
'configuration',
'report',
'installationTickets',
'documents'
'documents',
'checklist'
];
const [currentTab, setCurrentTab] = useState<string>(undefined);
@ -192,6 +193,10 @@ function SodioHomeInstallationTabs(props: SodioHomeInstallationTabsProps) {
{
value: 'documents',
label: <FormattedMessage id="documentsTab" defaultMessage="Documents" />
},
{
value: 'checklist',
label: <FormattedMessage id="checklist" defaultMessage="Checklist" />
}
]
: currentUser.userType == UserType.partner
@ -361,6 +366,10 @@ function SodioHomeInstallationTabs(props: SodioHomeInstallationTabsProps) {
{
value: 'documents',
label: <FormattedMessage id="documentsTab" defaultMessage="Documents" />
},
{
value: 'checklist',
label: <FormattedMessage id="checklist" defaultMessage="Checklist" />
}
]
: inInstallationView && currentUser.userType == UserType.partner

View File

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

View File

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

View File

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

View File

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

View File

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