Features: - Complete Next.js 16 app with TypeScript and Tailwind CSS - Customer-facing price calculator form with validation - Admin mode showing detailed price breakdowns - Accurate price calculations matching business requirements - Responsive design with custom shadcn/ui theme - API endpoint for quote requests - Danish postal code distance calculations - Complete test coverage against documentation examples 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
157 lines
No EOL
3.9 KiB
TypeScript
157 lines
No EOL
3.9 KiB
TypeScript
import { PRICES, PUMP_TRUCK_FEES, CONSTRAINTS, COVERAGE_AREAS } from "./constants"
|
|
|
|
export interface CalculationInput {
|
|
area: number // m²
|
|
height: number // cm
|
|
postalCode: string
|
|
distance: number // km (round trip)
|
|
}
|
|
|
|
export interface CalculationDetails {
|
|
// Input values
|
|
area: number
|
|
height: number
|
|
postalCode: string
|
|
distance: number
|
|
|
|
// 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
|
|
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 }
|
|
}
|
|
|
|
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 } = input
|
|
|
|
// Step 1: Calculate derived values
|
|
const insulation = calculateInsulation(area, height)
|
|
const compoundWeight = area * PRICES.COMPOUND_WEIGHT_PER_M2
|
|
|
|
// 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)
|
|
const startFee = PRICES.START_FEE
|
|
|
|
// Step 3: Calculate subtotal
|
|
const subtotal =
|
|
insulation.price + 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,
|
|
|
|
// Calculated values
|
|
insulationThickness: insulation.thickness,
|
|
insulationVolume: insulation.volume,
|
|
compoundWeight,
|
|
|
|
// Component prices
|
|
insulation: insulation.price,
|
|
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)}`
|
|
} |