Initial
This commit is contained in:
commit
eb580132cc
1
.gitignore
vendored
Normal file
1
.gitignore
vendored
Normal file
@ -0,0 +1 @@
|
||||
node_modules
|
30
LICENSE
Normal file
30
LICENSE
Normal file
@ -0,0 +1,30 @@
|
||||
|
||||
Copyright (c) 2025 Frederick Boniface. All Rights Reserved.
|
||||
|
||||
Redistribution and use in source and binary forms, with or without modification,
|
||||
are permitted provided that the following conditions are met:
|
||||
|
||||
1. Redistribution of source code must retain the above copyright notice, this
|
||||
list of conditions and the following disclaimer.
|
||||
|
||||
2. Redistribution in binary form must reproduce the above copyright notice,
|
||||
this list of conditions and the following disclaimer in the documentation
|
||||
and/or other materials provided with the distribution.
|
||||
|
||||
3. Neither the name of the copyright holder nor the names of its contributors
|
||||
may be used to endorse or promote products derived from this software without
|
||||
specific prior written permission.
|
||||
|
||||
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
|
||||
ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
|
||||
WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED.
|
||||
IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT,
|
||||
INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING,
|
||||
BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
|
||||
DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF
|
||||
LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE
|
||||
OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED
|
||||
OF THE POSSIBILITY OF SUCH DAMAGE.
|
||||
|
||||
You acknowledge that this software is not designed, licensed or intended for use
|
||||
in the design, construction, operation or maintenance of any military facility.
|
1170
package-lock.json
generated
Normal file
1170
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
27
package.json
Normal file
27
package.json
Normal file
@ -0,0 +1,27 @@
|
||||
{
|
||||
"name": "tracreport",
|
||||
"version": "0.0.1",
|
||||
"description": "Collect reports of failed air conditioning on trains",
|
||||
"main": "index.js",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"start": "npx tsx --watch src/index.ts",
|
||||
"test": "echo \"Error: no test specified\" && exit 1"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git.fjla.uk/fred.boniface/tracreport"
|
||||
},
|
||||
"author": "Frederick Boniface",
|
||||
"license": "BSD-3-Clause-No-Military-License",
|
||||
"dependencies": {
|
||||
"express": "^5.1.0",
|
||||
"mongoose": "^8.16.0",
|
||||
"nodemailer": "^7.0.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/express": "^5.0.3",
|
||||
"@types/node": "^24.0.4",
|
||||
"@types/nodemailer": "^6.4.17"
|
||||
}
|
||||
}
|
83
src/formHandler.ts
Normal file
83
src/formHandler.ts
Normal file
@ -0,0 +1,83 @@
|
||||
import nodemailer from 'nodemailer';
|
||||
import type { Transporter, SendMailOptions } from 'nodemailer';
|
||||
|
||||
interface Fault {
|
||||
coach: string;
|
||||
zone: string;
|
||||
}
|
||||
|
||||
interface Report {
|
||||
unitNumber: string;
|
||||
reported: string;
|
||||
comments: string;
|
||||
utcTimestamp: string;
|
||||
faults: Fault[];
|
||||
}
|
||||
|
||||
export async function handleFormData(data) {
|
||||
// Uploads form data to database and emails
|
||||
// to defined subscribers.
|
||||
|
||||
const knownKeys = ['unitNumber', 'reported', 'comments', 'utcTimestamp'];
|
||||
|
||||
const faults: Fault[] = [];
|
||||
|
||||
const report: Report = {
|
||||
unitNumber: data.unitNumber,
|
||||
reported: data.reported,
|
||||
comments: data.comments || '',
|
||||
utcTimestamp: data.utcTimestamp,
|
||||
faults: []
|
||||
};
|
||||
|
||||
for (const key in data) {
|
||||
if (!knownKeys.includes(key) && data[key] === 'on') {
|
||||
const match = key.match(/^(.+)-(.+)$/);
|
||||
if (match) {
|
||||
const [_, zone, coach] = match;
|
||||
report.faults.push({ coach, zone });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
submit(report);
|
||||
sendMail(report);
|
||||
}
|
||||
|
||||
async function submit(report: Report): Promise<void> {
|
||||
console.log(report);
|
||||
// Send to database
|
||||
// Send to email
|
||||
}
|
||||
|
||||
async function sendMail(report: Report): Promise<void> {
|
||||
const transporter = nodemailer.createTransport({
|
||||
host: process.env.SMTP_HOST,
|
||||
port: parseInt(process.env.SMTP_PORT || '587'),
|
||||
secure: true,
|
||||
auth: {
|
||||
user: process.env.SMTP_USER,
|
||||
pass: process.env.SMTP_PASS,
|
||||
}
|
||||
});
|
||||
|
||||
const faultList = report.faults.map(f => `- ${f.zone} (${f.coach})`).join('\n');
|
||||
|
||||
const mailOptions: SendMailOptions = {
|
||||
from: `"AC Reporter ${process.env.SMTP_USER}"`,
|
||||
to: process.env.MAIL_RECIPIENTS,
|
||||
subject: `New A/C Report: ${report.unitNumber}`,
|
||||
text: `A report has been submitted for unit ${report.unitNumber}.\n\n` +
|
||||
`Timestamp: ${report.utcTimestamp}\n\n` +
|
||||
`Faults:\n${faultList}\n\n` +
|
||||
`Reported to Maintenance: ${report.reported}` +
|
||||
`Comments:\n${report.comments} || 'None'`
|
||||
};
|
||||
|
||||
try {
|
||||
const info = await transporter.sendMail(mailOptions);
|
||||
console.log('📧 Report sent:', info.messageId);
|
||||
} catch (err) {
|
||||
console.error('❌ Failed to send email:', err);
|
||||
}
|
||||
}
|
27
src/index.ts
Normal file
27
src/index.ts
Normal file
@ -0,0 +1,27 @@
|
||||
import express, { Request, Response } from "express";
|
||||
import { handleFormData } from "./formHandler";
|
||||
|
||||
const app = express();
|
||||
const port = process.env.port || 3000;
|
||||
|
||||
app.use(express.static('static'));
|
||||
app.use(express.json());
|
||||
|
||||
app.get("/test", (req: Request, res: Response) => {
|
||||
res.json({message: "TrACreport is running"});
|
||||
});
|
||||
|
||||
app.post('/submit', (req, res) => {
|
||||
const report = req.body;
|
||||
|
||||
// For now, just log it
|
||||
console.log('📥 New form submission:');
|
||||
|
||||
// TODO: Validate and write to MongoDB later
|
||||
handleFormData(report);
|
||||
res.status(200).json({ status: 'ok', message: 'Form received' });
|
||||
});
|
||||
|
||||
app.listen(port, () => {
|
||||
console.log("Server running on port:", port);
|
||||
});
|
52
static/index.html
Normal file
52
static/index.html
Normal file
@ -0,0 +1,52 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta http-equiv="X-UA-Compatible" content="ie=edge">
|
||||
<title>TrACreport</title>
|
||||
<link rel="stylesheet" href="/style.css">
|
||||
</head>
|
||||
<body>
|
||||
<h1>Report an A/C Defect</h1>
|
||||
<form id="defectForm" action="POST">
|
||||
<label for="unitNumber">Unit Number</label><br>
|
||||
<input type="number" id="unitNumber" name="unitNumber" required>
|
||||
<div id="formExpansion">
|
||||
<!-- The contents of this DIV is inserted with JS -->
|
||||
</div>
|
||||
<div id="formHidden">
|
||||
<div id="radio-group">
|
||||
<h2>Have you reported this to maintenance?</h2>
|
||||
<label>
|
||||
<input class="radio" type="radio" name="reported" value="via Defect Book" required>
|
||||
via Defect Book
|
||||
</label>
|
||||
<label>
|
||||
<input class="radio" type="radio" name="reported" value="via App">
|
||||
via App
|
||||
</label>
|
||||
<label>
|
||||
<input class="radio" type="radio" name="reported" value="via Telephone">
|
||||
via Telephone
|
||||
</label>
|
||||
<label>
|
||||
<input class="radio" type="radio" name="reported" value="no">
|
||||
No (Explain reason in comments)
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<label id="commentsLabel" for="comments">Comments:</label>
|
||||
<p>eg. If you did not report, why? Did a fitter meet the train? Is there already a response in the defect book?</p>
|
||||
<textarea id="comments" name="comments" rows="5" cols="35"></textarea>
|
||||
<br><br>
|
||||
<button type="submit">Submit</button>
|
||||
<button type="reset">Reset</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div id="formStatus" class="status-message"></div>
|
||||
|
||||
<script src="script.js"></script>
|
||||
</body>
|
||||
</html>
|
176
static/script.js
Normal file
176
static/script.js
Normal file
@ -0,0 +1,176 @@
|
||||
let data = {}
|
||||
|
||||
const dataPromise = fetch("units.converted.json")
|
||||
.then(res => {
|
||||
if (!res.ok) throw new Error("Failed to load JSON");
|
||||
return res.json();
|
||||
})
|
||||
.then(json => {
|
||||
data = json;
|
||||
console.log("Loaded unit data");
|
||||
})
|
||||
.catch(err => {
|
||||
console.error("Error loading unit data", err);
|
||||
window.location.reload();
|
||||
});
|
||||
|
||||
|
||||
window.addEventListener('DOMContentLoaded', () => {
|
||||
const input = document.getElementById('unitNumber');
|
||||
const resultDiv = document.getElementById('formExpansion');
|
||||
|
||||
input.addEventListener('input', async () => {
|
||||
await dataPromise;
|
||||
|
||||
const value = input.value.trim();
|
||||
|
||||
if (value in data) {
|
||||
const match = data[value]; // this is now an array of { id, zones }
|
||||
resultDiv.textContent = "";
|
||||
loadForm(match);
|
||||
} else {
|
||||
resultDiv.textContent = "Enter a valid unit number";
|
||||
document.getElementById('formHidden').style.display = "none";
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
document.getElementById("defectForm").addEventListener("submit", formSubmit);
|
||||
window.addEventListener("load", retryOfflineReports);
|
||||
|
||||
async function loadForm(values) {
|
||||
const formExpansion = document.getElementById('formExpansion');
|
||||
const formHidden = document.getElementById('formHidden');
|
||||
|
||||
formExpansion.innerHTML = '';
|
||||
|
||||
const heading = document.createElement('h2');
|
||||
heading.textContent = "Choose all areas where A/C failed";
|
||||
formExpansion.appendChild(heading);
|
||||
|
||||
for (const vehicle of values) {
|
||||
const coachTitle = document.createElement('h3');
|
||||
coachTitle.textContent = vehicle.id;
|
||||
formExpansion.appendChild(coachTitle);
|
||||
|
||||
for (const zone of vehicle.zones) {
|
||||
const checkboxId = `${zone}-${vehicle.id}`;
|
||||
|
||||
const wrapper = document.createElement('div');
|
||||
wrapper.className = 'checkbox-wrapper';
|
||||
|
||||
const checkbox = document.createElement('input');
|
||||
checkbox.type = 'checkbox';
|
||||
checkbox.id = checkboxId;
|
||||
checkbox.name = checkboxId;
|
||||
checkbox.title = zone;
|
||||
|
||||
const label = document.createElement('label');
|
||||
label.htmlFor = checkboxId;
|
||||
label.textContent = zone;
|
||||
|
||||
wrapper.appendChild(checkbox);
|
||||
wrapper.appendChild(label);
|
||||
formExpansion.appendChild(wrapper);
|
||||
}
|
||||
}
|
||||
|
||||
formHidden.style = 'display:block';
|
||||
}
|
||||
|
||||
function reset() {
|
||||
document.getElementById('formHidden').style = 'display:hidden';
|
||||
document.getElementById('formExpansion').innerHTML = '';
|
||||
}
|
||||
|
||||
async function formSubmit(event) {
|
||||
event.preventDefault();
|
||||
|
||||
const form = event.target;
|
||||
const formData = new FormData(form);
|
||||
|
||||
const submitButton = form.querySelector('button[type="submit"]');
|
||||
submitButton.disabled = true;
|
||||
|
||||
const data = {};
|
||||
for (const [key, value] of formData.entries()) {
|
||||
if (data[key]) {
|
||||
if (!Array.isArray(data[key])) data[key] = [data[key]];
|
||||
data[key].push(value);
|
||||
} else {
|
||||
data[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
data.utcTimestamp = new Date().toISOString();
|
||||
|
||||
try {
|
||||
const res = await fetch("/submit", {
|
||||
method: "POST",
|
||||
headers: {"Content-Type": "application/json"},
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
|
||||
if (!res.ok) throw new Error("Submission failed");
|
||||
console.log("Form Submitted")
|
||||
showStatus("✅ Form received, thank-you.", type="success");
|
||||
} catch (err) {
|
||||
console.warn("Form Sending failed, saving to localStorage");
|
||||
saveReportOffline(data);
|
||||
showStatus("⚠️ Unable to send form, it will be automatically sent with the next report you submit.", type="warn");
|
||||
}
|
||||
form.reset();
|
||||
reset();
|
||||
submitButton.disabled = false;
|
||||
}
|
||||
|
||||
function saveReportOffline(report) {
|
||||
const key = "offlineReports";
|
||||
const reports = JSON.parse(localStorage.getItem(key) || "[]");
|
||||
reports.push(report);
|
||||
localStorage.setItem(key, JSON.stringify(reports));
|
||||
}
|
||||
|
||||
async function retryOfflineReports() {
|
||||
const key = "offlineReports";
|
||||
const stored = JSON.parse(localStorage.getItem(key) || "[]");
|
||||
|
||||
if (stored.length === 0) return;
|
||||
|
||||
console.log(`Attempting to resend ${stored.length} stored report(s)...`);
|
||||
|
||||
const remaining = [];
|
||||
|
||||
for (const report of stored) {
|
||||
try {
|
||||
const res = await fetch("/submit", {
|
||||
method: "POST",
|
||||
headers: {"Content-Type": "application/json"},
|
||||
body: JSON.stringify(report),
|
||||
});
|
||||
|
||||
if (!res.ok) throw new Error ("Submission Failed");
|
||||
console.log("Resent one stored report");
|
||||
} catch (err) {
|
||||
console.warn("Failed to resend stored report");
|
||||
remaining.push(report);
|
||||
}
|
||||
}
|
||||
|
||||
if (remaining.length === 0) {
|
||||
localStorage.removeItem(key);
|
||||
} else {
|
||||
localStorage.setItem(key, JSON.stringify(remaining));
|
||||
}
|
||||
}
|
||||
|
||||
function showStatus(message, type) {
|
||||
const statusDiv = document.getElementById('formStatus');
|
||||
statusDiv.textContent = message;
|
||||
statusDiv.className = `status-${type}`;
|
||||
statusDiv.style = 'display:flex';
|
||||
|
||||
setTimeout(() => {
|
||||
statusDiv.style = 'display:none';
|
||||
}, 5000);
|
||||
}
|
165
static/style.css
Normal file
165
static/style.css
Normal file
@ -0,0 +1,165 @@
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
|
||||
html {
|
||||
width: 100%;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
body {
|
||||
width: 100vw;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
text-align: center;
|
||||
color: #f2f3f2;
|
||||
background-color: #525252;
|
||||
}
|
||||
|
||||
h1 {
|
||||
padding-top: 20px;
|
||||
padding-bottom: 20px;
|
||||
margin:auto;
|
||||
width: 90%;
|
||||
}
|
||||
|
||||
form {
|
||||
width: 100vw;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
margin-bottom: 25px;
|
||||
}
|
||||
|
||||
#unitNumber {
|
||||
width: 40vw;
|
||||
font-size: larger;
|
||||
border-radius: 4px;
|
||||
text-align: center;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: larger;
|
||||
}
|
||||
|
||||
h3 {
|
||||
font-size: large;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
#formExpansion {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 0.7rem 2rem;
|
||||
}
|
||||
|
||||
#formExpansion h3 {
|
||||
flex-basis: 100%;
|
||||
margin-top: 1.0rem;
|
||||
margin-bottom: 0rem;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
#formExpansion h3:first-of-type {
|
||||
margin-top: 0rem;
|
||||
}
|
||||
|
||||
.checkbox-wrapper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
font-size: 1rem;
|
||||
cursor: pointer;
|
||||
width: 5rem;
|
||||
}
|
||||
|
||||
.checkbox-wrapper input[type="checkbox"] {
|
||||
width: 1.8rem;
|
||||
height: 1.8rem;
|
||||
margin-top: 0rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.checkbox-wrapper label {
|
||||
text-transform: capitalize;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
|
||||
#formHidden {
|
||||
display: none;
|
||||
width: 100vw;
|
||||
margin: auto;
|
||||
padding-top: 10px;
|
||||
}
|
||||
|
||||
#radio-group {
|
||||
width: 90vw;
|
||||
margin:auto;
|
||||
padding-top: 10px;
|
||||
padding-bottom: 10px;
|
||||
}
|
||||
|
||||
#radio-group p {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
#radio-group label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
font-size: 1rem;
|
||||
padding: 0.25rem 0.75rem; /* top/bottom 0.25rem, left/right 0.75rem */
|
||||
margin: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
#radio-group input[type="radio"] {
|
||||
width: 1.5rem;
|
||||
height: 1.5rem;
|
||||
margin-right: 0.5rem;
|
||||
}
|
||||
|
||||
#commentsLabel {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
p {
|
||||
width: 90vw;
|
||||
margin: auto;
|
||||
}
|
||||
|
||||
button {
|
||||
width: 60px;
|
||||
height: 40px;
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
#formStatus {
|
||||
display: none;
|
||||
position: fixed;
|
||||
top: 37.5vh;
|
||||
left: 13vw;
|
||||
width: 74vw;
|
||||
height: 25vh;
|
||||
text-align: center;
|
||||
font-weight: bold;
|
||||
border-radius: 25px;
|
||||
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.status-success {
|
||||
background-color: green;
|
||||
color: whitesmoke;
|
||||
}
|
||||
|
||||
.status-warn {
|
||||
background-color: yellow;
|
||||
color: black;
|
||||
}
|
||||
|
1435
static/units.converted.json
Normal file
1435
static/units.converted.json
Normal file
File diff suppressed because it is too large
Load Diff
Loading…
x
Reference in New Issue
Block a user