Files
EstiMaker/src/components/ScopeCard.tsx
T
tcardoza a3ac0feddf
CI / build (push) Has been skipped
CI / deploy (push) Successful in 50s
Added prevailing wage calculator and custom math modifiers
2026-03-20 15:11:32 -05:00

552 lines
32 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<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;
onOpenApplyTemplate: (scopeId: 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 [activeTemplateType, setActiveTemplateType] = createSignal<ScopeTextTemplateType>('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 (
<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-[10px] text-muted-foreground font-bold uppercase tracking-wider mt-1 h-6">
<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>
<span></span>
<SupplierDropdown
value={props.scope.supplier || ''}
options={appStore.suppliers()}
onSelect={(val) => props.onUpdateScope(props.scope.id, { supplier: val })}
/>
</div>
</div>
</div>
<div class="flex items-center gap-2">
<div class="flex items-center gap-1.5">
<button
onClick={() => props.onAddSubScope(props.scope.id)}
class="flex items-center gap-2 px-3 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="h-3.5 w-3.5" /> Sub-scope
</button>
<button
onClick={() => props.onOpenApplyTemplate(props.scope.id)}
class="flex items-center gap-2 px-3 py-2 bg-indigo-50 text-indigo-600 rounded-xl text-sm font-bold hover:bg-indigo-100 transition-colors border border-indigo-100"
>
<FileBox class="h-3.5 w-3.5" /> Template
</button>
</div>
<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>
<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"
value={bulkMarkup()}
onUpdate={(val) => setBulkMarkup(val)}
/>
<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>
<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"
value={bulkContingency()}
onUpdate={(val) => setBulkContingency(val)}
/>
<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>
<ScopeScaleWrapper>
{/* Sticky Column Headers */}
<div class="scope-sticky-header py-2 mb-2 hidden md:flex items-center gap-4 text-[10px] font-bold text-muted-foreground uppercase tracking-wider px-3">
<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>
{/* Scope-level items */}
<div
onClick={(e) => { if (e.target === e.currentTarget) props.onClearSelection(); }}
class="px-3 pb-3 space-y-2"
>
<Show when={scopeItems().length > 0}>
<div class="grid gap-2">
<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}
activeSupplier={props.scope.supplier}
/>
</LazyItem>
)}
</For>
</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>
</div>
{/* Sub-scopes — rendered inside the same scale wrapper so they zoom together */}
<div class="px-3 pb-3 space-y-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}
scopeSupplier={props.scope.supplier}
/>
)}
</For>
</div>
</ScopeScaleWrapper>
<div class="p-6 pt-4 space-y-4">
{/* Scope Extras & Totals */}
<div class="mt-4 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',
managementFee: (props.scope.managementFeeQuantity || 0) * (props.scope.managementFeeRate || 0)
})}
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">
{props.scope.useManagementLogic === 'percent' ? (
<NumericInput
value={props.scope.managementFee}
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"
/>
) : (
<div class="flex items-center gap-3">
<div class="flex flex-col items-end gap-0.5 text-[9px] font-black tracking-[0.3em] text-muted-foreground uppercase">
<span>Qty</span>
<NumericInput
value={props.scope.managementFeeQuantity}
onUpdate={(val) => {
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"
/>
</div>
<span class="text-xs font-black text-muted-foreground/70">×</span>
<div class="flex flex-col items-end gap-0.5 text-[9px] font-black tracking-[0.3em] text-muted-foreground uppercase">
<span>Rate</span>
<div class="flex items-center gap-1">
<span class="text-[10px] font-black text-muted-foreground">$</span>
<NumericInput
value={props.scope.managementFeeRate}
onUpdate={(val) => {
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"
/>
</div>
</div>
</div>
)}
<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',
deliveryFee: (props.scope.deliveryFeeQuantity || 0) * (props.scope.deliveryFeeRate || 0)
})}
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">
{props.scope.useDeliveryLogic === 'percent' ? (
<NumericInput
value={props.scope.deliveryFee}
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"
/>
) : (
<div class="flex items-center gap-3">
<div class="flex flex-col items-end gap-0.5 text-[9px] font-black tracking-[0.3em] text-muted-foreground uppercase">
<span>Qty</span>
<NumericInput
value={props.scope.deliveryFeeQuantity}
onUpdate={(val) => {
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"
/>
</div>
<span class="text-xs font-black text-muted-foreground/70">×</span>
<div class="flex flex-col items-end gap-0.5 text-[9px] font-black tracking-[0.3em] text-muted-foreground uppercase">
<span>Rate</span>
<div class="flex items-center gap-1">
<span class="text-[10px] font-black text-muted-foreground">$</span>
<NumericInput
value={props.scope.deliveryFeeRate}
onUpdate={(val) => {
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"
/>
</div>
</div>
</div>
)}
<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('#')}
headerActions={
<button
onClick={() => openTemplateModal('estimatorNotes')}
class="px-3 py-1 rounded-lg text-[10px] font-bold transition-all border text-muted-foreground hover:bg-muted border-border/40"
>
Template
</button>
}
/>
<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('#')}
headerActions={
<button
onClick={() => openTemplateModal('bidDescription')}
class="px-3 py-1 rounded-lg text-[10px] font-bold transition-all border text-muted-foreground hover:bg-muted border-border/40"
>
Template
</button>
}
/>
</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>
<Show when={totals().modifiersTotal !== 0}>
<div class="flex justify-end gap-8 text-indigo-600">
<span>Scope Modifiers:</span>
<span class="w-28 text-right">${formatNumber(totals().modifiersTotal)}</span>
</div>
</Show>
<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>
<ScopeTextTemplateModal
show={isTemplateModalOpen()}
templateType={activeTemplateType()}
currentValue={templateModalCurrentValue()}
onApply={applyTemplateValue}
onClose={() => setIsTemplateModalOpen(false)}
/>
</div>
);
};
export default ScopeCard;