calculator improvements
This commit is contained in:
@@ -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>
|
||||
|
||||
|
||||
@@ -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()}`;
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user