Supplier based pricing added

This commit is contained in:
2026-03-18 18:00:35 -05:00
parent 41803f4efa
commit 6780d80d10
8 changed files with 210 additions and 47 deletions
+7 -1
View File
@@ -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<EstimateBuilderProps> = (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<EstimateBuilderProps> = (props) => {
setShowNewEstimateModal(false);
}}
/>
<datalist id="supplier-suggestions">
<For each={appStore.suppliers()}>
{(sup: string) => <option value={sup} />}
</For>
</datalist>
</div>
);
};
+15 -2
View File
@@ -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<ItemRowProps> = (props) => {
@@ -47,8 +48,15 @@ const ItemRow: Component<ItemRowProps> = (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<ItemRowProps> = (props) => {
>
<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>
<div class="flex items-center gap-2">
<span class="text-[10px] text-gray-500 uppercase font-black tracking-wider">{result.category}</span>
<Show when={result.supplier}>
<span class="text-[9px] px-1.5 py-0.5 rounded-md bg-muted text-muted-foreground uppercase font-black tracking-widest">{result.supplier}</span>
</Show>
</div>
<span class="text-[11px] font-black text-primary">${result.price.toFixed(2)}</span>
</div>
</div>
+11 -1
View File
@@ -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<ScopeCardProps> = (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"
/>
<div class="flex items-center gap-2 text-xs text-muted-foreground font-bold uppercase tracking-wider">
<div class="flex items-center gap-2 text-[10px] text-muted-foreground font-bold uppercase tracking-wider mt-1 h-6">
<span>{props.items.length} Items</span>
<span></span>
<span>{formatQuantity(totals().totalQuantity)} Qty</span>
<span></span>
<span class="text-primary">${formatNumber(totals().total)} Total</span>
<span></span>
<SupplierDropdown
value={props.scope.supplier || ''}
options={appStore.suppliers()}
onSelect={(val) => props.onUpdateScope(props.scope.id, { supplier: val })}
/>
</div>
</div>
</div>
@@ -255,6 +263,7 @@ const ScopeCard: Component<ScopeCardProps> = (props) => {
anySelected={props.selectedIds.length > 0}
onToggleSelection={props.onToggleSelection}
hideColumns={props.hideColumns}
activeSupplier={props.scope.supplier}
/>
</LazyItem>
)}
@@ -291,6 +300,7 @@ const ScopeCard: Component<ScopeCardProps> = (props) => {
onToggleSelection={props.onToggleSelection}
onClearSelection={props.onClearSelection}
hideColumns={props.hideColumns}
scopeSupplier={props.scope.supplier}
/>
)}
</For>
+2
View File
@@ -25,6 +25,7 @@ interface SubScopeCardProps {
onToggleSelection: (id: string, isMulti: boolean, isShift: boolean) => void;
onClearSelection: () => void;
hideColumns?: boolean;
scopeSupplier?: string;
}
const SubScopeCard: Component<SubScopeCardProps> = (props) => {
@@ -218,6 +219,7 @@ const SubScopeCard: Component<SubScopeCardProps> = (props) => {
anySelected={props.selectedIds.length > 0}
onToggleSelection={props.onToggleSelection}
hideColumns={props.hideColumns}
activeSupplier={props.scopeSupplier}
/>
</LazyItem>
)}
+90
View File
@@ -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 (
<div class="relative inline-block">
<button
ref={buttonRef}
onClick={() => setIsOpen(!isOpen())}
class="flex items-center gap-1.5 group/supplier bg-zinc-100/50 hover:bg-zinc-100 border border-zinc-200/50 rounded-full px-3 py-1 transition-all duration-200 focus:outline-none focus:ring-4 focus:ring-primary/10 focus:border-primary/30 active:scale-95"
>
<span class="text-[9px] font-black uppercase tracking-widest text-muted-foreground/60 select-none">Supplier</span>
<span class="text-[9px] font-black uppercase tracking-widest text-foreground min-w-[30px] text-left">
{props.value || props.placeholder || 'Any'}
</span>
<ChevronDown
class={`w-2.5 h-2.5 text-muted-foreground transition-transform duration-300 ${isOpen() ? 'rotate-180' : 'group-hover/supplier:translate-y-0.5'}`}
/>
</button>
<Show when={isOpen()}>
<Portal>
<div
ref={containerRef}
class="fixed z-[1000] min-w-[160px] bg-white/90 backdrop-blur-xl border border-white/20 rounded-2xl shadow-2xl shadow-zinc-900/10 overflow-hidden animate-in fade-in zoom-in-95 duration-200 origin-top"
style={{
top: `${(getRect()?.bottom || 0) + 8}px`,
left: `${getRect()?.left || 0}px`
}}
>
<div class="p-1.5 flex flex-col gap-0.5">
<button
onClick={() => { props.onSelect(''); setIsOpen(false); }}
class={`w-full flex items-center justify-between px-3 py-2 rounded-xl text-[10px] font-black uppercase tracking-widest transition-all ${!props.value ? 'bg-primary text-primary-foreground shadow-lg shadow-primary/20' : 'hover:bg-zinc-100 text-muted-foreground/60'}`}
>
<span>Any</span>
<Show when={!props.value}>
<Check class="w-3 h-3" />
</Show>
</button>
<div class="h-px bg-zinc-100 my-0.5 mx-1"></div>
<For each={props.options}>
{(option) => (
<button
onClick={() => { props.onSelect(option); setIsOpen(false); }}
class={`w-full flex items-center justify-between px-3 py-2 rounded-xl text-[10px] font-black uppercase tracking-widest transition-all ${props.value === option ? 'bg-primary text-primary-foreground shadow-lg shadow-primary/20' : 'hover:bg-zinc-100 text-foreground'}`}
>
<span>{option}</span>
<Show when={props.value === option}>
<Check class="w-3 h-3" />
</Show>
</button>
)}
</For>
</div>
</div>
</Portal>
</Show>
</div>
);
};
+17
View File
@@ -7,6 +7,7 @@ import { ModifierRegistry } from '../utils/modifier-registry';
// Raw CSV Data
const [activeItems, setActiveItems] = createSignal<ExtractedItem[]>([]);
const [activeIgnoredLines, setActiveIgnoredLines] = createSignal<string[][]>([]);
const [suppliers, setSuppliers] = createSignal<string[]>([]);
// Estimate Metadata
const [activeEstimateId, setActiveEstimateId] = createSignal<string | null>(null);
@@ -44,6 +45,8 @@ const [activeTemplateModifiers, setActiveTemplateModifiers] = createStore<Modifi
const [activeTemplateSubScopeName, setActiveTemplateSubScopeName] = createSignal<string | undefined>(undefined);
const [activeTemplateId, setActiveTemplateId] = createSignal<string | null>(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);
}
}
};
+2 -1
View File
@@ -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;
}
+66 -42
View File
@@ -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<StandardPrice[]>([]);
@@ -11,13 +12,13 @@ const StandardPricing: Component = () => {
const [newCategory, setNewCategory] = createSignal('');
const [newName, setNewName] = createSignal('');
const [newPrice, setNewPrice] = createSignal<number>(0);
const [newPriceRange, setNewPriceRange] = createSignal('');
const [newSupplier, setNewSupplier] = 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 [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 = () => {
<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>
<label class="block text-[10px] font-black tracking-widest text-zinc-400 mb-1.5 uppercase pl-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"
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"
/>
</div>
<div class="flex-1 min-w-[200px]">
<label class="block text-xs font-bold text-gray-600 mb-1">Item Name</label>
<label class="block text-[10px] font-black tracking-widest text-zinc-400 mb-1.5 uppercase pl-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"
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"
/>
</div>
<div class="w-32 shrink-0">
<label class="block text-xs font-bold text-gray-600 mb-1">Price ($)</label>
<label class="block text-[10px] font-black tracking-widest text-zinc-400 mb-1.5 uppercase pl-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"
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"
/>
</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 class="w-40 shrink-0">
<label class="block text-[10px] font-black tracking-widest text-zinc-400 mb-1.5 uppercase pl-1">Supplier (Opt)</label>
<div class="relative group/sel">
<input
type="text"
value={newSupplier()}
onInput={(e) => 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"
/>
<div class="absolute right-3.5 top-1/2 -translate-y-1/2 pointer-events-none text-zinc-300 group-hover/sel:text-blue-500 transition-colors">
<ChevronDown class="w-4 h-4" />
</div>
</div>
</div>
<div class="flex items-end h-[74px]">
<button
onClick={handleAddPrice}
disabled={!newCategory() || !newName() || newPrice() < 0}
class="flex items-center gap-2 px-6 py-2.5 bg-blue-600 text-white rounded-xl font-black text-sm hover:bg-blue-700 hover:shadow-xl hover:shadow-blue-500/25 active:scale-95 active:shadow-inner disabled:opacity-30 disabled:pointer-events-none transition-all duration-200 h-[46px] shadow-lg shadow-blue-500/20"
>
<Plus class="w-4 h-4 transition-transform group-hover:rotate-90" />
<span>Add Item</span>
</button>
</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>
@@ -206,7 +219,7 @@ const StandardPricing: Component = () => {
<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">Supplier</th>
<th class="py-3 px-4 w-24"></th>
</tr>
</thead>
@@ -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}
</td>
@@ -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}
</td>
@@ -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)}`}
</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 || '-')}
<div class="relative group/sel">
<input
type="text"
value={editSupplier()}
onInput={(e) => 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"
/>
<div class="absolute right-2 top-1/2 -translate-y-1/2 pointer-events-none text-zinc-300 group-hover/sel:text-blue-500 transition-colors">
<ChevronDown class="w-3 h-3" />
</div>
</div>
) : (price.supplier || '-')}
</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">
@@ -317,6 +336,11 @@ const StandardPricing: Component = () => {
{(name) => <option value={name} />}
</For>
</datalist>
<datalist id="supplier-suggestions">
<For each={appStore.suppliers()}>
{(sup) => <option value={sup} />}
</For>
</datalist>
</div>
);
};