mirror of
https://github.com/soconnor0919/beenvoice.git
synced 2026-02-04 15:56:31 -05:00
Fix invoice edit cache invalidation issue
- Add cache invalidation after invoice create/update mutations - Properly invalidate both getById and getAll queries - Prevents stale data from being displayed after saving - Fixes flaky behavior where updates didn't appear immediately
This commit is contained in:
@@ -35,7 +35,6 @@ import type { InvoiceFormData, InvoiceItem } from "./invoice/types";
|
|||||||
|
|
||||||
import { CountUp } from "~/components/ui/count-up";
|
import { CountUp } from "~/components/ui/count-up";
|
||||||
|
|
||||||
|
|
||||||
interface InvoiceFormProps {
|
interface InvoiceFormProps {
|
||||||
invoiceId?: string;
|
invoiceId?: string;
|
||||||
}
|
}
|
||||||
@@ -48,10 +47,11 @@ function InvoiceFormSkeleton() {
|
|||||||
description="Loading invoice form"
|
description="Loading invoice form"
|
||||||
variant="gradient"
|
variant="gradient"
|
||||||
/>
|
/>
|
||||||
<div className="bg-muted p-1 rounded-xl h-12 w-full animate-pulse" /> {/* Tabs Skeleton */}
|
<div className="bg-muted h-12 w-full animate-pulse rounded-xl p-1" />{" "}
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6 mt-6">
|
{/* Tabs Skeleton */}
|
||||||
<div className="h-[200px] bg-muted animate-pulse rounded-xl" />
|
<div className="mt-6 grid grid-cols-1 gap-6 lg:grid-cols-2">
|
||||||
<div className="h-[200px] bg-muted animate-pulse rounded-xl" />
|
<div className="bg-muted h-[200px] animate-pulse rounded-xl" />
|
||||||
|
<div className="bg-muted h-[200px] animate-pulse rounded-xl" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -59,6 +59,7 @@ function InvoiceFormSkeleton() {
|
|||||||
|
|
||||||
export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
|
export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const utils = api.useUtils();
|
||||||
|
|
||||||
// State
|
// State
|
||||||
const [formData, setFormData] = useState<InvoiceFormData>({
|
const [formData, setFormData] = useState<InvoiceFormData>({
|
||||||
@@ -72,7 +73,14 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
|
|||||||
taxRate: 0,
|
taxRate: 0,
|
||||||
defaultHourlyRate: null,
|
defaultHourlyRate: null,
|
||||||
items: [
|
items: [
|
||||||
{ id: crypto.randomUUID(), date: new Date(), description: "", hours: 1, rate: 0, amount: 0 },
|
{
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
date: new Date(),
|
||||||
|
description: "",
|
||||||
|
hours: 1,
|
||||||
|
rate: 0,
|
||||||
|
amount: 0,
|
||||||
|
},
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -82,30 +90,44 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
|
|||||||
const [activeTab, setActiveTab] = useState("details");
|
const [activeTab, setActiveTab] = useState("details");
|
||||||
|
|
||||||
// Queries (Same as before)
|
// Queries (Same as before)
|
||||||
const { data: clients, isLoading: loadingClients } = api.clients.getAll.useQuery();
|
const { data: clients, isLoading: loadingClients } =
|
||||||
const { data: businesses, isLoading: loadingBusinesses } = api.businesses.getAll.useQuery();
|
api.clients.getAll.useQuery();
|
||||||
const { data: existingInvoice, isLoading: loadingInvoice } = api.invoices.getById.useQuery(
|
const { data: businesses, isLoading: loadingBusinesses } =
|
||||||
{ id: invoiceId! }, { enabled: !!invoiceId && invoiceId !== "new" },
|
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({
|
const deleteInvoice = api.invoices.delete.useMutation({
|
||||||
onSuccess: () => { toast.success("Invoice deleted"); router.push("/dashboard/invoices"); },
|
onSuccess: () => {
|
||||||
|
toast.success("Invoice deleted");
|
||||||
|
router.push("/dashboard/invoices");
|
||||||
|
},
|
||||||
onError: (e) => toast.error(e.message ?? "Failed to delete"),
|
onError: (e) => toast.error(e.message ?? "Failed to delete"),
|
||||||
});
|
});
|
||||||
|
|
||||||
// Init Effects (Same as before)
|
// Init Effects (Same as before)
|
||||||
useEffect(() => { setInitialized(false); }, [invoiceId]);
|
useEffect(() => {
|
||||||
|
setInitialized(false);
|
||||||
|
}, [invoiceId]);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (invoiceId && invoiceId !== "new" && existingInvoice && !initialized) {
|
if (invoiceId && invoiceId !== "new" && existingInvoice && !initialized) {
|
||||||
// ... (Mapping logic same as before)
|
// ... (Mapping logic same as before)
|
||||||
const mappedItems: InvoiceItem[] = existingInvoice.items?.map((item) => ({
|
const mappedItems: InvoiceItem[] =
|
||||||
id: crypto.randomUUID(),
|
existingInvoice.items
|
||||||
date: new Date(item.date),
|
?.map((item) => ({
|
||||||
description: item.description,
|
id: crypto.randomUUID(),
|
||||||
hours: item.hours,
|
date: new Date(item.date),
|
||||||
rate: item.rate,
|
description: item.description,
|
||||||
amount: item.amount,
|
hours: item.hours,
|
||||||
})).sort((a, b) => new Date(a.date).getTime() - new Date(b.date).getTime()) || [];
|
rate: item.rate,
|
||||||
|
amount: item.amount,
|
||||||
|
}))
|
||||||
|
.sort(
|
||||||
|
(a, b) => new Date(a.date).getTime() - new Date(b.date).getTime(),
|
||||||
|
) || [];
|
||||||
setFormData({
|
setFormData({
|
||||||
invoiceNumber: existingInvoice.invoiceNumber,
|
invoiceNumber: existingInvoice.invoiceNumber,
|
||||||
businessId: existingInvoice.businessId ?? "",
|
businessId: existingInvoice.businessId ?? "",
|
||||||
@@ -116,18 +138,39 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
|
|||||||
notes: existingInvoice.notes ?? "",
|
notes: existingInvoice.notes ?? "",
|
||||||
taxRate: existingInvoice.taxRate,
|
taxRate: existingInvoice.taxRate,
|
||||||
defaultHourlyRate: existingInvoice.client?.defaultHourlyRate ?? null,
|
defaultHourlyRate: existingInvoice.client?.defaultHourlyRate ?? null,
|
||||||
items: mappedItems.length > 0 ? mappedItems : [{ id: crypto.randomUUID(), date: new Date(), description: "", hours: 1, rate: 0, amount: 0 }],
|
items:
|
||||||
|
mappedItems.length > 0
|
||||||
|
? mappedItems
|
||||||
|
: [
|
||||||
|
{
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
date: new Date(),
|
||||||
|
description: "",
|
||||||
|
hours: 1,
|
||||||
|
rate: 0,
|
||||||
|
amount: 0,
|
||||||
|
},
|
||||||
|
],
|
||||||
});
|
});
|
||||||
setInitialized(true);
|
setInitialized(true);
|
||||||
} else if ((!invoiceId || invoiceId === "new") && businesses && !initialized) {
|
} else if (
|
||||||
const defaultBusiness = businesses.find((b) => b.isDefault) ?? businesses[0];
|
(!invoiceId || invoiceId === "new") &&
|
||||||
if (defaultBusiness) setFormData((prev) => ({ ...prev, businessId: defaultBusiness.id }));
|
businesses &&
|
||||||
|
!initialized
|
||||||
|
) {
|
||||||
|
const defaultBusiness =
|
||||||
|
businesses.find((b) => b.isDefault) ?? businesses[0];
|
||||||
|
if (defaultBusiness)
|
||||||
|
setFormData((prev) => ({ ...prev, businessId: defaultBusiness.id }));
|
||||||
setInitialized(true);
|
setInitialized(true);
|
||||||
}
|
}
|
||||||
}, [invoiceId, existingInvoice, businesses, initialized]);
|
}, [invoiceId, existingInvoice, businesses, initialized]);
|
||||||
|
|
||||||
const totals = React.useMemo(() => {
|
const totals = React.useMemo(() => {
|
||||||
const subtotal = formData.items.reduce((sum, item) => sum + item.hours * item.rate, 0);
|
const subtotal = formData.items.reduce(
|
||||||
|
(sum, item) => sum + item.hours * item.rate,
|
||||||
|
0,
|
||||||
|
);
|
||||||
const taxAmount = (subtotal * formData.taxRate) / 100;
|
const taxAmount = (subtotal * formData.taxRate) / 100;
|
||||||
const total = subtotal + taxAmount;
|
const total = subtotal + taxAmount;
|
||||||
return { subtotal, taxAmount, total };
|
return { subtotal, taxAmount, total };
|
||||||
@@ -138,11 +181,31 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
|
|||||||
const validDate = date instanceof Date ? date : new Date();
|
const validDate = date instanceof Date ? date : new Date();
|
||||||
setFormData((prev) => ({
|
setFormData((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
items: [...prev.items, { id: crypto.randomUUID(), date: validDate, description: "", hours: 1, rate: prev.defaultHourlyRate ?? 0, amount: prev.defaultHourlyRate ?? 0 }],
|
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 removeItem = (idx: number) => {
|
||||||
const updateItem = (idx: number, field: string, value: string | number | Date) => {
|
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) => ({
|
setFormData((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
items: prev.items.map((item, i) => {
|
items: prev.items.map((item, i) => {
|
||||||
@@ -154,7 +217,7 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
|
|||||||
return updated;
|
return updated;
|
||||||
}
|
}
|
||||||
return item;
|
return item;
|
||||||
})
|
}),
|
||||||
}));
|
}));
|
||||||
};
|
};
|
||||||
const moveItemUp = (idx: number) => {
|
const moveItemUp = (idx: number) => {
|
||||||
@@ -181,25 +244,48 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
|
|||||||
return { ...prev, items: newItems };
|
return { ...prev, items: newItems };
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
const reorderItems = (newItems: InvoiceItem[]) => setFormData(prev => ({ ...prev, items: newItems }));
|
const reorderItems = (newItems: InvoiceItem[]) =>
|
||||||
|
setFormData((prev) => ({ ...prev, items: newItems }));
|
||||||
|
|
||||||
const createInvoice = api.invoices.create.useMutation({
|
const createInvoice = api.invoices.create.useMutation({
|
||||||
onSuccess: (inv) => { toast.success("Created"); router.push(`/dashboard/invoices/${inv.id}`); },
|
onSuccess: (inv) => {
|
||||||
|
toast.success("Created");
|
||||||
|
void utils.invoices.getAll.invalidate();
|
||||||
|
router.push(`/dashboard/invoices/${inv.id}`);
|
||||||
|
},
|
||||||
onError: (e) => toast.error(e.message),
|
onError: (e) => toast.error(e.message),
|
||||||
});
|
});
|
||||||
const updateInvoice = api.invoices.update.useMutation({
|
const updateInvoice = api.invoices.update.useMutation({
|
||||||
onSuccess: () => { toast.success("Updated"); router.push(invoiceId === "new" ? "/dashboard/invoices" : `/dashboard/invoices/${invoiceId}`); },
|
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),
|
onError: (e) => toast.error(e.message),
|
||||||
});
|
});
|
||||||
|
|
||||||
const handleSubmit = async () => {
|
const handleSubmit = async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
if (!formData.clientId) { toast.error("Select Client"); setLoading(false); return; }
|
if (!formData.clientId) {
|
||||||
|
toast.error("Select Client");
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Validate Items - Check for empty description
|
// Validate Items - Check for empty description
|
||||||
let invalidItemIndex = -1;
|
let invalidItemIndex = -1;
|
||||||
for (let i = 0; i < formData.items.length; i++) {
|
for (let i = 0; i < formData.items.length; i++) {
|
||||||
if (!formData.items[i]?.description || formData.items[i]?.description.trim() === "") {
|
if (
|
||||||
|
!formData.items[i]?.description ||
|
||||||
|
formData.items[i]?.description.trim() === ""
|
||||||
|
) {
|
||||||
invalidItemIndex = i;
|
invalidItemIndex = i;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -212,12 +298,22 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
|
|||||||
|
|
||||||
// Timeout to allow tab switch rendering
|
// Timeout to allow tab switch rendering
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
const element = document.getElementById(`invoice-item-${invalidItemIndex}`);
|
const element = document.getElementById(
|
||||||
|
`invoice-item-${invalidItemIndex}`,
|
||||||
|
);
|
||||||
if (element) {
|
if (element) {
|
||||||
element.scrollIntoView({ behavior: "smooth", block: "center" });
|
element.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||||
// Optional: Highlight effect
|
// Optional: Highlight effect
|
||||||
element.classList.add("ring-2", "ring-destructive", "ring-offset-2");
|
element.classList.add("ring-2", "ring-destructive", "ring-offset-2");
|
||||||
setTimeout(() => element.classList.remove("ring-2", "ring-destructive", "ring-offset-2"), 2000);
|
setTimeout(
|
||||||
|
() =>
|
||||||
|
element.classList.remove(
|
||||||
|
"ring-2",
|
||||||
|
"ring-destructive",
|
||||||
|
"ring-offset-2",
|
||||||
|
),
|
||||||
|
2000,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}, 100);
|
}, 100);
|
||||||
return;
|
return;
|
||||||
@@ -234,27 +330,61 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
|
|||||||
notes: formData.notes,
|
notes: formData.notes,
|
||||||
taxRate: formData.taxRate,
|
taxRate: formData.taxRate,
|
||||||
items: formData.items
|
items: formData.items
|
||||||
.sort((a, b) => new Date(a.date).getTime() - new Date(b.date).getTime())
|
.sort(
|
||||||
.map(i => ({ date: i.date, description: i.description, hours: i.hours, rate: i.rate, amount: i.hours * i.rate })),
|
(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 });
|
if (invoiceId && invoiceId !== "new" && invoiceId !== undefined)
|
||||||
|
await updateInvoice.mutateAsync({ id: invoiceId, ...payload });
|
||||||
else await createInvoice.mutateAsync(payload);
|
else await createInvoice.mutateAsync(payload);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(e);
|
console.error(e);
|
||||||
} finally { setLoading(false); }
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const updateField = <K extends keyof InvoiceFormData>(field: K, value: InvoiceFormData[K]) => setFormData(p => ({ ...p, [field]: value }));
|
const updateField = <K extends keyof InvoiceFormData>(
|
||||||
|
field: K,
|
||||||
|
value: InvoiceFormData[K],
|
||||||
|
) => setFormData((p) => ({ ...p, [field]: value }));
|
||||||
const handleDelete = () => setDeleteDialogOpen(true);
|
const handleDelete = () => setDeleteDialogOpen(true);
|
||||||
const confirmDelete = () => { if (invoiceId) deleteInvoice.mutate({ id: invoiceId }); };
|
const confirmDelete = () => {
|
||||||
|
if (invoiceId) deleteInvoice.mutate({ id: invoiceId });
|
||||||
|
};
|
||||||
|
|
||||||
if (!initialized || loadingClients || loadingBusinesses || (invoiceId && invoiceId !== "new" && loadingInvoice)) return <InvoiceFormSkeleton />;
|
if (
|
||||||
|
!initialized ||
|
||||||
|
loadingClients ||
|
||||||
|
loadingBusinesses ||
|
||||||
|
(invoiceId && invoiceId !== "new" && loadingInvoice)
|
||||||
|
)
|
||||||
|
return <InvoiceFormSkeleton />;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="page-enter space-y-6 pb-32">
|
<div className="page-enter space-y-6 pb-32">
|
||||||
<PageHeader title={invoiceId !== "new" ? "Edit Invoice" : "Create Invoice"} description="Manage your invoice" variant="gradient">
|
<PageHeader
|
||||||
{invoiceId !== "new" && <Button variant="secondary" onClick={handleDelete} className="text-destructive">Delete</Button>}
|
title={invoiceId !== "new" ? "Edit Invoice" : "Create Invoice"}
|
||||||
|
description="Manage your invoice"
|
||||||
|
variant="gradient"
|
||||||
|
>
|
||||||
|
{invoiceId !== "new" && (
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
onClick={handleDelete}
|
||||||
|
className="text-destructive"
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
<Button onClick={handleSubmit} variant="secondary" disabled={loading}>
|
<Button onClick={handleSubmit} variant="secondary" disabled={loading}>
|
||||||
<Save className="mr-2 h-4 w-4" />
|
<Save className="mr-2 h-4 w-4" />
|
||||||
{loading ? "Saving..." : "Save"}
|
{loading ? "Saving..." : "Save"}
|
||||||
@@ -263,16 +393,38 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
|
|||||||
|
|
||||||
<Tabs value={activeTab} className="w-full" onValueChange={setActiveTab}>
|
<Tabs value={activeTab} className="w-full" onValueChange={setActiveTab}>
|
||||||
{/* TAB SELECTOR: w-full, p-1, visible background */}
|
{/* TAB SELECTOR: w-full, p-1, visible background */}
|
||||||
<TabsList className="grid w-full grid-cols-3 bg-muted p-1 rounded-xl h-auto">
|
<TabsList className="bg-muted grid h-auto w-full grid-cols-3 rounded-xl p-1">
|
||||||
<TabsTrigger value="details" className="rounded-lg py-2.5 data-[state=active]:bg-background data-[state=active]:shadow-sm">Details</TabsTrigger>
|
<TabsTrigger
|
||||||
<TabsTrigger value="items" className="rounded-lg py-2.5 data-[state=active]:bg-background data-[state=active]:shadow-sm">Items</TabsTrigger>
|
value="details"
|
||||||
<TabsTrigger value="timesheet" className="rounded-lg py-2.5 data-[state=active]:bg-background data-[state=active]:shadow-sm">Timesheet</TabsTrigger>
|
className="data-[state=active]:bg-background rounded-lg py-2.5 data-[state=active]:shadow-sm"
|
||||||
|
>
|
||||||
|
Details
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger
|
||||||
|
value="items"
|
||||||
|
className="data-[state=active]:bg-background rounded-lg py-2.5 data-[state=active]:shadow-sm"
|
||||||
|
>
|
||||||
|
Items
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger
|
||||||
|
value="timesheet"
|
||||||
|
className="data-[state=active]:bg-background rounded-lg py-2.5 data-[state=active]:shadow-sm"
|
||||||
|
>
|
||||||
|
Timesheet
|
||||||
|
</TabsTrigger>
|
||||||
</TabsList>
|
</TabsList>
|
||||||
|
|
||||||
{/* DETAILS TAB */}
|
{/* DETAILS TAB */}
|
||||||
<TabsContent value="details" className="grid grid-cols-1 lg:grid-cols-2 gap-6 focus-visible:outline-none mt-6">
|
<TabsContent
|
||||||
|
value="details"
|
||||||
|
className="mt-6 grid grid-cols-1 gap-6 focus-visible:outline-none lg:grid-cols-2"
|
||||||
|
>
|
||||||
<Card className="h-fit">
|
<Card className="h-fit">
|
||||||
<CardHeader><CardTitle className="flex gap-2 text-base"><User className="w-4 h-4" /> Client Details</CardTitle></CardHeader>
|
<CardHeader>
|
||||||
|
<CardTitle className="flex gap-2 text-base">
|
||||||
|
<User className="h-4 w-4" /> Client Details
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
<CardContent className="space-y-4">
|
<CardContent className="space-y-4">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>Client</Label>
|
<Label>Client</Label>
|
||||||
@@ -281,61 +433,207 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
|
|||||||
onValueChange={(v) => {
|
onValueChange={(v) => {
|
||||||
updateField("clientId", v);
|
updateField("clientId", v);
|
||||||
// Auto-fill Hourly Rate
|
// Auto-fill Hourly Rate
|
||||||
const selectedClient = clients?.find(c => c.id === v);
|
const selectedClient = clients?.find((c) => c.id === v);
|
||||||
const currentBusiness = businesses?.find(b => b.id === formData.businessId);
|
const currentBusiness = businesses?.find(
|
||||||
|
(b) => b.id === formData.businessId,
|
||||||
|
);
|
||||||
// Explicitly prioritize client rate, then business rate, then 0
|
// Explicitly prioritize client rate, then business rate, then 0
|
||||||
const clientRate = selectedClient && 'defaultHourlyRate' in selectedClient ? selectedClient.defaultHourlyRate : null;
|
const clientRate =
|
||||||
const businessRate = currentBusiness && 'defaultHourlyRate' in currentBusiness ? currentBusiness.defaultHourlyRate : null;
|
selectedClient && "defaultHourlyRate" in selectedClient
|
||||||
const rateToSet: number = (clientRate ?? businessRate ?? 0) as number;
|
? selectedClient.defaultHourlyRate
|
||||||
|
: null;
|
||||||
|
const businessRate =
|
||||||
|
currentBusiness &&
|
||||||
|
"defaultHourlyRate" in currentBusiness
|
||||||
|
? currentBusiness.defaultHourlyRate
|
||||||
|
: null;
|
||||||
|
const rateToSet: number = (clientRate ??
|
||||||
|
businessRate ??
|
||||||
|
0) as number;
|
||||||
|
|
||||||
updateField("defaultHourlyRate", rateToSet);
|
updateField("defaultHourlyRate", rateToSet);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<SelectTrigger className="w-full"><SelectValue placeholder="Select Client" /></SelectTrigger>
|
<SelectTrigger className="w-full">
|
||||||
<SelectContent>{clients?.map(c => <SelectItem key={c.id} value={c.id}>{c.name}</SelectItem>)}</SelectContent>
|
<SelectValue placeholder="Select Client" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{clients?.map((c) => (
|
||||||
|
<SelectItem key={c.id} value={c.id}>
|
||||||
|
{c.name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Business</Label>
|
||||||
|
<Select
|
||||||
|
value={formData.businessId}
|
||||||
|
onValueChange={(v) => updateField("businessId", v)}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="w-full">
|
||||||
|
<SelectValue placeholder="Select Business" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{businesses?.map((b) => (
|
||||||
|
<SelectItem key={b.id} value={b.id}>
|
||||||
|
{b.name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2"><Label>Business</Label><Select value={formData.businessId} onValueChange={(v) => updateField("businessId", v)}><SelectTrigger className="w-full"><SelectValue placeholder="Select Business" /></SelectTrigger><SelectContent>{businesses?.map(b => <SelectItem key={b.id} value={b.id}>{b.name}</SelectItem>)}</SelectContent></Select></div>
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card className="h-fit">
|
<Card className="h-fit">
|
||||||
<CardHeader><CardTitle className="flex gap-2 text-base"><Tag className="w-4 h-4" /> Invoice Config</CardTitle></CardHeader>
|
<CardHeader>
|
||||||
|
<CardTitle className="flex gap-2 text-base">
|
||||||
|
<Tag className="h-4 w-4" /> Invoice Config
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
<CardContent className="space-y-4">
|
<CardContent className="space-y-4">
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
<div className="space-y-2"><Label>Issue Date</Label><DatePicker date={formData.issueDate} onDateChange={(d) => updateField("issueDate", d ?? new Date())} className="w-full" /></div>
|
<div className="space-y-2">
|
||||||
<div className="space-y-2"><Label>Due Date</Label><DatePicker date={formData.dueDate} onDateChange={(d) => updateField("dueDate", d ?? new Date())} className="w-full" /></div>
|
<Label>Issue Date</Label>
|
||||||
|
<DatePicker
|
||||||
|
date={formData.issueDate}
|
||||||
|
onDateChange={(d) =>
|
||||||
|
updateField("issueDate", d ?? new Date())
|
||||||
|
}
|
||||||
|
className="w-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Due Date</Label>
|
||||||
|
<DatePicker
|
||||||
|
date={formData.dueDate}
|
||||||
|
onDateChange={(d) =>
|
||||||
|
updateField("dueDate", d ?? new Date())
|
||||||
|
}
|
||||||
|
className="w-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
<div className="space-y-2"><Label>Tax Rate</Label><NumberInput value={formData.taxRate} onChange={(v) => updateField("taxRate", v)} suffix="%" className="w-full" /></div>
|
<div className="space-y-2">
|
||||||
<div className="space-y-2"><Label>Hourly Rate</Label><NumberInput value={formData.defaultHourlyRate ?? 0} onChange={(v) => updateField("defaultHourlyRate", v)} prefix="$" disabled={!formData.clientId} className="w-full" /></div>
|
<Label>Tax Rate</Label>
|
||||||
|
<NumberInput
|
||||||
|
value={formData.taxRate}
|
||||||
|
onChange={(v) => updateField("taxRate", v)}
|
||||||
|
suffix="%"
|
||||||
|
className="w-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Hourly Rate</Label>
|
||||||
|
<NumberInput
|
||||||
|
value={formData.defaultHourlyRate ?? 0}
|
||||||
|
onChange={(v) => updateField("defaultHourlyRate", v)}
|
||||||
|
prefix="$"
|
||||||
|
disabled={!formData.clientId}
|
||||||
|
className="w-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Status</Label>
|
||||||
|
<Select
|
||||||
|
value={formData.status}
|
||||||
|
onValueChange={(v: "draft" | "sent" | "paid") =>
|
||||||
|
updateField("status", v)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="w-full">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{STATUS_OPTIONS.map((o) => (
|
||||||
|
<SelectItem key={o.value} value={o.value}>
|
||||||
|
{o.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2"><Label>Status</Label><Select value={formData.status} onValueChange={(v: "draft" | "sent" | "paid") => updateField("status", v)}><SelectTrigger className="w-full"><SelectValue /></SelectTrigger><SelectContent>{STATUS_OPTIONS.map(o => <SelectItem key={o.value} value={o.value}>{o.label}</SelectItem>)}</SelectContent></Select></div>
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
{/* ITEMS TAB */}
|
{/* ITEMS TAB */}
|
||||||
<TabsContent value="items" className="focus-visible:outline-none mt-6">
|
<TabsContent
|
||||||
<div className="mb-6 grid grid-cols-1 md:grid-cols-3 gap-4">
|
value="items"
|
||||||
<Card className="bg-primary/5 border-primary/20"><CardContent className="p-4 flex justify-between items-center"><span className="text-muted-foreground">Total</span><span className="text-2xl font-bold font-mono"><CountUp value={totals.total} prefix="$" /></span></CardContent></Card>
|
className="mt-6 focus-visible:outline-none"
|
||||||
<Card><CardContent className="p-4 flex justify-between items-center"><span className="text-muted-foreground">Subtotal</span><span className="text-xl font-semibold font-mono"><CountUp value={totals.subtotal} prefix="$" /></span></CardContent></Card>
|
>
|
||||||
<Card><CardContent className="p-4 flex justify-between items-center"><span className="text-muted-foreground">Hours</span><span className="text-xl font-semibold font-mono"><CountUp value={formData.items.reduce((s, i) => s + i.hours, 0)} suffix="h" /></span></CardContent></Card>
|
<div className="mb-6 grid grid-cols-1 gap-4 md:grid-cols-3">
|
||||||
|
<Card className="bg-primary/5 border-primary/20">
|
||||||
|
<CardContent className="flex items-center justify-between p-4">
|
||||||
|
<span className="text-muted-foreground">Total</span>
|
||||||
|
<span className="font-mono text-2xl font-bold">
|
||||||
|
<CountUp value={totals.total} prefix="$" />
|
||||||
|
</span>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<CardContent className="flex items-center justify-between p-4">
|
||||||
|
<span className="text-muted-foreground">Subtotal</span>
|
||||||
|
<span className="font-mono text-xl font-semibold">
|
||||||
|
<CountUp value={totals.subtotal} prefix="$" />
|
||||||
|
</span>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<CardContent className="flex items-center justify-between p-4">
|
||||||
|
<span className="text-muted-foreground">Hours</span>
|
||||||
|
<span className="font-mono text-xl font-semibold">
|
||||||
|
<CountUp
|
||||||
|
value={formData.items.reduce((s, i) => s + i.hours, 0)}
|
||||||
|
suffix="h"
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader><CardTitle className="flex gap-2"><List className="w-5 h-5" /> Invoice Items</CardTitle></CardHeader>
|
<CardHeader>
|
||||||
|
<CardTitle className="flex gap-2">
|
||||||
|
<List className="h-5 w-5" /> Invoice Items
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<InvoiceLineItems items={formData.items} onAddItem={addItem} onRemoveItem={removeItem} onUpdateItem={updateItem} onMoveUp={moveItemUp} onMoveDown={moveItemDown} onReorderItems={reorderItems} />
|
<InvoiceLineItems
|
||||||
|
items={formData.items}
|
||||||
|
onAddItem={addItem}
|
||||||
|
onRemoveItem={removeItem}
|
||||||
|
onUpdateItem={updateItem}
|
||||||
|
onMoveUp={moveItemUp}
|
||||||
|
onMoveDown={moveItemDown}
|
||||||
|
onReorderItems={reorderItems}
|
||||||
|
/>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
{/* TIMESHEET TAB */}
|
{/* TIMESHEET TAB */}
|
||||||
<TabsContent value="timesheet" className="focus-visible:outline-none mt-6">
|
<TabsContent
|
||||||
|
value="timesheet"
|
||||||
|
className="mt-6 focus-visible:outline-none"
|
||||||
|
>
|
||||||
<Card className="min-h-[600px] w-full">
|
<Card className="min-h-[600px] w-full">
|
||||||
<CardHeader><CardTitle className="flex gap-2"><CalendarIcon className="w-5 h-5" /> Timesheet</CardTitle></CardHeader>
|
<CardHeader>
|
||||||
|
<CardTitle className="flex gap-2">
|
||||||
|
<CalendarIcon className="h-5 w-5" /> Timesheet
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
<CardContent className="p-0 sm:p-0">
|
<CardContent className="p-0 sm:p-0">
|
||||||
<InvoiceCalendarView items={formData.items} onAddItem={addItem} onRemoveItem={removeItem} onUpdateItem={updateItem} defaultHourlyRate={formData.defaultHourlyRate} />
|
<InvoiceCalendarView
|
||||||
|
items={formData.items}
|
||||||
|
onAddItem={addItem}
|
||||||
|
onRemoveItem={removeItem}
|
||||||
|
onUpdateItem={updateItem}
|
||||||
|
defaultHourlyRate={formData.defaultHourlyRate}
|
||||||
|
/>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
@@ -343,7 +641,23 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Dialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
|
<Dialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
|
||||||
<DialogContent><DialogHeader><DialogTitle>Delete?</DialogTitle><DialogDescription>Cannot be undone.</DialogDescription></DialogHeader><DialogFooter><Button variant="outline" onClick={() => setDeleteDialogOpen(false)}>Cancel</Button><Button variant="destructive" onClick={confirmDelete}>Delete</Button></DialogFooter></DialogContent>
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Delete?</DialogTitle>
|
||||||
|
<DialogDescription>Cannot be undone.</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => setDeleteDialogOpen(false)}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button variant="destructive" onClick={confirmDelete}>
|
||||||
|
Delete
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user