foamking/lib/calculations.ts
mikl0s 3ebb63dc6c 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>
2026-02-22 20:59:11 +00:00

210 lines
5.3 KiB
TypeScript

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 {
// Input values
area: number
height: number
postalCode: string
distance: number
// Optional component selections
includeInsulation: boolean
includeFloorHeating: boolean
includeCompound: boolean
flooringType: FlooringType
// Calculated values
insulationThickness: number // cm
insulationVolume: number // m³
compoundWeight: number // kg
// Component prices
insulation: number
floorHeating: number
syntheticNet: number
selfLevelingCompound: number
pumpTruckFee: number
startFee: number
// Subtotals
subtotal: number
coveringFee: number
wasteFee: number
totalFees: number
// Transport
transport: number
bridgeFee: number
// Totals
totalExclVat: number
vat: number
totalInclVat: 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)
// 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 {
const tier = PUMP_TRUCK_FEES.find((tier) => weight > tier.minWeight)
return tier?.fee ?? PUMP_TRUCK_FEES[PUMP_TRUCK_FEES.length - 1].fee
}
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,
includeInsulation = true,
includeFloorHeating = true,
includeCompound = true,
flooringType = "STANDARD",
} = input
// Step 1: Calculate derived values
const insulation = calculateInsulation(area, height)
const compoundWeight = includeCompound ? area * PRICES.COMPOUND_WEIGHT_PER_M2 : 0
// 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 =
insulationPrice + floorHeating + syntheticNet + selfLevelingCompound + pumpTruckFee + startFee
// Step 4: Calculate percentage fees
const coveringFee = subtotal * PRICES.COVERING_PERCENTAGE
const wasteFee = subtotal * PRICES.WASTE_PERCENTAGE
const totalFees = coveringFee + wasteFee
// Step 5: Calculate transport
const transport = distance * PRICES.TRANSPORT_PER_KM
const bridgeFee = getBridgeFee(postalCode)
// Step 6: Calculate totals
const totalExclVat = subtotal + totalFees + transport + bridgeFee
const vat = totalExclVat * PRICES.VAT
const totalInclVat = totalExclVat * (1 + PRICES.VAT)
return {
// Input values
area,
height,
postalCode,
distance,
// Optional component selections
includeInsulation,
includeFloorHeating,
includeCompound,
flooringType,
// Calculated values
insulationThickness: insulation.thickness,
insulationVolume: insulation.volume,
compoundWeight,
// Component prices
insulation: insulationPrice,
floorHeating,
syntheticNet,
selfLevelingCompound,
pumpTruckFee,
startFee,
// Subtotals
subtotal,
coveringFee,
wasteFee,
totalFees,
// Transport
transport,
bridgeFee,
// Totals
totalExclVat,
vat,
totalInclVat,
}
}
export function formatPrice(price: number): string {
return new Intl.NumberFormat("da-DK", {
style: "currency",
currency: "DKK",
minimumFractionDigits: 0,
maximumFractionDigits: 0,
}).format(price)
}
export function formatEstimate(price: number): string {
// Round to nearest 500
const rounded = Math.round(price / 500) * 500
return `Ca. ${formatPrice(rounded)}`
}