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, ScopeTextTemplateType } 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'; import { appStore } from '../store/appStore'; import { SupplierDropdown } from './ui/SupplierDropdown'; import ScopeTextTemplateModal from './ScopeTextTemplateModal'; import { getItemsPricingTotals } from '../utils/pricing'; 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 [activeTemplateType, setActiveTemplateType] = createSignal('estimatorNotes'); const [isTemplateModalOpen, setIsTemplateModalOpen] = createSignal(false); const scopeItems = createMemo(() => props.items.filter(i => !i.subScopeId)); const totals = createMemo(() => { const scopeLevelTotals = getItemsPricingTotals(scopeItems(), []); let subtotal = scopeLevelTotals.subtotal; let markup = scopeLevelTotals.markup; let contingency = scopeLevelTotals.contingency; let totalQuantity = scopeLevelTotals.quantity; let modifiersTotal = 0; props.subScopes.forEach(ss => { const ssItems = props.items.filter(i => i.subScopeId === ss.id); const ssTotals = getItemsPricingTotals(ssItems, ss.modifiers || []); subtotal += ssTotals.subtotal; markup += ssTotals.markup; contingency += ssTotals.contingency; totalQuantity += ssTotals.quantity; (ss.modifiers || []).forEach(m => { if (!m.includeInTotal) return; modifiersTotal += ModifierRegistry.calculate(m, ssTotals.total, ssItems); }); }); // Add Management and Delivery after all scope and sub-scope item totals are accounted for. 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, 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); }; const openTemplateModal = (type: ScopeTextTemplateType) => { setActiveTemplateType(type); setIsTemplateModalOpen(true); }; const templateModalCurrentValue = createMemo(() => activeTemplateType() === 'estimatorNotes' ? props.scope.estimatorNotes || '' : props.scope.bidDescription || '' ); const applyTemplateValue = (value: string) => { if (activeTemplateType() === 'estimatorNotes') { props.onUpdateScope(props.scope.id, { estimatorNotes: value }); return; } props.onUpdateScope(props.scope.id, { bidDescription: value }); }; 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 props.onUpdateScope(props.scope.id, { supplier: val })} />
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} activeSupplier={props.scope.supplier} /> )}
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} scopeSupplier={props.scope.supplier} /> )}
{/* 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('#')} headerActions={ } /> props.onUpdateScope(props.scope.id, { bidDescription: val })} icon={FileText} highlight={props.scope.bidDescription?.includes('#')} headerActions={ } />
{/* 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)}
setIsTemplateModalOpen(false)} />
); }; export default ScopeCard;