import { For, Show, createMemo, createSignal, type Component } 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'; import NoteEditor from './NoteEditor'; import LazyItem from './LazyItem'; import ScopeScaleWrapper from './ui/ScopeScaleWrapper'; import { ModifierRegistry } from '../utils/modifier-registry'; interface ScopeCardProps { scope: Scope; subScopes: SubScope[]; items: EstimateItem[]; onUpdateScope: (id: string, updates: Partial) => void; onDeleteScope: (id: string) => void; onUpdateItem: (id: string, updates: Partial) => void; onDeleteItem: (id: string) => void; onAddSubScope: (scopeId: string) => void; onUpdateSubScope: (id: string, updates: Partial) => 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; onOpenApplyTemplate: (scopeId: string) => void; isSidebarCollapsed: () => boolean; selectedIds: string[]; onToggleSelection: (id: string, isMulti: boolean, isShift: boolean) => void; onClearSelection: () => void; hideColumns?: boolean; } const ScopeCard: Component = (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; let modifiersTotal = 0; props.subScopes.forEach(ss => { const ssItems = props.items.filter(i => i.subScopeId === ss.id); const ssTotalWithMarkup = ssItems.reduce((sum, item) => { const base = item.quantity * item.unitPrice; const markup = item.markupType === 'percent' ? base * (item.markup / 100) : item.markup; const contingency = item.contingencyType === 'percent' ? base * (item.contingency / 100) : item.contingency; return sum + base + markup + contingency; }, 0); (ss.modifiers || []).forEach(m => { if (!m.includeInTotal) return; modifiersTotal += ModifierRegistry.calculate(m, ssTotalWithMarkup, ssItems); }); }); return { subtotal, markup, contingency, mgmtTotal, deliveryTotal, modifiersTotal, totalQuantity, total: subtotal + markup + contingency + mgmtTotal + deliveryTotal + modifiersTotal }; }); 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 (
{ 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 */}
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" />
{props.items.length} Items {formatQuantity(totals().totalQuantity)} Qty ${formatNumber(totals().total)} Total
Bulk Adjust Scope:
setBulkMarkup(val)} />
setBulkContingency(val)} />
{/* Sticky Column Headers */} {/* Scope-level items */}
{ if (e.target === e.currentTarget) props.onClearSelection(); }} class="px-3 pb-3 space-y-2" > 0}>
!i.subScopeId)}> {(item) => ( 0} onToggleSelection={props.onToggleSelection} hideColumns={props.hideColumns} /> )}
Empty scope. Drag items here or add sub-scopes.
{/* Sub-scopes — rendered inside the same scale wrapper so they zoom together */}
{(subScope) => ( 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} /> )}
{/* Scope Extras & Totals */}
{/* Management Line */}
Management Fee
{props.scope.useManagementLogic === 'percent' ? ( 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" /> ) : (
Qty { const qty = val; const rate = props.scope.managementFeeRate || 0; props.onUpdateScope(props.scope.id, { managementFeeQuantity: qty, managementFee: qty * rate }); }} class="w-16 text-right text-sm border-b border-dashed border-border focus:border-primary outline-none bg-transparent" />
×
Rate
$ { const rate = val; const qty = props.scope.managementFeeQuantity || 0; props.onUpdateScope(props.scope.id, { managementFeeRate: rate, managementFee: qty * rate }); }} class="w-20 text-right text-sm border-b border-dashed border-border focus:border-primary outline-none bg-transparent" />
)} ${formatNumber(totals().mgmtTotal)}
{/* Delivery Fee */}
Delivery Fee
{props.scope.useDeliveryLogic === 'percent' ? ( 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" /> ) : (
Qty { const qty = val; const rate = props.scope.deliveryFeeRate || 0; props.onUpdateScope(props.scope.id, { deliveryFeeQuantity: qty, deliveryFee: qty * rate }); }} class="w-16 text-right text-sm border-b border-dashed border-border focus:border-primary outline-none bg-transparent" />
×
Rate
$ { const rate = val; const qty = props.scope.deliveryFeeQuantity || 0; props.onUpdateScope(props.scope.id, { deliveryFeeRate: rate, deliveryFee: qty * rate }); }} class="w-20 text-right text-sm border-b border-dashed border-border focus:border-primary outline-none bg-transparent" />
)} ${formatNumber(totals().deliveryTotal)}
{/* Scope Notes Section */}
props.onUpdateScope(props.scope.id, { estimatorNotes: val })} icon={ClipboardList} highlight={props.scope.estimatorNotes?.includes('#')} /> props.onUpdateScope(props.scope.id, { bidDescription: val })} icon={FileText} highlight={props.scope.bidDescription?.includes('#')} />
{/* Scope Totals Breakdown */}
Scope Subtotal: ${formatNumber(totals().subtotal)}
Scope Total Quantity: {formatQuantity(totals().totalQuantity)}
Scope Markup: ${formatNumber(totals().markup)}
Scope Contingency: ${formatNumber(totals().contingency)}
Scope Modifiers: ${formatNumber(totals().modifiersTotal)}
Scope Grand Total: ${formatNumber(totals().total)}
); }; export default ScopeCard;