Add business nickname support across app and API

This commit is contained in:
2025-08-11 01:50:20 -04:00
parent 93ffdf3c86
commit a680f89a46
19 changed files with 308 additions and 1193 deletions

View File

@@ -45,7 +45,7 @@ export default async function BusinessDetailPage({
return (
<div className="space-y-6 pb-32">
<PageHeader
title={business.name}
title={`${business.name}${business.nickname ? ` (${business.nickname})` : ""}`}
description="View business details and information"
variant="gradient"
>
@@ -69,7 +69,7 @@ export default async function BusinessDetailPage({
<Card className="bg-card border-border border">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<div className="bg-primary/10 p-2">
<div className="bg-primary/10 p-2">
<Building className="text-primary h-5 w-5" />
</div>
<span>Business Information</span>
@@ -84,7 +84,7 @@ export default async function BusinessDetailPage({
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
{business.email && (
<div className="flex items-center space-x-3">
<div className="bg-primary/10 p-2">
<div className="bg-primary/10 p-2">
<Mail className="text-primary h-4 w-4" />
</div>
<div>
@@ -100,7 +100,7 @@ export default async function BusinessDetailPage({
{business.phone && (
<div className="flex items-center space-x-3">
<div className="bg-primary/10 p-2">
<div className="bg-primary/10 p-2">
<Phone className="text-primary h-4 w-4" />
</div>
<div>
@@ -116,7 +116,7 @@ export default async function BusinessDetailPage({
{business.website && (
<div className="flex items-center space-x-3">
<div className="bg-primary/10 p-2">
<div className="bg-primary/10 p-2">
<Globe className="text-primary h-4 w-4" />
</div>
<div>
@@ -137,7 +137,7 @@ export default async function BusinessDetailPage({
{business.taxId && (
<div className="flex items-center space-x-3">
<div className="bg-primary/10 p-2">
<div className="bg-primary/10 p-2">
<Hash className="text-primary h-4 w-4" />
</div>
<div>
@@ -162,7 +162,7 @@ export default async function BusinessDetailPage({
Business Address
</h3>
<div className="flex items-start space-x-3">
<div className="bg-primary/10 p-2">
<div className="bg-primary/10 p-2">
<MapPin className="text-primary h-4 w-4" />
</div>
<div className="space-y-1 text-sm">
@@ -205,7 +205,7 @@ export default async function BusinessDetailPage({
<h3 className="mb-4 text-lg font-semibold">Business Details</h3>
<div className="space-y-4">
<div className="flex items-center space-x-3">
<div className="bg-primary/10 p-2">
<div className="bg-primary/10 p-2">
<Calendar className="text-primary h-4 w-4" />
</div>
<div>
@@ -218,10 +218,31 @@ export default async function BusinessDetailPage({
</div>
</div>
{business.nickname && (
<div className="flex items-center space-x-3">
<div className="bg-primary/10 p-2">
<Building className="text-primary h-4 w-4" />
</div>
<div>
<div className="flex items-center gap-2">
<p className="text-muted-foreground text-sm font-medium">
Nickname
</p>
<Badge variant="outline" className="text-xs">
Internal only
</Badge>
</div>
<p className="text-foreground text-sm">
{business.nickname}
</p>
</div>
</div>
)}
{/* Default Business Badge */}
{business.isDefault && (
<div className="flex items-center space-x-3">
<div className="bg-primary/10 p-2">
<div className="bg-primary/10 p-2">
<Building className="text-primary h-4 w-4" />
</div>
<div>
@@ -248,7 +269,7 @@ export default async function BusinessDetailPage({
<Card className="bg-card border-border border">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<div className="bg-primary/10 p-2">
<div className="bg-primary/10 p-2">
<Building className="text-primary h-5 w-5" />
</div>
<span>Quick Actions</span>

View File

@@ -22,6 +22,7 @@ import { toast } from "sonner";
interface Business {
id: string;
name: string;
nickname: string | null;
email: string | null;
phone: string | null;
addressLine1: string | null;
@@ -61,6 +62,11 @@ export function BusinessesDataTable({ businesses }: BusinessesDataTableProps) {
const utils = api.useUtils();
const searchableBusinesses = businesses.map((b) => ({
...b,
searchValue: `${b.name} ${b.nickname ?? ""}`.trim(),
}));
const deleteBusinessMutation = api.businesses.delete.useMutation({
onSuccess: () => {
toast.success("Business deleted successfully");
@@ -91,7 +97,7 @@ export function BusinessesDataTable({ businesses }: BusinessesDataTableProps) {
const business = row.original;
return (
<div className="flex items-center gap-3">
<div className="bg-primary/10 hidden p-2 sm:flex">
<div className="bg-primary/10 hidden p-2 sm:flex">
<Building className="text-primary h-4 w-4" />
</div>
<div className="min-w-0">
@@ -104,6 +110,17 @@ export function BusinessesDataTable({ businesses }: BusinessesDataTableProps) {
);
},
},
{
accessorKey: "nickname",
header: ({ column }) => (
<DataTableColumnHeader column={column} title="Nickname" />
),
cell: ({ row }) => row.original.nickname ?? "—",
meta: {
headerClassName: "hidden sm:table-cell",
cellClassName: "hidden sm:table-cell",
},
},
{
accessorKey: "phone",
header: ({ column }) => (
@@ -175,6 +192,15 @@ export function BusinessesDataTable({ businesses }: BusinessesDataTableProps) {
);
},
},
{
accessorKey: "searchValue",
header: "Search",
cell: () => null,
meta: {
headerClassName: "hidden",
cellClassName: "hidden",
},
},
{
id: "actions",
cell: ({ row }) => {
@@ -210,9 +236,9 @@ export function BusinessesDataTable({ businesses }: BusinessesDataTableProps) {
<>
<DataTable
columns={columns}
data={businesses}
searchKey="name"
searchPlaceholder="Search businesses..."
data={searchableBusinesses}
searchKey="searchValue"
searchPlaceholder="Search by name or nickname..."
onRowClick={handleRowClick}
/>

View File

@@ -31,7 +31,7 @@ export default async function BusinessesPage() {
</PageHeader>
<HydrateClient>
<Suspense fallback={<DataTableSkeleton columns={6} rows={5} />}>
<Suspense fallback={<DataTableSkeleton columns={7} rows={5} />}>
<BusinessesTable />
</Suspense>
</HydrateClient>

View File

@@ -165,6 +165,7 @@ export default function SendEmailPage() {
business: invoiceData.business
? {
name: invoiceData.business.name,
nickname: invoiceData.business.nickname,
email: invoiceData.business.email,
}
: undefined,

View File

@@ -98,6 +98,7 @@ export function DataTable<TData, TValue>({
React.useState<VisibilityState>({});
const [rowSelection, setRowSelection] = React.useState({});
const [globalFilter, setGlobalFilter] = React.useState("");
const [searchInput, setSearchInput] = React.useState("");
// Mobile detection hook
const [isMobile, setIsMobile] = React.useState(false);
@@ -171,6 +172,19 @@ export function DataTable<TData, TValue>({
table.setPageSize(isMobile ? 5 : pageSize);
}, [isMobile, pageSize, table]);
// Debounce search input updates to the table's global filter
React.useEffect(() => {
const timeout = setTimeout(() => {
setGlobalFilter(searchInput);
}, 300);
return () => clearTimeout(timeout);
}, [searchInput]);
// Keep search input in sync when globalFilter is changed externally (e.g., "Clear filters")
React.useEffect(() => {
setSearchInput(globalFilter ?? "");
}, [globalFilter]);
const pageSizeOptions = [5, 10, 20, 30, 50, 100];
// Handle row click
@@ -223,8 +237,8 @@ export function DataTable<TData, TValue>({
<Search className="text-foreground absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2" />
<Input
placeholder={searchPlaceholder}
value={globalFilter ?? ""}
onChange={(event) => setGlobalFilter(event.target.value)}
value={searchInput ?? ""}
onChange={(event) => setSearchInput(event.target.value)}
className="h-9 w-full pr-3 pl-9"
/>
</div>

View File

@@ -188,7 +188,7 @@ export function InvoiceView({ invoiceId }: InvoiceViewProps) {
<div className="flex items-start justify-between">
<div className="space-y-4">
<div className="flex items-center gap-3">
<div className="bg-primary/10 p-2">
<div className="bg-primary/10 p-2">
<FileText className="text-primary h-6 w-6" />
</div>
<div>
@@ -239,7 +239,7 @@ export function InvoiceView({ invoiceId }: InvoiceViewProps) {
>
{isExportingPDF ? (
<>
<div className="mr-2 h-4 w-4 animate-spin border-2 border-white border-t-transparent" />
<div className="mr-2 h-4 w-4 animate-spin border-2 border-white border-t-transparent" />
Generating PDF...
</>
) : (
@@ -326,7 +326,7 @@ export function InvoiceView({ invoiceId }: InvoiceViewProps) {
</CardTitle>
</CardHeader>
<CardContent>
<div className="border-border overflow-hidden border">
<div className="border-border overflow-hidden border">
<table className="w-full">
<thead className="bg-muted">
<tr>
@@ -479,7 +479,7 @@ export function InvoiceView({ invoiceId }: InvoiceViewProps) {
</Card>
{/* Danger Zone */}
<Card className="bg-card border-border border border-destructive/20">
<Card className="bg-card border-destructive/20 border">
<CardHeader>
<CardTitle className="text-destructive">Danger Zone</CardTitle>
</CardHeader>

View File

@@ -46,6 +46,7 @@ interface BusinessFormProps {
interface FormData {
name: string;
nickname: string;
email: string;
phone: string;
addressLine1: string;
@@ -64,6 +65,7 @@ interface FormData {
interface FormErrors {
name?: string;
nickname?: string;
email?: string;
phone?: string;
addressLine1?: string;
@@ -80,6 +82,7 @@ interface FormErrors {
const initialFormData: FormData = {
name: "",
nickname: "",
email: "",
phone: "",
addressLine1: "",
@@ -153,6 +156,7 @@ export function BusinessForm({ businessId, mode }: BusinessFormProps) {
if (business && mode === "edit") {
setFormData({
name: business.name,
nickname: business.nickname ?? "",
email: business.email ?? "",
phone: business.phone ?? "",
addressLine1: business.addressLine1 ?? "",
@@ -198,6 +202,10 @@ export function BusinessForm({ businessId, mode }: BusinessFormProps) {
if (!formData.name.trim()) {
newErrors.name = VALIDATION_MESSAGES.required;
}
// Nickname validation (optional, max 255 chars)
if (formData.nickname && formData.nickname.length > 255) {
newErrors.nickname = "Nickname must be 255 characters or less";
}
// Email validation
if (formData.email && !isValidEmail(formData.email)) {
@@ -280,6 +288,8 @@ export function BusinessForm({ businessId, mode }: BusinessFormProps) {
// Format website URL before submission
const dataToSubmit = {
...formData,
name: formData.name.trim(),
nickname: formData.nickname.trim(),
website: formData.website ? formatWebsiteUrl(formData.website) : "",
};
@@ -287,6 +297,7 @@ export function BusinessForm({ businessId, mode }: BusinessFormProps) {
// Create business data (excluding email config fields)
const businessData = {
name: dataToSubmit.name,
nickname: dataToSubmit.nickname,
email: dataToSubmit.email,
phone: dataToSubmit.phone,
addressLine1: dataToSubmit.addressLine1,
@@ -320,6 +331,7 @@ export function BusinessForm({ businessId, mode }: BusinessFormProps) {
// Update business data (excluding email config fields)
const businessData = {
name: dataToSubmit.name,
nickname: dataToSubmit.nickname,
email: dataToSubmit.email,
phone: dataToSubmit.phone,
addressLine1: dataToSubmit.addressLine1,
@@ -442,7 +454,7 @@ export function BusinessForm({ businessId, mode }: BusinessFormProps) {
<Card className="bg-card border-border border">
<CardHeader>
<div className="flex items-center gap-3">
<div className="bg-muted flex h-10 w-10 items-center justify-center ">
<div className="bg-muted flex h-10 w-10 items-center justify-center">
<Building className="text-muted-foreground h-5 w-5" />
</div>
<div>
@@ -475,6 +487,29 @@ export function BusinessForm({ businessId, mode }: BusinessFormProps) {
)}
</div>
<div className="space-y-2">
<Label htmlFor="nickname" className="text-sm font-medium">
Nickname
<span className="text-muted-foreground ml-1 text-xs font-normal">
(Optional)
</span>
</Label>
<Input
id="nickname"
value={formData.nickname}
onChange={(e) =>
handleInputChange("nickname", e.target.value)
}
placeholder="e.g., Personal, Work, LLC"
disabled={isSubmitting}
/>
{errors.nickname && (
<p className="text-destructive text-sm">
{errors.nickname}
</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="taxId" className="text-sm font-medium">
Tax ID (EIN)
@@ -569,7 +604,7 @@ export function BusinessForm({ businessId, mode }: BusinessFormProps) {
<Card className="bg-card border-border border">
<CardHeader>
<div className="flex items-center gap-3">
<div className="bg-muted flex h-10 w-10 items-center justify-center ">
<div className="bg-muted flex h-10 w-10 items-center justify-center">
<svg
className="text-muted-foreground h-5 w-5"
fill="none"
@@ -617,7 +652,7 @@ export function BusinessForm({ businessId, mode }: BusinessFormProps) {
<Card className="bg-card border-border border">
<CardHeader>
<div className="flex items-center gap-3">
<div className="bg-muted flex h-10 w-10 items-center justify-center ">
<div className="bg-muted flex h-10 w-10 items-center justify-center">
<Mail className="text-muted-foreground h-5 w-5" />
</div>
<div>
@@ -632,7 +667,7 @@ export function BusinessForm({ businessId, mode }: BusinessFormProps) {
<CardContent className="space-y-6">
{/* Current Status */}
{mode === "edit" && (
<div className="flex items-center justify-between bg-gray-50 p-4">
<div className="flex items-center justify-between bg-gray-50 p-4">
<div className="flex items-center gap-2">
<span className="text-sm font-medium">
Current Status:
@@ -806,7 +841,7 @@ export function BusinessForm({ businessId, mode }: BusinessFormProps) {
<Card className="bg-card border-border border">
<CardHeader>
<div className="flex items-center gap-3">
<div className="bg-muted flex h-10 w-10 items-center justify-center ">
<div className="bg-muted flex h-10 w-10 items-center justify-center">
<Star className="text-muted-foreground h-5 w-5" />
</div>
<div>
@@ -818,7 +853,7 @@ export function BusinessForm({ businessId, mode }: BusinessFormProps) {
</div>
</CardHeader>
<CardContent className="space-y-4">
<div className="bg-muted border-border/40 flex items-center justify-between border p-4">
<div className="bg-muted border-border/40 flex items-center justify-between border p-4">
<div className="space-y-0.5">
<Label
htmlFor="isDefault"
@@ -848,7 +883,7 @@ export function BusinessForm({ businessId, mode }: BusinessFormProps) {
<FloatingActionBar
leftContent={
<div className="flex items-center space-x-3">
<div className="bg-primary/10 p-2">
<div className="bg-primary/10 p-2">
<FileText className="text-primary h-5 w-5" />
</div>
<div>

View File

@@ -624,16 +624,22 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
updateField("businessId", value)
}
>
<SelectTrigger>
<SelectValue placeholder="Select your business" />
<SelectTrigger
aria-label="From Business"
className="w-full"
>
<span className="min-w-0 flex-1 truncate text-left">
<SelectValue placeholder="Select your business (nickname shown)" />
</span>
</SelectTrigger>
<SelectContent>
<SelectContent className="w-[--radix-select-trigger-width] min-w-[--radix-select-trigger-width]">
{businesses?.map((business) => (
<SelectItem
key={business.id}
value={business.id}
className="truncate"
>
{business.name}
<span className="block truncate">{`${business.name}${business.nickname ? ` (${business.nickname})` : ""}`}</span>
</SelectItem>
))}
</SelectContent>
@@ -647,13 +653,24 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
updateField("clientId", value)
}
>
<SelectTrigger>
<SelectValue placeholder="Select a client" />
<SelectTrigger
aria-label="Bill To Client"
className="w-full"
>
<span className="min-w-0 flex-1 truncate text-left">
<SelectValue placeholder="Select a client" />
</span>
</SelectTrigger>
<SelectContent>
<SelectContent className="w-[--radix-select-trigger-width] min-w-[--radix-select-trigger-width]">
{clients?.map((client) => (
<SelectItem key={client.id} value={client.id}>
{client.name}
<SelectItem
key={client.id}
value={client.id}
className="truncate"
>
<span className="block truncate">
{client.name}
</span>
</SelectItem>
))}
</SelectContent>
@@ -789,10 +806,18 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Business:</span>
<span className="text-muted-foreground">
Business (nickname shown):
</span>
<span className="font-medium">
{businesses?.find((b) => b.id === formData.businessId)
?.name ?? "Not selected"}
{(() => {
const b = businesses?.find(
(b) => b.id === formData.businessId,
);
return b
? `${b.name}${b.nickname ? ` (${b.nickname})` : ""}`
: "Not selected";
})()}
</span>
</div>
</div>

View File

@@ -42,14 +42,16 @@ function SelectTrigger({
data-slot="select-trigger"
data-size={size}
className={cn(
"data-[placeholder]:text-muted-foreground border-input bg-background text-foreground focus-visible:border-ring focus-visible:ring-ring/50 flex h-10 w-full items-center justify-between gap-2 border px-3 py-2 text-sm shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
"data-[placeholder]:text-muted-foreground border-input bg-background text-foreground focus-visible:border-ring focus-visible:ring-ring/50 relative flex h-10 w-full items-center justify-start gap-2 border px-3 py-2 pr-8 text-left text-sm shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
className,
)}
{...props}
>
{children}
<span className="min-w-0 flex-1 truncate text-left">{children}</span>
<SelectPrimitive.Icon asChild>
<ChevronDownIcon className="size-4 opacity-50" />
<span className="pointer-events-none absolute inset-y-0 right-2 flex items-center">
<ChevronDownIcon className="size-4 opacity-50" />
</span>
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
);
@@ -66,7 +68,7 @@ function SelectContent({
<SelectPrimitive.Content
data-slot="select-content"
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto border-0 shadow-md",
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto border-0 shadow-md",
position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className,
@@ -112,7 +114,7 @@ function SelectItem({
<SelectPrimitive.Item
data-slot="select-item"
className={cn(
"focus:bg-accent focus:text-foreground-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
"focus:bg-accent focus:text-foreground-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
className,
)}
{...props}
@@ -210,7 +212,7 @@ function SelectContentWithSearch({
<SelectPrimitive.Content
data-slot="select-content"
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-96 min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-hidden border-0 shadow-md",
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-96 min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-hidden border-0 shadow-md",
position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className,
@@ -235,7 +237,7 @@ function SelectContentWithSearch({
<Search className="mr-2 h-4 w-4 shrink-0 opacity-50" />
<input
ref={searchInputRef}
className="placeholder:text-muted-foreground text-foreground flex h-8 w-full border-0 bg-transparent py-2 text-sm outline-none focus:ring-0 focus:outline-none disabled:cursor-not-allowed disabled:opacity-50"
className="placeholder:text-muted-foreground text-foreground flex h-8 w-full border-0 bg-transparent py-2 text-sm outline-none focus:ring-0 focus:outline-none disabled:cursor-not-allowed disabled:opacity-50"
placeholder={searchPlaceholder}
value={searchValue}
onChange={(e) => onSearchChange(e.target.value)}

View File

@@ -13,6 +13,7 @@ interface InvoiceEmailTemplateProps {
};
business?: {
name: string;
nickname?: string | null;
email?: string | null;
phone?: string | null;
addressLine1?: string | null;

View File

@@ -20,6 +20,7 @@ interface InvoiceData {
notes?: string | null;
business?: {
name: string;
nickname?: string | null;
email?: string | null;
phone?: string | null;
addressLine1?: string | null;

View File

@@ -6,7 +6,17 @@ import { invoices } from "~/server/db/schema";
import { sql } from "drizzle-orm";
const businessSchema = z.object({
name: z.string().min(1, "Business name is required"),
name: z
.string()
.trim()
.min(1, "Business name is required")
.max(255, "Business name must be 255 characters or less"),
nickname: z
.string()
.trim()
.max(255, "Nickname must be 255 characters or less")
.optional()
.or(z.literal("")),
email: z.string().email().optional().or(z.literal("")),
phone: z.string().optional().or(z.literal("")),
addressLine1: z.string().optional().or(z.literal("")),
@@ -96,7 +106,54 @@ export const businessesRouter = createTRPCRouter({
const [newBusiness] = await ctx.db
.insert(businesses)
.values({
...input,
name: input.name.trim(),
nickname:
input.nickname && input.nickname.trim() !== ""
? input.nickname.trim()
: null,
email:
input.email && input.email.trim() !== ""
? input.email.trim()
: null,
phone:
input.phone && input.phone.trim() !== ""
? input.phone.trim()
: null,
addressLine1:
input.addressLine1 && input.addressLine1.trim() !== ""
? input.addressLine1.trim()
: null,
addressLine2:
input.addressLine2 && input.addressLine2.trim() !== ""
? input.addressLine2.trim()
: null,
city:
input.city && input.city.trim() !== "" ? input.city.trim() : null,
state:
input.state && input.state.trim() !== ""
? input.state.trim()
: null,
postalCode:
input.postalCode && input.postalCode.trim() !== ""
? input.postalCode.trim()
: null,
country:
input.country && input.country.trim() !== ""
? input.country.trim()
: null,
website:
input.website && input.website.trim() !== ""
? input.website.trim()
: null,
taxId:
input.taxId && input.taxId.trim() !== ""
? input.taxId.trim()
: null,
logoUrl:
input.logoUrl && input.logoUrl.trim() !== ""
? input.logoUrl.trim()
: null,
isDefault: input.isDefault ?? false,
createdById: ctx.session.user.id,
})
.returning();
@@ -126,7 +183,56 @@ export const businessesRouter = createTRPCRouter({
const [updatedBusiness] = await ctx.db
.update(businesses)
.set({
...updateData,
name: (updateData.name ?? "").trim(),
nickname:
updateData.nickname && updateData.nickname.trim() !== ""
? updateData.nickname.trim()
: null,
email:
updateData.email && updateData.email.trim() !== ""
? updateData.email.trim()
: null,
phone:
updateData.phone && updateData.phone.trim() !== ""
? updateData.phone.trim()
: null,
addressLine1:
updateData.addressLine1 && updateData.addressLine1.trim() !== ""
? updateData.addressLine1.trim()
: null,
addressLine2:
updateData.addressLine2 && updateData.addressLine2.trim() !== ""
? updateData.addressLine2.trim()
: null,
city:
updateData.city && updateData.city.trim() !== ""
? updateData.city.trim()
: null,
state:
updateData.state && updateData.state.trim() !== ""
? updateData.state.trim()
: null,
postalCode:
updateData.postalCode && updateData.postalCode.trim() !== ""
? updateData.postalCode.trim()
: null,
country:
updateData.country && updateData.country.trim() !== ""
? updateData.country.trim()
: null,
website:
updateData.website && updateData.website.trim() !== ""
? updateData.website.trim()
: null,
taxId:
updateData.taxId && updateData.taxId.trim() !== ""
? updateData.taxId.trim()
: null,
logoUrl:
updateData.logoUrl && updateData.logoUrl.trim() !== ""
? updateData.logoUrl.trim()
: null,
isDefault: updateData.isDefault ?? false,
updatedAt: new Date(),
})
.where(

View File

@@ -77,7 +77,7 @@ export const emailRouter = createTRPCRouter({
// Create email content
const subject =
input.customSubject ??
`Invoice ${invoice.invoiceNumber} from ${invoice.business?.name ?? "Your Business"}`;
`Invoice ${invoice.invoiceNumber} from ${invoice.business ? `${invoice.business.name}${invoice.business.nickname ? ` (${invoice.business.nickname})` : ""}` : "Your Business"}`;
const userName =
invoice.business?.emailFromName ??
@@ -124,7 +124,11 @@ export const emailRouter = createTRPCRouter({
// Use business's custom Resend setup
resendInstance = new Resend(invoice.business.resendApiKey);
const fromName =
invoice.business.emailFromName ?? invoice.business.name ?? userName;
invoice.business.emailFromName ??
(invoice.business.nickname
? `${invoice.business.name} (${invoice.business.nickname})`
: invoice.business.name) ??
userName;
fromEmail = `${fromName} <noreply@${invoice.business.resendDomain}>`;
} else if (env.RESEND_DOMAIN) {
// Use system Resend configuration

View File

@@ -25,6 +25,7 @@ const ClientBackupSchema = z.object({
const BusinessBackupSchema = z.object({
name: z.string(),
nickname: z.string().optional(),
email: z.string().optional(),
phone: z.string().optional(),
addressLine1: z.string().optional(),
@@ -51,6 +52,7 @@ const InvoiceItemBackupSchema = z.object({
const InvoiceBackupSchema = z.object({
invoiceNumber: z.string(),
businessName: z.string().optional(),
businessNickname: z.string().optional(),
clientName: z.string(),
issueDate: z.string().transform((str) => new Date(str)),
dueDate: z.string().transform((str) => new Date(str)),
@@ -205,6 +207,7 @@ export const settingsRouter = createTRPCRouter({
columns: {
id: true,
name: true,
nickname: true,
email: true,
phone: true,
addressLine1: true,
@@ -232,6 +235,7 @@ export const settingsRouter = createTRPCRouter({
business: {
columns: {
name: true,
nickname: true,
},
},
items: {
@@ -269,6 +273,7 @@ export const settingsRouter = createTRPCRouter({
})),
businesses: userBusinesses.map((business) => ({
name: business.name,
nickname: business.nickname ?? undefined,
email: business.email ?? undefined,
phone: business.phone ?? undefined,
addressLine1: business.addressLine1 ?? undefined,
@@ -285,6 +290,7 @@ export const settingsRouter = createTRPCRouter({
invoices: userInvoices.map((invoice) => ({
invoiceNumber: invoice.invoiceNumber,
businessName: invoice.business?.name,
businessNickname: invoice.business?.nickname,
clientName: invoice.client.name,
issueDate: invoice.issueDate,
dueDate: invoice.dueDate,
@@ -337,6 +343,9 @@ export const settingsRouter = createTRPCRouter({
if (newBusiness) {
businessIdMap.set(businessData.name, newBusiness.id);
if (businessData.nickname) {
businessIdMap.set(businessData.nickname, newBusiness.id);
}
}
}
@@ -347,9 +356,14 @@ export const settingsRouter = createTRPCRouter({
throw new Error(`Client ${invoiceData.clientName} not found`);
}
const businessId = invoiceData.businessName
? businessIdMap.get(invoiceData.businessName)
: null;
const businessId = invoiceData.businessNickname
? (businessIdMap.get(invoiceData.businessNickname) ??
(invoiceData.businessName
? (businessIdMap.get(invoiceData.businessName) ?? null)
: null))
: invoiceData.businessName
? (businessIdMap.get(invoiceData.businessName) ?? null)
: null;
const [newInvoice] = await tx
.insert(invoices)

View File

@@ -143,6 +143,7 @@ export const businesses = createTable(
.primaryKey()
.$defaultFn(() => crypto.randomUUID()),
name: d.varchar({ length: 255 }).notNull(),
nickname: d.varchar({ length: 255 }),
email: d.varchar({ length: 255 }),
phone: d.varchar({ length: 50 }),
addressLine1: d.varchar({ length: 255 }),
@@ -172,6 +173,7 @@ export const businesses = createTable(
(t) => [
index("business_created_by_idx").on(t.createdById),
index("business_name_idx").on(t.name),
index("business_nickname_idx").on(t.nickname),
index("business_email_idx").on(t.email),
index("business_is_default_idx").on(t.isDefault),
],

View File

@@ -26,6 +26,7 @@ export interface InvoiceWithRelations extends Invoice {
business: {
id: string;
name: string;
nickname: string | null;
email: string | null;
} | null;
invoiceItems: Array<{