From 4eb8ee2699f8dcebfc25fae20ca7d722f4bfc276 Mon Sep 17 00:00:00 2001 From: rckrdmrd Date: Sun, 18 Jan 2026 11:06:20 -0600 Subject: [PATCH] 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 --- src/features/reports/api/index.ts | 1 + src/features/reports/api/reports.api.ts | 226 +++++ src/features/reports/hooks/index.ts | 7 + src/features/reports/hooks/useReports.ts | 593 ++++++++++++ src/features/reports/index.ts | 10 + src/features/reports/types/index.ts | 1 + src/features/reports/types/reports.types.ts | 314 +++++++ src/features/settings/api/index.ts | 1 + src/features/settings/api/settings.api.ts | 199 ++++ src/features/settings/hooks/index.ts | 7 + src/features/settings/hooks/useSettings.ts | 492 ++++++++++ src/features/settings/index.ts | 10 + src/features/settings/types/index.ts | 1 + src/features/settings/types/settings.types.ts | 214 +++++ src/pages/reports/QuickReportsPage.tsx | 561 ++++++++++++ src/pages/reports/ReportsPage.tsx | 861 ++++++++++++++++++ src/pages/reports/index.ts | 2 + src/pages/settings/SettingsPage.tsx | 112 +++ src/pages/settings/UsersSettingsPage.tsx | 448 +++++++++ src/pages/settings/index.ts | 2 + 20 files changed, 4062 insertions(+) create mode 100644 src/features/reports/api/index.ts create mode 100644 src/features/reports/api/reports.api.ts create mode 100644 src/features/reports/hooks/index.ts create mode 100644 src/features/reports/hooks/useReports.ts create mode 100644 src/features/reports/index.ts create mode 100644 src/features/reports/types/index.ts create mode 100644 src/features/reports/types/reports.types.ts create mode 100644 src/features/settings/api/index.ts create mode 100644 src/features/settings/api/settings.api.ts create mode 100644 src/features/settings/hooks/index.ts create mode 100644 src/features/settings/hooks/useSettings.ts create mode 100644 src/features/settings/index.ts create mode 100644 src/features/settings/types/index.ts create mode 100644 src/features/settings/types/settings.types.ts create mode 100644 src/pages/reports/QuickReportsPage.tsx create mode 100644 src/pages/reports/ReportsPage.tsx create mode 100644 src/pages/reports/index.ts create mode 100644 src/pages/settings/SettingsPage.tsx create mode 100644 src/pages/settings/UsersSettingsPage.tsx create mode 100644 src/pages/settings/index.ts diff --git a/src/features/reports/api/index.ts b/src/features/reports/api/index.ts new file mode 100644 index 0000000..d75f785 --- /dev/null +++ b/src/features/reports/api/index.ts @@ -0,0 +1 @@ +export * from './reports.api'; diff --git a/src/features/reports/api/reports.api.ts b/src/features/reports/api/reports.api.ts new file mode 100644 index 0000000..a87fcb5 --- /dev/null +++ b/src/features/reports/api/reports.api.ts @@ -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 => { + 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(`${REPORTS_BASE}/definitions?${params}`); + return response.data; + }, + + getById: async (id: string): Promise => { + const response = await api.get(`${REPORTS_BASE}/definitions/${id}`); + return response.data; + }, + + getByCode: async (code: string): Promise => { + const response = await api.get(`${REPORTS_BASE}/definitions/code/${code}`); + return response.data; + }, + + create: async (data: ReportDefinitionCreateInput): Promise => { + const response = await api.post(`${REPORTS_BASE}/definitions`, data); + return response.data; + }, + + update: async (id: string, data: ReportDefinitionUpdateInput): Promise => { + const response = await api.patch(`${REPORTS_BASE}/definitions/${id}`, data); + return response.data; + }, + + delete: async (id: string): Promise => { + await api.delete(`${REPORTS_BASE}/definitions/${id}`); + }, + + toggleActive: async (id: string): Promise => { + const response = await api.post(`${REPORTS_BASE}/definitions/${id}/toggle`); + return response.data; + }, +}; + +// ============================================================================ +// Report Executions API +// ============================================================================ + +export const reportExecutionsApi = { + execute: async (data: ExecuteReportInput): Promise => { + const response = await api.post(`${REPORTS_BASE}/execute`, data); + return response.data; + }, + + getById: async (id: string): Promise => { + const response = await api.get(`${REPORTS_BASE}/executions/${id}`); + return response.data; + }, + + getRecent: async (filters: ReportExecutionFilters = {}): Promise => { + 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(`${REPORTS_BASE}/executions?${params}`); + return response.data; + }, + + cancel: async (id: string): Promise => { + const response = await api.post(`${REPORTS_BASE}/executions/${id}/cancel`); + return response.data; + }, + + download: async (id: string, format: ExportFormat): Promise => { + const response = await api.get(`${REPORTS_BASE}/executions/${id}/download/${format}`, { + responseType: 'blob', + }); + return response.data; + }, +}; + +// ============================================================================ +// Report Schedules API +// ============================================================================ + +export const reportSchedulesApi = { + getAll: async (filters: ReportScheduleFilters = {}): Promise => { + 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(`${REPORTS_BASE}/schedules?${params}`); + return response.data; + }, + + getById: async (id: string): Promise => { + const response = await api.get(`${REPORTS_BASE}/schedules/${id}`); + return response.data; + }, + + create: async (data: ReportScheduleCreateInput): Promise => { + const response = await api.post(`${REPORTS_BASE}/schedules`, data); + return response.data; + }, + + update: async (id: string, data: ReportScheduleUpdateInput): Promise => { + const response = await api.patch(`${REPORTS_BASE}/schedules/${id}`, data); + return response.data; + }, + + delete: async (id: string): Promise => { + await api.delete(`${REPORTS_BASE}/schedules/${id}`); + }, + + toggle: async (id: string): Promise => { + const response = await api.patch(`${REPORTS_BASE}/schedules/${id}/toggle`); + return response.data; + }, + + runNow: async (id: string): Promise => { + const response = await api.post(`${REPORTS_BASE}/schedules/${id}/run`); + return response.data; + }, +}; + +// ============================================================================ +// Quick Reports API +// ============================================================================ + +export const quickReportsApi = { + getTrialBalance: async (params: TrialBalanceParams): Promise => { + 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( + `${REPORTS_BASE}/quick/trial-balance?${queryParams}` + ); + return response.data; + }, + + getGeneralLedger: async (params: GeneralLedgerParams): Promise => { + 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( + `${REPORTS_BASE}/quick/general-ledger?${queryParams}` + ); + return response.data; + }, + + exportTrialBalance: async (params: TrialBalanceParams, format: ExportFormat): Promise => { + 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( + `${REPORTS_BASE}/quick/trial-balance/export?${queryParams}`, + { responseType: 'blob' } + ); + return response.data; + }, + + exportGeneralLedger: async (params: GeneralLedgerParams, format: ExportFormat): Promise => { + 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( + `${REPORTS_BASE}/quick/general-ledger/export?${queryParams}`, + { responseType: 'blob' } + ); + return response.data; + }, +}; diff --git a/src/features/reports/hooks/index.ts b/src/features/reports/hooks/index.ts new file mode 100644 index 0000000..f402ae3 --- /dev/null +++ b/src/features/reports/hooks/index.ts @@ -0,0 +1,7 @@ +export { + useReportDefinitions, + useReportExecutions, + useReportSchedules, + useQuickReports, + useReportExecution, +} from './useReports'; diff --git a/src/features/reports/hooks/useReports.ts b/src/features/reports/hooks/useReports.ts new file mode 100644 index 0000000..86ca241 --- /dev/null +++ b/src/features/reports/hooks/useReports.ts @@ -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; + getById: (id: string) => Promise; + getByCode: (code: string) => Promise; + create: (data: ReportDefinitionCreateInput) => Promise; + update: (id: string, data: ReportDefinitionUpdateInput) => Promise; + remove: (id: string) => Promise; + toggleActive: (id: string) => Promise; +} + +export function useReportDefinitions( + options: UseReportDefinitionsOptions = {} +): UseReportDefinitionsReturn { + const { initialFilters = {}, autoLoad = true } = options; + + const [definitions, setDefinitions] = useState([]); + const [total, setTotal] = useState(0); + const [page, setPage] = useState(1); + const [totalPages, setTotalPages] = useState(0); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + const [filters, setFilters] = useState(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 => { + return reportDefinitionsApi.getById(id); + }, []); + + const getByCode = useCallback(async (code: string): Promise => { + return reportDefinitionsApi.getByCode(code); + }, []); + + const create = useCallback(async (data: ReportDefinitionCreateInput): Promise => { + const result = await reportDefinitionsApi.create(data); + await fetchDefinitions(); + return result; + }, [fetchDefinitions]); + + const update = useCallback(async ( + id: string, + data: ReportDefinitionUpdateInput + ): Promise => { + const result = await reportDefinitionsApi.update(id, data); + await fetchDefinitions(); + return result; + }, [fetchDefinitions]); + + const remove = useCallback(async (id: string): Promise => { + await reportDefinitionsApi.delete(id); + await fetchDefinitions(); + }, [fetchDefinitions]); + + const toggleActive = useCallback(async (id: string): Promise => { + 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; + execute: (input: ExecuteReportInput) => Promise; + getById: (id: string) => Promise; + cancel: (id: string) => Promise; + download: (id: string, format: ExportFormat) => Promise; +} + +export function useReportExecutions( + options: UseReportExecutionsOptions = {} +): UseReportExecutionsReturn { + const { initialFilters = {}, autoLoad = true, pollInterval } = options; + + const [executions, setExecutions] = useState([]); + 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(null); + const [filters, setFilters] = useState(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 => { + 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 => { + return reportExecutionsApi.getById(id); + }, []); + + const cancel = useCallback(async (id: string): Promise => { + const result = await reportExecutionsApi.cancel(id); + await fetchExecutions(); + return result; + }, [fetchExecutions]); + + const download = useCallback(async (id: string, format: ExportFormat): Promise => { + 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; + getById: (id: string) => Promise; + create: (data: ReportScheduleCreateInput) => Promise; + update: (id: string, data: ReportScheduleUpdateInput) => Promise; + remove: (id: string) => Promise; + toggle: (id: string) => Promise; + runNow: (id: string) => Promise; +} + +export function useReportSchedules( + options: UseReportSchedulesOptions = {} +): UseReportSchedulesReturn { + const { initialFilters = {}, autoLoad = true } = options; + + const [schedules, setSchedules] = useState([]); + const [total, setTotal] = useState(0); + const [page, setPage] = useState(1); + const [totalPages, setTotalPages] = useState(0); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + const [filters, setFilters] = useState(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 => { + return reportSchedulesApi.getById(id); + }, []); + + const create = useCallback(async (data: ReportScheduleCreateInput): Promise => { + const result = await reportSchedulesApi.create(data); + await fetchSchedules(); + return result; + }, [fetchSchedules]); + + const update = useCallback(async ( + id: string, + data: ReportScheduleUpdateInput + ): Promise => { + const result = await reportSchedulesApi.update(id, data); + await fetchSchedules(); + return result; + }, [fetchSchedules]); + + const remove = useCallback(async (id: string): Promise => { + await reportSchedulesApi.delete(id); + await fetchSchedules(); + }, [fetchSchedules]); + + const toggle = useCallback(async (id: string): Promise => { + const result = await reportSchedulesApi.toggle(id); + await fetchSchedules(); + return result; + }, [fetchSchedules]); + + const runNow = useCallback(async (id: string): Promise => { + 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; + exportTrialBalance: (params: TrialBalanceParams, format: ExportFormat) => Promise; + + // General Ledger + generalLedger: GeneralLedgerResult | null; + isLoadingGeneralLedger: boolean; + generalLedgerError: Error | null; + getGeneralLedger: (params: GeneralLedgerParams) => Promise; + exportGeneralLedger: (params: GeneralLedgerParams, format: ExportFormat) => Promise; + + // General + clearResults: () => void; +} + +export function useQuickReports(): UseQuickReportsReturn { + const [trialBalance, setTrialBalance] = useState(null); + const [isLoadingTrialBalance, setIsLoadingTrialBalance] = useState(false); + const [trialBalanceError, setTrialBalanceError] = useState(null); + + const [generalLedger, setGeneralLedger] = useState(null); + const [isLoadingGeneralLedger, setIsLoadingGeneralLedger] = useState(false); + const [generalLedgerError, setGeneralLedgerError] = useState(null); + + const getTrialBalance = useCallback(async (params: TrialBalanceParams): Promise => { + 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 => { + 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 => { + 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 => { + 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; + cancel: () => Promise; + download: (format: ExportFormat) => Promise; +} + +export function useReportExecution( + options: UseReportExecutionOptions +): UseReportExecutionReturn { + const { executionId, pollInterval = 2000 } = options; + + const [execution, setExecution] = useState(null); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(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, + }; +} diff --git a/src/features/reports/index.ts b/src/features/reports/index.ts new file mode 100644 index 0000000..e39b52d --- /dev/null +++ b/src/features/reports/index.ts @@ -0,0 +1,10 @@ +// Reports Feature - Barrel Export + +// Types +export * from './types'; + +// API +export * from './api'; + +// Hooks +export * from './hooks'; diff --git a/src/features/reports/types/index.ts b/src/features/reports/types/index.ts new file mode 100644 index 0000000..1dd18c8 --- /dev/null +++ b/src/features/reports/types/index.ts @@ -0,0 +1 @@ +export * from './reports.types'; diff --git a/src/features/reports/types/reports.types.ts b/src/features/reports/types/reports.types.ts new file mode 100644 index 0000000..b259980 --- /dev/null +++ b/src/features/reports/types/reports.types.ts @@ -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; + columnsConfig: ColumnConfig[]; + groupingOptions: string[]; + totalsConfig: Record; + 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; + columnsConfig?: ColumnConfig[]; + exportFormats?: ExportFormat[]; + requiredPermissions?: string[]; +} + +export interface ReportDefinitionUpdateInput extends Partial { + 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; + status: ExecutionStatus; + startedAt?: string; + completedAt?: string; + executionTimeMs?: number; + rowCount?: number; + resultData: any[]; + resultSummary?: Record; + outputFiles: OutputFile[]; + errorMessage?: string; + errorDetails?: Record; + 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; + 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; + 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; + cronExpression: string; + timezone?: string; + deliveryMethod?: DeliveryMethod; + deliveryConfig?: DeliveryConfig; +} + +export interface ReportScheduleUpdateInput extends Partial { + 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 = { + financial: 'Financiero', + accounting: 'Contable', + tax: 'Fiscal', + management: 'Gerencial', + custom: 'Personalizado', +}; + +export const EXECUTION_STATUS_LABELS: Record = { + pending: 'Pendiente', + running: 'Ejecutando', + completed: 'Completado', + failed: 'Fallido', + cancelled: 'Cancelado', +}; + +export const DELIVERY_METHOD_LABELS: Record = { + none: 'Sin entrega', + email: 'Correo electronico', + storage: 'Almacenamiento', + webhook: 'Webhook', +}; diff --git a/src/features/settings/api/index.ts b/src/features/settings/api/index.ts new file mode 100644 index 0000000..54321f3 --- /dev/null +++ b/src/features/settings/api/index.ts @@ -0,0 +1 @@ +export * from './settings.api'; diff --git a/src/features/settings/api/settings.api.ts b/src/features/settings/api/settings.api.ts new file mode 100644 index 0000000..ca0630e --- /dev/null +++ b/src/features/settings/api/settings.api.ts @@ -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 => { + const response = await api.get(`${SETTINGS_BASE}/company`); + return response.data; + }, + + update: async (data: CompanyUpdateInput): Promise => { + const response = await api.patch(`${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 => { + await api.delete(`${SETTINGS_BASE}/company/logo`); + }, +}; + +// ============================================================================ +// Users API +// ============================================================================ + +export const usersApi = { + getAll: async (filters: UsersFilters = {}): Promise => { + 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(`${SETTINGS_BASE}/users?${params}`); + return response.data; + }, + + getById: async (id: string): Promise => { + const response = await api.get(`${SETTINGS_BASE}/users/${id}`); + return response.data; + }, + + create: async (data: UserCreateInput): Promise => { + const response = await api.post(`${SETTINGS_BASE}/users`, data); + return response.data; + }, + + update: async (id: string, data: UserUpdateInput): Promise => { + const response = await api.patch(`${SETTINGS_BASE}/users/${id}`, data); + return response.data; + }, + + delete: async (id: string): Promise => { + await api.delete(`${SETTINGS_BASE}/users/${id}`); + }, + + toggleActive: async (id: string): Promise => { + const response = await api.post(`${SETTINGS_BASE}/users/${id}/toggle-active`); + return response.data; + }, + + resetPassword: async (id: string, newPassword: string): Promise => { + await api.post(`${SETTINGS_BASE}/users/${id}/reset-password`, { password: newPassword }); + }, + + resendInvitation: async (id: string): Promise => { + await api.post(`${SETTINGS_BASE}/users/${id}/resend-invitation`); + }, +}; + +// ============================================================================ +// Profile API (Current User) +// ============================================================================ + +export const profileApi = { + get: async (): Promise => { + const response = await api.get(`${SETTINGS_BASE}/profile`); + return response.data; + }, + + update: async (data: ProfileUpdateInput): Promise => { + const response = await api.patch(`${SETTINGS_BASE}/profile`, data); + return response.data; + }, + + changePassword: async (data: ChangePasswordInput): Promise => { + 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 => { + await api.delete(`${SETTINGS_BASE}/profile/avatar`); + }, +}; + +// ============================================================================ +// System Settings API +// ============================================================================ + +export const systemSettingsApi = { + getAll: async (module?: string): Promise => { + const params = module ? `?module=${module}` : ''; + const response = await api.get(`${SETTINGS_BASE}/system${params}`); + return response.data; + }, + + get: async (module: string, key: string): Promise => { + const response = await api.get(`${SETTINGS_BASE}/system/${module}/${key}`); + return response.data; + }, + + update: async (module: string, key: string, data: SystemSettingUpdateInput): Promise => { + const response = await api.patch(`${SETTINGS_BASE}/system/${module}/${key}`, data); + return response.data; + }, + + reset: async (module: string, key: string): Promise => { + const response = await api.post(`${SETTINGS_BASE}/system/${module}/${key}/reset`); + return response.data; + }, +}; + +// ============================================================================ +// Audit Logs API +// ============================================================================ + +export const auditLogsApi = { + getAll: async (filters: AuditLogFilters = {}): Promise => { + 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(`${SETTINGS_BASE}/audit-logs?${params}`); + return response.data; + }, + + getById: async (id: string): Promise => { + const response = await api.get(`${SETTINGS_BASE}/audit-logs/${id}`); + return response.data; + }, + + export: async (filters: AuditLogFilters = {}): Promise => { + 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(`${SETTINGS_BASE}/audit-logs/export?${params}`, { + responseType: 'blob', + }); + return response.data; + }, +}; diff --git a/src/features/settings/hooks/index.ts b/src/features/settings/hooks/index.ts new file mode 100644 index 0000000..ce1fff7 --- /dev/null +++ b/src/features/settings/hooks/index.ts @@ -0,0 +1,7 @@ +export { + useCompany, + useUsers, + useProfile, + useSystemSettings, + useAuditLogs, +} from './useSettings'; diff --git a/src/features/settings/hooks/useSettings.ts b/src/features/settings/hooks/useSettings.ts new file mode 100644 index 0000000..a902f71 --- /dev/null +++ b/src/features/settings/hooks/useSettings.ts @@ -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; + update: (data: CompanyUpdateInput) => Promise; + uploadLogo: (file: File) => Promise; + removeLogo: () => Promise; +} + +export function useCompany(): UseCompanyReturn { + const [company, setCompany] = useState(null); + const [isLoading, setIsLoading] = useState(true); + const [isSaving, setIsSaving] = useState(false); + const [error, setError] = useState(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 => { + 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 => { + 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 => { + 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; + getById: (id: string) => Promise; + create: (data: UserCreateInput) => Promise; + update: (id: string, data: UserUpdateInput) => Promise; + remove: (id: string) => Promise; + toggleActive: (id: string) => Promise; + resetPassword: (id: string, newPassword: string) => Promise; + resendInvitation: (id: string) => Promise; +} + +export function useUsers(options: UseUsersOptions = {}): UseUsersReturn { + const { initialFilters = {}, autoLoad = true } = options; + + const [users, setUsers] = useState([]); + const [total, setTotal] = useState(0); + const [page, setPage] = useState(1); + const [totalPages, setTotalPages] = useState(0); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + const [filters, setFilters] = useState(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 => { + return usersApi.getById(id); + }, []); + + const create = useCallback(async (data: UserCreateInput): Promise => { + const result = await usersApi.create(data); + await fetchUsers(); + return result; + }, [fetchUsers]); + + const update = useCallback(async (id: string, data: UserUpdateInput): Promise => { + const result = await usersApi.update(id, data); + await fetchUsers(); + return result; + }, [fetchUsers]); + + const remove = useCallback(async (id: string): Promise => { + await usersApi.delete(id); + await fetchUsers(); + }, [fetchUsers]); + + const toggleActive = useCallback(async (id: string): Promise => { + const result = await usersApi.toggleActive(id); + await fetchUsers(); + return result; + }, [fetchUsers]); + + const resetPassword = useCallback(async (id: string, newPassword: string): Promise => { + await usersApi.resetPassword(id, newPassword); + }, []); + + const resendInvitation = useCallback(async (id: string): Promise => { + 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; + update: (data: ProfileUpdateInput) => Promise; + changePassword: (data: ChangePasswordInput) => Promise; + uploadAvatar: (file: File) => Promise; + removeAvatar: () => Promise; +} + +export function useProfile(): UseProfileReturn { + const [profile, setProfile] = useState(null); + const [isLoading, setIsLoading] = useState(true); + const [isSaving, setIsSaving] = useState(false); + const [error, setError] = useState(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 => { + 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 => { + 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 => { + 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 => { + 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; + get: (module: string, key: string) => Promise; + update: (module: string, key: string, data: SystemSettingUpdateInput) => Promise; + reset: (module: string, key: string) => Promise; +} + +export function useSystemSettings(options: UseSystemSettingsOptions = {}): UseSystemSettingsReturn { + const { module, autoLoad = true } = options; + + const [settings, setSettings] = useState([]); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(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 => { + return systemSettingsApi.get(mod, key); + }, []); + + const update = useCallback(async ( + mod: string, + key: string, + data: SystemSettingUpdateInput + ): Promise => { + const result = await systemSettingsApi.update(mod, key, data); + await fetchSettings(); + return result; + }, [fetchSettings]); + + const reset = useCallback(async (mod: string, key: string): Promise => { + 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; + getById: (id: string) => Promise; + exportLogs: () => Promise; +} + +export function useAuditLogs(options: UseAuditLogsOptions = {}): UseAuditLogsReturn { + const { initialFilters = {}, autoLoad = true } = options; + + const [logs, setLogs] = useState([]); + const [total, setTotal] = useState(0); + const [page, setPage] = useState(1); + const [totalPages, setTotalPages] = useState(0); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + const [filters, setFilters] = useState(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 => { + return auditLogsApi.getById(id); + }, []); + + const exportLogs = useCallback(async (): Promise => { + 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, + }; +} diff --git a/src/features/settings/index.ts b/src/features/settings/index.ts new file mode 100644 index 0000000..df65f04 --- /dev/null +++ b/src/features/settings/index.ts @@ -0,0 +1,10 @@ +// Settings Feature - Barrel Export + +// Types +export * from './types'; + +// API +export * from './api'; + +// Hooks +export * from './hooks'; diff --git a/src/features/settings/types/index.ts b/src/features/settings/types/index.ts new file mode 100644 index 0000000..fb37245 --- /dev/null +++ b/src/features/settings/types/index.ts @@ -0,0 +1 @@ +export * from './settings.types'; diff --git a/src/features/settings/types/settings.types.ts b/src/features/settings/types/settings.types.ts new file mode 100644 index 0000000..baea396 --- /dev/null +++ b/src/features/settings/types/settings.types.ts @@ -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; +} + +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; + newValues?: Record; + 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 = { + admin: 'Administrador', + manager: 'Gerente', + user: 'Usuario', + viewer: 'Visualizador', +}; + +export const AUDIT_ACTION_LABELS: Record = { + create: 'Creacion', + update: 'Actualizacion', + delete: 'Eliminacion', + login: 'Inicio de sesion', + logout: 'Cierre de sesion', + export: 'Exportacion', + import: 'Importacion', +}; + +export const THEME_LABELS: Record = { + light: 'Claro', + dark: 'Oscuro', + system: 'Sistema', +}; diff --git a/src/pages/reports/QuickReportsPage.tsx b/src/pages/reports/QuickReportsPage.tsx new file mode 100644 index 0000000..c5c5cab --- /dev/null +++ b/src/pages/reports/QuickReportsPage.tsx @@ -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('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[] = [ + { + key: 'accountCode', + header: 'Codigo', + render: (row) => ( + {row.accountCode} + ), + }, + { + key: 'accountName', + header: 'Cuenta', + render: (row) => ( + {row.accountName} + ), + }, + { + key: 'accountType', + header: 'Tipo', + render: (row) => ( + {row.accountType} + ), + }, + { + key: 'openingDebit', + header: 'Saldo Inicial Debe', + render: (row) => ( + + ${formatCurrency(row.openingDebit)} + + ), + }, + { + key: 'openingCredit', + header: 'Saldo Inicial Haber', + render: (row) => ( + + ${formatCurrency(row.openingCredit)} + + ), + }, + { + key: 'periodDebit', + header: 'Movimientos Debe', + render: (row) => ( + + ${formatCurrency(row.periodDebit)} + + ), + }, + { + key: 'periodCredit', + header: 'Movimientos Haber', + render: (row) => ( + + ${formatCurrency(row.periodCredit)} + + ), + }, + { + key: 'closingDebit', + header: 'Saldo Final Debe', + render: (row) => ( + + ${formatCurrency(row.closingDebit)} + + ), + }, + { + key: 'closingCredit', + header: 'Saldo Final Haber', + render: (row) => ( + + ${formatCurrency(row.closingCredit)} + + ), + }, + ]; + + const generalLedgerColumns: Column[] = [ + { + key: 'date', + header: 'Fecha', + render: (row) => ( + {row.date} + ), + }, + { + key: 'journalEntryNumber', + header: 'Asiento', + render: (row) => ( + {row.journalEntryNumber} + ), + }, + { + key: 'description', + header: 'Descripcion', + render: (row) => ( +
+ {row.description} + {row.reference && ( + Ref: {row.reference} + )} +
+ ), + }, + { + key: 'debit', + header: 'Debe', + render: (row) => ( + 0 ? 'text-green-600 font-medium' : 'text-gray-400'}`}> + ${formatCurrency(row.debit)} + + ), + }, + { + key: 'credit', + header: 'Haber', + render: (row) => ( + 0 ? 'text-red-600 font-medium' : 'text-gray-400'}`}> + ${formatCurrency(row.credit)} + + ), + }, + { + key: 'balance', + header: 'Saldo', + render: (row) => ( + = 0 ? 'text-gray-900' : 'text-red-600'}`}> + ${formatCurrency(row.balance)} + + ), + }, + ]; + + const isLoading = activeView === 'trial-balance' ? isLoadingTrialBalance : isLoadingGeneralLedger; + const error = activeView === 'trial-balance' ? trialBalanceError : generalLedgerError; + + if (error) { + return ( +
+ +
+ ); + } + + return ( +
+ + +
+
+

Reportes Rapidos

+

+ Genera Balance de Comprobacion y Libro Mayor de forma inmediata +

+
+
+ + {/* Report Type Selector */} +
+ setActiveView('trial-balance')} + > + +
+
+ +
+
+
+ Balance de Comprobacion +
+
+ Saldos iniciales, movimientos y saldos finales +
+
+
+
+
+ + setActiveView('general-ledger')} + > + +
+
+ +
+
+
+ Libro Mayor +
+
+ Movimientos detallados por cuenta +
+
+
+
+
+
+ + {/* Filters */} + + + + + Parametros del Reporte + + + +
+
+ + +
+ +
+ + 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" + /> +
+ +
+ + 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" + /> +
+ + {activeView === 'trial-balance' && ( +
+ +
+ )} + + {activeView === 'general-ledger' && ( +
+ + 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" + /> +
+ )} +
+ +
+ + + {(trialBalance || generalLedger) && ( + <> + + + + + )} +
+
+
+ + {/* Results */} + {activeView === 'trial-balance' && trialBalance && ( + + +
+ Balance de Comprobacion + + Generado: {trialBalance.generatedAt} + +
+
+ +
+ {/* Summary */} +
+
+
Saldo Inicial Debe
+
${formatCurrency(trialBalance.totals.openingDebit)}
+
+
+
Saldo Inicial Haber
+
${formatCurrency(trialBalance.totals.openingCredit)}
+
+
+
Movimientos Debe
+
${formatCurrency(trialBalance.totals.periodDebit)}
+
+
+
Movimientos Haber
+
${formatCurrency(trialBalance.totals.periodCredit)}
+
+
+
Saldo Final Debe
+
${formatCurrency(trialBalance.totals.closingDebit)}
+
+
+
Saldo Final Haber
+
${formatCurrency(trialBalance.totals.closingCredit)}
+
+
+ + +
+
+
+ )} + + {activeView === 'general-ledger' && generalLedger && ( + + +
+
+ Libro Mayor +

+ {generalLedger.accountCode} - {generalLedger.accountName} +

+
+ + Generado: {generalLedger.generatedAt} + +
+
+ +
+ {/* Summary */} +
+
+
Saldo Inicial
+
${formatCurrency(generalLedger.openingBalance)}
+
+
+
Total Debe
+
${formatCurrency(generalLedger.totalDebit)}
+
+
+
Total Haber
+
${formatCurrency(generalLedger.totalCredit)}
+
+
+
Saldo Final
+
= 0 ? 'text-gray-900' : 'text-red-600'}`}> + ${formatCurrency(generalLedger.closingBalance)} +
+
+
+ + +
+
+
+ )} +
+ ); +} + +export default QuickReportsPage; diff --git a/src/pages/reports/ReportsPage.tsx b/src/pages/reports/ReportsPage.tsx new file mode 100644 index 0000000..b0c54cd --- /dev/null +++ b/src/pages/reports/ReportsPage.tsx @@ -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 = { + 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 = { + 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('definitions'); + const [searchTerm, setSearchTerm] = useState(''); + const [selectedType, setSelectedType] = useState(''); + const [selectedStatus, setSelectedStatus] = useState(''); + const [definitionToDelete, setDefinitionToDelete] = useState(null); + const [executionToCancel, setExecutionToCancel] = useState(null); + const [scheduleToDelete, setScheduleToDelete] = useState(null); + const [scheduleToToggle, setScheduleToToggle] = useState(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[] = [ + { + key: 'name', + header: 'Reporte', + render: (def) => ( +
+
+ +
+
+
{def.name}
+
{def.code}
+
+
+ ), + }, + { + key: 'reportType', + header: 'Tipo', + render: (def) => ( + + {REPORT_TYPE_LABELS[def.reportType]} + + ), + }, + { + key: 'category', + header: 'Categoria', + render: (def) => ( + {def.category || '-'} + ), + }, + { + key: 'isActive', + header: 'Estado', + render: (def) => ( + + {def.isActive ? ( + <> + + Activo + + ) : ( + <> + + Inactivo + + )} + + ), + }, + { + key: 'isSystem', + header: 'Sistema', + render: (def) => ( + + {def.isSystem ? 'Si' : 'No'} + + ), + }, + { + key: 'actions', + header: '', + render: (def) => { + const items: DropdownItem[] = [ + { + key: 'view', + label: 'Ver detalle', + icon: , + onClick: () => console.log('View', def.id), + }, + { + key: 'execute', + label: 'Ejecutar', + icon: , + onClick: () => console.log('Execute', def.id), + }, + ]; + + if (!def.isSystem) { + items.push( + { + key: 'edit', + label: 'Editar', + icon: , + onClick: () => console.log('Edit', def.id), + }, + { + key: 'toggle', + label: def.isActive ? 'Desactivar' : 'Activar', + icon: def.isActive ? : , + onClick: () => toggleDefinitionActive(def.id), + }, + { + key: 'delete', + label: 'Eliminar', + icon: , + danger: true, + onClick: () => setDefinitionToDelete(def), + } + ); + } + + return ( + + + + } + items={items} + align="right" + /> + ); + }, + }, + ]; + + // Execution columns + const executionColumns: Column[] = [ + { + key: 'report', + header: 'Reporte', + render: (exec) => ( +
+
+ +
+
+
{exec.definitionName || 'Reporte'}
+
{exec.definitionCode || exec.definitionId}
+
+
+ ), + }, + { + key: 'status', + header: 'Estado', + render: (exec) => ( + + {exec.status === 'running' && } + {exec.status === 'completed' && } + {exec.status === 'failed' && } + {EXECUTION_STATUS_LABELS[exec.status]} + + ), + }, + { + key: 'startedAt', + header: 'Iniciado', + render: (exec) => ( + + {exec.startedAt ? formatDate(exec.startedAt, 'short') : '-'} + + ), + }, + { + key: 'completedAt', + header: 'Completado', + render: (exec) => ( + + {exec.completedAt ? formatDate(exec.completedAt, 'short') : '-'} + + ), + }, + { + key: 'duration', + header: 'Duracion', + render: (exec) => ( + + {exec.executionTimeMs ? `${(exec.executionTimeMs / 1000).toFixed(2)}s` : '-'} + + ), + }, + { + key: 'rows', + header: 'Filas', + render: (exec) => ( + + {exec.rowCount?.toLocaleString() || '-'} + + ), + }, + { + key: 'requestedBy', + header: 'Solicitado por', + render: (exec) => ( + + {exec.requestedByName || exec.requestedBy} + + ), + }, + { + key: 'actions', + header: '', + render: (exec) => { + const items: DropdownItem[] = [ + { + key: 'view', + label: 'Ver detalle', + icon: , + 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: , + onClick: () => downloadExecution(exec.id, file.format), + }); + }); + } + + if (exec.status === 'pending' || exec.status === 'running') { + items.push({ + key: 'cancel', + label: 'Cancelar', + icon: , + danger: true, + onClick: () => setExecutionToCancel(exec), + }); + } + + return ( + + + + } + items={items} + align="right" + /> + ); + }, + }, + ]; + + // Schedule columns + const scheduleColumns: Column[] = [ + { + key: 'name', + header: 'Programacion', + render: (sched) => ( +
+
+ +
+
+
{sched.name}
+
{sched.definitionName}
+
+
+ ), + }, + { + key: 'cronExpression', + header: 'Frecuencia', + render: (sched) => ( + + {sched.cronExpression} + + ), + }, + { + key: 'isActive', + header: 'Estado', + render: (sched) => ( + + {sched.isActive ? 'Activo' : 'Inactivo'} + + ), + }, + { + key: 'lastRunAt', + header: 'Ultima ejecucion', + render: (sched) => ( + + {sched.lastRunAt ? formatDate(sched.lastRunAt, 'short') : 'Nunca'} + + ), + }, + { + key: 'nextRunAt', + header: 'Proxima ejecucion', + render: (sched) => ( + + {sched.nextRunAt ? formatDate(sched.nextRunAt, 'short') : '-'} + + ), + }, + { + key: 'actions', + header: '', + render: (sched) => { + const items: DropdownItem[] = [ + { + key: 'view', + label: 'Ver detalle', + icon: , + onClick: () => console.log('View', sched.id), + }, + { + key: 'edit', + label: 'Editar', + icon: , + onClick: () => console.log('Edit', sched.id), + }, + { + key: 'runNow', + label: 'Ejecutar ahora', + icon: , + onClick: () => runScheduleNow(sched.id), + }, + { + key: 'toggle', + label: sched.isActive ? 'Pausar' : 'Activar', + icon: sched.isActive ? : , + onClick: () => setScheduleToToggle(sched), + }, + { + key: 'delete', + label: 'Eliminar', + icon: , + danger: true, + onClick: () => setScheduleToDelete(sched), + }, + ]; + + return ( + + + + } + 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 ( +
+ +
+ ); + } + + return ( +
+ + +
+
+

Reportes

+

+ Gestiona definiciones, ejecuciones y programaciones de reportes +

+
+
+ + {activeTab === 'definitions' && ( + + )} + {activeTab === 'schedules' && ( + + )} +
+
+ + {/* Summary Stats */} +
+ setActiveTab('definitions')}> + +
+
+ +
+
+
Definiciones activas
+
{activeDefinitions}
+
+
+
+
+ + setActiveTab('executions')}> + +
+
+ 0 ? 'animate-spin' : ''}`} /> +
+
+
En ejecucion
+
{runningExecutions}
+
+
+
+
+ + + +
+
+ +
+
+
Completados hoy
+
{completedToday}
+
+
+
+
+ + setActiveTab('schedules')}> + +
+
+ +
+
+
Programaciones
+
{activeSchedules}
+
+
+
+
+
+ + {/* Tabs */} +
+ +
+ + {/* Content based on active tab */} + + + + {activeTab === 'definitions' && 'Definiciones de Reportes'} + {activeTab === 'executions' && 'Historial de Ejecuciones'} + {activeTab === 'schedules' && 'Programaciones'} + + + +
+ {/* Filters */} +
+
+ + { + 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" + /> +
+ + {activeTab === 'definitions' && ( + + )} + + {activeTab === 'executions' && ( + + )} + + {(searchTerm || selectedType || selectedStatus) && ( + + )} +
+ + {/* Tables */} + {activeTab === 'definitions' && ( + definitions.length === 0 && !definitionsLoading ? ( + + ) : ( + setDefinitionsFilters({ page: p }), + }} + /> + ) + )} + + {activeTab === 'executions' && ( + executions.length === 0 && !executionsLoading ? ( + + ) : ( + {}, + }} + /> + ) + )} + + {activeTab === 'schedules' && ( + schedules.length === 0 && !schedulesLoading ? ( + + ) : ( + {}, + }} + /> + ) + )} +
+
+
+ + {/* Delete Definition Modal */} + 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 */} + 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 */} + 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 */} + 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'} + /> +
+ ); +} + +export default ReportsPage; diff --git a/src/pages/reports/index.ts b/src/pages/reports/index.ts new file mode 100644 index 0000000..cc4a256 --- /dev/null +++ b/src/pages/reports/index.ts @@ -0,0 +1,2 @@ +export { ReportsPage } from './ReportsPage'; +export { QuickReportsPage } from './QuickReportsPage'; diff --git a/src/pages/settings/SettingsPage.tsx b/src/pages/settings/SettingsPage.tsx new file mode 100644 index 0000000..17a451c --- /dev/null +++ b/src/pages/settings/SettingsPage.tsx @@ -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: , + href: '/settings/company', + }, + { + key: 'users', + label: 'Usuarios', + description: 'Gestiona usuarios, roles y permisos', + icon: , + href: '/settings/users', + }, + { + key: 'profile', + label: 'Mi perfil', + description: 'Tu informacion personal y preferencias', + icon: , + href: '/settings/profile', + }, + { + key: 'security', + label: 'Seguridad', + description: 'Contrasena, autenticacion y logs de auditoria', + icon: , + href: '/settings/security', + }, + { + key: 'system', + label: 'Sistema', + description: 'Configuracion avanzada del sistema', + icon: , + href: '/settings/system', + }, +]; + +export function SettingsPage() { + const [hoveredItem, setHoveredItem] = useState(null); + + return ( +
+ + +
+

Configuracion

+

+ Gestiona la configuracion de tu empresa, usuarios y sistema +

+
+ + +
+ ); +} + +export default SettingsPage; diff --git a/src/pages/settings/UsersSettingsPage.tsx b/src/pages/settings/UsersSettingsPage.tsx new file mode 100644 index 0000000..f248082 --- /dev/null +++ b/src/pages/settings/UsersSettingsPage.tsx @@ -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 = { + 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(''); + const [showActiveOnly, setShowActiveOnly] = useState(undefined); + const [userToDelete, setUserToDelete] = useState(null); + const [userToToggle, setUserToToggle] = useState(null); + const [userToResetPassword, setUserToResetPassword] = useState(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[] = [ + { + key: 'name', + header: 'Usuario', + render: (user) => ( +
+
+ {user.avatarUrl ? ( + {user.name} + ) : ( + + {user.name.split(' ').map(n => n[0]).join('').toUpperCase().slice(0, 2)} + + )} +
+
+
{user.name}
+
{user.email}
+
+
+ ), + }, + { + key: 'role', + header: 'Rol', + render: (user) => ( + + + {USER_ROLE_LABELS[user.role]} + + ), + }, + { + key: 'isActive', + header: 'Estado', + render: (user) => ( + + {user.isActive ? ( + <> + + Activo + + ) : ( + <> + + Inactivo + + )} + + ), + }, + { + key: 'lastLoginAt', + header: 'Ultimo acceso', + render: (user) => ( + + {user.lastLoginAt ? formatDate(user.lastLoginAt, 'short') : 'Nunca'} + + ), + }, + { + key: 'createdAt', + header: 'Creado', + render: (user) => ( + + {formatDate(user.createdAt, 'short')} + + ), + }, + { + key: 'actions', + header: '', + render: (user) => { + const items: DropdownItem[] = [ + { + key: 'view', + label: 'Ver detalle', + icon: , + onClick: () => console.log('View', user.id), + }, + { + key: 'edit', + label: 'Editar', + icon: , + onClick: () => console.log('Edit', user.id), + }, + { + key: 'toggle', + label: user.isActive ? 'Desactivar' : 'Activar', + icon: user.isActive ? : , + onClick: () => setUserToToggle(user), + }, + { + key: 'resetPassword', + label: 'Restablecer contrasena', + icon: , + onClick: () => setUserToResetPassword(user), + }, + { + key: 'resendInvitation', + label: 'Reenviar invitacion', + icon: , + onClick: () => resendInvitation(user.id), + }, + { + key: 'delete', + label: 'Eliminar', + icon: , + danger: true, + onClick: () => setUserToDelete(user), + }, + ]; + + return ( + + + + } + 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 ( +
+ +
+ ); + } + + return ( +
+ + +
+
+

Usuarios

+

+ Gestiona los usuarios de tu empresa +

+
+
+ + +
+
+ + {/* Summary Stats */} +
+ + +
+
+ +
+
+
Total usuarios
+
{total}
+
+
+
+
+ + handleActiveFilter(true)}> + +
+
+ +
+
+
Activos
+
{activeUsers}
+
+
+
+
+ + handleRoleFilter('admin')}> + +
+
+ +
+
+
Administradores
+
{adminCount}
+
+
+
+
+ + handleRoleFilter('manager')}> + +
+
+ +
+
+
Gerentes
+
{managerCount}
+
+
+
+
+
+ + + + Lista de Usuarios + + +
+ {/* Filters */} +
+
+ + 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" + /> +
+ + + + + + {(searchTerm || selectedRole || showActiveOnly !== undefined) && ( + + )} +
+ + {/* Table */} + {users.length === 0 && !isLoading ? ( + + ) : ( + setFilters({ page: p }), + }} + /> + )} +
+
+
+ + {/* Delete User Modal */} + 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 */} + 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 */} + setUserToResetPassword(null)} + onConfirm={handleResetPassword} + title="Restablecer contrasena" + message={`¿Restablecer la contrasena de "${userToResetPassword?.name}"? Se generara una contrasena temporal.`} + variant="warning" + confirmText="Restablecer" + /> +
+ ); +} + +export default UsersSettingsPage; diff --git a/src/pages/settings/index.ts b/src/pages/settings/index.ts new file mode 100644 index 0000000..806268e --- /dev/null +++ b/src/pages/settings/index.ts @@ -0,0 +1,2 @@ +export { SettingsPage } from './SettingsPage'; +export { UsersSettingsPage } from './UsersSettingsPage';