updates
This commit is contained in:
@@ -0,0 +1,65 @@
|
||||
"use client"
|
||||
|
||||
import { useAuthApi } from "@/shared/useApi"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/shared/components/ui/dropdown-menu"
|
||||
import { Ellipsis, Pencil, Trash2 } from "lucide-react"
|
||||
import { confirm } from "@/shared/components/confirm-dialog"
|
||||
import { toast } from "sonner"
|
||||
|
||||
type CustomerActionsProps = {
|
||||
customerId: string
|
||||
}
|
||||
|
||||
export function CustomerActions({ customerId }: CustomerActionsProps) {
|
||||
const api = useAuthApi()
|
||||
const router = useRouter()
|
||||
|
||||
const handleEdit = () => {
|
||||
router.push(`/sales/customers/${customerId}/edit`)
|
||||
}
|
||||
|
||||
const handleDelete = async () => {
|
||||
const confirmed = await confirm({
|
||||
title: "Delete Customer",
|
||||
description: "Are you sure you want to delete this customer? This action cannot be undone.",
|
||||
confirmLabel: "Delete",
|
||||
variant: "destructive",
|
||||
})
|
||||
if (!confirmed) return
|
||||
|
||||
try {
|
||||
await api.customers.destroy(customerId)
|
||||
toast.success("Customer deleted successfully.")
|
||||
router.push("/sales/customers")
|
||||
} catch {
|
||||
toast.error("Failed to delete customer.")
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon">
|
||||
<Ellipsis className="size-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={handleEdit}>
|
||||
<Pencil className="size-4" />
|
||||
Edit
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem variant="destructive" onClick={handleDelete}>
|
||||
<Trash2 className="size-4" />
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
"use client"
|
||||
|
||||
import { createContext, useContext } from "react"
|
||||
|
||||
type CustomerContextValue = {
|
||||
id: string
|
||||
label: string
|
||||
}
|
||||
|
||||
const CustomerContext = createContext<CustomerContextValue | null>(null)
|
||||
|
||||
export function CustomerProvider({
|
||||
customer,
|
||||
children,
|
||||
}: {
|
||||
customer: CustomerContextValue
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<CustomerContext.Provider value={customer}>
|
||||
{children}
|
||||
</CustomerContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export function useCustomer() {
|
||||
return useContext(CustomerContext)
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
import {
|
||||
User,
|
||||
Mail,
|
||||
Phone,
|
||||
MapPin,
|
||||
Building2,
|
||||
Globe,
|
||||
DollarSign,
|
||||
CreditCard,
|
||||
Tag,
|
||||
Users,
|
||||
FileText,
|
||||
} from "lucide-react"
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/shared/components/ui/card"
|
||||
import { Badge } from "@/shared/components/ui/badge"
|
||||
import { Separator } from "@/shared/components/ui/separator"
|
||||
|
||||
type CustomerData = {
|
||||
id?: number
|
||||
customer_type_id?: number
|
||||
salutation?: string
|
||||
first_name?: string
|
||||
last_name?: string
|
||||
company_name?: string
|
||||
email?: string
|
||||
phone?: string
|
||||
alternate_phone?: string
|
||||
opening_balance?: number
|
||||
credit_limit?: number
|
||||
website?: string
|
||||
referral_source_id?: number
|
||||
payment_terms_id?: number
|
||||
address_line_1?: string
|
||||
address_line_2?: string
|
||||
country_id?: number
|
||||
state_id?: number
|
||||
city?: string
|
||||
zip_code?: string
|
||||
created_at?: string
|
||||
updated_at?: string
|
||||
customer_type?: { id?: number; name?: string }
|
||||
referral_source?: { id?: number; title?: string }
|
||||
payment_terms?: { id?: number; title?: string }
|
||||
country?: { id?: number; name?: string }
|
||||
state?: { id?: number; name?: string }
|
||||
}
|
||||
|
||||
type CustomerGeneralInfoProps = {
|
||||
customer: CustomerData
|
||||
}
|
||||
|
||||
function InfoItem({
|
||||
icon: Icon,
|
||||
label,
|
||||
value,
|
||||
}: {
|
||||
icon: React.ComponentType<{ className?: string }>
|
||||
label: string
|
||||
value?: string | number | null
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-muted text-muted-foreground">
|
||||
<Icon className="size-4" />
|
||||
</div>
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<span className="text-xs text-muted-foreground">{label}</span>
|
||||
<span className="text-sm font-medium">
|
||||
{value != null && value !== "" ? (
|
||||
String(value)
|
||||
) : (
|
||||
<span className="text-muted-foreground">—</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function CustomerGeneralInfo({ customer }: CustomerGeneralInfoProps) {
|
||||
const fullName = [customer.salutation, customer.first_name, customer.last_name]
|
||||
.filter(Boolean)
|
||||
.join(" ")
|
||||
|
||||
return (
|
||||
<div className="grid gap-6 md:grid-cols-2">
|
||||
{/* Personal Info */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<User className="size-4" />
|
||||
Personal Information
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-4">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Badge variant="secondary">{fullName || "Unknown"}</Badge>
|
||||
{customer.customer_type?.name && (
|
||||
<Badge variant="outline">{customer.customer_type.name}</Badge>
|
||||
)}
|
||||
</div>
|
||||
<Separator />
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<InfoItem icon={User} label="Full Name" value={fullName} />
|
||||
<InfoItem icon={Building2} label="Company" value={customer.company_name} />
|
||||
<InfoItem icon={Tag} label="Customer Type" value={customer.customer_type?.name} />
|
||||
<InfoItem icon={Users} label="Referral Source" value={customer.referral_source?.title} />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Contact Info */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Phone className="size-4" />
|
||||
Contact Details
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-4 sm:grid-cols-2">
|
||||
<InfoItem icon={Mail} label="Email" value={customer.email} />
|
||||
<InfoItem icon={Phone} label="Phone" value={customer.phone} />
|
||||
<InfoItem icon={Phone} label="Alternate Phone" value={customer.alternate_phone} />
|
||||
<InfoItem icon={Globe} label="Website" value={customer.website} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Address */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<MapPin className="size-4" />
|
||||
Address
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-4 sm:grid-cols-2">
|
||||
<InfoItem icon={MapPin} label="Address Line 1" value={customer.address_line_1} />
|
||||
<InfoItem icon={MapPin} label="Address Line 2" value={customer.address_line_2} />
|
||||
<InfoItem icon={MapPin} label="City" value={customer.city} />
|
||||
<InfoItem icon={MapPin} label="ZIP Code" value={customer.zip_code} />
|
||||
<InfoItem icon={MapPin} label="State" value={customer.state?.name} />
|
||||
<InfoItem icon={MapPin} label="Country" value={customer.country?.name} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Financial Info */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<DollarSign className="size-4" />
|
||||
Financial Details
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-4 sm:grid-cols-2">
|
||||
<InfoItem icon={DollarSign} label="Opening Balance" value={customer.opening_balance} />
|
||||
<InfoItem icon={CreditCard} label="Credit Limit" value={customer.credit_limit} />
|
||||
<InfoItem icon={FileText} label="Payment Terms" value={customer.payment_terms?.title} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
"use client"
|
||||
|
||||
import { useRef, useState } from "react"
|
||||
import { useFormContext, useController, type FieldValues, type FieldPath } from "react-hook-form"
|
||||
import { useQuery } from "@tanstack/react-query"
|
||||
import { Building2, Loader2 } from "lucide-react"
|
||||
import { useAuthApi } from "@/shared/useApi"
|
||||
import { CUSTOMER_ROUTES } from "@garage/api"
|
||||
import { FieldShell } from "@/shared/components/form/field-shell"
|
||||
import {
|
||||
Combobox,
|
||||
ComboboxInput,
|
||||
ComboboxContent,
|
||||
ComboboxList,
|
||||
ComboboxItem,
|
||||
ComboboxEmpty,
|
||||
} from "@/shared/components/ui/combobox"
|
||||
|
||||
// ── Customer option type (enriched for display) ──
|
||||
|
||||
type CustomerOption = {
|
||||
value: string
|
||||
label: string
|
||||
first_name?: string
|
||||
last_name?: string
|
||||
company_name?: string
|
||||
email?: string
|
||||
phone?: string
|
||||
}
|
||||
|
||||
function buildCustomerOption(item: any): CustomerOption {
|
||||
const name = [item.first_name, item.last_name].filter(Boolean).join(" ")
|
||||
const label = name || item.company_name || `Customer #${item.id}`
|
||||
return {
|
||||
value: String(item.id),
|
||||
label,
|
||||
first_name: item.first_name,
|
||||
last_name: item.last_name,
|
||||
company_name: item.company_name,
|
||||
email: item.email,
|
||||
phone: item.phone,
|
||||
}
|
||||
}
|
||||
|
||||
function extractItems(response: unknown): any[] {
|
||||
if (Array.isArray(response)) return response
|
||||
const obj = response as any
|
||||
if (Array.isArray(obj?.data?.data)) return obj.data.data
|
||||
if (Array.isArray(obj?.data)) return obj.data
|
||||
return []
|
||||
}
|
||||
|
||||
function getInitials(opt: CustomerOption): string {
|
||||
if (opt.first_name || opt.last_name) {
|
||||
return [opt.first_name?.[0], opt.last_name?.[0]].filter(Boolean).join("").toUpperCase()
|
||||
}
|
||||
if (opt.company_name) return opt.company_name[0].toUpperCase()
|
||||
return "?"
|
||||
}
|
||||
|
||||
// ── Props ──
|
||||
|
||||
export type RhfCustomerSelectFieldProps<
|
||||
TValues extends FieldValues,
|
||||
TName extends FieldPath<TValues>,
|
||||
> = {
|
||||
name: TName
|
||||
label?: string
|
||||
description?: string
|
||||
required?: boolean
|
||||
disabled?: boolean
|
||||
placeholder?: string
|
||||
}
|
||||
|
||||
// ── Component ──
|
||||
|
||||
export function RhfCustomerSelectField<
|
||||
TValues extends FieldValues,
|
||||
TName extends FieldPath<TValues>,
|
||||
>({
|
||||
name,
|
||||
label = "Customer",
|
||||
description,
|
||||
required,
|
||||
disabled,
|
||||
placeholder = "Search by name, company, or phone...",
|
||||
}: RhfCustomerSelectFieldProps<TValues, TName>) {
|
||||
const api = useAuthApi()
|
||||
const anchorRef = useRef<HTMLDivElement>(null)
|
||||
const [inputValue, setInputValue] = useState("")
|
||||
|
||||
const { control } = useFormContext<TValues>()
|
||||
const {
|
||||
field,
|
||||
fieldState: { error },
|
||||
} = useController({ name, control, disabled })
|
||||
|
||||
const { data: options = [], isLoading } = useQuery<CustomerOption[]>({
|
||||
queryKey: [CUSTOMER_ROUTES.INDEX, "customer-select"],
|
||||
queryFn: async () => {
|
||||
const res = await api.customers.list()
|
||||
return extractItems(res).map(buildCustomerOption)
|
||||
},
|
||||
staleTime: 5 * 60 * 1000,
|
||||
})
|
||||
|
||||
const filtered = inputValue
|
||||
? options.filter((c) =>
|
||||
[c.first_name, c.last_name, c.company_name, c.email, c.phone]
|
||||
.filter(Boolean)
|
||||
.join(" ")
|
||||
.toLowerCase()
|
||||
.includes(inputValue.toLowerCase()),
|
||||
)
|
||||
: options
|
||||
|
||||
return (
|
||||
<FieldShell
|
||||
label={label}
|
||||
error={error?.message}
|
||||
description={description}
|
||||
required={required}
|
||||
>
|
||||
<div ref={anchorRef}>
|
||||
<Combobox
|
||||
value={field.value}
|
||||
onValueChange={(val: CustomerOption | CustomerOption[] | null) => {
|
||||
const single = Array.isArray(val) ? val[0] ?? null : val
|
||||
field.onChange(
|
||||
single ? { value: single.value, label: single.label } : null,
|
||||
)
|
||||
}}
|
||||
disabled={field.disabled}
|
||||
onInputValueChange={(val: string, { reason }: { reason: string }) => {
|
||||
if (reason === "input-change") setInputValue(val)
|
||||
}}
|
||||
isItemEqualToValue={(item: CustomerOption, val: any) =>
|
||||
item?.value === val?.value
|
||||
}
|
||||
>
|
||||
<ComboboxInput
|
||||
placeholder={placeholder}
|
||||
showClear={!!field.value}
|
||||
onBlur={field.onBlur}
|
||||
aria-invalid={!!error || undefined}
|
||||
/>
|
||||
<ComboboxContent anchor={anchorRef}>
|
||||
<ComboboxList className='overflow-auto'>
|
||||
{isLoading && (
|
||||
<div className="flex items-center justify-center py-6">
|
||||
<Loader2 className="size-4 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isLoading &&
|
||||
filtered.map((opt) => (
|
||||
<ComboboxItem key={opt.value} value={opt}>
|
||||
<div className="flex items-center gap-3 py-0.5 w-full min-w-0">
|
||||
{/* Avatar circle with initials */}
|
||||
<div className="flex size-9 shrink-0 items-center justify-center rounded-full bg-primary/10 text-primary border border-border text-sm font-medium">
|
||||
{getInitials(opt)}
|
||||
</div>
|
||||
|
||||
{/* Identity */}
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
|
||||
<span className="truncate text-sm font-medium leading-none">
|
||||
{opt.label}
|
||||
</span>
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
{opt.company_name && opt.label !== opt.company_name && (
|
||||
<span className="flex items-center gap-1 truncate text-xs text-muted-foreground">
|
||||
<Building2 className="size-3 shrink-0" />
|
||||
{opt.company_name}
|
||||
</span>
|
||||
)}
|
||||
{opt.phone && (
|
||||
<span className="text-xs text-muted-foreground truncate">
|
||||
{opt.phone}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ComboboxItem>
|
||||
))}
|
||||
|
||||
{!isLoading && filtered.length === 0 && (
|
||||
<ComboboxEmpty>No customers found</ComboboxEmpty>
|
||||
)}
|
||||
</ComboboxList>
|
||||
</ComboboxContent>
|
||||
</Combobox>
|
||||
</div>
|
||||
</FieldShell>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user