Build fixes, email preview system

This commit is contained in:
2025-07-29 19:45:38 -04:00
parent e6791f8cb8
commit 9370d5c935
78 changed files with 5798 additions and 10397 deletions

View File

@@ -1,5 +1,4 @@
import Image from "next/image";
import { cn } from "~/lib/utils";
interface LogoProps {
className?: string;
@@ -18,8 +17,8 @@ export function Logo({ className, size = "md" }: LogoProps) {
<Image
src="/beenvoice-logo.svg"
alt="beenvoice logo"
width={width}
height={height}
width={width}
height={height}
className={className}
priority
/>

View File

@@ -1,21 +1,12 @@
"use client";
import { Calendar, Clock, Edit, Eye, FileText, Plus, User } from "lucide-react";
import Link from "next/link";
import { api } from "~/trpc/react";
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
import { Button } from "~/components/ui/button";
import { Badge } from "~/components/ui/badge";
import { Button } from "~/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
import { Skeleton } from "~/components/ui/skeleton";
import {
FileText,
Clock,
Plus,
Edit,
Eye,
DollarSign,
User,
Calendar,
} from "lucide-react";
import { api } from "~/trpc/react";
export function CurrentOpenInvoiceCard() {
const { data: currentInvoice, isLoading } =

View File

@@ -1,6 +1,5 @@
"use client";
import * as React from "react";
import type {
ColumnDef,
ColumnFiltersState,
@@ -18,23 +17,25 @@ import {
import {
ArrowUpDown,
ChevronDown,
Search,
Filter,
ChevronLeft,
ChevronRight,
ChevronsLeft,
ChevronsRight,
Filter,
Search,
X,
} from "lucide-react";
import * as React from "react";
import { Button } from "~/components/ui/button";
import { Input } from "~/components/ui/input";
import { Card, CardContent } from "~/components/ui/card";
import {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuTrigger,
} from "~/components/ui/dropdown-menu";
import { Input } from "~/components/ui/input";
import {
Select,
SelectContent,
@@ -50,7 +51,6 @@ import {
TableHeader,
TableRow,
} from "~/components/ui/table";
import { Card, CardContent } from "~/components/ui/card";
import { cn } from "~/lib/utils";
interface DataTableProps<TData, TValue> {
@@ -77,7 +77,7 @@ interface DataTableProps<TData, TValue> {
export function DataTable<TData, TValue>({
columns,
data,
searchKey,
searchKey: _searchKey,
searchPlaceholder = "Search...",
showColumnVisibility = true,
showPagination = true,
@@ -511,7 +511,7 @@ export function DataTable<TData, TValue>({
}
// Helper component for sortable column headers
export function DataTableColumnHeader<TData, TValue>({
export function DataTableColumnHeader({
column,
title,
className,
@@ -552,7 +552,7 @@ export function DataTableColumnHeader<TData, TValue>({
// Export skeleton component for loading states
export function DataTableSkeleton({
columns = 5,
columns: _columns = 5,
rows = 5,
}: {
columns?: number;

View File

@@ -89,9 +89,7 @@ export function InvoiceView({ invoiceId }: InvoiceViewProps) {
deleteInvoice.mutate({ id: invoiceId });
};
const handleStatusUpdate = (
newStatus: "draft" | "sent" | "paid" | "overdue",
) => {
const handleStatusUpdate = (newStatus: "draft" | "sent" | "paid") => {
updateStatus.mutate({ id: invoiceId, status: newStatus });
};

View File

@@ -45,7 +45,7 @@ export function AddressForm({
className = "",
}: AddressFormProps) {
const handlePostalCodeChange = (value: string) => {
const formatted = formatPostalCode(value, country || "US");
const formatted = formatPostalCode(value, country ?? "US");
onChange("postalCode", formatted);
};
@@ -137,7 +137,7 @@ export function AddressForm({
key={`state-${state}`}
id="state"
options={stateOptions}
value={state || ""}
value={state ?? ""}
onValueChange={(value) => onChange("state", value)}
placeholder="Select a state"
className={errors.state ? "border-destructive" : ""}
@@ -194,7 +194,7 @@ export function AddressForm({
key={`country-${country}`}
id="country"
options={countryOptions}
value={country || ""}
value={country ?? ""}
onValueChange={(value) => {
// Don't save the placeholder value
if (value !== "__placeholder__") {
@@ -218,7 +218,8 @@ export function AddressForm({
return option.label;
}}
isOptionDisabled={(option) =>
option.disabled || option.value?.startsWith("divider-")
(option.disabled ?? false) ||
(option.value?.startsWith("divider-") ?? false)
}
/>
{errors.country && (

View File

@@ -1,40 +1,43 @@
"use client";
import {
Building,
Mail,
Phone,
Save,
Globe,
BadgeDollarSign,
Image,
Star,
Loader2,
ArrowLeft,
Building,
Eye,
EyeOff,
FileText,
Globe,
Info,
Key,
Loader2,
Mail,
Save,
Star,
User,
} from "lucide-react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useEffect, useState } from "react";
import { toast } from "sonner";
import { AddressForm } from "~/components/forms/address-form";
import { FloatingActionBar } from "~/components/layout/floating-action-bar";
import { PageHeader } from "~/components/layout/page-header";
import { Button } from "~/components/ui/button";
import { Alert, AlertDescription } from "~/components/ui/alert";
import { Badge } from "~/components/ui/badge";
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 { Switch } from "~/components/ui/switch";
import { AddressForm } from "~/components/forms/address-form";
import { FloatingActionBar } from "~/components/layout/floating-action-bar";
import { PageHeader } from "~/components/layout/page-header";
import { api } from "~/trpc/react";
import {
formatPhoneNumber,
formatWebsiteUrl,
formatTaxId,
formatWebsiteUrl,
isValidEmail,
VALIDATION_MESSAGES,
PLACEHOLDERS,
VALIDATION_MESSAGES,
} from "~/lib/form-constants";
import { api } from "~/trpc/react";
interface BusinessFormProps {
businessId?: string;
@@ -53,8 +56,10 @@ interface FormData {
country: string;
website: string;
taxId: string;
logoUrl: string;
isDefault: boolean;
resendApiKey: string;
resendDomain: string;
emailFromName: string;
}
interface FormErrors {
@@ -68,6 +73,9 @@ interface FormErrors {
country?: string;
website?: string;
taxId?: string;
resendApiKey?: string;
resendDomain?: string;
emailFromName?: string;
}
const initialFormData: FormData = {
@@ -82,8 +90,10 @@ const initialFormData: FormData = {
country: "United States",
website: "",
taxId: "",
logoUrl: "",
isDefault: false,
resendApiKey: "",
resendDomain: "",
emailFromName: "",
};
export function BusinessForm({ businessId, mode }: BusinessFormProps) {
@@ -91,6 +101,7 @@ export function BusinessForm({ businessId, mode }: BusinessFormProps) {
const [formData, setFormData] = useState<FormData>(initialFormData);
const [errors, setErrors] = useState<FormErrors>({});
const [isSubmitting, setIsSubmitting] = useState(false);
const [showApiKey, setShowApiKey] = useState(false);
const [isDirty, setIsDirty] = useState(false);
// Fetch business data if editing
@@ -100,6 +111,23 @@ export function BusinessForm({ businessId, mode }: BusinessFormProps) {
{ enabled: mode === "edit" && !!businessId },
);
// Fetch email configuration if editing
const { data: emailConfig, isLoading: isLoadingEmailConfig } =
api.businesses.getEmailConfig.useQuery(
{ id: businessId! },
{ enabled: mode === "edit" && !!businessId },
);
// Update email configuration mutation
const updateEmailConfig = api.businesses.updateEmailConfig.useMutation({
onSuccess: () => {
toast.success("Email configuration updated successfully");
},
onError: (error) => {
toast.error(`Failed to update email configuration: ${error.message}`);
},
});
const createBusiness = api.businesses.create.useMutation({
onSuccess: () => {
toast.success("Business created successfully");
@@ -135,11 +163,13 @@ export function BusinessForm({ businessId, mode }: BusinessFormProps) {
country: business.country ?? "United States",
website: business.website ?? "",
taxId: business.taxId ?? "",
logoUrl: business.logoUrl ?? "",
isDefault: business.isDefault ?? false,
resendApiKey: "", // Never pre-fill API key for security
resendDomain: emailConfig?.resendDomain ?? "",
emailFromName: emailConfig?.emailFromName ?? "",
});
}
}, [business, mode]);
}, [business, emailConfig, mode]);
const handleInputChange = (field: string, value: string | boolean) => {
setFormData((prev) => ({ ...prev, [field]: value }));
@@ -202,6 +232,36 @@ export function BusinessForm({ businessId, mode }: BusinessFormProps) {
}
}
// Email configuration validation
// API Key validation
if (formData.resendApiKey && !formData.resendApiKey.startsWith("re_")) {
newErrors.resendApiKey = "Resend API key should start with 're_'";
}
// Domain validation
if (formData.resendDomain) {
const domainRegex =
/^[a-zA-Z0-9][a-zA-Z0-9-]{0,61}[a-zA-Z0-9]?\.([a-zA-Z]{2,})+$/;
if (!domainRegex.test(formData.resendDomain)) {
newErrors.resendDomain =
"Please enter a valid domain (e.g., yourdomain.com)";
}
}
// If API key is provided, domain must also be provided
if (formData.resendApiKey && !formData.resendDomain) {
newErrors.resendDomain = "Domain is required when API key is provided";
}
// If domain is provided, API key must also be provided
if (
formData.resendDomain &&
!formData.resendApiKey &&
!emailConfig?.hasApiKey
) {
newErrors.resendApiKey = "API key is required when domain is provided";
}
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};
@@ -224,12 +284,73 @@ export function BusinessForm({ businessId, mode }: BusinessFormProps) {
};
if (mode === "create") {
await createBusiness.mutateAsync(dataToSubmit);
// Create business data (excluding email config fields)
const businessData = {
name: dataToSubmit.name,
email: dataToSubmit.email,
phone: dataToSubmit.phone,
addressLine1: dataToSubmit.addressLine1,
addressLine2: dataToSubmit.addressLine2,
city: dataToSubmit.city,
state: dataToSubmit.state,
postalCode: dataToSubmit.postalCode,
country: dataToSubmit.country,
website: dataToSubmit.website,
taxId: dataToSubmit.taxId,
isDefault: dataToSubmit.isDefault,
};
const newBusiness = await createBusiness.mutateAsync(businessData);
// Update email configuration separately if any email fields are provided
if (
newBusiness &&
(formData.resendApiKey ||
formData.resendDomain ||
formData.emailFromName)
) {
await updateEmailConfig.mutateAsync({
id: newBusiness.id,
resendApiKey: formData.resendApiKey || undefined,
resendDomain: formData.resendDomain || undefined,
emailFromName: formData.emailFromName || undefined,
});
}
} else {
// Update business data (excluding email config fields)
const businessData = {
name: dataToSubmit.name,
email: dataToSubmit.email,
phone: dataToSubmit.phone,
addressLine1: dataToSubmit.addressLine1,
addressLine2: dataToSubmit.addressLine2,
city: dataToSubmit.city,
state: dataToSubmit.state,
postalCode: dataToSubmit.postalCode,
country: dataToSubmit.country,
website: dataToSubmit.website,
taxId: dataToSubmit.taxId,
isDefault: dataToSubmit.isDefault,
};
await updateBusiness.mutateAsync({
id: businessId!,
...dataToSubmit,
...businessData,
});
// Update email configuration separately if any email fields are provided
if (
formData.resendApiKey ||
formData.resendDomain ||
formData.emailFromName
) {
await updateEmailConfig.mutateAsync({
id: businessId!,
resendApiKey: formData.resendApiKey || undefined,
resendDomain: formData.resendDomain || undefined,
emailFromName: formData.emailFromName || undefined,
});
}
}
} finally {
setIsSubmitting(false);
@@ -246,7 +367,10 @@ export function BusinessForm({ businessId, mode }: BusinessFormProps) {
router.push("/dashboard/businesses");
};
if (mode === "edit" && isLoadingBusiness) {
if (
(mode === "edit" && isLoadingBusiness) ||
(mode === "edit" && isLoadingEmailConfig)
) {
return (
<div className="space-y-6 pb-32">
<Card>
@@ -488,6 +612,189 @@ export function BusinessForm({ businessId, mode }: BusinessFormProps) {
</CardContent>
</Card>
{/* Email Configuration */}
<Card className="card-primary">
<CardHeader>
<div className="flex items-center gap-3">
<div className="bg-brand-muted flex h-10 w-10 items-center justify-center rounded-lg">
<Mail className="text-brand-light h-5 w-5" />
</div>
<div>
<CardTitle>Email Configuration</CardTitle>
<p className="text-muted-foreground mt-1 text-sm">
Configure your own Resend API key and domain for sending
invoices
</p>
</div>
</div>
</CardHeader>
<CardContent className="space-y-6">
{/* Current Status */}
{mode === "edit" && (
<div className="flex items-center justify-between rounded-lg bg-gray-50 p-4">
<div className="flex items-center gap-2">
<span className="text-sm font-medium">
Current Status:
</span>
{emailConfig?.hasApiKey && emailConfig?.resendDomain ? (
<Badge
variant="default"
className="bg-green-100 text-green-800"
>
<Key className="mr-1 h-3 w-3" />
Custom Configuration Active
</Badge>
) : (
<Badge variant="secondary">
<Globe className="mr-1 h-3 w-3" />
Using System Default
</Badge>
)}
</div>
{emailConfig?.resendDomain && (
<span className="text-sm text-gray-600">
Domain: {emailConfig.resendDomain}
</span>
)}
</div>
)}
<Alert>
<Info className="h-4 w-4" />
<AlertDescription>
To use your own email configuration, you&apos;ll need to:
<ul className="mt-2 list-inside list-disc space-y-1">
<li>
Create a free account at{" "}
<a
href="https://resend.com"
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 hover:underline"
>
resend.com
</a>
</li>
<li>Verify your domain in the Resend dashboard</li>
<li>Get your API key from the Resend dashboard</li>
</ul>
</AlertDescription>
</Alert>
<div className="space-y-4">
{/* API Key */}
<div className="space-y-2">
<Label
htmlFor="resendApiKey"
className="flex items-center gap-2"
>
<Key className="h-4 w-4" />
Resend API Key
{mode === "edit" && emailConfig?.hasApiKey && (
<Badge variant="outline" className="text-xs">
Currently Set
</Badge>
)}
</Label>
<div className="relative">
<Input
id="resendApiKey"
type={showApiKey ? "text" : "password"}
value={formData.resendApiKey}
onChange={(e) =>
handleInputChange("resendApiKey", e.target.value)
}
placeholder={
mode === "edit" && emailConfig?.hasApiKey
? "Enter new API key to update"
: "re_..."
}
className={errors.resendApiKey ? "border-red-500" : ""}
/>
<Button
type="button"
variant="ghost"
size="sm"
className="absolute top-1/2 right-2 h-6 w-6 -translate-y-1/2 p-0"
onClick={() => setShowApiKey(!showApiKey)}
>
{showApiKey ? (
<EyeOff className="h-4 w-4" />
) : (
<Eye className="h-4 w-4" />
)}
</Button>
</div>
{errors.resendApiKey && (
<p className="text-sm text-red-600">
{errors.resendApiKey}
</p>
)}
</div>
{/* Domain */}
<div className="space-y-2">
<Label
htmlFor="resendDomain"
className="flex items-center gap-2"
>
<Globe className="h-4 w-4" />
Verified Domain
</Label>
<Input
id="resendDomain"
type="text"
value={formData.resendDomain}
onChange={(e) =>
handleInputChange("resendDomain", e.target.value)
}
placeholder="yourdomain.com"
className={errors.resendDomain ? "border-red-500" : ""}
/>
{errors.resendDomain && (
<p className="text-sm text-red-600">
{errors.resendDomain}
</p>
)}
<p className="text-sm text-gray-600">
This domain must be verified in your Resend account before
emails can be sent.
</p>
</div>
{/* From Name */}
<div className="space-y-2">
<Label
htmlFor="emailFromName"
className="flex items-center gap-2"
>
<User className="h-4 w-4" />
From Name (Optional)
</Label>
<Input
id="emailFromName"
type="text"
value={formData.emailFromName}
onChange={(e) =>
handleInputChange("emailFromName", e.target.value)
}
placeholder={formData.name || "Your Business Name"}
className={errors.emailFromName ? "border-red-500" : ""}
/>
{errors.emailFromName && (
<p className="text-sm text-red-600">
{errors.emailFromName}
</p>
)}
<p className="text-sm text-gray-600">
This will appear as the sender name in emails. Defaults to
your business name.
</p>
</div>
</div>
</CardContent>
</Card>
{/* Settings */}
<Card className="card-primary">
<CardHeader>

View File

@@ -0,0 +1,345 @@
"use client";
import { useEditor, EditorContent } from "@tiptap/react";
import StarterKit from "@tiptap/starter-kit";
import { TextStyle } from "@tiptap/extension-text-style";
import { Color } from "@tiptap/extension-color";
import { TextAlign } from "@tiptap/extension-text-align";
import { Button } from "~/components/ui/button";
import { Input } from "~/components/ui/input";
import { Label } from "~/components/ui/label";
import { Separator } from "~/components/ui/separator";
import { useEffect } from "react";
import {
Bold,
Italic,
Underline,
List,
ListOrdered,
AlignLeft,
AlignCenter,
AlignRight,
Palette,
} from "lucide-react";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "~/components/ui/popover";
interface EmailComposerProps {
subject: string;
onSubjectChange: (subject: string) => void;
content?: string;
onContentChange?: (content: string) => void;
customMessage?: string;
onCustomMessageChange?: (customMessage: string) => void;
fromEmail: string;
toEmail: string;
ccEmail?: string;
onCcEmailChange?: (ccEmail: string) => void;
bccEmail?: string;
onBccEmailChange?: (bccEmail: string) => void;
className?: string;
}
const MenuButton = ({
onClick,
isActive,
children,
title,
}: {
onClick: () => void;
isActive?: boolean;
children: React.ReactNode;
title: string;
}) => (
<Button
type="button"
variant={isActive ? "default" : "ghost"}
size="sm"
onClick={onClick}
title={title}
className="h-8 w-8 p-0"
>
{children}
</Button>
);
export function EmailComposer({
subject,
onSubjectChange,
content: _content,
onContentChange: _onContentChange,
customMessage = "",
onCustomMessageChange,
fromEmail,
toEmail,
ccEmail = "",
onCcEmailChange,
bccEmail = "",
onBccEmailChange,
className,
}: EmailComposerProps) {
const editor = useEditor({
extensions: [
StarterKit,
TextStyle,
Color.configure({
types: ["textStyle"],
}),
TextAlign.configure({
types: ["heading", "paragraph"],
}),
],
content: customMessage,
immediatelyRender: false,
onUpdate: ({ editor }) => {
onCustomMessageChange?.(editor.getHTML());
},
editorProps: {
attributes: {
class:
"prose prose-sm sm:prose lg:prose-lg xl:prose-2xl mx-auto focus:outline-none min-h-[120px] p-4 border rounded-md bg-background",
},
},
});
// Update editor content when customMessage prop changes
useEffect(() => {
if (editor && customMessage !== undefined) {
const currentContent = editor.getHTML();
if (currentContent !== customMessage) {
editor.commands.setContent(customMessage);
}
}
}, [editor, customMessage]);
const colors = [
"#000000",
"#374151",
"#DC2626",
"#EA580C",
"#D97706",
"#65A30D",
"#16A34A",
"#0891B2",
"#2563EB",
"#7C3AED",
"#C026D3",
"#DC2626",
];
if (!editor) {
return (
<div className="bg-muted flex h-[200px] items-center justify-center rounded-md border">
<div className="text-center">
<div className="border-primary mx-auto mb-2 h-4 w-4 animate-spin rounded-full border-2 border-t-transparent"></div>
<p className="text-muted-foreground text-sm">Loading editor...</p>
</div>
</div>
);
}
return (
<div className={className}>
{/* Email Headers */}
<div className="bg-muted/20 space-y-4 rounded-lg border p-4">
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="from-email" className="text-sm font-medium">
From
</Label>
<Input
id="from-email"
value={fromEmail}
disabled
className="bg-muted text-muted-foreground"
/>
</div>
<div className="space-y-2">
<Label htmlFor="to-email" className="text-sm font-medium">
To
</Label>
<Input
id="to-email"
value={toEmail}
disabled
className="bg-muted text-muted-foreground"
/>
</div>
</div>
{(onCcEmailChange ?? onBccEmailChange) && (
<div className="grid grid-cols-2 gap-4">
{onCcEmailChange && (
<div className="space-y-2">
<Label htmlFor="cc-email" className="text-sm font-medium">
CC
</Label>
<Input
id="cc-email"
value={ccEmail ?? ""}
onChange={(e) => onCcEmailChange(e.target.value)}
placeholder="CC email addresses..."
className="bg-background"
/>
</div>
)}
{onBccEmailChange && (
<div className="space-y-2">
<Label htmlFor="bcc-email" className="text-sm font-medium">
BCC
</Label>
<Input
id="bcc-email"
value={bccEmail}
onChange={(e) => onBccEmailChange(e.target.value)}
placeholder="BCC email addresses..."
className="bg-background"
/>
</div>
)}
</div>
)}
<div className="space-y-2">
<Label htmlFor="subject" className="text-sm font-medium">
Subject
</Label>
<Input
id="subject"
value={subject}
onChange={(e) => onSubjectChange(e.target.value)}
placeholder="Enter email subject..."
className="bg-background"
/>
</div>
</div>
<Separator className="my-4" />
{/* Custom Message Field with Rich Text Editor */}
{onCustomMessageChange && (
<div className="space-y-4">
<div>
<Label className="text-sm font-medium">
Custom Message (Optional)
</Label>
<p className="text-muted-foreground mb-2 text-xs">
This message will appear between the greeting and invoice summary
</p>
</div>
{/* Editor Toolbar */}
<div className="bg-muted/20 flex flex-wrap items-center gap-1 rounded-lg border p-2">
<MenuButton
onClick={() => editor.chain().focus().toggleBold().run()}
isActive={editor.isActive("bold")}
title="Bold"
>
<Bold className="h-4 w-4" />
</MenuButton>
<MenuButton
onClick={() => editor.chain().focus().toggleItalic().run()}
isActive={editor.isActive("italic")}
title="Italic"
>
<Italic className="h-4 w-4" />
</MenuButton>
<MenuButton
onClick={() => editor.chain().focus().toggleStrike().run()}
isActive={editor.isActive("strike")}
title="Strikethrough"
>
<Underline className="h-4 w-4" />
</MenuButton>
<Separator orientation="vertical" className="mx-1 h-6" />
<MenuButton
onClick={() => editor.chain().focus().setTextAlign("left").run()}
isActive={editor.isActive({ textAlign: "left" })}
title="Align Left"
>
<AlignLeft className="h-4 w-4" />
</MenuButton>
<MenuButton
onClick={() =>
editor.chain().focus().setTextAlign("center").run()
}
isActive={editor.isActive({ textAlign: "center" })}
title="Align Center"
>
<AlignCenter className="h-4 w-4" />
</MenuButton>
<MenuButton
onClick={() => editor.chain().focus().setTextAlign("right").run()}
isActive={editor.isActive({ textAlign: "right" })}
title="Align Right"
>
<AlignRight className="h-4 w-4" />
</MenuButton>
<Separator orientation="vertical" className="mx-1 h-6" />
<MenuButton
onClick={() => editor.chain().focus().toggleBulletList().run()}
isActive={editor.isActive("bulletList")}
title="Bullet List"
>
<List className="h-4 w-4" />
</MenuButton>
<MenuButton
onClick={() => editor.chain().focus().toggleOrderedList().run()}
isActive={editor.isActive("orderedList")}
title="Ordered List"
>
<ListOrdered className="h-4 w-4" />
</MenuButton>
<Separator orientation="vertical" className="mx-1 h-6" />
<Popover>
<PopoverTrigger asChild>
<Button
variant="ghost"
size="sm"
className="h-8 w-8 p-0"
title="Text Color"
>
<Palette className="h-4 w-4" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-48 p-2">
<div className="grid grid-cols-6 gap-1">
{colors.map((color) => (
<button
key={color}
type="button"
className="h-6 w-6 rounded border border-gray-300 hover:scale-110"
style={{ backgroundColor: color }}
onClick={() => {
editor.chain().focus().setColor(color).run();
}}
title={color}
/>
))}
</div>
</PopoverContent>
</Popover>
</div>
{/* Rich Text Editor */}
<div>
<EditorContent editor={editor} />
</div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,167 @@
"use client";
import { generateInvoiceEmailTemplate } from "~/lib/email-templates";
interface EmailPreviewProps {
subject: string;
fromEmail: string;
toEmail: string;
ccEmail?: string;
bccEmail?: string;
content: string;
customMessage?: string;
invoice?: {
invoiceNumber: string;
issueDate: Date;
dueDate: Date;
taxRate: number;
status?: string;
totalAmount?: number;
client?: {
name: string;
email: string | null;
};
business?: {
name: string;
email: string | null;
};
items?: Array<{
id: string;
hours: number;
rate: number;
}>;
};
className?: string;
}
export function EmailPreview({
subject,
fromEmail,
toEmail,
ccEmail,
bccEmail,
content,
customMessage,
invoice,
className,
}: EmailPreviewProps) {
// Calculate total from invoice items if available
const calculateTotal = () => {
if (!invoice?.items) return 0;
const subtotal = invoice.items.reduce(
(sum, item) => sum + item.hours * item.rate,
0,
);
const taxAmount = subtotal * (invoice.taxRate / 100);
return subtotal + taxAmount;
};
// Generate the branded email template if invoice is provided
const emailTemplate = invoice
? generateInvoiceEmailTemplate({
invoice: {
invoiceNumber: invoice.invoiceNumber,
issueDate: invoice.issueDate,
dueDate: invoice.dueDate,
status: invoice.status ?? "draft",
totalAmount: invoice.totalAmount ?? calculateTotal(),
taxRate: invoice.taxRate,
notes: null,
client: {
name: invoice.client?.name ?? "Client",
email: invoice.client?.email ?? null,
},
business: invoice.business ?? null,
items:
invoice.items?.map((item) => ({
date: new Date(),
description: "Service",
hours: item.hours,
rate: item.rate,
amount: item.hours * item.rate,
})) ?? [],
},
customContent: content,
customMessage: customMessage,
userName: invoice.business?.name ?? "Your Business",
userEmail: fromEmail,
baseUrl:
typeof window !== "undefined"
? window.location.origin
: "https://beenvoice.app",
})
: null;
return (
<div className={className}>
{/* Email Headers */}
<div className="bg-muted/20 mb-4 space-y-3 rounded-lg p-4">
<div className="grid grid-cols-1 gap-3 text-sm md:grid-cols-3">
<div>
<span className="text-muted-foreground block text-xs font-medium">
From:
</span>
<span className="font-mono text-sm break-all">{fromEmail}</span>
</div>
<div>
<span className="text-muted-foreground block text-xs font-medium">
To:
</span>
<span className="font-mono text-sm break-all">{toEmail}</span>
</div>
<div>
<span className="text-muted-foreground block text-xs font-medium">
Subject:
</span>
<span className="text-sm font-semibold break-words">
{subject || "No subject"}
</span>
</div>
</div>
{(ccEmail ?? bccEmail) && (
<div className="grid grid-cols-1 gap-3 text-sm md:grid-cols-2">
{ccEmail && (
<div>
<span className="text-muted-foreground block text-xs font-medium">
CC:
</span>
<span className="font-mono text-sm break-all">{ccEmail}</span>
</div>
)}
{bccEmail && (
<div>
<span className="text-muted-foreground block text-xs font-medium">
BCC:
</span>
<span className="font-mono text-sm break-all">{bccEmail}</span>
</div>
)}
</div>
)}
</div>
{/* Email Content */}
{emailTemplate ? (
<div className="rounded-lg border bg-gray-50 p-1 shadow-sm">
<iframe
srcDoc={emailTemplate.html}
className="h-[700px] w-full rounded border-0"
title="Email Preview"
sandbox="allow-same-origin"
/>
</div>
) : (
<div className="text-muted-foreground flex min-h-[400px] items-center justify-center">
<p className="text-center text-sm">
Email preview will appear here...
<br />
<span className="text-xs">
Professional beenvoice-branded template will be generated
automatically
</span>
</p>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,98 @@
"use client";
import { useRouter } from "next/navigation";
import { Button } from "~/components/ui/button";
import { api } from "~/trpc/react";
import { Send, Loader2, Mail, MailCheck } from "lucide-react";
interface EnhancedSendInvoiceButtonProps {
invoiceId: string;
variant?: "default" | "outline" | "ghost" | "icon";
className?: string;
showResend?: boolean;
size?: "default" | "sm" | "lg" | "icon";
}
export function EnhancedSendInvoiceButton({
invoiceId,
variant = "outline",
className,
showResend = false,
size = "default",
}: EnhancedSendInvoiceButtonProps) {
const router = useRouter();
// Fetch invoice data
const { data: invoiceData, isLoading: invoiceLoading } =
api.invoices.getById.useQuery({
id: invoiceId,
});
// Check if client has email
const hasClientEmail =
invoiceData?.client?.email && invoiceData.client.email.trim() !== "";
const handleSendClick = () => {
router.push(`/dashboard/invoices/${invoiceId}/send`);
};
// Icon variant for compact display
if (variant === "icon") {
return (
<Button
variant="ghost"
size="sm"
className={className}
disabled={invoiceLoading || !hasClientEmail}
onClick={handleSendClick}
title={
!hasClientEmail
? "Client has no email address"
: showResend
? "Resend Invoice"
: "Send Invoice"
}
>
{invoiceLoading ? (
<Loader2 className="h-3 w-3 animate-spin sm:h-4 sm:w-4" />
) : hasClientEmail ? (
<Send className="h-3 w-3 sm:h-4 sm:w-4" />
) : (
<Mail className="h-3 w-3 opacity-50 sm:h-4 sm:w-4" />
)}
</Button>
);
}
return (
<Button
variant={variant}
size={size}
className={`shadow-sm ${className}`}
disabled={invoiceLoading || !hasClientEmail}
onClick={handleSendClick}
data-testid="enhanced-send-invoice-button"
>
{invoiceLoading ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
<span>Loading...</span>
</>
) : !hasClientEmail ? (
<>
<Mail className="mr-2 h-4 w-4 opacity-50" />
<span>No Email Address</span>
</>
) : (
<>
{invoiceData?.status === "sent" ? (
<MailCheck className="mr-2 h-4 w-4" />
) : (
<Send className="mr-2 h-4 w-4" />
)}
<span>{showResend ? "Resend Invoice" : "Send Invoice"}</span>
</>
)}
</Button>
);
}

View File

@@ -2,7 +2,7 @@
import * as React from "react";
import { useCallback } from "react";
import { useDropzone } from "react-dropzone";
import { useDropzone, type FileRejection } from "react-dropzone";
import { cn } from "~/lib/utils";
import { Upload, FileText, X, CheckCircle, AlertCircle } from "lucide-react";
import { Button } from "~/components/ui/button";
@@ -98,7 +98,7 @@ export function FileUpload({
const [errors, setErrors] = React.useState<Record<string, string>>({});
const onDrop = useCallback(
(acceptedFiles: File[], rejectedFiles: any[]) => {
(acceptedFiles: File[], rejectedFiles: FileRejection[]) => {
// Handle accepted files
const newFiles = [...files, ...acceptedFiles];
setFiles(newFiles);
@@ -106,19 +106,19 @@ export function FileUpload({
// 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") {
rejectedFiles.forEach(({ file, errors: fileErrors }) => {
const errorMessage = fileErrors
.map((error) => {
if (error.code === "file-too-large") {
return `File is too large. Max size is ${(maxSize / 1024 / 1024).toFixed(1)}MB`;
}
if (e.code === "file-invalid-type") {
if (error.code === "file-invalid-type") {
return "File type not supported";
}
if (e.code === "too-many-files") {
if (error.code === "too-many-files") {
return `Too many files. Max is ${maxFiles}`;
}
return e.message;
return error.message;
})
.join(", ");
newErrors[file.name] = errorMessage;

View File

@@ -38,7 +38,6 @@ const STATUS_OPTIONS = [
{ value: "draft", label: "Draft" },
{ value: "sent", label: "Sent" },
{ value: "paid", label: "Paid" },
{ value: "overdue", label: "Overdue" },
];
interface InvoiceFormProps {
@@ -60,7 +59,7 @@ interface FormData {
clientId: string;
issueDate: Date;
dueDate: Date;
status: "draft" | "sent" | "paid" | "overdue";
status: "draft" | "sent" | "paid";
notes: string;
taxRate: number;
defaultHourlyRate: number;
@@ -158,7 +157,7 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
clientId: existingInvoice.clientId,
issueDate: new Date(existingInvoice.issueDate),
dueDate: new Date(existingInvoice.dueDate),
status: existingInvoice.status as "draft" | "sent" | "paid" | "overdue",
status: existingInvoice.status as "draft" | "sent" | "paid",
notes: existingInvoice.notes ?? "",
taxRate: existingInvoice.taxRate,
defaultHourlyRate: 25,
@@ -523,9 +522,9 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
<Label htmlFor="status">Status</Label>
<Select
value={formData.status}
onValueChange={(
value: "draft" | "sent" | "paid" | "overdue",
) => updateField("status", value)}
onValueChange={(value: "draft" | "sent" | "paid") =>
updateField("status", value)
}
>
<SelectTrigger>
<SelectValue />

View File

@@ -1,23 +1,8 @@
"use client";
import * as React from "react";
import { Button } from "~/components/ui/button";
import { Card, CardContent } from "~/components/ui/card";
import { Input } from "~/components/ui/input";
import { Label } from "~/components/ui/label";
import { DatePicker } from "~/components/ui/date-picker";
import { NumberInput } from "~/components/ui/number-input";
import {
Trash2,
Plus,
GripVertical,
ChevronUp,
ChevronDown,
} from "lucide-react";
import { cn } from "~/lib/utils";
import {
DndContext,
closestCenter,
DndContext,
KeyboardSensor,
PointerSensor,
useSensor,
@@ -28,10 +13,24 @@ import {
arrayMove,
SortableContext,
sortableKeyboardCoordinates,
useSortable,
verticalListSortingStrategy,
} from "@dnd-kit/sortable";
import { useSortable } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import {
ChevronDown,
ChevronUp,
GripVertical,
Plus,
Trash2,
} from "lucide-react";
import * as React from "react";
import { Button } from "~/components/ui/button";
import { DatePicker } from "~/components/ui/date-picker";
import { Input } from "~/components/ui/input";
import { Label } from "~/components/ui/label";
import { NumberInput } from "~/components/ui/number-input";
import { cn } from "~/lib/utils";
interface InvoiceItem {
id: string;

View File

@@ -0,0 +1,451 @@
"use client";
import { useState } from "react";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "~/components/ui/dialog";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "~/components/ui/tabs";
import { Button } from "~/components/ui/button";
import { EmailComposer } from "./email-composer";
import { EmailPreview } from "./email-preview";
import { toast } from "sonner";
import { api } from "~/trpc/react";
import {
Send,
Loader2,
Eye,
Edit3,
CheckCircle,
AlertTriangle,
Mail,
} from "lucide-react";
import { Alert, AlertDescription } from "~/components/ui/alert";
interface SendEmailDialogProps {
invoiceId: string;
trigger: React.ReactNode;
invoice?: {
id: string;
invoiceNumber: string;
issueDate: Date;
dueDate: Date;
status: string;
taxRate: number;
client?: {
name: string;
email: string | null;
};
business?: {
name: string;
email: string | null;
};
items?: Array<{
id: string;
hours: number;
rate: number;
}>;
};
onEmailSent?: () => void;
}
export function SendEmailDialog({
invoiceId,
trigger,
invoice,
onEmailSent,
}: SendEmailDialogProps) {
const [isOpen, setIsOpen] = useState(false);
const [activeTab, setActiveTab] = useState("compose");
const [isSending, setIsSending] = useState(false);
const [isConfirming, setIsConfirming] = useState(false);
// Email content state
const [subject, setSubject] = useState(() =>
invoice
? `Invoice ${invoice.invoiceNumber} from ${invoice.business?.name ?? "Your Business"}`
: "Invoice from Your Business",
);
const [ccEmail, setCcEmail] = useState("");
const [bccEmail, setBccEmail] = useState("");
const [customMessage, setCustomMessage] = useState("");
const [emailContent, setEmailContent] = useState(() => {
const getTimeOfDayGreeting = () => {
const hour = new Date().getHours();
if (hour < 12) return "Good morning";
if (hour < 17) return "Good afternoon";
return "Good evening";
};
const formatDate = (date: Date) => {
return new Intl.DateTimeFormat("en-US", {
year: "numeric",
month: "long",
day: "numeric",
}).format(new Date(date));
};
if (!invoice) return "";
const businessName = invoice.business?.name ?? "Your Business";
const issueDate = formatDate(invoice.issueDate);
// Calculate total from items
const subtotal =
invoice.items?.reduce((sum, item) => sum + item.hours * item.rate, 0) ??
0;
const taxAmount = subtotal * (invoice.taxRate / 100);
const total = subtotal + taxAmount;
return `<p>${getTimeOfDayGreeting()},</p>
<p>I hope this email finds you well. Please find attached invoice <strong>${invoice.invoiceNumber}</strong> dated ${issueDate}.</p>
<p>The invoice details are as follows:</p>
<ul>
<li><strong>Invoice Number:</strong> ${invoice.invoiceNumber}</li>
<li><strong>Issue Date:</strong> ${issueDate}</li>
<li><strong>Amount Due:</strong> ${new Intl.NumberFormat("en-US", {
style: "currency",
currency: "USD",
}).format(total)}</li>
</ul>
<p>Please let me know if you have any questions or need any clarification regarding this invoice. I appreciate your prompt attention to this matter.</p>
<p>Thank you for your business!</p>
<p>Best regards,<br><strong>${businessName}</strong></p>`;
});
// Get utils for cache invalidation
const utils = api.useUtils();
// Email sending mutation
const sendEmailMutation = api.email.sendInvoice.useMutation({
onSuccess: (data) => {
toast.success("Email sent successfully!", {
description: data.message,
duration: 5000,
});
// Reset state and close dialog
setIsOpen(false);
setActiveTab("compose");
setIsSending(false);
setIsConfirming(false);
// Refresh invoice data
void utils.invoices.getById.invalidate({ id: invoiceId });
// Callback for parent component
onEmailSent?.();
},
onError: (error) => {
console.error("Email send error:", error);
let errorMessage = "Failed to send invoice email";
let errorDescription = error.message;
if (error.message.includes("Invalid recipient")) {
errorMessage = "Invalid Email Address";
errorDescription =
"Please check the client's email address and try again.";
} else if (error.message.includes("domain not verified")) {
errorMessage = "Email Configuration Issue";
errorDescription = "Please contact support to configure email sending.";
} else if (error.message.includes("rate limit")) {
errorMessage = "Too Many Emails";
errorDescription = "Please wait a moment before sending another email.";
} else if (error.message.includes("no email address")) {
errorMessage = "No Email Address";
errorDescription = "This client doesn't have an email address on file.";
}
toast.error(errorMessage, {
description: errorDescription,
duration: 6000,
});
setIsSending(false);
setIsConfirming(false);
},
});
const handleSendEmail = async () => {
if (!invoice?.client?.email || invoice.client.email.trim() === "") {
toast.error("No email address", {
description: "This client doesn't have an email address on file.",
});
return;
}
if (!subject.trim()) {
toast.error("Subject required", {
description: "Please enter an email subject before sending.",
});
return;
}
if (!emailContent.trim()) {
toast.error("Message required", {
description: "Please enter an email message before sending.",
});
return;
}
setIsSending(true);
try {
// Use the enhanced API with custom subject and content
await sendEmailMutation.mutateAsync({
invoiceId,
customSubject: subject,
customContent: emailContent,
customMessage: customMessage.trim() || undefined,
useHtml: true,
ccEmails: ccEmail.trim() || undefined,
bccEmails: bccEmail.trim() || undefined,
});
} catch (error) {
// Error handling is done in the mutation's onError
console.error("Send email error:", error);
}
};
const handleConfirmSend = () => {
setIsConfirming(true);
setActiveTab("confirm");
};
const fromEmail = invoice?.business?.email ?? "noreply@yourdomain.com";
const toEmail = invoice?.client?.email ?? "";
const canSend =
!isSending &&
subject.trim() &&
emailContent.trim() &&
toEmail &&
toEmail.trim() !== "";
return (
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogTrigger asChild>{trigger}</DialogTrigger>
<DialogContent className="flex max-h-[90vh] max-w-4xl flex-col overflow-hidden">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Mail className="h-5 w-5 text-green-600" />
Send Invoice Email
</DialogTitle>
<DialogDescription>
Compose and preview your invoice email before sending to{" "}
{invoice?.client?.name ?? "client"}.
</DialogDescription>
</DialogHeader>
{/* Warning for missing email */}
{(!toEmail || toEmail.trim() === "") && (
<Alert variant="destructive">
<AlertTriangle className="h-4 w-4" />
<AlertDescription>
This client doesn&apos;t have an email address. Please add an
email address to the client before sending the invoice.
</AlertDescription>
</Alert>
)}
{/* Branded Template Info */}
<Alert>
<Mail className="h-4 w-4" />
<AlertDescription>
<strong>Professional Email Template:</strong> Your email will be
sent using a beautifully designed, beenvoice-branded template with
proper fonts and styling. Any custom content you add will be
incorporated into the professional template automatically.
</AlertDescription>
</Alert>
<Tabs
value={activeTab}
onValueChange={setActiveTab}
className="min-h-0 flex-1"
>
<TabsList className="grid w-full grid-cols-3">
<TabsTrigger value="compose" className="flex items-center gap-2">
<Edit3 className="h-4 w-4" />
Compose
</TabsTrigger>
<TabsTrigger value="preview" className="flex items-center gap-2">
<Eye className="h-4 w-4" />
Preview
</TabsTrigger>
<TabsTrigger
value="confirm"
className="flex items-center gap-2"
disabled={!isConfirming}
>
<CheckCircle className="h-4 w-4" />
Confirm
</TabsTrigger>
</TabsList>
<div className="flex-1 overflow-hidden">
<TabsContent
value="compose"
className="mt-4 h-full overflow-y-auto"
>
<EmailComposer
subject={subject}
onSubjectChange={setSubject}
content={emailContent}
onContentChange={setEmailContent}
customMessage={customMessage}
onCustomMessageChange={setCustomMessage}
fromEmail={fromEmail}
toEmail={toEmail}
ccEmail={ccEmail}
onCcEmailChange={setCcEmail}
bccEmail={bccEmail}
onBccEmailChange={setBccEmail}
/>
</TabsContent>
<TabsContent
value="preview"
className="mt-4 h-full overflow-y-auto"
>
<EmailPreview
subject={subject}
fromEmail={fromEmail}
toEmail={toEmail}
ccEmail={ccEmail}
bccEmail={bccEmail}
content={emailContent}
customMessage={customMessage}
invoice={invoice}
className="pr-2"
/>
</TabsContent>
<TabsContent
value="confirm"
className="mt-4 h-full overflow-y-auto"
>
<div className="space-y-6 pr-2">
<Alert>
<CheckCircle className="h-4 w-4" />
<AlertDescription>
You&apos;re about to send this email to{" "}
<strong>{toEmail}</strong>. The invoice PDF will be
automatically attached.
</AlertDescription>
</Alert>
<EmailPreview
subject={subject}
fromEmail={fromEmail}
toEmail={toEmail}
content={emailContent}
customMessage={customMessage}
invoice={invoice}
/>
{invoice?.status === "draft" && (
<Alert>
<AlertTriangle className="h-4 w-4" />
<AlertDescription>
This invoice is currently in <strong>draft</strong>{" "}
status. Sending it will automatically change the status to{" "}
<strong>sent</strong>.
</AlertDescription>
</Alert>
)}
</div>
</TabsContent>
</div>
</Tabs>
<DialogFooter className="flex items-center justify-between">
<div className="flex items-center gap-2">
{activeTab === "compose" && (
<Button
variant="outline"
onClick={() => setActiveTab("preview")}
disabled={isSending}
>
<Eye className="mr-2 h-4 w-4" />
Preview
</Button>
)}
{activeTab === "preview" && (
<>
<Button
variant="outline"
onClick={() => setActiveTab("compose")}
disabled={isSending}
>
<Edit3 className="mr-2 h-4 w-4" />
Edit
</Button>
<Button
onClick={handleConfirmSend}
disabled={!canSend}
variant="default"
>
<CheckCircle className="mr-2 h-4 w-4" />
Review & Send
</Button>
</>
)}
{activeTab === "confirm" && (
<>
<Button
variant="outline"
onClick={() => setActiveTab("preview")}
disabled={isSending}
>
Back to Preview
</Button>
<Button
onClick={handleSendEmail}
disabled={!canSend || isSending}
className="bg-green-600 hover:bg-green-700"
>
{isSending ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Sending...
</>
) : (
<>
<Send className="mr-2 h-4 w-4" />
Send Email
</>
)}
</Button>
</>
)}
</div>
<Button
variant="ghost"
onClick={() => setIsOpen(false)}
disabled={isSending}
>
Cancel
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -1,20 +1,18 @@
"use client";
import { signOut, useSession } from "next-auth/react";
import Link from "next/link";
import { useSession, signOut } from "next-auth/react";
import { useState } from "react";
import { Button } from "~/components/ui/button";
import { Skeleton } from "~/components/ui/skeleton";
import { Logo } from "~/components/branding/logo";
import { SidebarTrigger } from "~/components/navigation/sidebar-trigger";
import { api } from "~/trpc/react";
import { FileText, Edit } from "lucide-react";
import { Button } from "~/components/ui/button";
import { Skeleton } from "~/components/ui/skeleton";
export function Navbar() {
const { data: session, status } = useSession();
const [isMobileNavOpen, setIsMobileNavOpen] = useState(false);
// Get current open invoice for quick access
const { data: currentInvoice } = api.invoices.getCurrentOpen.useQuery();
// const { data: currentInvoice } = api.invoices.getCurrentOpen.useQuery();
return (
<header className="fixed top-2 right-2 left-2 z-30 md:top-3 md:right-3 md:left-3">
@@ -30,7 +28,6 @@ export function Navbar() {
</Link>
</div>
<div className="flex items-center gap-2 md:gap-4">
{status === "loading" ? (
<>
<Skeleton className="bg-muted/20 hidden h-5 w-20 sm:inline" />

View File

@@ -22,11 +22,11 @@ export function PageHeader({
switch (variant) {
case "gradient":
return `${baseClasses} text-3xl text-brand-gradient`;
return `${baseClasses} text-3xl text-foreground`;
case "large":
return `${baseClasses} text-4xl text-foreground`;
case "large-gradient":
return `${baseClasses} text-4xl text-brand-gradient`;
return `${baseClasses} text-4xl text-foreground`;
default:
return `${baseClasses} text-3xl text-foreground`;
}

View File

@@ -1,21 +1,21 @@
"use client";
import { format } from "date-fns";
import { ChevronRight } from "lucide-react";
import Link from "next/link";
import { usePathname } from "next/navigation";
import React from "react";
import {
Breadcrumb,
BreadcrumbList,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbList,
BreadcrumbPage,
BreadcrumbSeparator,
} from "~/components/ui/breadcrumb";
import { usePathname } from "next/navigation";
import Link from "next/link";
import { ChevronRight } from "lucide-react";
import React from "react";
import { api } from "~/trpc/react";
import { format } from "date-fns";
import { Skeleton } from "~/components/ui/skeleton";
import { getRouteLabel, capitalize } from "~/lib/pluralize";
import { getRouteLabel } from "~/lib/pluralize";
import { api } from "~/trpc/react";
function isUUID(str: string) {
return /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/.test(
@@ -40,7 +40,7 @@ export function DashboardBreadcrumbs() {
const resourceType = segments[1]; // e.g., 'clients', 'invoices', 'businesses'
const resourceId =
segments[2] && isUUID(segments[2]) ? segments[2] : undefined;
const action = segments[3]; // e.g., 'edit'
// const action = segments[3]; // e.g., 'edit'
// Fetch client data if needed
const { data: client, isLoading: clientLoading } =

View File

@@ -1,12 +1,11 @@
"use client";
import { Button } from "~/components/ui/button";
import { Skeleton } from "~/components/ui/skeleton";
import { MenuIcon, X } from "lucide-react";
import { useState } from "react";
import { useSession } from "next-auth/react";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { useSession } from "next-auth/react";
import { Button } from "~/components/ui/button";
import { Skeleton } from "~/components/ui/skeleton";
import { navigationConfig } from "~/lib/navigation";
interface SidebarTriggerProps {

View File

@@ -0,0 +1,59 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "~/lib/utils"
const alertVariants = cva(
"relative w-full rounded-lg border p-4 [&>svg~*]:pl-7 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground",
{
variants: {
variant: {
default: "bg-background text-foreground",
destructive:
"border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive",
},
},
defaultVariants: {
variant: "default",
},
}
)
const Alert = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement> & VariantProps<typeof alertVariants>
>(({ className, variant, ...props }, ref) => (
<div
ref={ref}
role="alert"
className={cn(alertVariants({ variant }), className)}
{...props}
/>
))
Alert.displayName = "Alert"
const AlertTitle = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLHeadingElement>
>(({ className, ...props }, ref) => (
<h5
ref={ref}
className={cn("mb-1 font-medium leading-none tracking-tight", className)}
{...props}
/>
))
AlertTitle.displayName = "AlertTitle"
const AlertDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("text-sm [&_p]:leading-relaxed", className)}
{...props}
/>
))
AlertDescription.displayName = "AlertDescription"
export { Alert, AlertTitle, AlertDescription }

View File

@@ -252,7 +252,7 @@ function CalendarDayButton({
modifiers,
...props
}: React.ComponentProps<typeof DayButton>) {
const _defaultClassNames = getDefaultClassNames();
// const _defaultClassNames = getDefaultClassNames();
const ref = React.useRef<HTMLButtonElement>(null);
React.useEffect(() => {

View File

@@ -1,9 +1,9 @@
"use client"
"use client";
import * as React from "react"
import * as ProgressPrimitive from "@radix-ui/react-progress"
import * as React from "react";
import * as ProgressPrimitive from "@radix-ui/react-progress";
import { cn } from "~/lib/utils"
import { cn } from "~/lib/utils";
function Progress({
className,
@@ -15,17 +15,17 @@ function Progress({
data-slot="progress"
className={cn(
"bg-primary/20 relative h-2 w-full overflow-hidden rounded-full",
className
className,
)}
{...props}
>
<ProgressPrimitive.Indicator
data-slot="progress-indicator"
className="bg-primary h-full w-full flex-1 transition-all"
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
style={{ transform: `translateX(-${100 - (value ?? 0)}%)` }}
/>
</ProgressPrimitive.Root>
)
);
}
export { Progress }
export { Progress };