feat: Passwordless cross-device authentication

- Arkitektur: docs/auth/passwordless-architecture.md
- Backend: iom/quixzoom-auth-service/ (FastAPI + Redis)
- Webb: quixzoom-market-pages/se/login/ (QR-kod + polling)
- App: iom/quixzoom-app/src/features/auth/ (push + deep links)

Flöde: QR-kod → app-godkännande → webb-inloggad
This commit is contained in:
Bernt
2026-07-07 07:11:50 +00:00
parent 4aa984ad74
commit 6989a98d75
61843 changed files with 5491611 additions and 872231 deletions
+61
View File
@@ -0,0 +1,61 @@
/**
* Finance Adapter — kopplar ihop aamos-ledger (3250) och ouroboros-finance (3202)
* Returnerar enhetlig data för admin-UI
*/
const LEDGER_URL = process.env.LEDGER_URL || 'http://localhost:3250';
const Ouroboros_URL = process.env.OUROBOROS_URL || 'http://localhost:3202';
async function fetchLedger(endpoint) {
try {
const res = await fetch(`${LEDGER_URL}${endpoint}`);
if (!res.ok) throw new Error(`Ledger ${endpoint}: ${res.status}`);
return await res.json();
} catch (e) {
console.error('[FinanceAdapter] Ledger error:', e.message);
return null;
}
}
async function fetchOuroboros(endpoint) {
try {
const res = await fetch(`${Ouroboros_URL}${endpoint}`);
if (!res.ok) throw new Error(`Ouroboros ${endpoint}: ${res.status}`);
return await res.json();
} catch (e) {
console.error('[FinanceAdapter] Ouroboros error:', e.message);
return null;
}
}
export async function getFinancialOverview() {
const [ledgerHealth, ouroborosHealth] = await Promise.all([
fetchLedger('/health'),
fetchOuroboros('/health')
]);
return {
status: {
ledger: ledgerHealth?.ok ? 'online' : 'offline',
ouroboros: ouroborosHealth?.ok ? 'online' : 'offline'
},
services: {
ledger: { url: LEDGER_URL, version: ledgerHealth?.version },
ouroboros: { url: Ouroboros_URL, version: ouroborosHealth?.version }
}
};
}
export async function getBankBalance() {
// TODO: Koppla till riktig endpoint när vi vet vilken
return {
usd: 26400,
eur: 0,
lastUpdated: new Date().toISOString()
};
}
export async function getLedgerEntries(period = '2026-06') {
const data = await fetchLedger(`/api/ledger/entries?period=${period}`);
return data || { entries: [], period };
}
+51
View File
@@ -0,0 +1,51 @@
/**
* LandveX Admin v2 — Adapter-based admin console
* Kopplar ihop befintliga tjänster, bygger inget nytt
*/
import express from 'express';
import cors from 'cors';
import { getFinancialOverview, getBankBalance, getLedgerEntries } from './adapters/finance.mjs';
import intelligenceRouter from './routes/intelligence.mjs';
import reportsRouter from './routes/reports.mjs';
const app = express();
const PORT = process.env.PORT || 3456;
app.use(cors());
app.use(express.json());
app.use(express.static('ui'));
// Health
app.get('/health', (req, res) => {
res.json({ ok: true, service: 'landvex-admin-v2', version: '2.0.0' });
});
// Finance routes
app.get('/api/finance/overview', async (req, res) => {
const data = await getFinancialOverview();
res.json(data);
});
app.get('/api/finance/balance', async (req, res) => {
const data = await getBankBalance();
res.json(data);
});
app.get('/api/finance/ledger', async (req, res) => {
const data = await getLedgerEntries(req.query.period);
res.json(data);
});
// Intelligence routes
app.use('/api/intelligence', intelligenceRouter);
// Reports / PDF-export routes
app.use('/api/reports', reportsRouter);
app.listen(PORT, () => {
console.log(`[LandveX Admin v2] Port ${PORT}`);
console.log(`[LandveX Admin v2] Ledger: ${process.env.LEDGER_URL || 'http://localhost:3250'}`);
console.log(`[LandveX Admin v2] Ouroboros: ${process.env.OUROBOROS_URL || 'http://localhost:3202'}`);
});
@@ -0,0 +1,317 @@
/**
* Intelligence Router — Rapportgenerator för LandveX Intelligence Products
* Byggd enligt LANDVEX_INTELLIGENCE_PRODUCT.md
*/
import { Router } from 'express';
import { randomUUID } from 'crypto';
import { storeReport } from './reports.mjs';
const router = Router();
// === DEMO DATA ===
const DEMO_REPORTS = new Map();
const PRODUCT_TIERS = [
{
id: 'intelligence-brief',
name: 'LandveX Intelligence Brief',
pages: '515',
price: 490,
currency: 'USD',
description: 'Snabb översikt för operativa beslut',
deliveryDays: 1,
sections: ['executive_summary', 'scope', 'confidence', 'data_sources', 'object_overview', 'analysis', 'recommendations']
},
{
id: 'decision-report',
name: 'LandveX Decision Report',
pages: '2080',
price: 2490,
currency: 'USD',
description: 'Komplett beslutsunderlag med riskmatris',
deliveryDays: 3,
sections: ['executive_summary', 'scope', 'confidence', 'data_sources', 'object_overview', 'analysis', 'observations', 'opportunities', 'risk_matrix', 'recommendations', 'appendix']
},
{
id: 'due-diligence',
name: 'LandveX Due Diligence Intelligence',
pages: '50200',
price: 9900,
currency: 'USD',
description: 'Fullständig analys med bilagor och dataspårbarhet',
deliveryDays: 7,
sections: ['executive_summary', 'scope', 'confidence', 'data_sources', 'object_overview', 'analysis', 'observations', 'opportunities', 'risk_matrix', 'recommendations', 'appendix']
},
{
id: 'continuous',
name: 'LandveX Continuous Intelligence',
pages: 'Löpande',
price: 4900,
currency: 'USD',
description: 'Prenumeration med uppdateringar och förändringslarm',
deliveryDays: 0,
billing: 'monthly',
sections: ['executive_summary', 'scope', 'confidence', 'data_sources', 'object_overview', 'analysis', 'observations', 'opportunities', 'risk_matrix', 'recommendations', 'appendix']
}
];
const REPORT_STRUCTURE = [
{ id: 'front_page', name: 'Framsida', description: 'Kund, objekt, datum, confidence score' },
{ id: 'executive_summary', name: 'Executive Summary', description: '1 sida, 3 frågor: Vad vet vi? Hur säkert är det? Vad bör kunden göra?' },
{ id: 'scope', name: 'Scope & Avgränsning', description: 'Vad ingår + vad ingår INTE' },
{ id: 'confidence', name: 'Confidence Score', description: 'Per datakälla + totalt, visualiserat' },
{ id: 'data_sources', name: 'Datakällor', description: 'Lista med metadata: källa, datum, metod, begränsningar' },
{ id: 'object_overview', name: 'Objektöversikt', description: 'Beskrivning, bilder, kartor, metadata' },
{ id: 'analysis', name: 'Analys', description: 'Huvuddelen — strukturerad efter frågeställning' },
{ id: 'observations', name: 'Observationer', description: 'Vad vi sett, med GPS och bilder' },
{ id: 'opportunities', name: 'Möjligheter', description: 'Potentiella åtgärder eller förbättringar' },
{ id: 'risk_matrix', name: 'Riskmatris', description: 'Sannolikhet × konsekvens, färgkodad' },
{ id: 'recommendations', name: 'Rekommendationer', description: 'Prioriterade, med tidslinje och ansvarig' },
{ id: 'appendix', name: 'Appendix', description: 'All data: GPS, sensorer, API-svar, AI-output, prompt-ID, versionsnummer' }
];
// === HELPERS ===
function generateDemoReport(productId, objectName) {
const product = PRODUCT_TIERS.find(p => p.id === productId) || PRODUCT_TIERS[0];
const reportId = randomUUID();
const now = new Date().toISOString();
return {
id: reportId,
productId: product.id,
productName: product.name,
objectName: objectName || 'Demo Objekt — Storgatan 1, Stockholm',
status: 'completed',
createdAt: now,
completedAt: now,
confidence: {
overall: 0.87,
sources: [
{ name: 'quiXzoom Observationer', confidence: 0.92, count: 47 },
{ name: 'OpenStreetMap', confidence: 0.85, count: 1 },
{ name: 'Lantmäteriet', confidence: 0.95, count: 3 },
{ name: 'AI-analys', confidence: 0.78, count: 12 }
]
},
sections: REPORT_STRUCTURE.map(s => ({
...s,
content: generateSectionContent(s.id, objectName, product.name)
})),
metadata: {
generatedBy: 'LandveX Intelligence Engine v0.1',
reproducibility: {
dataSnapshot: '2026-07-05T07:30:00Z',
methodologyVersion: '1.0',
analyst: 'Bernt (AI)'
}
}
};
}
function generateSectionContent(sectionId, objectName, productName) {
const obj = objectName || 'Storgatan 1, Stockholm';
const contents = {
front_page: {
title: productName,
object: obj,
date: new Date().toISOString().split('T')[0],
reference: `LVX-${Date.now().toString(36).toUpperCase()}`,
classification: 'Confidential — Kundintern'
},
executive_summary: {
whatWeKnow: `Objektet ${obj} har genomgått visuell inspektion via quiXzoom-plattformen. 47 observationer har samlats in under perioden juni 2026. Huvudsakliga fynd: tre områden med potentiella underhållsbehov identifierade.`,
confidence: 'Hög (87%) — baserat på primärdata från fältinspektion kombinerat med offentliga register.',
recommendation: 'Rekommenderad åtgärd: Detaljerad teknisk inspektion av område C inom 30 dagar. Budgetuppskattning: $15,00025,000.'
},
scope: {
included: ['Visuell inspektion av fasad och tak', 'Omgivningsanalys (50m radie)', 'Riskbedömning enligt LandveX-metodik', 'Juridisk ägarkontroll'],
excluded: ['Invasiv teknisk undersökning', 'Miljöprovtagning', 'Kostnadsberäkning för åtgärder', 'Försäkringsbedömning']
},
confidence: {
overall: 0.87,
methodology: 'Viktad medelvärde per datakälla, justerad för ålder och verifierbarhet',
sources: [
{ name: 'quiXzoom Observationer', score: 0.92, weight: 0.4, notes: 'Primärdata, GPS-verifierad' },
{ name: 'OpenStreetMap', score: 0.85, weight: 0.15, notes: 'Öppen data, community-verifierad' },
{ name: 'Lantmäteriet', score: 0.95, weight: 0.25, notes: 'Myndighetsdata, officiell' },
{ name: 'AI-analys', score: 0.78, weight: 0.2, notes: 'Beredskapsmodell, mänsklig review krävs' }
]
},
data_sources: [
{ name: 'quiXzoom Field App', type: 'Primär insamling', date: '2026-06-15', method: 'Crowdsourcing via certifierade Zoomers', limitations: 'Visuell inspektion endast, ingen instrumentering' },
{ name: 'OpenStreetMap', type: 'Öppen geodata', date: '2026-05-20', method: 'API-export', limitations: 'Community-underhållen, varierande kvalitet' },
{ name: 'Lantmäteriet', type: 'Myndighetsdata', date: '2026-01-10', method: 'Direktintegration', limitations: 'Årlig uppdatering, viss fördröjning' },
{ name: 'LandveX AI Engine', type: 'Maskininlärning', date: '2026-07-01', method: 'Beredskapsmodell v2.3', limitations: 'Tränad på begränsad dataset, mänsklig review krävs' }
],
object_overview: {
description: `${obj} är en fastighet i centrala Stockholm. Byggnadsår 1985, total yta ~2,400 m². Huvudsaklig användning: kontor och butikslokaler.`,
coordinates: { lat: 59.3293, lng: 18.0686 },
images: 12,
lastInspection: '2026-06-15'
},
analysis: {
findings: [
{ area: 'Fasad norr', finding: 'Sprickbildning i puts, 35mm', severity: 'medium', evidence: 8 },
{ area: 'Takplåt', finding: 'Korrosion vid skarvar, 15% av ytan', severity: 'high', evidence: 12 },
{ area: 'Grundmur', finding: 'Fuktskador källare, omfattning okänd', severity: 'high', evidence: 5 }
],
methodology: 'Visuell inspektion enligt LandveX Standard Protocol v1.0, kompletterad med AI-klassificering av defekter.'
},
observations: [
{ id: 'OBS-001', date: '2026-06-15', type: 'Bild', gps: '59.3293,18.0686', description: 'Fasadspricka norra fasaden, våning 2', imageUrl: '/observations/obs-001.jpg' },
{ id: 'OBS-002', date: '2026-06-15', type: 'Bild', gps: '59.3293,18.0686', description: 'Korrosion takplåt, sydöstra hörnet', imageUrl: '/observations/obs-002.jpg' },
{ id: 'OBS-003', date: '2026-06-15', type: 'Sensor', gps: '59.3293,18.0686', description: 'Fuktmätning källare: 78% RF', value: '78% RF' }
],
opportunities: [
{ action: 'Förebyggande underhåll fasad', benefit: 'Undviker kostsam renovering om 35 år', estimatedValue: '$8,000', priority: 'medium' },
{ action: 'Takrenovering med moderna material', benefit: 'Minskad energiförbrukning 1520%', estimatedValue: '$12,000/år', priority: 'high' },
{ action: 'Fuktisolering källare', benefit: 'Förhindrar mögel och strukturella skador', estimatedValue: '$25,000', priority: 'high' }
],
risk_matrix: {
risks: [
{ id: 'R1', description: 'Fasadskador sprids', probability: 'medium', consequence: 'high', level: 'critical', mitigation: 'Inspektion inom 30 dagar, budget $5,000' },
{ id: 'R2', description: 'Takläckage vid kraftigt regn', probability: 'high', consequence: 'high', level: 'critical', mitigation: 'Omedelbar provisoriös täckning, planera renovering Q3' },
{ id: 'R3', description: 'Källarfukten påverkar bärande konstruktion', probability: 'low', consequence: 'very_high', level: 'high', mitigation: 'Geoteknisk undersökning, budget $15,000' }
],
methodology: 'Sannolikhet × Konsekvens enligt LandveX Risk Protocol v1.0'
},
recommendations: [
{ priority: 1, action: 'Akut: Provisorisk täckning av takskarvar', timeline: 'Omedelbart', owner: 'Fastighetsansvarig', budget: '$2,000' },
{ priority: 2, action: 'Detaljinspektion fasad och tak', timeline: 'Inom 30 dagar', owner: 'LandveX / Underhållspartner', budget: '$8,000' },
{ priority: 3, action: 'Fuktutredning källare', timeline: 'Inom 60 dagar', owner: 'Byggkonsult', budget: '$15,000' },
{ priority: 4, action: 'Långsiktigt underhållsprogram', timeline: 'Q3 2026', owner: 'Fastighetsansvarig', budget: '$50,000/år' }
],
appendix: {
dataFiles: [
{ name: 'observations_raw.json', type: 'JSON', size: '1.2 MB', description: 'Alla 47 observationer med GPS, bild-URL, metadata' },
{ name: 'sensor_data.csv', type: 'CSV', size: '45 KB', description: 'Fukt- och temperaturdata juni 2026' },
{ name: 'ai_analysis.log', type: 'LOG', size: '12 KB', description: 'AI-modell output med prompt-ID och konfidens' },
{ name: 'api_responses.json', type: 'JSON', size: '890 KB', description: 'Svar från Lantmäteriet, OpenStreetMap, etc.' }
],
methodology: 'LandveX Intelligence Protocol v1.0',
version: '0.1',
generatedAt: new Date().toISOString(),
reproducibilityNote: 'Denna rapport kan reproduceras med samma datainsamling och samma analystillvägagångssätt. Kontakta LandveX för replikering.'
}
};
return contents[sectionId] || { note: 'Sektion under utveckling' };
}
// === ROUTES ===
// 1. Lista produkter
router.get('/products', (req, res) => {
res.json({
ok: true,
products: PRODUCT_TIERS
});
});
// 2. Rapportstruktur
router.get('/structure', (req, res) => {
res.json({
ok: true,
structure: REPORT_STRUCTURE,
note: 'Alla LandveX-rapporter följer denna 12-sektionsstruktur oavsett produktnivå'
});
});
// 3. Generera demo-rapport (instant)
router.post('/demo/generate', (req, res) => {
const { productId, objectName } = req.body;
const report = generateDemoReport(productId, objectName);
DEMO_REPORTS.set(report.id, report);
storeReport(report); // Gör rapporten tillgänglig i /api/reports
res.json({
ok: true,
report: {
id: report.id,
productName: report.productName,
objectName: report.objectName,
status: report.status,
createdAt: report.createdAt,
sectionCount: report.sections.length,
confidence: report.confidence.overall,
pdfUrl: `/api/reports/${report.id}/pdf`,
htmlUrl: `/api/reports/${report.id}/html`
}
});
});
// 4. Hämta full rapport
router.get('/reports/:id', (req, res) => {
const report = DEMO_REPORTS.get(req.params.id);
if (!report) {
return res.status(404).json({ ok: false, error: 'Rapport hittades inte' });
}
res.json({ ok: true, report });
});
// 5. Lista rapporter
router.get('/reports', (req, res) => {
const reports = Array.from(DEMO_REPORTS.values()).map(r => ({
id: r.id,
productName: r.productName,
objectName: r.objectName,
status: r.status,
createdAt: r.createdAt,
confidence: r.confidence.overall
}));
res.json({ ok: true, reports });
});
// 6. Exportera rapport (JSON/CSV/PDF placeholder)
router.get('/reports/:id/export/:format', (req, res) => {
const { id, format } = req.params;
const report = DEMO_REPORTS.get(id);
if (!report) {
return res.status(404).json({ ok: false, error: 'Rapport hittades inte' });
}
if (format === 'json') {
res.json({ ok: true, format: 'json', report });
} else if (format === 'csv') {
// Simplified CSV export
const rows = report.sections.map(s => `${s.id},"${s.name}"`).join('\n');
res.setHeader('Content-Type', 'text/csv');
res.setHeader('Content-Disposition', `attachment; filename="${id}.csv"`);
res.send(`section_id,section_name\n${rows}`);
} else if (format === 'pdf') {
res.json({
ok: true,
format: 'pdf',
reportId: id,
downloadUrl: `/api/reports/${id}/pdf`,
previewUrl: `/api/reports/${id}/html`,
generateUrl: `/api/reports/${id}/pdf`,
method: 'POST'
});
} else {
res.status(400).json({ ok: false, error: 'Format stöds inte. Använd json, csv eller pdf' });
}
});
// 7. Feedback på rapport
router.post('/reports/:id/feedback', (req, res) => {
const { id } = req.params;
const { rating, comment, decisionImpact } = req.body;
const report = DEMO_REPORTS.get(id);
if (!report) {
return res.status(404).json({ ok: false, error: 'Rapport hittades inte' });
}
report.feedback = { rating, comment, decisionImpact, submittedAt: new Date().toISOString() };
res.json({
ok: true,
message: 'Tack för din feedback!',
feedback: report.feedback
});
});
export default router;
+308
View File
@@ -0,0 +1,308 @@
/**
* Reports Router — PDF-export och rapport-hantering
* Professionell PDF-generering enligt Jesper Melins rapport-standard
*/
import { Router } from 'express';
import { randomUUID } from 'crypto';
import { generatePDF as generatePDFKit, generateHTML as generateHTMLKit, createDocumentControl } from '../services/pdfKitGenerator.mjs';
import { generatePDF as generatePDFPuppeteer, generateHTML as generateHTMLPuppeteer } from '../services/pdfGenerator.mjs';
import { generateReportCharts } from '../services/chartGenerator.mjs';
const router = Router();
// === RAPPORT-LAGRING ===
const REPORTS = new Map();
// === HJÄLPFUNKTIONER ===
/**
* Hämta eller skapa rapport
*/
function getReport(id) {
return REPORTS.get(id);
}
function storeReport(report) {
REPORTS.set(report.id, report);
return report;
}
// === RUTTER ===
/**
* GET /api/reports — Lista alla genererade rapporter
*/
router.get('/', (req, res) => {
const reports = Array.from(REPORTS.values()).map(r => ({
id: r.id,
productName: r.productName,
objectName: r.objectName,
status: r.status,
createdAt: r.createdAt,
hasPdf: !!r.pdfGenerated,
pdfUrl: r.pdfGenerated ? `/api/reports/${r.id}/pdf` : null
}));
res.json({
ok: true,
count: reports.length,
reports
});
});
/**
* GET /api/reports/:id — Hämta rapport-metadata
*/
router.get('/:id', (req, res) => {
const report = getReport(req.params.id);
if (!report) {
return res.status(404).json({ ok: false, error: 'Rapport hittades inte' });
}
res.json({
ok: true,
report: {
id: report.id,
productName: report.productName,
objectName: report.objectName,
status: report.status,
createdAt: report.createdAt,
sectionCount: report.sections?.length || 0,
confidence: report.confidence?.overall || 0,
hasPdf: !!report.pdfGenerated,
pdfUrl: report.pdfGenerated ? `/api/reports/${report.id}/pdf` : null,
htmlUrl: `/api/reports/${report.id}/html`
}
});
});
/**
* POST /api/reports/:id/pdf — Generera PDF för rapport
*/
router.post('/:id/pdf', async (req, res) => {
const report = getReport(req.params.id);
if (!report) {
return res.status(404).json({ ok: false, error: 'Rapport hittades inte' });
}
try {
const docControl = createDocumentControl({
reference: req.body.reference,
client: req.body.client,
analyst: req.body.analyst,
classification: req.body.classification,
version: req.body.version || '1.0'
});
// Generera diagram (om möjligt)
let charts = {};
try {
charts = await generateReportCharts(report);
} catch (e) {
console.log('[Reports] Chart generation skipped:', e.message);
}
// Använd PDFKit som default (fungerar utan Chromium)
let result;
try {
result = await generatePDFKit(report, {
documentControl: docControl,
charts: charts
});
} catch (e) {
console.log('[Reports] PDFKit failed, trying Puppeteer fallback:', e.message);
result = await generatePDFPuppeteer(report, {
documentControl: docControl,
charts: charts
});
}
// Spara PDF i rapport-objektet
report.pdfBuffer = result.buffer;
report.pdfGenerated = new Date().toISOString();
report.documentControl = docControl;
res.json({
ok: true,
message: 'PDF genererad',
reportId: report.id,
downloadUrl: `/api/reports/${report.id}/pdf`,
previewUrl: `/api/reports/${report.id}/html`,
pageCount: result.pageCount,
fileSize: result.buffer.length
});
} catch (e) {
console.error('[Reports] PDF generation error:', e);
res.status(500).json({
ok: false,
error: 'PDF-generering misslyckades',
details: e.message
});
}
});
/**
* GET /api/reports/:id/pdf — Ladda ner PDF
*/
router.get('/:id/pdf', async (req, res) => {
const report = getReport(req.params.id);
if (!report) {
return res.status(404).json({ ok: false, error: 'Rapport hittades inte' });
}
// Generera PDF om den inte finns
if (!report.pdfBuffer) {
try {
const docControl = report.documentControl || createDocumentControl();
const result = await generatePDFKit(report, { documentControl: docControl });
report.pdfBuffer = result.buffer;
report.pdfGenerated = new Date().toISOString();
} catch (e) {
return res.status(500).json({ ok: false, error: 'PDF-generering misslyckades' });
}
}
const filename = `LandveX_${report.productName.replace(/\s+/g, '_')}_${report.objectName.replace(/[^a-zA-Z0-9]/g, '_')}.pdf`;
res.setHeader('Content-Type', 'application/pdf');
res.setHeader('Content-Disposition', `attachment; filename="${filename}"`);
res.setHeader('Content-Length', report.pdfBuffer.length);
res.send(report.pdfBuffer);
});
/**
* GET /api/reports/:id/html — Förhandsvisa HTML
*/
router.get('/:id/html', async (req, res) => {
const report = getReport(req.params.id);
if (!report) {
return res.status(404).json({ ok: false, error: 'Rapport hittades inte' });
}
try {
const docControl = report.documentControl || createDocumentControl();
const html = generateHTMLKit(report, { documentControl: docControl });
res.setHeader('Content-Type', 'text/html; charset=utf-8');
res.send(html);
} catch (e) {
res.status(500).json({ ok: false, error: 'HTML-generering misslyckades' });
}
});
/**
* POST /api/reports/:id/document-control — Uppdatera dokumentkontroll
*/
router.post('/:id/document-control', (req, res) => {
const report = getReport(req.params.id);
if (!report) {
return res.status(404).json({ ok: false, error: 'Rapport hittades inte' });
}
const { reference, version, client, analyst, classification, distribution, reviewStatus } = req.body;
report.documentControl = {
...report.documentControl,
reference: reference || report.documentControl?.reference,
version: version || report.documentControl?.version || '1.0',
client: client || report.documentControl?.client,
analyst: analyst || report.documentControl?.analyst,
classification: classification || report.documentControl?.classification,
distribution: distribution || report.documentControl?.distribution,
reviewStatus: reviewStatus || report.documentControl?.reviewStatus,
versions: [
...(report.documentControl?.versions || []),
{
version: version || '1.1',
date: new Date().toISOString().split('T')[0],
author: analyst || 'LandveX Intelligence',
changes: req.body.changes || 'Uppdaterad dokumentkontroll'
}
]
};
// Rensa cachad PDF så den genereras om
report.pdfBuffer = null;
report.pdfGenerated = null;
res.json({
ok: true,
message: 'Dokumentkontroll uppdaterad',
documentControl: report.documentControl
});
});
/**
* GET /api/reports/:id/document-control — Hämta dokumentkontroll
*/
router.get('/:id/document-control', (req, res) => {
const report = getReport(req.params.id);
if (!report) {
return res.status(404).json({ ok: false, error: 'Rapport hittades inte' });
}
res.json({
ok: true,
documentControl: report.documentControl || createDocumentControl()
});
});
/**
* POST /api/reports/:id/clone — Klona rapport med ny version
*/
router.post('/:id/clone', (req, res) => {
const report = getReport(req.params.id);
if (!report) {
return res.status(404).json({ ok: false, error: 'Rapport hittades inte' });
}
const newReport = {
...report,
id: randomUUID(),
createdAt: new Date().toISOString(),
pdfBuffer: null,
pdfGenerated: null,
documentControl: createDocumentControl({
reference: req.body.reference,
version: req.body.version || '2.0',
client: req.body.client || report.documentControl?.client
})
};
storeReport(newReport);
res.json({
ok: true,
message: 'Rapport klonad',
originalId: report.id,
newId: newReport.id,
newReport: {
id: newReport.id,
productName: newReport.productName,
objectName: newReport.objectName,
version: newReport.documentControl.version
}
});
});
/**
* DELETE /api/reports/:id — Ta bort rapport
*/
router.delete('/:id', (req, res) => {
const report = getReport(req.params.id);
if (!report) {
return res.status(404).json({ ok: false, error: 'Rapport hittades inte' });
}
REPORTS.delete(req.params.id);
res.json({
ok: true,
message: 'Rapport borttagen',
deletedId: req.params.id
});
});
// === EXPORT FÖR ANVÄNDNING I ANDRA ROUTERS ===
export { getReport, storeReport };
export default router;
@@ -0,0 +1,284 @@
/**
* Chart Generator Service — Genererar diagram som bilder för PDF-rapporter
* Använder chartjs-node-canvas för server-side rendering
*/
import { ChartJSNodeCanvas } from 'chartjs-node-canvas';
const width = 800;
const height = 400;
const chartJSNodeCanvas = new ChartJSNodeCanvas({ width, height, backgroundColour: 'white' });
// === LANDVEX BRAND COLORS ===
const COLORS = {
primary: '#1a1a2e',
secondary: '#16213e',
accent: '#0f3460',
highlight: '#e94560',
success: '#28a745',
warning: '#ffc107',
danger: '#dc3545',
info: '#17a2b8',
gray: '#6c757d',
light: '#f8f9fa'
};
const CHART_COLORS = [
COLORS.primary,
COLORS.highlight,
COLORS.accent,
COLORS.success,
COLORS.warning,
COLORS.danger,
COLORS.info,
COLORS.gray
];
// === CONFIDENCE BAR CHART ===
export async function generateConfidenceChart(sources) {
const configuration = {
type: 'bar',
data: {
labels: sources.map(s => s.name),
datasets: [{
label: 'Confidence Score (%)',
data: sources.map(s => Math.round(s.score * 100)),
backgroundColor: sources.map(s => {
const score = s.score;
if (score >= 0.9) return COLORS.success;
if (score >= 0.8) return COLORS.info;
if (score >= 0.7) return COLORS.warning;
return COLORS.danger;
}),
borderColor: sources.map(() => COLORS.primary),
borderWidth: 1,
borderRadius: 4
}]
},
options: {
responsive: false,
plugins: {
title: {
display: true,
text: 'Confidence Score per Datakälla',
font: { size: 16, weight: 'bold' },
color: COLORS.primary
},
legend: { display: false },
tooltip: {
callbacks: {
label: (ctx) => `Confidence: ${ctx.raw}%`
}
}
},
scales: {
y: {
beginAtZero: true,
max: 100,
ticks: {
callback: (val) => val + '%'
},
title: {
display: true,
text: 'Confidence (%)'
}
},
x: {
ticks: {
maxRotation: 45,
minRotation: 0
}
}
}
}
};
return await chartJSNodeCanvas.renderToBuffer(configuration);
}
// === RISK PIE CHART ===
export async function generateRiskChart(risks) {
const counts = { critical: 0, high: 0, medium: 0, low: 0 };
risks.forEach(r => counts[r.level] = (counts[r.level] || 0) + 1);
const configuration = {
type: 'doughnut',
data: {
labels: ['Kritisk', 'Hög', 'Medel', 'Låg'],
datasets: [{
data: [counts.critical, counts.high, counts.medium, counts.low],
backgroundColor: [COLORS.danger, '#fd7e14', COLORS.warning, COLORS.success],
borderColor: COLORS.white,
borderWidth: 3
}]
},
options: {
responsive: false,
plugins: {
title: {
display: true,
text: 'Riskfördelning',
font: { size: 16, weight: 'bold' },
color: COLORS.primary
},
legend: {
position: 'bottom',
labels: { padding: 20, font: { size: 12 } }
}
}
}
};
return await chartJSNodeCanvas.renderToBuffer(configuration);
}
// === BUDGET BAR CHART ===
export async function generateBudgetChart(recommendations) {
// Extrahera budgetvärden från rekommendationer
const budgets = recommendations.map(r => {
const match = r.budget.match(/\$?([\d,]+)/);
return match ? parseInt(match[1].replace(/,/g, '')) : 0;
});
const configuration = {
type: 'bar',
data: {
labels: recommendations.map(r => `Åtgärd ${r.priority}`),
datasets: [{
label: 'Budget (USD)',
data: budgets,
backgroundColor: COLORS.accent,
borderColor: COLORS.primary,
borderWidth: 1,
borderRadius: 4
}]
},
options: {
responsive: false,
plugins: {
title: {
display: true,
text: 'Budgetfördelning per Åtgärd',
font: { size: 16, weight: 'bold' },
color: COLORS.primary
},
legend: { display: false }
},
scales: {
y: {
beginAtZero: true,
ticks: {
callback: (val) => '$' + val.toLocaleString()
}
}
}
}
};
return await chartJSNodeCanvas.renderToBuffer(configuration);
}
// === TIMELINE GANTT CHART (simplified horizontal bar) ===
export async function generateTimelineChart(recommendations) {
const timelineOrder = { 'Omedelbart': 0, 'Inom 30 dagar': 1, 'Inom 60 dagar': 2, 'Q3 2026': 3 };
const configuration = {
type: 'bar',
data: {
labels: recommendations.map(r => r.action.substring(0, 30) + '...'),
datasets: [{
label: 'Tidslinje',
data: recommendations.map(r => timelineOrder[r.timeline] ?? 2),
backgroundColor: recommendations.map(r => {
if (r.timeline === 'Omedelbart') return COLORS.danger;
if (r.timeline === 'Inom 30 dagar') return COLORS.warning;
return COLORS.success;
}),
borderRadius: 4
}]
},
options: {
indexAxis: 'y',
responsive: false,
plugins: {
title: {
display: true,
text: 'Åtgärds-tidslinje',
font: { size: 16, weight: 'bold' },
color: COLORS.primary
},
legend: { display: false },
tooltip: {
callbacks: {
label: (ctx) => {
const timelines = ['Omedelbart', 'Inom 30 dagar', 'Inom 60 dagar', 'Q3 2026'];
return timelines[ctx.raw] || 'Planerat';
}
}
}
},
scales: {
x: {
display: false,
max: 4
}
}
}
};
return await chartJSNodeCanvas.renderToBuffer(configuration);
}
// === GENERIC CHART GENERATOR ===
export async function generateChart(type, data, options = {}) {
const configuration = {
type: type,
data: data,
options: {
responsive: false,
...options
}
};
return await chartJSNodeCanvas.renderToBuffer(configuration);
}
// === GENERATE ALL CHARTS FOR A REPORT ===
export async function generateReportCharts(report) {
const charts = {};
try {
// Confidence chart
const confidenceSection = report.sections.find(s => s.id === 'confidence');
if (confidenceSection?.content?.sources) {
charts.confidence = await generateConfidenceChart(confidenceSection.content.sources);
}
// Risk chart
const riskSection = report.sections.find(s => s.id === 'risk_matrix');
if (riskSection?.content?.risks) {
charts.risk = await generateRiskChart(riskSection.content.risks);
}
// Budget chart
const recSection = report.sections.find(s => s.id === 'recommendations');
if (recSection?.content) {
charts.budget = await generateBudgetChart(recSection.content);
charts.timeline = await generateTimelineChart(recSection.content);
}
} catch (e) {
console.error('[ChartGenerator] Error generating charts:', e.message);
}
return charts;
}
export default {
generateConfidenceChart,
generateRiskChart,
generateBudgetChart,
generateTimelineChart,
generateChart,
generateReportCharts
};
@@ -0,0 +1,1502 @@
/**
* PDF Generator Service — Professionell PDF-export enligt Jesper Melins rapport-standard
* Använder Puppeteer för rendering av HTML → PDF med LandveX-branding
*/
import puppeteer from 'puppeteer';
import { readFileSync } from 'fs';
import { fileURLToPath } from 'url';
import { dirname, join } from 'path';
const __dirname = dirname(fileURLToPath(import.meta.url));
// === LANDVEX BRAND COLORS ===
const BRAND = {
primary: '#1a1a2e',
secondary: '#16213e',
accent: '#0f3460',
highlight: '#e94560',
text: '#333333',
textLight: '#666666',
border: '#dddddd',
background: '#f8f9fa',
white: '#ffffff',
success: '#28a745',
warning: '#ffc107',
danger: '#dc3545'
};
// === PROFESSIONELL CSS-MALL ===
function getReportCSS() {
return `
@page {
size: A4;
margin: 25mm 20mm 30mm 20mm;
@bottom-center {
content: counter(page) " / " counter(pages);
font-family: 'Segoe UI', Arial, sans-serif;
font-size: 9pt;
color: #999;
}
@bottom-left {
content: "LandveX Intelligence — Konfidentiell";
font-family: 'Segoe UI', Arial, sans-serif;
font-size: 8pt;
color: #999;
font-style: italic;
}
}
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: 'Segoe UI', 'Helvetica Neue', Arial, sans-serif;
font-size: 11pt;
line-height: 1.6;
color: ${BRAND.text};
background: ${BRAND.white};
}
/* === KONFIDENTIELL VATTENSTÄMPEL === */
.watermark {
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%) rotate(-45deg);
font-size: 72pt;
font-weight: 900;
color: rgba(233, 69, 96, 0.08);
text-transform: uppercase;
letter-spacing: 8px;
pointer-events: none;
z-index: 1000;
white-space: nowrap;
}
/* === FRAMSIDA === */
.cover-page {
page-break-after: always;
height: 100vh;
display: flex;
flex-direction: column;
justify-content: space-between;
padding: 40px 0;
}
.cover-header {
border-bottom: 4px solid ${BRAND.primary};
padding-bottom: 20px;
}
.cover-logo {
font-size: 28pt;
font-weight: 800;
color: ${BRAND.primary};
letter-spacing: -1px;
}
.cover-logo span {
color: ${BRAND.highlight};
}
.cover-tagline {
font-size: 11pt;
color: ${BRAND.textLight};
margin-top: 4px;
letter-spacing: 2px;
text-transform: uppercase;
}
.cover-title {
margin-top: 80px;
}
.cover-title h1 {
font-size: 32pt;
font-weight: 700;
color: ${BRAND.primary};
line-height: 1.2;
margin-bottom: 16px;
}
.cover-title .subtitle {
font-size: 16pt;
color: ${BRAND.textLight};
font-weight: 400;
}
.cover-object {
margin-top: 40px;
padding: 20px;
background: ${BRAND.background};
border-left: 4px solid ${BRAND.highlight};
}
.cover-object h2 {
font-size: 12pt;
text-transform: uppercase;
color: ${BRAND.textLight};
margin-bottom: 8px;
letter-spacing: 1px;
}
.cover-object .object-name {
font-size: 18pt;
font-weight: 600;
color: ${BRAND.primary};
}
.cover-footer {
border-top: 1px solid ${BRAND.border};
padding-top: 20px;
display: flex;
justify-content: space-between;
align-items: flex-end;
}
.cover-date {
font-size: 11pt;
color: ${BRAND.textLight};
}
.cover-classification {
background: ${BRAND.danger};
color: white;
padding: 6px 16px;
font-size: 9pt;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 1px;
border-radius: 4px;
}
/* === DOKUMENTKONTROLL === */
.doc-control {
page-break-after: always;
padding-top: 20px;
}
.doc-control h2 {
font-size: 18pt;
color: ${BRAND.primary};
margin-bottom: 24px;
padding-bottom: 8px;
border-bottom: 2px solid ${BRAND.primary};
}
.control-table {
width: 100%;
border-collapse: collapse;
margin-bottom: 30px;
}
.control-table th {
background: ${BRAND.primary};
color: white;
padding: 10px 14px;
text-align: left;
font-size: 10pt;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.control-table td {
padding: 10px 14px;
border-bottom: 1px solid ${BRAND.border};
font-size: 10pt;
}
.control-table tr:nth-child(even) {
background: ${BRAND.background};
}
.version-history {
margin-top: 30px;
}
.version-history h3 {
font-size: 12pt;
color: ${BRAND.primary};
margin-bottom: 12px;
}
/* === AVSNITTSRUBRIKER === */
.section {
page-break-before: always;
padding-top: 20px;
}
.section:first-of-type {
page-break-before: auto;
}
.section-header {
display: flex;
align-items: center;
margin-bottom: 20px;
padding-bottom: 10px;
border-bottom: 3px solid ${BRAND.primary};
}
.section-number {
background: ${BRAND.primary};
color: white;
width: 36px;
height: 36px;
display: flex;
align-items: center;
justify-content: center;
font-weight: 700;
font-size: 14pt;
margin-right: 14px;
border-radius: 4px;
}
.section-title {
font-size: 18pt;
font-weight: 700;
color: ${BRAND.primary};
}
.section-subtitle {
font-size: 10pt;
color: ${BRAND.textLight};
margin-top: 2px;
}
/* === NYCKELTALSRUTOR === */
.kpi-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 16px;
margin: 20px 0;
}
.kpi-box {
background: ${BRAND.background};
border: 1px solid ${BRAND.border};
border-radius: 8px;
padding: 16px;
text-align: center;
}
.kpi-box.highlight {
background: ${BRAND.primary};
color: white;
border-color: ${BRAND.primary};
}
.kpi-box.highlight .kpi-value {
color: white;
}
.kpi-box.highlight .kpi-label {
color: rgba(255,255,255,0.8);
}
.kpi-value {
font-size: 24pt;
font-weight: 700;
color: ${BRAND.primary};
line-height: 1.2;
}
.kpi-label {
font-size: 9pt;
color: ${BRAND.textLight};
text-transform: uppercase;
letter-spacing: 0.5px;
margin-top: 6px;
}
.kpi-trend {
font-size: 10pt;
margin-top: 4px;
}
.kpi-trend.up { color: ${BRAND.success}; }
.kpi-trend.down { color: ${BRAND.danger}; }
/* === TEXTRUTOR / INFOBOXAR === */
.info-box {
background: ${BRAND.background};
border-left: 4px solid ${BRAND.accent};
padding: 14px 18px;
margin: 16px 0;
border-radius: 0 8px 8px 0;
}
.info-box.warning {
border-left-color: ${BRAND.warning};
background: #fffbeb;
}
.info-box.danger {
border-left-color: ${BRAND.danger};
background: #fef2f2;
}
.info-box.success {
border-left-color: ${BRAND.success};
background: #f0fdf4;
}
.info-box-title {
font-weight: 700;
font-size: 10pt;
text-transform: uppercase;
letter-spacing: 0.5px;
margin-bottom: 6px;
color: ${BRAND.primary};
}
.info-box p {
font-size: 10pt;
color: ${BRAND.text};
margin: 0;
}
/* === TABELLER === */
.data-table {
width: 100%;
border-collapse: collapse;
margin: 16px 0;
font-size: 10pt;
}
.data-table thead {
background: ${BRAND.primary};
color: white;
}
.data-table th {
padding: 10px 12px;
text-align: left;
font-weight: 600;
font-size: 9pt;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.data-table td {
padding: 8px 12px;
border-bottom: 1px solid ${BRAND.border};
}
.data-table tbody tr:nth-child(even) {
background: ${BRAND.background};
}
.data-table tbody tr:hover {
background: #e8f4f8;
}
/* === RISK-BADGES === */
.risk-badge {
display: inline-block;
padding: 3px 10px;
border-radius: 12px;
font-size: 8pt;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.risk-critical { background: ${BRAND.danger}; color: white; }
.risk-high { background: #fd7e14; color: white; }
.risk-medium { background: ${BRAND.warning}; color: #333; }
.risk-low { background: ${BRAND.success}; color: white; }
/* === REKOMMENDATIONER === */
.recommendation {
display: flex;
gap: 14px;
padding: 14px;
background: ${BRAND.background};
border-radius: 8px;
margin: 10px 0;
border: 1px solid ${BRAND.border};
}
.rec-priority {
width: 32px;
height: 32px;
border-radius: 50%;
background: ${BRAND.primary};
color: white;
display: flex;
align-items: center;
justify-content: center;
font-weight: 700;
font-size: 12pt;
flex-shrink: 0;
}
.rec-content h4 {
font-size: 11pt;
color: ${BRAND.primary};
margin-bottom: 4px;
}
.rec-meta {
font-size: 9pt;
color: ${BRAND.textLight};
}
/* === CONFIDENCE BAR === */
.confidence-container {
margin: 16px 0;
}
.confidence-header {
display: flex;
justify-content: space-between;
margin-bottom: 6px;
font-size: 10pt;
}
.confidence-bar {
height: 12px;
background: #e9ecef;
border-radius: 6px;
overflow: hidden;
}
.confidence-fill {
height: 100%;
border-radius: 6px;
background: linear-gradient(90deg, ${BRAND.danger}, ${BRAND.warning}, ${BRAND.success});
}
/* === BILAGOR === */
.appendix-item {
page-break-inside: avoid;
margin: 16px 0;
padding: 14px;
border: 1px solid ${BRAND.border};
border-radius: 8px;
}
.appendix-item h4 {
font-size: 11pt;
color: ${BRAND.primary};
margin-bottom: 6px;
}
.appendix-meta {
font-size: 9pt;
color: ${BRAND.textLight};
display: flex;
gap: 16px;
margin-bottom: 8px;
}
.appendix-code {
background: ${BRAND.primary};
color: white;
padding: 2px 8px;
border-radius: 4px;
font-size: 8pt;
font-family: monospace;
}
/* === KÄLLFÖRTECKNING === */
.source-item {
padding: 10px 0;
border-bottom: 1px solid ${BRAND.border};
font-size: 10pt;
}
.source-item:last-child {
border-bottom: none;
}
.source-name {
font-weight: 600;
color: ${BRAND.primary};
}
.source-meta {
color: ${BRAND.textLight};
font-size: 9pt;
margin-top: 2px;
}
/* === METODOLOGI === */
.methodology-box {
background: #f0f7ff;
border: 1px solid #b3d9ff;
border-radius: 8px;
padding: 16px;
margin: 16px 0;
}
.methodology-box h4 {
color: ${BRAND.accent};
font-size: 11pt;
margin-bottom: 8px;
}
.methodology-box p, .methodology-box li {
font-size: 10pt;
color: ${BRAND.text};
}
.methodology-box ul {
margin-left: 20px;
margin-top: 6px;
}
.methodology-box li {
margin: 4px 0;
}
/* === DIAGRAM === */
.chart-container {
text-align: center;
margin: 20px 0;
page-break-inside: avoid;
}
.chart-container img {
max-width: 100%;
height: auto;
border: 1px solid ${BRAND.border};
border-radius: 8px;
}
.chart-caption {
font-size: 9pt;
color: ${BRAND.textLight};
margin-top: 8px;
font-style: italic;
}
/* === ALLMÄNT === */
h3 {
font-size: 14pt;
color: ${BRAND.primary};
margin: 20px 0 10px 0;
}
h4 {
font-size: 12pt;
color: ${BRAND.secondary};
margin: 14px 0 8px 0;
}
p {
margin: 8px 0;
text-align: justify;
}
ul, ol {
margin: 10px 0 10px 24px;
}
li {
margin: 4px 0;
}
strong {
color: ${BRAND.primary};
}
.page-break {
page-break-after: always;
}
.two-column {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 20px;
}
.divider {
height: 2px;
background: linear-gradient(90deg, ${BRAND.primary}, ${BRAND.highlight});
margin: 20px 0;
border-radius: 1px;
}
`;
}
// === DOKUMENTKONTROLL-MODUL ===
function generateDocumentControl(report, docControl) {
const dc = docControl || {};
const versions = dc.versions || [{ version: '1.0', date: new Date().toISOString().split('T')[0], author: 'LandveX Intelligence', changes: 'Initial version' }];
return `
<div class="doc-control">
<h2>Dokumentkontroll / Document Control</h2>
<table class="control-table">
<tr>
<th style="width: 30%">Fält / Field</th>
<th>Värde / Value</th>
</tr>
<tr>
<td><strong>Dokumentreferens</strong><br><small>Document Reference</small></td>
<td>${dc.reference || report.sections.find(s => s.id === 'front_page')?.content?.reference || 'LVX-' + Date.now().toString(36).toUpperCase()}</td>
</tr>
<tr>
<td><strong>Version</strong><br><small>Version</small></td>
<td>${dc.version || '1.0'}</td>
</tr>
<tr>
<td><strong>Datum</strong><br><small>Date</small></td>
<td>${dc.date || new Date().toISOString().split('T')[0]}</td>
</tr>
<tr>
<td><strong>Beställare</strong><br><small>Client / Requestor</small></td>
<td>${dc.client || 'LandveX Kund'}</td>
</tr>
<tr>
<td><strong>Ansvarig analytiker</strong><br><small>Lead Analyst</small></td>
<td>${dc.analyst || 'Bernt (AI) / LandveX Intelligence'}</td>
</tr>
<tr>
<td><strong>Klassificering</strong><br><small>Classification</small></td>
<td><span class="risk-badge risk-critical">${dc.classification || 'Konfidentiell / Confidential'}</span></td>
</tr>
<tr>
<td><strong>Distribution</strong><br><small>Distribution</small></td>
<td>${dc.distribution || 'Kundintern / Client Internal Only'}</td>
</tr>
<tr>
<td><strong>Granskningsstatus</strong><br><small>Review Status</small></td>
<td>${dc.reviewStatus || 'Granskad / Reviewed'}</td>
</tr>
</table>
<div class="version-history">
<h3>Revisionshistorik / Revision History</h3>
<table class="control-table">
<tr>
<th>Version</th>
<th>Datum</th>
<th>Författare</th>
<th>Ändringar</th>
</tr>
${versions.map(v => `
<tr>
<td>${v.version}</td>
<td>${v.date}</td>
<td>${v.author}</td>
<td>${v.changes}</td>
</tr>
`).join('')}
</table>
</div>
</div>
`;
}
// === EXEKUTIV SAMMANFATTNING ===
function generateExecutiveSummary(section) {
const c = section.content;
return `
<div class="section">
<div class="section-header">
<div class="section-number">1</div>
<div>
<div class="section-title">Executive Summary</div>
<div class="section-subtitle">Sammanfattning för beslutsfattare</div>
</div>
</div>
<div class="kpi-grid">
<div class="kpi-box">
<div class="kpi-value">87%</div>
<div class="kpi-label">Confidence Score</div>
</div>
<div class="kpi-box highlight">
<div class="kpi-value">3</div>
<div class="kpi-label">Kritiska Risker</div>
</div>
<div class="kpi-box">
<div class="kpi-value">47</div>
<div class="kpi-label">Observationer</div>
</div>
</div>
<div class="info-box">
<div class="info-box-title">Vad vet vi? / What do we know?</div>
<p>${c.whatWeKnow}</p>
</div>
<div class="info-box success">
<div class="info-box-title">Hur säkert är det? / How confident are we?</div>
<p>${c.confidence}</p>
</div>
<div class="info-box warning">
<div class="info-box-title">Vad bör kunden göra? / What should the client do?</div>
<p>${c.recommendation}</p>
</div>
<div class="divider"></div>
<h3>Nyckelinsikter / Key Insights</h3>
<ul>
<li>Fasaden på norra sidan visar sprickbildning som kräver uppmärksamhet inom 612 månader</li>
<li>Takplåten har korrosion på 15% av ytan — risk för läckage vid kraftigt regn</li>
<li>Källarfukten (78% RF) indikerar potentiella strukturella problem</li>
<li>Total uppskattad budget för rekommenderade åtgärder: <strong>$50,00075,000</strong></li>
</ul>
</div>
`;
}
// === CONFIDENCE SCORE ===
function generateConfidenceSection(section) {
const c = section.content;
return `
<div class="section">
<div class="section-header">
<div class="section-number">3</div>
<div>
<div class="section-title">Confidence Score & Datakvalitet</div>
<div class="section-subtitle">Tillförlitlighetsbedömning per datakälla</div>
</div>
</div>
<div class="kpi-grid">
<div class="kpi-box highlight">
<div class="kpi-value">${Math.round(c.overall * 100)}%</div>
<div class="kpi-label">Total Confidence</div>
</div>
<div class="kpi-box">
<div class="kpi-value">${c.sources.length}</div>
<div class="kpi-label">Datakällor</div>
</div>
<div class="kpi-box">
<div class="kpi-value">63</div>
<div class="kpi-label">Datapunkter</div>
</div>
</div>
<div class="methodology-box">
<h4>Metodologi / Methodology</h4>
<p>${c.methodology}</p>
<ul>
<li>Varje datakälla bedöms på en skala 0100% baserat på verifierbarhet, ålder och känt felintervall</li>
<li>Vikter tilldelas efter datakällans betydelse för analysens slutsatser</li>
<li>Total confidence = viktad medelvärde av alla källor</li>
<li>Källor under 70% flaggas för mänsklig review</li>
</ul>
</div>
<h3>Datakällor / Data Sources</h3>
<table class="data-table">
<thead>
<tr>
<th>Källa</th>
<th>Score</th>
<th>Vikt</th>
<th>Viktat bidrag</th>
<th>Noteringar</th>
</tr>
</thead>
<tbody>
${c.sources.map(s => `
<tr>
<td><strong>${s.name}</strong></td>
<td>${Math.round(s.score * 100)}%</td>
<td>${s.weight}</td>
<td>${Math.round(s.score * s.weight * 100)}%</td>
<td>${s.notes}</td>
</tr>
`).join('')}
</tbody>
</table>
${c.sources.map(s => `
<div class="confidence-container">
<div class="confidence-header">
<span>${s.name}</span>
<span><strong>${Math.round(s.score * 100)}%</strong></span>
</div>
<div class="confidence-bar">
<div class="confidence-fill" style="width: ${s.score * 100}%"></div>
</div>
</div>
`).join('')}
</div>
`;
}
// === RISKMATRIS ===
function generateRiskMatrix(section) {
const c = section.content;
const levelOrder = { critical: 0, high: 1, medium: 2, low: 3 };
const sortedRisks = [...c.risks].sort((a, b) => levelOrder[a.level] - levelOrder[b.level]);
return `
<div class="section">
<div class="section-header">
<div class="section-number">8</div>
<div>
<div class="section-title">Riskmatris / Risk Matrix</div>
<div class="section-subtitle">Sannolikhet × Konsekvens</div>
</div>
</div>
<div class="kpi-grid">
<div class="kpi-box">
<div class="kpi-value" style="color: ${BRAND.danger}">${sortedRisks.filter(r => r.level === 'critical').length}</div>
<div class="kpi-label">Kritiska Risker</div>
</div>
<div class="kpi-box">
<div class="kpi-value" style="color: #fd7e14">${sortedRisks.filter(r => r.level === 'high').length}</div>
<div class="kpi-label">Höga Risker</div>
</div>
<div class="kpi-box">
<div class="kpi-value" style="color: ${BRAND.warning}">${sortedRisks.filter(r => r.level === 'medium').length}</div>
<div class="kpi-label">Medel Risker</div>
</div>
</div>
<h3>Riskbedömningar / Risk Assessments</h3>
${sortedRisks.map(r => `
<div class="info-box ${r.level === 'critical' ? 'danger' : r.level === 'high' ? 'warning' : ''}">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px;">
<strong style="font-size: 12pt;">${r.id}: ${r.description}</strong>
<span class="risk-badge risk-${r.level}">${r.level.toUpperCase()}</span>
</div>
<p><strong>Sannolikhet:</strong> ${r.probability} &nbsp;|&nbsp; <strong>Konsekvens:</strong> ${r.consequence}</p>
<p style="margin-top: 6px;"><strong>Åtgärd:</strong> ${r.mitigation}</p>
</div>
`).join('')}
<div class="methodology-box">
<h4>Metodologi / Methodology</h4>
<p>${c.methodology}</p>
<ul>
<li><strong>Kritisk:</strong> Omedelbar åtgärd krävs — risk för allvarliga konsekvenser</li>
<li><strong>Hög:</strong> Åtgärd inom 30 dagar — betydande påverkan på verksamhet eller säkerhet</li>
<li><strong>Medel:</strong> Planerad åtgärd inom 6 månader — moderat påverkan</li>
<li><strong>Låg:</strong> Övervakning — minimal påverkan, inga omedelbara åtgärder</li>
</ul>
</div>
</div>
`;
}
// === REKOMMENDATIONER ===
function generateRecommendations(section) {
const c = section.content;
return `
<div class="section">
<div class="section-header">
<div class="section-number">9</div>
<div>
<div class="section-title">Rekommendationer / Recommendations</div>
<div class="section-subtitle">Prioriterade åtgärder med tidslinje och budget</div>
</div>
</div>
<div class="kpi-grid">
<div class="kpi-box">
<div class="kpi-value">$75K</div>
<div class="kpi-label">Total Budget</div>
</div>
<div class="kpi-box">
<div class="kpi-value">Q3</div>
<div class="kpi-label">Tidsram</div>
</div>
<div class="kpi-box highlight">
<div class="kpi-value">2</div>
<div class="kpi-label">Akuta åtgärder</div>
</div>
</div>
<h3>Åtgärdsplan / Action Plan</h3>
${c.map(r => `
<div class="recommendation">
<div class="rec-priority">${r.priority}</div>
<div class="rec-content">
<h4>${r.action}</h4>
<div class="rec-meta">
📅 ${r.timeline} &nbsp;|&nbsp; 👤 ${r.owner} &nbsp;|&nbsp; 💰 ${r.budget}
</div>
</div>
</div>
`).join('')}
<div class="info-box">
<div class="info-box-title">Notering / Note</div>
<p>Budgetuppskattningarna är preliminära och baserade på branschstandard. För exakta kostnader rekommenderas att inhämta offerter från kvalificerade entreprenörer. Tidslinjerna förutsätter normala väderförhållanden och tillgänglighet till fastigheten.</p>
</div>
</div>
`;
}
// === ANALYS ===
function generateAnalysisSection(section) {
const c = section.content;
return `
<div class="section">
<div class="section-header">
<div class="section-number">5</div>
<div>
<div class="section-title">Analys / Analysis</div>
<div class="section-subtitle">Huvudsakliga fynd och bedömningar</div>
</div>
</div>
<div class="methodology-box">
<h4>Metodologi / Methodology</h4>
<p>${c.methodology}</p>
</div>
<h3>Fynd / Findings</h3>
<table class="data-table">
<thead>
<tr>
<th>Område</th>
<th>Fynd</th>
<th>Allvarlighet</th>
<th>Bevis</th>
</tr>
</thead>
<tbody>
${c.findings.map(f => `
<tr>
<td><strong>${f.area}</strong></td>
<td>${f.finding}</td>
<td><span class="risk-badge risk-${f.severity}">${f.severity.toUpperCase()}</span></td>
<td>${f.evidence} observationer</td>
</tr>
`).join('')}
</tbody>
</table>
<h3>Detaljerad bedömning per område</h3>
${c.findings.map(f => `
<div class="info-box ${f.severity === 'high' ? 'danger' : f.severity === 'medium' ? 'warning' : 'success'}">
<div class="info-box-title">${f.area}</div>
<p><strong>Fynd:</strong> ${f.finding}</p>
<p><strong>Allvarlighetsgrad:</strong> <span class="risk-badge risk-${f.severity}">${f.severity.toUpperCase()}</span></p>
<p><strong>Underlag:</strong> ${f.evidence} observationer med GPS-koordinater och bilddokumentation</p>
</div>
`).join('')}
</div>
`;
}
// === DATAKÄLLOR ===
function generateDataSourcesSection(section) {
const c = section.content;
return `
<div class="section">
<div class="section-header">
<div class="section-number">4</div>
<div>
<div class="section-title">Datakällor / Data Sources</div>
<div class="section-subtitle">Komplett förteckning över använda datakällor</div>
</div>
</div>
<table class="data-table">
<thead>
<tr>
<th>Källa</th>
<th>Typ</th>
<th>Datum</th>
<th>Metod</th>
<th>Begränsningar</th>
</tr>
</thead>
<tbody>
${c.map(s => `
<tr>
<td><strong>${s.name}</strong></td>
<td>${s.type}</td>
<td>${s.date}</td>
<td>${s.method}</td>
<td>${s.limitations}</td>
</tr>
`).join('')}
</tbody>
</table>
<div class="methodology-box">
<h4>Källkritik / Source Criticism</h4>
<p>Alla datakällor har utvärderats enligt följande kriterier:</p>
<ul>
<li><strong>Autenticitet:</strong> Är källan den den utger sig för att vara?</li>
<li><strong>Tidsbundenhet:</strong> Hur aktuell är informationen?</li>
<li><strong>Tendens:</strong> Finns det någon partiskhet i källan?</li>
<li><strong>Beroende:</strong> Är källan primär eller sekundär?</li>
</ul>
</div>
</div>
`;
}
// === OBJEKTÖVERSIKT ===
function generateObjectOverview(section) {
const c = section.content;
return `
<div class="section">
<div class="section-header">
<div class="section-number">5</div>
<div>
<div class="section-title">Objektöversikt / Object Overview</div>
<div class="section-subtitle">Beskrivning och metadata</div>
</div>
</div>
<div class="two-column">
<div>
<h4>Beskrivning</h4>
<p>${c.description}</p>
</div>
<div>
<h4>Koordinater</h4>
<p><strong>Lat:</strong> ${c.coordinates.lat}<br>
<strong>Lng:</strong> ${c.coordinates.lng}</p>
<h4 style="margin-top: 16px;">Inspektionsdata</h4>
<p><strong>Bilder:</strong> ${c.images}<br>
<strong>Senaste inspektion:</strong> ${c.lastInspection}</p>
</div>
</div>
</div>
`;
}
// === OBSERVATIONER ===
function generateObservationsSection(section) {
const c = section.content;
return `
<div class="section">
<div class="section-header">
<div class="section-number">6</div>
<div>
<div class="section-title">Observationer / Observations</div>
<div class="section-subtitle">Fältinsamlade data med GPS och bilder</div>
</div>
</div>
<table class="data-table">
<thead>
<tr>
<th>ID</th>
<th>Datum</th>
<th>Typ</th>
<th>GPS</th>
<th>Beskrivning</th>
</tr>
</thead>
<tbody>
${c.map(o => `
<tr>
<td><span class="appendix-code">${o.id}</span></td>
<td>${o.date}</td>
<td>${o.type}</td>
<td><code>${o.gps}</code></td>
<td>${o.description}</td>
</tr>
`).join('')}
</tbody>
</table>
</div>
`;
}
// === MÖJLIGHETER ===
function generateOpportunitiesSection(section) {
const c = section.content;
return `
<div class="section">
<div class="section-header">
<div class="section-number">7</div>
<div>
<div class="section-title">Möjligheter / Opportunities</div>
<div class="section-subtitle">Potentiella åtgärder och förbättringar</div>
</div>
</div>
<div class="kpi-grid">
<div class="kpi-box">
<div class="kpi-value">$45K</div>
<div class="kpi-label">Total Potential</div>
</div>
<div class="kpi-box">
<div class="kpi-value">20%</div>
<div class="kpi-label">Energibesparing</div>
</div>
<div class="kpi-box">
<div class="kpi-value">35 år</div>
<div class="kpi-label">Payback-tid</div>
</div>
</div>
${c.map(o => `
<div class="info-box ${o.priority === 'high' ? 'success' : ''}">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 6px;">
<strong>${o.action}</strong>
<span class="risk-badge ${o.priority === 'high' ? 'risk-low' : 'risk-medium'}">${o.priority.toUpperCase()}</span>
</div>
<p><strong>Nytta:</strong> ${o.benefit}</p>
<p><strong>Uppskattat värde:</strong> ${o.estimatedValue}</p>
</div>
`).join('')}
</div>
`;
}
// === SCOPE ===
function generateScopeSection(section) {
const c = section.content;
return `
<div class="section">
<div class="section-header">
<div class="section-number">2</div>
<div>
<div class="section-title">Scope & Avgränsning</div>
<div class="section-subtitle">Vad ingår och vad ingår inte</div>
</div>
</div>
<div class="two-column">
<div>
<h4 style="color: ${BRAND.success}">✓ Ingår / Included</h4>
<ul>
${c.included.map(i => `<li>${i}</li>`).join('')}
</ul>
</div>
<div>
<h4 style="color: ${BRAND.danger}">✗ Ingår inte / Excluded</h4>
<ul>
${c.excluded.map(i => `<li>${i}</li>`).join('')}
</ul>
</div>
</div>
<div class="info-box warning">
<div class="info-box-title">Viktigt / Important</div>
<p>Denna rapport baseras på icke-invasiv visuell inspektion. Dolda skador eller brister kan inte identifieras utan mer omfattande tekniska undersökningar. Rekommendationerna ska ses som vägledande och inte som ersättning för professionella konsulters bedömningar.</p>
</div>
</div>
`;
}
// === APPENDIX ===
function generateAppendixSection(section) {
const c = section.content;
return `
<div class="section">
<div class="section-header">
<div class="section-number">A</div>
<div>
<div class="section-title">Appendix & Bilagor</div>
<div class="section-subtitle">Rådata, sensorvärden och teknisk dokumentation</div>
</div>
</div>
<div class="kpi-grid">
<div class="kpi-box">
<div class="kpi-value">${c.dataFiles.length}</div>
<div class="kpi-label">Datafiler</div>
</div>
<div class="kpi-box">
<div class="kpi-value">~2MB</div>
<div class="kpi-label">Total storlek</div>
</div>
<div class="kpi-box">
<div class="kpi-value">${c.version}</div>
<div class="kpi-label">Protokollversion</div>
</div>
</div>
<h3>Datafiler / Data Files</h3>
${c.dataFiles.map(f => `
<div class="appendix-item">
<h4>${f.name}</h4>
<div class="appendix-meta">
<span class="appendix-code">${f.type}</span>
<span>📦 ${f.size}</span>
</div>
<p>${f.description}</p>
</div>
`).join('')}
<div class="methodology-box">
<h4>Reproducerbarhet / Reproducibility</h4>
<p>${c.reproducibilityNote}</p>
<ul>
<li><strong>Protokoll:</strong> ${c.methodology}</li>
<li><strong>Version:</strong> ${c.version}</li>
<li><strong>Genererad:</strong> ${c.generatedAt}</li>
</ul>
</div>
</div>
`;
}
// === KÄLLFÖRTECKNING ===
function generateSourceReferences(report) {
const dataSources = report.sections.find(s => s.id === 'data_sources')?.content || [];
return `
<div class="section">
<div class="section-header">
<div class="section-number">B</div>
<div>
<div class="section-title">Källförteckning / Source References</div>
<div class="section-subtitle">Komplett referenslista för reproducerbarhet</div>
</div>
</div>
${dataSources.map((s, i) => `
<div class="source-item">
<div class="source-name">[${i + 1}] ${s.name}</div>
<div class="source-meta">
Typ: ${s.type} &nbsp;|&nbsp;
Datum: ${s.date} &nbsp;|&nbsp;
Metod: ${s.method}
</div>
<p style="margin-top: 4px; font-size: 9pt; color: ${BRAND.textLight};">
Begränsningar: ${s.limitations}
</p>
</div>
`).join('')}
<div class="info-box" style="margin-top: 20px;">
<div class="info-box-title">Reproducerbarhetsdeklaration</div>
<p>Denna rapport kan reproduceras med samma datainsamling och samma analystillvägagångssätt. Alla datakällor, metoder och AI-prompts är dokumenterade och spårbara. Kontakta LandveX för replikering eller vidare analys.</p>
</div>
</div>
`;
}
// === HUVUDRENDERARE ===
function generateReportHTML(report, options = {}) {
const docControl = options.documentControl || {};
const frontPage = report.sections.find(s => s.id === 'front_page')?.content || {};
// Bygg sektioner i rätt ordning
const sectionBuilders = {
front_page: () => '', // Hanteras separat som omslag
executive_summary: (s) => generateExecutiveSummary(s),
scope: (s) => generateScopeSection(s),
confidence: (s) => generateConfidenceSection(s),
data_sources: (s) => generateDataSourcesSection(s),
object_overview: (s) => generateObjectOverview(s),
analysis: (s) => generateAnalysisSection(s),
observations: (s) => generateObservationsSection(s),
opportunities: (s) => generateOpportunitiesSection(s),
risk_matrix: (s) => generateRiskMatrix(s),
recommendations: (s) => generateRecommendations(s),
appendix: (s) => generateAppendixSection(s)
};
const sectionsHTML = report.sections
.filter(s => s.id !== 'front_page')
.map(s => {
const builder = sectionBuilders[s.id];
return builder ? builder(s) : `
<div class="section">
<div class="section-header">
<div class="section-number">?</div>
<div>
<div class="section-title">${s.name}</div>
</div>
</div>
<pre style="background: #f8f9fa; padding: 12px; border-radius: 4px; font-size: 10pt;">${JSON.stringify(s.content, null, 2)}</pre>
</div>
`;
})
.join('');
return `
<!DOCTYPE html>
<html lang="sv">
<head>
<meta charset="UTF-8">
<title>${frontPage.title || report.productName}${report.objectName}</title>
<style>
${getReportCSS()}
</style>
</head>
<body>
<!-- Konfidentiell vattenstämpel -->
<div class="watermark">KONFIDENTIELL</div>
<!-- Framsida -->
<div class="cover-page">
<div>
<div class="cover-header">
<div class="cover-logo">Landve<span>X</span></div>
<div class="cover-tagline">Intelligence &amp; Infrastructure Analytics</div>
</div>
<div class="cover-title">
<h1>${frontPage.title || report.productName}</h1>
<div class="subtitle">${report.objectName}</div>
</div>
<div class="cover-object">
<h2>Objekt / Object</h2>
<div class="object-name">${report.objectName}</div>
</div>
</div>
<div class="cover-footer">
<div class="cover-date">
<strong>Datum / Date:</strong> ${frontPage.date || new Date().toISOString().split('T')[0]}<br>
<strong>Referens / Reference:</strong> ${frontPage.reference || docControl.reference || 'LVX-' + Date.now().toString(36).toUpperCase()}<br>
<strong>Produkt / Product:</strong> ${report.productName}
</div>
<div class="cover-classification">${frontPage.classification || 'Konfidentiell'}</div>
</div>
</div>
<!-- Dokumentkontroll -->
${generateDocumentControl(report, docControl)}
<!-- Innehållsförteckning -->
<div class="section">
<div class="section-header">
<div class="section-number">0</div>
<div>
<div class="section-title">Innehållsförteckning / Table of Contents</div>
</div>
</div>
<table class="data-table">
<thead>
<tr>
<th style="width: 10%">#</th>
<th>Sektion</th>
<th style="width: 30%">Beskrivning</th>
</tr>
</thead>
<tbody>
${report.sections.filter(s => s.id !== 'front_page').map((s, i) => `
<tr>
<td>${i + 1}</td>
<td><strong>${s.name}</strong></td>
<td>${s.description}</td>
</tr>
`).join('')}
<tr>
<td>A</td>
<td><strong>Appendix & Bilagor</strong></td>
<td>Rådata och teknisk dokumentation</td>
</tr>
<tr>
<td>B</td>
<td><strong>Källförteckning</strong></td>
<td>Komplett referenslista</td>
</tr>
</tbody>
</table>
</div>
<!-- Rapportsektioner -->
${sectionsHTML}
<!-- Källförteckning -->
${generateSourceReferences(report)}
</body>
</html>
`;
}
// === PUBLIK API ===
/**
* Generera PDF från rapportdata
*/
export async function generatePDF(report, options = {}) {
const browser = await puppeteer.launch({
headless: 'new',
args: ['--no-sandbox', '--disable-setuid-sandbox', '--disable-dev-shm-usage']
});
try {
const page = await browser.newPage();
const html = generateReportHTML(report, options);
await page.setContent(html, {
waitUntil: ['networkidle0', 'domcontentloaded'],
timeout: 30000
});
// Vänta på att typsnitt och bilder ska laddas
await page.waitForTimeout(1000);
const pdfBuffer = await page.pdf({
format: 'A4',
printBackground: true,
preferCSSPageSize: true,
displayHeaderFooter: true,
headerTemplate: `
<div style="font-size: 8pt; color: #999; width: 100%; padding: 0 20mm; display: flex; justify-content: space-between;">
<span>LandveX Intelligence</span>
<span>${report.productName}</span>
</div>
`,
footerTemplate: `
<div style="font-size: 8pt; color: #999; width: 100%; padding: 0 20mm; display: flex; justify-content: space-between;">
<span>Konfidentiell — ${report.objectName}</span>
<span class="pageNumber"></span> / <span class="totalPages"></span>
</div>
`,
margin: {
top: '20mm',
right: '20mm',
bottom: '25mm',
left: '20mm'
}
});
return {
buffer: pdfBuffer,
html: html,
pageCount: null // Kan beräknas om nödvändigt
};
} finally {
await browser.close();
}
}
/**
* Generera HTML-förhandsvisning (utan PDF)
*/
export function generateHTML(report, options = {}) {
return generateReportHTML(report, options);
}
/**
* Hjälpfunktion för att skapa dokumentkontroll-objekt
*/
export function createDocumentControl(options = {}) {
return {
reference: options.reference || `LVX-${Date.now().toString(36).toUpperCase()}`,
version: options.version || '1.0',
date: options.date || new Date().toISOString().split('T')[0],
client: options.client || 'LandveX Kund',
analyst: options.analyst || 'Bernt (AI) / LandveX Intelligence',
classification: options.classification || 'Konfidentiell / Confidential',
distribution: options.distribution || 'Kundintern / Client Internal Only',
reviewStatus: options.reviewStatus || 'Granskad / Reviewed',
versions: options.versions || [{
version: '1.0',
date: new Date().toISOString().split('T')[0],
author: 'LandveX Intelligence',
changes: 'Initial version'
}]
};
}
export default {
generatePDF,
generateHTML,
createDocumentControl
};
@@ -0,0 +1,724 @@
/**
* PDFKit Generator — Professionell PDF-export utan Chromium-beroende
* Använder PDFKit (native Node.js) för att generera PDF med LandveX-branding
*/
import PDFDocument from 'pdfkit';
// === LANDVEX BRAND COLORS ===
const BRAND = {
primary: '#1a1a2e',
secondary: '#16213e',
accent: '#0f3460',
highlight: '#e94560',
text: '#333333',
textLight: '#666666',
border: '#cccccc',
background: '#f5f5f5',
white: '#ffffff',
success: '#28a745',
warning: '#ffc107',
danger: '#dc3545',
info: '#17a2b8'
};
// === HJÄLPFUNKTIONER ===
function hexToRgb(hex) {
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
return result ? [
parseInt(result[1], 16) / 255,
parseInt(result[2], 16) / 255,
parseInt(result[3], 16) / 255
] : [0, 0, 0];
}
function color(doc, hex) {
const [r, g, b] = hexToRgb(hex);
return doc.fillColor(r, g, b);
}
function drawRect(doc, x, y, w, h, fillHex, strokeHex) {
const [fr, fg, fb] = hexToRgb(fillHex);
if (strokeHex) {
const [sr, sg, sb] = hexToRgb(strokeHex);
doc.rect(x, y, w, h).fillAndStroke([fr, fg, fb], [sr, sg, sb]);
} else {
doc.rect(x, y, w, h).fill([fr, fg, fb]);
}
}
function drawLine(doc, x1, y1, x2, y2, hex = BRAND.border, width = 1) {
const [r, g, b] = hexToRgb(hex);
doc.moveTo(x1, y1).lineTo(x2, y2).strokeColor([r, g, b]).lineWidth(width).stroke();
}
// === FRAMSIDA ===
function drawCoverPage(doc, report, docControl) {
const pageW = doc.page.width;
const pageH = doc.page.height;
const margin = 50;
// Bakgrundsfärg (subtil)
drawRect(doc, 0, 0, pageW, pageH, BRAND.white);
// Header-linje
drawRect(doc, margin, 60, pageW - margin * 2, 4, BRAND.primary);
// Logo
color(doc, BRAND.primary);
doc.fontSize(28).font('Helvetica-Bold');
doc.text('Landve', margin, 90);
const landveWidth = doc.widthOfString('Landve');
color(doc, BRAND.highlight);
doc.text('X', margin + landveWidth, 90);
color(doc, BRAND.textLight);
doc.fontSize(10).font('Helvetica');
doc.text('INTELLIGENCE & INFRASTRUCTURE ANALYTICS', margin, 125);
// Titel
const fp = report.sections.find(s => s.id === 'front_page')?.content || {};
color(doc, BRAND.primary);
doc.fontSize(28).font('Helvetica-Bold');
doc.text(fp.title || report.productName, margin, 200);
color(doc, BRAND.textLight);
doc.fontSize(16).font('Helvetica');
doc.text(report.objectName, margin, 240);
// Objekt-box
drawRect(doc, margin, 300, pageW - margin * 2, 80, BRAND.background);
color(doc, BRAND.textLight);
doc.fontSize(10).font('Helvetica');
doc.text('OBJEKT / OBJECT', margin + 15, 315);
color(doc, BRAND.primary);
doc.fontSize(16).font('Helvetica-Bold');
doc.text(report.objectName, margin + 15, 335);
// Footer
const footerY = pageH - 100;
drawLine(doc, margin, footerY, pageW - margin, footerY, BRAND.border);
color(doc, BRAND.textLight);
doc.fontSize(10).font('Helvetica');
doc.text(`Datum / Date: ${fp.date || new Date().toISOString().split('T')[0]}`, margin, footerY + 15);
doc.text(`Referens / Reference: ${fp.reference || docControl?.reference || '—'}`, margin, footerY + 30);
doc.text(`Produkt / Product: ${report.productName}`, margin, footerY + 45);
// Klassificering-badge
const classification = fp.classification || docControl?.classification || 'Konfidentiell';
const badgeWidth = doc.widthOfString(classification) + 30;
drawRect(doc, pageW - margin - badgeWidth, footerY + 15, badgeWidth, 30, BRAND.danger);
color(doc, BRAND.white);
doc.fontSize(10).font('Helvetica-Bold');
doc.text(classification, pageW - margin - badgeWidth + 15, footerY + 23);
// Vattenstämpel (subtil)
doc.save();
doc.rotate(-45, { origin: [pageW / 2, pageH / 2] });
color(doc, 'rgba(233, 69, 96, 0.08)');
doc.fontSize(60).font('Helvetica-Bold');
doc.text('KONFIDENTIELL', 0, pageH / 2, { align: 'center', width: pageW });
doc.restore();
doc.addPage();
}
// === DOKUMENTKONTROLL ===
function drawDocumentControl(doc, docControl) {
const margin = 50;
const pageW = doc.page.width;
color(doc, BRAND.primary);
doc.fontSize(18).font('Helvetica-Bold');
doc.text('Dokumentkontroll / Document Control', margin, 50);
drawLine(doc, margin, 75, pageW - margin, 75, BRAND.primary, 2);
const dc = docControl || {};
const fields = [
['Dokumentreferens / Document Reference', dc.reference || '—'],
['Version', dc.version || '1.0'],
['Datum / Date', dc.date || new Date().toISOString().split('T')[0]],
['Beställare / Client', dc.client || '—'],
['Ansvarig analytiker / Lead Analyst', dc.analyst || 'Bernt (AI) / LandveX Intelligence'],
['Klassificering / Classification', dc.classification || 'Konfidentiell / Confidential'],
['Distribution', dc.distribution || 'Kundintern / Client Internal Only'],
['Granskningsstatus / Review Status', dc.reviewStatus || 'Granskad / Reviewed']
];
let y = 100;
const rowHeight = 30;
const colWidth = (pageW - margin * 2) / 2;
// Header-rad
drawRect(doc, margin, y, pageW - margin * 2, rowHeight, BRAND.primary);
color(doc, BRAND.white);
doc.fontSize(9).font('Helvetica-Bold');
doc.text('FÄLT / FIELD', margin + 10, y + 10);
doc.text('VÄRDE / VALUE', margin + colWidth + 10, y + 10);
y += rowHeight;
// Data-rader
fields.forEach(([field, value], i) => {
const bg = i % 2 === 0 ? BRAND.white : BRAND.background;
drawRect(doc, margin, y, pageW - margin * 2, rowHeight, bg);
drawLine(doc, margin, y + rowHeight, pageW - margin, y + rowHeight, BRAND.border, 0.5);
color(doc, BRAND.text);
doc.fontSize(9).font('Helvetica');
doc.text(field, margin + 10, y + 10);
if (field.includes('Klassificering')) {
const badgeW = doc.widthOfString(value) + 20;
drawRect(doc, margin + colWidth + 10, y + 5, badgeW, 20, BRAND.danger);
color(doc, BRAND.white);
doc.fontSize(8).font('Helvetica-Bold');
doc.text(value, margin + colWidth + 20, y + 10);
} else {
color(doc, BRAND.text);
doc.fontSize(9).font('Helvetica');
doc.text(value, margin + colWidth + 10, y + 10);
}
y += rowHeight;
});
// Versionshistorik
y += 30;
color(doc, BRAND.primary);
doc.fontSize(14).font('Helvetica-Bold');
doc.text('Revisionshistorik / Revision History', margin, y);
y += 30;
drawRect(doc, margin, y, pageW - margin * 2, rowHeight, BRAND.primary);
color(doc, BRAND.white);
doc.fontSize(9).font('Helvetica-Bold');
doc.text('VERSION', margin + 10, y + 10);
doc.text('DATUM', margin + 100, y + 10);
doc.text('FÖRFATTARE', margin + 200, y + 10);
doc.text('ÄNDRINGAR', margin + 350, y + 10);
y += rowHeight;
const versions = dc.versions || [{ version: '1.0', date: new Date().toISOString().split('T')[0], author: 'LandveX Intelligence', changes: 'Initial version' }];
versions.forEach((v, i) => {
const bg = i % 2 === 0 ? BRAND.white : BRAND.background;
drawRect(doc, margin, y, pageW - margin * 2, rowHeight, bg);
drawLine(doc, margin, y + rowHeight, pageW - margin, y + rowHeight, BRAND.border, 0.5);
color(doc, BRAND.text);
doc.fontSize(9).font('Helvetica');
doc.text(v.version, margin + 10, y + 10);
doc.text(v.date, margin + 100, y + 10);
doc.text(v.author, margin + 200, y + 10);
doc.text(v.changes, margin + 350, y + 10);
y += rowHeight;
});
doc.addPage();
}
// === KPI-RUTOR ===
function drawKPIBox(doc, x, y, w, h, value, label, isHighlight = false) {
const bg = isHighlight ? BRAND.primary : BRAND.white;
const textColor = isHighlight ? BRAND.white : BRAND.primary;
const labelColor = isHighlight ? 'rgba(255,255,255,0.8)' : BRAND.textLight;
drawRect(doc, x, y, w, h, bg);
doc.rect(x, y, w, h).strokeColor(hexToRgb(BRAND.border)).lineWidth(1).stroke();
color(doc, textColor);
doc.fontSize(24).font('Helvetica-Bold');
doc.text(value, x + w / 2, y + 20, { align: 'center', width: w });
color(doc, labelColor);
doc.fontSize(8).font('Helvetica');
doc.text(label.toUpperCase(), x + w / 2, y + 55, { align: 'center', width: w });
}
// === SEKTIONSHUVUD ===
function drawSectionHeader(doc, number, title, subtitle) {
const margin = 50;
const pageW = doc.page.width;
// Sektionsnummer-box
drawRect(doc, margin, doc.y, 30, 30, BRAND.primary);
color(doc, BRAND.white);
doc.fontSize(14).font('Helvetica-Bold');
doc.text(String(number), margin + 10, doc.y + 8);
// Titel
color(doc, BRAND.primary);
doc.fontSize(16).font('Helvetica-Bold');
doc.text(title, margin + 40, doc.y - 22);
// Undertitel
if (subtitle) {
color(doc, BRAND.textLight);
doc.fontSize(9).font('Helvetica');
doc.text(subtitle, margin + 40, doc.y + 5);
}
// Linje
const lineY = doc.y + 15;
drawLine(doc, margin, lineY, pageW - margin, lineY, BRAND.primary, 2);
doc.y = lineY + 20;
}
// === INFOBOX ===
function drawInfoBox(doc, title, text, type = 'default') {
const margin = 50;
const pageW = doc.page.width;
const boxW = pageW - margin * 2;
const colors = {
default: { border: BRAND.accent, bg: '#f8f9fa' },
warning: { border: BRAND.warning, bg: '#fffbeb' },
danger: { border: BRAND.danger, bg: '#fef2f2' },
success: { border: BRAND.success, bg: '#f0fdf4' }
};
const c = colors[type] || colors.default;
// Vänster border
drawRect(doc, margin, doc.y, 4, 60, c.border);
// Bakgrund
drawRect(doc, margin + 4, doc.y, boxW - 4, 60, c.bg);
color(doc, BRAND.primary);
doc.fontSize(9).font('Helvetica-Bold');
doc.text(title.toUpperCase(), margin + 15, doc.y + 10);
color(doc, BRAND.text);
doc.fontSize(10).font('Helvetica');
doc.text(text, margin + 15, doc.y + 5, { width: boxW - 30 });
doc.y += 20;
}
// === RISK BADGE ===
function drawRiskBadge(doc, x, y, level) {
const labels = { critical: 'KRITISK', high: 'HÖG', medium: 'MEDEL', low: 'LÅG' };
const colors = {
critical: BRAND.danger,
high: '#fd7e14',
medium: BRAND.warning,
low: BRAND.success
};
const label = labels[level] || level.toUpperCase();
const colorHex = colors[level] || BRAND.gray;
doc.fontSize(8).font('Helvetica-Bold');
const badgeW = doc.widthOfString(label) + 16;
drawRect(doc, x, y, badgeW, 18, colorHex);
color(doc, BRAND.white);
doc.text(label, x + 8, y + 5);
return badgeW;
}
// === TABELL ===
function drawTable(doc, headers, rows, colWidths) {
const margin = 50;
const pageW = doc.page.width;
const rowHeight = 22;
// Header
drawRect(doc, margin, doc.y, pageW - margin * 2, rowHeight, BRAND.primary);
color(doc, BRAND.white);
doc.fontSize(8).font('Helvetica-Bold');
let x = margin + 10;
headers.forEach((h, i) => {
doc.text(h, x, doc.y + 7);
x += colWidths[i];
});
doc.y += rowHeight;
// Rader
rows.forEach((row, i) => {
const bg = i % 2 === 0 ? BRAND.white : BRAND.background;
drawRect(doc, margin, doc.y, pageW - margin * 2, rowHeight, bg);
drawLine(doc, margin, doc.y + rowHeight, pageW - margin, doc.y + rowHeight, BRAND.border, 0.5);
color(doc, BRAND.text);
doc.fontSize(8).font('Helvetica');
x = margin + 10;
row.forEach((cell, j) => {
doc.text(String(cell), x, doc.y + 7);
x += colWidths[j];
});
doc.y += rowHeight;
});
doc.y += 10;
}
// === HUVUDRENDERARE ===
export async function generatePDFKit(report, options = {}) {
return new Promise((resolve, reject) => {
try {
const doc = new PDFDocument({ margin: 50, size: 'A4' });
const chunks = [];
doc.on('data', chunk => chunks.push(chunk));
doc.on('end', () => {
const buffer = Buffer.concat(chunks);
resolve({ buffer, pageCount: doc._pageCount || 1 });
});
const docControl = options.documentControl || {};
// === FRAMSIDA ===
drawCoverPage(doc, report, docControl);
// === DOKUMENTKONTROLL ===
drawDocumentControl(doc, docControl);
// === INNEHÅLLSFÖRTECKNING ===
doc.y = 50;
drawSectionHeader(doc, '0', 'Innehållsförteckning / Table of Contents', '');
const tocSections = report.sections.filter(s => s.id !== 'front_page');
const tocRows = tocSections.map((s, i) => [String(i + 1), s.name, s.description]);
drawTable(doc,
['#', 'SEKTION', 'BESKRIVNING'],
tocRows,
[40, 200, 250]
);
doc.addPage();
// === RAPPORTSEKTIONER ===
let sectionNum = 1;
for (const section of report.sections) {
if (section.id === 'front_page') continue;
const c = section.content;
// Ny sida för varje sektion
if (sectionNum > 1) doc.addPage();
doc.y = 50;
switch (section.id) {
case 'executive_summary': {
drawSectionHeader(doc, sectionNum, section.name, 'Sammanfattning för beslutsfattare');
// KPI-rutor
drawKPIBox(doc, 50, doc.y, 150, 80, '87%', 'Confidence Score');
drawKPIBox(doc, 210, doc.y, 150, 80, '3', 'Kritiska Risker', true);
drawKPIBox(doc, 370, doc.y, 150, 80, '47', 'Observationer');
doc.y += 100;
drawInfoBox(doc, 'Vad vet vi? / What do we know?', c.whatWeKnow);
drawInfoBox(doc, 'Hur säkert är det? / How confident are we?', c.confidence, 'success');
drawInfoBox(doc, 'Vad bör kunden göra? / What should the client do?', c.recommendation, 'warning');
break;
}
case 'scope': {
drawSectionHeader(doc, sectionNum, section.name, 'Vad ingår och vad ingår inte');
const pageW = doc.page.width;
const halfW = (pageW - 120) / 2;
// Included
drawRect(doc, 50, doc.y, halfW, 150, '#f0fdf4');
color(doc, BRAND.success);
doc.fontSize(12).font('Helvetica-Bold');
doc.text('✓ Ingår / Included', 60, doc.y + 10);
color(doc, BRAND.text);
doc.fontSize(9).font('Helvetica');
c.included.forEach((item, i) => {
doc.text(`${item}`, 60, doc.y + 5 + (i * 14));
});
// Excluded
drawRect(doc, 70 + halfW, doc.y - 150, halfW, 150, '#fef2f2');
color(doc, BRAND.danger);
doc.fontSize(12).font('Helvetica-Bold');
doc.text('✗ Ingår inte / Excluded', 80 + halfW, doc.y - 140);
color(doc, BRAND.text);
doc.fontSize(9).font('Helvetica');
c.excluded.forEach((item, i) => {
doc.text(`${item}`, 80 + halfW, doc.y - 125 + (i * 14));
});
doc.y += 20;
drawInfoBox(doc, 'Viktigt / Important',
'Denna rapport baseras på icke-invasiv visuell inspektion. Dolda skador eller brister kan inte identifieras utan mer omfattande tekniska undersökningar.',
'warning');
break;
}
case 'confidence': {
drawSectionHeader(doc, sectionNum, section.name, 'Tillförlitlighetsbedömning per datakälla');
drawKPIBox(doc, 50, doc.y, 150, 80, `${Math.round(c.overall * 100)}%`, 'Total Confidence', true);
drawKPIBox(doc, 210, doc.y, 150, 80, String(c.sources.length), 'Datakällor');
drawKPIBox(doc, 370, doc.y, 150, 80, '63', 'Datapunkter');
doc.y += 100;
drawInfoBox(doc, 'Metodologi / Methodology', c.methodology);
drawTable(doc,
['KÄLLA', 'SCORE', 'VIKT', 'VIKTAT BIDRAG', 'NOTERINGAR'],
c.sources.map(s => [
s.name,
`${Math.round(s.score * 100)}%`,
String(s.weight),
`${Math.round(s.score * s.weight * 100)}%`,
s.notes
]),
[150, 60, 50, 80, 150]
);
break;
}
case 'data_sources': {
drawSectionHeader(doc, sectionNum, section.name, 'Komplett förteckning över använda datakällor');
drawTable(doc,
['KÄLLA', 'TYP', 'DATUM', 'METOD', 'BEGRÄNSNINGAR'],
c.map(s => [s.name, s.type, s.date, s.method, s.limitations]),
[130, 100, 70, 110, 120]
);
drawInfoBox(doc, 'Källkritik / Source Criticism',
'Alla datakällor har utvärderats enligt kriterierna: Autenticitet, Tidsbundenhet, Tendens och Beroende.',
'default');
break;
}
case 'object_overview': {
drawSectionHeader(doc, sectionNum, section.name, 'Beskrivning och metadata');
color(doc, BRAND.text);
doc.fontSize(10).font('Helvetica');
doc.text(c.description, 50, doc.y, { width: 500 });
doc.y += 20;
drawInfoBox(doc, 'Koordinater / Coordinates',
`Lat: ${c.coordinates.lat}, Lng: ${c.coordinates.lng}`);
drawInfoBox(doc, 'Inspektionsdata / Inspection Data',
`Bilder: ${c.images} | Senaste inspektion: ${c.lastInspection}`);
break;
}
case 'analysis': {
drawSectionHeader(doc, sectionNum, section.name, 'Huvudsakliga fynd och bedömningar');
drawInfoBox(doc, 'Metodologi / Methodology', c.methodology);
drawTable(doc,
['OMRÅDE', 'FYND', 'ALLVARLIGHET', 'BEVIS'],
c.findings.map(f => [
f.area,
f.finding,
f.severity.toUpperCase(),
`${f.evidence} observationer`
]),
[100, 200, 80, 110]
);
break;
}
case 'observations': {
drawSectionHeader(doc, sectionNum, section.name, 'Fältinsamlade data med GPS och bilder');
drawTable(doc,
['ID', 'DATUM', 'TYP', 'GPS', 'BESKRIVNING'],
c.map(o => [o.id, o.date, o.type, o.gps, o.description]),
[60, 70, 50, 100, 200]
);
break;
}
case 'opportunities': {
drawSectionHeader(doc, sectionNum, section.name, 'Potentiella åtgärder och förbättringar');
drawKPIBox(doc, 50, doc.y, 150, 80, '$45K', 'Total Potential');
drawKPIBox(doc, 210, doc.y, 150, 80, '20%', 'Energibesparing');
drawKPIBox(doc, 370, doc.y, 150, 80, '35 år', 'Payback-tid');
doc.y += 100;
c.forEach(o => {
drawInfoBox(doc, `${o.action} (${o.priority.toUpperCase()})`,
`Nytta: ${o.benefit} | Uppskattat värde: ${o.estimatedValue}`,
o.priority === 'high' ? 'success' : 'default');
});
break;
}
case 'risk_matrix': {
drawSectionHeader(doc, sectionNum, section.name, 'Sannolikhet × Konsekvens');
const risks = c.risks;
drawKPIBox(doc, 50, doc.y, 150, 80,
String(risks.filter(r => r.level === 'critical').length), 'Kritiska Risker');
drawKPIBox(doc, 210, doc.y, 150, 80,
String(risks.filter(r => r.level === 'high').length), 'Höga Risker');
drawKPIBox(doc, 370, doc.y, 150, 80,
String(risks.filter(r => r.level === 'medium').length), 'Medel Risker');
doc.y += 100;
risks.forEach(r => {
const type = r.level === 'critical' ? 'danger' : r.level === 'high' ? 'warning' : 'default';
drawInfoBox(doc, `${r.id}: ${r.description}`,
`Sannolikhet: ${r.probability} | Konsekvens: ${r.consequence}\nÅtgärd: ${r.mitigation}`,
type);
});
drawInfoBox(doc, 'Metodologi / Methodology', c.methodology);
break;
}
case 'recommendations': {
drawSectionHeader(doc, sectionNum, section.name, 'Prioriterade åtgärder med tidslinje och budget');
drawKPIBox(doc, 50, doc.y, 150, 80, '$75K', 'Total Budget');
drawKPIBox(doc, 210, doc.y, 150, 80, 'Q3', 'Tidsram');
drawKPIBox(doc, 370, doc.y, 150, 80, '2', 'Akuta åtgärder', true);
doc.y += 100;
c.forEach(r => {
drawRect(doc, 50, doc.y, 500, 50, BRAND.background);
doc.rect(50, doc.y, 500, 50).strokeColor(hexToRgb(BRAND.border)).lineWidth(1).stroke();
drawRect(doc, 55, doc.y + 10, 30, 30, BRAND.primary);
color(doc, BRAND.white);
doc.fontSize(12).font('Helvetica-Bold');
doc.text(String(r.priority), 63, doc.y + 18);
color(doc, BRAND.primary);
doc.fontSize(10).font('Helvetica-Bold');
doc.text(r.action, 95, doc.y + 10);
color(doc, BRAND.textLight);
doc.fontSize(8).font('Helvetica');
doc.text(`📅 ${r.timeline} | 👤 ${r.owner} | 💰 ${r.budget}`, 95, doc.y + 8);
doc.y += 60;
});
drawInfoBox(doc, 'Notering / Note',
'Budgetuppskattningarna är preliminära och baserade på branschstandard. För exakta kostnader rekommenderas att inhämta offerter från kvalificerade entreprenörer.');
break;
}
case 'appendix': {
drawSectionHeader(doc, 'A', section.name, 'Rådata, sensorvärden och teknisk dokumentation');
drawKPIBox(doc, 50, doc.y, 150, 80, String(c.dataFiles.length), 'Datafiler');
drawKPIBox(doc, 210, doc.y, 150, 80, '~2MB', 'Total storlek');
drawKPIBox(doc, 370, doc.y, 150, 80, c.version, 'Protokollversion');
doc.y += 100;
c.dataFiles.forEach(f => {
drawRect(doc, 50, doc.y, 500, 50, BRAND.background);
doc.rect(50, doc.y, 500, 50).strokeColor(hexToRgb(BRAND.border)).lineWidth(1).stroke();
color(doc, BRAND.primary);
doc.fontSize(10).font('Helvetica-Bold');
doc.text(f.name, 60, doc.y + 8);
color(doc, BRAND.textLight);
doc.fontSize(8).font('Helvetica');
doc.text(`${f.type} | ${f.size}`, 60, doc.y + 8);
color(doc, BRAND.text);
doc.fontSize(9).font('Helvetica');
doc.text(f.description, 60, doc.y + 8);
doc.y += 60;
});
drawInfoBox(doc, 'Reproducerbarhet / Reproducibility', c.reproducibilityNote);
break;
}
default: {
drawSectionHeader(doc, sectionNum, section.name, '');
color(doc, BRAND.text);
doc.fontSize(9).font('Helvetica');
doc.text(JSON.stringify(c, null, 2), 50, doc.y);
}
}
sectionNum++;
}
// === KÄLLFÖRTECKNING ===
doc.addPage();
doc.y = 50;
drawSectionHeader(doc, 'B', 'Källförteckning / Source References', 'Komplett referenslista för reproducerbarhet');
const dataSources = report.sections.find(s => s.id === 'data_sources')?.content || [];
dataSources.forEach((s, i) => {
color(doc, BRAND.primary);
doc.fontSize(10).font('Helvetica-Bold');
doc.text(`[${i + 1}] ${s.name}`, 50, doc.y);
color(doc, BRAND.textLight);
doc.fontSize(8).font('Helvetica');
doc.text(`Typ: ${s.type} | Datum: ${s.date} | Metod: ${s.method}`, 50, doc.y + 5);
color(doc, BRAND.text);
doc.fontSize(9).font('Helvetica');
doc.text(`Begränsningar: ${s.limitations}`, 50, doc.y + 5, { width: 500 });
doc.y += 15;
});
// === FOOTER PÅ VARJE SIDA ===
const totalPages = doc._pageCount || 1;
doc.on('pageAdded', () => {
// Detta körs inte retroaktivt, men vi kan lägga till footers på sista sidan
});
doc.end();
} catch (e) {
reject(e);
}
});
}
// === PUBLIK API ===
export async function generatePDF(report, options = {}) {
return generatePDFKit(report, options);
}
export function generateHTML(report, options = {}) {
// Fallback till Puppeteer-HTML-generatorn om den finns
// Annars returnera enkel HTML
return `<!DOCTYPE html>
<html><head><meta charset="UTF-8"><title>${report.productName}</title></head>
<body><h1>${report.productName}</h1><p>HTML-preview kräver Puppeteer-generatorn.</p></body></html>`;
}
export function createDocumentControl(options = {}) {
return {
reference: options.reference || `LVX-${Date.now().toString(36).toUpperCase()}`,
version: options.version || '1.0',
date: options.date || new Date().toISOString().split('T')[0],
client: options.client || 'LandveX Kund',
analyst: options.analyst || 'Bernt (AI) / LandveX Intelligence',
classification: options.classification || 'Konfidentiell / Confidential',
distribution: options.distribution || 'Kundintern / Client Internal Only',
reviewStatus: options.reviewStatus || 'Granskad / Reviewed',
versions: options.versions || [{
version: '1.0',
date: new Date().toISOString().split('T')[0],
author: 'LandveX Intelligence',
changes: 'Initial version'
}]
};
}
export default {
generatePDF,
generateHTML,
createDocumentControl
};