add substep to upload delivery confirmation document and sync in document page

This commit is contained in:
Yinyin Liu 2026-04-28 08:57:03 +02:00
parent faec16f6fe
commit 45c816616f
16 changed files with 202 additions and 26 deletions

View File

@ -2598,6 +2598,7 @@ public class Controller : ControllerBase
[FromQuery] Int64? ticketId, [FromQuery] Int64? ticketId,
[FromQuery] Int64? ticketCommentId, [FromQuery] Int64? ticketCommentId,
[FromQuery] Int64? installationId, [FromQuery] Int64? installationId,
[FromQuery] Int64? checklistItemId,
[FromQuery] Token authToken) [FromQuery] Token authToken)
{ {
var user = Db.GetSession(authToken)?.User; var user = Db.GetSession(authToken)?.User;
@ -2676,6 +2677,7 @@ public class Controller : ControllerBase
TicketId = ticketId, TicketId = ticketId,
TicketCommentId = ticketCommentId, TicketCommentId = ticketCommentId,
InstallationId = installationId, InstallationId = installationId,
ChecklistItemId = checklistItemId,
Scope = scope, Scope = scope,
S3Key = s3Key, S3Key = s3Key,
OriginalName = safeFileName, OriginalName = safeFileName,
@ -2725,6 +2727,7 @@ public class Controller : ControllerBase
[FromQuery] Int64? ticketId, [FromQuery] Int64? ticketId,
[FromQuery] Int64? ticketCommentId, [FromQuery] Int64? ticketCommentId,
[FromQuery] Int64? installationId, [FromQuery] Int64? installationId,
[FromQuery] Int64? checklistItemId,
[FromQuery] Token authToken) [FromQuery] Token authToken)
{ {
var user = Db.GetSession(authToken)?.User; var user = Db.GetSession(authToken)?.User;
@ -2736,6 +2739,12 @@ public class Controller : ControllerBase
if (ticketCommentId.HasValue) if (ticketCommentId.HasValue)
return Ok(Db.GetDocumentsForComment(ticketCommentId.Value)); return Ok(Db.GetDocumentsForComment(ticketCommentId.Value));
if (checklistItemId.HasValue)
{
if (user.UserType != 2) return Unauthorized();
return Ok(Db.GetDocumentsForChecklistItem(checklistItemId.Value));
}
if (installationId.HasValue) if (installationId.HasValue)
{ {
// Access control: admin can list all; others need installation access // Access control: admin can list all; others need installation access
@ -2747,7 +2756,7 @@ public class Controller : ControllerBase
return Ok(Db.GetDocumentsForInstallation(installationId.Value)); return Ok(Db.GetDocumentsForInstallation(installationId.Value));
} }
return BadRequest("Provide ticketId, ticketCommentId, or installationId."); return BadRequest("Provide ticketId, ticketCommentId, installationId, or checklistItemId.");
} }
[HttpDelete(nameof(DeleteDocument))] [HttpDelete(nameof(DeleteDocument))]

View File

@ -41,7 +41,12 @@ public static class ChecklistStepDefinitions
{"text":"checklistStep7Sub4","checked":false} {"text":"checklistStep7Sub4","checked":false}
] ]
"""), """),
new( 8, "Installation delivered to customer site", NoSubtasks), new( 8, "Installation delivered to customer site",
"""
[
{"text":"checklistStep8Sub1","checked":false}
]
"""),
new( 9, "Installation connected to grid", NoSubtasks), new( 9, "Installation connected to grid", NoSubtasks),
new(10, "Hardware verified on site", NoSubtasks), new(10, "Hardware verified on site", NoSubtasks),
new(11, "Software verified on site", NoSubtasks), new(11, "Software verified on site", NoSubtasks),

View File

@ -15,6 +15,7 @@ public class Document
[Indexed] public Int64? TicketId { get; set; } [Indexed] public Int64? TicketId { get; set; }
[Indexed] public Int64? TicketCommentId { get; set; } [Indexed] public Int64? TicketCommentId { get; set; }
[Indexed] public Int64? InstallationId { get; set; } [Indexed] public Int64? InstallationId { get; set; }
[Indexed] public Int64? ChecklistItemId { get; set; }
public Int32 Scope { get; set; } = (Int32)DocumentScope.TicketAttachment; public Int32 Scope { get; set; } = (Int32)DocumentScope.TicketAttachment;
public String S3Key { get; set; } = ""; public String S3Key { get; set; } = "";

