Files
tilbudgivern/frontend/src/components/siteGeometry/SiteGeometryModal.js
T
f37adae2cb
CI - Test & Build / Lint & Type Check (push) Canceled after 0s
CI - Test & Build / Backend Unit Tests (push) Canceled after 0s
CI - Test & Build / Frontend Build (push) Canceled after 0s
CI - Test & Build / Security Scan (push) Canceled after 0s
CI - Test & Build / E2E Tests (Playwright) (push) Canceled after 0s
CI - Test & Build / CI Summary (push) Canceled after 0s
feat: deliver auditable Smart Pakke quote flow and free site geometry (#31)
* feat: move login credentials to a DB-backed users table with an admin management page

Replaces the hardcoded AUTH_USERNAME/AUTH_PASSWORD login check with a new
auth_accounts table (bcrypt-hashed passwords, admin/user roles). Adds
admin-only /api/users CRUD routes and a "Brugere" admin page in the
frontend for managing logins without redeploying. Removes the unused,
unmounted duplicate login route in src/routes/auth.js.

* docs: add architecture codemaps with diagrams for the whole system

Adds codemaps/architecture.md, backend.md, frontend.md, and data.md —
Mermaid-diagrammed design documentation verified against the live
codebase and database rather than assumed from CLAUDE.md. Covers the
unified-server.js request flow (mounted routers + ~183 inline routes),
68 backend services grouped by domain, the frontend's state-driven
view-switch (no React Router in practice despite BrowserRouter being
present), and the full 122-table DB schema with the auth_accounts vs
unrelated users table naming trap flagged explicitly. Links added from
the root README.

Co-Authored-By: Claude Sonnet 5 <[email protected]>

* feat: ship canonical roof quote workflow

* fix: keep migration dry-run idempotent

* [verified] feat: complete Smart Pakker management

* [verified] fix: ignore blank task dependencies

* [verified] fix: align package duplication with schema

* [verified] fix: enforce Discord status limits

* [verified] fix: link Smart Pakke materials safely

* [verified] fix: harden material link review

* [verified] feat: improve material matching

* fix: scope pitch validation to roof packages

* fix: support canonical snapshots on production schema

* [verified] fix: hide internal package metadata from PDF

* [verified] feat: deliver sales-ready customer PDF

* [verified] feat: ship sales-ready PDF with AI overview

* [verified] fix: authenticate project list requests

* [verified] fix: refresh project-list authentication

* [verified] fix: open existing project details

* [verified] fix: keep roof components searchable in builder

* [verified] fix: expose all Smart Package categories

* [verified] fix: authenticate project creation

* [verified] feat: make Smart Pakker the universal project flow

* [verified] feat: preview Smart Package contents

* [verified] test: keep generic release isolated from downpipe work

* feat: add first-class Smart Pakke rentals

* [verified] feat: add gutter and downpipe smart packages

* [verified] fix: prepare six-house gutter quote flow

* [verified] fix: open generic quotes without roof geometry

* [verified] fix: review generic quotes with authenticated APIs

* [verified] fix: calculate generic Smart Package quotes

* [verified] fix: return generic calculation breakdown

* feat: checkpoint generic signed snapshot validation with red-green tests

* feat: complete fail-closed generic quote approval and customer PDF flow

* feat: use generic signed snapshot in final review

* feat: redesign generic quote final review

* fix: harden generic review summaries

* feat: add auditable six-house package basis

* [verified] feat: finish auditable Smart Pakke UI

* [verified] fix: bind auditable quantity and price bases

* [verified] fix: keep six-house basis across package versions

* [verified] fix: complete smart package discovery management

* [verified] fix: simplify composition and generic scope

* [verified] test: keep explicit roof contracts fail closed

* [verified] fix: harden generic quote snapshots

* fix: make generic quote delivery customer safe

* [verified] fix: secure package catalog reads

* [verified] fix: close workspace provenance blockers

* fix: harden customer document language boundary

* [verified] fix: secure smart package internal reads

* fix: version package child mutations atomically

* feat: add generic customer quote text flow

* [verified] fix: allow manual customer numbers

* [verified] fix: expose optional roof geometry

* [verified] fix: rebase hydrated packages after geometry edits

* [verified] feat: add free editable site area map

* [verified] fix: harden map recovery and geocoding gate

* fix: bind map quantities to authoritative geometry

* fix: release geocoder lock before dispatch

* fix: separate roof and site geometry provenance

* fix: revoke stale admin authorization

* fix: migrate task geometry basis

* fix: make backend CI dependency-complete

* ci: seed isolated e2e login account

* fix: allow clean database bootstrap

* fix: skip indexes for optional tables

* test: use canonical mansard geometry in e2e

* [verified] fix(auth): enforce live operator boundary

* fix: fail close Ordrestyring offer transport

* fix(frontend): authenticate customer project requests

* fix: align canonical roof type contract

* [verified] fix: reconcile legacy package labor safely

* [verified] fix: audit site geometry deletion

* docs: add PR 31 reviewer guide

* docs: synchronize Obsidian vault

* docs: sync integrated reviewer guide to Obsidian

* ci: seed isolated auth account explicitly

* fix: close offer bootstrap and service readiness gaps

* fix: authenticate protected package callers

* fix: provision initial admin and disable generic send

* [verified] fix: close final quote release blockers

* [verified] fix: seed gutter packages before deployment

---------

Co-authored-by: alexpolo1 <[email protected]>
Co-authored-by: Claude Sonnet 5 <[email protected]>
2026-09-26 22:39:18 +02:00

161 lines
10 KiB
JavaScript

import React, { useEffect, useRef, useState } from 'react';
import { Dialog, DialogTitle, DialogContent, DialogActions, useMediaQuery } from '@mui/material';
import EditableAreaMap from './EditableAreaMap';
import './SiteGeometryModal.css';
export async function siteGeometryRequest(projectId, apiBaseUrl, method, body) {
const token = localStorage.getItem('accessToken');
const response = await fetch(`${apiBaseUrl}/api/customer-projects/projects/${projectId}/site-geometry${method === 'POST' ? '/geocode' : ''}`, {
method, headers: { ...(token ? { Authorization: `Bearer ${token}` } : {}), 'Content-Type': 'application/json' },
...(body ? { body: JSON.stringify(body) } : {})
});
const result = await response.json();
if (!response.ok) throw new Error(result.error || 'Kortområdet kunne ikke hentes.');
return result;
}
const savedPoints = geometry => geometry?.areas?.[0]?.geometry.coordinates[0].slice(0,-1) || [];
function preview(points) {
if (points.length < 3) return 0;
const r = Math.PI / 180;
return Math.abs(points.reduce((sum,p,i) => {
const next = points[(i+1)%points.length];
return sum + (next[0]-p[0])*r*(2+Math.sin(p[1]*r)+Math.sin(next[1]*r));
},0))*6371008.8**2/2;
}
export default function SiteGeometryModal({ projectId, apiBaseUrl = '', address = '', roof = false, onSave, onDelete, onClose, onManual, api }) {
const pointList = useRef(null);
const addressInput = useRef(null);
const mobile = useMediaQuery('(max-width:600px)');
const [saved,setSaved] = useState(null);
const [revision,setRevision] = useState(0);
const [points,setPoints] = useState([]);
const [center,setCenter] = useState([56,10]);
const [query,setQuery] = useState(address);
const [candidates,setCandidates] = useState([]);
const [error,setError] = useState('');
const [loading,setLoading] = useState(true);
const [loadFailed,setLoadFailed] = useState(false);
const [loadAttempt,setLoadAttempt] = useState(0);
const [busy,setBusy] = useState(false);
const [drawing,setDrawing] = useState(true);
const [deleteConfirm,setDeleteConfirm] = useState(false);
const [status,setStatus] = useState('');
const request = (method,body) => api ? api(method,body) : siteGeometryRequest(projectId,apiBaseUrl,method,body);
useEffect(() => {
let active = true;
const load = async () => {
setLoading(true);setLoadFailed(false);setError('');
try {
const result = await (api ? api('GET') : siteGeometryRequest(projectId,apiBaseUrl,'GET'));
if (!active) return;
const {geometry} = result;
setRevision(result.revision ?? geometry?.revision ?? 0);
setSaved(geometry);const next=savedPoints(geometry);setPoints(next);
setDrawing(!next.length);
if (next.length) setCenter([next[0][1],next[0][0]]);
} catch(e) {
if (active) {setError(e.message);setLoadFailed(true);}
} finally {
if (active) {
setLoading(false);
if (loadAttempt > 0) addressInput.current?.focus();
}
}
};
load();
return () => { active=false; };
},[projectId,apiBaseUrl,api,loadAttempt]);
const unavailable = busy || loading || loadFailed;
const dirty = JSON.stringify(points) !== JSON.stringify(savedPoints(saved));
const discard = () => !dirty || window.confirm('Du har ændringer, der ikke er gemt. Kassér ændringerne?');
const close = () => { if (!busy && discard()) onClose(); };
useEffect(() => {
if (!dirty) return undefined;
const warn = event => { event.preventDefault(); event.returnValue=''; };
window.addEventListener('beforeunload',warn);
return () => window.removeEventListener('beforeunload',warn);
},[dirty]);
const save = async () => {
setBusy(true);setError('');
try {
const {geometry} = await request('PUT',{expectedRevision:revision,geometry:{type:'Polygon',coordinates:[[...points,points[0]]]}});
if (geometry?.schema !== 'site_geometry_v1' || !Number.isFinite(geometry?.totals?.groundAreaM2) || !geometry?.revision) throw new Error('Serveren returnerede ikke et gyldigt område.');
setSaved(geometry);setRevision(geometry.revision);onSave(geometry);
} catch(e) { setError(e.message); } finally { setBusy(false); }
};
const remove = async () => {
setBusy(true);setError('');setStatus('');
try {
const result = await request('DELETE',{expectedRevision:revision});
if (result?.geometry !== null || result?.deleted !== true || !Number.isSafeInteger(result?.revision) || result.revision <= revision) {
throw new Error('Serveren bekræftede ikke sletningen med en ny revision.');
}
setSaved(null);setPoints([]);setRevision(result.revision);setDrawing(true);setDeleteConfirm(false);
setStatus('Kortområdet er slettet. Kortbaserede mængder kan ikke længere bruges.');
onDelete?.(result);
} catch(e) { setError(e.message);setDeleteConfirm(false); } finally { setBusy(false); }
};
const search = async event => {
event.preventDefault();setBusy(true);setError('');
try { setCandidates((await request('POST',{query})).candidates); }
catch(e) {setError(e.message);} finally {setBusy(false);}
};
const updatePoint = (index,axis,value) => setPoints(current => current.map((p,i) => i===index ? p.map((v,a) => a===axis ? value : v) : p));
return <><Dialog open PaperProps={{ 'aria-modal': true }} fullScreen={mobile} maxWidth="md" fullWidth onClose={close} aria-labelledby="site-geometry-title" className="site-area-dialog">
<DialogTitle id="site-geometry-title">Mål område på kort</DialogTitle>
<DialogContent>
<p>Korttegnet · <a href="https://www.openstreetmap.org/copyright" target="_blank" rel="noreferrer">© OpenStreetMap-bidragsydere</a> · <a href="https://opendatacommons.org/licenses/odbl/1-0/" target="_blank" rel="noreferrer">ODbL</a> · Ikke verificeret{saved?.audit?.updatedAt ? ` · Redigeret ${new Date(saved.audit.updatedAt).toLocaleDateString('da-DK')}` : ''}</p>
{roof && <p className="site-area-roof">Kortet måler kun grundprojektion. Skråt tagareal kræver et eksplicit taggrundlag med hældning eller en manuel tagopmåling.</p>}
<form onSubmit={search} className="site-area-search">
<label>Adresse<input ref={addressInput} autoFocus value={query} maxLength={200} onChange={e => setQuery(e.target.value)} /></label>
<button disabled={unavailable || query.trim().length<3} type="submit">Søg adresse</button>
</form>
{candidates.length > 0 && <ul aria-label="Adresseforslag">{candidates.map((c,i) => <li key={i}><button type="button" onClick={() => {setCenter([c.lat,c.lng]);setCandidates([]);}}>{c.formattedAddress}</button></li>)}</ul>}
{error && <p id="site-area-error" role="alert">{error}</p>}
{status && <p role="status">{status}</p>}
{loading && <p role="status">Henter område…</p>}
{loadFailed && <div className="site-area-tools">
<button type="button" disabled={loading} onClick={() => setLoadAttempt(attempt => attempt+1)}>Prøv igen</button>
<button type="button" onClick={onManual}>Indtast mål uden kort</button>
</div>}
<div className="site-area-tools">
<button type="button" disabled={unavailable} aria-pressed={drawing} onClick={() => setDrawing(true)}>Tegn område</button>
<button type="button" disabled={unavailable} aria-pressed={!drawing} onClick={() => setDrawing(false)}>Flyt punkter</button>
{saved
? <button type="button" disabled={unavailable} onClick={() => setDeleteConfirm(true)}>Slet gemt område</button>
: <button type="button" disabled={unavailable || !points.length} onClick={() => setPoints([])}>Ryd tegning</button>}
</div>
<p id="site-area-instructions">Tryk på kortet for at tegne. Træk punkter for at redigere. Brug punktlisten til tastatur og præcise koordinater.</p>
<EditableAreaMap points={points} onChange={setPoints} center={center} drawing={drawing && !unavailable} disabled={unavailable} />
<button type="button" onClick={() => pointList.current?.focus()}>Forlad kort</button>
<p aria-live="polite">{roof ? 'Grundprojektion' : 'Grundareal'}: {preview(points).toLocaleString('da-DK',{maximumFractionDigits:1})} m² · Forhåndsvisning</p>
<details><summary ref={pointList}>Rediger punkter med tastatur</summary>
{points.map((p,i) => <div className="site-area-point" key={i}>
<span>Punkt {i+1}</span>
{[0,1].map(axis => <label key={axis}>{axis===0 ? 'Længdegrad' : 'Breddegrad'}<input type="number" step="0.000001" min={axis===0 ? -180 : -90} max={axis===0 ? 180 : 90} aria-label={`${axis===0 ? 'Længdegrad' : 'Breddegrad'} punkt ${i+1}`} value={p[axis]} disabled={busy} onChange={e => updatePoint(i,axis,Number(e.target.value))}/></label>)}
<button disabled={busy} onClick={() => setPoints(points.filter((_,index) => index!==i))}>Slet punkt {i+1}</button>
<button disabled={busy} onClick={() => { const next=points[(i+1)%points.length];setPoints([...points.slice(0,i+1),[(p[0]+next[0])/2,(p[1]+next[1])/2],...points.slice(i+1)]); }}>Indsæt efter punkt {i+1}</button>
</div>)}
<button disabled={unavailable} onClick={() => setPoints([...points,[center[1],center[0]]])}>Tilføj punkt ved kortcentrum</button>
</details>
{!loadFailed && <button disabled={busy} onClick={() => {if(discard()) onManual();}}>Indtast mål uden kort</button>}
</DialogContent>
<DialogActions className="site-area-actions">
<button disabled={unavailable} onClick={() => {if(discard()) {setPoints(savedPoints(saved));setError('');}}}>Nulstil</button>
<button disabled={busy} onClick={close}>Annuller</button>
<button className="site-area-save" disabled={unavailable || points.length<3} onClick={save}>Gem område</button>
</DialogActions>
</Dialog>
<Dialog open={deleteConfirm} fullScreen={mobile} onClose={() => !busy && setDeleteConfirm(false)} aria-labelledby="site-geometry-delete-title">
<DialogTitle id="site-geometry-delete-title">Slet gemt kortområde permanent?</DialogTitle>
<DialogContent>
<p>Området fjernes med revisionsspor. Kortbaserede arbejdspakker og snapshots bliver ugyldige og skal opmåles igen eller erstattes med manuelle mål.</p>
{dirty && <p>Ikke-gemte ændringer i tegningen bliver også kasseret.</p>}
</DialogContent>
<DialogActions>
<button type="button" disabled={busy} autoFocus onClick={() => setDeleteConfirm(false)}>Behold område</button>
<button type="button" disabled={busy} onClick={remove}>Slet permanent</button>
</DialogActions>
</Dialog></>;
}