396 lines
22 KiB
TypeScript
396 lines
22 KiB
TypeScript
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';
|
|
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;
|
|
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 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>
|
|
<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 class="flex items-center ml-auto">
|
|
<button
|
|
onClick={() => props.onOpenApplyTemplate(props.scope.id)}
|
|
class="flex items-center gap-2 px-3 py-1.5 bg-background text-primary border border-primary/30 rounded-lg text-xs font-bold shadow-sm hover:bg-primary hover:text-primary-foreground hover:border-primary transition-all"
|
|
>
|
|
<FileBox class="w-3.5 h-3.5" /> Apply Template
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</Show>
|
|
|
|
<div
|
|
onClick={(e) => { if (e.target === e.currentTarget) props.onClearSelection(); }}
|
|
class="relative overflow-x-auto min-w-0"
|
|
>
|
|
<div class="p-6 space-y-4 min-w-full md:min-w-max flex flex-col items-stretch">
|
|
<Show when={scopeItems().length > 0}>
|
|
<div class="relative">
|
|
{/* Column Headers - Sticky, Square Top */}
|
|
<div class="sticky top-0 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)}>
|
|
{(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>
|
|
</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>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="p-6 pt-0 space-y-4 relative">
|
|
{/* 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">
|
|
<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"
|
|
/>
|
|
<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">
|
|
<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"
|
|
/>
|
|
<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;
|