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>
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All {column.title}</SelectItem>
<SelectItem value="all" className="gap-0">
All {column.title}
</SelectItem>
{column.options.map((option) => (
<SelectItem key={option.value} value={option.value}>
<SelectItem
key={option.value}
value={option.value}
className="gap-0"
>
{option.label}
</SelectItem>
))}

View File

@@ -44,7 +44,7 @@ interface FormData {
state: string;
postalCode: string;
country: string;
defaultHourlyRate: number;
defaultHourlyRate: number | null;
}
interface FormErrors {
@@ -69,7 +69,7 @@ const initialFormData: FormData = {
state: "",
postalCode: "",
country: "United States",
defaultHourlyRate: 100,
defaultHourlyRate: null,
};
export function ClientForm({ clientId, mode }: ClientFormProps) {
@@ -119,12 +119,12 @@ export function ClientForm({ clientId, mode }: ClientFormProps) {
state: client.state ?? "",
postalCode: client.postalCode ?? "",
country: client.country ?? "United States",
defaultHourlyRate: client.defaultHourlyRate ?? 100,
defaultHourlyRate: client.defaultHourlyRate ?? null,
});
}
}, [client, mode]);
const handleInputChange = (field: string, value: string | number) => {
const handleInputChange = (field: string, value: string | number | null) => {
setFormData((prev) => ({ ...prev, [field]: value }));
setIsDirty(true);
@@ -195,12 +195,17 @@ export function ClientForm({ clientId, mode }: ClientFormProps) {
setIsSubmitting(true);
try {
const apiData = {
...formData,
defaultHourlyRate: formData.defaultHourlyRate ?? undefined,
};
if (mode === "create") {
await createClient.mutateAsync(formData);
await createClient.mutateAsync(apiData);
} else {
await updateClient.mutateAsync({
id: clientId!,
...formData,
...apiData,
});
}
} finally {
@@ -290,7 +295,7 @@ export function ClientForm({ clientId, mode }: ClientFormProps) {
<Card className="bg-card border-border border">
<CardHeader>
<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" />
</div>
<div>
@@ -371,7 +376,7 @@ export function ClientForm({ clientId, mode }: ClientFormProps) {
<Card className="bg-card border-border border">
<CardHeader>
<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
className="text-primary h-5 w-5"
fill="none"
@@ -419,7 +424,7 @@ export function ClientForm({ clientId, mode }: ClientFormProps) {
<Card className="bg-card border-border border">
<CardHeader>
<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" />
</div>
<div>
@@ -436,18 +441,26 @@ export function ClientForm({ clientId, mode }: ClientFormProps) {
htmlFor="defaultHourlyRate"
className="text-sm font-medium"
>
Default Hourly Rate
Default Hourly Rate (Optional)
</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
value={formData.defaultHourlyRate}
value={formData.defaultHourlyRate ?? 0}
onChange={(value) =>
handleInputChange("defaultHourlyRate", value)
handleInputChange(
"defaultHourlyRate",
value === 0 ? null : value,
)
}
min={0}
step={1}
prefix="$"
width="full"
disabled={isSubmitting}
placeholder="0.00"
/>
{errors.defaultHourlyRate && (
<p className="text-destructive text-sm">
@@ -464,7 +477,7 @@ export function ClientForm({ clientId, mode }: ClientFormProps) {
<FloatingActionBar
leftContent={
<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" />
</div>
<div>

View File

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

View File

@@ -7,7 +7,7 @@ function Card({ className, ...props }: React.ComponentProps<"div">) {
<div
data-slot="card"
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,
)}
{...props}

View File

@@ -42,7 +42,7 @@ const createClientSchema = z.object({
.max(100, "Country name is too long")
.optional()
.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({

View File

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