feat(frontend): Add Reports and Settings modules (MGN-009, MGN-006)

Reports module:
- types/reports.types.ts: Complete type definitions for definitions, executions, schedules
- api/reports.api.ts: API clients for all report operations
- hooks/useReports.ts: Custom hooks (useReportDefinitions, useReportExecutions, etc.)
- ReportsPage.tsx: Main reports management with tabs
- QuickReportsPage.tsx: Trial Balance and General Ledger quick reports

Settings module:
- types/settings.types.ts: Types for company, users, profile, audit logs
- api/settings.api.ts: API clients for settings operations
- hooks/useSettings.ts: Custom hooks (useCompany, useUsers, useProfile, etc.)
- SettingsPage.tsx: Settings hub with navigation cards
- UsersSettingsPage.tsx: User management page

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
rckrdmrd 2026-01-18 11:06:20 -06:00
parent 07987788f8
commit 4eb8ee2699
20 changed files with 4062 additions and 0 deletions

View File

@ -0,0 +1 @@
export * from './reports.api';

View File

@ -0,0 +1,226 @@
import { api } from '@services/api/axios-instance';
import type {
ReportDefinition,
ReportDefinitionCreateInput,
ReportDefinitionUpdateInput,
ReportDefinitionFilters,
ReportDefinitionsResponse,
ReportExecution,
ExecuteReportInput,
ReportExecutionFilters,
ReportExecutionsResponse,
ReportSchedule,
ReportScheduleCreateInput,
ReportScheduleUpdateInput,
ReportScheduleFilters,
ReportSchedulesResponse,
TrialBalanceParams,
TrialBalanceResult,
GeneralLedgerParams,
GeneralLedgerResult,
ExportFormat,
} from '../types';
const REPORTS_BASE = '/api/v1/reports';
// ============================================================================
// Report Definitions API
// ============================================================================
export const reportDefinitionsApi = {
getAll: async (filters: ReportDefinitionFilters = {}): Promise<ReportDefinitionsResponse> => {
const params = new URLSearchParams();
if (filters.reportType) params.append('report_type', filters.reportType);
if (filters.category) params.append('category', filters.category);
if (filters.isSystem !== undefined) params.append('is_system', String(filters.isSystem));
if (filters.search) params.append('search', filters.search);
if (filters.page) params.append('page', String(filters.page));
if (filters.limit) params.append('limit', String(filters.limit));
const response = await api.get<ReportDefinitionsResponse>(`${REPORTS_BASE}/definitions?${params}`);
return response.data;
},
getById: async (id: string): Promise<ReportDefinition> => {
const response = await api.get<ReportDefinition>(`${REPORTS_BASE}/definitions/${id}`);
return response.data;
},
getByCode: async (code: string): Promise<ReportDefinition> => {
const response = await api.get<ReportDefinition>(`${REPORTS_BASE}/definitions/code/${code}`);
return response.data;
},
create: async (data: ReportDefinitionCreateInput): Promise<ReportDefinition> => {
const response = await api.post<ReportDefinition>(`${REPORTS_BASE}/definitions`, data);
return response.data;
},
update: async (id: string, data: ReportDefinitionUpdateInput): Promise<ReportDefinition> => {
const response = await api.patch<ReportDefinition>(`${REPORTS_BASE}/definitions/${id}`, data);
return response.data;
},
delete: async (id: string): Promise<void> => {
await api.delete(`${REPORTS_BASE}/definitions/${id}`);
},
toggleActive: async (id: string): Promise<ReportDefinition> => {
const response = await api.post<ReportDefinition>(`${REPORTS_BASE}/definitions/${id}/toggle`);
return response.data;
},
};
// ============================================================================
// Report Executions API
// ============================================================================
export const reportExecutionsApi = {
execute: async (data: ExecuteReportInput): Promise<ReportExecution> => {
const response = await api.post<ReportExecution>(`${REPORTS_BASE}/execute`, data);
return response.data;
},
getById: async (id: string): Promise<ReportExecution> => {
const response = await api.get<ReportExecution>(`${REPORTS_BASE}/executions/${id}`);
return response.data;
},
getRecent: async (filters: ReportExecutionFilters = {}): Promise<ReportExecutionsResponse> => {
const params = new URLSearchParams();
if (filters.definitionId) params.append('definition_id', filters.definitionId);
if (filters.status) params.append('status', filters.status);
if (filters.dateFrom) params.append('date_from', filters.dateFrom);
if (filters.dateTo) params.append('date_to', filters.dateTo);
if (filters.page) params.append('page', String(filters.page));
if (filters.limit) params.append('limit', String(filters.limit));
const response = await api.get<ReportExecutionsResponse>(`${REPORTS_BASE}/executions?${params}`);
return response.data;
},
cancel: async (id: string): Promise<ReportExecution> => {
const response = await api.post<ReportExecution>(`${REPORTS_BASE}/executions/${id}/cancel`);
return response.data;
},
download: async (id: string, format: ExportFormat): Promise<Blob> => {
const response = await api.get<Blob>(`${REPORTS_BASE}/executions/${id}/download/${format}`, {
responseType: 'blob',
});
return response.data;
},
};
// ============================================================================
// Report Schedules API
// ============================================================================
export const reportSchedulesApi = {
getAll: async (filters: ReportScheduleFilters = {}): Promise<ReportSchedulesResponse> => {
const params = new URLSearchParams();
if (filters.definitionId) params.append('definition_id', filters.definitionId);
if (filters.isActive !== undefined) params.append('is_active', String(filters.isActive));
if (filters.page) params.append('page', String(filters.page));
if (filters.limit) params.append('limit', String(filters.limit));
const response = await api.get<ReportSchedulesResponse>(`${REPORTS_BASE}/schedules?${params}`);
return response.data;
},
getById: async (id: string): Promise<ReportSchedule> => {
const response = await api.get<ReportSchedule>(`${REPORTS_BASE}/schedules/${id}`);
return response.data;
},
create: async (data: ReportScheduleCreateInput): Promise<ReportSchedule> => {
const response = await api.post<ReportSchedule>(`${REPORTS_BASE}/schedules`, data);
return response.data;
},
update: async (id: string, data: ReportScheduleUpdateInput): Promise<ReportSchedule> => {
const response = await api.patch<ReportSchedule>(`${REPORTS_BASE}/schedules/${id}`, data);
return response.data;
},
delete: async (id: string): Promise<void> => {
await api.delete(`${REPORTS_BASE}/schedules/${id}`);
},
toggle: async (id: string): Promise<ReportSchedule> => {
const response = await api.patch<ReportSchedule>(`${REPORTS_BASE}/schedules/${id}/toggle`);
return response.data;
},
runNow: async (id: string): Promise<ReportExecution> => {
const response = await api.post<ReportExecution>(`${REPORTS_BASE}/schedules/${id}/run`);
return response.data;
},
};
// ============================================================================
// Quick Reports API
// ============================================================================
export const quickReportsApi = {
getTrialBalance: async (params: TrialBalanceParams): Promise<TrialBalanceResult> => {
const queryParams = new URLSearchParams();
queryParams.append('company_id', params.companyId);
queryParams.append('date_from', params.dateFrom);
queryParams.append('date_to', params.dateTo);
if (params.includeZeroBalance !== undefined) {
queryParams.append('include_zero', String(params.includeZeroBalance));
}
const response = await api.get<TrialBalanceResult>(
`${REPORTS_BASE}/quick/trial-balance?${queryParams}`
);
return response.data;
},
getGeneralLedger: async (params: GeneralLedgerParams): Promise<GeneralLedgerResult> => {
const queryParams = new URLSearchParams();
queryParams.append('company_id', params.companyId);
queryParams.append('date_from', params.dateFrom);
queryParams.append('date_to', params.dateTo);
if (params.accountId) {
queryParams.append('account_id', params.accountId);
}
const response = await api.get<GeneralLedgerResult>(
`${REPORTS_BASE}/quick/general-ledger?${queryParams}`
);
return response.data;
},
exportTrialBalance: async (params: TrialBalanceParams, format: ExportFormat): Promise<Blob> => {
const queryParams = new URLSearchParams();
queryParams.append('company_id', params.companyId);
queryParams.append('date_from', params.dateFrom);
queryParams.append('date_to', params.dateTo);
queryParams.append('format', format);
const response = await api.get<Blob>(
`${REPORTS_BASE}/quick/trial-balance/export?${queryParams}`,
{ responseType: 'blob' }
);
return response.data;
},
exportGeneralLedger: async (params: GeneralLedgerParams, format: ExportFormat): Promise<Blob> => {
const queryParams = new URLSearchParams();
queryParams.append('company_id', params.companyId);
queryParams.append('date_from', params.dateFrom);
queryParams.append('date_to', params.dateTo);
if (params.accountId) {
queryParams.append('account_id', params.accountId);
}
queryParams.append('format', format);
const response = await api.get<Blob>(
`${REPORTS_BASE}/quick/general-ledger/export?${queryParams}`,
{ responseType: 'blob' }
);
return response.data;
},
};

View File

@ -0,0 +1,7 @@
export {
useReportDefinitions,
useReportExecutions,
useReportSchedules,
useQuickReports,
useReportExecution,
} from './useReports';

View File

