Standarad Pricing Feature Added
This commit is contained in:
@@ -32,6 +32,13 @@ const AppContainer: Component<AppContainerProps> = (props) => {
|
|||||||
>
|
>
|
||||||
Template Creator
|
Template Creator
|
||||||
</A>
|
</A>
|
||||||
|
<A
|
||||||
|
href="/standard-pricing"
|
||||||
|
class="inline-flex items-center px-3 py-2 text-sm font-medium text-gray-600 hover:text-gray-900 hover:bg-gray-50 rounded-md transition-colors"
|
||||||
|
activeClass="text-blue-600 bg-blue-50 hover:bg-blue-50 hover:text-blue-700"
|
||||||
|
>
|
||||||
|
Standard Pricing
|
||||||
|
</A>
|
||||||
</nav>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+153
-5
@@ -1,8 +1,10 @@
|
|||||||
import type { Component } from 'solid-js';
|
import type { Component } from 'solid-js';
|
||||||
import { createMemo, createEffect, createSignal, Show } from 'solid-js';
|
import { createMemo, createEffect, createSignal, Show, For, onCleanup } from 'solid-js';
|
||||||
|
import { Portal } from 'solid-js/web';
|
||||||
import { GripVertical, Trash2, Copy } from 'lucide-solid';
|
import { GripVertical, Trash2, Copy } from 'lucide-solid';
|
||||||
import NumericInput from './ui/NumericInput';
|
import NumericInput from './ui/NumericInput';
|
||||||
import type { EstimateItem } from '../types';
|
import type { EstimateItem, StandardPrice } from '../types';
|
||||||
|
import { pb, COLLECTIONS } from '../utils/db';
|
||||||
|
|
||||||
interface ItemRowProps {
|
interface ItemRowProps {
|
||||||
item: EstimateItem;
|
item: EstimateItem;
|
||||||
@@ -33,6 +35,144 @@ const ItemRow: Component<ItemRowProps> = (props) => {
|
|||||||
return base + markup + contingency;
|
return base + markup + contingency;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const [isSearchingPrice, setIsSearchingPrice] = createSignal(false);
|
||||||
|
const [searchResults, setSearchResults] = createSignal<StandardPrice[]>([]);
|
||||||
|
const [selectedIndex, setSelectedIndex] = createSignal(0);
|
||||||
|
|
||||||
|
const handlePriceSearch = async (searchString: string) => {
|
||||||
|
setIsSearchingPrice(true);
|
||||||
|
if (searchString.trim() === '') {
|
||||||
|
setSearchResults([]);
|
||||||
|
setSelectedIndex(0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const records = await pb.collection(COLLECTIONS.STANDARD_PRICES).getList(1, 10, {
|
||||||
|
filter: `name ~ "${searchString}" || category ~ "${searchString}"`,
|
||||||
|
sort: 'category,name'
|
||||||
|
});
|
||||||
|
setSearchResults(records.items as unknown as StandardPrice[]);
|
||||||
|
setSelectedIndex(0);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error searching standard prices:', error);
|
||||||
|
setSearchResults([]);
|
||||||
|
setSelectedIndex(0);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let desktopInputRef: HTMLInputElement | undefined;
|
||||||
|
let mobileInputRef: HTMLInputElement | undefined;
|
||||||
|
|
||||||
|
const handleSelectStandardPrice = (price: StandardPrice) => {
|
||||||
|
setIsSearchingPrice(false);
|
||||||
|
setSearchResults([]);
|
||||||
|
|
||||||
|
let updates: Partial<EstimateItem> = { unitPrice: price.price };
|
||||||
|
if (!props.item.description) {
|
||||||
|
updates.description = price.name;
|
||||||
|
}
|
||||||
|
props.onUpdate(props.item.id, updates);
|
||||||
|
|
||||||
|
desktopInputRef?.blur();
|
||||||
|
mobileInputRef?.blur();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePriceKeyDown = (e: KeyboardEvent) => {
|
||||||
|
if (!isSearchingPrice() || searchResults().length === 0) return;
|
||||||
|
|
||||||
|
if (e.key === 'ArrowDown') {
|
||||||
|
e.preventDefault();
|
||||||
|
setSelectedIndex((s) => Math.min(s + 1, searchResults().length - 1));
|
||||||
|
} else if (e.key === 'ArrowUp') {
|
||||||
|
e.preventDefault();
|
||||||
|
setSelectedIndex((s) => Math.max(s - 1, 0));
|
||||||
|
} else if (e.key === 'Enter') {
|
||||||
|
e.preventDefault();
|
||||||
|
const selected = searchResults()[selectedIndex()];
|
||||||
|
if (selected) {
|
||||||
|
handleSelectStandardPrice(selected);
|
||||||
|
}
|
||||||
|
} else if (e.key === 'Escape') {
|
||||||
|
e.preventDefault();
|
||||||
|
setIsSearchingPrice(false);
|
||||||
|
setSearchResults([]);
|
||||||
|
desktopInputRef?.blur();
|
||||||
|
mobileInputRef?.blur();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let anchorRef: HTMLDivElement | undefined;
|
||||||
|
let mobileAnchorRef: HTMLDivElement | undefined;
|
||||||
|
const [dropdownRect, setDropdownRect] = createSignal({ top: 0, left: 0, width: 256 });
|
||||||
|
|
||||||
|
const updateDropdownPosition = () => {
|
||||||
|
if (!isSearchingPrice()) return;
|
||||||
|
// Determine which ref is currently visible/active based on window width
|
||||||
|
const ref = window.innerWidth < 768 ? mobileAnchorRef : anchorRef;
|
||||||
|
if (ref) {
|
||||||
|
const rect = ref.getBoundingClientRect();
|
||||||
|
setDropdownRect({
|
||||||
|
top: rect.bottom + window.scrollY + 4,
|
||||||
|
left: rect.left + window.scrollX,
|
||||||
|
width: Math.max(256, rect.width) // Make it at least w-64
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
if (isSearchingPrice()) {
|
||||||
|
updateDropdownPosition();
|
||||||
|
window.addEventListener('scroll', updateDropdownPosition, true);
|
||||||
|
window.addEventListener('resize', updateDropdownPosition);
|
||||||
|
} else {
|
||||||
|
window.removeEventListener('scroll', updateDropdownPosition, true);
|
||||||
|
window.removeEventListener('resize', updateDropdownPosition);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
onCleanup(() => {
|
||||||
|
window.removeEventListener('scroll', updateDropdownPosition, true);
|
||||||
|
window.removeEventListener('resize', updateDropdownPosition);
|
||||||
|
});
|
||||||
|
|
||||||
|
const PriceDropdown = () => (
|
||||||
|
<Show when={isSearchingPrice()}>
|
||||||
|
<Portal>
|
||||||
|
<div
|
||||||
|
class="absolute bg-white border border-gray-200 shadow-xl rounded-xl z-[9999] overflow-hidden text-left pointer-events-auto"
|
||||||
|
style={{
|
||||||
|
top: `${dropdownRect().top}px`,
|
||||||
|
left: `${dropdownRect().left}px`,
|
||||||
|
width: `${dropdownRect().width}px`
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Show when={searchResults().length === 0}>
|
||||||
|
<div class="p-3 text-[11px] text-gray-500 text-center font-medium">Type to search prices...</div>
|
||||||
|
</Show>
|
||||||
|
<For each={searchResults()}>
|
||||||
|
{(result, index) => (
|
||||||
|
<div
|
||||||
|
class={`p-2 border-b border-gray-50 last:border-none cursor-pointer transition-colors ${selectedIndex() === index() ? 'bg-primary/20' : 'hover:bg-primary/10'}`}
|
||||||
|
onMouseEnter={() => setSelectedIndex(index())}
|
||||||
|
onMouseDown={(e) => {
|
||||||
|
// Prevent blur from firing before click
|
||||||
|
e.preventDefault();
|
||||||
|
handleSelectStandardPrice(result);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<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>
|
||||||
|
<span class="text-[11px] font-black text-primary">${result.price.toFixed(2)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</div>
|
||||||
|
</Portal>
|
||||||
|
</Show>
|
||||||
|
);
|
||||||
|
|
||||||
const [isEditing, setIsEditing] = createSignal(false);
|
const [isEditing, setIsEditing] = createSignal(false);
|
||||||
const [localDescription, setLocalDescription] = createSignal(props.item.description);
|
const [localDescription, setLocalDescription] = createSignal(props.item.description);
|
||||||
|
|
||||||
@@ -134,9 +274,10 @@ const ItemRow: Component<ItemRowProps> = (props) => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Price — w-24 */}
|
{/* Price — w-24 */}
|
||||||
<div class="w-24 shrink-0 relative">
|
<div class="w-24 shrink-0 relative" ref={anchorRef}>
|
||||||
<span class="absolute left-2.5 top-1/2 -translate-y-1/2 text-muted-foreground/50 text-[10px] pointer-events-none">$</span>
|
<span class="absolute left-2.5 top-1/2 -translate-y-1/2 text-muted-foreground/50 text-[10px] pointer-events-none">$</span>
|
||||||
<NumericInput
|
<NumericInput
|
||||||
|
inputRef={(el: any) => desktopInputRef = el}
|
||||||
dataFieldName="price"
|
dataFieldName="price"
|
||||||
step="0.1"
|
step="0.1"
|
||||||
value={props.item.unitPrice}
|
value={props.item.unitPrice}
|
||||||
@@ -153,7 +294,13 @@ const ItemRow: Component<ItemRowProps> = (props) => {
|
|||||||
disabled={props.anySelected}
|
disabled={props.anySelected}
|
||||||
class="w-full bg-muted/30 border border-border/60 rounded-xl pl-5 pr-2 py-1.5 text-xs text-right focus:bg-background focus:ring-2 focus:ring-primary/20 focus:border-primary outline-none disabled:opacity-50 cursor-text font-bold text-foreground/80 transition-all"
|
class="w-full bg-muted/30 border border-border/60 rounded-xl pl-5 pr-2 py-1.5 text-xs text-right focus:bg-background focus:ring-2 focus:ring-primary/20 focus:border-primary outline-none disabled:opacity-50 cursor-text font-bold text-foreground/80 transition-all"
|
||||||
placeholder="Price"
|
placeholder="Price"
|
||||||
|
allowTextSearch={true}
|
||||||
|
onTextSearch={handlePriceSearch}
|
||||||
|
onKeyDown={handlePriceKeyDown}
|
||||||
|
onTextSearchExit={() => setIsSearchingPrice(false)}
|
||||||
|
onBlur={() => setTimeout(() => setIsSearchingPrice(false), 150)}
|
||||||
/>
|
/>
|
||||||
|
<PriceDropdown />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Subtotal — w-24 */}
|
{/* Subtotal — w-24 */}
|
||||||
@@ -308,9 +455,10 @@ const ItemRow: Component<ItemRowProps> = (props) => {
|
|||||||
</div>
|
</div>
|
||||||
<div class="w-24">
|
<div class="w-24">
|
||||||
<div class="text-[9px] font-bold text-muted-foreground/60 uppercase mb-1 ml-1">Price</div>
|
<div class="text-[9px] font-bold text-muted-foreground/60 uppercase mb-1 ml-1">Price</div>
|
||||||
<div class="relative">
|
<div class="relative group/price" ref={mobileAnchorRef}>
|
||||||
<span class="absolute left-2.5 top-1/2 -translate-y-1/2 text-muted-foreground/50 text-[10px]">$</span>
|
<span class="absolute left-2.5 top-1/2 -translate-y-1/2 text-muted-foreground/50 text-[10px]">$</span>
|
||||||
<NumericInput step="0.1" value={props.item.unitPrice} onUpdate={(val) => props.onUpdate(props.item.id, { unitPrice: val })} disabled={props.anySelected} class="w-full bg-muted/30 border border-border/60 rounded-xl pl-5 pr-2 py-1.5 text-xs text-right focus:bg-background focus:ring-2 focus:ring-primary/20 focus:border-primary outline-none disabled:opacity-50 font-bold text-foreground/80 transition-all" placeholder="Price" />
|
<NumericInput inputRef={(el: any) => mobileInputRef = el} step="0.1" value={props.item.unitPrice} onUpdate={(val) => props.onUpdate(props.item.id, { unitPrice: val })} disabled={props.anySelected} class="w-full bg-muted/30 border border-border/60 rounded-xl pl-5 pr-2 py-1.5 text-xs text-right focus:bg-background focus:ring-2 focus:ring-primary/20 focus:border-primary outline-none disabled:opacity-50 font-bold text-foreground/80 transition-all" placeholder="Price" allowTextSearch={true} onTextSearch={handlePriceSearch} onKeyDown={handlePriceKeyDown} onTextSearchExit={() => setIsSearchingPrice(false)} onBlur={() => setTimeout(() => setIsSearchingPrice(false), 150)} />
|
||||||
|
<PriceDropdown />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Show when={props.isSidebarCollapsed() || !props.hideColumns}>
|
<Show when={props.isSidebarCollapsed() || !props.hideColumns}>
|
||||||
|
|||||||
@@ -6,6 +6,10 @@ interface NumericInputProps extends Omit<JSX.InputHTMLAttributes<HTMLInputElemen
|
|||||||
onUpdate: (val: number) => void;
|
onUpdate: (val: number) => void;
|
||||||
dataFieldName?: string;
|
dataFieldName?: string;
|
||||||
class?: string;
|
class?: string;
|
||||||
|
allowTextSearch?: boolean;
|
||||||
|
onTextSearch?: (searchString: string) => void;
|
||||||
|
onTextSearchExit?: () => void;
|
||||||
|
inputRef?: any;
|
||||||
}
|
}
|
||||||
|
|
||||||
const NumericInput: Component<NumericInputProps> = (props) => {
|
const NumericInput: Component<NumericInputProps> = (props) => {
|
||||||
@@ -13,7 +17,7 @@ const NumericInput: Component<NumericInputProps> = (props) => {
|
|||||||
const [localValue, setLocalValue] = createSignal(props.value.toString());
|
const [localValue, setLocalValue] = createSignal(props.value.toString());
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
if (!isFocused()) {
|
if (!isFocused() || (props.allowTextSearch && !localValue().startsWith('@') && props.value !== parseFloat(localValue()))) {
|
||||||
setLocalValue(props.value.toString());
|
setLocalValue(props.value.toString());
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -21,6 +25,15 @@ const NumericInput: Component<NumericInputProps> = (props) => {
|
|||||||
const handleInput = (e: InputEvent & { currentTarget: HTMLInputElement }) => {
|
const handleInput = (e: InputEvent & { currentTarget: HTMLInputElement }) => {
|
||||||
let val = e.currentTarget.value;
|
let val = e.currentTarget.value;
|
||||||
|
|
||||||
|
// Check if we allow text search and it starts with '@'
|
||||||
|
if (props.allowTextSearch && val.startsWith('@')) {
|
||||||
|
setLocalValue(val);
|
||||||
|
if (props.onTextSearch) {
|
||||||
|
props.onTextSearch(val.substring(1));
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Allow only numeric characters, one dot, and one leading minus sign
|
// Allow only numeric characters, one dot, and one leading minus sign
|
||||||
// This regex allows: "", "-", ".", "-.", and valid numbers
|
// This regex allows: "", "-", ".", "-.", and valid numbers
|
||||||
if (val !== '' && !/^-?(\d*\.?\d*)?$/.test(val)) {
|
if (val !== '' && !/^-?(\d*\.?\d*)?$/.test(val)) {
|
||||||
@@ -29,6 +42,11 @@ const NumericInput: Component<NumericInputProps> = (props) => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// If we were in text search but cleared it back to a number
|
||||||
|
if (props.allowTextSearch && localValue().startsWith('@') && !val.startsWith('@')) {
|
||||||
|
if (props.onTextSearchExit) props.onTextSearchExit();
|
||||||
|
}
|
||||||
|
|
||||||
setLocalValue(val);
|
setLocalValue(val);
|
||||||
|
|
||||||
// Only update the store if it's a valid numeric value for calculations
|
// Only update the store if it's a valid numeric value for calculations
|
||||||
@@ -59,14 +77,18 @@ const NumericInput: Component<NumericInputProps> = (props) => {
|
|||||||
|
|
||||||
const handleBlur = (e: FocusEvent) => {
|
const handleBlur = (e: FocusEvent) => {
|
||||||
setIsFocused(false);
|
setIsFocused(false);
|
||||||
// On blur, normalize the local value to the props value
|
// On blur, always normalize the local value to the props value
|
||||||
// But if the user left it empty or as a partial, props.value will be 0
|
|
||||||
setLocalValue(props.value.toString());
|
setLocalValue(props.value.toString());
|
||||||
|
|
||||||
|
if (props.allowTextSearch && localValue().startsWith('@') && props.onTextSearchExit) {
|
||||||
|
props.onTextSearchExit();
|
||||||
|
}
|
||||||
callHandler(props.onBlur, e);
|
callHandler(props.onBlur, e);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<input
|
<input
|
||||||
|
ref={props.inputRef}
|
||||||
{...props}
|
{...props}
|
||||||
type="text"
|
type="text"
|
||||||
inputmode="decimal"
|
inputmode="decimal"
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import './index.css';
|
|||||||
import AppContainer from './AppContainer';
|
import AppContainer from './AppContainer';
|
||||||
import ItemExtractor from './ItemExtractor';
|
import ItemExtractor from './ItemExtractor';
|
||||||
import TemplateCreator from './views/TemplateCreator';
|
import TemplateCreator from './views/TemplateCreator';
|
||||||
|
import StandardPricing from './views/StandardPricing';
|
||||||
|
|
||||||
const root = document.getElementById('root');
|
const root = document.getElementById('root');
|
||||||
|
|
||||||
@@ -17,5 +18,6 @@ render(() => (
|
|||||||
<Router root={AppContainer}>
|
<Router root={AppContainer}>
|
||||||
<Route path="/" component={ItemExtractor as any} />
|
<Route path="/" component={ItemExtractor as any} />
|
||||||
<Route path="/templates" component={TemplateCreator} />
|
<Route path="/templates" component={TemplateCreator} />
|
||||||
|
<Route path="/standard-pricing" component={StandardPricing} />
|
||||||
</Router>
|
</Router>
|
||||||
), root!);
|
), root!);
|
||||||
|
|||||||
@@ -75,3 +75,11 @@ export interface Template {
|
|||||||
modifiers?: Modifier[];
|
modifiers?: Modifier[];
|
||||||
defaultSubScopeName?: string;
|
defaultSubScopeName?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface StandardPrice {
|
||||||
|
id: string;
|
||||||
|
category: string;
|
||||||
|
name: string;
|
||||||
|
price: number;
|
||||||
|
priceRange?: string;
|
||||||
|
}
|
||||||
|
|||||||
+2
-1
@@ -5,5 +5,6 @@ export const pb = new PocketBase('https://pocketbase.ccllc.pro');
|
|||||||
|
|
||||||
export const COLLECTIONS = {
|
export const COLLECTIONS = {
|
||||||
TEMPLATES: 'EstiMaker_Templates',
|
TEMPLATES: 'EstiMaker_Templates',
|
||||||
ESTIMATES: 'EstiMaker_Estimates'
|
ESTIMATES: 'EstiMaker_Estimates',
|
||||||
|
STANDARD_PRICES: 'EstiMaker_StandardPrices'
|
||||||
} as const;
|
} as const;
|
||||||
|
|||||||
@@ -0,0 +1,324 @@
|
|||||||
|
import { createSignal, onMount, For, createMemo } from 'solid-js';
|
||||||
|
import type { Component } from 'solid-js';
|
||||||
|
import { Trash2, Plus, RefreshCw, Edit2, Save, X } from 'lucide-solid';
|
||||||
|
import { pb, COLLECTIONS } from '../utils/db';
|
||||||
|
import type { StandardPrice } from '../types';
|
||||||
|
|
||||||
|
const StandardPricing: Component = () => {
|
||||||
|
const [prices, setPrices] = createSignal<StandardPrice[]>([]);
|
||||||
|
const [isLoading, setIsLoading] = createSignal(true);
|
||||||
|
|
||||||
|
const [newCategory, setNewCategory] = createSignal('');
|
||||||
|
const [newName, setNewName] = createSignal('');
|
||||||
|
const [newPrice, setNewPrice] = createSignal<number>(0);
|
||||||
|
const [newPriceRange, setNewPriceRange] = 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 categories = createMemo(() => {
|
||||||
|
return [...new Set(prices().map(p => p.category))].sort();
|
||||||
|
});
|
||||||
|
|
||||||
|
const itemNames = createMemo(() => {
|
||||||
|
return [...new Set(prices().map(p => p.name))].sort();
|
||||||
|
});
|
||||||
|
|
||||||
|
const loadPrices = async () => {
|
||||||
|
setIsLoading(true);
|
||||||
|
try {
|
||||||
|
const records = await pb.collection(COLLECTIONS.STANDARD_PRICES).getFullList({
|
||||||
|
sort: 'category,name'
|
||||||
|
});
|
||||||
|
setPrices(records as unknown as StandardPrice[]);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading standard prices:', error);
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
loadPrices();
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleAddPrice = async () => {
|
||||||
|
if (!newCategory() || !newName() || newPrice() < 0) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const record = {
|
||||||
|
category: newCategory(),
|
||||||
|
name: newName(),
|
||||||
|
price: newPrice(),
|
||||||
|
priceRange: newPriceRange()
|
||||||
|
};
|
||||||
|
const added = await pb.collection(COLLECTIONS.STANDARD_PRICES).create(record);
|
||||||
|
setPrices([...prices(), added as unknown as StandardPrice]);
|
||||||
|
|
||||||
|
// Reset fields
|
||||||
|
setNewName('');
|
||||||
|
setNewPrice(0);
|
||||||
|
setNewPriceRange('');
|
||||||
|
// Keep category the same to easily add multiple items to same category
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error adding price:', error);
|
||||||
|
alert('Failed to add standard price.');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEditStart = (price: StandardPrice) => {
|
||||||
|
setEditingId(price.id);
|
||||||
|
setEditCategory(price.category);
|
||||||
|
setEditName(price.name);
|
||||||
|
setEditPrice(price.price);
|
||||||
|
setEditPriceRange(price.priceRange || '');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEditCancel = () => {
|
||||||
|
setEditingId(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEditSave = async () => {
|
||||||
|
const id = editingId();
|
||||||
|
if (!id || !editCategory() || !editName()) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const updates = {
|
||||||
|
category: editCategory(),
|
||||||
|
name: editName(),
|
||||||
|
price: editPrice(),
|
||||||
|
priceRange: editPriceRange()
|
||||||
|
};
|
||||||
|
await pb.collection(COLLECTIONS.STANDARD_PRICES).update(id, updates);
|
||||||
|
setPrices(prices().map(p => p.id === id ? { ...p, ...updates } : p));
|
||||||
|
setEditingId(null);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error updating price:', error);
|
||||||
|
alert('Failed to update standard price.');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeletePrice = async (id: string) => {
|
||||||
|
if (!confirm('Are you sure you want to delete this standard price?')) return;
|
||||||
|
try {
|
||||||
|
await pb.collection(COLLECTIONS.STANDARD_PRICES).delete(id);
|
||||||
|
setPrices(prices().filter(p => p.id !== id));
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error deleting price:', error);
|
||||||
|
alert('Failed to delete standard price.');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div class="flex flex-col items-center py-6 px-4 font-sans">
|
||||||
|
<div class="max-w-5xl w-full bg-white rounded-xl shadow-premium p-8 mb-8 border border-gray-100">
|
||||||
|
<div class="flex items-center justify-between mb-8 pb-4 border-b border-gray-100">
|
||||||
|
<div>
|
||||||
|
<h1 class="text-3xl font-bold text-gray-800 mb-2 tracking-tight">Standard Pricing</h1>
|
||||||
|
<p class="text-gray-500 font-medium">Add Standard Pricing via "@" in item price field.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={loadPrices}
|
||||||
|
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' : ''}`} />
|
||||||
|
Refresh
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Add New Row */}
|
||||||
|
<div class="bg-blue-50/50 rounded-xl p-4 mb-8 border border-blue-100">
|
||||||
|
<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>
|
||||||
|
<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"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="flex-1 min-w-[200px]">
|
||||||
|
<label class="block text-xs font-bold text-gray-600 mb-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"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="w-32 shrink-0">
|
||||||
|
<label class="block text-xs font-bold text-gray-600 mb-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"
|
||||||
|
/>
|
||||||
|
</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>
|
||||||
|
<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>
|
||||||
|
|
||||||
|
{/* List of Prices */}
|
||||||
|
<div>
|
||||||
|
<h3 class="text-sm font-bold text-gray-800 mb-4 uppercase tracking-wider">Existing Prices</h3>
|
||||||
|
|
||||||
|
{isLoading() ? (
|
||||||
|
<div class="py-12 text-center text-gray-500">Loading...</div>
|
||||||
|
) : prices().length === 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">
|
||||||
|
<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 w-24"></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<For each={prices()}>
|
||||||
|
{(price) => (
|
||||||
|
<tr class={`border-b border-gray-100 last:border-none transition-colors group ${editingId() === price.id ? 'bg-blue-50/30' : 'hover:bg-gray-50/50'}`}>
|
||||||
|
<td class="py-3 px-4 text-sm font-medium text-gray-700">
|
||||||
|
{editingId() === price.id ? (
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
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"
|
||||||
|
/>
|
||||||
|
) : price.category}
|
||||||
|
</td>
|
||||||
|
<td class="py-3 px-4 text-sm font-bold text-gray-900">
|
||||||
|
{editingId() === price.id ? (
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
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"
|
||||||
|
/>
|
||||||
|
) : price.name}
|
||||||
|
</td>
|
||||||
|
<td class="py-3 px-4 text-sm font-bold text-blue-600 text-right">
|
||||||
|
{editingId() === price.id ? (
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
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"
|
||||||
|
/>
|
||||||
|
) : `$${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 || '-')}
|
||||||
|
</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">
|
||||||
|
{editingId() === price.id ? (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
onClick={handleEditSave}
|
||||||
|
class="p-1.5 text-blue-600 hover:bg-blue-50 rounded-lg transition-colors"
|
||||||
|
title="Save"
|
||||||
|
>
|
||||||
|
<Save class="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleEditCancel}
|
||||||
|
class="p-1.5 text-gray-400 hover:bg-gray-100 rounded-lg transition-colors"
|
||||||
|
title="Cancel"
|
||||||
|
>
|
||||||
|
<X class="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
onClick={() => handleEditStart(price)}
|
||||||
|
class="p-1.5 text-gray-400 hover:text-blue-600 hover:bg-blue-50 rounded-lg transition-colors"
|
||||||
|
title="Edit"
|
||||||
|
>
|
||||||
|
<Edit2 class="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handleDeletePrice(price.id)}
|
||||||
|
class="p-1.5 text-red-400 hover:text-red-600 hover:bg-red-50 rounded-lg transition-colors"
|
||||||
|
title="Delete"
|
||||||
|
>
|
||||||
|
<Trash2 class="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Suggestions Memos */}
|
||||||
|
<datalist id="category-suggestions">
|
||||||
|
<For each={categories()}>
|
||||||
|
{(cat) => <option value={cat} />}
|
||||||
|
</For>
|
||||||
|
</datalist>
|
||||||
|
<datalist id="name-suggestions">
|
||||||
|
<For each={itemNames()}>
|
||||||
|
{(name) => <option value={name} />}
|
||||||
|
</For>
|
||||||
|
</datalist>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default StandardPricing;
|
||||||
Reference in New Issue
Block a user