"use client";
import * as React from "react";
import { useState, useEffect } from "react";
import { useRouter } from "next/navigation";
import { Button } from "~/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
import { Label } from "~/components/ui/label";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "~/components/ui/tabs";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "~/components/ui/select";
import { DatePicker } from "~/components/ui/date-picker";
import { Input } from "~/components/ui/input";
import { NumberInput } from "~/components/ui/number-input";
import { PageHeader } from "~/components/layout/page-header";
import { InvoiceLineItems } from "./invoice-line-items";
import { InvoiceCalendarView } from "./invoice-calendar-view";
import { api } from "~/trpc/react";
import { toast } from "sonner";
import {
Save,
Calendar as CalendarIcon,
Tag,
User,
List,
FileText,
ChevronDown,
} from "lucide-react";
import { SUPPORTED_CURRENCIES } from "~/lib/currency";
import { Textarea } from "~/components/ui/textarea";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "~/components/ui/dropdown-menu";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "~/components/ui/dialog";
import { STATUS_OPTIONS } from "./invoice/types";
import type { InvoiceFormData, InvoiceItem } from "./invoice/types";
import { CountUp } from "~/components/ui/count-up";
interface InvoiceFormProps {
invoiceId?: string;
}
function InvoiceFormSkeleton() {
return (
{" "}
{/* Tabs Skeleton */}
);
}
export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
const router = useRouter();
const utils = api.useUtils();
// State
const [formData, setFormData] = useState({
invoiceNumber: `INV-${new Date().toISOString().slice(0, 10).replace(/-/g, "")}-${Date.now().toString().slice(-6)}`,
invoicePrefix: "#",
businessId: "",
clientId: "",
issueDate: new Date(),
dueDate: new Date(),
status: "draft",
notes: "",
taxRate: 0,
currency: "USD",
defaultHourlyRate: null,
items: [
{
id: crypto.randomUUID(),
date: new Date(),
description: "",
hours: 1,
rate: 0,
amount: 0,
},
],
});
const [loading, setLoading] = useState(false);
const [initialized, setInitialized] = useState(false);
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [activeTab, setActiveTab] = useState("details");
// Queries (Same as before)
const { data: clients, isLoading: loadingClients } =
api.clients.getAll.useQuery();
const { data: noteTemplates } = api.invoiceTemplates.getByType.useQuery({
type: "notes",
});
const { data: businesses, isLoading: loadingBusinesses } =
api.businesses.getAll.useQuery();
const { data: existingInvoice, isLoading: loadingInvoice } =
api.invoices.getById.useQuery(
{ id: invoiceId! },
{ enabled: !!invoiceId && invoiceId !== "new" },
);
const deleteInvoice = api.invoices.delete.useMutation({
onSuccess: () => {
toast.success("Invoice deleted");
router.push("/dashboard/invoices");
},
onError: (e) => toast.error(e.message ?? "Failed to delete"),
});
// Init Effects (Same as before)
useEffect(() => {
setInitialized(false);
}, [invoiceId]);
useEffect(() => {
if (invoiceId && invoiceId !== "new" && existingInvoice && !initialized) {
// ... (Mapping logic same as before)
const mappedItems: InvoiceItem[] =
existingInvoice.items
?.map((item) => ({
id: crypto.randomUUID(),
date: new Date(item.date),
description: item.description,
hours: item.hours,
rate: item.rate,
amount: item.amount,
}))
.sort(
(a, b) => new Date(a.date).getTime() - new Date(b.date).getTime(),
) || [];
setFormData({
invoiceNumber: existingInvoice.invoiceNumber,
invoicePrefix: existingInvoice.invoicePrefix ?? "#",
businessId: existingInvoice.businessId ?? "",
clientId: existingInvoice.clientId,
issueDate: new Date(existingInvoice.issueDate),
dueDate: new Date(existingInvoice.dueDate),
status: existingInvoice.status as "draft" | "sent" | "paid",
notes: existingInvoice.notes ?? "",
taxRate: existingInvoice.taxRate,
currency: existingInvoice.currency ?? "USD",
defaultHourlyRate: existingInvoice.client?.defaultHourlyRate ?? null,
items:
mappedItems.length > 0
? mappedItems
: [
{
id: crypto.randomUUID(),
date: new Date(),
description: "",
hours: 1,
rate: 0,
amount: 0,
},
],
});
setInitialized(true);
} else if (
(!invoiceId || invoiceId === "new") &&
businesses &&
!initialized
) {
const defaultBusiness =
businesses.find((b) => b.isDefault) ?? businesses[0];
if (defaultBusiness)
setFormData((prev) => ({ ...prev, businessId: defaultBusiness.id }));
setInitialized(true);
}
}, [invoiceId, existingInvoice, businesses, initialized]);
const totals = React.useMemo(() => {
const subtotal = formData.items.reduce(
(sum, item) => sum + item.hours * item.rate,
0,
);
const taxAmount = (subtotal * formData.taxRate) / 100;
const total = subtotal + taxAmount;
return { subtotal, taxAmount, total };
}, [formData.items, formData.taxRate]);
// Handlers (addItem, updateItem etc. - same as before)
const addItem = (date?: unknown) => {
const validDate = date instanceof Date ? date : new Date();
setFormData((prev) => ({
...prev,
items: [
...prev.items,
{
id: crypto.randomUUID(),
date: validDate,
description: "",
hours: 1,
rate: prev.defaultHourlyRate ?? 0,
amount: prev.defaultHourlyRate ?? 0,
},
],
}));
};
const removeItem = (idx: number) => {
if (formData.items.length > 1)
setFormData((prev) => ({
...prev,
items: prev.items.filter((_, i) => i !== idx),
}));
};
const updateItem = (
idx: number,
field: string,
value: string | number | Date,
) => {
setFormData((prev) => ({
...prev,
items: prev.items.map((item, i) => {
if (i === idx) {
const updated = { ...item, [field]: value };
if (field === "hours" || field === "rate") {
updated.amount = updated.hours * updated.rate;
}
return updated;
}
return item;
}),
}));
};
const createInvoice = api.invoices.create.useMutation({
onSuccess: (inv) => {
toast.success("Created");
void utils.invoices.getAll.invalidate();
router.push(`/dashboard/invoices/${inv.id}`);
},
onError: (e) => toast.error(e.message),
});
const updateInvoice = api.invoices.update.useMutation({
onSuccess: () => {
toast.success("Updated");
if (invoiceId && invoiceId !== "new") {
void utils.invoices.getById.invalidate({ id: invoiceId });
}
void utils.invoices.getAll.invalidate();
router.push(
invoiceId === "new"
? "/dashboard/invoices"
: `/dashboard/invoices/${invoiceId}`,
);
},
onError: (e) => toast.error(e.message),
});
const handleSubmit = async () => {
setLoading(true);
if (!formData.clientId) {
toast.error("Select Client");
setLoading(false);
return;
}
// Validate Items - Check for empty description
let invalidItemIndex = -1;
for (let i = 0; i < formData.items.length; i++) {
if (
!formData.items[i]?.description ||
formData.items[i]?.description.trim() === ""
) {
invalidItemIndex = i;
break;
}
}
if (invalidItemIndex !== -1) {
toast.error(`Item #${invalidItemIndex + 1} is missing a description`);
setLoading(false);
setActiveTab("items"); // Switch to items tab
// Timeout to allow tab switch rendering
setTimeout(() => {
const element = document.getElementById(
`invoice-item-${invalidItemIndex}`,
);
if (element) {
element.scrollIntoView({ behavior: "smooth", block: "center" });
// Optional: Highlight effect
element.classList.add("ring-2", "ring-destructive", "ring-offset-2");
setTimeout(
() =>
element.classList.remove(
"ring-2",
"ring-destructive",
"ring-offset-2",
),
2000,
);
}
}, 100);
return;
}
try {
const payload = {
invoiceNumber: formData.invoiceNumber,
invoicePrefix: formData.invoicePrefix,
businessId: formData.businessId || "",
clientId: formData.clientId,
issueDate: formData.issueDate,
dueDate: formData.dueDate,
status: formData.status,
notes: formData.notes,
taxRate: formData.taxRate,
currency: formData.currency,
items: formData.items
.sort(
(a, b) => new Date(a.date).getTime() - new Date(b.date).getTime(),
)
.map((i) => ({
date: i.date,
description: i.description,
hours: i.hours,
rate: i.rate,
amount: i.hours * i.rate,
})),
};
if (invoiceId && invoiceId !== "new" && invoiceId !== undefined)
await updateInvoice.mutateAsync({ id: invoiceId, ...payload });
else await createInvoice.mutateAsync(payload);
} catch (e) {
console.error(e);
} finally {
setLoading(false);
}
};
const updateField = (
field: K,
value: InvoiceFormData[K],
) => setFormData((p) => ({ ...p, [field]: value }));
const handleDelete = () => setDeleteDialogOpen(true);
const confirmDelete = () => {
if (invoiceId) deleteInvoice.mutate({ id: invoiceId });
};
if (
!initialized ||
loadingClients ||
loadingBusinesses ||
(invoiceId && invoiceId !== "new" && loadingInvoice)
)
return ;
return (
<>
{invoiceId !== "new" && (
)}
{/* TAB SELECTOR: w-full, p-1, visible background */}
Details
Items
Timesheet
{/* DETAILS TAB */}
Client Details
Invoice Config
updateField("issueDate", d ?? new Date())
}
className="w-full"
/>
updateField("dueDate", d ?? new Date())
}
className="w-full"
/>
updateField("taxRate", v)}
suffix="%"
className="w-full"
/>
updateField("defaultHourlyRate", v)}
prefix="$"
disabled={!formData.clientId}
className="w-full"
/>
{/* Notes card — spans both columns */}
Notes
{noteTemplates && noteTemplates.length > 0 && (
{noteTemplates.map((t) => (
updateField("notes", t.content)}
>
{t.name}
))}
)}
{/* ITEMS TAB */}
Total
Subtotal
Hours
s + i.hours, 0)}
suffix="h"
/>
Invoice Items
{/* TIMESHEET TAB */}
Timesheet
>
);
}