feat: improve invoice view responsiveness and settings UX

- Replace custom invoice items table with responsive DataTable component
- Fix server/client component error by creating InvoiceItemsTable client
  component
- Merge danger zone with actions sidebar and use destructive button
  variant
- Standardize button text sizing across all action buttons
- Remove false claims from homepage (testimonials, ratings, fake user
  counts)
- Focus homepage messaging on freelancers with honest feature
  descriptions
- Fix dark mode support throughout app by replacing hard-coded colors
  with semantic classes
- Remove aggressive red styling from settings, add subtle red accents
  only
- Align import/export buttons and improve delete confirmation UX
- Update dark mode background to have subtle green tint instead of pure
  black
- Fix HTML nesting error in AlertDialog by using div instead of nested p
  tags

This update makes the invoice view properly responsive, removes
misleading marketing claims, and ensures consistent dark mode support
across the entire application.
This commit is contained in:
2025-07-15 02:35:55 -04:00
parent f331136090
commit c9a664869c
71 changed files with 2795 additions and 3043 deletions
+230
View File
@@ -0,0 +1,230 @@
"use client";
import { MapPin } from "lucide-react";
import { Input } from "~/components/ui/input";
import { Label } from "~/components/ui/label";
import { SearchableSelect } from "~/components/ui/select";
import {
US_STATES,
ALL_COUNTRIES,
POPULAR_COUNTRIES,
formatPostalCode,
PLACEHOLDERS,
} from "~/lib/form-constants";
interface AddressFormProps {
addressLine1: string;
addressLine2: string;
city: string;
state: string;
postalCode: string;
country: string;
onChange: (field: string, value: string) => void;
errors?: {
addressLine1?: string;
addressLine2?: string;
city?: string;
state?: string;
postalCode?: string;
country?: string;
};
required?: boolean;
className?: string;
}
export function AddressForm({
addressLine1,
addressLine2,
city,
state,
postalCode,
country,
onChange,
errors = {},
required = false,
className = "",
}: AddressFormProps) {
const handlePostalCodeChange = (value: string) => {
const formatted = formatPostalCode(value, country || "US");
onChange("postalCode", formatted);
};
// Combine popular and all countries, removing duplicates
const countryOptions = [
{ value: "__placeholder__", label: "Select a country", disabled: true },
{ value: "divider-popular", label: "Popular Countries", disabled: true },
...POPULAR_COUNTRIES,
{ value: "divider-all", label: "All Countries", disabled: true },
...ALL_COUNTRIES.filter(
(c) => !POPULAR_COUNTRIES.some((p) => p.value === c.value),
),
];
const stateOptions = [
{ value: "__placeholder__", label: "Select a state", disabled: true },
...US_STATES,
];
return (
<div className={`space-y-4 ${className}`}>
<div className="flex items-center gap-2 text-sm font-medium">
<MapPin className="text-muted-foreground h-4 w-4" />
<span>Address Information</span>
</div>
<div className="grid gap-4">
{/* Address Line 1 */}
<div className="space-y-2">
<Label htmlFor="addressLine1">
Address Line 1
{required && <span className="text-destructive ml-1">*</span>}
</Label>
<Input
id="addressLine1"
value={addressLine1}
onChange={(e) => onChange("addressLine1", e.target.value)}
placeholder={PLACEHOLDERS.addressLine1}
className={errors.addressLine1 ? "border-destructive" : ""}
/>
{errors.addressLine1 && (
<p className="text-destructive text-sm">{errors.addressLine1}</p>
)}
</div>
{/* Address Line 2 */}
<div className="space-y-2">
<Label htmlFor="addressLine2">
Address Line 2
<span className="text-muted-foreground ml-1 text-xs">
(Optional)
</span>
</Label>
<Input
id="addressLine2"
value={addressLine2}
onChange={(e) => onChange("addressLine2", e.target.value)}
placeholder={PLACEHOLDERS.addressLine2}
/>
</div>
{/* City and State/Province */}
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="city">
City{required && <span className="text-destructive ml-1">*</span>}
</Label>
<Input
id="city"
value={city}
onChange={(e) => onChange("city", e.target.value)}
placeholder={PLACEHOLDERS.city}
className={errors.city ? "border-destructive" : ""}
/>
{errors.city && (
<p className="text-destructive text-sm">{errors.city}</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="state">
{country === "United States" ? "State" : "State/Province"}
{required && country === "United States" && (
<span className="text-destructive ml-1">*</span>
)}
</Label>
{country === "United States" ? (
<SearchableSelect
id="state"
options={stateOptions}
value={state || ""}
onValueChange={(value) => onChange("state", value)}
placeholder="Select a state"
className={errors.state ? "border-destructive" : ""}
/>
) : (
<Input
id="state"
value={state}
onChange={(e) => onChange("state", e.target.value)}
placeholder="State/Province"
className={errors.state ? "border-destructive" : ""}
/>
)}
{errors.state && (
<p className="text-destructive text-sm">{errors.state}</p>
)}
</div>
</div>
{/* Postal Code and Country */}
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="postalCode">
{country === "United States" ? "ZIP Code" : "Postal Code"}
{required && <span className="text-destructive ml-1">*</span>}
</Label>
<Input
id="postalCode"
value={postalCode}
onChange={(e) => handlePostalCodeChange(e.target.value)}
placeholder={
country === "United States" ? "12345" : PLACEHOLDERS.postalCode
}
className={errors.postalCode ? "border-destructive" : ""}
maxLength={
country === "United States"
? 10
: country === "Canada"
? 7
: undefined
}
/>
{errors.postalCode && (
<p className="text-destructive text-sm">{errors.postalCode}</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="country">
Country
{required && <span className="text-destructive ml-1">*</span>}
</Label>
<SearchableSelect
id="country"
options={countryOptions}
value={country || ""}
onValueChange={(value) => {
// Don't save the placeholder value
if (value !== "__placeholder__") {
onChange("country", value);
// Reset state when country changes from United States
if (value !== "United States" && state.length === 2) {
onChange("state", "");
}
}
}}
placeholder="Select a country"
className={errors.country ? "border-destructive" : ""}
renderOption={(option) => {
if (option.value?.startsWith("divider-")) {
return (
<div className="text-muted-foreground px-2 py-1 text-xs font-semibold">
{option.label}
</div>
);
}
return option.label;
}}
isOptionDisabled={(option) =>
option.disabled || option.value?.startsWith("divider-")
}
/>
{errors.country && (
<p className="text-destructive text-sm">{errors.country}</p>
)}
</div>
</div>
</div>
</div>
);
}
+544
View File
@@ -0,0 +1,544 @@
"use client";
import {
Building,
Mail,
Phone,
Save,
Globe,
BadgeDollarSign,
Image,
Star,
Loader2,
ArrowLeft,
} from "lucide-react";
import Link from "next/link";
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 { Switch } from "~/components/ui/switch";
import { AddressForm } from "~/components/forms/address-form";
import { FloatingActionBar } from "~/components/layout/floating-action-bar";
import { api } from "~/trpc/react";
import {
formatPhoneNumber,
formatWebsiteUrl,
formatTaxId,
isValidEmail,
VALIDATION_MESSAGES,
PLACEHOLDERS,
} from "~/lib/form-constants";
interface BusinessFormProps {
businessId?: string;
mode: "create" | "edit";
}
interface FormData {
name: string;
email: string;
phone: string;
addressLine1: string;
addressLine2: string;
city: string;
state: string;
postalCode: string;
country: string;
website: string;
taxId: string;
logoUrl: string;
isDefault: boolean;
}
interface FormErrors {
name?: string;
email?: string;
phone?: string;
addressLine1?: string;
city?: string;
state?: string;
postalCode?: string;
country?: string;
website?: string;
taxId?: string;
}
const initialFormData: FormData = {
name: "",
email: "",
phone: "",
addressLine1: "",
addressLine2: "",
city: "",
state: "",
postalCode: "",
country: "United States",
website: "",
taxId: "",
logoUrl: "",
isDefault: false,
};
export function BusinessForm({ businessId, mode }: BusinessFormProps) {
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 business data if editing
const { data: business, isLoading: isLoadingBusiness } =
api.businesses.getById.useQuery(
{ id: businessId! },
{ enabled: mode === "edit" && !!businessId },
);
const createBusiness = api.businesses.create.useMutation({
onSuccess: () => {
toast.success("Business created successfully");
router.push("/dashboard/businesses");
},
onError: (error) => {
toast.error(error.message || "Failed to create business");
},
});
const updateBusiness = api.businesses.update.useMutation({
onSuccess: () => {
toast.success("Business updated successfully");
router.push("/dashboard/businesses");
},
onError: (error) => {
toast.error(error.message || "Failed to update business");
},
});
// Load business data when editing
useEffect(() => {
if (business && mode === "edit") {
setFormData({
name: business.name,
email: business.email ?? "",
phone: business.phone ?? "",
addressLine1: business.addressLine1 ?? "",
addressLine2: business.addressLine2 ?? "",
city: business.city ?? "",
state: business.state ?? "",
postalCode: business.postalCode ?? "",
country: business.country ?? "United States",
website: business.website ?? "",
taxId: business.taxId ?? "",
logoUrl: business.logoUrl ?? "",
isDefault: business.isDefault ?? false,
});
}
}, [business, mode]);
const handleInputChange = (field: string, value: string | boolean) => {
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 handleTaxIdChange = (value: string) => {
const formatted = formatTaxId(value, "EIN");
handleInputChange("taxId", 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 === "United States") {
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 {
// Format website URL before submission
const dataToSubmit = {
...formData,
website: formData.website ? formatWebsiteUrl(formData.website) : "",
};
if (mode === "create") {
await createBusiness.mutateAsync(dataToSubmit);
} else {
await updateBusiness.mutateAsync({
id: businessId!,
...dataToSubmit,
});
}
} 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/businesses");
};
if (mode === "edit" && isLoadingBusiness) {
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">
<Building 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 your business details
</p>
</div>
</div>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="name" className="text-sm font-medium">
Business 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="space-y-2">
<Label htmlFor="taxId" className="text-sm font-medium">
Tax ID (EIN)
<span className="text-muted-foreground ml-1 text-xs font-normal">
(Optional)
</span>
</Label>
<Input
id="taxId"
value={formData.taxId}
onChange={(e) => handleTaxIdChange(e.target.value)}
placeholder={PLACEHOLDERS.taxId}
className={`${errors.taxId ? "border-destructive" : ""}`}
disabled={isSubmitting}
maxLength={10}
/>
{errors.taxId && (
<p className="text-destructive text-sm">{errors.taxId}</p>
)}
</div>
<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>
<div className="space-y-2">
<Label htmlFor="website" className="text-sm font-medium">
Website
<span className="text-muted-foreground ml-1 text-xs font-normal">
(Optional)
</span>
</Label>
<Input
id="website"
value={formData.website}
onChange={(e) => handleInputChange("website", e.target.value)}
placeholder={PLACEHOLDERS.website}
className={`${errors.website ? "border-destructive" : ""}`}
disabled={isSubmitting}
/>
{errors.website && (
<p className="text-destructive text-sm">{errors.website}</p>
)}
</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>Business Address</CardTitle>
<p className="text-muted-foreground mt-1 text-sm">
Your business 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>
{/* Settings */}
<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">
<Star className="h-5 w-5 text-emerald-700 dark:text-emerald-400" />
</div>
<div>
<CardTitle>Settings</CardTitle>
<p className="text-muted-foreground mt-1 text-sm">
Configure business preferences
</p>
</div>
</div>
</CardHeader>
<CardContent className="space-y-4">
<div className="border-border/40 flex items-center justify-between rounded-xl border bg-gradient-to-r from-emerald-600/5 to-teal-600/5 p-4">
<div className="space-y-0.5">
<Label htmlFor="isDefault" className="text-base font-medium">
Default Business
</Label>
<p className="text-muted-foreground text-sm">
Set this as your default business for new invoices
</p>
</div>
<Switch
id="isDefault"
checked={formData.isDefault}
onCheckedChange={(checked) =>
handleInputChange("isDefault", checked)
}
disabled={isSubmitting}
/>
</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 business"
: "Editing business 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 Business" : "Save Changes"}
</>
)}
</Button>
</div>
</div>
</form>
<FloatingActionBar
triggerRef={footerRef}
title={
mode === "create"
? "Creating a new business"
: "Editing business details"
}
>
<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
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"
>
{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 Business" : "Save Changes"}
</>
)}
</Button>
</FloatingActionBar>
</div>
);
}
+424
View File
@@ -0,0 +1,424 @@
"use client";
import { UserPlus, Mail, Phone, Save, Loader2, ArrowLeft } from "lucide-react";
import Link from "next/link";
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 { 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;
}
interface FormErrors {
name?: string;
email?: string;
phone?: string;
addressLine1?: string;
city?: string;
state?: string;
postalCode?: string;
country?: string;
}
const initialFormData: FormData = {
name: "",
email: "",
phone: "",
addressLine1: "",
addressLine2: "",
city: "",
state: "",
postalCode: "",
country: "United States",
};
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",
});
}
}, [client, mode]);
const handleInputChange = (field: string, value: string) => {
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>
</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"
>
<ArrowLeft className="mr-2 h-4 w-4" />
Cancel
</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"
>
{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>
</FloatingActionBar>
</div>
);
}
+246
View File
@@ -0,0 +1,246 @@
"use client";
import * as React from "react";
import { useCallback } from "react";
import { useDropzone } from "react-dropzone";
import { cn } from "~/lib/utils";
import { Upload, FileText, X, CheckCircle, AlertCircle } from "lucide-react";
import { Button } from "~/components/ui/button";
interface FileUploadProps {
onFilesSelected: (files: File[]) => void;
accept?: Record<string, string[]>;
maxFiles?: number;
maxSize?: number;
className?: string;
disabled?: boolean;
placeholder?: string;
description?: string;
}
interface FilePreviewProps {
file: File;
onRemove: () => void;
status?: "success" | "error" | "pending";
error?: string;
}
function FilePreview({
file,
onRemove,
status = "pending",
error,
}: FilePreviewProps) {
const getStatusIcon = () => {
switch (status) {
case "success":
return <CheckCircle className="h-4 w-4 text-green-600" />;
case "error":
return <AlertCircle className="h-4 w-4 text-red-600" />;
default:
return <FileText className="h-4 w-4 text-gray-400" />;
}
};
const getStatusColor = () => {
switch (status) {
case "success":
return "border-green-200 bg-green-50";
case "error":
return "border-red-200 bg-red-50";
default:
return "border-gray-200 bg-gray-50";
}
};
return (
<div
className={cn(
"flex items-center justify-between rounded-lg border p-3",
getStatusColor(),
)}
>
<div className="flex items-center gap-3">
{getStatusIcon()}
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium text-gray-900">
{file.name}
</p>
<p className="text-xs text-gray-500">
{(file.size / 1024 / 1024).toFixed(2)} MB
</p>
{error && <p className="mt-1 text-xs text-red-600">{error}</p>}
</div>
</div>
<Button
variant="ghost"
size="sm"
onClick={onRemove}
className="h-6 w-6 p-0 text-gray-400 hover:text-gray-600"
>
<X className="h-3 w-3" />
</Button>
</div>
);
}
export function FileUpload({
onFilesSelected,
accept,
maxFiles = 10,
maxSize = 10 * 1024 * 1024, // 10MB default
className,
disabled = false,
placeholder = "Drag & drop files here, or click to select",
description,
}: FileUploadProps) {
const [files, setFiles] = React.useState<File[]>([]);
const [errors, setErrors] = React.useState<Record<string, string>>({});
const onDrop = useCallback(
(acceptedFiles: File[], rejectedFiles: any[]) => {
// Handle accepted files
const newFiles = [...files, ...acceptedFiles];
setFiles(newFiles);
onFilesSelected(newFiles);
// Handle rejected files
const newErrors: Record<string, string> = { ...errors };
rejectedFiles.forEach(({ file, errors }) => {
const errorMessage = errors
.map((e: any) => {
if (e.code === "file-too-large") {
return `File is too large. Max size is ${(maxSize / 1024 / 1024).toFixed(1)}MB`;
}
if (e.code === "file-invalid-type") {
return "File type not supported";
}
if (e.code === "too-many-files") {
return `Too many files. Max is ${maxFiles}`;
}
return e.message;
})
.join(", ");
newErrors[file.name] = errorMessage;
});
setErrors(newErrors);
},
[files, onFilesSelected, errors, maxFiles, maxSize],
);
const removeFile = (fileToRemove: File) => {
const newFiles = files.filter((file) => file !== fileToRemove);
setFiles(newFiles);
onFilesSelected(newFiles);
const newErrors = { ...errors };
delete newErrors[fileToRemove.name];
setErrors(newErrors);
};
const { getRootProps, getInputProps, isDragActive, isDragReject } =
useDropzone({
onDrop,
accept,
maxFiles,
maxSize,
disabled,
});
return (
<div className={cn("space-y-4", className)}>
<div
{...getRootProps()}
className={cn(
"cursor-pointer rounded-lg border-2 border-dashed p-8 text-center transition-colors",
"hover:border-emerald-400 hover:bg-emerald-50/50",
isDragActive && "border-emerald-400 bg-emerald-50/50",
isDragReject && "border-red-400 bg-red-50/50",
disabled && "cursor-not-allowed opacity-50",
"bg-white/80 backdrop-blur-sm",
)}
>
<input {...getInputProps()} />
<div className="flex flex-col items-center gap-4">
<div
className={cn(
"rounded-full p-3 transition-colors",
isDragActive ? "bg-emerald-100" : "bg-gray-100",
isDragReject && "bg-red-100",
)}
>
<Upload
className={cn(
"h-6 w-6 transition-colors",
isDragActive ? "text-emerald-600" : "text-gray-400",
isDragReject && "text-red-600",
)}
/>
</div>
<div className="space-y-2">
<p
className={cn(
"text-lg font-medium transition-colors",
isDragActive ? "text-emerald-600" : "text-gray-900",
isDragReject && "text-red-600",
)}
>
{isDragActive
? isDragReject
? "File type not supported"
: "Drop files here"
: placeholder}
</p>
{description && (
<p className="text-sm text-gray-500">{description}</p>
)}
<p className="text-xs text-gray-400">
Max {maxFiles} file{maxFiles !== 1 ? "s" : ""} {" "}
{(maxSize / 1024 / 1024).toFixed(1)}MB each
</p>
</div>
</div>
</div>
{/* File List */}
{files.length > 0 && (
<div className="space-y-2">
<h4 className="text-sm font-medium text-gray-700">Selected Files</h4>
<div className="max-h-60 space-y-2 overflow-y-auto">
{files.map((file, index) => (
<FilePreview
key={`${file.name}-${index}`}
file={file}
onRemove={() => removeFile(file)}
status={errors[file.name] ? "error" : "success"}
error={errors[file.name]}
/>
))}
</div>
</div>
)}
{/* Error Summary */}
{Object.keys(errors).length > 0 && (
<div className="rounded-lg border border-red-200 bg-red-50 p-3">
<div className="mb-2 flex items-center gap-2">
<AlertCircle className="h-4 w-4 text-red-600" />
<span className="text-sm font-medium text-red-800">
Upload Errors
</span>
</div>
<ul className="space-y-1 text-sm text-red-700">
{Object.entries(errors).map(([fileName, error]) => (
<li key={fileName} className="flex items-start gap-2">
<span className="text-red-600"></span>
<span>
<strong>{fileName}:</strong> {error}
</span>
</li>
))}
</ul>
</div>
)}
</div>
);
}
+799
View File
@@ -0,0 +1,799 @@
"use client";
import * as React from "react";
import { useState, useEffect } from "react";
import { api } from "~/trpc/react";
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
import { Input } from "~/components/ui/input";
import { Button } from "~/components/ui/button";
import { Label } from "~/components/ui/label";
import { DatePicker } from "~/components/ui/date-picker";
import { Badge } from "~/components/ui/badge";
import { Separator } from "~/components/ui/separator";
import { SearchableSelect } from "~/components/ui/select";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "~/components/ui/select";
import { toast } from "sonner";
import {
Calendar,
FileText,
User,
Plus,
Trash2,
DollarSign,
Clock,
Edit3,
Save,
X,
AlertCircle,
Building,
} from "lucide-react";
import { useRouter } from "next/navigation";
import { format } from "date-fns";
import { FormSkeleton } from "~/components/ui/skeleton";
import { EditableInvoiceItems } from "~/components/data/editable-invoice-items";
const STATUS_OPTIONS = [
{
value: "draft",
label: "Draft",
},
{
value: "sent",
label: "Sent",
},
{
value: "paid",
label: "Paid",
},
{
value: "overdue",
label: "Overdue",
},
] as const;
interface InvoiceFormProps {
invoiceId?: string;
}
export function InvoiceForm({ invoiceId }: InvoiceFormProps) {
const router = useRouter();
const [formData, setFormData] = useState({
invoiceNumber: `INV-${new Date().toISOString().slice(0, 10).replace(/-/g, "")}-${Date.now().toString().slice(-6)}`,
businessId: "",
clientId: "",
issueDate: new Date(),
dueDate: new Date(),
status: "draft" as "draft" | "sent" | "paid" | "overdue",
notes: "",
taxRate: 0,
items: [
{
id: crypto.randomUUID(),
date: new Date(),
description: "",
hours: 0,
rate: 0,
amount: 0,
},
],
});
const [loading, setLoading] = useState(false);
const [defaultRate, setDefaultRate] = useState(0);
// Fetch clients and businesses for dropdowns
const { data: clients, isLoading: loadingClients } =
api.clients.getAll.useQuery();
const { data: businesses, isLoading: loadingBusinesses } =
api.businesses.getAll.useQuery();
// Fetch existing invoice data if editing
const { data: existingInvoice, isLoading: loadingInvoice } =
api.invoices.getById.useQuery({ id: invoiceId! }, { enabled: !!invoiceId });
// Populate form with existing data when editing
React.useEffect(() => {
if (existingInvoice && invoiceId) {
setFormData({
invoiceNumber: existingInvoice.invoiceNumber,
businessId: existingInvoice.businessId ?? "",
clientId: existingInvoice.clientId,
issueDate: new Date(existingInvoice.issueDate),
dueDate: new Date(existingInvoice.dueDate),
status: existingInvoice.status as "draft" | "sent" | "paid" | "overdue",
notes: existingInvoice.notes ?? "",
taxRate: existingInvoice.taxRate,
items: existingInvoice.items?.map((item) => ({
id: crypto.randomUUID(),
date: new Date(item.date),
description: item.description,
hours: item.hours,
rate: item.rate,
amount: item.amount,
})) || [
{
id: crypto.randomUUID(),
date: new Date(),
description: "",
hours: 0,
rate: 0,
amount: 0,
},
],
});
// Set default rate from first item
if (existingInvoice.items?.[0]) {
setDefaultRate(existingInvoice.items[0].rate);
}
}
}, [existingInvoice, invoiceId]);
// Calculate totals
const totals = React.useMemo(() => {
const subtotal = formData.items.reduce(
(sum, item) => sum + item.hours * item.rate,
0,
);
const taxAmount = (subtotal * formData.taxRate) / 100;
const total = subtotal + taxAmount;
return {
subtotal,
taxAmount,
total,
};
}, [formData.items, formData.taxRate]);
// Add new item
const addItem = () => {
setFormData((prev) => ({
...prev,
items: [
...prev.items,
{
id: crypto.randomUUID(),
date: new Date(),
description: "",
hours: 0,
rate: defaultRate,
amount: 0,
},
],
}));
};
// Remove item
const removeItem = (idx: number) => {
if (formData.items.length > 1) {
setFormData((prev) => ({
...prev,
items: prev.items.filter((_, i) => i !== idx),
}));
}
};
// Apply default rate to all items
const applyDefaultRate = () => {
setFormData((prev) => ({
...prev,
items: prev.items.map((item) => ({
...item,
rate: defaultRate,
amount: item.hours * defaultRate,
})),
}));
};
// tRPC mutations
const createInvoice = api.invoices.create.useMutation({
onSuccess: () => {
toast.success("Invoice created successfully");
router.push("/dashboard/invoices");
},
onError: (error) => {
toast.error(error.message || "Failed to create invoice");
},
});
const updateInvoice = api.invoices.update.useMutation({
onSuccess: () => {
toast.success("Invoice updated successfully");
router.push(`/dashboard/invoices/${invoiceId}`);
},
onError: (error) => {
toast.error(error.message || "Failed to update invoice");
},
});
// Handle form submit
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
// Validate form
if (!formData.businessId) {
toast.error("Please select a business");
return;
}
if (!formData.clientId) {
toast.error("Please select a client");
return;
}
if (formData.items.some((item) => !item.description.trim())) {
toast.error("Please fill in all item descriptions");
return;
}
if (formData.items.some((item) => item.hours <= 0)) {
toast.error("Please enter valid hours for all items");
return;
}
if (formData.items.some((item) => item.rate <= 0)) {
toast.error("Please enter valid rates for all items");
return;
}
setLoading(true);
try {
// In the handleSubmit, ensure items are sent in the current array order with no sorting
const submitData = {
...formData,
items: formData.items.map((item) => ({
date: new Date(item.date),
description: item.description,
hours: item.hours,
rate: item.rate,
amount: item.amount,
// position will be set by backend based on array order
})),
};
if (invoiceId) {
await updateInvoice.mutateAsync({
id: invoiceId,
...submitData,
});
} else {
await createInvoice.mutateAsync(submitData);
}
} finally {
setLoading(false);
}
};
// Show loading state while fetching existing invoice data
if (invoiceId && loadingInvoice) {
return (
<div className="space-y-6 pb-20">
{/* Invoice Details Card Skeleton */}
<Card className="shadow-lg">
<CardHeader>
<div className="h-6 w-48 animate-pulse rounded bg-gray-300"></div>
</CardHeader>
<CardContent className="space-y-6">
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3 lg:gap-6 xl:grid-cols-4">
{Array.from({ length: 6 }).map((_, i) => (
<div key={i} className="space-y-2">
<div className="h-4 w-24 animate-pulse rounded bg-gray-300"></div>
<div className="h-10 animate-pulse rounded bg-gray-300"></div>
</div>
))}
</div>
</CardContent>
</Card>
{/* Invoice Items Card Skeleton */}
<Card className="shadow-lg">
<CardHeader>
<div className="flex items-center justify-between">
<div className="h-6 w-32 animate-pulse rounded bg-gray-300"></div>
<div className="h-10 w-24 animate-pulse rounded bg-gray-300"></div>
</div>
</CardHeader>
<CardContent className="space-y-4">
{/* Items Table Header Skeleton */}
<div className="grid grid-cols-12 gap-2 rounded-lg bg-gray-50 px-4 py-3">
{Array.from({ length: 8 }).map((_, i) => (
<div
key={i}
className="h-4 animate-pulse rounded bg-gray-300"
></div>
))}
</div>
{/* Items Skeleton */}
<div className="space-y-3">
{Array.from({ length: 3 }).map((_, i) => (
<div
key={i}
className="grid animate-pulse grid-cols-12 items-center gap-2 rounded-lg border border-gray-200 p-4"
>
{Array.from({ length: 8 }).map((_, j) => (
<div
key={j}
className="h-10 rounded bg-gray-300 dark:bg-gray-600"
></div>
))}
</div>
))}
</div>
</CardContent>
</Card>
{/* Form Controls Bar Skeleton */}
<div className="mt-6">
<div className="rounded-2xl border border-gray-200 bg-white/90 p-4 shadow-sm dark:border-gray-700 dark:bg-gray-800/90">
<div className="flex items-center justify-between">
<div className="h-4 w-32 animate-pulse rounded bg-gray-300 dark:bg-gray-600"></div>
<div className="flex items-center gap-3">
<div className="h-10 w-20 animate-pulse rounded bg-gray-300 dark:bg-gray-600"></div>
<div className="h-10 w-32 animate-pulse rounded bg-gray-300 dark:bg-gray-600"></div>
</div>
</div>
</div>
</div>
</div>
);
}
const selectedClient = clients?.find((c) => c.id === formData.clientId);
const selectedBusiness = businesses?.find(
(b) => b.id === formData.businessId,
);
// Show loading state while fetching clients
if (loadingClients) {
return (
<div className="space-y-6 pb-20">
{/* Invoice Details Card Skeleton */}
<Card className="shadow-lg">
<CardHeader>
<div className="h-6 w-48 animate-pulse rounded bg-gray-300"></div>
</CardHeader>
<CardContent className="space-y-6">
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3 lg:gap-6 xl:grid-cols-4">
{Array.from({ length: 6 }).map((_, i) => (
<div key={i} className="space-y-2">
<div className="h-4 w-24 animate-pulse rounded bg-gray-300"></div>
<div className="h-10 animate-pulse rounded bg-gray-300"></div>
</div>
))}
</div>
</CardContent>
</Card>
{/* Invoice Items Card Skeleton */}
<Card className="shadow-lg">
<CardHeader>
<div className="flex items-center justify-between">
<div className="h-6 w-32 animate-pulse rounded bg-gray-300"></div>
<div className="h-10 w-24 animate-pulse rounded bg-gray-300"></div>
</div>
</CardHeader>
<CardContent className="space-y-4">
{/* Items Table Header Skeleton */}
<div className="grid grid-cols-12 gap-2 rounded-lg bg-gray-50 px-4 py-3">
{Array.from({ length: 8 }).map((_, i) => (
<div
key={i}
className="h-4 animate-pulse rounded bg-gray-300"
></div>
))}
</div>
{/* Items Skeleton */}
<div className="space-y-3">
{Array.from({ length: 3 }).map((_, i) => (
<div
key={i}
className="grid animate-pulse grid-cols-12 items-center gap-2 rounded-lg border border-gray-200 p-4"
>
{Array.from({ length: 8 }).map((_, j) => (
<div key={j} className="h-10 rounded bg-gray-300"></div>
))}
</div>
))}
</div>
</CardContent>
</Card>
{/* Form Controls Bar Skeleton */}
<div className="mt-6">
<div className="rounded-2xl border border-gray-200 bg-white/90 p-4 shadow-sm">
<div className="flex items-center justify-between">
<div className="h-4 w-32 animate-pulse rounded bg-gray-300"></div>
<div className="flex items-center gap-3">
<div className="h-10 w-20 animate-pulse rounded bg-gray-300"></div>
<div className="h-10 w-32 animate-pulse rounded bg-gray-300"></div>
</div>
</div>
</div>
</div>
</div>
);
}
return (
<form id="invoice-form" onSubmit={handleSubmit} className="space-y-6 pb-20">
{/* Invoice Details Card */}
<Card className="shadow-lg">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-emerald-700">
<FileText className="h-5 w-5" />
Invoice Details
</CardTitle>
</CardHeader>
<CardContent className="space-y-6">
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3 lg:gap-6 xl:grid-cols-4">
<div className="space-y-2">
<Label htmlFor="invoiceNumber" className="text-sm font-medium">
Invoice Number
</Label>
<Input
id="invoiceNumber"
value={formData.invoiceNumber}
className="bg-muted"
placeholder="Auto-generated"
readOnly
/>
</div>
<div className="space-y-2">
<Label htmlFor="businessId" className="text-sm font-medium">
Business *
</Label>
<SearchableSelect
value={formData.businessId}
onValueChange={(value) =>
setFormData((f) => ({ ...f, businessId: value }))
}
options={
businesses?.map((business) => ({
value: business.id,
label: business.name,
})) ?? []
}
placeholder="Select a business"
searchPlaceholder="Search businesses..."
disabled={loadingBusinesses}
/>
</div>
<div className="space-y-2">
<Label htmlFor="clientId" className="text-sm font-medium">
Client *
</Label>
<SearchableSelect
value={formData.clientId}
onValueChange={(value) =>
setFormData((f) => ({ ...f, clientId: value }))
}
options={
clients?.map((client) => ({
value: client.id,
label: client.name,
})) ?? []
}
placeholder="Select a client"
searchPlaceholder="Search clients..."
disabled={loadingClients}
/>
</div>
<div className="space-y-2">
<Label htmlFor="status" className="text-sm font-medium">
Status
</Label>
<Select
value={formData.status}
onValueChange={(value) =>
setFormData((f) => ({
...f,
status: value as "draft" | "sent" | "paid" | "overdue",
}))
}
>
<SelectTrigger className="h-10">
<SelectValue placeholder="Select status" />
</SelectTrigger>
<SelectContent>
<SelectItem value="draft">Draft</SelectItem>
<SelectItem value="sent">Sent</SelectItem>
<SelectItem value="paid">Paid</SelectItem>
<SelectItem value="overdue">Overdue</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="issueDate" className="text-sm font-medium">
Issue Date *
</Label>
<DatePicker
date={formData.issueDate}
onDateChange={(date) =>
setFormData((f) => ({ ...f, issueDate: date ?? new Date() }))
}
placeholder="Select issue date"
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="dueDate" className="text-sm font-medium">
Due Date *
</Label>
<DatePicker
date={formData.dueDate}
onDateChange={(date) =>
setFormData((f) => ({ ...f, dueDate: date ?? new Date() }))
}
placeholder="Select due date"
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="defaultRate" className="text-sm font-medium">
Default Rate ($/hr)
</Label>
<div className="flex gap-2">
<Input
id="defaultRate"
type="number"
step="0.01"
value={defaultRate}
onChange={(e) =>
setDefaultRate(parseFloat(e.target.value) || 0)
}
placeholder="0.00"
className=""
/>
<Button
type="button"
onClick={applyDefaultRate}
variant="outline"
size="sm"
className="border-primary text-primary hover:bg-primary/10"
>
Apply
</Button>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="taxRate" className="text-sm font-medium">
Tax Rate (%)
</Label>
<Input
id="taxRate"
type="number"
step="0.01"
min="0"
max="100"
value={formData.taxRate}
onChange={(e) =>
setFormData((f) => ({
...f,
taxRate: parseFloat(e.target.value) || 0,
}))
}
placeholder="0.00"
className=""
/>
</div>
</div>
{selectedBusiness && (
<div className="rounded-lg border border-emerald-200 bg-emerald-50 p-4 dark:border-emerald-800 dark:bg-emerald-900/20">
<div className="mb-2 flex items-center gap-2 text-green-600">
<Building className="h-4 w-4" />
<span className="font-medium">Business Information</span>
</div>
<div className="text-muted-foreground text-sm">
<p className="font-medium">{selectedBusiness.name}</p>
{selectedBusiness.email && <p>{selectedBusiness.email}</p>}
{selectedBusiness.phone && <p>{selectedBusiness.phone}</p>}
{selectedBusiness.addressLine1 && (
<p>{selectedBusiness.addressLine1}</p>
)}
{(selectedBusiness.city ??
selectedBusiness.state ??
selectedBusiness.postalCode) && (
<p>
{[
selectedBusiness.city,
selectedBusiness.state,
selectedBusiness.postalCode,
]
.filter(Boolean)
.join(", ")}
</p>
)}
</div>
</div>
)}
{selectedClient && (
<div className="rounded-lg border border-emerald-200 bg-emerald-50 p-4 dark:border-emerald-800 dark:bg-emerald-900/20">
<div className="mb-2 flex items-center gap-2 text-green-600">
<User className="h-4 w-4" />
<span className="font-medium">Client Information</span>
</div>
<div className="text-muted-foreground text-sm">
<p className="font-medium">{selectedClient.name}</p>
{selectedClient.email && <p>{selectedClient.email}</p>}
{selectedClient.phone && <p>{selectedClient.phone}</p>}
</div>
</div>
)}
<div className="space-y-2">
<Label htmlFor="notes" className="text-sm font-medium">
Notes
</Label>
<textarea
id="notes"
value={formData.notes}
onChange={(e) =>
setFormData((f) => ({ ...f, notes: e.target.value }))
}
className="min-h-[80px] w-full resize-none rounded-md border border-gray-200 bg-white px-3 py-2 text-gray-700 focus:border-emerald-500 focus:ring-emerald-500 dark:border-gray-600 dark:bg-gray-700 dark:text-white"
placeholder="Additional notes, terms, or special instructions..."
/>
</div>
</CardContent>
</Card>
{/* Invoice Items Card */}
<Card className="shadow-lg">
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle className="flex items-center gap-2 text-emerald-700">
<Clock className="h-5 w-5" />
Invoice Items
</CardTitle>
<Button
type="button"
onClick={addItem}
variant="outline"
className="border-emerald-200 text-emerald-700 hover:bg-emerald-50"
>
<Plus className="mr-2 h-4 w-4" />
Add Item
</Button>
</div>
</CardHeader>
<CardContent className="space-y-4">
{/* Items Table Header */}
<div className="bg-muted text-muted-foreground grid grid-cols-12 items-center gap-2 rounded-lg px-4 py-3 text-sm font-medium">
<div className="col-span-1 text-center"></div>
<div className="col-span-2">Date</div>
<div className="col-span-4">Description</div>
<div className="col-span-1">Hours</div>
<div className="col-span-2">Rate ($)</div>
<div className="col-span-1">Amount</div>
<div className="col-span-1"></div>
</div>
{/* Items */}
<EditableInvoiceItems
items={formData.items}
onItemsChange={(newItems) =>
setFormData((prev) => ({ ...prev, items: newItems }))
}
onRemoveItem={removeItem}
/>
{/* Validation Messages */}
{formData.items.some((item) => !item.description.trim()) && (
<div className="flex items-center gap-2 text-sm text-amber-600">
<AlertCircle className="h-4 w-4" />
Please fill in all item descriptions
</div>
)}
{formData.items.some((item) => item.hours <= 0) && (
<div className="flex items-center gap-2 text-sm text-amber-600">
<AlertCircle className="h-4 w-4" />
Please enter valid hours for all items
</div>
)}
{formData.items.some((item) => item.rate <= 0) && (
<div className="flex items-center gap-2 text-sm text-amber-600">
<AlertCircle className="h-4 w-4" />
Please enter valid rates for all items
</div>
)}
<Separator />
{/* Totals */}
<div className="flex justify-end">
<div className="space-y-2 text-right">
<div className="space-y-1">
<div className="text-sm text-gray-600">
Subtotal: ${totals.subtotal.toFixed(2)}
</div>
{formData.taxRate > 0 && (
<div className="text-sm text-gray-600">
Tax ({formData.taxRate}%): ${totals.taxAmount.toFixed(2)}
</div>
)}
</div>
<div className="text-foreground text-lg font-medium">
Total Amount
</div>
<div className="text-3xl font-bold text-emerald-600 dark:text-emerald-400">
${totals.total.toFixed(2)}
</div>
<div className="text-sm text-gray-500 dark:text-gray-400">
{formData.items.length} item
{formData.items.length !== 1 ? "s" : ""}
</div>
</div>
</div>
</CardContent>
</Card>
{/* Form Controls Bar */}
<div className="mt-6">
<div className="rounded-2xl border border-gray-200 bg-white/90 p-4 shadow-sm dark:border-gray-700 dark:bg-gray-800/90">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2 text-sm text-gray-600 dark:text-gray-300">
<div className="flex items-center gap-1">
<div className="h-2 w-2 rounded-full bg-emerald-500"></div>
<span>Ready to save</span>
</div>
{formData.items.length > 0 && (
<span className="text-gray-400 dark:text-gray-500"></span>
)}
{formData.items.length > 0 && (
<span>
{formData.items.length} item
{formData.items.length !== 1 ? "s" : ""}
</span>
)}
</div>
<div className="flex items-center gap-3">
<Button
type="button"
variant="outline"
onClick={() => router.push("/dashboard/invoices")}
className="font-medium"
>
Cancel
</Button>
<Button
type="submit"
disabled={loading}
className="bg-gradient-to-r from-emerald-600 to-teal-600 font-medium text-white shadow-lg transition-all duration-200 hover:from-emerald-700 hover:to-teal-700 hover:shadow-xl"
>
{loading ? (
<>
<div className="border-primary-foreground mr-2 h-4 w-4 animate-spin rounded-full border-2 border-t-transparent" />
{invoiceId ? "Updating..." : "Creating..."}
</>
) : (
<>
<Save className="mr-2 h-4 w-4" />
{invoiceId ? "Update Invoice" : "Create Invoice"}
</>
)}
</Button>
</div>
</div>
</div>
</div>
</form>
);
}