True clean start with src folder

This commit is contained in:
2026-03-10 19:05:37 -05:00
parent 806ebad1c3
commit 5a8b20a827
25 changed files with 3305 additions and 1 deletions
+378
View File
@@ -0,0 +1,378 @@
import type { Component } from 'solid-js';
import { For, createMemo, Show, createSignal } from 'solid-js';
import { Trash2, Plus, Settings2, Percent, DollarSign, ChevronDown, FileText, ClipboardList } from 'lucide-solid';
import type { EstimateItem, Scope, SubScope, PricingType } from '../types';
import ItemRow from './ItemRow';
import SubScopeCard from './SubScopeCard';
import NoteEditor from './NoteEditor';
import LazyItem from './LazyItem';
interface ScopeCardProps {
scope: Scope;
subScopes: SubScope[];
items: EstimateItem[];
onUpdateScope: (id: string, updates: Partial<Scope>) => void;
onDeleteScope: (id: string) => void;
onUpdateItem: (id: string, updates: Partial<EstimateItem>) => void;
onDeleteItem: (id: string) => void;
onAddSubScope: (scopeId: string) => void;
onUpdateSubScope: (id: string, updates: Partial<SubScope>) => void;
onDeleteSubScope: (id: string) => void;
onDragStart: (e: DragEvent, id: string) => void;
onDrop: (e: DragEvent, scopeId: string, subScopeId?: string) => void;
onBulkUpdate: (scopeId: string, subScopeId: string | undefined, updates: { markup?: number, markupType?: PricingType, contingency?: number, contingencyType?: PricingType }) => void;
onDuplicateItem: (id: string) => void;
allScopes: Scope[];
allSubScopes: SubScope[];
onCopySubScopeItems: (sourceSubScopeId: string, targetSubScopeId: string) => void;
isSidebarCollapsed: () => boolean;
selectedIds: string[];
onToggleSelection: (id: string, isMulti: boolean, isShift: boolean) => void;
onClearSelection: () => void;
hideColumns?: boolean;
}
const ScopeCard: Component<ScopeCardProps> = (props) => {
const [showBulkActions, setShowBulkActions] = createSignal(false);
const [isCollapsed, setIsCollapsed] = createSignal(false);
const [bulkMarkup, setBulkMarkup] = createSignal(0);
const [bulkContingency, setBulkContingency] = createSignal(0);
const [isOver, setIsOver] = createSignal(false);
const scopeItems = createMemo(() => props.items.filter(i => !i.subScopeId));
const totals = createMemo(() => {
let subtotal = 0;
let markup = 0;
let contingency = 0;
let totalQuantity = 0;
props.items.forEach(item => {
const base = item.quantity * item.unitPrice;
subtotal += base;
totalQuantity += item.quantity;
markup += item.markupType === 'percent' ? base * (item.markup / 100) : item.markup;
contingency += item.contingencyType === 'percent' ? base * (item.contingency / 100) : item.contingency;
});
// Add Management and Delivery
const mgmtTotal = props.scope.useManagementLogic === 'percent'
? subtotal * (props.scope.managementFee / 100)
: props.scope.managementFee;
const deliveryTotal = props.scope.useDeliveryLogic === 'percent'
? subtotal * (props.scope.deliveryFee / 100)
: props.scope.deliveryFee;
return {
subtotal,
markup,
contingency,
mgmtTotal,
deliveryTotal,
totalQuantity,
total: subtotal + markup + contingency + mgmtTotal + deliveryTotal
};
});
const formatNumber = (num: number) => {
return num.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 });
};
const formatQuantity = (num: number) => {
return num.toLocaleString(undefined, { minimumFractionDigits: 0, maximumFractionDigits: 2 });
};
const handleDragOver = (e: DragEvent) => {
e.preventDefault();
e.dataTransfer!.dropEffect = 'move';
setIsOver(true);
};
const handleDragLeave = () => {
setIsOver(false);
};
return (
<div
onDragOver={handleDragOver}
onDrop={(e) => {
setIsOver(false);
props.onDrop(e, props.scope.id);
}}
onDragLeave={handleDragLeave}
class={`bg-muted/30 border rounded-3xl overflow-visible transition-all ring-1 ring-border/50 shadow-premium
${isOver() ? 'bg-primary/5 ring-4 ring-primary/10 border-primary' : 'border-border'}
`}
>
{/* Scope Header - Rounded Top */}
<div class="p-6 bg-card border-b border-border flex items-center justify-between rounded-t-3xl">
<div class="flex items-center gap-4">
<button
onClick={() => setIsCollapsed(!isCollapsed())}
class="p-2 hover:bg-muted rounded-lg transition-colors text-muted-foreground group"
>
<ChevronDown class={`w-4 h-4 transition-transform duration-200 ${isCollapsed() ? '-rotate-90' : ''}`} />
</button>
<div class="flex flex-col">
<input
type="text"
value={props.scope.name}
onInput={(e) => props.onUpdateScope(props.scope.id, { name: e.currentTarget.value })}
class="text-xl font-black text-foreground bg-transparent border-none outline-none focus:ring-0 w-64"
/>
<div class="flex items-center gap-2 text-xs text-muted-foreground font-bold uppercase tracking-wider">
<span>{props.items.length} Items</span>
<span></span>
<span>{formatQuantity(totals().totalQuantity)} Qty</span>
<span></span>
<span class="text-primary">${formatNumber(totals().total)} Total</span>
</div>
</div>
</div>
<div class="flex items-center gap-2">
<button
onClick={() => props.onAddSubScope(props.scope.id)}
class="flex items-center gap-2 px-4 py-2 bg-primary/10 text-primary rounded-xl text-sm font-bold hover:bg-primary/20 transition-colors border border-primary/20"
>
<Plus class="mr-2 h-3.5 w-3.5" /> Sub-scope
</button>
<div class="h-8 w-px bg-border mx-2"></div>
<button
onClick={() => setShowBulkActions(!showBulkActions())}
class="p-2 text-muted-foreground hover:text-foreground hover:bg-muted rounded-lg transition-all"
>
<Settings2 class="h-4 w-4" />
</button>
<button
onClick={() => props.onDeleteScope(props.scope.id)}
class="p-2 text-muted-foreground/40 hover:text-destructive hover:bg-destructive/10 rounded-lg transition-all"
>
<Trash2 class="h-4 w-4" />
</button>
</div>
</div>
<Show when={!isCollapsed()}>
<Show when={showBulkActions()}>
<div class="bg-primary/5 border-b border-primary/10 p-4 flex flex-wrap gap-4 items-center animate-in slide-in-from-top duration-300">
<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"
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)}
/>
<button
onClick={() => props.onBulkUpdate(props.scope.id, undefined, { markup: bulkMarkup(), markupType: 'percent' })}
class="px-2 py-1 bg-primary text-primary-foreground text-xs rounded-lg font-bold hover:bg-primary/90"
>
Apply
</button>
</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"
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)}
/>
<button
onClick={() => props.onBulkUpdate(props.scope.id, undefined, { contingency: bulkContingency(), contingencyType: 'percent' })}
class="px-2 py-1 bg-primary text-primary-foreground text-xs rounded-lg font-bold hover:bg-primary/90"
>
Apply
</button>
</div>
</div>
</Show>
<div
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>
<div class="grid gap-2">
{/* Scope Level Items */}
<For each={props.items.filter(i => !i.subScopeId)}>
{(item) => (
<LazyItem estimatedHeight={60}>
<ItemRow
item={item}
onUpdate={props.onUpdateItem}
onDelete={props.onDeleteItem}
onDragStart={props.onDragStart}
onDuplicateItem={props.onDuplicateItem}
isSidebarCollapsed={props.isSidebarCollapsed}
isSelected={props.selectedIds.includes(item.id)}
anySelected={props.selectedIds.length > 0}
onToggleSelection={props.onToggleSelection}
hideColumns={props.hideColumns}
/>
</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.
</div>
</Show>
</div>
{/* Sub-scopes */}
<div class="space-y-6 pt-4">
<For each={props.subScopes}>
{(subScope) => (
<SubScopeCard
subScope={subScope}
items={props.items.filter(i => i.subScopeId === subScope.id)}
onUpdateSubScope={props.onUpdateSubScope}
onUpdateItem={props.onUpdateItem}
onDeleteItem={props.onDeleteItem}
onDeleteSubScope={props.onDeleteSubScope}
onDragStart={props.onDragStart}
onDrop={(e, sid, ssid) => props.onDrop(e, sid, ssid)}
onDuplicateItem={props.onDuplicateItem}
allScopes={props.allScopes}
allSubScopes={props.allSubScopes}
onCopySubScopeItems={props.onCopySubScopeItems}
isSidebarCollapsed={props.isSidebarCollapsed}
selectedIds={props.selectedIds}
onToggleSelection={props.onToggleSelection}
onClearSelection={props.onClearSelection}
hideColumns={props.hideColumns}
/>
)}
</For>
</div>
{/* Scope Extras & Totals */}
<div class="mt-8 pt-6 border-t border-border space-y-3">
{/* Management Line */}
<div class="flex items-center justify-between group">
<div class="flex items-center gap-3">
<span class="text-sm font-medium text-muted-foreground">Management Fee</span>
<div class="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
<button
onClick={() => props.onUpdateScope(props.scope.id, { useManagementLogic: 'percent' })}
class={`p-1 rounded-md transition-colors ${props.scope.useManagementLogic === 'percent' ? 'bg-primary/10 text-primary' : 'hover:bg-muted'}`}
>
<Percent class="w-3 h-3" />
</button>
<button
onClick={() => props.onUpdateScope(props.scope.id, { useManagementLogic: 'simple' })}
class={`p-1 rounded-md transition-colors ${props.scope.useManagementLogic === 'simple' ? 'bg-primary/10 text-primary' : 'hover:bg-muted'}`}
>
<DollarSign class="w-3 h-3" />
</button>
</div>
</div>
<div class="flex items-center gap-4">
<input
type="number"
value={props.scope.managementFee}
onInput={(e) => props.onUpdateScope(props.scope.id, { managementFee: parseFloat(e.currentTarget.value) || 0 })}
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>
</div>
</div>
{/* Delivery Fee */}
<div class="flex items-center justify-between group">
<div class="flex items-center gap-3">
<span class="text-sm font-medium text-muted-foreground">Delivery Fee</span>
<div class="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
<button
onClick={() => props.onUpdateScope(props.scope.id, { useDeliveryLogic: 'percent' })}
class={`p-1 rounded-md transition-colors ${props.scope.useDeliveryLogic === 'percent' ? 'bg-primary/10 text-primary' : 'hover:bg-muted'}`}
>
<Percent class="w-3 h-3" />
</button>
<button
onClick={() => props.onUpdateScope(props.scope.id, { useDeliveryLogic: 'simple' })}
class={`p-1 rounded-md transition-colors ${props.scope.useDeliveryLogic === 'simple' ? 'bg-primary/10 text-primary' : 'hover:bg-muted'}`}
>
<DollarSign class="w-3 h-3" />
</button>
</div>
</div>
<div class="flex items-center gap-4">
<input
type="number"
value={props.scope.deliveryFee}
onInput={(e) => props.onUpdateScope(props.scope.id, { deliveryFee: parseFloat(e.currentTarget.value) || 0 })}
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>
</div>
</div>
{/* Scope Notes Section */}
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 pt-8 border-t border-border">
<NoteEditor
label="Estimator's Notes"
value={props.scope.estimatorNotes || ''}
onUpdate={(val) => props.onUpdateScope(props.scope.id, { estimatorNotes: val })}
icon={ClipboardList}
highlight={props.scope.estimatorNotes?.includes('#')}
/>
<NoteEditor
label="Scope Description (Appears on Finale Estimate)"
value={props.scope.bidDescription || ''}
onUpdate={(val) => props.onUpdateScope(props.scope.id, { bidDescription: val })}
icon={FileText}
highlight={props.scope.bidDescription?.includes('#')}
/>
</div>
{/* Scope Totals Breakdown */}
<div class="space-y-1 text-sm pt-4">
<div class="flex justify-end gap-8 text-muted-foreground italic">
<span>Scope Subtotal:</span>
<span class="w-28 text-right">${formatNumber(totals().subtotal)}</span>
</div>
<div class="flex justify-end gap-8 text-muted-foreground italic">
<span>Scope Total Quantity:</span>
<span class="w-28 text-right">{formatQuantity(totals().totalQuantity)}</span>
</div>
<div class="flex justify-end gap-8 text-primary">
<span>Scope Markup:</span>
<span class="w-28 text-right">${formatNumber(totals().markup)}</span>
</div>
<div class="flex justify-end gap-8 text-orange-600">
<span>Scope Contingency:</span>
<span class="w-28 text-right">${formatNumber(totals().contingency)}</span>
</div>
<div class="flex justify-end gap-8 font-black text-foreground text-xl mt-4">
<span>Scope Grand Total:</span>
<span class="w-32 text-right border-t-2 border-foreground pt-1">
${formatNumber(totals().total)}
</span>
</div>
</div>
</div>
</div>
</Show>
</div>
);
};
export default ScopeCard;