@ -0,0 +1,593 @@
import { useState, useEffect, useCallback } from 'react';
import {
reportDefinitionsApi,
reportExecutionsApi,
reportSchedulesApi,
quickReportsApi,
} from '../api';
import type {
ReportDefinition,
ReportDefinitionCreateInput,
ReportDefinitionUpdateInput,
ReportDefinitionFilters,
ReportExecution,
ExecuteReportInput,
ReportExecutionFilters,
ReportSchedule,
ReportScheduleCreateInput,
ReportScheduleUpdateInput,
ReportScheduleFilters,
TrialBalanceParams,
TrialBalanceResult,
GeneralLedgerParams,
GeneralLedgerResult,
ExportFormat,
} from '../types';
// ============================================================================
// Report Definitions Hook
// ============================================================================
interface UseReportDefinitionsOptions {
initialFilters?: ReportDefinitionFilters;
autoLoad?: boolean;
}
interface UseReportDefinitionsReturn {
definitions: ReportDefinition[];
total: number;
page: number;
totalPages: number;
isLoading: boolean;
error: Error | null;
filters: ReportDefinitionFilters;
setFilters: (filters: ReportDefinitionFilters) => void;
refresh: () => Promise<void>;
getById: (id: string) => Promise<ReportDefinition>;
getByCode: (code: string) => Promise<ReportDefinition>;
create: (data: ReportDefinitionCreateInput) => Promise<ReportDefinition>;
update: (id: string, data: ReportDefinitionUpdateInput) => Promise<ReportDefinition>;
remove: (id: string) => Promise<void>;
toggleActive: (id: string) => Promise<ReportDefinition>;
}
export function useReportDefinitions(
options: UseReportDefinitionsOptions = {}
): UseReportDefinitionsReturn {
const { initialFilters = {}, autoLoad = true } = options;
const [definitions, setDefinitions] = useState<ReportDefinition[]>([]);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
const [totalPages, setTotalPages] = useState(0);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<Error | null>(null);
const [filters, setFilters] = useState<ReportDefinitionFilters>(initialFilters);
const fetchDefinitions = useCallback(async () => {
setIsLoading(true);
setError(null);
try {
const response = await reportDefinitionsApi.getAll(filters);
setDefinitions(response.data);
setTotal(response.total);
setPage(response.page);
setTotalPages(response.totalPages);
} catch (err) {
setError(err instanceof Error ? err : new Error('Error fetching report definitions'));
} finally {
setIsLoading(false);
}
}, [filters]);
useEffect(() => {
if (autoLoad) {
fetchDefinitions();
}
}, [autoLoad, fetchDefinitions]);
const getById = useCallback(async (id: string): Promise<ReportDefinition> => {
return reportDefinitionsApi.getById(id);
}, []);
const getByCode = useCallback(async (code: string): Promise<ReportDefinition> => {
return reportDefinitionsApi.getByCode(code);
}, []);
const create = useCallback(async (data: ReportDefinitionCreateInput): Promise<ReportDefinition> => {
const result = await reportDefinitionsApi.create(data);
await fetchDefinitions();
return result;
}, [fetchDefinitions]);
const update = useCallback(async (
id: string,
data: ReportDefinitionUpdateInput
): Promise<ReportDefinition> => {
const result = await reportDefinitionsApi.update(id, data);
await fetchDefinitions();
return result;
}, [fetchDefinitions]);
const remove = useCallback(async (id: string): Promise<void> => {
await reportDefinitionsApi.delete(id);
await fetchDefinitions();
}, [fetchDefinitions]);
const toggleActive = useCallback(async (id: string): Promise<ReportDefinition> => {
const result = await reportDefinitionsApi.toggleActive(id);
await fetchDefinitions();
return result;
}, [fetchDefinitions]);
return {
definitions,
total,
page,
totalPages,
isLoading,
error,
filters,
setFilters,
refresh: fetchDefinitions,
getById,
getByCode,
create,
update,
remove,
toggleActive,
};
}
// ============================================================================
// Report Executions Hook
// ============================================================================
interface UseReportExecutionsOptions {
initialFilters?: ReportExecutionFilters;
autoLoad?: boolean;
pollInterval?: number;
}
interface UseReportExecutionsReturn {
executions: ReportExecution[];
total: number;
page: number;
totalPages: number;
isLoading: boolean;
isExecuting: boolean;
error: Error | null;
filters: ReportExecutionFilters;
setFilters: (filters: ReportExecutionFilters) => void;
refresh: () => Promise<void>;
execute: (input: ExecuteReportInput) => Promise<ReportExecution>;
getById: (id: string) => Promise<ReportExecution>;
cancel: (id: string) => Promise<ReportExecution>;
download: (id: string, format: ExportFormat) => Promise<void>;
}
export function useReportExecutions(
options: UseReportExecutionsOptions = {}
): UseReportExecutionsReturn {
const { initialFilters = {}, autoLoad = true, pollInterval } = options;
const [executions, setExecutions] = useState<ReportExecution[]>([]);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
const [totalPages, setTotalPages] = useState(0);
const [isLoading, setIsLoading] = useState(false);
const [isExecuting, setIsExecuting] = useState(false);
const [error, setError] = useState<Error | null>(null);
const [filters, setFilters] = useState<ReportExecutionFilters>(initialFilters);
const fetchExecutions = useCallback(async () => {
setIsLoading(true);
setError(null);
try {
const response = await reportExecutionsApi.getRecent(filters);
setExecutions(response.data);
setTotal(response.total);
setPage(response.page);
setTotalPages(response.totalPages);
} catch (err) {
setError(err instanceof Error ? err : new Error('Error fetching report executions'));
} finally {
setIsLoading(false);
}
}, [filters]);
useEffect(() => {
if (autoLoad) {
fetchExecutions();
}
}, [autoLoad, fetchExecutions]);
// Polling for running executions
useEffect(() => {
if (!pollInterval) return;
const hasRunning = executions.some(e => e.status === 'pending' || e.status === 'running');
if (!hasRunning) return;
const interval = setInterval(fetchExecutions, pollInterval);
return () => clearInterval(interval);
}, [pollInterval, executions, fetchExecutions]);
const execute = useCallback(async (input: ExecuteReportInput): Promise<ReportExecution> => {
setIsExecuting(true);
setError(null);
try {
const result = await reportExecutionsApi.execute(input);
await fetchExecutions();
return result;
} catch (err) {
const error = err instanceof Error ? err : new Error('Error executing report');
setError(error);
throw error;
} finally {
setIsExecuting(false);
}
}, [fetchExecutions]);
const getById = useCallback(async (id: string): Promise<ReportExecution> => {
return reportExecutionsApi.getById(id);
}, []);
const cancel = useCallback(async (id: string): Promise<ReportExecution> => {
const result = await reportExecutionsApi.cancel(id);
await fetchExecutions();
return result;
}, [fetchExecutions]);
const download = useCallback(async (id: string, format: ExportFormat): Promise<void> => {
try {
const blob = await reportExecutionsApi.download(id, format);
const url = window.URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = `report-${id}.${format}`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
window.URL.revokeObjectURL(url);
} catch (err) {
setError(err instanceof Error ? err : new Error('Error downloading report'));
throw err;
}
}, []);
return {
executions,
total,
page,
totalPages,
isLoading,
isExecuting,
error,
filters,
setFilters,
refresh: fetchExecutions,
execute,
getById,
cancel,
download,
};
}
// ============================================================================
// Report Schedules Hook
// ============================================================================
interface UseReportSchedulesOptions {
initialFilters?: ReportScheduleFilters;
autoLoad?: boolean;
}
interface UseReportSchedulesReturn {
schedules: ReportSchedule[];
total: number;
page: number;
totalPages: number;
isLoading: boolean;
error: Error | null;
filters: ReportScheduleFilters;
setFilters: (filters: ReportScheduleFilters) => void;
refresh: () => Promise<void>;
getById: (id: string) => Promise<ReportSchedule>;
create: (data: ReportScheduleCreateInput) => Promise<ReportSchedule>;
update: (id: string, data: ReportScheduleUpdateInput) => Promise<ReportSchedule>;
remove: (id: string) => Promise<void>;
toggle: (id: string) => Promise<ReportSchedule>;
runNow: (id: string) => Promise<ReportExecution>;
}
export function useReportSchedules(
options: UseReportSchedulesOptions = {}
): UseReportSchedulesReturn {
const { initialFilters = {}, autoLoad = true } = options;
const [schedules, setSchedules] = useState<ReportSchedule[]>([]);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
const [totalPages, setTotalPages] = useState(0);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<Error | null>(null);
const [filters, setFilters] = useState<ReportScheduleFilters>(initialFilters);
const fetchSchedules = useCallback(async () => {
setIsLoading(true);
setError(null);
try {
const response = await reportSchedulesApi.getAll(filters);
setSchedules(response.data);
setTotal(response.total);
setPage(response.page);
setTotalPages(response.totalPages);
} catch (err) {
setError(err instanceof Error ? err : new Error('Error fetching report schedules'));
} finally {
setIsLoading(false);
}
}, [filters]);
useEffect(() => {
if (autoLoad) {
fetchSchedules();
}
}, [autoLoad, fetchSchedules]);
const getById = useCallback(async (id: string): Promise<ReportSchedule> => {
return reportSchedulesApi.getById(id);
}, []);
const create = useCallback(async (data: ReportScheduleCreateInput): Promise<ReportSchedule> => {
const result = await reportSchedulesApi.create(data);
await fetchSchedules();
return result;
}, [fetchSchedules]);
const update = useCallback(async (
id: string,
data: ReportScheduleUpdateInput
): Promise<ReportSchedule> => {
const result = await reportSchedulesApi.update(id, data);
await fetchSchedules();
return result;
}, [fetchSchedules]);
const remove = useCallback(async (id: string): Promise<void> => {
await reportSchedulesApi.delete(id);
await fetchSchedules();
}, [fetchSchedules]);
const toggle = useCallback(async (id: string): Promise<ReportSchedule> => {
const result = await reportSchedulesApi.toggle(id);
await fetchSchedules();
return result;
}, [fetchSchedules]);
const runNow = useCallback(async (id: string): Promise<ReportExecution> => {
return reportSchedulesApi.runNow(id);
}, []);
return {
schedules,
total,
page,
totalPages,
isLoading,
error,
filters,
setFilters,
refresh: fetchSchedules,
getById,
create,
update,
remove,
toggle,
runNow,
};
}
// ============================================================================
// Quick Reports Hook
// ============================================================================
interface UseQuickReportsReturn {
// Trial Balance
trialBalance: TrialBalanceResult | null;
isLoadingTrialBalance: boolean;
trialBalanceError: Error | null;
getTrialBalance: (params: TrialBalanceParams) => Promise<TrialBalanceResult>;
exportTrialBalance: (params: TrialBalanceParams, format: ExportFormat) => Promise<void>;
// General Ledger
generalLedger: GeneralLedgerResult | null;
isLoadingGeneralLedger: boolean;
generalLedgerError: Error | null;
getGeneralLedger: (params: GeneralLedgerParams) => Promise<GeneralLedgerResult>;
exportGeneralLedger: (params: GeneralLedgerParams, format: ExportFormat) => Promise<void>;
// General
clearResults: () => void;
}
export function useQuickReports(): UseQuickReportsReturn {
const [trialBalance, setTrialBalance] = useState<TrialBalanceResult | null>(null);
const [isLoadingTrialBalance, setIsLoadingTrialBalance] = useState(false);
const [trialBalanceError, setTrialBalanceError] = useState<Error | null>(null);
const [generalLedger, setGeneralLedger] = useState<GeneralLedgerResult | null>(null);
const [isLoadingGeneralLedger, setIsLoadingGeneralLedger] = useState(false);
const [generalLedgerError, setGeneralLedgerError] = useState<Error | null>(null);
const getTrialBalance = useCallback(async (params: TrialBalanceParams): Promise<TrialBalanceResult> => {
setIsLoadingTrialBalance(true);
setTrialBalanceError(null);
try {
const result = await quickReportsApi.getTrialBalance(params);
setTrialBalance(result);
return result;
} catch (err) {
const error = err instanceof Error ? err : new Error('Error fetching trial balance');
setTrialBalanceError(error);
throw error;
} finally {
setIsLoadingTrialBalance(false);
}
}, []);
const exportTrialBalance = useCallback(async (
params: TrialBalanceParams,
format: ExportFormat
): Promise<void> => {
try {
const blob = await quickReportsApi.exportTrialBalance(params, format);
const url = window.URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = `trial-balance-${params.dateFrom}-${params.dateTo}.${format}`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
window.URL.revokeObjectURL(url);
} catch (err) {
throw err instanceof Error ? err : new Error('Error exporting trial balance');
}
}, []);
const getGeneralLedger = useCallback(async (params: GeneralLedgerParams): Promise<GeneralLedgerResult> => {
setIsLoadingGeneralLedger(true);
setGeneralLedgerError(null);
try {
const result = await quickReportsApi.getGeneralLedger(params);
setGeneralLedger(result);
return result;
} catch (err) {
const error = err instanceof Error ? err : new Error('Error fetching general ledger');
setGeneralLedgerError(error);
throw error;
} finally {
setIsLoadingGeneralLedger(false);
}
}, []);
const exportGeneralLedger = useCallback(async (
params: GeneralLedgerParams,
format: ExportFormat
): Promise<void> => {
try {
const blob = await quickReportsApi.exportGeneralLedger(params, format);
const url = window.URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = `general-ledger-${params.dateFrom}-${params.dateTo}.${format}`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
window.URL.revokeObjectURL(url);
} catch (err) {
throw err instanceof Error ? err : new Error('Error exporting general ledger');
}
}, []);
const clearResults = useCallback(() => {
setTrialBalance(null);
setGeneralLedger(null);
setTrialBalanceError(null);
setGeneralLedgerError(null);
}, []);
return {
trialBalance,
isLoadingTrialBalance,
trialBalanceError,
getTrialBalance,
exportTrialBalance,
generalLedger,
isLoadingGeneralLedger,
generalLedgerError,
getGeneralLedger,
exportGeneralLedger,
clearResults,
};
}
// ============================================================================
// Single Report Execution Hook (for tracking a specific execution)
// ============================================================================
interface UseReportExecutionOptions {
executionId: string;
pollInterval?: number;
}
interface UseReportExecutionReturn {
execution: ReportExecution | null;
isLoading: boolean;
error: Error | null;
refresh: () => Promise<void>;
cancel: () => Promise<void>;
download: (format: ExportFormat) => Promise<void>;
}
export function useReportExecution(
options: UseReportExecutionOptions
): UseReportExecutionReturn {
const { executionId, pollInterval = 2000 } = options;
const [execution, setExecution] = useState<ReportExecution | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<Error | null>(null);
const fetchExecution = useCallback(async () => {
try {
const result = await reportExecutionsApi.getById(executionId);
setExecution(result);
setError(null);
} catch (err) {
setError(err instanceof Error ? err : new Error('Error fetching execution'));
} finally {
setIsLoading(false);
}
}, [executionId]);
useEffect(() => {
fetchExecution();
}, [fetchExecution]);
// Poll while execution is running
useEffect(() => {
if (!execution) return;
if (execution.status !== 'pending' && execution.status !== 'running') return;
const interval = setInterval(fetchExecution, pollInterval);
return () => clearInterval(interval);
}, [execution, pollInterval, fetchExecution]);
const cancel = useCallback(async () => {
await reportExecutionsApi.cancel(executionId);
await fetchExecution();
}, [executionId, fetchExecution]);
const download = useCallback(async (format: ExportFormat) => {
const blob = await reportExecutionsApi.download(executionId, format);
const url = window.URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = `report-${executionId}.${format}`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
window.URL.revokeObjectURL(url);
}, [executionId]);
return {
execution,
isLoading,
error,
refresh: fetchExecution,
cancel,
download,
};
}

View File

@ -0,0 +1,10 @@
// Reports Feature - Barrel Export
// Types
export * from './types';
// API
export * from './api';
// Hooks
export * from './hooks';

View File

@ -0,0 +1 @@
export * from './reports.types';

View File

