61733979cb
The changes add a new defaultHourlyRate field to clients, including form updates and automatic rate propagation to invoices. Add default hourly rate for clients The subject line you provided already clearly expresses the changes, and no additional context is needed in the body, so I'll keep just the subject line.
479 lines
16 KiB
TypeScript
479 lines
16 KiB
TypeScript
"use client";
|
|
|
|
import { UserPlus, Save, Loader2, ArrowLeft, DollarSign } from "lucide-react";
|
|
|
|
import { useRouter } from "next/navigation";
|
|
import { useEffect, useState, useRef } from "react";
|
|
import { toast } from "sonner";
|
|
import { Button } from "~/components/ui/button";
|
|
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
|
|
import { Input } from "~/components/ui/input";
|
|
import { Label } from "~/components/ui/label";
|
|
import { FormSkeleton } from "~/components/ui/skeleton";
|
|
import { AddressForm } from "~/components/forms/address-form";
|
|
import { FloatingActionBar } from "~/components/layout/floating-action-bar";
|
|
import { NumberInput } from "~/components/ui/number-input";
|
|
import { api } from "~/trpc/react";
|
|
import {
|
|
formatPhoneNumber,
|
|
isValidEmail,
|
|
VALIDATION_MESSAGES,
|
|
PLACEHOLDERS,
|
|
} from "~/lib/form-constants";
|
|
|
|
interface ClientFormProps {
|
|
clientId?: string;
|
|
mode: "create" | "edit";
|
|
}
|
|
|
|
interface FormData {
|
|
name: string;
|
|
email: string;
|
|
phone: string;
|
|
addressLine1: string;
|
|
addressLine2: string;
|
|
city: string;
|
|
state: string;
|
|
postalCode: string;
|
|
country: string;
|
|
defaultHourlyRate: number;
|
|
}
|
|
|
|
interface FormErrors {
|
|
name?: string;
|
|
email?: string;
|
|
phone?: string;
|
|
addressLine1?: string;
|
|
city?: string;
|
|
state?: string;
|
|
postalCode?: string;
|
|
country?: string;
|
|
defaultHourlyRate?: string;
|
|
}
|
|
|
|
const initialFormData: FormData = {
|
|
name: "",
|
|
email: "",
|
|
phone: "",
|
|
addressLine1: "",
|
|
addressLine2: "",
|
|
city: "",
|
|
state: "",
|
|
postalCode: "",
|
|
country: "United States",
|
|
defaultHourlyRate: 100,
|
|
};
|
|
|
|
export function ClientForm({ clientId, mode }: ClientFormProps) {
|
|
const router = useRouter();
|
|
const [formData, setFormData] = useState<FormData>(initialFormData);
|
|
const [errors, setErrors] = useState<FormErrors>({});
|
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
|
const [isDirty, setIsDirty] = useState(false);
|
|
const footerRef = useRef<HTMLDivElement>(null);
|
|
|
|
// Fetch client data if editing
|
|
const { data: client, isLoading: isLoadingClient } =
|
|
api.clients.getById.useQuery(
|
|
{ id: clientId! },
|
|
{ enabled: mode === "edit" && !!clientId },
|
|
);
|
|
|
|
const createClient = api.clients.create.useMutation({
|
|
onSuccess: () => {
|
|
toast.success("Client created successfully");
|
|
router.push("/dashboard/clients");
|
|
},
|
|
onError: (error) => {
|
|
toast.error(error.message || "Failed to create client");
|
|
},
|
|
});
|
|
|
|
const updateClient = api.clients.update.useMutation({
|
|
onSuccess: () => {
|
|
toast.success("Client updated successfully");
|
|
router.push("/dashboard/clients");
|
|
},
|
|
onError: (error) => {
|
|
toast.error(error.message || "Failed to update client");
|
|
},
|
|
});
|
|
|
|
// Load client data when editing
|
|
useEffect(() => {
|
|
if (client && mode === "edit") {
|
|
setFormData({
|
|
name: client.name,
|
|
email: client.email ?? "",
|
|
phone: client.phone ?? "",
|
|
addressLine1: client.addressLine1 ?? "",
|
|
addressLine2: client.addressLine2 ?? "",
|
|
city: client.city ?? "",
|
|
state: client.state ?? "",
|
|
postalCode: client.postalCode ?? "",
|
|
country: client.country ?? "United States",
|
|
defaultHourlyRate: client.defaultHourlyRate ?? 100,
|
|
});
|
|
}
|
|
}, [client, mode]);
|
|
|
|
const handleInputChange = (field: string, value: string | number) => {
|
|
setFormData((prev) => ({ ...prev, [field]: value }));
|
|
setIsDirty(true);
|
|
|
|
// Clear error for this field when user starts typing
|
|
if (errors[field as keyof FormErrors]) {
|
|
setErrors((prev) => ({ ...prev, [field]: undefined }));
|
|
}
|
|
};
|
|
|
|
const handlePhoneChange = (value: string) => {
|
|
const formatted = formatPhoneNumber(value);
|
|
handleInputChange("phone", formatted);
|
|
};
|
|
|
|
const validateForm = (): boolean => {
|
|
const newErrors: FormErrors = {};
|
|
|
|
// Required fields
|
|
if (!formData.name.trim()) {
|
|
newErrors.name = VALIDATION_MESSAGES.required;
|
|
}
|
|
|
|
// Email validation
|
|
if (formData.email && !isValidEmail(formData.email)) {
|
|
newErrors.email = VALIDATION_MESSAGES.email;
|
|
}
|
|
|
|
// Phone validation (basic check for US format)
|
|
if (formData.phone) {
|
|
const phoneDigits = formData.phone.replace(/\D/g, "");
|
|
if (phoneDigits.length > 0 && phoneDigits.length < 10) {
|
|
newErrors.phone = VALIDATION_MESSAGES.phone;
|
|
}
|
|
}
|
|
|
|
// Address validation if any address field is filled
|
|
const hasAddressData =
|
|
formData.addressLine1 ||
|
|
formData.city ||
|
|
formData.state ||
|
|
formData.postalCode;
|
|
|
|
if (hasAddressData) {
|
|
if (!formData.addressLine1)
|
|
newErrors.addressLine1 = VALIDATION_MESSAGES.required;
|
|
if (!formData.city) newErrors.city = VALIDATION_MESSAGES.required;
|
|
if (!formData.country) newErrors.country = VALIDATION_MESSAGES.required;
|
|
|
|
if (formData.country === "US") {
|
|
if (!formData.state) newErrors.state = VALIDATION_MESSAGES.required;
|
|
if (!formData.postalCode)
|
|
newErrors.postalCode = VALIDATION_MESSAGES.required;
|
|
}
|
|
}
|
|
|
|
setErrors(newErrors);
|
|
return Object.keys(newErrors).length === 0;
|
|
};
|
|
|
|
const handleSubmit = async (e: React.FormEvent) => {
|
|
e.preventDefault();
|
|
|
|
if (!validateForm()) {
|
|
toast.error("Please correct the errors in the form");
|
|
return;
|
|
}
|
|
|
|
setIsSubmitting(true);
|
|
|
|
try {
|
|
if (mode === "create") {
|
|
await createClient.mutateAsync(formData);
|
|
} else {
|
|
await updateClient.mutateAsync({
|
|
id: clientId!,
|
|
...formData,
|
|
});
|
|
}
|
|
} finally {
|
|
setIsSubmitting(false);
|
|
}
|
|
};
|
|
|
|
const handleCancel = () => {
|
|
if (isDirty) {
|
|
const confirmed = window.confirm(
|
|
"You have unsaved changes. Are you sure you want to leave?",
|
|
);
|
|
if (!confirmed) return;
|
|
}
|
|
router.push("/dashboard/clients");
|
|
};
|
|
|
|
if (mode === "edit" && isLoadingClient) {
|
|
return <FormSkeleton />;
|
|
}
|
|
|
|
return (
|
|
<div className="mx-auto max-w-6xl">
|
|
<form onSubmit={handleSubmit} className="space-y-6">
|
|
{/* Main Form Container - styled like data table */}
|
|
<div className="space-y-4">
|
|
{/* Basic Information */}
|
|
<Card>
|
|
<CardHeader>
|
|
<div className="flex items-center gap-3">
|
|
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-gradient-to-r from-emerald-600/10 to-teal-600/10">
|
|
<UserPlus className="h-5 w-5 text-emerald-700 dark:text-emerald-400" />
|
|
</div>
|
|
<div>
|
|
<CardTitle>Basic Information</CardTitle>
|
|
<p className="text-muted-foreground mt-1 text-sm">
|
|
Enter the client's primary details
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</CardHeader>
|
|
<CardContent className="space-y-4">
|
|
<div className="space-y-2">
|
|
<Label htmlFor="name" className="text-sm font-medium">
|
|
Client Name<span className="text-destructive ml-1">*</span>
|
|
</Label>
|
|
<Input
|
|
id="name"
|
|
value={formData.name}
|
|
onChange={(e) => handleInputChange("name", e.target.value)}
|
|
placeholder={PLACEHOLDERS.name}
|
|
className={`${errors.name ? "border-destructive" : ""}`}
|
|
disabled={isSubmitting}
|
|
/>
|
|
{errors.name && (
|
|
<p className="text-destructive text-sm">{errors.name}</p>
|
|
)}
|
|
</div>
|
|
|
|
<div className="grid gap-4 sm:grid-cols-2">
|
|
<div className="space-y-2">
|
|
<Label htmlFor="email" className="text-sm font-medium">
|
|
Email
|
|
<span className="text-muted-foreground ml-1 text-xs font-normal">
|
|
(Optional)
|
|
</span>
|
|
</Label>
|
|
<Input
|
|
id="email"
|
|
type="email"
|
|
value={formData.email}
|
|
onChange={(e) => handleInputChange("email", e.target.value)}
|
|
placeholder={PLACEHOLDERS.email}
|
|
className={`${errors.email ? "border-destructive" : ""}`}
|
|
disabled={isSubmitting}
|
|
/>
|
|
{errors.email && (
|
|
<p className="text-destructive text-sm">{errors.email}</p>
|
|
)}
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<Label htmlFor="phone" className="text-sm font-medium">
|
|
Phone
|
|
<span className="text-muted-foreground ml-1 text-xs font-normal">
|
|
(Optional)
|
|
</span>
|
|
</Label>
|
|
<Input
|
|
id="phone"
|
|
type="tel"
|
|
value={formData.phone}
|
|
onChange={(e) => handlePhoneChange(e.target.value)}
|
|
placeholder={PLACEHOLDERS.phone}
|
|
className={`${errors.phone ? "border-destructive" : ""}`}
|
|
disabled={isSubmitting}
|
|
/>
|
|
{errors.phone && (
|
|
<p className="text-destructive text-sm">{errors.phone}</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* Address */}
|
|
<Card>
|
|
<CardHeader>
|
|
<div className="flex items-center gap-3">
|
|
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-gradient-to-r from-emerald-600/10 to-teal-600/10">
|
|
<svg
|
|
className="h-5 w-5 text-emerald-700 dark:text-emerald-400"
|
|
fill="none"
|
|
viewBox="0 0 24 24"
|
|
stroke="currentColor"
|
|
>
|
|
<path
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
strokeWidth={2}
|
|
d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z"
|
|
/>
|
|
<path
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
strokeWidth={2}
|
|
d="M15 11a3 3 0 11-6 0 3 3 0 016 0z"
|
|
/>
|
|
</svg>
|
|
</div>
|
|
<div>
|
|
<CardTitle>Address</CardTitle>
|
|
<p className="text-muted-foreground mt-1 text-sm">
|
|
Client's physical location
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<AddressForm
|
|
addressLine1={formData.addressLine1}
|
|
addressLine2={formData.addressLine2}
|
|
city={formData.city}
|
|
state={formData.state}
|
|
postalCode={formData.postalCode}
|
|
country={formData.country}
|
|
onChange={handleInputChange}
|
|
errors={errors}
|
|
required={false}
|
|
/>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* Billing Information */}
|
|
<Card>
|
|
<CardHeader>
|
|
<div className="flex items-center gap-3">
|
|
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-gradient-to-r from-emerald-600/10 to-teal-600/10">
|
|
<DollarSign className="h-5 w-5 text-emerald-700 dark:text-emerald-400" />
|
|
</div>
|
|
<div>
|
|
<CardTitle>Billing Information</CardTitle>
|
|
<p className="text-muted-foreground mt-1 text-sm">
|
|
Default billing rates for this client
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</CardHeader>
|
|
<CardContent className="space-y-4">
|
|
<div className="space-y-2">
|
|
<Label
|
|
htmlFor="defaultHourlyRate"
|
|
className="text-sm font-medium"
|
|
>
|
|
Default Hourly Rate
|
|
</Label>
|
|
<NumberInput
|
|
value={formData.defaultHourlyRate}
|
|
onChange={(value) =>
|
|
handleInputChange("defaultHourlyRate", value)
|
|
}
|
|
min={0}
|
|
step={1}
|
|
prefix="$"
|
|
width="full"
|
|
disabled={isSubmitting}
|
|
/>
|
|
{errors.defaultHourlyRate && (
|
|
<p className="text-destructive text-sm">
|
|
{errors.defaultHourlyRate}
|
|
</p>
|
|
)}
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
|
|
{/* Form Actions - original position */}
|
|
<div
|
|
ref={footerRef}
|
|
className="border-border/40 bg-background/60 flex items-center justify-between rounded-2xl border p-4 shadow-lg backdrop-blur-xl backdrop-saturate-150"
|
|
>
|
|
<p className="text-muted-foreground text-sm">
|
|
{mode === "create"
|
|
? "Creating a new client"
|
|
: "Editing client details"}
|
|
</p>
|
|
<div className="flex items-center gap-3">
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
onClick={handleCancel}
|
|
disabled={isSubmitting}
|
|
className="border-border/40 hover:bg-accent/50"
|
|
>
|
|
<ArrowLeft className="mr-2 h-4 w-4" />
|
|
Cancel
|
|
</Button>
|
|
<Button
|
|
type="submit"
|
|
disabled={isSubmitting || !isDirty}
|
|
className="bg-gradient-to-r from-emerald-600 to-teal-600 shadow-md transition-all duration-200 hover:from-emerald-700 hover:to-teal-700 hover:shadow-lg"
|
|
>
|
|
{isSubmitting ? (
|
|
<>
|
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
|
{mode === "create" ? "Creating..." : "Saving..."}
|
|
</>
|
|
) : (
|
|
<>
|
|
<Save className="mr-2 h-4 w-4" />
|
|
{mode === "create" ? "Create Client" : "Save Changes"}
|
|
</>
|
|
)}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</form>
|
|
|
|
<FloatingActionBar
|
|
triggerRef={footerRef}
|
|
title={
|
|
mode === "create" ? "Creating a new client" : "Editing client details"
|
|
}
|
|
>
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
onClick={handleCancel}
|
|
disabled={isSubmitting}
|
|
className="border-border/40 hover:bg-accent/50"
|
|
size="sm"
|
|
>
|
|
<ArrowLeft className="h-4 w-4 sm:mr-2" />
|
|
<span className="hidden sm:inline">Cancel</span>
|
|
</Button>
|
|
<Button
|
|
onClick={handleSubmit}
|
|
disabled={isSubmitting || !isDirty}
|
|
className="bg-gradient-to-r from-emerald-600 to-teal-600 shadow-md transition-all duration-200 hover:from-emerald-700 hover:to-teal-700 hover:shadow-lg"
|
|
size="sm"
|
|
>
|
|
{isSubmitting ? (
|
|
<>
|
|
<Loader2 className="h-4 w-4 animate-spin sm:mr-2" />
|
|
<span className="hidden sm:inline">
|
|
{mode === "create" ? "Creating..." : "Saving..."}
|
|
</span>
|
|
</>
|
|
) : (
|
|
<>
|
|
<Save className="h-4 w-4 sm:mr-2" />
|
|
<span className="hidden sm:inline">
|
|
{mode === "create" ? "Create Client" : "Save Changes"}
|
|
</span>
|
|
</>
|
|
)}
|
|
</Button>
|
|
</FloatingActionBar>
|
|
</div>
|
|
);
|
|
}
|