Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ demonstration, not a structural-safety assessment service.
- Submit an incident with a manually selected priority and optional photo.
- Share a public reporting link without granting dashboard access.
- Track a submitted report through an unguessable status link.
- Triage incidents with search across asset names, report IDs, descriptions, and resolution notes; combine status and observed-priority filters, then sort by recency or priority.
- Export just the visible incident reports as spreadsheet-safe CSV, in the displayed order. Tracking codes and photo data are excluded; the file contains the descriptions and notes already visible in your private workspace.
- Record maintenance and resolve incidents with notes; keep the complete history.
- Enable and clear a reversible flood scenario for road assets.
- Use the same application on desktop and mobile.
Expand Down
206 changes: 134 additions & 72 deletions public/static/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
initials,
severityName,
} from "./utils.js";
import { filterReports, reportsCsv } from "./reports.js";

const $ = (selector, parent = document) => parent.querySelector(selector);
const root = $("#root"),
Expand All @@ -20,6 +21,9 @@ const state = {
view: "overview",
archived: false,
reportFilter: "Open",
reportQuery: "",
reportSeverity: "",
reportSort: "newest",
};
let map,
toastTimer,
Expand Down Expand Up @@ -329,6 +333,10 @@ function renderShell() {
try {
await api("/auth/logout", { method: "POST" });
state.account = null;
state.reportQuery = "";
state.reportSeverity = "";
state.reportSort = "newest";
state.reportFilter = "Open";
history.pushState({}, "", "/");
landing();
} catch (e) {
Expand Down Expand Up @@ -994,12 +1002,7 @@ function reportsView() {
"Incident reports",
"Track observations from submission through to documented resolution.",
[
button(
"Share reporting link",
shareReporting,
"button secondary",
"share",
),
button("Share reporting link", shareReporting, "button secondary", "share"),
button("Submit report", () => reportForm(), "button", "plus"),
],
),
Expand All @@ -1012,75 +1015,134 @@ function reportsView() {
["Open", "Open reports"],
["Resolved", "Resolved"],
["", "All history"],
])
tabs.append(
button(
label,
() => {
state.reportFilter = value;
renderShell();
},
state.reportFilter === value ? "active" : "",
),
]) {
const tab = button(
label,
() => {
state.reportFilter = value;
renderShell();
},
state.reportFilter === value ? "active" : "",
);
content.append(el("div", { class: "toolbar" }, tabs));
const items = state.reports.filter(
(r) => !state.reportFilter || r.status === state.reportFilter,
),
grid = el("div", { class: "report-grid" });
for (const r of items) {
const card = el("article", { class: "report-card" }, [
el("div", { class: "row between" }, [
el(
"span",
{ class: "small muted" },
`REPORT #${r.id} · ${date(r.created_at)}`,
),
badge(
r.status === "Resolved" ? "Resolved" : severityName(r.severity),
r.status === "Resolved"
? ""
: r.severity === 15
? "emergency"
: r.severity === 10
? "high"
: "",
),
]),
el("h3", {}, r.asset_name),
el("p", {}, r.description),
r.resolution_note
? el("div", { class: "resolution" }, [
el("strong", {}, "Resolution: "),
r.resolution_note,
])
: null,
el("div", { class: "report-bottom" }, [
button(
r.has_image ? "View details & photo" : "View details",
() => reportDetail(r),
"text-button",
),
r.status === "Open"
? button(
"Resolve report",
() => resolveDialog(r),
"button secondary slim",
"check",
)
: el("span", { class: "small muted" }, date(r.resolved_at)),
]),
]);
grid.append(card);
tab.setAttribute("aria-pressed", String(state.reportFilter === value));
tabs.append(tab);
}
content.append(
items.length
? grid
: el("div", { class: "panel empty" }, [
el("strong", {}, "No reports in this view"),
"Submit an observation to begin the workflow, or choose a different status filter.",
]),
const visible = () =>
filterReports(state.reports, {
query: state.reportQuery,
status: state.reportFilter,
severity: state.reportSeverity,
sort: state.reportSort,
});
const exportButton = button(
"Export visible CSV",
() => {
const rows = visible();
if (!rows.length) return;
const url = URL.createObjectURL(
new Blob([reportsCsv(rows)], { type: "text/csv;charset=utf-8" }),
);
const link = el("a", { href: url, download: "structiq-incident-reports.csv" });
document.body.append(link);
link.click();
link.remove();
setTimeout(() => URL.revokeObjectURL(url), 1000);
toast(`Exported ${rows.length} visible report${rows.length === 1 ? "" : "s"}.`);
},
"button secondary slim",
"download",
);
content.append(el("div", { class: "toolbar" }, [tabs, exportButton]));
const filters = el("div", { class: "report-filters" });
filters.innerHTML = `<div><label for="report-query">Search reports</label><input id="report-query" type="search" placeholder="Asset, report ID, description, or resolution"></div><div><label for="report-severity">Observed priority</label><select id="report-severity"><option value="">All priorities</option><option value="15">High</option><option value="10">Medium</option><option value="5">Low</option></select></div><div><label for="report-sort">Sort reports</label><select id="report-sort"><option value="newest">Newest first</option><option value="oldest">Oldest first</option><option value="priority">Highest priority first</option></select></div>`;
const search = $("input", filters);
const [severity, sort] = filters.querySelectorAll("select");
search.value = state.reportQuery;
severity.value = state.reportSeverity;
sort.value = state.reportSort;
const count = el("p", { class: "small muted", role: "status", style: "margin:0" });
const reset = button(
"Clear filters",
() => {
state.reportFilter = "";
state.reportQuery = "";
state.reportSeverity = "";
state.reportSort = "newest";
renderShell();
$(".report-filters input").focus();
},
"text-button",
);
const area = el("div");
content.append(filters, el("div", { class: "row between report-results" }, [count, reset]), area);
function renderResults() {
const items = visible();
count.textContent = `${items.length} of ${state.reports.length} reports shown`;
exportButton.disabled = items.length === 0;
reset.disabled =
!state.reportQuery &&
!state.reportSeverity &&
!state.reportFilter &&
state.reportSort === "newest";
const grid = el("div", { class: "report-grid" });
for (const r of items) {
const card = el("article", { class: "report-card" }, [
el("div", { class: "row between" }, [
el("span", { class: "small muted" }, `REPORT #${r.id} · ${date(r.created_at)}`),
badge(
r.status === "Resolved" ? "Resolved" : severityName(r.severity),
r.status === "Resolved"
? ""
: r.severity === 15
? "emergency"
: r.severity === 10
? "high"
: "",
),
]),
el("h3", {}, r.asset_name),
el("p", {}, r.description),
r.resolution_note
? el("div", { class: "resolution" }, [
el("strong", {}, "Resolution: "),
r.resolution_note,
])
: null,
el("div", { class: "report-bottom" }, [
button(
r.has_image ? "View details & photo" : "View details",
() => reportDetail(r),
"text-button",
),
r.status === "Open"
? button("Resolve report", () => resolveDialog(r), "button secondary slim", "check")
: el("span", { class: "small muted" }, date(r.resolved_at)),
]),
]);
grid.append(card);
}
area.replaceChildren(
items.length
? grid
: el("div", { class: "panel empty" }, [
el("strong", {}, "No reports in this view"),
"Try another search, priority, or status, or clear the filters to see all reports.",
]),
);
}
search.oninput = () => {
state.reportQuery = search.value;
renderResults();
};
severity.onchange = () => {
state.reportSeverity = severity.value;
renderResults();
};
sort.onchange = () => {
state.reportSort = sort.value;
renderResults();
};
renderResults();
}
function resolveDialog(report) {
noteDialog(
Expand Down
68 changes: 68 additions & 0 deletions public/static/reports.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import { severityName } from "./utils.js";

export function filterReports(
reports,
{ query = "", status = "", severity = "", sort = "newest" } = {},
) {
const term = query.trim().toLocaleLowerCase();
const timestamp = (value) => Date.parse(value) || 0;
return reports
.filter(
(report) =>
(!status || report.status === status) &&
(!severity || report.severity === Number(severity)) &&
(!term ||
[report.id, report.asset_name, report.description, report.resolution_note].some((value) =>
String(value ?? "")
.toLocaleLowerCase()
.includes(term),
)),
)
.sort((a, b) => {
const newest = timestamp(b.created_at) - timestamp(a.created_at);
const order =
sort === "priority"
? b.severity - a.severity || newest
: sort === "oldest"
? -newest
: newest;
return order || b.id - a.id;
});
}

function csvCell(value) {
let text = String(value ?? "");
// Quoting alone does not stop spreadsheet formulas, including after whitespace.
if (/^[\s\uFEFF]*[=+@-]/u.test(text)) text = "'" + text;
return `"${text.replaceAll('"', '""')}"`;
}

/** Export only the supplied, authorized rows; keep tracking tokens and photos private. */
export function reportsCsv(reports) {
const rows = [
[
"Report ID",
"Asset",
"Description",
"Observed priority",
"Status",
"Created at",
"Resolved at",
"Resolution note",
"Photo attached",
],
];
for (const report of reports)
rows.push([
report.id,
report.asset_name,
report.description,
severityName(report.severity),
report.status,
report.created_at,
report.resolved_at,
report.resolution_note,
report.has_image ? "Yes" : "No",
]);
return "\uFEFF" + rows.map((row) => row.map(csvCell).join(",")).join("\r\n") + "\r\n";
}
16 changes: 16 additions & 0 deletions public/static/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -993,6 +993,22 @@ tbody tr:hover {
white-space: pre-wrap;
overflow-wrap: anywhere;
}
.report-filters {
display: grid;
grid-template-columns: minmax(240px, 2fr) repeat(2, minmax(150px, 1fr));
gap: 16px;
margin-bottom: 16px;
}
.report-filters > div, .report-filters label { display: flex; flex-direction: column; gap: 8px; min-width: 0; font-size: 13px; color: #52625c; }
.report-filters input, .report-filters select { width: 100%; min-height: 42px; padding: 10px 12px; border: 1px solid #cedbd4; border-radius: 7px; background: #fff; color: #253f35; }
.report-results { margin-bottom: 18px; gap: 12px; flex-wrap: wrap; }
@media (max-width: 900px) {
.report-filters { grid-template-columns: 1fr 1fr; }
.report-filters > div:first-child { grid-column: 1 / -1; }
}
@media (max-width: 520px) {
.report-filters { grid-template-columns: 1fr; }
}
.report-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
Expand Down
Loading
Loading