diff --git a/src/AppContainer.tsx b/src/AppContainer.tsx index dc375bf..930f3d8 100644 --- a/src/AppContainer.tsx +++ b/src/AppContainer.tsx @@ -32,6 +32,13 @@ const AppContainer: Component = (props) => { > Template Creator + + Standard Pricing + diff --git a/src/components/ItemRow.tsx b/src/components/ItemRow.tsx index c7d918a..25fc811 100644 --- a/src/components/ItemRow.tsx +++ b/src/components/ItemRow.tsx @@ -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 = (props) => { return base + markup + contingency; }); + const [isSearchingPrice, setIsSearchingPrice] = createSignal(false); + const [searchResults, setSearchResults] = createSignal([]); + 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 = { 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 = () => ( + + +
+ +
Type to search prices...
+
+ + {(result, index) => ( +
setSelectedIndex(index())} + onMouseDown={(e) => { + // Prevent blur from firing before click + e.preventDefault(); + handleSelectStandardPrice(result); + }} + > +
{result.name}
+
+ {result.category} + ${result.price.toFixed(2)} +
+
+ )} +
+
+
+
+ ); + const [isEditing, setIsEditing] = createSignal(false); const [localDescription, setLocalDescription] = createSignal(props.item.description); @@ -134,9 +274,10 @@ const ItemRow: Component = (props) => { {/* Price — w-24 */} -
+
$ desktopInputRef = el} dataFieldName="price" step="0.1" value={props.item.unitPrice} @@ -153,7 +294,13 @@ const ItemRow: Component = (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)} /> +
{/* Subtotal — w-24 */} @@ -308,9 +455,10 @@ const ItemRow: Component = (props) => {
Price
-
+
$ - 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" /> + 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)} /> +
diff --git a/src/components/ui/NumericInput.tsx b/src/components/ui/NumericInput.tsx index 7202566..8b54713 100644 --- a/src/components/ui/NumericInput.tsx +++ b/src/components/ui/NumericInput.tsx @@ -6,6 +6,10 @@ interface NumericInputProps extends Omit void; dataFieldName?: string; class?: string; + allowTextSearch?: boolean; + onTextSearch?: (searchString: string) => void; + onTextSearchExit?: () => void; + inputRef?: any; } const NumericInput: Component = (props) => { @@ -13,7 +17,7 @@ const NumericInput: Component = (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 = (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 = (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 = (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 ( ( + ), root!); diff --git a/src/types.ts b/src/types.ts index b575901..7a9ae4b 100644 --- a/src/types.ts +++ b/src/types.ts @@ -75,3 +75,11 @@ export interface Template { modifiers?: Modifier[]; defaultSubScopeName?: string; } + +export interface StandardPrice { + id: string; + category: string; + name: string; + price: number; + priceRange?: string; +} diff --git a/src/utils/db.ts b/src/utils/db.ts index dcb3c66..11d624d 100644 --- a/src/utils/db.ts +++ b/src/utils/db.ts @@ -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; diff --git a/src/views/StandardPricing.tsx b/src/views/StandardPricing.tsx new file mode 100644 index 0000000..19344a3 --- /dev/null +++ b/src/views/StandardPricing.tsx @@ -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([]); + const [isLoading, setIsLoading] = createSignal(true); + + const [newCategory, setNewCategory] = createSignal(''); + const [newName, setNewName] = createSignal(''); + const [newPrice, setNewPrice] = createSignal(0); + const [newPriceRange, setNewPriceRange] = createSignal(''); + + const [editingId, setEditingId] = createSignal(null); + const [editCategory, setEditCategory] = createSignal(''); + const [editName, setEditName] = createSignal(''); + const [editPrice, setEditPrice] = createSignal(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 ( +
+
+
+
+

Standard Pricing

+

Add Standard Pricing via "@" in item price field.

+
+ + +
+ + {/* Add New Row */} +
+

Add New Standard Price

+
+
+ + 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" + /> +
+
+ + 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" + /> +
+
+ + 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" + /> +
+
+ + 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" + /> +
+ +
+
+ + {/* List of Prices */} +
+

Existing Prices

+ + {isLoading() ? ( +
Loading...
+ ) : prices().length === 0 ? ( +
+ No standard prices added yet. +
+ ) : ( +
+ + + + + + + + + + + + + {(price) => ( + + + + + + + + )} + + +
CategoryItem NamePriceRange
+ {editingId() === price.id ? ( + 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} + + {editingId() === price.id ? ( + 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} + + {editingId() === price.id ? ( + 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)}`} + + {editingId() === price.id ? ( + 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 || '-')} + +
+ {editingId() === price.id ? ( + <> + + + + ) : ( + <> + + + + )} +
+
+
+ )} +
+
+ + {/* Suggestions Memos */} + + + {(cat) => + + + + {(name) => + +
+ ); +}; + +export default StandardPricing;