feat: add client management module

This commit is contained in:
2026-06-16 18:25:01 +05:30
parent 964b177ae1
commit d8587a43d4
19 changed files with 879 additions and 1 deletions

View File

@@ -0,0 +1,36 @@
'use client';
import { useCallback, useState } from 'react';
import { useDebounce } from '@/hooks/useDebounce';
export type ClientStatusFilter = 'all' | 'active' | 'inactive';
export function useClientFilters() {
const [skip, setSkip] = useState(0);
const [limit] = useState(10);
const [searchTerm, setSearchTermValue] = useState('');
const [statusFilter, setStatusFilterValue] = useState<ClientStatusFilter>('all');
const debouncedSearchTerm = useDebounce(searchTerm.trim(), 400);
const setSearchTerm = useCallback((value: string) => {
setSearchTermValue(value);
setSkip(0);
}, []);
const setStatusFilter = useCallback((value: ClientStatusFilter) => {
setStatusFilterValue(value);
setSkip(0);
}, []);
return {
skip,
setSkip,
limit,
searchTerm,
debouncedSearchTerm,
setSearchTerm,
statusFilter,
setStatusFilter,
};
}

View File

@@ -0,0 +1,109 @@
'use client';
import { useCallback } from 'react';
import { useForm, useWatch } from 'react-hook-form';
import { toast } from 'sonner';
import type { Client } from '@/types';
import { useSaveClientMutation } from './useClientMutations';
export interface ClientFormValues {
id?: number;
name: string;
email: string;
landline_number: string;
address: string;
gst: string;
pan: string;
tan: string;
contact_name: string;
contact_phone_number: string;
contact_email: string;
is_active: boolean;
}
const defaultValues: ClientFormValues = {
name: '',
email: '',
landline_number: '',
address: '',
gst: '',
pan: '',
tan: '',
contact_name: '',
contact_phone_number: '',
contact_email: '',
is_active: true,
};
export function useClientForm({ onSaved }: { onSaved: () => void }) {
const {
register,
handleSubmit: submitForm,
reset,
control,
formState: { isSubmitting, errors },
} = useForm<ClientFormValues>({
defaultValues,
});
const saveMutation = useSaveClientMutation({
onSaved: () => {
reset(defaultValues);
onSaved();
},
});
const clientId = useWatch({ control, name: 'id' });
const handleSubmit = submitForm(
(values) => saveMutation.mutate(values),
(formErrors) => {
if (
formErrors.name ||
formErrors.email ||
formErrors.landline_number ||
formErrors.address ||
formErrors.contact_name ||
formErrors.contact_phone_number ||
formErrors.contact_email
) {
toast.error('Complete all required client fields');
}
},
);
const openCreate = useCallback(() => {
reset(defaultValues);
}, [reset]);
const openEdit = useCallback(
(client: Client) => {
reset({
id: client.id,
name: client.name || '',
email: client.email || '',
landline_number: client.landline_number || '',
address: client.address || '',
gst: client.gst || '',
pan: client.pan || '',
tan: client.tan || '',
contact_name: client.contact_name || '',
contact_phone_number: client.contact_phone_number || '',
contact_email: client.contact_email || '',
is_active: client.is_active,
});
},
[reset],
);
return {
register,
handleSubmit,
reset,
openCreate,
openEdit,
clientId,
errors,
isSaving: isSubmitting || saveMutation.isPending,
};
}

View File

@@ -0,0 +1,61 @@
'use client';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { toast } from 'sonner';
import { clientService } from '@/services/api';
import type { Client } from '@/types';
import { clientKeys } from '../queries/clientKeys';
import type { ClientFormValues } from './useClientForm';
function optionalValue(value: string) {
const trimmed = value.trim();
return trimmed ? trimmed : null;
}
export function useSaveClientMutation({ onSaved }: { onSaved: () => void }) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (values: ClientFormValues) =>
clientService.saveClient({
id: values.id,
name: values.name.trim(),
email: values.email.trim(),
landline_number: values.landline_number.trim(),
address: values.address.trim(),
gst: optionalValue(values.gst),
pan: optionalValue(values.pan),
tan: optionalValue(values.tan),
contact_name: values.contact_name.trim(),
contact_phone_number: values.contact_phone_number.trim(),
contact_email: values.contact_email.trim(),
is_active: values.is_active,
}),
onSuccess: (_data, values) => {
toast.success(values.id ? 'Client updated' : 'Client created');
onSaved();
queryClient.invalidateQueries({ queryKey: clientKeys.all });
},
onError: (_error, values) => {
toast.error(values.id ? 'Failed to update client' : 'Failed to create client');
},
});
}
export function useClientStatusMutation() {
const queryClient = useQueryClient();
const mutation = useMutation({
mutationFn: (client: Client) => clientService.updateClientStatus(client.id, !client.is_active),
onSuccess: () => {
toast.success('Client status updated');
queryClient.invalidateQueries({ queryKey: clientKeys.all });
},
onError: () => toast.error('Failed to update client status'),
});
return {
...mutation,
pendingClientId: mutation.isPending ? mutation.variables?.id : undefined,
};
}

View File

@@ -0,0 +1,45 @@
'use client';
import { useMemo } from 'react';
import { useQuery } from '@tanstack/react-query';
import { clientService } from '@/services/api';
import type { ClientListParams } from '@/types';
import { clientKeys } from '../queries/clientKeys';
import type { ClientStatusFilter } from './useClientFilters';
interface UseClientsQueryParams {
skip: number;
limit: number;
searchTerm: string;
statusFilter: ClientStatusFilter;
}
function buildClientListParams({
skip,
limit,
searchTerm,
statusFilter,
}: UseClientsQueryParams): ClientListParams {
return {
skip,
limit,
search_term: searchTerm || undefined,
is_active: statusFilter === 'all' ? undefined : statusFilter === 'active',
sort_by: 'created_at',
sort_order: 'desc',
};
}
export function useClientsQuery(params: UseClientsQueryParams) {
const { skip, limit, searchTerm, statusFilter } = params;
const listParams = useMemo(
() => buildClientListParams({ skip, limit, searchTerm, statusFilter }),
[limit, searchTerm, skip, statusFilter],
);
return useQuery({
queryKey: clientKeys.list(listParams),
queryFn: () => clientService.getClients(listParams),
});
}