Initial commit: gate operator quotation app

React + Vite + Tailwind frontend with Express backend.
3-step wizard (Operator, Ground Loops, Access Control)
with PDF quote download and CAD pricing data.
This commit is contained in:
Todd
2026-05-21 09:13:59 -04:00
commit 74587ccceb
24 changed files with 5306 additions and 0 deletions

View File

@ -0,0 +1,176 @@
import { useReducer } from 'react';
import StepOperator from './StepOperator';
import StepGroundLoops from './StepGroundLoops';
import StepAccessControl from './StepAccessControl';
import QuoteSummary from './QuoteSummary';
const initialState = {
step: 1,
operator: null,
groundLoops: {
needed: false,
style: null,
types: [],
},
accessControl: [],
};
function reducer(state, action) {
switch (action.type) {
case 'SELECT_OPERATOR':
return { ...state, operator: action.payload, step: 2 };
case 'SET_GROUND_LOOPS_NEEDED':
return {
...state,
groundLoops: { needed: action.payload, style: null, types: [] },
step: action.payload ? state.step : state.step + 1,
};
case 'SET_GROUND_LOOP_STYLE':
return {
...state,
groundLoops: { ...state.groundLoops, style: action.payload },
};
case 'TOGGLE_GROUND_LOOP_TYPE': {
const id = action.payload;
const exists = state.groundLoops.types.includes(id);
return {
...state,
groundLoops: {
...state.groundLoops,
types: exists
? state.groundLoops.types.filter((t) => t !== id)
: [...state.groundLoops.types, id],
},
};
}
case 'TOGGLE_ACCESS_CONTROL': {
const id = action.payload;
const exists = state.accessControl.includes(id);
return {
...state,
accessControl: exists
? state.accessControl.filter((a) => a !== id)
: [...state.accessControl, id],
};
}
case 'NEXT_STEP':
return { ...state, step: Math.min(state.step + 1, 4) };
case 'PREV_STEP':
return { ...state, step: Math.max(state.step - 1, 1) };
case 'GO_TO_STEP':
return { ...state, step: Math.min(action.payload, 4) };
case 'RESET':
return { ...initialState };
default:
return state;
}
}
export default function Wizard({ pricing }) {
const [state, dispatch] = useReducer(reducer, initialState);
const { step, operator, groundLoops, accessControl } = state;
const steps = [
{ num: 1, label: 'Operator' },
{ num: 2, label: 'Ground Loops' },
{ num: 3, label: 'Access Control' },
{ num: 4, label: 'Quote' },
];
return (
<div className="max-w-4xl mx-auto px-4 py-8">
<div className="text-center mb-8">
<h1 className="text-3xl font-bold text-gray-900">
Gate Operator Quotation System
</h1>
<p className="text-gray-500 mt-2">
Select your configuration to generate a detailed cost estimate
</p>
</div>
<nav className="mb-8">
<ol className="flex items-center justify-center gap-2">
{steps.map((s) => (
<li key={s.num} className="flex items-center gap-2">
{s.num > 1 && (
<div
className={`w-8 h-0.5 ${
step >= s.num ? 'bg-blue-600' : 'bg-gray-300'
}`}
/>
)}
<button
onClick={() => {
if (s.num < step) dispatch({ type: 'GO_TO_STEP', payload: s.num });
}}
disabled={s.num > step}
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-full text-sm font-medium transition-colors ${
step === s.num
? 'bg-blue-600 text-white'
: step > s.num
? 'bg-blue-100 text-blue-700 cursor-pointer hover:bg-blue-200'
: 'bg-gray-100 text-gray-400 cursor-not-allowed'
}`}
>
<span
className={`w-5 h-5 rounded-full flex items-center justify-center text-xs font-bold ${
step === s.num
? 'bg-white text-blue-600'
: step > s.num
? 'bg-blue-600 text-white'
: 'bg-gray-300 text-gray-500'
}`}
>
{step > s.num ? '\u2713' : s.num}
</span>
{s.label}
</button>
</li>
))}
</ol>
</nav>
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-6 md:p-8">
{step === 1 && (
<StepOperator
operators={pricing.operators}
selected={operator}
onSelect={(id) => dispatch({ type: 'SELECT_OPERATOR', payload: id })}
/>
)}
{step === 2 && (
<StepGroundLoops
styles={pricing.groundLoopStyles}
types={pricing.groundLoopTypes}
value={groundLoops}
onSetNeeded={(needed) => dispatch({ type: 'SET_GROUND_LOOPS_NEEDED', payload: needed })}
onSetStyle={(id) => dispatch({ type: 'SET_GROUND_LOOP_STYLE', payload: id })}
onToggleType={(id) => dispatch({ type: 'TOGGLE_GROUND_LOOP_TYPE', payload: id })}
onNext={() => dispatch({ type: 'NEXT_STEP' })}
onBack={() => dispatch({ type: 'PREV_STEP' })}
/>
)}
{step === 3 && (
<StepAccessControl
accessControl={pricing.accessControl}
selected={accessControl}
onToggle={(id) => dispatch({ type: 'TOGGLE_ACCESS_CONTROL', payload: id })}
onBack={() => dispatch({ type: 'PREV_STEP' })}
onNext={() => dispatch({ type: 'NEXT_STEP' })}
/>
)}
{step === 4 && (
<QuoteSummary
pricing={pricing}
selections={{ operator, groundLoops, accessControl }}
onBack={() => dispatch({ type: 'PREV_STEP' })}
onNew={() => dispatch({ type: 'RESET' })}
/>
)}
</div>
</div>
);
}