Add admin dashboard, authentication, step wizard, and quote management

Expand the calculator with a multi-step wizard flow, admin dashboard with
quote tracking, login/auth system, distance API integration, and history
page. Add new UI components (dialog, progress, select, slider, switch),
update pricing logic, and improve the overall design with new assets.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
mikl0s 2026-02-22 20:59:11 +00:00
parent 7d2bbae1c6
commit 3ebb63dc6c
67 changed files with 14508 additions and 790 deletions

11
.env.example Normal file
View file

@ -0,0 +1,11 @@
# OpenRouteService API Key
# Get your free key at https://openrouteservice.org/dev/#/signup
# Free tier: 2,000 requests/day
OPENROUTE_API_KEY=your_api_key_here
# Email configuration (for quote requests)
# SMTP_HOST=smtp.example.com
# SMTP_PORT=587
# SMTP_USER=user@example.com
# SMTP_PASS=your_password
# EMAIL_TO=info@foamking.dk

5
.gitignore vendored
View file

@ -32,4 +32,7 @@ yarn-error.log*
# typescript
*.tsbuildinfo
next-env.d.ts
next-env.d.ts
# database
/data/

View file

@ -6,4 +6,4 @@
"printWidth": 100,
"plugins": ["prettier-plugin-tailwindcss"],
"tailwindFunctions": ["clsx", "cn"]
}
}

View file

@ -9,6 +9,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
## Technology Stack
**Planned stack:** Next.js + shadcn/ui + Tailwind CSS
- **Next.js**: For server-side rendering and API routes
- **shadcn/ui**: For accessible, customizable components
- **Tailwind CSS**: For styling
@ -17,6 +18,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
## Project Status
Currently in **documentation phase** - no implementation exists yet. Key documentation files:
- `docs/projektplan.md` - Complete project plan and requirements
- `docs/prisbeskrivelse.md` - Detailed pricing logic and formulas
- `docs/shadcn theme.txt` - Custom shadcn theme (blue/orange color scheme)
@ -25,6 +27,7 @@ Currently in **documentation phase** - no implementation exists yet. Key documen
## Core Requirements
### Input Form Fields
- Name (required, min 2 chars)
- Email (required, valid format)
- Phone (required, 8 digits)
@ -35,6 +38,7 @@ Currently in **documentation phase** - no implementation exists yet. Key documen
- Remarks (optional)
### Price Calculation Components
1. **Insulation**: 3,730 kr/m³ (subtract 5cm from height for concrete)
2. **Floor heating**: 205 kr/m² (always included)
3. **Synthetic mesh**: 49 kr/m² (always included)
@ -46,20 +50,24 @@ Currently in **documentation phase** - no implementation exists yet. Key documen
9. **VAT**: 25%
### Output
- Price estimate with ±10,000 kr variation
- Option to request binding quote (sends email to `info@foamking.dk`)
## Implementation Guidelines
### Distance Calculation
Three options for calculating transport distance:
1. **Postal code table** (recommended for MVP)
2. **OpenRouteService API** (free up to 2,000 requests/day)
3. **Google Maps API** (paid)
### Coverage Areas
- 4000-4999: West Zealand
- 2000-2999: Copenhagen
- 2000-2999: Copenhagen
- 3000-3999: North Zealand
- 4800-4899: Lolland-Falster
- 5000-5999: Funen (+500 kr Great Belt bridge fee)
@ -67,6 +75,7 @@ Three options for calculating transport distance:
### Development Commands
Since this is a new project, typical Next.js commands will apply once initialized:
```bash
# Initialize project
npx create-next-app@latest . --typescript --tailwind --app
@ -103,6 +112,7 @@ npm run typecheck
### Testing Scenarios
Test with examples from `prisbeskrivelse.md`:
- 50 m², 20 cm height, 2100 Copenhagen → ~95,500 kr
- Edge cases: minimum (25 m²) and maximum (300 m²) areas
- Different pump truck weight thresholds
@ -140,4 +150,4 @@ Test with examples from `prisbeskrivelse.md`:
- All prices exclude VAT unless specified
- The calculator provides estimates only - final quotes require on-site inspection
- Focus on Zealand, Lolland-Falster, and Funen regions
- The domain `beregner.foamking.dk` points to `185.158.133.1`
- The domain `beregner.foamking.dk` points to `185.158.133.1`

View file

@ -26,7 +26,7 @@ Kalkulatoren beregner priser baseret på:
- **Isolering**: 3.730 kr/m³ (eller 75 kr/m² simpel arbejdsløn)
- **Gulvvarme**: 205 kr/m² (altid inkluderet)
- **Syntetisk net**: 49 kr/m² (altid inkluderet)
- **Syntetisk net**: 49 kr/m² (altid inkluderet)
- **Flydespartel**: 450 kr/m² (90 kg/m²)
- **Pumpebil-tillæg**: 0-8.100 kr baseret på spartelvægt
- **Startgebyr**: 3.500 kr fast
@ -67,7 +67,7 @@ npm run dev
# Build for production
npm run build
# Start production server
# Start production server
npm start
# Lint code
@ -89,7 +89,7 @@ Ingen environment variables er nødvendige for MVP version. I produktion:
```bash
# Email service configuration
SMTP_HOST=smtp.example.com
SMTP_USER=user@example.com
SMTP_USER=user@example.com
SMTP_PASS=password
# Or use a service like SendGrid, AWS SES, etc.
@ -106,25 +106,27 @@ Projektet bruger en forudberegnet tabel over afstande fra 4550 Asnæs til danske
## 🎯 Dækningsområde
- **Sjælland**: Postnummer 2000-4999
- **Lolland-Falster**: Postnummer 4800-4899
- **Lolland-Falster**: Postnummer 4800-4899
- **Fyn**: Postnummer 5000-5999 (+500 kr Storebælt)
## 📱 Admin Mode
Klik på "Vis detaljer" for at se den fulde prissopgørelse med:
- Alle priskomponenter
- Beregningslogik step-by-step
- Beregningslogik step-by-step
- Isolerings- og transportdetaljer
- Procenttillæg og momsberegning
## 🧪 Testing
Test med eksempel fra dokumentationen:
- **Areal**: 50 m²
- **Højde**: 20 cm
- **Højde**: 20 cm
- **Postnummer**: 2100 (København)
- **Forventet resultat**: Ca. 95.500 kr inkl. moms
## 📄 Licens
Proprietary - Foam King Gulve
Proprietary - Foam King Gulve

157
app/admin/page.tsx Normal file
View file

@ -0,0 +1,157 @@
"use client"
import { useState } from "react"
import Image from "next/image"
import { CalculatorForm } from "@/components/calculator/calculator-form"
import { CalculationDetailsView } from "@/components/calculator/calculation-details"
import { Button } from "@/components/ui/button"
import type { CalculationDetails } from "@/lib/calculations"
import { formatEstimate } from "@/lib/calculations"
import { Send, Eye, EyeOff } from "lucide-react"
export default function AdminPage() {
const [calculationResult, setCalculationResult] = useState<CalculationDetails | null>(null)
const [showAdminMode, setShowAdminMode] = useState(true)
const [isRequestingQuote, setIsRequestingQuote] = useState(false)
const [customerInfo, setCustomerInfo] = useState<any>(null)
const [distanceSource, setDistanceSource] = useState<"openrouteservice" | "table" | null>(null)
const handleCalculation = (
result: CalculationDetails,
formData?: any,
source?: "openrouteservice" | "table"
) => {
setCalculationResult(result)
if (formData) {
setCustomerInfo(formData)
}
if (source) {
setDistanceSource(source)
}
}
const handleQuoteRequest = async () => {
if (!calculationResult || !customerInfo) return
setIsRequestingQuote(true)
try {
await new Promise((resolve) => setTimeout(resolve, 500))
console.log("Quote request (test mode):", {
customerInfo,
calculationDetails: calculationResult,
})
alert("TEST MODE: Tilbudsanmodning ville blive sendt til info@foamking.dk")
} catch (error) {
alert("Der opstod en fejl. Prøv igen senere.")
} finally {
setIsRequestingQuote(false)
}
}
return (
<main className="min-h-screen bg-gradient-to-b from-background to-muted/20">
<div className="container mx-auto px-4 py-8">
{/* Header */}
<div className="mb-8 text-center">
<div className="mb-4 flex justify-center">
<Image
src="/foam-king-logo.png"
alt="Foam King Gulve"
width={200}
height={80}
priority
className="h-20 w-auto"
/>
</div>
<h1 className="text-3xl font-bold">Foam King Gulve - Admin</h1>
<p className="mt-2 text-lg text-muted-foreground">
Detaljeret prisberegning til internt brug
</p>
</div>
{/* Admin Mode Toggle */}
<div className="mb-4 flex justify-center">
<Button
variant="ghost"
size="sm"
onClick={() => setShowAdminMode(!showAdminMode)}
className="gap-2"
>
{showAdminMode ? (
<>
<EyeOff className="h-4 w-4" />
Skjul detaljer
</>
) : (
<>
<Eye className="h-4 w-4" />
Vis detaljer
</>
)}
</Button>
</div>
{/* Calculator */}
<div className="mx-auto max-w-6xl">
<div className="grid gap-8 lg:grid-cols-2">
<div className="flex justify-center">
<CalculatorForm onCalculation={handleCalculation} showDetails={showAdminMode} />
</div>
{/* Results */}
<div className="space-y-6">
{!showAdminMode ? (
<div className="rounded-xl bg-card p-8 text-center shadow">
<h2 className="mb-4 text-xl font-semibold">Dit prisoverslag</h2>
{calculationResult ? (
<>
<p className="text-4xl font-bold text-primary">
{formatEstimate(calculationResult.totalInclVat)}
</p>
<p className="mt-2 text-sm text-muted-foreground">inkl. moms</p>
<p className="mt-4 text-sm text-muted-foreground">
*Prisen er vejledende og kan variere med ±10.000 kr afhængigt af konkrete
forhold
</p>
<Button
onClick={handleQuoteRequest}
size="lg"
className="mt-6 gap-2"
disabled={isRequestingQuote}
>
<Send className="h-4 w-4" />
Anmod om bindende tilbud
</Button>
</>
) : (
<p className="text-2xl text-muted-foreground">
Udfyld formularen og klik &quot;Beregn pris&quot;
</p>
)}
</div>
) : calculationResult ? (
<CalculationDetailsView
details={calculationResult}
distanceSource={distanceSource}
/>
) : (
<div className="rounded-xl bg-card p-8 text-center shadow">
<h2 className="mb-4 text-xl font-semibold">Detaljeret prisberegning</h2>
<p className="text-muted-foreground">
Udfyld formularen og klik &quot;Beregn pris&quot;
</p>
</div>
)}
</div>
</div>
</div>
{/* Footer */}
<footer className="mt-16 border-t pt-8 text-center text-sm text-muted-foreground">
<p>Foam King Gulve · Asnæs · CVR: 44 48 54 51</p>
<p className="mt-1">Vi dækker Sjælland, Lolland-Falster og Fyn</p>
</footer>
</div>
</main>
)
}

View file

@ -0,0 +1,26 @@
import { NextRequest, NextResponse } from "next/server"
import { login } from "@/lib/auth"
export async function POST(request: NextRequest) {
try {
const { email, password } = await request.json()
if (!email || !password) {
return NextResponse.json({ error: "Email og adgangskode er påkrævet" }, { status: 400 })
}
const result = await login(email, password)
if (!result.success) {
return NextResponse.json({ error: result.error }, { status: 401 })
}
return NextResponse.json({
success: true,
user: { name: result.user.name, email: result.user.email },
})
} catch (error) {
console.error("Login error:", error)
return NextResponse.json({ error: "Der opstod en fejl" }, { status: 500 })
}
}

View file

@ -0,0 +1,12 @@
import { NextResponse } from "next/server"
import { logout } from "@/lib/auth"
export async function POST() {
try {
await logout()
return NextResponse.json({ success: true })
} catch (error) {
console.error("Logout error:", error)
return NextResponse.json({ error: "Der opstod en fejl" }, { status: 500 })
}
}

View file

@ -0,0 +1,31 @@
import { NextRequest, NextResponse } from "next/server"
import { createUserWithPassword } from "@/lib/auth"
import { getUserByEmail } from "@/lib/db"
// One-time setup endpoint to create initial user
// Only works if no users exist yet or for the specific email
export async function POST(request: NextRequest) {
try {
const { email, password, name } = await request.json()
if (!email || !password || !name) {
return NextResponse.json({ error: "Email, password og navn er påkrævet" }, { status: 400 })
}
// Check if user already exists
const existingUser = getUserByEmail(email)
if (existingUser) {
return NextResponse.json({ error: "Bruger findes allerede" }, { status: 409 })
}
const user = await createUserWithPassword(email, password, name)
return NextResponse.json({
success: true,
user: { id: user.id, email: user.email, name: user.name },
})
} catch (error) {
console.error("Setup error:", error)
return NextResponse.json({ error: "Der opstod en fejl" }, { status: 500 })
}
}

145
app/api/distance/route.ts Normal file
View file

@ -0,0 +1,145 @@
import { NextResponse } from "next/server"
import { getDistance as getDistanceFromTable } from "@/lib/distance"
const OPENROUTE_API_KEY = process.env.OPENROUTE_API_KEY
const HOME_COORDINATES = { lat: 55.8089, lng: 11.4955 } // 4550 Asnæs
// Postal code prefix to city name for better geocoding
const POSTAL_TO_CITY: Record<string, string> = {
"1": "København",
"2": "København",
"30": "Helsingør",
"31": "Hornbæk",
"32": "Helsinge",
"33": "Frederiksværk",
"34": "Hillerød",
"35": "Værløse",
"36": "Frederikssund",
"40": "Roskilde",
"41": "Ringsted",
"42": "Slagelse",
"43": "Holbæk",
"44": "Kalundborg",
"45": "Nykøbing Sjælland",
"46": "Køge",
"47": "Næstved",
"48": "Nykøbing Falster",
"49": "Nakskov",
"50": "Odense",
"51": "Odense",
"52": "Odense",
"55": "Middelfart",
"56": "Faaborg",
"57": "Svendborg",
"58": "Nyborg",
}
function getCityFromPostalCode(postalCode: string): string {
// Try full prefix first (2 digits), then first digit
return (
POSTAL_TO_CITY[postalCode.slice(0, 2)] || POSTAL_TO_CITY[postalCode.slice(0, 1)] || "Denmark"
)
}
interface OpenRouteResponse {
features: {
geometry: {
coordinates: number[]
}
}[]
}
interface DirectionsResponse {
routes: {
summary: {
distance: number // meters
}
}[]
}
async function geocodeAddress(
address: string | null,
postalCode: string
): Promise<{ lat: number; lng: number } | null> {
if (!OPENROUTE_API_KEY) return null
try {
// Build search text from address and city (postal codes don't work well with OpenRouteService)
const city = getCityFromPostalCode(postalCode)
let searchText: string
if (address && address.trim()) {
searchText = `${address}, ${city}, Denmark`
} else {
searchText = `${city}, Denmark`
}
const url = `https://api.openrouteservice.org/geocode/search?api_key=${OPENROUTE_API_KEY}&text=${encodeURIComponent(searchText)}&boundary.country=DK&size=1`
const response = await fetch(url, { headers: { Accept: "application/json" } })
if (!response.ok) return null
const data: OpenRouteResponse = await response.json()
if (!data.features?.[0]?.geometry?.coordinates) return null
const [lng, lat] = data.features[0].geometry.coordinates
return { lat, lng }
} catch {
return null
}
}
async function getRouteDistance(
from: { lat: number; lng: number },
to: { lat: number; lng: number }
): Promise<number | null> {
if (!OPENROUTE_API_KEY) return null
try {
const url = `https://api.openrouteservice.org/v2/directions/driving-car?api_key=${OPENROUTE_API_KEY}&start=${from.lng},${from.lat}&end=${to.lng},${to.lat}`
const response = await fetch(url, { headers: { Accept: "application/geo+json" } })
if (!response.ok) return null
const data = await response.json()
const distance = data.features?.[0]?.properties?.summary?.distance
if (!distance) return null
// Convert meters to km and double for round trip
return Math.round((distance / 1000) * 2)
} catch {
return null
}
}
export async function GET(request: Request) {
const { searchParams } = new URL(request.url)
const postalCode = searchParams.get("postalCode")
const address = searchParams.get("address")
if (!postalCode || !/^\d{4}$/.test(postalCode)) {
return NextResponse.json({ error: "Invalid postal code" }, { status: 400 })
}
// Try OpenRouteService first if API key is configured
if (OPENROUTE_API_KEY) {
const destination = await geocodeAddress(address, postalCode)
if (destination) {
const distance = await getRouteDistance(HOME_COORDINATES, destination)
if (distance !== null) {
return NextResponse.json({
distance,
source: "openrouteservice",
postalCode,
})
}
}
}
// Fall back to postal code table
const distance = getDistanceFromTable(postalCode)
return NextResponse.json({
distance,
source: "table",
postalCode,
})
}

View file

