From 59c6211bb9ffa3afba26ba8477611b452ebd63c1 Mon Sep 17 00:00:00 2001 From: Timothy Cardoza Date: Fri, 20 Mar 2026 12:22:36 -0500 Subject: [PATCH] calculator improvements --- src/components/CalculatorPopover.tsx | 114 +++++++++++++++++++-- src/utils/standard-price-import.ts | 84 ++++++++++++++++ src/views/StandardPricing.tsx | 145 ++++++++++++++++++++++++++- 3 files changed, 330 insertions(+), 13 deletions(-) create mode 100644 src/utils/standard-price-import.ts diff --git a/src/components/CalculatorPopover.tsx b/src/components/CalculatorPopover.tsx index cf47d7f..124953e 100644 --- a/src/components/CalculatorPopover.tsx +++ b/src/components/CalculatorPopover.tsx @@ -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(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 = () => {
{ e.preventDefault(); setIsHovered(true); }} onDragLeave={() => setIsHovered(false)} onDrop={handleDrop} @@ -201,10 +253,10 @@ const CalculatorPopover: Component = () => {
{/* Display */} -
+
Calculation History
-
+
{(entry) => ( { {entry.label} -
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' && =} {formatNumber(entry.value || 0)} -
+
}>
- - {entry.label} - + 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} + + } + > + 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 + /> +
@@ -235,12 +319,20 @@ const CalculatorPopover: Component = () => {
-
+ +
+ {copyToast() ? 'Copied' : 'Click any number to copy'}
diff --git a/src/utils/standard-price-import.ts b/src/utils/standard-price-import.ts new file mode 100644 index 0000000..5134633 --- /dev/null +++ b/src/utils/standard-price-import.ts @@ -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) => { + 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()}`; +} diff --git a/src/views/StandardPricing.tsx b/src/views/StandardPricing.tsx index 0c620f0..deacc51 100644 --- a/src/views/StandardPricing.tsx +++ b/src/views/StandardPricing.tsx @@ -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(0); const [newSupplier, setNewSupplier] = createSignal(''); + const [importSupplier, setImportSupplier] = createSignal(''); + const [isImporting, setIsImporting] = createSignal(false); + const [isImportOpen, setIsImportOpen] = createSignal(false); + const [importSummary, setImportSummary] = createSignal(null); const [editingId, setEditingId] = createSignal(null); const [editCategory, setEditCategory] = createSignal(''); @@ -64,6 +69,80 @@ const StandardPricing: Component = () => { } }; + const readFileAsText = (file: File) => new Promise((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[]).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 = () => {
+
+ + + +
+
+
+

+ Required columns: Item Name, Price. + Optional columns: Category, Supplier. + Expected row order for your guide sheet: Category, Item Name, Price, Supplier. + If the file has no supplier column, the supplier entered below will be applied to every imported row. +

+
+
+
+ +
+ 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" + /> +
+ +
+
+
+ +
+
+ +
+ {importSummary()} +
+
+
+
+
+ {/* List of Prices */}

Existing Prices