Standarad Pricing Feature Added
CI / build (push) Has been skipped
CI / deploy (push) Successful in 49s

This commit is contained in:
2026-03-18 17:18:33 -05:00
parent fb7b138371
commit a1ca82e539
7 changed files with 521 additions and 9 deletions
+7
View File
@@ -32,6 +32,13 @@ const AppContainer: Component<AppContainerProps> = (props) => {
>
Template Creator
</A>
<A
href="/standard-pricing"
class="inline-flex items-center px-3 py-2 text-sm font-medium text-gray-600 hover:text-gray-900 hover:bg-gray-50 rounded-md transition-colors"
activeClass="text-blue-600 bg-blue-50 hover:bg-blue-50 hover:text-blue-700"
>
Standard Pricing
</A>
</nav>
</div>
</div>
+153 -5
View File
@@ -1,8 +1,10 @@
import type { Component } from 'solid-js';
import { createMemo, createEffect, createSignal, Show } from 'solid-js';
import { createMemo, createEffect, createSignal, Show, For, onCleanup } from 'solid-js';
import { Portal } from 'solid-js/web';
import { GripVertical, Trash2, Copy } from 'lucide-solid';
import NumericInput from './ui/NumericInput';
import type { EstimateItem } from '../types';
import type { EstimateItem, StandardPrice } from '../types';
import { pb, COLLECTIONS } from '../utils/db';
interface ItemRowProps {
item: EstimateItem;
@@ -33,6 +35,144 @@ const ItemRow: Component<ItemRowProps> = (props) => {
return base + markup + contingency;
});
const [isSearchingPrice, setIsSearchingPrice] = createSignal(false);
const [searchResults, setSearchResults] = createSignal<StandardPrice[]>([]);
const [selectedIndex, setSelectedIndex] = createSignal(0);
const handlePriceSearch = async (searchString: string) => {
setIsSearchingPrice(true);
if (searchString.trim() === '') {
setSearchResults([]);
setSelectedIndex(0);
return;
}
try {
const records = await pb.collection(COLLECTIONS.STANDARD_PRICES).getList(1, 10, {
filter: `name ~ "${searchString}" || category ~ "${searchString}"`,
sort: 'category,name'
});
setSearchResults(records.items as unknown as StandardPrice[]);
setSelectedIndex(0);
} catch (error) {
console.error('Error searching standard prices:', error);
setSearchResults([]);
setSelectedIndex(0);
}
};
let desktopInputRef: HTMLInputElement | undefined;
let mobileInputRef: HTMLInputElement | undefined;
const handleSelectStandardPrice = (price: StandardPrice) => {
setIsSearchingPrice(false);
setSearchResults([]);
let updates: Partial<EstimateItem> = { unitPrice: price.price };
if (!props.item.description) {
updates.description = price.name;
}
props.onUpdate(props.item.id, updates);
desktopInputRef?.blur();
mobileInputRef?.blur();
};
const handlePriceKeyDown = (e: KeyboardEvent) => {
if (!isSearchingPrice() || searchResults().length === 0) return;
if (e.key === 'ArrowDown') {
e.preventDefault();
setSelectedIndex((s) => Math.min(s + 1, searchResults().length - 1));
} else if (e.key === 'ArrowUp') {
e.preventDefault();
setSelectedIndex((s) => Math.max(s - 1, 0));
} else if (e.key === 'Enter') {
e.preventDefault();
const selected = searchResults()[selectedIndex()];
if (selected) {
handleSelectStandardPrice(selected);
}
} else if (e.key === 'Escape') {
e.preventDefault();
setIsSearchingPrice(false);
setSearchResults([]);
desktopInputRef?.blur();
mobileInputRef?.blur();
}
};
let anchorRef: HTMLDivElement | undefined;
let mobileAnchorRef: HTMLDivElement | undefined;
const [dropdownRect, setDropdownRect] = createSignal({ top: 0, left: 0, width: 256 });
const updateDropdownPosition = () => {
if (!isSearchingPrice()) return;
// Determine which ref is currently visible/active based on window width
const ref = window.innerWidth < 768 ? mobileAnchorRef : anchorRef;
if (ref) {
const rect = ref.getBoundingClientRect();
setDropdownRect({
top: rect.bottom + window.scrollY + 4,
left: rect.left + window.scrollX,
width: Math.max(256, rect.width) // Make it at least w-64
});
}
};
createEffect(() => {
if (isSearchingPrice()) {
updateDropdownPosition();
window.addEventListener('scroll', updateDropdownPosition, true);
window.addEventListener('resize', updateDropdownPosition);
} else {
window.removeEventListener('scroll', updateDropdownPosition, true);
window.removeEventListener('resize', updateDropdownPosition);
}
});
onCleanup(() => {
window.removeEventListener('scroll', updateDropdownPosition, true);
window.removeEventListener('resize', updateDropdownPosition);
});
const PriceDropdown = () => (
<Show when={isSearchingPrice()}>
<Portal>
<div
class="absolute bg-white border border-gray-200 shadow-xl rounded-xl z-[9999] overflow-hidden text-left pointer-events-auto"
style={{
top: `${dropdownRect().top}px`,
left: `${dropdownRect().left}px`,
width: `${dropdownRect().width}px`
}}
>
<Show when={searchResults().length === 0}>
<div class="p-3 text-[11px] text-gray-500 text-center font-medium">Type to search prices...</div>
</Show>
<For each={searchResults()}>
{(result, index) => (
<div
class={`p-2 border-b border-gray-50 last:border-none cursor-pointer transition-colors ${selectedIndex() === index() ? 'bg-primary/20' : 'hover:bg-primary/10'}`}
onMouseEnter={() => setSelectedIndex(index())}
onMouseDown={(e) => {
// Prevent blur from firing before click
e.preventDefault();
handleSelectStandardPrice(result);
}}
>
<div class="text-xs font-bold text-gray-900">{result.name}</div>
<div class="flex justify-between items-center mt-0.5">
<span class="text-[10px] text-gray-500 uppercase font-black tracking-wider">{result.category}</span>
<span class="text-[11px] font-black text-primary">${result.price.toFixed(2)}</span>
</div>
</div>
)}
</For>
</div>
</Portal>
</Show>
);
const [isEditing, setIsEditing] = createSignal(false);
const [localDescription, setLocalDescription] = createSignal(props.item.description);
@@ -134,9 +274,10 @@ const ItemRow: Component<ItemRowProps> = (props) => {
</div>
{/* Price — w-24 */}
<div class="w-24 shrink-0 relative">
<div class="w-24 shrink-0 relative" ref={anchorRef}>
<span class="absolute left-2.5 top-1/2 -translate-y-1/2 text-muted-foreground/50 text-[10px] pointer-events-none">$</span>
<NumericInput
inputRef={(el: any) => desktopInputRef = el}
dataFieldName="price"
step="0.1"
value={props.item.unitPrice}
@@ -153,7 +294,13 @@ const ItemRow: Component<ItemRowProps> = (props) => {
disabled={props.anySelected}
class="w-full bg-muted/30 border border-border/60 rounded-xl pl-5 pr-2 py-1.5 text-xs text-right focus:bg-background focus:ring-2 focus:ring-primary/20 focus:border-primary outline-none disabled:opacity-50 cursor-text font-bold text-foreground/80 transition-all"
placeholder="Price"
allowTextSearch={true}
onTextSearch={handlePriceSearch}
onKeyDown={handlePriceKeyDown}
onTextSearchExit={() => setIsSearchingPrice(false)}
onBlur={() => setTimeout(() => setIsSearchingPrice(false), 150)}
/>
<PriceDropdown />
</div>
{/* Subtotal — w-24 */}
@@ -308,9 +455,10 @@ const ItemRow: Component<ItemRowProps> = (props) => {
</div>
<div class="w-24">
<div class="text-[9px] font-bold text-muted-foreground/60 uppercase mb-1 ml-1">Price</div>
<div class="relative">
<div class="relative group/price" ref={mobileAnchorRef}>
<span class="absolute left-2.5 top-1/2 -translate-y-1/2 text-muted-foreground/50 text-[10px]">$</span>
<NumericInput step="0.1" value={props.item.unitPrice} onUpdate={(val) => props.onUpdate(props.item.id, { unitPrice: val })} disabled={props.anySelected} class="w-full bg-muted/30 border border-border/60 rounded-xl pl-5 pr-2 py-1.5 text-xs text-right focus:bg-background focus:ring-2 focus:ring-primary/20 focus:border-primary outline-none disabled:opacity-50 font-bold text-foreground/80 transition-all" placeholder="Price" />
<NumericInput inputRef={(el: any) => mobileInputRef = el} step="0.1" value={props.item.unitPrice} onUpdate={(val) => props.onUpdate(props.item.id, { unitPrice: val })} disabled={props.anySelected} class="w-full bg-muted/30 border border-border/60 rounded-xl pl-5 pr-2 py-1.5 text-xs text-right focus:bg-background focus:ring-2 focus:ring-primary/20 focus:border-primary outline-none disabled:opacity-50 font-bold text-foreground/80 transition-all" placeholder="Price" allowTextSearch={true} onTextSearch={handlePriceSearch} onKeyDown={handlePriceKeyDown} onTextSearchExit={() => setIsSearchingPrice(false)} onBlur={() => setTimeout(() => setIsSearchingPrice(false), 150)} />
<PriceDropdown />
</div>
</div>
<Show when={props.isSidebarCollapsed() || !props.hideColumns}>
+25 -3
View File
@@ -6,6 +6,10 @@ interface NumericInputProps extends Omit<JSX.InputHTMLAttributes<HTMLInputElemen
onUpdate: (val: number) => void;
dataFieldName?: string;
class?: string;
allowTextSearch?: boolean;
onTextSearch?: (searchString: string) => void;
onTextSearchExit?: () => void;
inputRef?: any;
}
const NumericInput: Component<NumericInputProps> = (props) => {
@@ -13,7 +17,7 @@ const NumericInput: Component<NumericInputProps> = (props) => {
const [localValue, setLocalValue] = createSignal(props.value.toString());
createEffect(() => {
if (!isFocused()) {
if (!isFocused() || (props.allowTextSearch && !localValue().startsWith('@') && props.value !== parseFloat(localValue()))) {
setLocalValue(props.value.toString());
}
});
@@ -21,6 +25,15 @@ const NumericInput: Component<NumericInputProps> = (props) => {
const handleInput = (e: InputEvent & { currentTarget: HTMLInputElement }) => {
let val = e.currentTarget.value;
// Check if we allow text search and it starts with '@'
if (props.allowTextSearch && val.startsWith('@')) {
setLocalValue(val);
if (props.onTextSearch) {
props.onTextSearch(val.substring(1));
}
return;
}
// Allow only numeric characters, one dot, and one leading minus sign
// This regex allows: "", "-", ".", "-.", and valid numbers
if (val !== '' && !/^-?(\d*\.?\d*)?$/.test(val)) {
@@ -29,6 +42,11 @@ const NumericInput: Component<NumericInputProps> = (props) => {
return;
}
// If we were in text search but cleared it back to a number
if (props.allowTextSearch && localValue().startsWith('@') && !val.startsWith('@')) {
if (props.onTextSearchExit) props.onTextSearchExit();
}
setLocalValue(val);
// Only update the store if it's a valid numeric value for calculations
@@ -59,14 +77,18 @@ const NumericInput: Component<NumericInputProps> = (props) => {
const handleBlur = (e: FocusEvent) => {
setIsFocused(false);
// On blur, normalize the local value to the props value
// But if the user left it empty or as a partial, props.value will be 0
// On blur, always normalize the local value to the props value
setLocalValue(props.value.toString());
if (props.allowTextSearch && localValue().startsWith('@') && props.onTextSearchExit) {
props.onTextSearchExit();
}
callHandler(props.onBlur, e);
};
return (
<input
ref={props.inputRef}
{...props}
type="text"
inputmode="decimal"
+2
View File
@@ -4,6 +4,7 @@ import './index.css';
import AppContainer from './AppContainer';
import ItemExtractor from './ItemExtractor';
import TemplateCreator from './views/TemplateCreator';
import StandardPricing from './views/StandardPricing';
const root = document.getElementById('root');
@@ -17,5 +18,6 @@ render(() => (
<Router root={AppContainer}>
<Route path="/" component={ItemExtractor as any} />
<Route path="/templates" component={TemplateCreator} />
<Route path="/standard-pricing" component={StandardPricing} />
</Router>
), root!);
+8
View File
@@ -75,3 +75,11 @@ export interface Template {
modifiers?: Modifier[];
defaultSubScopeName?: string;
}
export interface StandardPrice {
id: string;
category: string;
name: string;
price: number;
priceRange?: string;
}
+2 -1
View File
@@ -5,5 +5,6 @@ export const pb = new PocketBase('https://pocketbase.ccllc.pro');
export const COLLECTIONS = {
TEMPLATES: 'EstiMaker_Templates',
ESTIMATES: 'EstiMaker_Estimates'
ESTIMATES: 'EstiMaker_Estimates',
STANDARD_PRICES: 'EstiMaker_StandardPrices'
} as const;
+324
View File
@@ -0,0 +1,324 @@
import { createSignal, onMount, For, createMemo } from 'solid-js';
import type { Component } from 'solid-js';
import { Trash2, Plus, RefreshCw, Edit2, Save, X } from 'lucide-solid';
import { pb, COLLECTIONS } from '../utils/db';
import type { StandardPrice } from '../types';
const StandardPricing: Component = () => {
const [prices, setPrices] = createSignal<StandardPrice[]>([]);
const [isLoading, setIsLoading] = createSignal(true);
const [newCategory, setNewCategory] = createSignal('');
const [newName, setNewName] = createSignal('');
const [newPrice, setNewPrice] = createSignal<number>(0);
const [newPriceRange, setNewPriceRange] = createSignal('');
const [editingId, setEditingId] = createSignal<string | null>(null);
const [editCategory, setEditCategory] = createSignal('');
const [editName, setEditName] = createSignal('');
const [editPrice, setEditPrice] = createSignal<number>(0);
const [editPriceRange, setEditPriceRange] = createSignal('');
const categories = createMemo(() => {
return [...new Set(prices().map(p => p.category))].sort();
});
const itemNames = createMemo(() => {
return [...new Set(prices().map(p => p.name))].sort();
});
const loadPrices = async () => {
setIsLoading(true);
try {
const records = await pb.collection(COLLECTIONS.STANDARD_PRICES).getFullList({
sort: 'category,name'
});
setPrices(records as unknown as StandardPrice[]);
} catch (error) {
console.error('Error loading standard prices:', error);
} finally {
setIsLoading(false);
}
};
onMount(() => {
loadPrices();
});
const handleAddPrice = async () => {
if (!newCategory() || !newName() || newPrice() < 0) return;
try {
const record = {
category: newCategory(),
name: newName(),
price: newPrice(),
priceRange: newPriceRange()
};
const added = await pb.collection(COLLECTIONS.STANDARD_PRICES).create(record);
setPrices([...prices(), added as unknown as StandardPrice]);
// Reset fields
setNewName('');
setNewPrice(0);
setNewPriceRange('');
// Keep category the same to easily add multiple items to same category
} catch (error) {
console.error('Error adding price:', error);
alert('Failed to add standard price.');
}
};
const handleEditStart = (price: StandardPrice) => {
setEditingId(price.id);
setEditCategory(price.category);
setEditName(price.name);
setEditPrice(price.price);
setEditPriceRange(price.priceRange || '');
};
const handleEditCancel = () => {
setEditingId(null);
};
const handleEditSave = async () => {
const id = editingId();
if (!id || !editCategory() || !editName()) return;
try {
const updates = {
category: editCategory(),
name: editName(),
price: editPrice(),
priceRange: editPriceRange()
};
await pb.collection(COLLECTIONS.STANDARD_PRICES).update(id, updates);
setPrices(prices().map(p => p.id === id ? { ...p, ...updates } : p));
setEditingId(null);
} catch (error) {
console.error('Error updating price:', error);
alert('Failed to update standard price.');
}
};
const handleDeletePrice = async (id: string) => {
if (!confirm('Are you sure you want to delete this standard price?')) return;
try {
await pb.collection(COLLECTIONS.STANDARD_PRICES).delete(id);
setPrices(prices().filter(p => p.id !== id));
} catch (error) {
console.error('Error deleting price:', error);
alert('Failed to delete standard price.');
}
};
return (
<div class="flex flex-col items-center py-6 px-4 font-sans">
<div class="max-w-5xl w-full bg-white rounded-xl shadow-premium p-8 mb-8 border border-gray-100">
<div class="flex items-center justify-between mb-8 pb-4 border-b border-gray-100">
<div>
<h1 class="text-3xl font-bold text-gray-800 mb-2 tracking-tight">Standard Pricing</h1>
<p class="text-gray-500 font-medium">Add Standard Pricing via "@" in item price field.</p>
</div>
<button
onClick={loadPrices}
class="flex items-center gap-2 px-4 py-2 text-sm font-bold text-gray-700 bg-gray-50 border border-gray-200 rounded-xl hover:bg-gray-100 transition-all"
>
<RefreshCw class={`w-4 h-4 ${isLoading() ? 'animate-spin' : ''}`} />
Refresh
</button>
</div>
{/* Add New Row */}
<div class="bg-blue-50/50 rounded-xl p-4 mb-8 border border-blue-100">
<h3 class="text-sm font-bold text-blue-800 mb-4 uppercase tracking-wider">Add New Standard Price</h3>
<div class="flex items-end gap-4 overflow-x-auto pb-2">
<div class="flex-1 min-w-[200px]">
<label class="block text-xs font-bold text-gray-600 mb-1">Category</label>
<input
type="text"
value={newCategory()}
onInput={(e) => setNewCategory(e.currentTarget.value)}
placeholder="e.g. Drywall"
list="category-suggestions"
class="w-full bg-white border border-gray-200 rounded-lg px-3 py-2 text-sm focus:border-blue-500 outline-none"
/>
</div>
<div class="flex-1 min-w-[200px]">
<label class="block text-xs font-bold text-gray-600 mb-1">Item Name</label>
<input
type="text"
value={newName()}
onInput={(e) => setNewName(e.currentTarget.value)}
placeholder="e.g. Gyp Type X"
list="name-suggestions"
class="w-full bg-white border border-gray-200 rounded-lg px-3 py-2 text-sm focus:border-blue-500 outline-none"
/>
</div>
<div class="w-32 shrink-0">
<label class="block text-xs font-bold text-gray-600 mb-1">Price ($)</label>
<input
type="number"
step="0.01"
value={newPrice() || ''}
onInput={(e) => setNewPrice(parseFloat(e.currentTarget.value) || 0)}
placeholder="0.50"
class="w-full bg-white border border-gray-200 rounded-lg px-3 py-2 text-sm focus:border-blue-500 outline-none text-right font-bold"
/>
</div>
<div class="w-32 shrink-0">
<label class="block text-xs font-bold text-gray-600 mb-1">Range (Opt)</label>
<input
type="text"
value={newPriceRange()}
onInput={(e) => setNewPriceRange(e.currentTarget.value)}
placeholder="0.45-0.80"
class="w-full bg-white border border-gray-200 rounded-lg px-3 py-2 text-sm focus:border-blue-500 outline-none"
/>
</div>
<button
onClick={handleAddPrice}
disabled={!newCategory() || !newName() || newPrice() < 0}
class="flex items-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-lg font-bold hover:bg-blue-700 disabled:opacity-50 transition-colors shrink-0 h-[38px]"
>
<Plus class="w-4 h-4" />
Add
</button>
</div>
</div>
{/* List of Prices */}
<div>
<h3 class="text-sm font-bold text-gray-800 mb-4 uppercase tracking-wider">Existing Prices</h3>
{isLoading() ? (
<div class="py-12 text-center text-gray-500">Loading...</div>
) : prices().length === 0 ? (
<div class="py-12 text-center text-gray-500 border-2 border-dashed border-gray-200 rounded-xl">
No standard prices added yet.
</div>
) : (
<div class="border border-gray-200 rounded-xl overflow-hidden">
<table class="w-full text-left border-collapse">
<thead>
<tr class="bg-gray-50 border-b border-gray-200 text-xs text-gray-500 uppercase tracking-wider font-bold">
<th class="py-3 px-4">Category</th>
<th class="py-3 px-4">Item Name</th>
<th class="py-3 px-4 text-right">Price</th>
<th class="py-3 px-4">Range</th>
<th class="py-3 px-4 w-24"></th>
</tr>
</thead>
<tbody>
<For each={prices()}>
{(price) => (
<tr class={`border-b border-gray-100 last:border-none transition-colors group ${editingId() === price.id ? 'bg-blue-50/30' : 'hover:bg-gray-50/50'}`}>
<td class="py-3 px-4 text-sm font-medium text-gray-700">
{editingId() === price.id ? (
<input
type="text"
value={editCategory()}
onInput={(e) => setEditCategory(e.currentTarget.value)}
list="category-suggestions"
class="w-full bg-white border border-gray-200 rounded-lg px-2 py-1 text-xs focus:border-blue-500 outline-none"
/>
) : price.category}
</td>
<td class="py-3 px-4 text-sm font-bold text-gray-900">
{editingId() === price.id ? (
<input
type="text"
value={editName()}
onInput={(e) => setEditName(e.currentTarget.value)}
list="name-suggestions"
class="w-full bg-white border border-gray-200 rounded-lg px-2 py-1 text-xs focus:border-blue-500 outline-none"
/>
) : price.name}
</td>
<td class="py-3 px-4 text-sm font-bold text-blue-600 text-right">
{editingId() === price.id ? (
<input
type="number"
step="0.01"
value={editPrice()}
onInput={(e) => setEditPrice(parseFloat(e.currentTarget.value) || 0)}
class="w-24 bg-white border border-gray-200 rounded-lg px-2 py-1 text-xs focus:border-blue-500 outline-none text-right font-bold"
/>
) : `$${price.price.toFixed(2)}`}
</td>
<td class="py-3 px-4 text-sm text-gray-500">
{editingId() === price.id ? (
<input
type="text"
value={editPriceRange()}
onInput={(e) => setEditPriceRange(e.currentTarget.value)}
class="w-full bg-white border border-gray-200 rounded-lg px-2 py-1 text-xs focus:border-blue-500 outline-none"
/>
) : (price.priceRange || '-')}
</td>
<td class="py-3 px-4 text-right">
<div class="flex items-center justify-end gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
{editingId() === price.id ? (
<>
<button
onClick={handleEditSave}
class="p-1.5 text-blue-600 hover:bg-blue-50 rounded-lg transition-colors"
title="Save"
>
<Save class="w-4 h-4" />
</button>
<button
onClick={handleEditCancel}
class="p-1.5 text-gray-400 hover:bg-gray-100 rounded-lg transition-colors"
title="Cancel"
>
<X class="w-4 h-4" />
</button>
</>
) : (
<>
<button
onClick={() => handleEditStart(price)}
class="p-1.5 text-gray-400 hover:text-blue-600 hover:bg-blue-50 rounded-lg transition-colors"
title="Edit"
>
<Edit2 class="w-4 h-4" />
</button>
<button
onClick={() => handleDeletePrice(price.id)}
class="p-1.5 text-red-400 hover:text-red-600 hover:bg-red-50 rounded-lg transition-colors"
title="Delete"
>
<Trash2 class="w-4 h-4" />
</button>
</>
)}
</div>
</td>
</tr>
)}
</For>
</tbody>
</table>
</div>
)}
</div>
</div>
{/* Suggestions Memos */}
<datalist id="category-suggestions">
<For each={categories()}>
{(cat) => <option value={cat} />}
</For>
</datalist>
<datalist id="name-suggestions">
<For each={itemNames()}>
{(name) => <option value={name} />}
</For>
</datalist>
</div>
);
};
export default StandardPricing;