@ -1,116 +1,303 @@
import { NextResponse } from "next/server"
import { z } from "zod"
import { formatPrice } from "@/lib/calculations"
import type { CalculationDetails } from "@/lib/calculations"
import { NextRequest, NextResponse } from "next/server"
import nodemailer from "nodemailer"
import { formatPrice, type CalculationDetails } from "@/lib/calculations"
import { FLOORING_TYPES } from "@/lib/constants"
import { saveQuote } from "@/lib/db"
const quoteRequestSchema = z.object({
customerInfo: z.object({
name: z.string(),
email: z.string().email(),
phone: z.string(),
postalCode: z.string(),
address: z.string().optional(),
remarks: z.string().optional(),
}),
calculationDetails: z.object({
area: z.number(),
height: z.number(),
postalCode: z.string(),
distance: z.number(),
totalInclVat: z.number(),
// We'll validate other fields exist but not their exact shape
}) as z.ZodType<CalculationDetails>,
})
interface QuoteRequestBody {
customerInfo: {
name: string
email: string
phone: string
postalCode: string
address?: string
remarks?: string
}
calculationDetails: CalculationDetails
}
export async function POST(request: Request) {
function createTransporter() {
return nodemailer.createTransport({
host: process.env.SMTP_HOST,
port: parseInt(process.env.SMTP_PORT || "587"),
secure: process.env.SMTP_PORT === "465",
auth: {
user: process.env.SMTP_USER,
pass: process.env.SMTP_PASS,
},
})
}
function getFlooringTypeName(type: string): string {
return FLOORING_TYPES[type as keyof typeof FLOORING_TYPES]?.name || type
}
function formatCustomerEmail(
customer: QuoteRequestBody["customerInfo"],
details: CalculationDetails,
trackingUrl: string
): string {
const components = []
if (details.includeInsulation) components.push(`Isolering (${details.insulationThickness} cm)`)
if (details.includeFloorHeating) components.push("Gulvvarme syntetisk net + Ø16 PEX (excl. tilslutning)")
if (details.includeCompound)
components.push(`Flydespartel (${getFlooringTypeName(details.flooringType)})`)
return `
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<style>
body { font-family: Arial, sans-serif; line-height: 1.6; color: #333; max-width: 600px; margin: 0 auto; }
.header { background: linear-gradient(135deg, #1e3a5f 0%, #2d5a87 100%); color: white; padding: 30px; text-align: center; }
.header h1 { margin: 0; font-size: 24px; }
.content { padding: 30px; background: #f9f9f9; }
.price-box { background: white; border-radius: 12px; padding: 24px; text-align: center; margin: 20px 0; box-shadow: 0 2px 8px rgba(0,0,0,0.1); }
.price { font-size: 36px; font-weight: bold; color: #1e3a5f; }
.details { background: white; border-radius: 8px; padding: 20px; margin: 20px 0; }
.details h3 { margin-top: 0; color: #1e3a5f; }
.details ul { margin: 0; padding-left: 20px; }
.details li { margin: 8px 0; }
.footer { padding: 20px; text-align: center; font-size: 12px; color: #666; }
.note { background: #fff3cd; border-left: 4px solid #ffc107; padding: 12px; margin: 20px 0; font-size: 14px; }
</style>
</head>
<body>
<div class="header">
<h1>Foam King Gulve</h1>
<p>Dit prisoverslag</p>
</div>
<div class="content">
<p>Kære ${customer.name},</p>
<p>Tak for din interesse i Foam King Gulve. Her er dit prisoverslag baseret de oplysninger du har indtastet:</p>
<div class="price-box">
<div class="price">${formatPrice(Math.round(details.totalInclVat))}</div>
<div style="color: #666;">inkl. moms</div>
</div>
<div class="details">
<h3>Projektdetaljer</h3>
<ul>
<li><strong>Areal:</strong> ${details.area} m²</li>
<li><strong>Gulvhøjde:</strong> ${details.height} cm</li>
<li><strong>Placering:</strong> ${customer.postalCode}${customer.address ? `, ${customer.address}` : ""}</li>
</ul>
</div>
<div class="details">
<h3>Inkluderet i prisen</h3>
<ul>
${components.map((c) => `<li>${c}</li>`).join("")}
<li>Transport</li>
<li>Startgebyr (udstyr og sikkerhed)</li>
</ul>
</div>
<div class="note">
<strong>Bemærk:</strong> Dette er et vejledende prisoverslag og kan variere med ±10.000 kr afhængigt af konkrete forhold stedet.
</div>
<p>Vi kontakter dig snarest for at aftale et uforpligtende besøg, hvor vi kan give dig et præcist og bindende tilbud.</p>
<p>Har du spørgsmål i mellemtiden, er du velkommen til at kontakte os.</p>
<p>Med venlig hilsen,<br>
<strong>Foam King ApS</strong><br>
Tlf: 35 90 10 66<br>
Email: info@foamking.dk</p>
</div>
<div class="footer">
<p>Foam King ApS · Søgårdsvej 7, 4550 Asnæs · CVR: 44 48 54 51</p>
<p style="margin-top: 15px;">
<img src="${trackingUrl}" alt="Byg Garanti" width="120" height="auto" style="display: inline-block;" />
</p>
</div>
</body>
</html>
`
}
function formatFoamKingEmail(
customer: QuoteRequestBody["customerInfo"],
details: CalculationDetails,
quoteLink: string,
quoteId: number
): string {
const components = []
if (details.includeInsulation)
components.push(
`Isolering: ${details.insulationThickness} cm (${formatPrice(Math.round(details.insulation))})`
)
if (details.includeFloorHeating) {
components.push(`Gulvvarme: ${formatPrice(Math.round(details.floorHeating))}`)
components.push(`Syntetisk net: ${formatPrice(Math.round(details.syntheticNet))}`)
}
if (details.includeCompound) {
components.push(
`Flydespartel (${getFlooringTypeName(details.flooringType)}): ${formatPrice(Math.round(details.selfLevelingCompound))}`
)
components.push(
`Pumpebil (${details.compoundWeight} kg): ${formatPrice(Math.round(details.pumpTruckFee))}`
)
}
return `
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<style>
body { font-family: Arial, sans-serif; line-height: 1.6; color: #333; max-width: 700px; margin: 0 auto; }
.header { background: #e67e22; color: white; padding: 20px; }
.header h1 { margin: 0; font-size: 20px; }
.section { padding: 20px; border-bottom: 1px solid #eee; }
.section h2 { color: #1e3a5f; font-size: 16px; margin-top: 0; text-transform: uppercase; }
table { width: 100%; border-collapse: collapse; }
td { padding: 8px 0; }
td:first-child { color: #666; width: 50%; }
td:last-child { text-align: right; }
.price-row { background: #f5f5f5; font-weight: bold; }
.price-row td { padding: 12px 8px; }
.total { font-size: 18px; color: #1e3a5f; }
.remarks { background: #fffbeb; padding: 15px; border-left: 4px solid #f59e0b; margin-top: 10px; }
</style>
</head>
<body>
<div class="header">
<h1>Ny tilbudsanmodning #${quoteId}</h1>
</div>
<div style="background: #3b82f6; padding: 15px 20px; text-align: center;">
<a href="${quoteLink}" style="color: white; text-decoration: none; font-weight: bold; font-size: 16px;">
Se detaljeret tilbud online &rarr;
</a>
</div>
<div class="section">
<h2>Kundeoplysninger</h2>
<table>
<tr><td>Navn:</td><td><strong>${customer.name}</strong></td></tr>
<tr><td>Email:</td><td><a href="mailto:${customer.email}">${customer.email}</a></td></tr>
<tr><td>Telefon:</td><td><a href="tel:${customer.phone}">${customer.phone}</a></td></tr>
<tr><td>Postnummer:</td><td>${customer.postalCode}</td></tr>
${customer.address ? `<tr><td>Adresse:</td><td>${customer.address}</td></tr>` : ""}
</table>
${customer.remarks ? `<div class="remarks"><strong>Bemærkninger fra kunden:</strong><br>${customer.remarks}</div>` : ""}
</div>
<div class="section">
<h2>Projektspecifikationer</h2>
<table>
<tr><td>Gulvareal:</td><td><strong>${details.area} m²</strong></td></tr>
<tr><td>Gulvhøjde:</td><td>${details.height} cm</td></tr>
<tr><td>Isoleringstykkelse:</td><td>${details.insulationThickness} cm</td></tr>
<tr><td>Spartelvægt:</td><td>${details.compoundWeight} kg</td></tr>
<tr><td>Afstand (tur/retur):</td><td>${details.distance} km</td></tr>
</table>
</div>
<div class="section">
<h2>Prisberegning</h2>
<table>
${components
.map((c) => {
const [label, price] = c.split(": ")
return `<tr><td style="color: #666;">${label}:</td><td style="text-align: right;">${price}</td></tr>`
})
.join("")}
<tr><td style="color: #666;">Startgebyr:</td><td style="text-align: right;">${formatPrice(Math.round(details.startFee))}</td></tr>
<tr><td colspan="2" style="border-top: 1px solid #ddd; padding-top: 12px;"></td></tr>
<tr><td style="color: #666;">Subtotal:</td><td style="text-align: right;">${formatPrice(Math.round(details.subtotal))}</td></tr>
<tr><td style="color: #666;">Afdækning (0.7%):</td><td style="text-align: right;">${formatPrice(Math.round(details.coveringFee))}</td></tr>
<tr><td style="color: #666;">Affald (0.25%):</td><td style="text-align: right;">${formatPrice(Math.round(details.wasteFee))}</td></tr>
<tr><td style="color: #666;">Transport:</td><td style="text-align: right;">${formatPrice(Math.round(details.transport))}</td></tr>
${details.bridgeFee > 0 ? `<tr><td style="color: #666;">Storebælt:</td><td style="text-align: right;">${formatPrice(details.bridgeFee)}</td></tr>` : ""}
<tr style="background: #f5f5f5; font-weight: bold;"><td style="padding: 12px 8px;">Total ekskl. moms:</td><td style="text-align: right; padding: 12px 8px;">${formatPrice(Math.round(details.totalExclVat))}</td></tr>
<tr><td style="color: #666;">Moms (25%):</td><td style="text-align: right;">${formatPrice(Math.round(details.vat))}</td></tr>
<tr style="background: #f5f5f5; font-weight: bold;"><td style="padding: 12px 8px; font-size: 18px; color: #1e3a5f;">Total inkl. moms:</td><td style="text-align: right; padding: 12px 8px; font-size: 18px; color: #1e3a5f;">${formatPrice(Math.round(details.totalInclVat))}</td></tr>
</table>
</div>
<div class="section" style="border: none;">
<p style="color: #666; font-size: 12px;">
Denne anmodning er genereret automatisk fra prisberegneren beregner.foamking.dk<br>
Tidspunkt: ${new Date().toLocaleString("da-DK", { timeZone: "Europe/Copenhagen" })}
</p>
</div>
</body>
</html>
`
}
export async function POST(request: NextRequest) {
try {
const body = await request.json()
const { customerInfo, calculationDetails } = quoteRequestSchema.parse(body)
const body: QuoteRequestBody = await request.json()
const { customerInfo, calculationDetails } = body
if (!customerInfo || !calculationDetails) {
return NextResponse.json({ error: "Manglende data" }, { status: 400 })
}
// Save quote to database
const { id: quoteId, slug } = saveQuote({
postalCode: customerInfo.postalCode,
address: customerInfo.address,
area: calculationDetails.area,
height: calculationDetails.height,
includeFloorHeating: calculationDetails.includeFloorHeating,
flooringType: calculationDetails.flooringType,
customerName: customerInfo.name,
customerEmail: customerInfo.email,
customerPhone: customerInfo.phone,
remarks: customerInfo.remarks,
totalExclVat: calculationDetails.totalExclVat,
totalInclVat: calculationDetails.totalInclVat,
})
// Generate the quote link
const baseUrl = process.env.NEXT_PUBLIC_BASE_URL || "https://beregner.foamking.dk"
const quoteLink = `${baseUrl}/tilbud/${slug}`
const transporter = createTransporter()
// Get Foam King recipients (supports comma-separated emails)
const foamKingEmails = (process.env.EMAIL_TO || "info@foamking.dk")
.split(",")
.map((email) => email.trim())
.filter((email) => email.length > 0)
const fromName = process.env.EMAIL_FROM_NAME || "Foam King Prisberegner"
// Generate tracking URL for email open tracking
const trackingUrl = `${baseUrl}/api/track/${quoteId}`
// Send email to customer
await transporter.sendMail({
from: `"${fromName}" <${process.env.SMTP_USER}>`,
to: customerInfo.email,
subject: "Dit prisoverslag fra Foam King Gulve",
html: formatCustomerEmail(customerInfo, calculationDetails, trackingUrl),
})
// Send email to Foam King
await transporter.sendMail({
from: `"${fromName}" <${process.env.SMTP_USER}>`,
to: foamKingEmails,
replyTo: customerInfo.email,
subject: `Tilbud #${quoteId}: ${customerInfo.name} - ${customerInfo.postalCode} - ${calculationDetails.area}`,
html: formatFoamKingEmail(customerInfo, calculationDetails, quoteLink, quoteId),
})
// Format email content
const emailContent = formatEmailContent(customerInfo, calculationDetails)
// In production, you would send this via an email service
// For now, we'll just log it and return success
console.log("Quote request email:", emailContent)
// TODO: Implement actual email sending using a service like:
// - SendGrid
// - AWS SES
// - Resend
// - Nodemailer with SMTP
return NextResponse.json({
success: true,
message: "Tilbudsanmodning modtaget. Vi kontakter dig snarest muligt.",
message: "Tak! Vi har modtaget din anmodning og sendt en bekræftelse til din email.",
})
} catch (error) {
console.error("Quote request error:", error)
if (error instanceof z.ZodError) {
return NextResponse.json(
{ error: "Ugyldige data", details: error.errors },
{ status: 400 }
)
}
return NextResponse.json(
{ error: "Der opstod en fejl. Prøv igen senere." },
{ status: 500 }
)
return NextResponse.json({ error: "Der opstod en fejl. Prøv igen senere." }, { status: 500 })
}
}
function formatEmailContent(
customerInfo: z.infer<typeof quoteRequestSchema>["customerInfo"],
details: CalculationDetails
): string {
return `
Ny tilbudsanmodning fra Foam King Gulve Prisberegner
KUNDEOPLYSNINGER:
-----------------
Navn: ${customerInfo.name}
Email: ${customerInfo.email}
Telefon: ${customerInfo.phone}
Postnummer: ${customerInfo.postalCode}
Adresse: ${customerInfo.address || "Ikke angivet"}
PROJEKTDETALJER:
----------------
Gulvareal: ${details.area} m²
Gulvhøjde: ${details.height} cm
Isoleringstykkelse: ${details.insulationThickness} cm
Isoleringsvolumen: ${details.insulationVolume.toFixed(2)} m³
Spartelvægt: ${details.compoundWeight.toLocaleString("da-DK")} kg
PRISBEREGNING:
--------------
Isolering: ${formatPrice(details.insulation)}
Gulvvarme: ${formatPrice(details.floorHeating)}
Syntetisk net: ${formatPrice(details.syntheticNet)}
Flydespartel: ${formatPrice(details.selfLevelingCompound)}
Pumpebil-tillæg: ${formatPrice(details.pumpTruckFee)}
Startgebyr: ${formatPrice(details.startFee)}
Subtotal: ${formatPrice(details.subtotal)}
Tillæg (afdækning + affald): ${formatPrice(details.totalFees)}
Transport: ${formatPrice(details.transport)}
${details.bridgeFee > 0 ? `Storebælt-tillæg: ${formatPrice(details.bridgeFee)}` : ""}
Total ekskl. moms: ${formatPrice(details.totalExclVat)}
Moms (25%): ${formatPrice(details.vat)}
TOTAL INKL. MOMS: ${formatPrice(details.totalInclVat)}
BEMÆRKNINGER:
-------------
${customerInfo.remarks || "Ingen bemærkninger"}
AFSTAND:
--------
Kørselsafstand (tur-retur): ${details.distance} km
---
Sendt fra beregner.foamking.dk
`.trim()
}

48
app/api/quotes/route.ts Normal file
View file

@ -0,0 +1,48 @@
import { NextRequest, NextResponse } from "next/server"
import { getAllQuotes, updateQuoteStatus, type QuoteStatus } from "@/lib/db"
import { getCurrentUser } from "@/lib/auth"
export async function GET() {
try {
const user = await getCurrentUser()
if (!user) {
return NextResponse.json({ error: "Ikke autoriseret" }, { status: 401 })
}
const quotes = getAllQuotes()
return NextResponse.json({ quotes })
} catch (error) {
console.error("Get quotes error:", error)
return NextResponse.json({ error: "Der opstod en fejl" }, { status: 500 })
}
}
export async function PATCH(request: NextRequest) {
try {
const user = await getCurrentUser()
if (!user) {
return NextResponse.json({ error: "Ikke autoriseret" }, { status: 401 })
}
const { id, status } = await request.json()
if (!id || !status) {
return NextResponse.json({ error: "ID og status er påkrævet" }, { status: 400 })
}
const validStatuses: QuoteStatus[] = ["new", "contacted", "accepted", "rejected"]
if (!validStatuses.includes(status)) {
return NextResponse.json({ error: "Ugyldig status" }, { status: 400 })
}
const success = updateQuoteStatus(id, status)
if (!success) {
return NextResponse.json({ error: "Tilbud ikke fundet" }, { status: 404 })
}
return NextResponse.json({ success: true })
} catch (error) {
console.error("Update quote error:", error)
return NextResponse.json({ error: "Der opstod en fejl" }, { status: 500 })
}
}

View file

@ -0,0 +1,46 @@
import { NextRequest, NextResponse } from "next/server"
import { markEmailOpened } from "@/lib/db"
import fs from "fs"
import path from "path"
// Serve the byg-garanti image and track email opens
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params
const quoteId = parseInt(id, 10)
// Mark email as opened (only first time)
if (!isNaN(quoteId)) {
markEmailOpened(quoteId)
}
// Serve the image
const imagePath = path.join(process.cwd(), "public", "byg_trans.png")
try {
const imageBuffer = fs.readFileSync(imagePath)
return new NextResponse(imageBuffer, {
headers: {
"Content-Type": "image/png",
"Cache-Control": "no-cache, no-store, must-revalidate",
Pragma: "no-cache",
Expires: "0",
},
})
} catch {
// Return a 1x1 transparent pixel if image not found
const pixel = Buffer.from(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==",
"base64"
)
return new NextResponse(pixel, {
headers: {
"Content-Type": "image/png",
"Cache-Control": "no-cache",
},
})
}
}

266
app/dashboard/page.tsx Normal file
View file

@ -0,0 +1,266 @@
"use client"
import { useEffect, useState, useMemo } from "react"
import { useRouter } from "next/navigation"
import Image from "next/image"
import Link from "next/link"
import { Button } from "@/components/ui/button"
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
import { KanbanBoard } from "@/components/dashboard/kanban-board"
import { SearchFilter } from "@/components/dashboard/search-filter"
import { formatPrice } from "@/lib/calculations"
import { type StoredQuote, type QuoteStatus } from "@/lib/db"
import { LogOut, List, Loader2, RotateCcw, ExternalLink } from "lucide-react"
export default function DashboardPage() {
const router = useRouter()
const [quotes, setQuotes] = useState<StoredQuote[]>([])
const [loading, setLoading] = useState(true)
const [search, setSearch] = useState("")
// Dialogs
const [rejectQuote, setRejectQuote] = useState<StoredQuote | null>(null)
const [showRejected, setShowRejected] = useState(false)
useEffect(() => {
fetchQuotes()
}, [])
async function fetchQuotes() {
try {
const res = await fetch("/api/quotes")
if (!res.ok) {
if (res.status === 401) {
router.push("/login")
return
}
throw new Error("Failed to fetch quotes")
}
const data = await res.json()
setQuotes(data.quotes)
} catch (error) {
console.error("Failed to fetch quotes:", error)
} finally {
setLoading(false)
}
}
async function handleStatusChange(id: number, status: QuoteStatus) {
// Optimistic update
setQuotes((prev) => prev.map((q) => (q.id === id ? { ...q, status } : q)))
try {
const res = await fetch("/api/quotes", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ id, status }),
})
if (!res.ok) {
fetchQuotes()
}
} catch {
fetchQuotes()
}
}
function handleRejectClick(quote: StoredQuote) {
setRejectQuote(quote)
}
async function confirmReject() {
if (!rejectQuote) return
await handleStatusChange(rejectQuote.id, "rejected")
setRejectQuote(null)
}
async function handleLogout() {
try {
await fetch("/api/auth/logout", { method: "POST" })
router.push("/login")
router.refresh()
} catch (error) {
console.error("Logout failed:", error)
}
}
const filteredQuotes = useMemo(() => {
if (!search.trim()) return quotes
const term = search.toLowerCase()
return quotes.filter(
(q) =>
q.customerName.toLowerCase().includes(term) ||
q.customerEmail.toLowerCase().includes(term) ||
q.postalCode.includes(term)
)
}, [quotes, search])
const activeQuotes = filteredQuotes.filter((q) => q.status !== "rejected")
const rejectedQuotes = filteredQuotes.filter((q) => q.status === "rejected")
if (loading) {
return (
<main className="flex min-h-screen items-center justify-center bg-muted/30">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
</main>
)
}
return (
<main className="min-h-screen bg-muted/30">
{/* Header */}
<header className="sticky top-0 z-10 border-b bg-white">
<div className="container mx-auto flex items-center justify-between px-4 py-3">
<div className="flex items-center gap-4">
<Link href="/">
<Image
src="/foam-king-logo.png"
alt="Foam King"
width={120}
height={48}
className="h-10 w-auto"
/>
</Link>
<h1 className="hidden text-lg font-semibold sm:block">Dashboard</h1>
</div>
<div className="flex items-center gap-2">
<Link href="/historik">
<Button variant="ghost" size="sm">
<List className="mr-2 h-4 w-4" />
<span className="hidden sm:inline">Historik</span>
</Button>
</Link>
<Button variant="ghost" size="sm" onClick={handleLogout}>
<LogOut className="mr-2 h-4 w-4" />
<span className="hidden sm:inline">Log ud</span>
</Button>
</div>
</div>
</header>
{/* Main content */}
<div className="container mx-auto px-4 py-6">
{/* Search */}
<div className="mb-6 max-w-md">
<SearchFilter value={search} onChange={setSearch} />
</div>
{/* Kanban board */}
<KanbanBoard
quotes={activeQuotes}
onStatusChange={handleStatusChange}
onReject={handleRejectClick}
rejectedCount={rejectedQuotes.length}
onShowRejected={() => setShowRejected(true)}
/>
{/* Empty state */}
{quotes.length === 0 && !loading && (
<div className="py-12 text-center">
<p className="text-muted-foreground">
Ingen tilbud endnu. Når kunder anmoder om tilbud, vil de vises her.
</p>
</div>
)}
</div>
{/* Reject confirmation dialog */}
<Dialog open={!!rejectQuote} onOpenChange={() => setRejectQuote(null)}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Afvis tilbud?</DialogTitle>
<DialogDescription>
Er du sikker du vil afvise tilbuddet til{" "}
<span className="font-medium text-foreground">{rejectQuote?.customerName}</span>?
</DialogDescription>
</DialogHeader>
{rejectQuote && (
<div className="rounded-lg bg-muted/50 p-3 text-sm">
<div className="flex justify-between">
<span className="text-muted-foreground">Tilbud #{rejectQuote.id}</span>
<span className="font-medium">
{rejectQuote.totalInclVat
? formatPrice(Math.round(rejectQuote.totalInclVat))
: "—"}
</span>
</div>
<div className="text-muted-foreground">
{rejectQuote.postalCode} · {rejectQuote.area} m²
</div>
</div>
)}
<DialogFooter className="gap-2 sm:gap-0">
<Button variant="outline" onClick={() => setRejectQuote(null)}>
Annuller
</Button>
<Button variant="destructive" onClick={confirmReject}>
Afvis tilbud
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Rejected quotes modal */}
<Dialog open={showRejected} onOpenChange={setShowRejected}>
<DialogContent className="max-h-[80vh] overflow-hidden sm:max-w-lg">
<DialogHeader>
<DialogTitle>Afviste tilbud ({rejectedQuotes.length})</DialogTitle>
</DialogHeader>
<div className="max-h-[60vh] space-y-2 overflow-y-auto pr-2">
{rejectedQuotes.length === 0 ? (
<p className="py-8 text-center text-sm text-muted-foreground">Ingen afviste tilbud</p>
) : (
rejectedQuotes.map((quote) => {
const slug = `${quote.postalCode}-${quote.id}`
return (
<div
key={quote.id}
className="flex items-center gap-3 rounded-lg border bg-white p-3"
>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="truncate font-medium">{quote.customerName}</span>
<a
href={`/tilbud/${slug}`}
target="_blank"
rel="noopener noreferrer"
className="text-muted-foreground hover:text-primary"
>
<ExternalLink className="h-3.5 w-3.5" />
</a>
</div>
<div className="text-sm text-muted-foreground">
{quote.postalCode} · {quote.area} m² ·{" "}
{quote.totalInclVat ? formatPrice(Math.round(quote.totalInclVat)) : "—"}
</div>
</div>
<Button
size="sm"
variant="outline"
className="flex-shrink-0"
onClick={() => {
handleStatusChange(quote.id, "new")
}}
>
<RotateCcw className="mr-1.5 h-3.5 w-3.5" />
Gendan
</Button>
</div>
)
})
)}
</div>
</DialogContent>
</Dialog>
</main>
)
}

View file

@ -4,61 +4,48 @@
@layer base {
:root {
--background: oklch(0.985 0.0014 39.68);
--foreground: oklch(0.2683 0.0043 41.05);
--card: var(--color-white);
--card-foreground: oklch(0.2683 0.0043 41.05);
--popover: var(--color-white);
--popover-foreground: oklch(0.2683 0.0043 41.05);
--primary: oklch(0.8651 0.1153 207.08);
--primary-foreground: var(--color-black);
--secondary: oklch(0.72 0.1613 29.29);
--secondary-foreground: var(--color-black);
--muted: oklch(0.9674 0.0029 40.41);
--muted-foreground: oklch(0.4426 0.0055 43.48);
--accent: oklch(0.9674 0.0029 40.41);
--accent-foreground: oklch(0.2683 0.0043 41.05);
--destructive: oklch(0.577 0.245 27.325);
--destructive-foreground: oklch(0.985 0.0014 39.68);
--border: oklch(0.9227 0.0041 40.62);
--input: oklch(0.8693 0.0046 41.1);
--ring: oklch(0.8651 0.1153 207.08);
--chart-1: oklch(0.8651 0.1153 207.08);
--chart-2: oklch(0.72 0.1613 29.29);
--chart-3: oklch(0.7886 0.1393 211.4);
--chart-4: oklch(0.8154 0.1004 27.92);
--chart-5: oklch(0.8651 0.1153 207.08);
--background: 39.68 0.14% 98.5%;
--foreground: 41.05 1.6% 16.5%;
--card: 0 0% 100%;
--card-foreground: 41.05 1.6% 16.5%;
--popover: 0 0% 100%;
--popover-foreground: 41.05 1.6% 16.5%;
--primary: 207.08 60% 75%;
--primary-foreground: 0 0% 0%;
--secondary: 29.29 70% 60%;
--secondary-foreground: 0 0% 0%;
--muted: 40.41 3% 96%;
--muted-foreground: 43.48 3% 35%;
--accent: 40.41 3% 96%;
--accent-foreground: 41.05 1.6% 16.5%;
--destructive: 27.325 70% 45%;
--destructive-foreground: 0 0% 100%;
--border: 40.62 2% 90%;
--input: 41.1 2% 85%;
--ring: 207.08 60% 75%;
--radius: 1rem;
--color-white: #ffffff;
--color-black: #000000;
}
.dark {
--background: oklch(0.1465 0.0038 39.55);
--foreground: oklch(0.9227 0.0041 40.62);
--card: oklch(0.213 0.0041 40.86);
--card-foreground: oklch(0.9227 0.0041 40.62);
--popover: oklch(0.213 0.0041 40.86);
--popover-foreground: oklch(0.9227 0.0041 40.62);
--primary: oklch(0.8651 0.1153 207.08);
--primary-foreground: var(--color-black);
--secondary: oklch(0.72 0.1613 29.29);
--secondary-foreground: var(--color-black);
--muted: oklch(0.2683 0.0043 41.05);
--muted-foreground: oklch(0.8693 0.0046 41.1);
--accent: oklch(0.2683 0.0043 41.05);
--accent-foreground: oklch(0.9227 0.0041 40.62);
--destructive: oklch(0.704 0.191 22.216);
--destructive-foreground: oklch(0.985 0.0014 39.68);
--border: oklch(0.2683 0.0043 41.05);
--input: oklch(0.3732 0.0051 42.7);
--ring: oklch(0.8651 0.1153 207.08);
--chart-1: oklch(0.8651 0.1153 207.08);
--chart-2: oklch(0.72 0.1613 29.29);
--chart-3: oklch(0.7886 0.1393 211.4);
--chart-4: oklch(0.8154 0.1004 27.92);
--chart-5: oklch(0.8651 0.1153 207.08);
--background: 39.55 3% 9%;
--foreground: 40.62 2% 90%;
--card: 40.86 2% 13%;
--card-foreground: 40.62 2% 90%;
--popover: 40.86 2% 13%;
--popover-foreground: 40.62 2% 90%;
--primary: 207.08 60% 75%;
--primary-foreground: 0 0% 0%;
--secondary: 29.29 70% 60%;
--secondary-foreground: 0 0% 0%;
--muted: 41.05 1.6% 16.5%;
--muted-foreground: 41.1 2% 85%;
--accent: 41.05 1.6% 16.5%;
--accent-foreground: 40.62 2% 90%;
--destructive: 22.216 65% 55%;
--destructive-foreground: 0 0% 100%;
--border: 41.05 1.6% 16.5%;
--input: 42.7 3% 23%;
--ring: 207.08 60% 75%;
}
}
@ -69,4 +56,4 @@
body {
@apply bg-background text-foreground;
}
}
}

222
app/historik/page.tsx Normal file
View file

@ -0,0 +1,222 @@
"use client"
import { useEffect, useState, useMemo } from "react"
import { useRouter } from "next/navigation"
import Image from "next/image"
import Link from "next/link"
import { Button } from "@/components/ui/button"
import { SearchFilter } from "@/components/dashboard/search-filter"
import { formatPrice } from "@/lib/calculations"
import { type StoredQuote, type QuoteStatus } from "@/lib/db"
import { LogOut, LayoutDashboard, Loader2, ExternalLink } from "lucide-react"
const STATUS_LABELS: Record<QuoteStatus, { label: string; className: string }> = {
new: { label: "Ny", className: "bg-blue-100 text-blue-700" },
contacted: { label: "Kontaktet", className: "bg-amber-100 text-amber-700" },
accepted: { label: "Accepteret", className: "bg-green-100 text-green-700" },
rejected: { label: "Afvist", className: "bg-gray-100 text-gray-500" },
}
function formatDate(dateString: string): string {
return new Date(dateString).toLocaleDateString("da-DK", {
day: "numeric",
month: "short",
year: "numeric",
})
}
export default function HistorikPage() {
const router = useRouter()
const [quotes, setQuotes] = useState<StoredQuote[]>([])
const [loading, setLoading] = useState(true)
const [search, setSearch] = useState("")
useEffect(() => {
fetchQuotes()
}, [])
async function fetchQuotes() {
try {
const res = await fetch("/api/quotes")
if (!res.ok) {
if (res.status === 401) {
router.push("/login")
return
}
throw new Error("Failed to fetch quotes")
}
const data = await res.json()
setQuotes(data.quotes)
} catch (error) {
console.error("Failed to fetch quotes:", error)
} finally {
setLoading(false)
}
}
async function handleLogout() {
try {
await fetch("/api/auth/logout", { method: "POST" })
router.push("/login")
router.refresh()
} catch (error) {
console.error("Logout failed:", error)
}
}
const filteredQuotes = useMemo(() => {
if (!search.trim()) return quotes
const term = search.toLowerCase()
return quotes.filter(
(q) =>
q.customerName.toLowerCase().includes(term) ||
q.customerEmail.toLowerCase().includes(term) ||
q.postalCode.includes(term) ||
q.id.toString().includes(term)
)
}, [quotes, search])
if (loading) {
return (
<main className="flex min-h-screen items-center justify-center bg-muted/30">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
</main>
)
}
return (
<main className="min-h-screen bg-muted/30">
{/* Header */}
<header className="sticky top-0 z-10 border-b bg-white">
<div className="container mx-auto flex items-center justify-between px-4 py-3">
<div className="flex items-center gap-4">
<Link href="/">
<Image
src="/foam-king-logo.png"
alt="Foam King"
width={120}
height={48}
className="h-10 w-auto"
/>
</Link>
<h1 className="hidden text-lg font-semibold sm:block">Historik</h1>
</div>
<div className="flex items-center gap-2">
<Link href="/dashboard">
<Button variant="ghost" size="sm">
<LayoutDashboard className="mr-2 h-4 w-4" />
<span className="hidden sm:inline">Dashboard</span>
</Button>
</Link>
<Button variant="ghost" size="sm" onClick={handleLogout}>
<LogOut className="mr-2 h-4 w-4" />
<span className="hidden sm:inline">Log ud</span>
</Button>
</div>
</div>
</header>
{/* Main content */}
<div className="container mx-auto px-4 py-6">
{/* Search and stats */}
<div className="mb-6 flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div className="max-w-md flex-1">
<SearchFilter value={search} onChange={setSearch} />
</div>
<div className="text-sm text-muted-foreground">
{filteredQuotes.length} tilbud
{search && ` (af ${quotes.length})`}
</div>
</div>
{/* Table */}
<div className="overflow-hidden rounded-xl border bg-white shadow-sm">
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr className="border-b bg-muted/50">
<th className="px-4 py-3 text-left text-sm font-medium text-muted-foreground">
#
</th>
<th className="px-4 py-3 text-left text-sm font-medium text-muted-foreground">
Kunde
</th>
<th className="hidden px-4 py-3 text-left text-sm font-medium text-muted-foreground md:table-cell">
Lokation
</th>
<th className="hidden px-4 py-3 text-left text-sm font-medium text-muted-foreground sm:table-cell">
Areal
</th>
<th className="px-4 py-3 text-left text-sm font-medium text-muted-foreground">
Pris
</th>
<th className="px-4 py-3 text-left text-sm font-medium text-muted-foreground">
Status
</th>
<th className="hidden px-4 py-3 text-left text-sm font-medium text-muted-foreground lg:table-cell">
Dato
</th>
<th className="px-4 py-3 text-right text-sm font-medium text-muted-foreground"></th>
</tr>
</thead>
<tbody>
{filteredQuotes.map((quote) => {
const slug = `${quote.postalCode}-${quote.id}`
const status = STATUS_LABELS[quote.status]
return (
<tr key={quote.id} className="border-b last:border-0 hover:bg-muted/30">
<td className="px-4 py-3 text-sm text-muted-foreground">{quote.id}</td>
<td className="px-4 py-3">
<div className="font-medium">{quote.customerName}</div>
<div className="text-sm text-muted-foreground">{quote.customerEmail}</div>
</td>
<td className="hidden px-4 py-3 text-sm md:table-cell">
{quote.postalCode}
{quote.address && (
<span className="text-muted-foreground">, {quote.address}</span>
)}
</td>
<td className="hidden px-4 py-3 text-sm sm:table-cell">{quote.area} m²</td>
<td className="px-4 py-3 font-medium">
{quote.totalInclVat ? formatPrice(Math.round(quote.totalInclVat)) : "—"}
</td>
<td className="px-4 py-3">
<span
className={`inline-block rounded-full px-2 py-0.5 text-xs font-medium ${status.className}`}
>
{status.label}
</span>
</td>
<td className="hidden px-4 py-3 text-sm text-muted-foreground lg:table-cell">
{formatDate(quote.createdAt)}
</td>
<td className="px-4 py-3 text-right">
<a
href={`/tilbud/${slug}`}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1 text-sm text-primary hover:underline"
>
Åbn
<ExternalLink className="h-3 w-3" />
</a>
</td>
</tr>
)
})}
</tbody>
</table>
</div>
{filteredQuotes.length === 0 && (
<div className="py-12 text-center text-muted-foreground">
{search ? "Ingen tilbud matcher søgningen" : "Ingen tilbud endnu"}
</div>
)}
</div>
</div>
</main>
)
}

