2 Commits

22 changed files with 1457 additions and 1016 deletions

View File

@@ -6,6 +6,7 @@ import { PasswordField } from '@/components/form';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
import { AuthBackground } from '@/components/auth/AuthBackground';
import { GuestGuard } from '@/guards'; import { GuestGuard } from '@/guards';
import { useLoginForm } from '@/hooks/useLoginForm'; import { useLoginForm } from '@/hooks/useLoginForm';
import { ROUTES } from '@/utils/routes'; import { ROUTES } from '@/utils/routes';
@@ -17,8 +18,8 @@ export default function LoginPage() {
return ( return (
<GuestGuard> <GuestGuard>
<div className="flex min-h-screen w-full"> <div className="flex min-h-screen w-full">
<div className="hidden md:flex w-[40%]" /> <AuthBackground />
<div className="flex flex-1 md:w-[60%] items-center justify-center p-6 border-l border-border h-screen"> <div className="flex flex-1 md:w-1/2 items-center justify-center p-6 border-l border-border h-screen">
<section className="w-full max-w-sm space-y-7"> <section className="w-full max-w-sm space-y-7">
<header className="space-y-2 text-center md:text-left"> <header className="space-y-2 text-center md:text-left">
<p className="text-sm font-medium text-muted-foreground"> <p className="text-sm font-medium text-muted-foreground">

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,9 +1,10 @@
'use client'; 'use client';
import type { ComponentProps } from 'react'; 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 { Loader2 } from 'lucide-react';
import { FormField } from '@/components/form';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { import {
Dialog, Dialog,
@@ -14,7 +15,6 @@ import {
DialogTitle, DialogTitle,
} from '@/components/ui/dialog'; } from '@/components/ui/dialog';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { import {
Select, Select,
SelectContent, SelectContent,
@@ -32,7 +32,10 @@ interface TenantSheetProps {
tenantId?: number; tenantId?: number;
isEditMode: boolean; isEditMode: boolean;
register: UseFormRegister<TenantFormValues>; register: UseFormRegister<TenantFormValues>;
errors: FieldErrors<TenantFormValues>;
isSubmitted: boolean;
onSubmit: ComponentProps<'form'>['onSubmit']; onSubmit: ComponentProps<'form'>['onSubmit'];
canSubmit: boolean;
clientId: string; clientId: string;
planId: string; planId: string;
onClientChange: (clientId: string) => void; onClientChange: (clientId: string) => void;
@@ -51,7 +54,10 @@ export function TenantSheet({
tenantId, tenantId,
isEditMode, isEditMode,
register, register,
errors,
isSubmitted,
onSubmit, onSubmit,
canSubmit,
clientId, clientId,
planId, planId,
onClientChange, onClientChange,
@@ -63,181 +69,234 @@ export function TenantSheet({
isSaving, isSaving,
adminEmail, adminEmail,
}: TenantSheetProps) { }: TenantSheetProps) {
const fieldError = (field: keyof TenantFormValues) =>
isSubmitted ? errors[field]?.message : undefined;
return ( return (
<Dialog open={open} onOpenChange={onOpenChange}> <Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-h-[calc(100vh-2rem)] overflow-y-auto sm:max-w-3xl"> <DialogContent className="flex max-h-[calc(100vh-2rem)] flex-col gap-0 p-0 sm:max-w-3xl">
<DialogHeader> <DialogHeader className="shrink-0 border-b px-6 py-4 pr-12">
<DialogTitle> <DialogTitle className="text-lg leading-none font-semibold tracking-tight">
{tenantId ? 'Edit Tenant' : 'Create Tenant'} {tenantId ? 'Edit Tenant' : 'Create Tenant'}
</DialogTitle> </DialogTitle>
<DialogDescription> <DialogDescription className="text-sm">
Bind a client and subscription plan, then invite the tenant Bind a client and subscription plan, then invite the tenant
administrator. administrator.
</DialogDescription> </DialogDescription>
</DialogHeader> </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"> <form onSubmit={onSubmit} className="flex min-h-0 flex-1 flex-col">
<div className="space-y-2"> <div className="max-h-[50vh] space-y-5 overflow-y-auto px-6 py-4">
<Label htmlFor="tenant-name">Tenant Name</Label> <div className="space-y-1">
<Input <p>Basic Information</p>
id="tenant-name" <p className="text-muted-foreground">
placeholder="Acme Corp" Tenant identity and subscription binding.
{...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>
</p> </p>
</div> </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 <Button
type="button" type="button"
variant="outline" variant="outline"
@@ -246,7 +305,10 @@ export function TenantSheet({
> >
Cancel Cancel
</Button> </Button>
<Button type="submit" disabled={isSaving || isLookupsLoading}> <Button
type="submit"
disabled={isSaving || isLookupsLoading || !canSubmit}
>
{isSaving ? ( {isSaving ? (
<Loader2 className="mr-2 size-4 animate-spin" /> <Loader2 className="mr-2 size-4 animate-spin" />
) : null} ) : null}

View File

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

View File

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

View File

@@ -60,94 +60,100 @@ export function UserSheet({
return ( return (
<Dialog open={open} onOpenChange={onOpenChange}> <Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="flex max-h-[calc(100vh-2rem)] flex-col gap-4 overflow-visible sm:max-w-xl"> <DialogContent className="flex max-h-[calc(100vh-2rem)] flex-col gap-0 p-0 sm:max-w-xl">
<DialogHeader> <DialogHeader className="shrink-0 border-b px-6 py-4 pr-12">
<DialogTitle>{userId ? 'Edit User' : 'Create User'}</DialogTitle> <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> </DialogHeader>
<form <form
onSubmit={onSubmit} 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"> <div className="max-h-[50vh] space-y-5 overflow-y-auto px-6 py-4">
<FormField <div className="grid gap-4 md:grid-cols-2">
id="first-name" <FormField
label="First Name"
required
error={firstNameErrorMessage}
>
<Input
id="first-name" id="first-name"
placeholder="Enter first name" label="First Name"
aria-invalid={!!firstNameErrorMessage} required
{...register('first_name')} error={firstNameErrorMessage}
/> >
</FormField> <Input
id="first-name"
placeholder="Enter first name"
aria-invalid={!!firstNameErrorMessage}
{...register('first_name')}
/>
</FormField>
<FormField <FormField
id="last-name"
label="Last Name"
required
error={lastNameErrorMessage}
>
<Input
id="last-name" id="last-name"
placeholder="Enter last name" label="Last Name"
aria-invalid={!!lastNameErrorMessage} required
{...register('last_name')} error={lastNameErrorMessage}
/> >
</FormField> <Input
</div> id="last-name"
placeholder="Enter last name"
aria-invalid={!!lastNameErrorMessage}
{...register('last_name')}
/>
</FormField>
</div>
<div className="grid gap-4 md:grid-cols-2"> <div className="grid gap-4 md:grid-cols-2">
<FormField <FormField
id="email"
label="Email"
required
error={emailErrorMessage}
>
<Input
id="email" id="email"
placeholder="name@example.com" label="Email"
aria-invalid={!!emailErrorMessage} required
{...register('email')} error={emailErrorMessage}
/> >
</FormField> <Input
id="email"
placeholder="name@example.com"
aria-invalid={!!emailErrorMessage}
{...register('email')}
/>
</FormField>
<FormField <FormField
id="phone-number"
label="Phone Number"
error={phoneErrorMessage}
>
<Input
id="phone-number" id="phone-number"
placeholder="+919876543210" label="Phone Number"
aria-invalid={!!phoneErrorMessage} error={phoneErrorMessage}
{...register('phone_number')} >
<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> </FormField>
</div> </div>
<FormField label="Role" required error={roleErrorMessage}> <DialogFooter className="shrink-0 border-t px-6 py-4">
<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">
<Button <Button
type="button" type="button"
variant="outline" variant="outline"

View File

@@ -0,0 +1,85 @@
import { cn } from '@/lib/utils';
const BOKEH_ORBS = [
'top-[20%] left-[10%] size-[min(18vw,140px)] opacity-[0.14]',
'top-[-18%] right-[-20%] size-[min(42vw,380px)] opacity-[0.1]',
'bottom-[-17%] left-[-15%] size-[min(34vw,300px)] opacity-[0.12]',
] as const;
const PARTICLES = [
'top-[30%] left-[45%] opacity-15',
'top-[43%] right-[20%] opacity-20',
'top-[57%] left-[10%] opacity-20',
'bottom-[28%] left-[45%] opacity-20',
'bottom-[14%] right-[20%] opacity-20',
] as const;
type AuthBackgroundProps = {
className?: string;
};
export function AuthBackground({ className }: AuthBackgroundProps) {
return (
<div
className={cn(
'relative hidden h-screen w-1/2 shrink-0 overflow-hidden md:block',
className,
)}
aria-hidden
>
<div
className="absolute inset-0"
style={{
background:
'linear-gradient(155deg, color-mix(in oklch, var(--primary) 92%, white 8%) 0%, var(--primary) 32%, color-mix(in oklch, var(--primary) 72%, black 28%) 66%, color-mix(in oklch, var(--primary) 48%, black 52%) 100%)',
}}
/>
<div
className="absolute inset-0"
style={{
background:
'radial-gradient(circle at 50% 43%, color-mix(in oklch, var(--primary) 78%, white 22% / 34%), transparent 42%), radial-gradient(circle at 18% 8%, color-mix(in oklch, var(--primary) 70%, white 30% / 26%), transparent 36%)',
}}
/>
<svg
className="pointer-events-none absolute inset-0 size-full text-primary-foreground/8"
viewBox="0 0 100 100"
preserveAspectRatio="xMidYMid slice"
>
{Array.from({ length: 14 }, (_, index) => (
<circle
key={index}
cx="50"
cy="45"
r={8 + index * 5.2}
fill="none"
stroke="currentColor"
strokeWidth="0.08"
/>
))}
</svg>
{BOKEH_ORBS.map((position, index) => (
<div
key={index}
className={cn(
'absolute rounded-full bg-primary-foreground/16',
position,
)}
/>
))}
{PARTICLES.map((position, index) => (
<div
key={index}
className={cn(
'absolute size-1.5 rounded-full bg-primary-foreground/35',
position,
)}
/>
))}
</div>
);
}

View File

@@ -52,7 +52,7 @@ export function FilterSelector({
onValueChange={onProjectChange} onValueChange={onProjectChange}
disabled={isLoading || projects.length === 0} disabled={isLoading || projects.length === 0}
> >
<SelectTrigger className="h-10 text-sm w-full bg-background border-border/60 shadow-xs"> <SelectTrigger className="h-10 text-sm w-full bg-background border-border/60">
<SelectValue placeholder="Select project" /> <SelectValue placeholder="Select project" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
@@ -81,7 +81,7 @@ export function FilterSelector({
onValueChange={onPackageChange} onValueChange={onPackageChange}
disabled={isLoading} disabled={isLoading}
> >
<SelectTrigger className="h-10 text-sm w-full bg-background border-border/60 shadow-xs"> <SelectTrigger className="h-10 text-sm w-full bg-background border-border/60">
<SelectValue placeholder="All packages" /> <SelectValue placeholder="All packages" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
@@ -112,7 +112,7 @@ export function FilterSelector({
onValueChange={onChainageChange} onValueChange={onChainageChange}
disabled={isLoading} disabled={isLoading}
> >
<SelectTrigger className="h-10 text-sm w-full bg-background border-border/60 shadow-xs"> <SelectTrigger className="h-10 text-sm w-full bg-background border-border/60">
<SelectValue placeholder="All segments" /> <SelectValue placeholder="All segments" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>

View File

@@ -88,14 +88,14 @@ export function MultiSelectPopover({
return ( return (
<Popover> <Popover>
<PopoverTrigger asChild> <PopoverTrigger asChild>
<Button type="button" variant="outline" size="sm"> <Button type="button" variant="outline" size="sm" className="shadow-none">
{icon} {icon}
{label} {label}
<Badge variant="secondary">{values.length}</Badge> <Badge variant="secondary">{values.length}</Badge>
<ChevronDown /> <ChevronDown />
</Button> </Button>
</PopoverTrigger> </PopoverTrigger>
<PopoverContent align={align} className="w-60 p-0"> <PopoverContent align={align} className="w-60 p-0 shadow-none">
<div className="flex items-center gap-2 border-b p-3"> <div className="flex items-center gap-2 border-b p-3">
<button <button
type="button" type="button"

View File

@@ -137,7 +137,7 @@ function ComboboxContent({
data-slot="combobox-content" data-slot="combobox-content"
data-chips={!!anchor} data-chips={!!anchor}
className={cn( className={cn(
'group/combobox-content relative max-h-96 w-[var(--anchor-width)] max-w-[var(--available-width)] min-w-[calc(var(--anchor-width)+1.75rem)] origin-[var(--transform-origin)] overflow-hidden rounded-md bg-popover text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 data-[chips=true]:min-w-[var(--anchor-width)] 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 *:data-[slot=input-group]:m-1 *:data-[slot=input-group]:mb-0 *:data-[slot=input-group]:h-8 *:data-[slot=input-group]:border-input/30 *:data-[slot=input-group]:bg-input/30 *:data-[slot=input-group]:shadow-none data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95', 'group/combobox-content relative max-h-96 w-(--anchor-width) max-w-(--available-width) min-w-[calc(var(--anchor-width)+1.75rem)] origin-(--transform-origin) overflow-hidden rounded-md bg-popover text-popover-foreground ring-1 ring-foreground/10 duration-100 data-[chips=true]:min-w-(--anchor-width) 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 *:data-[slot=input-group]:m-1 *:data-[slot=input-group]:mb-0 *:data-[slot=input-group]:h-8 *:data-[slot=input-group]:border-input/30 *:data-[slot=input-group]:bg-input/30 *:data-[slot=input-group]:shadow-none data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95',
className, className,
)} )}
{...props} {...props}
@@ -254,7 +254,7 @@ function ComboboxChips({
<ComboboxPrimitive.Chips <ComboboxPrimitive.Chips
data-slot="combobox-chips" data-slot="combobox-chips"
className={cn( className={cn(
'flex min-h-9 flex-wrap items-center gap-1.5 rounded-md border border-input bg-transparent bg-clip-padding px-2.5 py-1.5 text-sm shadow-xs transition-[color,box-shadow] focus-within:border-ring has-aria-invalid:border-destructive has-data-[slot=combobox-chip]:px-1.5 dark:bg-input/30 dark:has-aria-invalid:border-destructive', 'flex min-h-9 flex-wrap items-center gap-1.5 rounded-md border border-input bg-transparent bg-clip-padding px-2.5 py-1.5 text-sm transition-[color,box-shadow] focus-within:border-ring has-aria-invalid:border-destructive has-data-[slot=combobox-chip]:px-1.5 dark:bg-input/30 dark:has-aria-invalid:border-destructive',
className, className,
)} )}
{...props} {...props}

View File

@@ -14,7 +14,7 @@ function InputGroup({ className, ...props }: React.ComponentProps<'div'>) {
data-slot="input-group" data-slot="input-group"
role="group" role="group"
className={cn( className={cn(
'group/input-group relative flex w-full items-center rounded-md border border-input shadow-xs transition-[color,box-shadow] outline-none dark:bg-input/30', 'group/input-group relative flex w-full items-center rounded-md border border-input transition-[color,box-shadow] outline-none dark:bg-input/30',
'h-9 min-w-0 has-[>textarea]:h-auto', 'h-9 min-w-0 has-[>textarea]:h-auto',
// Variants based on alignment. // Variants based on alignment.

View File

@@ -8,7 +8,7 @@ function Input({ className, type, ...props }: React.ComponentProps<'input'>) {
type={type} type={type}
data-slot="input" data-slot="input"
className={cn( className={cn(
'h-9 w-full min-w-0 rounded-md border border-input bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none selection:bg-primary selection:text-primary-foreground file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm dark:bg-input/30', 'h-9 w-full min-w-0 rounded-md border border-input bg-transparent px-3 py-1 text-base transition-[color,box-shadow] outline-none selection:bg-primary selection:text-primary-foreground file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm dark:bg-input/30',
'focus-visible:border-ring', 'focus-visible:border-ring',
'aria-invalid:border-destructive', 'aria-invalid:border-destructive',
className, className,

View File

@@ -37,7 +37,7 @@ function SelectTrigger({
data-slot="select-trigger" data-slot="select-trigger"
data-size={size} data-size={size}
className={cn( className={cn(
"flex h-9 w-full min-w-0 items-center justify-between gap-2 rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-xs transition-[color,box-shadow] outline-none focus-visible:border-ring disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive data-placeholder:text-muted-foreground data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 dark:bg-input/30 dark:hover:bg-input/50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground", "flex h-9 w-full min-w-0 items-center justify-between gap-2 rounded-md border border-input bg-transparent px-3 py-1 text-sm transition-[color,box-shadow] outline-none focus-visible:border-ring disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive data-placeholder:text-muted-foreground data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 dark:bg-input/30 dark:hover:bg-input/50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground",
className, className,
)} )}
{...props} {...props}
@@ -62,7 +62,7 @@ function SelectContent({
<SelectPrimitive.Content <SelectPrimitive.Content
data-slot="select-content" data-slot="select-content"
className={cn( className={cn(
'relative z-50 max-h-(--radix-select-content-available-height) min-w-32 origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border bg-popover text-popover-foreground shadow-md 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 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95', 'relative z-50 max-h-(--radix-select-content-available-height) min-w-32 origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border bg-popover text-popover-foreground 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 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95',
position === 'popper' && 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', '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, className,