Combine clients and businesses under entities, polish the web time clock, and show live invoice PDF preview with tighter line-item editing. Co-authored-by: Cursor <cursoragent@cursor.com>
463 lines
15 KiB
TypeScript
463 lines
15 KiB
TypeScript
"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<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}
|
|
disabled={disabled}
|
|
/>
|
|
{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, suggestions, onSelectSuggestion, onDescriptionChange, readOnly }, ref) => {
|
|
return (
|
|
<div
|
|
ref={ref}
|
|
className={cn(
|
|
"group hover:bg-muted/30 hidden min-h-11 grid-cols-[108px_minmax(180px,1fr)_96px_108px_88px_28px] items-center gap-1.5 border-b px-2 py-1.5 transition-colors md:grid",
|
|
)}
|
|
>
|
|
<DatePicker
|
|
date={item.date}
|
|
onDateChange={(date) => onUpdate(index, "date", date ?? new Date())}
|
|
size="sm"
|
|
className="w-full"
|
|
inputClassName="h-8 text-xs"
|
|
disabled={readOnly}
|
|
/>
|
|
|
|
<DescriptionAutocomplete
|
|
value={item.description}
|
|
onChange={(v) => onDescriptionChange(index, v)}
|
|
onSelect={(s) => onSelectSuggestion(index, s)}
|
|
suggestions={suggestions}
|
|
placeholder="Description"
|
|
className="h-8 w-full text-sm"
|
|
disabled={readOnly}
|
|
/>
|
|
|
|
<NumberInput
|
|
value={item.hours}
|
|
onChange={(value) => onUpdate(index, "hours", value)}
|
|
min={0}
|
|
step={0.25}
|
|
width="full"
|
|
className="h-8 font-mono [&_button]:h-7 [&_button]:w-5 [&_input]:min-w-10 [&_input]:text-xs"
|
|
suffix="h"
|
|
disabled={readOnly}
|
|
/>
|
|
|
|
<NumberInput
|
|
value={item.rate}
|
|
onChange={(value) => onUpdate(index, "rate", value)}
|
|
min={0}
|
|
step={1}
|
|
prefix="$"
|
|
width="full"
|
|
className="h-8 font-mono [&_button]:h-7 [&_button]:w-5 [&_input]:min-w-12 [&_input]:text-xs"
|
|
disabled={readOnly}
|
|
/>
|
|
|
|
<div className="text-primary text-right font-mono text-sm font-semibold tabular-nums">
|
|
${(item.hours * item.rate).toFixed(2)}
|
|
</div>
|
|
|
|
{!readOnly ? (
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() => onRemove(index)}
|
|
className="text-muted-foreground hover:text-destructive h-7 w-7 p-0"
|
|
disabled={!canRemove}
|
|
aria-label="Remove item"
|
|
>
|
|
<Trash2 className="h-3.5 w-3.5" />
|
|
</Button>
|
|
) : (
|
|
<span />
|
|
)}
|
|
</div>
|
|
);
|
|
},
|
|
);
|
|
LineItemCard.displayName = "LineItemCard";
|
|
|
|
function MobileLineItem({
|
|
item,
|
|
index,
|
|
canRemove,
|
|
onRemove,
|
|
onUpdate,
|
|
suggestions,
|
|
onSelectSuggestion,
|
|
onDescriptionChange,
|
|
readOnly,
|
|
}: LineItemRowProps) {
|
|
return (
|
|
<div
|
|
id={`invoice-item-${index}-mobile`}
|
|
className="border-border space-y-1.5 border-b px-3 py-2 md:hidden"
|
|
>
|
|
<div className="flex items-center gap-2">
|
|
<span className="text-muted-foreground w-5 shrink-0 text-center text-xs font-semibold">
|
|
{index + 1}
|
|
</span>
|
|
<DescriptionAutocomplete
|
|
value={item.description}
|
|
onChange={(v) => onDescriptionChange(index, v)}
|
|
onSelect={(s) => onSelectSuggestion(index, s)}
|
|
suggestions={suggestions}
|
|
placeholder="Description"
|
|
className="h-8 flex-1 text-sm"
|
|
disabled={readOnly}
|
|
/>
|
|
</div>
|
|
|
|
<div className="flex items-center gap-1.5 pl-7">
|
|
<DatePicker
|
|
date={item.date}
|
|
onDateChange={(date) => onUpdate(index, "date", date ?? new Date())}
|
|
size="sm"
|
|
className="w-[92px] shrink-0"
|
|
inputClassName="h-8 px-2 text-xs"
|
|
disabled={readOnly}
|
|
/>
|
|
<NumberInput
|
|
value={item.hours}
|
|
onChange={(value) => 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}
|
|
/>
|
|
<NumberInput
|
|
value={item.rate}
|
|
onChange={(value) => 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}
|
|
/>
|
|
<span className="text-primary ml-auto font-mono text-sm font-semibold tabular-nums">
|
|
${(item.hours * item.rate).toFixed(2)}
|
|
</span>
|
|
{!readOnly ? (
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() => onRemove(index)}
|
|
className="text-muted-foreground hover:text-destructive h-7 w-7 shrink-0 p-0"
|
|
disabled={!canRemove}
|
|
aria-label="Remove item"
|
|
>
|
|
<Trash2 className="h-3.5 w-3.5" />
|
|
</Button>
|
|
) : null}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
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,
|
|
invoiceId,
|
|
clientId,
|
|
defaultRate: _defaultRate,
|
|
className,
|
|
readOnly = false,
|
|
}: 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)}>
|
|
{readOnly ? (
|
|
<p className="text-muted-foreground text-sm">
|
|
Line items are locked after an invoice is sent. Revert to draft to edit entries.
|
|
</p>
|
|
) : null}
|
|
<AnimatePresence>
|
|
<div className="space-y-0 md:overflow-hidden md:rounded-lg md:border">
|
|
<div className="bg-muted/60 text-muted-foreground hidden grid-cols-[108px_minmax(180px,1fr)_96px_108px_88px_28px] gap-1.5 border-b px-2 py-1.5 text-[11px] font-semibold tracking-wide uppercase md:grid">
|
|
<span>Date</span>
|
|
<span>Description</span>
|
|
<span className="text-center">Hours</span>
|
|
<span className="text-center">Rate</span>
|
|
<span className="text-right">Amount</span>
|
|
<span />
|
|
</div>
|
|
{items.map((item, index) => (
|
|
<React.Fragment key={item.id}>
|
|
{/* Desktop/Tablet Card */}
|
|
<motion.div
|
|
layout
|
|
id={`invoice-item-${index}`}
|
|
initial={{ opacity: 0, y: 20 }}
|
|
animate={{ opacity: 1, y: 0 }}
|
|
exit={{ opacity: 0, y: -20 }}
|
|
transition={{ duration: 0.2, ease: "easeOut" }}
|
|
>
|
|
<LineItemCard
|
|
item={item}
|
|
index={index}
|
|
canRemove={canRemoveItems}
|
|
onRemove={onRemoveItem}
|
|
onUpdate={onUpdateItem}
|
|
suggestions={getSuggestionsForIndex(index)}
|
|
onSelectSuggestion={handleSelectSuggestion}
|
|
onDescriptionChange={handleDescriptionChange}
|
|
readOnly={readOnly}
|
|
/>
|
|
</motion.div>
|
|
|
|
{/* Mobile Card */}
|
|
<MobileLineItem
|
|
item={item}
|
|
index={index}
|
|
canRemove={canRemoveItems}
|
|
onRemove={onRemoveItem}
|
|
onUpdate={onUpdateItem}
|
|
suggestions={getSuggestionsForIndex(index)}
|
|
onSelectSuggestion={handleSelectSuggestion}
|
|
onDescriptionChange={handleDescriptionChange}
|
|
readOnly={readOnly}
|
|
/>
|
|
</React.Fragment>
|
|
))}
|
|
{invoiceId && (
|
|
<div className="border-t p-3 space-y-2">
|
|
<p className="text-muted-foreground flex items-center gap-1.5 text-xs font-medium">
|
|
<Timer className="h-3.5 w-3.5" /> Time clock
|
|
</p>
|
|
<p className="text-muted-foreground text-xs">
|
|
Track time on the dedicated time clock — entries sync across devices and
|
|
bill directly to an invoice.
|
|
</p>
|
|
<Button variant="outline" size="sm" className="w-full" asChild>
|
|
<Link
|
|
href={`/dashboard/time-clock?invoiceId=${invoiceId}${
|
|
clientId ? `&clientId=${clientId}` : ""
|
|
}`}
|
|
>
|
|
Open time clock
|
|
</Link>
|
|
</Button>
|
|
</div>
|
|
)}
|
|
{onAddItemWithValues && !readOnly ? (
|
|
<NLQuickAdd onAdd={onAddItemWithValues} />
|
|
) : null}
|
|
</div>
|
|
</AnimatePresence>
|
|
|
|
{!readOnly ? (
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
onClick={onAddItem}
|
|
className="border-border text-muted-foreground hover:text-primary hover:bg-accent/50 hover:border-primary/50 mt-2 w-full border-dashed py-3 transition-all"
|
|
>
|
|
<Plus className="mr-2 h-4 w-4" />
|
|
Add Line Item
|
|
</Button>
|
|
) : null}
|
|
</div>
|
|
);
|
|
}
|