BIN
app/icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

View file

@ -14,7 +14,8 @@ const geistMono = Geist_Mono({
export const metadata: Metadata = {
title: "Foam King Gulve - Prisberegner",
description: "Få et hurtigt prisoverslag på din nye gulvløsning med isolering, gulvvarme og støbning",
description:
"Få et hurtigt prisoverslag på din nye gulvløsning med isolering, gulvvarme og støbning",
}
export default function RootLayout({
@ -24,11 +25,9 @@ export default function RootLayout({
}>) {
return (
<html lang="da">
<body
className={`${geistSans.variable} ${geistMono.variable} font-sans antialiased`}
>
<body className={`${geistSans.variable} ${geistMono.variable} font-sans antialiased`}>
{children}
</body>
</html>
)
}
}

123
app/login/page.tsx Normal file
View file

@ -0,0 +1,123 @@
"use client"
import { useState } from "react"
import { useRouter } from "next/navigation"
import Image from "next/image"
import Link from "next/link"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Loader2 } from "lucide-react"
export default function LoginPage() {
const router = useRouter()
const [email, setEmail] = useState("")
const [password, setPassword] = useState("")
const [error, setError] = useState("")
const [loading, setLoading] = useState(false)
async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
setError("")
setLoading(true)
try {
const res = await fetch("/api/auth/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email, password }),
})
const data = await res.json()
if (!res.ok) {
setError(data.error || "Login fejlede")
return
}
router.push("/dashboard")
router.refresh()
} catch {
setError("Der opstod en fejl. Prøv igen.")
} finally {
setLoading(false)
}
}
return (
<main className="flex min-h-screen items-center justify-center bg-muted/30 p-4">
<div className="w-full max-w-sm">
<div className="rounded-2xl bg-white p-8 shadow-lg">
<div className="mb-8 text-center">
<Link href="/" className="inline-block">
<Image
src="/foam-king-logo.png"
alt="Foam King"
width={140}
height={56}
className="mx-auto h-12 w-auto"
/>
</Link>
<h1 className="mt-4 text-xl font-semibold">Log ind</h1>
<p className="mt-1 text-sm text-muted-foreground">Adgang til dashboard</p>
</div>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="email">Email</Label>
<Input
id="email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="din@email.dk"
required
autoComplete="email"
/>
</div>
<div className="space-y-2">
<Label htmlFor="password">Adgangskode</Label>
<Input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="••••••••"
required
autoComplete="current-password"
/>
</div>
{error && (
<div className="rounded-lg border border-red-200 bg-red-50 p-3 text-sm text-red-600">
{error}
</div>
)}
<Button
type="submit"
className="h-11 w-full bg-secondary hover:bg-secondary/90"
disabled={loading}
>
{loading ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Logger ind...
</>
) : (
"Log ind"
)}
</Button>
</form>
</div>
<p className="mt-6 text-center text-sm text-muted-foreground">
<Link href="/" className="hover:underline">
Tilbage til forsiden
</Link>
</p>
</div>
</main>
)
}

View file

@ -2,44 +2,61 @@
import { useState } from "react"
import Image from "next/image"
import { CalculatorForm } from "@/components/calculator/calculator-form"
import { CalculationDetailsView } from "@/components/calculator/calculation-details"
import { StepWizard } from "@/components/calculator/step-wizard"
import { Button } from "@/components/ui/button"
import type { CalculationDetails } from "@/lib/calculations"
import { formatEstimate } from "@/lib/calculations"
import { Send, Eye, EyeOff } from "lucide-react"
import { formatEstimate, type CalculationDetails } from "@/lib/calculations"
import {
Phone,
Mail,
MapPin,
CheckCircle2,
ArrowRight,
RotateCcw,
Loader2,
} from "lucide-react"
export default function Home() {
const [calculationResult, setCalculationResult] = useState<CalculationDetails | null>(null)
const [showAdminMode, setShowAdminMode] = useState(false)
const [isRequestingQuote, setIsRequestingQuote] = useState(false)
const [customerInfo, setCustomerInfo] = useState<any>(null)
const [result, setResult] = useState<CalculationDetails | null>(null)
const [customerData, setCustomerData] = useState<any>(null)
const [showResult, setShowResult] = useState(false)
const handleCalculation = (result: CalculationDetails, formData?: any) => {
setCalculationResult(result)
if (formData) {
setCustomerInfo(formData)
}
const handleComplete = (calculationResult: CalculationDetails, formData: any) => {
setResult(calculationResult)
setCustomerData(formData)
setShowResult(true)
}
const handleQuoteRequest = async () => {
if (!calculationResult || !customerInfo) return
setIsRequestingQuote(true)
const handleReset = () => {
setResult(null)
setCustomerData(null)
setShowResult(false)
}
const [isRequesting, setIsRequesting] = useState(false)
const handleRequestQuote = async () => {
if (!result || !customerData) return
setIsRequesting(true)
try {
const response = await fetch("/api/quote-request", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
customerInfo,
calculationDetails: calculationResult,
customerInfo: {
name: customerData.name,
email: customerData.email,
phone: customerData.phone,
postalCode: customerData.postalCode,
address: customerData.address,
remarks: customerData.remarks,
},
calculationDetails: result,
}),
})
const data = await response.json()
if (response.ok) {
alert(data.message)
} else {
@ -48,102 +65,271 @@ export default function Home() {
} catch (error) {
alert("Der opstod en fejl. Prøv igen senere.")
} finally {
setIsRequestingQuote(false)
setIsRequesting(false)
}
}
return (
<main className="min-h-screen bg-gradient-to-b from-background to-muted/20">
<div className="container mx-auto px-4 py-8">
{/* Header */}
<div className="mb-8 text-center">
<div className="mb-4 flex justify-center">
<Image
src="/foam-king-logo.png"
alt="Foam King Gulve"
width={200}
height={80}
priority
className="h-20 w-auto"
/>
</div>
<h1 className="text-3xl font-bold">Foam King Gulve</h1>
<p className="mt-2 text-lg text-muted-foreground">
Professionelle gulvløsninger med isolering, gulvvarme og støbning
</p>
<main className="min-h-screen bg-background">
{/* Hero Section */}
<section className="relative flex min-h-[70vh] items-center justify-center overflow-hidden">
{/* Background Image */}
<div className="absolute inset-0 z-0">
<Image
src="/gulv.jpeg"
alt="Smukt gulv i moderne hjem"
fill
className="object-cover"
priority
/>
<div className="absolute inset-0 bg-gradient-to-b from-black/60 via-black/40 to-black/70" />
</div>
{/* Admin Mode Toggle */}
<div className="mb-4 flex justify-center">
{/* Hero Content */}
<div className="container relative z-10 mx-auto px-4 text-center text-white">
<div className="mb-6">
<Image
src="/foam-king-logo.png"
alt="Foam King"
width={180}
height={72}
className="mx-auto h-16 w-auto brightness-0 invert"
priority
/>
</div>
<h1 className="mb-4 text-4xl font-bold tracking-tight sm:text-5xl md:text-6xl">
Gulvarbejde i<br />
<span className="text-secondary">verdensklasse</span>
</h1>
<p className="mx-auto mb-8 max-w-2xl text-lg text-white/90 sm:text-xl">
Professionel udførelse af betongulve, gulvvarme og isolering. Vi leverer kvalitet der
holder i mange år fremover.
</p>
<div className="mb-8 flex flex-wrap justify-center gap-4">
<div className="flex items-center gap-2 rounded-full bg-white/10 px-4 py-2 backdrop-blur-sm">
<CheckCircle2 className="h-5 w-5 text-secondary" />
<span>Stor erfaring</span>
</div>
<div className="flex items-center gap-2 rounded-full bg-white/10 px-4 py-2 backdrop-blur-sm">
<CheckCircle2 className="h-5 w-5 text-secondary" />
<span>Byg Garanti</span>
</div>
<div className="flex items-center gap-2 rounded-full bg-white/10 px-4 py-2 backdrop-blur-sm">
<CheckCircle2 className="h-5 w-5 text-secondary" />
<span>Gratis tilbud</span>
</div>
</div>
<Button
variant="ghost"
size="sm"
onClick={() => setShowAdminMode(!showAdminMode)}
className="gap-2"
size="lg"
className="h-14 bg-secondary px-8 text-lg text-secondary-foreground hover:bg-secondary/90"
onClick={() =>
document.getElementById("calculator")?.scrollIntoView({ behavior: "smooth" })
}
>
{showAdminMode ? (
<>
<EyeOff className="h-4 w-4" />
Skjul detaljer
</>
) : (
<>
<Eye className="h-4 w-4" />
Vis detaljer
</>
)}
dit prisoverslag
<ArrowRight className="ml-2 h-5 w-5" />
</Button>
</div>
{/* Calculator */}
<div className="mx-auto max-w-6xl">
<div className="grid gap-8 lg:grid-cols-2">
<div className="flex justify-center">
<CalculatorForm
onCalculation={handleCalculation}
showDetails={showAdminMode}
{/* Scroll Indicator */}
<div className="absolute bottom-8 left-1/2 z-10 -translate-x-1/2 animate-bounce">
<div className="flex h-12 w-8 items-start justify-center rounded-full border-2 border-white/50 pt-2">
<div className="h-3 w-1 rounded-full bg-white/70" />
</div>
</div>
</section>
{/* Calculator Section */}
<section
id="calculator"
className="bg-gradient-to-b from-muted/50 to-background py-16 sm:py-24"
>
<div className="container mx-auto px-4">
<div className="mb-12 text-center">
<p className="mb-2 font-semibold text-secondary">Prisberegner</p>
<h2 className="mb-4 text-3xl font-bold sm:text-4xl"> dit personlige tilbud</h2>
<p className="mx-auto max-w-xl text-muted-foreground">
Besvar nogle spørgsmål, kan give dig den mest nøjagtige prisberegning.
<br />
Det tager kun 2 minutter.
</p>
</div>
{!showResult ? (
<StepWizard onComplete={handleComplete} />
) : (
/* Result Card */
<div className="mx-auto max-w-lg">
<div className="rounded-2xl bg-white p-8 text-center shadow-lg">
<div className="mx-auto mb-6 flex h-16 w-16 items-center justify-center rounded-full bg-green-100">
<CheckCircle2 className="h-8 w-8 text-green-600" />
</div>
<h3 className="mb-2 text-2xl font-bold">Dit prisoverslag</h3>
<p className="mb-6 text-muted-foreground">
Baseret {result?.area} m² gulv i {customerData?.postalCode}
</p>
<div className="mb-6 rounded-xl bg-gradient-to-br from-primary/10 to-secondary/10 p-6">
<p className="mb-2 text-3xl font-bold text-primary sm:text-5xl">
{result && formatEstimate(result.totalInclVat)}
</p>
<p className="text-muted-foreground">inkl. moms</p>
</div>
<div className="mb-6 rounded-lg bg-muted/30 p-4 text-left text-sm">
<p className="mb-2 font-medium">Inkluderet i prisen:</p>
<ul className="space-y-1 text-muted-foreground">
{result?.includeInsulation && (
<li className="flex items-center gap-2">
<CheckCircle2 className="h-4 w-4 text-green-600" />
Isolering ({result.insulationThickness} cm)
</li>
)}
{result?.includeFloorHeating && (
<li className="flex items-center gap-2">
<CheckCircle2 className="h-4 w-4 text-green-600" />
Gulvvarme syntetisk net + Ø16 PEX (excl. tilslutning)
</li>
)}
{result?.includeCompound && (
<li className="flex items-center gap-2">
<CheckCircle2 className="h-4 w-4 text-green-600" />
Flydespartel (støbning)
</li>
)}
<li className="flex items-center gap-2">
<CheckCircle2 className="h-4 w-4 text-green-600" />
Transport til {customerData?.postalCode}
</li>
</ul>
</div>
<p className="mb-6 text-xs text-muted-foreground">
*Prisen er vejledende og kan variere med ±10.000 kr afhængigt af konkrete forhold
</p>
<div className="flex flex-col gap-3">
<Button
size="lg"
className="h-12 w-full bg-secondary text-secondary-foreground hover:bg-secondary/90"
onClick={handleRequestQuote}
disabled={isRequesting}
>
{isRequesting ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Sender...
</>
) : (
<>
<Mail className="mr-2 h-4 w-4" />
Anmod om bindende tilbud
</>
)}
</Button>
<Button
variant="outline"
size="lg"
className="h-12 w-full"
onClick={handleReset}
>
<RotateCcw className="mr-2 h-4 w-4" />
Ny beregning
</Button>
</div>
</div>
</div>
)}
</div>
</section>
{/* Features Section */}
<section className="bg-muted/30 py-16 sm:py-24">
<div className="container mx-auto px-4">
<div className="grid gap-6 md:grid-cols-3">
<div className="flex h-32 items-center justify-center rounded-2xl bg-white p-6 shadow-md transition-shadow hover:shadow-lg">
<Image
src="/dansk_kvalitet.png"
alt="Dansk Kvalitet"
width={75}
height={100}
className="h-20 w-auto object-contain"
/>
</div>
{/* Results */}
{calculationResult && (
<div className="space-y-6">
{!showAdminMode ? (
<div className="rounded-xl bg-card p-8 text-center shadow">
<h2 className="mb-4 text-xl font-semibold">Dit prisoverslag</h2>
<p className="text-4xl font-bold text-primary">
{formatEstimate(calculationResult.totalInclVat)}
</p>
<p className="mt-2 text-sm text-muted-foreground">inkl. moms</p>
<p className="mt-4 text-sm text-muted-foreground">
*Prisen er vejledende og kan variere med ±10.000 kr afhængigt af konkrete forhold
</p>
<Button
onClick={handleQuoteRequest}
size="lg"
className="mt-6 gap-2"
disabled={isRequestingQuote}
>
<Send className="h-4 w-4" />
Anmod om bindende tilbud
</Button>
</div>
) : (
<CalculationDetailsView details={calculationResult} />
)}
</div>
)}
<div className="flex h-32 items-center justify-center rounded-2xl bg-white p-6 shadow-md transition-shadow hover:shadow-lg">
<Image
src="/byg_trans.png"
alt="Byg Garanti"
width={360}
height={97}
className="h-20 w-auto object-contain"
/>
</div>
<div className="flex h-32 items-center justify-center rounded-2xl bg-white p-6 shadow-md transition-shadow hover:shadow-lg">
<Image
src="/tilfredshed_service.png"
alt="Tilfredshed & Service"
width={75}
height={100}
className="h-20 w-auto object-contain"
/>
</div>
</div>
</div>
</section>
{/* Footer */}
<footer className="mt-16 border-t pt-8 text-center text-sm text-muted-foreground">
<p>Foam King Gulve · Asnæs · CVR: 12345678</p>
<p className="mt-1">
Vi dækker Sjælland, Lolland-Falster og Fyn
{/* Coverage Section */}
<section className="bg-white py-16">
<div className="container mx-auto px-4 text-center">
<h2 className="mb-4 text-2xl font-bold">Vi dækker hele Østdanmark</h2>
<p className="mb-6 text-muted-foreground">
Sjælland · København · Nordsjælland · Lolland-Falster · Fyn
</p>
</footer>
</div>
<div className="flex justify-center gap-4">
<a href="tel:35901066" className="flex items-center gap-2 text-primary hover:underline">
<Phone className="h-4 w-4" />
35 90 10 66
</a>
<a
href="mailto:info@foamking.dk"
className="flex items-center gap-2 text-primary hover:underline"
>
<Mail className="h-4 w-4" />
info@foamking.dk
</a>
</div>
</div>
</section>
{/* Footer */}
<footer className="bg-foreground py-8 text-background">
<div className="container mx-auto px-4">
<div className="flex flex-col items-center justify-between gap-4 md:flex-row">
<div className="flex items-center gap-4">
<Image
src="/foam-king-logo.png"
alt="Foam King"
width={120}
height={48}
className="h-10 w-auto brightness-0 invert"
/>
<div className="text-sm text-background/70">
<p>Foam King ApS · CVR: 44 48 54 51</p>
<p className="flex items-center gap-1">
<MapPin className="h-3 w-3" />
Søgårdsvej 7, 4550 Asnæs
</p>
</div>
</div>
<p className="text-sm text-background/50">
© {new Date().getFullYear()} Foam King. Alle rettigheder forbeholdes.
</p>
</div>
</div>
</footer>
</main>
)
}
}

207
app/tilbud/[slug]/page.tsx Normal file
View file

@ -0,0 +1,207 @@
import { notFound } from "next/navigation"
import Image from "next/image"
import Link from "next/link"
import { getQuoteBySlug } from "@/lib/db"
import { calculatePrice, formatEstimate } from "@/lib/calculations"
import { getDistance } from "@/lib/distance"
import { CalculationDetailsView } from "@/components/calculator/calculation-details"
import { ArrowLeft, CheckCircle2, Calendar } from "lucide-react"
import { FLOORING_TYPES } from "@/lib/constants"
interface PageProps {
params: Promise<{ slug: string }>
}
export default async function TilbudPage({ params }: PageProps) {
const { slug } = await params
const quote = getQuoteBySlug(slug)
if (!quote) {
notFound()
}
// Recalculate the price based on stored inputs
const distance = getDistance(quote.postalCode)
const calculation = calculatePrice({
area: quote.area,
height: quote.height,
postalCode: quote.postalCode,
distance,
includeInsulation: true,
includeFloorHeating: quote.includeFloorHeating,
includeCompound: true,
flooringType: quote.flooringType as keyof typeof FLOORING_TYPES,
})
const createdDate = new Date(quote.createdAt).toLocaleDateString("da-DK", {
day: "numeric",
month: "long",
year: "numeric",
})
return (
<main className="min-h-screen bg-muted/30">
{/* Header */}
<header className="border-b bg-white">
<div className="container mx-auto flex items-center justify-between px-4 py-4">
<Link href="/" className="flex items-center gap-2">
<Image
src="/foam-king-logo.png"
alt="Foam King"
width={140}
height={56}
className="h-12 w-auto"
/>
</Link>
<div className="text-sm text-muted-foreground">Tilbud #{quote.id}</div>
</div>
</header>
<div className="container mx-auto max-w-4xl px-4 py-8">
<Link
href="/"
className="mb-6 inline-flex items-center gap-2 text-sm text-muted-foreground hover:text-foreground"
>
<ArrowLeft className="h-4 w-4" />
Tilbage til forsiden
</Link>
{/* Quote Header */}
<div className="mb-6 rounded-2xl bg-white p-8 shadow-lg">
<div className="mb-6 flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
<div>
<h1 className="text-2xl font-bold">Prisoverslag</h1>
<p className="mt-1 flex items-center gap-2 text-muted-foreground">
<Calendar className="h-4 w-4" />
Oprettet {createdDate}
</p>
</div>
</div>
{/* Customer Info */}
<div className="mb-6 rounded-xl bg-muted/50 p-4">
<h3 className="mb-3 font-semibold">Kundeoplysninger</h3>
<dl className="grid gap-x-6 gap-y-2 text-sm md:grid-cols-2">
<div className="flex justify-between md:block">
<dt className="text-muted-foreground">Navn:</dt>
<dd className="font-medium">{quote.customerName}</dd>
</div>
<div className="flex justify-between md:block">
<dt className="text-muted-foreground">Email:</dt>
<dd className="font-medium">
<a
href={`mailto:${quote.customerEmail}`}
className="text-primary hover:underline"
>
{quote.customerEmail}
</a>
</dd>
</div>
<div className="flex justify-between md:block">
<dt className="text-muted-foreground">Telefon:</dt>
<dd className="font-medium">
<a href={`tel:${quote.customerPhone}`} className="text-primary hover:underline">
{quote.customerPhone}
</a>
</dd>
</div>
<div className="flex justify-between md:block">
<dt className="text-muted-foreground">Adresse:</dt>
<dd className="font-medium">
{quote.postalCode}
{quote.address ? `, ${quote.address}` : ""}
</dd>
</div>
</dl>
{quote.remarks && (
<div className="mt-4 rounded-lg border border-amber-200 bg-amber-50 p-3">
<div className="text-sm font-medium text-amber-800">Bemærkninger:</div>
<p className="mt-1 text-sm text-amber-700">{quote.remarks}</p>
</div>
)}
</div>
{/* Price Box */}
<div className="mb-6 rounded-xl bg-gradient-to-br from-primary/10 to-secondary/10 p-6 text-center">
<p className="mb-2 text-5xl font-bold text-primary">
{formatEstimate(calculation.totalInclVat)}
</p>
<p className="text-muted-foreground">inkl. moms</p>
</div>
{/* Project Details */}
<div className="grid gap-6 md:grid-cols-2">
<div>
<h3 className="mb-3 font-semibold">Projektdetaljer</h3>
<dl className="space-y-2 text-sm">
<div className="flex justify-between">
<dt className="text-muted-foreground">Areal:</dt>
<dd className="font-medium">{quote.area} m²</dd>
</div>
<div className="flex justify-between">
<dt className="text-muted-foreground">Gulvhøjde:</dt>
<dd className="font-medium">{quote.height} cm</dd>
</div>
<div className="flex justify-between">
<dt className="text-muted-foreground">Placering:</dt>
<dd className="font-medium">
{quote.postalCode}
{quote.address ? `, ${quote.address}` : ""}
</dd>
</div>
<div className="flex justify-between">
<dt className="text-muted-foreground">Gulvbelægning:</dt>
<dd className="font-medium">
{FLOORING_TYPES[quote.flooringType as keyof typeof FLOORING_TYPES]?.name ||
quote.flooringType}
</dd>
</div>
</dl>
</div>
<div>
<h3 className="mb-3 font-semibold">Inkluderet i prisen</h3>
<ul className="space-y-2 text-sm">
<li className="flex items-center gap-2">
<CheckCircle2 className="h-4 w-4 text-green-600" />
Isolering ({calculation.insulationThickness} cm)
</li>
{quote.includeFloorHeating && (
<li className="flex items-center gap-2">
<CheckCircle2 className="h-4 w-4 text-green-600" />
Gulvvarme syntetisk net + Ø16 PEX (excl. tilslutning)
</li>
)}
<li className="flex items-center gap-2">
<CheckCircle2 className="h-4 w-4 text-green-600" />
Flydespartel (støbning)
</li>
<li className="flex items-center gap-2">
<CheckCircle2 className="h-4 w-4 text-green-600" />
Transport
</li>
</ul>
</div>
</div>
<p className="mt-6 text-center text-xs text-muted-foreground">
*Prisen er vejledende og kan variere med ±10.000 kr afhængigt af konkrete forhold
</p>
</div>
{/* Detailed Calculation */}
<div className="mb-6 rounded-2xl bg-white p-8 shadow-lg">
<h2 className="mb-4 text-xl font-bold">Detaljeret prisberegning</h2>
<CalculationDetailsView details={calculation} />
</div>
</div>
{/* Footer */}
<footer className="mt-8 border-t bg-white py-8">
<div className="container mx-auto px-4 text-center text-sm text-muted-foreground">
<p>Foam King ApS · Søgårdsvej 7, 4550 Asnæs · CVR: 44 48 54 51</p>
</div>
</footer>
</main>
)
}

View file