View File

@ -136,6 +136,12 @@ public static partial class Db
$"\"{oldText}\"", $"\"{key}\"", $"%\"{oldText}\"%"); $"\"{oldText}\"", $"\"{key}\"", $"%\"{oldText}\"%");
} }
// One-time backfill: step 8 originally had no subtasks; add the delivery-receipt subtask
// to existing rows so already-seeded installations pick up the new subtask after deploy.
Connection.Execute(
"UPDATE ChecklistItem SET Subtasks = ? WHERE StepNumber = 8 AND (Subtasks IS NULL OR Subtasks = '')",
"[{\"text\":\"checklistStep8Sub1\",\"checked\":false}]");
//UpdateKeys(); //UpdateKeys();
CleanupSessions().SupressAwaitWarning(); CleanupSessions().SupressAwaitWarning();
DeleteSnapshots().SupressAwaitWarning(); DeleteSnapshots().SupressAwaitWarning();

View File

@ -234,6 +234,13 @@ public static partial class Db
.OrderBy(d => d.CreatedAt) .OrderBy(d => d.CreatedAt)
.ToList(); .ToList();
public static List<Document> GetDocumentsForChecklistItem(Int64 checklistItemId)
=> Documents
.Where(d => d.ChecklistItemId == checklistItemId
&& d.Scope == (Int32)DocumentScope.InstallationDocument)
.OrderBy(d => d.CreatedAt)
.ToList();
// ── Checklist Queries ─────────────────────────────────────────────── // ── Checklist Queries ───────────────────────────────────────────────
public static List<ChecklistItem> GetChecklistForInstallation(Int64 installationId) public static List<ChecklistItem> GetChecklistForInstallation(Int64 installationId)

View File

