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:
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user