mirror of
https://github.com/soconnor0919/beenvoice.git
synced 2026-02-05 08:16:31 -05:00
Add business nickname support across app and API
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)}
|
||||
|
||||
Reference in New Issue
Block a user