Standarad Pricing Feature Added
This commit is contained in:
@@ -32,6 +32,13 @@ const AppContainer: Component<AppContainerProps> = (props) => {
|
||||
>
|
||||
Template Creator
|
||||
</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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+153
-5
@@ -1,8 +1,10 @@
|
||||
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 NumericInput from './ui/NumericInput';
|
||||
import type { EstimateItem } from '../types';
|
||||
import type { EstimateItem, StandardPrice } from '../types';
|
||||
import { pb, COLLECTIONS } from '../utils/db';
|
||||
|
||||
interface ItemRowProps {
|
||||
item: EstimateItem;
|
||||
@@ -33,6 +35,144 @@ const ItemRow: Component<ItemRowProps> = (props) => {
|
||||
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 [localDescription, setLocalDescription] = createSignal(props.item.description);
|
||||
|
||||
@@ -134,9 +274,10 @@ const ItemRow: Component<ItemRowProps> = (props) => {
|
||||
</div>
|
||||
|
||||
{/* 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>
|
||||
<NumericInput
|
||||
inputRef={(el: any) => desktopInputRef = el}
|
||||
dataFieldName="price"
|
||||
step="0.1"
|
||||
value={props.item.unitPrice}
|
||||
@@ -153,7 +294,13 @@ const ItemRow: Component<ItemRowProps> = (props) => {
|
||||
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"
|
||||
placeholder="Price"
|
||||
allowTextSearch={true}
|
||||
onTextSearch={handlePriceSearch}
|
||||
onKeyDown={handlePriceKeyDown}
|
||||
onTextSearchExit={() => setIsSearchingPrice(false)}
|
||||
onBlur={() => setTimeout(() => setIsSearchingPrice(false), 150)}
|
||||
/>
|
||||
<PriceDropdown />
|
||||
</div>
|
||||
|
||||
{/* Subtotal — w-24 */}
|
||||
@@ -308,9 +455,10 @@ const ItemRow: Component<ItemRowProps> = (props) => {
|
||||
</div>
|
||||
<div class="w-24">
|
||||
<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>
|
||||
<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>
|
||||
<Show when={props.isSidebarCollapsed() || !props.hideColumns}>
|
||||
|
||||
@@ -6,6 +6,10 @@ interface NumericInputProps extends Omit<JSX.InputHTMLAttributes<HTMLInputElemen
|
||||
onUpdate: (val: number) => void;
|
||||
dataFieldName?: string;
|
||||
class?: string;
|
||||
allowTextSearch?: boolean;
|
||||
onTextSearch?: (searchString: string) => void;
|
||||
onTextSearchExit?: () => void;
|
||||
inputRef?: any;
|
||||
}
|
||||
|
||||
const NumericInput: Component<NumericInputProps> = (props) => {
|
||||
@@ -13,7 +17,7 @@ const NumericInput: Component<NumericInputProps> = (props) => {
|
||||
const [localValue, setLocalValue] = createSignal(props.value.toString());
|
||||
|
||||
createEffect(() => {
|
||||
if (!isFocused()) {
|
||||
if (!isFocused() || (props.allowTextSearch && !localValue().startsWith('@') && props.value !== parseFloat(localValue()))) {
|
||||
setLocalValue(props.value.toString());
|
||||
}
|
||||
});
|
||||
@@ -21,6 +25,15 @@ const NumericInput: Component<NumericInputProps> = (props) => {
|
||||
const handleInput = (e: InputEvent & { currentTarget: HTMLInputElement }) => {
|
||||
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
|
||||
// This regex allows: "", "-", ".", "-.", and valid numbers
|
||||
if (val !== '' && !/^-?(\d*\.?\d*)?$/.test(val)) {
|
||||
@@ -29,6 +42,11 @@ const NumericInput: Component<NumericInputProps> = (props) => {
|
||||
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);
|
||||
|
||||
// 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) => {
|
||||
setIsFocused(false);
|
||||
// On blur, normalize the local value to the props value
|
||||
// But if the user left it empty or as a partial, props.value will be 0
|
||||
// On blur, always normalize the local value to the props value
|
||||
setLocalValue(props.value.toString());
|
||||
|
||||
if (props.allowTextSearch && localValue().startsWith('@') && props.onTextSearchExit) {
|
||||
props.onTextSearchExit();
|
||||
}
|
||||
callHandler(props.onBlur, e);
|
||||
};
|
||||
|
||||
return (
|
||||
<input
|
||||
ref={props.inputRef}
|
||||
{...props}
|
||||
type="text"
|
||||
inputmode="decimal"
|
||||
|
||||
@@ -4,6 +4,7 @@ import './index.css';
|
||||
import AppContainer from './AppContainer';
|
||||
import ItemExtractor from './ItemExtractor';
|
||||
import TemplateCreator from './views/TemplateCreator';
|
||||
import StandardPricing from './views/StandardPricing';
|
||||
|
||||
const root = document.getElementById('root');
|
||||
|
||||
@@ -17,5 +18,6 @@ render(() => (
|
||||
<Router root={AppContainer}>
|
||||
<Route path="/" component={ItemExtractor as any} />
|
||||
<Route path="/templates" component={TemplateCreator} />
|
||||
<Route path="/standard-pricing" component={StandardPricing} />
|
||||
</Router>
|
||||
), root!);
|
||||
|
||||
@@ -75,3 +75,11 @@ export interface Template {
|
||||
modifiers?: Modifier[];
|
||||
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 = {
|
||||
TEMPLATES: 'EstiMaker_Templates',
|
||||
ESTIMATES: 'EstiMaker_Estimates'
|
||||
ESTIMATES: 'EstiMaker_Estimates',
|
||||
STANDARD_PRICES: 'EstiMaker_StandardPrices'
|
||||
} 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