@ -1,19 +1,19 @@
import { AlertTriangle, CheckCircle, Check, X } from "lucide-react"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { formatPrice, type CalculationDetails } from "@/lib/calculations"
import { PRICES, CONSTRAINTS } from "@/lib/constants"
import { PRICES, CONSTRAINTS, FLOORING_TYPES } from "@/lib/constants"
interface CalculationDetailsProps {
details: CalculationDetails
distanceSource?: "openrouteservice" | "table" | null
}
export function CalculationDetailsView({ details }: CalculationDetailsProps) {
export function CalculationDetailsView({ details, distanceSource }: CalculationDetailsProps) {
return (
<Card className="w-full">
<CardHeader>
<CardTitle>Detaljeret Prisberegning</CardTitle>
<CardDescription>
Komplet oversigt over alle delpriser og beregninger
</CardDescription>
<CardDescription>Komplet oversigt over alle delpriser og beregninger</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
{/* Input Values */}
@ -39,13 +39,68 @@ export function CalculationDetailsView({ details }: CalculationDetailsProps) {
</div>
</div>
{/* Selected Components */}
<div>
<h3 className="mb-2 font-semibold">Valgte komponenter</h3>
<div className="grid gap-2 text-sm">
<div className="flex justify-between">
<span className="flex items-center gap-2 text-muted-foreground">
{details.includeInsulation ? (
<Check className="h-4 w-4 text-green-600" />
) : (
<X className="h-4 w-4 text-red-500" />
)}
Isolering:
</span>
<span className={details.includeInsulation ? "" : "text-muted-foreground"}>
{details.includeInsulation ? "Inkluderet" : "Fravalgt"}
</span>
</div>
<div className="flex justify-between">
<span className="flex items-center gap-2 text-muted-foreground">
{details.includeFloorHeating ? (
<Check className="h-4 w-4 text-green-600" />
) : (
<X className="h-4 w-4 text-red-500" />
)}
Gulvvarme:
</span>
<span className={details.includeFloorHeating ? "" : "text-muted-foreground"}>
{details.includeFloorHeating ? "Inkluderet" : "Fravalgt"}
</span>
</div>
<div className="flex justify-between">
<span className="flex items-center gap-2 text-muted-foreground">
{details.includeCompound ? (
<Check className="h-4 w-4 text-green-600" />
) : (
<X className="h-4 w-4 text-red-500" />
)}
Gulvstøbning:
</span>
<span className={details.includeCompound ? "" : "text-muted-foreground"}>
{details.includeCompound ? "Inkluderet" : "Fravalgt"}
</span>
</div>
{details.includeCompound && (
<div className="flex justify-between">
<span className="text-muted-foreground">Gulvbelægning:</span>
<span>{FLOORING_TYPES[details.flooringType]?.name || details.flooringType}</span>
</div>
)}
</div>
</div>
{/* Calculated Values */}
<div>
<h3 className="mb-2 font-semibold">Beregnede værdier</h3>
<div className="grid gap-2 text-sm">
<div className="flex justify-between">
<span className="text-muted-foreground">Isoleringstykkelse:</span>
<span>{details.insulationThickness} cm ({details.height} - {CONSTRAINTS.CONCRETE_THICKNESS} cm beton)</span>
<span>
{details.insulationThickness} cm ({details.height} -{" "}
{CONSTRAINTS.CONCRETE_THICKNESS} cm beton)
</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Isoleringsvolumen:</span>
@ -53,7 +108,10 @@ export function CalculationDetailsView({ details }: CalculationDetailsProps) {
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Spartelvægt:</span>
<span>{details.compoundWeight.toLocaleString("da-DK")} kg ({details.area} m² × {PRICES.COMPOUND_WEIGHT_PER_M2} kg/m²)</span>
<span>
{details.compoundWeight.toLocaleString("da-DK")} kg ({details.area} m² ×{" "}
{PRICES.COMPOUND_WEIGHT_PER_M2} kg/m²)
</span>
</div>
</div>
</div>
@ -62,33 +120,61 @@ export function CalculationDetailsView({ details }: CalculationDetailsProps) {
<div>
<h3 className="mb-2 font-semibold">Komponent priser</h3>
<div className="grid gap-2 text-sm">
<div className="flex justify-between">
<div
className={`flex justify-between ${!details.includeInsulation ? "opacity-50" : ""}`}
>
<span className="text-muted-foreground">
Isolering {details.insulationThickness > 0 ? `(${details.insulationVolume.toFixed(2)}× ${formatPrice(PRICES.INSULATION_TOTAL)}/m³)` : "(simpel arbejdsløn)"}:
Isolering{" "}
{details.includeInsulation
? details.insulationThickness > 0
? `(${details.insulationVolume.toFixed(2)}× ${formatPrice(PRICES.INSULATION_TOTAL_PER_M3)}/m³)`
: "(simpel arbejdsløn)"
: "(fravalgt)"}
:
</span>
<span className="font-medium">{formatPrice(details.insulation)}</span>
</div>
<div className="flex justify-between">
<div
className={`flex justify-between ${!details.includeFloorHeating ? "opacity-50" : ""}`}
>
<span className="text-muted-foreground">
Gulvvarme ({details.area} m² × {formatPrice(PRICES.FLOOR_HEATING_TOTAL)}/m²):
Gulvvarme{" "}
{details.includeFloorHeating
? `(${details.area}× ${formatPrice(PRICES.FLOOR_HEATING_TOTAL)}/m²)`
: "(fravalgt)"}
:
</span>
<span className="font-medium">{formatPrice(details.floorHeating)}</span>
</div>
<div className="flex justify-between">
<div
className={`flex justify-between ${!details.includeFloorHeating ? "opacity-50" : ""}`}
>
<span className="text-muted-foreground">
Syntetisk net ({details.area} m² × {formatPrice(PRICES.SYNTHETIC_NET_TOTAL)}/m²):
Syntetisk net{" "}
{details.includeFloorHeating
? `(${details.area}× ${formatPrice(PRICES.SYNTHETIC_NET_TOTAL)}/m²)`
: "(fravalgt)"}
:
</span>
<span className="font-medium">{formatPrice(details.syntheticNet)}</span>
</div>
<div className="flex justify-between">
<div className={`flex justify-between ${!details.includeCompound ? "opacity-50" : ""}`}>
<span className="text-muted-foreground">
Flydespartel ({details.area} m² × {formatPrice(PRICES.SELF_LEVELING_COMPOUND)}/m²):
Flydespartel{" "}
{details.includeCompound
? `(${details.area}× ${formatPrice(PRICES.SELF_LEVELING_COMPOUND)}/m²${FLOORING_TYPES[details.flooringType]?.compoundMultiplier > 1 ? " +28%" : ""})`
: "(fravalgt)"}
:
</span>
<span className="font-medium">{formatPrice(details.selfLevelingCompound)}</span>
</div>
<div className="flex justify-between">
<div className={`flex justify-between ${!details.includeCompound ? "opacity-50" : ""}`}>
<span className="text-muted-foreground">
Pumpebil-tillæg ({details.compoundWeight.toLocaleString("da-DK")} kg):
Pumpebil-tillæg{" "}
{details.includeCompound
? `(${details.compoundWeight.toLocaleString("da-DK")} kg)`
: "(fravalgt)"}
:
</span>
<span className="font-medium">{formatPrice(details.pumpTruckFee)}</span>
</div>
@ -142,6 +228,27 @@ export function CalculationDetailsView({ details }: CalculationDetailsProps) {
<span>{formatPrice(details.bridgeFee)}</span>
</div>
)}
{distanceSource && (
<div
className={`mt-2 flex items-center gap-2 rounded-md p-2 text-xs ${
distanceSource === "openrouteservice"
? "bg-green-50 text-green-700"
: "bg-amber-50 text-amber-700"
}`}
>
{distanceSource === "openrouteservice" ? (
<>
<CheckCircle className="h-3 w-3" />
<span>Præcis afstand via OpenRouteService</span>
</>
) : (
<>
<AlertTriangle className="h-3 w-3" />
<span>Præcis afstandsberegning ikke mulig - overslag brugt</span>
</>
)}
</div>
)}
</div>
</div>
@ -166,4 +273,4 @@ export function CalculationDetailsView({ details }: CalculationDetailsProps) {
</CardContent>
</Card>
)
}
}

View file

@ -1,29 +1,41 @@
"use client"
import { useState } from "react"
import { useForm } from "react-hook-form"
import { useForm, Controller } from "react-hook-form"
import { zodResolver } from "@hookform/resolvers/zod"
import * as z from "zod"
import { Calculator, Loader2 } from "lucide-react"
import { Calculator, Loader2, Thermometer, Layers, PaintBucket } from "lucide-react"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Textarea } from "@/components/ui/textarea"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { CONSTRAINTS } from "@/lib/constants"
import { validateDanishPostalCode, isInCoverageArea, getDistance } from "@/lib/distance"
import { calculatePrice, formatEstimate, type CalculationDetails } from "@/lib/calculations"
import { Switch } from "@/components/ui/switch"
import { Slider } from "@/components/ui/slider"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import { Progress } from "@/components/ui/progress"
import { CONSTRAINTS, FLOORING_TYPES, type FlooringType } from "@/lib/constants"
import { validateDanishPostalCode, getDistance } from "@/lib/distance"
import { calculatePrice, type CalculationDetails } from "@/lib/calculations"
const formSchema = z.object({
name: z.string().min(2, "Navn skal være mindst 2 tegn"),
name: z.string().refine((val) => {
const parts = val.trim().split(/\s+/)
return parts.length >= 2 && parts[0].length >= 3 && parts[1].length >= 3
}, "Indtast fornavn og efternavn (mindst 3 tegn hver)"),
email: z.string().email("Ugyldig email"),
phone: z.string().regex(/^\d{8}$/, "Telefonnummer skal være 8 cifre"),
postalCode: z
.string()
.length(4, "Postnummer skal være 4 cifre")
.refine(validateDanishPostalCode, "Ugyldigt dansk postnummer")
.refine(isInCoverageArea, "Beklager, vi dækker ikke dette område"),
.refine(validateDanishPostalCode, "Ugyldigt dansk postnummer"),
address: z.string().optional(),
area: z.coerce
.number()
@ -34,183 +46,424 @@ const formSchema = z.object({
.min(CONSTRAINTS.MIN_HEIGHT, `Minimum højde er ${CONSTRAINTS.MIN_HEIGHT} cm`)
.max(CONSTRAINTS.MAX_HEIGHT, `Maximum højde er ${CONSTRAINTS.MAX_HEIGHT} cm`),
remarks: z.string().optional(),
includeInsulation: z.boolean(),
includeFloorHeating: z.boolean(),
includeCompound: z.boolean(),
flooringType: z.string(),
})
type FormData = z.infer<typeof formSchema>
interface CalculatorFormProps {
onCalculation: (result: CalculationDetails, formData?: FormData) => void
onCalculation: (
result: CalculationDetails,
formData?: FormData,
distanceSource?: "openrouteservice" | "table"
) => void
showDetails?: boolean
}
interface CalculationProgress {
step: string
progress: number
}
export function CalculatorForm({ onCalculation, showDetails = false }: CalculatorFormProps) {
const [isCalculating, setIsCalculating] = useState(false)
const [calculationProgress, setCalculationProgress] = useState<CalculationProgress | null>(null)
const [result, setResult] = useState<CalculationDetails | null>(null)
const [distanceSource, setDistanceSource] = useState<"openrouteservice" | "table" | null>(null)
const {
register,
handleSubmit,
formState: { errors },
watch,
control,
} = useForm<FormData>({
resolver: zodResolver(formSchema),
defaultValues: {
name: "",
email: "",
phone: "",
postalCode: "",
address: "",
area: 50,
height: 15,
remarks: "",
includeInsulation: true,
includeFloorHeating: true,
includeCompound: true,
flooringType: "STANDARD",
},
})
const watchedIncludeCompound = watch("includeCompound")
const onSubmit = async (data: FormData) => {
setIsCalculating(true)
setDistanceSource(null)
setCalculationProgress({ step: "Finder din adresse...", progress: 20 })
try {
// Simulate API delay
await new Promise((resolve) => setTimeout(resolve, 500))
const distance = getDistance(data.postalCode)
let distance: number
let source: "openrouteservice" | "table" = "table"
try {
setCalculationProgress({ step: "Beregner afstand...", progress: 40 })
const params = new URLSearchParams({
postalCode: data.postalCode,
...(data.address && { address: data.address }),
})
const distanceResponse = await fetch(`/api/distance?${params}`)
const distanceData = await distanceResponse.json()
distance = distanceData.distance
source = distanceData.source
} catch {
distance = getDistance(data.postalCode)
source = "table"
}
setDistanceSource(source)
setCalculationProgress({ step: "Beregner pris...", progress: 70 })
await new Promise((resolve) => setTimeout(resolve, 200))
const calculationResult = calculatePrice({
area: data.area,
height: data.height,
postalCode: data.postalCode,
distance,
includeInsulation: data.includeInsulation,
includeFloorHeating: data.includeFloorHeating,
includeCompound: data.includeCompound,
flooringType: data.flooringType as FlooringType,
})
setCalculationProgress({ step: "Færdig!", progress: 100 })
await new Promise((resolve) => setTimeout(resolve, 300))
setResult(calculationResult)
onCalculation(calculationResult, data)
onCalculation(calculationResult, data, source)
} finally {
setIsCalculating(false)
setCalculationProgress(null)
}
}
return (
<Card className="w-full max-w-2xl">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-2xl">
<Calculator className="h-6 w-6" />
<Card className="w-full max-w-2xl shadow-lg">
<CardHeader className="rounded-t-lg bg-gradient-to-r from-secondary/10 to-secondary/5">
<CardTitle className="flex items-center gap-3 text-2xl">
<div className="rounded-full bg-primary p-2">
<Calculator className="h-5 w-5 text-secondary-foreground" />
</div>
Prisberegner
</CardTitle>
<CardDescription>
<CardDescription className="text-base">
et hurtigt overslag din nye gulvløsning
</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit(onSubmit)} className="space-y-6">
<div className="grid gap-4 sm:grid-cols-2">
<div>
<Label htmlFor="name">Navn *</Label>
<Input
id="name"
{...register("name")}
placeholder="Dit navn"
className="mt-1"
/>
{errors.name && (
<p className="mt-1 text-sm text-destructive">{errors.name.message}</p>
)}
<CardContent className="pt-6">
<form onSubmit={handleSubmit(onSubmit)} className="space-y-8">
{/* Contact Section */}
<section>
<h3 className="mb-4 text-sm font-semibold uppercase tracking-wide text-muted-foreground">
Kontaktoplysninger
</h3>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="name">Navn</Label>
<Input id="name" {...register("name")} placeholder="Dit navn" />
{errors.name && <p className="text-sm text-destructive">{errors.name.message}</p>}
</div>
<div className="space-y-2">
<Label htmlFor="email">Email</Label>
<Input id="email" type="email" {...register("email")} placeholder="din@email.dk" />
{errors.email && <p className="text-sm text-destructive">{errors.email.message}</p>}
</div>
<div className="space-y-2">
<Label htmlFor="phone">Telefon</Label>
<Input id="phone" {...register("phone")} placeholder="12345678" />
{errors.phone && <p className="text-sm text-destructive">{errors.phone.message}</p>}
</div>
<div className="space-y-2">
<Label htmlFor="postalCode">Postnummer</Label>
<Input id="postalCode" {...register("postalCode")} placeholder="4550" />
{errors.postalCode && (
<p className="text-sm text-destructive">{errors.postalCode.message}</p>
)}
</div>
<div className="space-y-2 sm:col-span-2">
<Label htmlFor="address">
Adresse <span className="font-normal text-muted-foreground">(valgfrit)</span>
</Label>
<Input id="address" {...register("address")} placeholder="Vejnavn og nummer" />
</div>
</div>
<div>
<Label htmlFor="email">Email *</Label>
<Input
id="email"
type="email"
{...register("email")}
placeholder="din@email.dk"
className="mt-1"
/>
{errors.email && (
<p className="mt-1 text-sm text-destructive">{errors.email.message}</p>
)}
</section>
{/* Floor Dimensions Section */}
<section>
<h3 className="mb-4 text-sm font-semibold uppercase tracking-wide text-muted-foreground">
Gulvmål
</h3>
<div className="space-y-6 rounded-xl bg-muted/30 p-5">
{/* Area Slider */}
<div className="space-y-3">
<div className="flex items-center justify-between">
<Label className="text-base">Gulvareal</Label>
<div className="flex items-baseline gap-1">
<Controller
name="area"
control={control}
render={({ field }) => (
<Input
type="number"
value={field.value}
onChange={(e) => field.onChange(Number(e.target.value))}
className="h-8 w-16 border-0 bg-transparent p-0 text-right text-lg font-semibold"
min={CONSTRAINTS.MIN_AREA}
max={CONSTRAINTS.MAX_AREA}
/>
)}
/>
<span className="text-muted-foreground">m²</span>
</div>
</div>
<Controller
name="area"
control={control}
render={({ field }) => (
<Slider
min={CONSTRAINTS.MIN_AREA}
max={CONSTRAINTS.MAX_AREA}
step={5}
value={[field.value || CONSTRAINTS.MIN_AREA]}
onValueChange={([value]) => field.onChange(value)}
className="py-2"
/>
)}
/>
<div className="flex justify-between text-xs text-muted-foreground">
<span>{CONSTRAINTS.MIN_AREA} m²</span>
<span>{CONSTRAINTS.MAX_AREA} m²</span>
</div>
{errors.area && <p className="text-sm text-destructive">{errors.area.message}</p>}
</div>
{/* Height Slider */}
<div className="space-y-3">
<div className="flex items-center justify-between">
<Label className="text-base">Gulvhøjde</Label>
<div className="flex items-baseline gap-1">
<Controller
name="height"
control={control}
render={({ field }) => (
<Input
type="number"
value={field.value}
onChange={(e) => field.onChange(Number(e.target.value))}
className="h-8 w-16 border-0 bg-transparent p-0 text-right text-lg font-semibold"
min={CONSTRAINTS.MIN_HEIGHT}
max={CONSTRAINTS.MAX_HEIGHT}
/>
)}
/>
<span className="text-muted-foreground">cm</span>
</div>
</div>
<Controller
name="height"
control={control}
render={({ field }) => (
<Slider
min={CONSTRAINTS.MIN_HEIGHT}
max={CONSTRAINTS.MAX_HEIGHT}
step={1}
value={[field.value || CONSTRAINTS.MIN_HEIGHT]}
onValueChange={([value]) => field.onChange(value)}
className="py-2"
/>
)}
/>
<div className="flex justify-between text-xs text-muted-foreground">
<span>{CONSTRAINTS.MIN_HEIGHT} cm</span>
<span>{CONSTRAINTS.MAX_HEIGHT} cm</span>
</div>
{errors.height && (
<p className="text-sm text-destructive">{errors.height.message}</p>
)}
</div>
</div>
<div>
<Label htmlFor="phone">Telefon *</Label>
<Input
id="phone"
{...register("phone")}
placeholder="12345678"
className="mt-1"
</section>
{/* Components Section */}
<section>
<h3 className="mb-4 text-sm font-semibold uppercase tracking-wide text-muted-foreground">
Vælg komponenter
</h3>
<div className="grid gap-3">
{/* Insulation Toggle */}
<Controller
name="includeInsulation"
control={control}
render={({ field }) => (
<label
className={`flex cursor-pointer items-center gap-4 rounded-xl border-2 p-4 transition-all ${
field.value
? "border-secondary bg-secondary/10"
: "border-muted hover:border-muted-foreground/30"
}`}
>
<div
className={`rounded-lg p-2 ${field.value ? "bg-secondary text-secondary-foreground" : "bg-muted"}`}
>
<Layers className="h-5 w-5" />
</div>
<div className="flex-1">
<div className="font-medium">Isolering</div>
<div className="text-sm text-muted-foreground">
Gulvisolering under varmeanlæg
</div>
</div>
<Switch checked={field.value} onCheckedChange={field.onChange} />
</label>
)}
/>
{errors.phone && (
<p className="mt-1 text-sm text-destructive">{errors.phone.message}</p>
)}
</div>
<div>
<Label htmlFor="postalCode">Postnummer *</Label>
<Input
id="postalCode"
{...register("postalCode")}
placeholder="4550"
className="mt-1"
{/* Floor Heating Toggle */}
<Controller
name="includeFloorHeating"
control={control}
render={({ field }) => (
<label
className={`flex cursor-pointer items-center gap-4 rounded-xl border-2 p-4 transition-all ${
field.value
? "border-secondary bg-secondary/10"
: "border-muted hover:border-muted-foreground/30"
}`}
>
<div
className={`rounded-lg p-2 ${field.value ? "bg-secondary text-secondary-foreground" : "bg-muted"}`}
>
<Thermometer className="h-5 w-5" />
</div>
<div className="flex-1">
<div className="font-medium">Gulvvarme</div>
<div className="text-sm text-muted-foreground">Syntetisk net + Ø16 PEX (excl. tilslutning)</div>
</div>
<Switch checked={field.value} onCheckedChange={field.onChange} />
</label>
)}
/>
{errors.postalCode && (
<p className="mt-1 text-sm text-destructive">{errors.postalCode.message}</p>
)}
</div>
</div>
<div>
<Label htmlFor="address">Adresse</Label>
<Input
id="address"
{...register("address")}
placeholder="Vejnavn og nummer (valgfrit)"
className="mt-1"
/>
</div>
<div className="grid gap-4 sm:grid-cols-2">
<div>
<Label htmlFor="area">
Gulvareal (m²) *
<span className="ml-1 text-xs text-muted-foreground">
({CONSTRAINTS.MIN_AREA}-{CONSTRAINTS.MAX_AREA} m²)
</span>
</Label>
<Input
id="area"
type="number"
{...register("area")}
placeholder="50"
className="mt-1"
{/* Compound Toggle */}
<Controller
name="includeCompound"
control={control}
render={({ field }) => (
<label
className={`flex cursor-pointer items-center gap-4 rounded-xl border-2 p-4 transition-all ${
field.value
? "border-secondary bg-secondary/10"
: "border-muted hover:border-muted-foreground/30"
}`}
>
<div
className={`rounded-lg p-2 ${field.value ? "bg-secondary text-secondary-foreground" : "bg-muted"}`}
>
<PaintBucket className="h-5 w-5" />
</div>
<div className="flex-1">
<div className="font-medium">Gulvstøbning</div>
<div className="text-sm text-muted-foreground">
Flydespartel til færdigt gulv
</div>
</div>
<Switch checked={field.value} onCheckedChange={field.onChange} />
</label>
)}
/>
{errors.area && (
<p className="mt-1 text-sm text-destructive">{errors.area.message}</p>
)}
</div>
<div>
<Label htmlFor="height">
Gulvhøjde (cm) *
<span className="ml-1 text-xs text-muted-foreground">
({CONSTRAINTS.MIN_HEIGHT}-{CONSTRAINTS.MAX_HEIGHT} cm)
</span>
</Label>
<Input
id="height"
type="number"
{...register("height")}
placeholder="20"
className="mt-1"
</section>
{/* Flooring Type Section */}
{watchedIncludeCompound && (
<section>
<h3 className="mb-4 text-sm font-semibold uppercase tracking-wide text-muted-foreground">
Gulvbelægning
</h3>
<Controller
name="flooringType"
control={control}
render={({ field }) => (
<div className="grid gap-2 sm:grid-cols-3">
{Object.entries(FLOORING_TYPES).map(([key, type]) => (
<label
key={key}
className={`flex cursor-pointer flex-col items-center gap-2 rounded-xl border-2 p-4 text-center transition-all ${
field.value === key
? "border-secondary bg-secondary/10"
: "border-muted hover:border-muted-foreground/30"
}`}
>
<input
type="radio"
value={key}
checked={field.value === key}
onChange={() => field.onChange(key)}
className="sr-only"
/>
<span className="font-medium">{type.name}</span>
<span className="text-xs text-muted-foreground">{type.description}</span>
{type.compoundMultiplier > 1 && (
<span className="rounded-full bg-amber-50 px-2 py-0.5 text-xs font-medium text-amber-600">
+28% spartel
</span>
)}
</label>
))}
</div>
)}
/>
{errors.height && (
<p className="mt-1 text-sm text-destructive">{errors.height.message}</p>
)}
</div>
</div>
<div>
<Label htmlFor="remarks">Bemærkninger</Label>
</section>
)}
{/* Remarks Section */}
<section>
<h3 className="mb-4 text-sm font-semibold uppercase tracking-wide text-muted-foreground">
Bemærkninger <span className="font-normal">(valgfrit)</span>
</h3>
<Textarea
id="remarks"
{...register("remarks")}
placeholder="Eventuelle særlige ønsker eller spørgsmål"
className="mt-1"
rows={3}
className="resize-none"
/>
</div>
<Button type="submit" size="lg" className="w-full" disabled={isCalculating}>
</section>
{/* Progress Indicator */}
{calculationProgress && (
<div className="space-y-2 rounded-xl border border-secondary/30 bg-secondary/10 p-4">
<div className="flex items-center justify-between text-sm">
<span className="font-medium">{calculationProgress.step}</span>
<span className="text-muted-foreground">{calculationProgress.progress}%</span>
</div>
<Progress value={calculationProgress.progress} className="h-2" />
</div>
)}
<Button
type="submit"
size="lg"
className="h-12 w-full text-base font-semibold"
disabled={isCalculating}
>
{isCalculating ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
<Loader2 className="mr-2 h-5 w-5 animate-spin" />
Beregner...
</>
) : (
@ -218,16 +471,7 @@ export function CalculatorForm({ onCalculation, showDetails = false }: Calculato
)}
</Button>
</form>
{result && !showDetails && (
<div className="mt-6 rounded-lg bg-muted p-6 text-center">
<p className="text-3xl font-bold">{formatEstimate(result.totalInclVat)}</p>
<p className="mt-2 text-sm text-muted-foreground">
*Prisen er vejledende og kan variere afhængigt af konkrete forhold
</p>
</div>
)}
</CardContent>
</Card>
)
}
}

View file

@ -0,0 +1,570 @@
"use client"
import { useState } from "react"
import { useForm, Controller } from "react-hook-form"
import { zodResolver } from "@hookform/resolvers/zod"
import * as z from "zod"
import {
ArrowRight,
ArrowLeft,
MapPin,
Ruler,
Settings,
User,
Check,
Loader2,
CheckCircle2,
} from "lucide-react"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Textarea } from "@/components/ui/textarea"
import { Switch } from "@/components/ui/switch"
import { Slider } from "@/components/ui/slider"
import { CONSTRAINTS, FLOORING_TYPES, type FlooringType } from "@/lib/constants"
import { validateDanishPostalCode, getDistance } from "@/lib/distance"
import {
calculatePrice,
formatPrice,
formatEstimate,
type CalculationDetails,
} from "@/lib/calculations"
const formSchema = z.object({
postalCode: z
.string()
.length(4, "Postnummer skal være 4 cifre")
.refine(validateDanishPostalCode, "Vi dækker desværre ikke dette område"),
address: z.string().optional(),
area: z.coerce
.number()
.min(CONSTRAINTS.MIN_AREA, `Minimum ${CONSTRAINTS.MIN_AREA}`)
.max(CONSTRAINTS.MAX_AREA, `Maximum ${CONSTRAINTS.MAX_AREA}`),
height: z.coerce
.number()
.min(CONSTRAINTS.MIN_HEIGHT, `Minimum ${CONSTRAINTS.MIN_HEIGHT} cm`)
.max(CONSTRAINTS.MAX_HEIGHT, `Maximum ${CONSTRAINTS.MAX_HEIGHT} cm`),
includeInsulation: z.boolean(),
includeFloorHeating: z.boolean(),
includeCompound: z.boolean(),
flooringType: z.string(),
name: z.string().refine((val) => {
const parts = val.trim().split(/\s+/)
return parts.length >= 2 && parts[0].length >= 3 && parts[1].length >= 3
}, "Indtast fornavn og efternavn (mindst 3 tegn hver)"),
email: z.string().email("Ugyldig email"),
phone: z.string().regex(/^\d{8}$/, "Telefonnummer skal være 8 cifre"),
remarks: z.string().optional(),
})
type FormData = z.infer<typeof formSchema>
interface StepWizardProps {
onComplete: (result: CalculationDetails, formData: FormData) => void
}
const steps = [
{ id: 1, name: "Placering", icon: MapPin },
{ id: 2, name: "Gulvmål", icon: Ruler },
{ id: 3, name: "Løsning", icon: Settings },
{ id: 4, name: "Kontakt", icon: User },
]
export function StepWizard({ onComplete }: StepWizardProps) {
const [currentStep, setCurrentStep] = useState(1)
const [isCalculating, setIsCalculating] = useState(false)
const [showHeightTip, setShowHeightTip] = useState(false)
const {
register,
handleSubmit,
formState: { errors },
watch,
control,
trigger,
getValues,
} = useForm<FormData>({
resolver: zodResolver(formSchema),
defaultValues: {
postalCode: "",
address: "",
area: 75,
height: 15,
includeInsulation: true,
includeFloorHeating: true,
includeCompound: true,
flooringType: "STANDARD",
name: "",
email: "",
phone: "",
remarks: "",
},
mode: "onChange",
})
const watchedValues = watch()
const validateStep = async (step: number): Promise<boolean> => {
let fieldsToValidate: (keyof FormData)[] = []
switch (step) {
case 1:
fieldsToValidate = ["postalCode"]
break
case 2:
fieldsToValidate = ["area", "height"]
break
case 3:
fieldsToValidate = []
break
case 4:
fieldsToValidate = ["name", "email", "phone"]
break
}
const result = await trigger(fieldsToValidate)
return result
}
const nextStep = async () => {
const isValid = await validateStep(currentStep)
if (isValid && currentStep < 4) {
setCurrentStep(currentStep + 1)
}
}
const prevStep = () => {
if (currentStep > 1) {
setCurrentStep(currentStep - 1)
}
}
const onSubmit = async (data: FormData) => {
setIsCalculating(true)
try {
let distance: number
try {
const params = new URLSearchParams({
postalCode: data.postalCode,
...(data.address && { address: data.address }),
})
const response = await fetch(`/api/distance?${params}`)
const distanceData = await response.json()
distance = distanceData.distance
} catch {
distance = getDistance(data.postalCode)
}
const result = calculatePrice({
area: data.area,
height: data.height,
postalCode: data.postalCode,
distance,
includeInsulation: data.includeInsulation,
includeFloorHeating: data.includeFloorHeating,
includeCompound: data.includeCompound,
flooringType: data.flooringType as FlooringType,
})
onComplete(result, data)
} finally {
setIsCalculating(false)
}
}
return (
<div className="mx-auto w-full max-w-lg">
{/* Progress Steps */}
<div className="mb-8">
<div className="flex justify-between">
{steps.map((step, index) => {
const Icon = step.icon
const isActive = currentStep === step.id
const isCompleted = currentStep > step.id
return (
<div key={step.id} className="flex flex-1 flex-col items-center">
<div className="relative flex w-full items-center justify-center">
{index > 0 && (
<div
className={`absolute left-0 right-1/2 top-5 h-0.5 -translate-y-1/2 ${
isCompleted || isActive ? "bg-secondary" : "bg-muted"
}`}
/>
)}
{index < steps.length - 1 && (
<div
className={`absolute left-1/2 right-0 top-5 h-0.5 -translate-y-1/2 ${
isCompleted ? "bg-secondary" : "bg-muted"
}`}
/>
)}
<div
className={`relative z-10 flex h-10 w-10 items-center justify-center rounded-full border-2 transition-all ${
isActive
? "border-secondary bg-secondary text-secondary-foreground"
: isCompleted
? "border-secondary bg-secondary text-secondary-foreground"
: "border-muted bg-background text-muted-foreground"
}`}
>
{isCompleted ? <Check className="h-5 w-5" /> : <Icon className="h-5 w-5" />}
</div>
</div>
<span
className={`mt-2 text-xs font-medium ${
isActive || isCompleted ? "text-foreground" : "text-muted-foreground"
}`}
>
{step.name}
</span>
</div>
)
})}
</div>
</div>
{/* Form Card */}
<div className="rounded-2xl bg-white p-6 shadow-lg sm:p-8">
<form onSubmit={handleSubmit(onSubmit)}>
{/* Step 1: Location */}
{currentStep === 1 && (
<div className="space-y-6">
<div className="mb-6 text-center">
<h2 className="text-xl font-semibold">Hvor skal gulvet lægges?</h2>
<p className="mt-1 text-muted-foreground">
Vi dækker Sjælland, Lolland-Falster og Fyn
</p>
</div>
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="postalCode">Postnummer *</Label>
<Input
id="postalCode"
{...register("postalCode")}
placeholder="F.eks. 4550"
className="h-12 text-lg"
maxLength={4}
/>
{errors.postalCode && (
<p className="text-sm text-destructive">{errors.postalCode.message}</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="address">
Adresse <span className="font-normal text-muted-foreground">(valgfrit)</span>
</Label>
<Input
id="address"
{...register("address")}
placeholder="Vejnavn og nummer"
className="h-12"
/>
</div>
</div>
</div>
)}
{/* Step 2: Floor Dimensions */}
{currentStep === 2 && (
<div className="space-y-6">
<div className="mb-6 text-center">
<h2 className="text-xl font-semibold">Hvor stort er gulvet?</h2>
<p className="mt-1 text-muted-foreground">Angiv areal og ønsket gulvhøjde</p>
</div>
<div className="space-y-8">
{/* Area Slider */}
<div className="space-y-4">
<div className="flex items-center justify-between">
<Label className="text-base">Gulvareal</Label>
<div className="flex items-baseline gap-1 rounded-lg bg-muted/50 px-3 py-1">
<span className="text-2xl font-bold">{watchedValues.area}</span>
<span className="text-muted-foreground">m²</span>
</div>
</div>
<Controller
name="area"
control={control}
render={({ field }) => (
<Slider
min={CONSTRAINTS.MIN_AREA}
max={CONSTRAINTS.MAX_AREA}
step={1}
value={[field.value]}
onValueChange={([value]) => field.onChange(value)}
className="py-4"
/>
)}
/>
<div className="flex justify-between text-xs text-muted-foreground">
<span>{CONSTRAINTS.MIN_AREA} m²</span>
<span>{CONSTRAINTS.MAX_AREA} m²</span>
</div>
</div>
{/* Height Slider */}
<div className="space-y-4">
<div className="flex items-center justify-between">
<Label className="relative text-base">
Gulvhøjde
<button
type="button"
onClick={() => setShowHeightTip(!showHeightTip)}
className="ml-1.5 inline-flex cursor-pointer items-center justify-center rounded-full border border-muted-foreground/30 text-muted-foreground hover:bg-muted/50"
style={{ width: "16px", height: "16px", fontSize: "11px", position: "relative", top: "-1px" }}
>
?
</button>
{showHeightTip && (
<span className="absolute left-0 top-full z-10 mt-1 w-48 rounded-md bg-foreground px-2 py-1 text-xs text-background shadow-lg">
Angiv dybde fra bund til ønsket niveau
</span>
)}
</Label>
<div className="flex items-baseline gap-1 rounded-lg bg-muted/50 px-3 py-1">
<span className="text-2xl font-bold">{watchedValues.height}</span>
<span className="text-muted-foreground">cm</span>
</div>
</div>
<Controller
name="height"
control={control}
render={({ field }) => (
<Slider
min={CONSTRAINTS.MIN_HEIGHT}
max={CONSTRAINTS.MAX_HEIGHT}
step={1}
value={[field.value]}
onValueChange={([value]) => field.onChange(value)}
className="py-4"
/>
)}
/>
<div className="flex justify-between text-xs text-muted-foreground">
<span>{CONSTRAINTS.MIN_HEIGHT} cm</span>
<span>{CONSTRAINTS.MAX_HEIGHT} cm</span>
</div>
</div>
</div>
</div>
)}
{/* Step 3: Components */}
{currentStep === 3 && (
<div className="space-y-6">
<div className="mb-6 text-center">
<h2 className="text-xl font-semibold">Hvad skal inkluderes?</h2>
<p className="mt-1 text-muted-foreground">Vælg de komponenter du ønsker</p>
</div>
{/* Always included */}
<div className="mb-4 rounded-xl border border-green-200 bg-green-50 p-4">
<p className="mb-2 text-sm font-medium text-green-800">Altid inkluderet:</p>
<div className="space-y-2">
<div className="flex items-center gap-3">
<CheckCircle2 className="h-5 w-5 flex-shrink-0 text-green-600" />
<div>
<span className="font-medium">Isolering</span>
<span className="ml-2 text-sm text-muted-foreground">PUR skumisolering</span>
</div>
</div>
<div className="flex items-center gap-3">
<CheckCircle2 className="h-5 w-5 flex-shrink-0 text-green-600" />
<div>
<span className="font-medium">Gulvstøbning</span>
<span className="ml-2 text-sm text-muted-foreground">Flydespartel</span>
</div>
</div>
</div>
</div>
<p className="mb-2 text-sm font-medium text-muted-foreground">Tilvalg:</p>
<div className="space-y-3">
<Controller
name="includeFloorHeating"
control={control}
render={({ field }) => (
<label
className={`flex cursor-pointer items-center justify-between rounded-xl border-2 p-4 transition-all ${
field.value ? "border-secondary bg-secondary/5" : "border-muted"
}`}
>
<div>
<div className="font-medium">Gulvvarme</div>
<div className="text-sm text-muted-foreground">
Syntetisk net + Ø16 PEX (excl. tilslutning)
</div>
</div>
<Switch checked={field.value} onCheckedChange={field.onChange} />
</label>
)}
/>
</div>
{/* Flooring Type */}
{true && (
<div className="border-t pt-4">
<Label className="mb-3 block text-sm text-muted-foreground">
Hvilken gulvbelægning skal lægges?
</Label>
<Controller
name="flooringType"
control={control}
render={({ field }) => (
<div className="grid gap-2">
{Object.entries(FLOORING_TYPES).map(([key, type]) => (
<label
key={key}
className={`flex cursor-pointer items-center gap-3 rounded-lg border p-3 transition-all ${
field.value === key
? "border-secondary bg-secondary/5"
: "border-muted hover:border-muted-foreground/30"
}`}
>
<input
type="radio"
value={key}
checked={field.value === key}
onChange={() => field.onChange(key)}
className="sr-only"
/>
<div
className={`flex h-4 w-4 items-center justify-center rounded-full border-2 ${
field.value === key
? "border-secondary"
: "border-muted-foreground/30"
}`}
>
{field.value === key && (
<div className="h-2 w-2 rounded-full bg-secondary" />
)}
</div>
<div className="flex-1">
<span className="font-medium">{type.name}</span>
{type.compoundMultiplier > 1 && (
<span className="ml-2 rounded-full bg-amber-50 px-2 py-0.5 text-xs text-amber-600">
+28% spartel
</span>
)}
</div>
</label>
))}
</div>
)}
/>
</div>
)}
</div>
)}
{/* Step 4: Contact */}
{currentStep === 4 && (
<div className="space-y-6">
<div className="mb-6 text-center">
<h2 className="text-xl font-semibold">Dine kontaktoplysninger</h2>
<p className="mt-1 text-muted-foreground"> vi kan sende dit prisoverslag</p>
</div>
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="name">Navn *</Label>
<Input
id="name"
{...register("name")}
placeholder="Dit fulde navn"
className="h-12"
/>
{errors.name && <p className="text-sm text-destructive">{errors.name.message}</p>}
</div>
<div className="space-y-2">
<Label htmlFor="email">Email *</Label>
<Input
id="email"
type="email"
{...register("email")}
placeholder="din@email.dk"
className="h-12"
/>
{errors.email && (
<p className="text-sm text-destructive">{errors.email.message}</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="phone">Telefon *</Label>
<Input
id="phone"
{...register("phone")}
placeholder="12345678"
className="h-12"
maxLength={8}
/>
{errors.phone && (
<p className="text-sm text-destructive">{errors.phone.message}</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="remarks">
Bemærkninger{" "}
<span className="font-normal text-muted-foreground">(valgfrit)</span>
</Label>
<Textarea
id="remarks"
{...register("remarks")}
placeholder="Særlige ønsker eller spørgsmål"
rows={3}
className="resize-none"
/>
</div>
</div>
</div>
)}
{/* Navigation */}
<div className="mt-8 flex gap-3">
{currentStep > 1 && (
<Button type="button" variant="outline" onClick={prevStep} className="h-12 flex-1">
<ArrowLeft className="mr-2 h-4 w-4" />
Tilbage
</Button>
)}
{currentStep < 4 ? (
<Button
type="button"
onClick={nextStep}
className="h-12 flex-1 bg-secondary text-secondary-foreground hover:bg-secondary/90"
>
Næste
<ArrowRight className="ml-2 h-4 w-4" />
</Button>
) : (
<Button
type="submit"
className="h-12 flex-1 bg-secondary text-secondary-foreground hover:bg-secondary/90"
disabled={isCalculating}
>
{isCalculating ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Beregner...
</>
) : (
"Se mit prisoverslag"
)}
</Button>
)}
</div>
</form>
</div>
</div>
)
}

View file

@ -0,0 +1,134 @@
"use client"
import { formatPrice } from "@/lib/calculations"
import { type StoredQuote, type QuoteStatus } from "@/lib/db"
import { QuoteCard } from "./quote-card"
interface KanbanColumnProps {
title: string
status: QuoteStatus
quotes: StoredQuote[]
showTotal?: boolean
onStatusChange: (id: number, status: QuoteStatus) => void
onReject: (quote: StoredQuote) => void
}
function KanbanColumn({
title,
status,
quotes,
showTotal = true,
onStatusChange,
onReject,
}: KanbanColumnProps) {
const total = quotes.reduce((sum, q) => sum + (q.totalInclVat || 0), 0)
const bgColor = {
new: "bg-blue-50/50 border-blue-200",
contacted: "bg-amber-50/50 border-amber-200",
accepted: "bg-green-50/50 border-green-200",
rejected: "bg-gray-50/50 border-gray-200",
}[status]
const headerColor = {
new: "text-blue-700",
contacted: "text-amber-700",
accepted: "text-green-700",
rejected: "text-gray-500",
}[status]
return (
<div className={`flex flex-col rounded-xl border ${bgColor}`}>
{/* Fixed header */}
<div className="flex-shrink-0 border-b border-inherit p-3">
<div className="flex items-center justify-between">
<h2 className={`font-semibold ${headerColor}`}>{title}</h2>
<span className={`text-sm font-medium ${headerColor}`}>{quotes.length}</span>
</div>
{showTotal && quotes.length > 0 && (
<div className="mt-1 text-sm text-muted-foreground">
Værdi: <span className="font-medium">{formatPrice(Math.round(total))}</span>
</div>
)}
</div>
{/* Scrollable content */}
<div
className="flex-1 space-y-2 overflow-y-auto p-2"
style={{ maxHeight: "calc(100vh - 220px)" }}
>
{quotes.length === 0 ? (
<p className="py-6 text-center text-xs text-muted-foreground">Ingen tilbud</p>
) : (
quotes.map((quote) => (
<QuoteCard
key={quote.id}
quote={quote}
onStatusChange={onStatusChange}
onReject={onReject}
/>
))
)}
</div>
</div>
)
}
interface KanbanBoardProps {
quotes: StoredQuote[]
onStatusChange: (id: number, status: QuoteStatus) => void
onReject: (quote: StoredQuote) => void
rejectedCount: number
onShowRejected: () => void
}
export function KanbanBoard({
quotes,
onStatusChange,
onReject,
rejectedCount,
onShowRejected,
}: KanbanBoardProps) {
// Only show 3 main columns
const columns: { title: string; status: QuoteStatus; showTotal: boolean }[] = [
{ title: "Nye tilbud", status: "new", showTotal: true },
{ title: "Kunde kontaktet", status: "contacted", showTotal: true },
{ title: "Tilbud accepteret", status: "accepted", showTotal: true },
]
const groupedQuotes = columns.reduce(
(acc, col) => {
acc[col.status] = quotes.filter((q) => q.status === col.status)
return acc
},
{} as Record<QuoteStatus, StoredQuote[]>
)
return (
<div>
<div className="grid grid-cols-1 gap-4 md:grid-cols-3">
{columns.map((col) => (
<KanbanColumn
key={col.status}
title={col.title}
status={col.status}
quotes={groupedQuotes[col.status] || []}
showTotal={col.showTotal}
onStatusChange={onStatusChange}
onReject={onReject}
/>
))}
</div>
{/* Rejected quotes link */}
{rejectedCount > 0 && (
<button
onClick={onShowRejected}
className="mt-4 text-sm text-muted-foreground hover:text-foreground hover:underline"
>
Se {rejectedCount} afviste tilbud
</button>
)}
</div>
)
}

View file

@ -0,0 +1,114 @@
"use client"
import { formatPrice } from "@/lib/calculations"
import { type StoredQuote, type QuoteStatus } from "@/lib/db"
import { Button } from "@/components/ui/button"
import { Phone, Check, X, ExternalLink } from "lucide-react"
interface QuoteCardProps {
quote: StoredQuote
onStatusChange: (id: number, status: QuoteStatus) => void
onReject: (quote: StoredQuote) => void
}
function formatRelativeDate(dateString: string): string {
const date = new Date(dateString)
const now = new Date()
const diffMs = now.getTime() - date.getTime()
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24))
if (diffDays === 0) return "I dag"
if (diffDays === 1) return "I går"
if (diffDays < 7) return `${diffDays}d`
if (diffDays < 30) return `${Math.floor(diffDays / 7)}u`
return `${Math.floor(diffDays / 30)}m`
}
export function QuoteCard({ quote, onStatusChange, onReject }: QuoteCardProps) {
const slug = `${quote.postalCode}-${quote.id}`
const detailUrl = `/tilbud/${slug}`
return (
<div className="group flex items-center gap-2 rounded-lg border bg-white p-2 shadow-sm transition-shadow hover:shadow-md">
{/* Main content - clickable */}
<a href={detailUrl} target="_blank" rel="noopener noreferrer" className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-1.5">
<span className="truncate text-sm font-medium">{quote.customerName}</span>
<ExternalLink className="h-3 w-3 flex-shrink-0 text-muted-foreground opacity-0 group-hover:opacity-100" />
</div>
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<span>{quote.postalCode}</span>
<span>·</span>
<span>{quote.area}m²</span>
<span>·</span>
<span>{formatRelativeDate(quote.createdAt)}</span>
</div>
</div>
<div className="flex-shrink-0 text-right">
<div className="text-sm font-semibold text-primary">
{quote.totalInclVat ? formatPrice(Math.round(quote.totalInclVat)) : "—"}
</div>
</div>
</div>
</a>
{/* Action buttons - always visible */}
<div className="flex flex-shrink-0 items-center gap-0.5 border-l pl-2">
<Button
size="icon"
variant="ghost"
className={`h-7 w-7 ${
quote.status === "contacted"
? "text-blue-300"
: "text-blue-600 hover:bg-blue-50 hover:text-blue-700"
}`}
onClick={(e) => {
e.stopPropagation()
if (quote.status !== "contacted") {
onStatusChange(quote.id, "contacted")
}
}}
title="Marker som kontaktet"
disabled={quote.status === "contacted"}
>
<Phone className="h-3.5 w-3.5" />
</Button>
<Button
size="icon"
variant="ghost"
className={`h-7 w-7 ${
quote.status === "accepted"
? "text-green-300"
: "text-green-600 hover:bg-green-50 hover:text-green-700"
}`}
onClick={(e) => {
e.stopPropagation()
if (quote.status !== "accepted") {
onStatusChange(quote.id, "accepted")
}
}}
title="Accepter"
disabled={quote.status === "accepted"}
>
<Check className="h-3.5 w-3.5" />
</Button>
<Button
size="icon"
variant="ghost"
className="h-7 w-7 text-red-600 hover:bg-red-50 hover:text-red-700"
onClick={(e) => {
e.stopPropagation()
onReject(quote)
}}
title="Afvis"
>
<X className="h-3.5 w-3.5" />
</Button>
</div>
</div>
)
}

View file

@ -0,0 +1,35 @@
"use client"
import { Search, X } from "lucide-react"
import { Input } from "@/components/ui/input"
import { Button } from "@/components/ui/button"
interface SearchFilterProps {
value: string
onChange: (value: string) => void
}
export function SearchFilter({ value, onChange }: SearchFilterProps) {
return (
<div className="relative">
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
type="text"
placeholder="Søg efter kunde, email eller postnummer..."
value={value}
onChange={(e) => onChange(e.target.value)}
className="h-11 pl-10 pr-10"
/>
{value && (
<Button
variant="ghost"
size="sm"
className="absolute right-1 top-1/2 h-8 w-8 -translate-y-1/2 p-0"
onClick={() => onChange("")}
>
<X className="h-4 w-4" />
</Button>
)}
</div>
)
}

View file

@ -9,14 +9,11 @@ const buttonVariants = cva(
{
variants: {
variant: {
default:
"bg-primary text-primary-foreground shadow hover:bg-primary/90",
destructive:
"bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90",
default: "bg-primary text-primary-foreground shadow hover:bg-primary/90",
destructive: "bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90",
outline:
"border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground",
secondary:
"bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80",
secondary: "bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground",
link: "text-primary underline-offset-4 hover:underline",
},
@ -35,8 +32,7 @@ const buttonVariants = cva(
)
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
extends React.ButtonHTMLAttributes<HTMLButtonElement>, VariantProps<typeof buttonVariants> {
asChild?: boolean
}
@ -44,14 +40,10 @@ const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button"
return (
<Comp
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
{...props}
/>
<Comp className={cn(buttonVariants({ variant, size, className }))} ref={ref} {...props} />
)
}
)
Button.displayName = "Button"
export { Button, buttonVariants }
export { Button, buttonVariants }

View file

@ -2,75 +2,54 @@ import * as React from "react"
import { cn } from "@/lib/utils"
const Card = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn(
"rounded-xl border bg-card text-card-foreground shadow",
className
)}
{...props}
/>
))
const Card = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("rounded-xl border bg-white text-card-foreground shadow-sm", className)}
{...props}
/>
)
)
Card.displayName = "Card"
const CardHeader = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex flex-col space-y-1.5 p-6", className)}
{...props}
/>
))
const CardHeader = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn("flex flex-col space-y-1.5 p-6", className)} {...props} />
)
)
CardHeader.displayName = "CardHeader"
const CardTitle = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("font-semibold leading-none tracking-tight", className)}
{...props}
/>
))
const CardTitle = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("font-semibold leading-none tracking-tight", className)}
{...props}
/>
)
)
CardTitle.displayName = "CardTitle"
const CardDescription = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
))
const CardDescription = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn("text-sm text-muted-foreground", className)} {...props} />
)
)
CardDescription.displayName = "CardDescription"
const CardContent = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
))
const CardContent = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
)
)
CardContent.displayName = "CardContent"
const CardFooter = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex items-center p-6 pt-0", className)}
{...props}
/>
))
const CardFooter = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn("flex items-center p-6 pt-0", className)} {...props} />
)
)
CardFooter.displayName = "CardFooter"
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }

