calculator improvements

This commit is contained in:
2026-03-20 12:22:36 -05:00
parent cc0a1ad15d
commit 59c6211bb9
3 changed files with 330 additions and 13 deletions
+103 -11
View File
@@ -7,6 +7,8 @@ interface Entry {
type: 'number' | 'field' | 'marker' | 'result';
value?: number;
label?: string;
markerName?: string;
markerTime?: string;
}
import { Portal } from 'solid-js/web';
@@ -17,6 +19,9 @@ const CalculatorPopover: Component = () => {
const [operator, setOperator] = createSignal<'+' | '-' | '*' | '/' | null>(null);
const [currentInput, setCurrentInput] = createSignal('');
const [isHovered, setIsHovered] = createSignal(false);
const [copyToast, setCopyToast] = createSignal(false);
const [editingMarkerId, setEditingMarkerId] = createSignal<string | null>(null);
const [markerDraft, setMarkerDraft] = createSignal('');
// For simplicity in this version, we'll just track a running total
const [runningTotal, setRunningTotal] = createSignal(0);
@@ -90,7 +95,24 @@ const CalculatorPopover: Component = () => {
};
const createMarker = () => {
setEntries(prev => [...prev, { id: crypto.randomUUID(), type: 'marker', label: `Marker ${new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}` }]);
const markerTime = new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
setEntries(prev => [...prev, { id: crypto.randomUUID(), type: 'marker', markerName: 'Marker', markerTime }]);
};
const startEditingMarker = (entry: Entry) => {
if (entry.type !== 'marker') return;
setEditingMarkerId(entry.id);
setMarkerDraft(entry.markerName || 'Marker');
};
const saveMarkerName = (id: string) => {
const nextName = markerDraft().trim() || 'Marker';
setEntries(prev => prev.map(entry =>
entry.id === id && entry.type === 'marker'
? { ...entry, markerName: nextName }
: entry
));
setEditingMarkerId(null);
};
const clearInput = () => {
@@ -110,6 +132,36 @@ const CalculatorPopover: Component = () => {
return num.toLocaleString(undefined, { minimumFractionDigits: 0, maximumFractionDigits: 4 });
};
const getCopyValue = () => {
if (currentInput() !== '') {
return currentInput().replace(/,/g, '');
}
return String(runningTotal()).replace(/,/g, '');
};
const copyDisplayedValue = async () => {
try {
await navigator.clipboard.writeText(getCopyValue());
setCopyToast(true);
window.setTimeout(() => setCopyToast(false), 2000);
} catch (err) {
console.error('Failed to copy calculator value', err);
}
};
const copyEntryValue = async (entry: Entry) => {
if (entry.type === 'marker' || entry.value === undefined) return;
try {
await navigator.clipboard.writeText(String(entry.value).replace(/,/g, ''));
setCopyToast(true);
window.setTimeout(() => setCopyToast(false), 2000);
} catch (err) {
console.error('Failed to copy calculator history value', err);
}
};
// Keyboard support
const handleKeyDown = (e: KeyboardEvent) => {
if (!isOpen()) return;
@@ -177,7 +229,7 @@ const CalculatorPopover: Component = () => {
<div class="fixed bottom-6 right-6 z-[100] no-print">
<Show when={isOpen()}>
<div
class={`absolute bottom-24 right-0 w-[400px] bg-card rounded-[3rem] shadow-elevated border border-border overflow-hidden flex flex-col transition-all animate-in slide-in-from-bottom-8 duration-500 ease-out ${isHovered() ? 'ring-4 ring-primary/20 bg-primary/5' : ''}`}
class={`absolute bottom-24 right-0 w-[400px] max-h-[calc(100vh-12rem)] bg-card rounded-[3rem] shadow-elevated border border-border overflow-hidden flex flex-col transition-all animate-in slide-in-from-bottom-8 duration-500 ease-out ${isHovered() ? 'ring-4 ring-primary/20 bg-primary/5' : ''}`}
onDragOver={(e) => { e.preventDefault(); setIsHovered(true); }}
onDragLeave={() => setIsHovered(false)}
onDrop={handleDrop}
@@ -201,10 +253,10 @@ const CalculatorPopover: Component = () => {
</div>
{/* Display */}
<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="p-8 bg-muted text-foreground min-h-[220px] flex-1 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">
<div class="flex-1 flex flex-col items-end gap-2 mb-4 min-h-0 overflow-y-auto w-full custom-scrollbar pr-2 scroll-smooth pt-8">
<For each={entries()}>
{(entry) => (
<Show when={entry.type === 'marker'} fallback={
@@ -212,19 +264,51 @@ const CalculatorPopover: Component = () => {
<Show when={entry.label}>
<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' :
<button
type="button"
onClick={() => copyEntryValue(entry)}
title="Copy value"
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 text-foreground/80 border border-border/60'
}`}>
}`}
>
{entry.type === 'result' && <span class="mr-2 opacity-60">=</span>}
{formatNumber(entry.value || 0)}
</div>
</button>
</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-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>
<Show
when={editingMarkerId() === entry.id}
fallback={
<button
type="button"
onClick={() => startEditingMarker(entry)}
title="Rename marker"
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.markerName || 'Marker')} {entry.markerTime}
</button>
}
>
<input
type="text"
value={markerDraft()}
onInput={(e) => setMarkerDraft(e.currentTarget.value)}
onBlur={() => saveMarkerName(entry.id)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
saveMarkerName(entry.id);
} else if (e.key === 'Escape') {
setEditingMarkerId(null);
}
}}
class="text-[9px] font-black text-muted-foreground/80 uppercase tracking-[0.2em] whitespace-nowrap bg-background px-3 py-1 rounded-full border border-primary/30 italic text-center outline-none"
aria-label="Marker name"
autofocus
/>
</Show>
<div class="h-px flex-1 bg-gradient-to-l from-transparent to-border"></div>
</div>
</Show>
@@ -235,12 +319,20 @@ const CalculatorPopover: Component = () => {
</Show>
</div>
<div class="text-4xl xl:text-5xl font-black tracking-tighter mt-2 flex items-baseline gap-3 group-hover/display:scale-105 transition-transform duration-500 origin-right">
<button
type="button"
onClick={copyDisplayedValue}
title="Copy value"
class="text-4xl xl:text-5xl font-black tracking-tighter mt-2 flex items-baseline gap-3 group-hover/display:scale-105 transition-transform duration-500 origin-right text-right"
>
<Show when={currentInput() === '' && entries().length > 0 && entries()[entries().length - 1].type === 'result'}>
<span class="text-primary text-base font-bold uppercase tracking-widest opacity-60">Total</span>
</Show>
<span class="text-primary text-2xl font-medium">$</span>
{currentInput() || formatNumber(runningTotal())}
</button>
<div class="mt-3 text-[10px] font-black uppercase tracking-[0.25em] text-primary/60">
{copyToast() ? 'Copied' : 'Click any number to copy'}
</div>
</div>
+84
View File
@@ -0,0 +1,84 @@
import { parseCSV } from './csv-parser';
export interface ParsedStandardPriceRow {
category: string;
name: string;
price: number;
supplier?: string;
}
export interface StandardPriceImportResult {
rows: ParsedStandardPriceRow[];
skipped: number;
}
const HEADER_ALIASES = {
category: new Set(['category', 'group', 'type', 'materialcategory']),
name: new Set(['name', 'item', 'itemname', 'material', 'materialname', 'description', 'product']),
price: new Set(['price', 'unitprice', 'cost', 'amount', 'materialprice']),
supplier: new Set(['supplier', 'vendor'])
};
const normalizeHeader = (value: string) => value.toLowerCase().replace(/[^a-z0-9]/g, '');
const parsePrice = (value: string) => {
const cleaned = value.replace(/[$,\s]/g, '');
const parsed = Number.parseFloat(cleaned);
return Number.isFinite(parsed) ? parsed : null;
};
const getColumnIndex = (headers: string[], aliases: Set<string>) => {
return headers.findIndex(header => aliases.has(normalizeHeader(header)));
};
export function parseStandardPricesCSV(csvText: string, defaultSupplier?: string): StandardPriceImportResult {
const rows = parseCSV(csvText)
.map(row => row.map(cell => cell.trim()))
.filter(row => row.some(cell => cell !== ''));
if (rows.length === 0) {
throw new Error('The CSV file is empty.');
}
const headerRow = rows[0];
const dataRows = rows.slice(1);
const categoryIndex = getColumnIndex(headerRow, HEADER_ALIASES.category);
const nameIndex = getColumnIndex(headerRow, HEADER_ALIASES.name);
const priceIndex = getColumnIndex(headerRow, HEADER_ALIASES.price);
const supplierIndex = getColumnIndex(headerRow, HEADER_ALIASES.supplier);
if (nameIndex === -1 || priceIndex === -1) {
throw new Error('CSV must include item name and price columns.');
}
const parsedRows: ParsedStandardPriceRow[] = [];
let skipped = 0;
for (const row of dataRows) {
const name = row[nameIndex]?.trim() || '';
const price = parsePrice(row[priceIndex] || '');
if (!name || price === null || price < 0) {
skipped += 1;
continue;
}
const category = categoryIndex >= 0 ? row[categoryIndex]?.trim() || 'Uncategorized' : 'Uncategorized';
const supplierFromRow = supplierIndex >= 0 ? row[supplierIndex]?.trim() || '' : '';
const supplier = supplierFromRow || defaultSupplier?.trim() || '';
parsedRows.push({
category,
name,
price,
supplier: supplier || undefined
});
}
return { rows: parsedRows, skipped };
}
export function normalizeStandardPriceKey(name: string, supplier?: string) {
return `${name.trim().toLowerCase()}::${(supplier || '').trim().toLowerCase()}`;
}
+143 -2
View File
@@ -1,9 +1,10 @@
import { createSignal, onMount, For, createMemo, createResource } from 'solid-js';
import { createSignal, onMount, For, createMemo, createResource, Show } from 'solid-js';
import type { Component } from 'solid-js';
import { Trash2, Plus, RefreshCw, Edit2, Save, X, ChevronDown } from 'lucide-solid';
import { Trash2, Plus, RefreshCw, Edit2, Save, X, ChevronDown, Upload } from 'lucide-solid';
import { pb, COLLECTIONS } from '../utils/db';
import type { StandardPrice } from '../types';
import { appStore } from '../store/appStore';
import { normalizeStandardPriceKey, parseStandardPricesCSV } from '../utils/standard-price-import';
const StandardPricing: Component = () => {
const fetchPrices = async () => {
@@ -20,6 +21,10 @@ const StandardPricing: Component = () => {
const [newName, setNewName] = createSignal('');
const [newPrice, setNewPrice] = createSignal<number>(0);
const [newSupplier, setNewSupplier] = createSignal('');
const [importSupplier, setImportSupplier] = createSignal('');
const [isImporting, setIsImporting] = createSignal(false);
const [isImportOpen, setIsImportOpen] = createSignal(false);
const [importSummary, setImportSummary] = createSignal<string | null>(null);
const [editingId, setEditingId] = createSignal<string | null>(null);
const [editCategory, setEditCategory] = createSignal('');
@@ -64,6 +69,80 @@ const StandardPricing: Component = () => {
}
};
const readFileAsText = (file: File) => new Promise<string>((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(String(reader.result || ''));
reader.onerror = () => reject(new Error('Failed to read the selected file.'));
reader.readAsText(file);
});
const handleImportFile = async (event: Event) => {
const input = event.currentTarget as HTMLInputElement;
const file = input.files?.[0];
if (!file) return;
setImportSummary(null);
setIsImporting(true);
try {
const csvText = await readFileAsText(file);
const { rows, skipped } = parseStandardPricesCSV(csvText, importSupplier());
if (rows.length === 0) {
throw new Error('No valid pricing rows were found in the CSV.');
}
const existingRecords = await pb.collection(COLLECTIONS.STANDARD_PRICES).getFullList({
fields: 'id,name,supplier',
requestKey: null
});
const existingByKey = new Map(
(existingRecords as unknown as Pick<StandardPrice, 'id' | 'name' | 'supplier'>[]).map(record => [
normalizeStandardPriceKey(record.name, record.supplier),
record.id
])
);
let created = 0;
let updated = 0;
for (const row of rows) {
const record = {
category: row.category,
name: row.name,
price: row.price,
supplier: row.supplier || ''
};
const existingId = existingByKey.get(normalizeStandardPriceKey(row.name, row.supplier));
if (existingId) {
await pb.collection(COLLECTIONS.STANDARD_PRICES).update(existingId, record);
updated += 1;
continue;
}
const createdRecord = await pb.collection(COLLECTIONS.STANDARD_PRICES).create(record);
existingByKey.set(normalizeStandardPriceKey(row.name, row.supplier), createdRecord.id);
created += 1;
}
await refetch();
await appStore.loadSuppliers();
setImportSummary(`Imported ${rows.length} rows: ${created} added, ${updated} replaced${skipped ? `, ${skipped} skipped` : ''}.`);
} catch (error) {
console.error('Error importing standard prices:', error);
const message = error instanceof Error ? error.message : 'Failed to import standard prices.';
setImportSummary(message);
alert(message);
} finally {
setIsImporting(false);
input.value = '';
}
};
const handleEditStart = (price: StandardPrice) => {
setEditingId(price.id);
setEditCategory(price.category);
@@ -193,6 +272,68 @@ const StandardPricing: Component = () => {
</div>
</div>
<div class="bg-gray-50 rounded-xl mb-8 border border-gray-200 overflow-hidden">
<button
onClick={() => setIsImportOpen(!isImportOpen())}
class="w-full flex items-center justify-between gap-4 px-4 py-3 text-left hover:bg-gray-100/80 transition-colors"
>
<div>
<h3 class="text-sm font-bold text-gray-800 uppercase tracking-wider">Import Supplier Pricing CSV</h3>
<p class="text-sm text-gray-500 mt-1">Upload an Excel-style CSV and replace matching item names within the same supplier list.</p>
</div>
<ChevronDown class={`w-5 h-5 text-gray-400 transition-transform ${isImportOpen() ? 'rotate-180' : ''}`} />
</button>
<Show when={isImportOpen()}>
<div class="border-t border-gray-200 bg-white p-4">
<div class="flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between">
<div class="flex-1">
<p class="text-sm text-gray-600">
Required columns: <span class="font-semibold text-gray-800">Item Name</span>, <span class="font-semibold text-gray-800">Price</span>.
Optional columns: <span class="font-semibold text-gray-800">Category</span>, <span class="font-semibold text-gray-800">Supplier</span>.
Expected row order for your guide sheet: <span class="font-semibold text-gray-800">Category, Item Name, Price, Supplier</span>.
If the file has no supplier column, the supplier entered below will be applied to every imported row.
</p>
</div>
<div class="flex flex-col sm:flex-row gap-3 lg:items-end">
<div class="min-w-[220px]">
<label class="block text-[10px] font-black tracking-widest text-zinc-400 mb-1.5 uppercase pl-1">Supplier Override</label>
<div class="relative group/sel">
<input
type="text"
value={importSupplier()}
onInput={(e) => setImportSupplier(e.currentTarget.value)}
placeholder="Used if CSV has no supplier"
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 transition-all duration-200 shadow-sm shadow-zinc-200/50 placeholder:text-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>
<label class={`flex items-center justify-center gap-2 px-5 py-2.5 rounded-xl font-black text-sm transition-all duration-200 h-[46px] ${isImporting() ? 'bg-gray-100 text-gray-500 border border-gray-200 cursor-wait' : 'bg-blue-600 text-white hover:bg-blue-700 hover:shadow-xl hover:shadow-blue-500/25 active:scale-95 active:shadow-inner cursor-pointer shadow-lg shadow-blue-500/20'}`}>
<Upload class={`w-4 h-4 ${isImporting() ? 'animate-pulse' : ''}`} />
<span>{isImporting() ? 'Importing...' : 'Choose CSV'}</span>
<input
type="file"
accept=".csv,text/csv"
onChange={handleImportFile}
disabled={isImporting()}
class="hidden"
/>
</label>
</div>
</div>
<Show when={importSummary()}>
<div class="mt-4 text-sm font-medium text-gray-700 bg-gray-50 border border-gray-200 rounded-xl px-4 py-3">
{importSummary()}
</div>
</Show>
</div>
</Show>
</div>
{/* List of Prices */}
<div>
<h3 class="text-sm font-bold text-gray-800 mb-4 uppercase tracking-wider">Existing Prices</h3>