diff --git a/src/components/EstimateBuilder.tsx b/src/components/EstimateBuilder.tsx index c611c14..b450d89 100644 --- a/src/components/EstimateBuilder.tsx +++ b/src/components/EstimateBuilder.tsx @@ -1,5 +1,5 @@ import type { Component } from 'solid-js'; -import { createSignal, createMemo, onMount, Show } from 'solid-js'; +import { createSignal, createMemo, onMount, Show, For } from 'solid-js'; import { FileText, ChevronDown, CheckCircle2, X } from 'lucide-solid'; import { Portal } from 'solid-js/web'; @@ -66,6 +66,7 @@ const EstimateBuilder: Component = (props) => { onMount(() => { if (!generalEstimateNotes()) setGeneralEstimateNotes(GENERAL_NOTES_DEFAULT); if (!generalPreNotes()) setGeneralPreNotes(GENERAL_PRE_NOTES_DEFAULT); + appStore.loadSuppliers(); if (items.length === 0 && props.initialItems.length > 0) { const transformed: EstimateItem[] = props.initialItems.map(item => ({ @@ -398,6 +399,11 @@ const EstimateBuilder: Component = (props) => { setShowNewEstimateModal(false); }} /> + + + {(sup: string) => + ); }; diff --git a/src/components/ItemRow.tsx b/src/components/ItemRow.tsx index 25fc811..22eaadd 100644 --- a/src/components/ItemRow.tsx +++ b/src/components/ItemRow.tsx @@ -17,6 +17,7 @@ interface ItemRowProps { onToggleSelection: (id: string, isMulti: boolean, isShift: boolean) => void; onDuplicateItem: (id: string) => void; hideColumns?: boolean; + activeSupplier?: string; } const ItemRow: Component = (props) => { @@ -47,8 +48,15 @@ const ItemRow: Component = (props) => { return; } try { + const filterParts = [`(name ~ "${searchString}" || category ~ "${searchString}")`]; + if (props.activeSupplier) { + // Assuming exact match or contains? Let's use exact match for supplier + filterParts.push(`supplier = "${props.activeSupplier}"`); + } + const filterQuery = filterParts.join(' && '); + const records = await pb.collection(COLLECTIONS.STANDARD_PRICES).getList(1, 10, { - filter: `name ~ "${searchString}" || category ~ "${searchString}"`, + filter: filterQuery, sort: 'category,name' }); setSearchResults(records.items as unknown as StandardPrice[]); @@ -162,7 +170,12 @@ const ItemRow: Component = (props) => { >
{result.name}
- {result.category} +
+ {result.category} + + {result.supplier} + +
${result.price.toFixed(2)}
diff --git a/src/components/ScopeCard.tsx b/src/components/ScopeCard.tsx index 6be6edd..0cf1d08 100644 --- a/src/components/ScopeCard.tsx +++ b/src/components/ScopeCard.tsx @@ -8,6 +8,8 @@ import NoteEditor from './NoteEditor'; import LazyItem from './LazyItem'; import ScopeScaleWrapper from './ui/ScopeScaleWrapper'; import { ModifierRegistry } from '../utils/modifier-registry'; +import { appStore } from '../store/appStore'; +import { SupplierDropdown } from './ui/SupplierDropdown'; interface ScopeCardProps { scope: Scope; @@ -142,12 +144,18 @@ const ScopeCard: Component = (props) => { onInput={(e) => props.onUpdateScope(props.scope.id, { name: e.currentTarget.value })} class="text-xl font-black text-foreground bg-transparent border-none outline-none focus:ring-0 w-64" /> -
+
{props.items.length} Items {formatQuantity(totals().totalQuantity)} Qty ${formatNumber(totals().total)} Total + + props.onUpdateScope(props.scope.id, { supplier: val })} + />
@@ -255,6 +263,7 @@ const ScopeCard: Component = (props) => { anySelected={props.selectedIds.length > 0} onToggleSelection={props.onToggleSelection} hideColumns={props.hideColumns} + activeSupplier={props.scope.supplier} /> )} @@ -291,6 +300,7 @@ const ScopeCard: Component = (props) => { onToggleSelection={props.onToggleSelection} onClearSelection={props.onClearSelection} hideColumns={props.hideColumns} + scopeSupplier={props.scope.supplier} /> )} diff --git a/src/components/SubScopeCard.tsx b/src/components/SubScopeCard.tsx index f90a6a6..a73eb7b 100644 --- a/src/components/SubScopeCard.tsx +++ b/src/components/SubScopeCard.tsx @@ -25,6 +25,7 @@ interface SubScopeCardProps { onToggleSelection: (id: string, isMulti: boolean, isShift: boolean) => void; onClearSelection: () => void; hideColumns?: boolean; + scopeSupplier?: string; } const SubScopeCard: Component = (props) => { @@ -218,6 +219,7 @@ const SubScopeCard: Component = (props) => { anySelected={props.selectedIds.length > 0} onToggleSelection={props.onToggleSelection} hideColumns={props.hideColumns} + activeSupplier={props.scopeSupplier} /> )} diff --git a/src/components/ui/SupplierDropdown.tsx b/src/components/ui/SupplierDropdown.tsx new file mode 100644 index 0000000..f07f9a9 --- /dev/null +++ b/src/components/ui/SupplierDropdown.tsx @@ -0,0 +1,90 @@ +import { createSignal, Show, For, onMount, onCleanup } from 'solid-js'; +import { Portal } from 'solid-js/web'; +import { ChevronDown, Check } from 'lucide-solid'; + +interface SupplierDropdownProps { + value: string; + options: string[]; + onSelect: (value: string) => void; + placeholder?: string; +} + +export const SupplierDropdown = (props: SupplierDropdownProps) => { + const [isOpen, setIsOpen] = createSignal(false); + let containerRef: HTMLDivElement | undefined; + let buttonRef: HTMLButtonElement | undefined; + + const handleClickOutside = (e: MouseEvent) => { + if (containerRef && !containerRef.contains(e.target as Node) && + buttonRef && !buttonRef.contains(e.target as Node)) { + setIsOpen(false); + } + }; + + onMount(() => { + document.addEventListener('click', handleClickOutside); + }); + + onCleanup(() => { + document.removeEventListener('click', handleClickOutside); + }); + + const getRect = () => buttonRef?.getBoundingClientRect(); + + return ( +
+ + + + +
+
+ +
+ + {(option) => ( + + )} + +
+
+
+
+
+ ); +}; diff --git a/src/store/appStore.ts b/src/store/appStore.ts index a30e87a..d38e9f8 100644 --- a/src/store/appStore.ts +++ b/src/store/appStore.ts @@ -7,6 +7,7 @@ import { ModifierRegistry } from '../utils/modifier-registry'; // Raw CSV Data const [activeItems, setActiveItems] = createSignal([]); const [activeIgnoredLines, setActiveIgnoredLines] = createSignal([]); +const [suppliers, setSuppliers] = createSignal([]); // Estimate Metadata const [activeEstimateId, setActiveEstimateId] = createSignal(null); @@ -44,6 +45,8 @@ const [activeTemplateModifiers, setActiveTemplateModifiers] = createStore(undefined); const [activeTemplateId, setActiveTemplateId] = createSignal(null); +import { pb, COLLECTIONS } from '../utils/db'; + export const appStore = { // Raw CSV Extraction items: activeItems, @@ -162,5 +165,19 @@ export const appStore = { setActiveTemplateModifiers([]); setActiveTemplateSubScopeName(undefined); setActiveTemplateId(null); + }, + + // Suppliers + suppliers, + async loadSuppliers() { + try { + const records = await pb.collection(COLLECTIONS.STANDARD_PRICES).getFullList({ + fields: 'supplier' + }); + const unique = [...new Set(records.map(r => r.supplier).filter(Boolean))].sort() as string[]; + setSuppliers(unique); + } catch (error) { + console.error('Error loading suppliers:', error); + } } }; diff --git a/src/types.ts b/src/types.ts index 7a9ae4b..d30c3a6 100644 --- a/src/types.ts +++ b/src/types.ts @@ -13,6 +13,7 @@ export interface Scope { useDeliveryLogic: 'percent' | 'simple'; estimatorNotes?: string; bidDescription?: string; + supplier?: string; } export type ModifierType = 'tax' | 'man-hours' | 'custom'; @@ -81,5 +82,5 @@ export interface StandardPrice { category: string; name: string; price: number; - priceRange?: string; + supplier?: string; } diff --git a/src/views/StandardPricing.tsx b/src/views/StandardPricing.tsx index 19344a3..5ba2125 100644 --- a/src/views/StandardPricing.tsx +++ b/src/views/StandardPricing.tsx @@ -1,8 +1,9 @@ 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 { Trash2, Plus, RefreshCw, Edit2, Save, X, ChevronDown } from 'lucide-solid'; import { pb, COLLECTIONS } from '../utils/db'; import type { StandardPrice } from '../types'; +import { appStore } from '../store/appStore'; const StandardPricing: Component = () => { const [prices, setPrices] = createSignal([]); @@ -11,13 +12,13 @@ const StandardPricing: Component = () => { const [newCategory, setNewCategory] = createSignal(''); const [newName, setNewName] = createSignal(''); const [newPrice, setNewPrice] = createSignal(0); - const [newPriceRange, setNewPriceRange] = createSignal(''); + const [newSupplier, setNewSupplier] = createSignal(''); const [editingId, setEditingId] = createSignal(null); const [editCategory, setEditCategory] = createSignal(''); const [editName, setEditName] = createSignal(''); const [editPrice, setEditPrice] = createSignal(0); - const [editPriceRange, setEditPriceRange] = createSignal(''); + const [editSupplier, setEditSupplier] = createSignal(''); const categories = createMemo(() => { return [...new Set(prices().map(p => p.category))].sort(); @@ -43,6 +44,7 @@ const StandardPricing: Component = () => { onMount(() => { loadPrices(); + appStore.loadSuppliers(); }); const handleAddPrice = async () => { @@ -53,15 +55,16 @@ const StandardPricing: Component = () => { category: newCategory(), name: newName(), price: newPrice(), - priceRange: newPriceRange() + supplier: newSupplier() }; const added = await pb.collection(COLLECTIONS.STANDARD_PRICES).create(record); setPrices([...prices(), added as unknown as StandardPrice]); + appStore.loadSuppliers(); // Refresh global list // Reset fields setNewName(''); setNewPrice(0); - setNewPriceRange(''); + setNewSupplier(''); // Keep category the same to easily add multiple items to same category } catch (error) { console.error('Error adding price:', error); @@ -74,7 +77,7 @@ const StandardPricing: Component = () => { setEditCategory(price.category); setEditName(price.name); setEditPrice(price.price); - setEditPriceRange(price.priceRange || ''); + setEditSupplier(price.supplier || ''); }; const handleEditCancel = () => { @@ -90,11 +93,12 @@ const StandardPricing: Component = () => { category: editCategory(), name: editName(), price: editPrice(), - priceRange: editPriceRange() + supplier: editSupplier() }; await pb.collection(COLLECTIONS.STANDARD_PRICES).update(id, updates); setPrices(prices().map(p => p.id === id ? { ...p, ...updates } : p)); setEditingId(null); + appStore.loadSuppliers(); // Refresh global list } catch (error) { console.error('Error updating price:', error); alert('Failed to update standard price.'); @@ -106,6 +110,7 @@ const StandardPricing: Component = () => { try { await pb.collection(COLLECTIONS.STANDARD_PRICES).delete(id); setPrices(prices().filter(p => p.id !== id)); + appStore.loadSuppliers(); // Refresh global list } catch (error) { console.error('Error deleting price:', error); alert('Failed to delete standard price.'); @@ -135,56 +140,64 @@ const StandardPricing: Component = () => {

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" + class="w-full bg-zinc-50/50 border border-zinc-200/60 rounded-xl px-4 py-2.5 text-sm focus:bg-white focus:ring-4 focus:ring-blue-500/10 focus:border-blue-500 outline-none transition-all duration-200 shadow-sm shadow-zinc-200/50 placeholder:text-zinc-300" />
- + 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" + class="w-full bg-zinc-50/50 border border-zinc-200/60 rounded-xl px-4 py-2.5 text-sm focus:bg-white focus:ring-4 focus:ring-blue-500/10 focus:border-blue-500 outline-none transition-all duration-200 shadow-sm shadow-zinc-200/50 placeholder:text-zinc-300" />
- + 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" + placeholder="0.00" + class="w-full bg-zinc-50/50 border border-zinc-200/60 rounded-xl px-4 py-2.5 text-sm focus:bg-white focus:ring-4 focus:ring-blue-500/10 focus:border-blue-500 outline-none text-right font-black transition-all duration-200 shadow-sm shadow-zinc-200/50" />
-
- - 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" - /> +
+ +
+ setNewSupplier(e.currentTarget.value)} + placeholder="Any..." + list="supplier-suggestions" + class="w-full bg-zinc-50/50 border border-zinc-200/60 rounded-xl px-4 py-2.5 text-sm focus:bg-white focus:ring-4 focus:ring-blue-500/10 focus:border-blue-500 outline-none pr-10 cursor-pointer transition-all duration-200 shadow-sm shadow-zinc-200/50 placeholder:text-zinc-300 hover:border-zinc-300" + /> +
+ +
+
+
+
+
-
@@ -206,7 +219,7 @@ const StandardPricing: Component = () => { Category Item Name Price - Range + Supplier @@ -221,7 +234,7 @@ const StandardPricing: Component = () => { 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" + class="w-full bg-white border border-zinc-200/60 rounded-xl px-3 py-1.5 text-xs focus:ring-4 focus:ring-blue-500/10 focus:border-blue-500 outline-none transition-all duration-200 shadow-sm" /> ) : price.category} @@ -232,7 +245,7 @@ const StandardPricing: Component = () => { 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" + class="w-full bg-white border border-zinc-200/60 rounded-xl px-3 py-1.5 text-xs focus:ring-4 focus:ring-blue-500/10 focus:border-blue-500 outline-none transition-all duration-200 shadow-sm" /> ) : price.name} @@ -243,19 +256,25 @@ const StandardPricing: Component = () => { 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" + class="w-24 bg-white border border-zinc-200/60 rounded-xl px-3 py-1.5 text-xs focus:ring-4 focus:ring-blue-500/10 focus:border-blue-500 outline-none text-right font-black transition-all duration-200 shadow-sm" /> ) : `$${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 || '-')} +
+ setEditSupplier(e.currentTarget.value)} + list="supplier-suggestions" + class="w-full bg-white border border-zinc-200/60 rounded-xl px-3 py-1.5 text-xs focus:ring-4 focus:ring-blue-500/10 focus:border-blue-500 outline-none pr-8 cursor-pointer transition-all duration-200 shadow-sm" + /> +
+ +
+
+ ) : (price.supplier || '-')}
@@ -317,6 +336,11 @@ const StandardPricing: Component = () => { {(name) =>
); };