refactor: Align form validation UX across management dialogs

This commit is contained in:
2026-06-18 11:05:31 +05:30
parent d7c4027328
commit 9695c80e20
23 changed files with 1458 additions and 1017 deletions

View File

@@ -1,9 +1,10 @@
'use client';
import type { ComponentProps } from 'react';
import type { UseFormRegister } from 'react-hook-form';
import type { FieldErrors, UseFormRegister } from 'react-hook-form';
import { Loader2 } from 'lucide-react';
import { FormField } from '@/components/form';
import { Button } from '@/components/ui/button';
import {
Dialog,
@@ -14,7 +15,6 @@ import {
DialogTitle,
} from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import type { ClientFormValues } from '../hooks/useClientForm';
interface ClientSheetProps {
@@ -22,7 +22,10 @@ interface ClientSheetProps {
onOpenChange: (open: boolean) => void;
clientId?: number;
register: UseFormRegister<ClientFormValues>;
errors: FieldErrors<ClientFormValues>;
isSubmitted: boolean;
onSubmit: ComponentProps<'form'>['onSubmit'];
canSubmit: boolean;
isSaving: boolean;
}
@@ -31,115 +34,168 @@ export function ClientSheet({
onOpenChange,
clientId,
register,
errors,
isSubmitted,
onSubmit,
canSubmit,
isSaving,
}: ClientSheetProps) {
const nameErrorMessage = isSubmitted ? errors.name?.message : undefined;
const emailErrorMessage = isSubmitted ? errors.email?.message : undefined;
const landlineErrorMessage = isSubmitted
? errors.landline_number?.message
: undefined;
const addressErrorMessage = isSubmitted ? errors.address?.message : undefined;
const contactNameErrorMessage = isSubmitted
? errors.contact_name?.message
: undefined;
const contactPhoneErrorMessage = isSubmitted
? errors.contact_phone_number?.message
: undefined;
const contactEmailErrorMessage = isSubmitted
? errors.contact_email?.message
: undefined;
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-h-[calc(100vh-2rem)] overflow-y-auto sm:max-w-3xl">
<DialogHeader>
<DialogTitle>
<DialogContent className="flex max-h-[calc(100vh-2rem)] flex-col gap-0 p-0 sm:max-w-3xl">
<DialogHeader className="shrink-0 border-b px-6 py-4 pr-12">
<DialogTitle className="text-lg leading-none font-semibold tracking-tight">
{clientId ? 'Edit Client' : 'Create Client'}
</DialogTitle>
<DialogDescription>
<DialogDescription className="text-sm">
Manage company and primary contact details.
</DialogDescription>
</DialogHeader>
<form onSubmit={onSubmit} className="flex flex-1 flex-col gap-5 px-4">
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="client-name">Name</Label>
<Input
<form onSubmit={onSubmit} className="flex min-h-0 flex-1 flex-col">
<div className="max-h-[58vh] space-y-5 overflow-y-auto px-6 py-4">
<div className="grid gap-4 md:grid-cols-2">
<FormField
id="client-name"
placeholder="Acme Corp"
{...register('name', { required: true })}
label="Client Name"
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="client-email">Email</Label>
<Input
error={nameErrorMessage}
>
<Input
id="client-name"
placeholder="Enter client name"
aria-invalid={!!nameErrorMessage}
{...register('name')}
/>
</FormField>
<FormField
id="client-email"
type="email"
placeholder="info@acme.com"
{...register('email', { required: true })}
label="Company Email"
required
/>
error={emailErrorMessage}
>
<Input
id="client-email"
type="email"
placeholder="info@example.com"
aria-invalid={!!emailErrorMessage}
{...register('email')}
/>
</FormField>
</div>
</div>
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="landline-number">Landline Number</Label>
<Input
<div className="grid gap-4 md:grid-cols-2">
<FormField
id="landline-number"
placeholder="+91-22-12345678"
{...register('landline_number', { required: true })}
label="Landline Number"
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="address">Address</Label>
<Input
error={landlineErrorMessage}
>
<Input
id="landline-number"
placeholder="+91-22-12345678"
aria-invalid={!!landlineErrorMessage}
{...register('landline_number')}
/>
</FormField>
<FormField
id="address"
placeholder="12 MG Road, Mumbai, MH 400001"
{...register('address', { required: true })}
label="Address"
required
/>
error={addressErrorMessage}
>
<Input
id="address"
placeholder="12 MG Road, Mumbai, MH 400001"
aria-invalid={!!addressErrorMessage}
{...register('address')}
/>
</FormField>
</div>
</div>
<div className="grid gap-4 md:grid-cols-3">
<div className="space-y-2">
<Label htmlFor="gst">GST</Label>
<Input
id="gst"
placeholder="27ABCDE1234F1Z5"
{...register('gst')}
/>
</div>
<div className="space-y-2">
<Label htmlFor="pan">PAN</Label>
<Input id="pan" placeholder="ABCDE1234F" {...register('pan')} />
</div>
<div className="space-y-2">
<Label htmlFor="tan">TAN</Label>
<Input id="tan" placeholder="MUMA12345B" {...register('tan')} />
</div>
</div>
<div className="grid gap-4 md:grid-cols-3">
<FormField id="gst" label="GST">
<Input
id="gst"
placeholder="27ABCDE1234F1Z5"
{...register('gst')}
/>
</FormField>
<div className="grid gap-4 md:grid-cols-3">
<div className="space-y-2">
<Label htmlFor="contact-name">Contact Name</Label>
<Input
<FormField id="pan" label="PAN">
<Input id="pan" placeholder="ABCDE1234F" {...register('pan')} />
</FormField>
<FormField id="tan" label="TAN">
<Input id="tan" placeholder="MUMA12345B" {...register('tan')} />
</FormField>
</div>
<div className="grid gap-4 md:grid-cols-3">
<FormField
id="contact-name"
placeholder="Jane Doe"
{...register('contact_name', { required: true })}
label="Contact Name"
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="contact-phone-number">Contact Phone</Label>
<Input
error={contactNameErrorMessage}
>
<Input
id="contact-name"
placeholder="Enter contact name"
aria-invalid={!!contactNameErrorMessage}
{...register('contact_name')}
/>
</FormField>
<FormField
id="contact-phone-number"
placeholder="+91-9876543210"
{...register('contact_phone_number', { required: true })}
label="Contact Phone"
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="contact-email">Contact Email</Label>
<Input
error={contactPhoneErrorMessage}
>
<Input
id="contact-phone-number"
placeholder="+919876543210"
aria-invalid={!!contactPhoneErrorMessage}
{...register('contact_phone_number')}
/>
</FormField>
<FormField
id="contact-email"
type="email"
placeholder="jane.doe@acme.com"
{...register('contact_email', { required: true })}
label="Contact Email"
required
/>
error={contactEmailErrorMessage}
>
<Input
id="contact-email"
type="email"
placeholder="contact@example.com"
aria-invalid={!!contactEmailErrorMessage}
{...register('contact_email')}
/>
</FormField>
</div>
</div>
<DialogFooter className="px-0">
<DialogFooter className="shrink-0 border-t px-6 py-4">
<Button
type="button"
variant="outline"
@@ -148,7 +204,8 @@ export function ClientSheet({
>
Cancel
</Button>
<Button type="submit" disabled={isSaving}>
<Button type="submit" disabled={isSaving || !canSubmit}>
{isSaving ? (
<Loader2 className="mr-2 size-4 animate-spin" />
) : null}

View File

@@ -1,26 +1,42 @@
'use client';
import { zodResolver } from '@hookform/resolvers/zod';
import { useCallback } from 'react';
import { useForm, useWatch } from 'react-hook-form';
import { toast } from 'sonner';
import { z } from 'zod';
import type { Client } from '@/types';
import { useSaveClientMutation } from './useClientMutations';
export interface ClientFormValues {
id?: number;
name: string;
email: string;
landline_number: string;
address: string;
gst: string;
pan: string;
tan: string;
contact_name: string;
contact_phone_number: string;
contact_email: string;
is_active: boolean;
}
const phoneSchema = z
.string()
.trim()
.min(1, 'Phone number is required')
.regex(/^\+(?:[0-9] ?|-){6,18}[0-9]$/, 'Please enter a valid phone number');
const optionalTaxIdSchema = z.string().trim();
const clientFormSchema = z.object({
id: z.number().optional(),
name: z.string().trim().min(1, 'Client name is required'),
email: z.string().trim().min(1, 'Email is required').email('Invalid email'),
landline_number: phoneSchema,
address: z.string().trim().min(1, 'Address is required'),
gst: optionalTaxIdSchema,
pan: optionalTaxIdSchema,
tan: optionalTaxIdSchema,
contact_name: z.string().trim().min(1, 'Contact name is required'),
contact_phone_number: phoneSchema,
contact_email: z
.string()
.trim()
.min(1, 'Contact email is required')
.email('Invalid contact email'),
is_active: z.boolean(),
});
export type ClientFormValues = z.infer<typeof clientFormSchema>;
const defaultValues: ClientFormValues = {
name: '',
@@ -42,9 +58,12 @@ export function useClientForm({ onSaved }: { onSaved: () => void }) {
handleSubmit: submitForm,
reset,
control,
formState: { isSubmitting, errors },
formState: { isSubmitting, errors, isSubmitted },
} = useForm<ClientFormValues>({
defaultValues,
mode: 'onSubmit',
reValidateMode: 'onChange',
resolver: zodResolver(clientFormSchema),
});
const saveMutation = useSaveClientMutation({
onSaved: () => {
@@ -54,20 +73,32 @@ export function useClientForm({ onSaved }: { onSaved: () => void }) {
});
const clientId = useWatch({ control, name: 'id' });
const name = useWatch({ control, name: 'name' }) || '';
const email = useWatch({ control, name: 'email' }) || '';
const landlineNumber = useWatch({ control, name: 'landline_number' }) || '';
const address = useWatch({ control, name: 'address' }) || '';
const contactName = useWatch({ control, name: 'contact_name' }) || '';
const contactPhoneNumber =
useWatch({ control, name: 'contact_phone_number' }) || '';
const contactEmail = useWatch({ control, name: 'contact_email' }) || '';
const canSubmit =
name.trim().length > 0 &&
email.trim().length > 0 &&
landlineNumber.trim().length > 0 &&
address.trim().length > 0 &&
contactName.trim().length > 0 &&
contactPhoneNumber.trim().length > 0 &&
contactEmail.trim().length > 0;
const handleSubmit = submitForm(
(values) => saveMutation.mutate(values),
(formErrors) => {
if (
formErrors.name ||
formErrors.email ||
formErrors.landline_number ||
formErrors.address ||
formErrors.contact_name ||
formErrors.contact_phone_number ||
formErrors.contact_email
) {
toast.error('Complete all required client fields');
const firstError = Object.values(formErrors).find(
(error) => error?.message,
);
if (firstError?.message) {
toast.error(firstError.message);
}
},
);
@@ -104,6 +135,8 @@ export function useClientForm({ onSaved }: { onSaved: () => void }) {
openEdit,
clientId,
errors,
isSubmitted,
canSubmit,
isSaving: isSubmitting || saveMutation.isPending,
};
}

View File

@@ -46,7 +46,15 @@ export default function ClientsPage() {
});
const { openCreate: prepareCreateClient, openEdit: prepareEditClient } =
clientForm;
const { register, handleSubmit, clientId, isSaving } = clientForm;
const {
register,
handleSubmit,
clientId,
errors,
isSubmitted,
canSubmit,
isSaving,
} = clientForm;
const statusMutation = useClientStatusMutation();
const { mutate: updateClientStatus, pendingClientId } = statusMutation;
@@ -126,7 +134,10 @@ export default function ClientsPage() {
onOpenChange={setIsSheetOpen}
clientId={clientId}
register={register}
errors={errors}
isSubmitted={isSubmitted}
onSubmit={handleSubmit}
canSubmit={canSubmit}
isSaving={isSaving}
/>
</>

View File

@@ -2,7 +2,7 @@
import type { ComponentProps } from 'react';
import type { FieldErrors, UseFormRegister } from 'react-hook-form';
import { Globe, Loader2, Package as PackageIcon } from 'lucide-react';
import { Loader2 } from 'lucide-react';
import { FormField } from '@/components/form';
import { Button } from '@/components/ui/button';
@@ -71,117 +71,126 @@ export function PackageDialog({
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent
className="max-w-2xl"
className="flex max-h-[calc(100vh-2rem)] flex-col gap-0 p-0 sm:max-w-2xl"
onOpenAutoFocus={(event) => event.preventDefault()}
>
<DialogHeader className="gap-2">
<DialogTitle className="flex items-center gap-3">
<div className="rounded-lg bg-primary p-2 text-primary-foreground shadow-sm">
<PackageIcon className="size-5" />
</div>
{packageId ? 'Edit Package Details' : 'Create New Package'}
<DialogHeader className="shrink-0 border-b px-6 py-4 pr-12">
<DialogTitle className="text-lg leading-none font-semibold tracking-tight">
{packageId ? 'Edit Package' : 'Create Package'}
</DialogTitle>
<DialogDescription>
{packageId
? 'Update the technical specifications for your road infrastructure package.'
: 'Select a project and provide the essential data to establish a new package.'}
<DialogDescription className="text-sm">
Manage package details and optional chainage range.
</DialogDescription>
</DialogHeader>
<form onSubmit={onSubmit} className="space-y-6">
{!packageId ? (
<FormField label="Project" required error={projectErrorMessage}>
<Select value={projectId} onValueChange={onProjectChange}>
<SelectTrigger aria-invalid={!!projectErrorMessage}>
{isProjectsLoading ? (
<span className="flex items-center gap-2 text-muted-foreground">
<Loader2 className="size-4 animate-spin" />
Loading...
</span>
) : (
<SelectValue placeholder="Choose a project" />
)}
</SelectTrigger>
<SelectContent>
{projects.map((project) => (
<SelectItem key={project.id} value={project.id}>
{project.name}
</SelectItem>
))}
</SelectContent>
</Select>
<input
type="hidden"
{...register('project_id')}
value={projectId}
readOnly
/>
</FormField>
) : null}
<form onSubmit={onSubmit} className="flex min-h-0 flex-1 flex-col">
<div className="max-h-[58vh] space-y-6 overflow-y-auto px-6 py-4">
<section className="space-y-4">
<div className="space-y-1">
<h3 className="text-sm font-semibold">Basic details</h3>
<p className="text-sm text-muted-foreground">
Project binding, package name, and region.
</p>
</div>
<div className="grid gap-5 md:grid-cols-2">
<FormField
id="package-name"
label="Package Name"
required
error={nameErrorMessage}
>
<Input
id="package-name"
placeholder="e.g. Package 01"
aria-invalid={!!nameErrorMessage}
{...register('name')}
/>
</FormField>
{!packageId ? (
<FormField label="Project" required error={projectErrorMessage}>
<Select value={projectId} onValueChange={onProjectChange}>
<SelectTrigger aria-invalid={!!projectErrorMessage}>
{isProjectsLoading ? (
<span className="flex items-center gap-2 text-muted-foreground">
<Loader2 className="size-4 animate-spin" />
Loading...
</span>
) : (
<SelectValue placeholder="Choose a project" />
)}
</SelectTrigger>
<SelectContent>
{projects.map((project) => (
<SelectItem key={project.id} value={project.id}>
{project.name}
</SelectItem>
))}
</SelectContent>
</Select>
<input
type="hidden"
{...register('project_id')}
value={projectId}
readOnly
/>
</FormField>
) : null}
<FormField
id="package-region"
label={
<span className="inline-flex items-center gap-2">
<Globe className="size-3.5 opacity-60" />
Region
</span>
}
>
<Input
id="package-region"
placeholder="e.g. North Zone"
{...register('region')}
/>
</FormField>
<div className="grid gap-4 md:grid-cols-2">
<FormField
id="package-name"
label="Package Name"
required
error={nameErrorMessage}
>
<Input
id="package-name"
placeholder="Enter package name"
aria-invalid={!!nameErrorMessage}
{...register('name')}
/>
</FormField>
<FormField
id="package-start"
label="Segment Start (km)"
error={startErrorMessage}
>
<Input
id="package-start"
type="number"
step="0.01"
placeholder="0.00"
aria-invalid={!!startErrorMessage}
{...register('chainage_start_km')}
/>
</FormField>
<FormField id="package-region" label="Region">
<Input
id="package-region"
placeholder="North Zone"
{...register('region')}
/>
</FormField>
</div>
</section>
<FormField
id="package-end"
label="Segment End (km)"
error={endErrorMessage}
>
<Input
id="package-end"
type="number"
step="0.01"
placeholder="0.00"
aria-invalid={!!endErrorMessage}
{...register('chainage_end_km')}
/>
</FormField>
<section className="space-y-4">
<div className="space-y-1">
<h3 className="text-sm font-semibold">Chainage range</h3>
<p className="text-sm text-muted-foreground">
Optional start and end kilometre values.
</p>
</div>
<div className="grid gap-4 md:grid-cols-2">
<FormField
id="package-start"
label="Start (km)"
error={startErrorMessage}
>
<Input
id="package-start"
type="number"
step="0.01"
placeholder="0.00"
aria-invalid={!!startErrorMessage}
{...register('chainage_start_km')}
/>
</FormField>
<FormField
id="package-end"
label="End (km)"
error={endErrorMessage}
>
<Input
id="package-end"
type="number"
step="0.01"
placeholder="0.00"
aria-invalid={!!endErrorMessage}
{...register('chainage_end_km')}
/>
</FormField>
</div>
</section>
</div>
<DialogFooter>
<DialogFooter className="shrink-0 border-t px-6 py-4">
<Button
type="button"
variant="outline"
@@ -191,7 +200,7 @@ export function PackageDialog({
Cancel
</Button>
<Button type="submit" disabled={isSaving || !canSubmit}>
{isSaving ? <Loader2 className="size-4 animate-spin" /> : null}
{isSaving ? <Loader2 className="mr-2 size-4 animate-spin" /> : null}
{packageId ? 'Update Package' : 'Create Package'}
</Button>
</DialogFooter>

View File

@@ -1,14 +1,15 @@
'use client';
import type { ComponentProps } from 'react';
import type { Control, UseFormRegister } from 'react-hook-form';
import type { Control, FieldErrors, UseFormRegister } from 'react-hook-form';
import { Controller } from 'react-hook-form';
import { Loader2 } from 'lucide-react';
import { FormField } from '@/components/form';
import { PermissionTree } from '@/components/permission-tree';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import {
Select,
SelectContent,
@@ -34,7 +35,10 @@ interface PlanSheetProps {
planId?: number;
register: UseFormRegister<PlanFormValues>;
control: Control<PlanFormValues>;
errors: FieldErrors<PlanFormValues>;
isSubmitted: boolean;
onSubmit: ComponentProps<'form'>['onSubmit'];
canSubmit: boolean;
permissionTree: PermissionTreeItem[];
permissionIds: number[];
onPermissionIdsChange: (ids: number[]) => void;
@@ -48,177 +52,237 @@ export function PlanSheet({
planId,
register,
control,
errors,
isSubmitted,
onSubmit,
canSubmit,
permissionTree,
permissionIds,
onPermissionIdsChange,
isPermissionsLoading,
isSaving,
}: PlanSheetProps) {
const fieldError = (field: keyof PlanFormValues) =>
isSubmitted ? errors[field]?.message : undefined;
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent className="w-full overflow-y-auto sm:max-w-3xl">
<SheetHeader>
<SheetTitle>{planId ? 'Edit Plan' : 'Create Plan'}</SheetTitle>
<SheetDescription>
<SheetContent className="w-full gap-0 p-0 sm:max-w-3xl">
<SheetHeader className="shrink-0 border-b px-6 py-4 pr-12">
<SheetTitle className="text-lg leading-none font-semibold tracking-tight">
{planId ? 'Edit Plan' : 'Create Plan'}
</SheetTitle>
<SheetDescription className="text-sm">
Configure subscription limits, billing, and permission access.
</SheetDescription>
</SheetHeader>
<form onSubmit={onSubmit} className="flex flex-1 flex-col gap-5 px-4">
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="plan-name">Name</Label>
<Input
<form onSubmit={onSubmit} className="flex min-h-0 flex-1 flex-col">
<div className="flex-1 space-y-5 overflow-y-auto px-6 py-4">
<div className="grid gap-4 md:grid-cols-2">
<FormField
id="plan-name"
placeholder="Starter"
{...register('name', { required: true })}
label="Name"
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="plan-slug">Slug</Label>
<Input
error={fieldError('name')}
>
<Input
id="plan-name"
placeholder="Enter plan name"
aria-invalid={!!fieldError('name')}
{...register('name')}
/>
</FormField>
<FormField
id="plan-slug"
placeholder="starter"
{...register('slug', { required: true })}
label="Slug"
required
/>
error={fieldError('slug')}
>
<Input
id="plan-slug"
placeholder="starter"
aria-invalid={!!fieldError('slug')}
{...register('slug')}
/>
</FormField>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="plan-description">Description</Label>
<textarea
<FormField
id="plan-description"
placeholder="Basic plan for small teams"
{...register('description', { required: true })}
label="Description"
required
className="min-h-20 w-full rounded-md border border-input bg-background px-3 py-2 outline-none focus-visible:border-ring"
/>
</div>
error={fieldError('description')}
>
<Textarea
id="plan-description"
placeholder="Basic plan for small teams"
aria-invalid={!!fieldError('description')}
{...register('description')}
className="min-h-20"
/>
</FormField>
<div className="grid gap-4 md:grid-cols-3">
<div className="space-y-2">
<Label htmlFor="plan-price">Price</Label>
<Input
<div className="grid gap-4 md:grid-cols-3">
<FormField
id="plan-price"
type="number"
min="0"
step="0.01"
placeholder="29.99"
{...register('price', { required: true })}
label="Price"
required
/>
</div>
<div className="space-y-2">
<Label>Billing Cycle</Label>
<Controller
control={control}
name="billing_cycle"
render={({ field }) => (
<Select value={field.value} onValueChange={field.onChange}>
<SelectTrigger>
<SelectValue placeholder="Billing cycle" />
</SelectTrigger>
<SelectContent>
<SelectItem value="monthly">Monthly</SelectItem>
<SelectItem value="quarterly">Quarterly</SelectItem>
<SelectItem value="yearly">Yearly</SelectItem>
</SelectContent>
</Select>
)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="plan-trial-days">Trial Days</Label>
<Input
error={fieldError('price')}
>
<Input
id="plan-price"
type="number"
min="0"
step="0.01"
placeholder="29.99"
aria-invalid={!!fieldError('price')}
{...register('price')}
/>
</FormField>
<FormField
label="Billing Cycle"
required
error={fieldError('billing_cycle')}
>
<Controller
control={control}
name="billing_cycle"
render={({ field }) => (
<Select value={field.value} onValueChange={field.onChange}>
<SelectTrigger>
<SelectValue placeholder="Billing cycle" />
</SelectTrigger>
<SelectContent>
<SelectItem value="monthly">Monthly</SelectItem>
<SelectItem value="quarterly">Quarterly</SelectItem>
<SelectItem value="yearly">Yearly</SelectItem>
</SelectContent>
</Select>
)}
/>
</FormField>
<FormField
id="plan-trial-days"
type="number"
min="0"
{...register('trial_days', { valueAsNumber: true })}
/>
label="Trial Days"
error={fieldError('trial_days')}
>
<Input
id="plan-trial-days"
type="number"
min="0"
aria-invalid={!!fieldError('trial_days')}
{...register('trial_days', { valueAsNumber: true })}
/>
</FormField>
</div>
</div>
<div className="grid gap-4 md:grid-cols-4">
<div className="space-y-2">
<Label htmlFor="max-projects">Projects</Label>
<Input
<div className="grid gap-4 md:grid-cols-4">
<FormField
id="max-projects"
type="number"
min="0"
{...register('max_projects', { valueAsNumber: true })}
/>
</div>
<div className="space-y-2">
<Label htmlFor="max-organizations">Organizations</Label>
<Input
label="Projects"
error={fieldError('max_projects')}
>
<Input
id="max-projects"
type="number"
min="0"
aria-invalid={!!fieldError('max_projects')}
{...register('max_projects', { valueAsNumber: true })}
/>
</FormField>
<FormField
id="max-organizations"
type="number"
min="0"
{...register('max_organizations', { valueAsNumber: true })}
/>
</div>
<div className="space-y-2">
<Label htmlFor="max-users">Users</Label>
<Input
label="Organizations"
error={fieldError('max_organizations')}
>
<Input
id="max-organizations"
type="number"
min="0"
aria-invalid={!!fieldError('max_organizations')}
{...register('max_organizations', { valueAsNumber: true })}
/>
</FormField>
<FormField
id="max-users"
type="number"
min="0"
{...register('max_users', { valueAsNumber: true })}
/>
</div>
<div className="space-y-2">
<Label htmlFor="max-roles">Roles</Label>
<Input
label="Users"
error={fieldError('max_users')}
>
<Input
id="max-users"
type="number"
min="0"
aria-invalid={!!fieldError('max_users')}
{...register('max_users', { valueAsNumber: true })}
/>
</FormField>
<FormField
id="max-roles"
type="number"
min="0"
{...register('max_roles', { valueAsNumber: true })}
/>
label="Roles"
error={fieldError('max_roles')}
>
<Input
id="max-roles"
type="number"
min="0"
aria-invalid={!!fieldError('max_roles')}
{...register('max_roles', { valueAsNumber: true })}
/>
</FormField>
</div>
</div>
<div className="flex flex-wrap gap-5">
<label className="flex items-center gap-2">
<input
type="checkbox"
className="size-4 rounded border-border accent-primary"
{...register('is_active')}
/>
Active
</label>
<label className="flex items-center gap-2">
<input
type="checkbox"
className="size-4 rounded border-border accent-primary"
{...register('is_custom')}
/>
Custom plan
</label>
</div>
<div className="space-y-3">
<div className="flex items-center justify-between gap-3">
<Label>Permissions</Label>
<span className="text-muted-foreground">
{permissionIds.length} selected
</span>
<div className="flex flex-wrap gap-5">
<label className="flex items-center gap-2">
<input
type="checkbox"
className="size-4 rounded border-border accent-primary"
{...register('is_active')}
/>
Active
</label>
<label className="flex items-center gap-2">
<input
type="checkbox"
className="size-4 rounded border-border accent-primary"
{...register('is_custom')}
/>
Custom plan
</label>
</div>
{isPermissionsLoading ? (
<div className="rounded-md border p-6 text-muted-foreground">
Loading permissions...
</div>
) : (
<PermissionTree
items={permissionTree}
selectedIds={permissionIds}
onChange={onPermissionIdsChange}
/>
)}
<FormField
label="Permissions"
required
error={fieldError('permission_ids')}
className="space-y-3"
labelEnd={
<span className="text-muted-foreground">
{permissionIds.length} selected
</span>
}
>
{isPermissionsLoading ? (
<div className="rounded-md border p-6 text-muted-foreground">
Loading permissions...
</div>
) : (
<PermissionTree
items={permissionTree}
selectedIds={permissionIds}
onChange={onPermissionIdsChange}
/>
)}
</FormField>
</div>
<SheetFooter className="px-0">
<SheetFooter className="shrink-0 border-t px-6 py-4 sm:flex-row sm:justify-end">
<Button
type="button"
variant="outline"
@@ -227,7 +291,7 @@ export function PlanSheet({
>
Cancel
</Button>
<Button type="submit" disabled={isSaving}>
<Button type="submit" disabled={isSaving || !canSubmit}>
{isSaving ? (
<Loader2 className="mr-2 size-4 animate-spin" />
) : null}

View File

@@ -1,5 +1,6 @@
'use client';
import { zodResolver } from '@hookform/resolvers/zod';
import {
collectDefaultPermissionIds,
collectPermissionIdsByKeys,
@@ -8,25 +9,39 @@ import type { PermissionTreeItem, Plan } from '@/types';
import { useCallback } from 'react';
import { useForm, useWatch } from 'react-hook-form';
import { toast } from 'sonner';
import { z } from 'zod';
import { useSavePlanMutation } from './usePlanMutations';
export interface PlanFormValues {
id?: number;
name: string;
slug: string;
description: string;
price: string;
billing_cycle: string;
trial_days: number;
max_projects: number;
max_organizations: number;
max_users: number;
max_roles: number;
permission_ids: number[];
is_active: boolean;
is_custom: boolean;
}
const nonNegativeNumber = z.number().refine(
(value) => Number.isFinite(value) && value >= 0,
{ message: 'Enter a valid number' },
);
const planFormSchema = z.object({
id: z.number().optional(),
name: z.string().trim().min(1, 'Plan name is required'),
slug: z.string().trim().min(1, 'Slug is required'),
description: z.string().trim().min(1, 'Description is required'),
price: z
.string()
.trim()
.min(1, 'Price is required')
.refine((value) => Number.isFinite(Number(value)) && Number(value) >= 0, {
message: 'Enter a valid price',
}),
billing_cycle: z.string().trim().min(1, 'Billing cycle is required'),
trial_days: nonNegativeNumber,
max_projects: nonNegativeNumber,
max_organizations: nonNegativeNumber,
max_users: nonNegativeNumber,
max_roles: nonNegativeNumber,
permission_ids: z.array(z.number()).min(1, 'Select at least one permission'),
is_active: z.boolean(),
is_custom: z.boolean(),
});
export type PlanFormValues = z.infer<typeof planFormSchema>;
const defaultValues: PlanFormValues = {
name: '',
@@ -62,9 +77,12 @@ export function usePlanForm({
handleSubmit,
reset,
setValue,
formState: { isSubmitting },
formState: { errors, isSubmitting, isSubmitted },
} = useForm<PlanFormValues>({
defaultValues,
mode: 'onSubmit',
reValidateMode: 'onChange',
resolver: zodResolver(planFormSchema),
});
const saveMutation = useSavePlanMutation({
onSaved: () => {
@@ -75,26 +93,42 @@ export function usePlanForm({
const planId = useWatch({ control, name: 'id' });
const permissionIds = useWatch({ control, name: 'permission_ids' }) || [];
const name = useWatch({ control, name: 'name' }) || '';
const slug = useWatch({ control, name: 'slug' }) || '';
const description = useWatch({ control, name: 'description' }) || '';
const price = useWatch({ control, name: 'price' }) || '';
const canSubmit =
name.trim().length > 0 &&
slug.trim().length > 0 &&
description.trim().length > 0 &&
price.trim().length > 0 &&
permissionIds.length > 0;
const onSubmit = handleSubmit((values) => {
if (values.permission_ids.length === 0) {
toast.error('Select at least one permission');
return;
}
const onSubmit = handleSubmit(
(values) => {
saveMutation.mutate({
...values,
name: values.name.trim(),
slug: values.slug.trim(),
description: values.description.trim(),
price: values.price.trim(),
trial_days: toNumber(values.trial_days),
max_projects: toNumber(values.max_projects),
max_organizations: toNumber(values.max_organizations),
max_users: toNumber(values.max_users),
max_roles: toNumber(values.max_roles),
});
},
(formErrors) => {
const firstMessage = Object.values(formErrors).find(
(error) => error?.message,
)?.message;
saveMutation.mutate({
...values,
name: values.name.trim(),
slug: values.slug.trim(),
description: values.description.trim(),
price: values.price.trim(),
trial_days: toNumber(values.trial_days),
max_projects: toNumber(values.max_projects),
max_organizations: toNumber(values.max_organizations),
max_users: toNumber(values.max_users),
max_roles: toNumber(values.max_roles),
});
});
if (firstMessage) {
toast.error(String(firstMessage));
}
},
);
const openCreate = useCallback(() => {
reset({
@@ -146,6 +180,9 @@ export function usePlanForm({
planId,
permissionIds,
setPermissionIds,
errors,
isSubmitted,
canSubmit,
isSaving: isSubmitting || saveMutation.isPending,
};
}

View File

@@ -67,6 +67,9 @@ export default function PlansPage() {
planId,
permissionIds,
setPermissionIds,
errors,
isSubmitted,
canSubmit,
isSaving,
} = planForm;
const statusMutation = usePlanStatusMutation();
@@ -183,7 +186,10 @@ export default function PlansPage() {
planId={planId}
register={register}
control={control}
errors={errors}
isSubmitted={isSubmitted}
onSubmit={onSubmit}
canSubmit={canSubmit}
permissionTree={permissionsQuery.permissionTree}
permissionIds={permissionIds}
onPermissionIdsChange={setPermissionIds}

View File

@@ -2,7 +2,7 @@
import type { ComponentProps } from 'react';
import type { FieldErrors, UseFormRegister } from 'react-hook-form';
import { Layers, Loader2, MapPin, Route } from 'lucide-react';
import { Loader2 } from 'lucide-react';
import { FormField } from '@/components/form';
import { Button } from '@/components/ui/button';
@@ -54,144 +54,135 @@ export function ProjectDialog({
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent
className="max-w-2xl"
className="flex max-h-[calc(100vh-2rem)] flex-col gap-0 p-0 sm:max-w-2xl"
onOpenAutoFocus={(event) => event.preventDefault()}
>
<DialogHeader className="gap-2">
<DialogTitle className="flex items-center gap-3">
<div className="rounded-lg bg-primary p-2 text-primary-foreground shadow-sm">
<Layers className="size-5" />
</div>
{projectId ? 'Edit Project Details' : 'Create New Project'}
<DialogHeader className="shrink-0 border-b px-6 py-4 pr-12">
<DialogTitle className="text-lg leading-none font-semibold tracking-tight">
{projectId ? 'Edit Project' : 'Create Project'}
</DialogTitle>
<DialogDescription>
{projectId
? 'Update the technical specifications for your road infrastructure project.'
: 'Provide the essential road data to establish a new analysis project.'}
<DialogDescription className="text-sm">
Manage road project identity and optional coordinate boundaries.
</DialogDescription>
</DialogHeader>
<form onSubmit={onSubmit} className="space-y-6">
<FormField
id="project-name"
label="Project Name"
required
error={nameErrorMessage}
>
<Input
id="project-name"
placeholder="Enter a descriptive project name"
aria-invalid={!!nameErrorMessage}
{...register('name')}
/>
</FormField>
<form onSubmit={onSubmit} className="flex min-h-0 flex-1 flex-col">
<div className="max-h-[58vh] space-y-6 overflow-y-auto px-6 py-4">
<section className="space-y-4">
<div className="space-y-1">
<h3 className="text-sm font-semibold">Basic details</h3>
<p className="text-sm text-muted-foreground">
Project name and corridor information.
</p>
</div>
<div className="grid gap-5 md:grid-cols-2">
<FormField id="project-state" label="State">
<Input
id="project-state"
placeholder="e.g. Maharashtra"
{...register('state')}
/>
</FormField>
<FormField
id="project-corridor"
label={
<span className="inline-flex items-center gap-2">
<Route className="size-3.5 opacity-60" />
Corridor Name
</span>
}
>
<Input
id="project-corridor"
placeholder="e.g. Mumbai-Goa Highway"
{...register('corridor_name')}
/>
</FormField>
</div>
<FormField
id="project-name"
label="Project Name"
required
error={nameErrorMessage}
>
<Input
id="project-name"
placeholder="Enter project name"
aria-invalid={!!nameErrorMessage}
{...register('name')}
/>
</FormField>
<div className="grid gap-8 pt-2 md:grid-cols-2">
<div className="space-y-4">
<p className="flex items-center gap-2 text-muted-foreground">
<MapPin className="size-3.5 opacity-60" />
START POINT
</p>
<div className="grid grid-cols-2 gap-4">
<FormField
id="project-start-lat"
label="Lat"
error={startLatErrorMessage}
>
<div className="grid gap-4 md:grid-cols-2">
<FormField id="project-state" label="State">
<Input
id="project-state"
placeholder="Maharashtra"
{...register('state')}
/>
</FormField>
<FormField id="project-corridor" label="Corridor Name">
<Input
id="project-corridor"
placeholder="Mumbai-Goa Highway"
{...register('corridor_name')}
/>
</FormField>
</div>
</section>
<section className="space-y-4">
<div className="space-y-1">
<h3 className="text-sm font-semibold">Coordinates</h3>
<p className="text-sm text-muted-foreground">
Optional start and end points for the project boundary.
</p>
</div>
<div className="space-y-5">
<div className="grid grid-cols-2 gap-4">
<FormField
id="project-start-lat"
type="number"
step="any"
placeholder="0.0000"
className="font-mono"
aria-invalid={!!startLatErrorMessage}
{...register('start_lat')}
/>
</FormField>
<FormField
id="project-start-lng"
label="Lng"
error={startLngErrorMessage}
>
<Input
label="Start Latitude"
error={startLatErrorMessage}
>
<Input
id="project-start-lat"
type="number"
step="any"
placeholder="0.0000"
aria-invalid={!!startLatErrorMessage}
{...register('start_lat')}
/>
</FormField>
<FormField
id="project-start-lng"
type="number"
step="any"
placeholder="0.0000"
className="font-mono"
aria-invalid={!!startLngErrorMessage}
{...register('start_lng')}
/>
</FormField>
</div>
</div>
label="Start Longitude"
error={startLngErrorMessage}
>
<Input
id="project-start-lng"
type="number"
step="any"
placeholder="0.0000"
aria-invalid={!!startLngErrorMessage}
{...register('start_lng')}
/>
</FormField>
</div>
<div className="space-y-4">
<p className="flex items-center gap-2 text-muted-foreground">
<MapPin className="size-3.5 opacity-60" />
END POINT
</p>
<div className="grid grid-cols-2 gap-4">
<FormField
id="project-end-lat"
label="Lat"
error={endLatErrorMessage}
>
<Input
<div className="grid grid-cols-2 gap-4">
<FormField
id="project-end-lat"
type="number"
step="any"
placeholder="0.0000"
className="font-mono"
aria-invalid={!!endLatErrorMessage}
{...register('end_lat')}
/>
</FormField>
<FormField
id="project-end-lng"
label="Lng"
error={endLngErrorMessage}
>
<Input
label="End Latitude"
error={endLatErrorMessage}
>
<Input
id="project-end-lat"
type="number"
step="any"
placeholder="0.0000"
aria-invalid={!!endLatErrorMessage}
{...register('end_lat')}
/>
</FormField>
<FormField
id="project-end-lng"
type="number"
step="any"
placeholder="0.0000"
className="font-mono"
aria-invalid={!!endLngErrorMessage}
{...register('end_lng')}
/>
</FormField>
label="End Longitude"
error={endLngErrorMessage}
>
<Input
id="project-end-lng"
type="number"
step="any"
placeholder="0.0000"
aria-invalid={!!endLngErrorMessage}
{...register('end_lng')}
/>
</FormField>
</div>
</div>
</div>
</section>
</div>
<DialogFooter>
<DialogFooter className="shrink-0 border-t px-6 py-4">
<Button
type="button"
variant="outline"
@@ -201,7 +192,7 @@ export function ProjectDialog({
Cancel
</Button>
<Button type="submit" disabled={isSaving || !canSubmit}>
{isSaving ? <Loader2 className="size-4 animate-spin" /> : null}
{isSaving ? <Loader2 className="mr-2 size-4 animate-spin" /> : null}
{projectId ? 'Update Project' : 'Create Project'}
</Button>
</DialogFooter>

View File

@@ -62,11 +62,13 @@ export function RoleSheet({
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent
side="right"
className="flex h-full flex-col gap-0 p-0 sm:max-w-3xl lg:max-w-2xl"
className="flex h-full flex-col gap-0 p-0 sm:max-w-3xl lg:max-w-2xl"
>
<SheetHeader className="shrink-0 border-b px-6 py-4">
<SheetTitle>{roleId ? 'Edit Role' : 'Create Role'}</SheetTitle>
<SheetDescription>
<SheetHeader className="shrink-0 border-b px-6 py-4 pr-12">
<SheetTitle className="text-lg leading-none font-semibold tracking-tight">
{roleId ? 'Edit Role' : 'Create Role'}
</SheetTitle>
<SheetDescription className="text-sm">
Assign the role details and permission access for this organization.
</SheetDescription>
</SheetHeader>

View File

@@ -2,7 +2,7 @@
import type { ComponentProps } from 'react';
import type { FieldErrors, UseFormRegister } from 'react-hook-form';
import { Loader2, MapPin, Milestone } from 'lucide-react';
import { Loader2 } from 'lucide-react';
import { FormField } from '@/components/form';
import { Button } from '@/components/ui/button';
@@ -75,253 +75,267 @@ export function SegmentDialog({
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent
className="max-h-[calc(100vh-2rem)] overflow-y-auto sm:max-w-2xl"
className="flex max-h-[calc(100vh-2rem)] flex-col gap-0 p-0 sm:max-w-2xl"
onOpenAutoFocus={(event) => event.preventDefault()}
>
<DialogHeader className="gap-2">
<DialogTitle className="flex items-center gap-3">
<div className="rounded-lg bg-primary p-2 text-primary-foreground shadow-sm">
<Milestone className="size-5" />
</div>
<DialogHeader className="shrink-0 border-b px-6 py-4 pr-12">
<DialogTitle className="text-lg leading-none font-semibold tracking-tight">
{segmentId ? 'Edit Segment' : 'Create Segment'}
</DialogTitle>
<DialogDescription>
{segmentId
? 'Update the technical specifications for your road segment.'
: 'Select a project and package, then provide the segment data.'}
<DialogDescription className="text-sm">
Manage segment binding, chainage values, direction, and coordinates.
</DialogDescription>
</DialogHeader>
<form onSubmit={onSubmit} className="space-y-6">
{!segmentId ? (
<div className="grid gap-5 md:grid-cols-2">
<FormField
label="Project"
required
error={getError('project_id')}
>
<Select value={projectId} onValueChange={onProjectChange}>
<SelectTrigger aria-invalid={!!getError('project_id')}>
{isProjectsLoading ? (
<span className="flex items-center gap-2 text-muted-foreground">
<Loader2 className="size-4 animate-spin" />
Loading...
</span>
) : (
<SelectValue placeholder="Choose a project" />
)}
</SelectTrigger>
<SelectContent>
{projects.map((project) => (
<SelectItem key={project.id} value={project.id}>
{project.name}
</SelectItem>
))}
</SelectContent>
</Select>
<input
type="hidden"
{...register('project_id')}
value={projectId}
readOnly
/>
</FormField>
<form onSubmit={onSubmit} className="flex min-h-0 flex-1 flex-col">
<div className="max-h-[58vh] space-y-6 overflow-y-auto px-6 py-4">
<section className="space-y-4">
<div className="space-y-1">
<h3 className="text-sm font-semibold">Basic details</h3>
<p className="text-sm text-muted-foreground">
Project, package, segment name, and direction.
</p>
</div>
<FormField
label="Package"
required
error={getError('package_id')}
>
<Select
value={packageId}
onValueChange={onPackageChange}
disabled={!projectId || isPackagesLoading}
>
<SelectTrigger aria-invalid={!!getError('package_id')}>
{isPackagesLoading ? (
<span className="flex items-center gap-2 text-muted-foreground">
<Loader2 className="size-4 animate-spin" />
Loading...
</span>
) : (
<SelectValue
placeholder={
projectId
? 'Choose a package'
: 'Select project first'
}
/>
)}
</SelectTrigger>
<SelectContent>
{packages.map((pkg) => (
<SelectItem key={pkg.id} value={pkg.id}>
{pkg.name}
</SelectItem>
))}
</SelectContent>
</Select>
<input
type="hidden"
{...register('package_id')}
value={packageId}
readOnly
/>
</FormField>
</div>
) : null}
{!segmentId ? (
<div className="grid gap-4 md:grid-cols-2">
<FormField
label="Project"
required
error={getError('project_id')}
>
<Select value={projectId} onValueChange={onProjectChange}>
<SelectTrigger aria-invalid={!!getError('project_id')}>
{isProjectsLoading ? (
<span className="flex items-center gap-2 text-muted-foreground">
<Loader2 className="size-4 animate-spin" />
Loading...
</span>
) : (
<SelectValue placeholder="Choose a project" />
)}
</SelectTrigger>
<SelectContent>
{projects.map((project) => (
<SelectItem key={project.id} value={project.id}>
{project.name}
</SelectItem>
))}
</SelectContent>
</Select>
<input
type="hidden"
{...register('project_id')}
value={projectId}
readOnly
/>
</FormField>
<div className="grid gap-5 md:grid-cols-2">
<FormField
id="segment-name"
label="Segment Name"
required
error={getError('segment_name')}
>
<Input
id="segment-name"
placeholder="e.g. Mumbai to Pune"
aria-invalid={!!getError('segment_name')}
{...register('segment_name')}
/>
</FormField>
<FormField
label="Package"
required
error={getError('package_id')}
>
<Select
value={packageId}
onValueChange={onPackageChange}
disabled={!projectId || isPackagesLoading}
>
<SelectTrigger aria-invalid={!!getError('package_id')}>
{isPackagesLoading ? (
<span className="flex items-center gap-2 text-muted-foreground">
<Loader2 className="size-4 animate-spin" />
Loading...
</span>
) : (
<SelectValue
placeholder={
projectId
? 'Choose a package'
: 'Select project first'
}
/>
)}
</SelectTrigger>
<SelectContent>
{packages.map((pkg) => (
<SelectItem key={pkg.id} value={pkg.id}>
{pkg.name}
</SelectItem>
))}
</SelectContent>
</Select>
<input
type="hidden"
{...register('package_id')}
value={packageId}
readOnly
/>
</FormField>
</div>
) : null}
<FormField label="Direction" required>
<Select value={direction} onValueChange={onDirectionChange}>
<SelectTrigger>
<SelectValue placeholder="Select direction" />
</SelectTrigger>
<SelectContent>
<SelectItem value="UP">UP</SelectItem>
<SelectItem value="DOWN">DOWN</SelectItem>
</SelectContent>
</Select>
</FormField>
<FormField
id="segment-start-km"
label="Start (km)"
required
error={getError('chainage_start_km')}
>
<Input
id="segment-start-km"
type="number"
step="any"
min="0"
placeholder="0.0"
aria-invalid={!!getError('chainage_start_km')}
{...register('chainage_start_km')}
/>
</FormField>
<FormField
id="segment-end-km"
label="End (km)"
required
error={getError('chainage_end_km')}
>
<Input
id="segment-end-km"
type="number"
step="any"
min="0"
placeholder="1.0"
aria-invalid={!!getError('chainage_end_km')}
{...register('chainage_end_km')}
/>
</FormField>
</div>
<div className="grid gap-6 md:grid-cols-2">
<div className="space-y-4">
<p className="flex items-center gap-2 text-muted-foreground">
<MapPin className="size-3.5 opacity-60" />
START COORDINATES
</p>
<div className="grid grid-cols-2 gap-4">
<div className="grid gap-4 md:grid-cols-2">
<FormField
id="start-lat"
label="Latitude"
id="segment-name"
label="Segment Name"
required
error={getError('start_lat')}
error={getError('segment_name')}
>
<Input
id="segment-name"
placeholder="Enter segment name"
aria-invalid={!!getError('segment_name')}
{...register('segment_name')}
/>
</FormField>
<FormField label="Direction" required>
<Select value={direction} onValueChange={onDirectionChange}>
<SelectTrigger>
<SelectValue placeholder="Select direction" />
</SelectTrigger>
<SelectContent>
<SelectItem value="UP">UP</SelectItem>
<SelectItem value="DOWN">DOWN</SelectItem>
</SelectContent>
</Select>
</FormField>
</div>
</section>
<section className="space-y-4">
<div className="space-y-1">
<h3 className="text-sm font-semibold">Chainage</h3>
<p className="text-sm text-muted-foreground">
Required start and end kilometre values.
</p>
</div>
<div className="grid gap-4 md:grid-cols-2">
<FormField
id="segment-start-km"
label="Start (km)"
required
error={getError('chainage_start_km')}
>
<Input
id="segment-start-km"
type="number"
step="any"
min="0"
placeholder="0.0"
aria-invalid={!!getError('chainage_start_km')}
{...register('chainage_start_km')}
/>
</FormField>
<FormField
id="segment-end-km"
label="End (km)"
required
error={getError('chainage_end_km')}
>
<Input
id="segment-end-km"
type="number"
step="any"
min="0"
placeholder="1.0"
aria-invalid={!!getError('chainage_end_km')}
{...register('chainage_end_km')}
/>
</FormField>
</div>
</section>
<section className="space-y-4">
<div className="space-y-1">
<h3 className="text-sm font-semibold">Coordinates</h3>
<p className="text-sm text-muted-foreground">
Required start and end point latitude and longitude.
</p>
</div>
<div className="space-y-5">
<div className="grid grid-cols-2 gap-4">
<FormField
id="start-lat"
type="number"
step="any"
min="-90"
max="90"
placeholder="-90 to 90"
aria-invalid={!!getError('start_lat')}
{...register('start_lat')}
/>
</FormField>
<FormField
id="start-lng"
label="Longitude"
required
error={getError('start_lng')}
>
<Input
label="Start Latitude"
required
error={getError('start_lat')}
>
<Input
id="start-lat"
type="number"
step="any"
min="-90"
max="90"
placeholder="-90 to 90"
aria-invalid={!!getError('start_lat')}
{...register('start_lat')}
/>
</FormField>
<FormField
id="start-lng"
type="number"
step="any"
min="-180"
max="180"
placeholder="-180 to 180"
aria-invalid={!!getError('start_lng')}
{...register('start_lng')}
/>
</FormField>
</div>
</div>
label="Start Longitude"
required
error={getError('start_lng')}
>
<Input
id="start-lng"
type="number"
step="any"
min="-180"
max="180"
placeholder="-180 to 180"
aria-invalid={!!getError('start_lng')}
{...register('start_lng')}
/>
</FormField>
</div>
<div className="space-y-4">
<p className="flex items-center gap-2 text-muted-foreground">
<MapPin className="size-3.5 opacity-60" />
END COORDINATES
</p>
<div className="grid grid-cols-2 gap-4">
<FormField
id="end-lat"
label="Latitude"
required
error={getError('end_lat')}
>
<Input
<div className="grid grid-cols-2 gap-4">
<FormField
id="end-lat"
type="number"
step="any"
min="-90"
max="90"
placeholder="-90 to 90"
aria-invalid={!!getError('end_lat')}
{...register('end_lat')}
/>
</FormField>
<FormField
id="end-lng"
label="Longitude"
required
error={getError('end_lng')}
>
<Input
label="End Latitude"
required
error={getError('end_lat')}
>
<Input
id="end-lat"
type="number"
step="any"
min="-90"
max="90"
placeholder="-90 to 90"
aria-invalid={!!getError('end_lat')}
{...register('end_lat')}
/>
</FormField>
<FormField
id="end-lng"
type="number"
step="any"
min="-180"
max="180"
placeholder="-180 to 180"
aria-invalid={!!getError('end_lng')}
{...register('end_lng')}
/>
</FormField>
label="End Longitude"
required
error={getError('end_lng')}
>
<Input
id="end-lng"
type="number"
step="any"
min="-180"
max="180"
placeholder="-180 to 180"
aria-invalid={!!getError('end_lng')}
{...register('end_lng')}
/>
</FormField>
</div>
</div>
</div>
</section>
</div>
<DialogFooter>
<DialogFooter className="shrink-0 border-t px-6 py-4">
<Button
type="button"
variant="outline"
@@ -331,7 +345,7 @@ export function SegmentDialog({
Cancel
</Button>
<Button type="submit" disabled={isSaving || !canSubmit}>
{isSaving ? <Loader2 className="size-4 animate-spin" /> : null}
{isSaving ? <Loader2 className="mr-2 size-4 animate-spin" /> : null}
{segmentId ? 'Update Segment' : 'Create Segment'}
</Button>
</DialogFooter>

View File

@@ -1,9 +1,10 @@
'use client';
import type { ComponentProps } from 'react';
import type { UseFormRegister } from 'react-hook-form';
import type { FieldErrors, UseFormRegister } from 'react-hook-form';
import { Loader2 } from 'lucide-react';
import { FormField } from '@/components/form';
import { Button } from '@/components/ui/button';
import {
Dialog,
@@ -14,7 +15,6 @@ import {
DialogTitle,
} from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import {
Select,
SelectContent,
@@ -32,7 +32,10 @@ interface TenantSheetProps {
tenantId?: number;
isEditMode: boolean;
register: UseFormRegister<TenantFormValues>;
errors: FieldErrors<TenantFormValues>;
isSubmitted: boolean;
onSubmit: ComponentProps<'form'>['onSubmit'];
canSubmit: boolean;
clientId: string;
planId: string;
onClientChange: (clientId: string) => void;
@@ -51,7 +54,10 @@ export function TenantSheet({
tenantId,
isEditMode,
register,
errors,
isSubmitted,
onSubmit,
canSubmit,
clientId,
planId,
onClientChange,
@@ -63,181 +69,234 @@ export function TenantSheet({
isSaving,
adminEmail,
}: TenantSheetProps) {
const fieldError = (field: keyof TenantFormValues) =>
isSubmitted ? errors[field]?.message : undefined;
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-h-[calc(100vh-2rem)] overflow-y-auto sm:max-w-3xl">
<DialogHeader>
<DialogTitle>
<DialogContent className="flex max-h-[calc(100vh-2rem)] flex-col gap-0 p-0 sm:max-w-3xl">
<DialogHeader className="shrink-0 border-b px-6 py-4 pr-12">
<DialogTitle className="text-lg leading-none font-semibold tracking-tight">
{tenantId ? 'Edit Tenant' : 'Create Tenant'}
</DialogTitle>
<DialogDescription>
<DialogDescription className="text-sm">
Bind a client and subscription plan, then invite the tenant
administrator.
</DialogDescription>
</DialogHeader>
<form onSubmit={onSubmit} className="flex flex-1 flex-col gap-5 px-4">
<div className="space-y-1">
<p>Basic Information</p>
<p className="text-muted-foreground">
Tenant identity and subscription binding.
</p>
</div>
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="tenant-name">Tenant Name</Label>
<Input
id="tenant-name"
placeholder="Acme Corp"
{...register('name', {
required: true,
onChange: (event) => onNameChange(event.target.value),
})}
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="tenant-slug">Slug</Label>
<Input
id="tenant-slug"
placeholder="acme-corp"
{...register('slug', { required: true })}
required
readOnly={isEditMode}
/>
</div>
</div>
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label>Client</Label>
<Select
value={clientId}
onValueChange={onClientChange}
disabled={isLookupsLoading}
>
<SelectTrigger>
<SelectValue
placeholder={
isLookupsLoading ? 'Loading clients...' : 'Select client'
}
/>
</SelectTrigger>
<SelectContent>
{clients.map((client) => (
<SelectItem key={client.id} value={String(client.id)}>
{client.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>Subscription Plan</Label>
<Select
value={planId}
onValueChange={onPlanChange}
disabled={isLookupsLoading}
>
<SelectTrigger>
<SelectValue
placeholder={
isLookupsLoading ? 'Loading plans...' : 'Select plan'
}
/>
</SelectTrigger>
<SelectContent>
{plans.map((plan) => (
<SelectItem key={plan.id} value={String(plan.id)}>
{plan.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="tenant-domain">Domain</Label>
<Input
id="tenant-domain"
placeholder="acme.example.com"
{...register('domain')}
/>
</div>
<div className="space-y-2 md:col-span-2">
<Label htmlFor="tenant-description">Description</Label>
<textarea
id="tenant-description"
placeholder="North zone operations"
{...register('description')}
className="min-h-20 w-full rounded-md border border-input bg-background px-3 py-2 outline-none focus-visible:border-ring"
/>
</div>
</div>
{!isEditMode ? (
<>
<div className="space-y-1">
<p>Tenant Administrator</p>
<p className="text-muted-foreground">
Invitation details for the primary tenant admin account.
</p>
</div>
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="admin-first-name">First Name</Label>
<Input
id="admin-first-name"
placeholder="Jane"
{...register('admin_first_name', { required: !isEditMode })}
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="admin-last-name">Last Name</Label>
<Input
id="admin-last-name"
placeholder="Doe"
{...register('admin_last_name', { required: !isEditMode })}
required
/>
</div>
</div>
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="admin-email">Admin Email</Label>
<Input
id="admin-email"
type="email"
placeholder="jane@acme.com"
{...register('admin_email', { required: !isEditMode })}
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="admin-phone">Phone Number</Label>
<Input
id="admin-phone"
placeholder="+91-9000011111"
{...register('admin_phone_number')}
/>
</div>
</div>
</>
) : adminEmail ? (
<div className="rounded-md border bg-muted/40 p-4 text-muted-foreground">
Admin invitation details cannot be changed after tenant creation.
<p className="mt-1 text-foreground">
Current admin email: <span>{adminEmail}</span>
<form onSubmit={onSubmit} className="flex min-h-0 flex-1 flex-col">
<div className="max-h-[50vh] space-y-5 overflow-y-auto px-6 py-4">
<div className="space-y-1">
<p>Basic Information</p>
<p className="text-muted-foreground">
Tenant identity and subscription binding.
</p>
</div>
) : null}
<DialogFooter className="px-0">
<div className="grid gap-4 md:grid-cols-2">
<FormField
id="tenant-name"
label="Tenant Name"
required
error={fieldError('name')}
>
<Input
id="tenant-name"
placeholder="Enter tenant name"
aria-invalid={!!fieldError('name')}
{...register('name', {
onChange: (event) => onNameChange(event.target.value),
})}
/>
</FormField>
<FormField
id="tenant-slug"
label="Slug"
required
error={fieldError('slug')}
>
<Input
id="tenant-slug"
placeholder="acme-corp"
aria-invalid={!!fieldError('slug')}
{...register('slug')}
readOnly={isEditMode}
/>
</FormField>
</div>
<div className="grid gap-4 md:grid-cols-2">
<FormField label="Client" required error={fieldError('client_id')}>
<Select
value={clientId}
onValueChange={onClientChange}
disabled={isLookupsLoading}
>
<SelectTrigger>
<SelectValue
placeholder={
isLookupsLoading
? 'Loading clients...'
: 'Select client'
}
/>
</SelectTrigger>
<SelectContent>
{clients.map((client) => (
<SelectItem key={client.id} value={String(client.id)}>
{client.name}
</SelectItem>
))}
</SelectContent>
</Select>
<input
type="hidden"
{...register('client_id')}
value={clientId}
readOnly
/>
</FormField>
<FormField
label="Subscription Plan"
required
error={fieldError('plan_id')}
>
<Select
value={planId}
onValueChange={onPlanChange}
disabled={isLookupsLoading}
>
<SelectTrigger>
<SelectValue
placeholder={
isLookupsLoading ? 'Loading plans...' : 'Select plan'
}
/>
</SelectTrigger>
<SelectContent>
{plans.map((plan) => (
<SelectItem key={plan.id} value={String(plan.id)}>
{plan.name}
</SelectItem>
))}
</SelectContent>
</Select>
<input
type="hidden"
{...register('plan_id')}
value={planId}
readOnly
/>
</FormField>
</div>
<div className="grid gap-4 md:grid-cols-2">
<FormField id="tenant-domain" label="Domain">
<Input
id="tenant-domain"
placeholder="acme.example.com"
{...register('domain')}
/>
</FormField>
<FormField
id="tenant-description"
label="Description"
className="md:col-span-2"
>
<textarea
id="tenant-description"
placeholder="North zone operations"
{...register('description')}
className="min-h-20 w-full rounded-md border border-input bg-background px-3 py-2 outline-none focus-visible:border-ring"
/>
</FormField>
</div>
{!isEditMode ? (
<>
<div className="space-y-1">
<p>Tenant Administrator</p>
<p className="text-muted-foreground">
Invitation details for the primary tenant admin account.
</p>
</div>
<div className="grid gap-4 md:grid-cols-2">
<FormField
id="admin-first-name"
label="First Name"
required
error={fieldError('admin_first_name')}
>
<Input
id="admin-first-name"
placeholder="Enter first name"
aria-invalid={!!fieldError('admin_first_name')}
{...register('admin_first_name')}
/>
</FormField>
<FormField
id="admin-last-name"
label="Last Name"
required
error={fieldError('admin_last_name')}
>
<Input
id="admin-last-name"
placeholder="Enter last name"
aria-invalid={!!fieldError('admin_last_name')}
{...register('admin_last_name')}
/>
</FormField>
</div>
<div className="grid gap-4 md:grid-cols-2">
<FormField
id="admin-email"
label="Admin Email"
required
error={fieldError('admin_email')}
>
<Input
id="admin-email"
type="email"
placeholder="admin@example.com"
aria-invalid={!!fieldError('admin_email')}
{...register('admin_email')}
/>
</FormField>
<FormField
id="admin-phone"
label="Phone Number"
error={fieldError('admin_phone_number')}
>
<Input
id="admin-phone"
placeholder="+91-9000011111"
aria-invalid={!!fieldError('admin_phone_number')}
{...register('admin_phone_number')}
/>
</FormField>
</div>
</>
) : adminEmail ? (
<div className="rounded-md border bg-muted/40 p-4 text-muted-foreground">
Admin invitation details cannot be changed after tenant
creation.
<p className="mt-1 text-foreground">
Current admin email: <span>{adminEmail}</span>
</p>
</div>
) : null}
</div>
<DialogFooter className="shrink-0 border-t px-6 py-4">
<Button
type="button"
variant="outline"
@@ -246,7 +305,10 @@ export function TenantSheet({
>
Cancel
</Button>
<Button type="submit" disabled={isSaving || isLookupsLoading}>
<Button
type="submit"
disabled={isSaving || isLookupsLoading || !canSubmit}
>
{isSaving ? (
<Loader2 className="mr-2 size-4 animate-spin" />
) : null}

View File

@@ -1,26 +1,74 @@
'use client';
import { zodResolver } from '@hookform/resolvers/zod';
import { useCallback } from 'react';
import { useForm, useWatch } from 'react-hook-form';
import { toast } from 'sonner';
import { z } from 'zod';
import type { Tenant } from '@/types';
import { useSaveTenantMutation } from './useTenantMutations';
export interface TenantFormValues {
id?: number;
name: string;
slug: string;
client_id: string;
plan_id: string;
domain: string;
description: string;
admin_first_name: string;
admin_last_name: string;
admin_email: string;
admin_phone_number: string;
}
const optionalPhoneSchema = z
.string()
.trim()
.refine((value) => value === '' || /^\+(?:[0-9] ?|-){6,18}[0-9]$/.test(value), {
message: 'Please enter a valid phone number',
});
const tenantFormSchema = z
.object({
id: z.number().optional(),
name: z.string().trim().min(1, 'Tenant name is required'),
slug: z.string().trim().min(1, 'Slug is required'),
client_id: z.string().trim().min(1, 'Client is required'),
plan_id: z.string().trim().min(1, 'Plan is required'),
domain: z.string().trim(),
description: z.string().trim(),
admin_first_name: z.string().trim(),
admin_last_name: z.string().trim(),
admin_email: z.string().trim(),
admin_phone_number: optionalPhoneSchema,
})
.superRefine((values, ctx) => {
if (values.id) return;
if (!values.admin_first_name.trim()) {
ctx.addIssue({
code: 'custom',
path: ['admin_first_name'],
message: 'Admin first name is required',
});
}
if (!values.admin_last_name.trim()) {
ctx.addIssue({
code: 'custom',
path: ['admin_last_name'],
message: 'Admin last name is required',
});
}
if (!values.admin_email.trim()) {
ctx.addIssue({
code: 'custom',
path: ['admin_email'],
message: 'Admin email is required',
});
return;
}
if (!z.string().email().safeParse(values.admin_email).success) {
ctx.addIssue({
code: 'custom',
path: ['admin_email'],
message: 'Invalid admin email',
});
}
});
export type TenantFormValues = z.infer<typeof tenantFormSchema>;
const defaultValues: TenantFormValues = {
name: '',
@@ -57,9 +105,12 @@ export function useTenantForm({ onSaved }: { onSaved: () => void }) {
reset,
control,
setValue,
formState: { isSubmitting },
formState: { errors, isSubmitting, isSubmitted },
} = useForm<TenantFormValues>({
defaultValues,
mode: 'onSubmit',
reValidateMode: 'onChange',
resolver: zodResolver(tenantFormSchema),
});
const saveMutation = useSaveTenantMutation({
onSaved: () => {
@@ -71,30 +122,33 @@ export function useTenantForm({ onSaved }: { onSaved: () => void }) {
const tenantId = useWatch({ control, name: 'id' });
const clientId = useWatch({ control, name: 'client_id' }) || '';
const planId = useWatch({ control, name: 'plan_id' }) || '';
const name = useWatch({ control, name: 'name' }) || '';
const slug = useWatch({ control, name: 'slug' }) || '';
const adminFirstName = useWatch({ control, name: 'admin_first_name' }) || '';
const adminLastName = useWatch({ control, name: 'admin_last_name' }) || '';
const adminEmail = useWatch({ control, name: 'admin_email' }) || '';
const isEditMode = Boolean(tenantId);
const canSubmit =
name.trim().length > 0 &&
slug.trim().length > 0 &&
clientId.trim().length > 0 &&
planId.trim().length > 0 &&
(isEditMode ||
(adminFirstName.trim().length > 0 &&
adminLastName.trim().length > 0 &&
adminEmail.trim().length > 0));
const handleSubmit = submitForm(
(values) => {
if (!values.client_id || !values.plan_id) {
toast.error('Select a client and plan');
return;
}
(values) => saveMutation.mutate(values),
(formErrors) => {
const firstMessage = Object.values(formErrors).find(
(error) => error?.message,
)?.message;
if (!isEditMode) {
if (
!values.admin_first_name.trim() ||
!values.admin_last_name.trim() ||
!values.admin_email.trim()
) {
toast.error('Complete all required admin fields');
return;
}
if (firstMessage) {
toast.error(String(firstMessage));
}
saveMutation.mutate(values);
},
() => toast.error('Complete all required tenant fields'),
);
const openCreate = useCallback(() => {
@@ -154,6 +208,9 @@ export function useTenantForm({ onSaved }: { onSaved: () => void }) {
planId,
adminEmail,
isEditMode,
errors,
isSubmitted,
canSubmit,
applyClientAdminDefaults,
syncSlugFromName,
isSaving: isSubmitting || saveMutation.isPending,

View File

@@ -62,6 +62,9 @@ export default function TenantsPage() {
planId,
adminEmail,
isEditMode,
errors,
isSubmitted,
canSubmit,
applyClientAdminDefaults,
syncSlugFromName,
setValue,
@@ -180,7 +183,10 @@ export default function TenantsPage() {
tenantId={tenantId}
isEditMode={isEditMode}
register={register}
errors={errors}
isSubmitted={isSubmitted}
onSubmit={handleSubmit}
canSubmit={canSubmit}
clientId={clientId}
planId={planId}
onClientChange={handleClientChange}

View File

@@ -60,94 +60,100 @@ export function UserSheet({
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="flex max-h-[calc(100vh-2rem)] flex-col gap-4 overflow-visible sm:max-w-xl">
<DialogHeader>
<DialogTitle>{userId ? 'Edit User' : 'Create User'}</DialogTitle>
<DialogContent className="flex max-h-[calc(100vh-2rem)] flex-col gap-0 p-0 sm:max-w-xl">
<DialogHeader className="shrink-0 border-b px-6 py-4 pr-12">
<DialogTitle className="text-lg leading-none font-semibold tracking-tight">
{userId ? 'Edit User' : 'Create User'}
</DialogTitle>
<DialogDescription>Assign user details and a role.</DialogDescription>
<DialogDescription className="text-sm">
Assign user details and a role.
</DialogDescription>
</DialogHeader>
<form
onSubmit={onSubmit}
className="flex min-h-0 flex-1 flex-col gap-5 overflow-y-auto px-4"
className="flex min-h-0 flex-1 flex-col"
>
<div className="grid gap-4 md:grid-cols-2">
<FormField
id="first-name"
label="First Name"
required
error={firstNameErrorMessage}
>
<Input
<div className="max-h-[50vh] space-y-5 overflow-y-auto px-6 py-4">
<div className="grid gap-4 md:grid-cols-2">
<FormField
id="first-name"
placeholder="Enter first name"
aria-invalid={!!firstNameErrorMessage}
{...register('first_name')}
/>
</FormField>
label="First Name"
required
error={firstNameErrorMessage}
>
<Input
id="first-name"
placeholder="Enter first name"
aria-invalid={!!firstNameErrorMessage}
{...register('first_name')}
/>
</FormField>
<FormField
id="last-name"
label="Last Name"
required
error={lastNameErrorMessage}
>
<Input
<FormField
id="last-name"
placeholder="Enter last name"
aria-invalid={!!lastNameErrorMessage}
{...register('last_name')}
/>
</FormField>
</div>
label="Last Name"
required
error={lastNameErrorMessage}
>
<Input
id="last-name"
placeholder="Enter last name"
aria-invalid={!!lastNameErrorMessage}
{...register('last_name')}
/>
</FormField>
</div>
<div className="grid gap-4 md:grid-cols-2">
<FormField
id="email"
label="Email"
required
error={emailErrorMessage}
>
<Input
<div className="grid gap-4 md:grid-cols-2">
<FormField
id="email"
placeholder="name@example.com"
aria-invalid={!!emailErrorMessage}
{...register('email')}
/>
</FormField>
label="Email"
required
error={emailErrorMessage}
>
<Input
id="email"
placeholder="name@example.com"
aria-invalid={!!emailErrorMessage}
{...register('email')}
/>
</FormField>
<FormField
id="phone-number"
label="Phone Number"
error={phoneErrorMessage}
>
<Input
<FormField
id="phone-number"
placeholder="+919876543210"
aria-invalid={!!phoneErrorMessage}
{...register('phone_number')}
label="Phone Number"
error={phoneErrorMessage}
>
<Input
id="phone-number"
placeholder="+919876543210"
aria-invalid={!!phoneErrorMessage}
{...register('phone_number')}
/>
</FormField>
</div>
<FormField label="Role" required error={roleErrorMessage}>
<RoleSelect
value={roleId}
onValueChange={onRoleChange}
enabled={open}
disabled={isSaving}
portalContainer={roleComboboxPortalRef}
/>
<input
type="hidden"
{...register('role_id')}
value={roleId}
readOnly
/>
</FormField>
</div>
<FormField label="Role" required error={roleErrorMessage}>
<RoleSelect
value={roleId}
onValueChange={onRoleChange}
enabled={open}
disabled={isSaving}
portalContainer={roleComboboxPortalRef}
/>
<input
type="hidden"
{...register('role_id')}
value={roleId}
readOnly
/>
</FormField>
<DialogFooter className="px-0">
<DialogFooter className="shrink-0 border-t px-6 py-4">
<Button
type="button"
variant="outline"