104
components/ui/dialog.tsx Normal file
View file

@ -0,0 +1,104 @@
"use client"
import * as React from "react"
import * as DialogPrimitive from "@radix-ui/react-dialog"
import { X } from "lucide-react"
import { cn } from "@/lib/utils"
const Dialog = DialogPrimitive.Root
const DialogTrigger = DialogPrimitive.Trigger
const DialogPortal = DialogPrimitive.Portal
const DialogClose = DialogPrimitive.Close
const DialogOverlay = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Overlay
ref={ref}
className={cn(
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className
)}
{...props}
/>
))
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
const DialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
ref={ref}
className={cn(
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
className
)}
{...props}
>
{children}
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPortal>
))
DialogContent.displayName = DialogPrimitive.Content.displayName
const DialogHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
<div className={cn("flex flex-col space-y-1.5 text-center sm:text-left", className)} {...props} />
)
DialogHeader.displayName = "DialogHeader"
const DialogFooter = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn("flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2", className)}
{...props}
/>
)
DialogFooter.displayName = "DialogFooter"
const DialogTitle = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Title
ref={ref}
className={cn("text-lg font-semibold leading-none tracking-tight", className)}
{...props}
/>
))
DialogTitle.displayName = DialogPrimitive.Title.displayName
const DialogDescription = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Description
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
))
DialogDescription.displayName = DialogPrimitive.Description.displayName
export {
Dialog,
DialogPortal,
DialogOverlay,
DialogClose,
DialogTrigger,
DialogContent,
DialogHeader,
DialogFooter,
DialogTitle,
DialogDescription,
}

