Add PWA-ready MVP: split HTML/CSS/JS + DDD layering + service worker

- public/index.html: markup-only entry, classic <script> tags, no inline styles/handlers.
- public/css/: base, overview, form, modals, install, responsive stylesheets.
- public/js/: namespace bootstrap + main wiring (event handlers, init).
- public/manifest.webmanifest + icons/icon.svg + sw.js: installable, offline app shell.
- src/Domain: sectionMap, scoring, orderParser (pure, no I/O).
- src/Application: state, persistence, screens, inspection form, order overview,
  photo service, XML import, export, PWA registration, install screen.
- src/Infrastructure: IndexedDB, geolocation, utils, seed CSV, CSV upload parser.

Runs from file:// (PWA gated off) and from http(s)/localhost (PWA fully active).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Randy Fischer 2026-04-15 15:25:38 +02:00
parent 367bbca688
commit 776bd6366d
30 changed files with 2163 additions and 0 deletions

26
public/css/base.css Normal file
View File

@ -0,0 +1,26 @@
:root {
--prorail-blue: #003082;
--prorail-light: #e8edf4;
--header-bg: #d4dce8;
--editable-bg: #f0f0f0;
--border: #999;
--section-header: #b8c8dc;
--score-green: #92d050;
--score-yellow: #ffc000;
--score-red: #ff4444;
--font: 'Segoe UI', Calibri, Arial, sans-serif;
--section-tong: #ef9a9a;
--section-midden: #90caf9;
--section-punt: #a5d6a7;
--status-open: #ffc107;
--status-done: #4caf50;
--status-ready: #8bc34a;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: var(--font); font-size: 11px; background: #f5f5f5; padding: 0; }
.screen { display: none; }
.screen.active { display: block; }
.hidden { display: none !important; }
.flex-spacer { flex: 1; }

276
public/css/form.css Normal file
View File

@ -0,0 +1,276 @@
.form-toolbar {
display: flex; align-items: center; gap: 8px; padding: 8px 12px;
background: var(--prorail-blue); color: #fff; flex-wrap: wrap;
position: sticky; top: 0; z-index: 100;
}
.form-toolbar button, .form-toolbar label {
background: #fff; color: var(--prorail-blue); border: none; padding: 6px 14px;
border-radius: 4px; font-size: 12px; font-weight: 600; cursor: pointer;
display: inline-flex; align-items: center; gap: 4px;
}
.form-toolbar button:hover, .form-toolbar label:hover { background: var(--prorail-light); }
.form-toolbar .title { flex: 1; font-size: 11px; opacity: 0.8; }
.form-toolbar input[type="file"] { display: none; }
.btn-back {
background: transparent !important; color: #fff !important;
border: 1px solid rgba(255,255,255,0.4) !important;
font-size: 13px !important;
}
.btn-back:hover { background: rgba(255,255,255,0.15) !important; }
.btn-opname-gereed {
background: var(--status-done) !important; color: #fff !important;
font-size: 13px !important; padding: 8px 18px !important;
border-radius: 6px !important; font-weight: 700 !important;
box-shadow: 0 2px 6px rgba(76,175,80,0.4);
transition: transform 0.1s, box-shadow 0.1s;
}
.btn-opname-gereed:hover {
background: #43a047 !important; transform: scale(1.02);
box-shadow: 0 3px 10px rgba(76,175,80,0.5);
}
.btn-opname-gereed:active { transform: scale(0.98); }
.tab-bar {
display: flex; background: #e0e7f0; border-bottom: 2px solid var(--prorail-blue);
}
.tab-btn {
flex: 1; padding: 12px 20px; border: none; background: #d0d8e4; color: #555;
font-family: var(--font); font-size: 13px; font-weight: 600; cursor: pointer;
border-right: 1px solid #bbb; transition: background 0.2s, color 0.2s;
display: flex; align-items: center; justify-content: center; gap: 6px;
}
.tab-btn:last-child { border-right: none; }
.tab-btn.active {
background: #fff; color: var(--prorail-blue);
border-bottom: 3px solid var(--prorail-blue); margin-bottom: -2px;
}
.tab-btn:not(.active):hover { background: #c8d2e0; }
.tab-btn .tab-badge {
background: var(--prorail-blue); color: #fff; border-radius: 10px;
padding: 1px 6px; font-size: 10px; min-width: 18px; text-align: center;
display: none;
}
.tab-btn .tab-badge.visible { display: inline-block; }
.tab-btn.active .tab-badge { background: var(--score-green); color: #000; }
.tab-panel { display: none; }
.tab-panel.active { display: block; }
.form-container {
max-width: 1100px; margin: 0 auto; background: #fff;
border: 2px solid var(--prorail-blue); border-top: none;
box-shadow: 0 2px 8px rgba(0,0,0,0.15);
}
.form-title {
background: var(--prorail-blue); color: #fff; text-align: center;
font-size: 16px; font-weight: 700; padding: 10px; letter-spacing: 0.5px;
}
.header-grid {
display: grid; grid-template-columns: 180px 1fr 180px 1fr;
border-bottom: 1px solid var(--border);
}
.header-grid .cell {
padding: 3px 6px; border: 1px solid #ccc; font-size: 10.5px;
min-height: 22px; display: flex; align-items: center;
}
.header-grid .label { background: var(--header-bg); font-weight: 600; color: #333; }
.header-grid .value { background: #fff; color: #000; }
.task-section {
display: grid; grid-template-columns: 180px 1fr;
border-bottom: 1px solid var(--border);
}
.task-section .label {
background: var(--header-bg); font-weight: 600; padding: 3px 6px;
border: 1px solid #ccc; font-size: 10.5px;
}
.task-section .value { padding: 3px 6px; border: 1px solid #ccc; font-size: 10.5px; }
.inspecteur-grid {
display: grid; grid-template-columns: 180px 1fr 180px 1fr;
border-bottom: 1px solid var(--border);
}
.inspecteur-grid .cell {
padding: 3px 6px; border: 1px solid #ccc; font-size: 10.5px;
min-height: 24px; display: flex; align-items: center;
}
.inspecteur-grid .label { background: var(--header-bg); font-weight: 600; }
.inspecteur-grid .editable { background: var(--editable-bg); }
.inspecteur-grid input {
width: 100%; border: none; background: transparent;
font-family: var(--font); font-size: 10.5px; outline: none; padding: 2px 0;
}
.warning {
background: #fff3cd; color: #856404; font-size: 10px; font-style: italic;
padding: 3px 6px; text-align: center; border-bottom: 1px solid var(--border);
}
.totaal-grid {
display: grid; grid-template-columns: 180px 1fr 180px 1fr;
border-bottom: 2px solid var(--prorail-blue);
}
.totaal-grid .cell {
padding: 4px 6px; border: 1px solid #ccc; font-size: 11px;
min-height: 26px; display: flex; align-items: center;
}
.totaal-grid .label { background: var(--section-header); font-weight: 700; }
.totaal-grid .score-cell {
background: var(--editable-bg); font-weight: 700; font-size: 14px; justify-content: center;
}
.totaal-grid .fotonummers-cell { font-size: 9px; }
.assessment-table { width: 100%; border-collapse: collapse; }
.assessment-table th {
background: var(--section-header); font-weight: 700; font-size: 10.5px;
padding: 5px 4px; border: 1px solid var(--border); text-align: center;
position: sticky; top: 44px; z-index: 2;
}
.assessment-table td {
border: 1px solid #ccc; padding: 3px 4px; font-size: 10.5px; vertical-align: middle;
}
.assessment-table .nr-col { width: 35px; text-align: center; font-weight: 600; }
.assessment-table .loc-col { min-width: 200px; }
.assessment-table .score-col { width: 80px; text-align: center; }
.assessment-table .foto-col { width: 80px; text-align: center; }
.assessment-table .opm-col { min-width: 180px; }
.assessment-table .section-row td {
background: var(--prorail-light); font-weight: 700; font-size: 11px;
color: var(--prorail-blue); padding: 6px 4px;
}
.assessment-table .editable-cell { background: var(--editable-bg); }
.assessment-table select {
width: 100%; border: 1px solid #ccc; background: #fff; font-family: var(--font);
font-size: 10.5px; padding: 2px; border-radius: 2px; text-align: center; cursor: pointer;
}
.assessment-table input[type="text"] {
width: 100%; border: none; background: transparent; font-family: var(--font);
font-size: 10px; outline: none; padding: 1px 2px;
}
.foto-btn {
display: inline-flex; align-items: center; justify-content: center; gap: 3px;
background: var(--prorail-blue); color: #fff; border: none; padding: 4px 8px;
border-radius: 3px; font-size: 10px; cursor: pointer; width: 100%; position: relative;
}
.foto-btn:hover { background: #004db3; }
.foto-btn .badge {
position: absolute; top: -4px; right: -4px; background: var(--score-red); color: #fff;
border-radius: 50%; width: 16px; height: 16px; font-size: 9px;
display: flex; align-items: center; justify-content: center; font-weight: 700;
}
.foto-btn .badge:empty { display: none; }
.score-b1,.score-b2,.score-b3 { background-color: var(--score-green) !important; color: #000; }
.score-g1,.score-g2 { background-color: var(--score-green) !important; color: #000; }
.score-g3,.score-g4 { background-color: var(--score-yellow) !important; color: #000; }
.score-e3,.score-e4 { background-color: var(--score-yellow) !important; color: #000; }
.score-e5,.score-e6 { background-color: var(--score-red) !important; color: #fff; }
.opmerkingen-section { border-top: 2px solid var(--prorail-blue); }
.opmerkingen-section .section-label {
background: var(--section-header); font-weight: 700; padding: 5px 6px;
font-size: 11px; border-bottom: 1px solid var(--border);
}
.opmerkingen-section textarea {
width: 100%; min-height: 80px; border: none; background: var(--editable-bg);
font-family: var(--font); font-size: 10.5px; padding: 6px; resize: vertical; outline: none;
}
.status-bar {
display: flex; justify-content: space-between; align-items: center;
padding: 4px 12px; background: var(--prorail-light); font-size: 10px;
color: #555; border-top: 1px solid #ccc;
}
.overview-container { max-width: 800px; margin: 0 auto; padding: 16px 8px; background: #fff; }
.overview-title {
text-align: center; font-size: 18px; font-weight: 700;
color: var(--prorail-blue); margin-bottom: 12px;
}
.instruction-box {
background: #fff; border: 2px solid #333; border-radius: 8px;
padding: 12px 16px; margin: 0 auto 20px auto; max-width: 500px;
font-size: 11px; line-height: 1.5; color: #333;
}
.instruction-box strong { color: var(--prorail-blue); }
.wissel-overview {
display: grid; grid-template-columns: 110px 1fr 110px;
grid-template-rows: auto auto auto auto auto;
gap: 0; max-width: 700px; margin: 0 auto 16px auto; align-items: stretch;
}
.ovz-row { grid-column: 1 / -1; display: flex; justify-content: space-between; padding: 8px 0; }
.side-labels { display: flex; flex-direction: column; justify-content: space-around; padding: 4px 0; }
.section-strip {
position: relative; border: 1px solid #aaa; display: flex; flex-direction: column;
justify-content: center; align-items: center; min-height: 140px; overflow: hidden;
}
.section-strip.puntstuk { background: var(--section-punt); border-radius: 4px 4px 0 0; }
.section-strip.midden { background: var(--section-midden); }
.section-strip.tong { background: var(--section-tong); border-radius: 0 0 4px 4px; }
.section-strip .section-name {
font-size: 13px; font-weight: 700; color: var(--prorail-blue);
text-align: center; z-index: 1; text-shadow: 0 0 4px rgba(255,255,255,0.8);
}
.section-strip .section-sub { font-size: 10px; color: #444; text-align: center; z-index: 1; }
.section-strip .section-sub.sm { font-size: 9px; margin-top: 4px; color: #666; }
.track-overlay { position: absolute; inset: 0; pointer-events: none; }
.capture-label {
display: flex; flex-direction: column; align-items: center; gap: 4px;
cursor: pointer; padding: 8px 4px; border-radius: 6px;
transition: background 0.15s, transform 0.1s; position: relative;
user-select: none; -webkit-tap-highlight-color: transparent;
}
.capture-label:hover { background: rgba(0,48,130,0.08); }
.capture-label:active { transform: scale(0.95); background: rgba(0,48,130,0.15); }
.capture-label input[type="file"] { display: none; }
.capture-label .cap-icon {
width: 44px; height: 44px; background: var(--prorail-blue); border-radius: 50%;
display: flex; align-items: center; justify-content: center; color: #fff;
box-shadow: 0 2px 6px rgba(0,48,130,0.4); position: relative;
}
.capture-label .cap-icon .cap-badge {
position: absolute; top: -3px; right: -3px; background: var(--score-green); color: #000;
border-radius: 50%; min-width: 18px; height: 18px; font-size: 10px; font-weight: 700;
display: flex; align-items: center; justify-content: center; border: 2px solid #fff;
}
.capture-label .cap-icon .cap-badge:empty { display: none; }
.capture-label .cap-text { font-size: 10px; font-weight: 600; color: var(--prorail-blue); text-align: center; }
.capture-label .cap-arrow { font-size: 16px; color: var(--prorail-blue); line-height: 1; }
.capture-label .cap-arrow.lg { font-size: 14px; }
.side-labels.left .capture-label .cap-arrow::after { content: "\2192"; }
.side-labels.right .capture-label .cap-arrow::after { content: "\2190"; }
.overview-thumbs {
max-width: 700px; margin: 10px auto; padding: 8px;
background: var(--prorail-light); border-radius: 6px; min-height: 40px;
}
.overview-thumbs h4 { font-size: 11px; color: var(--prorail-blue); margin-bottom: 6px; }
.overview-thumbs .thumb-grid { display: flex; flex-wrap: wrap; gap: 6px; }
.overview-thumbs .thumb-item { width: 72px; position: relative; }
.overview-thumbs .thumb-item img {
width: 72px; height: 54px; object-fit: cover; border-radius: 4px;
border: 2px solid #ccc; cursor: pointer;
}
.overview-thumbs .thumb-item img:hover { border-color: var(--prorail-blue); }
.overview-thumbs .thumb-item .thumb-label {
font-size: 8px; text-align: center; color: #555; margin-top: 1px;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.overview-thumbs .thumb-item .thumb-del {
position: absolute; top: -4px; right: -4px; background: rgba(255,0,0,0.85); color: #fff;
border: none; border-radius: 50%; width: 18px; height: 18px; font-size: 11px;
cursor: pointer; display: flex; align-items: center; justify-content: center;
}
.overview-thumbs .no-photos { font-size: 11px; color: #888; font-style: italic; padding: 4px 0; }
.svg-link-row { text-align: center; margin: 12px 0; }
.svg-link-btn {
display: inline-flex; align-items: center; gap: 6px; background: var(--prorail-blue);
color: #fff; border: none; padding: 10px 20px; border-radius: 6px;
font-size: 13px; font-weight: 600; cursor: pointer; font-family: var(--font);
}
.svg-link-btn:hover { background: #004db3; }

76
public/css/install.css Normal file
View File

@ -0,0 +1,76 @@
.install-toolbar {
display: flex; align-items: center; gap: 8px; padding: 10px 16px;
background: var(--prorail-blue); color: #fff;
position: sticky; top: 0; z-index: 100;
}
.install-toolbar h1 { flex: 1; font-size: 16px; font-weight: 700; }
.install-toolbar .btn-back {
background: transparent; color: #fff; border: 1px solid rgba(255,255,255,0.4);
padding: 6px 12px; border-radius: 4px; font-size: 12px; cursor: pointer;
display: inline-flex; align-items: center; gap: 4px;
}
.install-toolbar .btn-back:hover { background: rgba(255,255,255,0.15); }
.install-body {
max-width: 640px; margin: 0 auto; padding: 20px 16px;
}
.install-hero {
background: #fff; border-radius: 12px; padding: 24px;
box-shadow: 0 2px 8px rgba(0,0,0,0.08); text-align: center; margin-bottom: 20px;
}
.install-hero img { width: 96px; height: 96px; margin-bottom: 12px; }
.install-hero h2 { color: var(--prorail-blue); font-size: 20px; margin-bottom: 8px; }
.install-hero p { color: #555; font-size: 13px; line-height: 1.5; }
.install-status {
padding: 10px 14px; border-radius: 6px; font-size: 12px; margin-bottom: 16px;
display: flex; align-items: center; gap: 8px;
}
.install-status.ok { background: #e8f5e9; color: #2e7d32; border: 1px solid #a5d6a7; }
.install-status.warn { background: #fff8e1; color: #f57f17; border: 1px solid #ffe082; }
.install-status.info { background: #e3f2fd; color: #1565c0; border: 1px solid #90caf9; }
.install-steps {
background: #fff; border-radius: 10px; padding: 20px;
box-shadow: 0 2px 8px rgba(0,0,0,0.08); margin-bottom: 16px;
}
.install-steps h3 { color: var(--prorail-blue); font-size: 15px; margin-bottom: 12px; }
.install-steps ol { padding-left: 20px; }
.install-steps li {
font-size: 13px; line-height: 1.7; color: #333; margin-bottom: 6px;
}
.install-steps code {
background: #f0f0f0; padding: 1px 6px; border-radius: 3px; font-size: 12px;
font-family: 'Consolas', monospace;
}
.install-steps .key {
display: inline-block; padding: 2px 6px; border: 1px solid #bbb;
border-bottom-width: 2px; border-radius: 3px; background: #f8f8f8;
font-size: 11px; font-family: 'Segoe UI', sans-serif; margin: 0 2px;
}
.install-cta {
display: block; width: 100%; background: var(--prorail-blue); color: #fff;
border: none; padding: 14px 20px; border-radius: 8px;
font-size: 15px; font-weight: 700; cursor: pointer;
font-family: var(--font); margin-top: 12px;
box-shadow: 0 2px 6px rgba(0,48,130,0.3);
}
.install-cta:hover { background: #004db3; }
.install-cta:disabled { background: #aaa; cursor: not-allowed; box-shadow: none; }
.install-benefits {
background: #fff; border-radius: 10px; padding: 20px;
box-shadow: 0 2px 8px rgba(0,0,0,0.08); margin-bottom: 16px;
}
.install-benefits h3 { color: var(--prorail-blue); font-size: 15px; margin-bottom: 10px; }
.install-benefits ul { list-style: none; padding: 0; }
.install-benefits li {
font-size: 13px; color: #333; padding: 6px 0; padding-left: 28px;
position: relative; line-height: 1.5;
}
.install-benefits li::before {
content: '\2713'; position: absolute; left: 6px; top: 6px;
color: var(--status-done); font-weight: 700;
}

49
public/css/modals.css Normal file
View File

@ -0,0 +1,49 @@
.modal-overlay {
display: none; position: fixed; inset: 0; background: rgba(0,0,0,0.7);
z-index: 1000; align-items: center; justify-content: center;
}
.modal-overlay.active { display: flex; }
.modal {
background: #fff; border-radius: 8px; max-width: 600px; width: 95%;
max-height: 90vh; overflow-y: auto; padding: 16px; position: relative;
}
.modal h3 { color: var(--prorail-blue); margin-bottom: 10px; font-size: 14px; }
.modal-close {
position: absolute; top: 8px; right: 12px; background: none;
border: none; font-size: 22px; cursor: pointer; color: #666;
}
.modal-actions { display: flex; gap: 8px; margin-bottom: 12px; flex-wrap: wrap; }
.modal-actions button, .modal-actions label {
background: var(--prorail-blue); color: #fff; border: none; padding: 10px 16px;
border-radius: 4px; font-size: 13px; cursor: pointer;
display: inline-flex; align-items: center; gap: 6px; font-weight: 600;
}
.modal-actions button:hover, .modal-actions label:hover { background: #004db3; }
.modal-actions input[type="file"] { display: none; }
.photo-grid {
display: grid; grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
gap: 8px; margin-top: 10px;
}
.photo-grid .empty {
color: #888; font-size: 12px; padding: 10px;
}
.photo-thumb {
position: relative; aspect-ratio: 4/3; border: 1px solid #ccc;
border-radius: 4px; overflow: hidden; cursor: pointer;
}
.photo-thumb img { width: 100%; height: 100%; object-fit: cover; }
.photo-thumb .delete-btn {
position: absolute; top: 2px; right: 2px; background: rgba(255,0,0,0.8);
color: #fff; border: none; border-radius: 50%; width: 20px; height: 20px;
font-size: 12px; cursor: pointer; display: flex; align-items: center; justify-content: center;
}
.photo-thumb .photo-time {
position: absolute; bottom: 0; left: 0; right: 0; background: rgba(0,0,0,0.6);
color: #fff; font-size: 8px; text-align: center; padding: 1px;
}
.lightbox {
display: none; position: fixed; inset: 0; background: rgba(0,0,0,0.9);
z-index: 2000; align-items: center; justify-content: center; cursor: zoom-out;
}
.lightbox.active { display: flex; }
.lightbox img { max-width: 95%; max-height: 95vh; object-fit: contain; }

150
public/css/overview.css Normal file
View File

@ -0,0 +1,150 @@
.overview-toolbar {
display: flex; align-items: center; gap: 8px; padding: 10px 16px;
background: var(--prorail-blue); color: #fff; flex-wrap: wrap;
position: sticky; top: 0; z-index: 100;
}
.overview-toolbar h1 {
flex: 1; font-size: 16px; font-weight: 700; letter-spacing: 0.3px;
}
.overview-toolbar button, .overview-toolbar label {
background: #fff; color: var(--prorail-blue); border: none; padding: 6px 14px;
border-radius: 4px; font-size: 12px; font-weight: 600; cursor: pointer;
display: inline-flex; align-items: center; gap: 4px;
}
.overview-toolbar button:hover, .overview-toolbar label:hover { background: var(--prorail-light); }
.overview-toolbar input[type="file"] { display: none; }
.overview-body { max-width: 1100px; margin: 0 auto; padding: 16px; }
.summary-row {
display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 12px; margin-bottom: 20px;
}
.summary-card {
background: #fff; border-radius: 10px; padding: 16px 20px;
border-left: 5px solid var(--prorail-blue);
box-shadow: 0 2px 8px rgba(0,0,0,0.08);
display: flex; align-items: center; gap: 14px;
}
.summary-card.wissel { border-left-color: #7b1fa2; }
.summary-card.overweg { border-left-color: #0288d1; }
.summary-card.spoor { border-left-color: #e65100; }
.summary-card.totaal { border-left-color: var(--prorail-blue); }
.summary-card .card-icon {
width: 44px; height: 44px; border-radius: 50%; display: flex;
align-items: center; justify-content: center; flex-shrink: 0;
font-size: 20px;
}
.summary-card.wissel .card-icon { background: #f3e5f5; color: #7b1fa2; }
.summary-card.overweg .card-icon { background: #e1f5fe; color: #0288d1; }
.summary-card.spoor .card-icon { background: #fff3e0; color: #e65100; }
.summary-card.totaal .card-icon { background: var(--prorail-light); color: var(--prorail-blue); }
.summary-card .card-info { flex: 1; }
.summary-card .card-value { font-size: 26px; font-weight: 800; color: #222; line-height: 1; }
.summary-card .card-label { font-size: 11px; color: #777; margin-top: 2px; }
.progress-section { margin-bottom: 16px; }
.progress-bar-bg {
height: 8px; background: #e0e0e0; border-radius: 4px; overflow: hidden;
}
.progress-bar-fill {
height: 100%; width: 0%; background: var(--status-done); border-radius: 4px;
transition: width 0.4s ease;
}
.progress-text { font-size: 11px; color: #666; margin-top: 4px; text-align: right; }
.order-table-wrap {
background: #fff; border-radius: 10px; box-shadow: 0 2px 8px rgba(0,0,0,0.08);
overflow: visible;
}
.order-table-header {
display: flex; align-items: center; padding: 12px 16px; gap: 10px;
border-bottom: 1px solid #eee;
}
.order-table-header h2 {
flex: 1; font-size: 14px; color: var(--prorail-blue);
}
.filter-btns { display: flex; gap: 4px; flex-wrap: wrap; }
.filter-btn {
border: 1px solid #ccc; background: #fff; padding: 4px 10px;
border-radius: 12px; font-size: 10px; cursor: pointer; font-family: var(--font);
transition: all 0.15s;
}
.filter-btn.active { background: var(--prorail-blue); color: #fff; border-color: var(--prorail-blue); }
.filter-btn:hover:not(.active) { background: #f0f0f0; }
.order-table-scroll { overflow-x: auto; }
.order-table {
width: 100%; border-collapse: collapse; table-layout: fixed;
}
.order-table col.col-order { width: 120px; }
.order-table col.col-type { width: 130px; }
.order-table col.col-omschr { width: auto; }
.order-table col.col-hoevh { width: 110px; }
.order-table col.col-status { width: 130px; }
.order-table th {
background: var(--prorail-light); font-size: 10px; font-weight: 700;
padding: 6px 10px; text-align: left; color: #555;
border-bottom: 1px solid #ddd; white-space: nowrap;
position: relative; user-select: none; overflow: hidden;
}
.order-table th .th-content {
display: flex; align-items: center; gap: 4px;
}
.order-table th .col-resize {
position: absolute; top: 0; right: -3px; width: 6px; height: 100%;
cursor: col-resize; z-index: 2;
}
.order-table th .col-resize::after {
content: ''; position: absolute; top: 25%; bottom: 25%; right: 2px;
width: 2px; background: transparent; border-radius: 1px;
transition: background 0.15s;
}
.order-table th:hover .col-resize::after { background: #bbb; }
.order-table th .col-resize:active::after,
.order-table th .col-resize.dragging::after { background: var(--prorail-blue); }
.order-table thead tr.filter-row th {
background: #fff; padding: 4px 6px; border-bottom: 2px solid #ddd;
font-weight: 400;
}
.order-table .col-filter {
width: 100%; border: 1px solid #ddd; border-radius: 3px;
padding: 4px 6px; font-size: 10px; font-family: var(--font);
background: #fafafa; outline: none; transition: border-color 0.15s;
}
.order-table .col-filter:focus { border-color: var(--prorail-blue); background: #fff; }
.order-table .col-filter::placeholder { color: #bbb; }
.order-table .col-filter.has-value { background: #fffde7; border-color: #f9a825; }
.order-table td {
padding: 10px 10px; font-size: 11px; border-bottom: 1px solid #f0f0f0;
vertical-align: middle; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.order-table tbody tr {
cursor: pointer; transition: background 0.1s;
}
.order-table tbody tr:hover { background: #f5f8ff; }
.order-table tbody tr:active { background: #e8edf4; }
.type-badge {
display: inline-block; padding: 2px 8px; border-radius: 10px;
font-size: 10px; font-weight: 600; white-space: nowrap;
}
.type-badge.wissel { background: #f3e5f5; color: #7b1fa2; }
.type-badge.overweg { background: #e1f5fe; color: #0288d1; }
.type-badge.spoor { background: #fff3e0; color: #e65100; }
.status-badge {
display: inline-flex; align-items: center; gap: 4px;
padding: 3px 10px; border-radius: 10px; font-size: 10px; font-weight: 600;
}
.status-badge.open { background: #fff8e1; color: #f57f17; }
.status-badge.done { background: #e8f5e9; color: #2e7d32; }
.status-badge .status-dot {
width: 8px; height: 8px; border-radius: 50%;
}
.status-badge.open .status-dot { background: var(--status-open); }
.status-badge.done .status-dot { background: var(--status-done); }
.order-table .hoevh-cell { text-align: right; font-weight: 600; }

21
public/css/responsive.css Normal file
View File

@ -0,0 +1,21 @@
@media print {
.form-toolbar, .tab-bar, .foto-btn, .modal-overlay, .lightbox, .status-bar,
.capture-label, .overview-thumbs, .svg-link-row, .btn-opname-gereed, .btn-back { display: none !important; }
body { padding: 0; background: #fff; }
.form-container { border: 1px solid #000; box-shadow: none; }
.tab-panel { display: block !important; }
.screen { display: block !important; }
#screen-overview { display: none !important; }
}
@media (max-width: 768px) {
.header-grid { grid-template-columns: 120px 1fr 120px 1fr; }
.header-grid .cell { font-size: 9.5px; padding: 2px 4px; }
.foto-btn { padding: 8px; font-size: 12px; }
.assessment-table select { padding: 6px; font-size: 12px; }
.wissel-overview { grid-template-columns: 80px 1fr 80px; }
.capture-label .cap-icon { width: 38px; height: 38px; }
.section-strip { min-height: 110px; }
.summary-row { grid-template-columns: 1fr 1fr; }
.order-table td, .order-table th { padding: 8px 6px; font-size: 10px; }
}

15
public/icons/icon.svg Normal file
View File

@ -0,0 +1,15 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
<rect width="512" height="512" rx="96" fill="#003082"/>
<g stroke="#ffffff" stroke-width="18" stroke-linecap="round" fill="none">
<line x1="160" y1="80" x2="160" y2="432"/>
<line x1="352" y1="80" x2="352" y2="432"/>
<line x1="100" y1="140" x2="412" y2="140"/>
<line x1="100" y1="220" x2="412" y2="220"/>
<line x1="100" y1="300" x2="412" y2="300"/>
<line x1="100" y1="380" x2="412" y2="380"/>
</g>
<g fill="#ffc000">
<circle cx="256" cy="256" r="44"/>
</g>
<text x="256" y="478" text-anchor="middle" fill="#ffffff" font-family="Segoe UI, Arial, sans-serif" font-size="44" font-weight="800" letter-spacing="2">DUIMSTOK</text>
</svg>

After

Width:  |  Height:  |  Size: 734 B

320
public/index.html Normal file
View File

@ -0,0 +1,320 @@
<!DOCTYPE html>
<html lang="nl">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=yes">
<meta name="theme-color" content="#003082">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="default">
<meta name="apple-mobile-web-app-title" content="Duimstok">
<link rel="manifest" href="manifest.webmanifest">
<link rel="apple-touch-icon" href="icons/icon.svg">
<title>Duimstok-inspecties Bovenbouw</title>
<link rel="stylesheet" href="css/base.css">
<link rel="stylesheet" href="css/overview.css">
<link rel="stylesheet" href="css/form.css">
<link rel="stylesheet" href="css/modals.css">
<link rel="stylesheet" href="css/install.css">
<link rel="stylesheet" href="css/responsive.css">
</head>
<body>
<div class="screen active" id="screen-overview">
<div class="overview-toolbar">
<h1>Duimstok-inspecties Bovenbouw</h1>
<button id="btnOpenInstall" title="Installeren als app">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 3v12"/><polyline points="7 10 12 15 17 10"/><rect x="3" y="17" width="18" height="4" rx="1"/></svg>
Installeren
</button>
<label>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/></svg>
CSV laden
<input type="file" id="csvFileInput" accept=".csv,.txt">
</label>
</div>
<div class="overview-body">
<div class="summary-row" id="summaryCards"></div>
<div class="progress-section">
<div class="progress-bar-bg"><div class="progress-bar-fill" id="progressFill"></div></div>
<div class="progress-text" id="progressText"></div>
</div>
<div class="order-table-wrap">
<div class="order-table-header">
<h2>Orders</h2>
<div class="filter-btns">
<button class="filter-btn active" data-filter="alle">Alle</button>
<button class="filter-btn" data-filter="Wissel">Wissels</button>
<button class="filter-btn" data-filter="Overwegbevloering">Overwegen</button>
<button class="filter-btn" data-filter="Spoor">Spoor</button>
<button class="filter-btn" data-filter="open">Nog open</button>
</div>
</div>
<div class="order-table-scroll">
<table class="order-table" id="orderTable">
<colgroup>
<col class="col-order"><col class="col-type"><col class="col-omschr">
<col class="col-hoevh"><col class="col-status">
</colgroup>
<thead>
<tr class="label-row">
<th><span class="th-content">Order</span><div class="col-resize" data-col="0"></div></th>
<th><span class="th-content">Type</span><div class="col-resize" data-col="1"></div></th>
<th><span class="th-content">Omschrijving</span><div class="col-resize" data-col="2"></div></th>
<th><span class="th-content">Hoeveelheid</span><div class="col-resize" data-col="3"></div></th>
<th><span class="th-content">Status</span></th>
</tr>
<tr class="filter-row">
<th><input class="col-filter" type="text" data-col="order" placeholder="Filter..."></th>
<th><input class="col-filter" type="text" data-col="type" placeholder="Filter..."></th>
<th><input class="col-filter" type="text" data-col="omschrijving" placeholder="Filter..."></th>
<th><input class="col-filter" type="text" data-col="hoeveelheid" placeholder="Filter..."></th>
<th><input class="col-filter" type="text" data-col="status" placeholder="Filter..."></th>
</tr>
</thead>
<tbody id="orderTableBody"></tbody>
</table>
</div>
</div>
</div>
</div>
<div class="screen" id="screen-form">
<div class="form-toolbar">
<button class="btn-back" id="btnBack">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="15 18 9 12 15 6"/></svg>
Overzicht
</button>
<span class="title" id="formToolbarTitle">ProRail Duimstok-inspectie</span>
<label>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/></svg>
XML laden
<input type="file" id="xmlFileInput" accept=".xml">
</label>
<button id="btnExport">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"/><polyline points="17 21 17 13 7 13 7 21"/><polyline points="7 3 7 8 15 8"/></svg>
Opslaan
</button>
<button id="btnPrint">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="6 9 6 2 18 2 18 9"/><path d="M6 18H4a2 2 0 0 1-2-2v-5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2v5a2 2 0 0 1-2 2h-2"/><rect x="6" y="14" width="12" height="8"/></svg>
Afdrukken
</button>
<button class="btn-opname-gereed" id="btnOpnameGereed">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><polyline points="20 6 9 17 4 12"/></svg>
Opname gereed
</button>
</div>
<div class="tab-bar">
<button class="tab-btn active" data-tab="inspectie">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/></svg>
Inspectie
</button>
<button class="tab-btn" data-tab="overzicht">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M23 19a2 2 0 0 1-2 2H3a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h4l2-3h6l2 3h4a2 2 0 0 1 2 2z"/><circle cx="12" cy="13" r="4"/></svg>
Overzicht
<span class="tab-badge" id="tabBadgeOvz">0</span>
</button>
</div>
<div class="tab-panel active" id="panel-inspectie">
<div class="form-container" id="formContainer">
<div class="form-title">Duimstokformulier Gewoon of Symmetrisch wissel</div>
<div class="header-grid">
<div class="cell label">Geocode</div><div class="cell value" id="hdr_geo"></div>
<div class="cell label">Generatie</div><div class="cell value" id="hdr_generatie"></div>
<div class="cell label">Geocode omschrijving</div><div class="cell value" id="hdr_geotxt"></div>
<div class="cell label">Profiel</div><div class="cell value" id="hdr_profiel"></div>
<div class="cell label">Equipmentnummer</div><div class="cell value" id="hdr_equnr"></div>
<div class="cell label">Wisselligger soort</div><div class="cell value" id="hdr_dwarsligger"></div>
<div class="cell label">Wisselnummer</div><div class="cell value" id="hdr_wisselnr"></div>
<div class="cell label">Achterkant afwijking</div><div class="cell value" id="hdr_afwijking"></div>
<div class="cell label">Objectsoort</div><div class="cell value" id="hdr_eqart"></div>
<div class="cell label">Hergebruikt object?</div><div class="cell value" id="hdr_hergebruikt"></div>
<div class="cell label">Wisselsoort</div><div class="cell value" id="hdr_soort"></div>
<div class="cell label">Aangesloten wisselverwarming</div><div class="cell value" id="hdr_wisselverw"></div>
<div class="cell label">Startpunt</div><div class="cell value" id="hdr_startpoint"></div>
<div class="cell label">Hoofdspoor/Zijspoor</div><div class="cell value" id="hdr_spoor"></div>
<div class="cell label">Eindpunt</div><div class="cell value" id="hdr_endpoint"></div>
<div class="cell label">Afschrijvingsgroep</div><div class="cell value" id="hdr_afschr"></div>
<div class="cell label">Mee-/tegengebogen wissel</div><div class="cell value" id="hdr_meetegengebogen"></div>
<div class="cell label">Wissel classificatie</div><div class="cell value" id="hdr_classificatie"></div>
<div class="cell label">Voegloos</div><div class="cell value" id="hdr_voegloos"></div>
<div class="cell label">Plaatsingsdatum</div><div class="cell value" id="hdr_plaatsingsdatum"></div>
<div class="cell label">Hoekverhouding</div><div class="cell value" id="hdr_hoekverhouding"></div>
<div class="cell label">Aanschafdatum</div><div class="cell value" id="hdr_aanschafdatum"></div>
<div class="cell label">Omschrijving</div><div class="cell value" id="hdr_omschrijving"></div>
<div class="cell label">Order-operatie</div><div class="cell value" id="hdr_orderoperatie"></div>
</div>
<div class="task-section">
<div class="label">Taak omschrijving</div><div class="value" id="hdr_taakomschrijving"></div>
</div>
<div class="task-section">
<div class="label">Aanleiding probleem</div><div class="value" id="hdr_aanleiding"></div>
</div>
<div class="inspecteur-grid">
<div class="cell label">Inspecteur</div>
<div class="cell editable"><input type="text" id="inp_inspecteur" placeholder="Naam inspecteur"></div>
<div class="cell label">Techn. verv. jaar</div>
<div class="cell editable"><input type="text" id="inp_techjaar" placeholder=""></div>
</div>
<div class="inspecteur-grid">
<div class="cell label">Inspectiedatum</div>
<div class="cell editable"><input type="date" id="inp_inspectiedatum"></div>
<div class="cell label">(Nieuw) Inspectiejaar</div>
<div class="cell editable"><input type="text" id="inp_inspjaar" placeholder=""></div>
</div>
<div class="warning">Zowel de naam van de inspecteur als de inspectiedatum dienen ingevuld te worden!</div>
<div class="totaal-grid">
<div class="cell label">Totaalscore</div><div class="cell score-cell" id="totaalscore">-</div>
<div class="cell label">Fotonummers</div><div class="cell fotonummers-cell" id="totaal_fotonummers"></div>
</div>
<table class="assessment-table" id="assessmentTable">
<thead><tr>
<th class="nr-col">Nr</th><th class="loc-col">Locatie in wissel</th>
<th class="score-col">DWL Score</th><th class="score-col">BAL Score</th>
<th class="foto-col">Foto's</th><th class="opm-col">Opmerkingen / bijzonderheden</th>
</tr></thead>
<tbody id="assessmentBody"></tbody>
</table>
<div class="opmerkingen-section">
<div class="section-label">Overige opmerkingen (Enter voor nieuwe regel)</div>
<textarea id="inp_opmerkingen" placeholder="Opmerkingen hier invoeren..."></textarea>
</div>
<div class="status-bar">
<span id="statusOrder">Geen order geladen</span>
<span id="statusFotos">Foto's: 0</span>
<span id="statusSaved"></span>
</div>
</div>
</div>
<div class="tab-panel" id="panel-overzicht">
<div class="overview-container">
<div class="overview-title" id="overzichtTitle">Gewoon of Symmetrisch wissel</div>
<div class="instruction-box">
<strong>Instructie:</strong> Eerste foto bevat het bordje met het wisselnummer.
Daarna dienen de foto's buitenom of rechtsom in volgorde te worden gemaakt.
Foto's met bijzonderheden dienen in het opmerkingen-veld begeleid te worden
in combinatie met het fotonummer.
</div>
<div class="wissel-overview">
<div class="ovz-row">
<label class="capture-label" data-pos="ovz_tl">
<div class="cap-icon"><svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M23 19a2 2 0 0 1-2 2H3a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h4l2-3h6l2 3h4a2 2 0 0 1 2 2z"/><circle cx="12" cy="13" r="4"/></svg><span class="cap-badge" id="cbadge_ovz_tl"></span></div>
<span class="cap-text">Overzichtsfoto</span><span class="cap-arrow lg">&#8600;</span>
<input type="file" accept="image/*" capture="environment" data-pos="ovz_tl">
</label>
<div class="flex-spacer"></div>
<label class="capture-label" data-pos="ovz_tr">
<div class="cap-icon"><svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M23 19a2 2 0 0 1-2 2H3a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h4l2-3h6l2 3h4a2 2 0 0 1 2 2z"/><circle cx="12" cy="13" r="4"/></svg><span class="cap-badge" id="cbadge_ovz_tr"></span></div>
<span class="cap-text">Overzichtsfoto</span><span class="cap-arrow lg">&#8601;</span>
<input type="file" accept="image/*" capture="environment" data-pos="ovz_tr">
</label>
</div>
<div class="side-labels left"><label class="capture-label" data-pos="foto_s3_l"><div class="cap-icon"><svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M23 19a2 2 0 0 1-2 2H3a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h4l2-3h6l2 3h4a2 2 0 0 1 2 2z"/><circle cx="12" cy="13" r="4"/></svg><span class="cap-badge" id="cbadge_foto_s3_l"></span></div><span class="cap-text">Foto</span><span class="cap-arrow"></span><input type="file" accept="image/*" capture="environment" data-pos="foto_s3_l"></label></div>
<div class="section-strip puntstuk">
<svg class="track-overlay" viewBox="0 0 200 140" preserveAspectRatio="none"><line x1="90" y1="0" x2="90" y2="140" stroke="#555" stroke-width="2.5"/><line x1="110" y1="0" x2="110" y2="140" stroke="#555" stroke-width="2.5"/><line x1="110" y1="140" x2="155" y2="0" stroke="#555" stroke-width="2.5"/><line x1="115" y1="140" x2="165" y2="0" stroke="#555" stroke-width="2.5"/><line x1="70" y1="20" x2="140" y2="20" stroke="#8B7355" stroke-width="3" opacity="0.5"/><line x1="70" y1="45" x2="148" y2="45" stroke="#8B7355" stroke-width="3" opacity="0.5"/><line x1="70" y1="70" x2="152" y2="70" stroke="#8B7355" stroke-width="3" opacity="0.5"/><line x1="70" y1="95" x2="155" y2="95" stroke="#8B7355" stroke-width="3" opacity="0.5"/><line x1="70" y1="120" x2="158" y2="120" stroke="#8B7355" stroke-width="3" opacity="0.5"/></svg>
<div class="section-name">Puntstuk</div><div class="section-sub">gedeelte</div>
<div class="section-sub sm">Sectie 3</div>
</div>
<div class="side-labels right"><label class="capture-label" data-pos="foto_s3_r"><div class="cap-icon"><svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M23 19a2 2 0 0 1-2 2H3a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h4l2-3h6l2 3h4a2 2 0 0 1 2 2z"/><circle cx="12" cy="13" r="4"/></svg><span class="cap-badge" id="cbadge_foto_s3_r"></span></div><span class="cap-text">Foto</span><span class="cap-arrow"></span><input type="file" accept="image/*" capture="environment" data-pos="foto_s3_r"></label></div>
<div class="side-labels left"><label class="capture-label" data-pos="foto_s2_l"><div class="cap-icon"><svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M23 19a2 2 0 0 1-2 2H3a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h4l2-3h6l2 3h4a2 2 0 0 1 2 2z"/><circle cx="12" cy="13" r="4"/></svg><span class="cap-badge" id="cbadge_foto_s2_l"></span></div><span class="cap-text">Foto</span><span class="cap-arrow"></span><input type="file" accept="image/*" capture="environment" data-pos="foto_s2_l"></label></div>
<div class="section-strip midden">
<svg class="track-overlay" viewBox="0 0 200 140" preserveAspectRatio="none"><line x1="90" y1="0" x2="90" y2="140" stroke="#555" stroke-width="2.5"/><line x1="110" y1="0" x2="110" y2="140" stroke="#555" stroke-width="2.5"/><line x1="130" y1="0" x2="115" y2="140" stroke="#555" stroke-width="2.5"/><line x1="140" y1="0" x2="120" y2="140" stroke="#555" stroke-width="2.5"/><line x1="70" y1="25" x2="148" y2="25" stroke="#8B7355" stroke-width="3" opacity="0.5"/><line x1="70" y1="55" x2="143" y2="55" stroke="#8B7355" stroke-width="3" opacity="0.5"/><line x1="70" y1="85" x2="138" y2="85" stroke="#8B7355" stroke-width="3" opacity="0.5"/><line x1="70" y1="115" x2="133" y2="115" stroke="#8B7355" stroke-width="3" opacity="0.5"/></svg>
<div class="section-name">Midden-</div><div class="section-name">gedeelte</div>
<div class="section-sub sm">Sectie 2</div>
</div>
<div class="side-labels right"><label class="capture-label" data-pos="foto_s2_r"><div class="cap-icon"><svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M23 19a2 2 0 0 1-2 2H3a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h4l2-3h6l2 3h4a2 2 0 0 1 2 2z"/><circle cx="12" cy="13" r="4"/></svg><span class="cap-badge" id="cbadge_foto_s2_r"></span></div><span class="cap-text">Foto</span><span class="cap-arrow"></span><input type="file" accept="image/*" capture="environment" data-pos="foto_s2_r"></label></div>
<div class="side-labels left"><label class="capture-label" data-pos="foto_s1_l"><div class="cap-icon"><svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M23 19a2 2 0 0 1-2 2H3a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h4l2-3h6l2 3h4a2 2 0 0 1 2 2z"/><circle cx="12" cy="13" r="4"/></svg><span class="cap-badge" id="cbadge_foto_s1_l"></span></div><span class="cap-text">Foto</span><span class="cap-arrow"></span><input type="file" accept="image/*" capture="environment" data-pos="foto_s1_l"></label></div>
<div class="section-strip tong">
<svg class="track-overlay" viewBox="0 0 200 140" preserveAspectRatio="none"><line x1="90" y1="0" x2="90" y2="140" stroke="#555" stroke-width="2.5"/><line x1="110" y1="0" x2="110" y2="140" stroke="#555" stroke-width="2.5"/><line x1="113" y1="0" x2="110" y2="90" stroke="#555" stroke-width="2"/><line x1="117" y1="0" x2="114" y2="80" stroke="#555" stroke-width="2"/><circle cx="112" cy="95" r="3" fill="#c00"/><line x1="70" y1="15" x2="130" y2="15" stroke="#8B7355" stroke-width="3" opacity="0.5"/><line x1="70" y1="45" x2="130" y2="45" stroke="#8B7355" stroke-width="3" opacity="0.5"/><line x1="70" y1="75" x2="130" y2="75" stroke="#8B7355" stroke-width="3" opacity="0.5"/><line x1="70" y1="105" x2="130" y2="105" stroke="#8B7355" stroke-width="3" opacity="0.5"/><rect x="85" y="125" width="4" height="12" fill="#ffc000"/><rect x="111" y="125" width="4" height="12" fill="#ffc000"/></svg>
<div class="section-name">Tong-</div><div class="section-name">beweging</div>
<div class="section-sub sm">Sectie 1</div>
</div>
<div class="side-labels right"><label class="capture-label" data-pos="foto_s1_r"><div class="cap-icon"><svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M23 19a2 2 0 0 1-2 2H3a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h4l2-3h6l2 3h4a2 2 0 0 1 2 2z"/><circle cx="12" cy="13" r="4"/></svg><span class="cap-badge" id="cbadge_foto_s1_r"></span></div><span class="cap-text">Foto</span><span class="cap-arrow"></span><input type="file" accept="image/*" capture="environment" data-pos="foto_s1_r"></label></div>
<div class="ovz-row">
<label class="capture-label" data-pos="ovz_bl">
<div class="cap-icon"><svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M23 19a2 2 0 0 1-2 2H3a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h4l2-3h6l2 3h4a2 2 0 0 1 2 2z"/><circle cx="12" cy="13" r="4"/></svg><span class="cap-badge" id="cbadge_ovz_bl"></span></div>
<span class="cap-text">Overzichtsfoto</span><span class="cap-arrow lg">&#8599;</span>
<input type="file" accept="image/*" capture="environment" data-pos="ovz_bl">
</label>
<div class="flex-spacer"></div>
<label class="capture-label" data-pos="ovz_br">
<div class="cap-icon"><svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M23 19a2 2 0 0 1-2 2H3a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h4l2-3h6l2 3h4a2 2 0 0 1 2 2z"/><circle cx="12" cy="13" r="4"/></svg><span class="cap-badge" id="cbadge_ovz_br"></span></div>
<span class="cap-text">Overzichtsfoto</span><span class="cap-arrow lg">&#8598;</span>
<input type="file" accept="image/*" capture="environment" data-pos="ovz_br">
</label>
</div>
</div>
<div class="overview-thumbs" id="overviewThumbs">
<h4>Vastgelegde foto's</h4>
<div class="thumb-grid" id="overviewThumbGrid">
<div class="no-photos">Nog geen foto's gemaakt. Tik op een Foto- of Overzichtsfoto-knop hierboven.</div>
</div>
</div>
<div class="svg-link-row">
<button class="svg-link-btn" id="btnOpenSvg">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="18" height="18" rx="2" ry="2"/><circle cx="8.5" cy="8.5" r="1.5"/><polyline points="21 15 16 10 5 21"/></svg>
Technische tekening openen (SVG)
</button>
</div>
</div>
</div>
</div>
<div class="screen" id="screen-install">
<div class="install-toolbar">
<button class="btn-back" id="btnInstallBack">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="15 18 9 12 15 6"/></svg>
Terug
</button>
<h1>App installeren</h1>
</div>
<div class="install-body" id="installBody"></div>
</div>
<div class="modal-overlay" id="photoModal">
<div class="modal">
<button class="modal-close" id="photoModalClose">&times;</button>
<h3 id="photoModalTitle">Foto's</h3>
<div class="modal-actions">
<label><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M23 19a2 2 0 0 1-2 2H3a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h4l2-3h6l2 3h4a2 2 0 0 1 2 2z"/><circle cx="12" cy="13" r="4"/></svg>Foto maken<input type="file" accept="image/*" capture="environment" id="cameraInput"></label>
<label><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="18" height="18" rx="2" ry="2"/><circle cx="8.5" cy="8.5" r="1.5"/><polyline points="21 15 16 10 5 21"/></svg>Uit galerij<input type="file" accept="image/*" multiple id="galleryInput"></label>
</div>
<div class="photo-grid" id="photoGrid"></div>
</div>
</div>
<div class="lightbox" id="lightbox">
<img id="lightboxImg" src="" alt="Foto">
</div>
<script src="js/namespace.js"></script>
<script src="../src/Domain/sectionMap.js"></script>
<script src="../src/Domain/scoring.js"></script>
<script src="../src/Domain/orderParser.js"></script>
<script src="../src/Infrastructure/utils.js"></script>
<script src="../src/Infrastructure/geolocation.js"></script>
<script src="../src/Infrastructure/db.js"></script>
<script src="../src/Infrastructure/seedOrders.js"></script>
<script src="../src/Infrastructure/csvLoader.js"></script>
<script src="../src/Application/state.js"></script>
<script src="../src/Application/persistence.js"></script>
<script src="../src/Application/screens.js"></script>
<script src="../src/Application/photoService.js"></script>
<script src="../src/Application/inspectionForm.js"></script>
<script src="../src/Application/orderOverview.js"></script>
<script src="../src/Application/xmlImport.js"></script>
<script src="../src/Application/exportService.js"></script>
<script src="../src/Application/pwa.js"></script>
<script src="../src/Application/installScreen.js"></script>
<script src="js/main.js"></script>
</body>
</html>

114
public/js/main.js Normal file
View File

@ -0,0 +1,114 @@
(function () {
const A = window.App.Application;
const D = window.App.Domain;
const I = window.App.Infrastructure;
function openOrder(orderKey) {
A.state.currentOrderKey = orderKey;
const order = A.state.orders.find(o => o.orderKey === orderKey);
if (!order) return;
A.resetForm();
A.prefillFromOrder(order);
A.showScreen('screen-form');
A.loadSavedData(orderKey);
}
function goBackToOverview() {
A.saveCurrentForm();
A.state.currentOrderKey = null;
A.resetForm();
A.renderOverviewScreen(openOrder);
A.showScreen('screen-overview');
}
async function markOpnameGereed() {
if (!A.state.currentOrderKey) { alert('Geen order geselecteerd.'); return; }
await A.saveCurrentForm();
A.state.orderStatuses[A.state.currentOrderKey] = 'Opname gereed';
await I.saveOrderStatus(A.state.currentOrderKey, 'Opname gereed');
goBackToOverview();
}
function wireOverviewToolbar() {
document.getElementById('csvFileInput').addEventListener('change', async (e) => {
const file = e.target.files[0];
if (!file) return;
A.state.orders = await I.loadOrdersFromUpload(file);
A.renderOverviewScreen(openOrder);
e.target.value = '';
});
document.getElementById('btnOpenInstall').addEventListener('click', () => A.openInstallScreen());
document.querySelectorAll('.filter-btn').forEach(btn => {
btn.addEventListener('click', () => A.setFilter(btn.dataset.filter, openOrder));
});
document.querySelectorAll('.col-filter').forEach(inp => {
inp.addEventListener('input', () => {
inp.classList.toggle('has-value', inp.value.length > 0);
A.setColumnFilter(inp.dataset.col, inp.value, openOrder);
});
});
}
function wireInstallScreen() {
document.getElementById('btnInstallBack').addEventListener('click', () => {
A.showScreen('screen-overview');
});
}
function wireFormToolbar() {
document.getElementById('btnBack').addEventListener('click', goBackToOverview);
document.getElementById('btnOpnameGereed').addEventListener('click', markOpnameGereed);
document.getElementById('btnExport').addEventListener('click', () => A.exportFormData());
document.getElementById('btnPrint').addEventListener('click', () => window.print());
document.getElementById('xmlFileInput').addEventListener('change', async (e) => {
const file = e.target.files[0];
if (!file) return;
A.parseAndLoadXML(await file.text());
});
document.querySelectorAll('.tab-btn').forEach(btn => {
btn.addEventListener('click', () => A.switchTab(btn.dataset.tab));
});
document.getElementById('cameraInput').addEventListener('change', A.handlePhotoCapture);
document.getElementById('galleryInput').addEventListener('change', A.handlePhotoCapture);
document.getElementById('photoModalClose').addEventListener('click', A.closePhotoModal);
document.getElementById('lightbox').addEventListener('click', A.closeLightbox);
document.getElementById('btnOpenSvg').addEventListener('click', () => {
window.open('../PDF/Plaatje wissel_GW.svg', '_blank');
});
}
function wireKeyboard() {
document.addEventListener('keydown', e => {
if (e.key === 'Escape') { A.closePhotoModal(); A.closeLightbox(); }
});
}
async function init() {
await I.openDB();
A.state.orders = D.parseCSV(I.SEED_ORDERS_CSV);
A.state.orderStatuses = await I.loadAllOrderStatuses();
wireOverviewToolbar();
wireFormToolbar();
wireInstallScreen();
wireKeyboard();
A.renderOverviewScreen(openOrder);
A.initColumnResize();
A.initOverviewCapture();
A.pwa.register();
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
})();

1
public/js/namespace.js Normal file
View File

@ -0,0 +1 @@
window.App = window.App || { Domain: {}, Application: {}, Infrastructure: {} };

View File

@ -0,0 +1,21 @@
{
"name": "Duimstok-inspecties Bovenbouw",
"short_name": "Duimstok",
"description": "ProRail duimstok-inspecties voor wissels, overwegen en spoor.",
"lang": "nl",
"dir": "ltr",
"start_url": "./index.html",
"scope": "./",
"display": "standalone",
"orientation": "any",
"background_color": "#f5f5f5",
"theme_color": "#003082",
"icons": [
{
"src": "icons/icon.svg",
"sizes": "any",
"type": "image/svg+xml",
"purpose": "any maskable"
}
]
}

72
public/sw.js Normal file
View File

@ -0,0 +1,72 @@
const CACHE_VERSION = 'duimstok-v1';
const APP_SHELL = [
'./',
'./index.html',
'./manifest.webmanifest',
'./icons/icon.svg',
'./css/base.css',
'./css/overview.css',
'./css/form.css',
'./css/modals.css',
'./css/install.css',
'./css/responsive.css',
'./js/namespace.js',
'./js/main.js',
'../src/Domain/sectionMap.js',
'../src/Domain/scoring.js',
'../src/Domain/orderParser.js',
'../src/Infrastructure/utils.js',
'../src/Infrastructure/geolocation.js',
'../src/Infrastructure/db.js',
'../src/Infrastructure/seedOrders.js',
'../src/Infrastructure/csvLoader.js',
'../src/Application/state.js',
'../src/Application/persistence.js',
'../src/Application/screens.js',
'../src/Application/photoService.js',
'../src/Application/inspectionForm.js',
'../src/Application/orderOverview.js',
'../src/Application/xmlImport.js',
'../src/Application/exportService.js',
'../src/Application/pwa.js',
'../src/Application/installScreen.js'
];
self.addEventListener('install', (event) => {
event.waitUntil(
caches.open(CACHE_VERSION).then((cache) => cache.addAll(APP_SHELL))
);
self.skipWaiting();
});
self.addEventListener('activate', (event) => {
event.waitUntil(
caches.keys().then((keys) =>
Promise.all(keys.filter((k) => k !== CACHE_VERSION).map((k) => caches.delete(k)))
).then(() => self.clients.claim())
);
});
self.addEventListener('fetch', (event) => {
const req = event.request;
if (req.method !== 'GET') return;
const url = new URL(req.url);
if (url.origin !== self.location.origin) return;
event.respondWith(
caches.match(req).then((cached) => {
if (cached) return cached;
return fetch(req).then((resp) => {
if (resp && resp.status === 200 && resp.type === 'basic') {
const copy = resp.clone();
caches.open(CACHE_VERSION).then((cache) => cache.put(req, copy));
}
return resp;
}).catch(() => {
if (req.mode === 'navigate') return caches.match('./index.html');
return new Response('', { status: 504, statusText: 'Offline' });
});
})
);
});

View File

@ -0,0 +1,34 @@
(function (A, I) {
A.exportFormData = async function () {
await A.saveCurrentForm();
const fd = A.state.formData;
const x = ['<?xml version="1.0" encoding="utf-8"?>', '<FORM>'];
x.push(`<AUFNR_VORNR>${I.esc(fd.orderNr)}</AUFNR_VORNR>`);
x.push(`<INSPECTEUR>${I.esc(document.getElementById('inp_inspecteur').value)}</INSPECTEUR>`);
x.push(`<INSP_DATUM>${I.esc(document.getElementById('inp_inspectiedatum').value)}</INSP_DATUM>`);
x.push(`<TECH_JAAR>${I.esc(document.getElementById('inp_techjaar').value)}</TECH_JAAR>`);
x.push(`<INSP_JAAR>${I.esc(document.getElementById('inp_inspjaar').value)}</INSP_JAAR>`);
x.push(`<OPMERKING>${I.esc(document.getElementById('inp_opmerkingen').value)}</OPMERKING>`);
x.push('<OVERZICHTFOTOS>');
for (const [pos, photos] of Object.entries(fd.overviewPhotos)) {
photos.forEach((_, i) => x.push(`<FOTO><POSITIE>${I.esc(pos)}</POSITIE><NAAM>${I.esc(fd.orderNr.replace('/', '-'))}_${pos}_foto${i + 1}</NAAM></FOTO>`));
}
x.push('</OVERZICHTFOTOS>');
x.push('<BEOORDELINGEN>');
for (const [nr, data] of Object.entries(fd.scores)) {
const fNrs = (fd.photos[nr] || []).map((_, i) => `${fd.orderNr}_loc${nr}_foto${i + 1}`).join('; ');
x.push(`<WISSEL_SCORE><NR>${nr}</NR><DWL_SCORE>${I.esc(data.dwl || '')}</DWL_SCORE><BAL_SCORE>${I.esc(data.bal || '')}</BAL_SCORE><FOTO_NR>${I.esc(fNrs)}</FOTO_NR><OPMERKING>${I.esc(data.opm || '')}</OPMERKING></WISSEL_SCORE>`);
}
x.push('</BEOORDELINGEN>', '</FORM>');
I.downloadBlob(new Blob([x.join('\n')], { type: 'application/xml' }),
`${fd.orderNr.replace('/', '-')}_inspectie_${new Date().toISOString().slice(0, 10)}.xml`);
let pc = 0;
for (const [nr, photos] of Object.entries(fd.photos)) {
photos.forEach((p, i) => { pc++; I.downloadBlob(I.dataUrlToBlob(p.dataUrl), `${fd.orderNr.replace('/', '-')}_loc${nr}_foto${i + 1}.jpg`); });
}
for (const [pos, photos] of Object.entries(fd.overviewPhotos)) {
photos.forEach((p, i) => { pc++; I.downloadBlob(I.dataUrlToBlob(p.dataUrl), `${fd.orderNr.replace('/', '-')}_${pos}_foto${i + 1}.jpg`); });
}
alert(`Opgeslagen!\n- 1 XML-bestand\n- ${pc} foto('s)`);
};
})(window.App.Application, window.App.Infrastructure);

View File

@ -0,0 +1,144 @@
(function (A, D, I) {
A.switchTab = function (tabId) {
document.querySelectorAll('.tab-btn').forEach(btn =>
btn.classList.toggle('active', btn.dataset.tab === tabId));
document.querySelectorAll('.tab-panel').forEach(panel =>
panel.classList.toggle('active', panel.id === 'panel-' + tabId));
};
A.resetForm = function () {
A.state.formData = A.emptyFormData();
document.querySelectorAll('.header-grid .value').forEach(el => el.textContent = '');
document.querySelectorAll('#hdr_taakomschrijving, #hdr_aanleiding').forEach(el => el.textContent = '');
document.getElementById('inp_inspecteur').value = '';
document.getElementById('inp_inspectiedatum').value = '';
document.getElementById('inp_techjaar').value = '';
document.getElementById('inp_inspjaar').value = '';
document.getElementById('inp_opmerkingen').value = '';
document.getElementById('assessmentBody').innerHTML = '';
const totaal = document.getElementById('totaalscore');
totaal.textContent = '-';
totaal.className = 'cell score-cell';
document.getElementById('totaal_fotonummers').textContent = '';
document.getElementById('statusFotos').textContent = "Foto's: 0";
document.getElementById('statusSaved').textContent = '';
document.getElementById('statusOrder').textContent = '';
A.updateOverviewBadges();
A.renderOverviewThumbs();
A.switchTab('inspectie');
};
A.prefillFromOrder = function (order) {
A.state.formData.orderNr = order.orderKey;
document.getElementById('hdr_omschrijving').textContent = order.omschrijving;
document.getElementById('hdr_eqart').textContent = order.objectsoort;
document.getElementById('hdr_startpoint').textContent = order.startpunt;
document.getElementById('hdr_endpoint').textContent = order.eindpunt;
document.getElementById('hdr_orderoperatie').textContent = order.orderKey;
document.getElementById('inp_inspecteur').value = order.inspecteur || '';
document.getElementById('statusOrder').textContent = 'Order: ' + order.orderKey + ' | ' + order.omschrijving;
document.getElementById('formToolbarTitle').textContent = order.omschrijving;
};
function buildScoreOptions(selected) {
return '<option value="">-</option>' +
D.SCORE_VALUES.map(v => `<option value="${v}" ${selected === v ? 'selected' : ''}>${v}</option>`).join('');
}
A.buildAssessmentTable = function (scoreElements) {
const tbody = document.getElementById('assessmentBody');
tbody.innerHTML = '';
let currentSection = '';
scoreElements.forEach(el => {
const nr = parseInt(el.querySelector('NR').textContent.trim());
const locatie = el.querySelector('LOCATIE').textContent.trim();
const dwlScore = el.querySelector('DWL_SCORE').textContent.trim();
const balScore = el.querySelector('BAL_SCORE').textContent.trim();
const section = D.getSectionForNr(nr);
if (section && section !== currentSection) {
currentSection = section;
const sr = document.createElement('tr');
sr.className = 'section-row';
sr.innerHTML = `<td colspan="6">${section}</td>`;
tbody.appendChild(sr);
}
const tr = document.createElement('tr');
tr.dataset.nr = nr;
tr.innerHTML = `
<td class="nr-col">${nr}</td>
<td class="loc-col">${locatie}</td>
<td class="score-col editable-cell"><select data-nr="${nr}" data-type="dwl">${buildScoreOptions(dwlScore)}</select></td>
<td class="score-col editable-cell"><select data-nr="${nr}" data-type="bal">${buildScoreOptions(balScore)}</select></td>
<td class="foto-col"><button class="foto-btn" data-action="open-photo" data-nr="${nr}" data-loc="${locatie.replace(/"/g, '&quot;')}"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M23 19a2 2 0 0 1-2 2H3a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h4l2-3h6l2 3h4a2 2 0 0 1 2 2z"/><circle cx="12" cy="13" r="4"/></svg>Foto's<span class="badge" id="badge_${nr}"></span></button></td>
<td class="opm-col editable-cell"><input type="text" data-nr="${nr}" data-field="opm" placeholder=""></td>`;
tbody.appendChild(tr);
if (!A.state.formData.scores[nr]) A.state.formData.scores[nr] = { dwl: dwlScore, bal: balScore, opm: '' };
if (!A.state.formData.photos[nr]) A.state.formData.photos[nr] = [];
const dwlSel = tr.querySelector('select[data-type="dwl"]');
if (dwlScore) dwlSel.className = D.scoreClass(dwlScore);
const balSel = tr.querySelector('select[data-type="bal"]');
if (balScore) balSel.className = D.scoreClass(balScore);
});
tbody.querySelectorAll('select[data-nr]').forEach(sel => sel.addEventListener('change', () => onScoreChange(sel)));
tbody.querySelectorAll('input[data-field="opm"]').forEach(inp => inp.addEventListener('input', () => onOpmChange(inp)));
tbody.querySelectorAll('button[data-action="open-photo"]').forEach(btn =>
btn.addEventListener('click', () => A.openPhotoModal(+btn.dataset.nr, btn.dataset.loc)));
};
function onScoreChange(sel) {
const nr = parseInt(sel.dataset.nr);
const type = sel.dataset.type;
const val = sel.value;
if (!A.state.formData.scores[nr]) A.state.formData.scores[nr] = { dwl: '', bal: '', opm: '' };
A.state.formData.scores[nr][type] = val;
sel.className = D.scoreClass(val);
A.updateTotaalscore();
A.autoSave();
}
function onOpmChange(inp) {
const nr = parseInt(inp.dataset.nr);
if (!A.state.formData.scores[nr]) A.state.formData.scores[nr] = { dwl: '', bal: '', opm: '' };
A.state.formData.scores[nr].opm = inp.value;
A.autoSave();
}
A.updateTotaalscore = function () {
const worst = D.computeWorstScore(A.state.formData.scores);
const el = document.getElementById('totaalscore');
el.textContent = worst || '-';
el.className = 'cell score-cell';
if (worst) el.classList.add(D.scoreClass(worst));
};
A.loadSavedData = async function (orderNr) {
const saved = await I.loadInspection(orderNr);
if (!saved) return;
if (saved.inspecteur) document.getElementById('inp_inspecteur').value = saved.inspecteur;
if (saved.inspectiedatum) document.getElementById('inp_inspectiedatum').value = saved.inspectiedatum;
if (saved.techJaar) document.getElementById('inp_techjaar').value = saved.techJaar;
if (saved.inspJaar) document.getElementById('inp_inspjaar').value = saved.inspJaar;
if (saved.opmerkingen) document.getElementById('inp_opmerkingen').value = saved.opmerkingen;
if (saved.scores) {
A.state.formData.scores = saved.scores;
for (const [nr, data] of Object.entries(saved.scores)) {
const dS = document.querySelector(`select[data-nr="${nr}"][data-type="dwl"]`);
const bS = document.querySelector(`select[data-nr="${nr}"][data-type="bal"]`);
const oI = document.querySelector(`input[data-nr="${nr}"][data-field="opm"]`);
if (dS && data.dwl) { dS.value = data.dwl; dS.className = D.scoreClass(data.dwl); }
if (bS && data.bal) { bS.value = data.bal; bS.className = D.scoreClass(data.bal); }
if (oI && data.opm) oI.value = data.opm;
}
A.updateTotaalscore();
}
if (saved.photos) { A.state.formData.photos = saved.photos; A.updatePhotoBadges(); }
if (saved.overviewPhotos) {
A.state.formData.overviewPhotos = saved.overviewPhotos;
A.updateOverviewBadges();
A.renderOverviewThumbs();
}
document.getElementById('statusSaved').textContent = 'Eerder opgeslagen data hersteld';
};
})(window.App.Application, window.App.Domain, window.App.Infrastructure);

View File

@ -0,0 +1,147 @@
(function (A) {
function statusBlock() {
if (A.pwa.isFileProtocol()) {
return `<div class="install-status warn">
<strong>Let op:</strong>&nbsp;de app is nu geopend vanaf uw bestandssysteem
(<code>file://</code>). Installeren als PWA werkt alleen via een webserver
(bijv. <code>http://localhost</code> of een gedeploydde versie).
</div>`;
}
if (A.pwa.isStandalone()) {
return `<div class="install-status ok">
<strong>Geïnstalleerd.</strong>&nbsp;U gebruikt de app al in PWA-modus.
</div>`;
}
if (!A.pwa.isSupported()) {
return `<div class="install-status warn">
Deze browser ondersteunt PWA-installatie niet volledig. Probeer het in
Chrome, Edge, Safari (iOS 16.4+) of een Chromium-gebaseerde browser.
</div>`;
}
return `<div class="install-status info">
Volg onderstaande stappen om de app op dit apparaat te installeren.
</div>`;
}
function stepsForPlatform(platform) {
if (platform === 'ios') {
return `<ol>
<li>Tik onderin het scherm op het <strong>Delen</strong>-pictogram
(vierkant met pijl omhoog).</li>
<li>Scrol omlaag en tik op <strong>Zet op beginscherm</strong>.</li>
<li>Bevestig met <strong>Voeg toe</strong>. Het Duimstok-icoon verschijnt
tussen uw apps.</li>
<li>Open de app vanaf het beginscherm voor volledige werking offline.</li>
</ol>
<p style="font-size:12px;color:#666;margin-top:10px;">
iOS ondersteunt geen automatische installatieknop. Installatie gebeurt
altijd via het Delen-menu van Safari.
</p>`;
}
if (platform === 'android') {
return `<ol>
<li>Gebruik de knop <em>App installeren</em> hieronder, of</li>
<li>Tik rechtsboven op <strong>\u22EE</strong> (menu) en kies
<strong>App installeren</strong> / <strong>Toevoegen aan startscherm</strong>.</li>
<li>Bevestig met <strong>Installeren</strong>.</li>
</ol>`;
}
if (platform === 'desktop-chromium') {
return `<ol>
<li>Gebruik de knop <em>App installeren</em> hieronder, of</li>
<li>Klik op het installeerpictogram <strong>\u2B07</strong> rechts in de
adresbalk.</li>
<li>Als alternatief: menu <span class="key">\u22EE</span>
&rarr; <strong>Apps</strong> &rarr; <strong>Deze site installeren</strong>.</li>
</ol>`;
}
if (platform === 'firefox') {
return `<ol>
<li>Firefox desktop ondersteunt PWA-installatie niet standaard.</li>
<li>Maak een snelkoppeling op het bureaublad via
<strong>Bestand</strong> &rarr; <strong>Pagina opslaan als...</strong>
of gebruik Firefox op Android, waar <em>Toevoegen aan startscherm</em>
wel werkt.</li>
</ol>`;
}
if (platform === 'desktop-safari') {
return `<ol>
<li>Ga naar het menu <strong>Archief</strong> &rarr;
<strong>Voeg toe aan Dock</strong> (macOS Sonoma of nieuwer).</li>
<li>De app verschijnt in het Dock en werkt offline.</li>
</ol>`;
}
return `<ol>
<li>Uw browser is niet herkend. Zoek in het menu naar
<em>"Installeren"</em> of <em>"Toevoegen aan startscherm"</em>.</li>
</ol>`;
}
function benefitsBlock() {
return `<div class="install-benefits">
<h3>Voordelen van installatie</h3>
<ul>
<li>App opent vanaf uw startscherm, zonder browseradresbalk.</li>
<li>Werkt offline \u2014 inspecties zijn ook zonder dekking in te vullen.</li>
<li>Foto's en gegevens blijven lokaal opgeslagen op het apparaat.</li>
<li>Snellere start omdat bestanden in de cache staan.</li>
</ul>
</div>`;
}
function mainCTA() {
if (A.pwa.isFileProtocol()) return '';
if (A.pwa.isStandalone()) return '';
return `<button class="install-cta" id="installCta"
${A.pwa.canPromptInstall() ? '' : 'disabled'}>
${A.pwa.canPromptInstall() ? 'App installeren' : 'Installatie niet beschikbaar via knop'}
</button>`;
}
A.renderInstallScreen = function () {
const body = document.getElementById('installBody');
const platform = A.pwa.getPlatform();
body.innerHTML = `
<div class="install-hero">
<img src="icons/icon.svg" alt="Duimstok app-icoon">
<h2>Duimstok-inspecties als app</h2>
<p>Installeer deze applicatie op uw telefoon, tablet of desktop
voor offline gebruik in het veld.</p>
</div>
${statusBlock()}
<div class="install-steps">
<h3>Installatie op uw apparaat</h3>
${stepsForPlatform(platform)}
${mainCTA()}
</div>
${benefitsBlock()}
`;
const cta = document.getElementById('installCta');
if (cta && !cta.disabled) {
cta.addEventListener('click', async () => {
cta.disabled = true;
cta.textContent = 'Bezig...';
await A.pwa.promptInstall();
A.renderInstallScreen();
});
}
};
A.openInstallScreen = function () {
A.renderInstallScreen();
A.showScreen('screen-install');
};
document.addEventListener('pwa:install-available', () => {
if (document.getElementById('screen-install').classList.contains('active')) {
A.renderInstallScreen();
}
});
document.addEventListener('pwa:installed', () => {
if (document.getElementById('screen-install').classList.contains('active')) {
A.renderInstallScreen();
}
});
})(window.App.Application);

View File

@ -0,0 +1,147 @@
(function (A, D) {
function getOrderDisplayFields(o) {
const status = D.getEffectiveStatus(o, A.state.orderStatuses);
const done = D.isCompleted(o, A.state.orderStatuses);
const statusText = done ? (status === 'Gereed' ? 'Gereed' : 'Opname gereed') : 'Op te nemen';
return {
orderText: o.order + '/' + o.operatie,
typeText: o.objectsoort,
omschrText: o.omschrijving,
hoevhText: o.hoeveelheid + ' ' + o.eenheid,
statusText
};
}
function matchesColumnFilters(o) {
const d = getOrderDisplayFields(o);
const lc = s => s.toLowerCase();
const f = A.state.columnFilters;
if (f.order && !lc(d.orderText).includes(lc(f.order))) return false;
if (f.type && !lc(d.typeText).includes(lc(f.type))) return false;
if (f.omschrijving && !lc(d.omschrText).includes(lc(f.omschrijving))) return false;
if (f.hoeveelheid && !lc(d.hoevhText).includes(lc(f.hoeveelheid))) return false;
if (f.status && !lc(d.statusText).includes(lc(f.status))) return false;
return true;
}
function renderSummaryCards() {
const { orders, orderStatuses } = A.state;
const remaining = orders.filter(o => !D.isCompleted(o, orderStatuses));
const wissels = remaining.filter(o => o.objectsoort === 'Wissel').length;
const overwegen = remaining.filter(o => o.objectsoort === 'Overwegbevloering').length;
const spoorMeters = remaining.filter(o => o.objectsoort === 'Spoor')
.reduce((sum, o) => sum + o.hoeveelheid, 0);
document.getElementById('summaryCards').innerHTML = `
<div class="summary-card wissel">
<div class="card-icon">&#9783;</div>
<div class="card-info"><div class="card-value">${wissels}</div><div class="card-label">Wissels nog op te nemen</div></div>
</div>
<div class="summary-card overweg">
<div class="card-icon">&#9641;</div>
<div class="card-info"><div class="card-value">${overwegen}</div><div class="card-label">Overwegbevloeringen nog op te nemen</div></div>
</div>
<div class="summary-card spoor">
<div class="card-icon">&#8942;</div>
<div class="card-info"><div class="card-value">${spoorMeters} m</div><div class="card-label">Meter spoor nog op te nemen</div></div>
</div>
<div class="summary-card totaal">
<div class="card-icon">&#8721;</div>
<div class="card-info"><div class="card-value">${remaining.length} / ${orders.length}</div><div class="card-label">Totaal nog uit te voeren</div></div>
</div>
`;
const done = orders.filter(o => D.isCompleted(o, orderStatuses)).length;
const pct = orders.length > 0 ? Math.round((done / orders.length) * 100) : 0;
document.getElementById('progressFill').style.width = pct + '%';
document.getElementById('progressText').textContent = `${done} van ${orders.length} voltooid (${pct}%)`;
}
function renderOrderTable(onRowClick) {
const tbody = document.getElementById('orderTableBody');
const filtered = A.state.orders.filter(o => {
if (A.state.currentFilter === 'open' && D.isCompleted(o, A.state.orderStatuses)) return false;
if (A.state.currentFilter !== 'alle' && A.state.currentFilter !== 'open'
&& o.objectsoort !== A.state.currentFilter) return false;
return matchesColumnFilters(o);
});
tbody.innerHTML = filtered.map(o => {
const d = getOrderDisplayFields(o);
const done = D.isCompleted(o, A.state.orderStatuses);
const typeClass = o.objectsoort === 'Wissel' ? 'wissel'
: o.objectsoort === 'Overwegbevloering' ? 'overweg' : 'spoor';
const statusClass = done ? 'done' : 'open';
return `<tr data-order-key="${o.orderKey}">
<td title="${d.orderText}"><strong>${o.order}</strong>/${o.operatie}</td>
<td title="${d.typeText}"><span class="type-badge ${typeClass}">${o.objectsoort}</span></td>
<td title="${d.omschrText}">${o.omschrijving}</td>
<td class="hoevh-cell" title="${d.hoevhText}">${o.hoeveelheid} ${o.eenheid}</td>
<td title="${d.statusText}"><span class="status-badge ${statusClass}"><span class="status-dot"></span>${d.statusText}</span></td>
</tr>`;
}).join('');
tbody.querySelectorAll('tr[data-order-key]').forEach(tr => {
tr.addEventListener('click', () => onRowClick(tr.dataset.orderKey));
});
}
A.renderOverviewScreen = function (onRowClick) {
renderSummaryCards();
renderOrderTable(onRowClick);
};
A.setFilter = function (filter, onRowClick) {
A.state.currentFilter = filter;
document.querySelectorAll('.filter-btn').forEach(b => {
b.classList.toggle('active', b.dataset.filter === filter);
});
renderOrderTable(onRowClick);
};
A.setColumnFilter = function (col, value, onRowClick) {
A.state.columnFilters[col] = value;
renderOrderTable(onRowClick);
};
A.initColumnResize = function () {
const table = document.getElementById('orderTable');
if (!table) return;
const cols = table.querySelectorAll('colgroup col');
const handles = table.querySelectorAll('.col-resize');
handles.forEach(handle => {
handle.addEventListener('mousedown', startResize);
handle.addEventListener('touchstart', startResize, { passive: false });
});
function startResize(e) {
e.preventDefault();
e.stopPropagation();
const colIdx = parseInt(e.target.dataset.col);
const col = cols[colIdx];
if (!col) return;
const th = e.target.parentElement;
const startX = e.type === 'touchstart' ? e.touches[0].clientX : e.clientX;
const startWidth = th.offsetWidth;
e.target.classList.add('dragging');
function onMove(ev) {
const clientX = ev.type === 'touchmove' ? ev.touches[0].clientX : ev.clientX;
const newWidth = Math.max(50, startWidth + (clientX - startX));
col.style.width = newWidth + 'px';
}
function onUp() {
e.target.classList.remove('dragging');
document.removeEventListener('mousemove', onMove);
document.removeEventListener('mouseup', onUp);
document.removeEventListener('touchmove', onMove);
document.removeEventListener('touchend', onUp);
}
document.addEventListener('mousemove', onMove);
document.addEventListener('mouseup', onUp);
document.addEventListener('touchmove', onMove, { passive: false });
document.addEventListener('touchend', onUp);
}
};
})(window.App.Application, window.App.Domain);

View File

@ -0,0 +1,22 @@
(function (A, I) {
function readFormFields() {
const fd = A.state.formData;
fd.inspecteur = document.getElementById('inp_inspecteur').value;
fd.inspectiedatum = document.getElementById('inp_inspectiedatum').value;
fd.techJaar = document.getElementById('inp_techjaar').value;
fd.inspJaar = document.getElementById('inp_inspjaar').value;
fd.opmerkingen = document.getElementById('inp_opmerkingen').value;
}
A.saveCurrentForm = async function () {
if (!A.state.formData.orderNr) return;
readFormFields();
await I.saveInspection(A.state.formData);
document.getElementById('statusSaved').textContent =
'Opgeslagen: ' + new Date().toLocaleTimeString('nl-NL');
};
A.autoSave = function () {
A.scheduleAutoSave(() => A.saveCurrentForm());
};
})(window.App.Application, window.App.Infrastructure);

View File

@ -0,0 +1,163 @@
(function (A, D, I) {
A.openPhotoModal = function (nr, loc) {
A.state.currentPhotoRow = nr;
document.getElementById('photoModalTitle').textContent = `Foto's - Nr ${nr}: ${loc}`;
document.getElementById('photoModal').classList.add('active');
A.renderPhotoGrid();
};
A.closePhotoModal = function () {
document.getElementById('photoModal').classList.remove('active');
A.state.currentPhotoRow = null;
};
A.handlePhotoCapture = async function (e) {
if (!e.target.files.length || A.state.currentPhotoRow === null) return;
const row = A.state.currentPhotoRow;
for (const file of e.target.files) {
if (!A.state.formData.photos[row]) A.state.formData.photos[row] = [];
A.state.formData.photos[row].push({
dataUrl: await I.readFileAsDataUrl(file),
timestamp: new Date().toISOString(),
gps: await I.getGPS(),
filename: file.name
});
}
e.target.value = '';
A.renderPhotoGrid();
A.updatePhotoBadges();
A.autoSave();
};
A.renderPhotoGrid = function () {
const grid = document.getElementById('photoGrid');
const row = A.state.currentPhotoRow;
const photos = A.state.formData.photos[row] || [];
if (!photos.length) {
grid.innerHTML = '<p class="empty">Nog geen foto\'s.</p>';
return;
}
grid.innerHTML = photos.map((p, i) => `
<div class="photo-thumb">
<img src="${p.dataUrl}" data-r="${row}" data-i="${i}">
<button class="delete-btn" data-action="delete-photo" data-r="${row}" data-i="${i}">&times;</button>
<div class="photo-time">${new Date(p.timestamp).toLocaleTimeString('nl-NL', { hour: '2-digit', minute: '2-digit' })}${p.gps ? ' \u2316' : ''}</div>
</div>`).join('');
grid.querySelectorAll('img[data-r]').forEach(img => img.addEventListener('click', () => {
const ph = A.state.formData.photos[img.dataset.r]?.[+img.dataset.i];
if (ph) A.openLightbox(ph.dataUrl);
}));
grid.querySelectorAll('button[data-action="delete-photo"]').forEach(btn => btn.addEventListener('click', (ev) => {
ev.stopPropagation();
A.deletePhoto(+btn.dataset.r, +btn.dataset.i);
}));
};
A.deletePhoto = function (nr, i) {
if (!confirm('Foto verwijderen?')) return;
A.state.formData.photos[nr].splice(i, 1);
A.renderPhotoGrid();
A.updatePhotoBadges();
A.autoSave();
};
A.updatePhotoBadges = function () {
let total = 0;
for (const [nr, photos] of Object.entries(A.state.formData.photos)) {
const b = document.getElementById('badge_' + nr);
if (b) b.textContent = photos.length || '';
total += photos.length;
}
for (const photos of Object.values(A.state.formData.overviewPhotos)) total += photos.length;
document.getElementById('statusFotos').textContent = `Foto's: ${total}`;
const sum = [];
for (const [nr, photos] of Object.entries(A.state.formData.photos)) {
if (photos.length) sum.push(`Loc${nr}(${photos.length})`);
}
document.getElementById('totaal_fotonummers').textContent = sum.join(', ');
};
A.handleOverviewCapture = async function (e) {
const pos = e.target.dataset.pos;
if (!e.target.files.length || !pos) return;
if (!A.state.formData.overviewPhotos[pos]) A.state.formData.overviewPhotos[pos] = [];
for (const file of e.target.files) {
A.state.formData.overviewPhotos[pos].push({
dataUrl: await I.readFileAsDataUrl(file),
timestamp: new Date().toISOString(),
gps: await I.getGPS(),
filename: file.name
});
}
e.target.value = '';
A.updateOverviewBadges();
A.renderOverviewThumbs();
A.autoSave();
};
A.updateOverviewBadges = function () {
let tot = 0;
for (const [pos, photos] of Object.entries(A.state.formData.overviewPhotos)) {
const b = document.getElementById('cbadge_' + pos);
if (b) b.textContent = photos.length || '';
tot += photos.length;
}
const tb = document.getElementById('tabBadgeOvz');
tb.textContent = tot;
tb.classList.toggle('visible', tot > 0);
A.updatePhotoBadges();
};
A.renderOverviewThumbs = function () {
const grid = document.getElementById('overviewThumbGrid');
const all = [];
for (const [pos, photos] of Object.entries(A.state.formData.overviewPhotos)) {
photos.forEach((p, i) => all.push({ pos, index: i, photo: p, label: D.overviewPositions[pos] || pos }));
}
if (!all.length) {
grid.innerHTML = '<div class="no-photos">Nog geen foto\'s gemaakt.</div>';
return;
}
grid.innerHTML = all.map(it => `
<div class="thumb-item">
<img src="${it.photo.dataUrl}" data-pos="${it.pos}" data-idx="${it.index}">
<button class="thumb-del" data-action="delete-ov-photo" data-pos="${it.pos}" data-idx="${it.index}">&times;</button>
<div class="thumb-label">${it.label}</div>
</div>`).join('');
grid.querySelectorAll('img[data-pos]').forEach(img => img.addEventListener('click', ev => {
ev.stopPropagation();
const ph = A.state.formData.overviewPhotos[img.dataset.pos]?.[+img.dataset.idx];
if (ph) A.openLightbox(ph.dataUrl);
}));
grid.querySelectorAll('button[data-action="delete-ov-photo"]').forEach(btn => btn.addEventListener('click', ev => {
ev.stopPropagation();
A.deleteOvPhoto(btn.dataset.pos, +btn.dataset.idx);
}));
};
A.deleteOvPhoto = function (pos, i) {
if (!confirm('Foto verwijderen?')) return;
A.state.formData.overviewPhotos[pos].splice(i, 1);
if (!A.state.formData.overviewPhotos[pos].length) delete A.state.formData.overviewPhotos[pos];
A.updateOverviewBadges();
A.renderOverviewThumbs();
A.autoSave();
};
A.initOverviewCapture = function () {
document.querySelectorAll('.capture-label input[type="file"]').forEach(inp =>
inp.addEventListener('change', A.handleOverviewCapture));
};
A.openLightbox = function (src) {
document.getElementById('lightboxImg').src = src;
document.getElementById('lightbox').classList.add('active');
};
A.closeLightbox = function () {
document.getElementById('lightbox').classList.remove('active');
document.getElementById('lightboxImg').src = '';
};
})(window.App.Application, window.App.Domain, window.App.Infrastructure);

59
src/Application/pwa.js Normal file
View File

@ -0,0 +1,59 @@
(function (A) {
let deferredPrompt = null;
let swRegistration = null;
A.pwa = {
isFileProtocol() {
return location.protocol === 'file:';
},
isSupported() {
return 'serviceWorker' in navigator && !A.pwa.isFileProtocol();
},
isStandalone() {
return window.matchMedia('(display-mode: standalone)').matches
|| window.navigator.standalone === true;
},
getPlatform() {
const ua = navigator.userAgent;
const isIOS = /iPad|iPhone|iPod/.test(ua) && !window.MSStream;
const isIPadOS = navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1;
if (isIOS || isIPadOS) return 'ios';
if (/Android/.test(ua)) return 'android';
if (/Chrome|Chromium|Edg/.test(ua)) return 'desktop-chromium';
if (/Firefox/.test(ua)) return 'firefox';
if (/Safari/.test(ua)) return 'desktop-safari';
return 'other';
},
canPromptInstall() {
return deferredPrompt !== null;
},
async promptInstall() {
if (!deferredPrompt) return { outcome: 'unavailable' };
deferredPrompt.prompt();
const choice = await deferredPrompt.userChoice;
deferredPrompt = null;
return choice;
},
async register() {
if (!A.pwa.isSupported()) return null;
try {
swRegistration = await navigator.serviceWorker.register('./sw.js', { scope: './' });
return swRegistration;
} catch (err) {
console.warn('Service worker registration failed:', err);
return null;
}
}
};
window.addEventListener('beforeinstallprompt', (e) => {
e.preventDefault();
deferredPrompt = e;
document.dispatchEvent(new CustomEvent('pwa:install-available'));
});
window.addEventListener('appinstalled', () => {
deferredPrompt = null;
document.dispatchEvent(new CustomEvent('pwa:installed'));
});
})(window.App.Application);

View File

@ -0,0 +1,7 @@
(function (A) {
A.showScreen = function (id) {
document.querySelectorAll('.screen').forEach(s => s.classList.remove('active'));
document.getElementById(id).classList.add('active');
window.scrollTo(0, 0);
};
})(window.App.Application);

26
src/Application/state.js Normal file
View File

@ -0,0 +1,26 @@
(function (A) {
A.state = {
orders: [],
orderStatuses: {},
currentFilter: 'alle',
currentOrderKey: null,
currentPhotoRow: null,
columnFilters: { order: '', type: '', omschrijving: '', hoeveelheid: '', status: '' },
formData: emptyFormData()
};
A.emptyFormData = emptyFormData;
function emptyFormData() {
return {
orderNr: '', inspecteur: '', inspectiedatum: '', techJaar: '', inspJaar: '',
opmerkingen: '', scores: {}, photos: {}, overviewPhotos: {}
};
}
let saveTimeout = null;
A.scheduleAutoSave = function (saveFn) {
if (saveTimeout) clearTimeout(saveTimeout);
saveTimeout = setTimeout(saveFn, 2000);
};
})(window.App.Application);

View File

@ -0,0 +1,52 @@
(function (A, I) {
A.parseAndLoadXML = function (xmlText) {
const parser = new DOMParser();
const xml = parser.parseFromString(xmlText, 'text/xml');
const getText = (tag) => { const el = xml.querySelector(tag); return el ? el.textContent.trim() : ''; };
const orderNr = getText('AUFNR_VORNR');
A.state.formData.orderNr = orderNr;
A.state.currentOrderKey = orderNr;
document.getElementById('hdr_geo').textContent = getText('GEO');
document.getElementById('hdr_geotxt').textContent = getText('GEOTXT');
document.getElementById('hdr_equnr').textContent = getText('EQUNR');
document.getElementById('hdr_wisselnr').textContent = getText('WISSELNR') || getText('EQFNR_EQUI');
document.getElementById('hdr_eqart').textContent = getText('EQART');
document.getElementById('hdr_soort').textContent = getText('SOORT');
document.getElementById('hdr_startpoint').textContent = getText('START_POINT');
document.getElementById('hdr_endpoint').textContent = getText('END_POINT');
document.getElementById('hdr_hoekverhouding').textContent = getText('HOEKVERHOUDING');
document.getElementById('hdr_omschrijving').textContent = getText('EQKTX');
document.getElementById('hdr_orderoperatie').textContent = getText('AUFNR_VORNR');
document.getElementById('hdr_profiel').textContent = getText('PROFIEL');
document.getElementById('hdr_dwarsligger').textContent = getText('DWARSLIGGER') || '-';
document.getElementById('hdr_afwijking').textContent = getText('AFWIJKING') || '-';
document.getElementById('hdr_hergebruikt').textContent = '-';
document.getElementById('hdr_wisselverw').textContent = '-';
document.getElementById('hdr_spoor').textContent = getText('SPOOR');
document.getElementById('hdr_afschr').textContent = getText('ZAFSCHR_GRP_CODE');
document.getElementById('hdr_meetegengebogen').textContent = '-';
document.getElementById('hdr_classificatie').textContent = '-';
document.getElementById('hdr_voegloos').textContent = '-';
document.getElementById('hdr_plaatsingsdatum').textContent = I.formatDate(getText('DATUM_START'));
document.getElementById('hdr_aanschafdatum').textContent = '-';
document.getElementById('hdr_generatie').textContent = '-';
document.getElementById('hdr_taakomschrijving').textContent = getText('LTXA1');
document.getElementById('hdr_aanleiding').textContent = getText('KURZTEXT');
const eqktx = getText('EQKTX');
if (eqktx) document.getElementById('overzichtTitle').textContent = eqktx;
document.getElementById('formToolbarTitle').textContent = eqktx || orderNr;
if (getText('INSPECTEUR')) document.getElementById('inp_inspecteur').value = getText('INSPECTEUR');
if (getText('INSP_DATUM')) document.getElementById('inp_inspectiedatum').value = getText('INSP_DATUM');
if (getText('TECH_JAAR')) document.getElementById('inp_techjaar').value = getText('TECH_JAAR');
if (getText('INSP_JAAR')) document.getElementById('inp_inspjaar').value = getText('INSP_JAAR');
if (getText('OPMERKING')) document.getElementById('inp_opmerkingen').value = getText('OPMERKING');
A.buildAssessmentTable(xml.querySelectorAll('WISSEL_SCORE'));
document.getElementById('statusOrder').textContent = 'Order: ' + orderNr + ' | ' + eqktx;
A.loadSavedData(orderNr);
};
})(window.App.Application, window.App.Infrastructure);

32
src/Domain/orderParser.js Normal file
View File

@ -0,0 +1,32 @@
(function (D) {
D.parseCSV = function (csvText) {
const lines = csvText.trim().split('\n');
if (lines.length < 2) return [];
return lines.slice(1).filter(l => l.trim()).map(line => {
const cols = line.split(';');
const operatie = cols[1].trim();
return {
order: cols[0].trim(),
operatie: operatie,
orderKey: cols[0].trim() + '/' + operatie.padStart(4, '0'),
objectsoort: cols[2].trim(),
omschrijving: cols[3].trim(),
startpunt: cols[4].trim(),
eindpunt: cols[5].trim(),
hoeveelheid: parseFloat(cols[6].replace(',', '.')),
eenheid: cols[7].trim(),
csvStatus: cols[8].trim(),
inspecteur: cols[9] ? cols[9].trim() : ''
};
});
};
D.getEffectiveStatus = function (order, orderStatuses) {
return orderStatuses[order.orderKey] || order.csvStatus;
};
D.isCompleted = function (order, orderStatuses) {
const s = D.getEffectiveStatus(order, orderStatuses);
return s === 'Opname gereed' || s === 'Gereed';
};
})(window.App.Domain);

17
src/Domain/scoring.js Normal file
View File

@ -0,0 +1,17 @@
(function (D) {
D.SCORE_VALUES = ['B1','B2','B3','G1','G2','G3','G4','E3','E4','E5','E6'];
const SCORE_ORDER = ['', ...D.SCORE_VALUES];
D.computeWorstScore = function (scores) {
let wIdx = 0, wScore = '';
for (const data of Object.values(scores)) {
const m = Math.max(SCORE_ORDER.indexOf(data.dwl || ''), SCORE_ORDER.indexOf(data.bal || ''));
if (m > wIdx) { wIdx = m; wScore = SCORE_ORDER[m]; }
}
return wScore;
};
D.scoreClass = function (value) {
return value ? 'score-' + value.toLowerCase() : '';
};
})(window.App.Domain);

22
src/Domain/sectionMap.js Normal file
View File

@ -0,0 +1,22 @@
(function (D) {
D.sectionMap = {
'Sectie 1: Tongbeweging': [1, 2, 3, 4],
'Sectie 2: Middengedeelte': [5, 6, 7, 8, 9, 10],
'Sectie 3: Puntstuk gedeelte': [11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23]
};
D.getSectionForNr = function (nr) {
for (const [section, nrs] of Object.entries(D.sectionMap)) {
if (nrs.includes(nr)) return section;
}
return null;
};
D.overviewPositions = {
ovz_tl: 'Overzichtsfoto LB', ovz_tr: 'Overzichtsfoto RB',
ovz_bl: 'Overzichtsfoto LO', ovz_br: 'Overzichtsfoto RO',
foto_s3_l: 'Foto Puntstuk L', foto_s3_r: 'Foto Puntstuk R',
foto_s2_l: 'Foto Midden L', foto_s2_r: 'Foto Midden R',
foto_s1_l: 'Foto Tong L', foto_s1_r: 'Foto Tong R'
};
})(window.App.Domain);

View File

@ -0,0 +1,6 @@
(function (I, D) {
I.loadOrdersFromUpload = async function (file) {
const text = await file.text();
return D.parseCSV(text);
};
})(window.App.Infrastructure, window.App.Domain);

69
src/Infrastructure/db.js Normal file
View File

@ -0,0 +1,69 @@
(function (I) {
const DB_NAME = 'DuimstokInspecties';
const DB_VERSION = 3;
let db = null;
I.openDB = function () {
return new Promise((resolve, reject) => {
const req = indexedDB.open(DB_NAME, DB_VERSION);
req.onupgradeneeded = (e) => {
const d = e.target.result;
if (!d.objectStoreNames.contains('inspections'))
d.createObjectStore('inspections', { keyPath: 'orderNr' });
if (!d.objectStoreNames.contains('orderStatuses'))
d.createObjectStore('orderStatuses', { keyPath: 'orderKey' });
};
req.onsuccess = (e) => { db = e.target.result; resolve(db); };
req.onerror = (e) => reject(e);
});
};
async function ensureDb() {
if (!db) await I.openDB();
return db;
}
I.saveInspection = async function (formData) {
const d = await ensureDb();
return new Promise((resolve, reject) => {
const tx = d.transaction('inspections', 'readwrite');
tx.objectStore('inspections').put(JSON.parse(JSON.stringify(formData)));
tx.oncomplete = () => resolve();
tx.onerror = reject;
});
};
I.loadInspection = async function (orderNr) {
const d = await ensureDb();
return new Promise((resolve, reject) => {
const tx = d.transaction('inspections', 'readonly');
const req = tx.objectStore('inspections').get(orderNr);
req.onsuccess = () => resolve(req.result);
req.onerror = reject;
});
};
I.saveOrderStatus = async function (orderKey, status) {
const d = await ensureDb();
return new Promise((resolve, reject) => {
const tx = d.transaction('orderStatuses', 'readwrite');
tx.objectStore('orderStatuses').put({ orderKey, status, updatedAt: new Date().toISOString() });
tx.oncomplete = resolve;
tx.onerror = reject;
});
};
I.loadAllOrderStatuses = async function () {
const d = await ensureDb();
return new Promise((resolve, reject) => {
const tx = d.transaction('orderStatuses', 'readonly');
const req = tx.objectStore('orderStatuses').getAll();
req.onsuccess = () => {
const map = {};
(req.result || []).forEach(r => { map[r.orderKey] = r.status; });
resolve(map);
};
req.onerror = reject;
});
};
})(window.App.Infrastructure);

View File

@ -0,0 +1,12 @@
(function (I) {
I.getGPS = function () {
return new Promise(resolve => {
if (!navigator.geolocation) { resolve(null); return; }
navigator.geolocation.getCurrentPosition(
p => resolve({ lat: p.coords.latitude, lng: p.coords.longitude, accuracy: p.coords.accuracy }),
() => resolve(null),
{ enableHighAccuracy: true, timeout: 5000, maximumAge: 30000 }
);
});
};
})(window.App.Infrastructure);

View File

@ -0,0 +1,28 @@
(function (I) {
I.SEED_ORDERS_CSV = `Order;Operatie;Objectsoort;Omschr. TO taak;Startpunt;Eindpunt;Operatiehoevh.;Eenheid;Status;Inspecteur
4170437;10;Spoor;034 sp RU DWL 300-13.900;9.752,00;9.782,00;30;m;Op te nemen;Hein de Vries - Middelkamp
4170439;10;Spoor;034 sp NE DWL 300-13.899;9.757,00;9.788,00;31;m;Op te nemen;Hein de Vries - Middelkamp
4170313;10;Overwegbevloering;033 sp 601a 15.3 bevl 1 Kanaaldijk;15.326,00;15.333,00;1;st;Op te nemen;Hein de Vries - Middelkamp
4170314;10;Overwegbevloering;033 sp 602a 15.3 bevl 1 Kanaaldijk;15.326,00;15.333,00;1;st;Op te nemen;Hein de Vries - Middelkamp
4170315;10;Overwegbevloering;033 sp DZ 16.1 bevl 1 Bockhorstweg;16.156,00;16.163,00;1;st;Op te nemen;Hein de Vries - Middelkamp
4170330;10;Overwegbevloering;033 sp DZ 20.4 bevl 1 Hazenberg;20.450,00;20.459,00;1;st;Op te nemen;Hein de Vries - Middelkamp
4551480;10;Wissel;011 Wl 7 GW 1:9;67.818,00;67.856,00;1;st;Op te nemen;Hein de Vries - Middelkamp
4551480;20;Wissel;011 Wl 8 GW 1:9;67.847,00;67.882,00;1;st;Op te nemen;Hein de Vries - Middelkamp
4551480;40;Wissel;011 Wl 11 GW 1:9;67.869,00;67.908,00;1;st;Op te nemen;Hein de Vries - Middelkamp
4551480;30;Wissel;011 Wl 10 GW 1:9;67.887,00;67.926,00;1;st;Op te nemen;Hein de Vries - Middelkamp
4551480;50;Wissel;011 Wl 12 GW 1:9;67.905,00;67.941,00;1;st;Op te nemen;Hein de Vries - Middelkamp
4551480;60;Wissel;011 Wl 13 GW 1:9;67.908,00;67.944,00;1;st;Op te nemen;Hein de Vries - Middelkamp
4551480;70;Wissel;011 Wl 29 GW 1:9;68.522,00;68.561,00;1;st;Op te nemen;Hein de Vries - Middelkamp
4551480;80;Wissel;011 Wl 32 GW 1:9;68.579,00;68.617,00;1;st;Op te nemen;Hein de Vries - Middelkamp
4551480;90;Wissel;011 Wl 33 GW 1:9;68.582,00;68.619,00;1;st;Op te nemen;Hein de Vries - Middelkamp
4551480;100;Wissel;011 Wl 34 GW 1:9;68.592,00;68.626,00;1;st;Op te nemen;Hein de Vries - Middelkamp
4551480;120;Wissel;011 Wl 36 GW 1:9;68.618,00;68.653,00;1;st;Op te nemen;Hein de Vries - Middelkamp
4551480;110;Wissel;011 Wl 35 GW 1:9;68.620,00;68.655,00;1;st;Op te nemen;Hein de Vries - Middelkamp
4551480;130;Wissel;011 Wl 70 GW 1:9;68.811,00;68.850,00;1;st;Op te nemen;Hein de Vries - Middelkamp
4551480;140;Wissel;011 Wl 71 GW 1:9;68.855,00;68.891,00;1;st;Op te nemen;Hein de Vries - Middelkamp
4551480;150;Wissel;011 Wl 72 GW 1:9;68.882,00;68.918,00;1;st;Op te nemen;Hein de Vries - Middelkamp
4551480;160;Wissel;011 Wl 74 GW 1:9;68.915,00;68.948,00;1;st;Op te nemen;Hein de Vries - Middelkamp
4551480;170;Wissel;011 Wl 82 GW 1:9;69.089,00;69.127,00;1;st;Op te nemen;Hein de Vries - Middelkamp
4551480;180;Wissel;011 Wl 86 GW 1:9;69.131,00;69.169,00;1;st;Op te nemen;Hein de Vries - Middelkamp
4170452;10;Spoor;038 sp WY DWL 95.975-105.000;96.357,00;96.829,00;472;m;Gereed;Hein de Vries - Middelkamp`;
})(window.App.Infrastructure);

View File

@ -0,0 +1,35 @@
(function (I) {
I.esc = function (s) {
return String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
};
I.downloadBlob = function (blob, name) {
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = name;
a.click();
URL.revokeObjectURL(a.href);
};
I.dataUrlToBlob = function (dataUrl) {
const parts = dataUrl.split(',');
const mime = parts[0].match(/:(.*?);/)[1];
const bin = atob(parts[1]);
const arr = new Uint8Array(bin.length);
for (let i = 0; i < bin.length; i++) arr[i] = bin.charCodeAt(i);
return new Blob([arr], { type: mime });
};
I.readFileAsDataUrl = function (file) {
return new Promise(resolve => {
const fr = new FileReader();
fr.onload = () => resolve(fr.result);
fr.readAsDataURL(file);
});
};
I.formatDate = function (d) {
if (!d || d.length !== 8) return d || '-';
return d.substring(6, 8) + '.' + d.substring(4, 6) + '.' + d.substring(0, 4);
};
})(window.App.Infrastructure);