diff --git a/.cursor/mcp.json b/.cursor/mcp.json new file mode 100644 index 0000000..c55d0b8 --- /dev/null +++ b/.cursor/mcp.json @@ -0,0 +1,7 @@ +{ + "mcpServers": { + "dual-graph": { + "url": "http://127.0.0.1:8080/mcp" + } + } +} \ No newline at end of file diff --git a/src/app/(modules)/clients/components/ClientColumns.tsx b/src/app/(modules)/clients/components/ClientColumns.tsx new file mode 100644 index 0000000..6202681 --- /dev/null +++ b/src/app/(modules)/clients/components/ClientColumns.tsx @@ -0,0 +1,111 @@ +'use client'; + +import { useMemo } from 'react'; +import type { ColumnDef } from '@tanstack/react-table'; +import { Edit, RotateCcw } from 'lucide-react'; + +import { Badge } from '@/components/ui/badge'; +import { Button } from '@/components/ui/button'; +import { PERMISSIONS } from '@/constants/permissions'; +import { usePermissions } from '@/hooks/usePermissions'; +import type { Client } from '@/types'; + +function formatDate(value?: string | null) { + if (!value) return '-'; + return new Intl.DateTimeFormat('en-IN', { + day: '2-digit', + month: 'short', + year: 'numeric', + }).format(new Date(value)); +} + +interface UseClientColumnsParams { + onEdit: (client: Client) => void; + onToggleStatus: (client: Client) => void; + pendingClientId?: number; +} + +export function useClientColumns({ + onEdit, + onToggleStatus, + pendingClientId, +}: UseClientColumnsParams): ColumnDef[] { + const { hasPermission } = usePermissions(); + const canEdit = hasPermission(PERMISSIONS.CLIENT.UPDATE); + const canDelete = hasPermission(PERMISSIONS.CLIENT.DELETE); + + return useMemo(() => { + const columns: ColumnDef[] = [ + { + accessorKey: 'name', + header: 'Client', + cell: ({ row }) => {row.original.name}, + }, + { + accessorKey: 'email', + header: 'Email', + }, + { + accessorKey: 'contact_name', + header: 'Contact', + cell: ({ row }) => ( +
+

{row.original.contact_name}

+

{row.original.contact_phone_number}

+
+ ), + }, + { + accessorKey: 'gst', + header: 'GST', + cell: ({ row }) => row.original.gst || '-', + }, + { + accessorKey: 'created_at', + header: 'Created', + cell: ({ row }) => formatDate(row.original.created_at), + }, + { + accessorKey: 'is_active', + header: 'Status', + cell: ({ row }) => ( + + {row.original.is_active ? 'Active' : 'Inactive'} + + ), + }, + ]; + + if (!canEdit && !canDelete) return columns; + + columns.push({ + id: 'actions', + header: () =>
Actions
, + cell: ({ row }) => { + const client = row.original; + return ( +
+ {canEdit ? ( + + ) : null} + {canDelete ? ( + + ) : null} +
+ ); + }, + }); + + return columns; + }, [canDelete, canEdit, onEdit, onToggleStatus, pendingClientId]); +} diff --git a/src/app/(modules)/clients/components/ClientFilters.tsx b/src/app/(modules)/clients/components/ClientFilters.tsx new file mode 100644 index 0000000..5672b76 --- /dev/null +++ b/src/app/(modules)/clients/components/ClientFilters.tsx @@ -0,0 +1,49 @@ +'use client'; + +import { Input } from '@/components/ui/input'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; +import type { ClientStatusFilter } from '../hooks/useClientFilters'; + +interface ClientFiltersProps { + searchTerm: string; + statusFilter: ClientStatusFilter; + onSearchChange: (value: string) => void; + onStatusChange: (value: ClientStatusFilter) => void; +} + +export function ClientFilters({ + searchTerm, + statusFilter, + onSearchChange, + onStatusChange, +}: ClientFiltersProps) { + return ( +
+ onSearchChange(event.target.value)} + placeholder="Search clients" + className="md:max-w-sm" + /> + +
+ ); +} diff --git a/src/app/(modules)/clients/components/ClientSheet.tsx b/src/app/(modules)/clients/components/ClientSheet.tsx new file mode 100644 index 0000000..273fe0d --- /dev/null +++ b/src/app/(modules)/clients/components/ClientSheet.tsx @@ -0,0 +1,152 @@ +'use client'; + +import type { ComponentProps } from 'react'; +import type { UseFormRegister } from 'react-hook-form'; +import { Loader2 } from 'lucide-react'; + +import { Button } from '@/components/ui/button'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import type { ClientFormValues } from '../hooks/useClientForm'; + +interface ClientSheetProps { + open: boolean; + onOpenChange: (open: boolean) => void; + clientId?: number; + register: UseFormRegister; + onSubmit: ComponentProps<'form'>['onSubmit']; + isSaving: boolean; +} + +export function ClientSheet({ + open, + onOpenChange, + clientId, + register, + onSubmit, + isSaving, +}: ClientSheetProps) { + return ( + + + + {clientId ? 'Edit Client' : 'Create Client'} + Manage company and primary contact details. + +
+
+
+ + +
+
+ + +
+
+ +
+
+ + +
+
+ + +
+
+ +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+ + +
+
+ + +
+
+ + +
+
+ + + + + +
+
+
+ ); +} diff --git a/src/app/(modules)/clients/components/ClientTable.tsx b/src/app/(modules)/clients/components/ClientTable.tsx new file mode 100644 index 0000000..bf36fb7 --- /dev/null +++ b/src/app/(modules)/clients/components/ClientTable.tsx @@ -0,0 +1,47 @@ +'use client'; + +import type { ReactNode } from 'react'; +import type { ColumnDef } from '@tanstack/react-table'; + +import { DataTable } from '@/components/data-table'; +import type { Client } from '@/types'; + +interface ClientTableProps { + columns: ColumnDef[]; + clients: Client[]; + isLoading: boolean; + toolbar: ReactNode; + skip: number; + limit: number; + total: number; + onPageChange: (skip: number) => void; +} + +export function ClientTable({ + columns, + clients, + isLoading, + toolbar, + skip, + limit, + total, + onPageChange, +}: ClientTableProps) { + return ( + undefined, + }} + /> + ); +} diff --git a/src/app/(modules)/clients/hooks/useClientFilters.ts b/src/app/(modules)/clients/hooks/useClientFilters.ts new file mode 100644 index 0000000..b2461c0 --- /dev/null +++ b/src/app/(modules)/clients/hooks/useClientFilters.ts @@ -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('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, + }; +} diff --git a/src/app/(modules)/clients/hooks/useClientForm.ts b/src/app/(modules)/clients/hooks/useClientForm.ts new file mode 100644 index 0000000..e251d22 --- /dev/null +++ b/src/app/(modules)/clients/hooks/useClientForm.ts @@ -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({ + 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, + }; +} diff --git a/src/app/(modules)/clients/hooks/useClientMutations.ts b/src/app/(modules)/clients/hooks/useClientMutations.ts new file mode 100644 index 0000000..4de6237 --- /dev/null +++ b/src/app/(modules)/clients/hooks/useClientMutations.ts @@ -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, + }; +} diff --git a/src/app/(modules)/clients/hooks/useClientQueries.ts b/src/app/(modules)/clients/hooks/useClientQueries.ts new file mode 100644 index 0000000..5f53c41 --- /dev/null +++ b/src/app/(modules)/clients/hooks/useClientQueries.ts @@ -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), + }); +} diff --git a/src/app/(modules)/clients/page.tsx b/src/app/(modules)/clients/page.tsx new file mode 100644 index 0000000..6f70c46 --- /dev/null +++ b/src/app/(modules)/clients/page.tsx @@ -0,0 +1,130 @@ +'use client'; + +import { useCallback, useMemo, useState } from 'react'; +import { Building2, Plus } from 'lucide-react'; + +import { PageHeader } from '@/components/page-header'; +import { PoweredBy } from '@/components/powered-by'; +import { Button } from '@/components/ui/button'; +import { PERMISSIONS } from '@/constants/permissions'; +import { PermissionGuard } from '@/guards'; +import type { Client } from '@/types'; + +import { useClientColumns } from './components/ClientColumns'; +import { ClientFilters } from './components/ClientFilters'; +import { ClientSheet } from './components/ClientSheet'; +import { ClientTable } from './components/ClientTable'; +import { useClientFilters } from './hooks/useClientFilters'; +import { useClientForm } from './hooks/useClientForm'; +import { useClientStatusMutation } from './hooks/useClientMutations'; +import { useClientsQuery } from './hooks/useClientQueries'; + +export default function ClientsPage() { + const [isSheetOpen, setIsSheetOpen] = useState(false); + const { + skip, + setSkip, + limit, + searchTerm, + debouncedSearchTerm, + setSearchTerm, + statusFilter, + setStatusFilter, + } = useClientFilters(); + + const clientsQuery = useClientsQuery({ + skip, + limit, + searchTerm: debouncedSearchTerm, + statusFilter, + }); + const clientForm = useClientForm({ + onSaved: () => setIsSheetOpen(false), + }); + const { openCreate: prepareCreateClient, openEdit: prepareEditClient } = clientForm; + const { register, handleSubmit, clientId, isSaving } = clientForm; + const statusMutation = useClientStatusMutation(); + const { mutate: updateClientStatus, pendingClientId } = statusMutation; + + const openCreate = useCallback(() => { + prepareCreateClient(); + setIsSheetOpen(true); + }, [prepareCreateClient]); + + const openEdit = useCallback( + (client: Client) => { + prepareEditClient(client); + setIsSheetOpen(true); + }, + [prepareEditClient], + ); + + const toggleStatus = useCallback( + (client: Client) => { + updateClientStatus(client); + }, + [updateClientStatus], + ); + + const columns = useClientColumns({ + onEdit: openEdit, + onToggleStatus: toggleStatus, + pendingClientId, + }); + + const total = clientsQuery.data?.total ?? 0; + const clients = clientsQuery.data?.items ?? []; + const toolbar = useMemo( + () => ( + + ), + [searchTerm, setSearchTerm, setStatusFilter, statusFilter], + ); + + return ( + <> +
+ + + + } + /> + + + + +
+ + + + ); +} diff --git a/src/app/(modules)/clients/queries/clientKeys.ts b/src/app/(modules)/clients/queries/clientKeys.ts new file mode 100644 index 0000000..3972489 --- /dev/null +++ b/src/app/(modules)/clients/queries/clientKeys.ts @@ -0,0 +1,7 @@ +import type { ClientListParams } from '@/types'; + +export const clientKeys = { + all: ['clients'] as const, + lists: () => [...clientKeys.all, 'list'] as const, + list: (params: ClientListParams) => [...clientKeys.lists(), params] as const, +}; diff --git a/src/config/app.routes.ts b/src/config/app.routes.ts index 0d7270a..2b1d271 100644 --- a/src/config/app.routes.ts +++ b/src/config/app.routes.ts @@ -15,6 +15,10 @@ export const appRoutes: AppRoute[] = [ path: ROUTES.USERS, permission: PERMISSIONS.USER.READ, }, + { + path: ROUTES.CLIENTS, + permission: PERMISSIONS.CLIENT.READ, + }, ]; export function getRoutePermission(pathname: string) { diff --git a/src/config/menu.config.ts b/src/config/menu.config.ts index 246f372..3415b75 100644 --- a/src/config/menu.config.ts +++ b/src/config/menu.config.ts @@ -7,6 +7,7 @@ import { Settings, ShieldCheck, Users, + Building2, } from 'lucide-react'; import type { ComponentType, SVGProps } from 'react'; @@ -63,6 +64,12 @@ export const menuItems: MenuItem[] = [ icon: Users, permission: PERMISSIONS.USER.READ, }, + { + title: 'Clients', + path: ROUTES.CLIENTS, + icon: Building2, + permission: PERMISSIONS.CLIENT.READ, + }, ], }, ]; diff --git a/src/constants/permissions.ts b/src/constants/permissions.ts index cb63963..d9958f7 100644 --- a/src/constants/permissions.ts +++ b/src/constants/permissions.ts @@ -1,7 +1,7 @@ export const PERMISSIONS = { ROLE: { CREATE: 'administration.roles.create', - READ: 'administration.roles.reads', + READ: 'administration.roles.read', UPDATE: 'administration.roles.update', DELETE: 'administration.roles.delete', }, @@ -11,6 +11,12 @@ export const PERMISSIONS = { UPDATE: 'administration.users.update', DELETE: 'administration.users.delete', }, + CLIENT: { + CREATE: 'administration.client.create', + READ: 'administration.client.read', + UPDATE: 'administration.client.update', + DELETE: 'administration.client.delete', + }, LOG: { VIEW: 'log.view', }, diff --git a/src/services/api/client.service.ts b/src/services/api/client.service.ts new file mode 100644 index 0000000..c5d2a29 --- /dev/null +++ b/src/services/api/client.service.ts @@ -0,0 +1,54 @@ +import axiosClient from '../axios/axios'; +import type { Client, ClientListParams, ClientListResponse, ClientRequest } from '@/types'; + +const CLIENTS_ENDPOINT = 'api/clients'; + +function toClientPayload(payload: ClientRequest) { + return { + name: payload.name, + email: payload.email, + landline_number: payload.landline_number, + address: payload.address, + gst: payload.gst ?? null, + pan: payload.pan ?? null, + tan: payload.tan ?? null, + contact_name: payload.contact_name, + contact_phone_number: payload.contact_phone_number, + contact_email: payload.contact_email, + is_active: payload.is_active ?? true, + }; +} + +export const clientService = { + getClients: async (params?: ClientListParams): Promise => { + const response = await axiosClient.get(CLIENTS_ENDPOINT, { + params: { + skip: params?.skip ?? 0, + limit: params?.limit ?? 10, + search_term: params?.search_term, + name: params?.name, + email: params?.email, + contact_name: params?.contact_name, + is_active: params?.is_active, + sort_by: params?.sort_by, + sort_order: params?.sort_order, + }, + }); + return response.data; + }, + + saveClient: async (payload: ClientRequest): Promise => { + const requestPayload = toClientPayload(payload); + const response = payload.id + ? await axiosClient.put(`${CLIENTS_ENDPOINT}/${payload.id}`, requestPayload) + : await axiosClient.post(CLIENTS_ENDPOINT, requestPayload); + return response.data; + }, + + updateClientStatus: async (id: number, isActive: boolean): Promise => { + const response = await axiosClient.patch(`${CLIENTS_ENDPOINT}/${id}/status`, { + is_active: isActive, + }); + return response.data; + }, +}; diff --git a/src/services/api/index.ts b/src/services/api/index.ts index a278d8d..698fd0d 100644 --- a/src/services/api/index.ts +++ b/src/services/api/index.ts @@ -9,3 +9,4 @@ export { projectDataService } from './project.service'; export * from './permission.service'; export * from './role.service'; export * from './user.service'; +export * from './client.service'; diff --git a/src/types/client.ts b/src/types/client.ts new file mode 100644 index 0000000..eb0db55 --- /dev/null +++ b/src/types/client.ts @@ -0,0 +1,50 @@ +import type { PaginationParams } from './common'; + +export interface Client { + id: number; + name: string; + email: string; + landline_number: string; + address: string; + gst: string | null; + pan: string | null; + tan: string | null; + contact_name: string; + contact_phone_number: string; + contact_email: string; + is_active: boolean; + created_at: string; + updated_at: string | null; +} + +export interface ClientRequest { + id?: number; + name: string; + email: string; + landline_number: string; + address: string; + gst?: string | null; + pan?: string | null; + tan?: string | null; + contact_name: string; + contact_phone_number: string; + contact_email: string; + is_active?: boolean; +} + +export interface ClientListParams extends PaginationParams { + search_term?: string; + name?: string; + email?: string; + contact_name?: string; + is_active?: boolean; + sort_by?: string; + sort_order?: string; +} + +export interface ClientListResponse { + items: Client[]; + total: number; + active_count?: number; + inactive_count?: number; +} diff --git a/src/types/index.ts b/src/types/index.ts index 1e52690..8215128 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -10,3 +10,4 @@ export * from './auth.type'; export * from './permission'; export * from './role'; export * from './user'; +export * from './client'; diff --git a/src/utils/routes.ts b/src/utils/routes.ts index 201820f..becb81a 100644 --- a/src/utils/routes.ts +++ b/src/utils/routes.ts @@ -6,6 +6,7 @@ export const ROUTES = { CHAINAGE: '/segment', ROLES: '/roles', USERS: '/users', + CLIENTS: '/clients', ACCESS: '/access', ACCOUNT: '/account', UPLOAD: '/upload',