View file

@ -8,7 +8,7 @@ const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<"input">>(
<input
type={type}
className={cn(
"flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-base shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
"flex h-9 w-full rounded-md border border-input bg-white px-3 py-1 text-base shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
className
)}
ref={ref}
@ -19,4 +19,4 @@ const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<"input">>(
)
Input.displayName = "Input"
export { Input }
export { Input }

View file

@ -10,15 +10,10 @@ const labelVariants = cva(
const Label = React.forwardRef<
React.ElementRef<typeof LabelPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> &
VariantProps<typeof labelVariants>
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> & VariantProps<typeof labelVariants>
>(({ className, ...props }, ref) => (
<LabelPrimitive.Root
ref={ref}
className={cn(labelVariants(), className)}
{...props}
/>
<LabelPrimitive.Root ref={ref} className={cn(labelVariants(), className)} {...props} />
))
Label.displayName = LabelPrimitive.Root.displayName
export { Label }
export { Label }

View file

@ -0,0 +1,25 @@
"use client"
import * as React from "react"
import * as ProgressPrimitive from "@radix-ui/react-progress"
import { cn } from "@/lib/utils"
const Progress = React.forwardRef<
React.ElementRef<typeof ProgressPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof ProgressPrimitive.Root>
>(({ className, value, ...props }, ref) => (
<ProgressPrimitive.Root
ref={ref}
className={cn("relative h-3 w-full overflow-hidden rounded-full bg-primary", className)}
{...props}
>
<ProgressPrimitive.Indicator
className="h-full w-full flex-1 bg-secondary transition-all duration-300 ease-out"
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
/>
</ProgressPrimitive.Root>
))
Progress.displayName = ProgressPrimitive.Root.displayName
export { Progress }

153
components/ui/select.tsx Normal file
View file

@ -0,0 +1,153 @@
"use client"
import * as React from "react"
import * as SelectPrimitive from "@radix-ui/react-select"
import { Check, ChevronDown, ChevronUp } from "lucide-react"
import { cn } from "@/lib/utils"
const Select = SelectPrimitive.Root
const SelectGroup = SelectPrimitive.Group
const SelectValue = SelectPrimitive.Value
const SelectTrigger = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Trigger
ref={ref}
className={cn(
"flex h-10 w-full items-center justify-between rounded-md border border-input bg-white px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
className
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDown className="h-4 w-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
))
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName
const SelectScrollUpButton = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.ScrollUpButton>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton>
>(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollUpButton
ref={ref}
className={cn("flex cursor-default items-center justify-center py-1", className)}
{...props}
>
<ChevronUp className="h-4 w-4" />
</SelectPrimitive.ScrollUpButton>
))
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName
const SelectScrollDownButton = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.ScrollDownButton>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton>
>(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollDownButton
ref={ref}
className={cn("flex cursor-default items-center justify-center py-1", className)}
{...props}
>
<ChevronDown className="h-4 w-4" />
</SelectPrimitive.ScrollDownButton>
))
SelectScrollDownButton.displayName = SelectPrimitive.ScrollDownButton.displayName
const SelectContent = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
>(({ className, children, position = "popper", ...props }, ref) => (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
ref={ref}
className={cn(
"relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border bg-white text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className
)}
position={position}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
className={cn(
"p-1",
position === "popper" &&
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]"
)}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
))
SelectContent.displayName = SelectPrimitive.Content.displayName
const SelectLabel = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
>(({ className, ...props }, ref) => (
<SelectPrimitive.Label
ref={ref}
className={cn("py-1.5 pl-8 pr-2 text-sm font-semibold", className)}
{...props}
/>
))
SelectLabel.displayName = SelectPrimitive.Label.displayName
const SelectItem = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Item
ref={ref}
className={cn(
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
))
SelectItem.displayName = SelectPrimitive.Item.displayName
const SelectSeparator = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
>(({ className, ...props }, ref) => (
<SelectPrimitive.Separator
ref={ref}
className={cn("-mx-1 my-1 h-px bg-muted", className)}
{...props}
/>
))
SelectSeparator.displayName = SelectPrimitive.Separator.displayName
export {
Select,
SelectGroup,
SelectValue,
SelectTrigger,
SelectContent,
SelectLabel,
SelectItem,
SelectSeparator,
SelectScrollUpButton,
SelectScrollDownButton,
}

25
components/ui/slider.tsx Normal file
View file

@ -0,0 +1,25 @@
"use client"
import * as React from "react"
import * as SliderPrimitive from "@radix-ui/react-slider"
import { cn } from "@/lib/utils"
const Slider = React.forwardRef<
React.ElementRef<typeof SliderPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof SliderPrimitive.Root>
>(({ className, ...props }, ref) => (
<SliderPrimitive.Root
ref={ref}
className={cn("relative flex w-full touch-none select-none items-center", className)}
{...props}
>
<SliderPrimitive.Track className="relative h-3 w-full grow overflow-hidden rounded-full bg-primary">
<SliderPrimitive.Range className="absolute h-full bg-secondary" />
</SliderPrimitive.Track>
<SliderPrimitive.Thumb className="block h-6 w-6 cursor-grab rounded-full border-2 border-secondary bg-white shadow-lg ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 active:cursor-grabbing disabled:pointer-events-none disabled:opacity-50" />
</SliderPrimitive.Root>
))
Slider.displayName = SliderPrimitive.Root.displayName
export { Slider }

29
components/ui/switch.tsx Normal file
View file

@ -0,0 +1,29 @@
"use client"
import * as React from "react"
import * as SwitchPrimitives from "@radix-ui/react-switch"
import { cn } from "@/lib/utils"
const Switch = React.forwardRef<
React.ElementRef<typeof SwitchPrimitives.Root>,
React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root>
>(({ className, ...props }, ref) => (
<SwitchPrimitives.Root
className={cn(
"peer inline-flex h-6 w-11 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-secondary data-[state=unchecked]:bg-input",
className
)}
{...props}
ref={ref}
>
<SwitchPrimitives.Thumb
className={cn(
"pointer-events-none block h-5 w-5 rounded-full bg-white shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-5 data-[state=unchecked]:translate-x-0"
)}
/>
</SwitchPrimitives.Root>
))
Switch.displayName = SwitchPrimitives.Root.displayName
export { Switch }

View file

@ -2,21 +2,20 @@ import * as React from "react"
import { cn } from "@/lib/utils"
const Textarea = React.forwardRef<
HTMLTextAreaElement,
React.ComponentProps<"textarea">
>(({ className, ...props }, ref) => {
return (
<textarea
className={cn(
"flex min-h-[60px] w-full rounded-md border border-input bg-transparent px-3 py-2 text-base shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
className
)}
ref={ref}
{...props}
/>
)
})
const Textarea = React.forwardRef<HTMLTextAreaElement, React.ComponentProps<"textarea">>(
({ className, ...props }, ref) => {
return (
<textarea
className={cn(
"flex min-h-[60px] w-full rounded-md border border-input bg-white px-3 py-2 text-base shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
className
)}
ref={ref}
{...props}
/>
)
}
)
Textarea.displayName = "Textarea"
export { Textarea }
export { Textarea }

View file

