template updating added and item text field fixes

This commit is contained in:
2026-03-13 14:12:13 -05:00
parent a07b145c76
commit c090c18734
7 changed files with 289 additions and 66 deletions
+15 -18
View File
@@ -1,6 +1,7 @@
import type { Component } from 'solid-js';
import { createMemo, createEffect, createSignal, Show } from 'solid-js';
import { GripVertical, Trash2, Copy } from 'lucide-solid';
import NumericInput from './ui/NumericInput';
import type { EstimateItem } from '../types';
interface ItemRowProps {
@@ -108,13 +109,12 @@ const ItemRow: Component<ItemRowProps> = (props) => {
</div>
<div class="w-20 shrink-0 mt-1">
<input
type="number"
data-field-name="qty"
<NumericInput
dataFieldName="qty"
value={props.item.quantity}
onInput={(e) => props.onUpdate(props.item.id, { quantity: parseFloat(e.currentTarget.value) || 0 })}
onUpdate={(val) => props.onUpdate(props.item.id, { quantity: val })}
onDragStart={(e) => {
const val = parseFloat(e.currentTarget.value) || 0;
const val = props.item.quantity;
const data = {
type: 'item-field',
itemId: props.item.id,
@@ -137,14 +137,13 @@ const ItemRow: Component<ItemRowProps> = (props) => {
<div class="w-24 shrink-0 mt-1">
<div class="relative">
<span class="absolute left-2.5 top-1/2 -translate-y-1/2 text-muted-foreground/50 text-[10px]">$</span>
<input
type="number"
data-field-name="price"
<NumericInput
dataFieldName="price"
step="0.1"
value={props.item.unitPrice}
onInput={(e) => props.onUpdate(props.item.id, { unitPrice: parseFloat(e.currentTarget.value) || 0 })}
onUpdate={(val) => props.onUpdate(props.item.id, { unitPrice: val })}
onDragStart={(e) => {
const val = parseFloat(e.currentTarget.value) || 0;
const val = props.item.unitPrice;
const data = {
type: 'item-field',
itemId: props.item.id,
@@ -188,11 +187,10 @@ const ItemRow: Component<ItemRowProps> = (props) => {
<Show when={props.isSidebarCollapsed() || !props.hideColumns}>
<div class="w-32 shrink-0 flex items-center gap-1.5 mt-1">
<input
type="number"
data-field-name="markup"
<NumericInput
dataFieldName="markup"
value={props.item.markup}
onInput={(e) => props.onUpdate(props.item.id, { markup: parseFloat(e.currentTarget.value) || 0 })}
onUpdate={(val) => props.onUpdate(props.item.id, { markup: val })}
disabled={props.anySelected}
class="w-full bg-muted/30 border border-border/60 rounded-xl px-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-primary transition-all"
placeholder="Markup"
@@ -210,11 +208,10 @@ const ItemRow: Component<ItemRowProps> = (props) => {
</div>
<div class="w-32 shrink-0 flex items-center gap-1.5 mt-1">
<input
type="number"
data-field-name="contingency"
<NumericInput
dataFieldName="contingency"
value={props.item.contingency}
onInput={(e) => props.onUpdate(props.item.id, { contingency: parseFloat(e.currentTarget.value) || 0 })}
onUpdate={(val) => props.onUpdate(props.item.id, { contingency: val })}
disabled={props.anySelected}
class="w-full bg-muted/30 border border-border/60 rounded-xl px-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="Contingency"
+11 -12
View File
@@ -1,6 +1,7 @@
import type { Component } from 'solid-js';
import { For, createMemo, Show, createSignal } from 'solid-js';
import { Trash2, Plus, Settings2, Percent, DollarSign, ChevronDown, FileText, ClipboardList, FileBox } from 'lucide-solid';
import NumericInput from './ui/NumericInput';
import type { EstimateItem, Scope, SubScope, PricingType } from '../types';
import ItemRow from './ItemRow';
import SubScopeCard from './SubScopeCard';
@@ -162,10 +163,10 @@ const ScopeCard: Component<ScopeCardProps> = (props) => {
<span class="text-sm font-semibold text-primary">Bulk Adjust Scope:</span>
<div class="flex items-center gap-2">
<label class="text-xs text-primary/70">Markup %:</label>
<input
type="number"
<NumericInput
class="w-16 px-2 py-1 border border-primary/20 bg-background rounded text-sm focus:ring-2 focus:ring-primary outline-none"
onInput={(e) => setBulkMarkup(parseFloat(e.currentTarget.value) || 0)}
value={bulkMarkup()}
onUpdate={(val) => setBulkMarkup(val)}
/>
<button
onClick={() => props.onBulkUpdate(props.scope.id, undefined, { markup: bulkMarkup(), markupType: 'percent' })}
@@ -176,10 +177,10 @@ const ScopeCard: Component<ScopeCardProps> = (props) => {
</div>
<div class="flex items-center gap-2 border-l border-primary/20 pl-4">
<label class="text-xs text-primary/70">Contingency %:</label>
<input
type="number"
<NumericInput
class="w-16 px-2 py-1 border border-primary/20 bg-background rounded text-sm focus:ring-2 focus:ring-primary outline-none"
onInput={(e) => setBulkContingency(parseFloat(e.currentTarget.value) || 0)}
value={bulkContingency()}
onUpdate={(val) => setBulkContingency(val)}
/>
<button
onClick={() => props.onBulkUpdate(props.scope.id, undefined, { contingency: bulkContingency(), contingencyType: 'percent' })}
@@ -299,10 +300,9 @@ const ScopeCard: Component<ScopeCardProps> = (props) => {
</div>
</div>
<div class="flex items-center gap-4">
<input
type="number"
<NumericInput
value={props.scope.managementFee}
onInput={(e) => props.onUpdateScope(props.scope.id, { managementFee: parseFloat(e.currentTarget.value) || 0 })}
onUpdate={(val) => props.onUpdateScope(props.scope.id, { managementFee: val })}
class="w-20 text-right text-sm border-b border-dashed border-border focus:border-primary outline-none bg-transparent"
/>
<span class="w-28 text-right text-foreground font-medium">${formatNumber(totals().mgmtTotal)}</span>
@@ -329,10 +329,9 @@ const ScopeCard: Component<ScopeCardProps> = (props) => {
</div>
</div>
<div class="flex items-center gap-4">
<input
type="number"
<NumericInput
value={props.scope.deliveryFee}
onInput={(e) => props.onUpdateScope(props.scope.id, { deliveryFee: parseFloat(e.currentTarget.value) || 0 })}
onUpdate={(val) => props.onUpdateScope(props.scope.id, { deliveryFee: val })}
class="w-20 text-right text-sm border-b border-dashed border-border focus:border-primary outline-none bg-transparent"
/>
<span class="w-28 text-right text-foreground font-medium">${formatNumber(totals().deliveryTotal)}</span>
+9 -12
View File
@@ -1,6 +1,7 @@
import type { Component } from 'solid-js';
import { createSignal, createEffect } from 'solid-js';
import { Trash2, GripVertical } from 'lucide-solid';
import NumericInput from '../ui/NumericInput';
import type { TemplateItem } from '../../types';
interface TemplateItemRowProps {
@@ -56,10 +57,9 @@ const TemplateItemRow: Component<TemplateItemRowProps> = (props) => {
</div>
<div class="w-24 shrink-0 mt-1">
<input
type="number"
<NumericInput
value={props.item.quantity}
onInput={(e) => props.onUpdate(props.item.id, { quantity: parseFloat(e.currentTarget.value) || 0 })}
onUpdate={(val) => props.onUpdate(props.item.id, { quantity: val })}
class="w-full bg-muted/30 border border-border/60 rounded-xl px-2 py-1.5 text-xs text-right focus:bg-background focus:ring-2 focus:ring-primary/20 focus:border-primary outline-none font-bold text-foreground/80 transition-all"
placeholder="Qty"
/>
@@ -68,11 +68,10 @@ const TemplateItemRow: Component<TemplateItemRowProps> = (props) => {
<div class="w-32 shrink-0 mt-1">
<div class="relative">
<span class="absolute left-2.5 top-1/2 -translate-y-1/2 text-muted-foreground/50 text-[10px]">$</span>
<input
type="number"
<NumericInput
step="0.1"
value={props.item.unitPrice}
onInput={(e) => props.onUpdate(props.item.id, { unitPrice: parseFloat(e.currentTarget.value) || 0 })}
onUpdate={(val) => props.onUpdate(props.item.id, { unitPrice: val })}
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 font-bold text-foreground/80 transition-all"
placeholder="Price"
/>
@@ -80,10 +79,9 @@ const TemplateItemRow: Component<TemplateItemRowProps> = (props) => {
</div>
<div class="w-28 shrink-0 flex items-center gap-1.5 mt-1">
<input
type="number"
<NumericInput
value={props.item.markup}
onInput={(e) => props.onUpdate(props.item.id, { markup: parseFloat(e.currentTarget.value) || 0 })}
onUpdate={(val) => props.onUpdate(props.item.id, { markup: val })}
class="w-full bg-muted/30 border border-border/60 rounded-xl px-2 py-1.5 text-xs text-right focus:bg-background focus:ring-2 focus:ring-primary/20 focus:border-primary outline-none font-bold text-primary transition-all"
placeholder="Markup"
/>
@@ -96,10 +94,9 @@ const TemplateItemRow: Component<TemplateItemRowProps> = (props) => {
</div>
<div class="w-28 shrink-0 flex items-center gap-1.5 mt-1">
<input
type="number"
<NumericInput
value={props.item.contingency}
onInput={(e) => props.onUpdate(props.item.id, { contingency: parseFloat(e.currentTarget.value) || 0 })}
onUpdate={(val) => props.onUpdate(props.item.id, { contingency: val })}
class="w-full bg-muted/30 border border-border/60 rounded-xl px-2 py-1.5 text-xs text-right focus:bg-background focus:ring-2 focus:ring-primary/20 focus:border-primary outline-none font-bold text-foreground/80 transition-all"
placeholder="Cont"
/>
@@ -0,0 +1,105 @@
import type { Component } from 'solid-js';
import { createSignal, Show, For, createEffect } from 'solid-js';
import { Portal } from 'solid-js/web';
import { X, FileBox, CheckCircle2 } from 'lucide-solid';
import { pb, COLLECTIONS } from '../../utils/db';
interface TemplateLoadModalProps {
show: boolean;
onClose: () => void;
onLoad: (template: any) => void;
}
const TemplateLoadModal: Component<TemplateLoadModalProps> = (props) => {
const [templates, setTemplates] = createSignal<any[]>([]);
const [isLoading, setIsLoading] = createSignal(false);
createEffect(() => {
if (props.show) {
loadTemplates();
}
});
const loadTemplates = async () => {
setIsLoading(true);
try {
const records = await pb.collection(COLLECTIONS.TEMPLATES).getFullList({
sort: '-created'
});
setTemplates(records);
} catch (err) {
console.error(err);
alert('Failed to load templates.');
} finally {
setIsLoading(false);
}
};
return (
<Show when={props.show}>
<Portal>
<div class="fixed inset-0 z-[100] flex flex-col items-center justify-center bg-background/80 backdrop-blur-sm p-4 animate-in fade-in duration-200">
<div class="bg-card w-full max-w-2xl max-h-[85vh] flex flex-col rounded-3xl shadow-premium border border-border overflow-hidden animate-in zoom-in-95 duration-200">
<div class="flex items-center justify-between p-6 border-b border-border">
<div class="flex items-center gap-3">
<div class="p-2 bg-primary/10 text-primary rounded-xl">
<FileBox class="w-5 h-5" />
</div>
<div>
<h3 class="text-xl font-black text-foreground">Load Template</h3>
<p class="text-[10px] uppercase font-bold text-muted-foreground tracking-wider mt-0.5">Select a template to edit</p>
</div>
</div>
<button onClick={props.onClose} class="p-2 hover:bg-muted rounded-xl transition-colors">
<X class="w-5 h-5 text-muted-foreground" />
</button>
</div>
<div class="flex-1 flex flex-col p-6 gap-6 overflow-hidden">
{/* Templates List */}
<div class="flex-1 flex flex-col min-h-0 space-y-3">
<label class="text-xs font-black text-muted-foreground uppercase tracking-wider">Saved Templates</label>
<div class="flex-1 overflow-y-auto space-y-3 bg-muted/10 border border-border/50 rounded-2xl p-4">
<Show
when={!isLoading()}
fallback={<div class="py-12 text-center text-muted-foreground text-sm font-medium">Loading templates...</div>}
>
<For each={templates()}>
{(record) => (
<div class="group flex items-center justify-between p-4 rounded-xl border border-border/60 bg-card hover:border-primary/50 hover:shadow-premium transition-all">
<div>
<h4 class="font-bold text-foreground">{record.name}</h4>
<p class="text-[10px] text-muted-foreground font-medium uppercase tracking-wider mt-1">
{record.items?.length || 0} Items {new Date(record.created).toLocaleDateString()}
</p>
</div>
<button
onClick={() => {
props.onLoad(record);
props.onClose();
}}
class="flex items-center gap-2 px-4 py-2 bg-primary/10 text-primary border border-primary/20 rounded-lg text-xs font-bold shadow-sm hover:bg-primary hover:text-primary-foreground hover:border-primary transition-all"
>
<CheckCircle2 class="w-4 h-4" /> Load
</button>
</div>
)}
</For>
<Show when={templates().length === 0}>
<div class="py-12 flex flex-col items-center justify-center text-center text-muted-foreground/60 border-2 border-dashed border-border/60 rounded-xl bg-background/50 h-full">
<FileBox class="w-8 h-8 mb-3 opacity-50" />
<p class="text-sm font-medium">No saved templates found.</p>
</div>
</Show>
</Show>
</div>
</div>
</div>
</div>
</div>
</Portal>
</Show>
);
};
export default TemplateLoadModal;
+83
View File
@@ -0,0 +1,83 @@
import type { Component, JSX } from 'solid-js';
import { createSignal, createEffect } from 'solid-js';
interface NumericInputProps extends Omit<JSX.InputHTMLAttributes<HTMLInputElement>, 'onInput' | 'value'> {
value: number;
onUpdate: (val: number) => void;
dataFieldName?: string;
class?: string;
}
const NumericInput: Component<NumericInputProps> = (props) => {
const [isFocused, setIsFocused] = createSignal(false);
const [localValue, setLocalValue] = createSignal(props.value.toString());
createEffect(() => {
if (!isFocused()) {
setLocalValue(props.value.toString());
}
});
const handleInput = (e: InputEvent & { currentTarget: HTMLInputElement }) => {
let val = e.currentTarget.value;
// Allow only numeric characters, one dot, and one leading minus sign
// This regex allows: "", "-", ".", "-.", and valid numbers
if (val !== '' && !/^-?(\d*\.?\d*)?$/.test(val)) {
// If invalid, revert to previous local value (simple way to block invalid keys)
e.currentTarget.value = localValue();
return;
}
setLocalValue(val);
// Only update the store if it's a valid numeric value for calculations
if (val === '' || val === '-' || val === '.' || val === '-.') {
props.onUpdate(0);
} else {
const parsed = parseFloat(val);
if (!isNaN(parsed)) {
props.onUpdate(parsed);
}
}
};
const callHandler = (handler: any, event: any) => {
if (typeof handler === 'function') {
handler(event);
} else if (Array.isArray(handler)) {
handler[0](handler[1], event);
}
};
const handleFocus = (e: FocusEvent) => {
setIsFocused(true);
// Select all text on focus to make it easier to replace values
(e.currentTarget as HTMLInputElement).select();
callHandler(props.onFocus, e);
};
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
setLocalValue(props.value.toString());
callHandler(props.onBlur, e);
};
return (
<input
{...props}
type="text"
inputmode="decimal"
value={localValue()}
onInput={handleInput}
onFocus={handleFocus}
onBlur={handleBlur}
class={props.class}
data-field-name={props.dataFieldName}
/>
);
};
export default NumericInput;