@ -32,6 +32,7 @@ interface FileUploadButtonProps {
ticketId?: number; ticketId?: number;
ticketCommentId?: number; ticketCommentId?: number;
installationId?: number; installationId?: number;
checklistItemId?: number;
onUploaded?: (doc: UploadedDocument) => void; onUploaded?: (doc: UploadedDocument) => void;
disabled?: boolean; disabled?: boolean;
} }
@ -41,6 +42,7 @@ function FileUploadButton({
ticketId, ticketId,
ticketCommentId, ticketCommentId,
installationId, installationId,
checklistItemId,
onUploaded, onUploaded,
disabled = false disabled = false
}: FileUploadButtonProps) { }: FileUploadButtonProps) {
@ -87,7 +89,7 @@ function FileUploadButton({
try { try {
const res = await axiosConfig.post('/UploadDocument', formData, { const res = await axiosConfig.post('/UploadDocument', formData, {
params: { scope, ticketId, ticketCommentId, installationId }, params: { scope, ticketId, ticketCommentId, installationId, checklistItemId },
headers: { 'Content-Type': 'multipart/form-data' }, headers: { 'Content-Type': 'multipart/form-data' },
onUploadProgress: (e) => { onUploadProgress: (e) => {
if (e.total) setProgress(Math.round((e.loaded * 100) / e.total)); if (e.total) setProgress(Math.round((e.loaded * 100) / e.total));

View File

@ -26,14 +26,17 @@ import {
ChecklistStatus, ChecklistStatus,
ChecklistSubtask, ChecklistSubtask,
parseSubtasks, parseSubtasks,
serializeSubtasks serializeSubtasks,
UPLOADABLE_SUBTASK_KEYS
} from 'src/interfaces/ChecklistTypes'; } from 'src/interfaces/ChecklistTypes';
import { AdminUser } from 'src/interfaces/TicketTypes'; import { AdminUser } from 'src/interfaces/TicketTypes';
import SubtaskDocumentUpload from './SubtaskDocumentUpload';
type EmailIconState = 'idle' | 'loading' | 'success' | 'error'; type EmailIconState = 'idle' | 'loading' | 'success' | 'error';
interface Props { interface Props {
item: ChecklistItem; item: ChecklistItem;
installationId: number;
adminUsers: AdminUser[]; adminUsers: AdminUser[];
onUpdate: ( onUpdate: (
id: number, id: number,
@ -54,7 +57,7 @@ const statusColors: Record<number, string> = {
[ChecklistStatus.Done]: '#2e7d32' [ChecklistStatus.Done]: '#2e7d32'
}; };
function ChecklistStepRow({ item, adminUsers, onUpdate, onNotify }: Props) { function ChecklistStepRow({ item, installationId, adminUsers, onUpdate, onNotify }: Props) {
const intl = useIntl(); const intl = useIntl();
const [comments, setComments] = useState(item.comments ?? ''); const [comments, setComments] = useState(item.comments ?? '');
const [emailState, setEmailState] = useState<EmailIconState>('idle'); const [emailState, setEmailState] = useState<EmailIconState>('idle');
@ -282,22 +285,29 @@ function ChecklistStepRow({ item, adminUsers, onUpdate, onNotify }: Props) {
<TableCell colSpan={6} sx={{ backgroundColor: '#fafafa', py: 1 }}> <TableCell colSpan={6} sx={{ backgroundColor: '#fafafa', py: 1 }}>
<Box pl={6} display="flex" flexDirection="column"> <Box pl={6} display="flex" flexDirection="column">
{subtasks.map((s, i) => ( {subtasks.map((s, i) => (
<FormControlLabel <Box key={i}>
key={i} <FormControlLabel
control={ control={
<Checkbox <Checkbox
size="small" size="small"
checked={s.checked} checked={s.checked}
onChange={() => handleSubtaskToggle(i)} onChange={() => handleSubtaskToggle(i)}
/>
}
label={
<Typography variant="body2" component="span">
{intl.formatMessage({ id: s.text, defaultMessage: s.text })}
</Typography>
}
sx={{ ml: 0 }}
/>
{UPLOADABLE_SUBTASK_KEYS.has(s.text) && (
<SubtaskDocumentUpload
installationId={installationId}
checklistItemId={item.id}
/> />
} )}
label={ </Box>
<Typography variant="body2" component="span">
{intl.formatMessage({ id: s.text, defaultMessage: s.text })}
</Typography>
}
sx={{ ml: 0 }}
/>
))} ))}
</Box> </Box>
</TableCell> </TableCell>

View File

@ -235,6 +235,7 @@ function InstallationChecklistTab({ installationId }: Props) {
<ChecklistStepRow <ChecklistStepRow
key={item.id} key={item.id}
item={item} item={item}
installationId={installationId}
adminUsers={adminUsers} adminUsers={adminUsers}
onUpdate={handleUpdate} onUpdate={handleUpdate}
onNotify={handleNotify} onNotify={handleNotify}

View File

@ -0,0 +1,122 @@
import React, { useCallback, useEffect, useState } from 'react';
import { Box, IconButton, Stack, Typography } from '@mui/material';
import DownloadIcon from '@mui/icons-material/Download';
import InsertDriveFileIcon from '@mui/icons-material/InsertDriveFile';
import PictureAsPdfIcon from '@mui/icons-material/PictureAsPdf';
import { FormattedMessage } from 'react-intl';
import axiosConfig from 'src/Resources/axiosConfig';
import FileUploadButton, {
UploadedDocument
} from 'src/components/FileUploadButton';
interface DocumentItem {
id: number;
originalName: string;
contentType: string;
sizeBytes: number;
createdAt: string;
}
interface Props {
installationId: number;
checklistItemId: number;
}
function fileIcon(contentType: string) {
if (contentType === 'application/pdf')
return <PictureAsPdfIcon fontSize="small" color="error" />;
return <InsertDriveFileIcon fontSize="small" />;
}
function SubtaskDocumentUpload({ installationId, checklistItemId }: Props) {
const [docs, setDocs] = useState<DocumentItem[]>([]);
const [refreshKey, setRefreshKey] = useState(0);
const fetchDocs = useCallback(() => {
axiosConfig
.get('/GetDocuments', { params: { checklistItemId } })
.then((res) => {
if (Array.isArray(res.data)) setDocs(res.data);
})
.catch(() => setDocs([]));
}, [checklistItemId]);
useEffect(() => {
fetchDocs();
}, [fetchDocs, refreshKey]);
const handleDownload = (doc: DocumentItem) => {
axiosConfig
.get('/DownloadDocument', {
params: { id: doc.id },
responseType: 'blob'
})
.then((res) => {
const url = window.URL.createObjectURL(new Blob([res.data]));
const link = document.createElement('a');
link.href = url;
link.setAttribute('download', doc.originalName);
document.body.appendChild(link);
link.click();
link.remove();
window.URL.revokeObjectURL(url);
})
.catch(() => {});
};
const handleUploaded = (_doc: UploadedDocument) => {
setRefreshKey((k) => k + 1);
};
return (
<Box mt={0.5} mb={1}>
{docs.length > 0 && (
<Stack spacing={0.25} mb={0.75}>
{docs.map((d) => (
<Stack
key={d.id}
direction="row"
alignItems="center"
spacing={1}
sx={{ pl: 4 }}
>
{fileIcon(d.contentType)}
<Typography variant="body2" sx={{ flex: 1 }} noWrap>
{d.originalName}
</Typography>
<IconButton
size="small"
onClick={() => handleDownload(d)}
aria-label="download"
>
<DownloadIcon fontSize="small" />
</IconButton>
</Stack>
))}
</Stack>
)}
<Box sx={{ pl: 4 }}>
<FileUploadButton
scope={1}
installationId={installationId}
checklistItemId={checklistItemId}
onUploaded={handleUploaded}
/>
</Box>
{docs.length === 0 && (
<Typography
variant="caption"
color="text.secondary"
sx={{ pl: 4, display: 'block', mt: 0.5 }}
>
<FormattedMessage
id="checklistNoAttachments"
defaultMessage="No file attached yet."
/>
</Typography>
)}
</Box>
);
}
export default SubtaskDocumentUpload;

View File

@ -75,7 +75,7 @@ const FlatInstallationView = (props: FlatInstallationViewProps) => {
setProgressMap(map); setProgressMap(map);
}) })
.catch(() => setProgressMap(new Map())); .catch(() => setProgressMap(new Map()));
}, [showChecklistColumn]); }, [showChecklistColumn, currentLocation.pathname]);
const HoverableTableRow = styled(TableRow)(({ theme }) => ({ const HoverableTableRow = styled(TableRow)(({ theme }) => ({
cursor: 'pointer', cursor: 'pointer',

View File

@ -44,12 +44,15 @@ const FlatInstallationView = (props: FlatInstallationViewProps) => {
const showChecklistColumn = const showChecklistColumn =
currentUser?.userType === UserType.admin && currentUser?.userType === UserType.admin &&
CHECKLIST_ENABLED_PRODUCTS.has(props.product ?? -1); CHECKLIST_ENABLED_PRODUCTS.has(props.product ?? -1);
const isListViewPath =
currentLocation.pathname === baseRoute + 'list' ||
currentLocation.pathname === baseRoute + routes.list;
const [progressMap, setProgressMap] = useState<Map<number, ChecklistSummary>>( const [progressMap, setProgressMap] = useState<Map<number, ChecklistSummary>>(
new Map() new Map()
); );
useEffect(() => { useEffect(() => {
if (!showChecklistColumn) return; if (!showChecklistColumn || !isListViewPath) return;
axiosConfig axiosConfig
.get('/GetChecklistSummary') .get('/GetChecklistSummary')
.then((res) => { .then((res) => {
@ -61,7 +64,7 @@ const FlatInstallationView = (props: FlatInstallationViewProps) => {
setProgressMap(map); setProgressMap(map);
}) })
.catch(() => setProgressMap(new Map())); .catch(() => setProgressMap(new Map()));
}, [showChecklistColumn]); }, [showChecklistColumn, isListViewPath]);
const sortedInstallations = useMemo(() => { const sortedInstallations = useMemo(() => {
return [...props.installations].sort((a, b) => { return [...props.installations].sort((a, b) => {
@ -114,9 +117,7 @@ const FlatInstallationView = (props: FlatInstallationViewProps) => {
} }
})); }));
const isListView = const isListView = isListViewPath;
currentLocation.pathname === baseRoute + 'list' ||
currentLocation.pathname === baseRoute + routes.list;
return ( return (
<Grid container spacing={1} sx={{ marginTop: 0.1 }}> <Grid container spacing={1} sx={{ marginTop: 0.1 }}>

View File

@ -25,6 +25,10 @@ export type ChecklistItem = {
export const CHECKLIST_ENABLED_PRODUCTS: ReadonlySet<number> = new Set([2, 4, 5]); export const CHECKLIST_ENABLED_PRODUCTS: ReadonlySet<number> = new Set([2, 4, 5]);
export const UPLOADABLE_SUBTASK_KEYS: ReadonlySet<string> = new Set([
'checklistStep8Sub1'
]);
export type ChecklistSummary = { export type ChecklistSummary = {
installationId: number; installationId: number;
done: number; done: number;

View File

@ -743,6 +743,8 @@
"checklistStep7Sub2": "Produkt-ID in config.json konfiguriert", "checklistStep7Sub2": "Produkt-ID in config.json konfiguriert",
"checklistStep7Sub3": "USB-ID in config.json konfiguriert", "checklistStep7Sub3": "USB-ID in config.json konfiguriert",
"checklistStep7Sub4": "Datenlesung vom Wechselrichter getestet", "checklistStep7Sub4": "Datenlesung vom Wechselrichter getestet",
"checklistStep8Sub1": "Lieferschein mit Kundenunterschrift erhalten und hochgeladen",
"checklistNoAttachments": "Noch keine Datei angehängt.",
"setupProgress": "Setup-Fortschritt", "setupProgress": "Setup-Fortschritt",
"checklistPhaseEmpty": "Nicht gestartet", "checklistPhaseEmpty": "Nicht gestartet",
"checklistPhasePreparation": "Vorbereitung", "checklistPhasePreparation": "Vorbereitung",

View File

@ -491,6 +491,8 @@
"checklistStep7Sub2": "Product ID configured in config.json", "checklistStep7Sub2": "Product ID configured in config.json",
"checklistStep7Sub3": "USB ID configured in config.json", "checklistStep7Sub3": "USB ID configured in config.json",
"checklistStep7Sub4": "Inverter data reading from inverter tested", "checklistStep7Sub4": "Inverter data reading from inverter tested",
"checklistStep8Sub1": "Delivery receipt with customer signature received and uploaded",
"checklistNoAttachments": "No file attached yet.",
"setupProgress": "Setup Progress", "setupProgress": "Setup Progress",
"checklistPhaseEmpty": "Not started", "checklistPhaseEmpty": "Not started",
"checklistPhasePreparation": "Preparation", "checklistPhasePreparation": "Preparation",

View File

@ -743,6 +743,8 @@
"checklistStep7Sub2": "ID produit configuré dans config.json", "checklistStep7Sub2": "ID produit configuré dans config.json",
"checklistStep7Sub3": "ID USB configuré dans config.json", "checklistStep7Sub3": "ID USB configuré dans config.json",
"checklistStep7Sub4": "Lecture des données de l'onduleur testée", "checklistStep7Sub4": "Lecture des données de l'onduleur testée",
"checklistStep8Sub1": "Bon de livraison signé par le client reçu et téléversé",
"checklistNoAttachments": "Aucun fichier joint pour le moment.",
"setupProgress": "Progression installation", "setupProgress": "Progression installation",
"checklistPhaseEmpty": "Non commencé", "checklistPhaseEmpty": "Non commencé",
"checklistPhasePreparation": "Préparation", "checklistPhasePreparation": "Préparation",

View File

@ -743,6 +743,8 @@
"checklistStep7Sub2": "ID prodotto configurato in config.json", "checklistStep7Sub2": "ID prodotto configurato in config.json",
"checklistStep7Sub3": "ID USB configurato in config.json", "checklistStep7Sub3": "ID USB configurato in config.json",
"checklistStep7Sub4": "Lettura dati inverter testata", "checklistStep7Sub4": "Lettura dati inverter testata",
"checklistStep8Sub1": "Bolla di consegna con firma del cliente ricevuta e caricata",
"checklistNoAttachments": "Nessun file allegato.",
"setupProgress": "Avanzamento installazione", "setupProgress": "Avanzamento installazione",
"checklistPhaseEmpty": "Non avviato", "checklistPhaseEmpty": "Non avviato",
"checklistPhasePreparation": "Preparazione", "checklistPhasePreparation": "Preparazione",