@ -1,97 +1,97 @@
Retelser fra rene
<br/>Gulvareal 25-300 m2
Gulvhøjde (cm): 0-100 cm (isoleringstykkelse)
<br/><br/><br/>![💰](data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAMAAABEpIrGAAADAFBMVEUAAAD/yij/yij/yij/yij/yijiphD/yijiphDiphDmrBTiphDiphBrS0b/yijiphD/yij/yij/yij/yijmqxPiphD5wyP/yij/yijvthv9yCfprxbkqBL0vR/2vyHyuh7iphD7xiX6wyTRojG1izfImjPtuixrS0aWbjrboBOsgzmQaz+fcy72wip+W0LaqjDjsi6+kjWZcz10U0Sjezv4wSKHY0AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACFs/+SAAAAGHRSTlMAMGDfvxCfn3CAzxCvz3AgIIDvQHC/78+4pAktAAABqElEQVR4XoVSXVfiMBC9SZtGoO2ibBHX//+HfNvnPZ4jiiJQWQr92JlJUrHsOd6HztdNZuamwDdQwwRlOmhVYKVOLhxAz9Wrumbn7cBxPKjTCT2v2YncWf2lSiiARpy2E3NBIBj+WKMnbC8IK+iIzFhhLj0uCNHExjlMC9/qYsiEM1P1F/XqP1uorLqNVI0urp9HUj/XYaq2hTjFYVT9DtmeYFIbXMLtg1uSZvJWIXdOYa5L4COxrkOYQU8TNvlkZ5JH9pK1K/gWM9EGmSwdmyc6YTbvkpOUcQ1zDZshraMFcK/C3fKR+0HSdnu7syWFf2o/AxPkdQlbuj6ttw02nBy5JF/kzrP+1R41vVVFwUlplluGvAmMRUulpvQRnpmh+hWQj5b49UHSvAXGspMZ5CZCdrhDlf1AswgEVvHsuUvqfbV5PZP/p/pCiNHcleNctvHIhMBLMXSKPQ57pOOeEAnBvxJejhkb27z0BLh+1gsFpDcVlmdVWoOFao9BCFX2KzLWnZ9YafcrDbBmRWWLTlfHQZH+/xPXP3eetbtZCI7b6JS73+F7/AOyqXEFmvkN7gAAAABJRU5ErkJggg==) Priskonstanter (ekskl. moms)
Timesatser & Basis
Timepris: 550 kr/time
Kørsel: 18,75 kr/km omfatter bil, disel og mandskab
Startgebyr: 3500 kr
AFDækningsbidrag: 0,7% Afdækning (Plast og tape mv.)
Affald : 0,25% af subtotal udført arbejde (beton, skum plastaffald mv.)
<br/>Materiale: 2.850 kr/m³
Arbejdsløn: 880 pr m3
Enkel arbejdsløn (uden højde): 75 kr/m² pr m2 areal som tillæg til de 2 ovenstående priser
Gulvvarme (altid inkluderet)
Materiale: 75,00KR pr m2
Arbejdsløn: 130,00 KR pr m2
Syntetisk net (altid inkluderet)
Materiale: 24 kr/m²
Arbejdsløn: 25 kr/m²
Flydende spartelmasse
Materiale: 450 kr/m²
Vægt: 90 kg/m²
<br/>Pumpebil har omkostninger ved(prisinterval baseret på vægt)
0-3000 kg 8.100,00 kr pr gang
3000-5000 6.000,00 kr pr gang
5000-8000 3.800,00 kr pr gang
![🧮](data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAYAAABzenr0AAADv0lEQVR4AdSWA7BkRxSGb9mpKa7N5/tsrxkbk1KcrI1nI7Zt23Zy197t99bu2MnJ+ftiZm8NeuJM1V/fcZ8eG/+px+Zbzn+DRayav2F2jTP7jf/+ArtOKaUjxabck51iHchPl/vz0gTbEva+3FQLPFiQ/sQuM6nmSEkmbU8e9QS4KzPp+E2jhpmHijKoN32MxURM1WOm/gIzCkmWZtHurGTCEF5AaW9OKqRiPRNy5fqiNImDtyWNJPDjKTni5TOLra8qc3mBsWoGiNye44v0F9hUnUl8k5hanzLSWpc68gnHl+DneUnd70/InBWpHjP13wNLj8dgDDqeFQR9NnIVLJNVE8YA5jh5M5yY+f95E3559WD67TqDfmo3rN+uN+jbZUYQ/LnLeAJ1v3Qb1i9XGZJ9AUIcU/z1ajuHXrYVMQsz9Rfo7Es8kH5oNBQ3LBwswIP1x8nHZk9448cWXqbDIPCntmPl5ZqxMKhmYKb+Akcb+uLW4apxqJqZEn4s+WowU3+B3msmPsFNs1gmiJzrO/bxrCAYRchVoMclZmovsP3Os7rp47EmKxCJqI3Aikh07YTehF9/XEW0r5Boa5ZQ3GQKOlAU4sYMS3FNqs3VqYIOFds+uCHD9temzeKeWbAPvThFai/w3UclRL25ROvSiUQOD06DH+LnySHuyvP7qg7c9lCBgGAffmEi6S/weh7x0wZJh37fpfDRn69hzYJ9+JnxCbwJ7z31DrxurKE+BjTp1bsxvK+0F3j71jPuWPJUZcWFdxQM9TEAuvVubPaDpYp1L084xg/v/fSWs/UXuP7JqdT61mRa9GSF9LP9nSnEQzE4CHvh4xUCnP1QKXKoicgH7j1e/yVov28CLXyigq64v5iWPVOlyLegS+8tUvEbV1ZZTe1lwo1F44rnq73e++6emcACt4/HLSERhSYrGCUnI8XvvW2G1F4Ar5dzSCAS3XrHBiviMaEvoraua94Y2NAza2DtdtNj3Y6afgvWBOAPWLb5eBWvFx7R79QGUTtgxZaKQU27PN53VY3+V/H07vdp6NWSuJGGXf+1x/z5z9+h/ObdckjXIUpq3WaB8E+fd/2sYdd+Gepp6AE9f+lVd+u/B6Y2vkp8CxqwbJNi/0Xr1EA+RAys2abiYMHS1yTIt6SVc2dbbg61IA53e5e036q/wCl1TxA/3ZL1RBgRO97x3/Dl4Q91bIFaxMK5su16+Z/+S4aCit93vC3k/LGW4P8nO8Lmg/jUxCAzQWaD7ADxMSwHuY7O2IFh1AGDAQAAwNd371sKcVQAAAAASUVORK5CYII=) Beregningsproces
Trin 1: Afledte værdier
Isolering (m³) = Areal × (Højde ÷ 100)
Pumpet vægt (kg) = Areal × 90 kg/m²
Trin 2: Beregn komponenter
Isolering:
<br/>Hvis højde > 0: Materiale + arbejde baseret på m³
Hvis højde = 0: Kun simpel arbejde baseret på m²
Gulvvarme: Materiale + arbejde (altid inkluderet)
<br/>Syntetisk net: Materiale + arbejde (altid inkluderet)
<br/>Flydende spartelmasse: Materiale baseret på areal
<br/>Pumpebil: Vælg prisinterval ud fra total vægt
<br/>Startgebyr: Fast beløb
<br/>Trin 3: Subtotal A
Subtotal A = Sum af alle ovenstående komponenter
Trin 4: Tillæg til subtotal
AFDækningsbidrag = Subtotal A × 0,7% omksotninger til afdækning plast og tape mv.
Affald = Subtotal A × 0,25% Dette er affald omk. Efter udført arbejde
Trin 5: Transportomkostninger
Afstand fra base (4550 Asnæs) baseret på postnummer:
<br/>4000-4999: 40 km tur-retur (Vestsjælland)
2000-2999: 160 km tur-retur (København)
3000-3999: 120 km tur-retur (Nordsjælland)
5000-5999: 60 km tur-retur (Fyn)
<br/>Beregning:
Bemærkninger at der er ekstra omkostninger hvor der er bro afgifter eller sejlans forbundet med udførsel af opgaven
Fyn er indeholdt med broafgift
Jeg tænker hvis vi har km til opgaven og anvender km. Takst på 18,75 så er transport indeholdt
Køretid (timer) = Afstand ÷ 70 km/t
Transport arbejde = 2 medarbejdere × Timepris × Køretid × 2 (tur-retur)
Transport bil = Afstand × 4 kr/km
Total transport = Transport arbejde + Transport bil
Trin 6: Total før hast
Total før hast = Subtotal A + Dækningsbidrag + Spild + Total transport
Trin 7: Hastighedstillæg
Hastighedsmultiplikator:
\- Normal: × 1,00
\- Hurtigt: × 1,10
\- Rush: × 1,20
<br/>Pris ekskl. moms = Total før hast × Hastighedsmultiplikator
Trin 8: Moms og prisinterval
Moms = Pris ekskl. moms × 25%
Pris inkl. moms = Pris ekskl. moms + Moms
<br/>Prisinterval:
Min pris = Pris inkl. moms - 10.000 kr
Max pris = Pris inkl. moms + 10.000 kr
![📈](data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAYAAABzenr0AAAB50lEQVR4AWIYFGDDvqP7BwgbAIgrB996giCO/1M/m/HPLIO6/QtqRXXj1LZt27Ztm9vOJnPZd8/v7dtucprMzXxuvrNzFKC5d5jI1/jcIjfb1t4BPXBtuziSLg9Pcg/wTjjAYXQkWXn++GEALnp7aPK1zx/IcGuHWIDNxWVITAHOGuqpjwRQ2doNBoUDoHjZekenyIy1FU2+6O+PPuIqgLpvWluwPmIAWN2vV1fFAtwcHUq6H9ZUg0ksAOx3SD7j7Y3bEJfpm3A2OJgmXzH/B00Ih9zHdBUYyMlndcc5IEYC0H3x/Rtpv8MSCoC674eHoEkcgHy/6wxQ19mPTnhA0+hl22xoZOe8gs/00ioc8ve0VgD0xFKiTa2ffM7DMloCLOmev69GALnuXACgjBAUD4BQBdDn48vqzg9gxd0NAkMVIDgmgXJrn/P6Asgn4WhBEQ28/vMrfZ4anZAgpn5+J6ODI/TA/b5UUKBpOuo/CVFTHCQ4YCA5VgJ9toID0YWPBJCUltXeVmXgPX+qOYKAjSsAlB2CgwzqAgME6s4X4Dg9TWPHMzZsOj4A0IRsU8329uv7Oza6CXHowFXXL+NXgYbSStzP0O3iAWpiEtivFw9QmZJJJhzsjfobarLdjWBtCGQ0DBBWAACcIW/qxNrtTAAAAABJRU5ErkJggg==) Eksempel på beregning
Input:
<br/>Areal: 50 m²
Højde: 20 cm Ved beregning skal der på de 20 cm. Fratrækkes 5 cm. Til beton så der kun bliver 15 cm. isolering
Postnummer: 2100 (København)
Tidsramme: Normal
Output:
<br/>Isolering: 7,5 m3
Pumpet vægt: 4.500 kg
Transport: 160 km tur-retur
Estimeret pris: Ca. 120.000-140.000 kr inkl. moms
Prisinterval: ±10.000 kr variation
![⚠️](data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAYAAABzenr0AAACJUlEQVR4AWKgBHxaxiAAwgwDAQC9l0P8FkEcxq+5rl0yXy+z7Tpl252zu2Vesl2n7Eu2dcm262meMJ938q/5/PfwfbG7Mw/WSriLAt9YELV4sRfrcoNkmagZmYEPB8otwBEX5N2uohQnuyMRV6LFFMjm5cb8kbVAAwtwpRlwtrY28GF/GUELtukp/HQIcKdrtC3o9BSmgeutKGzRgk36xwNwe2dFLJxUGeTy5kDagn36Hu2qoWbNmiBNG9XA8/2eqAV5+gv1dHocC1G9enWD46vkLcjSP+hj7Ptq1aoZHFsZSI8FQfpLjSgOmmB6ClWpUsXA4owQpL/USIv071QBlSpV0sjPCHl6Q6RvxxAVK1bUCK6O9ulJn/YBwjAkaNEo4DJRC4Lz3kivGT3Qg+/7BL3b+Xq5bQtGegV43cfJyj9NPm+UC8/zCHq11cvtWzDueDznb3b85eRzRzpwnK/0aOPo5bYtmOkprq/5JnOUgXQ6Tfiby6xbMNP/hcNLHaRSKZDZIzJcZt2Cmf4vHFqaQSKRAJk1PM1lwhYs0pPz6x3EYjGQDVMMA8IW5Ok1iyakMHNYCs/2OfwvbMEiPaHoovHKwNCkNiBvwSL9wvFJlC1bFqRfuxiXCVuwSE8WjkugdOnSIH3blucycQvS9AZPVe0LxiYwfmCMv7ns/1vgigjJfqPKRG/A3A0Z4S6w5/2eEr98oaWJQoqaOcnbrYVrKmGiX+k/A8p4TZk7YN6vAAAAAElFTkSuQmCC) Vigtige noter
Alle priser er afrundede til nærmeste hele krone
Prisintervallet er ±10.000 kr (i alt 20.000 kr spænd) for at dække variation i konkrete forhold
Transportomkostninger er estimerede baseret på postnummerområder
Minimum areal er 25 m², maksimum er 300 m²
Maksimal højde er 100 cm
Retelser fra rene
<br/>Gulvareal 25-300 m2
Gulvhøjde (cm): 0-100 cm (isoleringstykkelse)
<br/><br/><br/>![💰](data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAMAAABEpIrGAAADAFBMVEUAAAD/yij/yij/yij/yij/yijiphD/yijiphDiphDmrBTiphDiphBrS0b/yijiphD/yij/yij/yij/yijmqxPiphD5wyP/yij/yijvthv9yCfprxbkqBL0vR/2vyHyuh7iphD7xiX6wyTRojG1izfImjPtuixrS0aWbjrboBOsgzmQaz+fcy72wip+W0LaqjDjsi6+kjWZcz10U0Sjezv4wSKHY0AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACFs/+SAAAAGHRSTlMAMGDfvxCfn3CAzxCvz3AgIIDvQHC/78+4pAktAAABqElEQVR4XoVSXVfiMBC9SZtGoO2ibBHX//+HfNvnPZ4jiiJQWQr92JlJUrHsOd6HztdNZuamwDdQwwRlOmhVYKVOLhxAz9Wrumbn7cBxPKjTCT2v2YncWf2lSiiARpy2E3NBIBj+WKMnbC8IK+iIzFhhLj0uCNHExjlMC9/qYsiEM1P1F/XqP1uorLqNVI0urp9HUj/XYaq2hTjFYVT9DtmeYFIbXMLtg1uSZvJWIXdOYa5L4COxrkOYQU8TNvlkZ5JH9pK1K/gWM9EGmSwdmyc6YTbvkpOUcQ1zDZshraMFcK/C3fKR+0HSdnu7syWFf2o/AxPkdQlbuj6ttw02nBy5JF/kzrP+1R41vVVFwUlplluGvAmMRUulpvQRnpmh+hWQj5b49UHSvAXGspMZ5CZCdrhDlf1AswgEVvHsuUvqfbV5PZP/p/pCiNHcleNctvHIhMBLMXSKPQ57pOOeEAnBvxJejhkb27z0BLh+1gsFpDcVlmdVWoOFao9BCFX2KzLWnZ9YafcrDbBmRWWLTlfHQZH+/xPXP3eetbtZCI7b6JS73+F7/AOyqXEFmvkN7gAAAABJRU5ErkJggg==) Priskonstanter (ekskl. moms)
Timesatser & Basis
Timepris: 550 kr/time
Kørsel: 18,75 kr/km omfatter bil, disel og mandskab
Startgebyr: 3500 kr
AFDækningsbidrag: 0,7% Afdækning (Plast og tape mv.)
Affald : 0,25% af subtotal udført arbejde (beton, skum plastaffald mv.)
<br/>Materiale: 2.850 kr/m³
Arbejdsløn: 880 pr m3
Enkel arbejdsløn (uden højde): 75 kr/m² pr m2 areal som tillæg til de 2 ovenstående priser
Gulvvarme (altid inkluderet)
Materiale: 75,00KR pr m2
Arbejdsløn: 130,00 KR pr m2
Syntetisk net (altid inkluderet)
Materiale: 24 kr/m²
Arbejdsløn: 25 kr/m²
Flydende spartelmasse
Materiale: 450 kr/m²
Vægt: 90 kg/m²
<br/>Pumpebil har omkostninger ved(prisinterval baseret på vægt)
0-3000 kg 8.100,00 kr pr gang
3000-5000 6.000,00 kr pr gang
5000-8000 3.800,00 kr pr gang
![🧮](data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAYAAABzenr0AAADv0lEQVR4AdSWA7BkRxSGb9mpKa7N5/tsrxkbk1KcrI1nI7Zt23Zy197t99bu2MnJ+ftiZm8NeuJM1V/fcZ8eG/+px+Zbzn+DRayav2F2jTP7jf/+ArtOKaUjxabck51iHchPl/vz0gTbEva+3FQLPFiQ/sQuM6nmSEkmbU8e9QS4KzPp+E2jhpmHijKoN32MxURM1WOm/gIzCkmWZtHurGTCEF5AaW9OKqRiPRNy5fqiNImDtyWNJPDjKTni5TOLra8qc3mBsWoGiNye44v0F9hUnUl8k5hanzLSWpc68gnHl+DneUnd70/InBWpHjP13wNLj8dgDDqeFQR9NnIVLJNVE8YA5jh5M5yY+f95E3559WD67TqDfmo3rN+uN+jbZUYQ/LnLeAJ1v3Qb1i9XGZJ9AUIcU/z1ajuHXrYVMQsz9Rfo7Es8kH5oNBQ3LBwswIP1x8nHZk9448cWXqbDIPCntmPl5ZqxMKhmYKb+Akcb+uLW4apxqJqZEn4s+WowU3+B3msmPsFNs1gmiJzrO/bxrCAYRchVoMclZmovsP3Os7rp47EmKxCJqI3Aikh07YTehF9/XEW0r5Boa5ZQ3GQKOlAU4sYMS3FNqs3VqYIOFds+uCHD9temzeKeWbAPvThFai/w3UclRL25ROvSiUQOD06DH+LnySHuyvP7qg7c9lCBgGAffmEi6S/weh7x0wZJh37fpfDRn69hzYJ9+JnxCbwJ7z31DrxurKE+BjTp1bsxvK+0F3j71jPuWPJUZcWFdxQM9TEAuvVubPaDpYp1L084xg/v/fSWs/UXuP7JqdT61mRa9GSF9LP9nSnEQzE4CHvh4xUCnP1QKXKoicgH7j1e/yVov28CLXyigq64v5iWPVOlyLegS+8tUvEbV1ZZTe1lwo1F44rnq73e++6emcACt4/HLSERhSYrGCUnI8XvvW2G1F4Ar5dzSCAS3XrHBiviMaEvoraua94Y2NAza2DtdtNj3Y6afgvWBOAPWLb5eBWvFx7R79QGUTtgxZaKQU27PN53VY3+V/H07vdp6NWSuJGGXf+1x/z5z9+h/ObdckjXIUpq3WaB8E+fd/2sYdd+Gepp6AE9f+lVd+u/B6Y2vkp8CxqwbJNi/0Xr1EA+RAys2abiYMHS1yTIt6SVc2dbbg61IA53e5e036q/wCl1TxA/3ZL1RBgRO97x3/Dl4Q91bIFaxMK5su16+Z/+S4aCit93vC3k/LGW4P8nO8Lmg/jUxCAzQWaD7ADxMSwHuY7O2IFh1AGDAQAAwNd371sKcVQAAAAASUVORK5CYII=) Beregningsproces
Trin 1: Afledte værdier
Isolering (m³) = Areal × (Højde ÷ 100)
Pumpet vægt (kg) = Areal × 90 kg/m²
Trin 2: Beregn komponenter
Isolering:
<br/>Hvis højde > 0: Materiale + arbejde baseret på m³
Hvis højde = 0: Kun simpel arbejde baseret på m²
Gulvvarme: Materiale + arbejde (altid inkluderet)
<br/>Syntetisk net: Materiale + arbejde (altid inkluderet)
<br/>Flydende spartelmasse: Materiale baseret på areal
<br/>Pumpebil: Vælg prisinterval ud fra total vægt
<br/>Startgebyr: Fast beløb
<br/>Trin 3: Subtotal A
Subtotal A = Sum af alle ovenstående komponenter
Trin 4: Tillæg til subtotal
AFDækningsbidrag = Subtotal A × 0,7% omksotninger til afdækning plast og tape mv.
Affald = Subtotal A × 0,25% Dette er affald omk. Efter udført arbejde
Trin 5: Transportomkostninger
Afstand fra base (4550 Asnæs) baseret på postnummer:
<br/>4000-4999: 40 km tur-retur (Vestsjælland)
2000-2999: 160 km tur-retur (København)
3000-3999: 120 km tur-retur (Nordsjælland)
5000-5999: 60 km tur-retur (Fyn)
<br/>Beregning:
Bemærkninger at der er ekstra omkostninger hvor der er bro afgifter eller sejlans forbundet med udførsel af opgaven
Fyn er indeholdt med broafgift
Jeg tænker hvis vi har km til opgaven og anvender km. Takst på 18,75 så er transport indeholdt
Køretid (timer) = Afstand ÷ 70 km/t
Transport arbejde = 2 medarbejdere × Timepris × Køretid × 2 (tur-retur)
Transport bil = Afstand × 4 kr/km
Total transport = Transport arbejde + Transport bil
Trin 6: Total før hast
Total før hast = Subtotal A + Dækningsbidrag + Spild + Total transport
Trin 7: Hastighedstillæg
Hastighedsmultiplikator:
\- Normal: × 1,00
\- Hurtigt: × 1,10
\- Rush: × 1,20
<br/>Pris ekskl. moms = Total før hast × Hastighedsmultiplikator
Trin 8: Moms og prisinterval
Moms = Pris ekskl. moms × 25%
Pris inkl. moms = Pris ekskl. moms + Moms
<br/>Prisinterval:
Min pris = Pris inkl. moms - 10.000 kr
Max pris = Pris inkl. moms + 10.000 kr
![📈](data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAYAAABzenr0AAAB50lEQVR4AWIYFGDDvqP7BwgbAIgrB996giCO/1M/m/HPLIO6/QtqRXXj1LZt27Ztm9vOJnPZd8/v7dtucprMzXxuvrNzFKC5d5jI1/jcIjfb1t4BPXBtuziSLg9Pcg/wTjjAYXQkWXn++GEALnp7aPK1zx/IcGuHWIDNxWVITAHOGuqpjwRQ2doNBoUDoHjZekenyIy1FU2+6O+PPuIqgLpvWluwPmIAWN2vV1fFAtwcHUq6H9ZUg0ksAOx3SD7j7Y3bEJfpm3A2OJgmXzH/B00Ih9zHdBUYyMlndcc5IEYC0H3x/Rtpv8MSCoC674eHoEkcgHy/6wxQ19mPTnhA0+hl22xoZOe8gs/00ioc8ve0VgD0xFKiTa2ffM7DMloCLOmev69GALnuXACgjBAUD4BQBdDn48vqzg9gxd0NAkMVIDgmgXJrn/P6Asgn4WhBEQ28/vMrfZ4anZAgpn5+J6ODI/TA/b5UUKBpOuo/CVFTHCQ4YCA5VgJ9toID0YWPBJCUltXeVmXgPX+qOYKAjSsAlB2CgwzqAgME6s4X4Dg9TWPHMzZsOj4A0IRsU8329uv7Oza6CXHowFXXL+NXgYbSStzP0O3iAWpiEtivFw9QmZJJJhzsjfobarLdjWBtCGQ0DBBWAACcIW/qxNrtTAAAAABJRU5ErkJggg==) Eksempel på beregning
Input:
<br/>Areal: 50 m²
Højde: 20 cm Ved beregning skal der på de 20 cm. Fratrækkes 5 cm. Til beton så der kun bliver 15 cm. isolering
Postnummer: 2100 (København)
Tidsramme: Normal
Output:
<br/>Isolering: 7,5 m3
Pumpet vægt: 4.500 kg
Transport: 160 km tur-retur
Estimeret pris: Ca. 120.000-140.000 kr inkl. moms
Prisinterval: ±10.000 kr variation
![⚠️](data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAYAAABzenr0AAACJUlEQVR4AWKgBHxaxiAAwgwDAQC9l0P8FkEcxq+5rl0yXy+z7Tpl252zu2Vesl2n7Eu2dcm262meMJ938q/5/PfwfbG7Mw/WSriLAt9YELV4sRfrcoNkmagZmYEPB8otwBEX5N2uohQnuyMRV6LFFMjm5cb8kbVAAwtwpRlwtrY28GF/GUELtukp/HQIcKdrtC3o9BSmgeutKGzRgk36xwNwe2dFLJxUGeTy5kDagn36Hu2qoWbNmiBNG9XA8/2eqAV5+gv1dHocC1G9enWD46vkLcjSP+hj7Ptq1aoZHFsZSI8FQfpLjSgOmmB6ClWpUsXA4owQpL/USIv071QBlSpV0sjPCHl6Q6RvxxAVK1bUCK6O9ulJn/YBwjAkaNEo4DJRC4Lz3kivGT3Qg+/7BL3b+Xq5bQtGegV43cfJyj9NPm+UC8/zCHq11cvtWzDueDznb3b85eRzRzpwnK/0aOPo5bYtmOkprq/5JnOUgXQ6Tfiby6xbMNP/hcNLHaRSKZDZIzJcZt2Cmf4vHFqaQSKRAJk1PM1lwhYs0pPz6x3EYjGQDVMMA8IW5Ok1iyakMHNYCs/2OfwvbMEiPaHoovHKwNCkNiBvwSL9wvFJlC1bFqRfuxiXCVuwSE8WjkugdOnSIH3blucycQvS9AZPVe0LxiYwfmCMv7ns/1vgigjJfqPKRG/A3A0Z4S6w5/2eEr98oaWJQoqaOcnbrYVrKmGiX+k/A8p4TZk7YN6vAAAAAElFTkSuQmCC) Vigtige noter
Alle priser er afrundede til nærmeste hele krone
Prisintervallet er ±10.000 kr (i alt 20.000 kr spænd) for at dække variation i konkrete forhold
Transportomkostninger er estimerede baseret på postnummerområder
Minimum areal er 25 m², maksimum er 300 m²
Maksimal højde er 100 cm

BIN
docs/byg_trans.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

BIN
docs/company_email.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

BIN
docs/dashboard.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 111 KiB

BIN
docs/gulv.jpeg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 171 KiB

BIN
docs/lovable.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

BIN
docs/mobile.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 224 KiB

BIN
docs/new_design.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

View file

@ -6,11 +6,11 @@ Dette dokument beskriver prisberegningslogikken for Foam King Gulves overslagsbe
## 1. Input fra kunden
| Felt | Enhed | Interval | Beskrivelse |
|------|-------|----------|-------------|
| Gulvareal | m² | 25-300 | Areal opmåles fra indvendig væg til væg (inkl. skillevægge) |
| Gulvhøjde | cm | 0-100 | Højde fra underlag til overkant af gulv (ekskl. gulvbelægning). Der fratrækkes automatisk 5 cm til betonstøbning |
| Postnummer | - | Dansk postnr. | Bruges til beregning af kørselsafstand fra 4550 Asnæs |
| Felt | Enhed | Interval | Beskrivelse |
| ---------- | ----- | ------------- | ---------------------------------------------------------------------------------------------------------------- |
| Gulvareal | m² | 25-300 | Areal opmåles fra indvendig væg til væg (inkl. skillevægge) |
| Gulvhøjde | cm | 0-100 | Højde fra underlag til overkant af gulv (ekskl. gulvbelægning). Der fratrækkes automatisk 5 cm til betonstøbning |
| Postnummer | - | Dansk postnr. | Bruges til beregning af kørselsafstand fra 4550 Asnæs |
---
@ -18,74 +18,74 @@ Dette dokument beskriver prisberegningslogikken for Foam King Gulves overslagsbe
### 2.1 Isolering
| Beskrivelse | Pris | Enhed |
|-------------|------|-------|
| Isolering - materialer | 2.850 kr | pr. m³ |
| Isolering - arbejdsløn | 880 kr | pr. m³ |
| **Isolering samlet** | **3.730 kr** | **pr. m³** |
| Beskrivelse | Pris | Enhed |
| ---------------------- | ------------ | ---------- |
| Isolering - materialer | 2.850 kr | pr. m³ |
| Isolering - arbejdsløn | 880 kr | pr. m³ |
| **Isolering samlet** | **3.730 kr** | **pr. m³** |
*Hvis højde = 0 (ingen isolering): Simpel arbejdsløn på 75 kr/m² tilføjes i stedet.*
_Hvis højde = 0 (ingen isolering): Simpel arbejdsløn på 75 kr/m² tilføjes i stedet._
### 2.2 Gulvvarme (altid inkluderet)
| Beskrivelse | Pris | Enhed |
|-------------|------|-------|
| Gulvvarme - materialer | 75 kr | pr. m² |
| Gulvvarme - arbejdsløn | 130 kr | pr. m² |
| **Gulvvarme samlet** | **205 kr** | **pr. m²** |
| Beskrivelse | Pris | Enhed |
| ---------------------- | ---------- | ---------- |
| Gulvvarme - materialer | 75 kr | pr. m² |
| Gulvvarme - arbejdsløn | 130 kr | pr. m² |
| **Gulvvarme samlet** | **205 kr** | **pr. m²** |
*Gulvvarme udføres som Ø16 Pex i relevante gulvvarmekredse (ekskl. tilslutning til fordeler).*
_Gulvvarme udføres som Ø16 Pex i relevante gulvvarmekredse (ekskl. tilslutning til fordeler)._
### 2.3 Syntetisk net (altid inkluderet)
| Beskrivelse | Pris | Enhed |
|-------------|------|-------|
| Syntetisk net - materialer | 24 kr | pr. m² |
| Syntetisk net - arbejdsløn | 25 kr | pr. m² |
| **Syntetisk net samlet** | **49 kr** | **pr. m²** |
| Beskrivelse | Pris | Enhed |
| -------------------------- | --------- | ---------- |
| Syntetisk net - materialer | 24 kr | pr. m² |
| Syntetisk net - arbejdsløn | 25 kr | pr. m² |
| **Syntetisk net samlet** | **49 kr** | **pr. m²** |
### 2.4 Flydespartel
| Beskrivelse | Pris | Enhed |
|-------------|------|-------|
| Beskrivelse | Pris | Enhed |
| ------------------------- | ------ | ------ |
| Flydespartel - materialer | 450 kr | pr. m² |
| Spartelforbrug | 90 kg | pr. m² |
| Spartelforbrug | 90 kg | pr. m² |
*Flydespartel støbes i 50 mm tykkelse og er egnet til svømmende trægulv og klinker.*
_Flydespartel støbes i 50 mm tykkelse og er egnet til svømmende trægulv og klinker._
### 2.5 Pumpebil-tillæg
Tillæg baseret på den samlede spartelvægt (areal × 90 kg/m²):
| Vægtinterval | Tillæg |
|--------------|--------|
| Over 8.000 kg | 0 kr |
| Vægtinterval | Tillæg |
| -------------- | -------- |
| Over 8.000 kg | 0 kr |
| 5.000-8.000 kg | 3.800 kr |
| 3.000-5.000 kg | 6.000 kr |
| 0-3.000 kg | 8.100 kr |
| 0-3.000 kg | 8.100 kr |
### 2.6 Faste gebyrer
| Beskrivelse | Pris |
|-------------|------|
| Beskrivelse | Pris |
| ---------------------------------------------- | -------- |
| Startgebyr (leje af anlæg og sikkerhedsudstyr) | 3.500 kr |
### 2.7 Transport
| Beskrivelse | Pris | Enhed |
|-------------|------|-------|
| Kørsel (inkl. bil, diesel og mandskab) | 18,75 kr | pr. km |
| Storebælt brotillæg (kun Fyn, postnr. 5000-5999) | 500 kr | fast |
| Beskrivelse | Pris | Enhed |
| ------------------------------------------------ | -------- | ------ |
| Kørsel (inkl. bil, diesel og mandskab) | 18,75 kr | pr. km |
| Storebælt brotillæg (kun Fyn, postnr. 5000-5999) | 500 kr | fast |
*Afstand beregnes som tur-retur fra 4550 Asnæs til kundens adresse.*
_Afstand beregnes som tur-retur fra 4550 Asnæs til kundens adresse._
### 2.8 Procenttillæg
| Beskrivelse | Procent | Forklaring |
|-------------|---------|------------|
| Afdækning | 0,7% | Plast, tape mv. til afdækning af arbejdsområde |
| Affald | 0,25% | Bortskaffelse af beton, skum, plastaffald mv. |
| **Samlet tillæg** | **0,95%** | Af subtotal |
| Beskrivelse | Procent | Forklaring |
| ----------------- | --------- | ---------------------------------------------- |
| Afdækning | 0,7% | Plast, tape mv. til afdækning af arbejdsområde |
| Affald | 0,25% | Bortskaffelse af beton, skum, plastaffald mv. |
| **Samlet tillæg** | **0,95%** | Af subtotal |
---
@ -102,6 +102,7 @@ Spartelvægt (kg) = Areal × 90
### Trin 2: Beregn komponenter
**Isolering:**
```
Hvis isoleringstykkelse > 0:
Isolering = Isoleringsvolumen × 3.730 kr
@ -110,21 +111,25 @@ Ellers:
```
**Gulvvarme:**
```
Gulvvarme = Areal × 205 kr
```
**Syntetisk net:**
```
Syntetisk net = Areal × 49 kr
```
**Flydespartel:**
```
Flydespartel = Areal × 450 kr
```
**Pumpebil-tillæg:**
```
Hvis spartelvægt > 8.000 kg: Pumpebil = 0 kr
Hvis spartelvægt > 5.000 kg: Pumpebil = 3.800 kr
@ -169,6 +174,7 @@ Total inkl. moms = Total ekskl. moms × 1,25
## 4. Regneeksempel
**Input:**
- Areal: 50 m²
- Højde: 20 cm
- Postnummer: 2100 (København)
@ -203,9 +209,10 @@ Total inkl. moms = 76.365,42 × 1,25 = 95.456,78 kr
```
**Output til kunden:**
> Ca. 95.500 kr inkl. moms
>
> *Den endelige pris kan variere afhængigt af konkrete forhold på stedet.*
> _Den endelige pris kan variere afhængigt af konkrete forhold på stedet._
---
@ -221,5 +228,5 @@ Total inkl. moms = 76.365,42 × 1,25 = 95.456,78 kr
---
*Dokumentversion: 1.0*
*Sidst opdateret: Januar 2026*
_Dokumentversion: 1.0_
_Sidst opdateret: Januar 2026_

View file

@ -3,16 +3,20 @@
## 1. Projektbeskrivelse
### 1.1 Formål
Udvikle en online overslagsberegner til Foam King Gulve, der giver potentielle kunder et hurtigt prisestimat på gulvløsninger. Beregneren skal være tilgængelig på `beregner.foamking.dk`.
### 1.2 Målgruppe
- Private husejere
- Sommerhusejere
- Hovedentreprenører
- Bygherrer
### 1.3 Scope
Beregneren dækker **kun gulvløsninger** med følgende komponenter:
- Isolering mellem strøer
- Gulvvarme
- Syntetisk net
@ -24,20 +28,21 @@ Beregneren dækker **kun gulvløsninger** med følgende komponenter:
### 2.1 Input-felter
| Felt | Type | Validering | Påkrævet |
|------|------|------------|----------|
| Navn | Tekst | Min. 2 tegn | Ja |
| Email | Email | Gyldig email-format | Ja |
| Telefon | Tal | 8 cifre | Ja |
| Postnummer | Tal | 4 cifre, gyldigt dansk postnr. | Ja |
| Adresse | Tekst | - | Nej |
| Gulvareal | Tal | 25-300 m² | Ja |
| Gulvhøjde | Tal | 0-100 cm | Ja |
| Bemærkninger | Tekstfelt | - | Nej |
| Felt | Type | Validering | Påkrævet |
| ------------ | --------- | ------------------------------ | -------- |
| Navn | Tekst | Min. 2 tegn | Ja |
| Email | Email | Gyldig email-format | Ja |
| Telefon | Tal | 8 cifre | Ja |
| Postnummer | Tal | 4 cifre, gyldigt dansk postnr. | Ja |
| Adresse | Tekst | - | Nej |
| Gulvareal | Tal | 25-300 m² | Ja |
| Gulvhøjde | Tal | 0-100 cm | Ja |
| Bemærkninger | Tekstfelt | - | Nej |
### 2.2 Output
Beregneren skal vise:
1. **Prisestimat**: "Ca. X kr inkl. moms"
2. **Disclaimer**: Note om at prisen er vejledende og kan variere
3. **Kontaktmulighed**: Mulighed for at anmode om et bindende tilbud
@ -45,6 +50,7 @@ Beregneren skal vise:
### 2.3 Beregningslogik
Se [prisbeskrivelse.md](prisbeskrivelse.md) for komplet dokumentation af:
- Alle priskonstanter
- Beregningsformler
- Trin-for-trin beregningsproces
@ -59,6 +65,7 @@ Se [prisbeskrivelse.md](prisbeskrivelse.md) for komplet dokumentation af:
**Krav:** Præcis beregning af kørselsafstand fra 4550 Asnæs til kundens adresse.
**Mulige løsninger:**
1. **Google Maps Distance Matrix API**
- Præcis afstand
- Koster pr. request
@ -78,13 +85,13 @@ Se [prisbeskrivelse.md](prisbeskrivelse.md) for komplet dokumentation af:
Foam King arbejder primært i følgende områder:
| Postnummer-interval | Område | Bro/færge-tillæg |
|---------------------|--------|------------------|
| 4000-4999 | Vestsjælland | Ingen |
| 2000-2999 | København | Ingen |
| 3000-3999 | Nordsjælland | Ingen |
| 4800-4899 | Lolland-Falster | Ingen |
| 5000-5999 | Fyn | 500 kr (Storebælt) |
| Postnummer-interval | Område | Bro/færge-tillæg |
| ------------------- | --------------- | ------------------ |
| 4000-4999 | Vestsjælland | Ingen |
| 2000-2999 | København | Ingen |
| 3000-3999 | Nordsjælland | Ingen |
| 4800-4899 | Lolland-Falster | Ingen |
| 5000-5999 | Fyn | 500 kr (Storebælt) |
### 3.3 Hosting
@ -116,15 +123,19 @@ Foam King arbejder primært i følgende områder:
## 5. Dataflow
### 5.1 Ved prisberegning (kun visning)
- Ingen data gemmes
- Beregning sker i browseren
### 5.2 Ved tilbudsanmodning
Data sendes til:
1. Email til `info@foamking.dk` med kalkulationsskema
2. (Valgfrit) Integration med eksisterende system
Indhold i email:
- Kundens kontaktoplysninger
- Indtastede værdier (areal, højde, postnr.)
- Beregnet prisestimat
@ -138,20 +149,20 @@ Følgende punkter skal afklares før/under udvikling:
### 6.1 Forretningslogik
| Nr. | Spørgsmål | Status |
|-----|-----------|--------|
| 1 | Skal kunden kunne fravælge gulvvarme? | Afventer |
| 2 | Skal der være mulighed for forskellige gulvbelægninger (påvirker sparteltype)? | Afventer |
| 3 | Hvad er den præcise Storebælt-pris? (Antaget 500 kr) | Afventer bekræftelse |
| 4 | Skal opgaver uden for dækningsområdet afvises eller vises med advarsel? | Afventer |
| Nr. | Spørgsmål | Status |
| --- | ------------------------------------------------------------------------------ | -------------------- |
| 1 | Skal kunden kunne fravælge gulvvarme? | Afventer |
| 2 | Skal der være mulighed for forskellige gulvbelægninger (påvirker sparteltype)? | Afventer |
| 3 | Hvad er den præcise Storebælt-pris? (Antaget 500 kr) | Afventer bekræftelse |
| 4 | Skal opgaver uden for dækningsområdet afvises eller vises med advarsel? | Afventer |
### 6.2 Teknisk
| Nr. | Spørgsmål | Status |
|-----|-----------|--------|
| 5 | Hvilken afstands-API foretrækkes? | Afventer |
| 6 | Skal beregnerdata gemmes i database? | Afventer |
| 7 | Er der eksisterende CRM/system der skal integreres med? | Afventer |
| Nr. | Spørgsmål | Status |
| --- | ------------------------------------------------------- | -------- |
| 5 | Hvilken afstands-API foretrækkes? | Afventer |
| 6 | Skal beregnerdata gemmes i database? | Afventer |
| 7 | Er der eksisterende CRM/system der skal integreres med? | Afventer |
---
@ -159,15 +170,15 @@ Følgende punkter skal afklares før/under udvikling:
### 7.1 Faste priser
| Komponent | Samlet pris | Enhed |
|-----------|-------------|-------|
| Isolering | 3.730 kr | pr. m³ |
| Gulvvarme | 205 kr | pr. m² |
| Syntetisk net | 49 kr | pr. m² |
| Flydespartel | 450 kr | pr. m² |
| Startgebyr | 3.500 kr | fast |
| Kørsel | 18,75 kr | pr. km |
| Storebælt (Fyn) | 500 kr | fast |
| Komponent | Samlet pris | Enhed |
| --------------- | ----------- | ------ |
| Isolering | 3.730 kr | pr. m³ |
| Gulvvarme | 205 kr | pr. m² |
| Syntetisk net | 49 kr | pr. m² |
| Flydespartel | 450 kr | pr. m² |
| Startgebyr | 3.500 kr | fast |
| Kørsel | 18,75 kr | pr. km |
| Storebælt (Fyn) | 500 kr | fast |
### 7.2 Variable tillæg
@ -180,6 +191,7 @@ Følgende punkter skal afklares før/under udvikling:
| < 3.000 kg | 8.100 kr |
**Procenttillæg:**
- Afdækning: 0,7%
- Affald: 0,25%
@ -205,5 +217,5 @@ Pris = (Isolering + Gulvvarme + Net + Spartel + Pumpebil + Startgebyr)
---
*Dokumentversion: 1.0*
*Sidst opdateret: Januar 2026*
_Dokumentversion: 1.0_
_Sidst opdateret: Januar 2026_

BIN
docs/tilbud.pdf Normal file

Binary file not shown.

View file

@ -19,4 +19,4 @@ const eslintConfig = [
},
]
export default eslintConfig
export default eslintConfig

116
lib/auth.ts Normal file
View file

@ -0,0 +1,116 @@
import bcrypt from "bcrypt"
import { cookies } from "next/headers"
import {
createSession,
deleteSession,
getSession,
getUserByEmail,
getUserById,
createUser as dbCreateUser,
cleanExpiredSessions,
type User,
} from "./db"
const SALT_ROUNDS = 10
const SESSION_COOKIE_NAME = "session"
const SESSION_DURATION_DAYS = 7
export async function hashPassword(password: string): Promise<string> {
return bcrypt.hash(password, SALT_ROUNDS)
}
export async function verifyPassword(password: string, hash: string): Promise<boolean> {
return bcrypt.compare(password, hash)
}
function generateSessionId(): string {
const bytes = new Uint8Array(32)
crypto.getRandomValues(bytes)
return Array.from(bytes)
.map((b) => b.toString(16).padStart(2, "0"))
.join("")
}
export async function login(
email: string,
password: string
): Promise<{ success: true; user: User } | { success: false; error: string }> {
const user = getUserByEmail(email)
if (!user) {
return { success: false, error: "Forkert email eller adgangskode" }
}
const validPassword = await verifyPassword(password, user.passwordHash)
if (!validPassword) {
return { success: false, error: "Forkert email eller adgangskode" }
}
// Clean up old sessions periodically
cleanExpiredSessions()
// Create new session
const sessionId = generateSessionId()
const expiresAt = new Date()
expiresAt.setDate(expiresAt.getDate() + SESSION_DURATION_DAYS)
createSession(sessionId, user.id, expiresAt)
// Set cookie
const cookieStore = await cookies()
cookieStore.set(SESSION_COOKIE_NAME, sessionId, {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "lax",
expires: expiresAt,
path: "/",
})
return {
success: true,
user: { id: user.id, email: user.email, name: user.name, createdAt: user.createdAt },
}
}
export async function logout(): Promise<void> {
const cookieStore = await cookies()
const sessionId = cookieStore.get(SESSION_COOKIE_NAME)?.value
if (sessionId) {
deleteSession(sessionId)
cookieStore.delete(SESSION_COOKIE_NAME)
}
}
export async function getCurrentUser(): Promise<User | null> {
const cookieStore = await cookies()
const sessionId = cookieStore.get(SESSION_COOKIE_NAME)?.value
if (!sessionId) return null
const session = getSession(sessionId)
if (!session) return null
// Check if session is expired
if (session.expiresAt < new Date()) {
deleteSession(sessionId)
return null
}
return getUserById(session.userId)
}
export async function isAuthenticated(): Promise<boolean> {
const user = await getCurrentUser()
return user !== null
}
// Helper to create users (run from CLI or seed script)
export async function createUserWithPassword(
email: string,
password: string,
name: string
): Promise<User> {
const passwordHash = await hashPassword(password)
return dbCreateUser(email, passwordHash, name)
}

View file

@ -1,10 +1,22 @@
import { PRICES, PUMP_TRUCK_FEES, CONSTRAINTS, COVERAGE_AREAS } from "./constants"
import {
PRICES,
PUMP_TRUCK_FEES,
CONSTRAINTS,
COVERAGE_AREAS,
FLOORING_TYPES,
type FlooringType,
} from "./constants"
export interface CalculationInput {
area: number // m²
height: number // cm
postalCode: string
distance: number // km (round trip)
// Optional components
includeInsulation?: boolean // default: true
includeFloorHeating?: boolean // default: true
includeCompound?: boolean // default: true
flooringType?: FlooringType // default: KLINKER
}
export interface CalculationDetails {
@ -14,6 +26,12 @@ export interface CalculationDetails {
postalCode: string
distance: number
// Optional component selections
includeInsulation: boolean
includeFloorHeating: boolean
includeCompound: boolean
flooringType: FlooringType
// Calculated values
insulationThickness: number // cm
insulationVolume: number // m³
@ -43,16 +61,29 @@ export interface CalculationDetails {
totalInclVat: number
}
export function calculateInsulation(area: number, height: number): {
export function calculateInsulation(
area: number,
height: number
): {
thickness: number
volume: number
volumePrice: number
baseLabor: number
price: number
} {
const thickness = Math.max(0, height - CONSTRAINTS.CONCRETE_THICKNESS)
const volume = area * (thickness / 100)
const price = thickness > 0 ? volume * PRICES.INSULATION_TOTAL : area * PRICES.SIMPLE_LABOR
return { thickness, volume, price }
// Volume-based cost (materials + labor per m³)
const volumePrice = thickness > 0 ? volume * PRICES.INSULATION_TOTAL_PER_M3 : 0
// Base labor cost (always applied per m² when insulation is included)
const baseLabor = area * PRICES.INSULATION_BASE_LABOR
// Total insulation price
const price = volumePrice + baseLabor
return { thickness, volume, volumePrice, baseLabor, price }
}
export function calculatePumpTruckFee(weight: number): number {
@ -62,33 +93,49 @@ export function calculatePumpTruckFee(weight: number): number {
export function getBridgeFee(postalCode: string): number {
const postalNumber = parseInt(postalCode)
for (const area of Object.values(COVERAGE_AREAS)) {
if (postalNumber >= area.start && postalNumber <= area.end) {
return area.bridgeFee
}
}
return 0
}
export function calculatePrice(input: CalculationInput): CalculationDetails {
const { area, height, postalCode, distance } = input
const {
area,
height,
postalCode,
distance,
includeInsulation = true,
includeFloorHeating = true,
includeCompound = true,
flooringType = "STANDARD",
} = input
// Step 1: Calculate derived values
const insulation = calculateInsulation(area, height)
const compoundWeight = area * PRICES.COMPOUND_WEIGHT_PER_M2
const compoundWeight = includeCompound ? area * PRICES.COMPOUND_WEIGHT_PER_M2 : 0
// Step 2: Calculate components
const floorHeating = area * PRICES.FLOOR_HEATING_TOTAL
const syntheticNet = area * PRICES.SYNTHETIC_NET_TOTAL
const selfLevelingCompound = area * PRICES.SELF_LEVELING_COMPOUND
const pumpTruckFee = calculatePumpTruckFee(compoundWeight)
// Get flooring type multiplier
const flooringConfig = FLOORING_TYPES[flooringType]
const compoundMultiplier = flooringConfig?.compoundMultiplier ?? 1.0
// Step 2: Calculate components (only if included)
const insulationPrice = includeInsulation ? insulation.price : 0
const floorHeating = includeFloorHeating ? area * PRICES.FLOOR_HEATING_TOTAL : 0
const syntheticNet = includeFloorHeating ? area * PRICES.SYNTHETIC_NET_TOTAL : 0 // Net only with heating
const selfLevelingCompound = includeCompound
? area * PRICES.SELF_LEVELING_COMPOUND * compoundMultiplier
: 0
const pumpTruckFee = includeCompound ? calculatePumpTruckFee(compoundWeight) : 0
const startFee = PRICES.START_FEE
// Step 3: Calculate subtotal
const subtotal =
insulation.price + floorHeating + syntheticNet + selfLevelingCompound + pumpTruckFee + startFee
insulationPrice + floorHeating + syntheticNet + selfLevelingCompound + pumpTruckFee + startFee
// Step 4: Calculate percentage fees
const coveringFee = subtotal * PRICES.COVERING_PERCENTAGE
@ -111,13 +158,19 @@ export function calculatePrice(input: CalculationInput): CalculationDetails {
postalCode,
distance,
// Optional component selections
includeInsulation,
includeFloorHeating,
includeCompound,
flooringType,
// Calculated values
insulationThickness: insulation.thickness,
insulationVolume: insulation.volume,
compoundWeight,
// Component prices
insulation: insulation.price,
insulation: insulationPrice,
floorHeating,
syntheticNet,
selfLevelingCompound,
@ -154,4 +207,4 @@ export function formatEstimate(price: number): string {
// Round to nearest 500
const rounded = Math.round(price / 500) * 500
return `Ca. ${formatPrice(rounded)}`
}
}

View file

@ -1,9 +1,10 @@
export const PRICES = {
// Isolering
// Isolering (per m³ volume)
INSULATION_MATERIALS: 2850, // kr/m³
INSULATION_LABOR: 880, // kr/m³
INSULATION_TOTAL: 3730, // kr/m³
SIMPLE_LABOR: 75, // kr/m² (når højde = 0)
INSULATION_LABOR_PER_M3: 880, // kr/m³
INSULATION_TOTAL_PER_M3: 3730, // kr/m³
// Base insulation labor (always applied per m² area)
INSULATION_BASE_LABOR: 75, // kr/m² - always added when insulation is included
// Gulvvarme (altid inkluderet)
FLOOR_HEATING_MATERIALS: 75, // kr/m²
@ -35,23 +36,42 @@ export const PRICES = {
VAT: 0.25, // 25%
} as const
// Pumpebil fees include both materials and labor (from rene.pdf)
export const PUMP_TRUCK_FEES = [
{ minWeight: 8000, fee: 0 },
{ minWeight: 5000, fee: 3800 },
{ minWeight: 3000, fee: 6000 },
{ minWeight: 0, fee: 8100 },
{ minWeight: 8000, materialFee: 0, laborFee: 8800, fee: 8800 }, // >8000 kg
{ minWeight: 5000, materialFee: 3800, laborFee: 8000, fee: 11800 }, // 5000-8000 kg
{ minWeight: 3000, materialFee: 6000, laborFee: 7000, fee: 13000 }, // 3000-5000 kg
{ minWeight: 0, materialFee: 8100, laborFee: 7000, fee: 15100 }, // 0-3000 kg
] as const
export const CONSTRAINTS = {
MIN_AREA: 25, // m²
MAX_AREA: 300, // m²
MIN_HEIGHT: 0, // cm
MAX_AREA: 250, // m²
MIN_HEIGHT: 8, // cm
MAX_HEIGHT: 100, // cm
CONCRETE_THICKNESS: 5, // cm (fratrækkes fra højde)
HOME_POSTAL_CODE: "4550", // Asnæs
HOME_CITY: "Asnæs",
} as const
// Gulvbelægningstyper - påvirker valg af gulvspartel
export const FLOORING_TYPES = {
STANDARD: {
id: "standard",
name: "Klinker / Svømmende trægulv",
description: "Fliser, klinker eller trægulv der ikke limes",
compoundMultiplier: 1.0, // Standard compound
},
GLUED_WOOD: {
id: "glued_wood",
name: "Limet trægulv",
description: "Trægulv der limes fast til underlaget",
compoundMultiplier: 1.28, // Premium compound kræves (+28%)
},
} as const
export type FlooringType = keyof typeof FLOORING_TYPES
export const COVERAGE_AREAS = {
WEST_ZEALAND: { start: 4000, end: 4999, bridgeFee: 0 },
COPENHAGEN: { start: 2000, end: 2999, bridgeFee: 0 },
@ -60,4 +80,4 @@ export const COVERAGE_AREAS = {
FUNEN: { start: 5000, end: 5999, bridgeFee: PRICES.GREAT_BELT_FEE },
} as const
export type CoverageArea = keyof typeof COVERAGE_AREAS
export type CoverageArea = keyof typeof COVERAGE_AREAS

272
lib/db.ts Normal file
View file

@ -0,0 +1,272 @@
import Database from "better-sqlite3"
import path from "path"
// Database file stored in project root
const DB_PATH = path.join(process.cwd(), "data", "quotes.db")
// Ensure data directory exists
import fs from "fs"
const dataDir = path.dirname(DB_PATH)
if (!fs.existsSync(dataDir)) {
fs.mkdirSync(dataDir, { recursive: true })
}
const db = new Database(DB_PATH)
// Initialize database schema
db.exec(`
CREATE TABLE IF NOT EXISTS quotes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
postal_code TEXT NOT NULL,
address TEXT,
area REAL NOT NULL,
height REAL NOT NULL,
include_floor_heating INTEGER NOT NULL DEFAULT 1,
flooring_type TEXT NOT NULL DEFAULT 'STANDARD',
customer_name TEXT NOT NULL,
customer_email TEXT NOT NULL,
customer_phone TEXT NOT NULL,
remarks TEXT,
total_excl_vat REAL,
total_incl_vat REAL,
status TEXT NOT NULL DEFAULT 'new',
created_at TEXT DEFAULT CURRENT_TIMESTAMP
)
`)
// Migration: Add status column if it doesn't exist
try {
db.exec("ALTER TABLE quotes ADD COLUMN status TEXT NOT NULL DEFAULT 'new'")
} catch {
// Column already exists
}
// Migration: Add email_opened_at column for tracking
try {
db.exec("ALTER TABLE quotes ADD COLUMN email_opened_at TEXT")
} catch {
// Column already exists
}
// Users table for authentication
db.exec(`
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
email TEXT UNIQUE NOT NULL,
password_hash TEXT NOT NULL,
name TEXT NOT NULL,
created_at TEXT DEFAULT CURRENT_TIMESTAMP
)
`)
// Sessions table for login sessions
db.exec(`
CREATE TABLE IF NOT EXISTS sessions (
id TEXT PRIMARY KEY,
user_id INTEGER NOT NULL,
expires_at TEXT NOT NULL,
FOREIGN KEY (user_id) REFERENCES users(id)
)
`)
// Start IDs at 1000
const countResult = db.prepare("SELECT COUNT(*) as count FROM quotes").get() as { count: number }
if (countResult.count === 0) {
db.exec(
"INSERT INTO quotes (id, postal_code, area, height, customer_name, customer_email, customer_phone) VALUES (999, '0000', 0, 0, 'init', 'init', 'init')"
)
db.exec("DELETE FROM quotes WHERE id = 999")
// Reset autoincrement to start at 1000
db.exec("UPDATE sqlite_sequence SET seq = 999 WHERE name = 'quotes'")
}
export interface QuoteInput {
postalCode: string
address?: string
area: number
height: number
includeFloorHeating: boolean
flooringType: string
customerName: string
customerEmail: string
customerPhone: string
remarks?: string
totalExclVat?: number
totalInclVat?: number
}
export type QuoteStatus = "new" | "contacted" | "accepted" | "rejected"
export interface StoredQuote extends QuoteInput {
id: number
status: QuoteStatus
createdAt: string
emailOpenedAt: string | null
}
export function saveQuote(quote: QuoteInput): { id: number; slug: string } {
const stmt = db.prepare(`
INSERT INTO quotes (
postal_code, address, area, height, include_floor_heating, flooring_type,
customer_name, customer_email, customer_phone, remarks, total_excl_vat, total_incl_vat
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`)
const result = stmt.run(
quote.postalCode,
quote.address || null,
quote.area,
quote.height,
quote.includeFloorHeating ? 1 : 0,
quote.flooringType,
quote.customerName,
quote.customerEmail,
quote.customerPhone,
quote.remarks || null,
quote.totalExclVat || null,
quote.totalInclVat || null
)
const id = result.lastInsertRowid as number
const slug = `${quote.postalCode}-${id}`
return { id, slug }
}
export function getQuoteBySlug(slug: string): StoredQuote | null {
const match = slug.match(/^(\d{4})-(\d+)$/)
if (!match) return null
const [, postalCode, idStr] = match
const id = parseInt(idStr, 10)
const stmt = db.prepare("SELECT * FROM quotes WHERE id = ? AND postal_code = ?")
const row = stmt.get(id, postalCode) as any
if (!row) return null
return rowToQuote(row)
}
export function getQuoteById(id: number): StoredQuote | null {
const stmt = db.prepare("SELECT * FROM quotes WHERE id = ?")
const row = stmt.get(id) as any
if (!row) return null
return rowToQuote(row)
}
export function getAllQuotes(): StoredQuote[] {
const stmt = db.prepare("SELECT * FROM quotes ORDER BY id DESC")
const rows = stmt.all() as any[]
return rows.map(rowToQuote)
}
export function updateQuoteStatus(id: number, status: QuoteStatus): boolean {
const stmt = db.prepare("UPDATE quotes SET status = ? WHERE id = ?")
const result = stmt.run(status, id)
return result.changes > 0
}
export function markEmailOpened(id: number): boolean {
// Only update if not already set (first open)
const stmt = db.prepare(
"UPDATE quotes SET email_opened_at = ? WHERE id = ? AND email_opened_at IS NULL"
)
const result = stmt.run(new Date().toISOString(), id)
return result.changes > 0
}
function rowToQuote(row: any): StoredQuote {
return {
id: row.id,
postalCode: row.postal_code,
address: row.address,
area: row.area,
height: row.height,
includeFloorHeating: row.include_floor_heating === 1,
flooringType: row.flooring_type,
customerName: row.customer_name,
customerEmail: row.customer_email,
customerPhone: row.customer_phone,
remarks: row.remarks,
totalExclVat: row.total_excl_vat,
totalInclVat: row.total_incl_vat,
status: row.status || "new",
createdAt: row.created_at,
emailOpenedAt: row.email_opened_at || null,
}
}
// User management
export interface User {
id: number
email: string
name: string
createdAt: string
}
export interface UserWithPassword extends User {
passwordHash: string
}
export function createUser(email: string, passwordHash: string, name: string): User {
const stmt = db.prepare("INSERT INTO users (email, password_hash, name) VALUES (?, ?, ?)")
const result = stmt.run(email, passwordHash, name)
return {
id: result.lastInsertRowid as number,
email,
name,
createdAt: new Date().toISOString(),
}
}
export function getUserByEmail(email: string): UserWithPassword | null {
const stmt = db.prepare("SELECT * FROM users WHERE email = ?")
const row = stmt.get(email) as any
if (!row) return null
return {
id: row.id,
email: row.email,
name: row.name,
passwordHash: row.password_hash,
createdAt: row.created_at,
}
}
export function getUserById(id: number): User | null {
const stmt = db.prepare("SELECT id, email, name, created_at FROM users WHERE id = ?")
const row = stmt.get(id) as any
if (!row) return null
return {
id: row.id,
email: row.email,
name: row.name,
createdAt: row.created_at,
}
}
// Session management
export function createSession(sessionId: string, userId: number, expiresAt: Date): void {
const stmt = db.prepare("INSERT INTO sessions (id, user_id, expires_at) VALUES (?, ?, ?)")
stmt.run(sessionId, userId, expiresAt.toISOString())
}
export function getSession(sessionId: string): { userId: number; expiresAt: Date } | null {
const stmt = db.prepare("SELECT user_id, expires_at FROM sessions WHERE id = ?")
const row = stmt.get(sessionId) as any
if (!row) return null
return {
userId: row.user_id,
expiresAt: new Date(row.expires_at),
}
}
export function deleteSession(sessionId: string): void {
const stmt = db.prepare("DELETE FROM sessions WHERE id = ?")
stmt.run(sessionId)
}
export function cleanExpiredSessions(): void {
const stmt = db.prepare("DELETE FROM sessions WHERE expires_at < ?")
stmt.run(new Date().toISOString())
}

View file

@ -55,6 +55,18 @@ export const POSTAL_CODE_DISTANCES: Record<string, number> = {
// Default distances based on first two digits of postal code
const DEFAULT_DISTANCES: Record<string, number> = {
// København centrum (1000-1999)
"10": 200,
"11": 200,
"12": 200,
"13": 200,
"14": 200,
"15": 200,
"16": 200,
"17": 200,
"18": 200,
"19": 200,
// København (2000-2999)
"20": 200, // København området
"21": 206,
"22": 208,
@ -109,6 +121,8 @@ export function getDistance(postalCode: string): number {
// If still no match, estimate based on region
const firstDigit = postalCode[0]
switch (firstDigit) {
case "1":
return 200 // København centrum average
case "2":
return 190 // København average
case "3":
@ -123,23 +137,9 @@ export function getDistance(postalCode: string): number {
}
export function isInCoverageArea(postalCode: string): boolean {
const firstDigit = postalCode[0]
const postalNumber = parseInt(postalCode)
// Check main coverage areas
if (["2", "3", "4", "5"].includes(firstDigit)) {
// Special check for Lolland-Falster (4800-4899)
if (postalNumber >= 4800 && postalNumber <= 4899) {
return true
}
// Exclude other 4900+ areas
if (postalNumber >= 4900 && postalNumber < 5000) {
return false
}
return true
}
return false
// Coverage area: 0-5999 (Sjælland, Lolland-Falster, Fyn)
return postalNumber >= 0 && postalNumber <= 5999
}
export function validateDanishPostalCode(postalCode: string): boolean {
@ -151,4 +151,4 @@ export function validateDanishPostalCode(postalCode: string): boolean {
// Valid ranges for Danish postal codes
const code = parseInt(postalCode)
return code >= 1000 && code <= 9999
}
}

View file

@ -3,4 +3,4 @@ import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
}

34
middleware.ts Normal file
View file

@ -0,0 +1,34 @@
import { NextResponse } from "next/server"
import type { NextRequest } from "next/server"
// Routes that require authentication
const protectedPaths = ["/dashboard", "/historik", "/admin"]
export function middleware(request: NextRequest) {
const { pathname } = request.nextUrl
// Check if path requires authentication
const isProtectedPath = protectedPaths.some((path) => pathname.startsWith(path))
if (!isProtectedPath) {
return NextResponse.next()
}
// Check for session cookie
const sessionCookie = request.cookies.get("session")
if (!sessionCookie?.value) {
// Redirect to login
const loginUrl = new URL("/login", request.url)
loginUrl.searchParams.set("redirect", pathname)
return NextResponse.redirect(loginUrl)
}
// Cookie exists - let the page validate the session
// (Session validation happens server-side in the page)
return NextResponse.next()
}
export const config = {
matcher: ["/dashboard/:path*", "/historik/:path*", "/admin/:path*"],
}

View file

@ -1,7 +1,7 @@
import type { NextConfig } from 'next'
import type { NextConfig } from "next"
const nextConfig: NextConfig = {
/* config options here */
}
export default nextConfig
export default nextConfig

10013
package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

View file

@ -3,41 +3,52 @@
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"dev": "next dev -p 3001 -H 0.0.0.0",
"build": "next build",
"start": "next start",
"start": "next start -p 3001 -H 0.0.0.0",
"lint": "next lint",
"typecheck": "tsc --noEmit",
"format": "prettier --write .",
"format:check": "prettier --check ."
},
"dependencies": {
"@hookform/resolvers": "5.2.2",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-label": "2.1.8",
"@radix-ui/react-progress": "^1.1.8",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-slider": "^1.3.6",
"@radix-ui/react-slot": "1.2.4",
"@radix-ui/react-switch": "^1.2.6",
"bcrypt": "^6.0.0",
"better-sqlite3": "^12.6.0",
"class-variance-authority": "0.7.1",
"clsx": "2.1.1",
"lucide-react": "0.562.0",
"next": "16.1.1",
"nodemailer": "^7.0.12",
"react": "19.2.3",
"react-dom": "19.2.3",
"next": "16.1.1",
"clsx": "2.2.1",
"tailwind-merge": "2.7.0",
"lucide-react": "0.483.0",
"class-variance-authority": "0.7.1",
"@radix-ui/react-label": "2.1.2",
"@radix-ui/react-slot": "1.1.1",
"react-hook-form": "7.55.1",
"@hookform/resolvers": "3.10.2",
"zod": "3.24.2"
"react-hook-form": "7.70.0",
"tailwind-merge": "3.4.0",
"zod": "4.3.5"
},
"devDependencies": {
"@eslint/eslintrc": "3.3.3",
"@types/bcrypt": "^6.0.0",
"@types/better-sqlite3": "^7.6.13",
"@types/node": "25.0.5",
"@types/nodemailer": "^7.0.5",
"@types/react": "19.2.8",
"@types/react-dom": "19.2.3",
"typescript": "5.9.3",
"tailwindcss": "3.4.17",
"postcss": "8.5.6",
"autoprefixer": "10.4.23",
"eslint": "9.39.2",
"eslint-config-next": "16.1.1",
"@eslint/eslintrc": "3.3.3",
"postcss": "8.5.6",
"prettier": "3.7.4",
"prettier-plugin-tailwindcss": "0.7.2",
"tailwindcss-animate": "1.0.7"
"tailwindcss": "3.4.17",
"tailwindcss-animate": "1.0.7",
"typescript": "5.9.3"
}
}
}

View file

@ -6,4 +6,4 @@ const config = {
},
}
export default config
export default config

BIN
public/byg_trans.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

BIN
public/dansk_kvalitet.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 73 KiB

BIN
public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

BIN
public/gulv.jpeg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 171 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 89 KiB

View file

@ -3,10 +3,10 @@ import type { Config } from "tailwindcss"
export default {
darkMode: ["class"],
content: [
'./pages/**/*.{ts,tsx}',
'./components/**/*.{ts,tsx}',
'./app/**/*.{ts,tsx}',
'./src/**/*.{ts,tsx}',
"./pages/**/*.{ts,tsx}",
"./components/**/*.{ts,tsx}",
"./app/**/*.{ts,tsx}",
"./src/**/*.{ts,tsx}",
],
prefix: "",
theme: {
@ -75,4 +75,4 @@ export default {
},
},
plugins: [require("tailwindcss-animate")],
} satisfies Config
} satisfies Config

View file

@ -10,7 +10,7 @@
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"jsx": "react-jsx",
"incremental": true,
"plugins": [
{
@ -19,8 +19,15 @@
],
"paths": {
"@/*": ["./*"]
}
},
"target": "ES2017"
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
".next/dev/types/**/*.ts"
],
"exclude": ["node_modules"]
}
}