feat: add fuzzy autocomplete and NL quick-add for invoice line items

- Description field now shows a fuzzy-matched dropdown of past line items
  (description, hours, rate) as you type via Fuse.js — zero server cost
- Selecting a suggestion pre-fills description, hours, and rate in one click
- NL quick-add bar lets you type e.g. "3hrs web design @120" + Enter to
  append a fully-parsed line item without clicking through fields
- New tRPC query `getLineItemHistory` returns deduplicated past line items
  for the current user, ordered by recency
- New `parseLineItem` utility handles hours/rate extraction via regex

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-10 03:08:00 -04:00
parent 0ba9c2f029
commit 7819e438df
7 changed files with 286 additions and 58 deletions
+18
View File
@@ -51,6 +51,7 @@ import {
} from "~/components/ui/dialog";
import { STATUS_OPTIONS } from "./invoice/types";
import type { InvoiceFormData, InvoiceItem } from "./invoice/types";
import type { ParsedLineItem } from "~/lib/parse-line-item";
import { CountUp } from "~/components/ui/count-up";
@@ -293,6 +294,22 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
],
}));
};
const addItemWithValues = (parsed: ParsedLineItem) => {
setFormData((prev) => ({
...prev,
items: [
...prev.items,
{
id: crypto.randomUUID(),
date: new Date(),
description: parsed.description,
hours: parsed.hours ?? 1,
rate: parsed.rate ?? prev.defaultHourlyRate ?? 0,
amount: (parsed.hours ?? 1) * (parsed.rate ?? prev.defaultHourlyRate ?? 0),
},
],
}));
};
const removeItem = (idx: number) => {
if (formData.items.length > 1)
setFormData((prev) => ({
@@ -787,6 +804,7 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
onAddItem={addItem}
onRemoveItem={removeItem}
onUpdateItem={updateItem}
onAddItemWithValues={addItemWithValues}
/>
</CardContent>
</Card>
+167 -6
View File
@@ -1,7 +1,8 @@
"use client";
import { Plus, Trash2 } from "lucide-react";
import { Plus, Trash2, Zap } from "lucide-react";
import * as React from "react";
import { useState, useRef, useEffect } from "react";
import { motion, AnimatePresence } from "framer-motion";
import { Button } from "~/components/ui/button";
import { DatePicker } from "~/components/ui/date-picker";
@@ -9,6 +10,11 @@ import { Input } from "~/components/ui/input";
import { Label } from "~/components/ui/label";
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;
@@ -28,6 +34,7 @@ interface InvoiceLineItemsProps {
field: string,
value: string | number | Date,
) => void;
onAddItemWithValues?: (parsed: ParsedLineItem) => void;
className?: string;
}
@@ -41,10 +48,101 @@ interface LineItemRowProps {
field: string,
value: string | number | Date,
) => void;
suggestions: LineItemSuggestion[];
onSelectSuggestion: (index: number, suggestion: LineItemSuggestion) => void;
onDescriptionChange: (index: number, value: string) => void;
}
interface DescriptionAutocompleteProps {
value: string;
onChange: (value: string) => void;
onSelect: (suggestion: LineItemSuggestion) => void;
suggestions: LineItemSuggestion[];
placeholder?: string;
className?: string;
}
function DescriptionAutocomplete({
value,
onChange,
onSelect,
suggestions,
placeholder,
className,
}: DescriptionAutocompleteProps) {
const [open, setOpen] = useState(false);
const [activeIndex, setActiveIndex] = useState(-1);
const containerRef = useRef<HTMLDivElement>(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<HTMLInputElement>) {
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 (
<div ref={containerRef} className="relative w-full">
<Input
value={value}
onChange={(e) => { onChange(e.target.value); setOpen(true); setActiveIndex(-1); }}
onFocus={() => setOpen(true)}
onKeyDown={handleKeyDown}
placeholder={placeholder}
className={className}
/>
{showDropdown && (
<div className="bg-popover text-popover-foreground border-border absolute top-full left-0 z-50 mt-1 w-full overflow-hidden rounded-md border shadow-md">
{suggestions.map((s, i) => (
<button
key={s.description}
type="button"
onMouseDown={(e) => {
e.preventDefault();
onSelect(s);
setOpen(false);
}}
className={cn(
"hover:bg-accent hover:text-accent-foreground flex w-full items-center justify-between px-3 py-2 text-left text-sm",
i === activeIndex && "bg-accent text-accent-foreground",
)}
>
<span className="truncate font-medium">{s.description}</span>
<span className="text-muted-foreground ml-3 shrink-0 font-mono text-xs">
{s.hours}h · ${s.rate}/hr
</span>
</button>
))}
</div>
)}
</div>
);
}
const LineItemCard = React.forwardRef<HTMLDivElement, LineItemRowProps>(
({ item, index, canRemove, onRemove, onUpdate }, ref) => {
({ item, index, canRemove, onRemove, onUpdate, suggestions, onSelectSuggestion, onDescriptionChange }, ref) => {
return (
<div
ref={ref}
@@ -60,9 +158,11 @@ const LineItemCard = React.forwardRef<HTMLDivElement, LineItemRowProps>(
inputClassName="h-9"
/>
<Input
<DescriptionAutocomplete
value={item.description}
onChange={(e) => onUpdate(index, "description", e.target.value)}
onChange={(v) => onDescriptionChange(index, v)}
onSelect={(s) => onSelectSuggestion(index, s)}
suggestions={suggestions}
placeholder="Describe the work performed..."
className="h-9 w-full text-sm font-medium"
/>
@@ -114,6 +214,9 @@ function MobileLineItem({
canRemove,
onRemove,
onUpdate,
suggestions,
onSelectSuggestion,
onDescriptionChange,
}: LineItemRowProps) {
return (
<motion.div
@@ -129,9 +232,11 @@ function MobileLineItem({
{/* Description */}
<div className="space-y-1">
<Label className="text-muted-foreground text-xs">Description</Label>
<Input
<DescriptionAutocomplete
value={item.description}
onChange={(e) => onUpdate(index, "description", e.target.value)}
onChange={(v) => onDescriptionChange(index, v)}
onSelect={(s) => onSelectSuggestion(index, s)}
suggestions={suggestions}
placeholder="Describe the work performed..."
className="pl-3 text-sm"
/>
@@ -208,14 +313,61 @@ function MobileLineItem({
);
}
function NLQuickAdd({ onAdd }: { onAdd: (parsed: ParsedLineItem) => void }) {
const [value, setValue] = useState("");
function handleKeyDown(e: React.KeyboardEvent<HTMLInputElement>) {
if (e.key === "Enter" && value.trim()) {
e.preventDefault();
onAdd(parseLineItem(value));
setValue("");
}
}
return (
<div className="flex items-center gap-2 px-3 py-2">
<Zap className="text-muted-foreground h-4 w-4 shrink-0" />
<Input
value={value}
onChange={(e) => setValue(e.target.value)}
onKeyDown={handleKeyDown}
placeholder='Quick add: "3hrs web design @120" — press Enter'
className="h-8 border-dashed text-sm"
/>
</div>
);
}
export function InvoiceLineItems({
items,
onAddItem,
onRemoveItem,
onUpdateItem,
onAddItemWithValues,
className,
}: InvoiceLineItemsProps) {
const canRemoveItems = items.length > 1;
const { search } = useLineItemSuggestions();
const [queriedIndex, setQueriedIndex] = useState<number | null>(null);
const [suggestions, setSuggestions] = useState<LineItemSuggestion[]>([]);
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 (
<div className={cn("space-y-2", className)}>
@@ -246,6 +398,9 @@ export function InvoiceLineItems({
canRemove={canRemoveItems}
onRemove={onRemoveItem}
onUpdate={onUpdateItem}
suggestions={getSuggestionsForIndex(index)}
onSelectSuggestion={handleSelectSuggestion}
onDescriptionChange={handleDescriptionChange}
/>
</motion.div>
@@ -256,9 +411,15 @@ export function InvoiceLineItems({
canRemove={canRemoveItems}
onRemove={onRemoveItem}
onUpdate={onUpdateItem}
suggestions={getSuggestionsForIndex(index)}
onSelectSuggestion={handleSelectSuggestion}
onDescriptionChange={handleDescriptionChange}
/>
</React.Fragment>
))}
{onAddItemWithValues && (
<NLQuickAdd onAdd={onAddItemWithValues} />
)}
</div>
</AnimatePresence>
+32
View File
@@ -0,0 +1,32 @@
import { useMemo } from "react";
import Fuse from "fuse.js";
import { api } from "~/trpc/react";
export interface LineItemSuggestion {
description: string;
hours: number;
rate: number;
}
export function useLineItemSuggestions() {
const { data: history = [] } = api.invoices.getLineItemHistory.useQuery(undefined, {
staleTime: 5 * 60 * 1000,
});
const fuse = useMemo(
() =>
new Fuse(history, {
keys: ["description"],
threshold: 0.4,
minMatchCharLength: 2,
}),
[history],
);
function search(query: string): LineItemSuggestion[] {
if (!query || query.length < 2) return history.slice(0, 6);
return fuse.search(query, { limit: 6 }).map((r) => r.item);
}
return { search, hasHistory: history.length > 0 };
}
+30
View File
@@ -0,0 +1,30 @@
export interface ParsedLineItem {
description: string;
hours: number | null;
rate: number | null;
}
export function parseLineItem(input: string): ParsedLineItem {
let text = input.trim();
let hours: number | null = null;
let rate: number | null = null;
// Extract hours: "3h", "3hr", "3hrs", "3 hours", "3.5hours"
const hoursMatch = /(\d+\.?\d*)\s*h(?:ours?|rs?)\b/i.exec(text);
if (hoursMatch?.[0] && hoursMatch[1]) {
hours = parseFloat(hoursMatch[1]);
text = text.replace(hoursMatch[0], " ").trim();
}
// Extract rate: "@120", "@$120", "at 120", "at $120", "$120/hr", "$120ph"
const rateMatch = /(?:@\s*\$?|at\s+\$?)(\d+\.?\d*)|(\$\d+\.?\d*)(?:\/h(?:rs?)?|ph)?\b/i.exec(text);
if (rateMatch?.[0]) {
const rawRate = rateMatch[1] ?? rateMatch[2] ?? "";
rate = parseFloat(rawRate.replace("$", ""));
text = text.replace(rateMatch[0], " ").trim();
}
const description = text.replace(/\s+/g, " ").replace(/^[\s,]+|[\s,]+$/g, "").trim();
return { description: description || input.trim(), hours, rate };
}
+35 -1
View File
@@ -1,5 +1,5 @@
import { z } from "zod";
import { eq, inArray } from "drizzle-orm";
import { desc, eq, inArray } from "drizzle-orm";
import { createTRPCRouter, protectedProcedure } from "../trpc";
import {
invoices,
@@ -131,6 +131,40 @@ export const invoicesRouter = createTRPCRouter({
}
}),
getLineItemHistory: protectedProcedure.query(async ({ ctx }) => {
const userInvoices = await ctx.db
.select({ id: invoices.id })
.from(invoices)
.where(eq(invoices.createdById, ctx.session.user.id));
if (userInvoices.length === 0) return [];
const invoiceIds = userInvoices.map((i) => i.id);
const rows = await ctx.db
.select({
description: invoiceItems.description,
hours: invoiceItems.hours,
rate: invoiceItems.rate,
createdAt: invoiceItems.createdAt,
})
.from(invoiceItems)
.where(inArray(invoiceItems.invoiceId, invoiceIds))
.orderBy(desc(invoiceItems.createdAt))
.limit(500);
// Deduplicate by description, keeping most recent occurrence
const seen = new Set<string>();
return rows
.filter((r) => {
const key = r.description.toLowerCase();
if (seen.has(key)) return false;
seen.add(key);
return true;
})
.slice(0, 200)
.map(({ description, hours, rate }) => ({ description, hours, rate }));
}),
getCurrentOpen: protectedProcedure.query(async ({ ctx }) => {
try {
// Get the most recent draft invoice