feat(clients): align client form with first and last name API fields

This commit is contained in:
2026-06-23 12:56:23 +05:30
parent a731fca5e0
commit 27cf5c9d3f
16 changed files with 162 additions and 85 deletions

2
next-env.d.ts vendored
View File

@@ -1,6 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
import "./.next/dev/types/routes.d.ts";
import "./.next/types/routes.d.ts";
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.

View File

@@ -19,10 +19,12 @@ export function useClientColumns(): ColumnDef<Client>[] {
return useMemo(() => {
return [
{
accessorKey: 'name',
accessorKey: 'first_name',
header: 'Client',
cell: ({ row }) => (
<span className="font-medium">{row.original.name}</span>
<span className="font-medium">
{`${row.original.first_name} ${row.original.last_name}`.trim()}
</span>
),
},
{

View File

@@ -40,7 +40,12 @@ export function ClientSheet({
canSubmit,
isSaving,
}: ClientSheetProps) {
const nameErrorMessage = isSubmitted ? errors.name?.message : undefined;
const firstNameErrorMessage = isSubmitted
? errors.first_name?.message
: undefined;
const lastNameErrorMessage = isSubmitted
? errors.last_name?.message
: undefined;
const emailErrorMessage = isSubmitted ? errors.email?.message : undefined;
const landlineErrorMessage = isSubmitted
? errors.landline_number?.message
@@ -68,23 +73,43 @@ export function ClientSheet({
</DialogDescription>
</DialogHeader>
<form onSubmit={onSubmit} className="flex min-h-0 flex-1 flex-col">
<form
noValidate
onSubmit={onSubmit}
className="flex min-h-0 flex-1 flex-col"
>
<div className="max-h-[58vh] space-y-5 overflow-y-auto px-6 py-4">
<div className="grid gap-4 md:grid-cols-2">
<FormField
id="client-name"
label="Client Name"
id="client-first-name"
label="First Name"
required
error={nameErrorMessage}
error={firstNameErrorMessage}
>
<Input
id="client-name"
placeholder="Enter client name"
aria-invalid={!!nameErrorMessage}
{...register('name')}
id="client-first-name"
placeholder="Enter first name"
aria-invalid={!!firstNameErrorMessage}
{...register('first_name')}
/>
</FormField>
<FormField
id="client-last-name"
label="Last Name"
required
error={lastNameErrorMessage}
>
<Input
id="client-last-name"
placeholder="Enter last name"
aria-invalid={!!lastNameErrorMessage}
{...register('last_name')}
/>
</FormField>
</div>
<div className="grid gap-4 md:grid-cols-2">
<FormField
id="client-email"
label="Company Email"
@@ -94,14 +119,12 @@ export function ClientSheet({
<Input
id="client-email"
type="email"
placeholder="info@example.com"
placeholder="Enter company email"
aria-invalid={!!emailErrorMessage}
{...register('email')}
/>
</FormField>
</div>
<div className="grid gap-4 md:grid-cols-2">
<FormField
id="landline-number"
label="Landline Number"
@@ -110,42 +133,50 @@ export function ClientSheet({
>
<Input
id="landline-number"
placeholder="+91-22-12345678"
placeholder="Enter landline number"
aria-invalid={!!landlineErrorMessage}
{...register('landline_number')}
/>
</FormField>
<FormField
id="address"
label="Address"
required
error={addressErrorMessage}
>
<Input
id="address"
placeholder="12 MG Road, Mumbai, MH 400001"
aria-invalid={!!addressErrorMessage}
{...register('address')}
/>
</FormField>
</div>
<FormField
id="address"
label="Address"
required
error={addressErrorMessage}
>
<Input
id="address"
placeholder="Enter address"
aria-invalid={!!addressErrorMessage}
{...register('address')}
/>
</FormField>
<div className="grid gap-4 md:grid-cols-3">
<FormField id="gst" label="GST">
<Input
id="gst"
placeholder="27ABCDE1234F1Z5"
placeholder="Enter GST number"
{...register('gst')}
/>
</FormField>
<FormField id="pan" label="PAN">
<Input id="pan" placeholder="ABCDE1234F" {...register('pan')} />
<Input
id="pan"
placeholder="Enter PAN number"
{...register('pan')}
/>
</FormField>
<FormField id="tan" label="TAN">
<Input id="tan" placeholder="MUMA12345B" {...register('tan')} />
<Input
id="tan"
placeholder="Enter TAN number"
{...register('tan')}
/>
</FormField>
</div>
@@ -172,7 +203,7 @@ export function ClientSheet({
>
<Input
id="contact-phone-number"
placeholder="+919876543210"
placeholder="Enter contact phone"
aria-invalid={!!contactPhoneErrorMessage}
{...register('contact_phone_number')}
/>
@@ -187,7 +218,7 @@ export function ClientSheet({
<Input
id="contact-email"
type="email"
placeholder="contact@example.com"
placeholder="Enter contact email"
aria-invalid={!!contactEmailErrorMessage}
{...register('contact_email')}
/>

View File

@@ -9,37 +9,32 @@ import { z } from 'zod';
import type { Client } from '@/types';
import { useSaveClientMutation } from './useClientMutations';
const phoneSchema = z
.string()
.trim()
.min(1, 'Phone number is required')
.regex(/^\+(?:[0-9] ?|-){6,18}[0-9]$/, 'Please enter a valid phone number');
const requiredText = (message: string) => z.string().trim().min(1, message);
const optionalTaxIdSchema = z.string().trim();
const clientFormSchema = z.object({
id: z.number().optional(),
name: z.string().trim().min(1, 'Client name is required'),
email: z.string().trim().min(1, 'Email is required').email('Invalid email'),
landline_number: phoneSchema,
address: z.string().trim().min(1, 'Address is required'),
first_name: requiredText('First name is required'),
last_name: requiredText('Last name is required'),
email: requiredText('Email is required').email('Invalid email'),
landline_number: requiredText('Landline number is required'),
address: requiredText('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'),
contact_name: requiredText('Contact name is required'),
contact_phone_number: requiredText('Contact phone number is required'),
contact_email: requiredText('Contact email is required').email(
'Invalid contact email',
),
is_active: z.boolean(),
});
export type ClientFormValues = z.infer<typeof clientFormSchema>;
const defaultValues: ClientFormValues = {
name: '',
first_name: '',
last_name: '',
email: '',
landline_number: '',
address: '',
@@ -73,7 +68,8 @@ export function useClientForm({ onSaved }: { onSaved: () => void }) {
});
const clientId = useWatch({ control, name: 'id' });
const name = useWatch({ control, name: 'name' }) || '';
const firstName = useWatch({ control, name: 'first_name' }) || '';
const lastName = useWatch({ control, name: 'last_name' }) || '';
const email = useWatch({ control, name: 'email' }) || '';
const landlineNumber = useWatch({ control, name: 'landline_number' }) || '';
const address = useWatch({ control, name: 'address' }) || '';
@@ -82,7 +78,8 @@ export function useClientForm({ onSaved }: { onSaved: () => void }) {
useWatch({ control, name: 'contact_phone_number' }) || '';
const contactEmail = useWatch({ control, name: 'contact_email' }) || '';
const canSubmit =
name.trim().length > 0 &&
firstName.trim().length > 0 &&
lastName.trim().length > 0 &&
email.trim().length > 0 &&
landlineNumber.trim().length > 0 &&
address.trim().length > 0 &&
@@ -111,7 +108,8 @@ export function useClientForm({ onSaved }: { onSaved: () => void }) {
(client: Client) => {
reset({
id: client.id,
name: client.name || '',
first_name: client.first_name || '',
last_name: client.last_name || '',
email: client.email || '',
landline_number: client.landline_number || '',
address: client.address || '',

View File

@@ -20,7 +20,8 @@ export function useSaveClientMutation({ onSaved }: { onSaved: () => void }) {
mutationFn: (values: ClientFormValues) =>
clientService.saveClient({
id: values.id,
name: values.name.trim(),
first_name: values.first_name.trim(),
last_name: values.last_name.trim(),
email: values.email.trim(),
landline_number: values.landline_number.trim(),
address: values.address.trim(),

View File

@@ -83,7 +83,11 @@ export function SegmentDialog({
</DialogDescription>
</DialogHeader>
<form onSubmit={onSubmit} className="flex min-h-0 flex-1 flex-col">
<form
noValidate
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">

View File

@@ -141,17 +141,7 @@ export function useSegmentForm({ onSaved }: { onSaved: () => void }) {
},
});
const onSubmit = handleSubmit(
(values) => saveMutation.mutate(values),
(formErrors) => {
const firstMessage = Object.values(formErrors).find(
(error) => error?.message,
)?.message;
if (firstMessage) {
toast.error(String(firstMessage));
}
},
);
const onSubmit = handleSubmit((values) => saveMutation.mutate(values));
const openCreate = useCallback(() => {
reset(defaultValues);

View File

@@ -128,7 +128,11 @@ export function TenantSheet({
</div>
<div className="grid gap-4 md:grid-cols-2">
<FormField label="Client" required error={fieldError('client_id')}>
<FormField
label="Client"
required
error={fieldError('client_id')}
>
<Select
value={clientId}
onValueChange={onClientChange}
@@ -146,7 +150,9 @@ export function TenantSheet({
<SelectContent>
{clients.map((client) => (
<SelectItem key={client.id} value={String(client.id)}>
{client.name}
{[client.first_name, client.last_name]
.filter(Boolean)
.join(' ')}
</SelectItem>
))}
</SelectContent>

View File

@@ -1,9 +1,10 @@
'use client';
import { ArrowLeft, BarChart3 } from 'lucide-react';
import { ArrowLeft } from 'lucide-react';
import { useRouter } from 'next/navigation';
import { Button } from '@/components/ui/button';
import { SparkleButton } from '@/components/ui/sparkle-button';
import { PERMISSIONS } from '@/constants/permissions';
import { PermissionGuard } from '@/guards';
import type { TicketDetail } from '@/types';
@@ -28,14 +29,12 @@ export function TicketDetailHeader({ ticket }: { ticket: TicketDetail }) {
<div className="flex shrink-0 items-center gap-2">
{ticket.video_id ? (
<PermissionGuard permissions={PERMISSIONS.TICKET.ASSIGN}>
<Button
variant="outline"
size="sm"
<SparkleButton
className="h-10 min-w-36 px-5"
onClick={() => router.push(`/results/${ticket.video_id}`)}
>
<BarChart3 className="size-4" />
Analysis
</Button>
View Analysis
</SparkleButton>
</PermissionGuard>
) : null}

View File

@@ -11,6 +11,10 @@ import { RepairVideoComparison } from './repair-report/RepairVideoComparison';
export function TicketRepairReportCard({ ticket }: { ticket: TicketDetail }) {
const repair = ticket.repair;
if (!repair) {
return null;
}
return (
<section className="space-y-5">
<RepairReportPanel ticket={ticket} />

View File

@@ -0,0 +1,34 @@
'use client';
import * as React from 'react';
import { cn } from '@/lib/utils';
type SparkleButtonProps = React.ButtonHTMLAttributes<HTMLButtonElement>;
export function SparkleButton({
className,
children,
type = 'button',
...props
}: SparkleButtonProps) {
return (
<button
type={type}
className={cn(
'group inline-flex h-12 min-w-40 shrink-0 cursor-pointer items-center justify-center gap-3 rounded-full border-0 bg-[#1C1A1C] px-6 text-sm font-semibold text-[#AAAAAA] outline-none transition-all duration-500 ease-in-out hover:-translate-y-0.5 hover:bg-[linear-gradient(0deg,#A47CF3,#683FEA)] hover:text-white hover:[box-shadow:inset_0_1px_0_0_rgba(255,255,255,0.4),inset_0_-4px_0_0_rgba(0,0,0,0.2),0_0_0_4px_rgba(255,255,255,0.2),0_0_180px_0_#9917FF] focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50',
className,
)}
{...props}
>
<svg
aria-hidden="true"
viewBox="0 0 24 24"
className="size-5 fill-[#AAAAAA] transition-all duration-700 ease-in-out group-hover:scale-125 group-hover:fill-white"
>
<path d="M10 21.236 6.755 14.745.264 11.5l6.491-3.245L10 1.764l3.245 6.491 6.491 3.245-6.491 3.245L10 21.236ZM18 21l1.5 3 1.5-3 3-1.5-3-1.5-1.5-3-1.5 3-3 1.5 3 1.5ZM19.333 4.667 20.5 7l1.167-2.333L24 3.5l-2.333-1.167L20.5 0l-1.167 2.333L17 3.5l2.333 1.167Z" />
</svg>
<span>{children}</span>
</button>
);
}

View File

@@ -40,7 +40,7 @@ export default function VideoPlayerSection({
isLoading: isVideoLoading,
isError: isVideoError,
refetch: refetchVideo,
} = useAnnotatedVideoQuery(data.annotated_video_url);
} = useAnnotatedVideoQuery(data.processed_video_url || '');
return (
<div className="space-y-6">

View File

@@ -9,7 +9,8 @@ import type {
function toClientPayload(payload: ClientRequest) {
return {
name: payload.name,
first_name: payload.first_name,
last_name: payload.last_name,
email: payload.email,
landline_number: payload.landline_number,
address: payload.address,
@@ -34,7 +35,8 @@ export const clientService = {
skip: params?.skip ?? 0,
limit: params?.limit ?? 10,
search_term: params?.search_term,
name: params?.name,
first_name: params?.first_name,
last_name: params?.last_name,
email: params?.email,
contact_name: params?.contact_name,
is_active: params?.is_active,

View File

@@ -3,7 +3,9 @@ import { DetectionResultLog } from './detection';
export type CompletedVideoResult = {
video_id: string;
status: 'completed' | string;
annotated_video_url: string;
// annotated_video_url: string;
processed_video_url: string | null;
raw_video_url: string | null;
fps: number;
duration_seconds: number;
detection_mode?: string;

View File

@@ -2,7 +2,8 @@ import type { PaginationParams } from './common';
export interface Client {
id: number;
name: string;
first_name: string;
last_name: string;
email: string;
landline_number: string;
address: string;
@@ -19,7 +20,8 @@ export interface Client {
export interface ClientRequest {
id?: number;
name: string;
first_name: string;
last_name: string;
email: string;
landline_number: string;
address: string;
@@ -34,7 +36,8 @@ export interface ClientRequest {
export interface ClientListParams extends PaginationParams {
search_term?: string;
name?: string;
first_name?: string;
last_name?: string;
email?: string;
contact_name?: string;
is_active?: boolean;

View File

@@ -28,6 +28,7 @@ export interface TicketVideo {
id: string;
name: string | null;
url: string | null;
processed_video_url: string | null;
thumbnail: string | null;
}