mirror of
https://github.com/soconnor0919/beenvoice.git
synced 2025-12-13 01:24:44 -05:00
Make hourly rate optional for clients and invoices
This commit is contained in:
@@ -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>
|
||||
))}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user