b5784061eb
The commit makes several updates to the client-related pages in the dashboard: - Convert client edit/new pages to client components - Remove server-side rendering wrappers - Update client detail page styling and layout - Add back button to client detail page - Fix ID param handling in edit page - Adjust visual styles and spacing I focused on capturing the key changes while staying within the 50 character limit for the subject line and using the imperative mood. The subject line alone adequately describes the core change without needing a message body.
521 lines
17 KiB
TypeScript
521 lines
17 KiB
TypeScript
"use client";
|
|
|
|
import {
|
|
UserPlus,
|
|
Save,
|
|
Loader2,
|
|
ArrowLeft,
|
|
DollarSign,
|
|
FileText,
|
|
} from "lucide-react";
|
|
|
|
import { useRouter } from "next/navigation";
|
|
import { useEffect, useState } 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 { Skeleton } from "~/components/ui/skeleton";
|
|
import { AddressForm } from "~/components/forms/address-form";
|
|
import { FloatingActionBar } from "~/components/layout/floating-action-bar";
|
|
import { PageHeader } from "~/components/layout/page-header";
|
|
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);
|
|
|
|
// 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 (
|
|
<div className="space-y-6 pb-32">
|
|
<Card>
|
|
<CardHeader>
|
|
<Skeleton className="h-6 w-32" />
|
|
</CardHeader>
|
|
<CardContent className="space-y-4">
|
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
|
<div className="space-y-2">
|
|
<Skeleton className="h-4 w-16" />
|
|
<Skeleton className="h-10 w-full" />
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Skeleton className="h-4 w-16" />
|
|
<Skeleton className="h-10 w-full" />
|
|
</div>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Skeleton className="h-4 w-16" />
|
|
<Skeleton className="h-10 w-full" />
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<>
|
|
<div className="space-y-6 pb-32">
|
|
<PageHeader
|
|
title={mode === "edit" ? "Edit Client" : "Add Client"}
|
|
description={
|
|
mode === "edit"
|
|
? "Update client information below"
|
|
: "Enter client details below to add a new client."
|
|
}
|
|
variant="gradient"
|
|
>
|
|
<Button
|
|
type="submit"
|
|
form="client-form"
|
|
disabled={isSubmitting}
|
|
className="btn-brand-primary shadow-md"
|
|
>
|
|
{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>
|
|
</PageHeader>
|
|
|
|
<form id="client-form" onSubmit={handleSubmit} className="space-y-6">
|
|
{/* Main Form Container - styled like data table */}
|
|
<div className="space-y-4">
|
|
{/* Basic Information */}
|
|
<Card className="card-primary">
|
|
<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 className="card-primary">
|
|
<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 className="card-primary">
|
|
<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>
|
|
</div>
|
|
|
|
<FloatingActionBar
|
|
leftContent={
|
|
<div className="flex items-center space-x-3">
|
|
<div className="rounded-lg bg-blue-100 p-2 dark:bg-blue-900/30">
|
|
<FileText className="h-5 w-5 text-blue-600 dark:text-blue-400" />
|
|
</div>
|
|
<div>
|
|
<p className="font-medium text-gray-900 dark:text-gray-100">
|
|
{mode === "create"
|
|
? "Creating a new client"
|
|
: "Editing client details"}
|
|
</p>
|
|
<p className="text-sm text-gray-600 dark:text-gray-300">
|
|
{mode === "create"
|
|
? "Complete the form to create your client"
|
|
: "Update your client information"}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
}
|
|
>
|
|
<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>
|
|
</>
|
|
);
|
|
}
|