T07b: frontend feature pages — companies + contacts + settings + audit + dashboard + search

- 11 new feature pages (CompaniesList/Detail/Form, ContactsList/Detail/Form,
  SettingsProfile/Roles/Users, AuditLog, GlobalSearchResults)
- 3 page updates (Dashboard with StatCard+ActivityFeed, Settings with tree nav+Outlet,
  TopBar with SearchDropdown)
- 13 new routes in routes/index.tsx
- i18n updates (de.json + en.json) with companies/contacts/settings/audit/search keys
- 12 new test files + 2 existing test fixes (TopBar, AppShell)
- 7 shared components (DataGrid, Tabs, SearchDropdown, CsvImportDialog, StatCard,
  ActivityFeed, UnsavedChangesGuard)
- 16 new API hooks in hooks.ts
- Verification: 141 tests pass, build succeeds, tsc --noEmit clean
This commit is contained in:
leocrm-bot
2026-06-29 11:01:39 +02:00
parent 22976abe92
commit 700b7a71ad
47 changed files with 4089 additions and 157 deletions
+275 -5
View File
@@ -1,5 +1,5 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { apiPost, apiGet, apiPatch, apiDelete } from './client';
import { apiPost, apiGet, apiPatch, apiDelete, apiClient } from './client';
import { useAuthStore } from '@/store/authStore';
export interface LoginPayload {
@@ -23,6 +23,57 @@ export interface PaginatedResponse<T> {
page_size: number;
}
export interface Company {
id: string;
name: string;
account_number?: string | null;
industry?: string | null;
phone?: string | null;
email?: string | null;
website?: string | null;
description?: string | null;
created_at?: string;
updated_at?: string;
}
export interface CompanyDetail extends Company {
contacts?: Contact[];
}
export interface Contact {
id: string;
first_name: string;
last_name: string;
email: string;
phone?: string | null;
position?: string | null;
company_ids?: string[];
created_at?: string;
updated_at?: string;
}
export interface ContactDetail extends Contact {
companies?: Company[];
}
export interface AuditLogEntry {
id: string;
timestamp: string;
user: string;
action: string;
entity: string;
entity_id?: string;
details?: string;
}
export interface SearchResult {
type: 'company' | 'contact';
id: string;
name: string;
description?: string;
url: string;
}
export function useLogin() {
const { setUser, setError } = useAuthStore();
return useMutation({
@@ -100,13 +151,126 @@ export function useUsers(page = 1, pageSize = 25) {
});
}
export function useCompanies(page = 1, pageSize = 25, search?: string) {
export function useUser(id?: string) {
return useQuery({
queryKey: ['users', id],
queryFn: () => apiGet<any>(`/users/${id}`),
enabled: !!id,
});
}
export function useCreateUser() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (data: any) => apiPost('/users', data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['users'] });
},
});
}
export function useUpdateUser() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ id, data }: { id: string; data: any }) =>
apiPatch(`/users/${id}`, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['users'] });
},
});
}
export function useDeleteUser() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: string) => apiDelete(`/users/${id}`),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['users'] });
},
});
}
export function useCompanies(page = 1, pageSize = 25, search?: string, industry?: string, sortBy?: string, sortOrder?: string) {
const params = new URLSearchParams({ page: String(page), page_size: String(pageSize) });
if (search) params.set('search', search);
if (industry) params.set('industry', industry);
if (sortBy) params.set('sort_by', sortBy);
if (sortOrder) params.set('sort_order', sortOrder);
return useQuery({
queryKey: ['companies', page, pageSize, search],
queryKey: ['companies', page, pageSize, search, industry, sortBy, sortOrder],
queryFn: () =>
apiGet<PaginatedResponse<any>>(`/companies?${params.toString()}`),
apiGet<PaginatedResponse<Company>>(`/companies?${params.toString()}`),
});
}
export function useCompany(id?: string) {
return useQuery({
queryKey: ['companies', id],
queryFn: () => apiGet<CompanyDetail>(`/companies/${id}`),
enabled: !!id,
});
}
export function useCreateCompany() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (data: Partial<Company>) => apiPost('/companies', data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['companies'] });
},
});
}
export function useUpdateCompany() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ id, data }: { id: string; data: Partial<Company> }) =>
apiPatch(`/companies/${id}`, data),
onSuccess: (_data, variables) => {
queryClient.invalidateQueries({ queryKey: ['companies'] });
queryClient.invalidateQueries({ queryKey: ['companies', variables.id] });
},
});
}
export function useDeleteCompany() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: string) => apiDelete(`/companies/${id}`),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['companies'] });
},
});
}
export function useCompanyExport(search?: string, industry?: string) {
return useMutation({
mutationFn: async () => {
const params = new URLSearchParams({ format: 'csv' });
if (search) params.set('search', search);
if (industry) params.set('industry', industry);
const response = await apiClient.get(`/companies/export?${params.toString()}`, {
responseType: 'blob',
});
return response.data;
},
});
}
export function useCompanyImport() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (file: File) => {
const formData = new FormData();
formData.append('file', file);
const response = await apiClient.post('/companies/import', formData, {
headers: { 'Content-Type': 'multipart/form-data' },
});
return response.data;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['companies'] });
},
});
}
@@ -116,7 +280,113 @@ export function useContacts(page = 1, pageSize = 25, search?: string) {
return useQuery({
queryKey: ['contacts', page, pageSize, search],
queryFn: () =>
apiGet<PaginatedResponse<any>>(`/contacts?${params.toString()}`),
apiGet<PaginatedResponse<Contact>>(`/contacts?${params.toString()}`),
});
}
export function useContact(id?: string) {
return useQuery({
queryKey: ['contacts', id],
queryFn: () => apiGet<ContactDetail>(`/contacts/${id}`),
enabled: !!id,
});
}
export function useCreateContact() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (data: Partial<Contact> & { company_ids?: string[] }) =>
apiPost('/contacts', data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['contacts'] });
},
});
}
export function useUpdateContact() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ id, data }: { id: string; data: Partial<Contact> & { company_ids?: string[] } }) =>
apiPatch(`/contacts/${id}`, data),
onSuccess: (_data, variables) => {
queryClient.invalidateQueries({ queryKey: ['contacts'] });
queryClient.invalidateQueries({ queryKey: ['contacts', variables.id] });
},
});
}
export function useDeleteContact() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: string) => apiDelete(`/contacts/${id}`),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['contacts'] });
},
});
}
export function useAuditLog(page = 1, pageSize = 25, filters?: { user?: string; action?: string; entity?: string; dateFrom?: string; dateTo?: string }) {
const params = new URLSearchParams({ page: String(page), page_size: String(pageSize) });
if (filters?.user) params.set('user', filters.user);
if (filters?.action) params.set('action', filters.action);
if (filters?.entity) params.set('entity', filters.entity);
if (filters?.dateFrom) params.set('date_from', filters.dateFrom);
if (filters?.dateTo) params.set('date_to', filters.dateTo);
return useQuery({
queryKey: ['auditLog', page, pageSize, filters],
queryFn: () =>
apiGet<PaginatedResponse<AuditLogEntry>>(`/audit?${params.toString()}`),
retry: false,
});
}
export function useGlobalSearch(query: string, entityTypes?: string[]) {
return useQuery({
queryKey: ['globalSearch', query, entityTypes],
queryFn: async (): Promise<SearchResult[]> => {
if (!query.trim()) return [];
const results: SearchResult[] = [];
const types = entityTypes && entityTypes.length > 0 ? entityTypes : ['company', 'contact'];
const tasks: Promise<void>[] = [];
if (types.includes('company')) {
tasks.push(
apiGet<PaginatedResponse<Company>>(`/companies?page=1&page_size=10&search=${encodeURIComponent(query)}`)
.then((data) => {
for (const item of data.items) {
results.push({
type: 'company',
id: item.id,
name: item.name,
description: item.industry || item.email || '',
url: `/companies/${item.id}`,
});
}
})
.catch(() => {})
);
}
if (types.includes('contact')) {
tasks.push(
apiGet<PaginatedResponse<Contact>>(`/contacts?page=1&page_size=10&search=${encodeURIComponent(query)}`)
.then((data) => {
for (const item of data.items) {
results.push({
type: 'contact',
id: item.id,
name: `${item.first_name} ${item.last_name}`,
description: item.email || item.phone || '',
url: `/contacts/${item.id}`,
});
}
})
.catch(() => {})
);
}
await Promise.all(tasks);
return results;
},
enabled: query.trim().length > 0,
staleTime: 30 * 1000,
});
}