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 + + + + + + + + + +
+ +
+

Duimstok-inspecties Bovenbouw

+ + +
+ +
+ +
+ +
+
+
+
+ +
+
+

Orders

+
+ + + + + +
+
+
+ + + + + + + + + + + + + + + + + + + + + + +
Order
Type
Omschrijving
Hoeveelheid
Status
+
+
+ +
+
+ +
+ +
+ + ProRail Duimstok-inspectie + + + + +
+ +
+ + +
+ +
+
+
Duimstokformulier Gewoon of Symmetrisch wissel
+
+
Geocode
+
Generatie
+
Geocode omschrijving
+
Profiel
+
Equipmentnummer
+
Wisselligger soort
+
Wisselnummer
+
Achterkant afwijking
+
Objectsoort
+
Hergebruikt object?
+
Wisselsoort
+
Aangesloten wisselverwarming
+
Startpunt
+
Hoofdspoor/Zijspoor
+
Eindpunt
+
Afschrijvingsgroep
+
Mee-/tegengebogen wissel
+
Wissel classificatie
+
Voegloos
+
Plaatsingsdatum
+
Hoekverhouding
+
Aanschafdatum
+
Omschrijving
+
Order-operatie
+
+
+
Taak omschrijving
+
+
+
Aanleiding probleem
+
+
+
Inspecteur
+
+
Techn. verv. jaar
+
+
+
+
Inspectiedatum
+
+
(Nieuw) Inspectiejaar
+
+
+
Zowel de naam van de inspecteur als de inspectiedatum dienen ingevuld te worden!
+
+
Totaalscore
-
+
Fotonummers
+
+ + + + + + + +
NrLocatie in wisselDWL ScoreBAL ScoreFoto'sOpmerkingen / bijzonderheden
+
+ + +
+
+ Geen order geladen + Foto's: 0 + +
+
+
+ +
+
+
Gewoon of Symmetrisch wissel
+
+ Instructie: 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. +
+
+
+ +
+ +
+
+
+ +
Puntstuk
gedeelte
+
Sectie 3
+
+
+
+
+ +
Midden-
gedeelte
+
Sectie 2
+
+
+
+
+ +
Tong-
beweging
+
Sectie 1
+
+
+
+ +
+ +
+
+
+

Vastgelegde foto's

+
+
Nog geen foto's gemaakt. Tik op een Foto- of Overzichtsfoto-knop hierboven.
+
+
+ +
+
+
+ +
+
+ +

App installeren

+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + 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 = ['', '
']; + x.push(`${I.esc(fd.orderNr)}`); + x.push(`${I.esc(document.getElementById('inp_inspecteur').value)}`); + x.push(`${I.esc(document.getElementById('inp_inspectiedatum').value)}`); + x.push(`${I.esc(document.getElementById('inp_techjaar').value)}`); + x.push(`${I.esc(document.getElementById('inp_inspjaar').value)}`); + x.push(`${I.esc(document.getElementById('inp_opmerkingen').value)}`); + x.push(''); + for (const [pos, photos] of Object.entries(fd.overviewPhotos)) { + photos.forEach((_, i) => x.push(`${I.esc(pos)}${I.esc(fd.orderNr.replace('/', '-'))}_${pos}_foto${i + 1}`)); + } + x.push(''); + x.push(''); + 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(`${nr}${I.esc(data.dwl || '')}${I.esc(data.bal || '')}${I.esc(fNrs)}${I.esc(data.opm || '')}`); + } + x.push('', '
'); + 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 => ``).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} + + + + `; + 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 `
    +
  1. Tik onderin het scherm op het Delen-pictogram + (vierkant met pijl omhoog).
  2. +
  3. Scrol omlaag en tik op Zet op beginscherm.
  4. +
  5. Bevestig met Voeg toe. Het Duimstok-icoon verschijnt + tussen uw apps.
  6. +
  7. Open de app vanaf het beginscherm voor volledige werking offline.
  8. +
+

+ iOS ondersteunt geen automatische installatieknop. Installatie gebeurt + altijd via het Delen-menu van Safari. +

`; + } + if (platform === 'android') { + return `
    +
  1. Gebruik de knop App installeren hieronder, of
  2. +
  3. Tik rechtsboven op \u22EE (menu) en kies + App installeren / Toevoegen aan startscherm.
  4. +
  5. Bevestig met Installeren.
  6. +
`; + } + if (platform === 'desktop-chromium') { + return `
    +
  1. Gebruik de knop App installeren hieronder, of
  2. +
  3. Klik op het installeerpictogram \u2B07 rechts in de + adresbalk.
  4. +
  5. Als alternatief: menu \u22EE + → AppsDeze site installeren.
  6. +
`; + } + if (platform === 'firefox') { + return `
    +
  1. Firefox desktop ondersteunt PWA-installatie niet standaard.
  2. +
  3. Maak een snelkoppeling op het bureaublad via + BestandPagina opslaan als... + of gebruik Firefox op Android, waar Toevoegen aan startscherm + wel werkt.
  4. +
`; + } + if (platform === 'desktop-safari') { + return `
    +
  1. Ga naar het menu Archief → + Voeg toe aan Dock (macOS Sonoma of nieuwer).
  2. +
  3. De app verschijnt in het Dock en werkt offline.
  4. +
`; + } + return `
    +
  1. Uw browser is niet herkend. Zoek in het menu naar + "Installeren" of "Toevoegen aan startscherm".
  2. +
`; + } + + function benefitsBlock() { + return `
+

Voordelen van installatie

+ +
`; + } + + function mainCTA() { + if (A.pwa.isFileProtocol()) return ''; + if (A.pwa.isStandalone()) return ''; + return ``; + } + + A.renderInstallScreen = function () { + const body = document.getElementById('installBody'); + const platform = A.pwa.getPlatform(); + body.innerHTML = ` +
+ Duimstok app-icoon +

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);