Compare commits

...

5 Commits

Author SHA1 Message Date
tcardoza 74e859ef71 standard pricing with suppliers fixes
CI / build (push) Has been skipped
CI / deploy (push) Successful in 45s
2026-03-18 18:04:39 -05:00
tcardoza 6780d80d10 Supplier based pricing added 2026-03-18 18:00:35 -05:00
tcardoza 41803f4efa Unassigned items filter 2026-03-18 17:29:54 -05:00
tcardoza c5b750bd08 Calculator UI Fix 2026-03-18 17:27:45 -05:00
tcardoza aac836501e Calculator keystroke fixes 2026-03-18 17:21:50 -05:00
10 changed files with 316 additions and 96 deletions
+43 -14
View File
@@ -114,6 +114,14 @@ const CalculatorPopover: Component = () => {
const handleKeyDown = (e: KeyboardEvent) => {
if (!isOpen()) return;
// Don't intercept if an input is focused
const activeElement = document.activeElement;
const isInputFocused = activeElement instanceof HTMLInputElement ||
activeElement instanceof HTMLTextAreaElement ||
activeElement?.hasAttribute('contenteditable');
if (isInputFocused) return;
if (e.key >= '0' && e.key <= '9') {
handleNumberClick(e.key);
} else if (e.key === '.') {
@@ -137,12 +145,31 @@ const CalculatorPopover: Component = () => {
}
};
const handlePaste = (e: ClipboardEvent) => {
if (!isOpen()) return;
// Don't intercept if an input is focused
const activeElement = document.activeElement;
const isInputFocused = activeElement instanceof HTMLInputElement ||
activeElement instanceof HTMLTextAreaElement ||
activeElement?.hasAttribute('contenteditable');
if (isInputFocused) return;
const pastedText = e.clipboardData?.getData('text/plain');
if (pastedText && !isNaN(parseFloat(pastedText))) {
addValue(parseFloat(pastedText), 'Pasted Value');
}
};
onMount(() => {
window.addEventListener('keydown', handleKeyDown);
window.addEventListener('paste', handlePaste);
});
onCleanup(() => {
window.removeEventListener('keydown', handleKeyDown);
window.removeEventListener('paste', handlePaste);
});
return (
@@ -174,19 +201,19 @@ const CalculatorPopover: Component = () => {
</div>
{/* Display */}
<div class="p-8 bg-foreground text-background min-h-[220px] flex flex-col justify-end items-end relative overflow-hidden group/display">
<div class="absolute top-6 left-8 text-[10px] font-black text-background/30 uppercase tracking-[0.3em]">Calculation History</div>
<div class="p-8 bg-muted text-foreground min-h-[220px] flex flex-col justify-end items-end relative overflow-hidden group/display border-b border-border">
<div class="absolute top-6 left-8 text-[10px] font-black text-muted-foreground/30 uppercase tracking-[0.3em]">Calculation History</div>
<div class="flex flex-col items-end gap-2 mb-4 max-h-56 overflow-y-auto w-full custom-scrollbar pr-2 scroll-smooth">
<For each={entries()}>
{(entry) => (
<Show when={entry.type === 'marker'} fallback={
<div class={`flex items-center gap-3 group/entry transition-all ${entry.type === 'result' ? 'mt-3 pt-3 border-t border-background/10 w-full justify-end' : ''}`}>
<div class={`flex items-center gap-3 group/entry transition-all ${entry.type === 'result' ? 'mt-3 pt-3 border-t border-border w-full justify-end' : ''}`}>
<Show when={entry.label}>
<span class="text-[9px] font-black text-background/40 uppercase tracking-wider truncate max-w-[200px] group-hover/entry:text-background/60 transition-colors">{entry.label}</span>
<span class="text-[9px] font-black text-muted-foreground/40 uppercase tracking-wider truncate max-w-[200px] group-hover/entry:text-muted-foreground/60 transition-colors">{entry.label}</span>
</Show>
<div class={`px-3 py-1 rounded-xl text-xs font-black shadow-sm transition-transform group-hover/entry:scale-105 ${entry.type === 'field' ? 'bg-primary text-primary-foreground shadow-primary/20' :
entry.type === 'result' ? 'bg-primary/20 text-primary border border-primary/30' : 'bg-background/10 text-background/80'
entry.type === 'result' ? 'bg-primary/20 text-primary border border-primary/30' : 'bg-background text-foreground/80 border border-border/60'
}`}>
{entry.type === 'result' && <span class="mr-2 opacity-60">=</span>}
{formatNumber(entry.value || 0)}
@@ -194,11 +221,11 @@ const CalculatorPopover: Component = () => {
</div>
}>
<div class="w-full flex items-center gap-4 my-4 opacity-50">
<div class="h-px flex-1 bg-gradient-to-r from-transparent to-background/20"></div>
<span class="text-[9px] font-black text-background/60 uppercase tracking-[0.2em] whitespace-nowrap bg-background/5 px-3 py-1 rounded-full border border-background/10 italic">
<div class="h-px flex-1 bg-gradient-to-r from-transparent to-border"></div>
<span class="text-[9px] font-black text-muted-foreground/60 uppercase tracking-[0.2em] whitespace-nowrap bg-muted px-3 py-1 rounded-full border border-border italic">
{entry.label}
</span>
<div class="h-px flex-1 bg-gradient-to-l from-transparent to-background/20"></div>
<div class="h-px flex-1 bg-gradient-to-l from-transparent to-border"></div>
</div>
</Show>
)}
@@ -251,17 +278,19 @@ const CalculatorPopover: Component = () => {
</div>
</Show>
<button
onClick={() => setIsOpen(!isOpen())}
class={`p-5 rounded-full shadow-elevated transition-all duration-500 group relative border-2 ${isOpen() ? 'bg-foreground text-background border-foreground rotate-90 scale-110' : 'bg-primary text-primary-foreground border-primary shadow-primary/20 hover:scale-110 hover:shadow-2xl'}`}
>
{isOpen() ? <X class="w-6 h-6" /> : <Calculator class="w-6 h-6" />}
<div class="group relative flex items-center">
<Show when={!isOpen()}>
<span class="absolute right-full mr-6 px-4 py-2 bg-foreground text-background text-[10px] font-black uppercase tracking-widest rounded-xl opacity-0 group-hover:opacity-100 transition-all translate-x-4 group-hover:translate-x-0 pointer-events-none shadow-2xl flex items-center gap-2">
<Calculator class="w-3 h-3 text-primary" /> Calculator
</span>
</Show>
</button>
<button
onClick={() => setIsOpen(!isOpen())}
class={`p-5 rounded-full shadow-elevated transition-all duration-500 relative border-2 ${isOpen() ? 'bg-foreground text-background border-foreground rotate-90 scale-110' : 'bg-primary text-primary-foreground border-primary shadow-primary/20 hover:scale-110 hover:shadow-2xl'}`}
>
{isOpen() ? <X class="w-6 h-6" /> : <Calculator class="w-6 h-6" />}
</button>
</div>
</div>
</Portal>
+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>
)}
@@ -1,6 +1,6 @@
import type { Component, Accessor } from 'solid-js';
import { Show, For } from 'solid-js';
import { ListTree } from 'lucide-solid';
import { Show, For, createSignal, createMemo } from 'solid-js';
import { ListTree, Search, X } from 'lucide-solid';
import ItemRow from '../ItemRow';
import LazyItem from '../LazyItem';
import type { EstimateItem } from '../../types';
@@ -20,6 +20,16 @@ interface UnassignedItemsSectionProps {
}
export const UnassignedItemsSection: Component<UnassignedItemsSectionProps> = (props) => {
const [filterText, setFilterText] = createSignal('');
const filteredItems = createMemo(() => {
const text = filterText().toLowerCase().trim();
if (!text) return props.unassignedItems;
return props.unassignedItems.filter(item =>
(item.description || '').toLowerCase().includes(text)
);
});
return (
<Show when={props.unassignedItems.length > 0}>
<div
@@ -39,9 +49,29 @@ export const UnassignedItemsSection: Component<UnassignedItemsSectionProps> = (p
<p class="text-muted-foreground text-sm">Drag these items into scopes to begin your estimate.</p>
</div>
</div>
<span class="px-4 py-1.5 bg-card border border-border text-primary rounded-full text-[10px] font-black uppercase tracking-widest shadow-sm">
{props.unassignedItems.length} Items Pending
</span>
<div class="flex items-center gap-4">
<div class="relative group/search">
<Search class="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground/40 group-focus-within/search:text-primary transition-colors" />
<input
type="text"
value={filterText()}
onInput={(e) => setFilterText(e.currentTarget.value)}
placeholder="Filter unassigned items..."
class="pl-10 pr-10 py-2 bg-card border border-border rounded-xl text-sm focus:border-primary focus:ring-4 focus:ring-primary/10 outline-none transition-all w-64 shadow-sm"
/>
<Show when={filterText()}>
<button
onClick={() => setFilterText('')}
class="absolute right-3 top-1/2 -translate-y-1/2 p-1 hover:bg-muted rounded-lg transition-all text-muted-foreground/40 hover:text-foreground"
>
<X class="w-3 h-3" />
</button>
</Show>
</div>
<span class="px-4 py-1.5 bg-card border border-border text-primary rounded-full text-[10px] font-black uppercase tracking-widest shadow-sm">
{filteredItems().length} of {props.unassignedItems.length} Items
</span>
</div>
</div>
<div
onClick={(e) => { if (e.target === e.currentTarget) props.clearSelection(e); }}
@@ -64,7 +94,7 @@ export const UnassignedItemsSection: Component<UnassignedItemsSectionProps> = (p
</div>
<div class="grid gap-2">
<For each={props.unassignedItems}>
<For each={filteredItems()}>
{(item) => (
<LazyItem estimatedHeight={60}>
<ItemRow
+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>
);
};
+18
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,20 @@ export const appStore = {
setActiveTemplateModifiers([]);
setActiveTemplateSubScopeName(undefined);
setActiveTemplateId(null);
},
// Suppliers
suppliers,
async loadSuppliers() {
try {
const records = await pb.collection(COLLECTIONS.STANDARD_PRICES).getFullList({
fields: 'supplier',
requestKey: null
});
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;
}
+92 -71
View File
@@ -1,48 +1,42 @@
import { createSignal, onMount, For, createMemo } from 'solid-js';
import { createSignal, onMount, For, createMemo, createResource } 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[]>([]);
const [isLoading, setIsLoading] = createSignal(true);
const fetchPrices = async () => {
const records = await pb.collection(COLLECTIONS.STANDARD_PRICES).getFullList({
sort: 'category,name',
requestKey: null
});
return records as unknown as StandardPrice[];
};
const [prices, { refetch }] = createResource(fetchPrices);
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();
return [...new Set((prices() || []).map(p => p.category))].sort();
});
const itemNames = createMemo(() => {
return [...new Set(prices().map(p => p.name))].sort();
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();
appStore.loadSuppliers();
});
const handleAddPrice = async () => {
@@ -53,15 +47,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]);
await pb.collection(COLLECTIONS.STANDARD_PRICES).create(record);
refetch();
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 +69,7 @@ const StandardPricing: Component = () => {
setEditCategory(price.category);
setEditName(price.name);
setEditPrice(price.price);
setEditPriceRange(price.priceRange || '');
setEditSupplier(price.supplier || '');
};
const handleEditCancel = () => {
@@ -90,11 +85,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));
refetch();
setEditingId(null);
appStore.loadSuppliers(); // Refresh global list
} catch (error) {
console.error('Error updating price:', error);
alert('Failed to update standard price.');
@@ -105,7 +101,8 @@ const StandardPricing: Component = () => {
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));
refetch();
appStore.loadSuppliers(); // Refresh global list
} catch (error) {
console.error('Error deleting price:', error);
alert('Failed to delete standard price.');
@@ -122,10 +119,10 @@ const StandardPricing: Component = () => {
</div>
<button
onClick={loadPrices}
onClick={refetch}
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' : ''}`} />
<RefreshCw class={`w-4 h-4 ${prices.loading ? 'animate-spin' : ''}`} />
Refresh
</button>
</div>
@@ -135,56 +132,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>
@@ -192,21 +197,26 @@ const StandardPricing: Component = () => {
<div>
<h3 class="text-sm font-bold text-gray-800 mb-4 uppercase tracking-wider">Existing Prices</h3>
{isLoading() ? (
{prices.loading && !prices() ? (
<div class="py-12 text-center text-gray-500">Loading...</div>
) : prices().length === 0 ? (
) : (prices()?.length || 0) === 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">
<div class="border border-gray-200 rounded-xl overflow-hidden relative">
{prices.loading && (
<div class="absolute inset-0 bg-white/50 backdrop-blur-[1px] flex items-center justify-center z-10 transition-opacity">
<RefreshCw class="w-8 h-8 text-blue-500 animate-spin" />
</div>
)}
<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">Supplier</th>
<th class="py-3 px-4 w-24"></th>
</tr>
</thead>
@@ -221,7 +231,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 +242,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 +253,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 +333,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>
);
};