202 lines
10 KiB
TypeScript
202 lines
10 KiB
TypeScript
import type { Component } from 'solid-js';
|
|
import { For, createMemo, Show, createSignal } from 'solid-js';
|
|
import { Trash2, ChevronDown, Copy, ListTree } from 'lucide-solid';
|
|
import type { EstimateItem, SubScope, Scope } from '../types';
|
|
import ItemRow from './ItemRow';
|
|
import LazyItem from './LazyItem';
|
|
|
|
interface SubScopeCardProps {
|
|
subScope: SubScope;
|
|
items: EstimateItem[];
|
|
onUpdateSubScope: (id: string, updates: Partial<SubScope>) => void;
|
|
onUpdateItem: (id: string, updates: Partial<EstimateItem>) => void;
|
|
onDeleteItem: (id: string) => void;
|
|
onDeleteSubScope: (id: string) => void;
|
|
onDragStart: (e: DragEvent, id: string) => void;
|
|
onDrop: (e: DragEvent, scopeId: string, subScopeId: string) => 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 SubScopeCard: Component<SubScopeCardProps> = (props) => {
|
|
const [isCollapsed, setIsCollapsed] = createSignal(false);
|
|
const [isOver, setIsOver] = createSignal(false);
|
|
const [showCopyMenu, setShowCopyMenu] = createSignal(false);
|
|
|
|
const totals = createMemo(() => {
|
|
return props.items.reduce((acc, 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 {
|
|
amount: acc.amount + base + markup + contingency,
|
|
quantity: acc.quantity + item.quantity
|
|
};
|
|
}, { amount: 0, quantity: 0 });
|
|
});
|
|
|
|
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.stopPropagation();
|
|
setIsOver(true);
|
|
};
|
|
|
|
const handleDragLeave = () => {
|
|
setIsOver(false);
|
|
};
|
|
|
|
const handleDrop = (e: DragEvent) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
setIsOver(false);
|
|
props.onDrop(e, props.subScope.scopeId, props.subScope.id);
|
|
};
|
|
|
|
return (
|
|
<div
|
|
id={`subscope-${props.subScope.id}`}
|
|
onDragOver={handleDragOver}
|
|
onDragLeave={handleDragLeave}
|
|
onDrop={handleDrop}
|
|
class={`border rounded-xl transition-all duration-200 ml-8
|
|
${isOver()
|
|
? 'bg-primary/5 border-primary ring-4 ring-primary/10 z-10'
|
|
: 'bg-card border-border hover:border-border/50 shadow-sm'
|
|
}`}
|
|
>
|
|
<div class="px-4 py-3 flex items-center justify-between border-b border-border/40">
|
|
<div class="flex items-center gap-3">
|
|
<button
|
|
onClick={() => setIsCollapsed(!isCollapsed())}
|
|
class="p-1 hover:bg-muted rounded transition-colors text-muted-foreground"
|
|
>
|
|
<ChevronDown class={`w-4 h-4 transition-transform duration-200 ${isCollapsed() ? '-rotate-90' : ''}`} />
|
|
</button>
|
|
<div class="p-1.5 bg-muted rounded-lg text-muted-foreground">
|
|
<ListTree class="w-3.5 h-3.5" />
|
|
</div>
|
|
<input
|
|
type="text"
|
|
value={props.subScope.name}
|
|
onInput={(e) => props.onUpdateSubScope(props.subScope.id, { name: e.currentTarget.value })}
|
|
class="bg-transparent border-none outline-none font-black text-foreground text-sm focus:ring-0 w-48"
|
|
/>
|
|
<div class="flex items-center gap-2 text-[10px] font-bold uppercase tracking-wider text-muted-foreground">
|
|
<span>{props.items.length} Items</span>
|
|
<span>•</span>
|
|
<span class="text-muted-foreground/60">{formatQuantity(totals().quantity)} Qty</span>
|
|
<span>•</span>
|
|
<span class="text-primary font-black">${formatNumber(totals().amount)}</span>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="flex items-center gap-1">
|
|
<div class="relative">
|
|
<button
|
|
onClick={() => setShowCopyMenu(!showCopyMenu())}
|
|
class="flex items-center gap-1.5 px-2 py-1 bg-background border border-border rounded-lg text-[10px] font-black uppercase tracking-wider text-muted-foreground hover:text-primary hover:border-primary/30 transition-all"
|
|
title="Copy items to another sub-scope"
|
|
>
|
|
<span class="p-0.5 bg-muted rounded">
|
|
<Copy class="w-2.5 h-2.5" />
|
|
</span>
|
|
Copy To
|
|
</button>
|
|
|
|
<Show when={showCopyMenu()}>
|
|
<div
|
|
class="absolute right-0 mt-1 w-64 bg-popover border border-border rounded-xl shadow-elevated z-[100] py-2 animate-in fade-in zoom-in-95 duration-200"
|
|
>
|
|
<div class="px-4 py-2 border-b border-border/40 mb-1">
|
|
<span class="text-[9px] font-black text-muted-foreground uppercase tracking-[0.2em]">Select Target Sub-scope</span>
|
|
</div>
|
|
<div class="max-h-64 overflow-y-auto">
|
|
<For each={props.allScopes}>
|
|
{(scope) => (
|
|
<div class="px-2">
|
|
<div class="px-2 py-1 text-[9px] font-black text-primary/50 uppercase tracking-widest">{scope.name}</div>
|
|
<For each={props.allSubScopes.filter(ss => ss.scopeId === scope.id && ss.id !== props.subScope.id)}>
|
|
{(targetSS) => (
|
|
<button
|
|
onClick={() => {
|
|
props.onCopySubScopeItems(props.subScope.id, targetSS.id);
|
|
setShowCopyMenu(false);
|
|
}}
|
|
class="w-full text-left px-3 py-2 rounded-lg text-xs font-bold text-muted-foreground hover:bg-accent hover:text-accent-foreground transition-colors flex items-center justify-between group"
|
|
>
|
|
<span>{targetSS.name}</span>
|
|
<ChevronDown class="w-3 h-3 opacity-0 group-hover:opacity-40 -rotate-90" />
|
|
</button>
|
|
)}
|
|
</For>
|
|
</div>
|
|
)}
|
|
</For>
|
|
</div>
|
|
</div>
|
|
</Show>
|
|
</div>
|
|
|
|
<button
|
|
onClick={() => props.onDeleteSubScope(props.subScope.id)}
|
|
class="p-1.5 text-muted-foreground/30 hover:text-destructive hover:bg-destructive/10 rounded-lg transition-colors"
|
|
>
|
|
<Trash2 class="w-3.5 h-3.5" />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<Show when={!isCollapsed()}>
|
|
<div
|
|
onClick={(e) => { if (e.target === e.currentTarget) props.onClearSelection(); }}
|
|
class="p-2 space-y-1.5"
|
|
>
|
|
<For each={props.items}>
|
|
{(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={props.items.length === 0}>
|
|
<div class="text-center py-4 text-[10px] font-bold text-muted-foreground/40 uppercase tracking-widest italic">
|
|
Empty Sub-scope
|
|
</div>
|
|
</Show>
|
|
</div>
|
|
</Show>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default SubScopeCard;
|
|
|