@ -0,0 +1,314 @@
// Reports Types - Definitions, Executions, Schedules
// ============================================================================
// Report Definition Types
// ============================================================================
export type ReportType = 'financial' | 'accounting' | 'tax' | 'management' | 'custom';
export type ExecutionStatus = 'pending' | 'running' | 'completed' | 'failed' | 'cancelled';
export type DeliveryMethod = 'none' | 'email' | 'storage' | 'webhook';
export type ExportFormat = 'pdf' | 'excel' | 'csv';
export interface ParameterSchema {
type: 'string' | 'number' | 'date' | 'boolean' | 'select';
label: string;
required?: boolean;
default?: any;
options?: { value: string; label: string }[];
min?: number;
max?: number;
}
export interface ColumnConfig {
key: string;
header: string;
type: 'text' | 'number' | 'currency' | 'date' | 'percentage';
width?: number;
align?: 'left' | 'center' | 'right';
format?: string;
aggregation?: 'sum' | 'avg' | 'count' | 'min' | 'max';
}
export interface ReportDefinition {
id: string;
tenantId: string;
code: string;
name: string;
description?: string;
reportType: ReportType;
category?: string;
baseQuery?: string;
queryFunction?: string;
parametersSchema: Record<string, ParameterSchema>;
columnsConfig: ColumnConfig[];
groupingOptions: string[];
totalsConfig: Record<string, any>;
exportFormats: ExportFormat[];
pdfTemplate?: string;
xlsxTemplate?: string;
isSystem: boolean;
isActive: boolean;
requiredPermissions: string[];
version: number;
createdAt: string;
}
export interface ReportDefinitionCreateInput {
code: string;
name: string;
description?: string;
reportType?: ReportType;
category?: string;
baseQuery?: string;
queryFunction?: string;
parametersSchema?: Record<string, ParameterSchema>;
columnsConfig?: ColumnConfig[];
exportFormats?: ExportFormat[];
requiredPermissions?: string[];
}
export interface ReportDefinitionUpdateInput extends Partial<ReportDefinitionCreateInput> {
isActive?: boolean;
}
export interface ReportDefinitionFilters {
reportType?: ReportType;
category?: string;
isSystem?: boolean;
search?: string;
page?: number;
limit?: number;
}
// ============================================================================
// Report Execution Types
// ============================================================================
export interface ReportExecution {
id: string;
tenantId: string;
definitionId: string;
definitionName?: string;
definitionCode?: string;
parameters: Record<string, any>;
status: ExecutionStatus;
startedAt?: string;
completedAt?: string;
executionTimeMs?: number;
rowCount?: number;
resultData: any[];
resultSummary?: Record<string, any>;
outputFiles: OutputFile[];
errorMessage?: string;
errorDetails?: Record<string, any>;
requestedBy: string;
requestedByName?: string;
createdAt: string;
}
export interface OutputFile {
format: ExportFormat;
filename: string;
url?: string;
size?: number;
generatedAt: string;
}
export interface ExecuteReportInput {
definitionId: string;
parameters: Record<string, any>;
exportFormats?: ExportFormat[];
}
export interface ReportExecutionFilters {
definitionId?: string;
status?: ExecutionStatus;
dateFrom?: string;
dateTo?: string;
page?: number;
limit?: number;
}
// ============================================================================
// Report Schedule Types
// ============================================================================
export interface ReportSchedule {
id: string;
tenantId: string;
definitionId: string;
definitionName?: string;
companyId?: string;
name: string;
defaultParameters: Record<string, any>;
cronExpression: string;
timezone: string;
isActive: boolean;
lastExecutionId?: string;
lastRunAt?: string;
nextRunAt?: string;
deliveryMethod: DeliveryMethod;
deliveryConfig: DeliveryConfig;
createdAt: string;
}
export interface DeliveryConfig {
recipients?: string[];
subject?: string;
body?: string;
storagePath?: string;
webhookUrl?: string;
}
export interface ReportScheduleCreateInput {
definitionId: string;
name: string;
companyId?: string;
defaultParameters?: Record<string, any>;
cronExpression: string;
timezone?: string;
deliveryMethod?: DeliveryMethod;
deliveryConfig?: DeliveryConfig;
}
export interface ReportScheduleUpdateInput extends Partial<ReportScheduleCreateInput> {
isActive?: boolean;
}
export interface ReportScheduleFilters {
definitionId?: string;
isActive?: boolean;
page?: number;
limit?: number;
}
// ============================================================================
// Quick Report Types
// ============================================================================
export interface TrialBalanceParams {
companyId: string;
dateFrom: string;
dateTo: string;
includeZeroBalance?: boolean;
}
export interface TrialBalanceRow {
accountCode: string;
accountName: string;
accountType: string;
openingDebit: number;
openingCredit: number;
periodDebit: number;
periodCredit: number;
closingDebit: number;
closingCredit: number;
}
export interface TrialBalanceResult {
rows: TrialBalanceRow[];
totals: {
openingDebit: number;
openingCredit: number;
periodDebit: number;
periodCredit: number;
closingDebit: number;
closingCredit: number;
};
parameters: TrialBalanceParams;
generatedAt: string;
}
export interface GeneralLedgerParams {
companyId: string;
accountId?: string;
dateFrom: string;
dateTo: string;
}
export interface GeneralLedgerRow {
date: string;
journalEntryNumber: string;
description: string;
reference?: string;
debit: number;
credit: number;
balance: number;
}
export interface GeneralLedgerResult {
accountCode: string;
accountName: string;
rows: GeneralLedgerRow[];
openingBalance: number;
closingBalance: number;
totalDebit: number;
totalCredit: number;
parameters: GeneralLedgerParams;
generatedAt: string;
}
// ============================================================================
// Response Types
// ============================================================================
export interface ReportDefinitionsResponse {
data: ReportDefinition[];
total: number;
page: number;
limit: number;
totalPages: number;
}
export interface ReportExecutionsResponse {
data: ReportExecution[];
total: number;
page: number;
limit: number;
totalPages: number;
}
export interface ReportSchedulesResponse {
data: ReportSchedule[];
total: number;
page: number;
limit: number;
totalPages: number;
}
// ============================================================================
// Report Category Constants
// ============================================================================
export const REPORT_CATEGORIES = {
FINANCIAL: 'financial',
SALES: 'sales',
INVENTORY: 'inventory',
PURCHASES: 'purchases',
TAX: 'tax',
HR: 'hr',
CUSTOM: 'custom',
} as const;
export const REPORT_TYPE_LABELS: Record<ReportType, string> = {
financial: 'Financiero',
accounting: 'Contable',
tax: 'Fiscal',
management: 'Gerencial',
custom: 'Personalizado',
};
export const EXECUTION_STATUS_LABELS: Record<ExecutionStatus, string> = {
pending: 'Pendiente',
running: 'Ejecutando',
completed: 'Completado',
failed: 'Fallido',
cancelled: 'Cancelado',
};
export const DELIVERY_METHOD_LABELS: Record<DeliveryMethod, string> = {
none: 'Sin entrega',
email: 'Correo electronico',
storage: 'Almacenamiento',
webhook: 'Webhook',
};

View File

@ -0,0 +1 @@
export * from './settings.api';

View File

@ -0,0 +1,199 @@
import { api } from '@services/api/axios-instance';
import type {
Company,
CompanyUpdateInput,
User,
UserCreateInput,
UserUpdateInput,
UsersFilters,
UsersResponse,
Profile,
ProfileUpdateInput,
ChangePasswordInput,
SystemSettings,
SystemSettingUpdateInput,
AuditLog,
AuditLogFilters,
AuditLogsResponse,
} from '../types';
const SETTINGS_BASE = '/api/v1/settings';
// ============================================================================
// Company API
// ============================================================================
export const companyApi = {
get: async (): Promise<Company> => {
const response = await api.get<Company>(`${SETTINGS_BASE}/company`);
return response.data;
},
update: async (data: CompanyUpdateInput): Promise<Company> => {
const response = await api.patch<Company>(`${SETTINGS_BASE}/company`, data);
return response.data;
},
uploadLogo: async (file: File): Promise<{ url: string }> => {
const formData = new FormData();
formData.append('logo', file);
const response = await api.post<{ url: string }>(`${SETTINGS_BASE}/company/logo`, formData, {
headers: { 'Content-Type': 'multipart/form-data' },
});
return response.data;
},
removeLogo: async (): Promise<void> => {
await api.delete(`${SETTINGS_BASE}/company/logo`);
},
};
// ============================================================================
// Users API
// ============================================================================
export const usersApi = {
getAll: async (filters: UsersFilters = {}): Promise<UsersResponse> => {
const params = new URLSearchParams();
if (filters.page) params.append('page', String(filters.page));
if (filters.limit) params.append('limit', String(filters.limit));
if (filters.search) params.append('search', filters.search);
if (filters.role) params.append('role', filters.role);
if (filters.isActive !== undefined) params.append('is_active', String(filters.isActive));
const response = await api.get<UsersResponse>(`${SETTINGS_BASE}/users?${params}`);
return response.data;
},
getById: async (id: string): Promise<User> => {
const response = await api.get<User>(`${SETTINGS_BASE}/users/${id}`);
return response.data;
},
create: async (data: UserCreateInput): Promise<User> => {
const response = await api.post<User>(`${SETTINGS_BASE}/users`, data);
return response.data;
},
update: async (id: string, data: UserUpdateInput): Promise<User> => {
const response = await api.patch<User>(`${SETTINGS_BASE}/users/${id}`, data);
return response.data;
},
delete: async (id: string): Promise<void> => {
await api.delete(`${SETTINGS_BASE}/users/${id}`);
},
toggleActive: async (id: string): Promise<User> => {
const response = await api.post<User>(`${SETTINGS_BASE}/users/${id}/toggle-active`);
return response.data;
},
resetPassword: async (id: string, newPassword: string): Promise<void> => {
await api.post(`${SETTINGS_BASE}/users/${id}/reset-password`, { password: newPassword });
},
resendInvitation: async (id: string): Promise<void> => {
await api.post(`${SETTINGS_BASE}/users/${id}/resend-invitation`);
},
};
// ============================================================================
// Profile API (Current User)
// ============================================================================
export const profileApi = {
get: async (): Promise<Profile> => {
const response = await api.get<Profile>(`${SETTINGS_BASE}/profile`);
return response.data;
},
update: async (data: ProfileUpdateInput): Promise<Profile> => {
const response = await api.patch<Profile>(`${SETTINGS_BASE}/profile`, data);
return response.data;
},
changePassword: async (data: ChangePasswordInput): Promise<void> => {
await api.post(`${SETTINGS_BASE}/profile/change-password`, data);
},
uploadAvatar: async (file: File): Promise<{ url: string }> => {
const formData = new FormData();
formData.append('avatar', file);
const response = await api.post<{ url: string }>(`${SETTINGS_BASE}/profile/avatar`, formData, {
headers: { 'Content-Type': 'multipart/form-data' },
});
return response.data;
},
removeAvatar: async (): Promise<void> => {
await api.delete(`${SETTINGS_BASE}/profile/avatar`);
},
};
// ============================================================================
// System Settings API
// ============================================================================
export const systemSettingsApi = {
getAll: async (module?: string): Promise<SystemSettings[]> => {
const params = module ? `?module=${module}` : '';
const response = await api.get<SystemSettings[]>(`${SETTINGS_BASE}/system${params}`);
return response.data;
},
get: async (module: string, key: string): Promise<SystemSettings> => {
const response = await api.get<SystemSettings>(`${SETTINGS_BASE}/system/${module}/${key}`);
return response.data;
},
update: async (module: string, key: string, data: SystemSettingUpdateInput): Promise<SystemSettings> => {
const response = await api.patch<SystemSettings>(`${SETTINGS_BASE}/system/${module}/${key}`, data);
return response.data;
},
reset: async (module: string, key: string): Promise<SystemSettings> => {
const response = await api.post<SystemSettings>(`${SETTINGS_BASE}/system/${module}/${key}/reset`);
return response.data;
},
};
// ============================================================================
// Audit Logs API
// ============================================================================
export const auditLogsApi = {
getAll: async (filters: AuditLogFilters = {}): Promise<AuditLogsResponse> => {
const params = new URLSearchParams();
if (filters.userId) params.append('user_id', filters.userId);
if (filters.action) params.append('action', filters.action);
if (filters.entityType) params.append('entity_type', filters.entityType);
if (filters.entityId) params.append('entity_id', filters.entityId);
if (filters.dateFrom) params.append('date_from', filters.dateFrom);
if (filters.dateTo) params.append('date_to', filters.dateTo);
if (filters.page) params.append('page', String(filters.page));
if (filters.limit) params.append('limit', String(filters.limit));
const response = await api.get<AuditLogsResponse>(`${SETTINGS_BASE}/audit-logs?${params}`);
return response.data;
},
getById: async (id: string): Promise<AuditLog> => {
const response = await api.get<AuditLog>(`${SETTINGS_BASE}/audit-logs/${id}`);
return response.data;
},
export: async (filters: AuditLogFilters = {}): Promise<Blob> => {
const params = new URLSearchParams();
if (filters.userId) params.append('user_id', filters.userId);
if (filters.action) params.append('action', filters.action);
if (filters.entityType) params.append('entity_type', filters.entityType);
if (filters.dateFrom) params.append('date_from', filters.dateFrom);
if (filters.dateTo) params.append('date_to', filters.dateTo);
const response = await api.get<Blob>(`${SETTINGS_BASE}/audit-logs/export?${params}`, {
responseType: 'blob',
});
return response.data;
},
};

View File

@ -0,0 +1,7 @@
export {
useCompany,
useUsers,
useProfile,
useSystemSettings,
useAuditLogs,
} from './useSettings';

View File

