calculator improvements
This commit is contained in:
@@ -7,6 +7,8 @@ interface Entry {
|
|||||||
type: 'number' | 'field' | 'marker' | 'result';
|
type: 'number' | 'field' | 'marker' | 'result';
|
||||||
value?: number;
|
value?: number;
|
||||||
label?: string;
|
label?: string;
|
||||||
|
markerName?: string;
|
||||||
|
markerTime?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
import { Portal } from 'solid-js/web';
|
import { Portal } from 'solid-js/web';
|
||||||
@@ -17,6 +19,9 @@ const CalculatorPopover: Component = () => {
|
|||||||
const [operator, setOperator] = createSignal<'+' | '-' | '*' | '/' | null>(null);
|
const [operator, setOperator] = createSignal<'+' | '-' | '*' | '/' | null>(null);
|
||||||
const [currentInput, setCurrentInput] = createSignal('');
|
const [currentInput, setCurrentInput] = createSignal('');
|
||||||
const [isHovered, setIsHovered] = createSignal(false);
|
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
|
// For simplicity in this version, we'll just track a running total
|
||||||
const [runningTotal, setRunningTotal] = createSignal(0);
|
const [runningTotal, setRunningTotal] = createSignal(0);
|
||||||
@@ -90,7 +95,24 @@ const CalculatorPopover: Component = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const createMarker = () => {
|
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 = () => {
|
const clearInput = () => {
|
||||||
@@ -110,6 +132,36 @@ const CalculatorPopover: Component = () => {
|
|||||||
return num.toLocaleString(undefined, { minimumFractionDigits: 0, maximumFractionDigits: 4 });
|
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
|
// Keyboard support
|
||||||
const handleKeyDown = (e: KeyboardEvent) => {
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
if (!isOpen()) return;
|
if (!isOpen()) return;
|
||||||
@@ -177,7 +229,7 @@ const CalculatorPopover: Component = () => {
|
|||||||
<div class="fixed bottom-6 right-6 z-[100] no-print">
|
<div class="fixed bottom-6 right-6 z-[100] no-print">
|
||||||
<Show when={isOpen()}>
|
<Show when={isOpen()}>
|
||||||
<div
|
<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); }}
|
onDragOver={(e) => { e.preventDefault(); setIsHovered(true); }}
|
||||||
onDragLeave={() => setIsHovered(false)}
|
onDragLeave={() => setIsHovered(false)}
|
||||||
onDrop={handleDrop}
|
onDrop={handleDrop}
|
||||||
@@ -201,10 +253,10 @@ const CalculatorPopover: Component = () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Display */}
|
{/* 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="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()}>
|
<For each={entries()}>
|
||||||
{(entry) => (
|
{(entry) => (
|
||||||
<Show when={entry.type === 'marker'} fallback={
|
<Show when={entry.type === 'marker'} fallback={
|
||||||
@@ -212,19 +264,51 @@ const CalculatorPopover: Component = () => {
|
|||||||
<Show when={entry.label}>
|
<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>
|
<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>
|
</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' ? '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>}
|
{entry.type === 'result' && <span class="mr-2 opacity-60">=</span>}
|
||||||
{formatNumber(entry.value || 0)}
|
{formatNumber(entry.value || 0)}
|
||||||
</div>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
}>
|
}>
|
||||||
<div class="w-full flex items-center gap-4 my-4 opacity-50">
|
<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>
|
<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">
|
<Show
|
||||||
{entry.label}
|
when={editingMarkerId() === entry.id}
|
||||||
</span>
|
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 class="h-px flex-1 bg-gradient-to-l from-transparent to-border"></div>
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
@@ -235,12 +319,20 @@ const CalculatorPopover: Component = () => {
|
|||||||
</Show>
|
</Show>
|
||||||
</div>
|
</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'}>
|
<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>
|
<span class="text-primary text-base font-bold uppercase tracking-widest opacity-60">Total</span>
|
||||||
</Show>
|
</Show>
|
||||||
<span class="text-primary text-2xl font-medium">$</span>
|
<span class="text-primary text-2xl font-medium">$</span>
|
||||||
{currentInput() || formatNumber(runningTotal())}
|
{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>
|
||||||
</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 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 { pb, COLLECTIONS } from '../utils/db';
|
||||||
import type { StandardPrice } from '../types';
|
import type { StandardPrice } from '../types';
|
||||||
import { appStore } from '../store/appStore';
|
import { appStore } from '../store/appStore';
|
||||||
|
import { normalizeStandardPriceKey, parseStandardPricesCSV } from '../utils/standard-price-import';
|
||||||
|
|
||||||
const StandardPricing: Component = () => {
|
const StandardPricing: Component = () => {
|
||||||
const fetchPrices = async () => {
|
const fetchPrices = async () => {
|
||||||
@@ -20,6 +21,10 @@ const StandardPricing: Component = () => {
|
|||||||
const [newName, setNewName] = createSignal('');
|
const [newName, setNewName] = createSignal('');
|
||||||
const [newPrice, setNewPrice] = createSignal<number>(0);
|
const [newPrice, setNewPrice] = createSignal<number>(0);
|
||||||
const [newSupplier, setNewSupplier] = createSignal('');
|
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 [editingId, setEditingId] = createSignal<string | null>(null);
|
||||||
const [editCategory, setEditCategory] = createSignal('');
|
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) => {
|
const handleEditStart = (price: StandardPrice) => {
|
||||||
setEditingId(price.id);
|
setEditingId(price.id);
|
||||||
setEditCategory(price.category);
|
setEditCategory(price.category);
|
||||||
@@ -193,6 +272,68 @@ const StandardPricing: Component = () => {
|
|||||||
</div>
|
</div>
|
||||||
</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 */}
|
{/* List of Prices */}
|
||||||
<div>
|
<div>
|
||||||
<h3 class="text-sm font-bold text-gray-800 mb-4 uppercase tracking-wider">Existing Prices</h3>
|
<h3 class="text-sm font-bold text-gray-800 mb-4 uppercase tracking-wider">Existing Prices</h3>
|
||||||
|
|||||||
Reference in New Issue
Block a user