diff --git a/public/css/base.css b/public/css/base.css
new file mode 100644
index 0000000..95de9bd
--- /dev/null
+++ b/public/css/base.css
@@ -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; }
diff --git a/public/css/form.css b/public/css/form.css
new file mode 100644
index 0000000..f32dd76
--- /dev/null
+++ b/public/css/form.css
@@ -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; }
diff --git a/public/css/install.css b/public/css/install.css
new file mode 100644
index 0000000..ffadc8c
--- /dev/null
+++ b/public/css/install.css
@@ -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;
+}
diff --git a/public/css/modals.css b/public/css/modals.css
new file mode 100644
index 0000000..1302540
--- /dev/null
+++ b/public/css/modals.css
@@ -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; }
diff --git a/public/css/overview.css b/public/css/overview.css
new file mode 100644
index 0000000..1c32511
--- /dev/null
+++ b/public/css/overview.css
@@ -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; }
diff --git a/public/css/responsive.css b/public/css/responsive.css
new file mode 100644
index 0000000..cbd57e3
--- /dev/null
+++ b/public/css/responsive.css
@@ -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; }
+}
diff --git a/public/icons/icon.svg b/public/icons/icon.svg
new file mode 100644
index 0000000..a618dfb
--- /dev/null
+++ b/public/icons/icon.svg
@@ -0,0 +1,15 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+ DUIMSTOK
+
diff --git a/public/index.html b/public/index.html
new file mode 100644
index 0000000..7c6662b
--- /dev/null
+++ b/public/index.html
@@ -0,0 +1,320 @@
+
+
+
+
+
+
+
+
+
+
+
+Duimstok-inspecties Bovenbouw
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/public/js/main.js b/public/js/main.js
new file mode 100644
index 0000000..6fa8978
--- /dev/null
+++ b/public/js/main.js
@@ -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();
+ }
+})();
diff --git a/public/js/namespace.js b/public/js/namespace.js
new file mode 100644
index 0000000..e28c863
--- /dev/null
+++ b/public/js/namespace.js
@@ -0,0 +1 @@
+window.App = window.App || { Domain: {}, Application: {}, Infrastructure: {} };
diff --git a/public/manifest.webmanifest b/public/manifest.webmanifest
new file mode 100644
index 0000000..81a1538
--- /dev/null
+++ b/public/manifest.webmanifest
@@ -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"
+ }
+ ]
+}
diff --git a/public/sw.js b/public/sw.js
new file mode 100644
index 0000000..ed08810
--- /dev/null
+++ b/public/sw.js
@@ -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' });
+ });
+ })
+ );
+});
diff --git a/src/Application/exportService.js b/src/Application/exportService.js
new file mode 100644
index 0000000..bb724dd
--- /dev/null
+++ b/src/Application/exportService.js
@@ -0,0 +1,34 @@
+(function (A, I) {
+ A.exportFormData = async function () {
+ await A.saveCurrentForm();
+ const fd = A.state.formData;
+ const x = ['', '');
+ 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);
diff --git a/src/Application/inspectionForm.js b/src/Application/inspectionForm.js
new file mode 100644
index 0000000..9f08eb3
--- /dev/null
+++ b/src/Application/inspectionForm.js
@@ -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 '- ' +
+ D.SCORE_VALUES.map(v => `${v} `).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 = `${section} `;
+ tbody.appendChild(sr);
+ }
+ const tr = document.createElement('tr');
+ tr.dataset.nr = nr;
+ tr.innerHTML = `
+ ${nr}
+ ${locatie}
+ ${buildScoreOptions(dwlScore)}
+ ${buildScoreOptions(balScore)}
+ Foto's
+ `;
+ 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);
diff --git a/src/Application/installScreen.js b/src/Application/installScreen.js
new file mode 100644
index 0000000..aa7962b
--- /dev/null
+++ b/src/Application/installScreen.js
@@ -0,0 +1,147 @@
+(function (A) {
+ function statusBlock() {
+ if (A.pwa.isFileProtocol()) {
+ return `
+ Let op: de app is nu geopend vanaf uw bestandssysteem
+ (file://). Installeren als PWA werkt alleen via een webserver
+ (bijv. http://localhost of een gedeploydde versie).
+
`;
+ }
+ if (A.pwa.isStandalone()) {
+ return `
+ Geïnstalleerd. U gebruikt de app al in PWA-modus.
+
`;
+ }
+ if (!A.pwa.isSupported()) {
+ return `
+ Deze browser ondersteunt PWA-installatie niet volledig. Probeer het in
+ Chrome, Edge, Safari (iOS 16.4+) of een Chromium-gebaseerde browser.
+
`;
+ }
+ return `
+ Volg onderstaande stappen om de app op dit apparaat te installeren.
+
`;
+ }
+
+ function stepsForPlatform(platform) {
+ if (platform === 'ios') {
+ return `
+ Tik onderin het scherm op het Delen -pictogram
+ (vierkant met pijl omhoog).
+ Scrol omlaag en tik op Zet op beginscherm .
+ Bevestig met Voeg toe . Het Duimstok-icoon verschijnt
+ tussen uw apps.
+ Open de app vanaf het beginscherm voor volledige werking offline.
+
+
+ iOS ondersteunt geen automatische installatieknop. Installatie gebeurt
+ altijd via het Delen-menu van Safari.
+
`;
+ }
+ if (platform === 'android') {
+ return `
+ Gebruik de knop App installeren hieronder, of
+ Tik rechtsboven op \u22EE (menu) en kies
+ App installeren / Toevoegen aan startscherm .
+ Bevestig met Installeren .
+ `;
+ }
+ if (platform === 'desktop-chromium') {
+ return `
+ Gebruik de knop App installeren hieronder, of
+ Klik op het installeerpictogram \u2B07 rechts in de
+ adresbalk.
+ Als alternatief: menu \u22EE
+ → Apps → Deze site installeren .
+ `;
+ }
+ if (platform === 'firefox') {
+ return `
+ Firefox desktop ondersteunt PWA-installatie niet standaard.
+ Maak een snelkoppeling op het bureaublad via
+ Bestand → Pagina opslaan als...
+ of gebruik Firefox op Android, waar Toevoegen aan startscherm
+ wel werkt.
+ `;
+ }
+ if (platform === 'desktop-safari') {
+ return `
+ Ga naar het menu Archief →
+ Voeg toe aan Dock (macOS Sonoma of nieuwer).
+ De app verschijnt in het Dock en werkt offline.
+ `;
+ }
+ return `
+ Uw browser is niet herkend. Zoek in het menu naar
+ "Installeren" of "Toevoegen aan startscherm" .
+ `;
+ }
+
+ function benefitsBlock() {
+ return `
+
Voordelen van installatie
+
+ App opent vanaf uw startscherm, zonder browseradresbalk.
+ Werkt offline \u2014 inspecties zijn ook zonder dekking in te vullen.
+ Foto's en gegevens blijven lokaal opgeslagen op het apparaat.
+ Snellere start omdat bestanden in de cache staan.
+
+
`;
+ }
+
+ function mainCTA() {
+ if (A.pwa.isFileProtocol()) return '';
+ if (A.pwa.isStandalone()) return '';
+ return `
+ ${A.pwa.canPromptInstall() ? 'App installeren' : 'Installatie niet beschikbaar via knop'}
+ `;
+ }
+
+ A.renderInstallScreen = function () {
+ const body = document.getElementById('installBody');
+ const platform = A.pwa.getPlatform();
+ body.innerHTML = `
+
+
+
Duimstok-inspecties als app
+
Installeer deze applicatie op uw telefoon, tablet of desktop
+ voor offline gebruik in het veld.
+
+ ${statusBlock()}
+
+
Installatie op uw apparaat
+ ${stepsForPlatform(platform)}
+ ${mainCTA()}
+
+ ${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);
diff --git a/src/Application/orderOverview.js b/src/Application/orderOverview.js
new file mode 100644
index 0000000..b96eb6d
--- /dev/null
+++ b/src/Application/orderOverview.js
@@ -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 = `
+
+
☷
+
${wissels}
Wissels nog op te nemen
+
+
+
▩
+
${overwegen}
Overwegbevloeringen nog op te nemen
+
+
+
⋮
+
${spoorMeters} m
Meter spoor nog op te nemen
+
+
+
∑
+
${remaining.length} / ${orders.length}
Totaal nog uit te voeren
+
+ `;
+
+ 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 `
+ ${o.order} /${o.operatie}
+ ${o.objectsoort}
+ ${o.omschrijving}
+ ${o.hoeveelheid} ${o.eenheid}
+ ${d.statusText}
+ `;
+ }).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);
diff --git a/src/Application/persistence.js b/src/Application/persistence.js
new file mode 100644
index 0000000..8c8dd14
--- /dev/null
+++ b/src/Application/persistence.js
@@ -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);
diff --git a/src/Application/photoService.js b/src/Application/photoService.js
new file mode 100644
index 0000000..d660afa
--- /dev/null
+++ b/src/Application/photoService.js
@@ -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 = 'Nog geen foto\'s.
';
+ return;
+ }
+ grid.innerHTML = photos.map((p, i) => `
+
+
+
×
+
${new Date(p.timestamp).toLocaleTimeString('nl-NL', { hour: '2-digit', minute: '2-digit' })}${p.gps ? ' \u2316' : ''}
+
`).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 = 'Nog geen foto\'s gemaakt.
';
+ return;
+ }
+ grid.innerHTML = all.map(it => `
+
+
+
×
+
${it.label}
+
`).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);
diff --git a/src/Application/pwa.js b/src/Application/pwa.js
new file mode 100644
index 0000000..73b9ac1
--- /dev/null
+++ b/src/Application/pwa.js
@@ -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);
diff --git a/src/Application/screens.js b/src/Application/screens.js
new file mode 100644
index 0000000..ed69a5c
--- /dev/null
+++ b/src/Application/screens.js
@@ -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);
diff --git a/src/Application/state.js b/src/Application/state.js
new file mode 100644
index 0000000..8e0de94
--- /dev/null
+++ b/src/Application/state.js
@@ -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);
diff --git a/src/Application/xmlImport.js b/src/Application/xmlImport.js
new file mode 100644
index 0000000..f2a2ad9
--- /dev/null
+++ b/src/Application/xmlImport.js
@@ -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);
diff --git a/src/Domain/orderParser.js b/src/Domain/orderParser.js
new file mode 100644
index 0000000..4e5699d
--- /dev/null
+++ b/src/Domain/orderParser.js
@@ -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);
diff --git a/src/Domain/scoring.js b/src/Domain/scoring.js
new file mode 100644
index 0000000..de8d93a
--- /dev/null
+++ b/src/Domain/scoring.js
@@ -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);
diff --git a/src/Domain/sectionMap.js b/src/Domain/sectionMap.js
new file mode 100644
index 0000000..75c4ee6
--- /dev/null
+++ b/src/Domain/sectionMap.js
@@ -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);
diff --git a/src/Infrastructure/csvLoader.js b/src/Infrastructure/csvLoader.js
new file mode 100644
index 0000000..e9f4970
--- /dev/null
+++ b/src/Infrastructure/csvLoader.js
@@ -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);
diff --git a/src/Infrastructure/db.js b/src/Infrastructure/db.js
new file mode 100644
index 0000000..ab859f2
--- /dev/null
+++ b/src/Infrastructure/db.js
@@ -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);
diff --git a/src/Infrastructure/geolocation.js b/src/Infrastructure/geolocation.js
new file mode 100644
index 0000000..e3f25fe
--- /dev/null
+++ b/src/Infrastructure/geolocation.js
@@ -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);
diff --git a/src/Infrastructure/seedOrders.js b/src/Infrastructure/seedOrders.js
new file mode 100644
index 0000000..66c3235
--- /dev/null
+++ b/src/Infrastructure/seedOrders.js
@@ -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);
diff --git a/src/Infrastructure/utils.js b/src/Infrastructure/utils.js
new file mode 100644
index 0000000..74b83b1
--- /dev/null
+++ b/src/Infrastructure/utils.js
@@ -0,0 +1,35 @@
+(function (I) {
+ I.esc = function (s) {
+ return String(s).replace(/&/g, '&').replace(//g, '>');
+ };
+
+ 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);