checklist page version1.0
This commit is contained in:
parent
5666191a6b
commit
11940b4684
|
|
@ -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.");
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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),
|
||||
};
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -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)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -26,5 +26,6 @@
|
|||
"report": "report",
|
||||
"installationTickets": "installationTickets",
|
||||
"documents": "documents",
|
||||
"checklist": "checklist",
|
||||
"tickets": "/tickets/"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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>}
|
||||
|
|
|
|||
|
|
@ -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 ? (
|
||||
<>
|
||||
|
|
|
|||
|
|
@ -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>}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue