update forms

Co-authored-by: Copilot <copilot@github.com>
This commit is contained in:
2026-05-07 21:02:15 +03:00
co-authored by Copilot
parent dd32658500
commit 38565298fc
32 changed files with 797 additions and 236 deletions
@@ -3,6 +3,8 @@
import { useAuthApi } from "@/shared/useApi"
import { useRouter } from "next/navigation"
import { Button } from "@/shared/components/ui/button"
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/shared/components/ui/dialog"
import { ScrollArea } from "@/shared/components/ui/scroll-area"
import {
DropdownMenu,
DropdownMenuContent,
@@ -12,6 +14,8 @@ import {
import { Ellipsis, Pencil, Trash2 } from "lucide-react"
import { confirm } from "@/shared/components/confirm-dialog"
import { toast } from "sonner"
import { useFormDialog } from "@/shared/components/form-dialog"
import { CustomerForm } from "./customer-form"
type CustomerActionsProps = {
customerId: string
@@ -20,10 +24,7 @@ type CustomerActionsProps = {
export function CustomerActions({ customerId }: CustomerActionsProps) {
const api = useAuthApi()
const router = useRouter()
const handleEdit = () => {
router.push(`/sales/customers/${customerId}/edit`)
}
const editDialog = useFormDialog("customer-details-edit")
const handleDelete = async () => {
const confirmed = await confirm({
@@ -44,22 +45,41 @@ export function CustomerActions({ customerId }: CustomerActionsProps) {
}
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>
<>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon">
<Ellipsis className="size-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => editDialog.open(customerId)}>
<Pencil className="size-4" />
Edit
</DropdownMenuItem>
<DropdownMenuItem variant="destructive" onClick={handleDelete}>
<Trash2 className="size-4" />
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<Dialog open={editDialog.isOpen} onOpenChange={(v) => { if (!v) editDialog.close() }}>
<DialogContent className="min-w-xl lg:min-w-4xl">
<DialogHeader>
<DialogTitle className="text-2xl font-bold">Edit Customer</DialogTitle>
</DialogHeader>
<ScrollArea className="max-h-[80vh] px-4">
<CustomerForm
resourceId={editDialog.resourceId}
onSuccess={() => {
editDialog.close()
router.refresh()
}}
/>
</ScrollArea>
</DialogContent>
</Dialog>
</>
)
}
@@ -51,13 +51,16 @@ const CUSTOMER_DEFAULT_VALUES: CustomerFormValues = {
payment_terms: null,
country: null,
state: null,
salutation: "",
salutation: "Mr.",
first_name: "",
last_name: "",
company_name: "",
email: "",
phone: "",
alternate_phone: "",
opening_balance: undefined,
credit_limit: undefined,
website: "",
address_line_1: "",
address_line_2: "",
city: "",
@@ -82,6 +85,9 @@ function mapCustomerToFormValues(data: unknown): CustomerFormValues {
email: c.email || "",
phone: c.phone || "",
alternate_phone: c.alternate_phone || "",
opening_balance: c.opening_balance ?? undefined,
credit_limit: c.credit_limit ?? undefined,
website: c.website || "",
address_line_1: c.address_line_1 || "",
address_line_2: c.address_line_2 || "",
city: c.city || "",
@@ -96,13 +102,16 @@ function mapFormToPayload(values: CustomerFormValues) {
payment_terms_id: toId(values.payment_terms),
country_id: toId(values.country),
state_id: toId(values.state),
salutation: values.salutation || undefined,
salutation: values.salutation,
first_name: values.first_name,
last_name: values.last_name,
company_name: values.company_name || undefined,
email: values.email || undefined,
email: values.email,
phone: values.phone || undefined,
alternate_phone: values.alternate_phone || undefined,
opening_balance: values.opening_balance,
credit_limit: values.credit_limit,
website: values.website || undefined,
address_line_1: values.address_line_1 || undefined,
address_line_2: values.address_line_2 || undefined,
city: values.city || undefined,
@@ -169,6 +178,7 @@ export function CustomerForm({ resourceId, initialData, onSuccess }: CustomerFor
{/* Basic Info */}
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<RhfSelectField
required
name="salutation"
label="Salutation"
placeholder="Select salutation"
@@ -201,12 +211,19 @@ export function CustomerForm({ resourceId, initialData, onSuccess }: CustomerFor
{/* Contact */}
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<RhfTextField name="email" label="Email" placeholder="john@example.com" type="email" />
<RhfTextField name="email" label="Email" placeholder="john@example.com" type="email" required />
<RhfTextField name="phone" label="Phone" placeholder="0501234567" type="tel" />
</div>
<RhfTextField name="alternate_phone" label="Alternate Phone" placeholder="0551234567" type="tel" />
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<RhfTextField name="opening_balance" label="Opening Balance" placeholder="0" type="number" />
<RhfTextField name="credit_limit" label="Credit Limit" placeholder="0" type="number" />
</div>
<RhfTextField name="website" label="Website" placeholder="https://example.com" type="url" />
{/* Relations */}
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<RhfAsyncSelectField
@@ -10,6 +10,30 @@ const relationFieldSchema = z
type RelationField = z.infer<typeof relationFieldSchema>
const optionalTrimmedString = (max: number) =>
z.preprocess(
(value) => {
if (typeof value !== "string") {
return value
}
const trimmed = value.trim()
return trimmed === "" ? undefined : trimmed
},
z.string().max(max).optional(),
)
const optionalInteger = z.preprocess(
(value) => {
if (value === "" || value == null) {
return undefined
}
return Number(value)
},
z.number().int().optional(),
)
const customerFormSchema = z.object({
// ── Relations (stored as objects, mapped to IDs on submit) ──
customer_type: relationFieldSchema.refine((val) => !!val?.value, "Customer type is required"),
@@ -19,23 +43,32 @@ const customerFormSchema = z.object({
state: relationFieldSchema,
// ── Basic info ──
salutation: z.string().optional(),
first_name: z.string().min(1, "First name is required"),
last_name: z.string().min(1, "Last name is required"),
company_name: z.string().optional(),
salutation: z.string().trim().min(1, "Salutation is required").max(50, "Salutation cannot exceed 50 characters"),
first_name: z.string().trim().min(1, "First name is required").max(50, "First name cannot exceed 50 characters"),
last_name: z.string().trim().min(1, "Last name is required").max(50, "Last name cannot exceed 50 characters"),
company_name: optionalTrimmedString(50),
// ── Contact ──
email: z
.union([z.string().email("Enter a valid email address"), z.literal("")])
.optional(),
phone: z.string().optional(),
alternate_phone: z.string().optional(),
.string()
.trim()
.min(1, "Email is required")
.email("Enter a valid email address")
.max(100, "Email cannot exceed 100 characters"),
phone: optionalTrimmedString(20),
alternate_phone: optionalTrimmedString(20),
opening_balance: optionalInteger,
credit_limit: optionalInteger,
website: optionalTrimmedString(255).refine(
(value) => !value || z.string().url().safeParse(value).success,
"Enter a valid website URL",
),
// ── Address ──
address_line_1: z.string().optional(),
address_line_2: z.string().optional(),
city: z.string().optional(),
zip_code: z.string().optional(),
address_line_1: optionalTrimmedString(255),
address_line_2: optionalTrimmedString(255),
city: optionalTrimmedString(100),
zip_code: optionalTrimmedString(20),
})
type CustomerFormValues = z.infer<typeof customerFormSchema>