Make hourly rate optional for clients and invoices

This commit is contained in:
2025-07-31 19:11:20 -04:00
parent 817689001c
commit d9515f7723
6 changed files with 104 additions and 40 deletions

View File

@@ -251,9 +251,15 @@ export function DataTable<TData, TValue>({
</div> </div>
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="all">All {column.title}</SelectItem> <SelectItem value="all" className="gap-0">
All {column.title}
</SelectItem>
{column.options.map((option) => ( {column.options.map((option) => (
<SelectItem key={option.value} value={option.value}> <SelectItem
key={option.value}
value={option.value}
className="gap-0"
>
{option.label} {option.label}
</SelectItem> </SelectItem>
))} ))}

View File

@@ -44,7 +44,7 @@ interface FormData {
state: string; state: string;
postalCode: string; postalCode: string;
country: string; country: string;
defaultHourlyRate: number; defaultHourlyRate: number | null;
} }
interface FormErrors { interface FormErrors {
@@ -69,7 +69,7 @@ const initialFormData: FormData = {
state: "", state: "",
postalCode: "", postalCode: "",
country: "United States", country: "United States",
defaultHourlyRate: 100, defaultHourlyRate: null,
}; };
export function ClientForm({ clientId, mode }: ClientFormProps) { export function ClientForm({ clientId, mode }: ClientFormProps) {
@@ -119,12 +119,12 @@ export function ClientForm({ clientId, mode }: ClientFormProps) {
state: client.state ?? "", state: client.state ?? "",
postalCode: client.postalCode ?? "", postalCode: client.postalCode ?? "",
country: client.country ?? "United States", country: client.country ?? "United States",
defaultHourlyRate: client.defaultHourlyRate ?? 100, defaultHourlyRate: client.defaultHourlyRate ?? null,
}); });
} }
}, [client, mode]); }, [client, mode]);
const handleInputChange = (field: string, value: string | number) => { const handleInputChange = (field: string, value: string | number | null) => {
setFormData((prev) => ({ ...prev, [field]: value })); setFormData((prev) => ({ ...prev, [field]: value }));
setIsDirty(true); setIsDirty(true);
@@ -195,12 +195,17 @@ export function ClientForm({ clientId, mode }: ClientFormProps) {
setIsSubmitting(true); setIsSubmitting(true);
try { try {
const apiData = {
...formData,
defaultHourlyRate: formData.defaultHourlyRate ?? undefined,
};
if (mode === "create") { if (mode === "create") {
await createClient.mutateAsync(formData); await createClient.mutateAsync(apiData);
} else { } else {
await updateClient.mutateAsync({ await updateClient.mutateAsync({
id: clientId!, id: clientId!,
...formData, ...apiData,
}); });
} }
} finally { } finally {
@@ -290,7 +295,7 @@ export function ClientForm({ clientId, mode }: ClientFormProps) {
<Card className="bg-card border-border border"> <Card className="bg-card border-border border">
<CardHeader> <CardHeader>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className="bg-primary/10 flex h-10 w-10 items-center justify-center "> <div className="bg-primary/10 flex h-10 w-10 items-center justify-center">
<UserPlus className="text-primary h-5 w-5" /> <UserPlus className="text-primary h-5 w-5" />
</div> </div>
<div> <div>
@@ -371,7 +376,7 @@ export function ClientForm({ clientId, mode }: ClientFormProps) {
<Card className="bg-card border-border border"> <Card className="bg-card border-border border">
<CardHeader> <CardHeader>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className="bg-primary/10 flex h-10 w-10 items-center justify-center "> <div className="bg-primary/10 flex h-10 w-10 items-center justify-center">
<svg <svg
className="text-primary h-5 w-5" className="text-primary h-5 w-5"
fill="none" fill="none"
@@ -419,7 +424,7 @@ export function ClientForm({ clientId, mode }: ClientFormProps) {
<Card className="bg-card border-border border"> <Card className="bg-card border-border border">
<CardHeader> <CardHeader>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className="bg-primary/10 flex h-10 w-10 items-center justify-center "> <div className="bg-primary/10 flex h-10 w-10 items-center justify-center">
<DollarSign className="text-primary h-5 w-5" /> <DollarSign className="text-primary h-5 w-5" />
</div> </div>
<div> <div>
@@ -436,18 +441,26 @@ export function ClientForm({ clientId, mode }: ClientFormProps) {
htmlFor="defaultHourlyRate" htmlFor="defaultHourlyRate"
className="text-sm font-medium" className="text-sm font-medium"
> >
Default Hourly Rate Default Hourly Rate (Optional)
</Label> </Label>
<p className="text-muted-foreground mb-2 text-xs">
This rate will be used as the default when creating new
invoice items for this client.
</p>
<NumberInput <NumberInput
value={formData.defaultHourlyRate} value={formData.defaultHourlyRate ?? 0}
onChange={(value) => onChange={(value) =>
handleInputChange("defaultHourlyRate", value) handleInputChange(
"defaultHourlyRate",
value === 0 ? null : value,
)
} }
min={0} min={0}
step={1} step={1}
prefix="$" prefix="$"
width="full" width="full"
disabled={isSubmitting} disabled={isSubmitting}
placeholder="0.00"
/> />
{errors.defaultHourlyRate && ( {errors.defaultHourlyRate && (
<p className="text-destructive text-sm"> <p className="text-destructive text-sm">
@@ -464,7 +477,7 @@ export function ClientForm({ clientId, mode }: ClientFormProps) {
<FloatingActionBar <FloatingActionBar
leftContent={ leftContent={
<div className="flex items-center space-x-3"> <div className="flex items-center space-x-3">
<div className="bg-primary/10 p-2"> <div className="bg-primary/10 p-2">
<FileText className="text-primary h-5 w-5" /> <FileText className="text-primary h-5 w-5" />
</div> </div>
<div> <div>

View File

@@ -25,6 +25,7 @@ import { InvoiceLineItems } from "./invoice-line-items";
import { api } from "~/trpc/react"; import { api } from "~/trpc/react";
import { toast } from "sonner"; import { toast } from "sonner";
import { FileText, DollarSign, Check, Save, Clock, Trash2 } from "lucide-react"; import { FileText, DollarSign, Check, Save, Clock, Trash2 } from "lucide-react";
import { cn } from "~/lib/utils";
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
@@ -62,7 +63,7 @@ interface FormData {
status: "draft" | "sent" | "paid"; status: "draft" | "sent" | "paid";
notes: string; notes: string;
taxRate: number; taxRate: number;
defaultHourlyRate: number; defaultHourlyRate: number | null;
items: InvoiceItem[]; items: InvoiceItem[];
} }
@@ -100,15 +101,15 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
status: "draft", status: "draft",
notes: "", notes: "",
taxRate: 0, taxRate: 0,
defaultHourlyRate: 25, defaultHourlyRate: null,
items: [ items: [
{ {
id: crypto.randomUUID(), id: crypto.randomUUID(),
date: new Date(), date: new Date(),
description: "", description: "",
hours: 1, hours: 1,
rate: 25, rate: 0,
amount: 25, amount: 0,
}, },
], ],
}); });
@@ -160,7 +161,7 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
status: existingInvoice.status as "draft" | "sent" | "paid", status: existingInvoice.status as "draft" | "sent" | "paid",
notes: existingInvoice.notes ?? "", notes: existingInvoice.notes ?? "",
taxRate: existingInvoice.taxRate, taxRate: existingInvoice.taxRate,
defaultHourlyRate: 25, defaultHourlyRate: null,
items: items:
existingInvoice.items?.map((item) => ({ existingInvoice.items?.map((item) => ({
id: crypto.randomUUID(), id: crypto.randomUUID(),
@@ -194,18 +195,18 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
initialized, initialized,
]); ]);
// Update default hourly rate when client changes (only during initialization) // Update default hourly rate when client changes
useEffect(() => { useEffect(() => {
if (!initialized || !formData.clientId || !clients) return; if (!formData.clientId || !clients) return;
const selectedClient = clients.find((c) => c.id === formData.clientId); const selectedClient = clients.find((c) => c.id === formData.clientId);
if (selectedClient?.defaultHourlyRate) { if (selectedClient?.defaultHourlyRate != null) {
setFormData((prev) => ({ setFormData((prev) => ({
...prev, ...prev,
defaultHourlyRate: selectedClient.defaultHourlyRate, defaultHourlyRate: selectedClient.defaultHourlyRate,
})); }));
} }
}, [formData.clientId, clients, initialized]); }, [formData.clientId, clients]);
// Calculate totals // Calculate totals
const totals = React.useMemo(() => { const totals = React.useMemo(() => {
@@ -229,8 +230,8 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
date: new Date(), date: new Date(),
description: "", description: "",
hours: 1, hours: 1,
rate: prev.defaultHourlyRate, rate: prev.defaultHourlyRate ?? 0,
amount: prev.defaultHourlyRate, amount: prev.defaultHourlyRate ?? 0,
}, },
], ],
})); }));
@@ -623,18 +624,62 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="defaultHourlyRate"> <Label htmlFor="defaultHourlyRate">
Default Hourly Rate Default Hourly Rate for New Items
</Label> </Label>
<NumberInput <p
value={formData.defaultHourlyRate} className={cn(
onChange={(value) => "mb-2 text-xs",
updateField("defaultHourlyRate", value) formData.clientId &&
} clients?.find((c) => c.id === formData.clientId)
min={0} ?.defaultHourlyRate
step={1} ? "text-green-600"
prefix="$" : "text-muted-foreground",
width="full" )}
/> >
{formData.clientId &&
clients?.find((c) => c.id === formData.clientId)
?.defaultHourlyRate
? `✓ Inherited from ${clients.find((c) => c.id === formData.clientId)?.name}: $${clients.find((c) => c.id === formData.clientId)?.defaultHourlyRate}/hour`
: formData.clientId
? "Client has no default rate set - enter rate manually"
: "Select a client first, or enter rate manually"}
</p>
<div className="relative">
<NumberInput
value={formData.defaultHourlyRate ?? 0}
onChange={(value) =>
updateField("defaultHourlyRate", value)
}
min={0}
step={1}
prefix="$"
width="full"
className={cn(
formData.clientId &&
clients?.find(
(c) => c.id === formData.clientId,
)?.defaultHourlyRate
? "border-green-200 bg-green-50/50"
: "",
)}
placeholder={
formData.clientId &&
clients?.find((c) => c.id === formData.clientId)
?.defaultHourlyRate
? "Inherited from client"
: "Enter hourly rate"
}
/>
{formData.clientId &&
clients?.find((c) => c.id === formData.clientId)
?.defaultHourlyRate && (
<div className="absolute top-1/2 right-3 -translate-y-1/2">
<span className="text-xs text-green-600">
</span>
</div>
)}
</div>
</div> </div>
</div> </div>

View File

@@ -7,7 +7,7 @@ function Card({ className, ...props }: React.ComponentProps<"div">) {
<div <div
data-slot="card" data-slot="card"
className={cn( className={cn(
"bg-card text-card-foreground border-border/40 flex flex-col border shadow-lg backdrop-blur-xl backdrop-saturate-150", "bg-card text-card-foreground border-border/40 flex flex-col border shadow-lg",
className, className,
)} )}
{...props} {...props}

View File

@@ -42,7 +42,7 @@ const createClientSchema = z.object({
.max(100, "Country name is too long") .max(100, "Country name is too long")
.optional() .optional()
.or(z.literal("")), .or(z.literal("")),
defaultHourlyRate: z.number().min(0, "Rate must be positive").default(100), defaultHourlyRate: z.number().min(0, "Rate must be positive").optional(),
}); });
const updateClientSchema = createClientSchema.partial().extend({ const updateClientSchema = createClientSchema.partial().extend({

View File

@@ -108,7 +108,7 @@ export const clients = createTable(
state: d.varchar({ length: 50 }), state: d.varchar({ length: 50 }),
postalCode: d.varchar({ length: 20 }), postalCode: d.varchar({ length: 20 }),
country: d.varchar({ length: 100 }), country: d.varchar({ length: 100 }),
defaultHourlyRate: d.real().notNull().default(100.0), defaultHourlyRate: d.real(),
createdById: d createdById: d
.varchar({ length: 255 }) .varchar({ length: 255 })
.notNull() .notNull()