Integrate Web Pricing Editor and update configurations
This commit is contained in:
@ -1,11 +1,13 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import Wizard from './components/Wizard';
|
||||
import Login from './components/Login';
|
||||
import AdminPricing from './components/AdminPricing';
|
||||
|
||||
export default function App() {
|
||||
const [pricing, setPricing] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [authenticated, setAuthenticated] = useState(false);
|
||||
const [isAdminMode, setIsAdminMode] = useState(false);
|
||||
|
||||
const fetchPricing = useCallback(async () => {
|
||||
try {
|
||||
@ -49,6 +51,7 @@ export default function App() {
|
||||
}
|
||||
setAuthenticated(false);
|
||||
setPricing(null);
|
||||
setIsAdminMode(false);
|
||||
};
|
||||
|
||||
if (!authenticated && !loading) {
|
||||
@ -82,8 +85,32 @@ export default function App() {
|
||||
);
|
||||
}
|
||||
|
||||
if (isAdminMode) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
<AdminPricing
|
||||
pricing={pricing}
|
||||
onSave={(newPricing) => setPricing(newPricing)}
|
||||
onClose={() => setIsAdminMode(false)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
<div className="min-h-screen bg-gray-50 relative">
|
||||
<div className="absolute top-4 left-4 z-10">
|
||||
<button
|
||||
onClick={() => setIsAdminMode(true)}
|
||||
className="text-xs text-gray-500 hover:text-gray-700 transition-colors flex items-center gap-1 bg-white px-3 py-1.5 rounded-lg border border-gray-200 shadow-sm"
|
||||
>
|
||||
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
</svg>
|
||||
Edit Pricing
|
||||
</button>
|
||||
</div>
|
||||
<Wizard pricing={pricing} onLogout={handleLogout} />
|
||||
</div>
|
||||
);
|
||||
|
||||
292
client/src/components/AdminPricing.jsx
Normal file
292
client/src/components/AdminPricing.jsx
Normal file
@ -0,0 +1,292 @@
|
||||
import { useState } from 'react';
|
||||
|
||||
export default function AdminPricing({ pricing, onSave, onClose }) {
|
||||
const [data, setData] = useState(JSON.parse(JSON.stringify(pricing)));
|
||||
const [activeTab, setActiveTab] = useState('operators');
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [message, setMessage] = useState(null);
|
||||
|
||||
const tabs = [
|
||||
{ id: 'operators', label: 'Operators' },
|
||||
{ id: 'loopDetectors', label: 'Loop Detectors' },
|
||||
{ id: 'groundLoopStyles', label: 'Loop Styles' },
|
||||
{ id: 'groundLoopSizes', label: 'Loop Sizes' },
|
||||
{ id: 'groundLoopTypes', label: 'Loop Types' },
|
||||
{ id: 'remoteButtonOptions', label: 'Remote Buttons' },
|
||||
{ id: 'accessControl', label: 'Access Control' },
|
||||
];
|
||||
|
||||
const handlePriceChange = (section, index, field, value) => {
|
||||
const updated = { ...data };
|
||||
const numVal = parseFloat(value);
|
||||
updated[section][index][field] = isNaN(numVal) ? 0 : numVal;
|
||||
setData(updated);
|
||||
};
|
||||
|
||||
const handleTextChange = (section, index, field, value) => {
|
||||
const updated = { ...data };
|
||||
updated[section][index][field] = value;
|
||||
setData(updated);
|
||||
};
|
||||
|
||||
const handleOperatorPartPriceChange = (opIndex, partType, partIndex, field, value) => {
|
||||
const updated = { ...data };
|
||||
const numVal = parseFloat(value);
|
||||
updated.operators[opIndex][partType][partIndex][field] = isNaN(numVal) ? 0 : numVal;
|
||||
setData(updated);
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
setSaving(true);
|
||||
setMessage(null);
|
||||
try {
|
||||
const response = await fetch('/api/pricing', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(data),
|
||||
credentials: 'include',
|
||||
});
|
||||
const result = await response.json();
|
||||
if (response.ok) {
|
||||
setMessage({ type: 'success', text: 'Pricing successfully saved!' });
|
||||
onSave(data);
|
||||
} else {
|
||||
setMessage({ type: 'error', text: result.error || 'Failed to save pricing.' });
|
||||
}
|
||||
} catch (err) {
|
||||
setMessage({ type: 'error', text: 'Network error saving pricing.' });
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="max-w-6xl mx-auto px-4 py-8">
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-gray-900">Edit Pricing & Choices</h1>
|
||||
<p className="text-gray-500 mt-1">Adjust base prices, required/optional parts, and options</p>
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 bg-gray-100 hover:bg-gray-200 text-gray-700 rounded-lg font-medium transition-colors"
|
||||
>
|
||||
Back to Estimator
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={saving}
|
||||
className="px-5 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg font-medium transition-colors disabled:opacity-50"
|
||||
>
|
||||
{saving ? 'Saving...' : 'Save Changes'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{message && (
|
||||
<div className={`p-4 mb-6 rounded-lg font-medium ${message.type === 'success' ? 'bg-green-50 text-green-700 border border-green-200' : 'bg-red-50 text-red-700 border border-red-200'}`}>
|
||||
{message.text}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="flex border-b border-gray-200 mb-6 overflow-x-auto whitespace-nowrap">
|
||||
{tabs.map((tab) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
className={`py-3 px-4 font-medium text-sm border-b-2 transition-all ${
|
||||
activeTab === tab.id
|
||||
? 'border-blue-600 text-blue-600'
|
||||
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
|
||||
}`}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
|
||||
{activeTab === 'operators' && (
|
||||
<div className="space-y-8">
|
||||
{data.operators.map((op, opIdx) => (
|
||||
<div key={op.id} className="border border-gray-100 rounded-xl p-5 bg-gray-50/50">
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mb-4">
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-gray-500 uppercase tracking-wider mb-1">Name</label>
|
||||
<input
|
||||
type="text"
|
||||
value={op.name}
|
||||
onChange={(e) => handleTextChange('operators', opIdx, 'name', e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-1 focus:ring-blue-500 bg-white"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-gray-500 uppercase tracking-wider mb-1">Model</label>
|
||||
<input
|
||||
type="text"
|
||||
value={op.model}
|
||||
onChange={(e) => handleTextChange('operators', opIdx, 'model', e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-1 focus:ring-blue-500 bg-white"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-gray-500 uppercase tracking-wider mb-1">Base Price ($)</label>
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
value={op.basePrice}
|
||||
onChange={(e) => handlePriceChange('operators', opIdx, 'basePrice', e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-1 focus:ring-blue-500 bg-white"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Required Parts */}
|
||||
{op.requiredParts && op.requiredParts.length > 0 && (
|
||||
<div className="mt-4">
|
||||
<h4 className="text-sm font-semibold text-gray-700 mb-2">Required Parts & Fees Included</h4>
|
||||
<div className="space-y-2">
|
||||
{op.requiredParts.map((part, partIdx) => (
|
||||
<div key={part.id} className="grid grid-cols-1 md:grid-cols-3 gap-3 bg-white p-3 rounded-lg border border-gray-200">
|
||||
<div className="flex items-center text-sm font-medium text-gray-700">{part.name}</div>
|
||||
<div>
|
||||
<label className="block text-[10px] text-gray-400 uppercase">Qty</label>
|
||||
<input
|
||||
type="number"
|
||||
value={part.qty}
|
||||
onChange={(e) => handleOperatorPartPriceChange(opIdx, 'requiredParts', partIdx, 'qty', e.target.value)}
|
||||
className="w-full px-2 py-1 border border-gray-300 rounded focus:outline-none focus:ring-1 focus:ring-blue-500 bg-white text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-[10px] text-gray-400 uppercase">Unit Price ($)</label>
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
value={part.unitPrice}
|
||||
onChange={(e) => handleOperatorPartPriceChange(opIdx, 'requiredParts', partIdx, 'unitPrice', e.target.value)}
|
||||
className="w-full px-2 py-1 border border-gray-300 rounded focus:outline-none focus:ring-1 focus:ring-blue-500 bg-white text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Optional Parts */}
|
||||
{op.optionalParts && op.optionalParts.length > 0 && (
|
||||
<div className="mt-4">
|
||||
<h4 className="text-sm font-semibold text-gray-700 mb-2">Optional Parts</h4>
|
||||
<div className="space-y-2">
|
||||
{op.optionalParts.map((part, partIdx) => (
|
||||
<div key={part.id} className="grid grid-cols-1 md:grid-cols-3 gap-3 bg-white p-3 rounded-lg border border-gray-200">
|
||||
<div className="flex items-center text-sm font-medium text-gray-700">{part.name}</div>
|
||||
<div>
|
||||
<label className="block text-[10px] text-gray-400 uppercase">Qty</label>
|
||||
<input
|
||||
type="number"
|
||||
value={part.qty}
|
||||
onChange={(e) => handleOperatorPartPriceChange(opIdx, 'optionalParts', partIdx, 'qty', e.target.value)}
|
||||
className="w-full px-2 py-1 border border-gray-300 rounded focus:outline-none focus:ring-1 focus:ring-blue-500 bg-white text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-[10px] text-gray-400 uppercase">Unit Price ($)</label>
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
value={part.unitPrice}
|
||||
onChange={(e) => handleOperatorPartPriceChange(opIdx, 'optionalParts', partIdx, 'unitPrice', e.target.value)}
|
||||
className="w-full px-2 py-1 border border-gray-300 rounded focus:outline-none focus:ring-1 focus:ring-blue-500 bg-white text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Arm Options for barrier */}
|
||||
{op.armOptions && op.armOptions.length > 0 && (
|
||||
<div className="mt-4">
|
||||
<h4 className="text-sm font-semibold text-gray-700 mb-2">Arm Options</h4>
|
||||
<div className="space-y-2">
|
||||
{op.armOptions.map((arm, armIdx) => (
|
||||
<div key={arm.id} className="grid grid-cols-1 md:grid-cols-2 gap-3 bg-white p-3 rounded-lg border border-gray-200">
|
||||
<div className="flex items-center text-sm font-medium text-gray-700">{arm.name}</div>
|
||||
<div>
|
||||
<label className="block text-[10px] text-gray-400 uppercase">Price ($)</label>
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
value={arm.price}
|
||||
onChange={(e) => handleOperatorPartPriceChange(opIdx, 'armOptions', armIdx, 'price', e.target.value)}
|
||||
className="w-full px-2 py-1 border border-gray-300 rounded focus:outline-none focus:ring-1 focus:ring-blue-500 bg-white text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab !== 'operators' && (
|
||||
<div className="space-y-4">
|
||||
{data[activeTab].map((item, idx) => (
|
||||
<div key={item.id} className="grid grid-cols-1 md:grid-cols-3 gap-4 bg-gray-50 p-4 rounded-xl border border-gray-150">
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-gray-500 uppercase tracking-wider mb-1">Name</label>
|
||||
<div className="font-semibold text-gray-800 text-sm mt-2">{item.name}</div>
|
||||
</div>
|
||||
{item.description !== undefined && (
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-gray-500 uppercase tracking-wider mb-1">Description</label>
|
||||
<input
|
||||
type="text"
|
||||
value={item.description}
|
||||
onChange={(e) => handleTextChange(activeTab, idx, 'description', e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-1 focus:ring-blue-500 bg-white text-sm"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{item.price !== undefined && (
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-gray-500 uppercase tracking-wider mb-1">Price ($)</label>
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
value={item.price}
|
||||
onChange={(e) => handlePriceChange(activeTab, idx, 'price', e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-1 focus:ring-blue-500 bg-white text-sm"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{item.additionalCost !== undefined && (
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-gray-500 uppercase tracking-wider mb-1">Additional Cost ($)</label>
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
value={item.additionalCost}
|
||||
onChange={(e) => handlePriceChange(activeTab, idx, 'additionalCost', e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-1 focus:ring-blue-500 bg-white text-sm"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user