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>
11
.env.example
Normal 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
|
|
@ -32,4 +32,7 @@ yarn-error.log*
|
|||
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
next-env.d.ts
|
||||
|
||||
# database
|
||||
/data/
|
||||
|
|
@ -6,4 +6,4 @@
|
|||
"printWidth": 100,
|
||||
"plugins": ["prettier-plugin-tailwindcss"],
|
||||
"tailwindFunctions": ["clsx", "cn"]
|
||||
}
|
||||
}
|
||||
|
|
|
|||
14
CLAUDE.md
|
|
@ -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`
|
||||
|
|
|
|||
16
README.md
|
|
@ -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
|
|
@ -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 "Beregn pris"
|
||||
</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 "Beregn pris"
|
||||
</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>
|
||||
)
|
||||
}
|
||||
26
app/api/auth/login/route.ts
Normal 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 })
|
||||
}
|
||||
}
|
||||
12
app/api/auth/logout/route.ts
Normal 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 })
|
||||
}
|
||||
}
|
||||
31
app/api/auth/setup/route.ts
Normal 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
|
|
@ -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,
|
||||
})
|
||||
}
|
||||
|
|
@ -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 på 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 på 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 →
|
||||
</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 på 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} m²`,
|
||||
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
|
|
@ -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 })
|
||||
}
|
||||
}
|
||||
46
app/api/track/[id]/route.ts
Normal 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
|
|
@ -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 på 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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
|
|
@ -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
|
After Width: | Height: | Size: 3.2 KiB |
|
|
@ -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
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
392
app/page.tsx
|
|
@ -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
|
||||
</>
|
||||
)}
|
||||
Få 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">Få dit personlige tilbud</h2>
|
||||
<p className="mx-auto max-w-xl text-muted-foreground">
|
||||
Besvar nogle få spørgsmål, så 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 på {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
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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)} m³ × ${formatPrice(PRICES.INSULATION_TOTAL)}/m³)` : "(simpel arbejdsløn)"}:
|
||||
Isolering{" "}
|
||||
{details.includeInsulation
|
||||
? details.insulationThickness > 0
|
||||
? `(${details.insulationVolume.toFixed(2)} m³ × ${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} m² × ${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} m² × ${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} m² × ${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>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
Få et hurtigt overslag på 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>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
570
components/calculator/step-wizard.tsx
Normal 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} m²`)
|
||||
.max(CONSTRAINTS.MAX_AREA, `Maximum ${CONSTRAINTS.MAX_AREA} m²`),
|
||||
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">Så 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>
|
||||
)
|
||||
}
|
||||
134
components/dashboard/kanban-board.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
114
components/dashboard/quote-card.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
35
components/dashboard/search-filter.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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,
|
||||
}
|
||||
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
|
|||
25
components/ui/progress.tsx
Normal 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
|
|
@ -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
|
|
@ -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
|
|
@ -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 }
|
||||
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -1,97 +1,97 @@
|
|||
Retelser fra rene
|
||||
<br/>Gulvareal 25-300 m2
|
||||
Gulvhøjde (cm): 0-100 cm (isoleringstykkelse)
|
||||
<br/><br/><br/> 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
|
||||
|
||||
 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
|
||||
 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
|
||||
 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/> 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
|
||||
|
||||
 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
|
||||
 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
|
||||
 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
|
After Width: | Height: | Size: 47 KiB |
BIN
docs/company_email.png
Normal file
|
After Width: | Height: | Size: 28 KiB |
BIN
docs/dashboard.png
Normal file
|
After Width: | Height: | Size: 111 KiB |
BIN
docs/gulv.jpeg
Normal file
|
After Width: | Height: | Size: 171 KiB |
BIN
docs/lovable.png
Normal file
|
After Width: | Height: | Size: 1.7 MiB |
BIN
docs/mobile.png
Normal file
|
After Width: | Height: | Size: 224 KiB |
BIN
docs/new_design.png
Normal file
|
After Width: | Height: | Size: 1.8 MiB |
|
|
@ -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_
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -19,4 +19,4 @@ const eslintConfig = [
|
|||
},
|
||||
]
|
||||
|
||||
export default eslintConfig
|
||||
export default eslintConfig
|
||||
|
|
|
|||
116
lib/auth.ts
Normal 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)
|
||||
}
|
||||
|
|
@ -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)}`
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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())
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,4 +3,4 @@ import { twMerge } from "tailwind-merge"
|
|||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
||||
}
|
||||
|
|
|
|||
34
middleware.ts
Normal 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*"],
|
||||
}
|
||||
|
|
@ -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
47
package.json
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,4 +6,4 @@ const config = {
|
|||
},
|
||||
}
|
||||
|
||||
export default config
|
||||
export default config
|
||||
|
|
|
|||
BIN
public/byg_trans.png
Normal file
|
After Width: | Height: | Size: 47 KiB |
BIN
public/dansk_kvalitet.png
Normal file
|
After Width: | Height: | Size: 73 KiB |
BIN
public/favicon.ico
Normal file
|
After Width: | Height: | Size: 4.2 KiB |
BIN
public/gulv.jpeg
Normal file
|
After Width: | Height: | Size: 171 KiB |
BIN
public/tilfredshed_service.png
Normal file
|
After Width: | Height: | Size: 89 KiB |
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
}
|
||||
}
|
||||
|
|
|
|||