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>
210 lines
5.3 KiB
TypeScript
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)}`
|
|
}
|