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: 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]>
161 lines
10 KiB
JavaScript
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></>;
|
|
}
|