Compare commits
5 Commits
a1ca82e539
...
74e859ef71
| Author | SHA1 | Date | |
|---|---|---|---|
| 74e859ef71 | |||
| 6780d80d10 | |||
| 41803f4efa | |||
| c5b750bd08 | |||
| aac836501e |
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user