feat(frontend): Add Reports and Settings modules (MGN-009, MGN-006)
Reports module: - types/reports.types.ts: Complete type definitions for definitions, executions, schedules - api/reports.api.ts: API clients for all report operations - hooks/useReports.ts: Custom hooks (useReportDefinitions, useReportExecutions, etc.) - ReportsPage.tsx: Main reports management with tabs - QuickReportsPage.tsx: Trial Balance and General Ledger quick reports Settings module: - types/settings.types.ts: Types for company, users, profile, audit logs - api/settings.api.ts: API clients for settings operations - hooks/useSettings.ts: Custom hooks (useCompany, useUsers, useProfile, etc.) - SettingsPage.tsx: Settings hub with navigation cards - UsersSettingsPage.tsx: User management page Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
07987788f8
commit
4eb8ee2699
1
src/features/reports/api/index.ts
Normal file
1
src/features/reports/api/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export * from './reports.api';
|
||||
226
src/features/reports/api/reports.api.ts
Normal file
226
src/features/reports/api/reports.api.ts
Normal file
@ -0,0 +1,226 @@
|
||||
import { api } from '@services/api/axios-instance';
|
||||
import type {
|
||||
ReportDefinition,
|
||||
ReportDefinitionCreateInput,
|
||||
ReportDefinitionUpdateInput,
|
||||
ReportDefinitionFilters,
|
||||
ReportDefinitionsResponse,
|
||||
ReportExecution,
|
||||
ExecuteReportInput,
|
||||
ReportExecutionFilters,
|
||||
ReportExecutionsResponse,
|
||||
ReportSchedule,
|
||||
ReportScheduleCreateInput,
|
||||
ReportScheduleUpdateInput,
|
||||
ReportScheduleFilters,
|
||||
ReportSchedulesResponse,
|
||||
TrialBalanceParams,
|
||||
TrialBalanceResult,
|
||||
GeneralLedgerParams,
|
||||
GeneralLedgerResult,
|
||||
ExportFormat,
|
||||
} from '../types';
|
||||
|
||||
const REPORTS_BASE = '/api/v1/reports';
|
||||
|
||||
// ============================================================================
|
||||
// Report Definitions API
|
||||
// ============================================================================
|
||||
|
||||
export const reportDefinitionsApi = {
|
||||
getAll: async (filters: ReportDefinitionFilters = {}): Promise<ReportDefinitionsResponse> => {
|
||||
const params = new URLSearchParams();
|
||||
if (filters.reportType) params.append('report_type', filters.reportType);
|
||||
if (filters.category) params.append('category', filters.category);
|
||||
if (filters.isSystem !== undefined) params.append('is_system', String(filters.isSystem));
|
||||
if (filters.search) params.append('search', filters.search);
|
||||
if (filters.page) params.append('page', String(filters.page));
|
||||
if (filters.limit) params.append('limit', String(filters.limit));
|
||||
|
||||
const response = await api.get<ReportDefinitionsResponse>(`${REPORTS_BASE}/definitions?${params}`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
getById: async (id: string): Promise<ReportDefinition> => {
|
||||
const response = await api.get<ReportDefinition>(`${REPORTS_BASE}/definitions/${id}`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
getByCode: async (code: string): Promise<ReportDefinition> => {
|
||||
const response = await api.get<ReportDefinition>(`${REPORTS_BASE}/definitions/code/${code}`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
create: async (data: ReportDefinitionCreateInput): Promise<ReportDefinition> => {
|
||||
const response = await api.post<ReportDefinition>(`${REPORTS_BASE}/definitions`, data);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
update: async (id: string, data: ReportDefinitionUpdateInput): Promise<ReportDefinition> => {
|
||||
const response = await api.patch<ReportDefinition>(`${REPORTS_BASE}/definitions/${id}`, data);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
delete: async (id: string): Promise<void> => {
|
||||
await api.delete(`${REPORTS_BASE}/definitions/${id}`);
|
||||
},
|
||||
|
||||
toggleActive: async (id: string): Promise<ReportDefinition> => {
|
||||
const response = await api.post<ReportDefinition>(`${REPORTS_BASE}/definitions/${id}/toggle`);
|
||||
return response.data;
|
||||
},
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// Report Executions API
|
||||
// ============================================================================
|
||||
|
||||
export const reportExecutionsApi = {
|
||||
execute: async (data: ExecuteReportInput): Promise<ReportExecution> => {
|
||||
const response = await api.post<ReportExecution>(`${REPORTS_BASE}/execute`, data);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
getById: async (id: string): Promise<ReportExecution> => {
|
||||
const response = await api.get<ReportExecution>(`${REPORTS_BASE}/executions/${id}`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
getRecent: async (filters: ReportExecutionFilters = {}): Promise<ReportExecutionsResponse> => {
|
||||
const params = new URLSearchParams();
|
||||
if (filters.definitionId) params.append('definition_id', filters.definitionId);
|
||||
if (filters.status) params.append('status', filters.status);
|
||||
if (filters.dateFrom) params.append('date_from', filters.dateFrom);
|
||||
if (filters.dateTo) params.append('date_to', filters.dateTo);
|
||||
if (filters.page) params.append('page', String(filters.page));
|
||||
if (filters.limit) params.append('limit', String(filters.limit));
|
||||
|
||||
const response = await api.get<ReportExecutionsResponse>(`${REPORTS_BASE}/executions?${params}`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
cancel: async (id: string): Promise<ReportExecution> => {
|
||||
const response = await api.post<ReportExecution>(`${REPORTS_BASE}/executions/${id}/cancel`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
download: async (id: string, format: ExportFormat): Promise<Blob> => {
|
||||
const response = await api.get<Blob>(`${REPORTS_BASE}/executions/${id}/download/${format}`, {
|
||||
responseType: 'blob',
|
||||
});
|
||||
return response.data;
|
||||
},
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// Report Schedules API
|
||||
// ============================================================================
|
||||
|
||||
export const reportSchedulesApi = {
|
||||
getAll: async (filters: ReportScheduleFilters = {}): Promise<ReportSchedulesResponse> => {
|
||||
const params = new URLSearchParams();
|
||||
if (filters.definitionId) params.append('definition_id', filters.definitionId);
|
||||
if (filters.isActive !== undefined) params.append('is_active', String(filters.isActive));
|
||||
if (filters.page) params.append('page', String(filters.page));
|
||||
if (filters.limit) params.append('limit', String(filters.limit));
|
||||
|
||||
const response = await api.get<ReportSchedulesResponse>(`${REPORTS_BASE}/schedules?${params}`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
getById: async (id: string): Promise<ReportSchedule> => {
|
||||
const response = await api.get<ReportSchedule>(`${REPORTS_BASE}/schedules/${id}`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
create: async (data: ReportScheduleCreateInput): Promise<ReportSchedule> => {
|
||||
const response = await api.post<ReportSchedule>(`${REPORTS_BASE}/schedules`, data);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
update: async (id: string, data: ReportScheduleUpdateInput): Promise<ReportSchedule> => {
|
||||
const response = await api.patch<ReportSchedule>(`${REPORTS_BASE}/schedules/${id}`, data);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
delete: async (id: string): Promise<void> => {
|
||||
await api.delete(`${REPORTS_BASE}/schedules/${id}`);
|
||||
},
|
||||
|
||||
toggle: async (id: string): Promise<ReportSchedule> => {
|
||||
const response = await api.patch<ReportSchedule>(`${REPORTS_BASE}/schedules/${id}/toggle`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
runNow: async (id: string): Promise<ReportExecution> => {
|
||||
const response = await api.post<ReportExecution>(`${REPORTS_BASE}/schedules/${id}/run`);
|
||||
return response.data;
|
||||
},
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// Quick Reports API
|
||||
// ============================================================================
|
||||
|
||||
export const quickReportsApi = {
|
||||
getTrialBalance: async (params: TrialBalanceParams): Promise<TrialBalanceResult> => {
|
||||
const queryParams = new URLSearchParams();
|
||||
queryParams.append('company_id', params.companyId);
|
||||
queryParams.append('date_from', params.dateFrom);
|
||||
queryParams.append('date_to', params.dateTo);
|
||||
if (params.includeZeroBalance !== undefined) {
|
||||
queryParams.append('include_zero', String(params.includeZeroBalance));
|
||||
}
|
||||
|
||||
const response = await api.get<TrialBalanceResult>(
|
||||
`${REPORTS_BASE}/quick/trial-balance?${queryParams}`
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
getGeneralLedger: async (params: GeneralLedgerParams): Promise<GeneralLedgerResult> => {
|
||||
const queryParams = new URLSearchParams();
|
||||
queryParams.append('company_id', params.companyId);
|
||||
queryParams.append('date_from', params.dateFrom);
|
||||
queryParams.append('date_to', params.dateTo);
|
||||
if (params.accountId) {
|
||||
queryParams.append('account_id', params.accountId);
|
||||
}
|
||||
|
||||
const response = await api.get<GeneralLedgerResult>(
|
||||
`${REPORTS_BASE}/quick/general-ledger?${queryParams}`
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
exportTrialBalance: async (params: TrialBalanceParams, format: ExportFormat): Promise<Blob> => {
|
||||
const queryParams = new URLSearchParams();
|
||||
queryParams.append('company_id', params.companyId);
|
||||
queryParams.append('date_from', params.dateFrom);
|
||||
queryParams.append('date_to', params.dateTo);
|
||||
queryParams.append('format', format);
|
||||
|
||||
const response = await api.get<Blob>(
|
||||
`${REPORTS_BASE}/quick/trial-balance/export?${queryParams}`,
|
||||
{ responseType: 'blob' }
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
exportGeneralLedger: async (params: GeneralLedgerParams, format: ExportFormat): Promise<Blob> => {
|
||||
const queryParams = new URLSearchParams();
|
||||
queryParams.append('company_id', params.companyId);
|
||||
queryParams.append('date_from', params.dateFrom);
|
||||
queryParams.append('date_to', params.dateTo);
|
||||
if (params.accountId) {
|
||||
queryParams.append('account_id', params.accountId);
|
||||
}
|
||||
queryParams.append('format', format);
|
||||
|
||||
const response = await api.get<Blob>(
|
||||
`${REPORTS_BASE}/quick/general-ledger/export?${queryParams}`,
|
||||
{ responseType: 'blob' }
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
};
|
||||
7
src/features/reports/hooks/index.ts
Normal file
7
src/features/reports/hooks/index.ts
Normal file
@ -0,0 +1,7 @@
|
||||
export {
|
||||
useReportDefinitions,
|
||||
useReportExecutions,
|
||||
useReportSchedules,
|
||||
useQuickReports,
|
||||
useReportExecution,
|
||||
} from './useReports';
|
||||
593
src/features/reports/hooks/useReports.ts
Normal file
593
src/features/reports/hooks/useReports.ts
Normal file
@ -0,0 +1,593 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import {
|
||||
reportDefinitionsApi,
|
||||
reportExecutionsApi,
|
||||
reportSchedulesApi,
|
||||
quickReportsApi,
|
||||
} from '../api';
|
||||
import type {
|
||||
ReportDefinition,
|
||||
ReportDefinitionCreateInput,
|
||||
ReportDefinitionUpdateInput,
|
||||
ReportDefinitionFilters,
|
||||
ReportExecution,
|
||||
ExecuteReportInput,
|
||||
ReportExecutionFilters,
|
||||
ReportSchedule,
|
||||
ReportScheduleCreateInput,
|
||||
ReportScheduleUpdateInput,
|
||||
ReportScheduleFilters,
|
||||
TrialBalanceParams,
|
||||
TrialBalanceResult,
|
||||
GeneralLedgerParams,
|
||||
GeneralLedgerResult,
|
||||
ExportFormat,
|
||||
} from '../types';
|
||||
|
||||
// ============================================================================
|
||||
// Report Definitions Hook
|
||||
// ============================================================================
|
||||
|
||||
interface UseReportDefinitionsOptions {
|
||||
initialFilters?: ReportDefinitionFilters;
|
||||
autoLoad?: boolean;
|
||||
}
|
||||
|
||||
interface UseReportDefinitionsReturn {
|
||||
definitions: ReportDefinition[];
|
||||
total: number;
|
||||
page: number;
|
||||
totalPages: number;
|
||||
isLoading: boolean;
|
||||
error: Error | null;
|
||||
filters: ReportDefinitionFilters;
|
||||
setFilters: (filters: ReportDefinitionFilters) => void;
|
||||
refresh: () => Promise<void>;
|
||||
getById: (id: string) => Promise<ReportDefinition>;
|
||||
getByCode: (code: string) => Promise<ReportDefinition>;
|
||||
create: (data: ReportDefinitionCreateInput) => Promise<ReportDefinition>;
|
||||
update: (id: string, data: ReportDefinitionUpdateInput) => Promise<ReportDefinition>;
|
||||
remove: (id: string) => Promise<void>;
|
||||
toggleActive: (id: string) => Promise<ReportDefinition>;
|
||||
}
|
||||
|
||||
export function useReportDefinitions(
|
||||
options: UseReportDefinitionsOptions = {}
|
||||
): UseReportDefinitionsReturn {
|
||||
const { initialFilters = {}, autoLoad = true } = options;
|
||||
|
||||
const [definitions, setDefinitions] = useState<ReportDefinition[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [page, setPage] = useState(1);
|
||||
const [totalPages, setTotalPages] = useState(0);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<Error | null>(null);
|
||||
const [filters, setFilters] = useState<ReportDefinitionFilters>(initialFilters);
|
||||
|
||||
const fetchDefinitions = useCallback(async () => {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const response = await reportDefinitionsApi.getAll(filters);
|
||||
setDefinitions(response.data);
|
||||
setTotal(response.total);
|
||||
setPage(response.page);
|
||||
setTotalPages(response.totalPages);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err : new Error('Error fetching report definitions'));
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [filters]);
|
||||
|
||||
useEffect(() => {
|
||||
if (autoLoad) {
|
||||
fetchDefinitions();
|
||||
}
|
||||
}, [autoLoad, fetchDefinitions]);
|
||||
|
||||
const getById = useCallback(async (id: string): Promise<ReportDefinition> => {
|
||||
return reportDefinitionsApi.getById(id);
|
||||
}, []);
|
||||
|
||||
const getByCode = useCallback(async (code: string): Promise<ReportDefinition> => {
|
||||
return reportDefinitionsApi.getByCode(code);
|
||||
}, []);
|
||||
|
||||
const create = useCallback(async (data: ReportDefinitionCreateInput): Promise<ReportDefinition> => {
|
||||
const result = await reportDefinitionsApi.create(data);
|
||||
await fetchDefinitions();
|
||||
return result;
|
||||
}, [fetchDefinitions]);
|
||||
|
||||
const update = useCallback(async (
|
||||
id: string,
|
||||
data: ReportDefinitionUpdateInput
|
||||
): Promise<ReportDefinition> => {
|
||||
const result = await reportDefinitionsApi.update(id, data);
|
||||
await fetchDefinitions();
|
||||
return result;
|
||||
}, [fetchDefinitions]);
|
||||
|
||||
const remove = useCallback(async (id: string): Promise<void> => {
|
||||
await reportDefinitionsApi.delete(id);
|
||||
await fetchDefinitions();
|
||||
}, [fetchDefinitions]);
|
||||
|
||||
const toggleActive = useCallback(async (id: string): Promise<ReportDefinition> => {
|
||||
const result = await reportDefinitionsApi.toggleActive(id);
|
||||
await fetchDefinitions();
|
||||
return result;
|
||||
}, [fetchDefinitions]);
|
||||
|
||||
return {
|
||||
definitions,
|
||||
total,
|
||||
page,
|
||||
totalPages,
|
||||
isLoading,
|
||||
error,
|
||||
filters,
|
||||
setFilters,
|
||||
refresh: fetchDefinitions,
|
||||
getById,
|
||||
getByCode,
|
||||
create,
|
||||
update,
|
||||
remove,
|
||||
toggleActive,
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Report Executions Hook
|
||||
// ============================================================================
|
||||
|
||||
interface UseReportExecutionsOptions {
|
||||
initialFilters?: ReportExecutionFilters;
|
||||
autoLoad?: boolean;
|
||||
pollInterval?: number;
|
||||
}
|
||||
|
||||
interface UseReportExecutionsReturn {
|
||||
executions: ReportExecution[];
|
||||
total: number;
|
||||
page: number;
|
||||
totalPages: number;
|
||||
isLoading: boolean;
|
||||
isExecuting: boolean;
|
||||
error: Error | null;
|
||||
filters: ReportExecutionFilters;
|
||||
setFilters: (filters: ReportExecutionFilters) => void;
|
||||
refresh: () => Promise<void>;
|
||||
execute: (input: ExecuteReportInput) => Promise<ReportExecution>;
|
||||
getById: (id: string) => Promise<ReportExecution>;
|
||||
cancel: (id: string) => Promise<ReportExecution>;
|
||||
download: (id: string, format: ExportFormat) => Promise<void>;
|
||||
}
|
||||
|
||||
export function useReportExecutions(
|
||||
options: UseReportExecutionsOptions = {}
|
||||
): UseReportExecutionsReturn {
|
||||
const { initialFilters = {}, autoLoad = true, pollInterval } = options;
|
||||
|
||||
const [executions, setExecutions] = useState<ReportExecution[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [page, setPage] = useState(1);
|
||||
const [totalPages, setTotalPages] = useState(0);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isExecuting, setIsExecuting] = useState(false);
|
||||
const [error, setError] = useState<Error | null>(null);
|
||||
const [filters, setFilters] = useState<ReportExecutionFilters>(initialFilters);
|
||||
|
||||
const fetchExecutions = useCallback(async () => {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const response = await reportExecutionsApi.getRecent(filters);
|
||||
setExecutions(response.data);
|
||||
setTotal(response.total);
|
||||
setPage(response.page);
|
||||
setTotalPages(response.totalPages);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err : new Error('Error fetching report executions'));
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [filters]);
|
||||
|
||||
useEffect(() => {
|
||||
if (autoLoad) {
|
||||
fetchExecutions();
|
||||
}
|
||||
}, [autoLoad, fetchExecutions]);
|
||||
|
||||
// Polling for running executions
|
||||
useEffect(() => {
|
||||
if (!pollInterval) return;
|
||||
|
||||
const hasRunning = executions.some(e => e.status === 'pending' || e.status === 'running');
|
||||
if (!hasRunning) return;
|
||||
|
||||
const interval = setInterval(fetchExecutions, pollInterval);
|
||||
return () => clearInterval(interval);
|
||||
}, [pollInterval, executions, fetchExecutions]);
|
||||
|
||||
const execute = useCallback(async (input: ExecuteReportInput): Promise<ReportExecution> => {
|
||||
setIsExecuting(true);
|
||||
setError(null);
|
||||
try {
|
||||
const result = await reportExecutionsApi.execute(input);
|
||||
await fetchExecutions();
|
||||
return result;
|
||||
} catch (err) {
|
||||
const error = err instanceof Error ? err : new Error('Error executing report');
|
||||
setError(error);
|
||||
throw error;
|
||||
} finally {
|
||||
setIsExecuting(false);
|
||||
}
|
||||
}, [fetchExecutions]);
|
||||
|
||||
const getById = useCallback(async (id: string): Promise<ReportExecution> => {
|
||||
return reportExecutionsApi.getById(id);
|
||||
}, []);
|
||||
|
||||
const cancel = useCallback(async (id: string): Promise<ReportExecution> => {
|
||||
const result = await reportExecutionsApi.cancel(id);
|
||||
await fetchExecutions();
|
||||
return result;
|
||||
}, [fetchExecutions]);
|
||||
|
||||
const download = useCallback(async (id: string, format: ExportFormat): Promise<void> => {
|
||||
try {
|
||||
const blob = await reportExecutionsApi.download(id, format);
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = `report-${id}.${format}`;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
window.URL.revokeObjectURL(url);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err : new Error('Error downloading report'));
|
||||
throw err;
|
||||
}
|
||||
}, []);
|
||||
|
||||
return {
|
||||
executions,
|
||||
total,
|
||||
page,
|
||||
totalPages,
|
||||
isLoading,
|
||||
isExecuting,
|
||||
error,
|
||||
filters,
|
||||
setFilters,
|
||||
refresh: fetchExecutions,
|
||||
execute,
|
||||
getById,
|
||||
cancel,
|
||||
download,
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Report Schedules Hook
|
||||
// ============================================================================
|
||||
|
||||
interface UseReportSchedulesOptions {
|
||||
initialFilters?: ReportScheduleFilters;
|
||||
autoLoad?: boolean;
|
||||
}
|
||||
|
||||
interface UseReportSchedulesReturn {
|
||||
schedules: ReportSchedule[];
|
||||
total: number;
|
||||
page: number;
|
||||
totalPages: number;
|
||||
isLoading: boolean;
|
||||
error: Error | null;
|
||||
filters: ReportScheduleFilters;
|
||||
setFilters: (filters: ReportScheduleFilters) => void;
|
||||
refresh: () => Promise<void>;
|
||||
getById: (id: string) => Promise<ReportSchedule>;
|
||||
create: (data: ReportScheduleCreateInput) => Promise<ReportSchedule>;
|
||||
update: (id: string, data: ReportScheduleUpdateInput) => Promise<ReportSchedule>;
|
||||
remove: (id: string) => Promise<void>;
|
||||
toggle: (id: string) => Promise<ReportSchedule>;
|
||||
runNow: (id: string) => Promise<ReportExecution>;
|
||||
}
|
||||
|
||||
export function useReportSchedules(
|
||||
options: UseReportSchedulesOptions = {}
|
||||
): UseReportSchedulesReturn {
|
||||
const { initialFilters = {}, autoLoad = true } = options;
|
||||
|
||||
const [schedules, setSchedules] = useState<ReportSchedule[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [page, setPage] = useState(1);
|
||||
const [totalPages, setTotalPages] = useState(0);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<Error | null>(null);
|
||||
const [filters, setFilters] = useState<ReportScheduleFilters>(initialFilters);
|
||||
|
||||
const fetchSchedules = useCallback(async () => {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const response = await reportSchedulesApi.getAll(filters);
|
||||
setSchedules(response.data);
|
||||
setTotal(response.total);
|
||||
setPage(response.page);
|
||||
setTotalPages(response.totalPages);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err : new Error('Error fetching report schedules'));
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [filters]);
|
||||
|
||||
useEffect(() => {
|
||||
if (autoLoad) {
|
||||
fetchSchedules();
|
||||
}
|
||||
}, [autoLoad, fetchSchedules]);
|
||||
|
||||
const getById = useCallback(async (id: string): Promise<ReportSchedule> => {
|
||||
return reportSchedulesApi.getById(id);
|
||||
}, []);
|
||||
|
||||
const create = useCallback(async (data: ReportScheduleCreateInput): Promise<ReportSchedule> => {
|
||||
const result = await reportSchedulesApi.create(data);
|
||||
await fetchSchedules();
|
||||
return result;
|
||||
}, [fetchSchedules]);
|
||||
|
||||
const update = useCallback(async (
|
||||
id: string,
|
||||
data: ReportScheduleUpdateInput
|
||||
): Promise<ReportSchedule> => {
|
||||
const result = await reportSchedulesApi.update(id, data);
|
||||
await fetchSchedules();
|
||||
return result;
|
||||
}, [fetchSchedules]);
|
||||
|
||||
const remove = useCallback(async (id: string): Promise<void> => {
|
||||
await reportSchedulesApi.delete(id);
|
||||
await fetchSchedules();
|
||||
}, [fetchSchedules]);
|
||||
|
||||
const toggle = useCallback(async (id: string): Promise<ReportSchedule> => {
|
||||
const result = await reportSchedulesApi.toggle(id);
|
||||
await fetchSchedules();
|
||||
return result;
|
||||
}, [fetchSchedules]);
|
||||
|
||||
const runNow = useCallback(async (id: string): Promise<ReportExecution> => {
|
||||
return reportSchedulesApi.runNow(id);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
schedules,
|
||||
total,
|
||||
page,
|
||||
totalPages,
|
||||
isLoading,
|
||||
error,
|
||||
filters,
|
||||
setFilters,
|
||||
refresh: fetchSchedules,
|
||||
getById,
|
||||
create,
|
||||
update,
|
||||
remove,
|
||||
toggle,
|
||||
runNow,
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Quick Reports Hook
|
||||
// ============================================================================
|
||||
|
||||
interface UseQuickReportsReturn {
|
||||
// Trial Balance
|
||||
trialBalance: TrialBalanceResult | null;
|
||||
isLoadingTrialBalance: boolean;
|
||||
trialBalanceError: Error | null;
|
||||
getTrialBalance: (params: TrialBalanceParams) => Promise<TrialBalanceResult>;
|
||||
exportTrialBalance: (params: TrialBalanceParams, format: ExportFormat) => Promise<void>;
|
||||
|
||||
// General Ledger
|
||||
generalLedger: GeneralLedgerResult | null;
|
||||
isLoadingGeneralLedger: boolean;
|
||||
generalLedgerError: Error | null;
|
||||
getGeneralLedger: (params: GeneralLedgerParams) => Promise<GeneralLedgerResult>;
|
||||
exportGeneralLedger: (params: GeneralLedgerParams, format: ExportFormat) => Promise<void>;
|
||||
|
||||
// General
|
||||
clearResults: () => void;
|
||||
}
|
||||
|
||||
export function useQuickReports(): UseQuickReportsReturn {
|
||||
const [trialBalance, setTrialBalance] = useState<TrialBalanceResult | null>(null);
|
||||
const [isLoadingTrialBalance, setIsLoadingTrialBalance] = useState(false);
|
||||
const [trialBalanceError, setTrialBalanceError] = useState<Error | null>(null);
|
||||
|
||||
const [generalLedger, setGeneralLedger] = useState<GeneralLedgerResult | null>(null);
|
||||
const [isLoadingGeneralLedger, setIsLoadingGeneralLedger] = useState(false);
|
||||
const [generalLedgerError, setGeneralLedgerError] = useState<Error | null>(null);
|
||||
|
||||
const getTrialBalance = useCallback(async (params: TrialBalanceParams): Promise<TrialBalanceResult> => {
|
||||
setIsLoadingTrialBalance(true);
|
||||
setTrialBalanceError(null);
|
||||
try {
|
||||
const result = await quickReportsApi.getTrialBalance(params);
|
||||
setTrialBalance(result);
|
||||
return result;
|
||||
} catch (err) {
|
||||
const error = err instanceof Error ? err : new Error('Error fetching trial balance');
|
||||
setTrialBalanceError(error);
|
||||
throw error;
|
||||
} finally {
|
||||
setIsLoadingTrialBalance(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const exportTrialBalance = useCallback(async (
|
||||
params: TrialBalanceParams,
|
||||
format: ExportFormat
|
||||
): Promise<void> => {
|
||||
try {
|
||||
const blob = await quickReportsApi.exportTrialBalance(params, format);
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = `trial-balance-${params.dateFrom}-${params.dateTo}.${format}`;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
window.URL.revokeObjectURL(url);
|
||||
} catch (err) {
|
||||
throw err instanceof Error ? err : new Error('Error exporting trial balance');
|
||||
}
|
||||
}, []);
|
||||
|
||||
const getGeneralLedger = useCallback(async (params: GeneralLedgerParams): Promise<GeneralLedgerResult> => {
|
||||
setIsLoadingGeneralLedger(true);
|
||||
setGeneralLedgerError(null);
|
||||
try {
|
||||
const result = await quickReportsApi.getGeneralLedger(params);
|
||||
setGeneralLedger(result);
|
||||
return result;
|
||||
} catch (err) {
|
||||
const error = err instanceof Error ? err : new Error('Error fetching general ledger');
|
||||
setGeneralLedgerError(error);
|
||||
throw error;
|
||||
} finally {
|
||||
setIsLoadingGeneralLedger(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const exportGeneralLedger = useCallback(async (
|
||||
params: GeneralLedgerParams,
|
||||
format: ExportFormat
|
||||
): Promise<void> => {
|
||||
try {
|
||||
const blob = await quickReportsApi.exportGeneralLedger(params, format);
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = `general-ledger-${params.dateFrom}-${params.dateTo}.${format}`;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
window.URL.revokeObjectURL(url);
|
||||
} catch (err) {
|
||||
throw err instanceof Error ? err : new Error('Error exporting general ledger');
|
||||
}
|
||||
}, []);
|
||||
|
||||
const clearResults = useCallback(() => {
|
||||
setTrialBalance(null);
|
||||
setGeneralLedger(null);
|
||||
setTrialBalanceError(null);
|
||||
setGeneralLedgerError(null);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
trialBalance,
|
||||
isLoadingTrialBalance,
|
||||
trialBalanceError,
|
||||
getTrialBalance,
|
||||
exportTrialBalance,
|
||||
generalLedger,
|
||||
isLoadingGeneralLedger,
|
||||
generalLedgerError,
|
||||
getGeneralLedger,
|
||||
exportGeneralLedger,
|
||||
clearResults,
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Single Report Execution Hook (for tracking a specific execution)
|
||||
// ============================================================================
|
||||
|
||||
interface UseReportExecutionOptions {
|
||||
executionId: string;
|
||||
pollInterval?: number;
|
||||
}
|
||||
|
||||
interface UseReportExecutionReturn {
|
||||
execution: ReportExecution | null;
|
||||
isLoading: boolean;
|
||||
error: Error | null;
|
||||
refresh: () => Promise<void>;
|
||||
cancel: () => Promise<void>;
|
||||
download: (format: ExportFormat) => Promise<void>;
|
||||
}
|
||||
|
||||
export function useReportExecution(
|
||||
options: UseReportExecutionOptions
|
||||
): UseReportExecutionReturn {
|
||||
const { executionId, pollInterval = 2000 } = options;
|
||||
|
||||
const [execution, setExecution] = useState<ReportExecution | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<Error | null>(null);
|
||||
|
||||
const fetchExecution = useCallback(async () => {
|
||||
try {
|
||||
const result = await reportExecutionsApi.getById(executionId);
|
||||
setExecution(result);
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err : new Error('Error fetching execution'));
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [executionId]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchExecution();
|
||||
}, [fetchExecution]);
|
||||
|
||||
// Poll while execution is running
|
||||
useEffect(() => {
|
||||
if (!execution) return;
|
||||
if (execution.status !== 'pending' && execution.status !== 'running') return;
|
||||
|
||||
const interval = setInterval(fetchExecution, pollInterval);
|
||||
return () => clearInterval(interval);
|
||||
}, [execution, pollInterval, fetchExecution]);
|
||||
|
||||
const cancel = useCallback(async () => {
|
||||
await reportExecutionsApi.cancel(executionId);
|
||||
await fetchExecution();
|
||||
}, [executionId, fetchExecution]);
|
||||
|
||||
const download = useCallback(async (format: ExportFormat) => {
|
||||
const blob = await reportExecutionsApi.download(executionId, format);
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = `report-${executionId}.${format}`;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
window.URL.revokeObjectURL(url);
|
||||
}, [executionId]);
|
||||
|
||||
return {
|
||||
execution,
|
||||
isLoading,
|
||||
error,
|
||||
refresh: fetchExecution,
|
||||
cancel,
|
||||
download,
|
||||
};
|
||||
}
|
||||
10
src/features/reports/index.ts
Normal file
10
src/features/reports/index.ts
Normal file
@ -0,0 +1,10 @@
|
||||
// Reports Feature - Barrel Export
|
||||
|
||||
// Types
|
||||
export * from './types';
|
||||
|
||||
// API
|
||||
export * from './api';
|
||||
|
||||
// Hooks
|
||||
export * from './hooks';
|
||||
1
src/features/reports/types/index.ts
Normal file
1
src/features/reports/types/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export * from './reports.types';
|
||||
314
src/features/reports/types/reports.types.ts
Normal file
314
src/features/reports/types/reports.types.ts
Normal file
@ -0,0 +1,314 @@
|
||||
// Reports Types - Definitions, Executions, Schedules
|
||||
|
||||
// ============================================================================
|
||||
// Report Definition Types
|
||||
// ============================================================================
|
||||
|
||||
export type ReportType = 'financial' | 'accounting' | 'tax' | 'management' | 'custom';
|
||||
export type ExecutionStatus = 'pending' | 'running' | 'completed' | 'failed' | 'cancelled';
|
||||
export type DeliveryMethod = 'none' | 'email' | 'storage' | 'webhook';
|
||||
export type ExportFormat = 'pdf' | 'excel' | 'csv';
|
||||
|
||||
export interface ParameterSchema {
|
||||
type: 'string' | 'number' | 'date' | 'boolean' | 'select';
|
||||
label: string;
|
||||
required?: boolean;
|
||||
default?: any;
|
||||
options?: { value: string; label: string }[];
|
||||
min?: number;
|
||||
max?: number;
|
||||
}
|
||||
|
||||
export interface ColumnConfig {
|
||||
key: string;
|
||||
header: string;
|
||||
type: 'text' | 'number' | 'currency' | 'date' | 'percentage';
|
||||
width?: number;
|
||||
align?: 'left' | 'center' | 'right';
|
||||
format?: string;
|
||||
aggregation?: 'sum' | 'avg' | 'count' | 'min' | 'max';
|
||||
}
|
||||
|
||||
export interface ReportDefinition {
|
||||
id: string;
|
||||
tenantId: string;
|
||||
code: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
reportType: ReportType;
|
||||
category?: string;
|
||||
baseQuery?: string;
|
||||
queryFunction?: string;
|
||||
parametersSchema: Record<string, ParameterSchema>;
|
||||
columnsConfig: ColumnConfig[];
|
||||
groupingOptions: string[];
|
||||
totalsConfig: Record<string, any>;
|
||||
exportFormats: ExportFormat[];
|
||||
pdfTemplate?: string;
|
||||
xlsxTemplate?: string;
|
||||
isSystem: boolean;
|
||||
isActive: boolean;
|
||||
requiredPermissions: string[];
|
||||
version: number;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface ReportDefinitionCreateInput {
|
||||
code: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
reportType?: ReportType;
|
||||
category?: string;
|
||||
baseQuery?: string;
|
||||
queryFunction?: string;
|
||||
parametersSchema?: Record<string, ParameterSchema>;
|
||||
columnsConfig?: ColumnConfig[];
|
||||
exportFormats?: ExportFormat[];
|
||||
requiredPermissions?: string[];
|
||||
}
|
||||
|
||||
export interface ReportDefinitionUpdateInput extends Partial<ReportDefinitionCreateInput> {
|
||||
isActive?: boolean;
|
||||
}
|
||||
|
||||
export interface ReportDefinitionFilters {
|
||||
reportType?: ReportType;
|
||||
category?: string;
|
||||
isSystem?: boolean;
|
||||
search?: string;
|
||||
page?: number;
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Report Execution Types
|
||||
// ============================================================================
|
||||
|
||||
export interface ReportExecution {
|
||||
id: string;
|
||||
tenantId: string;
|
||||
definitionId: string;
|
||||
definitionName?: string;
|
||||
definitionCode?: string;
|
||||
parameters: Record<string, any>;
|
||||
status: ExecutionStatus;
|
||||
startedAt?: string;
|
||||
completedAt?: string;
|
||||
executionTimeMs?: number;
|
||||
rowCount?: number;
|
||||
resultData: any[];
|
||||
resultSummary?: Record<string, any>;
|
||||
outputFiles: OutputFile[];
|
||||
errorMessage?: string;
|
||||
errorDetails?: Record<string, any>;
|
||||
requestedBy: string;
|
||||
requestedByName?: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface OutputFile {
|
||||
format: ExportFormat;
|
||||
filename: string;
|
||||
url?: string;
|
||||
size?: number;
|
||||
generatedAt: string;
|
||||
}
|
||||
|
||||
export interface ExecuteReportInput {
|
||||
definitionId: string;
|
||||
parameters: Record<string, any>;
|
||||
exportFormats?: ExportFormat[];
|
||||
}
|
||||
|
||||
export interface ReportExecutionFilters {
|
||||
definitionId?: string;
|
||||
status?: ExecutionStatus;
|
||||
dateFrom?: string;
|
||||
dateTo?: string;
|
||||
page?: number;
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Report Schedule Types
|
||||
// ============================================================================
|
||||
|
||||
export interface ReportSchedule {
|
||||
id: string;
|
||||
tenantId: string;
|
||||
definitionId: string;
|
||||
definitionName?: string;
|
||||
companyId?: string;
|
||||
name: string;
|
||||
defaultParameters: Record<string, any>;
|
||||
cronExpression: string;
|
||||
timezone: string;
|
||||
isActive: boolean;
|
||||
lastExecutionId?: string;
|
||||
lastRunAt?: string;
|
||||
nextRunAt?: string;
|
||||
deliveryMethod: DeliveryMethod;
|
||||
deliveryConfig: DeliveryConfig;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface DeliveryConfig {
|
||||
recipients?: string[];
|
||||
subject?: string;
|
||||
body?: string;
|
||||
storagePath?: string;
|
||||
webhookUrl?: string;
|
||||
}
|
||||
|
||||
export interface ReportScheduleCreateInput {
|
||||
definitionId: string;
|
||||
name: string;
|
||||
companyId?: string;
|
||||
defaultParameters?: Record<string, any>;
|
||||
cronExpression: string;
|
||||
timezone?: string;
|
||||
deliveryMethod?: DeliveryMethod;
|
||||
deliveryConfig?: DeliveryConfig;
|
||||
}
|
||||
|
||||
export interface ReportScheduleUpdateInput extends Partial<ReportScheduleCreateInput> {
|
||||
isActive?: boolean;
|
||||
}
|
||||
|
||||
export interface ReportScheduleFilters {
|
||||
definitionId?: string;
|
||||
isActive?: boolean;
|
||||
page?: number;
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Quick Report Types
|
||||
// ============================================================================
|
||||
|
||||
export interface TrialBalanceParams {
|
||||
companyId: string;
|
||||
dateFrom: string;
|
||||
dateTo: string;
|
||||
includeZeroBalance?: boolean;
|
||||
}
|
||||
|
||||
export interface TrialBalanceRow {
|
||||
accountCode: string;
|
||||
accountName: string;
|
||||
accountType: string;
|
||||
openingDebit: number;
|
||||
openingCredit: number;
|
||||
periodDebit: number;
|
||||
periodCredit: number;
|
||||
closingDebit: number;
|
||||
closingCredit: number;
|
||||
}
|
||||
|
||||
export interface TrialBalanceResult {
|
||||
rows: TrialBalanceRow[];
|
||||
totals: {
|
||||
openingDebit: number;
|
||||
openingCredit: number;
|
||||
periodDebit: number;
|
||||
periodCredit: number;
|
||||
closingDebit: number;
|
||||
closingCredit: number;
|
||||
};
|
||||
parameters: TrialBalanceParams;
|
||||
generatedAt: string;
|
||||
}
|
||||
|
||||
export interface GeneralLedgerParams {
|
||||
companyId: string;
|
||||
accountId?: string;
|
||||
dateFrom: string;
|
||||
dateTo: string;
|
||||
}
|
||||
|
||||
export interface GeneralLedgerRow {
|
||||
date: string;
|
||||
journalEntryNumber: string;
|
||||
description: string;
|
||||
reference?: string;
|
||||
debit: number;
|
||||
credit: number;
|
||||
balance: number;
|
||||
}
|
||||
|
||||
export interface GeneralLedgerResult {
|
||||
accountCode: string;
|
||||
accountName: string;
|
||||
rows: GeneralLedgerRow[];
|
||||
openingBalance: number;
|
||||
closingBalance: number;
|
||||
totalDebit: number;
|
||||
totalCredit: number;
|
||||
parameters: GeneralLedgerParams;
|
||||
generatedAt: string;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Response Types
|
||||
// ============================================================================
|
||||
|
||||
export interface ReportDefinitionsResponse {
|
||||
data: ReportDefinition[];
|
||||
total: number;
|
||||
page: number;
|
||||
limit: number;
|
||||
totalPages: number;
|
||||
}
|
||||
|
||||
export interface ReportExecutionsResponse {
|
||||
data: ReportExecution[];
|
||||
total: number;
|
||||
page: number;
|
||||
limit: number;
|
||||
totalPages: number;
|
||||
}
|
||||
|
||||
export interface ReportSchedulesResponse {
|
||||
data: ReportSchedule[];
|
||||
total: number;
|
||||
page: number;
|
||||
limit: number;
|
||||
totalPages: number;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Report Category Constants
|
||||
// ============================================================================
|
||||
|
||||
export const REPORT_CATEGORIES = {
|
||||
FINANCIAL: 'financial',
|
||||
SALES: 'sales',
|
||||
INVENTORY: 'inventory',
|
||||
PURCHASES: 'purchases',
|
||||
TAX: 'tax',
|
||||
HR: 'hr',
|
||||
CUSTOM: 'custom',
|
||||
} as const;
|
||||
|
||||
export const REPORT_TYPE_LABELS: Record<ReportType, string> = {
|
||||
financial: 'Financiero',
|
||||
accounting: 'Contable',
|
||||
tax: 'Fiscal',
|
||||
management: 'Gerencial',
|
||||
custom: 'Personalizado',
|
||||
};
|
||||
|
||||
export const EXECUTION_STATUS_LABELS: Record<ExecutionStatus, string> = {
|
||||
pending: 'Pendiente',
|
||||
running: 'Ejecutando',
|
||||
completed: 'Completado',
|
||||
failed: 'Fallido',
|
||||
cancelled: 'Cancelado',
|
||||
};
|
||||
|
||||
export const DELIVERY_METHOD_LABELS: Record<DeliveryMethod, string> = {
|
||||
none: 'Sin entrega',
|
||||
email: 'Correo electronico',
|
||||
storage: 'Almacenamiento',
|
||||
webhook: 'Webhook',
|
||||
};
|
||||
1
src/features/settings/api/index.ts
Normal file
1
src/features/settings/api/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export * from './settings.api';
|
||||
199
src/features/settings/api/settings.api.ts
Normal file
199
src/features/settings/api/settings.api.ts
Normal file
@ -0,0 +1,199 @@
|
||||
import { api } from '@services/api/axios-instance';
|
||||
import type {
|
||||
Company,
|
||||
CompanyUpdateInput,
|
||||
User,
|
||||
UserCreateInput,
|
||||
UserUpdateInput,
|
||||
UsersFilters,
|
||||
UsersResponse,
|
||||
Profile,
|
||||
ProfileUpdateInput,
|
||||
ChangePasswordInput,
|
||||
SystemSettings,
|
||||
SystemSettingUpdateInput,
|
||||
AuditLog,
|
||||
AuditLogFilters,
|
||||
AuditLogsResponse,
|
||||
} from '../types';
|
||||
|
||||
const SETTINGS_BASE = '/api/v1/settings';
|
||||
|
||||
// ============================================================================
|
||||
// Company API
|
||||
// ============================================================================
|
||||
|
||||
export const companyApi = {
|
||||
get: async (): Promise<Company> => {
|
||||
const response = await api.get<Company>(`${SETTINGS_BASE}/company`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
update: async (data: CompanyUpdateInput): Promise<Company> => {
|
||||
const response = await api.patch<Company>(`${SETTINGS_BASE}/company`, data);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
uploadLogo: async (file: File): Promise<{ url: string }> => {
|
||||
const formData = new FormData();
|
||||
formData.append('logo', file);
|
||||
const response = await api.post<{ url: string }>(`${SETTINGS_BASE}/company/logo`, formData, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' },
|
||||
});
|
||||
return response.data;
|
||||
},
|
||||
|
||||
removeLogo: async (): Promise<void> => {
|
||||
await api.delete(`${SETTINGS_BASE}/company/logo`);
|
||||
},
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// Users API
|
||||
// ============================================================================
|
||||
|
||||
export const usersApi = {
|
||||
getAll: async (filters: UsersFilters = {}): Promise<UsersResponse> => {
|
||||
const params = new URLSearchParams();
|
||||
if (filters.page) params.append('page', String(filters.page));
|
||||
if (filters.limit) params.append('limit', String(filters.limit));
|
||||
if (filters.search) params.append('search', filters.search);
|
||||
if (filters.role) params.append('role', filters.role);
|
||||
if (filters.isActive !== undefined) params.append('is_active', String(filters.isActive));
|
||||
|
||||
const response = await api.get<UsersResponse>(`${SETTINGS_BASE}/users?${params}`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
getById: async (id: string): Promise<User> => {
|
||||
const response = await api.get<User>(`${SETTINGS_BASE}/users/${id}`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
create: async (data: UserCreateInput): Promise<User> => {
|
||||
const response = await api.post<User>(`${SETTINGS_BASE}/users`, data);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
update: async (id: string, data: UserUpdateInput): Promise<User> => {
|
||||
const response = await api.patch<User>(`${SETTINGS_BASE}/users/${id}`, data);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
delete: async (id: string): Promise<void> => {
|
||||
await api.delete(`${SETTINGS_BASE}/users/${id}`);
|
||||
},
|
||||
|
||||
toggleActive: async (id: string): Promise<User> => {
|
||||
const response = await api.post<User>(`${SETTINGS_BASE}/users/${id}/toggle-active`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
resetPassword: async (id: string, newPassword: string): Promise<void> => {
|
||||
await api.post(`${SETTINGS_BASE}/users/${id}/reset-password`, { password: newPassword });
|
||||
},
|
||||
|
||||
resendInvitation: async (id: string): Promise<void> => {
|
||||
await api.post(`${SETTINGS_BASE}/users/${id}/resend-invitation`);
|
||||
},
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// Profile API (Current User)
|
||||
// ============================================================================
|
||||
|
||||
export const profileApi = {
|
||||
get: async (): Promise<Profile> => {
|
||||
const response = await api.get<Profile>(`${SETTINGS_BASE}/profile`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
update: async (data: ProfileUpdateInput): Promise<Profile> => {
|
||||
const response = await api.patch<Profile>(`${SETTINGS_BASE}/profile`, data);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
changePassword: async (data: ChangePasswordInput): Promise<void> => {
|
||||
await api.post(`${SETTINGS_BASE}/profile/change-password`, data);
|
||||
},
|
||||
|
||||
uploadAvatar: async (file: File): Promise<{ url: string }> => {
|
||||
const formData = new FormData();
|
||||
formData.append('avatar', file);
|
||||
const response = await api.post<{ url: string }>(`${SETTINGS_BASE}/profile/avatar`, formData, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' },
|
||||
});
|
||||
return response.data;
|
||||
},
|
||||
|
||||
removeAvatar: async (): Promise<void> => {
|
||||
await api.delete(`${SETTINGS_BASE}/profile/avatar`);
|
||||
},
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// System Settings API
|
||||
// ============================================================================
|
||||
|
||||
export const systemSettingsApi = {
|
||||
getAll: async (module?: string): Promise<SystemSettings[]> => {
|
||||
const params = module ? `?module=${module}` : '';
|
||||
const response = await api.get<SystemSettings[]>(`${SETTINGS_BASE}/system${params}`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
get: async (module: string, key: string): Promise<SystemSettings> => {
|
||||
const response = await api.get<SystemSettings>(`${SETTINGS_BASE}/system/${module}/${key}`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
update: async (module: string, key: string, data: SystemSettingUpdateInput): Promise<SystemSettings> => {
|
||||
const response = await api.patch<SystemSettings>(`${SETTINGS_BASE}/system/${module}/${key}`, data);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
reset: async (module: string, key: string): Promise<SystemSettings> => {
|
||||
const response = await api.post<SystemSettings>(`${SETTINGS_BASE}/system/${module}/${key}/reset`);
|
||||
return response.data;
|
||||
},
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// Audit Logs API
|
||||
// ============================================================================
|
||||
|
||||
export const auditLogsApi = {
|
||||
getAll: async (filters: AuditLogFilters = {}): Promise<AuditLogsResponse> => {
|
||||
const params = new URLSearchParams();
|
||||
if (filters.userId) params.append('user_id', filters.userId);
|
||||
if (filters.action) params.append('action', filters.action);
|
||||
if (filters.entityType) params.append('entity_type', filters.entityType);
|
||||
if (filters.entityId) params.append('entity_id', filters.entityId);
|
||||
if (filters.dateFrom) params.append('date_from', filters.dateFrom);
|
||||
if (filters.dateTo) params.append('date_to', filters.dateTo);
|
||||
if (filters.page) params.append('page', String(filters.page));
|
||||
if (filters.limit) params.append('limit', String(filters.limit));
|
||||
|
||||
const response = await api.get<AuditLogsResponse>(`${SETTINGS_BASE}/audit-logs?${params}`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
getById: async (id: string): Promise<AuditLog> => {
|
||||
const response = await api.get<AuditLog>(`${SETTINGS_BASE}/audit-logs/${id}`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
export: async (filters: AuditLogFilters = {}): Promise<Blob> => {
|
||||
const params = new URLSearchParams();
|
||||
if (filters.userId) params.append('user_id', filters.userId);
|
||||
if (filters.action) params.append('action', filters.action);
|
||||
if (filters.entityType) params.append('entity_type', filters.entityType);
|
||||
if (filters.dateFrom) params.append('date_from', filters.dateFrom);
|
||||
if (filters.dateTo) params.append('date_to', filters.dateTo);
|
||||
|
||||
const response = await api.get<Blob>(`${SETTINGS_BASE}/audit-logs/export?${params}`, {
|
||||
responseType: 'blob',
|
||||
});
|
||||
return response.data;
|
||||
},
|
||||
};
|
||||
7
src/features/settings/hooks/index.ts
Normal file
7
src/features/settings/hooks/index.ts
Normal file
@ -0,0 +1,7 @@
|
||||
export {
|
||||
useCompany,
|
||||
useUsers,
|
||||
useProfile,
|
||||
useSystemSettings,
|
||||
useAuditLogs,
|
||||
} from './useSettings';
|
||||
492
src/features/settings/hooks/useSettings.ts
Normal file
492
src/features/settings/hooks/useSettings.ts
Normal file
@ -0,0 +1,492 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import {
|
||||
companyApi,
|
||||
usersApi,
|
||||
profileApi,
|
||||
systemSettingsApi,
|
||||
auditLogsApi,
|
||||
} from '../api';
|
||||
import type {
|
||||
Company,
|
||||
CompanyUpdateInput,
|
||||
User,
|
||||
UserCreateInput,
|
||||
UserUpdateInput,
|
||||
UsersFilters,
|
||||
Profile,
|
||||
ProfileUpdateInput,
|
||||
ChangePasswordInput,
|
||||
SystemSettings,
|
||||
SystemSettingUpdateInput,
|
||||
AuditLog,
|
||||
AuditLogFilters,
|
||||
} from '../types';
|
||||
|
||||
// ============================================================================
|
||||
// Company Hook
|
||||
// ============================================================================
|
||||
|
||||
interface UseCompanyReturn {
|
||||
company: Company | null;
|
||||
isLoading: boolean;
|
||||
isSaving: boolean;
|
||||
error: Error | null;
|
||||
refresh: () => Promise<void>;
|
||||
update: (data: CompanyUpdateInput) => Promise<Company>;
|
||||
uploadLogo: (file: File) => Promise<string>;
|
||||
removeLogo: () => Promise<void>;
|
||||
}
|
||||
|
||||
export function useCompany(): UseCompanyReturn {
|
||||
const [company, setCompany] = useState<Company | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [error, setError] = useState<Error | null>(null);
|
||||
|
||||
const fetchCompany = useCallback(async () => {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const data = await companyApi.get();
|
||||
setCompany(data);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err : new Error('Error fetching company'));
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchCompany();
|
||||
}, [fetchCompany]);
|
||||
|
||||
const update = useCallback(async (data: CompanyUpdateInput): Promise<Company> => {
|
||||
setIsSaving(true);
|
||||
setError(null);
|
||||
try {
|
||||
const result = await companyApi.update(data);
|
||||
setCompany(result);
|
||||
return result;
|
||||
} catch (err) {
|
||||
const error = err instanceof Error ? err : new Error('Error updating company');
|
||||
setError(error);
|
||||
throw error;
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const uploadLogo = useCallback(async (file: File): Promise<string> => {
|
||||
try {
|
||||
const { url } = await companyApi.uploadLogo(file);
|
||||
await fetchCompany();
|
||||
return url;
|
||||
} catch (err) {
|
||||
throw err instanceof Error ? err : new Error('Error uploading logo');
|
||||
}
|
||||
}, [fetchCompany]);
|
||||
|
||||
const removeLogo = useCallback(async (): Promise<void> => {
|
||||
try {
|
||||
await companyApi.removeLogo();
|
||||
await fetchCompany();
|
||||
} catch (err) {
|
||||
throw err instanceof Error ? err : new Error('Error removing logo');
|
||||
}
|
||||
}, [fetchCompany]);
|
||||
|
||||
return {
|
||||
company,
|
||||
isLoading,
|
||||
isSaving,
|
||||
error,
|
||||
refresh: fetchCompany,
|
||||
update,
|
||||
uploadLogo,
|
||||
removeLogo,
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Users Hook
|
||||
// ============================================================================
|
||||
|
||||
interface UseUsersOptions {
|
||||
initialFilters?: UsersFilters;
|
||||
autoLoad?: boolean;
|
||||
}
|
||||
|
||||
interface UseUsersReturn {
|
||||
users: User[];
|
||||
total: number;
|
||||
page: number;
|
||||
totalPages: number;
|
||||
isLoading: boolean;
|
||||
error: Error | null;
|
||||
filters: UsersFilters;
|
||||
setFilters: (filters: UsersFilters) => void;
|
||||
refresh: () => Promise<void>;
|
||||
getById: (id: string) => Promise<User>;
|
||||
create: (data: UserCreateInput) => Promise<User>;
|
||||
update: (id: string, data: UserUpdateInput) => Promise<User>;
|
||||
remove: (id: string) => Promise<void>;
|
||||
toggleActive: (id: string) => Promise<User>;
|
||||
resetPassword: (id: string, newPassword: string) => Promise<void>;
|
||||
resendInvitation: (id: string) => Promise<void>;
|
||||
}
|
||||
|
||||
export function useUsers(options: UseUsersOptions = {}): UseUsersReturn {
|
||||
const { initialFilters = {}, autoLoad = true } = options;
|
||||
|
||||
const [users, setUsers] = useState<User[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [page, setPage] = useState(1);
|
||||
const [totalPages, setTotalPages] = useState(0);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<Error | null>(null);
|
||||
const [filters, setFilters] = useState<UsersFilters>(initialFilters);
|
||||
|
||||
const fetchUsers = useCallback(async () => {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const response = await usersApi.getAll(filters);
|
||||
setUsers(response.data);
|
||||
setTotal(response.total);
|
||||
setPage(response.page);
|
||||
setTotalPages(response.totalPages);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err : new Error('Error fetching users'));
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [filters]);
|
||||
|
||||
useEffect(() => {
|
||||
if (autoLoad) {
|
||||
fetchUsers();
|
||||
}
|
||||
}, [autoLoad, fetchUsers]);
|
||||
|
||||
const getById = useCallback(async (id: string): Promise<User> => {
|
||||
return usersApi.getById(id);
|
||||
}, []);
|
||||
|
||||
const create = useCallback(async (data: UserCreateInput): Promise<User> => {
|
||||
const result = await usersApi.create(data);
|
||||
await fetchUsers();
|
||||
return result;
|
||||
}, [fetchUsers]);
|
||||
|
||||
const update = useCallback(async (id: string, data: UserUpdateInput): Promise<User> => {
|
||||
const result = await usersApi.update(id, data);
|
||||
await fetchUsers();
|
||||
return result;
|
||||
}, [fetchUsers]);
|
||||
|
||||
const remove = useCallback(async (id: string): Promise<void> => {
|
||||
await usersApi.delete(id);
|
||||
await fetchUsers();
|
||||
}, [fetchUsers]);
|
||||
|
||||
const toggleActive = useCallback(async (id: string): Promise<User> => {
|
||||
const result = await usersApi.toggleActive(id);
|
||||
await fetchUsers();
|
||||
return result;
|
||||
}, [fetchUsers]);
|
||||
|
||||
const resetPassword = useCallback(async (id: string, newPassword: string): Promise<void> => {
|
||||
await usersApi.resetPassword(id, newPassword);
|
||||
}, []);
|
||||
|
||||
const resendInvitation = useCallback(async (id: string): Promise<void> => {
|
||||
await usersApi.resendInvitation(id);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
users,
|
||||
total,
|
||||
page,
|
||||
totalPages,
|
||||
isLoading,
|
||||
error,
|
||||
filters,
|
||||
setFilters,
|
||||
refresh: fetchUsers,
|
||||
getById,
|
||||
create,
|
||||
update,
|
||||
remove,
|
||||
toggleActive,
|
||||
resetPassword,
|
||||
resendInvitation,
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Profile Hook
|
||||
// ============================================================================
|
||||
|
||||
interface UseProfileReturn {
|
||||
profile: Profile | null;
|
||||
isLoading: boolean;
|
||||
isSaving: boolean;
|
||||
error: Error | null;
|
||||
refresh: () => Promise<void>;
|
||||
update: (data: ProfileUpdateInput) => Promise<Profile>;
|
||||
changePassword: (data: ChangePasswordInput) => Promise<void>;
|
||||
uploadAvatar: (file: File) => Promise<string>;
|
||||
removeAvatar: () => Promise<void>;
|
||||
}
|
||||
|
||||
export function useProfile(): UseProfileReturn {
|
||||
const [profile, setProfile] = useState<Profile | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [error, setError] = useState<Error | null>(null);
|
||||
|
||||
const fetchProfile = useCallback(async () => {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const data = await profileApi.get();
|
||||
setProfile(data);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err : new Error('Error fetching profile'));
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchProfile();
|
||||
}, [fetchProfile]);
|
||||
|
||||
const update = useCallback(async (data: ProfileUpdateInput): Promise<Profile> => {
|
||||
setIsSaving(true);
|
||||
setError(null);
|
||||
try {
|
||||
const result = await profileApi.update(data);
|
||||
setProfile(result);
|
||||
return result;
|
||||
} catch (err) {
|
||||
const error = err instanceof Error ? err : new Error('Error updating profile');
|
||||
setError(error);
|
||||
throw error;
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const changePassword = useCallback(async (data: ChangePasswordInput): Promise<void> => {
|
||||
setIsSaving(true);
|
||||
setError(null);
|
||||
try {
|
||||
await profileApi.changePassword(data);
|
||||
} catch (err) {
|
||||
const error = err instanceof Error ? err : new Error('Error changing password');
|
||||
setError(error);
|
||||
throw error;
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const uploadAvatar = useCallback(async (file: File): Promise<string> => {
|
||||
try {
|
||||
const { url } = await profileApi.uploadAvatar(file);
|
||||
await fetchProfile();
|
||||
return url;
|
||||
} catch (err) {
|
||||
throw err instanceof Error ? err : new Error('Error uploading avatar');
|
||||
}
|
||||
}, [fetchProfile]);
|
||||
|
||||
const removeAvatar = useCallback(async (): Promise<void> => {
|
||||
try {
|
||||
await profileApi.removeAvatar();
|
||||
await fetchProfile();
|
||||
} catch (err) {
|
||||
throw err instanceof Error ? err : new Error('Error removing avatar');
|
||||
}
|
||||
}, [fetchProfile]);
|
||||
|
||||
return {
|
||||
profile,
|
||||
isLoading,
|
||||
isSaving,
|
||||
error,
|
||||
refresh: fetchProfile,
|
||||
update,
|
||||
changePassword,
|
||||
uploadAvatar,
|
||||
removeAvatar,
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// System Settings Hook
|
||||
// ============================================================================
|
||||
|
||||
interface UseSystemSettingsOptions {
|
||||
module?: string;
|
||||
autoLoad?: boolean;
|
||||
}
|
||||
|
||||
interface UseSystemSettingsReturn {
|
||||
settings: SystemSettings[];
|
||||
isLoading: boolean;
|
||||
error: Error | null;
|
||||
refresh: () => Promise<void>;
|
||||
get: (module: string, key: string) => Promise<SystemSettings>;
|
||||
update: (module: string, key: string, data: SystemSettingUpdateInput) => Promise<SystemSettings>;
|
||||
reset: (module: string, key: string) => Promise<SystemSettings>;
|
||||
}
|
||||
|
||||
export function useSystemSettings(options: UseSystemSettingsOptions = {}): UseSystemSettingsReturn {
|
||||
const { module, autoLoad = true } = options;
|
||||
|
||||
const [settings, setSettings] = useState<SystemSettings[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<Error | null>(null);
|
||||
|
||||
const fetchSettings = useCallback(async () => {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const data = await systemSettingsApi.getAll(module);
|
||||
setSettings(data);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err : new Error('Error fetching settings'));
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [module]);
|
||||
|
||||
useEffect(() => {
|
||||
if (autoLoad) {
|
||||
fetchSettings();
|
||||
}
|
||||
}, [autoLoad, fetchSettings]);
|
||||
|
||||
const get = useCallback(async (mod: string, key: string): Promise<SystemSettings> => {
|
||||
return systemSettingsApi.get(mod, key);
|
||||
}, []);
|
||||
|
||||
const update = useCallback(async (
|
||||
mod: string,
|
||||
key: string,
|
||||
data: SystemSettingUpdateInput
|
||||
): Promise<SystemSettings> => {
|
||||
const result = await systemSettingsApi.update(mod, key, data);
|
||||
await fetchSettings();
|
||||
return result;
|
||||
}, [fetchSettings]);
|
||||
|
||||
const reset = useCallback(async (mod: string, key: string): Promise<SystemSettings> => {
|
||||
const result = await systemSettingsApi.reset(mod, key);
|
||||
await fetchSettings();
|
||||
return result;
|
||||
}, [fetchSettings]);
|
||||
|
||||
return {
|
||||
settings,
|
||||
isLoading,
|
||||
error,
|
||||
refresh: fetchSettings,
|
||||
get,
|
||||
update,
|
||||
reset,
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Audit Logs Hook
|
||||
// ============================================================================
|
||||
|
||||
interface UseAuditLogsOptions {
|
||||
initialFilters?: AuditLogFilters;
|
||||
autoLoad?: boolean;
|
||||
}
|
||||
|
||||
interface UseAuditLogsReturn {
|
||||
logs: AuditLog[];
|
||||
total: number;
|
||||
page: number;
|
||||
totalPages: number;
|
||||
isLoading: boolean;
|
||||
error: Error | null;
|
||||
filters: AuditLogFilters;
|
||||
setFilters: (filters: AuditLogFilters) => void;
|
||||
refresh: () => Promise<void>;
|
||||
getById: (id: string) => Promise<AuditLog>;
|
||||
exportLogs: () => Promise<void>;
|
||||
}
|
||||
|
||||
export function useAuditLogs(options: UseAuditLogsOptions = {}): UseAuditLogsReturn {
|
||||
const { initialFilters = {}, autoLoad = true } = options;
|
||||
|
||||
const [logs, setLogs] = useState<AuditLog[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [page, setPage] = useState(1);
|
||||
const [totalPages, setTotalPages] = useState(0);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<Error | null>(null);
|
||||
const [filters, setFilters] = useState<AuditLogFilters>(initialFilters);
|
||||
|
||||
const fetchLogs = useCallback(async () => {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const response = await auditLogsApi.getAll(filters);
|
||||
setLogs(response.data);
|
||||
setTotal(response.total);
|
||||
setPage(response.page);
|
||||
setTotalPages(response.totalPages);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err : new Error('Error fetching audit logs'));
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [filters]);
|
||||
|
||||
useEffect(() => {
|
||||
if (autoLoad) {
|
||||
fetchLogs();
|
||||
}
|
||||
}, [autoLoad, fetchLogs]);
|
||||
|
||||
const getById = useCallback(async (id: string): Promise<AuditLog> => {
|
||||
return auditLogsApi.getById(id);
|
||||
}, []);
|
||||
|
||||
const exportLogs = useCallback(async (): Promise<void> => {
|
||||
try {
|
||||
const blob = await auditLogsApi.export(filters);
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = `audit-logs-${new Date().toISOString().split('T')[0]}.csv`;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
window.URL.revokeObjectURL(url);
|
||||
} catch (err) {
|
||||
throw err instanceof Error ? err : new Error('Error exporting audit logs');
|
||||
}
|
||||
}, [filters]);
|
||||
|
||||
return {
|
||||
logs,
|
||||
total,
|
||||
page,
|
||||
totalPages,
|
||||
isLoading,
|
||||
error,
|
||||
filters,
|
||||
setFilters,
|
||||
refresh: fetchLogs,
|
||||
getById,
|
||||
exportLogs,
|
||||
};
|
||||
}
|
||||
10
src/features/settings/index.ts
Normal file
10
src/features/settings/index.ts
Normal file
@ -0,0 +1,10 @@
|
||||
// Settings Feature - Barrel Export
|
||||
|
||||
// Types
|
||||
export * from './types';
|
||||
|
||||
// API
|
||||
export * from './api';
|
||||
|
||||
// Hooks
|
||||
export * from './hooks';
|
||||
1
src/features/settings/types/index.ts
Normal file
1
src/features/settings/types/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export * from './settings.types';
|
||||
214
src/features/settings/types/settings.types.ts
Normal file
214
src/features/settings/types/settings.types.ts
Normal file
@ -0,0 +1,214 @@
|
||||
// Settings Types - Company, Users, Profile
|
||||
|
||||
// ============================================================================
|
||||
// Company Types
|
||||
// ============================================================================
|
||||
|
||||
export interface Company {
|
||||
id: string;
|
||||
tenantId: string;
|
||||
name: string;
|
||||
legalName?: string;
|
||||
taxId?: string;
|
||||
email?: string;
|
||||
phone?: string;
|
||||
website?: string;
|
||||
address?: string;
|
||||
city?: string;
|
||||
state?: string;
|
||||
country?: string;
|
||||
zipCode?: string;
|
||||
logoUrl?: string;
|
||||
currency: string;
|
||||
fiscalYear?: string;
|
||||
timezone?: string;
|
||||
isActive: boolean;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface CompanyUpdateInput {
|
||||
name?: string;
|
||||
legalName?: string;
|
||||
taxId?: string;
|
||||
email?: string;
|
||||
phone?: string;
|
||||
website?: string;
|
||||
address?: string;
|
||||
city?: string;
|
||||
state?: string;
|
||||
country?: string;
|
||||
zipCode?: string;
|
||||
currency?: string;
|
||||
fiscalYear?: string;
|
||||
timezone?: string;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// User Types
|
||||
// ============================================================================
|
||||
|
||||
export type UserRole = 'admin' | 'manager' | 'user' | 'viewer';
|
||||
|
||||
export interface User {
|
||||
id: string;
|
||||
tenantId: string;
|
||||
email: string;
|
||||
name: string;
|
||||
role: UserRole;
|
||||
isActive: boolean;
|
||||
avatarUrl?: string;
|
||||
lastLoginAt?: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface UserCreateInput {
|
||||
email: string;
|
||||
name: string;
|
||||
password: string;
|
||||
role: UserRole;
|
||||
}
|
||||
|
||||
export interface UserUpdateInput {
|
||||
email?: string;
|
||||
name?: string;
|
||||
role?: UserRole;
|
||||
isActive?: boolean;
|
||||
}
|
||||
|
||||
export interface UsersFilters {
|
||||
page?: number;
|
||||
limit?: number;
|
||||
search?: string;
|
||||
role?: UserRole;
|
||||
isActive?: boolean;
|
||||
}
|
||||
|
||||
export interface UsersResponse {
|
||||
data: User[];
|
||||
total: number;
|
||||
page: number;
|
||||
limit: number;
|
||||
totalPages: number;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Profile Types
|
||||
// ============================================================================
|
||||
|
||||
export interface Profile extends User {
|
||||
preferences?: UserPreferences;
|
||||
}
|
||||
|
||||
export interface UserPreferences {
|
||||
language?: string;
|
||||
dateFormat?: string;
|
||||
numberFormat?: string;
|
||||
theme?: 'light' | 'dark' | 'system';
|
||||
notifications?: NotificationPreferences;
|
||||
}
|
||||
|
||||
export interface NotificationPreferences {
|
||||
email: boolean;
|
||||
push: boolean;
|
||||
inApp: boolean;
|
||||
}
|
||||
|
||||
export interface ProfileUpdateInput {
|
||||
name?: string;
|
||||
email?: string;
|
||||
avatarUrl?: string;
|
||||
preferences?: Partial<UserPreferences>;
|
||||
}
|
||||
|
||||
export interface ChangePasswordInput {
|
||||
currentPassword: string;
|
||||
newPassword: string;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// System Settings Types
|
||||
// ============================================================================
|
||||
|
||||
export interface SystemSettings {
|
||||
id: string;
|
||||
tenantId: string;
|
||||
module: string;
|
||||
key: string;
|
||||
value: any;
|
||||
description?: string;
|
||||
updatedBy?: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface SystemSettingUpdateInput {
|
||||
value: any;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Audit Log Types
|
||||
// ============================================================================
|
||||
|
||||
export interface AuditLog {
|
||||
id: string;
|
||||
tenantId: string;
|
||||
userId: string;
|
||||
userName?: string;
|
||||
action: AuditAction;
|
||||
entityType: string;
|
||||
entityId: string;
|
||||
oldValues?: Record<string, any>;
|
||||
newValues?: Record<string, any>;
|
||||
ipAddress?: string;
|
||||
userAgent?: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export type AuditAction = 'create' | 'update' | 'delete' | 'login' | 'logout' | 'export' | 'import';
|
||||
|
||||
export interface AuditLogFilters {
|
||||
userId?: string;
|
||||
action?: AuditAction;
|
||||
entityType?: string;
|
||||
entityId?: string;
|
||||
dateFrom?: string;
|
||||
dateTo?: string;
|
||||
page?: number;
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
export interface AuditLogsResponse {
|
||||
data: AuditLog[];
|
||||
total: number;
|
||||
page: number;
|
||||
limit: number;
|
||||
totalPages: number;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Constants and Labels
|
||||
// ============================================================================
|
||||
|
||||
export const USER_ROLE_LABELS: Record<UserRole, string> = {
|
||||
admin: 'Administrador',
|
||||
manager: 'Gerente',
|
||||
user: 'Usuario',
|
||||
viewer: 'Visualizador',
|
||||
};
|
||||
|
||||
export const AUDIT_ACTION_LABELS: Record<AuditAction, string> = {
|
||||
create: 'Creacion',
|
||||
update: 'Actualizacion',
|
||||
delete: 'Eliminacion',
|
||||
login: 'Inicio de sesion',
|
||||
logout: 'Cierre de sesion',
|
||||
export: 'Exportacion',
|
||||
import: 'Importacion',
|
||||
};
|
||||
|
||||
export const THEME_LABELS: Record<string, string> = {
|
||||
light: 'Claro',
|
||||
dark: 'Oscuro',
|
||||
system: 'Sistema',
|
||||
};
|
||||
561
src/pages/reports/QuickReportsPage.tsx
Normal file
561
src/pages/reports/QuickReportsPage.tsx
Normal file
@ -0,0 +1,561 @@
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
FileSpreadsheet,
|
||||
Download,
|
||||
Search,
|
||||
Calendar,
|
||||
RefreshCw,
|
||||
BookOpen,
|
||||
Scale,
|
||||
Building2,
|
||||
Filter,
|
||||
} from 'lucide-react';
|
||||
import { Button } from '@components/atoms/Button';
|
||||
import { Card, CardHeader, CardTitle, CardContent } from '@components/molecules/Card';
|
||||
import { DataTable, type Column } from '@components/organisms/DataTable';
|
||||
import { Breadcrumbs } from '@components/organisms/Breadcrumbs';
|
||||
import { ErrorEmptyState } from '@components/templates/EmptyState';
|
||||
import { useQuickReports } from '@features/reports/hooks';
|
||||
import type {
|
||||
TrialBalanceRow,
|
||||
GeneralLedgerRow,
|
||||
ExportFormat,
|
||||
} from '@features/reports/types';
|
||||
import { formatNumber } from '@utils/formatters';
|
||||
|
||||
type ReportView = 'trial-balance' | 'general-ledger';
|
||||
|
||||
const formatCurrency = (value: number): string => {
|
||||
return formatNumber(value, 'es-MX', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
||||
};
|
||||
|
||||
const getStartOfMonth = (): string => {
|
||||
const date = new Date();
|
||||
const isoDate = new Date(date.getFullYear(), date.getMonth(), 1).toISOString().split('T')[0];
|
||||
return isoDate ?? '';
|
||||
};
|
||||
|
||||
const getToday = (): string => {
|
||||
const isoDate = new Date().toISOString().split('T')[0];
|
||||
return isoDate ?? '';
|
||||
};
|
||||
|
||||
export function QuickReportsPage() {
|
||||
const [activeView, setActiveView] = useState<ReportView>('trial-balance');
|
||||
const [companyId, setCompanyId] = useState('');
|
||||
const [dateFrom, setDateFrom] = useState(getStartOfMonth());
|
||||
const [dateTo, setDateTo] = useState(getToday());
|
||||
const [includeZeroBalance, setIncludeZeroBalance] = useState(false);
|
||||
const [accountId, setAccountId] = useState('');
|
||||
|
||||
const {
|
||||
trialBalance,
|
||||
isLoadingTrialBalance,
|
||||
trialBalanceError,
|
||||
getTrialBalance,
|
||||
exportTrialBalance,
|
||||
generalLedger,
|
||||
isLoadingGeneralLedger,
|
||||
generalLedgerError,
|
||||
getGeneralLedger,
|
||||
exportGeneralLedger,
|
||||
clearResults,
|
||||
} = useQuickReports();
|
||||
|
||||
const handleGenerateTrialBalance = async () => {
|
||||
if (!companyId) {
|
||||
alert('Seleccione una empresa');
|
||||
return;
|
||||
}
|
||||
await getTrialBalance({
|
||||
companyId,
|
||||
dateFrom,
|
||||
dateTo,
|
||||
includeZeroBalance,
|
||||
});
|
||||
};
|
||||
|
||||
const handleGenerateGeneralLedger = async () => {
|
||||
if (!companyId) {
|
||||
alert('Seleccione una empresa');
|
||||
return;
|
||||
}
|
||||
await getGeneralLedger({
|
||||
companyId,
|
||||
dateFrom,
|
||||
dateTo,
|
||||
accountId: accountId || undefined,
|
||||
});
|
||||
};
|
||||
|
||||
const handleExport = async (format: ExportFormat) => {
|
||||
if (!companyId) return;
|
||||
|
||||
if (activeView === 'trial-balance') {
|
||||
await exportTrialBalance({
|
||||
companyId,
|
||||
dateFrom,
|
||||
dateTo,
|
||||
includeZeroBalance,
|
||||
}, format);
|
||||
} else {
|
||||
await exportGeneralLedger({
|
||||
companyId,
|
||||
dateFrom,
|
||||
dateTo,
|
||||
accountId: accountId || undefined,
|
||||
}, format);
|
||||
}
|
||||
};
|
||||
|
||||
const trialBalanceColumns: Column<TrialBalanceRow>[] = [
|
||||
{
|
||||
key: 'accountCode',
|
||||
header: 'Codigo',
|
||||
render: (row) => (
|
||||
<span className="font-mono text-sm text-gray-900">{row.accountCode}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'accountName',
|
||||
header: 'Cuenta',
|
||||
render: (row) => (
|
||||
<span className="text-sm text-gray-900">{row.accountName}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'accountType',
|
||||
header: 'Tipo',
|
||||
render: (row) => (
|
||||
<span className="text-sm text-gray-600">{row.accountType}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'openingDebit',
|
||||
header: 'Saldo Inicial Debe',
|
||||
render: (row) => (
|
||||
<span className="text-sm text-right text-gray-900 block">
|
||||
${formatCurrency(row.openingDebit)}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'openingCredit',
|
||||
header: 'Saldo Inicial Haber',
|
||||
render: (row) => (
|
||||
<span className="text-sm text-right text-gray-900 block">
|
||||
${formatCurrency(row.openingCredit)}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'periodDebit',
|
||||
header: 'Movimientos Debe',
|
||||
render: (row) => (
|
||||
<span className="text-sm text-right text-blue-600 block font-medium">
|
||||
${formatCurrency(row.periodDebit)}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'periodCredit',
|
||||
header: 'Movimientos Haber',
|
||||
render: (row) => (
|
||||
<span className="text-sm text-right text-blue-600 block font-medium">
|
||||
${formatCurrency(row.periodCredit)}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'closingDebit',
|
||||
header: 'Saldo Final Debe',
|
||||
render: (row) => (
|
||||
<span className="text-sm text-right text-gray-900 block font-semibold">
|
||||
${formatCurrency(row.closingDebit)}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'closingCredit',
|
||||
header: 'Saldo Final Haber',
|
||||
render: (row) => (
|
||||
<span className="text-sm text-right text-gray-900 block font-semibold">
|
||||
${formatCurrency(row.closingCredit)}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
const generalLedgerColumns: Column<GeneralLedgerRow>[] = [
|
||||
{
|
||||
key: 'date',
|
||||
header: 'Fecha',
|
||||
render: (row) => (
|
||||
<span className="text-sm text-gray-600">{row.date}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'journalEntryNumber',
|
||||
header: 'Asiento',
|
||||
render: (row) => (
|
||||
<span className="font-mono text-sm text-blue-600">{row.journalEntryNumber}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'description',
|
||||
header: 'Descripcion',
|
||||
render: (row) => (
|
||||
<div>
|
||||
<span className="text-sm text-gray-900">{row.description}</span>
|
||||
{row.reference && (
|
||||
<span className="text-xs text-gray-500 block">Ref: {row.reference}</span>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'debit',
|
||||
header: 'Debe',
|
||||
render: (row) => (
|
||||
<span className={`text-sm text-right block ${row.debit > 0 ? 'text-green-600 font-medium' : 'text-gray-400'}`}>
|
||||
${formatCurrency(row.debit)}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'credit',
|
||||
header: 'Haber',
|
||||
render: (row) => (
|
||||
<span className={`text-sm text-right block ${row.credit > 0 ? 'text-red-600 font-medium' : 'text-gray-400'}`}>
|
||||
${formatCurrency(row.credit)}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'balance',
|
||||
header: 'Saldo',
|
||||
render: (row) => (
|
||||
<span className={`text-sm text-right block font-semibold ${row.balance >= 0 ? 'text-gray-900' : 'text-red-600'}`}>
|
||||
${formatCurrency(row.balance)}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
const isLoading = activeView === 'trial-balance' ? isLoadingTrialBalance : isLoadingGeneralLedger;
|
||||
const error = activeView === 'trial-balance' ? trialBalanceError : generalLedgerError;
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="p-6">
|
||||
<ErrorEmptyState onRetry={clearResults} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6 p-6">
|
||||
<Breadcrumbs items={[
|
||||
{ label: 'Reportes', href: '/reports' },
|
||||
{ label: 'Reportes Rapidos' },
|
||||
]} />
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">Reportes Rapidos</h1>
|
||||
<p className="text-sm text-gray-500">
|
||||
Genera Balance de Comprobacion y Libro Mayor de forma inmediata
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Report Type Selector */}
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<Card
|
||||
className={`cursor-pointer transition-all ${
|
||||
activeView === 'trial-balance'
|
||||
? 'ring-2 ring-blue-500 shadow-md'
|
||||
: 'hover:shadow-md'
|
||||
}`}
|
||||
onClick={() => setActiveView('trial-balance')}
|
||||
>
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className={`flex h-12 w-12 items-center justify-center rounded-lg ${
|
||||
activeView === 'trial-balance' ? 'bg-blue-100' : 'bg-gray-100'
|
||||
}`}>
|
||||
<Scale className={`h-6 w-6 ${
|
||||
activeView === 'trial-balance' ? 'text-blue-600' : 'text-gray-500'
|
||||
}`} />
|
||||
</div>
|
||||
<div>
|
||||
<div className={`font-semibold ${
|
||||
activeView === 'trial-balance' ? 'text-blue-600' : 'text-gray-900'
|
||||
}`}>
|
||||
Balance de Comprobacion
|
||||
</div>
|
||||
<div className="text-sm text-gray-500">
|
||||
Saldos iniciales, movimientos y saldos finales
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card
|
||||
className={`cursor-pointer transition-all ${
|
||||
activeView === 'general-ledger'
|
||||
? 'ring-2 ring-blue-500 shadow-md'
|
||||
: 'hover:shadow-md'
|
||||
}`}
|
||||
onClick={() => setActiveView('general-ledger')}
|
||||
>
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className={`flex h-12 w-12 items-center justify-center rounded-lg ${
|
||||
activeView === 'general-ledger' ? 'bg-blue-100' : 'bg-gray-100'
|
||||
}`}>
|
||||
<BookOpen className={`h-6 w-6 ${
|
||||
activeView === 'general-ledger' ? 'text-blue-600' : 'text-gray-500'
|
||||
}`} />
|
||||
</div>
|
||||
<div>
|
||||
<div className={`font-semibold ${
|
||||
activeView === 'general-ledger' ? 'text-blue-600' : 'text-gray-900'
|
||||
}`}>
|
||||
Libro Mayor
|
||||
</div>
|
||||
<div className="text-sm text-gray-500">
|
||||
Movimientos detallados por cuenta
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Filter className="h-5 w-5" />
|
||||
Parametros del Reporte
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
<Building2 className="inline-block h-4 w-4 mr-1" />
|
||||
Empresa
|
||||
</label>
|
||||
<select
|
||||
value={companyId}
|
||||
onChange={(e) => setCompanyId(e.target.value)}
|
||||
className="w-full rounded-md border border-gray-300 px-3 py-2 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
|
||||
>
|
||||
<option value="">Seleccione empresa</option>
|
||||
{/* Companies would be loaded from a hook */}
|
||||
<option value="1">Empresa Principal</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
<Calendar className="inline-block h-4 w-4 mr-1" />
|
||||
Fecha desde
|
||||
</label>
|
||||
<input
|
||||
type="date"
|
||||
value={dateFrom}
|
||||
onChange={(e) => setDateFrom(e.target.value)}
|
||||
className="w-full rounded-md border border-gray-300 px-3 py-2 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
<Calendar className="inline-block h-4 w-4 mr-1" />
|
||||
Fecha hasta
|
||||
</label>
|
||||
<input
|
||||
type="date"
|
||||
value={dateTo}
|
||||
onChange={(e) => setDateTo(e.target.value)}
|
||||
className="w-full rounded-md border border-gray-300 px-3 py-2 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{activeView === 'trial-balance' && (
|
||||
<div className="flex items-end">
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={includeZeroBalance}
|
||||
onChange={(e) => setIncludeZeroBalance(e.target.checked)}
|
||||
className="rounded border-gray-300 text-blue-600 focus:ring-blue-500"
|
||||
/>
|
||||
<span className="text-sm text-gray-700">Incluir saldos en cero</span>
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeView === 'general-ledger' && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
<Search className="inline-block h-4 w-4 mr-1" />
|
||||
Cuenta (opcional)
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={accountId}
|
||||
onChange={(e) => setAccountId(e.target.value)}
|
||||
placeholder="ID o codigo de cuenta"
|
||||
className="w-full rounded-md border border-gray-300 px-3 py-2 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 mt-4">
|
||||
<Button
|
||||
onClick={activeView === 'trial-balance' ? handleGenerateTrialBalance : handleGenerateGeneralLedger}
|
||||
disabled={isLoading || !companyId}
|
||||
>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<RefreshCw className="mr-2 h-4 w-4 animate-spin" />
|
||||
Generando...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<FileSpreadsheet className="mr-2 h-4 w-4" />
|
||||
Generar Reporte
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
|
||||
{(trialBalance || generalLedger) && (
|
||||
<>
|
||||
<Button variant="outline" onClick={() => handleExport('excel')}>
|
||||
<Download className="mr-2 h-4 w-4" />
|
||||
Excel
|
||||
</Button>
|
||||
<Button variant="outline" onClick={() => handleExport('pdf')}>
|
||||
<Download className="mr-2 h-4 w-4" />
|
||||
PDF
|
||||
</Button>
|
||||
<Button variant="outline" onClick={() => handleExport('csv')}>
|
||||
<Download className="mr-2 h-4 w-4" />
|
||||
CSV
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Results */}
|
||||
{activeView === 'trial-balance' && trialBalance && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle>Balance de Comprobacion</CardTitle>
|
||||
<span className="text-sm text-gray-500">
|
||||
Generado: {trialBalance.generatedAt}
|
||||
</span>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
{/* Summary */}
|
||||
<div className="grid grid-cols-2 gap-4 sm:grid-cols-6 p-4 bg-gray-50 rounded-lg">
|
||||
<div>
|
||||
<div className="text-xs text-gray-500">Saldo Inicial Debe</div>
|
||||
<div className="font-semibold">${formatCurrency(trialBalance.totals.openingDebit)}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs text-gray-500">Saldo Inicial Haber</div>
|
||||
<div className="font-semibold">${formatCurrency(trialBalance.totals.openingCredit)}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs text-gray-500">Movimientos Debe</div>
|
||||
<div className="font-semibold text-blue-600">${formatCurrency(trialBalance.totals.periodDebit)}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs text-gray-500">Movimientos Haber</div>
|
||||
<div className="font-semibold text-blue-600">${formatCurrency(trialBalance.totals.periodCredit)}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs text-gray-500">Saldo Final Debe</div>
|
||||
<div className="font-bold text-green-600">${formatCurrency(trialBalance.totals.closingDebit)}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs text-gray-500">Saldo Final Haber</div>
|
||||
<div className="font-bold text-green-600">${formatCurrency(trialBalance.totals.closingCredit)}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DataTable
|
||||
data={trialBalance.rows}
|
||||
columns={trialBalanceColumns}
|
||||
isLoading={false}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{activeView === 'general-ledger' && generalLedger && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<CardTitle>Libro Mayor</CardTitle>
|
||||
<p className="text-sm text-gray-500 mt-1">
|
||||
{generalLedger.accountCode} - {generalLedger.accountName}
|
||||
</p>
|
||||
</div>
|
||||
<span className="text-sm text-gray-500">
|
||||
Generado: {generalLedger.generatedAt}
|
||||
</span>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
{/* Summary */}
|
||||
<div className="grid grid-cols-2 gap-4 sm:grid-cols-4 p-4 bg-gray-50 rounded-lg">
|
||||
<div>
|
||||
<div className="text-xs text-gray-500">Saldo Inicial</div>
|
||||
<div className="font-semibold">${formatCurrency(generalLedger.openingBalance)}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs text-gray-500">Total Debe</div>
|
||||
<div className="font-semibold text-green-600">${formatCurrency(generalLedger.totalDebit)}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs text-gray-500">Total Haber</div>
|
||||
<div className="font-semibold text-red-600">${formatCurrency(generalLedger.totalCredit)}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs text-gray-500">Saldo Final</div>
|
||||
<div className={`font-bold ${generalLedger.closingBalance >= 0 ? 'text-gray-900' : 'text-red-600'}`}>
|
||||
${formatCurrency(generalLedger.closingBalance)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DataTable
|
||||
data={generalLedger.rows}
|
||||
columns={generalLedgerColumns}
|
||||
isLoading={false}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default QuickReportsPage;
|
||||
861
src/pages/reports/ReportsPage.tsx
Normal file
861
src/pages/reports/ReportsPage.tsx
Normal file
@ -0,0 +1,861 @@
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
FileText,
|
||||
Plus,
|
||||
MoreVertical,
|
||||
Eye,
|
||||
Play,
|
||||
Clock,
|
||||
RefreshCw,
|
||||
Search,
|
||||
Download,
|
||||
Trash2,
|
||||
Edit2,
|
||||
XCircle,
|
||||
CheckCircle,
|
||||
AlertCircle,
|
||||
BarChart3,
|
||||
PlayCircle,
|
||||
PauseCircle,
|
||||
} from 'lucide-react';
|
||||
import { Button } from '@components/atoms/Button';
|
||||
import { Card, CardHeader, CardTitle, CardContent } from '@components/molecules/Card';
|
||||
import { DataTable, type Column } from '@components/organisms/DataTable';
|
||||
import { Dropdown, type DropdownItem } from '@components/organisms/Dropdown';
|
||||
import { Breadcrumbs } from '@components/organisms/Breadcrumbs';
|
||||
import { ConfirmModal } from '@components/organisms/Modal';
|
||||
import { NoDataEmptyState, ErrorEmptyState } from '@components/templates/EmptyState';
|
||||
import {
|
||||
useReportDefinitions,
|
||||
useReportExecutions,
|
||||
useReportSchedules,
|
||||
} from '@features/reports/hooks';
|
||||
import type {
|
||||
ReportDefinition,
|
||||
ReportExecution,
|
||||
ReportSchedule,
|
||||
ExecutionStatus,
|
||||
ReportType,
|
||||
} from '@features/reports/types';
|
||||
import {
|
||||
REPORT_TYPE_LABELS,
|
||||
EXECUTION_STATUS_LABELS,
|
||||
} from '@features/reports/types';
|
||||
import { formatDate } from '@utils/formatters';
|
||||
|
||||
type TabType = 'definitions' | 'executions' | 'schedules';
|
||||
|
||||
const statusColors: Record<ExecutionStatus, string> = {
|
||||
pending: 'bg-yellow-100 text-yellow-700',
|
||||
running: 'bg-blue-100 text-blue-700',
|
||||
completed: 'bg-green-100 text-green-700',
|
||||
failed: 'bg-red-100 text-red-700',
|
||||
cancelled: 'bg-gray-100 text-gray-700',
|
||||
};
|
||||
|
||||
const typeColors: Record<ReportType, string> = {
|
||||
financial: 'bg-green-100 text-green-700',
|
||||
accounting: 'bg-blue-100 text-blue-700',
|
||||
tax: 'bg-purple-100 text-purple-700',
|
||||
management: 'bg-orange-100 text-orange-700',
|
||||
custom: 'bg-gray-100 text-gray-700',
|
||||
};
|
||||
|
||||
export function ReportsPage() {
|
||||
const [activeTab, setActiveTab] = useState<TabType>('definitions');
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [selectedType, setSelectedType] = useState<ReportType | ''>('');
|
||||
const [selectedStatus, setSelectedStatus] = useState<ExecutionStatus | ''>('');
|
||||
const [definitionToDelete, setDefinitionToDelete] = useState<ReportDefinition | null>(null);
|
||||
const [executionToCancel, setExecutionToCancel] = useState<ReportExecution | null>(null);
|
||||
const [scheduleToDelete, setScheduleToDelete] = useState<ReportSchedule | null>(null);
|
||||
const [scheduleToToggle, setScheduleToToggle] = useState<ReportSchedule | null>(null);
|
||||
|
||||
// Definitions hook
|
||||
const {
|
||||
definitions,
|
||||
total: definitionsTotal,
|
||||
page: definitionsPage,
|
||||
totalPages: definitionsTotalPages,
|
||||
isLoading: definitionsLoading,
|
||||
error: definitionsError,
|
||||
setFilters: setDefinitionsFilters,
|
||||
refresh: refreshDefinitions,
|
||||
remove: removeDefinition,
|
||||
toggleActive: toggleDefinitionActive,
|
||||
} = useReportDefinitions({
|
||||
initialFilters: { search: searchTerm, reportType: selectedType || undefined },
|
||||
});
|
||||
|
||||
// Executions hook
|
||||
const {
|
||||
executions,
|
||||
total: executionsTotal,
|
||||
page: executionsPage,
|
||||
totalPages: executionsTotalPages,
|
||||
isLoading: executionsLoading,
|
||||
error: executionsError,
|
||||
refresh: refreshExecutions,
|
||||
cancel: cancelExecution,
|
||||
download: downloadExecution,
|
||||
} = useReportExecutions({
|
||||
initialFilters: { status: selectedStatus || undefined },
|
||||
pollInterval: 5000,
|
||||
});
|
||||
|
||||
// Schedules hook
|
||||
const {
|
||||
schedules,
|
||||
total: schedulesTotal,
|
||||
page: schedulesPage,
|
||||
totalPages: schedulesTotalPages,
|
||||
isLoading: schedulesLoading,
|
||||
error: schedulesError,
|
||||
refresh: refreshSchedules,
|
||||
remove: removeSchedule,
|
||||
toggle: toggleSchedule,
|
||||
runNow: runScheduleNow,
|
||||
} = useReportSchedules();
|
||||
|
||||
// Definition columns
|
||||
const definitionColumns: Column<ReportDefinition>[] = [
|
||||
{
|
||||
key: 'name',
|
||||
header: 'Reporte',
|
||||
render: (def) => (
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-blue-50">
|
||||
<FileText className="h-5 w-5 text-blue-600" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-medium text-gray-900">{def.name}</div>
|
||||
<div className="text-sm text-gray-500">{def.code}</div>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'reportType',
|
||||
header: 'Tipo',
|
||||
render: (def) => (
|
||||
<span className={`inline-flex rounded-full px-2 py-1 text-xs font-medium ${typeColors[def.reportType]}`}>
|
||||
{REPORT_TYPE_LABELS[def.reportType]}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'category',
|
||||
header: 'Categoria',
|
||||
render: (def) => (
|
||||
<span className="text-sm text-gray-600">{def.category || '-'}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'isActive',
|
||||
header: 'Estado',
|
||||
render: (def) => (
|
||||
<span className={`inline-flex items-center gap-1 rounded-full px-2 py-1 text-xs font-medium ${
|
||||
def.isActive ? 'bg-green-100 text-green-700' : 'bg-gray-100 text-gray-600'
|
||||
}`}>
|
||||
{def.isActive ? (
|
||||
<>
|
||||
<CheckCircle className="h-3 w-3" />
|
||||
Activo
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<XCircle className="h-3 w-3" />
|
||||
Inactivo
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'isSystem',
|
||||
header: 'Sistema',
|
||||
render: (def) => (
|
||||
<span className={`text-sm ${def.isSystem ? 'text-blue-600' : 'text-gray-500'}`}>
|
||||
{def.isSystem ? 'Si' : 'No'}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'actions',
|
||||
header: '',
|
||||
render: (def) => {
|
||||
const items: DropdownItem[] = [
|
||||
{
|
||||
key: 'view',
|
||||
label: 'Ver detalle',
|
||||
icon: <Eye className="h-4 w-4" />,
|
||||
onClick: () => console.log('View', def.id),
|
||||
},
|
||||
{
|
||||
key: 'execute',
|
||||
label: 'Ejecutar',
|
||||
icon: <Play className="h-4 w-4" />,
|
||||
onClick: () => console.log('Execute', def.id),
|
||||
},
|
||||
];
|
||||
|
||||
if (!def.isSystem) {
|
||||
items.push(
|
||||
{
|
||||
key: 'edit',
|
||||
label: 'Editar',
|
||||
icon: <Edit2 className="h-4 w-4" />,
|
||||
onClick: () => console.log('Edit', def.id),
|
||||
},
|
||||
{
|
||||
key: 'toggle',
|
||||
label: def.isActive ? 'Desactivar' : 'Activar',
|
||||
icon: def.isActive ? <PauseCircle className="h-4 w-4" /> : <PlayCircle className="h-4 w-4" />,
|
||||
onClick: () => toggleDefinitionActive(def.id),
|
||||
},
|
||||
{
|
||||
key: 'delete',
|
||||
label: 'Eliminar',
|
||||
icon: <Trash2 className="h-4 w-4" />,
|
||||
danger: true,
|
||||
onClick: () => setDefinitionToDelete(def),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Dropdown
|
||||
trigger={
|
||||
<button className="rounded p-1 hover:bg-gray-100">
|
||||
<MoreVertical className="h-4 w-4 text-gray-500" />
|
||||
</button>
|
||||
}
|
||||
items={items}
|
||||
align="right"
|
||||
/>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
// Execution columns
|
||||
const executionColumns: Column<ReportExecution>[] = [
|
||||
{
|
||||
key: 'report',
|
||||
header: 'Reporte',
|
||||
render: (exec) => (
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-purple-50">
|
||||
<BarChart3 className="h-5 w-5 text-purple-600" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-medium text-gray-900">{exec.definitionName || 'Reporte'}</div>
|
||||
<div className="text-sm text-gray-500">{exec.definitionCode || exec.definitionId}</div>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'status',
|
||||
header: 'Estado',
|
||||
render: (exec) => (
|
||||
<span className={`inline-flex items-center gap-1 rounded-full px-2 py-1 text-xs font-medium ${statusColors[exec.status]}`}>
|
||||
{exec.status === 'running' && <RefreshCw className="h-3 w-3 animate-spin" />}
|
||||
{exec.status === 'completed' && <CheckCircle className="h-3 w-3" />}
|
||||
{exec.status === 'failed' && <AlertCircle className="h-3 w-3" />}
|
||||
{EXECUTION_STATUS_LABELS[exec.status]}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'startedAt',
|
||||
header: 'Iniciado',
|
||||
render: (exec) => (
|
||||
<span className="text-sm text-gray-600">
|
||||
{exec.startedAt ? formatDate(exec.startedAt, 'short') : '-'}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'completedAt',
|
||||
header: 'Completado',
|
||||
render: (exec) => (
|
||||
<span className="text-sm text-gray-600">
|
||||
{exec.completedAt ? formatDate(exec.completedAt, 'short') : '-'}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'duration',
|
||||
header: 'Duracion',
|
||||
render: (exec) => (
|
||||
<span className="text-sm text-gray-600">
|
||||
{exec.executionTimeMs ? `${(exec.executionTimeMs / 1000).toFixed(2)}s` : '-'}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'rows',
|
||||
header: 'Filas',
|
||||
render: (exec) => (
|
||||
<span className="text-sm text-gray-600">
|
||||
{exec.rowCount?.toLocaleString() || '-'}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'requestedBy',
|
||||
header: 'Solicitado por',
|
||||
render: (exec) => (
|
||||
<span className="text-sm text-gray-600">
|
||||
{exec.requestedByName || exec.requestedBy}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'actions',
|
||||
header: '',
|
||||
render: (exec) => {
|
||||
const items: DropdownItem[] = [
|
||||
{
|
||||
key: 'view',
|
||||
label: 'Ver detalle',
|
||||
icon: <Eye className="h-4 w-4" />,
|
||||
onClick: () => console.log('View', exec.id),
|
||||
},
|
||||
];
|
||||
|
||||
if (exec.status === 'completed' && exec.outputFiles.length > 0) {
|
||||
exec.outputFiles.forEach((file) => {
|
||||
items.push({
|
||||
key: `download-${file.format}`,
|
||||
label: `Descargar ${file.format.toUpperCase()}`,
|
||||
icon: <Download className="h-4 w-4" />,
|
||||
onClick: () => downloadExecution(exec.id, file.format),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
if (exec.status === 'pending' || exec.status === 'running') {
|
||||
items.push({
|
||||
key: 'cancel',
|
||||
label: 'Cancelar',
|
||||
icon: <XCircle className="h-4 w-4" />,
|
||||
danger: true,
|
||||
onClick: () => setExecutionToCancel(exec),
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<Dropdown
|
||||
trigger={
|
||||
<button className="rounded p-1 hover:bg-gray-100">
|
||||
<MoreVertical className="h-4 w-4 text-gray-500" />
|
||||
</button>
|
||||
}
|
||||
items={items}
|
||||
align="right"
|
||||
/>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
// Schedule columns
|
||||
const scheduleColumns: Column<ReportSchedule>[] = [
|
||||
{
|
||||
key: 'name',
|
||||
header: 'Programacion',
|
||||
render: (sched) => (
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-orange-50">
|
||||
<Clock className="h-5 w-5 text-orange-600" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-medium text-gray-900">{sched.name}</div>
|
||||
<div className="text-sm text-gray-500">{sched.definitionName}</div>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'cronExpression',
|
||||
header: 'Frecuencia',
|
||||
render: (sched) => (
|
||||
<code className="text-sm text-gray-600 bg-gray-100 px-2 py-1 rounded">
|
||||
{sched.cronExpression}
|
||||
</code>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'isActive',
|
||||
header: 'Estado',
|
||||
render: (sched) => (
|
||||
<span className={`inline-flex items-center gap-1 rounded-full px-2 py-1 text-xs font-medium ${
|
||||
sched.isActive ? 'bg-green-100 text-green-700' : 'bg-gray-100 text-gray-600'
|
||||
}`}>
|
||||
{sched.isActive ? 'Activo' : 'Inactivo'}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'lastRunAt',
|
||||
header: 'Ultima ejecucion',
|
||||
render: (sched) => (
|
||||
<span className="text-sm text-gray-600">
|
||||
{sched.lastRunAt ? formatDate(sched.lastRunAt, 'short') : 'Nunca'}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'nextRunAt',
|
||||
header: 'Proxima ejecucion',
|
||||
render: (sched) => (
|
||||
<span className="text-sm text-gray-600">
|
||||
{sched.nextRunAt ? formatDate(sched.nextRunAt, 'short') : '-'}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'actions',
|
||||
header: '',
|
||||
render: (sched) => {
|
||||
const items: DropdownItem[] = [
|
||||
{
|
||||
key: 'view',
|
||||
label: 'Ver detalle',
|
||||
icon: <Eye className="h-4 w-4" />,
|
||||
onClick: () => console.log('View', sched.id),
|
||||
},
|
||||
{
|
||||
key: 'edit',
|
||||
label: 'Editar',
|
||||
icon: <Edit2 className="h-4 w-4" />,
|
||||
onClick: () => console.log('Edit', sched.id),
|
||||
},
|
||||
{
|
||||
key: 'runNow',
|
||||
label: 'Ejecutar ahora',
|
||||
icon: <Play className="h-4 w-4" />,
|
||||
onClick: () => runScheduleNow(sched.id),
|
||||
},
|
||||
{
|
||||
key: 'toggle',
|
||||
label: sched.isActive ? 'Pausar' : 'Activar',
|
||||
icon: sched.isActive ? <PauseCircle className="h-4 w-4" /> : <PlayCircle className="h-4 w-4" />,
|
||||
onClick: () => setScheduleToToggle(sched),
|
||||
},
|
||||
{
|
||||
key: 'delete',
|
||||
label: 'Eliminar',
|
||||
icon: <Trash2 className="h-4 w-4" />,
|
||||
danger: true,
|
||||
onClick: () => setScheduleToDelete(sched),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<Dropdown
|
||||
trigger={
|
||||
<button className="rounded p-1 hover:bg-gray-100">
|
||||
<MoreVertical className="h-4 w-4 text-gray-500" />
|
||||
</button>
|
||||
}
|
||||
items={items}
|
||||
align="right"
|
||||
/>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const handleDeleteDefinition = async () => {
|
||||
if (definitionToDelete) {
|
||||
await removeDefinition(definitionToDelete.id);
|
||||
setDefinitionToDelete(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancelExecution = async () => {
|
||||
if (executionToCancel) {
|
||||
await cancelExecution(executionToCancel.id);
|
||||
setExecutionToCancel(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteSchedule = async () => {
|
||||
if (scheduleToDelete) {
|
||||
await removeSchedule(scheduleToDelete.id);
|
||||
setScheduleToDelete(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggleSchedule = async () => {
|
||||
if (scheduleToToggle) {
|
||||
await toggleSchedule(scheduleToToggle.id);
|
||||
setScheduleToToggle(null);
|
||||
}
|
||||
};
|
||||
|
||||
const getCurrentRefresh = () => {
|
||||
switch (activeTab) {
|
||||
case 'definitions': return refreshDefinitions;
|
||||
case 'executions': return refreshExecutions;
|
||||
case 'schedules': return refreshSchedules;
|
||||
}
|
||||
};
|
||||
|
||||
const isCurrentLoading = () => {
|
||||
switch (activeTab) {
|
||||
case 'definitions': return definitionsLoading;
|
||||
case 'executions': return executionsLoading;
|
||||
case 'schedules': return schedulesLoading;
|
||||
}
|
||||
};
|
||||
|
||||
const getCurrentError = () => {
|
||||
switch (activeTab) {
|
||||
case 'definitions': return definitionsError;
|
||||
case 'executions': return executionsError;
|
||||
case 'schedules': return schedulesError;
|
||||
}
|
||||
};
|
||||
|
||||
// Stats
|
||||
const activeDefinitions = definitions.filter(d => d.isActive).length;
|
||||
const runningExecutions = executions.filter(e => e.status === 'running').length;
|
||||
const completedToday = executions.filter(e => {
|
||||
if (!e.completedAt) return false;
|
||||
const today = new Date().toDateString();
|
||||
return new Date(e.completedAt).toDateString() === today && e.status === 'completed';
|
||||
}).length;
|
||||
const activeSchedules = schedules.filter(s => s.isActive).length;
|
||||
|
||||
const error = getCurrentError();
|
||||
if (error) {
|
||||
return (
|
||||
<div className="p-6">
|
||||
<ErrorEmptyState onRetry={getCurrentRefresh()} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6 p-6">
|
||||
<Breadcrumbs items={[
|
||||
{ label: 'Reportes' },
|
||||
]} />
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">Reportes</h1>
|
||||
<p className="text-sm text-gray-500">
|
||||
Gestiona definiciones, ejecuciones y programaciones de reportes
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" onClick={getCurrentRefresh()} disabled={isCurrentLoading()}>
|
||||
<RefreshCw className={`mr-2 h-4 w-4 ${isCurrentLoading() ? 'animate-spin' : ''}`} />
|
||||
Actualizar
|
||||
</Button>
|
||||
{activeTab === 'definitions' && (
|
||||
<Button>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Nuevo reporte
|
||||
</Button>
|
||||
)}
|
||||
{activeTab === 'schedules' && (
|
||||
<Button>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Nueva programacion
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Summary Stats */}
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-4">
|
||||
<Card className="cursor-pointer hover:shadow-md transition-shadow" onClick={() => setActiveTab('definitions')}>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-blue-100">
|
||||
<FileText className="h-5 w-5 text-blue-600" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm text-gray-500">Definiciones activas</div>
|
||||
<div className="text-xl font-bold text-blue-600">{activeDefinitions}</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="cursor-pointer hover:shadow-md transition-shadow" onClick={() => setActiveTab('executions')}>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-yellow-100">
|
||||
<RefreshCw className={`h-5 w-5 text-yellow-600 ${runningExecutions > 0 ? 'animate-spin' : ''}`} />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm text-gray-500">En ejecucion</div>
|
||||
<div className="text-xl font-bold text-yellow-600">{runningExecutions}</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-green-100">
|
||||
<CheckCircle className="h-5 w-5 text-green-600" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm text-gray-500">Completados hoy</div>
|
||||
<div className="text-xl font-bold text-green-600">{completedToday}</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="cursor-pointer hover:shadow-md transition-shadow" onClick={() => setActiveTab('schedules')}>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-orange-100">
|
||||
<Clock className="h-5 w-5 text-orange-600" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm text-gray-500">Programaciones</div>
|
||||
<div className="text-xl font-bold text-orange-600">{activeSchedules}</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="border-b border-gray-200">
|
||||
<nav className="-mb-px flex space-x-8">
|
||||
<button
|
||||
onClick={() => setActiveTab('definitions')}
|
||||
className={`py-4 px-1 border-b-2 font-medium text-sm ${
|
||||
activeTab === 'definitions'
|
||||
? 'border-blue-500 text-blue-600'
|
||||
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
|
||||
}`}
|
||||
>
|
||||
<FileText className="inline-block h-4 w-4 mr-2" />
|
||||
Definiciones ({definitionsTotal})
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('executions')}
|
||||
className={`py-4 px-1 border-b-2 font-medium text-sm ${
|
||||
activeTab === 'executions'
|
||||
? 'border-blue-500 text-blue-600'
|
||||
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
|
||||
}`}
|
||||
>
|
||||
<BarChart3 className="inline-block h-4 w-4 mr-2" />
|
||||
Ejecuciones ({executionsTotal})
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('schedules')}
|
||||
className={`py-4 px-1 border-b-2 font-medium text-sm ${
|
||||
activeTab === 'schedules'
|
||||
? 'border-blue-500 text-blue-600'
|
||||
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
|
||||
}`}
|
||||
>
|
||||
<Clock className="inline-block h-4 w-4 mr-2" />
|
||||
Programaciones ({schedulesTotal})
|
||||
</button>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
{/* Content based on active tab */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>
|
||||
{activeTab === 'definitions' && 'Definiciones de Reportes'}
|
||||
{activeTab === 'executions' && 'Historial de Ejecuciones'}
|
||||
{activeTab === 'schedules' && 'Programaciones'}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
{/* Filters */}
|
||||
<div className="flex flex-wrap items-center gap-4">
|
||||
<div className="relative flex-1 min-w-[200px]">
|
||||
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-gray-400" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Buscar..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => {
|
||||
setSearchTerm(e.target.value);
|
||||
if (activeTab === 'definitions') {
|
||||
setDefinitionsFilters({ search: e.target.value });
|
||||
}
|
||||
}}
|
||||
className="w-full rounded-md border border-gray-300 py-2 pl-10 pr-4 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{activeTab === 'definitions' && (
|
||||
<select
|
||||
value={selectedType}
|
||||
onChange={(e) => {
|
||||
setSelectedType(e.target.value as ReportType | '');
|
||||
setDefinitionsFilters({ reportType: e.target.value as ReportType || undefined });
|
||||
}}
|
||||
className="rounded-md border border-gray-300 px-3 py-2 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
|
||||
>
|
||||
<option value="">Todos los tipos</option>
|
||||
{Object.entries(REPORT_TYPE_LABELS).map(([value, label]) => (
|
||||
<option key={value} value={value}>{label}</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
|
||||
{activeTab === 'executions' && (
|
||||
<select
|
||||
value={selectedStatus}
|
||||
onChange={(e) => setSelectedStatus(e.target.value as ExecutionStatus | '')}
|
||||
className="rounded-md border border-gray-300 px-3 py-2 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
|
||||
>
|
||||
<option value="">Todos los estados</option>
|
||||
{Object.entries(EXECUTION_STATUS_LABELS).map(([value, label]) => (
|
||||
<option key={value} value={value}>{label}</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
|
||||
{(searchTerm || selectedType || selectedStatus) && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setSearchTerm('');
|
||||
setSelectedType('');
|
||||
setSelectedStatus('');
|
||||
if (activeTab === 'definitions') {
|
||||
setDefinitionsFilters({});
|
||||
}
|
||||
}}
|
||||
>
|
||||
Limpiar filtros
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Tables */}
|
||||
{activeTab === 'definitions' && (
|
||||
definitions.length === 0 && !definitionsLoading ? (
|
||||
<NoDataEmptyState entityName="definiciones de reportes" />
|
||||
) : (
|
||||
<DataTable
|
||||
data={definitions}
|
||||
columns={definitionColumns}
|
||||
isLoading={definitionsLoading}
|
||||
pagination={{
|
||||
page: definitionsPage,
|
||||
totalPages: definitionsTotalPages,
|
||||
total: definitionsTotal,
|
||||
limit: 20,
|
||||
onPageChange: (p) => setDefinitionsFilters({ page: p }),
|
||||
}}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
|
||||
{activeTab === 'executions' && (
|
||||
executions.length === 0 && !executionsLoading ? (
|
||||
<NoDataEmptyState entityName="ejecuciones" />
|
||||
) : (
|
||||
<DataTable
|
||||
data={executions}
|
||||
columns={executionColumns}
|
||||
isLoading={executionsLoading}
|
||||
pagination={{
|
||||
page: executionsPage,
|
||||
totalPages: executionsTotalPages,
|
||||
total: executionsTotal,
|
||||
limit: 20,
|
||||
onPageChange: () => {},
|
||||
}}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
|
||||
{activeTab === 'schedules' && (
|
||||
schedules.length === 0 && !schedulesLoading ? (
|
||||
<NoDataEmptyState entityName="programaciones" />
|
||||
) : (
|
||||
<DataTable
|
||||
data={schedules}
|
||||
columns={scheduleColumns}
|
||||
isLoading={schedulesLoading}
|
||||
pagination={{
|
||||
page: schedulesPage,
|
||||
totalPages: schedulesTotalPages,
|
||||
total: schedulesTotal,
|
||||
limit: 20,
|
||||
onPageChange: () => {},
|
||||
}}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Delete Definition Modal */}
|
||||
<ConfirmModal
|
||||
isOpen={!!definitionToDelete}
|
||||
onClose={() => setDefinitionToDelete(null)}
|
||||
onConfirm={handleDeleteDefinition}
|
||||
title="Eliminar definicion"
|
||||
message={`¿Eliminar la definicion "${definitionToDelete?.name}"? Esta accion no se puede deshacer.`}
|
||||
variant="danger"
|
||||
confirmText="Eliminar"
|
||||
/>
|
||||
|
||||
{/* Cancel Execution Modal */}
|
||||
<ConfirmModal
|
||||
isOpen={!!executionToCancel}
|
||||
onClose={() => setExecutionToCancel(null)}
|
||||
onConfirm={handleCancelExecution}
|
||||
title="Cancelar ejecucion"
|
||||
message="¿Cancelar la ejecucion de este reporte? El reporte no se generara."
|
||||
variant="danger"
|
||||
confirmText="Cancelar ejecucion"
|
||||
/>
|
||||
|
||||
{/* Delete Schedule Modal */}
|
||||
<ConfirmModal
|
||||
isOpen={!!scheduleToDelete}
|
||||
onClose={() => setScheduleToDelete(null)}
|
||||
onConfirm={handleDeleteSchedule}
|
||||
title="Eliminar programacion"
|
||||
message={`¿Eliminar la programacion "${scheduleToDelete?.name}"? Esta accion no se puede deshacer.`}
|
||||
variant="danger"
|
||||
confirmText="Eliminar"
|
||||
/>
|
||||
|
||||
{/* Toggle Schedule Modal */}
|
||||
<ConfirmModal
|
||||
isOpen={!!scheduleToToggle}
|
||||
onClose={() => setScheduleToToggle(null)}
|
||||
onConfirm={handleToggleSchedule}
|
||||
title={scheduleToToggle?.isActive ? 'Pausar programacion' : 'Activar programacion'}
|
||||
message={scheduleToToggle?.isActive
|
||||
? `¿Pausar la programacion "${scheduleToToggle?.name}"? No se ejecutara automaticamente hasta que se reactive.`
|
||||
: `¿Activar la programacion "${scheduleToToggle?.name}"? Se ejecutara segun la frecuencia configurada.`
|
||||
}
|
||||
variant={scheduleToToggle?.isActive ? 'warning' : 'success'}
|
||||
confirmText={scheduleToToggle?.isActive ? 'Pausar' : 'Activar'}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ReportsPage;
|
||||
2
src/pages/reports/index.ts
Normal file
2
src/pages/reports/index.ts
Normal file
@ -0,0 +1,2 @@
|
||||
export { ReportsPage } from './ReportsPage';
|
||||
export { QuickReportsPage } from './QuickReportsPage';
|
||||
112
src/pages/settings/SettingsPage.tsx
Normal file
112
src/pages/settings/SettingsPage.tsx
Normal file
@ -0,0 +1,112 @@
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
Building2,
|
||||
Users as UsersIcon,
|
||||
User,
|
||||
Shield,
|
||||
Settings2,
|
||||
ChevronRight,
|
||||
} from 'lucide-react';
|
||||
import { Card, CardContent } from '@components/molecules/Card';
|
||||
import { Breadcrumbs } from '@components/organisms/Breadcrumbs';
|
||||
|
||||
type SettingsSection = 'company' | 'users' | 'profile' | 'security' | 'system';
|
||||
|
||||
interface SettingsMenuItem {
|
||||
key: SettingsSection;
|
||||
label: string;
|
||||
description: string;
|
||||
icon: React.ReactNode;
|
||||
href: string;
|
||||
}
|
||||
|
||||
const menuItems: SettingsMenuItem[] = [
|
||||
{
|
||||
key: 'company',
|
||||
label: 'Empresa',
|
||||
description: 'Informacion general, logo y configuracion fiscal',
|
||||
icon: <Building2 className="h-6 w-6 text-blue-600" />,
|
||||
href: '/settings/company',
|
||||
},
|
||||
{
|
||||
key: 'users',
|
||||
label: 'Usuarios',
|
||||
description: 'Gestiona usuarios, roles y permisos',
|
||||
icon: <UsersIcon className="h-6 w-6 text-green-600" />,
|
||||
href: '/settings/users',
|
||||
},
|
||||
{
|
||||
key: 'profile',
|
||||
label: 'Mi perfil',
|
||||
description: 'Tu informacion personal y preferencias',
|
||||
icon: <User className="h-6 w-6 text-purple-600" />,
|
||||
href: '/settings/profile',
|
||||
},
|
||||
{
|
||||
key: 'security',
|
||||
label: 'Seguridad',
|
||||
description: 'Contrasena, autenticacion y logs de auditoria',
|
||||
icon: <Shield className="h-6 w-6 text-red-600" />,
|
||||
href: '/settings/security',
|
||||
},
|
||||
{
|
||||
key: 'system',
|
||||
label: 'Sistema',
|
||||
description: 'Configuracion avanzada del sistema',
|
||||
icon: <Settings2 className="h-6 w-6 text-gray-600" />,
|
||||
href: '/settings/system',
|
||||
},
|
||||
];
|
||||
|
||||
export function SettingsPage() {
|
||||
const [hoveredItem, setHoveredItem] = useState<SettingsSection | null>(null);
|
||||
|
||||
return (
|
||||
<div className="space-y-6 p-6">
|
||||
<Breadcrumbs items={[
|
||||
{ label: 'Configuracion' },
|
||||
]} />
|
||||
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">Configuracion</h1>
|
||||
<p className="text-sm text-gray-500">
|
||||
Gestiona la configuracion de tu empresa, usuarios y sistema
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{menuItems.map((item) => (
|
||||
<a
|
||||
key={item.key}
|
||||
href={item.href}
|
||||
onMouseEnter={() => setHoveredItem(item.key)}
|
||||
onMouseLeave={() => setHoveredItem(null)}
|
||||
>
|
||||
<Card className={`cursor-pointer transition-all h-full ${
|
||||
hoveredItem === item.key ? 'shadow-md ring-2 ring-blue-500' : 'hover:shadow-md'
|
||||
}`}>
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded-lg bg-gray-50">
|
||||
{item.icon}
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold text-gray-900">{item.label}</h3>
|
||||
<p className="text-sm text-gray-500 mt-1">{item.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
<ChevronRight className={`h-5 w-5 text-gray-400 transition-transform ${
|
||||
hoveredItem === item.key ? 'translate-x-1' : ''
|
||||
}`} />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default SettingsPage;
|
||||
448
src/pages/settings/UsersSettingsPage.tsx
Normal file
448
src/pages/settings/UsersSettingsPage.tsx
Normal file
@ -0,0 +1,448 @@
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
Users as UsersIcon,
|
||||
Plus,
|
||||
MoreVertical,
|
||||
Eye,
|
||||
Edit2,
|
||||
Trash2,
|
||||
Key,
|
||||
Mail,
|
||||
RefreshCw,
|
||||
Search,
|
||||
UserCheck,
|
||||
UserX,
|
||||
Shield,
|
||||
} from 'lucide-react';
|
||||
import { Button } from '@components/atoms/Button';
|
||||
import { Card, CardHeader, CardTitle, CardContent } from '@components/molecules/Card';
|
||||
import { DataTable, type Column } from '@components/organisms/DataTable';
|
||||
import { Dropdown, type DropdownItem } from '@components/organisms/Dropdown';
|
||||
import { Breadcrumbs } from '@components/organisms/Breadcrumbs';
|
||||
import { ConfirmModal } from '@components/organisms/Modal';
|
||||
import { NoDataEmptyState, ErrorEmptyState } from '@components/templates/EmptyState';
|
||||
import { useUsers } from '@features/settings/hooks';
|
||||
import type { User, UserRole } from '@features/settings/types';
|
||||
import { USER_ROLE_LABELS } from '@features/settings/types';
|
||||
import { formatDate } from '@utils/formatters';
|
||||
|
||||
const roleColors: Record<UserRole, string> = {
|
||||
admin: 'bg-red-100 text-red-700',
|
||||
manager: 'bg-blue-100 text-blue-700',
|
||||
user: 'bg-green-100 text-green-700',
|
||||
viewer: 'bg-gray-100 text-gray-700',
|
||||
};
|
||||
|
||||
export function UsersSettingsPage() {
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [selectedRole, setSelectedRole] = useState<UserRole | ''>('');
|
||||
const [showActiveOnly, setShowActiveOnly] = useState<boolean | undefined>(undefined);
|
||||
const [userToDelete, setUserToDelete] = useState<User | null>(null);
|
||||
const [userToToggle, setUserToToggle] = useState<User | null>(null);
|
||||
const [userToResetPassword, setUserToResetPassword] = useState<User | null>(null);
|
||||
|
||||
const {
|
||||
users,
|
||||
total,
|
||||
page,
|
||||
totalPages,
|
||||
isLoading,
|
||||
error,
|
||||
setFilters,
|
||||
refresh,
|
||||
remove,
|
||||
toggleActive,
|
||||
resetPassword,
|
||||
resendInvitation,
|
||||
} = useUsers({
|
||||
initialFilters: {
|
||||
search: searchTerm,
|
||||
role: selectedRole || undefined,
|
||||
isActive: showActiveOnly,
|
||||
},
|
||||
});
|
||||
|
||||
const handleSearch = (value: string) => {
|
||||
setSearchTerm(value);
|
||||
setFilters({ search: value });
|
||||
};
|
||||
|
||||
const handleRoleFilter = (role: UserRole | '') => {
|
||||
setSelectedRole(role);
|
||||
setFilters({ role: role || undefined });
|
||||
};
|
||||
|
||||
const handleActiveFilter = (active: boolean | undefined) => {
|
||||
setShowActiveOnly(active);
|
||||
setFilters({ isActive: active });
|
||||
};
|
||||
|
||||
const columns: Column<User>[] = [
|
||||
{
|
||||
key: 'name',
|
||||
header: 'Usuario',
|
||||
render: (user) => (
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-blue-100">
|
||||
{user.avatarUrl ? (
|
||||
<img src={user.avatarUrl} alt={user.name} className="h-10 w-10 rounded-full object-cover" />
|
||||
) : (
|
||||
<span className="text-sm font-medium text-blue-600">
|
||||
{user.name.split(' ').map(n => n[0]).join('').toUpperCase().slice(0, 2)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-medium text-gray-900">{user.name}</div>
|
||||
<div className="text-sm text-gray-500">{user.email}</div>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'role',
|
||||
header: 'Rol',
|
||||
render: (user) => (
|
||||
<span className={`inline-flex items-center gap-1 rounded-full px-2 py-1 text-xs font-medium ${roleColors[user.role]}`}>
|
||||
<Shield className="h-3 w-3" />
|
||||
{USER_ROLE_LABELS[user.role]}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'isActive',
|
||||
header: 'Estado',
|
||||
render: (user) => (
|
||||
<span className={`inline-flex items-center gap-1 rounded-full px-2 py-1 text-xs font-medium ${
|
||||
user.isActive ? 'bg-green-100 text-green-700' : 'bg-gray-100 text-gray-600'
|
||||
}`}>
|
||||
{user.isActive ? (
|
||||
<>
|
||||
<UserCheck className="h-3 w-3" />
|
||||
Activo
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<UserX className="h-3 w-3" />
|
||||
Inactivo
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'lastLoginAt',
|
||||
header: 'Ultimo acceso',
|
||||
render: (user) => (
|
||||
<span className="text-sm text-gray-600">
|
||||
{user.lastLoginAt ? formatDate(user.lastLoginAt, 'short') : 'Nunca'}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'createdAt',
|
||||
header: 'Creado',
|
||||
render: (user) => (
|
||||
<span className="text-sm text-gray-600">
|
||||
{formatDate(user.createdAt, 'short')}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'actions',
|
||||
header: '',
|
||||
render: (user) => {
|
||||
const items: DropdownItem[] = [
|
||||
{
|
||||
key: 'view',
|
||||
label: 'Ver detalle',
|
||||
icon: <Eye className="h-4 w-4" />,
|
||||
onClick: () => console.log('View', user.id),
|
||||
},
|
||||
{
|
||||
key: 'edit',
|
||||
label: 'Editar',
|
||||
icon: <Edit2 className="h-4 w-4" />,
|
||||
onClick: () => console.log('Edit', user.id),
|
||||
},
|
||||
{
|
||||
key: 'toggle',
|
||||
label: user.isActive ? 'Desactivar' : 'Activar',
|
||||
icon: user.isActive ? <UserX className="h-4 w-4" /> : <UserCheck className="h-4 w-4" />,
|
||||
onClick: () => setUserToToggle(user),
|
||||
},
|
||||
{
|
||||
key: 'resetPassword',
|
||||
label: 'Restablecer contrasena',
|
||||
icon: <Key className="h-4 w-4" />,
|
||||
onClick: () => setUserToResetPassword(user),
|
||||
},
|
||||
{
|
||||
key: 'resendInvitation',
|
||||
label: 'Reenviar invitacion',
|
||||
icon: <Mail className="h-4 w-4" />,
|
||||
onClick: () => resendInvitation(user.id),
|
||||
},
|
||||
{
|
||||
key: 'delete',
|
||||
label: 'Eliminar',
|
||||
icon: <Trash2 className="h-4 w-4" />,
|
||||
danger: true,
|
||||
onClick: () => setUserToDelete(user),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<Dropdown
|
||||
trigger={
|
||||
<button className="rounded p-1 hover:bg-gray-100">
|
||||
<MoreVertical className="h-4 w-4 text-gray-500" />
|
||||
</button>
|
||||
}
|
||||
items={items}
|
||||
align="right"
|
||||
/>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const handleDeleteUser = async () => {
|
||||
if (userToDelete) {
|
||||
await remove(userToDelete.id);
|
||||
setUserToDelete(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggleUser = async () => {
|
||||
if (userToToggle) {
|
||||
await toggleActive(userToToggle.id);
|
||||
setUserToToggle(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleResetPassword = async () => {
|
||||
if (userToResetPassword) {
|
||||
// Generate a temporary password or show a form
|
||||
const tempPassword = 'TempPass123!';
|
||||
await resetPassword(userToResetPassword.id, tempPassword);
|
||||
setUserToResetPassword(null);
|
||||
alert(`Contrasena restablecida. Nueva contrasena temporal: ${tempPassword}`);
|
||||
}
|
||||
};
|
||||
|
||||
// Stats
|
||||
const activeUsers = users.filter(u => u.isActive).length;
|
||||
const adminCount = users.filter(u => u.role === 'admin').length;
|
||||
const managerCount = users.filter(u => u.role === 'manager').length;
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="p-6">
|
||||
<ErrorEmptyState onRetry={refresh} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6 p-6">
|
||||
<Breadcrumbs items={[
|
||||
{ label: 'Configuracion', href: '/settings' },
|
||||
{ label: 'Usuarios' },
|
||||
]} />
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">Usuarios</h1>
|
||||
<p className="text-sm text-gray-500">
|
||||
Gestiona los usuarios de tu empresa
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" onClick={refresh} disabled={isLoading}>
|
||||
<RefreshCw className={`mr-2 h-4 w-4 ${isLoading ? 'animate-spin' : ''}`} />
|
||||
Actualizar
|
||||
</Button>
|
||||
<Button>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Nuevo usuario
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Summary Stats */}
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-4">
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-blue-100">
|
||||
<UsersIcon className="h-5 w-5 text-blue-600" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm text-gray-500">Total usuarios</div>
|
||||
<div className="text-xl font-bold text-blue-600">{total}</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="cursor-pointer hover:shadow-md transition-shadow" onClick={() => handleActiveFilter(true)}>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-green-100">
|
||||
<UserCheck className="h-5 w-5 text-green-600" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm text-gray-500">Activos</div>
|
||||
<div className="text-xl font-bold text-green-600">{activeUsers}</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="cursor-pointer hover:shadow-md transition-shadow" onClick={() => handleRoleFilter('admin')}>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-red-100">
|
||||
<Shield className="h-5 w-5 text-red-600" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm text-gray-500">Administradores</div>
|
||||
<div className="text-xl font-bold text-red-600">{adminCount}</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="cursor-pointer hover:shadow-md transition-shadow" onClick={() => handleRoleFilter('manager')}>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-purple-100">
|
||||
<UsersIcon className="h-5 w-5 text-purple-600" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm text-gray-500">Gerentes</div>
|
||||
<div className="text-xl font-bold text-purple-600">{managerCount}</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Lista de Usuarios</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
{/* Filters */}
|
||||
<div className="flex flex-wrap items-center gap-4">
|
||||
<div className="relative flex-1 min-w-[200px]">
|
||||
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-gray-400" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Buscar usuarios..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => handleSearch(e.target.value)}
|
||||
className="w-full rounded-md border border-gray-300 py-2 pl-10 pr-4 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<select
|
||||
value={selectedRole}
|
||||
onChange={(e) => handleRoleFilter(e.target.value as UserRole | '')}
|
||||
className="rounded-md border border-gray-300 px-3 py-2 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
|
||||
>
|
||||
<option value="">Todos los roles</option>
|
||||
{Object.entries(USER_ROLE_LABELS).map(([value, label]) => (
|
||||
<option key={value} value={value}>{label}</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
<select
|
||||
value={showActiveOnly === undefined ? '' : showActiveOnly.toString()}
|
||||
onChange={(e) => handleActiveFilter(e.target.value === '' ? undefined : e.target.value === 'true')}
|
||||
className="rounded-md border border-gray-300 px-3 py-2 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
|
||||
>
|
||||
<option value="">Todos los estados</option>
|
||||
<option value="true">Activos</option>
|
||||
<option value="false">Inactivos</option>
|
||||
</select>
|
||||
|
||||
{(searchTerm || selectedRole || showActiveOnly !== undefined) && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setSearchTerm('');
|
||||
setSelectedRole('');
|
||||
setShowActiveOnly(undefined);
|
||||
setFilters({});
|
||||
}}
|
||||
>
|
||||
Limpiar filtros
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Table */}
|
||||
{users.length === 0 && !isLoading ? (
|
||||
<NoDataEmptyState entityName="usuarios" />
|
||||
) : (
|
||||
<DataTable
|
||||
data={users}
|
||||
columns={columns}
|
||||
isLoading={isLoading}
|
||||
pagination={{
|
||||
page,
|
||||
totalPages,
|
||||
total,
|
||||
limit: 20,
|
||||
onPageChange: (p) => setFilters({ page: p }),
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Delete User Modal */}
|
||||
<ConfirmModal
|
||||
isOpen={!!userToDelete}
|
||||
onClose={() => setUserToDelete(null)}
|
||||
onConfirm={handleDeleteUser}
|
||||
title="Eliminar usuario"
|
||||
message={`¿Eliminar al usuario "${userToDelete?.name}"? Esta accion no se puede deshacer.`}
|
||||
variant="danger"
|
||||
confirmText="Eliminar"
|
||||
/>
|
||||
|
||||
{/* Toggle User Modal */}
|
||||
<ConfirmModal
|
||||
isOpen={!!userToToggle}
|
||||
onClose={() => setUserToToggle(null)}
|
||||
onConfirm={handleToggleUser}
|
||||
title={userToToggle?.isActive ? 'Desactivar usuario' : 'Activar usuario'}
|
||||
message={userToToggle?.isActive
|
||||
? `¿Desactivar al usuario "${userToToggle?.name}"? No podra acceder al sistema.`
|
||||
: `¿Activar al usuario "${userToToggle?.name}"? Podra acceder al sistema nuevamente.`
|
||||
}
|
||||
variant={userToToggle?.isActive ? 'warning' : 'success'}
|
||||
confirmText={userToToggle?.isActive ? 'Desactivar' : 'Activar'}
|
||||
/>
|
||||
|
||||
{/* Reset Password Modal */}
|
||||
<ConfirmModal
|
||||
isOpen={!!userToResetPassword}
|
||||
onClose={() => setUserToResetPassword(null)}
|
||||
onConfirm={handleResetPassword}
|
||||
title="Restablecer contrasena"
|
||||
message={`¿Restablecer la contrasena de "${userToResetPassword?.name}"? Se generara una contrasena temporal.`}
|
||||
variant="warning"
|
||||
confirmText="Restablecer"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default UsersSettingsPage;
|
||||
2
src/pages/settings/index.ts
Normal file
2
src/pages/settings/index.ts
Normal file
@ -0,0 +1,2 @@
|
||||
export { SettingsPage } from './SettingsPage';
|
||||
export { UsersSettingsPage } from './UsersSettingsPage';
|
||||
Loading…
Reference in New Issue
Block a user