mirror of
https://github.com/soconnor0919/beenvoice.git
synced 2025-12-15 10:34:43 -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>
|
</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>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
Reference in New Issue
Block a user