@ -0,0 +1,492 @@
import { useState, useEffect, useCallback } from 'react';
import {
companyApi,
usersApi,
profileApi,
systemSettingsApi,
auditLogsApi,
} from '../api';
import type {
Company,
CompanyUpdateInput,
User,
UserCreateInput,
UserUpdateInput,
UsersFilters,
Profile,
ProfileUpdateInput,
ChangePasswordInput,
SystemSettings,
SystemSettingUpdateInput,
AuditLog,
AuditLogFilters,
} from '../types';
// ============================================================================
// Company Hook
// ============================================================================
interface UseCompanyReturn {
company: Company | null;
isLoading: boolean;
isSaving: boolean;
error: Error | null;
refresh: () => Promise<void>;
update: (data: CompanyUpdateInput) => Promise<Company>;
uploadLogo: (file: File) => Promise<string>;
removeLogo: () => Promise<void>;
}
export function useCompany(): UseCompanyReturn {
const [company, setCompany] = useState<Company | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [isSaving, setIsSaving] = useState(false);
const [error, setError] = useState<Error | null>(null);
const fetchCompany = useCallback(async () => {
setIsLoading(true);
setError(null);
try {
const data = await companyApi.get();
setCompany(data);
} catch (err) {
setError(err instanceof Error ? err : new Error('Error fetching company'));
} finally {
setIsLoading(false);
}
}, []);
useEffect(() => {
fetchCompany();
}, [fetchCompany]);
const update = useCallback(async (data: CompanyUpdateInput): Promise<Company> => {
setIsSaving(true);
setError(null);
try {
const result = await companyApi.update(data);
setCompany(result);
return result;
} catch (err) {
const error = err instanceof Error ? err : new Error('Error updating company');
setError(error);
throw error;
} finally {
setIsSaving(false);
}
}, []);
const uploadLogo = useCallback(async (file: File): Promise<string> => {
try {
const { url } = await companyApi.uploadLogo(file);
await fetchCompany();
return url;
} catch (err) {
throw err instanceof Error ? err : new Error('Error uploading logo');
}
}, [fetchCompany]);
const removeLogo = useCallback(async (): Promise<void> => {
try {
await companyApi.removeLogo();
await fetchCompany();
} catch (err) {
throw err instanceof Error ? err : new Error('Error removing logo');
}
}, [fetchCompany]);
return {
company,
isLoading,
isSaving,
error,
refresh: fetchCompany,
update,
uploadLogo,
removeLogo,
};
}
// ============================================================================
// Users Hook
// ============================================================================
interface UseUsersOptions {
initialFilters?: UsersFilters;
autoLoad?: boolean;
}
interface UseUsersReturn {
users: User[];
total: number;
page: number;
totalPages: number;
isLoading: boolean;
error: Error | null;
filters: UsersFilters;
setFilters: (filters: UsersFilters) => void;
refresh: () => Promise<void>;
getById: (id: string) => Promise<User>;
create: (data: UserCreateInput) => Promise<User>;
update: (id: string, data: UserUpdateInput) => Promise<User>;
remove: (id: string) => Promise<void>;
toggleActive: (id: string) => Promise<User>;
resetPassword: (id: string, newPassword: string) => Promise<void>;
resendInvitation: (id: string) => Promise<void>;
}
export function useUsers(options: UseUsersOptions = {}): UseUsersReturn {
const { initialFilters = {}, autoLoad = true } = options;
const [users, setUsers] = useState<User[]>([]);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
const [totalPages, setTotalPages] = useState(0);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<Error | null>(null);
const [filters, setFilters] = useState<UsersFilters>(initialFilters);
const fetchUsers = useCallback(async () => {
setIsLoading(true);
setError(null);
try {
const response = await usersApi.getAll(filters);
setUsers(response.data);
setTotal(response.total);
setPage(response.page);
setTotalPages(response.totalPages);
} catch (err) {
setError(err instanceof Error ? err : new Error('Error fetching users'));
} finally {
setIsLoading(false);
}
}, [filters]);
useEffect(() => {
if (autoLoad) {
fetchUsers();
}
}, [autoLoad, fetchUsers]);
const getById = useCallback(async (id: string): Promise<User> => {
return usersApi.getById(id);
}, []);
const create = useCallback(async (data: UserCreateInput): Promise<User> => {
const result = await usersApi.create(data);
await fetchUsers();
return result;
}, [fetchUsers]);
const update = useCallback(async (id: string, data: UserUpdateInput): Promise<User> => {
const result = await usersApi.update(id, data);
await fetchUsers();
return result;
}, [fetchUsers]);
const remove = useCallback(async (id: string): Promise<void> => {
await usersApi.delete(id);
await fetchUsers();
}, [fetchUsers]);
const toggleActive = useCallback(async (id: string): Promise<User> => {
const result = await usersApi.toggleActive(id);
await fetchUsers();
return result;
}, [fetchUsers]);
const resetPassword = useCallback(async (id: string, newPassword: string): Promise<void> => {
await usersApi.resetPassword(id, newPassword);
}, []);
const resendInvitation = useCallback(async (id: string): Promise<void> => {
await usersApi.resendInvitation(id);
}, []);
return {
users,
total,
page,
totalPages,
isLoading,
error,
filters,
setFilters,
refresh: fetchUsers,
getById,
create,
update,
remove,
toggleActive,
resetPassword,
resendInvitation,
};
}
// ============================================================================
// Profile Hook
// ============================================================================
interface UseProfileReturn {
profile: Profile | null;
isLoading: boolean;
isSaving: boolean;
error: Error | null;
refresh: () => Promise<void>;
update: (data: ProfileUpdateInput) => Promise<Profile>;
changePassword: (data: ChangePasswordInput) => Promise<void>;
uploadAvatar: (file: File) => Promise<string>;
removeAvatar: () => Promise<void>;
}
export function useProfile(): UseProfileReturn {
const [profile, setProfile] = useState<Profile | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [isSaving, setIsSaving] = useState(false);
const [error, setError] = useState<Error | null>(null);
const fetchProfile = useCallback(async () => {
setIsLoading(true);
setError(null);
try {
const data = await profileApi.get();
setProfile(data);
} catch (err) {
setError(err instanceof Error ? err : new Error('Error fetching profile'));
} finally {
setIsLoading(false);
}
}, []);
useEffect(() => {
fetchProfile();
}, [fetchProfile]);
const update = useCallback(async (data: ProfileUpdateInput): Promise<Profile> => {
setIsSaving(true);
setError(null);
try {
const result = await profileApi.update(data);
setProfile(result);
return result;
} catch (err) {
const error = err instanceof Error ? err : new Error('Error updating profile');
setError(error);
throw error;
} finally {
setIsSaving(false);
}
}, []);
const changePassword = useCallback(async (data: ChangePasswordInput): Promise<void> => {
setIsSaving(true);
setError(null);
try {
await profileApi.changePassword(data);
} catch (err) {
const error = err instanceof Error ? err : new Error('Error changing password');
setError(error);
throw error;
} finally {
setIsSaving(false);
}
}, []);
const uploadAvatar = useCallback(async (file: File): Promise<string> => {
try {
const { url } = await profileApi.uploadAvatar(file);
await fetchProfile();
return url;
} catch (err) {
throw err instanceof Error ? err : new Error('Error uploading avatar');
}
}, [fetchProfile]);
const removeAvatar = useCallback(async (): Promise<void> => {
try {
await profileApi.removeAvatar();
await fetchProfile();
} catch (err) {
throw err instanceof Error ? err : new Error('Error removing avatar');
}
}, [fetchProfile]);
return {
profile,
isLoading,
isSaving,
error,
refresh: fetchProfile,
update,
changePassword,
uploadAvatar,
removeAvatar,
};
}
// ============================================================================
// System Settings Hook
// ============================================================================
interface UseSystemSettingsOptions {
module?: string;
autoLoad?: boolean;
}
interface UseSystemSettingsReturn {
settings: SystemSettings[];
isLoading: boolean;
error: Error | null;
refresh: () => Promise<void>;
get: (module: string, key: string) => Promise<SystemSettings>;
update: (module: string, key: string, data: SystemSettingUpdateInput) => Promise<SystemSettings>;
reset: (module: string, key: string) => Promise<SystemSettings>;
}
export function useSystemSettings(options: UseSystemSettingsOptions = {}): UseSystemSettingsReturn {
const { module, autoLoad = true } = options;
const [settings, setSettings] = useState<SystemSettings[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<Error | null>(null);
const fetchSettings = useCallback(async () => {
setIsLoading(true);
setError(null);
try {
const data = await systemSettingsApi.getAll(module);
setSettings(data);
} catch (err) {
setError(err instanceof Error ? err : new Error('Error fetching settings'));
} finally {
setIsLoading(false);
}
}, [module]);
useEffect(() => {
if (autoLoad) {
fetchSettings();
}
}, [autoLoad, fetchSettings]);
const get = useCallback(async (mod: string, key: string): Promise<SystemSettings> => {
return systemSettingsApi.get(mod, key);
}, []);
const update = useCallback(async (
mod: string,
key: string,
data: SystemSettingUpdateInput
): Promise<SystemSettings> => {
const result = await systemSettingsApi.update(mod, key, data);
await fetchSettings();
return result;
}, [fetchSettings]);
const reset = useCallback(async (mod: string, key: string): Promise<SystemSettings> => {
const result = await systemSettingsApi.reset(mod, key);
await fetchSettings();
return result;
}, [fetchSettings]);
return {
settings,
isLoading,
error,
refresh: fetchSettings,
get,
update,
reset,
};
}
// ============================================================================
// Audit Logs Hook
// ============================================================================
interface UseAuditLogsOptions {
initialFilters?: AuditLogFilters;
autoLoad?: boolean;
}
interface UseAuditLogsReturn {
logs: AuditLog[];
total: number;
page: number;
totalPages: number;
isLoading: boolean;
error: Error | null;
filters: AuditLogFilters;
setFilters: (filters: AuditLogFilters) => void;
refresh: () => Promise<void>;
getById: (id: string) => Promise<AuditLog>;
exportLogs: () => Promise<void>;
}
export function useAuditLogs(options: UseAuditLogsOptions = {}): UseAuditLogsReturn {
const { initialFilters = {}, autoLoad = true } = options;
const [logs, setLogs] = useState<AuditLog[]>([]);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
const [totalPages, setTotalPages] = useState(0);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<Error | null>(null);
const [filters, setFilters] = useState<AuditLogFilters>(initialFilters);
const fetchLogs = useCallback(async () => {
setIsLoading(true);
setError(null);
try {
const response = await auditLogsApi.getAll(filters);
setLogs(response.data);
setTotal(response.total);
setPage(response.page);
setTotalPages(response.totalPages);
} catch (err) {
setError(err instanceof Error ? err : new Error('Error fetching audit logs'));
} finally {
setIsLoading(false);
}
}, [filters]);
useEffect(() => {
if (autoLoad) {
fetchLogs();
}
}, [autoLoad, fetchLogs]);
const getById = useCallback(async (id: string): Promise<AuditLog> => {
return auditLogsApi.getById(id);
}, []);
const exportLogs = useCallback(async (): Promise<void> => {
try {
const blob = await auditLogsApi.export(filters);
const url = window.URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = `audit-logs-${new Date().toISOString().split('T')[0]}.csv`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
window.URL.revokeObjectURL(url);
} catch (err) {
throw err instanceof Error ? err : new Error('Error exporting audit logs');
}
}, [filters]);
return {
logs,
total,
page,
totalPages,
isLoading,
error,
filters,
setFilters,
refresh: fetchLogs,
getById,
exportLogs,
};
}

View File

@ -0,0 +1,10 @@
// Settings Feature - Barrel Export
// Types
export * from './types';
// API
export * from './api';
// Hooks
export * from './hooks';

View File

@ -0,0 +1 @@
export * from './settings.types';

View File

@ -0,0 +1,214 @@
// Settings Types - Company, Users, Profile
// ============================================================================
// Company Types
// ============================================================================
export interface Company {
id: string;
tenantId: string;
name: string;
legalName?: string;
taxId?: string;
email?: string;
phone?: string;
website?: string;
address?: string;
city?: string;
state?: string;
country?: string;
zipCode?: string;
logoUrl?: string;
currency: string;
fiscalYear?: string;
timezone?: string;
isActive: boolean;
createdAt: string;
updatedAt: string;
}
export interface CompanyUpdateInput {
name?: string;
legalName?: string;
taxId?: string;
email?: string;
phone?: string;
website?: string;
address?: string;
city?: string;
state?: string;
country?: string;
zipCode?: string;
currency?: string;
fiscalYear?: string;
timezone?: string;
}
// ============================================================================
// User Types
// ============================================================================
export type UserRole = 'admin' | 'manager' | 'user' | 'viewer';
export interface User {
id: string;
tenantId: string;
email: string;
name: string;
role: UserRole;
isActive: boolean;
avatarUrl?: string;
lastLoginAt?: string;
createdAt: string;
updatedAt: string;
}
export interface UserCreateInput {
email: string;
name: string;
password: string;
role: UserRole;
}
export interface UserUpdateInput {
email?: string;
name?: string;
role?: UserRole;
isActive?: boolean;
}
export interface UsersFilters {
page?: number;
limit?: number;
search?: string;
role?: UserRole;
isActive?: boolean;
}
export interface UsersResponse {
data: User[];
total: number;
page: number;
limit: number;
totalPages: number;
}
// ============================================================================
// Profile Types
// ============================================================================
export interface Profile extends User {
preferences?: UserPreferences;
}
export interface UserPreferences {
language?: string;
dateFormat?: string;
numberFormat?: string;
theme?: 'light' | 'dark' | 'system';
notifications?: NotificationPreferences;
}
export interface NotificationPreferences {
email: boolean;
push: boolean;
inApp: boolean;
}
export interface ProfileUpdateInput {
name?: string;
email?: string;
avatarUrl?: string;
preferences?: Partial<UserPreferences>;
}
export interface ChangePasswordInput {
currentPassword: string;
newPassword: string;
}
// ============================================================================
// System Settings Types
// ============================================================================
export interface SystemSettings {
id: string;
tenantId: string;
module: string;
key: string;
value: any;
description?: string;
updatedBy?: string;
updatedAt: string;
}
export interface SystemSettingUpdateInput {
value: any;
}
// ============================================================================
// Audit Log Types
// ============================================================================
export interface AuditLog {
id: string;
tenantId: string;
userId: string;
userName?: string;
action: AuditAction;
entityType: string;
entityId: string;
oldValues?: Record<string, any>;
newValues?: Record<string, any>;
ipAddress?: string;
userAgent?: string;
createdAt: string;
}
export type AuditAction = 'create' | 'update' | 'delete' | 'login' | 'logout' | 'export' | 'import';
export interface AuditLogFilters {
userId?: string;
action?: AuditAction;
entityType?: string;
entityId?: string;
dateFrom?: string;
dateTo?: string;
page?: number;
limit?: number;
}
export interface AuditLogsResponse {
data: AuditLog[];
total: number;
page: number;
limit: number;
totalPages: number;
}
// ============================================================================
// Constants and Labels
// ============================================================================
export const USER_ROLE_LABELS: Record<UserRole, string> = {
admin: 'Administrador',
manager: 'Gerente',
user: 'Usuario',
viewer: 'Visualizador',
};
export const AUDIT_ACTION_LABELS: Record<AuditAction, string> = {
create: 'Creacion',
update: 'Actualizacion',
delete: 'Eliminacion',
login: 'Inicio de sesion',
logout: 'Cierre de sesion',
export: 'Exportacion',
import: 'Importacion',
};
export const THEME_LABELS: Record<string, string> = {
light: 'Claro',
dark: 'Oscuro',
system: 'Sistema',
};

View File

@ -0,0 +1,561 @@
import { useState } from 'react';
import {
FileSpreadsheet,
Download,
Search,
Calendar,
RefreshCw,
BookOpen,
Scale,
Building2,
Filter,
} from 'lucide-react';
import { Button } from '@components/atoms/Button';
import { Card, CardHeader, CardTitle, CardContent } from '@components/molecules/Card';
import { DataTable, type Column } from '@components/organisms/DataTable';
import { Breadcrumbs } from '@components/organisms/Breadcrumbs';
import { ErrorEmptyState } from '@components/templates/EmptyState';
import { useQuickReports } from '@features/reports/hooks';
import type {
TrialBalanceRow,
GeneralLedgerRow,
ExportFormat,
} from '@features/reports/types';
import { formatNumber } from '@utils/formatters';
type ReportView = 'trial-balance' | 'general-ledger';
const formatCurrency = (value: number): string => {
return formatNumber(value, 'es-MX', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
};
const getStartOfMonth = (): string => {
const date = new Date();
const isoDate = new Date(date.getFullYear(), date.getMonth(), 1).toISOString().split('T')[0];
return isoDate ?? '';
};
const getToday = (): string => {
const isoDate = new Date().toISOString().split('T')[0];
return isoDate ?? '';
};
export function QuickReportsPage() {
const [activeView, setActiveView] = useState<ReportView>('trial-balance');
const [companyId, setCompanyId] = useState('');
const [dateFrom, setDateFrom] = useState(getStartOfMonth());
const [dateTo, setDateTo] = useState(getToday());
const [includeZeroBalance, setIncludeZeroBalance] = useState(false);
const [accountId, setAccountId] = useState('');
const {
trialBalance,
isLoadingTrialBalance,
trialBalanceError,
getTrialBalance,
exportTrialBalance,
generalLedger,
isLoadingGeneralLedger,
generalLedgerError,
getGeneralLedger,
exportGeneralLedger,
clearResults,
} = useQuickReports();
const handleGenerateTrialBalance = async () => {
if (!companyId) {
alert('Seleccione una empresa');
return;
}
await getTrialBalance({
companyId,
dateFrom,
dateTo,
includeZeroBalance,
});
};
const handleGenerateGeneralLedger = async () => {
if (!companyId) {
alert('Seleccione una empresa');
return;
}
await getGeneralLedger({
companyId,
dateFrom,
dateTo,
accountId: accountId || undefined,
});
};
const handleExport = async (format: ExportFormat) => {
if (!companyId) return;
if (activeView === 'trial-balance') {
await exportTrialBalance({
companyId,
dateFrom,
dateTo,
includeZeroBalance,
}, format);
} else {
await exportGeneralLedger({
companyId,
dateFrom,
dateTo,
accountId: accountId || undefined,
}, format);
}
};
const trialBalanceColumns: Column<TrialBalanceRow>[] = [
{
key: 'accountCode',
header: 'Codigo',
render: (row) => (
<span className="font-mono text-sm text-gray-900">{row.accountCode}</span>
),
},
{
key: 'accountName',
header: 'Cuenta',
render: (row) => (
<span className="text-sm text-gray-900">{row.accountName}</span>
),
},
{
key: 'accountType',
header: 'Tipo',
render: (row) => (
<span className="text-sm text-gray-600">{row.accountType}</span>
),
},
{
key: 'openingDebit',
header: 'Saldo Inicial Debe',
render: (row) => (
<span className="text-sm text-right text-gray-900 block">
${formatCurrency(row.openingDebit)}
</span>
),
},
{
key: 'openingCredit',
header: 'Saldo Inicial Haber',
render: (row) => (
<span className="text-sm text-right text-gray-900 block">
${formatCurrency(row.openingCredit)}
</span>
),
},
{
key: 'periodDebit',
header: 'Movimientos Debe',
render: (row) => (
<span className="text-sm text-right text-blue-600 block font-medium">
${formatCurrency(row.periodDebit)}
</span>
),
},
{
key: 'periodCredit',
header: 'Movimientos Haber',
render: (row) => (
<span className="text-sm text-right text-blue-600 block font-medium">
${formatCurrency(row.periodCredit)}
</span>
),
},
{
key: 'closingDebit',
header: 'Saldo Final Debe',
render: (row) => (
<span className="text-sm text-right text-gray-900 block font-semibold">
${formatCurrency(row.closingDebit)}
</span>
),
},
{
key: 'closingCredit',
header: 'Saldo Final Haber',
render: (row) => (
<span className="text-sm text-right text-gray-900 block font-semibold">
${formatCurrency(row.closingCredit)}
</span>
),
},
];
const generalLedgerColumns: Column<GeneralLedgerRow>[] = [
{
key: 'date',
header: 'Fecha',
render: (row) => (
<span className="text-sm text-gray-600">{row.date}</span>
),
},
{
key: 'journalEntryNumber',
header: 'Asiento',
render: (row) => (
<span className="font-mono text-sm text-blue-600">{row.journalEntryNumber}</span>
),
},
{
key: 'description',
header: 'Descripcion',
render: (row) => (
<div>
<span className="text-sm text-gray-900">{row.description}</span>
{row.reference && (
<span className="text-xs text-gray-500 block">Ref: {row.reference}</span>
)}
</div>
),
},
{
key: 'debit',
header: 'Debe',
render: (row) => (
<span className={`text-sm text-right block ${row.debit > 0 ? 'text-green-600 font-medium' : 'text-gray-400'}`}>
${formatCurrency(row.debit)}
</span>
),
},
{
key: 'credit',
header: 'Haber',
render: (row) => (
<span className={`text-sm text-right block ${row.credit > 0 ? 'text-red-600 font-medium' : 'text-gray-400'}`}>
${formatCurrency(row.credit)}
</span>
),
},
{
key: 'balance',
header: 'Saldo',
render: (row) => (
<span className={`text-sm text-right block font-semibold ${row.balance >= 0 ? 'text-gray-900' : 'text-red-600'}`}>
${formatCurrency(row.balance)}
</span>
),
},
];
const isLoading = activeView === 'trial-balance' ? isLoadingTrialBalance : isLoadingGeneralLedger;
const error = activeView === 'trial-balance' ? trialBalanceError : generalLedgerError;
if (error) {
return (
<div className="p-6">
<ErrorEmptyState onRetry={clearResults} />
</div>
);
}
return (
<div className="space-y-6 p-6">
<Breadcrumbs items={[
{ label: 'Reportes', href: '/reports' },
{ label: 'Reportes Rapidos' },
]} />
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-gray-900">Reportes Rapidos</h1>
<p className="text-sm text-gray-500">
Genera Balance de Comprobacion y Libro Mayor de forma inmediata
</p>
</div>
</div>
{/* Report Type Selector */}
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<Card
className={`cursor-pointer transition-all ${
activeView === 'trial-balance'
? 'ring-2 ring-blue-500 shadow-md'
: 'hover:shadow-md'
}`}
onClick={() => setActiveView('trial-balance')}
>
<CardContent className="p-6">
<div className="flex items-center gap-4">
<div className={`flex h-12 w-12 items-center justify-center rounded-lg ${
activeView === 'trial-balance' ? 'bg-blue-100' : 'bg-gray-100'
}`}>
<Scale className={`h-6 w-6 ${
activeView === 'trial-balance' ? 'text-blue-600' : 'text-gray-500'
}`} />
</div>
<div>
<div className={`font-semibold ${
activeView === 'trial-balance' ? 'text-blue-600' : 'text-gray-900'
}`}>
Balance de Comprobacion
</div>
<div className="text-sm text-gray-500">
Saldos iniciales, movimientos y saldos finales
</div>
</div>
</div>
</CardContent>
</Card>
<Card
className={`cursor-pointer transition-all ${
activeView === 'general-ledger'
? 'ring-2 ring-blue-500 shadow-md'
: 'hover:shadow-md'
}`}
onClick={() => setActiveView('general-ledger')}
>
<CardContent className="p-6">
<div className="flex items-center gap-4">
<div className={`flex h-12 w-12 items-center justify-center rounded-lg ${
activeView === 'general-ledger' ? 'bg-blue-100' : 'bg-gray-100'
}`}>
<BookOpen className={`h-6 w-6 ${
activeView === 'general-ledger' ? 'text-blue-600' : 'text-gray-500'
}`} />
</div>
<div>
<div className={`font-semibold ${
activeView === 'general-ledger' ? 'text-blue-600' : 'text-gray-900'
}`}>
Libro Mayor
</div>
<div className="text-sm text-gray-500">
Movimientos detallados por cuenta
</div>
</div>
</div>
</CardContent>
</Card>
</div>
{/* Filters */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Filter className="h-5 w-5" />
Parametros del Reporte
</CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
<Building2 className="inline-block h-4 w-4 mr-1" />
Empresa
</label>
<select
value={companyId}
onChange={(e) => setCompanyId(e.target.value)}
className="w-full rounded-md border border-gray-300 px-3 py-2 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
>
<option value="">Seleccione empresa</option>
{/* Companies would be loaded from a hook */}
<option value="1">Empresa Principal</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
<Calendar className="inline-block h-4 w-4 mr-1" />
Fecha desde
</label>
<input
type="date"
value={dateFrom}
onChange={(e) => setDateFrom(e.target.value)}
className="w-full rounded-md border border-gray-300 px-3 py-2 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
<Calendar className="inline-block h-4 w-4 mr-1" />
Fecha hasta
</label>
<input
type="date"
value={dateTo}
onChange={(e) => setDateTo(e.target.value)}
className="w-full rounded-md border border-gray-300 px-3 py-2 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
/>
</div>
{activeView === 'trial-balance' && (
<div className="flex items-end">
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={includeZeroBalance}
onChange={(e) => setIncludeZeroBalance(e.target.checked)}
className="rounded border-gray-300 text-blue-600 focus:ring-blue-500"
/>
<span className="text-sm text-gray-700">Incluir saldos en cero</span>
</label>
</div>
)}
{activeView === 'general-ledger' && (
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
<Search className="inline-block h-4 w-4 mr-1" />
Cuenta (opcional)
</label>
<input
type="text"
value={accountId}
onChange={(e) => setAccountId(e.target.value)}
placeholder="ID o codigo de cuenta"
className="w-full rounded-md border border-gray-300 px-3 py-2 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
/>
</div>
)}
</div>
<div className="flex gap-2 mt-4">
<Button
onClick={activeView === 'trial-balance' ? handleGenerateTrialBalance : handleGenerateGeneralLedger}
disabled={isLoading || !companyId}
>
{isLoading ? (
<>
<RefreshCw className="mr-2 h-4 w-4 animate-spin" />
Generando...
</>
) : (
<>
<FileSpreadsheet className="mr-2 h-4 w-4" />
Generar Reporte
</>
)}
</Button>
{(trialBalance || generalLedger) && (
<>
<Button variant="outline" onClick={() => handleExport('excel')}>
<Download className="mr-2 h-4 w-4" />
Excel
</Button>
<Button variant="outline" onClick={() => handleExport('pdf')}>
<Download className="mr-2 h-4 w-4" />
PDF
</Button>
<Button variant="outline" onClick={() => handleExport('csv')}>
<Download className="mr-2 h-4 w-4" />
CSV
</Button>
</>
)}
</div>
</CardContent>
</Card>
{/* Results */}
{activeView === 'trial-balance' && trialBalance && (
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle>Balance de Comprobacion</CardTitle>
<span className="text-sm text-gray-500">
Generado: {trialBalance.generatedAt}
</span>
</div>
</CardHeader>
<CardContent>
<div className="space-y-4">
{/* Summary */}
<div className="grid grid-cols-2 gap-4 sm:grid-cols-6 p-4 bg-gray-50 rounded-lg">
<div>
<div className="text-xs text-gray-500">Saldo Inicial Debe</div>
<div className="font-semibold">${formatCurrency(trialBalance.totals.openingDebit)}</div>
</div>
<div>
<div className="text-xs text-gray-500">Saldo Inicial Haber</div>
<div className="font-semibold">${formatCurrency(trialBalance.totals.openingCredit)}</div>
</div>
<div>
<div className="text-xs text-gray-500">Movimientos Debe</div>
<div className="font-semibold text-blue-600">${formatCurrency(trialBalance.totals.periodDebit)}</div>
</div>
<div>
<div className="text-xs text-gray-500">Movimientos Haber</div>
<div className="font-semibold text-blue-600">${formatCurrency(trialBalance.totals.periodCredit)}</div>
</div>
<div>
<div className="text-xs text-gray-500">Saldo Final Debe</div>
<div className="font-bold text-green-600">${formatCurrency(trialBalance.totals.closingDebit)}</div>
</div>
<div>
<div className="text-xs text-gray-500">Saldo Final Haber</div>
<div className="font-bold text-green-600">${formatCurrency(trialBalance.totals.closingCredit)}</div>
</div>
</div>
<DataTable
data={trialBalance.rows}
columns={trialBalanceColumns}
isLoading={false}
/>
</div>
</CardContent>
</Card>
)}
{activeView === 'general-ledger' && generalLedger && (
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle>Libro Mayor</CardTitle>
<p className="text-sm text-gray-500 mt-1">
{generalLedger.accountCode} - {generalLedger.accountName}
</p>
</div>
<span className="text-sm text-gray-500">
Generado: {generalLedger.generatedAt}
</span>
</div>
</CardHeader>
<CardContent>
<div className="space-y-4">
{/* Summary */}
<div className="grid grid-cols-2 gap-4 sm:grid-cols-4 p-4 bg-gray-50 rounded-lg">
<div>
<div className="text-xs text-gray-500">Saldo Inicial</div>
<div className="font-semibold">${formatCurrency(generalLedger.openingBalance)}</div>
</div>
<div>
<div className="text-xs text-gray-500">Total Debe</div>
<div className="font-semibold text-green-600">${formatCurrency(generalLedger.totalDebit)}</div>
</div>
<div>
<div className="text-xs text-gray-500">Total Haber</div>
<div className="font-semibold text-red-600">${formatCurrency(generalLedger.totalCredit)}</div>
</div>
<div>
<div className="text-xs text-gray-500">Saldo Final</div>
<div className={`font-bold ${generalLedger.closingBalance >= 0 ? 'text-gray-900' : 'text-red-600'}`}>
${formatCurrency(generalLedger.closingBalance)}
</div>
</div>
</div>
<DataTable
data={generalLedger.rows}
columns={generalLedgerColumns}
isLoading={false}
/>
</div>
</CardContent>
</Card>
)}
</div>
);
}
export default QuickReportsPage;

View File

@ -0,0 +1,861 @@
import { useState } from 'react';
import {
FileText,
Plus,
MoreVertical,
Eye,
Play,
Clock,
RefreshCw,
Search,
Download,
Trash2,
Edit2,
XCircle,
CheckCircle,
AlertCircle,
BarChart3,
PlayCircle,
PauseCircle,
} from 'lucide-react';
import { Button } from '@components/atoms/Button';
import { Card, CardHeader, CardTitle, CardContent } from '@components/molecules/Card';
import { DataTable, type Column } from '@components/organisms/DataTable';
import { Dropdown, type DropdownItem } from '@components/organisms/Dropdown';
import { Breadcrumbs } from '@components/organisms/Breadcrumbs';
import { ConfirmModal } from '@components/organisms/Modal';
import { NoDataEmptyState, ErrorEmptyState } from '@components/templates/EmptyState';
import {
useReportDefinitions,
useReportExecutions,
useReportSchedules,
} from '@features/reports/hooks';
import type {
ReportDefinition,
ReportExecution,
ReportSchedule,
ExecutionStatus,
ReportType,
} from '@features/reports/types';
import {
REPORT_TYPE_LABELS,
EXECUTION_STATUS_LABELS,
} from '@features/reports/types';
import { formatDate } from '@utils/formatters';
type TabType = 'definitions' | 'executions' | 'schedules';
const statusColors: Record<ExecutionStatus, string> = {
pending: 'bg-yellow-100 text-yellow-700',
running: 'bg-blue-100 text-blue-700',
completed: 'bg-green-100 text-green-700',
failed: 'bg-red-100 text-red-700',
cancelled: 'bg-gray-100 text-gray-700',
};
const typeColors: Record<ReportType, string> = {
financial: 'bg-green-100 text-green-700',
accounting: 'bg-blue-100 text-blue-700',
tax: 'bg-purple-100 text-purple-700',
management: 'bg-orange-100 text-orange-700',
custom: 'bg-gray-100 text-gray-700',
};
export function ReportsPage() {
const [activeTab, setActiveTab] = useState<TabType>('definitions');
const [searchTerm, setSearchTerm] = useState('');
const [selectedType, setSelectedType] = useState<ReportType | ''>('');
const [selectedStatus, setSelectedStatus] = useState<ExecutionStatus | ''>('');
const [definitionToDelete, setDefinitionToDelete] = useState<ReportDefinition | null>(null);
const [executionToCancel, setExecutionToCancel] = useState<ReportExecution | null>(null);
const [scheduleToDelete, setScheduleToDelete] = useState<ReportSchedule | null>(null);
const [scheduleToToggle, setScheduleToToggle] = useState<ReportSchedule | null>(null);
// Definitions hook
const {
definitions,
total: definitionsTotal,
page: definitionsPage,
totalPages: definitionsTotalPages,
isLoading: definitionsLoading,
error: definitionsError,
setFilters: setDefinitionsFilters,
refresh: refreshDefinitions,
remove: removeDefinition,
toggleActive: toggleDefinitionActive,
} = useReportDefinitions({
initialFilters: { search: searchTerm, reportType: selectedType || undefined },
});
// Executions hook
const {
executions,
total: executionsTotal,
page: executionsPage,
totalPages: executionsTotalPages,
isLoading: executionsLoading,
error: executionsError,
refresh: refreshExecutions,
cancel: cancelExecution,
download: downloadExecution,
} = useReportExecutions({
initialFilters: { status: selectedStatus || undefined },
pollInterval: 5000,
});
// Schedules hook
const {
schedules,
total: schedulesTotal,
page: schedulesPage,
totalPages: schedulesTotalPages,
isLoading: schedulesLoading,
error: schedulesError,
refresh: refreshSchedules,
remove: removeSchedule,
toggle: toggleSchedule,
runNow: runScheduleNow,
} = useReportSchedules();
// Definition columns
const definitionColumns: Column<ReportDefinition>[] = [
{
key: 'name',
header: 'Reporte',
render: (def) => (
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-blue-50">
<FileText className="h-5 w-5 text-blue-600" />
</div>
<div>
<div className="font-medium text-gray-900">{def.name}</div>
<div className="text-sm text-gray-500">{def.code}</div>
</div>
</div>
),
},
{
key: 'reportType',
header: 'Tipo',
render: (def) => (
<span className={`inline-flex rounded-full px-2 py-1 text-xs font-medium ${typeColors[def.reportType]}`}>
{REPORT_TYPE_LABELS[def.reportType]}
</span>
),
},
{
key: 'category',
header: 'Categoria',
render: (def) => (
<span className="text-sm text-gray-600">{def.category || '-'}</span>
),
},
{
key: 'isActive',
header: 'Estado',
render: (def) => (
<span className={`inline-flex items-center gap-1 rounded-full px-2 py-1 text-xs font-medium ${
def.isActive ? 'bg-green-100 text-green-700' : 'bg-gray-100 text-gray-600'
}`}>
{def.isActive ? (
<>
<CheckCircle className="h-3 w-3" />
Activo
</>
) : (
<>
<XCircle className="h-3 w-3" />
Inactivo
</>
)}
</span>
),
},
{
key: 'isSystem',
header: 'Sistema',
render: (def) => (
<span className={`text-sm ${def.isSystem ? 'text-blue-600' : 'text-gray-500'}`}>
{def.isSystem ? 'Si' : 'No'}
</span>
),
},
{
key: 'actions',
header: '',
render: (def) => {
const items: DropdownItem[] = [
{
key: 'view',
label: 'Ver detalle',
icon: <Eye className="h-4 w-4" />,
onClick: () => console.log('View', def.id),
},
{
key: 'execute',
label: 'Ejecutar',
icon: <Play className="h-4 w-4" />,
onClick: () => console.log('Execute', def.id),
},
];
if (!def.isSystem) {
items.push(
{
key: 'edit',
label: 'Editar',
icon: <Edit2 className="h-4 w-4" />,
onClick: () => console.log('Edit', def.id),
},
{
key: 'toggle',
label: def.isActive ? 'Desactivar' : 'Activar',
icon: def.isActive ? <PauseCircle className="h-4 w-4" /> : <PlayCircle className="h-4 w-4" />,
onClick: () => toggleDefinitionActive(def.id),
},
{
key: 'delete',
label: 'Eliminar',
icon: <Trash2 className="h-4 w-4" />,
danger: true,
onClick: () => setDefinitionToDelete(def),
}
);
}
return (
<Dropdown
trigger={
<button className="rounded p-1 hover:bg-gray-100">
<MoreVertical className="h-4 w-4 text-gray-500" />
</button>
}
items={items}
align="right"
/>
);
},
},
];
// Execution columns
const executionColumns: Column<ReportExecution>[] = [
{
key: 'report',
header: 'Reporte',
render: (exec) => (
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-purple-50">
<BarChart3 className="h-5 w-5 text-purple-600" />
</div>
<div>
<div className="font-medium text-gray-900">{exec.definitionName || 'Reporte'}</div>
<div className="text-sm text-gray-500">{exec.definitionCode || exec.definitionId}</div>
</div>
</div>
),
},
{
key: 'status',
header: 'Estado',
render: (exec) => (
<span className={`inline-flex items-center gap-1 rounded-full px-2 py-1 text-xs font-medium ${statusColors[exec.status]}`}>
{exec.status === 'running' && <RefreshCw className="h-3 w-3 animate-spin" />}
{exec.status === 'completed' && <CheckCircle className="h-3 w-3" />}
{exec.status === 'failed' && <AlertCircle className="h-3 w-3" />}
{EXECUTION_STATUS_LABELS[exec.status]}
</span>
),
},
{
key: 'startedAt',
header: 'Iniciado',
render: (exec) => (
<span className="text-sm text-gray-600">
{exec.startedAt ? formatDate(exec.startedAt, 'short') : '-'}
</span>
),
},
{
key: 'completedAt',
header: 'Completado',
render: (exec) => (
<span className="text-sm text-gray-600">
{exec.completedAt ? formatDate(exec.completedAt, 'short') : '-'}
</span>
),
},
{
key: 'duration',
header: 'Duracion',
render: (exec) => (
<span className="text-sm text-gray-600">
{exec.executionTimeMs ? `${(exec.executionTimeMs / 1000).toFixed(2)}s` : '-'}
</span>
),
},
{
key: 'rows',
header: 'Filas',
render: (exec) => (
<span className="text-sm text-gray-600">
{exec.rowCount?.toLocaleString() || '-'}
</span>
),
},
{
key: 'requestedBy',
header: 'Solicitado por',
render: (exec) => (
<span className="text-sm text-gray-600">
{exec.requestedByName || exec.requestedBy}
</span>
),
},
{
key: 'actions',
header: '',
render: (exec) => {
const items: DropdownItem[] = [
{
key: 'view',
label: 'Ver detalle',
icon: <Eye className="h-4 w-4" />,
onClick: () => console.log('View', exec.id),
},
];
if (exec.status === 'completed' && exec.outputFiles.length > 0) {
exec.outputFiles.forEach((file) => {
items.push({
key: `download-${file.format}`,
label: `Descargar ${file.format.toUpperCase()}`,
icon: <Download className="h-4 w-4" />,
onClick: () => downloadExecution(exec.id, file.format),
});
});
}
if (exec.status === 'pending' || exec.status === 'running') {
items.push({
key: 'cancel',
label: 'Cancelar',
icon: <XCircle className="h-4 w-4" />,
danger: true,
onClick: () => setExecutionToCancel(exec),
});
}
return (
<Dropdown
trigger={
<button className="rounded p-1 hover:bg-gray-100">
<MoreVertical className="h-4 w-4 text-gray-500" />
</button>
}
items={items}
align="right"
/>
);
},
},
];
// Schedule columns
const scheduleColumns: Column<ReportSchedule>[] = [
{
key: 'name',
header: 'Programacion',
render: (sched) => (
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-orange-50">
<Clock className="h-5 w-5 text-orange-600" />
</div>
<div>
<div className="font-medium text-gray-900">{sched.name}</div>
<div className="text-sm text-gray-500">{sched.definitionName}</div>
</div>
</div>
),
},
{
key: 'cronExpression',
header: 'Frecuencia',
render: (sched) => (
<code className="text-sm text-gray-600 bg-gray-100 px-2 py-1 rounded">
{sched.cronExpression}
</code>
),
},
{
key: 'isActive',
header: 'Estado',
render: (sched) => (
<span className={`inline-flex items-center gap-1 rounded-full px-2 py-1 text-xs font-medium ${
sched.isActive ? 'bg-green-100 text-green-700' : 'bg-gray-100 text-gray-600'
}`}>
{sched.isActive ? 'Activo' : 'Inactivo'}
</span>
),
},
{
key: 'lastRunAt',
header: 'Ultima ejecucion',
render: (sched) => (
<span className="text-sm text-gray-600">
{sched.lastRunAt ? formatDate(sched.lastRunAt, 'short') : 'Nunca'}
</span>
),
},
{
key: 'nextRunAt',
header: 'Proxima ejecucion',
render: (sched) => (
<span className="text-sm text-gray-600">
{sched.nextRunAt ? formatDate(sched.nextRunAt, 'short') : '-'}
</span>
),
},
{
key: 'actions',
header: '',
render: (sched) => {
const items: DropdownItem[] = [
{
key: 'view',
label: 'Ver detalle',
icon: <Eye className="h-4 w-4" />,
onClick: () => console.log('View', sched.id),
},
{
key: 'edit',
label: 'Editar',
icon: <Edit2 className="h-4 w-4" />,
onClick: () => console.log('Edit', sched.id),
},
{
key: 'runNow',
label: 'Ejecutar ahora',
icon: <Play className="h-4 w-4" />,
onClick: () => runScheduleNow(sched.id),
},
{
key: 'toggle',
label: sched.isActive ? 'Pausar' : 'Activar',
icon: sched.isActive ? <PauseCircle className="h-4 w-4" /> : <PlayCircle className="h-4 w-4" />,
onClick: () => setScheduleToToggle(sched),
},
{
key: 'delete',
label: 'Eliminar',
icon: <Trash2 className="h-4 w-4" />,
danger: true,
onClick: () => setScheduleToDelete(sched),
},
];
return (
<Dropdown
trigger={
<button className="rounded p-1 hover:bg-gray-100">
<MoreVertical className="h-4 w-4 text-gray-500" />
</button>
}
items={items}
align="right"
/>
);
},
},
];
const handleDeleteDefinition = async () => {
if (definitionToDelete) {
await removeDefinition(definitionToDelete.id);
setDefinitionToDelete(null);
}
};
const handleCancelExecution = async () => {
if (executionToCancel) {
await cancelExecution(executionToCancel.id);
setExecutionToCancel(null);
}
};
const handleDeleteSchedule = async () => {
if (scheduleToDelete) {
await removeSchedule(scheduleToDelete.id);
setScheduleToDelete(null);
}
};
const handleToggleSchedule = async () => {
if (scheduleToToggle) {
await toggleSchedule(scheduleToToggle.id);
setScheduleToToggle(null);
}
};
const getCurrentRefresh = () => {
switch (activeTab) {
case 'definitions': return refreshDefinitions;
case 'executions': return refreshExecutions;
case 'schedules': return refreshSchedules;
}
};
const isCurrentLoading = () => {
switch (activeTab) {
case 'definitions': return definitionsLoading;
case 'executions': return executionsLoading;
case 'schedules': return schedulesLoading;
}
};
const getCurrentError = () => {
switch (activeTab) {
case 'definitions': return definitionsError;
case 'executions': return executionsError;
case 'schedules': return schedulesError;
}
};
// Stats
const activeDefinitions = definitions.filter(d => d.isActive).length;
const runningExecutions = executions.filter(e => e.status === 'running').length;
const completedToday = executions.filter(e => {
if (!e.completedAt) return false;
const today = new Date().toDateString();
return new Date(e.completedAt).toDateString() === today && e.status === 'completed';
}).length;
const activeSchedules = schedules.filter(s => s.isActive).length;
const error = getCurrentError();
if (error) {
return (
<div className="p-6">
<ErrorEmptyState onRetry={getCurrentRefresh()} />
</div>
);
}
return (
<div className="space-y-6 p-6">
<Breadcrumbs items={[
{ label: 'Reportes' },
]} />
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-gray-900">Reportes</h1>
<p className="text-sm text-gray-500">
Gestiona definiciones, ejecuciones y programaciones de reportes
</p>
</div>
<div className="flex gap-2">
<Button variant="outline" onClick={getCurrentRefresh()} disabled={isCurrentLoading()}>
<RefreshCw className={`mr-2 h-4 w-4 ${isCurrentLoading() ? 'animate-spin' : ''}`} />
Actualizar
</Button>
{activeTab === 'definitions' && (
<Button>
<Plus className="mr-2 h-4 w-4" />
Nuevo reporte
</Button>
)}
{activeTab === 'schedules' && (
<Button>
<Plus className="mr-2 h-4 w-4" />
Nueva programacion
</Button>
)}
</div>
</div>
{/* Summary Stats */}
<div className="grid grid-cols-1 gap-4 sm:grid-cols-4">
<Card className="cursor-pointer hover:shadow-md transition-shadow" onClick={() => setActiveTab('definitions')}>
<CardContent className="p-4">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-blue-100">
<FileText className="h-5 w-5 text-blue-600" />
</div>
<div>
<div className="text-sm text-gray-500">Definiciones activas</div>
<div className="text-xl font-bold text-blue-600">{activeDefinitions}</div>
</div>
</div>
</CardContent>
</Card>
<Card className="cursor-pointer hover:shadow-md transition-shadow" onClick={() => setActiveTab('executions')}>
<CardContent className="p-4">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-yellow-100">
<RefreshCw className={`h-5 w-5 text-yellow-600 ${runningExecutions > 0 ? 'animate-spin' : ''}`} />
</div>
<div>
<div className="text-sm text-gray-500">En ejecucion</div>
<div className="text-xl font-bold text-yellow-600">{runningExecutions}</div>
</div>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="p-4">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-green-100">
<CheckCircle className="h-5 w-5 text-green-600" />
</div>
<div>
<div className="text-sm text-gray-500">Completados hoy</div>
<div className="text-xl font-bold text-green-600">{completedToday}</div>
</div>
</div>
</CardContent>
</Card>
<Card className="cursor-pointer hover:shadow-md transition-shadow" onClick={() => setActiveTab('schedules')}>
<CardContent className="p-4">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-orange-100">
<Clock className="h-5 w-5 text-orange-600" />
</div>
<div>
<div className="text-sm text-gray-500">Programaciones</div>
<div className="text-xl font-bold text-orange-600">{activeSchedules}</div>
</div>
</div>
</CardContent>
</Card>
</div>
{/* Tabs */}
<div className="border-b border-gray-200">
<nav className="-mb-px flex space-x-8">
<button
onClick={() => setActiveTab('definitions')}
className={`py-4 px-1 border-b-2 font-medium text-sm ${
activeTab === 'definitions'
? 'border-blue-500 text-blue-600'
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
}`}
>
<FileText className="inline-block h-4 w-4 mr-2" />
Definiciones ({definitionsTotal})
</button>
<button
onClick={() => setActiveTab('executions')}
className={`py-4 px-1 border-b-2 font-medium text-sm ${
activeTab === 'executions'
? 'border-blue-500 text-blue-600'
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
}`}
>
<BarChart3 className="inline-block h-4 w-4 mr-2" />
Ejecuciones ({executionsTotal})
</button>
<button
onClick={() => setActiveTab('schedules')}
className={`py-4 px-1 border-b-2 font-medium text-sm ${
activeTab === 'schedules'
? 'border-blue-500 text-blue-600'
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
}`}
>
<Clock className="inline-block h-4 w-4 mr-2" />
Programaciones ({schedulesTotal})
</button>
</nav>
</div>
{/* Content based on active tab */}
<Card>
<CardHeader>
<CardTitle>
{activeTab === 'definitions' && 'Definiciones de Reportes'}
{activeTab === 'executions' && 'Historial de Ejecuciones'}
{activeTab === 'schedules' && 'Programaciones'}
</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-4">
{/* Filters */}
<div className="flex flex-wrap items-center gap-4">
<div className="relative flex-1 min-w-[200px]">
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-gray-400" />
<input
type="text"
placeholder="Buscar..."
value={searchTerm}
onChange={(e) => {
setSearchTerm(e.target.value);
if (activeTab === 'definitions') {
setDefinitionsFilters({ search: e.target.value });
}
}}
className="w-full rounded-md border border-gray-300 py-2 pl-10 pr-4 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
/>
</div>
{activeTab === 'definitions' && (
<select
value={selectedType}
onChange={(e) => {
setSelectedType(e.target.value as ReportType | '');
setDefinitionsFilters({ reportType: e.target.value as ReportType || undefined });
}}
className="rounded-md border border-gray-300 px-3 py-2 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
>
<option value="">Todos los tipos</option>
{Object.entries(REPORT_TYPE_LABELS).map(([value, label]) => (
<option key={value} value={value}>{label}</option>
))}
</select>
)}
{activeTab === 'executions' && (
<select
value={selectedStatus}
onChange={(e) => setSelectedStatus(e.target.value as ExecutionStatus | '')}
className="rounded-md border border-gray-300 px-3 py-2 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
>
<option value="">Todos los estados</option>
{Object.entries(EXECUTION_STATUS_LABELS).map(([value, label]) => (
<option key={value} value={value}>{label}</option>
))}
</select>
)}
{(searchTerm || selectedType || selectedStatus) && (
<Button
variant="ghost"
size="sm"
onClick={() => {
setSearchTerm('');
setSelectedType('');
setSelectedStatus('');
if (activeTab === 'definitions') {
setDefinitionsFilters({});
}
}}
>
Limpiar filtros
</Button>
)}
</div>
{/* Tables */}
{activeTab === 'definitions' && (
definitions.length === 0 && !definitionsLoading ? (
<NoDataEmptyState entityName="definiciones de reportes" />
) : (
<DataTable
data={definitions}
columns={definitionColumns}
isLoading={definitionsLoading}
pagination={{
page: definitionsPage,
totalPages: definitionsTotalPages,
total: definitionsTotal,
limit: 20,
onPageChange: (p) => setDefinitionsFilters({ page: p }),
}}
/>
)
)}
{activeTab === 'executions' && (
executions.length === 0 && !executionsLoading ? (
<NoDataEmptyState entityName="ejecuciones" />
) : (
<DataTable
data={executions}
columns={executionColumns}
isLoading={executionsLoading}
pagination={{
page: executionsPage,
totalPages: executionsTotalPages,
total: executionsTotal,
limit: 20,
onPageChange: () => {},
}}
/>
)
)}
{activeTab === 'schedules' && (
schedules.length === 0 && !schedulesLoading ? (
<NoDataEmptyState entityName="programaciones" />
) : (
<DataTable
data={schedules}
columns={scheduleColumns}
isLoading={schedulesLoading}
pagination={{
page: schedulesPage,
totalPages: schedulesTotalPages,
total: schedulesTotal,
limit: 20,
onPageChange: () => {},
}}
/>
)
)}
</div>
</CardContent>
</Card>
{/* Delete Definition Modal */}
<ConfirmModal
isOpen={!!definitionToDelete}
onClose={() => setDefinitionToDelete(null)}
onConfirm={handleDeleteDefinition}
title="Eliminar definicion"
message={`¿Eliminar la definicion "${definitionToDelete?.name}"? Esta accion no se puede deshacer.`}
variant="danger"
confirmText="Eliminar"
/>
{/* Cancel Execution Modal */}
<ConfirmModal
isOpen={!!executionToCancel}
onClose={() => setExecutionToCancel(null)}
onConfirm={handleCancelExecution}
title="Cancelar ejecucion"
message="¿Cancelar la ejecucion de este reporte? El reporte no se generara."
variant="danger"
confirmText="Cancelar ejecucion"
/>
{/* Delete Schedule Modal */}
<ConfirmModal
isOpen={!!scheduleToDelete}
onClose={() => setScheduleToDelete(null)}
onConfirm={handleDeleteSchedule}
title="Eliminar programacion"
message={`¿Eliminar la programacion "${scheduleToDelete?.name}"? Esta accion no se puede deshacer.`}
variant="danger"
confirmText="Eliminar"
/>
{/* Toggle Schedule Modal */}
<ConfirmModal
isOpen={!!scheduleToToggle}
onClose={() => setScheduleToToggle(null)}
onConfirm={handleToggleSchedule}
title={scheduleToToggle?.isActive ? 'Pausar programacion' : 'Activar programacion'}
message={scheduleToToggle?.isActive
? `¿Pausar la programacion "${scheduleToToggle?.name}"? No se ejecutara automaticamente hasta que se reactive.`
: `¿Activar la programacion "${scheduleToToggle?.name}"? Se ejecutara segun la frecuencia configurada.`
}
variant={scheduleToToggle?.isActive ? 'warning' : 'success'}
confirmText={scheduleToToggle?.isActive ? 'Pausar' : 'Activar'}
/>
</div>
);
}
export default ReportsPage;

View File

@ -0,0 +1,2 @@
export { ReportsPage } from './ReportsPage';
export { QuickReportsPage } from './QuickReportsPage';

View File

@ -0,0 +1,112 @@
import { useState } from 'react';
import {
Building2,
Users as UsersIcon,
User,
Shield,
Settings2,
ChevronRight,
} from 'lucide-react';
import { Card, CardContent } from '@components/molecules/Card';
import { Breadcrumbs } from '@components/organisms/Breadcrumbs';
type SettingsSection = 'company' | 'users' | 'profile' | 'security' | 'system';
interface SettingsMenuItem {
key: SettingsSection;
label: string;
description: string;
icon: React.ReactNode;
href: string;
}
const menuItems: SettingsMenuItem[] = [
{
key: 'company',
label: 'Empresa',
description: 'Informacion general, logo y configuracion fiscal',
icon: <Building2 className="h-6 w-6 text-blue-600" />,
href: '/settings/company',
},
{
key: 'users',
label: 'Usuarios',
description: 'Gestiona usuarios, roles y permisos',
icon: <UsersIcon className="h-6 w-6 text-green-600" />,
href: '/settings/users',
},
{
key: 'profile',
label: 'Mi perfil',
description: 'Tu informacion personal y preferencias',
icon: <User className="h-6 w-6 text-purple-600" />,
href: '/settings/profile',
},
{
key: 'security',
label: 'Seguridad',
description: 'Contrasena, autenticacion y logs de auditoria',
icon: <Shield className="h-6 w-6 text-red-600" />,
href: '/settings/security',
},
{
key: 'system',
label: 'Sistema',
description: 'Configuracion avanzada del sistema',
icon: <Settings2 className="h-6 w-6 text-gray-600" />,
href: '/settings/system',
},
];
export function SettingsPage() {
const [hoveredItem, setHoveredItem] = useState<SettingsSection | null>(null);
return (
<div className="space-y-6 p-6">
<Breadcrumbs items={[
{ label: 'Configuracion' },
]} />
<div>
<h1 className="text-2xl font-bold text-gray-900">Configuracion</h1>
<p className="text-sm text-gray-500">
Gestiona la configuracion de tu empresa, usuarios y sistema
</p>
</div>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{menuItems.map((item) => (
<a
key={item.key}
href={item.href}
onMouseEnter={() => setHoveredItem(item.key)}
onMouseLeave={() => setHoveredItem(null)}
>
<Card className={`cursor-pointer transition-all h-full ${
hoveredItem === item.key ? 'shadow-md ring-2 ring-blue-500' : 'hover:shadow-md'
}`}>
<CardContent className="p-6">
<div className="flex items-start justify-between">
<div className="flex items-start gap-4">
<div className="flex h-12 w-12 items-center justify-center rounded-lg bg-gray-50">
{item.icon}
</div>
<div>
<h3 className="font-semibold text-gray-900">{item.label}</h3>
<p className="text-sm text-gray-500 mt-1">{item.description}</p>
</div>
</div>
<ChevronRight className={`h-5 w-5 text-gray-400 transition-transform ${
hoveredItem === item.key ? 'translate-x-1' : ''
}`} />
</div>
</CardContent>
</Card>
</a>
))}
</div>
</div>
);
}
export default SettingsPage;

View File

@ -0,0 +1,448 @@
import { useState } from 'react';
import {
Users as UsersIcon,
Plus,
MoreVertical,
Eye,
Edit2,
Trash2,
Key,
Mail,
RefreshCw,
Search,
UserCheck,
UserX,
Shield,
} from 'lucide-react';
import { Button } from '@components/atoms/Button';
import { Card, CardHeader, CardTitle, CardContent } from '@components/molecules/Card';
import { DataTable, type Column } from '@components/organisms/DataTable';
import { Dropdown, type DropdownItem } from '@components/organisms/Dropdown';
import { Breadcrumbs } from '@components/organisms/Breadcrumbs';
import { ConfirmModal } from '@components/organisms/Modal';
import { NoDataEmptyState, ErrorEmptyState } from '@components/templates/EmptyState';
import { useUsers } from '@features/settings/hooks';
import type { User, UserRole } from '@features/settings/types';
import { USER_ROLE_LABELS } from '@features/settings/types';
import { formatDate } from '@utils/formatters';
const roleColors: Record<UserRole, string> = {
admin: 'bg-red-100 text-red-700',
manager: 'bg-blue-100 text-blue-700',
user: 'bg-green-100 text-green-700',
viewer: 'bg-gray-100 text-gray-700',
};
export function UsersSettingsPage() {
const [searchTerm, setSearchTerm] = useState('');
const [selectedRole, setSelectedRole] = useState<UserRole | ''>('');
const [showActiveOnly, setShowActiveOnly] = useState<boolean | undefined>(undefined);
const [userToDelete, setUserToDelete] = useState<User | null>(null);
const [userToToggle, setUserToToggle] = useState<User | null>(null);
const [userToResetPassword, setUserToResetPassword] = useState<User | null>(null);
const {
users,
total,
page,
totalPages,
isLoading,
error,
setFilters,
refresh,
remove,
toggleActive,
resetPassword,
resendInvitation,
} = useUsers({
initialFilters: {
search: searchTerm,
role: selectedRole || undefined,
isActive: showActiveOnly,
},
});
const handleSearch = (value: string) => {
setSearchTerm(value);
setFilters({ search: value });
};
const handleRoleFilter = (role: UserRole | '') => {
setSelectedRole(role);
setFilters({ role: role || undefined });
};
const handleActiveFilter = (active: boolean | undefined) => {
setShowActiveOnly(active);
setFilters({ isActive: active });
};
const columns: Column<User>[] = [
{
key: 'name',
header: 'Usuario',
render: (user) => (
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-blue-100">
{user.avatarUrl ? (
<img src={user.avatarUrl} alt={user.name} className="h-10 w-10 rounded-full object-cover" />
) : (
<span className="text-sm font-medium text-blue-600">
{user.name.split(' ').map(n => n[0]).join('').toUpperCase().slice(0, 2)}
</span>
)}
</div>
<div>
<div className="font-medium text-gray-900">{user.name}</div>
<div className="text-sm text-gray-500">{user.email}</div>
</div>
</div>
),
},
{
key: 'role',
header: 'Rol',
render: (user) => (
<span className={`inline-flex items-center gap-1 rounded-full px-2 py-1 text-xs font-medium ${roleColors[user.role]}`}>
<Shield className="h-3 w-3" />
{USER_ROLE_LABELS[user.role]}
</span>
),
},
{
key: 'isActive',
header: 'Estado',
render: (user) => (
<span className={`inline-flex items-center gap-1 rounded-full px-2 py-1 text-xs font-medium ${
user.isActive ? 'bg-green-100 text-green-700' : 'bg-gray-100 text-gray-600'
}`}>
{user.isActive ? (
<>
<UserCheck className="h-3 w-3" />
Activo
</>
) : (
<>
<UserX className="h-3 w-3" />
Inactivo
</>
)}
</span>
),
},
{
key: 'lastLoginAt',
header: 'Ultimo acceso',
render: (user) => (
<span className="text-sm text-gray-600">
{user.lastLoginAt ? formatDate(user.lastLoginAt, 'short') : 'Nunca'}
</span>
),
},
{
key: 'createdAt',
header: 'Creado',
render: (user) => (
<span className="text-sm text-gray-600">
{formatDate(user.createdAt, 'short')}
</span>
),
},
{
key: 'actions',
header: '',
render: (user) => {
const items: DropdownItem[] = [
{
key: 'view',
label: 'Ver detalle',
icon: <Eye className="h-4 w-4" />,
onClick: () => console.log('View', user.id),
},
{
key: 'edit',
label: 'Editar',
icon: <Edit2 className="h-4 w-4" />,
onClick: () => console.log('Edit', user.id),
},
{
key: 'toggle',
label: user.isActive ? 'Desactivar' : 'Activar',
icon: user.isActive ? <UserX className="h-4 w-4" /> : <UserCheck className="h-4 w-4" />,
onClick: () => setUserToToggle(user),
},
{
key: 'resetPassword',
label: 'Restablecer contrasena',
icon: <Key className="h-4 w-4" />,
onClick: () => setUserToResetPassword(user),
},
{
key: 'resendInvitation',
label: 'Reenviar invitacion',
icon: <Mail className="h-4 w-4" />,
onClick: () => resendInvitation(user.id),
},
{
key: 'delete',
label: 'Eliminar',
icon: <Trash2 className="h-4 w-4" />,
danger: true,
onClick: () => setUserToDelete(user),
},
];
return (
<Dropdown
trigger={
<button className="rounded p-1 hover:bg-gray-100">
<MoreVertical className="h-4 w-4 text-gray-500" />
</button>
}
items={items}
align="right"
/>
);
},
},
];
const handleDeleteUser = async () => {
if (userToDelete) {
await remove(userToDelete.id);
setUserToDelete(null);
}
};
const handleToggleUser = async () => {
if (userToToggle) {
await toggleActive(userToToggle.id);
setUserToToggle(null);
}
};
const handleResetPassword = async () => {
if (userToResetPassword) {
// Generate a temporary password or show a form
const tempPassword = 'TempPass123!';
await resetPassword(userToResetPassword.id, tempPassword);
setUserToResetPassword(null);
alert(`Contrasena restablecida. Nueva contrasena temporal: ${tempPassword}`);
}
};
// Stats
const activeUsers = users.filter(u => u.isActive).length;
const adminCount = users.filter(u => u.role === 'admin').length;
const managerCount = users.filter(u => u.role === 'manager').length;
if (error) {
return (
<div className="p-6">
<ErrorEmptyState onRetry={refresh} />
</div>
);
}
return (
<div className="space-y-6 p-6">
<Breadcrumbs items={[
{ label: 'Configuracion', href: '/settings' },
{ label: 'Usuarios' },
]} />
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-gray-900">Usuarios</h1>
<p className="text-sm text-gray-500">
Gestiona los usuarios de tu empresa
</p>
</div>
<div className="flex gap-2">
<Button variant="outline" onClick={refresh} disabled={isLoading}>
<RefreshCw className={`mr-2 h-4 w-4 ${isLoading ? 'animate-spin' : ''}`} />
Actualizar
</Button>
<Button>
<Plus className="mr-2 h-4 w-4" />
Nuevo usuario
</Button>
</div>
</div>
{/* Summary Stats */}
<div className="grid grid-cols-1 gap-4 sm:grid-cols-4">
<Card>
<CardContent className="p-4">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-blue-100">
<UsersIcon className="h-5 w-5 text-blue-600" />
</div>
<div>
<div className="text-sm text-gray-500">Total usuarios</div>
<div className="text-xl font-bold text-blue-600">{total}</div>
</div>
</div>
</CardContent>
</Card>
<Card className="cursor-pointer hover:shadow-md transition-shadow" onClick={() => handleActiveFilter(true)}>
<CardContent className="p-4">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-green-100">
<UserCheck className="h-5 w-5 text-green-600" />
</div>
<div>
<div className="text-sm text-gray-500">Activos</div>
<div className="text-xl font-bold text-green-600">{activeUsers}</div>
</div>
</div>
</CardContent>
</Card>
<Card className="cursor-pointer hover:shadow-md transition-shadow" onClick={() => handleRoleFilter('admin')}>
<CardContent className="p-4">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-red-100">
<Shield className="h-5 w-5 text-red-600" />
</div>
<div>
<div className="text-sm text-gray-500">Administradores</div>
<div className="text-xl font-bold text-red-600">{adminCount}</div>
</div>
</div>
</CardContent>
</Card>
<Card className="cursor-pointer hover:shadow-md transition-shadow" onClick={() => handleRoleFilter('manager')}>
<CardContent className="p-4">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-purple-100">
<UsersIcon className="h-5 w-5 text-purple-600" />
</div>
<div>
<div className="text-sm text-gray-500">Gerentes</div>
<div className="text-xl font-bold text-purple-600">{managerCount}</div>
</div>
</div>
</CardContent>
</Card>
</div>
<Card>
<CardHeader>
<CardTitle>Lista de Usuarios</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-4">
{/* Filters */}
<div className="flex flex-wrap items-center gap-4">
<div className="relative flex-1 min-w-[200px]">
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-gray-400" />
<input
type="text"
placeholder="Buscar usuarios..."
value={searchTerm}
onChange={(e) => handleSearch(e.target.value)}
className="w-full rounded-md border border-gray-300 py-2 pl-10 pr-4 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
/>
</div>
<select
value={selectedRole}
onChange={(e) => handleRoleFilter(e.target.value as UserRole | '')}
className="rounded-md border border-gray-300 px-3 py-2 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
>
<option value="">Todos los roles</option>
{Object.entries(USER_ROLE_LABELS).map(([value, label]) => (
<option key={value} value={value}>{label}</option>
))}
</select>
<select
value={showActiveOnly === undefined ? '' : showActiveOnly.toString()}
onChange={(e) => handleActiveFilter(e.target.value === '' ? undefined : e.target.value === 'true')}
className="rounded-md border border-gray-300 px-3 py-2 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
>
<option value="">Todos los estados</option>
<option value="true">Activos</option>
<option value="false">Inactivos</option>
</select>
{(searchTerm || selectedRole || showActiveOnly !== undefined) && (
<Button
variant="ghost"
size="sm"
onClick={() => {
setSearchTerm('');
setSelectedRole('');
setShowActiveOnly(undefined);
setFilters({});
}}
>
Limpiar filtros
</Button>
)}
</div>
{/* Table */}
{users.length === 0 && !isLoading ? (
<NoDataEmptyState entityName="usuarios" />
) : (
<DataTable
data={users}
columns={columns}
isLoading={isLoading}
pagination={{
page,
totalPages,
total,
limit: 20,
onPageChange: (p) => setFilters({ page: p }),
}}
/>
)}
</div>
</CardContent>
</Card>
{/* Delete User Modal */}
<ConfirmModal
isOpen={!!userToDelete}
onClose={() => setUserToDelete(null)}
onConfirm={handleDeleteUser}
title="Eliminar usuario"
message={`¿Eliminar al usuario "${userToDelete?.name}"? Esta accion no se puede deshacer.`}
variant="danger"
confirmText="Eliminar"
/>
{/* Toggle User Modal */}
<ConfirmModal
isOpen={!!userToToggle}
onClose={() => setUserToToggle(null)}
onConfirm={handleToggleUser}
title={userToToggle?.isActive ? 'Desactivar usuario' : 'Activar usuario'}
message={userToToggle?.isActive
? `¿Desactivar al usuario "${userToToggle?.name}"? No podra acceder al sistema.`
: `¿Activar al usuario "${userToToggle?.name}"? Podra acceder al sistema nuevamente.`
}
variant={userToToggle?.isActive ? 'warning' : 'success'}
confirmText={userToToggle?.isActive ? 'Desactivar' : 'Activar'}
/>
{/* Reset Password Modal */}
<ConfirmModal
isOpen={!!userToResetPassword}
onClose={() => setUserToResetPassword(null)}
onConfirm={handleResetPassword}
title="Restablecer contrasena"
message={`¿Restablecer la contrasena de "${userToResetPassword?.name}"? Se generara una contrasena temporal.`}
variant="warning"
confirmText="Restablecer"
/>
</div>
);
}
export default UsersSettingsPage;

View File

@ -0,0 +1,2 @@
export { SettingsPage } from './SettingsPage';
export { UsersSettingsPage } from './UsersSettingsPage';