Compare commits
2 Commits
af4d7fae97
...
4add7a6c83
| Author | SHA1 | Date | |
|---|---|---|---|
| 4add7a6c83 | |||
| 9695c80e20 |
@@ -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">
|
||||||
|
|||||||
@@ -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">
|
|
||||||
|
<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">
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
<div className="space-y-2">
|
<FormField
|
||||||
<Label htmlFor="client-name">Name</Label>
|
id="client-name"
|
||||||
|
label="Client Name"
|
||||||
|
required
|
||||||
|
error={nameErrorMessage}
|
||||||
|
>
|
||||||
<Input
|
<Input
|
||||||
id="client-name"
|
id="client-name"
|
||||||
placeholder="Acme Corp"
|
placeholder="Enter client name"
|
||||||
{...register('name', { required: true })}
|
aria-invalid={!!nameErrorMessage}
|
||||||
required
|
{...register('name')}
|
||||||
/>
|
/>
|
||||||
</div>
|
</FormField>
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="client-email">Email</Label>
|
<FormField
|
||||||
|
id="client-email"
|
||||||
|
label="Company Email"
|
||||||
|
required
|
||||||
|
error={emailErrorMessage}
|
||||||
|
>
|
||||||
<Input
|
<Input
|
||||||
id="client-email"
|
id="client-email"
|
||||||
type="email"
|
type="email"
|
||||||
placeholder="info@acme.com"
|
placeholder="info@example.com"
|
||||||
{...register('email', { required: true })}
|
aria-invalid={!!emailErrorMessage}
|
||||||
required
|
{...register('email')}
|
||||||
/>
|
/>
|
||||||
</div>
|
</FormField>
|
||||||
</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>
|
id="landline-number"
|
||||||
|
label="Landline Number"
|
||||||
|
required
|
||||||
|
error={landlineErrorMessage}
|
||||||
|
>
|
||||||
<Input
|
<Input
|
||||||
id="landline-number"
|
id="landline-number"
|
||||||
placeholder="+91-22-12345678"
|
placeholder="+91-22-12345678"
|
||||||
{...register('landline_number', { required: true })}
|
aria-invalid={!!landlineErrorMessage}
|
||||||
required
|
{...register('landline_number')}
|
||||||
/>
|
/>
|
||||||
</div>
|
</FormField>
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="address">Address</Label>
|
<FormField
|
||||||
|
id="address"
|
||||||
|
label="Address"
|
||||||
|
required
|
||||||
|
error={addressErrorMessage}
|
||||||
|
>
|
||||||
<Input
|
<Input
|
||||||
id="address"
|
id="address"
|
||||||
placeholder="12 MG Road, Mumbai, MH 400001"
|
placeholder="12 MG Road, Mumbai, MH 400001"
|
||||||
{...register('address', { required: true })}
|
aria-invalid={!!addressErrorMessage}
|
||||||
required
|
{...register('address')}
|
||||||
/>
|
/>
|
||||||
</div>
|
</FormField>
|
||||||
</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')}
|
||||||
/>
|
/>
|
||||||
</div>
|
</FormField>
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="pan">PAN</Label>
|
<FormField id="pan" label="PAN">
|
||||||
<Input id="pan" placeholder="ABCDE1234F" {...register('pan')} />
|
<Input id="pan" placeholder="ABCDE1234F" {...register('pan')} />
|
||||||
</div>
|
</FormField>
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="tan">TAN</Label>
|
<FormField id="tan" label="TAN">
|
||||||
<Input id="tan" placeholder="MUMA12345B" {...register('tan')} />
|
<Input id="tan" placeholder="MUMA12345B" {...register('tan')} />
|
||||||
</div>
|
</FormField>
|
||||||
</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
|
||||||
<Label htmlFor="contact-name">Contact Name</Label>
|
id="contact-name"
|
||||||
|
label="Contact Name"
|
||||||
|
required
|
||||||
|
error={contactNameErrorMessage}
|
||||||
|
>
|
||||||
<Input
|
<Input
|
||||||
id="contact-name"
|
id="contact-name"
|
||||||
placeholder="Jane Doe"
|
placeholder="Enter contact name"
|
||||||
{...register('contact_name', { required: true })}
|
aria-invalid={!!contactNameErrorMessage}
|
||||||
required
|
{...register('contact_name')}
|
||||||
/>
|
/>
|
||||||
</div>
|
</FormField>
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="contact-phone-number">Contact Phone</Label>
|
<FormField
|
||||||
|
id="contact-phone-number"
|
||||||
|
label="Contact Phone"
|
||||||
|
required
|
||||||
|
error={contactPhoneErrorMessage}
|
||||||
|
>
|
||||||
<Input
|
<Input
|
||||||
id="contact-phone-number"
|
id="contact-phone-number"
|
||||||
placeholder="+91-9876543210"
|
placeholder="+919876543210"
|
||||||
{...register('contact_phone_number', { required: true })}
|
aria-invalid={!!contactPhoneErrorMessage}
|
||||||
required
|
{...register('contact_phone_number')}
|
||||||
/>
|
/>
|
||||||
</div>
|
</FormField>
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="contact-email">Contact Email</Label>
|
<FormField
|
||||||
|
id="contact-email"
|
||||||
|
label="Contact Email"
|
||||||
|
required
|
||||||
|
error={contactEmailErrorMessage}
|
||||||
|
>
|
||||||
<Input
|
<Input
|
||||||
id="contact-email"
|
id="contact-email"
|
||||||
type="email"
|
type="email"
|
||||||
placeholder="jane.doe@acme.com"
|
placeholder="contact@example.com"
|
||||||
{...register('contact_email', { required: true })}
|
aria-invalid={!!contactEmailErrorMessage}
|
||||||
required
|
{...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}
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -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,24 +71,28 @@ 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">
|
||||||
|
<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>
|
||||||
|
|
||||||
{!packageId ? (
|
{!packageId ? (
|
||||||
<FormField label="Project" required error={projectErrorMessage}>
|
<FormField label="Project" required error={projectErrorMessage}>
|
||||||
<Select value={projectId} onValueChange={onProjectChange}>
|
<Select value={projectId} onValueChange={onProjectChange}>
|
||||||
@@ -119,7 +123,7 @@ export function PackageDialog({
|
|||||||
</FormField>
|
</FormField>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<div className="grid gap-5 md:grid-cols-2">
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
<FormField
|
<FormField
|
||||||
id="package-name"
|
id="package-name"
|
||||||
label="Package Name"
|
label="Package Name"
|
||||||
@@ -128,31 +132,34 @@ export function PackageDialog({
|
|||||||
>
|
>
|
||||||
<Input
|
<Input
|
||||||
id="package-name"
|
id="package-name"
|
||||||
placeholder="e.g. Package 01"
|
placeholder="Enter package name"
|
||||||
aria-invalid={!!nameErrorMessage}
|
aria-invalid={!!nameErrorMessage}
|
||||||
{...register('name')}
|
{...register('name')}
|
||||||
/>
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
|
|
||||||
<FormField
|
<FormField id="package-region" label="Region">
|
||||||
id="package-region"
|
|
||||||
label={
|
|
||||||
<span className="inline-flex items-center gap-2">
|
|
||||||
<Globe className="size-3.5 opacity-60" />
|
|
||||||
Region
|
|
||||||
</span>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Input
|
<Input
|
||||||
id="package-region"
|
id="package-region"
|
||||||
placeholder="e.g. North Zone"
|
placeholder="North Zone"
|
||||||
{...register('region')}
|
{...register('region')}
|
||||||
/>
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<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
|
<FormField
|
||||||
id="package-start"
|
id="package-start"
|
||||||
label="Segment Start (km)"
|
label="Start (km)"
|
||||||
error={startErrorMessage}
|
error={startErrorMessage}
|
||||||
>
|
>
|
||||||
<Input
|
<Input
|
||||||
@@ -167,7 +174,7 @@ export function PackageDialog({
|
|||||||
|
|
||||||
<FormField
|
<FormField
|
||||||
id="package-end"
|
id="package-end"
|
||||||
label="Segment End (km)"
|
label="End (km)"
|
||||||
error={endErrorMessage}
|
error={endErrorMessage}
|
||||||
>
|
>
|
||||||
<Input
|
<Input
|
||||||
@@ -180,8 +187,10 @@ export function PackageDialog({
|
|||||||
/>
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
</div>
|
</div>
|
||||||
|
</section>
|
||||||
|
</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>
|
||||||
|
|||||||
@@ -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,70 +52,101 @@ 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">
|
|
||||||
|
<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">
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
<div className="space-y-2">
|
<FormField
|
||||||
<Label htmlFor="plan-name">Name</Label>
|
id="plan-name"
|
||||||
|
label="Name"
|
||||||
|
required
|
||||||
|
error={fieldError('name')}
|
||||||
|
>
|
||||||
<Input
|
<Input
|
||||||
id="plan-name"
|
id="plan-name"
|
||||||
placeholder="Starter"
|
placeholder="Enter plan name"
|
||||||
{...register('name', { required: true })}
|
aria-invalid={!!fieldError('name')}
|
||||||
required
|
{...register('name')}
|
||||||
/>
|
/>
|
||||||
</div>
|
</FormField>
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="plan-slug">Slug</Label>
|
<FormField
|
||||||
|
id="plan-slug"
|
||||||
|
label="Slug"
|
||||||
|
required
|
||||||
|
error={fieldError('slug')}
|
||||||
|
>
|
||||||
<Input
|
<Input
|
||||||
id="plan-slug"
|
id="plan-slug"
|
||||||
placeholder="starter"
|
placeholder="starter"
|
||||||
{...register('slug', { required: true })}
|
aria-invalid={!!fieldError('slug')}
|
||||||
required
|
{...register('slug')}
|
||||||
/>
|
/>
|
||||||
</div>
|
</FormField>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<FormField
|
||||||
<Label htmlFor="plan-description">Description</Label>
|
id="plan-description"
|
||||||
<textarea
|
label="Description"
|
||||||
|
required
|
||||||
|
error={fieldError('description')}
|
||||||
|
>
|
||||||
|
<Textarea
|
||||||
id="plan-description"
|
id="plan-description"
|
||||||
placeholder="Basic plan for small teams"
|
placeholder="Basic plan for small teams"
|
||||||
{...register('description', { required: true })}
|
aria-invalid={!!fieldError('description')}
|
||||||
required
|
{...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"
|
className="min-h-20"
|
||||||
/>
|
/>
|
||||||
</div>
|
</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>
|
id="plan-price"
|
||||||
|
label="Price"
|
||||||
|
required
|
||||||
|
error={fieldError('price')}
|
||||||
|
>
|
||||||
<Input
|
<Input
|
||||||
id="plan-price"
|
id="plan-price"
|
||||||
type="number"
|
type="number"
|
||||||
min="0"
|
min="0"
|
||||||
step="0.01"
|
step="0.01"
|
||||||
placeholder="29.99"
|
placeholder="29.99"
|
||||||
{...register('price', { required: true })}
|
aria-invalid={!!fieldError('price')}
|
||||||
required
|
{...register('price')}
|
||||||
/>
|
/>
|
||||||
</div>
|
</FormField>
|
||||||
<div className="space-y-2">
|
|
||||||
<Label>Billing Cycle</Label>
|
<FormField
|
||||||
|
label="Billing Cycle"
|
||||||
|
required
|
||||||
|
error={fieldError('billing_cycle')}
|
||||||
|
>
|
||||||
<Controller
|
<Controller
|
||||||
control={control}
|
control={control}
|
||||||
name="billing_cycle"
|
name="billing_cycle"
|
||||||
@@ -128,55 +163,79 @@ export function PlanSheet({
|
|||||||
</Select>
|
</Select>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</FormField>
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="plan-trial-days">Trial Days</Label>
|
<FormField
|
||||||
|
id="plan-trial-days"
|
||||||
|
label="Trial Days"
|
||||||
|
error={fieldError('trial_days')}
|
||||||
|
>
|
||||||
<Input
|
<Input
|
||||||
id="plan-trial-days"
|
id="plan-trial-days"
|
||||||
type="number"
|
type="number"
|
||||||
min="0"
|
min="0"
|
||||||
|
aria-invalid={!!fieldError('trial_days')}
|
||||||
{...register('trial_days', { valueAsNumber: true })}
|
{...register('trial_days', { valueAsNumber: true })}
|
||||||
/>
|
/>
|
||||||
</div>
|
</FormField>
|
||||||
</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>
|
id="max-projects"
|
||||||
|
label="Projects"
|
||||||
|
error={fieldError('max_projects')}
|
||||||
|
>
|
||||||
<Input
|
<Input
|
||||||
id="max-projects"
|
id="max-projects"
|
||||||
type="number"
|
type="number"
|
||||||
min="0"
|
min="0"
|
||||||
|
aria-invalid={!!fieldError('max_projects')}
|
||||||
{...register('max_projects', { valueAsNumber: true })}
|
{...register('max_projects', { valueAsNumber: true })}
|
||||||
/>
|
/>
|
||||||
</div>
|
</FormField>
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="max-organizations">Organizations</Label>
|
<FormField
|
||||||
|
id="max-organizations"
|
||||||
|
label="Organizations"
|
||||||
|
error={fieldError('max_organizations')}
|
||||||
|
>
|
||||||
<Input
|
<Input
|
||||||
id="max-organizations"
|
id="max-organizations"
|
||||||
type="number"
|
type="number"
|
||||||
min="0"
|
min="0"
|
||||||
|
aria-invalid={!!fieldError('max_organizations')}
|
||||||
{...register('max_organizations', { valueAsNumber: true })}
|
{...register('max_organizations', { valueAsNumber: true })}
|
||||||
/>
|
/>
|
||||||
</div>
|
</FormField>
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="max-users">Users</Label>
|
<FormField
|
||||||
|
id="max-users"
|
||||||
|
label="Users"
|
||||||
|
error={fieldError('max_users')}
|
||||||
|
>
|
||||||
<Input
|
<Input
|
||||||
id="max-users"
|
id="max-users"
|
||||||
type="number"
|
type="number"
|
||||||
min="0"
|
min="0"
|
||||||
|
aria-invalid={!!fieldError('max_users')}
|
||||||
{...register('max_users', { valueAsNumber: true })}
|
{...register('max_users', { valueAsNumber: true })}
|
||||||
/>
|
/>
|
||||||
</div>
|
</FormField>
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="max-roles">Roles</Label>
|
<FormField
|
||||||
|
id="max-roles"
|
||||||
|
label="Roles"
|
||||||
|
error={fieldError('max_roles')}
|
||||||
|
>
|
||||||
<Input
|
<Input
|
||||||
id="max-roles"
|
id="max-roles"
|
||||||
type="number"
|
type="number"
|
||||||
min="0"
|
min="0"
|
||||||
|
aria-invalid={!!fieldError('max_roles')}
|
||||||
{...register('max_roles', { valueAsNumber: true })}
|
{...register('max_roles', { valueAsNumber: true })}
|
||||||
/>
|
/>
|
||||||
</div>
|
</FormField>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-wrap gap-5">
|
<div className="flex flex-wrap gap-5">
|
||||||
@@ -198,13 +257,17 @@ export function PlanSheet({
|
|||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-3">
|
<FormField
|
||||||
<div className="flex items-center justify-between gap-3">
|
label="Permissions"
|
||||||
<Label>Permissions</Label>
|
required
|
||||||
|
error={fieldError('permission_ids')}
|
||||||
|
className="space-y-3"
|
||||||
|
labelEnd={
|
||||||
<span className="text-muted-foreground">
|
<span className="text-muted-foreground">
|
||||||
{permissionIds.length} selected
|
{permissionIds.length} selected
|
||||||
</span>
|
</span>
|
||||||
</div>
|
}
|
||||||
|
>
|
||||||
{isPermissionsLoading ? (
|
{isPermissionsLoading ? (
|
||||||
<div className="rounded-md border p-6 text-muted-foreground">
|
<div className="rounded-md border p-6 text-muted-foreground">
|
||||||
Loading permissions...
|
Loading permissions...
|
||||||
@@ -216,9 +279,10 @@ export function PlanSheet({
|
|||||||
onChange={onPermissionIdsChange}
|
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}
|
||||||
|
|||||||
@@ -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,13 +93,19 @@ 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');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
saveMutation.mutate({
|
saveMutation.mutate({
|
||||||
...values,
|
...values,
|
||||||
name: values.name.trim(),
|
name: values.name.trim(),
|
||||||
@@ -94,7 +118,17 @@ export function usePlanForm({
|
|||||||
max_users: toNumber(values.max_users),
|
max_users: toNumber(values.max_users),
|
||||||
max_roles: toNumber(values.max_roles),
|
max_roles: toNumber(values.max_roles),
|
||||||
});
|
});
|
||||||
});
|
},
|
||||||
|
(formErrors) => {
|
||||||
|
const firstMessage = Object.values(formErrors).find(
|
||||||
|
(error) => error?.message,
|
||||||
|
)?.message;
|
||||||
|
|
||||||
|
if (firstMessage) {
|
||||||
|
toast.error(String(firstMessage));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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,24 +54,28 @@ 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">
|
||||||
|
<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>
|
||||||
|
|
||||||
<FormField
|
<FormField
|
||||||
id="project-name"
|
id="project-name"
|
||||||
label="Project Name"
|
label="Project Name"
|
||||||
@@ -80,47 +84,43 @@ export function ProjectDialog({
|
|||||||
>
|
>
|
||||||
<Input
|
<Input
|
||||||
id="project-name"
|
id="project-name"
|
||||||
placeholder="Enter a descriptive project name"
|
placeholder="Enter project name"
|
||||||
aria-invalid={!!nameErrorMessage}
|
aria-invalid={!!nameErrorMessage}
|
||||||
{...register('name')}
|
{...register('name')}
|
||||||
/>
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
|
|
||||||
<div className="grid gap-5 md:grid-cols-2">
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
<FormField id="project-state" label="State">
|
<FormField id="project-state" label="State">
|
||||||
<Input
|
<Input
|
||||||
id="project-state"
|
id="project-state"
|
||||||
placeholder="e.g. Maharashtra"
|
placeholder="Maharashtra"
|
||||||
{...register('state')}
|
{...register('state')}
|
||||||
/>
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
<FormField
|
<FormField id="project-corridor" label="Corridor Name">
|
||||||
id="project-corridor"
|
|
||||||
label={
|
|
||||||
<span className="inline-flex items-center gap-2">
|
|
||||||
<Route className="size-3.5 opacity-60" />
|
|
||||||
Corridor Name
|
|
||||||
</span>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Input
|
<Input
|
||||||
id="project-corridor"
|
id="project-corridor"
|
||||||
placeholder="e.g. Mumbai-Goa Highway"
|
placeholder="Mumbai-Goa Highway"
|
||||||
{...register('corridor_name')}
|
{...register('corridor_name')}
|
||||||
/>
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
</div>
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<div className="grid gap-8 pt-2 md:grid-cols-2">
|
<section className="space-y-4">
|
||||||
<div className="space-y-4">
|
<div className="space-y-1">
|
||||||
<p className="flex items-center gap-2 text-muted-foreground">
|
<h3 className="text-sm font-semibold">Coordinates</h3>
|
||||||
<MapPin className="size-3.5 opacity-60" />
|
<p className="text-sm text-muted-foreground">
|
||||||
START POINT
|
Optional start and end points for the project boundary.
|
||||||
</p>
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-5">
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
<FormField
|
<FormField
|
||||||
id="project-start-lat"
|
id="project-start-lat"
|
||||||
label="Lat"
|
label="Start Latitude"
|
||||||
error={startLatErrorMessage}
|
error={startLatErrorMessage}
|
||||||
>
|
>
|
||||||
<Input
|
<Input
|
||||||
@@ -128,14 +128,13 @@ export function ProjectDialog({
|
|||||||
type="number"
|
type="number"
|
||||||
step="any"
|
step="any"
|
||||||
placeholder="0.0000"
|
placeholder="0.0000"
|
||||||
className="font-mono"
|
|
||||||
aria-invalid={!!startLatErrorMessage}
|
aria-invalid={!!startLatErrorMessage}
|
||||||
{...register('start_lat')}
|
{...register('start_lat')}
|
||||||
/>
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
<FormField
|
<FormField
|
||||||
id="project-start-lng"
|
id="project-start-lng"
|
||||||
label="Lng"
|
label="Start Longitude"
|
||||||
error={startLngErrorMessage}
|
error={startLngErrorMessage}
|
||||||
>
|
>
|
||||||
<Input
|
<Input
|
||||||
@@ -143,23 +142,16 @@ export function ProjectDialog({
|
|||||||
type="number"
|
type="number"
|
||||||
step="any"
|
step="any"
|
||||||
placeholder="0.0000"
|
placeholder="0.0000"
|
||||||
className="font-mono"
|
|
||||||
aria-invalid={!!startLngErrorMessage}
|
aria-invalid={!!startLngErrorMessage}
|
||||||
{...register('start_lng')}
|
{...register('start_lng')}
|
||||||
/>
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
</div>
|
</div>
|
||||||
</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">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
<FormField
|
<FormField
|
||||||
id="project-end-lat"
|
id="project-end-lat"
|
||||||
label="Lat"
|
label="End Latitude"
|
||||||
error={endLatErrorMessage}
|
error={endLatErrorMessage}
|
||||||
>
|
>
|
||||||
<Input
|
<Input
|
||||||
@@ -167,14 +159,13 @@ export function ProjectDialog({
|
|||||||
type="number"
|
type="number"
|
||||||
step="any"
|
step="any"
|
||||||
placeholder="0.0000"
|
placeholder="0.0000"
|
||||||
className="font-mono"
|
|
||||||
aria-invalid={!!endLatErrorMessage}
|
aria-invalid={!!endLatErrorMessage}
|
||||||
{...register('end_lat')}
|
{...register('end_lat')}
|
||||||
/>
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
<FormField
|
<FormField
|
||||||
id="project-end-lng"
|
id="project-end-lng"
|
||||||
label="Lng"
|
label="End Longitude"
|
||||||
error={endLngErrorMessage}
|
error={endLngErrorMessage}
|
||||||
>
|
>
|
||||||
<Input
|
<Input
|
||||||
@@ -182,16 +173,16 @@ export function ProjectDialog({
|
|||||||
type="number"
|
type="number"
|
||||||
step="any"
|
step="any"
|
||||||
placeholder="0.0000"
|
placeholder="0.0000"
|
||||||
className="font-mono"
|
|
||||||
aria-invalid={!!endLngErrorMessage}
|
aria-invalid={!!endLngErrorMessage}
|
||||||
{...register('end_lng')}
|
{...register('end_lng')}
|
||||||
/>
|
/>
|
||||||
</FormField>
|
</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>
|
||||||
|
|||||||
@@ -64,9 +64,11 @@ export function RoleSheet({
|
|||||||
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>
|
||||||
|
|||||||
@@ -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,26 +75,30 @@ 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">
|
||||||
|
<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>
|
||||||
|
|
||||||
{!segmentId ? (
|
{!segmentId ? (
|
||||||
<div className="grid gap-5 md:grid-cols-2">
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
<FormField
|
<FormField
|
||||||
label="Project"
|
label="Project"
|
||||||
required
|
required
|
||||||
@@ -171,7 +175,7 @@ export function SegmentDialog({
|
|||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<div className="grid gap-5 md:grid-cols-2">
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
<FormField
|
<FormField
|
||||||
id="segment-name"
|
id="segment-name"
|
||||||
label="Segment Name"
|
label="Segment Name"
|
||||||
@@ -180,7 +184,7 @@ export function SegmentDialog({
|
|||||||
>
|
>
|
||||||
<Input
|
<Input
|
||||||
id="segment-name"
|
id="segment-name"
|
||||||
placeholder="e.g. Mumbai to Pune"
|
placeholder="Enter segment name"
|
||||||
aria-invalid={!!getError('segment_name')}
|
aria-invalid={!!getError('segment_name')}
|
||||||
{...register('segment_name')}
|
{...register('segment_name')}
|
||||||
/>
|
/>
|
||||||
@@ -197,7 +201,18 @@ export function SegmentDialog({
|
|||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</FormField>
|
</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
|
<FormField
|
||||||
id="segment-start-km"
|
id="segment-start-km"
|
||||||
label="Start (km)"
|
label="Start (km)"
|
||||||
@@ -232,17 +247,21 @@ export function SegmentDialog({
|
|||||||
/>
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
</div>
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<div className="grid gap-6 md:grid-cols-2">
|
<section className="space-y-4">
|
||||||
<div className="space-y-4">
|
<div className="space-y-1">
|
||||||
<p className="flex items-center gap-2 text-muted-foreground">
|
<h3 className="text-sm font-semibold">Coordinates</h3>
|
||||||
<MapPin className="size-3.5 opacity-60" />
|
<p className="text-sm text-muted-foreground">
|
||||||
START COORDINATES
|
Required start and end point latitude and longitude.
|
||||||
</p>
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-5">
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
<FormField
|
<FormField
|
||||||
id="start-lat"
|
id="start-lat"
|
||||||
label="Latitude"
|
label="Start Latitude"
|
||||||
required
|
required
|
||||||
error={getError('start_lat')}
|
error={getError('start_lat')}
|
||||||
>
|
>
|
||||||
@@ -259,7 +278,7 @@ export function SegmentDialog({
|
|||||||
</FormField>
|
</FormField>
|
||||||
<FormField
|
<FormField
|
||||||
id="start-lng"
|
id="start-lng"
|
||||||
label="Longitude"
|
label="Start Longitude"
|
||||||
required
|
required
|
||||||
error={getError('start_lng')}
|
error={getError('start_lng')}
|
||||||
>
|
>
|
||||||
@@ -275,17 +294,11 @@ export function SegmentDialog({
|
|||||||
/>
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
</div>
|
</div>
|
||||||
</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">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
<FormField
|
<FormField
|
||||||
id="end-lat"
|
id="end-lat"
|
||||||
label="Latitude"
|
label="End Latitude"
|
||||||
required
|
required
|
||||||
error={getError('end_lat')}
|
error={getError('end_lat')}
|
||||||
>
|
>
|
||||||
@@ -302,7 +315,7 @@ export function SegmentDialog({
|
|||||||
</FormField>
|
</FormField>
|
||||||
<FormField
|
<FormField
|
||||||
id="end-lng"
|
id="end-lng"
|
||||||
label="Longitude"
|
label="End Longitude"
|
||||||
required
|
required
|
||||||
error={getError('end_lng')}
|
error={getError('end_lng')}
|
||||||
>
|
>
|
||||||
@@ -319,9 +332,10 @@ export function SegmentDialog({
|
|||||||
</FormField>
|
</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>
|
||||||
|
|||||||
@@ -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,19 +69,24 @@ 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">
|
|
||||||
|
<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">
|
<div className="space-y-1">
|
||||||
<p>Basic Information</p>
|
<p>Basic Information</p>
|
||||||
<p className="text-muted-foreground">
|
<p className="text-muted-foreground">
|
||||||
@@ -84,33 +95,40 @@ export function TenantSheet({
|
|||||||
</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="tenant-name">Tenant Name</Label>
|
id="tenant-name"
|
||||||
|
label="Tenant Name"
|
||||||
|
required
|
||||||
|
error={fieldError('name')}
|
||||||
|
>
|
||||||
<Input
|
<Input
|
||||||
id="tenant-name"
|
id="tenant-name"
|
||||||
placeholder="Acme Corp"
|
placeholder="Enter tenant name"
|
||||||
|
aria-invalid={!!fieldError('name')}
|
||||||
{...register('name', {
|
{...register('name', {
|
||||||
required: true,
|
|
||||||
onChange: (event) => onNameChange(event.target.value),
|
onChange: (event) => onNameChange(event.target.value),
|
||||||
})}
|
})}
|
||||||
required
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</FormField>
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="tenant-slug">Slug</Label>
|
<FormField
|
||||||
|
id="tenant-slug"
|
||||||
|
label="Slug"
|
||||||
|
required
|
||||||
|
error={fieldError('slug')}
|
||||||
|
>
|
||||||
<Input
|
<Input
|
||||||
id="tenant-slug"
|
id="tenant-slug"
|
||||||
placeholder="acme-corp"
|
placeholder="acme-corp"
|
||||||
{...register('slug', { required: true })}
|
aria-invalid={!!fieldError('slug')}
|
||||||
required
|
{...register('slug')}
|
||||||
readOnly={isEditMode}
|
readOnly={isEditMode}
|
||||||
/>
|
/>
|
||||||
</div>
|
</FormField>
|
||||||
</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="Client" required error={fieldError('client_id')}>
|
||||||
<Label>Client</Label>
|
|
||||||
<Select
|
<Select
|
||||||
value={clientId}
|
value={clientId}
|
||||||
onValueChange={onClientChange}
|
onValueChange={onClientChange}
|
||||||
@@ -119,7 +137,9 @@ export function TenantSheet({
|
|||||||
<SelectTrigger>
|
<SelectTrigger>
|
||||||
<SelectValue
|
<SelectValue
|
||||||
placeholder={
|
placeholder={
|
||||||
isLookupsLoading ? 'Loading clients...' : 'Select client'
|
isLookupsLoading
|
||||||
|
? 'Loading clients...'
|
||||||
|
: 'Select client'
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
@@ -131,9 +151,19 @@ export function TenantSheet({
|
|||||||
))}
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
<input
|
||||||
<div className="space-y-2">
|
type="hidden"
|
||||||
<Label>Subscription Plan</Label>
|
{...register('client_id')}
|
||||||
|
value={clientId}
|
||||||
|
readOnly
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
label="Subscription Plan"
|
||||||
|
required
|
||||||
|
error={fieldError('plan_id')}
|
||||||
|
>
|
||||||
<Select
|
<Select
|
||||||
value={planId}
|
value={planId}
|
||||||
onValueChange={onPlanChange}
|
onValueChange={onPlanChange}
|
||||||
@@ -154,27 +184,36 @@ export function TenantSheet({
|
|||||||
))}
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
<input
|
||||||
|
type="hidden"
|
||||||
|
{...register('plan_id')}
|
||||||
|
value={planId}
|
||||||
|
readOnly
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
</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 id="tenant-domain" label="Domain">
|
||||||
<Label htmlFor="tenant-domain">Domain</Label>
|
|
||||||
<Input
|
<Input
|
||||||
id="tenant-domain"
|
id="tenant-domain"
|
||||||
placeholder="acme.example.com"
|
placeholder="acme.example.com"
|
||||||
{...register('domain')}
|
{...register('domain')}
|
||||||
/>
|
/>
|
||||||
</div>
|
</FormField>
|
||||||
<div className="space-y-2 md:col-span-2">
|
|
||||||
<Label htmlFor="tenant-description">Description</Label>
|
<FormField
|
||||||
|
id="tenant-description"
|
||||||
|
label="Description"
|
||||||
|
className="md:col-span-2"
|
||||||
|
>
|
||||||
<textarea
|
<textarea
|
||||||
id="tenant-description"
|
id="tenant-description"
|
||||||
placeholder="North zone operations"
|
placeholder="North zone operations"
|
||||||
{...register('description')}
|
{...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"
|
className="min-h-20 w-full rounded-md border border-input bg-background px-3 py-2 outline-none focus-visible:border-ring"
|
||||||
/>
|
/>
|
||||||
</div>
|
</FormField>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{!isEditMode ? (
|
{!isEditMode ? (
|
||||||
@@ -187,57 +226,77 @@ export function TenantSheet({
|
|||||||
</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="admin-first-name">First Name</Label>
|
id="admin-first-name"
|
||||||
|
label="First Name"
|
||||||
|
required
|
||||||
|
error={fieldError('admin_first_name')}
|
||||||
|
>
|
||||||
<Input
|
<Input
|
||||||
id="admin-first-name"
|
id="admin-first-name"
|
||||||
placeholder="Jane"
|
placeholder="Enter first name"
|
||||||
{...register('admin_first_name', { required: !isEditMode })}
|
aria-invalid={!!fieldError('admin_first_name')}
|
||||||
required
|
{...register('admin_first_name')}
|
||||||
/>
|
/>
|
||||||
</div>
|
</FormField>
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="admin-last-name">Last Name</Label>
|
<FormField
|
||||||
|
id="admin-last-name"
|
||||||
|
label="Last Name"
|
||||||
|
required
|
||||||
|
error={fieldError('admin_last_name')}
|
||||||
|
>
|
||||||
<Input
|
<Input
|
||||||
id="admin-last-name"
|
id="admin-last-name"
|
||||||
placeholder="Doe"
|
placeholder="Enter last name"
|
||||||
{...register('admin_last_name', { required: !isEditMode })}
|
aria-invalid={!!fieldError('admin_last_name')}
|
||||||
required
|
{...register('admin_last_name')}
|
||||||
/>
|
/>
|
||||||
</div>
|
</FormField>
|
||||||
</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="admin-email">Admin Email</Label>
|
id="admin-email"
|
||||||
|
label="Admin Email"
|
||||||
|
required
|
||||||
|
error={fieldError('admin_email')}
|
||||||
|
>
|
||||||
<Input
|
<Input
|
||||||
id="admin-email"
|
id="admin-email"
|
||||||
type="email"
|
type="email"
|
||||||
placeholder="jane@acme.com"
|
placeholder="admin@example.com"
|
||||||
{...register('admin_email', { required: !isEditMode })}
|
aria-invalid={!!fieldError('admin_email')}
|
||||||
required
|
{...register('admin_email')}
|
||||||
/>
|
/>
|
||||||
</div>
|
</FormField>
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="admin-phone">Phone Number</Label>
|
<FormField
|
||||||
|
id="admin-phone"
|
||||||
|
label="Phone Number"
|
||||||
|
error={fieldError('admin_phone_number')}
|
||||||
|
>
|
||||||
<Input
|
<Input
|
||||||
id="admin-phone"
|
id="admin-phone"
|
||||||
placeholder="+91-9000011111"
|
placeholder="+91-9000011111"
|
||||||
|
aria-invalid={!!fieldError('admin_phone_number')}
|
||||||
{...register('admin_phone_number')}
|
{...register('admin_phone_number')}
|
||||||
/>
|
/>
|
||||||
</div>
|
</FormField>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
) : adminEmail ? (
|
) : adminEmail ? (
|
||||||
<div className="rounded-md border bg-muted/40 p-4 text-muted-foreground">
|
<div className="rounded-md border bg-muted/40 p-4 text-muted-foreground">
|
||||||
Admin invitation details cannot be changed after tenant creation.
|
Admin invitation details cannot be changed after tenant
|
||||||
|
creation.
|
||||||
<p className="mt-1 text-foreground">
|
<p className="mt-1 text-foreground">
|
||||||
Current admin email: <span>{adminEmail}</span>
|
Current admin email: <span>{adminEmail}</span>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
</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"
|
||||||
@@ -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}
|
||||||
|
|||||||
@@ -1,27 +1,75 @@
|
|||||||
'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: '',
|
||||||
slug: '',
|
slug: '',
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -60,17 +60,22 @@ 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="max-h-[50vh] space-y-5 overflow-y-auto px-6 py-4">
|
||||||
<div className="grid gap-4 md:grid-cols-2">
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
<FormField
|
<FormField
|
||||||
id="first-name"
|
id="first-name"
|
||||||
@@ -146,8 +151,9 @@ export function UserSheet({
|
|||||||
readOnly
|
readOnly
|
||||||
/>
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
|
</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"
|
||||||
|
|||||||
85
src/components/auth/AuthBackground.tsx
Normal file
85
src/components/auth/AuthBackground.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user