Full Pocketbase Integration

This commit is contained in:
2026-03-10 19:42:40 -05:00
parent 39c5021e2f
commit 6906cfe5da
11 changed files with 427 additions and 53 deletions
+109
View File
@@ -0,0 +1,109 @@
import type { Component } from 'solid-js';
import { createSignal, Show, For, createEffect } from 'solid-js';
import { Portal } from 'solid-js/web';
import { X } from 'lucide-solid';
import { pb, COLLECTIONS } from '../utils/db';
import { appStore } from '../store/appStore';
interface CloudLoadModalProps {
show: boolean;
onClose: () => void;
}
const CloudLoadModal: Component<CloudLoadModalProps> = (props) => {
const [cloudEstimates, setCloudEstimates] = createSignal<any[]>([]);
const [isCloudLoading, setIsCloudLoading] = createSignal(false);
createEffect(() => {
if (props.show) {
loadCloudEstimatesList();
}
});
const loadCloudEstimatesList = async () => {
setIsCloudLoading(true);
try {
const records = await pb.collection(COLLECTIONS.ESTIMATES).getFullList({
sort: '-created'
});
setCloudEstimates(records);
} catch (err) {
console.error(err);
alert('Failed to list estimates.');
} finally {
setIsCloudLoading(false);
}
};
const loadFromCloud = (record: any) => {
const data = record.items; // items holds the full JSON blob
if (!data) return;
if (data.scopes) appStore.setScopes(data.scopes);
if (data.subScopes) appStore.setSubScopes(data.subScopes);
if (data.items) appStore.setEstimateItems(data.items);
if (data.clientInfo) appStore.setClientInfo(data.clientInfo);
if (data.jobInfo) appStore.setJobInfo(data.jobInfo);
if (data.generalEstimateNotes !== undefined) appStore.setGeneralEstimateNotes(data.generalEstimateNotes);
if (data.generalPreNotes !== undefined) appStore.setGeneralPreNotes(data.generalPreNotes);
appStore.setEstimateName(record.name);
appStore.setEstimateId(record.id);
// Also clear out the extracted CSV items so we don't accidentally switch back to the upload screen or cause conflicts
appStore.setItems([]);
props.onClose();
};
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">
<h3 class="text-xl font-black">Load from Cloud</h3>
<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 overflow-y-auto p-4 flex flex-col">
<Show
when={!isCloudLoading()}
fallback={<div class="py-12 text-center text-muted-foreground">Loading estimates...</div>}
>
<div class="space-y-3">
<For each={cloudEstimates()}>
{(record) => (
<div class="group flex items-center justify-between p-4 rounded-2xl border border-border/60 bg-muted/10 hover:bg-primary/5 hover:border-primary/30 transition-all">
<div>
<h4 class="font-bold text-foreground mb-1">{record.name}</h4>
<p class="text-[10px] text-muted-foreground font-medium uppercase tracking-wider">
{new Date(record.created).toLocaleDateString()}
</p>
</div>
<button
onClick={() => loadFromCloud(record)}
class="px-4 py-2 bg-background border border-border rounded-xl text-xs font-bold shadow-sm hover:text-primary hover:border-primary/50 transition-all opacity-0 group-hover:opacity-100"
>
Load
</button>
</div>
)}
</For>
<Show when={cloudEstimates().length === 0}>
<div class="py-12 text-center text-muted-foreground/60 border-2 border-dashed border-border/60 rounded-xl bg-muted/5">
No saved estimates found.
</div>
</Show>
</div>
</Show>
</div>
</div>
</div>
</Portal>
</Show>
);
};
export default CloudLoadModal;
+123 -28
View File
@@ -1,10 +1,9 @@
import type { Component } from 'solid-js';
import { createSignal, For, createMemo, onMount, Show } from 'solid-js';
import { Portal } from 'solid-js/web';
import { createStore } from 'solid-js/store';
import { Plus, ChevronRight, ListTree, Calculator, FolderKanban, Info, PanelLeftOpen, PanelLeftClose, FileUp, Download, Upload, FileText, ChevronDown, AlertTriangle, CheckCircle2, X } from 'lucide-solid';
import { GENERAL_NOTES_DEFAULT, GENERAL_PRE_NOTES_DEFAULT } from '../company-info';
import type { Scope, SubScope, EstimateItem, JobInfo } from '../types';
import type { Scope, SubScope, EstimateItem } from '../types';
import type { ExtractedItem } from '../utils/csv-parser';
import ScopeCard from './ScopeCard';
import ItemRow from './ItemRow';
@@ -12,6 +11,9 @@ import PrintEstimate from './PrintEstimate';
import NoteEditor from './NoteEditor';
import LazyItem from './LazyItem';
import CalculatorPopover from './CalculatorPopover';
import { appStore } from '../store/appStore';
import { pb, COLLECTIONS } from '../utils/db';
import CloudLoadModal from './CloudLoadModal';
interface EstimateBuilderProps {
initialItems: ExtractedItem[];
@@ -19,34 +21,42 @@ interface EstimateBuilderProps {
}
const EstimateBuilder: Component<EstimateBuilderProps> = (props) => {
const [scopes, setScopes] = createStore<Scope[]>([]);
const [subScopes, setSubScopes] = createStore<SubScope[]>([]);
const [items, setItems] = createStore<EstimateItem[]>([]);
// Use Global Store State
const scopes = appStore.scopes;
const setScopes = appStore.setScopes;
const subScopes = appStore.subScopes;
const setSubScopes = appStore.setSubScopes;
const items = appStore.estimateItems;
const setItems = appStore.setEstimateItems;
const clientInfo = appStore.clientInfo;
const setClientInfo = appStore.setClientInfo;
const jobInfo = appStore.jobInfo;
const setJobInfo = appStore.setJobInfo;
const generalEstimateNotes = appStore.generalEstimateNotes;
const setGeneralEstimateNotes = appStore.setGeneralEstimateNotes;
const generalPreNotes = appStore.generalPreNotes;
const setGeneralPreNotes = appStore.setGeneralPreNotes;
const [isSidebarCollapsed, setIsSidebarCollapsed] = createSignal(false);
const [isNotesExpanded, setIsNotesExpanded] = createSignal(true);
const [isPreNotesExpanded, setIsPreNotesExpanded] = createSignal(true);
const [hoveredScopeId, setHoveredScopeId] = createSignal<string | null>(null);
const [selectedIds, setSelectedIds] = createSignal<string[]>([]);
const [lastSelectedId, setLastSelectedId] = createSignal<string | null>(null);
const [generalEstimateNotes, setGeneralEstimateNotes] = createSignal(GENERAL_NOTES_DEFAULT);
const [generalPreNotes, setGeneralPreNotes] = createSignal(GENERAL_PRE_NOTES_DEFAULT);
// Client & Project signals
const [clientInfo, setClientInfo] = createStore({
name: '',
address: '',
phone: '',
fax: ''
});
const [jobInfo, setJobInfo] = createStore<JobInfo>({
number: '',
name: '',
address: '',
workOrder: ''
});
// Cloud Modals
const [showSaveModal, setShowSaveModal] = createSignal(false);
const [showLoadModal, setShowLoadModal] = createSignal(false);
const [isCloudLoading, setIsCloudLoading] = createSignal(false);
onMount(() => {
// Convert initial items to Estimate items
// Initialize general notes if not already set by an imported estimate
if (!generalEstimateNotes()) setGeneralEstimateNotes(GENERAL_NOTES_DEFAULT);
if (!generalPreNotes()) setGeneralPreNotes(GENERAL_PRE_NOTES_DEFAULT);
// Convert initial items to Estimate items only if we are creating a new estimate
// (i.e. we don't already have estimateItems loaded)
if (items.length === 0 && props.initialItems.length > 0) {
const transformed: EstimateItem[] = props.initialItems.map(item => ({
id: item.id,
description: item.description,
@@ -58,6 +68,7 @@ const EstimateBuilder: Component<EstimateBuilderProps> = (props) => {
contingencyType: 'percent'
}));
setItems(transformed);
}
});
const unassignedItems = createMemo(() => items.filter(i => !i.scopeId));
@@ -169,6 +180,37 @@ const EstimateBuilder: Component<EstimateBuilderProps> = (props) => {
reader.readAsText(file);
};
const getFullEstimateData = () => {
return {
scopes: [...scopes],
subScopes: [...subScopes],
items: [...items],
clientInfo: { ...clientInfo },
jobInfo: { ...jobInfo },
generalEstimateNotes: generalEstimateNotes(),
generalPreNotes: generalPreNotes()
};
};
const saveToCloud = async (name: string) => {
setIsCloudLoading(true);
try {
const data = getFullEstimateData();
await pb.collection(COLLECTIONS.ESTIMATES).create({
name,
items: data
});
setShowSaveModal(false);
appStore.setEstimateName(name); // update header
alert('Estimate saved to PocketBase successfully!');
} catch (err) {
console.error(err);
alert('Failed to save. Ensure EstiMaker_Estimates collection permissions are public.');
} finally {
setIsCloudLoading(false);
}
};
const addScope = () => {
const scopeId = crypto.randomUUID();
const newScope: Scope = {
@@ -455,14 +497,27 @@ const EstimateBuilder: Component<EstimateBuilderProps> = (props) => {
</button>
<div class="h-6 w-px bg-border mx-1"></div>
<label class="flex items-center gap-2 px-3 py-2 bg-muted text-muted-foreground rounded-xl text-xs font-bold hover:bg-accent hover:text-accent-foreground transition-all border border-border cursor-pointer">
<Upload class="w-4 h-4" /> Load Estimate
<Upload class="w-4 h-4" /> Import JSON
<input type="file" accept=".json" class="hidden" onChange={importEstimate} />
</label>
<button
onClick={exportEstimate}
class="flex items-center gap-2 px-3 py-2 bg-muted text-muted-foreground rounded-xl text-xs font-bold hover:bg-accent hover:text-accent-foreground transition-all border border-border mr-2"
>
<Download class="w-4 h-4" /> Export JSON
</button>
<div class="h-6 w-px bg-border mx-1"></div>
<button
onClick={() => setShowLoadModal(true)}
class="flex items-center gap-2 px-3 py-2 bg-blue-50 text-blue-600 rounded-xl text-xs font-bold hover:bg-blue-100 transition-all border border-blue-200"
>
<Download class="w-4 h-4" /> Load
</button>
<button
onClick={() => setShowSaveModal(true)}
class="flex items-center gap-2 px-4 py-2 bg-primary text-primary-foreground rounded-xl text-sm font-bold hover:bg-primary/90 transition-all shadow-lg shadow-primary/10"
>
<Download class="w-4 h-4" /> Save Estimate
<Upload class="w-4 h-4" /> Save
</button>
</div>
</div>
@@ -899,7 +954,7 @@ const EstimateBuilder: Component<EstimateBuilderProps> = (props) => {
<div class="mt-8">
<button
onClick={finalizeBid}
class="w-full py-5 bg-primary hover:bg-primary/90 text-primary-foreground rounded-2xl font-black text-xs uppercase tracking-[0.2em] transition-all shadow-xl shadow-primary/20 active:scale-[0.98] flex items-center justify-center gap-3"
class="w-full py-5 bg-primary hover:bg-primary/90 text-primary-foreground rounded-2xl font-black text-xs uppercase tracking-[0.2em] transition-all shadow-xl shadow-primary/20 active:scale-95 flex items-center justify-center gap-3"
>
<Plus class="w-5 h-5" /> Finalize Bid
</button>
@@ -961,14 +1016,54 @@ const EstimateBuilder: Component<EstimateBuilderProps> = (props) => {
</Portal>
</Show>
{/* Copy Toast */}
<Show when={copyToast() !== null}>
{/* Toast */}
<Show when={copyToast()}>
<div class="fixed bottom-6 right-6 bg-gray-900 border border-gray-800 text-white px-4 py-3 rounded-xl shadow-2xl flex items-center gap-3 animate-in slide-in-from-bottom-5 z-50 pointer-events-none fade-out-90 duration-300">
<CheckCircle2 class="w-5 h-5 text-green-400" />
<span class="font-medium text-sm drop-shadow-sm">{copyToast()}</span>
</div>
</Show>
{/* Save Modal */}
<Show when={showSaveModal()}>
<Portal>
<div class="fixed bottom-24 left-1/2 -translate-x-1/2 z-[101] bg-gray-900/90 backdrop-blur-md text-white text-[11px] font-black uppercase tracking-widest px-6 py-3 rounded-2xl shadow-2xl animate-in fade-in slide-in-from-bottom-2 border border-gray-700">
{copyToast()}
<div class="fixed inset-0 z-[100] flex 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-md rounded-3xl shadow-premium border border-border p-6 animate-in zoom-in-95 duration-200">
<div class="flex items-center justify-between mb-6">
<h3 class="text-xl font-black">Save to Cloud</h3>
<button onClick={() => setShowSaveModal(false)} class="p-2 hover:bg-muted rounded-xl transition-colors">
<X class="w-5 h-5 text-muted-foreground" />
</button>
</div>
<div class="space-y-4">
<input
type="text"
id="cloud-save-name"
placeholder="Estimate Name (e.g. Smith Master Bath)"
class="w-full bg-muted/30 border border-border/60 rounded-xl px-4 py-3 text-sm focus:bg-background focus:ring-2 focus:ring-primary/20 focus:border-primary outline-none transition-all font-medium"
value={appStore.estimateName()}
/>
<div class="flex gap-3 justify-end mt-6">
<button onClick={() => setShowSaveModal(false)} class="px-5 py-2.5 rounded-xl font-bold text-sm hover:bg-muted transition-colors">Cancel</button>
<button
disabled={isCloudLoading()}
onClick={() => {
const name = (document.getElementById('cloud-save-name') as HTMLInputElement).value;
if (name) saveToCloud(name);
}}
class="px-5 py-2.5 bg-primary text-primary-foreground rounded-xl font-bold text-sm shadow-premium hover:shadow-premium-hover transition-all disabled:opacity-50"
>
{isCloudLoading() ? 'Saving...' : 'Save Estimate'}
</button>
</div>
</div>
</div>
</div>
</Portal>
</Show>
{/* Load Modal */}
<CloudLoadModal show={showLoadModal()} onClose={() => setShowLoadModal(false)} />
</div>
);
};
+14 -1
View File
@@ -1,5 +1,5 @@
import type { Component } from 'solid-js';
import { For } from 'solid-js';
import { For, Show } from 'solid-js';
import { Plus } from 'lucide-solid';
import type { TemplateItem } from '../../types';
import TemplateItemRow from './TemplateItemRow';
@@ -26,6 +26,19 @@ const TemplateItemList: Component<TemplateItemListProps> = (props) => {
</div>
<div class="space-y-2">
{/* Table Header */}
<Show when={props.items.length > 0}>
<div class="flex items-center gap-4 px-3 py-1 text-[10px] font-bold text-muted-foreground/50 uppercase tracking-wider">
<div class="w-4 shrink-0"></div> {/* Grip Spacer */}
<div class="flex-1">Description</div>
<div class="w-24 shrink-0 text-right">Qty</div>
<div class="w-32 shrink-0 text-right pr-6">Price</div>
<div class="w-28 shrink-0 text-center">Markup</div>
<div class="w-28 shrink-0 text-center">Cont</div>
<div class="w-10 shrink-0"></div> {/* Actions Spacer */}
</div>
</Show>
<For each={props.items}>
{(item) => (
<TemplateItemRow
@@ -79,6 +79,38 @@ const TemplateItemRow: Component<TemplateItemRowProps> = (props) => {
</div>
</div>
<div class="w-28 shrink-0 flex items-center gap-1.5 mt-1">
<input
type="number"
value={props.item.markup}
onInput={(e) => props.onUpdate(props.item.id, { markup: parseFloat(e.currentTarget.value) || 0 })}
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"
/>
<button
onClick={() => props.onUpdate(props.item.id, { markupType: props.item.markupType === 'percent' ? 'amount' : 'percent' })}
class="text-[9px] font-black px-1.5 py-1.5 rounded-lg bg-muted text-muted-foreground hover:bg-primary/10 hover:text-primary min-w-[24px] transition-colors border border-border/40"
>
{props.item.markupType === 'percent' ? '%' : '$'}
</button>
</div>
<div class="w-28 shrink-0 flex items-center gap-1.5 mt-1">
<input
type="number"
value={props.item.contingency}
onInput={(e) => props.onUpdate(props.item.id, { contingency: parseFloat(e.currentTarget.value) || 0 })}
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"
/>
<button
onClick={() => props.onUpdate(props.item.id, { contingencyType: props.item.contingencyType === 'percent' ? 'amount' : 'percent' })}
class="text-[9px] font-black px-1.5 py-1.5 rounded-lg bg-muted text-muted-foreground hover:bg-primary/10 hover:text-primary min-w-[24px] transition-colors border border-border/40"
>
{props.item.contingencyType === 'percent' ? '%' : '$'}
</button>
</div>
<div class="w-10 shrink-0 flex items-center justify-end gap-1.5 mt-1.5 opacity-0 group-hover:opacity-100 transition-opacity">
<button
onClick={() => props.onDelete(props.item.id)}