Files
boc/landvex-site/inspector-app.html
T
Bernt 1a12fb870b dev-api: add developer portal, OpenAPI spec, and Flatenbadet case study
- New /developers/ page with API docs, SDKs, pricing, use cases
- OpenAPI 3.0 spec for Orders, Missions, Photos, Analytics
- Case study: Glasskiosken i Flatenbadet — complete ROI analysis
- Updated /order/ with recurring missions and frequency dropdown
2026-07-14 15:27:33 +00:00

978 lines
39 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="sv">
<head>
<meta charset="UTF-8">
<link rel="icon" type="image/x-icon" href="/favicon.ico">
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png">
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover, user-scalable=no">
<title>LandveX Inspector</title>
<link rel="stylesheet" >
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css">
<style>
* { box-sizing: border-box; margin: 0; padding: 0; -webkit-tap-highlight-color: transparent; }
html, body {
font-family: var(--font-text);
background: var(--grouped-background-primary);
color: var(--label-primary);
height: 100%; overflow: hidden;
max-width: 390px; margin: 0 auto;
}
/* Safe areas */
.nav-bar {
background: var(--glass-bg);
backdrop-filter: var(--glass-blur);
-webkit-backdrop-filter: var(--glass-blur);
border-bottom: 0.5px solid var(--separator);
padding: 0 16px 12px;
padding-top: calc(14px + env(safe-area-inset-top));
position: fixed; top: 0; left: 0; right: 0;
max-width: 390px; margin: 0 auto;
z-index: 1000;
}
.nav-row { display: flex; align-items: center; justify-content: space-between; }
.nav-title { font-size: 17px; font-weight: 600; }
.nav-date { font-size: 13px; color: var(--label-secondary); margin-top: 2px; }
.nav-avatar {
width: 32px; height: 32px; border-radius: var(--radius-full);
background: var(--ios-blue); color: white;
display: flex; align-items: center; justify-content: center;
font-size: 14px; font-weight: 700;
}
.refresh-btn {
background: none; border: none; font-size: 20px; cursor: pointer;
padding: 4px; color: var(--ios-blue);
}
/* Scroll area */
.scroll-area {
position: fixed; top: 0; left: 0; right: 0; bottom: 0;
max-width: 390px; margin: 0 auto;
overflow-y: auto; -webkit-overflow-scrolling: touch;
padding-bottom: calc(80px + env(safe-area-inset-bottom));
}
.scroll-inner { padding-top: 96px; }
/* Pull-to-refresh indicator */
.ptr-indicator {
text-align: center; padding: 12px; font-size: 13px;
color: var(--label-secondary); display: none;
}
.ptr-indicator.active { display: block; }
/* Screens */
.screen { display: none; }
.screen.active { display: block; }
/* Sections */
.section-header {
font-size: 13px; font-weight: 600; color: var(--label-secondary);
text-transform: uppercase; letter-spacing: 0.5px;
padding: 0 20px; margin: 20px 0 8px;
}
/* Mission cards */
.mission-card {
margin: 0 16px 10px;
background: var(--grouped-background-secondary);
border-radius: var(--radius-md); overflow: hidden;
box-shadow: 0 1px 3px rgba(0,0,0,0.08);
cursor: pointer;
transition: transform 0.15s, box-shadow 0.15s;
-webkit-touch-callout: none;
}
.mission-card:active { transform: scale(0.98); box-shadow: 0 0 0 rgba(0,0,0,0); }
.mission-top { display: flex; align-items: center; padding: 14px 16px; gap: 12px; }
.mission-type-icon {
width: 44px; height: 44px; border-radius: var(--radius-md);
display: flex; align-items: center; justify-content: center;
font-size: 22px; flex-shrink: 0;
}
.type-bridge { background: rgba(0,122,255,0.12); }
.type-road { background: rgba(255,149,0,0.12); }
.type-facade { background: rgba(88,86,214,0.12); }
.type-roof { background: rgba(52,199,89,0.12); }
.mission-info { flex: 1; min-width: 0; }
.mission-name { font-size: 16px; font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.mission-addr { font-size: 13px; color: var(--label-secondary); margin-top: 2px; }
.mission-meta { display: flex; align-items: center; gap: 6px; margin-top: 6px; }
.mission-priority {
padding: 2px 8px; border-radius: var(--radius-sm);
font-size: 11px; font-weight: 700;
}
.priority-urgent { background: rgba(255,59,48,0.12); color: var(--ios-red); }
.priority-normal { background: rgba(0,122,255,0.10); color: var(--ios-blue); }
.priority-low { background: var(--fill-tertiary); color: var(--label-secondary); }
.mission-time { font-size: 12px; color: var(--label-secondary); }
.mission-arrow { color: var(--ios-gray3); font-size: 14px; }
/* Map */
#map { height: 200px; margin: 0 16px 4px; border-radius: var(--radius-md); overflow: hidden; }
/* Tab bar */
.tab-bar {
position: fixed; bottom: 0; left: 0; right: 0;
max-width: 390px; margin: 0 auto;
background: var(--glass-thick-bg);
backdrop-filter: var(--glass-thick-blur);
-webkit-backdrop-filter: var(--glass-thick-blur);
border-top: 0.5px solid var(--separator);
padding-bottom: env(safe-area-inset-bottom);
display: flex; z-index: 999;
}
.tab-item {
flex: 1; display: flex; flex-direction: column; align-items: center;
padding: 10px 0 8px; cursor: pointer;
color: var(--label-secondary); font-size: 10px; font-weight: 500;
gap: 3px; transition: color 0.15s;
}
.tab-item.active { color: var(--ios-blue); }
.tab-icon { font-size: 24px; line-height: 1; }
/* ===== INSPECTION FORM SCREEN ===== */
.form-nav {
display: flex; align-items: center; padding: 14px 16px;
padding-top: calc(14px + env(safe-area-inset-top));
background: var(--glass-bg);
backdrop-filter: var(--glass-blur);
border-bottom: 0.5px solid var(--separator);
gap: 8px;
}
.back-btn {
color: var(--ios-blue); font-size: 16px; font-weight: 500;
background: none; border: none; cursor: pointer; padding: 4px;
display: flex; align-items: center; gap: 4px;
}
.form-title { flex: 1; font-size: 17px; font-weight: 600; text-align: center; }
.form-scroll { padding: 16px; }
/* Steps */
.step-indicator {
display: flex; justify-content: center; gap: 8px; margin-bottom: 20px;
}
.step-dot {
width: 8px; height: 8px; border-radius: var(--radius-full);
background: var(--ios-gray4); transition: background 0.3s;
}
.step-dot.done { background: var(--ios-green); }
.step-dot.active { background: var(--ios-blue); width: 20px; border-radius: var(--radius-sm); }
/* Form elements */
.form-section { margin-bottom: 24px; }
.form-section-title { font-size: 13px; font-weight: 600; color: var(--label-secondary); text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 10px; }
.form-card {
background: var(--grouped-background-secondary);
border-radius: var(--radius-md); overflow: hidden;
box-shadow: 0 1px 3px rgba(0,0,0,0.08);
}
.form-field {
padding: 13px 16px; border-bottom: 0.5px solid var(--separator);
}
.form-field:last-child { border-bottom: none; }
.field-label { font-size: 13px; color: var(--label-secondary); margin-bottom: 4px; }
.field-input {
width: 100%; border: none; outline: none; background: transparent;
font-size: 16px; font-family: var(--font-text); color: var(--label-primary);
-webkit-appearance: none;
}
.field-textarea {
width: 100%; border: none; outline: none; background: transparent;
font-size: 15px; font-family: var(--font-text); color: var(--label-primary);
resize: none; min-height: 80px;
-webkit-appearance: none;
}
select.field-input { cursor: pointer; }
/* Checklist */
.check-item {
display: flex; align-items: flex-start; padding: 13px 16px; gap: 12px;
border-bottom: 0.5px solid var(--separator); cursor: pointer;
}
.check-item:last-child { border-bottom: none; }
.check-box {
width: 24px; height: 24px; border-radius: var(--radius-full);
border: 2px solid var(--ios-gray3);
flex-shrink: 0; display: flex; align-items: center; justify-content: center;
transition: all 0.15s;
}
.check-box.checked {
background: var(--ios-blue); border-color: var(--ios-blue);
color: white; font-size: 14px;
}
.check-text { font-size: 15px; flex: 1; padding-top: 1px; }
.check-sub { font-size: 13px; color: var(--label-secondary); margin-top: 2px; }
/* Photo grid */
.photo-grid {
display: grid; grid-template-columns: repeat(3, 1fr);
gap: 4px; padding: 0;
}
.photo-thumb {
aspect-ratio: 1; background: var(--fill-tertiary);
border-radius: var(--radius-md); display: flex; align-items: center; justify-content: center;
cursor: pointer; overflow: hidden; position: relative;
transition: opacity 0.15s;
}
.photo-thumb:active { opacity: 0.7; }
.photo-add-icon { font-size: 32px; color: var(--ios-blue); }
.photo-preview { width: 100%; height: 100%; object-fit: cover; display: none; }
.photo-delete {
position: absolute; top: 4px; right: 4px;
width: 20px; height: 20px; border-radius: var(--radius-full);
background: rgba(242,242,247,0.5); color: white;
font-size: 12px; display: none; align-items: center; justify-content: center;
cursor: pointer;
}
/* Signature canvas */
.sig-wrap {
background: var(--fill-quaternary); border-radius: var(--radius-md);
overflow: hidden; position: relative; margin: 0 16px;
}
#sig-canvas {
display: block; width: 100%; touch-action: none;
cursor: crosshair;
}
.sig-label {
position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%);
color: var(--label-tertiary); font-size: 13px; pointer-events: none;
transition: opacity 0.2s;
}
.sig-actions { display: flex; gap: 8px; padding: 10px 16px; }
.sig-clear-btn {
flex: 1; background: var(--fill-tertiary); border: none;
border-radius: var(--radius-md); padding: 10px; font-size: 14px; font-weight: 500;
color: var(--ios-red); cursor: pointer; font-family: var(--font-text);
}
/* Submit button */
.submit-btn {
display: block; width: calc(100% - 32px); margin: 16px;
background: var(--ios-blue); color: white; border: none;
border-radius: var(--radius-md); padding: 15px; font-size: 16px; font-weight: 600;
cursor: pointer; font-family: var(--font-text);
box-shadow: 0 2px 12px rgba(0,122,255,0.3);
transition: opacity 0.15s;
}
.submit-btn:active { opacity: 0.8; }
.submit-btn:disabled { opacity: 0.4; cursor: not-allowed; }
/* Step nav buttons */
.step-nav {
display: flex; gap: 12px; padding: 0 16px 16px;
}
.step-btn {
flex: 1; padding: 13px; border-radius: var(--radius-md);
border: none; font-size: 15px; font-weight: 600;
cursor: pointer; font-family: var(--font-text);
transition: opacity 0.15s;
}
.step-btn:active { opacity: 0.75; }
.step-prev { background: var(--fill-tertiary); color: var(--label-primary); }
.step-next { background: var(--ios-blue); color: white; box-shadow: 0 2px 10px rgba(0,122,255,0.25); }
/* ===== CONFIRMATION SCREEN ===== */
.confirm-screen {
display: flex; flex-direction: column; align-items: center;
justify-content: center; min-height: 100vh; padding: 40px 24px;
text-align: center;
}
.confirm-icon {
width: 80px; height: 80px; border-radius: var(--radius-full);
background: rgba(52,199,89,0.15); border: 2px solid var(--ios-green);
display: flex; align-items: center; justify-content: center;
font-size: 40px; margin-bottom: 24px;
animation: pop-in 0.5s cubic-bezier(0.34,1.56,0.64,1);
}
@keyframes pop-in { 0%{transform:scale(0)} 100%{transform:scale(1)} }
.confirm-title { font-size: 24px; font-weight: 700; margin-bottom: 8px; }
.confirm-report-num {
font-size: 13px; color: var(--label-secondary);
background: var(--fill-tertiary); padding: 6px 16px; border-radius: var(--radius-xl);
font-family: var(--font-mono); margin-bottom: 24px;
}
.confirm-details {
background: var(--grouped-background-secondary);
border-radius: var(--radius-md); width: 100%; text-align: left; overflow: hidden;
box-shadow: 0 1px 3px rgba(0,0,0,0.08); margin-bottom: 24px;
}
.confirm-row {
display: flex; justify-content: space-between; padding: 12px 16px;
border-bottom: 0.5px solid var(--separator); font-size: 14px;
}
.confirm-row:last-child { border-bottom: none; }
.confirm-key { color: var(--label-secondary); }
.confirm-val { font-weight: 500; }
.confirm-btn {
width: 100%; background: var(--ios-blue); color: white; border: none;
border-radius: var(--radius-md); padding: 14px; font-size: 16px; font-weight: 600;
cursor: pointer; font-family: var(--font-text);
box-shadow: 0 2px 12px rgba(0,122,255,0.3);
}
@media (max-width: 640px) {
.hero h1 { font-size: 2rem !important; }
.hero p { font-size: 1rem !important; }
.card { padding: 1.5rem !important; }
.grid { grid-template-columns: 1fr !important; }
.btn { padding: 0.75rem 1.5rem !important; font-size: 1rem !important; }
}
</style>
<style id="wcag-contrast-enforcement">
/* AAMOS GLOBAL CONTRAST ENFORCEMENT — WCAG AA (4.5:1 minimum) */
/* Regel: Ingen text under 70% opacity. Aldrig. */
body { -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; }
/* Fix common low-contrast patterns */
[style*="rgba(60,60,67,0.4)"] { color: rgba(60,60,67,0.65) !important; }
[style*="rgba(60,60,67,0.3)"] { color: rgba(60,60,67,0.65) !important; }
[style*="rgba(0,0,0,0.4)"] { color: rgba(0,0,0,0.72) !important; }
[style*="rgba(0,0,0,0.3)"] { color: rgba(0,0,0,0.72) !important; }
[style*="opacity: 0.4"] { opacity: 0.72 !important; }
[style*="opacity: 0.3"] { opacity: 0.72 !important; }
@media (max-width: 640px) {
.hero h1 { font-size: 2rem !important; }
.hero p { font-size: 1rem !important; }
.card { padding: 1.5rem !important; }
.grid { grid-template-columns: 1fr !important; }
.btn { padding: 0.75rem 1.5rem !important; font-size: 1rem !important; }
}
</style>
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": [
"Organization",
"Corporation"
],
"@id": "https://landvex.com/#organization",
"name": "Landvex",
"alternateName": [
"LandveX",
"Landvex AB",
"LandveX AB"
],
"url": "https://landvex.com",
"logo": {
"@type": "ImageObject",
"url": "https://landvex.com/apple-touch-icon.png",
"width": 180,
"height": 180
},
"foundingDate": "2024",
"foundingLocation": {
"@type": "Place",
"addressLocality": "Stockholm",
"addressRegion": "Stockholm",
"addressCountry": "SE"
},
"legalName": "Landvex Inc",
"address": {
"@type": "PostalAddress",
"addressLocality": "Houston",
"addressRegion": "TX",
"addressCountry": "US"
},
"location": [
{
"@type": "Place",
"name": "Houston, Texas, USA (US HQ)",
"address": {
"@type": "PostalAddress",
"addressLocality": "Houston",
"addressRegion": "TX",
"addressCountry": "US"
}
},
{
"@type": "Place",
"name": "Stockholm, Sweden (EU HQ)",
"address": {
"@type": "PostalAddress",
"addressLocality": "Stockholm",
"addressRegion": "Stockholm",
"addressCountry": "SE"
}
}
],
"taxID": "pending-EIN",
"description": "Landvex is a decision intelligence company that identifies contradictions between official narratives and observed physical reality. Using the quiXzoom field observation network and the AMOS AI analysis engine, Landvex delivers infrastructure risk indexes, urban intelligence scores, and contradiction reports to infrastructure owners, municipalities, enterprises, and investors globally.",
"disambiguatingDescription": "Landvex (LandveX) is a decision intelligence company with US headquarters in Houston, Texas (Landvex Inc.) and European headquarters in Stockholm, Sweden (Landvex AB, org.nr 559141-7042). Landvex delivers control intelligence — infrastructure risk scores, urban intelligence, and contradiction analysis — to municipalities, enterprises, infrastructure operators and investors. Landvex is not a UK meat wholesaler, food distributor, logistics company, package tracker, or land investment platform.",
"knowsAbout": [
"control intelligence",
"decision intelligence",
"infrastructure risk assessment",
"urban intelligence",
"field data collection",
"contradiction detection",
"physical world analytics",
"geospatial analytics"
],
"brand": {
"@type": "Brand",
"name": "Landvex",
"slogan": "Control intelligence for the physical world."
},
"sameAs": [
"https://landvex.com",
"https://www.linkedin.com/company/landvex",
"https://x.com/landvex",
"https://twitter.com/landvex",
"https://www.crunchbase.com/organization/landvex",
"https://github.com/landvex",
"https://www.instagram.com/landvex"
],
"contactPoint": {
"@type": "ContactPoint",
"email": "contact@landvex.com",
"contactType": "sales"
},
"slogan": "Where reported reality conflicts with observed reality."
}
</script>
</head>
<body>
<!-- ==================== MAIN LIST SCREEN ==================== -->
<div id="screen-list" class="screen active">
<div class="nav-bar">
<div class="nav-row">
<div>
<div class="nav-title"> Dagens Uppdrag</div>
<div class="nav-date" id="today-date"></div>
</div>
<div style="display:flex;gap:10px;align-items:center">
<button class="refresh-btn" onclick="pullRefresh()" title="Uppdatera"></button>
<div class="nav-avatar">IG</div>
</div>
</div>
</div>
<div class="scroll-area">
<div class="scroll-inner">
<div class="ptr-indicator" id="ptr">⏳ Uppdaterar uppdrag...</div>
<!-- Map -->
<div class="section-header">Karta</div>
<div id="map"></div>
<!-- Missions -->
<div class="section-header">Uppdrag idag (<span id="mission-count">3</span>)</div>
<div class="mission-card" onclick="openMission('bro-e45')">
<div class="mission-top">
<div class="mission-type-icon type-bridge"></div>
<div class="mission-info">
<div class="mission-name">Bro E45 — Götaälvbron</div>
<div class="mission-addr">Göteborg, Hamnbanan</div>
<div class="mission-meta">
<span class="mission-priority priority-urgent">AKUT</span>
<span class="mission-time">08:0010:30</span>
</div>
</div>
<div class="mission-arrow"></div>
</div>
</div>
<div class="mission-card" onclick="openMission('vag-rv40')">
<div class="mission-top">
<div class="mission-type-icon type-road"></div>
<div class="mission-info">
<div class="mission-name">Väg RV40 km 142</div>
<div class="mission-addr">BoråsGöteborg, spricköversyn</div>
<div class="mission-meta">
<span class="mission-priority priority-normal">NORMAL</span>
<span class="mission-time">11:0013:00</span>
</div>
</div>
<div class="mission-arrow"></div>
</div>
</div>
<div class="mission-card" onclick="openMission('fasad-kungsbacka')">
<div class="mission-top">
<div class="mission-type-icon type-facade"><svg width="16" height="16" viewBox="0 0 16 16" fill="none"><rect x="3" y="2" width="10" height="12" rx="1" stroke="#666" stroke-width="1.5"/><path d="M6 5H7M6 8H7M6 11H7M9 5H10M9 8H10M9 11H10" stroke="#666" stroke-width="1.5" stroke-linecap="round"/></svg></div>
<div class="mission-info">
<div class="mission-name">Fasad Kungsbacka centrum</div>
<div class="mission-addr">Storgatan 12, Kungsbacka</div>
<div class="mission-meta">
<span class="mission-priority priority-low">LÅG</span>
<span class="mission-time">14:0016:00</span>
</div>
</div>
<div class="mission-arrow"></div>
</div>
</div>
<div class="mission-card" onclick="openMission('tak-lindome')">
<div class="mission-top">
<div class="mission-type-icon type-roof"></div>
<div class="mission-info">
<div class="mission-name">Tak Lindome industripark</div>
<div class="mission-addr">Lindome, Mölndal</div>
<div class="mission-meta">
<span class="mission-priority priority-normal">NORMAL</span>
<span class="mission-time">16:3017:30</span>
</div>
</div>
<div class="mission-arrow"></div>
</div>
</div>
</div><!-- /scroll-inner -->
</div><!-- /scroll-area -->
</div><!-- /screen-list -->
<!-- ==================== INSPECTION FORM SCREEN ==================== -->
<div id="screen-form" class="screen">
<div class="form-nav">
<button class="back-btn" onclick="goBack()"> Uppdrag</button>
<div class="form-title" id="form-title">Inspektion</div>
</div>
<div class="scroll-area" id="form-scroll-area">
<div id="form-content">
<!-- Steps injected by JS -->
</div>
</div>
</div>
<!-- ==================== CONFIRMATION SCREEN ==================== -->
<div id="screen-confirm" class="screen">
<div class="confirm-screen">
<div class="confirm-icon"><svg width="16" height="16" viewBox="0 0 16 16" fill="none"><path d="M3 8L6.5 11.5L13 4.5" stroke="#22c55e" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg></div>
<div class="confirm-title">Rapport inskickad!</div>
<div class="confirm-report-num" id="report-num">LVX-2026-0000</div>
<div class="confirm-details">
<div class="confirm-row">
<span class="confirm-key">Objekt</span>
<span class="confirm-val" id="conf-object"></span>
</div>
<div class="confirm-row">
<span class="confirm-key">Typ</span>
<span class="confirm-val" id="conf-type"></span>
</div>
<div class="confirm-row">
<span class="confirm-key">Inspektör</span>
<span class="confirm-val">I. Gustafsson</span>
</div>
<div class="confirm-row">
<span class="confirm-key">Tid</span>
<span class="confirm-val" id="conf-time"></span>
</div>
<div class="confirm-row">
<span class="confirm-key">Status</span>
<span class="confirm-val" style="color:var(--ios-green)"><svg width="16" height="16" viewBox="0 0 16 16" fill="none"><path d="M3 8L6.5 11.5L13 4.5" stroke="#22c55e" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg> Synkad</span>
</div>
</div>
<button class="confirm-btn" onclick="goHome()">← Tillbaka till uppdrag</button>
</div>
</div>
<!-- TAB BAR -->
<div class="tab-bar" id="main-tab-bar">
<div class="tab-item active" onclick="switchTab('list')">
<div class="tab-icon"><svg width="16" height="16" viewBox="0 0 16 16" fill="none"><rect x="3" y="2" width="10" height="12" rx="1" stroke="#666" stroke-width="1.5"/><path d="M6 5H10M6 8H10M6 11H8" stroke="#666" stroke-width="1.5" stroke-linecap="round"/></svg></div>
<span>Uppdrag</span>
</div>
<div class="tab-item" onclick="switchTab('map-tab')">
<div class="tab-icon"></div>
<span>Karta</span>
</div>
<div class="tab-item" onclick="switchTab('reports')">
<div class="tab-icon"></div>
<span>Rapporter</span>
</div>
<div class="tab-item" onclick="switchTab('profile')">
<div class="tab-icon"></div>
<span>Profil</span>
</div>
</div>
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
<script>
// State
const STORAGE_KEY = 'landvex_inspector_state';
let currentMission = null;
let currentStep = 0;
let checklistState = {};
let photoCount = 0;
let sigDrawing = false, sigHasData = false;
let sigCtx, sigCanvas;
const MISSIONS = {
'bro-e45': { name: 'Bro E45 — Götaälvbron', type: 'bro', lat: 57.706, lng: 11.975 },
'vag-rv40': { name: 'Väg RV40 km 142', type: 'väg', lat: 57.721, lng: 12.929 },
'fasad-kungsbacka':{ name: 'Fasad Kungsbacka centrum', type: 'fasad', lat: 57.504, lng: 12.076 },
'tak-lindome': { name: 'Tak Lindome industripark', type: 'tak', lat: 57.572, lng: 12.091 },
};
const CHECKLISTS = {
bro: [
{ id:'bro1', text:'Visuell undersökning av bärande konstruktion', sub:'Sprickor, korrosion, deformation' },
{ id:'bro2', text:'Kontroll av skyddsskikt och ytbehandling', sub:'Betongtäckning, epoxibehandling' },
{ id:'bro3', text:'Fogar och rörelsefogars skick', sub:'Täthetskontroll, gummilister' },
{ id:'bro4', text:'Dränering och avvattningssystem', sub:'Brunnar, rör, igensättning' },
{ id:'bro5', text:'Räcken och säkerhetsanordningar', sub:'Fastspänning, deformation' },
{ id:'bro6', text:'Fundament och grundläggning', sub:'Erosion, sättningar under bro' },
{ id:'bro7', text:'Belysning och signalering', sub:'Funktionskontroll, kablage' },
],
väg: [
{ id:'vag1', text:'Sprickkartering och klassificering', sub:'Längsgående, tvärgående, nätsprickor' },
{ id:'vag2', text:'Deformationsmätning (spårdjup)', sub:'Hjulspår, sättningar' },
{ id:'vag3', text:'Kantskador och slänter', sub:'Urgrävning, erosion, vegetation' },
{ id:'vag4', text:'Vägmarkeringar', sub:'Reflexivitet, synlighet' },
{ id:'vag5', text:'Avvattningssystem', sub:'Diken, trummor, brunnar' },
{ id:'vag6', text:'Skyltar och vägmärken', sub:'Synlighet, fastspänning, skador' },
],
fasad: [
{ id:'fas1', text:'Putsytans skick', sub:'Sprickor, avlossnat puts, fukt' },
{ id:'fas2', text:'Fogar och tätningslister', sub:'Kring fönster och dörrar' },
{ id:'fas3', text:'Fönsterbågar och karmar', sub:'Röta, deformation, tätning' },
{ id:'fas4', text:'Balkonger och utskjutande delar', sub:'Armering, korrosion, räcken' },
{ id:'fas5', text:'Sockel och grundmur', sub:'Fuktintrång, sprickbildning' },
{ id:'fas6', text:'Ventilationsöppningar', sub:'Igensättning, nätskydd' },
],
tak: [
{ id:'tak1', text:'Takbeläggning och ytskikt', sub:'Skador, lagningar, ålder' },
{ id:'tak2', text:'Takavvattning (rännor och stuprör)', sub:'Fastsättning, blockering' },
{ id:'tak3', text:'Genomföringar och skorstenar', sub:'Tätning, flashning' },
{ id:'tak4', text:'Takkonstruktion (om åtkomlig)', sub:'Röta, fukt, bärförmåga' },
{ id:'tak5', text:'Tillträdesanordningar', sub:'Stegar, luckor, säkerhetslina' },
{ id:'tak6', text:'Solceller/tekniska installationer', sub:'Skador, fastspänning' },
],
};
// Init
document.getElementById('today-date').textContent = new Date().toLocaleDateString('sv-SE', { weekday:'long', year:'numeric', month:'long', day:'numeric' });
// Init map
const map = L.map('map', { zoomControl:false, attributionControl:false }).setView([57.7, 12.0], 9);
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png').addTo(map);
Object.entries(MISSIONS).forEach(([id, m]) => {
L.marker([m.lat, m.lng]).addTo(map).bindPopup(`<b>${m.name}</b>`);
});
// Screens
function showScreen(id) {
document.querySelectorAll('.screen').forEach(s => s.classList.remove('active'));
document.getElementById(id).classList.add('active');
const showTabs = id === 'screen-list';
document.getElementById('main-tab-bar').style.display = showTabs ? 'flex' : 'none';
}
function goBack() {
showScreen('screen-list');
saveOfflineState();
}
function goHome() {
showScreen('screen-list');
currentMission = null;
currentStep = 0;
checklistState = {};
photoCount = 0;
}
function switchTab(tab) {
document.querySelectorAll('.tab-item').forEach(t => t.classList.remove('active'));
event.currentTarget.classList.add('active');
if (tab === 'map-tab') {
setTimeout(() => map.invalidateSize(), 100);
document.getElementById('map').scrollIntoView({ behavior:'smooth' });
}
}
// Missions
function openMission(id) {
currentMission = id;
currentStep = 0;
checklistState = {};
const m = MISSIONS[id];
document.getElementById('form-title').textContent = m.type.charAt(0).toUpperCase() + m.type.slice(1);
renderStep();
showScreen('screen-form');
}
function renderStep() {
const m = MISSIONS[currentMission];
const checklist = CHECKLISTS[m.type] || [];
const steps = ['info','checklist','photos','signature'];
const labels = ['Grundinfo','Checklista','Foton','Signatur'];
const stepDots = steps.map((s,i) => {
const cls = i < currentStep ? 'step-dot done' : i === currentStep ? 'step-dot active' : 'step-dot';
return `<div class="${cls}"></div>`;
}).join('');
let content = '';
if (currentStep === 0) {
// Step 1: Basic info
content = `
<div class="form-scroll">
<div class="step-indicator">${stepDots}</div>
<div class="form-section">
<div class="form-section-title">Objekt</div>
<div class="form-card">
<div class="form-field">
<div class="field-label">Objektnamn</div>
<input class="field-input" id="f-name" value="${m.name}" placeholder="Objektnamn">
</div>
<div class="form-field">
<div class="field-label">Objekttyp</div>
<select class="field-input" id="f-type">
<option value="bro" ${m.type==='bro'?'selected':''}> Bro</option>
<option value="väg" ${m.type==='väg'?'selected':''}> Väg</option>
<option value="fasad" ${m.type==='fasad'?'selected':''}><svg width="16" height="16" viewBox="0 0 16 16" fill="none"><rect x="3" y="2" width="10" height="12" rx="1" stroke="#666" stroke-width="1.5"/><path d="M6 5H7M6 8H7M6 11H7M9 5H10M9 8H10M9 11H10" stroke="#666" stroke-width="1.5" stroke-linecap="round"/></svg> Fasad</option>
<option value="tak" ${m.type==='tak'?'selected':''}> Tak</option>
</select>
</div>
<div class="form-field">
<div class="field-label">Adress / Plats</div>
<input class="field-input" id="f-addr" value="${m.lat.toFixed(3)}, ${m.lng.toFixed(3)}" placeholder="Adress">
</div>
</div>
</div>
<div class="form-section">
<div class="form-section-title">Inspektör</div>
<div class="form-card">
<div class="form-field">
<div class="field-label">Namn</div>
<input class="field-input" value="I. Gustafsson">
</div>
<div class="form-field">
<div class="field-label">Väder</div>
<select class="field-input" id="f-weather">
<option> Soligt</option>
<option> Molnigt</option>
<option> Regn</option>
<option> Snö</option>
</select>
</div>
<div class="form-field">
<div class="field-label">Anmärkningar</div>
<textarea class="field-textarea" id="f-notes" placeholder="Fri text..."></textarea>
</div>
</div>
</div>
</div>
<div class="step-nav">
<button class="step-btn step-next" onclick="nextStep()">Checklist →</button>
</div>`;
} else if (currentStep === 1) {
// Step 2: Checklist
const items = checklist.map(c => `
<div class="check-item" onclick="toggleCheck('${c.id}')">
<div class="check-box ${checklistState[c.id] ? 'checked' : ''}" id="chk-${c.id}">
${checklistState[c.id] ? '<svg width="16" height="16" viewBox="0 0 16 16" fill="none"><path d="M3 8L6.5 11.5L13 4.5" stroke="#22c55e" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>' : ''}
</div>
<div>
<div class="check-text">${c.text}</div>
<div class="check-sub">${c.sub}</div>
</div>
</div>`).join('');
const done = checklist.filter(c => checklistState[c.id]).length;
content = `
<div class="form-scroll">
<div class="step-indicator">${stepDots}</div>
<div class="form-section">
<div class="form-section-title">Checklista (${done}/${checklist.length})</div>
<div class="form-card">${items}</div>
</div>
</div>
<div class="step-nav">
<button class="step-btn step-prev" onclick="prevStep()">← Tillbaka</button>
<button class="step-btn step-next" onclick="nextStep()">Foton →</button>
</div>`;
} else if (currentStep === 2) {
// Step 3: Photos
const photoSlots = Array(6).fill(0).map((_, i) => `
<div class="photo-thumb" id="photo-slot-${i}" onclick="addPhoto(${i})">
<img loading="lazy" loading="lazy" loading="lazy" loading="lazy" class="photo-preview" id="photo-img-${i}" src="" alt="Inspection photo">
<div class="photo-add-icon" id="photo-add-${i}"><svg width="16" height="16" viewBox="0 0 16 16" fill="none"><rect x="2" y="4" width="12" height="9" rx="2" stroke="#666" stroke-width="1.5"/><circle cx="8" cy="8.5" r="2.5" stroke="#666" stroke-width="1.5"/><path d="M5 4L6 2H10L11 4" stroke="#666" stroke-width="1.5"/></svg></div>
<div class="photo-delete" id="photo-del-${i}" onclick="deletePhoto(event,${i})"></div>
</div>`).join('');
content = `
<div class="form-scroll">
<div class="step-indicator">${stepDots}</div>
<div class="form-section">
<div class="form-section-title">Foto-dokumentation</div>
<div class="form-card" style="padding:12px">
<div class="photo-grid">${photoSlots}</div>
</div>
<div style="font-size:12px;color:var(--label-secondary);padding:8px 4px">
Tryck på kamera-ikonen för att ta foto eller välja från biblioteket
</div>
</div>
</div>
<div class="step-nav">
<button class="step-btn step-prev" onclick="prevStep()">← Checklista</button>
<button class="step-btn step-next" onclick="nextStep()">Signatur →</button>
</div>
<input type="file" id="photo-input" accept="image/*" capture="environment" style="display:none" onchange="handlePhoto(event)">`;
} else if (currentStep === 3) {
// Step 4: Signature
content = `
<div class="form-scroll" style="padding-bottom:0">
<div class="step-indicator">${stepDots}</div>
<div class="form-section">
<div class="form-section-title">Digital signatur</div>
<div class="sig-wrap">
<canvas id="sig-canvas" height="180"></canvas>
<div class="sig-label" id="sig-hint"> Rita din signatur här</div>
</div>
<div class="sig-actions">
<button class="sig-clear-btn" onclick="clearSig()"> Rensa</button>
</div>
</div>
</div>
<div style="padding:0 16px">
<button class="submit-btn" id="submit-btn" onclick="submitReport()"> Skicka in rapport</button>
</div>
<div style="padding:0 16px 16px">
<button class="step-btn step-prev" style="width:100%;margin-top:8px" onclick="prevStep()">← Foton</button>
</div>`;
}
document.getElementById('form-content').innerHTML = content;
// Init signature canvas if on step 3
if (currentStep === 3) initSig();
}
function toggleCheck(id) {
checklistState[id] = !checklistState[id];
renderStep();
saveOfflineState();
}
function nextStep() {
currentStep = Math.min(3, currentStep + 1);
renderStep();
document.getElementById('form-scroll-area').scrollTop = 0;
}
function prevStep() {
currentStep = Math.max(0, currentStep - 1);
renderStep();
document.getElementById('form-scroll-area').scrollTop = 0;
}
// Photos
let activePhotoSlot = 0;
function addPhoto(slot) {
activePhotoSlot = slot;
document.getElementById('photo-input').click();
}
function handlePhoto(e) {
const file = e.target.files[0];
if (!file) return;
const url = URL.createObjectURL(file);
const img = document.getElementById(`photo-img-${activePhotoSlot}`);
const add = document.getElementById(`photo-add-${activePhotoSlot}`);
const del = document.getElementById(`photo-del-${activePhotoSlot}`);
img.src = url; img.style.display = 'block';
add.style.display = 'none'; del.style.display = 'flex';
e.target.value = '';
saveOfflineState();
}
function deletePhoto(e, slot) {
e.stopPropagation();
const img = document.getElementById(`photo-img-${slot}`);
const add = document.getElementById(`photo-add-${slot}`);
const del = document.getElementById(`photo-del-${slot}`);
img.src = ''; img.style.display = 'none';
add.style.display = 'flex'; del.style.display = 'none';
}
// Signature
function initSig() {
sigCanvas = document.getElementById('sig-canvas');
if (!sigCanvas) return;
sigCanvas.width = sigCanvas.offsetWidth;
sigCtx = sigCanvas.getContext('2d');
sigCtx.strokeStyle = '#007AFF';
sigCtx.lineWidth = 2.5;
sigCtx.lineCap = 'round';
sigCtx.lineJoin = 'round';
function getPos(e) {
const r = sigCanvas.getBoundingClientRect();
const t = e.touches ? e.touches[0] : e;
return { x: (t.clientX - r.left) * (sigCanvas.width / r.width), y: (t.clientY - r.top) * (sigCanvas.height / r.height) };
}
sigCanvas.addEventListener('mousedown', e => { sigDrawing = true; const p = getPos(e); sigCtx.beginPath(); sigCtx.moveTo(p.x, p.y); });
sigCanvas.addEventListener('mousemove', e => { if (!sigDrawing) return; const p = getPos(e); sigCtx.lineTo(p.x, p.y); sigCtx.stroke(); sigHasData = true; document.getElementById('sig-hint').style.opacity = '0'; });
sigCanvas.addEventListener('mouseup', () => sigDrawing = false);
sigCanvas.addEventListener('touchstart', e => { e.preventDefault(); sigDrawing = true; const p = getPos(e); sigCtx.beginPath(); sigCtx.moveTo(p.x, p.y); }, { passive:false });
sigCanvas.addEventListener('touchmove', e => { e.preventDefault(); if (!sigDrawing) return; const p = getPos(e); sigCtx.lineTo(p.x, p.y); sigCtx.stroke(); sigHasData = true; document.getElementById('sig-hint').style.opacity = '0'; }, { passive:false });
sigCanvas.addEventListener('touchend', () => sigDrawing = false);
}
function clearSig() {
if (sigCtx) sigCtx.clearRect(0, 0, sigCanvas.width, sigCanvas.height);
sigHasData = false;
const hint = document.getElementById('sig-hint');
if (hint) hint.style.opacity = '1';
}
// Submit
function submitReport() {
const btn = document.getElementById('submit-btn');
btn.disabled = true; btn.textContent = '⏳ Skickar...';
const m = MISSIONS[currentMission];
const reportNum = `LVX-${new Date().getFullYear()}-${String(Math.floor(Math.random()*9000)+1000)}`;
// Offline: save to localStorage
const reports = JSON.parse(localStorage.getItem('landvex_reports') || '[]');
reports.push({
id: reportNum, mission: currentMission, object: m.name, type: m.type,
checklist: checklistState, timestamp: new Date().toISOString(),
synced: false,
});
localStorage.setItem('landvex_reports', JSON.stringify(reports));
// Try online sync
setTimeout(() => {
document.getElementById('report-num').textContent = reportNum;
document.getElementById('conf-object').textContent = m.name;
document.getElementById('conf-type').textContent = m.type;
document.getElementById('conf-time').textContent = new Date().toLocaleString('sv-SE');
showScreen('screen-confirm');
document.getElementById('main-tab-bar').style.display = 'none';
}, 1500);
}
// Offline state
function saveOfflineState() {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify({ currentMission, currentStep, checklistState }));
} catch(e) {}
}
function pullRefresh() {
const ptr = document.getElementById('ptr');
ptr.classList.add('active');
setTimeout(() => ptr.classList.remove('active'), 1500);
}
</script>
</body>
</html>