"use client"; import { Plus, Timer, Trash2, Zap } from "lucide-react"; import * as React from "react"; import { useState, useRef, useEffect } from "react"; import { motion, AnimatePresence } from "framer-motion"; import Link from "next/link"; import { Button } from "~/components/ui/button"; import { DatePicker } from "~/components/ui/date-picker"; import { Input } from "~/components/ui/input"; import { NumberInput } from "~/components/ui/number-input"; import { cn } from "~/lib/utils"; import { parseLineItem, type ParsedLineItem } from "~/lib/parse-line-item"; import { useLineItemSuggestions, type LineItemSuggestion, } from "~/hooks/use-line-item-suggestions"; interface InvoiceItem { id: string; date: Date; description: string; hours: number; rate: number; amount: number; } interface InvoiceLineItemsProps { items: InvoiceItem[]; onAddItem: () => void; onRemoveItem: (index: number) => void; onUpdateItem: ( index: number, field: string, value: string | number | Date, ) => void; onAddItemWithValues?: (parsed: ParsedLineItem) => void; invoiceId?: string; clientId?: string; defaultRate?: number; className?: string; readOnly?: boolean; } interface LineItemRowProps { item: InvoiceItem; index: number; canRemove: boolean; onRemove: (index: number) => void; onUpdate: ( index: number, field: string, value: string | number | Date, ) => void; suggestions: LineItemSuggestion[]; onSelectSuggestion: (index: number, suggestion: LineItemSuggestion) => void; onDescriptionChange: (index: number, value: string) => void; readOnly?: boolean; } interface DescriptionAutocompleteProps { value: string; onChange: (value: string) => void; onSelect: (suggestion: LineItemSuggestion) => void; suggestions: LineItemSuggestion[]; placeholder?: string; className?: string; disabled?: boolean; } function DescriptionAutocomplete({ value, onChange, onSelect, suggestions, placeholder, className, disabled, }: DescriptionAutocompleteProps) { const [open, setOpen] = useState(false); const [activeIndex, setActiveIndex] = useState(-1); const containerRef = useRef(null); const showDropdown = open && suggestions.length > 0; useEffect(() => { function handleClickOutside(e: MouseEvent) { if (containerRef.current && !containerRef.current.contains(e.target as Node)) { setOpen(false); } } document.addEventListener("mousedown", handleClickOutside); return () => document.removeEventListener("mousedown", handleClickOutside); }, []); function handleKeyDown(e: React.KeyboardEvent) { if (!showDropdown) return; if (e.key === "ArrowDown") { e.preventDefault(); setActiveIndex((i) => Math.min(i + 1, suggestions.length - 1)); } else if (e.key === "ArrowUp") { e.preventDefault(); setActiveIndex((i) => Math.max(i - 1, -1)); } else if (e.key === "Enter" && activeIndex >= 0) { e.preventDefault(); const s = suggestions[activeIndex]; if (s) { onSelect(s); setOpen(false); } } else if (e.key === "Escape") { setOpen(false); } } return (
{ onChange(e.target.value); setOpen(true); setActiveIndex(-1); }} onFocus={() => setOpen(true)} onKeyDown={handleKeyDown} placeholder={placeholder} className={className} disabled={disabled} /> {showDropdown && (
{suggestions.map((s, i) => ( ))}
)}
); } const LineItemCard = React.forwardRef( ({ item, index, canRemove, onRemove, onUpdate, suggestions, onSelectSuggestion, onDescriptionChange, readOnly }, ref) => { return ( ); }, ); LineItemCard.displayName = "LineItemCard"; function MobileLineItem({ item, index, canRemove, onRemove, onUpdate, suggestions, onSelectSuggestion, onDescriptionChange, readOnly, }: LineItemRowProps) { return (
{index + 1} onDescriptionChange(index, v)} onSelect={(s) => onSelectSuggestion(index, s)} suggestions={suggestions} placeholder="Description" className="h-8 flex-1 text-sm" disabled={readOnly} />
onUpdate(index, "date", date ?? new Date())} size="sm" className="w-[92px] shrink-0" inputClassName="h-8 px-2 text-xs" disabled={readOnly} /> onUpdate(index, "hours", value)} min={0} step={0.25} width="full" className="h-8 w-[88px] shrink-0 font-mono [&_button]:h-7 [&_button]:w-5 [&_input]:min-w-8 [&_input]:text-xs" suffix="h" disabled={readOnly} /> onUpdate(index, "rate", value)} min={0} step={1} prefix="$" width="full" className="h-8 w-[84px] shrink-0 font-mono [&_button]:h-7 [&_button]:w-5 [&_input]:min-w-10 [&_input]:text-xs" disabled={readOnly} /> ${(item.hours * item.rate).toFixed(2)} {!readOnly ? ( ) : null}
); } function NLQuickAdd({ onAdd }: { onAdd: (parsed: ParsedLineItem) => void }) { const [value, setValue] = useState(""); function handleKeyDown(e: React.KeyboardEvent) { if (e.key === "Enter" && value.trim()) { e.preventDefault(); onAdd(parseLineItem(value)); setValue(""); } } return (
setValue(e.target.value)} onKeyDown={handleKeyDown} placeholder='Quick add: "3hrs web design @120" — press Enter' className="h-8 border-dashed text-sm" />
); } export function InvoiceLineItems({ items, onAddItem, onRemoveItem, onUpdateItem, onAddItemWithValues, invoiceId, clientId, defaultRate: _defaultRate, className, readOnly = false, }: InvoiceLineItemsProps) { const canRemoveItems = items.length > 1; const { search } = useLineItemSuggestions(); const [queriedIndex, setQueriedIndex] = useState(null); const [suggestions, setSuggestions] = useState([]); function handleDescriptionChange(index: number, value: string) { onUpdateItem(index, "description", value); setQueriedIndex(index); setSuggestions(search(value)); } function handleSelectSuggestion(index: number, s: LineItemSuggestion) { onUpdateItem(index, "description", s.description); onUpdateItem(index, "hours", s.hours); onUpdateItem(index, "rate", s.rate); setSuggestions([]); setQueriedIndex(null); } function getSuggestionsForIndex(index: number): LineItemSuggestion[] { return queriedIndex === index ? suggestions : []; } return (
{readOnly ? (

Line items are locked after an invoice is sent. Revert to draft to edit entries.

) : null}
Date Description Hours Rate Amount
{items.map((item, index) => ( {/* Desktop/Tablet Card */} {/* Mobile Card */} ))} {invoiceId && (

Time clock

Track time on the dedicated time clock — entries sync across devices and bill directly to an invoice.

)} {onAddItemWithValues && !readOnly ? ( ) : null}
{!readOnly ? ( ) : null}
); }