Compare commits
3 Commits
001d7ad3c1
...
c090c18734
| Author | SHA1 | Date | |
|---|---|---|---|
| c090c18734 | |||
| a07b145c76 | |||
| 6617574c16 |
@@ -10,7 +10,7 @@ const AppContainer: Component<AppContainerProps> = (props) => {
|
||||
<div class="min-h-screen bg-gray-50/50">
|
||||
{/* Top Navigation Bar */}
|
||||
<header class="bg-white border-b border-gray-200 sticky top-0 z-10 no-print">
|
||||
<div class="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div class="max-w-[1800px] w-full mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div class="flex justify-between h-16">
|
||||
<div class="flex items-center gap-8">
|
||||
<div class="flex-shrink-0 flex items-center">
|
||||
@@ -39,7 +39,7 @@ const AppContainer: Component<AppContainerProps> = (props) => {
|
||||
</header>
|
||||
|
||||
{/* Main Content Area */}
|
||||
<main class="max-w-6xl mx-auto p-4 sm:p-6 lg:p-8">
|
||||
<main class="max-w-[1800px] w-full mx-auto p-4 sm:p-6 lg:p-8">
|
||||
{props.children}
|
||||
</main>
|
||||
</div>
|
||||
|
||||
@@ -212,7 +212,7 @@ const EstimateBuilder: Component<EstimateBuilderProps> = (props) => {
|
||||
onAddScope={addScope}
|
||||
/>
|
||||
|
||||
<main onClick={clearSelection} class="flex-1 p-8 bg-zinc-50/50 space-y-8 min-h-screen">
|
||||
<main onClick={clearSelection} class="flex-1 min-w-0 p-4 md:p-8 bg-zinc-50/50 space-y-8 min-h-screen">
|
||||
<ProjectDetailsForm />
|
||||
|
||||
<div class="bg-muted/30 border border-border rounded-3xl p-6 mb-8">
|
||||
|
||||
+17
-20
@@ -1,6 +1,7 @@
|
||||
import type { Component } from 'solid-js';
|
||||
import { createMemo, createEffect, createSignal, Show } from 'solid-js';
|
||||
import { GripVertical, Trash2, Copy } from 'lucide-solid';
|
||||
import NumericInput from './ui/NumericInput';
|
||||
import type { EstimateItem } from '../types';
|
||||
|
||||
interface ItemRowProps {
|
||||
@@ -70,13 +71,13 @@ const ItemRow: Component<ItemRowProps> = (props) => {
|
||||
e.stopPropagation();
|
||||
props.onToggleSelection(props.item.id, e.metaKey || e.ctrlKey, e.shiftKey);
|
||||
}}
|
||||
class={`cursor-grab transition-colors shrink-0 w-4 mt-2 p-1 -m-1 rounded hover:bg-muted ${props.isSelected ? 'text-primary' : 'text-muted-foreground/30 hover:text-muted-foreground/60'}`}
|
||||
class={`cursor-grab transition-colors shrink-0 w-4 mt-2 p-1 rounded hover:bg-muted ${props.isSelected ? 'text-primary' : 'text-muted-foreground/30 hover:text-muted-foreground/60'}`}
|
||||
title="Drag to move or click to select"
|
||||
>
|
||||
<GripVertical class="w-4 h-4" />
|
||||
</div>
|
||||
|
||||
<div class="flex-1 relative">
|
||||
<div class="flex-1 relative min-w-[200px]">
|
||||
|
||||
<textarea
|
||||
ref={descriptionRef}
|
||||
@@ -108,13 +109,12 @@ const ItemRow: Component<ItemRowProps> = (props) => {
|
||||
</div>
|
||||
|
||||
<div class="w-20 shrink-0 mt-1">
|
||||
<input
|
||||
type="number"
|
||||
data-field-name="qty"
|
||||
<NumericInput
|
||||
dataFieldName="qty"
|
||||
value={props.item.quantity}
|
||||
onInput={(e) => props.onUpdate(props.item.id, { quantity: parseFloat(e.currentTarget.value) || 0 })}
|
||||
onUpdate={(val) => props.onUpdate(props.item.id, { quantity: val })}
|
||||
onDragStart={(e) => {
|
||||
const val = parseFloat(e.currentTarget.value) || 0;
|
||||
const val = props.item.quantity;
|
||||
const data = {
|
||||
type: 'item-field',
|
||||
itemId: props.item.id,
|
||||
@@ -137,14 +137,13 @@ const ItemRow: Component<ItemRowProps> = (props) => {
|
||||
<div class="w-24 shrink-0 mt-1">
|
||||
<div class="relative">
|
||||
<span class="absolute left-2.5 top-1/2 -translate-y-1/2 text-muted-foreground/50 text-[10px]">$</span>
|
||||
<input
|
||||
type="number"
|
||||
data-field-name="price"
|
||||
<NumericInput
|
||||
dataFieldName="price"
|
||||
step="0.1"
|
||||
value={props.item.unitPrice}
|
||||
onInput={(e) => props.onUpdate(props.item.id, { unitPrice: parseFloat(e.currentTarget.value) || 0 })}
|
||||
onUpdate={(val) => props.onUpdate(props.item.id, { unitPrice: val })}
|
||||
onDragStart={(e) => {
|
||||
const val = parseFloat(e.currentTarget.value) || 0;
|
||||
const val = props.item.unitPrice;
|
||||
const data = {
|
||||
type: 'item-field',
|
||||
itemId: props.item.id,
|
||||
@@ -188,11 +187,10 @@ const ItemRow: Component<ItemRowProps> = (props) => {
|
||||
|
||||
<Show when={props.isSidebarCollapsed() || !props.hideColumns}>
|
||||
<div class="w-32 shrink-0 flex items-center gap-1.5 mt-1">
|
||||
<input
|
||||
type="number"
|
||||
data-field-name="markup"
|
||||
<NumericInput
|
||||
dataFieldName="markup"
|
||||
value={props.item.markup}
|
||||
onInput={(e) => props.onUpdate(props.item.id, { markup: parseFloat(e.currentTarget.value) || 0 })}
|
||||
onUpdate={(val) => props.onUpdate(props.item.id, { markup: val })}
|
||||
disabled={props.anySelected}
|
||||
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 disabled:opacity-50 font-bold text-primary transition-all"
|
||||
placeholder="Markup"
|
||||
@@ -210,11 +208,10 @@ const ItemRow: Component<ItemRowProps> = (props) => {
|
||||
</div>
|
||||
|
||||
<div class="w-32 shrink-0 flex items-center gap-1.5 mt-1">
|
||||
<input
|
||||
type="number"
|
||||
data-field-name="contingency"
|
||||
<NumericInput
|
||||
dataFieldName="contingency"
|
||||
value={props.item.contingency}
|
||||
onInput={(e) => props.onUpdate(props.item.id, { contingency: parseFloat(e.currentTarget.value) || 0 })}
|
||||
onUpdate={(val) => props.onUpdate(props.item.id, { contingency: val })}
|
||||
disabled={props.anySelected}
|
||||
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 disabled:opacity-50 font-bold text-foreground/80 transition-all"
|
||||
placeholder="Contingency"
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { Component } from 'solid-js';
|
||||
import { For, createMemo, Show, createSignal } from 'solid-js';
|
||||
import { Trash2, Plus, Settings2, Percent, DollarSign, ChevronDown, FileText, ClipboardList, FileBox } from 'lucide-solid';
|
||||
import NumericInput from './ui/NumericInput';
|
||||
import type { EstimateItem, Scope, SubScope, PricingType } from '../types';
|
||||
import ItemRow from './ItemRow';
|
||||
import SubScopeCard from './SubScopeCard';
|
||||
@@ -162,10 +163,10 @@ const ScopeCard: Component<ScopeCardProps> = (props) => {
|
||||
<span class="text-sm font-semibold text-primary">Bulk Adjust Scope:</span>
|
||||
<div class="flex items-center gap-2">
|
||||
<label class="text-xs text-primary/70">Markup %:</label>
|
||||
<input
|
||||
type="number"
|
||||
<NumericInput
|
||||
class="w-16 px-2 py-1 border border-primary/20 bg-background rounded text-sm focus:ring-2 focus:ring-primary outline-none"
|
||||
onInput={(e) => setBulkMarkup(parseFloat(e.currentTarget.value) || 0)}
|
||||
value={bulkMarkup()}
|
||||
onUpdate={(val) => setBulkMarkup(val)}
|
||||
/>
|
||||
<button
|
||||
onClick={() => props.onBulkUpdate(props.scope.id, undefined, { markup: bulkMarkup(), markupType: 'percent' })}
|
||||
@@ -176,10 +177,10 @@ const ScopeCard: Component<ScopeCardProps> = (props) => {
|
||||
</div>
|
||||
<div class="flex items-center gap-2 border-l border-primary/20 pl-4">
|
||||
<label class="text-xs text-primary/70">Contingency %:</label>
|
||||
<input
|
||||
type="number"
|
||||
<NumericInput
|
||||
class="w-16 px-2 py-1 border border-primary/20 bg-background rounded text-sm focus:ring-2 focus:ring-primary outline-none"
|
||||
onInput={(e) => setBulkContingency(parseFloat(e.currentTarget.value) || 0)}
|
||||
value={bulkContingency()}
|
||||
onUpdate={(val) => setBulkContingency(val)}
|
||||
/>
|
||||
<button
|
||||
onClick={() => props.onBulkUpdate(props.scope.id, undefined, { contingency: bulkContingency(), contingencyType: 'percent' })}
|
||||
@@ -203,24 +204,26 @@ const ScopeCard: Component<ScopeCardProps> = (props) => {
|
||||
onClick={(e) => { if (e.target === e.currentTarget) props.onClearSelection(); }}
|
||||
class="p-6 space-y-4 relative"
|
||||
>
|
||||
{/* Column Headers - Sticky, Square Top */}
|
||||
<div class="sticky top-header z-20 bg-background/95 backdrop-blur-sm border-b border-border py-2 mb-2 hidden md:flex items-center gap-4 text-[10px] font-bold text-muted-foreground uppercase tracking-wider px-3 rounded-none">
|
||||
<div class="w-4"></div> {/* Grip space */}
|
||||
<div class="flex-1">Description</div>
|
||||
<div class="w-20 text-right">Qty</div>
|
||||
<div class="w-24 text-right">Price</div>
|
||||
<div class="w-24 text-right">Subtotal</div>
|
||||
<Show when={props.isSidebarCollapsed() || !props.hideColumns}>
|
||||
<div class="w-32 text-center">Markup</div>
|
||||
<div class="w-32 text-center">Contingency</div>
|
||||
</Show>
|
||||
<div class="w-28 text-right px-2">Total</div>
|
||||
<div class="w-16"></div> {/* Action space */}
|
||||
</div>
|
||||
<Show when={scopeItems().length > 0}>
|
||||
<div class="relative pb-4">
|
||||
{/* Column Headers - Sticky, Square Top */}
|
||||
<div class="sticky top-header z-20 bg-background/95 backdrop-blur-sm border-b border-border py-2 mb-2 hidden md:flex items-center gap-4 text-[10px] font-bold text-muted-foreground uppercase tracking-wider px-3 rounded-none">
|
||||
<div class="w-4 shrink-0"></div> {/* Grip space */}
|
||||
<div class="flex-1 min-w-[200px]">Description</div>
|
||||
<div class="w-20 shrink-0 text-right">Qty</div>
|
||||
<div class="w-24 shrink-0 text-right">Price</div>
|
||||
<div class="w-24 shrink-0 text-right">Subtotal</div>
|
||||
<Show when={props.isSidebarCollapsed() || !props.hideColumns}>
|
||||
<div class="w-32 shrink-0 text-center">Markup</div>
|
||||
<div class="w-32 shrink-0 text-center">Contingency</div>
|
||||
</Show>
|
||||
<div class="w-28 shrink-0 text-right">Total</div>
|
||||
<div class="w-16 shrink-0"></div> {/* Action space */}
|
||||
</div>
|
||||
|
||||
<div class="grid gap-2">
|
||||
{/* Scope Level Items */}
|
||||
<For each={props.items.filter(i => !i.subScopeId)}>
|
||||
<div class="grid gap-2">
|
||||
{/* Scope Level Items */}
|
||||
<For each={props.items.filter(i => !i.subScopeId)}>
|
||||
{(item) => (
|
||||
<LazyItem estimatedHeight={60}>
|
||||
<ItemRow
|
||||
@@ -237,13 +240,16 @@ const ScopeCard: Component<ScopeCardProps> = (props) => {
|
||||
/>
|
||||
</LazyItem>
|
||||
)}
|
||||
</For>
|
||||
<Show when={scopeItems().length === 0 && props.subScopes.length === 0}>
|
||||
<div class="text-center py-8 text-muted-foreground/50 italic text-sm">
|
||||
Empty scope. Drag items here or add sub-scopes.
|
||||
</For>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<Show when={scopeItems().length === 0 && props.subScopes.length === 0}>
|
||||
<div class="text-center py-8 text-muted-foreground/50 italic text-sm">
|
||||
Empty scope. Drag items here or add sub-scopes.
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
{/* Sub-scopes */}
|
||||
<div class="space-y-6 pt-4">
|
||||
@@ -294,10 +300,9 @@ const ScopeCard: Component<ScopeCardProps> = (props) => {
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-4">
|
||||
<input
|
||||
type="number"
|
||||
<NumericInput
|
||||
value={props.scope.managementFee}
|
||||
onInput={(e) => props.onUpdateScope(props.scope.id, { managementFee: parseFloat(e.currentTarget.value) || 0 })}
|
||||
onUpdate={(val) => props.onUpdateScope(props.scope.id, { managementFee: val })}
|
||||
class="w-20 text-right text-sm border-b border-dashed border-border focus:border-primary outline-none bg-transparent"
|
||||
/>
|
||||
<span class="w-28 text-right text-foreground font-medium">${formatNumber(totals().mgmtTotal)}</span>
|
||||
@@ -324,10 +329,9 @@ const ScopeCard: Component<ScopeCardProps> = (props) => {
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-4">
|
||||
<input
|
||||
type="number"
|
||||
<NumericInput
|
||||
value={props.scope.deliveryFee}
|
||||
onInput={(e) => props.onUpdateScope(props.scope.id, { deliveryFee: parseFloat(e.currentTarget.value) || 0 })}
|
||||
onUpdate={(val) => props.onUpdateScope(props.scope.id, { deliveryFee: val })}
|
||||
class="w-20 text-right text-sm border-b border-dashed border-border focus:border-primary outline-none bg-transparent"
|
||||
/>
|
||||
<span class="w-28 text-right text-foreground font-medium">${formatNumber(totals().deliveryTotal)}</span>
|
||||
|
||||
@@ -166,8 +166,25 @@ const SubScopeCard: Component<SubScopeCardProps> = (props) => {
|
||||
<Show when={!isCollapsed()}>
|
||||
<div
|
||||
onClick={(e) => { if (e.target === e.currentTarget) props.onClearSelection(); }}
|
||||
class="p-2 space-y-1.5"
|
||||
class="p-2 space-y-1.5 relative"
|
||||
>
|
||||
{/* SubScope Column Headers */}
|
||||
<Show when={props.items.length > 0}>
|
||||
<div class="sticky top-header z-20 bg-background/95 backdrop-blur-sm hidden md:flex items-center gap-4 text-[9px] font-bold text-muted-foreground/60 uppercase tracking-wider px-3 pb-2 pt-1 mb-2 border-b border-border/40">
|
||||
<div class="w-4 shrink-0"></div>
|
||||
<div class="flex-1 min-w-[200px]">Description</div>
|
||||
<div class="w-20 shrink-0 text-right">Qty</div>
|
||||
<div class="w-24 shrink-0 text-right">Price</div>
|
||||
<div class="w-24 shrink-0 text-right">Subtotal</div>
|
||||
<Show when={props.isSidebarCollapsed() || !props.hideColumns}>
|
||||
<div class="w-32 shrink-0 text-center">Markup</div>
|
||||
<div class="w-32 shrink-0 text-center">Contingency</div>
|
||||
</Show>
|
||||
<div class="w-28 shrink-0 text-right px-2">Total</div>
|
||||
<div class="w-16 shrink-0"></div>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<For each={props.items}>
|
||||
{(item) => (
|
||||
<LazyItem estimatedHeight={60}>
|
||||
|
||||
@@ -18,7 +18,7 @@ export const BuilderHeader: Component<BuilderHeaderProps> = (props) => {
|
||||
const { scopes, estimateItems: items } = appStore;
|
||||
|
||||
return (
|
||||
<div class="h-16 px-6 border-b border-border bg-background/50 flex items-center justify-between sticky top-0 z-50 backdrop-blur-md">
|
||||
<div class="min-h-[4rem] py-3 px-6 border-b border-border bg-background/50 flex flex-wrap gap-y-4 items-center justify-between sticky top-0 z-50 backdrop-blur-md">
|
||||
<div class="flex items-center gap-4">
|
||||
<button
|
||||
onClick={props.onToggleSidebar}
|
||||
@@ -41,7 +41,7 @@ export const BuilderHeader: Component<BuilderHeaderProps> = (props) => {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<button
|
||||
onClick={props.onAddScope}
|
||||
class="flex items-center gap-2 px-4 py-2 bg-foreground text-background rounded-xl text-sm font-bold hover:bg-foreground/90 transition-all shadow-lg shadow-foreground/10 mr-2"
|
||||
|
||||
@@ -6,7 +6,7 @@ export const ProjectDetailsForm: Component = () => {
|
||||
const { clientInfo, setClientInfo, jobInfo, setJobInfo } = appStore;
|
||||
|
||||
return (
|
||||
<div class="bg-card border border-border rounded-[2.5rem] p-8 shadow-premium transition-all">
|
||||
<div class="bg-card border border-border rounded-[2.5rem] p-6 md:p-8 shadow-premium transition-all w-full max-w-full overflow-hidden">
|
||||
<div class="flex items-center gap-3 text-foreground mb-8">
|
||||
<div class="p-2.5 bg-primary/10 text-primary rounded-2xl shadow-sm">
|
||||
<FolderKanban class="w-5 h-5" />
|
||||
@@ -14,15 +14,15 @@ export const ProjectDetailsForm: Component = () => {
|
||||
<h3 class="font-black text-xl tracking-tight">Client & Project Details</h3>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-x-12 gap-y-8">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-x-8 lg:gap-x-12 gap-y-8 w-full">
|
||||
<div class="space-y-6">
|
||||
<h4 class="text-[10px] font-black text-muted-foreground uppercase tracking-[0.2em] px-1 blur-[0.2px]">Client Information</h4>
|
||||
<div class="grid gap-3">
|
||||
<input type="text" value={clientInfo.name} onInput={(e) => setClientInfo('name', e.currentTarget.value)} class="bg-muted/20 border border-border/60 p-3 rounded-2xl text-sm outline-none focus:bg-background focus:ring-4 focus:ring-primary/10 focus:border-primary transition-all font-medium" placeholder="Client Name" />
|
||||
<input type="text" value={clientInfo.address} onInput={(e) => setClientInfo('address', e.currentTarget.value)} class="bg-muted/20 border border-border/60 p-3 rounded-2xl text-sm outline-none focus:bg-background focus:ring-4 focus:ring-primary/10 focus:border-primary transition-all font-medium" placeholder="Client Address" />
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<input type="text" value={clientInfo.phone} onInput={(e) => setClientInfo('phone', e.currentTarget.value)} class="bg-muted/20 border border-border/60 p-3 rounded-2xl text-sm outline-none focus:bg-background focus:ring-4 focus:ring-primary/10 focus:border-primary transition-all font-medium" placeholder="Phone" />
|
||||
<input type="text" value={clientInfo.fax} onInput={(e) => setClientInfo('fax', e.currentTarget.value)} class="bg-muted/20 border border-border/60 p-3 rounded-2xl text-sm outline-none focus:bg-background focus:ring-4 focus:ring-primary/10 focus:border-primary transition-all font-medium" placeholder="Fax" />
|
||||
<div class="flex flex-col sm:flex-row gap-3">
|
||||
<input type="text" value={clientInfo.phone} onInput={(e) => setClientInfo('phone', e.currentTarget.value)} class="bg-muted/20 border border-border/60 p-3 rounded-2xl text-sm outline-none focus:bg-background focus:ring-4 focus:ring-primary/10 focus:border-primary transition-all font-medium flex-1 min-w-0" placeholder="Phone" />
|
||||
<input type="text" value={clientInfo.fax} onInput={(e) => setClientInfo('fax', e.currentTarget.value)} class="bg-muted/20 border border-border/60 p-3 rounded-2xl text-sm outline-none focus:bg-background focus:ring-4 focus:ring-primary/10 focus:border-primary transition-all font-medium flex-1 min-w-0" placeholder="Fax" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -30,9 +30,9 @@ export const ProjectDetailsForm: Component = () => {
|
||||
<div class="space-y-6">
|
||||
<h4 class="text-[10px] font-black text-muted-foreground uppercase tracking-[0.2em] px-1 blur-[0.2px]">Project Information</h4>
|
||||
<div class="grid gap-3">
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<input type="text" value={jobInfo.number} onInput={(e) => setJobInfo('number', e.currentTarget.value)} class="bg-muted/20 border border-border/60 p-3 rounded-2xl text-sm outline-none focus:bg-background focus:ring-4 focus:ring-primary/10 focus:border-primary transition-all font-medium" placeholder="Job Number" />
|
||||
<input type="text" value={jobInfo.name} onInput={(e) => setJobInfo('name', e.currentTarget.value)} class="bg-muted/20 border border-border/60 p-3 rounded-2xl text-sm outline-none focus:bg-background focus:ring-4 focus:ring-primary/10 focus:border-primary transition-all font-medium" placeholder="Job Name" />
|
||||
<div class="flex flex-col sm:flex-row gap-3">
|
||||
<input type="text" value={jobInfo.number} onInput={(e) => setJobInfo('number', e.currentTarget.value)} class="bg-muted/20 border border-border/60 p-3 rounded-2xl text-sm outline-none focus:bg-background focus:ring-4 focus:ring-primary/10 focus:border-primary transition-all font-medium flex-1 min-w-0" placeholder="Job Number" />
|
||||
<input type="text" value={jobInfo.name} onInput={(e) => setJobInfo('name', e.currentTarget.value)} class="bg-muted/20 border border-border/60 p-3 rounded-2xl text-sm outline-none focus:bg-background focus:ring-4 focus:ring-primary/10 focus:border-primary transition-all font-medium flex-1 min-w-0" placeholder="Job Name" />
|
||||
</div>
|
||||
<input type="text" value={jobInfo.workOrder} onInput={(e) => setJobInfo('workOrder', e.currentTarget.value)} class="bg-muted/20 border border-border/60 p-3 rounded-2xl text-sm outline-none focus:bg-background focus:ring-4 focus:ring-primary/10 focus:border-primary transition-all font-medium" placeholder="Work Order Number" />
|
||||
<input type="text" value={jobInfo.address} onInput={(e) => setJobInfo('address', e.currentTarget.value)} class="bg-muted/20 border border-border/60 p-3 rounded-2xl text-sm outline-none focus:bg-background focus:ring-4 focus:ring-primary/10 focus:border-primary transition-all font-medium" placeholder="Job Site Address" />
|
||||
|
||||
@@ -43,20 +43,21 @@ export const UnassignedItemsSection: Component<UnassignedItemsSectionProps> = (p
|
||||
{props.unassignedItems.length} Items Pending
|
||||
</span>
|
||||
</div>
|
||||
{/* Sticky Column Headers for Unassigned */}
|
||||
<div class="sticky top-header z-10 bg-background/95 backdrop-blur-sm border-b border-border py-3 mb-4 hidden md:flex items-center gap-4 text-[10px] font-black text-muted-foreground/60 uppercase tracking-[0.2em] px-3">
|
||||
<div class="w-4"></div>
|
||||
<div class="flex-1">Description</div>
|
||||
<div class="w-20 text-right">Qty</div>
|
||||
<div class="w-24 text-right">Unit Price</div>
|
||||
<div class="w-24 text-right">Subtotal</div>
|
||||
<Show when={props.isSidebarCollapsed() || !props.anyDescriptionIsLong()}>
|
||||
<div class="w-32 text-center">Markup</div>
|
||||
<div class="w-32 text-center">Contingency</div>
|
||||
</Show>
|
||||
<div class="w-28 text-right px-2">Total</div>
|
||||
<div class="w-16"></div>
|
||||
</div>
|
||||
<div class="relative pb-4">
|
||||
{/* Sticky Column Headers for Unassigned */}
|
||||
<div class="sticky top-header z-10 bg-background/95 backdrop-blur-sm border-b border-border py-3 mb-4 hidden md:flex items-center gap-4 text-[10px] font-black text-muted-foreground/60 uppercase tracking-[0.2em] px-3">
|
||||
<div class="w-4 shrink-0"></div>
|
||||
<div class="flex-1 min-w-[200px]">Description</div>
|
||||
<div class="w-20 shrink-0 text-right">Qty</div>
|
||||
<div class="w-24 shrink-0 text-right">Unit Price</div>
|
||||
<div class="w-24 shrink-0 text-right">Subtotal</div>
|
||||
<Show when={props.isSidebarCollapsed() || !props.anyDescriptionIsLong()}>
|
||||
<div class="w-32 shrink-0 text-center">Markup</div>
|
||||
<div class="w-32 shrink-0 text-center">Contingency</div>
|
||||
</Show>
|
||||
<div class="w-28 shrink-0 text-right">Total</div>
|
||||
<div class="w-16 shrink-0"></div>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-2">
|
||||
<For each={props.unassignedItems}>
|
||||
@@ -79,6 +80,7 @@ export const UnassignedItemsSection: Component<UnassignedItemsSectionProps> = (p
|
||||
</For>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { Component } from 'solid-js';
|
||||
import { createSignal, createEffect } from 'solid-js';
|
||||
import { Trash2, GripVertical } from 'lucide-solid';
|
||||
import NumericInput from '../ui/NumericInput';
|
||||
import type { TemplateItem } from '../../types';
|
||||
|
||||
interface TemplateItemRowProps {
|
||||
@@ -56,10 +57,9 @@ const TemplateItemRow: Component<TemplateItemRowProps> = (props) => {
|
||||
</div>
|
||||
|
||||
<div class="w-24 shrink-0 mt-1">
|
||||
<input
|
||||
type="number"
|
||||
<NumericInput
|
||||
value={props.item.quantity}
|
||||
onInput={(e) => props.onUpdate(props.item.id, { quantity: parseFloat(e.currentTarget.value) || 0 })}
|
||||
onUpdate={(val) => props.onUpdate(props.item.id, { quantity: val })}
|
||||
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="Qty"
|
||||
/>
|
||||
@@ -68,11 +68,10 @@ const TemplateItemRow: Component<TemplateItemRowProps> = (props) => {
|
||||
<div class="w-32 shrink-0 mt-1">
|
||||
<div class="relative">
|
||||
<span class="absolute left-2.5 top-1/2 -translate-y-1/2 text-muted-foreground/50 text-[10px]">$</span>
|
||||
<input
|
||||
type="number"
|
||||
<NumericInput
|
||||
step="0.1"
|
||||
value={props.item.unitPrice}
|
||||
onInput={(e) => props.onUpdate(props.item.id, { unitPrice: parseFloat(e.currentTarget.value) || 0 })}
|
||||
onUpdate={(val) => props.onUpdate(props.item.id, { unitPrice: val })}
|
||||
class="w-full bg-muted/30 border border-border/60 rounded-xl pl-5 pr-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="Price"
|
||||
/>
|
||||
@@ -80,10 +79,9 @@ const TemplateItemRow: Component<TemplateItemRowProps> = (props) => {
|
||||
</div>
|
||||
|
||||
<div class="w-28 shrink-0 flex items-center gap-1.5 mt-1">
|
||||
<input
|
||||
type="number"
|
||||
<NumericInput
|
||||
value={props.item.markup}
|
||||
onInput={(e) => props.onUpdate(props.item.id, { markup: parseFloat(e.currentTarget.value) || 0 })}
|
||||
onUpdate={(val) => props.onUpdate(props.item.id, { markup: val })}
|
||||
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"
|
||||
/>
|
||||
@@ -96,10 +94,9 @@ const TemplateItemRow: Component<TemplateItemRowProps> = (props) => {
|
||||
</div>
|
||||
|
||||
<div class="w-28 shrink-0 flex items-center gap-1.5 mt-1">
|
||||
<input
|
||||
type="number"
|
||||
<NumericInput
|
||||
value={props.item.contingency}
|
||||
onInput={(e) => props.onUpdate(props.item.id, { contingency: parseFloat(e.currentTarget.value) || 0 })}
|
||||
onUpdate={(val) => props.onUpdate(props.item.id, { contingency: val })}
|
||||
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"
|
||||
/>
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
import type { Component } from 'solid-js';
|
||||
import { createSignal, Show, For, createEffect } from 'solid-js';
|
||||
import { Portal } from 'solid-js/web';
|
||||
import { X, FileBox, CheckCircle2 } from 'lucide-solid';
|
||||
import { pb, COLLECTIONS } from '../../utils/db';
|
||||
|
||||
interface TemplateLoadModalProps {
|
||||
show: boolean;
|
||||
onClose: () => void;
|
||||
onLoad: (template: any) => void;
|
||||
}
|
||||
|
||||
const TemplateLoadModal: Component<TemplateLoadModalProps> = (props) => {
|
||||
const [templates, setTemplates] = createSignal<any[]>([]);
|
||||
const [isLoading, setIsLoading] = createSignal(false);
|
||||
|
||||
createEffect(() => {
|
||||
if (props.show) {
|
||||
loadTemplates();
|
||||
}
|
||||
});
|
||||
|
||||
const loadTemplates = async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const records = await pb.collection(COLLECTIONS.TEMPLATES).getFullList({
|
||||
sort: '-created'
|
||||
});
|
||||
setTemplates(records);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
alert('Failed to load templates.');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
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">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="p-2 bg-primary/10 text-primary rounded-xl">
|
||||
<FileBox class="w-5 h-5" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-xl font-black text-foreground">Load Template</h3>
|
||||
<p class="text-[10px] uppercase font-bold text-muted-foreground tracking-wider mt-0.5">Select a template to edit</p>
|
||||
</div>
|
||||
</div>
|
||||
<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 flex flex-col p-6 gap-6 overflow-hidden">
|
||||
{/* Templates List */}
|
||||
<div class="flex-1 flex flex-col min-h-0 space-y-3">
|
||||
<label class="text-xs font-black text-muted-foreground uppercase tracking-wider">Saved Templates</label>
|
||||
<div class="flex-1 overflow-y-auto space-y-3 bg-muted/10 border border-border/50 rounded-2xl p-4">
|
||||
<Show
|
||||
when={!isLoading()}
|
||||
fallback={<div class="py-12 text-center text-muted-foreground text-sm font-medium">Loading templates...</div>}
|
||||
>
|
||||
<For each={templates()}>
|
||||
{(record) => (
|
||||
<div class="group flex items-center justify-between p-4 rounded-xl border border-border/60 bg-card hover:border-primary/50 hover:shadow-premium transition-all">
|
||||
<div>
|
||||
<h4 class="font-bold text-foreground">{record.name}</h4>
|
||||
<p class="text-[10px] text-muted-foreground font-medium uppercase tracking-wider mt-1">
|
||||
{record.items?.length || 0} Items • {new Date(record.created).toLocaleDateString()}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => {
|
||||
props.onLoad(record);
|
||||
props.onClose();
|
||||
}}
|
||||
class="flex items-center gap-2 px-4 py-2 bg-primary/10 text-primary border border-primary/20 rounded-lg text-xs font-bold shadow-sm hover:bg-primary hover:text-primary-foreground hover:border-primary transition-all"
|
||||
>
|
||||
<CheckCircle2 class="w-4 h-4" /> Load
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
<Show when={templates().length === 0}>
|
||||
<div class="py-12 flex flex-col items-center justify-center text-center text-muted-foreground/60 border-2 border-dashed border-border/60 rounded-xl bg-background/50 h-full">
|
||||
<FileBox class="w-8 h-8 mb-3 opacity-50" />
|
||||
<p class="text-sm font-medium">No saved templates found.</p>
|
||||
</div>
|
||||
</Show>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Portal>
|
||||
</Show>
|
||||
);
|
||||
};
|
||||
|
||||
export default TemplateLoadModal;
|
||||
@@ -0,0 +1,83 @@
|
||||
import type { Component, JSX } from 'solid-js';
|
||||
import { createSignal, createEffect } from 'solid-js';
|
||||
|
||||
interface NumericInputProps extends Omit<JSX.InputHTMLAttributes<HTMLInputElement>, 'onInput' | 'value'> {
|
||||
value: number;
|
||||
onUpdate: (val: number) => void;
|
||||
dataFieldName?: string;
|
||||
class?: string;
|
||||
}
|
||||
|
||||
const NumericInput: Component<NumericInputProps> = (props) => {
|
||||
const [isFocused, setIsFocused] = createSignal(false);
|
||||
const [localValue, setLocalValue] = createSignal(props.value.toString());
|
||||
|
||||
createEffect(() => {
|
||||
if (!isFocused()) {
|
||||
setLocalValue(props.value.toString());
|
||||
}
|
||||
});
|
||||
|
||||
const handleInput = (e: InputEvent & { currentTarget: HTMLInputElement }) => {
|
||||
let val = e.currentTarget.value;
|
||||
|
||||
// Allow only numeric characters, one dot, and one leading minus sign
|
||||
// This regex allows: "", "-", ".", "-.", and valid numbers
|
||||
if (val !== '' && !/^-?(\d*\.?\d*)?$/.test(val)) {
|
||||
// If invalid, revert to previous local value (simple way to block invalid keys)
|
||||
e.currentTarget.value = localValue();
|
||||
return;
|
||||
}
|
||||
|
||||
setLocalValue(val);
|
||||
|
||||
// Only update the store if it's a valid numeric value for calculations
|
||||
if (val === '' || val === '-' || val === '.' || val === '-.') {
|
||||
props.onUpdate(0);
|
||||
} else {
|
||||
const parsed = parseFloat(val);
|
||||
if (!isNaN(parsed)) {
|
||||
props.onUpdate(parsed);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const callHandler = (handler: any, event: any) => {
|
||||
if (typeof handler === 'function') {
|
||||
handler(event);
|
||||
} else if (Array.isArray(handler)) {
|
||||
handler[0](handler[1], event);
|
||||
}
|
||||
};
|
||||
|
||||
const handleFocus = (e: FocusEvent) => {
|
||||
setIsFocused(true);
|
||||
// Select all text on focus to make it easier to replace values
|
||||
(e.currentTarget as HTMLInputElement).select();
|
||||
callHandler(props.onFocus, e);
|
||||
};
|
||||
|
||||
const handleBlur = (e: FocusEvent) => {
|
||||
setIsFocused(false);
|
||||
// On blur, normalize the local value to the props value
|
||||
// But if the user left it empty or as a partial, props.value will be 0
|
||||
setLocalValue(props.value.toString());
|
||||
callHandler(props.onBlur, e);
|
||||
};
|
||||
|
||||
return (
|
||||
<input
|
||||
{...props}
|
||||
type="text"
|
||||
inputmode="decimal"
|
||||
value={localValue()}
|
||||
onInput={handleInput}
|
||||
onFocus={handleFocus}
|
||||
onBlur={handleBlur}
|
||||
class={props.class}
|
||||
data-field-name={props.dataFieldName}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default NumericInput;
|
||||
@@ -35,7 +35,8 @@ const [generalPreNotes, setGeneralPreNotes] = createSignal('');
|
||||
|
||||
// Template Creator State
|
||||
const [activeTemplateName, setActiveTemplateName] = createSignal('');
|
||||
const [activeTemplateItems, setActiveTemplateItems] = createSignal<TemplateItem[]>([]);
|
||||
const [activeTemplateItems, setActiveTemplateItems] = createStore<TemplateItem[]>([]);
|
||||
const [activeTemplateId, setActiveTemplateId] = createSignal<string | null>(null);
|
||||
|
||||
export const appStore = {
|
||||
// Raw CSV Extraction
|
||||
@@ -60,6 +61,8 @@ export const appStore = {
|
||||
generalPreNotes, setGeneralPreNotes,
|
||||
|
||||
// Templates
|
||||
templateId: activeTemplateId,
|
||||
setTemplateId: setActiveTemplateId,
|
||||
templateName: activeTemplateName,
|
||||
setTemplateName: setActiveTemplateName,
|
||||
templateItems: activeTemplateItems,
|
||||
|
||||
@@ -1,17 +1,23 @@
|
||||
import type { Component } from 'solid-js';
|
||||
import { Save } from 'lucide-solid';
|
||||
import { FolderOpen, Save } from 'lucide-solid';
|
||||
import type { TemplateItem } from '../types';
|
||||
import TemplateItemList from '../components/template/TemplateItemList';
|
||||
import TemplateLoadModal from '../components/template/TemplateLoadModal';
|
||||
import { pb, COLLECTIONS } from '../utils/db';
|
||||
import { createSignal } from 'solid-js';
|
||||
|
||||
import { appStore } from '../store/appStore';
|
||||
|
||||
const TemplateCreator: Component = () => {
|
||||
const templateId = appStore.templateId;
|
||||
const setTemplateId = appStore.setTemplateId;
|
||||
const templateName = appStore.templateName;
|
||||
const setTemplateName = appStore.setTemplateName;
|
||||
const items = appStore.templateItems;
|
||||
const setItems = appStore.setTemplateItems;
|
||||
|
||||
const [showLoadModal, setShowLoadModal] = createSignal(false);
|
||||
|
||||
const handleAddItem = () => {
|
||||
const newItem: TemplateItem = {
|
||||
id: crypto.randomUUID(),
|
||||
@@ -23,34 +29,46 @@ const TemplateCreator: Component = () => {
|
||||
contingency: 10, // default
|
||||
contingencyType: 'percent'
|
||||
};
|
||||
setItems([...items(), newItem]);
|
||||
setItems([...items, newItem]);
|
||||
};
|
||||
|
||||
const handleUpdateItem = (id: string, updates: Partial<TemplateItem>) => {
|
||||
setItems(items().map(item => item.id === id ? { ...item, ...updates } : item));
|
||||
setItems(item => item.id === id, updates);
|
||||
};
|
||||
|
||||
const handleDeleteItem = (id: string) => {
|
||||
setItems(items().filter(item => item.id !== id));
|
||||
setItems(items.filter(item => item.id !== id));
|
||||
};
|
||||
|
||||
const handleLoadTemplate = (record: any) => {
|
||||
setTemplateId(record.id);
|
||||
setTemplateName(record.name);
|
||||
setItems(JSON.parse(JSON.stringify(record.items || [])));
|
||||
};
|
||||
|
||||
const handleNewTemplate = () => {
|
||||
setTemplateId(null);
|
||||
setTemplateName('');
|
||||
setItems([]);
|
||||
};
|
||||
|
||||
const handleSaveTemplate = async () => {
|
||||
if (!templateName() || items().length === 0) return;
|
||||
if (!templateName() || items.length === 0) return;
|
||||
|
||||
try {
|
||||
const record = {
|
||||
name: templateName(),
|
||||
items: items() // PocketBase will store this as JSON
|
||||
items: JSON.parse(JSON.stringify(items)) // Ensure plain objects for PocketBase
|
||||
};
|
||||
|
||||
await pb.collection(COLLECTIONS.TEMPLATES).create(record);
|
||||
alert(`Template "${record.name}" saved successfully!`);
|
||||
|
||||
// Reset form
|
||||
setTemplateName('');
|
||||
setItems([]);
|
||||
|
||||
// Refresh the list if we had one
|
||||
if (templateId()) {
|
||||
await pb.collection(COLLECTIONS.TEMPLATES).update(templateId()!, record);
|
||||
alert(`Template "${record.name}" updated successfully!`);
|
||||
} else {
|
||||
const newRecord = await pb.collection(COLLECTIONS.TEMPLATES).create(record);
|
||||
setTemplateId(newRecord.id);
|
||||
alert(`Template "${record.name}" saved successfully!`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error saving template:', error);
|
||||
alert('Failed to save template. Make sure the collection and fields exist.');
|
||||
@@ -66,14 +84,29 @@ const TemplateCreator: Component = () => {
|
||||
<p class="text-gray-500 font-medium">Create templates for your estimate sub-scopes.</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={handleSaveTemplate}
|
||||
disabled={!templateName() || items().length === 0}
|
||||
class="flex items-center gap-2 px-6 py-2.5 text-sm font-bold text-white bg-blue-600 rounded-xl hover:bg-blue-700 transition-all shadow-premium hover:shadow-premium-hover disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<Save class="w-4 h-4" />
|
||||
Save Template
|
||||
</button>
|
||||
<div class="flex items-center gap-3">
|
||||
<button
|
||||
onClick={handleNewTemplate}
|
||||
class="flex items-center gap-2 px-4 py-2.5 text-sm font-bold text-gray-700 bg-white border border-gray-200 rounded-xl hover:bg-gray-50 transition-all shadow-sm"
|
||||
>
|
||||
New
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowLoadModal(true)}
|
||||
class="flex items-center gap-2 px-4 py-2.5 text-sm font-bold text-blue-600 bg-blue-50 border border-blue-100 rounded-xl hover:bg-blue-100 transition-all shadow-sm"
|
||||
>
|
||||
<FolderOpen class="w-4 h-4" />
|
||||
Load
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSaveTemplate}
|
||||
disabled={!templateName() || items.length === 0}
|
||||
class="flex items-center gap-2 px-6 py-2.5 text-sm font-bold text-white bg-blue-600 rounded-xl hover:bg-blue-700 transition-all shadow-premium hover:shadow-premium-hover disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<Save class="w-4 h-4" />
|
||||
{templateId() ? 'Update Template' : 'Save Template'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-8">
|
||||
@@ -90,12 +123,18 @@ const TemplateCreator: Component = () => {
|
||||
</div>
|
||||
|
||||
<TemplateItemList
|
||||
items={items()}
|
||||
items={items}
|
||||
onAddItem={handleAddItem}
|
||||
onUpdateItem={handleUpdateItem}
|
||||
onDeleteItem={handleDeleteItem}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<TemplateLoadModal
|
||||
show={showLoadModal()}
|
||||
onClose={() => setShowLoadModal(false)}
|
||||
onLoad={handleLoadTemplate}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user