[TASK-2026-01-20-005] feat: Resolve P2 gaps - Actions, Settings, Kanban
EPIC-P2-001: Frontend Actions Implementation - Replace 31 console.log placeholders with navigate() calls - 16 pages updated with proper routing EPIC-P2-002: Settings Subpages Creation - Add CompanySettingsPage.tsx - Add ProfileSettingsPage.tsx - Add SecuritySettingsPage.tsx - Add SystemSettingsPage.tsx - Update router with new routes EPIC-P2-003: Bug Fix ValuationReportsPage - Fix recursive getToday() function EPIC-P2-006: CRM Pipeline Kanban - Add PipelineKanbanPage.tsx - Add KanbanColumn.tsx component - Add KanbanCard.tsx component - Add CRM routes to router Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
f1a9ea3d1f
commit
3a461cb184
@ -35,6 +35,20 @@ const PlansPage = lazy(() => import('@pages/billing/PlansPage').then(m => ({ def
|
||||
const InvoicesPage = lazy(() => import('@pages/billing/InvoicesPage').then(m => ({ default: m.InvoicesPage })));
|
||||
const UsagePage = lazy(() => import('@pages/billing/UsagePage').then(m => ({ default: m.UsagePage })));
|
||||
|
||||
// Settings pages
|
||||
const SettingsPage = lazy(() => import('@pages/settings/SettingsPage').then(m => ({ default: m.SettingsPage })));
|
||||
const CompanySettingsPage = lazy(() => import('@pages/settings/CompanySettingsPage').then(m => ({ default: m.CompanySettingsPage })));
|
||||
const UsersSettingsPage = lazy(() => import('@pages/settings/UsersSettingsPage').then(m => ({ default: m.UsersSettingsPage })));
|
||||
const ProfileSettingsPage = lazy(() => import('@pages/settings/ProfileSettingsPage').then(m => ({ default: m.ProfileSettingsPage })));
|
||||
const SecuritySettingsPage = lazy(() => import('@pages/settings/SecuritySettingsPage').then(m => ({ default: m.SecuritySettingsPage })));
|
||||
const SystemSettingsPage = lazy(() => import('@pages/settings/SystemSettingsPage').then(m => ({ default: m.SystemSettingsPage })));
|
||||
const AuditLogsPage = lazy(() => import('@pages/settings/AuditLogsPage').then(m => ({ default: m.AuditLogsPage })));
|
||||
|
||||
// CRM pages
|
||||
const PipelineKanbanPage = lazy(() => import('@pages/crm/PipelineKanbanPage').then(m => ({ default: m.PipelineKanbanPage })));
|
||||
const LeadsPage = lazy(() => import('@pages/crm/LeadsPage').then(m => ({ default: m.LeadsPage })));
|
||||
const OpportunitiesPage = lazy(() => import('@pages/crm/OpportunitiesPage').then(m => ({ default: m.OpportunitiesPage })));
|
||||
|
||||
function LazyWrapper({ children }: { children: React.ReactNode }) {
|
||||
return <Suspense fallback={<FullPageSpinner />}>{children}</Suspense>;
|
||||
}
|
||||
@ -230,11 +244,40 @@ export const router = createBrowserRouter([
|
||||
</DashboardWrapper>
|
||||
),
|
||||
},
|
||||
// CRM routes
|
||||
{
|
||||
path: '/crm',
|
||||
element: <Navigate to="/crm/pipeline" replace />,
|
||||
},
|
||||
{
|
||||
path: '/crm/pipeline',
|
||||
element: (
|
||||
<DashboardWrapper>
|
||||
<PipelineKanbanPage />
|
||||
</DashboardWrapper>
|
||||
),
|
||||
},
|
||||
{
|
||||
path: '/crm/leads',
|
||||
element: (
|
||||
<DashboardWrapper>
|
||||
<LeadsPage />
|
||||
</DashboardWrapper>
|
||||
),
|
||||
},
|
||||
{
|
||||
path: '/crm/opportunities',
|
||||
element: (
|
||||
<DashboardWrapper>
|
||||
<OpportunitiesPage />
|
||||
</DashboardWrapper>
|
||||
),
|
||||
},
|
||||
{
|
||||
path: '/crm/*',
|
||||
element: (
|
||||
<DashboardWrapper>
|
||||
<div className="text-center text-gray-500">Módulo CRM - En desarrollo</div>
|
||||
<div className="text-center text-gray-500">Seccion CRM - En desarrollo</div>
|
||||
</DashboardWrapper>
|
||||
),
|
||||
},
|
||||
@ -246,11 +289,68 @@ export const router = createBrowserRouter([
|
||||
</DashboardWrapper>
|
||||
),
|
||||
},
|
||||
// Settings routes
|
||||
{
|
||||
path: '/settings',
|
||||
element: (
|
||||
<DashboardWrapper>
|
||||
<SettingsPage />
|
||||
</DashboardWrapper>
|
||||
),
|
||||
},
|
||||
{
|
||||
path: '/settings/company',
|
||||
element: (
|
||||
<DashboardWrapper>
|
||||
<CompanySettingsPage />
|
||||
</DashboardWrapper>
|
||||
),
|
||||
},
|
||||
{
|
||||
path: '/settings/users',
|
||||
element: (
|
||||
<DashboardWrapper>
|
||||
<UsersSettingsPage />
|
||||
</DashboardWrapper>
|
||||
),
|
||||
},
|
||||
{
|
||||
path: '/settings/profile',
|
||||
element: (
|
||||
<DashboardWrapper>
|
||||
<ProfileSettingsPage />
|
||||
</DashboardWrapper>
|
||||
),
|
||||
},
|
||||
{
|
||||
path: '/settings/security',
|
||||
element: (
|
||||
<DashboardWrapper>
|
||||
<SecuritySettingsPage />
|
||||
</DashboardWrapper>
|
||||
),
|
||||
},
|
||||
{
|
||||
path: '/settings/security/audit-logs',
|
||||
element: (
|
||||
<DashboardWrapper>
|
||||
<AuditLogsPage />
|
||||
</DashboardWrapper>
|
||||
),
|
||||
},
|
||||
{
|
||||
path: '/settings/system',
|
||||
element: (
|
||||
<DashboardWrapper>
|
||||
<SystemSettingsPage />
|
||||
</DashboardWrapper>
|
||||
),
|
||||
},
|
||||
{
|
||||
path: '/settings/*',
|
||||
element: (
|
||||
<DashboardWrapper>
|
||||
<div className="text-center text-gray-500">Configuración - En desarrollo</div>
|
||||
<div className="text-center text-gray-500">Seccion de Configuracion - En desarrollo</div>
|
||||
</DashboardWrapper>
|
||||
),
|
||||
},
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
Users,
|
||||
Plus,
|
||||
@ -56,6 +57,7 @@ const formatCurrency = (value: number): string => {
|
||||
};
|
||||
|
||||
export function LeadsPage() {
|
||||
const navigate = useNavigate();
|
||||
const [selectedStatus, setSelectedStatus] = useState<LeadStatus | ''>('');
|
||||
const [selectedSource, setSelectedSource] = useState<LeadSource | ''>('');
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
@ -86,7 +88,7 @@ export function LeadsPage() {
|
||||
key: 'view',
|
||||
label: 'Ver detalle',
|
||||
icon: <Eye className="h-4 w-4" />,
|
||||
onClick: () => console.log('View', lead.id),
|
||||
onClick: () => navigate(`/crm/leads/${lead.id}`),
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
Target,
|
||||
Plus,
|
||||
@ -41,6 +42,7 @@ const formatCurrency = (value: number): string => {
|
||||
};
|
||||
|
||||
export function OpportunitiesPage() {
|
||||
const navigate = useNavigate();
|
||||
const [selectedStatus, setSelectedStatus] = useState<OpportunityStatus | ''>('');
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [oppToWin, setOppToWin] = useState<Opportunity | null>(null);
|
||||
@ -69,7 +71,7 @@ export function OpportunitiesPage() {
|
||||
key: 'view',
|
||||
label: 'Ver detalle',
|
||||
icon: <Eye className="h-4 w-4" />,
|
||||
onClick: () => console.log('View', opp.id),
|
||||
onClick: () => navigate(`/crm/opportunities/${opp.id}`),
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
430
src/pages/crm/PipelineKanbanPage.tsx
Normal file
430
src/pages/crm/PipelineKanbanPage.tsx
Normal file
@ -0,0 +1,430 @@
|
||||
import { useState, useMemo } from 'react';
|
||||
import {
|
||||
Plus,
|
||||
RefreshCw,
|
||||
Search,
|
||||
Filter,
|
||||
List,
|
||||
} from 'lucide-react';
|
||||
import { Button } from '@components/atoms/Button';
|
||||
import { Breadcrumbs } from '@components/organisms/Breadcrumbs';
|
||||
import { DateRangePicker, type DateRange } from '@components/organisms/DatePicker';
|
||||
import { Select, type SelectOption } from '@components/organisms/Select';
|
||||
import { useToast } from '@components/organisms/Toast';
|
||||
import { KanbanColumn, type StageKey } from '@components/crm/KanbanColumn';
|
||||
import type { KanbanOpportunity } from '@components/crm/KanbanCard';
|
||||
import { formatNumber } from '@utils/formatters';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
// Stage labels for display
|
||||
const stageLabels: Record<StageKey, string> = {
|
||||
new: 'Nuevo',
|
||||
qualified: 'Calificado',
|
||||
proposition: 'Propuesta',
|
||||
won: 'Ganado',
|
||||
lost: 'Perdido',
|
||||
};
|
||||
|
||||
// Mock users for filter
|
||||
const mockUsers: SelectOption[] = [
|
||||
{ value: 'user-1', label: 'Juan Garcia' },
|
||||
{ value: 'user-2', label: 'Maria Lopez' },
|
||||
{ value: 'user-3', label: 'Carlos Rodriguez' },
|
||||
{ value: 'user-4', label: 'Ana Martinez' },
|
||||
];
|
||||
|
||||
// Mock data for opportunities
|
||||
const generateMockOpportunities = (): Record<StageKey, KanbanOpportunity[]> => ({
|
||||
new: [
|
||||
{
|
||||
id: '1',
|
||||
name: 'Proyecto ERP para Constructora ABC',
|
||||
expectedRevenue: 150000,
|
||||
expectedCloseDate: '2026-02-15',
|
||||
assignedTo: { id: 'user-1', name: 'Juan Garcia' },
|
||||
probability: 20,
|
||||
partnerName: 'Constructora ABC S.A.',
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
name: 'Sistema de Inventarios - Retail Plus',
|
||||
expectedRevenue: 75000,
|
||||
expectedCloseDate: '2026-02-28',
|
||||
assignedTo: { id: 'user-2', name: 'Maria Lopez' },
|
||||
probability: 15,
|
||||
partnerName: 'Retail Plus',
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
name: 'Integracion CRM - TechCorp',
|
||||
expectedRevenue: 45000,
|
||||
probability: 10,
|
||||
partnerName: 'TechCorp Internacional',
|
||||
},
|
||||
],
|
||||
qualified: [
|
||||
{
|
||||
id: '4',
|
||||
name: 'Modulo de Facturacion Electronica',
|
||||
expectedRevenue: 85000,
|
||||
expectedCloseDate: '2026-02-10',
|
||||
assignedTo: { id: 'user-1', name: 'Juan Garcia' },
|
||||
probability: 40,
|
||||
partnerName: 'Distribuidora Nacional',
|
||||
},
|
||||
{
|
||||
id: '5',
|
||||
name: 'Dashboard Ejecutivo - Financiera',
|
||||
expectedRevenue: 120000,
|
||||
expectedCloseDate: '2026-03-01',
|
||||
assignedTo: { id: 'user-3', name: 'Carlos Rodriguez' },
|
||||
probability: 50,
|
||||
partnerName: 'Financiera del Norte',
|
||||
},
|
||||
],
|
||||
proposition: [
|
||||
{
|
||||
id: '6',
|
||||
name: 'Sistema de Gestion de Proyectos',
|
||||
expectedRevenue: 200000,
|
||||
expectedCloseDate: '2026-01-30',
|
||||
assignedTo: { id: 'user-2', name: 'Maria Lopez' },
|
||||
probability: 65,
|
||||
partnerName: 'Consultoria Global',
|
||||
},
|
||||
{
|
||||
id: '7',
|
||||
name: 'Automatizacion de Procesos - Manufactura',
|
||||
expectedRevenue: 180000,
|
||||
expectedCloseDate: '2026-02-05',
|
||||
assignedTo: { id: 'user-4', name: 'Ana Martinez' },
|
||||
probability: 70,
|
||||
partnerName: 'Manufactura Industrial',
|
||||
},
|
||||
{
|
||||
id: '8',
|
||||
name: 'Portal de Clientes Web',
|
||||
expectedRevenue: 95000,
|
||||
expectedCloseDate: '2026-02-20',
|
||||
assignedTo: { id: 'user-1', name: 'Juan Garcia' },
|
||||
probability: 60,
|
||||
partnerName: 'Servicios Express',
|
||||
},
|
||||
],
|
||||
won: [
|
||||
{
|
||||
id: '9',
|
||||
name: 'Implementacion ERP Completo',
|
||||
expectedRevenue: 350000,
|
||||
expectedCloseDate: '2026-01-15',
|
||||
assignedTo: { id: 'user-3', name: 'Carlos Rodriguez' },
|
||||
probability: 100,
|
||||
partnerName: 'Grupo Industrial MX',
|
||||
},
|
||||
{
|
||||
id: '10',
|
||||
name: 'Modulo de Recursos Humanos',
|
||||
expectedRevenue: 65000,
|
||||
expectedCloseDate: '2026-01-10',
|
||||
assignedTo: { id: 'user-2', name: 'Maria Lopez' },
|
||||
probability: 100,
|
||||
partnerName: 'Corporativo Sur',
|
||||
},
|
||||
],
|
||||
lost: [
|
||||
{
|
||||
id: '11',
|
||||
name: 'Migracion de Sistema Legacy',
|
||||
expectedRevenue: 125000,
|
||||
expectedCloseDate: '2026-01-05',
|
||||
assignedTo: { id: 'user-4', name: 'Ana Martinez' },
|
||||
probability: 0,
|
||||
partnerName: 'Empresa Tradicional',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const formatCurrency = (value: number): string => {
|
||||
return formatNumber(value, 'es-MX', { minimumFractionDigits: 0, maximumFractionDigits: 0 });
|
||||
};
|
||||
|
||||
export function PipelineKanbanPage() {
|
||||
const { success } = useToast();
|
||||
|
||||
// State for filters
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [dateRange, setDateRange] = useState<DateRange>({ start: null, end: null });
|
||||
const [selectedUser, setSelectedUser] = useState<string>('');
|
||||
const [showFilters, setShowFilters] = useState(false);
|
||||
|
||||
// State for opportunities by stage
|
||||
const [opportunitiesByStage, setOpportunitiesByStage] = useState<Record<StageKey, KanbanOpportunity[]>>(
|
||||
generateMockOpportunities
|
||||
);
|
||||
|
||||
// Filter opportunities based on search and filters
|
||||
const filteredOpportunities = useMemo(() => {
|
||||
const result: Record<StageKey, KanbanOpportunity[]> = {
|
||||
new: [],
|
||||
qualified: [],
|
||||
proposition: [],
|
||||
won: [],
|
||||
lost: [],
|
||||
};
|
||||
|
||||
(Object.keys(opportunitiesByStage) as StageKey[]).forEach((stage) => {
|
||||
result[stage] = opportunitiesByStage[stage].filter((opp) => {
|
||||
// Search filter
|
||||
if (searchTerm) {
|
||||
const searchLower = searchTerm.toLowerCase();
|
||||
if (
|
||||
!opp.name.toLowerCase().includes(searchLower) &&
|
||||
!opp.partnerName?.toLowerCase().includes(searchLower)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// User filter
|
||||
if (selectedUser && opp.assignedTo?.id !== selectedUser) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Date range filter
|
||||
if (dateRange.start && dateRange.end && opp.expectedCloseDate) {
|
||||
const closeDate = new Date(opp.expectedCloseDate);
|
||||
if (closeDate < dateRange.start || closeDate > dateRange.end) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
});
|
||||
|
||||
return result;
|
||||
}, [opportunitiesByStage, searchTerm, selectedUser, dateRange]);
|
||||
|
||||
// Calculate totals
|
||||
const totals = useMemo(() => {
|
||||
const allOpps = Object.values(filteredOpportunities).flat();
|
||||
const openOpps = [...filteredOpportunities.new, ...filteredOpportunities.qualified, ...filteredOpportunities.proposition];
|
||||
|
||||
return {
|
||||
totalOpportunities: allOpps.length,
|
||||
totalValue: allOpps.reduce((sum, o) => sum + o.expectedRevenue, 0),
|
||||
openValue: openOpps.reduce((sum, o) => sum + o.expectedRevenue, 0),
|
||||
weightedValue: openOpps.reduce((sum, o) => sum + (o.expectedRevenue * o.probability / 100), 0),
|
||||
wonValue: filteredOpportunities.won.reduce((sum, o) => sum + o.expectedRevenue, 0),
|
||||
};
|
||||
}, [filteredOpportunities]);
|
||||
|
||||
// Handle card drop (stage change)
|
||||
const handleDrop = (opportunity: KanbanOpportunity, targetStage: StageKey) => {
|
||||
// Find current stage
|
||||
let currentStage: StageKey | null = null;
|
||||
for (const [stage, items] of Object.entries(opportunitiesByStage)) {
|
||||
if (items.some((item) => item.id === opportunity.id)) {
|
||||
currentStage = stage as StageKey;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!currentStage || currentStage === targetStage) return;
|
||||
|
||||
// Update state
|
||||
setOpportunitiesByStage((prev) => {
|
||||
const newState = { ...prev };
|
||||
|
||||
// Remove from current stage
|
||||
newState[currentStage!] = prev[currentStage!].filter((item) => item.id !== opportunity.id);
|
||||
|
||||
// Update probability based on stage
|
||||
const updatedOpp = { ...opportunity };
|
||||
if (targetStage === 'won') {
|
||||
updatedOpp.probability = 100;
|
||||
} else if (targetStage === 'lost') {
|
||||
updatedOpp.probability = 0;
|
||||
}
|
||||
|
||||
// Add to target stage
|
||||
newState[targetStage] = [...prev[targetStage], updatedOpp];
|
||||
|
||||
return newState;
|
||||
});
|
||||
|
||||
// Show toast notification
|
||||
success(
|
||||
'Etapa actualizada',
|
||||
`"${opportunity.name}" movida a ${stageLabels[targetStage]}`
|
||||
);
|
||||
};
|
||||
|
||||
// Handle card click
|
||||
const handleCardClick = (opportunity: KanbanOpportunity) => {
|
||||
console.log('Card clicked:', opportunity);
|
||||
// Here you could navigate to opportunity detail or open a modal
|
||||
};
|
||||
|
||||
// Refresh data
|
||||
const handleRefresh = () => {
|
||||
setOpportunitiesByStage(generateMockOpportunities());
|
||||
success('Datos actualizados', 'El pipeline ha sido actualizado');
|
||||
};
|
||||
|
||||
// Clear filters
|
||||
const handleClearFilters = () => {
|
||||
setSearchTerm('');
|
||||
setDateRange({ start: null, end: null });
|
||||
setSelectedUser('');
|
||||
};
|
||||
|
||||
const hasActiveFilters = searchTerm || selectedUser || dateRange.start;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
{/* Header */}
|
||||
<div className="flex-shrink-0 space-y-4 p-6 pb-0">
|
||||
<Breadcrumbs
|
||||
items={[
|
||||
{ label: 'CRM', href: '/crm' },
|
||||
{ label: 'Pipeline' },
|
||||
]}
|
||||
/>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">Pipeline de Ventas</h1>
|
||||
<p className="text-sm text-gray-500">
|
||||
Gestiona oportunidades arrastrando tarjetas entre etapas
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" onClick={handleRefresh}>
|
||||
<RefreshCw className="mr-2 h-4 w-4" />
|
||||
Actualizar
|
||||
</Button>
|
||||
<Link to="/crm/opportunities">
|
||||
<Button variant="outline">
|
||||
<List className="mr-2 h-4 w-4" />
|
||||
Vista Lista
|
||||
</Button>
|
||||
</Link>
|
||||
<Button>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Nueva oportunidad
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Summary Stats */}
|
||||
<div className="grid grid-cols-2 gap-4 sm:grid-cols-5">
|
||||
<div className="rounded-lg border bg-white p-3">
|
||||
<div className="text-sm text-gray-500">Oportunidades</div>
|
||||
<div className="text-xl font-bold text-gray-900">{totals.totalOpportunities}</div>
|
||||
</div>
|
||||
<div className="rounded-lg border bg-white p-3">
|
||||
<div className="text-sm text-gray-500">Pipeline Total</div>
|
||||
<div className="text-xl font-bold text-blue-600">${formatCurrency(totals.openValue)}</div>
|
||||
</div>
|
||||
<div className="rounded-lg border bg-white p-3">
|
||||
<div className="text-sm text-gray-500">Ponderado</div>
|
||||
<div className="text-xl font-bold text-amber-600">${formatCurrency(totals.weightedValue)}</div>
|
||||
</div>
|
||||
<div className="rounded-lg border bg-white p-3">
|
||||
<div className="text-sm text-gray-500">Ganado</div>
|
||||
<div className="text-xl font-bold text-green-600">${formatCurrency(totals.wonValue)}</div>
|
||||
</div>
|
||||
<div className="rounded-lg border bg-white p-3">
|
||||
<div className="text-sm text-gray-500">Valor Total</div>
|
||||
<div className="text-xl font-bold text-gray-900">${formatCurrency(totals.totalValue)}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="rounded-lg border bg-white p-4">
|
||||
<div className="flex flex-wrap items-center gap-4">
|
||||
{/* Search */}
|
||||
<div className="relative flex-1 min-w-[200px] max-w-md">
|
||||
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-gray-400" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Buscar oportunidades..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="w-full rounded-md border border-gray-300 py-2 pl-10 pr-4 text-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Toggle Filters */}
|
||||
<Button
|
||||
variant={showFilters ? 'secondary' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => setShowFilters(!showFilters)}
|
||||
>
|
||||
<Filter className="mr-2 h-4 w-4" />
|
||||
Filtros
|
||||
{hasActiveFilters && (
|
||||
<span className="ml-2 rounded-full bg-primary-600 px-1.5 py-0.5 text-xs text-white">
|
||||
{[searchTerm, selectedUser, dateRange.start].filter(Boolean).length}
|
||||
</span>
|
||||
)}
|
||||
</Button>
|
||||
|
||||
{hasActiveFilters && (
|
||||
<Button variant="ghost" size="sm" onClick={handleClearFilters}>
|
||||
Limpiar filtros
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Expanded Filters */}
|
||||
{showFilters && (
|
||||
<div className="mt-4 flex flex-wrap items-end gap-4 border-t pt-4">
|
||||
<div className="min-w-[200px]">
|
||||
<label className="mb-1.5 block text-sm font-medium text-gray-700">
|
||||
Responsable
|
||||
</label>
|
||||
<Select
|
||||
options={mockUsers}
|
||||
value={selectedUser}
|
||||
onChange={(v) => setSelectedUser(v as string)}
|
||||
placeholder="Todos los usuarios"
|
||||
clearable
|
||||
/>
|
||||
</div>
|
||||
<div className="min-w-[280px]">
|
||||
<label className="mb-1.5 block text-sm font-medium text-gray-700">
|
||||
Fecha de cierre esperada
|
||||
</label>
|
||||
<DateRangePicker
|
||||
value={dateRange}
|
||||
onChange={setDateRange}
|
||||
placeholder="Seleccionar rango"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Kanban Board */}
|
||||
<div className="flex-1 overflow-x-auto p-6">
|
||||
<div className="flex gap-4 min-w-max">
|
||||
{(Object.keys(stageLabels) as StageKey[]).map((stage) => (
|
||||
<KanbanColumn
|
||||
key={stage}
|
||||
title={stageLabels[stage]}
|
||||
stage={stage}
|
||||
items={filteredOpportunities[stage]}
|
||||
onDrop={handleDrop}
|
||||
onCardClick={handleCardClick}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default PipelineKanbanPage;
|
||||
@ -1,4 +1,5 @@
|
||||
import { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
BookOpen,
|
||||
Plus,
|
||||
@ -45,6 +46,7 @@ const formatCurrency = (value: number): string => {
|
||||
};
|
||||
|
||||
export function AccountsPage() {
|
||||
const navigate = useNavigate();
|
||||
const [selectedType, setSelectedType] = useState<AccountTypeEnum | ''>('');
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [showDeprecated, setShowDeprecated] = useState(false);
|
||||
@ -82,19 +84,19 @@ export function AccountsPage() {
|
||||
key: 'view',
|
||||
label: 'Ver detalle',
|
||||
icon: <Eye className="h-4 w-4" />,
|
||||
onClick: () => console.log('View', account.id),
|
||||
onClick: () => navigate(`/financial/accounts/${account.id}`),
|
||||
},
|
||||
{
|
||||
key: 'edit',
|
||||
label: 'Editar',
|
||||
icon: <Edit className="h-4 w-4" />,
|
||||
onClick: () => console.log('Edit', account.id),
|
||||
onClick: () => navigate(`/financial/accounts/${account.id}/edit`),
|
||||
},
|
||||
{
|
||||
key: 'children',
|
||||
label: 'Ver subcuentas',
|
||||
icon: <FolderTree className="h-4 w-4" />,
|
||||
onClick: () => console.log('Children', account.id),
|
||||
onClick: () => navigate(`/financial/accounts?parentId=${account.id}`),
|
||||
},
|
||||
{
|
||||
key: 'delete',
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
Receipt,
|
||||
Plus,
|
||||
@ -49,6 +50,7 @@ const formatCurrency = (value: number): string => {
|
||||
};
|
||||
|
||||
export function InvoicesPage() {
|
||||
const navigate = useNavigate();
|
||||
const [selectedStatus, setSelectedStatus] = useState<FinancialInvoiceStatus | ''>('');
|
||||
const [selectedType, setSelectedType] = useState<InvoiceType | ''>('');
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
@ -83,7 +85,7 @@ export function InvoicesPage() {
|
||||
key: 'view',
|
||||
label: 'Ver detalle',
|
||||
icon: <Eye className="h-4 w-4" />,
|
||||
onClick: () => console.log('View', invoice.id),
|
||||
onClick: () => navigate(`/financial/invoices/${invoice.id}`),
|
||||
},
|
||||
];
|
||||
|
||||
@ -108,7 +110,8 @@ export function InvoicesPage() {
|
||||
key: 'payment',
|
||||
label: 'Registrar pago',
|
||||
icon: <CreditCard className="h-4 w-4" />,
|
||||
onClick: () => console.log('Register payment', invoice.id),
|
||||
// TODO: Implement payment modal - setShowPaymentModal(true)
|
||||
onClick: () => navigate(`/financial/invoices/${invoice.id}/payment`),
|
||||
});
|
||||
items.push({
|
||||
key: 'cancel',
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
FileText,
|
||||
Plus,
|
||||
@ -46,6 +47,7 @@ const getToday = (): string => {
|
||||
};
|
||||
|
||||
export function JournalEntriesPage() {
|
||||
const navigate = useNavigate();
|
||||
const [selectedStatus, setSelectedStatus] = useState<EntryStatus | ''>('');
|
||||
const [selectedJournal, setSelectedJournal] = useState('');
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
@ -82,7 +84,7 @@ export function JournalEntriesPage() {
|
||||
key: 'view',
|
||||
label: 'Ver detalle',
|
||||
icon: <Eye className="h-4 w-4" />,
|
||||
onClick: () => console.log('View', entry.id),
|
||||
onClick: () => navigate(`/financial/entries/${entry.id}`),
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
ClipboardList,
|
||||
Plus,
|
||||
@ -43,6 +44,7 @@ const statusColors: Record<CountStatus, string> = {
|
||||
};
|
||||
|
||||
export function InventoryCountsPage() {
|
||||
const navigate = useNavigate();
|
||||
const [selectedWarehouse, setSelectedWarehouse] = useState<string>('');
|
||||
const [selectedStatus, setSelectedStatus] = useState<CountStatus | ''>('');
|
||||
const [selectedType, setSelectedType] = useState<CountType | ''>('');
|
||||
@ -76,7 +78,7 @@ export function InventoryCountsPage() {
|
||||
key: 'view',
|
||||
label: 'Ver detalle',
|
||||
icon: <Eye className="h-4 w-4" />,
|
||||
onClick: () => console.log('View', count.id),
|
||||
onClick: () => navigate(`/inventory/counts/${count.id}`),
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
ArrowDownToLine,
|
||||
ArrowUpFromLine,
|
||||
@ -55,6 +56,7 @@ const statusColors: Record<MovementStatus, string> = {
|
||||
};
|
||||
|
||||
export function MovementsPage() {
|
||||
const navigate = useNavigate();
|
||||
const [selectedType, setSelectedType] = useState<MovementType | ''>('');
|
||||
const [selectedStatus, setSelectedStatus] = useState<MovementStatus | ''>('');
|
||||
const [selectedWarehouse, setSelectedWarehouse] = useState<string>('');
|
||||
@ -89,7 +91,7 @@ export function MovementsPage() {
|
||||
key: 'view',
|
||||
label: 'Ver detalle',
|
||||
icon: <Eye className="h-4 w-4" />,
|
||||
onClick: () => console.log('View', movement.id),
|
||||
onClick: () => navigate(`/inventory/movements/${movement.id}`),
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
AlertTriangle,
|
||||
Package,
|
||||
@ -40,6 +41,7 @@ const alertLevelLabels: Record<AlertLevel, string> = {
|
||||
};
|
||||
|
||||
export function ReorderAlertsPage() {
|
||||
const navigate = useNavigate();
|
||||
const [selectedWarehouse, setSelectedWarehouse] = useState<string>('');
|
||||
const [selectedAlertLevel, setSelectedAlertLevel] = useState<AlertLevel | ''>('');
|
||||
|
||||
@ -82,19 +84,19 @@ export function ReorderAlertsPage() {
|
||||
key: 'view',
|
||||
label: 'Ver producto',
|
||||
icon: <Eye className="h-4 w-4" />,
|
||||
onClick: () => console.log('View product', alert.productId),
|
||||
onClick: () => navigate(`/inventory/products/${alert.productId}`),
|
||||
},
|
||||
{
|
||||
key: 'history',
|
||||
label: 'Ver historial',
|
||||
icon: <TrendingDown className="h-4 w-4" />,
|
||||
onClick: () => console.log('View history', alert.productId),
|
||||
onClick: () => navigate(`/inventory/products/${alert.productId}/history`),
|
||||
},
|
||||
{
|
||||
key: 'order',
|
||||
label: 'Crear orden de compra',
|
||||
icon: <ShoppingCart className="h-4 w-4" />,
|
||||
onClick: () => console.log('Create PO', alert.productId),
|
||||
onClick: () => navigate(`/purchases/orders/new?productId=${alert.productId}`),
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@ -24,7 +24,10 @@ const formatCurrency = (value: number): string => {
|
||||
return formatNumber(value, 'es-MX', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
||||
};
|
||||
|
||||
const getToday = (): string => getToday() || '';
|
||||
const getToday = (): string => {
|
||||
const isoDate = new Date().toISOString();
|
||||
return isoDate.substring(0, 10);
|
||||
};
|
||||
|
||||
export function ValuationReportsPage() {
|
||||
const [selectedWarehouse, setSelectedWarehouse] = useState<string>('');
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
FolderKanban,
|
||||
Plus,
|
||||
@ -42,6 +43,7 @@ const statusColors: Record<ProjectStatus, string> = {
|
||||
};
|
||||
|
||||
export function ProjectsPage() {
|
||||
const navigate = useNavigate();
|
||||
const [selectedStatus, setSelectedStatus] = useState<ProjectStatus | ''>('');
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [projectToActivate, setProjectToActivate] = useState<Project | null>(null);
|
||||
@ -72,7 +74,7 @@ export function ProjectsPage() {
|
||||
key: 'view',
|
||||
label: 'Ver detalle',
|
||||
icon: <Eye className="h-4 w-4" />,
|
||||
onClick: () => console.log('View', project.id),
|
||||
onClick: () => navigate(`/projects/${project.id}`),
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
ListTodo,
|
||||
Plus,
|
||||
@ -57,6 +58,7 @@ const priorityColors: Record<TaskPriority, string> = {
|
||||
};
|
||||
|
||||
export function TasksPage() {
|
||||
const navigate = useNavigate();
|
||||
const [selectedStatus, setSelectedStatus] = useState<TaskStatus | ''>('');
|
||||
const [selectedPriority, setSelectedPriority] = useState<TaskPriority | ''>('');
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
@ -89,7 +91,7 @@ export function TasksPage() {
|
||||
key: 'view',
|
||||
label: 'Ver detalle',
|
||||
icon: <Eye className="h-4 w-4" />,
|
||||
onClick: () => console.log('View', task.id),
|
||||
onClick: () => navigate(`/projects/tasks/${task.id}`),
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
ShoppingBag,
|
||||
Plus,
|
||||
@ -47,6 +48,7 @@ const formatCurrency = (value: number): string => {
|
||||
};
|
||||
|
||||
export function PurchaseOrdersPage() {
|
||||
const navigate = useNavigate();
|
||||
const [selectedStatus, setSelectedStatus] = useState<PurchaseOrderStatus | ''>('');
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [dateFrom, setDateFrom] = useState('');
|
||||
@ -80,7 +82,7 @@ export function PurchaseOrdersPage() {
|
||||
key: 'view',
|
||||
label: 'Ver detalle',
|
||||
icon: <Eye className="h-4 w-4" />,
|
||||
onClick: () => console.log('View', order.id),
|
||||
onClick: () => navigate(`/purchases/orders/${order.id}`),
|
||||
},
|
||||
{
|
||||
key: 'pdf',
|
||||
@ -111,13 +113,15 @@ export function PurchaseOrdersPage() {
|
||||
key: 'receive',
|
||||
label: 'Recibir productos',
|
||||
icon: <Package className="h-4 w-4" />,
|
||||
onClick: () => console.log('Receive', order.id),
|
||||
// TODO: Implement receive modal - setShowReceiveModal(true)
|
||||
onClick: () => navigate(`/purchases/orders/${order.id}/receive`),
|
||||
});
|
||||
items.push({
|
||||
key: 'invoice',
|
||||
label: 'Crear factura',
|
||||
icon: <FileText className="h-4 w-4" />,
|
||||
onClick: () => console.log('Invoice', order.id),
|
||||
// TODO: Implement invoice modal - setShowInvoiceModal(true)
|
||||
onClick: () => navigate(`/purchases/orders/${order.id}/invoice`),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
Package,
|
||||
Plus,
|
||||
@ -44,6 +45,7 @@ const getToday = (): string => {
|
||||
};
|
||||
|
||||
export function PurchaseReceiptsPage() {
|
||||
const navigate = useNavigate();
|
||||
const [selectedStatus, setSelectedStatus] = useState<ReceiptStatus | ''>('');
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [dateFrom, setDateFrom] = useState('');
|
||||
@ -76,7 +78,7 @@ export function PurchaseReceiptsPage() {
|
||||
key: 'view',
|
||||
label: 'Ver detalle',
|
||||
icon: <Eye className="h-4 w-4" />,
|
||||
onClick: () => console.log('View', receipt.id),
|
||||
onClick: () => navigate(`/purchases/receipts/${receipt.id}`),
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
FileText,
|
||||
Plus,
|
||||
@ -62,6 +63,7 @@ const typeColors: Record<ReportType, string> = {
|
||||
};
|
||||
|
||||
export function ReportsPage() {
|
||||
const navigate = useNavigate();
|
||||
const [activeTab, setActiveTab] = useState<TabType>('definitions');
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [selectedType, setSelectedType] = useState<ReportType | ''>('');
|
||||
@ -189,13 +191,14 @@ export function ReportsPage() {
|
||||
key: 'view',
|
||||
label: 'Ver detalle',
|
||||
icon: <Eye className="h-4 w-4" />,
|
||||
onClick: () => console.log('View', def.id),
|
||||
onClick: () => navigate(`/reports/definitions/${def.id}`),
|
||||
},
|
||||
{
|
||||
key: 'execute',
|
||||
label: 'Ejecutar',
|
||||
icon: <Play className="h-4 w-4" />,
|
||||
onClick: () => console.log('Execute', def.id),
|
||||
// TODO: Keep for now - opens report execution modal/flow
|
||||
onClick: () => navigate(`/reports/definitions/${def.id}/execute`),
|
||||
},
|
||||
];
|
||||
|
||||
@ -205,7 +208,7 @@ export function ReportsPage() {
|
||||
key: 'edit',
|
||||
label: 'Editar',
|
||||
icon: <Edit2 className="h-4 w-4" />,
|
||||
onClick: () => console.log('Edit', def.id),
|
||||
onClick: () => navigate(`/reports/definitions/${def.id}/edit`),
|
||||
},
|
||||
{
|
||||
key: 'toggle',
|
||||
@ -321,7 +324,7 @@ export function ReportsPage() {
|
||||
key: 'view',
|
||||
label: 'Ver detalle',
|
||||
icon: <Eye className="h-4 w-4" />,
|
||||
onClick: () => console.log('View', exec.id),
|
||||
onClick: () => navigate(`/reports/executions/${exec.id}`),
|
||||
},
|
||||
];
|
||||
|
||||
@ -425,13 +428,13 @@ export function ReportsPage() {
|
||||
key: 'view',
|
||||
label: 'Ver detalle',
|
||||
icon: <Eye className="h-4 w-4" />,
|
||||
onClick: () => console.log('View', sched.id),
|
||||
onClick: () => navigate(`/reports/schedules/${sched.id}`),
|
||||
},
|
||||
{
|
||||
key: 'edit',
|
||||
label: 'Editar',
|
||||
icon: <Edit2 className="h-4 w-4" />,
|
||||
onClick: () => console.log('Edit', sched.id),
|
||||
onClick: () => navigate(`/reports/schedules/${sched.id}/edit`),
|
||||
},
|
||||
{
|
||||
key: 'runNow',
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
FileText,
|
||||
Plus,
|
||||
@ -52,6 +53,7 @@ const formatCurrency = (value: number): string => {
|
||||
};
|
||||
|
||||
export function QuotationsPage() {
|
||||
const navigate = useNavigate();
|
||||
const [selectedStatus, setSelectedStatus] = useState<QuotationStatus | ''>('');
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [dateFrom, setDateFrom] = useState('');
|
||||
@ -89,7 +91,7 @@ export function QuotationsPage() {
|
||||
key: 'view',
|
||||
label: 'Ver detalle',
|
||||
icon: <Eye className="h-4 w-4" />,
|
||||
onClick: () => console.log('View', quotation.id),
|
||||
onClick: () => navigate(`/sales/quotations/${quotation.id}`),
|
||||
},
|
||||
{
|
||||
key: 'download',
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
ShoppingCart,
|
||||
Plus,
|
||||
@ -70,6 +71,7 @@ const formatCurrency = (value: number): string => {
|
||||
};
|
||||
|
||||
export function SalesOrdersPage() {
|
||||
const navigate = useNavigate();
|
||||
const [selectedStatus, setSelectedStatus] = useState<SalesOrderStatus | ''>('');
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [dateFrom, setDateFrom] = useState('');
|
||||
@ -104,7 +106,7 @@ export function SalesOrdersPage() {
|
||||
key: 'view',
|
||||
label: 'Ver detalle',
|
||||
icon: <Eye className="h-4 w-4" />,
|
||||
onClick: () => console.log('View', order.id),
|
||||
onClick: () => navigate(`/sales/orders/${order.id}`),
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
472
src/pages/settings/CompanySettingsPage.tsx
Normal file
472
src/pages/settings/CompanySettingsPage.tsx
Normal file
@ -0,0 +1,472 @@
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
Building2,
|
||||
Upload,
|
||||
Save,
|
||||
Phone,
|
||||
Mail,
|
||||
Globe,
|
||||
MapPin,
|
||||
Hash,
|
||||
DollarSign,
|
||||
FileText,
|
||||
} from 'lucide-react';
|
||||
import { Button } from '@components/atoms/Button';
|
||||
import { Input } from '@components/atoms/Input';
|
||||
import { Card, CardHeader, CardTitle, CardContent, CardFooter } from '@components/molecules/Card';
|
||||
import { Breadcrumbs } from '@components/organisms/Breadcrumbs';
|
||||
|
||||
interface CompanyFormData {
|
||||
// General info
|
||||
name: string;
|
||||
legalName: string;
|
||||
logo: string | null;
|
||||
// Contact info
|
||||
phone: string;
|
||||
email: string;
|
||||
website: string;
|
||||
// Address
|
||||
street: string;
|
||||
city: string;
|
||||
state: string;
|
||||
postalCode: string;
|
||||
country: string;
|
||||
// Fiscal info
|
||||
taxId: string;
|
||||
fiscalRegime: string;
|
||||
fiscalAddress: string;
|
||||
// Settings
|
||||
currency: string;
|
||||
numberFormat: string;
|
||||
dateFormat: string;
|
||||
}
|
||||
|
||||
const initialFormData: CompanyFormData = {
|
||||
name: 'Mi Empresa S.A. de C.V.',
|
||||
legalName: 'Mi Empresa Sociedad Anonima de Capital Variable',
|
||||
logo: null,
|
||||
phone: '+52 55 1234 5678',
|
||||
email: 'contacto@miempresa.com',
|
||||
website: 'https://miempresa.com',
|
||||
street: 'Av. Reforma 123, Col. Centro',
|
||||
city: 'Ciudad de Mexico',
|
||||
state: 'CDMX',
|
||||
postalCode: '06000',
|
||||
country: 'Mexico',
|
||||
taxId: 'XAXX010101000',
|
||||
fiscalRegime: '601',
|
||||
fiscalAddress: 'Av. Reforma 123, Col. Centro, CDMX, 06000',
|
||||
currency: 'MXN',
|
||||
numberFormat: '1,234.56',
|
||||
dateFormat: 'DD/MM/YYYY',
|
||||
};
|
||||
|
||||
const fiscalRegimes = [
|
||||
{ value: '601', label: '601 - General de Ley Personas Morales' },
|
||||
{ value: '603', label: '603 - Personas Morales con Fines no Lucrativos' },
|
||||
{ value: '605', label: '605 - Sueldos y Salarios e Ingresos Asimilados a Salarios' },
|
||||
{ value: '606', label: '606 - Arrendamiento' },
|
||||
{ value: '607', label: '607 - Regimen de Enajenacion o Adquisicion de Bienes' },
|
||||
{ value: '608', label: '608 - Demas ingresos' },
|
||||
{ value: '610', label: '610 - Residentes en el Extranjero sin Establecimiento Permanente en Mexico' },
|
||||
{ value: '611', label: '611 - Ingresos por Dividendos (socios y accionistas)' },
|
||||
{ value: '612', label: '612 - Personas Fisicas con Actividades Empresariales y Profesionales' },
|
||||
{ value: '614', label: '614 - Ingresos por intereses' },
|
||||
{ value: '615', label: '615 - Regimen de los ingresos por obtencion de premios' },
|
||||
{ value: '616', label: '616 - Sin obligaciones fiscales' },
|
||||
{ value: '620', label: '620 - Sociedades Cooperativas de Produccion que optan por diferir sus ingresos' },
|
||||
{ value: '621', label: '621 - Incorporacion Fiscal' },
|
||||
{ value: '622', label: '622 - Actividades Agricolas, Ganaderas, Silvicolas y Pesqueras' },
|
||||
{ value: '623', label: '623 - Opcional para Grupos de Sociedades' },
|
||||
{ value: '624', label: '624 - Coordinados' },
|
||||
{ value: '625', label: '625 - Regimen de las Actividades Empresariales con ingresos a traves de Plataformas Tecnologicas' },
|
||||
{ value: '626', label: '626 - Regimen Simplificado de Confianza' },
|
||||
];
|
||||
|
||||
const currencies = [
|
||||
{ value: 'MXN', label: 'MXN - Peso Mexicano' },
|
||||
{ value: 'USD', label: 'USD - Dolar Estadounidense' },
|
||||
{ value: 'EUR', label: 'EUR - Euro' },
|
||||
{ value: 'CAD', label: 'CAD - Dolar Canadiense' },
|
||||
];
|
||||
|
||||
const numberFormats = [
|
||||
{ value: '1,234.56', label: '1,234.56 (Coma para miles, punto decimal)' },
|
||||
{ value: '1.234,56', label: '1.234,56 (Punto para miles, coma decimal)' },
|
||||
{ value: '1 234.56', label: '1 234.56 (Espacio para miles, punto decimal)' },
|
||||
];
|
||||
|
||||
const dateFormats = [
|
||||
{ value: 'DD/MM/YYYY', label: 'DD/MM/YYYY (31/12/2026)' },
|
||||
{ value: 'MM/DD/YYYY', label: 'MM/DD/YYYY (12/31/2026)' },
|
||||
{ value: 'YYYY-MM-DD', label: 'YYYY-MM-DD (2026-12-31)' },
|
||||
];
|
||||
|
||||
export function CompanySettingsPage() {
|
||||
const [formData, setFormData] = useState<CompanyFormData>(initialFormData);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [logoPreview, setLogoPreview] = useState<string | null>(null);
|
||||
|
||||
const handleInputChange = (field: keyof CompanyFormData, value: string) => {
|
||||
setFormData((prev) => ({ ...prev, [field]: value }));
|
||||
};
|
||||
|
||||
const handleLogoChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = event.target.files?.[0];
|
||||
if (file) {
|
||||
const reader = new FileReader();
|
||||
reader.onloadend = () => {
|
||||
setLogoPreview(reader.result as string);
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
setIsSaving(true);
|
||||
// Simulate API call
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
setIsSaving(false);
|
||||
alert('Configuracion guardada correctamente');
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6 p-6">
|
||||
<Breadcrumbs
|
||||
items={[
|
||||
{ label: 'Configuracion', href: '/settings' },
|
||||
{ label: 'Empresa' },
|
||||
]}
|
||||
/>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">Configuracion de Empresa</h1>
|
||||
<p className="text-sm text-gray-500">
|
||||
Administra la informacion general de tu empresa
|
||||
</p>
|
||||
</div>
|
||||
<Button onClick={handleSave} isLoading={isSaving}>
|
||||
<Save className="mr-2 h-4 w-4" />
|
||||
Guardar cambios
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 lg:grid-cols-2">
|
||||
{/* General Information */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Building2 className="h-5 w-5 text-blue-600" />
|
||||
Informacion General
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{/* Logo */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Logo de la empresa
|
||||
</label>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex h-20 w-20 items-center justify-center rounded-lg border-2 border-dashed border-gray-300 bg-gray-50">
|
||||
{logoPreview ? (
|
||||
<img
|
||||
src={logoPreview}
|
||||
alt="Logo preview"
|
||||
className="h-full w-full object-contain rounded-lg"
|
||||
/>
|
||||
) : (
|
||||
<Building2 className="h-8 w-8 text-gray-400" />
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<label className="cursor-pointer">
|
||||
<span className="inline-flex items-center gap-2 rounded-md border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50">
|
||||
<Upload className="h-4 w-4" />
|
||||
Subir logo
|
||||
</span>
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
onChange={handleLogoChange}
|
||||
className="hidden"
|
||||
/>
|
||||
</label>
|
||||
<p className="mt-1 text-xs text-gray-500">
|
||||
PNG, JPG o SVG. Maximo 2MB.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Company Name */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Nombre comercial
|
||||
</label>
|
||||
<Input
|
||||
value={formData.name}
|
||||
onChange={(e) => handleInputChange('name', e.target.value)}
|
||||
placeholder="Nombre de la empresa"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Legal Name */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Razon social
|
||||
</label>
|
||||
<Input
|
||||
value={formData.legalName}
|
||||
onChange={(e) => handleInputChange('legalName', e.target.value)}
|
||||
placeholder="Razon social completa"
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Contact Information */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Phone className="h-5 w-5 text-green-600" />
|
||||
Informacion de Contacto
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
<Phone className="inline-block h-4 w-4 mr-1" />
|
||||
Telefono
|
||||
</label>
|
||||
<Input
|
||||
type="tel"
|
||||
value={formData.phone}
|
||||
onChange={(e) => handleInputChange('phone', e.target.value)}
|
||||
placeholder="+52 55 1234 5678"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
<Mail className="inline-block h-4 w-4 mr-1" />
|
||||
Correo electronico
|
||||
</label>
|
||||
<Input
|
||||
type="email"
|
||||
value={formData.email}
|
||||
onChange={(e) => handleInputChange('email', e.target.value)}
|
||||
placeholder="contacto@empresa.com"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
<Globe className="inline-block h-4 w-4 mr-1" />
|
||||
Sitio web
|
||||
</label>
|
||||
<Input
|
||||
type="url"
|
||||
value={formData.website}
|
||||
onChange={(e) => handleInputChange('website', e.target.value)}
|
||||
placeholder="https://www.empresa.com"
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Address */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<MapPin className="h-5 w-5 text-red-600" />
|
||||
Direccion
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Calle y numero
|
||||
</label>
|
||||
<Input
|
||||
value={formData.street}
|
||||
onChange={(e) => handleInputChange('street', e.target.value)}
|
||||
placeholder="Av. Reforma 123, Col. Centro"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Ciudad
|
||||
</label>
|
||||
<Input
|
||||
value={formData.city}
|
||||
onChange={(e) => handleInputChange('city', e.target.value)}
|
||||
placeholder="Ciudad de Mexico"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Estado
|
||||
</label>
|
||||
<Input
|
||||
value={formData.state}
|
||||
onChange={(e) => handleInputChange('state', e.target.value)}
|
||||
placeholder="CDMX"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Codigo postal
|
||||
</label>
|
||||
<Input
|
||||
value={formData.postalCode}
|
||||
onChange={(e) => handleInputChange('postalCode', e.target.value)}
|
||||
placeholder="06000"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Pais
|
||||
</label>
|
||||
<Input
|
||||
value={formData.country}
|
||||
onChange={(e) => handleInputChange('country', e.target.value)}
|
||||
placeholder="Mexico"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Fiscal Information */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<FileText className="h-5 w-5 text-purple-600" />
|
||||
Informacion Fiscal
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
<Hash className="inline-block h-4 w-4 mr-1" />
|
||||
RFC / Tax ID
|
||||
</label>
|
||||
<Input
|
||||
value={formData.taxId}
|
||||
onChange={(e) => handleInputChange('taxId', e.target.value.toUpperCase())}
|
||||
placeholder="XAXX010101000"
|
||||
className="uppercase"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Regimen fiscal
|
||||
</label>
|
||||
<select
|
||||
value={formData.fiscalRegime}
|
||||
onChange={(e) => handleInputChange('fiscalRegime', e.target.value)}
|
||||
className="block w-full rounded-md border border-gray-300 px-3 py-2 text-sm shadow-sm focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500"
|
||||
>
|
||||
{fiscalRegimes.map((regime) => (
|
||||
<option key={regime.value} value={regime.value}>
|
||||
{regime.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Domicilio fiscal
|
||||
</label>
|
||||
<Input
|
||||
value={formData.fiscalAddress}
|
||||
onChange={(e) => handleInputChange('fiscalAddress', e.target.value)}
|
||||
placeholder="Domicilio fiscal completo"
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Regional Settings */}
|
||||
<Card className="lg:col-span-2">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<DollarSign className="h-5 w-5 text-yellow-600" />
|
||||
Configuracion Regional
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid gap-4 sm:grid-cols-3">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Moneda predeterminada
|
||||
</label>
|
||||
<select
|
||||
value={formData.currency}
|
||||
onChange={(e) => handleInputChange('currency', e.target.value)}
|
||||
className="block w-full rounded-md border border-gray-300 px-3 py-2 text-sm shadow-sm focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500"
|
||||
>
|
||||
{currencies.map((currency) => (
|
||||
<option key={currency.value} value={currency.value}>
|
||||
{currency.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Formato de numeros
|
||||
</label>
|
||||
<select
|
||||
value={formData.numberFormat}
|
||||
onChange={(e) => handleInputChange('numberFormat', e.target.value)}
|
||||
className="block w-full rounded-md border border-gray-300 px-3 py-2 text-sm shadow-sm focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500"
|
||||
>
|
||||
{numberFormats.map((format) => (
|
||||
<option key={format.value} value={format.value}>
|
||||
{format.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Formato de fecha
|
||||
</label>
|
||||
<select
|
||||
value={formData.dateFormat}
|
||||
onChange={(e) => handleInputChange('dateFormat', e.target.value)}
|
||||
className="block w-full rounded-md border border-gray-300 px-3 py-2 text-sm shadow-sm focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500"
|
||||
>
|
||||
{dateFormats.map((format) => (
|
||||
<option key={format.value} value={format.value}>
|
||||
{format.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
<CardFooter className="flex justify-end">
|
||||
<Button onClick={handleSave} isLoading={isSaving}>
|
||||
<Save className="mr-2 h-4 w-4" />
|
||||
Guardar cambios
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default CompanySettingsPage;
|
||||
549
src/pages/settings/ProfileSettingsPage.tsx
Normal file
549
src/pages/settings/ProfileSettingsPage.tsx
Normal file
@ -0,0 +1,549 @@
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
User,
|
||||
Camera,
|
||||
Save,
|
||||
Mail,
|
||||
Phone,
|
||||
Lock,
|
||||
Eye,
|
||||
EyeOff,
|
||||
Bell,
|
||||
Globe,
|
||||
Check,
|
||||
} from 'lucide-react';
|
||||
import { Button } from '@components/atoms/Button';
|
||||
import { Input } from '@components/atoms/Input';
|
||||
import { Card, CardHeader, CardTitle, CardContent, CardFooter } from '@components/molecules/Card';
|
||||
import { Breadcrumbs } from '@components/organisms/Breadcrumbs';
|
||||
|
||||
interface ProfileFormData {
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
email: string;
|
||||
phone: string;
|
||||
avatar: string | null;
|
||||
language: string;
|
||||
timezone: string;
|
||||
}
|
||||
|
||||
interface PasswordFormData {
|
||||
currentPassword: string;
|
||||
newPassword: string;
|
||||
confirmPassword: string;
|
||||
}
|
||||
|
||||
interface NotificationSettings {
|
||||
emailNotifications: boolean;
|
||||
pushNotifications: boolean;
|
||||
smsNotifications: boolean;
|
||||
marketingEmails: boolean;
|
||||
securityAlerts: boolean;
|
||||
weeklyReport: boolean;
|
||||
monthlyReport: boolean;
|
||||
}
|
||||
|
||||
const initialProfileData: ProfileFormData = {
|
||||
firstName: 'Juan',
|
||||
lastName: 'Perez',
|
||||
email: 'juan.perez@miempresa.com',
|
||||
phone: '+52 55 1234 5678',
|
||||
avatar: null,
|
||||
language: 'es-MX',
|
||||
timezone: 'America/Mexico_City',
|
||||
};
|
||||
|
||||
const initialPasswordData: PasswordFormData = {
|
||||
currentPassword: '',
|
||||
newPassword: '',
|
||||
confirmPassword: '',
|
||||
};
|
||||
|
||||
const initialNotifications: NotificationSettings = {
|
||||
emailNotifications: true,
|
||||
pushNotifications: true,
|
||||
smsNotifications: false,
|
||||
marketingEmails: false,
|
||||
securityAlerts: true,
|
||||
weeklyReport: true,
|
||||
monthlyReport: false,
|
||||
};
|
||||
|
||||
const languages = [
|
||||
{ value: 'es-MX', label: 'Espanol (Mexico)' },
|
||||
{ value: 'es-ES', label: 'Espanol (Espana)' },
|
||||
{ value: 'en-US', label: 'English (US)' },
|
||||
{ value: 'en-GB', label: 'English (UK)' },
|
||||
{ value: 'pt-BR', label: 'Portugues (Brasil)' },
|
||||
];
|
||||
|
||||
const timezones = [
|
||||
{ value: 'America/Mexico_City', label: '(UTC-06:00) Ciudad de Mexico' },
|
||||
{ value: 'America/Tijuana', label: '(UTC-08:00) Tijuana' },
|
||||
{ value: 'America/Cancun', label: '(UTC-05:00) Cancun' },
|
||||
{ value: 'America/New_York', label: '(UTC-05:00) Nueva York' },
|
||||
{ value: 'America/Los_Angeles', label: '(UTC-08:00) Los Angeles' },
|
||||
{ value: 'Europe/Madrid', label: '(UTC+01:00) Madrid' },
|
||||
{ value: 'Europe/London', label: '(UTC+00:00) Londres' },
|
||||
];
|
||||
|
||||
function ToggleSwitch({
|
||||
checked,
|
||||
onChange,
|
||||
label,
|
||||
description,
|
||||
}: {
|
||||
checked: boolean;
|
||||
onChange: (checked: boolean) => void;
|
||||
label: string;
|
||||
description?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-center justify-between py-3">
|
||||
<div>
|
||||
<div className="text-sm font-medium text-gray-900">{label}</div>
|
||||
{description && <div className="text-sm text-gray-500">{description}</div>}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={checked}
|
||||
onClick={() => onChange(!checked)}
|
||||
className={`relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2 ${
|
||||
checked ? 'bg-primary-600' : 'bg-gray-200'
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out ${
|
||||
checked ? 'translate-x-5' : 'translate-x-0'
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ProfileSettingsPage() {
|
||||
const [profileData, setProfileData] = useState<ProfileFormData>(initialProfileData);
|
||||
const [passwordData, setPasswordData] = useState<PasswordFormData>(initialPasswordData);
|
||||
const [notifications, setNotifications] = useState<NotificationSettings>(initialNotifications);
|
||||
const [isSavingProfile, setIsSavingProfile] = useState(false);
|
||||
const [isSavingPassword, setIsSavingPassword] = useState(false);
|
||||
const [isSavingNotifications, setIsSavingNotifications] = useState(false);
|
||||
const [showCurrentPassword, setShowCurrentPassword] = useState(false);
|
||||
const [showNewPassword, setShowNewPassword] = useState(false);
|
||||
const [showConfirmPassword, setShowConfirmPassword] = useState(false);
|
||||
const [avatarPreview, setAvatarPreview] = useState<string | null>(null);
|
||||
|
||||
const handleProfileChange = (field: keyof ProfileFormData, value: string) => {
|
||||
setProfileData((prev) => ({ ...prev, [field]: value }));
|
||||
};
|
||||
|
||||
const handlePasswordChange = (field: keyof PasswordFormData, value: string) => {
|
||||
setPasswordData((prev) => ({ ...prev, [field]: value }));
|
||||
};
|
||||
|
||||
const handleNotificationChange = (field: keyof NotificationSettings, value: boolean) => {
|
||||
setNotifications((prev) => ({ ...prev, [field]: value }));
|
||||
};
|
||||
|
||||
const handleAvatarChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = event.target.files?.[0];
|
||||
if (file) {
|
||||
const reader = new FileReader();
|
||||
reader.onloadend = () => {
|
||||
setAvatarPreview(reader.result as string);
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveProfile = async () => {
|
||||
setIsSavingProfile(true);
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
setIsSavingProfile(false);
|
||||
alert('Perfil actualizado correctamente');
|
||||
};
|
||||
|
||||
const handleSavePassword = async () => {
|
||||
if (passwordData.newPassword !== passwordData.confirmPassword) {
|
||||
alert('Las contrasenas no coinciden');
|
||||
return;
|
||||
}
|
||||
if (passwordData.newPassword.length < 8) {
|
||||
alert('La contrasena debe tener al menos 8 caracteres');
|
||||
return;
|
||||
}
|
||||
setIsSavingPassword(true);
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
setIsSavingPassword(false);
|
||||
setPasswordData(initialPasswordData);
|
||||
alert('Contrasena actualizada correctamente');
|
||||
};
|
||||
|
||||
const handleSaveNotifications = async () => {
|
||||
setIsSavingNotifications(true);
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
setIsSavingNotifications(false);
|
||||
alert('Preferencias de notificaciones actualizadas');
|
||||
};
|
||||
|
||||
const initials = `${profileData.firstName[0]}${profileData.lastName[0]}`.toUpperCase();
|
||||
|
||||
return (
|
||||
<div className="space-y-6 p-6">
|
||||
<Breadcrumbs
|
||||
items={[
|
||||
{ label: 'Configuracion', href: '/settings' },
|
||||
{ label: 'Mi perfil' },
|
||||
]}
|
||||
/>
|
||||
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">Mi Perfil</h1>
|
||||
<p className="text-sm text-gray-500">
|
||||
Administra tu informacion personal y preferencias
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 lg:grid-cols-2">
|
||||
{/* Profile Information */}
|
||||
<Card className="lg:col-span-2">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<User className="h-5 w-5 text-purple-600" />
|
||||
Informacion Personal
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex flex-col sm:flex-row gap-6">
|
||||
{/* Avatar */}
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
<div className="relative">
|
||||
<div className="flex h-24 w-24 items-center justify-center rounded-full bg-primary-100 text-2xl font-bold text-primary-600 overflow-hidden">
|
||||
{avatarPreview ? (
|
||||
<img
|
||||
src={avatarPreview}
|
||||
alt="Avatar preview"
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
initials
|
||||
)}
|
||||
</div>
|
||||
<label className="absolute bottom-0 right-0 cursor-pointer">
|
||||
<span className="flex h-8 w-8 items-center justify-center rounded-full bg-primary-600 text-white shadow-lg hover:bg-primary-700">
|
||||
<Camera className="h-4 w-4" />
|
||||
</span>
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
onChange={handleAvatarChange}
|
||||
className="hidden"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500">Cambiar foto</p>
|
||||
</div>
|
||||
|
||||
{/* Form Fields */}
|
||||
<div className="flex-1 grid gap-4 sm:grid-cols-2">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Nombre
|
||||
</label>
|
||||
<Input
|
||||
value={profileData.firstName}
|
||||
onChange={(e) => handleProfileChange('firstName', e.target.value)}
|
||||
placeholder="Tu nombre"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Apellido
|
||||
</label>
|
||||
<Input
|
||||
value={profileData.lastName}
|
||||
onChange={(e) => handleProfileChange('lastName', e.target.value)}
|
||||
placeholder="Tu apellido"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
<Mail className="inline-block h-4 w-4 mr-1" />
|
||||
Correo electronico
|
||||
</label>
|
||||
<Input
|
||||
type="email"
|
||||
value={profileData.email}
|
||||
onChange={(e) => handleProfileChange('email', e.target.value)}
|
||||
placeholder="tu@email.com"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
<Phone className="inline-block h-4 w-4 mr-1" />
|
||||
Telefono
|
||||
</label>
|
||||
<Input
|
||||
type="tel"
|
||||
value={profileData.phone}
|
||||
onChange={(e) => handleProfileChange('phone', e.target.value)}
|
||||
placeholder="+52 55 1234 5678"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
<CardFooter className="flex justify-end">
|
||||
<Button onClick={handleSaveProfile} isLoading={isSavingProfile}>
|
||||
<Save className="mr-2 h-4 w-4" />
|
||||
Guardar perfil
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
|
||||
{/* Change Password */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Lock className="h-5 w-5 text-red-600" />
|
||||
Cambiar Contrasena
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Contrasena actual
|
||||
</label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
type={showCurrentPassword ? 'text' : 'password'}
|
||||
value={passwordData.currentPassword}
|
||||
onChange={(e) => handlePasswordChange('currentPassword', e.target.value)}
|
||||
placeholder="Tu contrasena actual"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowCurrentPassword(!showCurrentPassword)}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600"
|
||||
>
|
||||
{showCurrentPassword ? (
|
||||
<EyeOff className="h-4 w-4" />
|
||||
) : (
|
||||
<Eye className="h-4 w-4" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Nueva contrasena
|
||||
</label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
type={showNewPassword ? 'text' : 'password'}
|
||||
value={passwordData.newPassword}
|
||||
onChange={(e) => handlePasswordChange('newPassword', e.target.value)}
|
||||
placeholder="Minimo 8 caracteres"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowNewPassword(!showNewPassword)}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600"
|
||||
>
|
||||
{showNewPassword ? (
|
||||
<EyeOff className="h-4 w-4" />
|
||||
) : (
|
||||
<Eye className="h-4 w-4" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Confirmar nueva contrasena
|
||||
</label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
type={showConfirmPassword ? 'text' : 'password'}
|
||||
value={passwordData.confirmPassword}
|
||||
onChange={(e) => handlePasswordChange('confirmPassword', e.target.value)}
|
||||
placeholder="Repite la nueva contrasena"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowConfirmPassword(!showConfirmPassword)}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600"
|
||||
>
|
||||
{showConfirmPassword ? (
|
||||
<EyeOff className="h-4 w-4" />
|
||||
) : (
|
||||
<Eye className="h-4 w-4" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
{passwordData.newPassword &&
|
||||
passwordData.confirmPassword &&
|
||||
passwordData.newPassword !== passwordData.confirmPassword && (
|
||||
<p className="mt-1 text-sm text-red-600">
|
||||
Las contrasenas no coinciden
|
||||
</p>
|
||||
)}
|
||||
{passwordData.newPassword &&
|
||||
passwordData.confirmPassword &&
|
||||
passwordData.newPassword === passwordData.confirmPassword && (
|
||||
<p className="mt-1 text-sm text-green-600 flex items-center gap-1">
|
||||
<Check className="h-4 w-4" />
|
||||
Las contrasenas coinciden
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
<CardFooter className="flex justify-end">
|
||||
<Button
|
||||
onClick={handleSavePassword}
|
||||
isLoading={isSavingPassword}
|
||||
disabled={
|
||||
!passwordData.currentPassword ||
|
||||
!passwordData.newPassword ||
|
||||
!passwordData.confirmPassword ||
|
||||
passwordData.newPassword !== passwordData.confirmPassword
|
||||
}
|
||||
>
|
||||
<Lock className="mr-2 h-4 w-4" />
|
||||
Actualizar contrasena
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
|
||||
{/* Locale Settings */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Globe className="h-5 w-5 text-blue-600" />
|
||||
Idioma y Region
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Idioma
|
||||
</label>
|
||||
<select
|
||||
value={profileData.language}
|
||||
onChange={(e) => handleProfileChange('language', e.target.value)}
|
||||
className="block w-full rounded-md border border-gray-300 px-3 py-2 text-sm shadow-sm focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500"
|
||||
>
|
||||
{languages.map((lang) => (
|
||||
<option key={lang.value} value={lang.value}>
|
||||
{lang.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Zona horaria
|
||||
</label>
|
||||
<select
|
||||
value={profileData.timezone}
|
||||
onChange={(e) => handleProfileChange('timezone', e.target.value)}
|
||||
className="block w-full rounded-md border border-gray-300 px-3 py-2 text-sm shadow-sm focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500"
|
||||
>
|
||||
{timezones.map((tz) => (
|
||||
<option key={tz.value} value={tz.value}>
|
||||
{tz.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</CardContent>
|
||||
<CardFooter className="flex justify-end">
|
||||
<Button onClick={handleSaveProfile} isLoading={isSavingProfile}>
|
||||
<Save className="mr-2 h-4 w-4" />
|
||||
Guardar preferencias
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
|
||||
{/* Notification Settings */}
|
||||
<Card className="lg:col-span-2">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Bell className="h-5 w-5 text-yellow-600" />
|
||||
Notificaciones
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid gap-4 sm:grid-cols-2 divide-y sm:divide-y-0 sm:divide-x">
|
||||
<div className="space-y-1">
|
||||
<h4 className="font-medium text-gray-900 mb-3">Canales de notificacion</h4>
|
||||
<ToggleSwitch
|
||||
checked={notifications.emailNotifications}
|
||||
onChange={(value) => handleNotificationChange('emailNotifications', value)}
|
||||
label="Notificaciones por email"
|
||||
description="Recibe alertas y actualizaciones por correo"
|
||||
/>
|
||||
<ToggleSwitch
|
||||
checked={notifications.pushNotifications}
|
||||
onChange={(value) => handleNotificationChange('pushNotifications', value)}
|
||||
label="Notificaciones push"
|
||||
description="Notificaciones en el navegador"
|
||||
/>
|
||||
<ToggleSwitch
|
||||
checked={notifications.smsNotifications}
|
||||
onChange={(value) => handleNotificationChange('smsNotifications', value)}
|
||||
label="Notificaciones SMS"
|
||||
description="Recibe alertas por mensaje de texto"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="pt-4 sm:pt-0 sm:pl-4 space-y-1">
|
||||
<h4 className="font-medium text-gray-900 mb-3">Tipos de notificacion</h4>
|
||||
<ToggleSwitch
|
||||
checked={notifications.securityAlerts}
|
||||
onChange={(value) => handleNotificationChange('securityAlerts', value)}
|
||||
label="Alertas de seguridad"
|
||||
description="Inicios de sesion y cambios de contrasena"
|
||||
/>
|
||||
<ToggleSwitch
|
||||
checked={notifications.weeklyReport}
|
||||
onChange={(value) => handleNotificationChange('weeklyReport', value)}
|
||||
label="Reporte semanal"
|
||||
description="Resumen de actividad semanal"
|
||||
/>
|
||||
<ToggleSwitch
|
||||
checked={notifications.monthlyReport}
|
||||
onChange={(value) => handleNotificationChange('monthlyReport', value)}
|
||||
label="Reporte mensual"
|
||||
description="Resumen de actividad mensual"
|
||||
/>
|
||||
<ToggleSwitch
|
||||
checked={notifications.marketingEmails}
|
||||
onChange={(value) => handleNotificationChange('marketingEmails', value)}
|
||||
label="Emails de marketing"
|
||||
description="Novedades, ofertas y actualizaciones"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
<CardFooter className="flex justify-end">
|
||||
<Button onClick={handleSaveNotifications} isLoading={isSavingNotifications}>
|
||||
<Save className="mr-2 h-4 w-4" />
|
||||
Guardar notificaciones
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ProfileSettingsPage;
|
||||
662
src/pages/settings/SecuritySettingsPage.tsx
Normal file
662
src/pages/settings/SecuritySettingsPage.tsx
Normal file
@ -0,0 +1,662 @@
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
Shield,
|
||||
Smartphone,
|
||||
Key,
|
||||
Monitor,
|
||||
LogOut,
|
||||
MapPin,
|
||||
Clock,
|
||||
Check,
|
||||
X,
|
||||
AlertTriangle,
|
||||
Trash2,
|
||||
QrCode,
|
||||
Copy,
|
||||
} from 'lucide-react';
|
||||
import { Button } from '@components/atoms/Button';
|
||||
import { Input } from '@components/atoms/Input';
|
||||
import { Card, CardHeader, CardTitle, CardContent } from '@components/molecules/Card';
|
||||
import { DataTable, type Column } from '@components/organisms/DataTable';
|
||||
import { Breadcrumbs } from '@components/organisms/Breadcrumbs';
|
||||
import { ConfirmModal } from '@components/organisms/Modal';
|
||||
import { formatDate } from '@utils/formatters';
|
||||
|
||||
interface Session {
|
||||
id: string;
|
||||
device: string;
|
||||
browser: string;
|
||||
location: string;
|
||||
ipAddress: string;
|
||||
lastActive: string;
|
||||
isCurrent: boolean;
|
||||
}
|
||||
|
||||
interface LoginHistory {
|
||||
id: string;
|
||||
date: string;
|
||||
ipAddress: string;
|
||||
location: string;
|
||||
device: string;
|
||||
browser: string;
|
||||
status: 'success' | 'failed';
|
||||
}
|
||||
|
||||
interface AuthorizedDevice {
|
||||
id: string;
|
||||
name: string;
|
||||
type: 'desktop' | 'mobile' | 'tablet';
|
||||
lastUsed: string;
|
||||
trusted: boolean;
|
||||
}
|
||||
|
||||
const mockSessions: Session[] = [
|
||||
{
|
||||
id: '1',
|
||||
device: 'MacBook Pro',
|
||||
browser: 'Chrome 120',
|
||||
location: 'Ciudad de Mexico, Mexico',
|
||||
ipAddress: '189.203.xxx.xxx',
|
||||
lastActive: new Date().toISOString(),
|
||||
isCurrent: true,
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
device: 'iPhone 15',
|
||||
browser: 'Safari Mobile',
|
||||
location: 'Ciudad de Mexico, Mexico',
|
||||
ipAddress: '187.190.xxx.xxx',
|
||||
lastActive: new Date(Date.now() - 3600000).toISOString(),
|
||||
isCurrent: false,
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
device: 'Windows PC',
|
||||
browser: 'Edge 120',
|
||||
location: 'Guadalajara, Mexico',
|
||||
ipAddress: '201.175.xxx.xxx',
|
||||
lastActive: new Date(Date.now() - 86400000).toISOString(),
|
||||
isCurrent: false,
|
||||
},
|
||||
];
|
||||
|
||||
const mockLoginHistory: LoginHistory[] = [
|
||||
{
|
||||
id: '1',
|
||||
date: new Date().toISOString(),
|
||||
ipAddress: '189.203.xxx.xxx',
|
||||
location: 'Ciudad de Mexico, Mexico',
|
||||
device: 'MacBook Pro',
|
||||
browser: 'Chrome 120',
|
||||
status: 'success',
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
date: new Date(Date.now() - 7200000).toISOString(),
|
||||
ipAddress: '189.203.xxx.xxx',
|
||||
location: 'Ciudad de Mexico, Mexico',
|
||||
device: 'MacBook Pro',
|
||||
browser: 'Chrome 120',
|
||||
status: 'success',
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
date: new Date(Date.now() - 14400000).toISOString(),
|
||||
ipAddress: '200.100.xxx.xxx',
|
||||
location: 'Ubicacion desconocida',
|
||||
device: 'Unknown',
|
||||
browser: 'Unknown',
|
||||
status: 'failed',
|
||||
},
|
||||
{
|
||||
id: '4',
|
||||
date: new Date(Date.now() - 86400000).toISOString(),
|
||||
ipAddress: '187.190.xxx.xxx',
|
||||
location: 'Ciudad de Mexico, Mexico',
|
||||
device: 'iPhone 15',
|
||||
browser: 'Safari Mobile',
|
||||
status: 'success',
|
||||
},
|
||||
{
|
||||
id: '5',
|
||||
date: new Date(Date.now() - 172800000).toISOString(),
|
||||
ipAddress: '201.175.xxx.xxx',
|
||||
location: 'Guadalajara, Mexico',
|
||||
device: 'Windows PC',
|
||||
browser: 'Edge 120',
|
||||
status: 'success',
|
||||
},
|
||||
{
|
||||
id: '6',
|
||||
date: new Date(Date.now() - 259200000).toISOString(),
|
||||
ipAddress: '189.203.xxx.xxx',
|
||||
location: 'Ciudad de Mexico, Mexico',
|
||||
device: 'MacBook Pro',
|
||||
browser: 'Chrome 120',
|
||||
status: 'success',
|
||||
},
|
||||
{
|
||||
id: '7',
|
||||
date: new Date(Date.now() - 345600000).toISOString(),
|
||||
ipAddress: '189.203.xxx.xxx',
|
||||
location: 'Ciudad de Mexico, Mexico',
|
||||
device: 'MacBook Pro',
|
||||
browser: 'Firefox 121',
|
||||
status: 'success',
|
||||
},
|
||||
{
|
||||
id: '8',
|
||||
date: new Date(Date.now() - 432000000).toISOString(),
|
||||
ipAddress: '123.456.xxx.xxx',
|
||||
location: 'Ubicacion desconocida',
|
||||
device: 'Unknown',
|
||||
browser: 'Unknown',
|
||||
status: 'failed',
|
||||
},
|
||||
{
|
||||
id: '9',
|
||||
date: new Date(Date.now() - 518400000).toISOString(),
|
||||
ipAddress: '187.190.xxx.xxx',
|
||||
location: 'Ciudad de Mexico, Mexico',
|
||||
device: 'iPhone 15',
|
||||
browser: 'Safari Mobile',
|
||||
status: 'success',
|
||||
},
|
||||
{
|
||||
id: '10',
|
||||
date: new Date(Date.now() - 604800000).toISOString(),
|
||||
ipAddress: '189.203.xxx.xxx',
|
||||
location: 'Ciudad de Mexico, Mexico',
|
||||
device: 'MacBook Pro',
|
||||
browser: 'Chrome 119',
|
||||
status: 'success',
|
||||
},
|
||||
];
|
||||
|
||||
const mockAuthorizedDevices: AuthorizedDevice[] = [
|
||||
{
|
||||
id: '1',
|
||||
name: 'MacBook Pro de Juan',
|
||||
type: 'desktop',
|
||||
lastUsed: new Date().toISOString(),
|
||||
trusted: true,
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
name: 'iPhone de Juan',
|
||||
type: 'mobile',
|
||||
lastUsed: new Date(Date.now() - 3600000).toISOString(),
|
||||
trusted: true,
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
name: 'iPad Pro',
|
||||
type: 'tablet',
|
||||
lastUsed: new Date(Date.now() - 604800000).toISOString(),
|
||||
trusted: false,
|
||||
},
|
||||
];
|
||||
|
||||
export function SecuritySettingsPage() {
|
||||
const [is2FAEnabled, setIs2FAEnabled] = useState(false);
|
||||
const [show2FASetup, setShow2FASetup] = useState(false);
|
||||
const [verificationCode, setVerificationCode] = useState('');
|
||||
const [sessions] = useState<Session[]>(mockSessions);
|
||||
const [loginHistory] = useState<LoginHistory[]>(mockLoginHistory);
|
||||
const [authorizedDevices, setAuthorizedDevices] = useState<AuthorizedDevice[]>(mockAuthorizedDevices);
|
||||
const [sessionToRevoke, setSessionToRevoke] = useState<Session | null>(null);
|
||||
const [deviceToRemove, setDeviceToRemove] = useState<AuthorizedDevice | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const mock2FASecret = 'JBSWY3DPEHPK3PXP';
|
||||
|
||||
const handleEnable2FA = () => {
|
||||
setShow2FASetup(true);
|
||||
};
|
||||
|
||||
const handleVerify2FA = async () => {
|
||||
if (verificationCode.length !== 6) {
|
||||
alert('El codigo debe tener 6 digitos');
|
||||
return;
|
||||
}
|
||||
setIsLoading(true);
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
setIs2FAEnabled(true);
|
||||
setShow2FASetup(false);
|
||||
setVerificationCode('');
|
||||
setIsLoading(false);
|
||||
alert('Autenticacion de dos factores activada correctamente');
|
||||
};
|
||||
|
||||
const handleDisable2FA = async () => {
|
||||
setIsLoading(true);
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
setIs2FAEnabled(false);
|
||||
setIsLoading(false);
|
||||
alert('Autenticacion de dos factores desactivada');
|
||||
};
|
||||
|
||||
const handleRevokeSession = async () => {
|
||||
if (sessionToRevoke) {
|
||||
setIsLoading(true);
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
setIsLoading(false);
|
||||
setSessionToRevoke(null);
|
||||
alert(`Sesion en ${sessionToRevoke.device} cerrada`);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRevokeAllSessions = async () => {
|
||||
setIsLoading(true);
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
setIsLoading(false);
|
||||
alert('Todas las sesiones han sido cerradas (excepto la actual)');
|
||||
};
|
||||
|
||||
const handleRemoveDevice = async () => {
|
||||
if (deviceToRemove) {
|
||||
setAuthorizedDevices((prev) => prev.filter((d) => d.id !== deviceToRemove.id));
|
||||
setDeviceToRemove(null);
|
||||
alert(`Dispositivo "${deviceToRemove.name}" eliminado`);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCopySecret = () => {
|
||||
navigator.clipboard.writeText(mock2FASecret);
|
||||
alert('Codigo secreto copiado al portapapeles');
|
||||
};
|
||||
|
||||
const getDeviceIcon = (type: AuthorizedDevice['type']) => {
|
||||
switch (type) {
|
||||
case 'desktop':
|
||||
return <Monitor className="h-5 w-5" />;
|
||||
case 'mobile':
|
||||
return <Smartphone className="h-5 w-5" />;
|
||||
case 'tablet':
|
||||
return <Monitor className="h-5 w-5" />;
|
||||
default:
|
||||
return <Monitor className="h-5 w-5" />;
|
||||
}
|
||||
};
|
||||
|
||||
const sessionColumns: Column<Session>[] = [
|
||||
{
|
||||
key: 'device',
|
||||
header: 'Dispositivo',
|
||||
render: (session) => (
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-gray-100">
|
||||
<Monitor className="h-5 w-5 text-gray-600" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-medium text-gray-900 flex items-center gap-2">
|
||||
{session.device}
|
||||
{session.isCurrent && (
|
||||
<span className="inline-flex items-center rounded-full bg-green-100 px-2 py-0.5 text-xs font-medium text-green-700">
|
||||
Actual
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-sm text-gray-500">{session.browser}</div>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'location',
|
||||
header: 'Ubicacion',
|
||||
render: (session) => (
|
||||
<div className="flex items-center gap-2">
|
||||
<MapPin className="h-4 w-4 text-gray-400" />
|
||||
<div>
|
||||
<div className="text-sm text-gray-900">{session.location}</div>
|
||||
<div className="text-xs text-gray-500 font-mono">{session.ipAddress}</div>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'lastActive',
|
||||
header: 'Ultima actividad',
|
||||
render: (session) => (
|
||||
<div className="flex items-center gap-2 text-sm text-gray-600">
|
||||
<Clock className="h-4 w-4" />
|
||||
{session.isCurrent ? 'Ahora' : formatDate(session.lastActive, 'short')}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'actions',
|
||||
header: '',
|
||||
render: (session) =>
|
||||
!session.isCurrent && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setSessionToRevoke(session)}
|
||||
className="text-red-600 hover:text-red-700 hover:bg-red-50"
|
||||
>
|
||||
<LogOut className="h-4 w-4" />
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
const loginHistoryColumns: Column<LoginHistory>[] = [
|
||||
{
|
||||
key: 'date',
|
||||
header: 'Fecha',
|
||||
render: (log) => (
|
||||
<span className="text-sm text-gray-900">
|
||||
{formatDate(log.date, 'full')}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'status',
|
||||
header: 'Estado',
|
||||
render: (log) => (
|
||||
<span
|
||||
className={`inline-flex items-center gap-1 rounded-full px-2 py-1 text-xs font-medium ${
|
||||
log.status === 'success'
|
||||
? 'bg-green-100 text-green-700'
|
||||
: 'bg-red-100 text-red-700'
|
||||
}`}
|
||||
>
|
||||
{log.status === 'success' ? (
|
||||
<>
|
||||
<Check className="h-3 w-3" />
|
||||
Exitoso
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<X className="h-3 w-3" />
|
||||
Fallido
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'location',
|
||||
header: 'Ubicacion',
|
||||
render: (log) => (
|
||||
<div className="flex items-center gap-2">
|
||||
<MapPin className="h-4 w-4 text-gray-400" />
|
||||
<span className="text-sm text-gray-600">{log.location}</span>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'device',
|
||||
header: 'Dispositivo',
|
||||
render: (log) => (
|
||||
<div className="text-sm text-gray-600">
|
||||
{log.device} / {log.browser}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'ipAddress',
|
||||
header: 'IP',
|
||||
render: (log) => (
|
||||
<span className="text-sm text-gray-500 font-mono">{log.ipAddress}</span>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="space-y-6 p-6">
|
||||
<Breadcrumbs
|
||||
items={[
|
||||
{ label: 'Configuracion', href: '/settings' },
|
||||
{ label: 'Seguridad' },
|
||||
]}
|
||||
/>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">Seguridad</h1>
|
||||
<p className="text-sm text-gray-500">
|
||||
Administra la seguridad de tu cuenta
|
||||
</p>
|
||||
</div>
|
||||
<Button variant="outline" onClick={() => window.location.href = '/settings/security/audit-logs'}>
|
||||
Ver logs de auditoria
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Two-Factor Authentication */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Key className="h-5 w-5 text-purple-600" />
|
||||
Autenticacion de Dos Factores (2FA)
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{!show2FASetup ? (
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<div
|
||||
className={`flex h-12 w-12 items-center justify-center rounded-full ${
|
||||
is2FAEnabled ? 'bg-green-100' : 'bg-gray-100'
|
||||
}`}
|
||||
>
|
||||
<Shield
|
||||
className={`h-6 w-6 ${is2FAEnabled ? 'text-green-600' : 'text-gray-400'}`}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-medium text-gray-900">
|
||||
{is2FAEnabled
|
||||
? 'Autenticacion de dos factores activada'
|
||||
: 'Autenticacion de dos factores desactivada'}
|
||||
</div>
|
||||
<div className="text-sm text-gray-500">
|
||||
{is2FAEnabled
|
||||
? 'Tu cuenta esta protegida con un segundo factor de autenticacion'
|
||||
: 'Anade una capa extra de seguridad a tu cuenta'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant={is2FAEnabled ? 'outline' : 'primary'}
|
||||
onClick={is2FAEnabled ? handleDisable2FA : handleEnable2FA}
|
||||
isLoading={isLoading}
|
||||
>
|
||||
{is2FAEnabled ? 'Desactivar' : 'Activar 2FA'}
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-start gap-4 p-4 bg-yellow-50 rounded-lg">
|
||||
<AlertTriangle className="h-5 w-5 text-yellow-600 flex-shrink-0 mt-0.5" />
|
||||
<div className="text-sm text-yellow-800">
|
||||
<p className="font-medium">Configura tu aplicacion de autenticacion</p>
|
||||
<p className="mt-1">
|
||||
Escanea el codigo QR con tu aplicacion de autenticacion (Google Authenticator, Authy, etc.)
|
||||
o ingresa el codigo secreto manualmente.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col sm:flex-row gap-6 items-center">
|
||||
{/* QR Code placeholder */}
|
||||
<div className="flex h-48 w-48 items-center justify-center rounded-lg border-2 border-dashed border-gray-300 bg-gray-50">
|
||||
<div className="text-center">
|
||||
<QrCode className="h-16 w-16 text-gray-400 mx-auto" />
|
||||
<p className="mt-2 text-xs text-gray-500">Codigo QR</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Codigo secreto (si no puedes escanear)
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
<code className="flex-1 rounded-md bg-gray-100 px-4 py-2 font-mono text-sm">
|
||||
{mock2FASecret}
|
||||
</code>
|
||||
<Button variant="outline" size="sm" onClick={handleCopySecret}>
|
||||
<Copy className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Codigo de verificacion
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
value={verificationCode}
|
||||
onChange={(e) => setVerificationCode(e.target.value.replace(/\D/g, '').slice(0, 6))}
|
||||
placeholder="000000"
|
||||
className="font-mono text-center text-lg tracking-widest"
|
||||
maxLength={6}
|
||||
/>
|
||||
<Button onClick={handleVerify2FA} isLoading={isLoading}>
|
||||
Verificar
|
||||
</Button>
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-gray-500">
|
||||
Ingresa el codigo de 6 digitos de tu aplicacion
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button variant="ghost" onClick={() => setShow2FASetup(false)}>
|
||||
Cancelar
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Active Sessions */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Monitor className="h-5 w-5 text-blue-600" />
|
||||
Sesiones Activas
|
||||
</CardTitle>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleRevokeAllSessions}
|
||||
className="text-red-600 border-red-300 hover:bg-red-50"
|
||||
>
|
||||
<LogOut className="mr-2 h-4 w-4" />
|
||||
Cerrar todas las sesiones
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<DataTable
|
||||
data={sessions}
|
||||
columns={sessionColumns}
|
||||
isLoading={false}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Login History */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Clock className="h-5 w-5 text-green-600" />
|
||||
Historial de Inicios de Sesion
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-sm text-gray-500 mb-4">
|
||||
Ultimos 10 intentos de inicio de sesion
|
||||
</p>
|
||||
<DataTable
|
||||
data={loginHistory}
|
||||
columns={loginHistoryColumns}
|
||||
isLoading={false}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Authorized Devices */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Smartphone className="h-5 w-5 text-orange-600" />
|
||||
Dispositivos Autorizados
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
{authorizedDevices.map((device) => (
|
||||
<div
|
||||
key={device.id}
|
||||
className="flex items-center justify-between p-4 rounded-lg border bg-gray-50"
|
||||
>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded-lg bg-white border">
|
||||
{getDeviceIcon(device.type)}
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-medium text-gray-900 flex items-center gap-2">
|
||||
{device.name}
|
||||
{device.trusted && (
|
||||
<span className="inline-flex items-center rounded-full bg-blue-100 px-2 py-0.5 text-xs font-medium text-blue-700">
|
||||
<Check className="h-3 w-3 mr-1" />
|
||||
Confiable
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-sm text-gray-500">
|
||||
Ultimo uso: {formatDate(device.lastUsed, 'short')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setDeviceToRemove(device)}
|
||||
className="text-red-600 hover:text-red-700 hover:bg-red-50"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Revoke Session Modal */}
|
||||
<ConfirmModal
|
||||
isOpen={!!sessionToRevoke}
|
||||
onClose={() => setSessionToRevoke(null)}
|
||||
onConfirm={handleRevokeSession}
|
||||
title="Cerrar sesion"
|
||||
message={`¿Cerrar la sesion en "${sessionToRevoke?.device}"? El usuario tendra que volver a iniciar sesion.`}
|
||||
variant="warning"
|
||||
confirmText="Cerrar sesion"
|
||||
/>
|
||||
|
||||
{/* Remove Device Modal */}
|
||||
<ConfirmModal
|
||||
isOpen={!!deviceToRemove}
|
||||
onClose={() => setDeviceToRemove(null)}
|
||||
onConfirm={handleRemoveDevice}
|
||||
title="Eliminar dispositivo"
|
||||
message={`¿Eliminar "${deviceToRemove?.name}" de la lista de dispositivos autorizados? El dispositivo tendra que volver a autenticarse.`}
|
||||
variant="danger"
|
||||
confirmText="Eliminar"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default SecuritySettingsPage;
|
||||
923
src/pages/settings/SystemSettingsPage.tsx
Normal file
923
src/pages/settings/SystemSettingsPage.tsx
Normal file
@ -0,0 +1,923 @@
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
Database,
|
||||
Download,
|
||||
Upload,
|
||||
FileText,
|
||||
Key,
|
||||
Plus,
|
||||
Copy,
|
||||
Eye,
|
||||
EyeOff,
|
||||
Trash2,
|
||||
Check,
|
||||
X,
|
||||
AlertTriangle,
|
||||
Clock,
|
||||
HardDrive,
|
||||
Cpu,
|
||||
Activity,
|
||||
Package,
|
||||
ToggleLeft,
|
||||
ToggleRight,
|
||||
Calendar,
|
||||
Filter,
|
||||
} from 'lucide-react';
|
||||
import { Button } from '@components/atoms/Button';
|
||||
import { Input } from '@components/atoms/Input';
|
||||
import { Card, CardHeader, CardTitle, CardContent, CardFooter } from '@components/molecules/Card';
|
||||
import { DataTable, type Column } from '@components/organisms/DataTable';
|
||||
import { Breadcrumbs } from '@components/organisms/Breadcrumbs';
|
||||
import { ConfirmModal } from '@components/organisms/Modal';
|
||||
import { formatDate } from '@utils/formatters';
|
||||
|
||||
interface BackupConfig {
|
||||
autoBackup: boolean;
|
||||
frequency: 'daily' | 'weekly' | 'monthly';
|
||||
retentionDays: number;
|
||||
lastBackup: string | null;
|
||||
nextBackup: string | null;
|
||||
storageLocation: string;
|
||||
}
|
||||
|
||||
interface SystemLog {
|
||||
id: string;
|
||||
timestamp: string;
|
||||
level: 'info' | 'warning' | 'error';
|
||||
source: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
interface ApiKey {
|
||||
id: string;
|
||||
name: string;
|
||||
key: string;
|
||||
createdAt: string;
|
||||
lastUsed: string | null;
|
||||
permissions: string[];
|
||||
isActive: boolean;
|
||||
}
|
||||
|
||||
interface Module {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
version: string;
|
||||
isActive: boolean;
|
||||
isCore: boolean;
|
||||
}
|
||||
|
||||
const initialBackupConfig: BackupConfig = {
|
||||
autoBackup: true,
|
||||
frequency: 'daily',
|
||||
retentionDays: 30,
|
||||
lastBackup: new Date(Date.now() - 86400000).toISOString(),
|
||||
nextBackup: new Date(Date.now() + 43200000).toISOString(),
|
||||
storageLocation: 'cloud',
|
||||
};
|
||||
|
||||
const mockSystemLogs: SystemLog[] = [
|
||||
{
|
||||
id: '1',
|
||||
timestamp: new Date().toISOString(),
|
||||
level: 'info',
|
||||
source: 'auth',
|
||||
message: 'Usuario juan.perez@miempresa.com inicio sesion exitosamente',
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
timestamp: new Date(Date.now() - 300000).toISOString(),
|
||||
level: 'warning',
|
||||
source: 'database',
|
||||
message: 'Conexion a base de datos lenta (>500ms)',
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
timestamp: new Date(Date.now() - 600000).toISOString(),
|
||||
level: 'info',
|
||||
source: 'backup',
|
||||
message: 'Respaldo automatico completado exitosamente',
|
||||
},
|
||||
{
|
||||
id: '4',
|
||||
timestamp: new Date(Date.now() - 900000).toISOString(),
|
||||
level: 'error',
|
||||
source: 'email',
|
||||
message: 'Error al enviar notificacion: SMTP timeout',
|
||||
},
|
||||
{
|
||||
id: '5',
|
||||
timestamp: new Date(Date.now() - 1200000).toISOString(),
|
||||
level: 'info',
|
||||
source: 'system',
|
||||
message: 'Modulo de inventario actualizado a v2.1.0',
|
||||
},
|
||||
{
|
||||
id: '6',
|
||||
timestamp: new Date(Date.now() - 1500000).toISOString(),
|
||||
level: 'warning',
|
||||
source: 'storage',
|
||||
message: 'Uso de almacenamiento al 75%',
|
||||
},
|
||||
{
|
||||
id: '7',
|
||||
timestamp: new Date(Date.now() - 1800000).toISOString(),
|
||||
level: 'info',
|
||||
source: 'auth',
|
||||
message: 'Token de API generado para integracion externa',
|
||||
},
|
||||
{
|
||||
id: '8',
|
||||
timestamp: new Date(Date.now() - 2100000).toISOString(),
|
||||
level: 'error',
|
||||
source: 'api',
|
||||
message: 'Rate limit excedido para IP 192.168.1.100',
|
||||
},
|
||||
{
|
||||
id: '9',
|
||||
timestamp: new Date(Date.now() - 2400000).toISOString(),
|
||||
level: 'info',
|
||||
source: 'database',
|
||||
message: 'Migracion de base de datos completada',
|
||||
},
|
||||
{
|
||||
id: '10',
|
||||
timestamp: new Date(Date.now() - 2700000).toISOString(),
|
||||
level: 'info',
|
||||
source: 'system',
|
||||
message: 'Sistema iniciado correctamente',
|
||||
},
|
||||
];
|
||||
|
||||
const mockApiKeys: ApiKey[] = [
|
||||
{
|
||||
id: '1',
|
||||
name: 'Integracion Shopify',
|
||||
key: 'sk_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxx',
|
||||
createdAt: new Date(Date.now() - 2592000000).toISOString(),
|
||||
lastUsed: new Date(Date.now() - 3600000).toISOString(),
|
||||
permissions: ['read:products', 'write:orders'],
|
||||
isActive: true,
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
name: 'API Movil',
|
||||
key: 'sk_live_yyyyyyyyyyyyyyyyyyyyyyyyyyy',
|
||||
createdAt: new Date(Date.now() - 5184000000).toISOString(),
|
||||
lastUsed: new Date().toISOString(),
|
||||
permissions: ['read:all', 'write:all'],
|
||||
isActive: true,
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
name: 'Webhook Pagos',
|
||||
key: 'sk_live_zzzzzzzzzzzzzzzzzzzzzzzzzzz',
|
||||
createdAt: new Date(Date.now() - 7776000000).toISOString(),
|
||||
lastUsed: null,
|
||||
permissions: ['write:payments'],
|
||||
isActive: false,
|
||||
},
|
||||
];
|
||||
|
||||
const mockModules: Module[] = [
|
||||
{
|
||||
id: '1',
|
||||
name: 'Core',
|
||||
description: 'Funcionalidades basicas del sistema',
|
||||
version: '1.0.0',
|
||||
isActive: true,
|
||||
isCore: true,
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
name: 'Inventario',
|
||||
description: 'Gestion de productos y stock',
|
||||
version: '2.1.0',
|
||||
isActive: true,
|
||||
isCore: false,
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
name: 'Ventas',
|
||||
description: 'Punto de venta y facturacion',
|
||||
version: '1.5.0',
|
||||
isActive: true,
|
||||
isCore: false,
|
||||
},
|
||||
{
|
||||
id: '4',
|
||||
name: 'Compras',
|
||||
description: 'Ordenes de compra y proveedores',
|
||||
version: '1.3.0',
|
||||
isActive: true,
|
||||
isCore: false,
|
||||
},
|
||||
{
|
||||
id: '5',
|
||||
name: 'Contabilidad',
|
||||
description: 'Plan de cuentas y asientos contables',
|
||||
version: '1.2.0',
|
||||
isActive: false,
|
||||
isCore: false,
|
||||
},
|
||||
{
|
||||
id: '6',
|
||||
name: 'CRM',
|
||||
description: 'Gestion de clientes y oportunidades',
|
||||
version: '1.0.0',
|
||||
isActive: false,
|
||||
isCore: false,
|
||||
},
|
||||
{
|
||||
id: '7',
|
||||
name: 'Proyectos',
|
||||
description: 'Gestion de proyectos y tareas',
|
||||
version: '1.0.0',
|
||||
isActive: false,
|
||||
isCore: false,
|
||||
},
|
||||
{
|
||||
id: '8',
|
||||
name: 'RRHH',
|
||||
description: 'Recursos humanos y nomina',
|
||||
version: '0.9.0',
|
||||
isActive: false,
|
||||
isCore: false,
|
||||
},
|
||||
];
|
||||
|
||||
export function SystemSettingsPage() {
|
||||
const [backupConfig, setBackupConfig] = useState<BackupConfig>(initialBackupConfig);
|
||||
const [systemLogs] = useState<SystemLog[]>(mockSystemLogs);
|
||||
const [apiKeys, setApiKeys] = useState<ApiKey[]>(mockApiKeys);
|
||||
const [modules, setModules] = useState<Module[]>(mockModules);
|
||||
const [isBackingUp, setIsBackingUp] = useState(false);
|
||||
const [isRestoring, setIsRestoring] = useState(false);
|
||||
const [showNewApiKeyModal, setShowNewApiKeyModal] = useState(false);
|
||||
const [newApiKeyName, setNewApiKeyName] = useState('');
|
||||
const [generatedApiKey, setGeneratedApiKey] = useState<string | null>(null);
|
||||
const [visibleKeys, setVisibleKeys] = useState<Set<string>>(new Set());
|
||||
const [apiKeyToDelete, setApiKeyToDelete] = useState<ApiKey | null>(null);
|
||||
const [logLevelFilter, setLogLevelFilter] = useState<string>('');
|
||||
const [logSourceFilter, setLogSourceFilter] = useState<string>('');
|
||||
|
||||
const handleBackupNow = async () => {
|
||||
setIsBackingUp(true);
|
||||
await new Promise((resolve) => setTimeout(resolve, 3000));
|
||||
setBackupConfig((prev) => ({
|
||||
...prev,
|
||||
lastBackup: new Date().toISOString(),
|
||||
}));
|
||||
setIsBackingUp(false);
|
||||
alert('Respaldo completado exitosamente');
|
||||
};
|
||||
|
||||
const handleRestore = async () => {
|
||||
setIsRestoring(true);
|
||||
await new Promise((resolve) => setTimeout(resolve, 2000));
|
||||
setIsRestoring(false);
|
||||
alert('Por favor selecciona un archivo de respaldo para restaurar');
|
||||
};
|
||||
|
||||
const handleBackupConfigChange = (field: keyof BackupConfig, value: unknown) => {
|
||||
setBackupConfig((prev) => ({ ...prev, [field]: value }));
|
||||
};
|
||||
|
||||
const handleCreateApiKey = async () => {
|
||||
if (!newApiKeyName.trim()) {
|
||||
alert('Ingresa un nombre para la API key');
|
||||
return;
|
||||
}
|
||||
const newKey = `sk_live_${Math.random().toString(36).substring(2, 32)}`;
|
||||
const newApiKey: ApiKey = {
|
||||
id: String(apiKeys.length + 1),
|
||||
name: newApiKeyName,
|
||||
key: newKey,
|
||||
createdAt: new Date().toISOString(),
|
||||
lastUsed: null,
|
||||
permissions: ['read:all'],
|
||||
isActive: true,
|
||||
};
|
||||
setApiKeys((prev) => [...prev, newApiKey]);
|
||||
setGeneratedApiKey(newKey);
|
||||
};
|
||||
|
||||
const handleCloseNewApiKeyModal = () => {
|
||||
setShowNewApiKeyModal(false);
|
||||
setNewApiKeyName('');
|
||||
setGeneratedApiKey(null);
|
||||
};
|
||||
|
||||
const handleCopyApiKey = (key: string) => {
|
||||
navigator.clipboard.writeText(key);
|
||||
alert('API key copiada al portapapeles');
|
||||
};
|
||||
|
||||
const toggleKeyVisibility = (keyId: string) => {
|
||||
setVisibleKeys((prev) => {
|
||||
const newSet = new Set(prev);
|
||||
if (newSet.has(keyId)) {
|
||||
newSet.delete(keyId);
|
||||
} else {
|
||||
newSet.add(keyId);
|
||||
}
|
||||
return newSet;
|
||||
});
|
||||
};
|
||||
|
||||
const handleDeleteApiKey = async () => {
|
||||
if (apiKeyToDelete) {
|
||||
setApiKeys((prev) => prev.filter((k) => k.id !== apiKeyToDelete.id));
|
||||
setApiKeyToDelete(null);
|
||||
alert(`API key "${apiKeyToDelete.name}" eliminada`);
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggleModule = (moduleId: string) => {
|
||||
setModules((prev) =>
|
||||
prev.map((m) =>
|
||||
m.id === moduleId && !m.isCore ? { ...m, isActive: !m.isActive } : m
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
const getLevelIcon = (level: SystemLog['level']) => {
|
||||
switch (level) {
|
||||
case 'info':
|
||||
return <Activity className="h-4 w-4 text-blue-600" />;
|
||||
case 'warning':
|
||||
return <AlertTriangle className="h-4 w-4 text-yellow-600" />;
|
||||
case 'error':
|
||||
return <X className="h-4 w-4 text-red-600" />;
|
||||
}
|
||||
};
|
||||
|
||||
const getLevelColor = (level: SystemLog['level']) => {
|
||||
switch (level) {
|
||||
case 'info':
|
||||
return 'bg-blue-100 text-blue-700';
|
||||
case 'warning':
|
||||
return 'bg-yellow-100 text-yellow-700';
|
||||
case 'error':
|
||||
return 'bg-red-100 text-red-700';
|
||||
}
|
||||
};
|
||||
|
||||
const filteredLogs = systemLogs.filter((log) => {
|
||||
if (logLevelFilter && log.level !== logLevelFilter) return false;
|
||||
if (logSourceFilter && log.source !== logSourceFilter) return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
const uniqueSources = [...new Set(systemLogs.map((l) => l.source))];
|
||||
|
||||
const logColumns: Column<SystemLog>[] = [
|
||||
{
|
||||
key: 'timestamp',
|
||||
header: 'Fecha',
|
||||
render: (log) => (
|
||||
<span className="text-sm text-gray-600 whitespace-nowrap">
|
||||
{formatDate(log.timestamp, 'full')}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'level',
|
||||
header: 'Nivel',
|
||||
render: (log) => (
|
||||
<span
|
||||
className={`inline-flex items-center gap-1 rounded-full px-2 py-1 text-xs font-medium ${getLevelColor(
|
||||
log.level
|
||||
)}`}
|
||||
>
|
||||
{getLevelIcon(log.level)}
|
||||
{log.level.toUpperCase()}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'source',
|
||||
header: 'Fuente',
|
||||
render: (log) => (
|
||||
<span className="inline-flex items-center rounded-full bg-gray-100 px-2 py-1 text-xs font-medium text-gray-700">
|
||||
{log.source}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'message',
|
||||
header: 'Mensaje',
|
||||
render: (log) => <span className="text-sm text-gray-900">{log.message}</span>,
|
||||
},
|
||||
];
|
||||
|
||||
const apiKeyColumns: Column<ApiKey>[] = [
|
||||
{
|
||||
key: 'name',
|
||||
header: 'Nombre',
|
||||
render: (apiKey) => (
|
||||
<div>
|
||||
<div className="font-medium text-gray-900">{apiKey.name}</div>
|
||||
<div className="text-xs text-gray-500">
|
||||
Creada: {formatDate(apiKey.createdAt, 'short')}
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'key',
|
||||
header: 'API Key',
|
||||
render: (apiKey) => (
|
||||
<div className="flex items-center gap-2">
|
||||
<code className="rounded bg-gray-100 px-2 py-1 font-mono text-xs">
|
||||
{visibleKeys.has(apiKey.id)
|
||||
? apiKey.key
|
||||
: `${apiKey.key.slice(0, 10)}${'*'.repeat(20)}`}
|
||||
</code>
|
||||
<button
|
||||
onClick={() => toggleKeyVisibility(apiKey.id)}
|
||||
className="text-gray-400 hover:text-gray-600"
|
||||
>
|
||||
{visibleKeys.has(apiKey.id) ? (
|
||||
<EyeOff className="h-4 w-4" />
|
||||
) : (
|
||||
<Eye className="h-4 w-4" />
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleCopyApiKey(apiKey.key)}
|
||||
className="text-gray-400 hover:text-gray-600"
|
||||
>
|
||||
<Copy className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'lastUsed',
|
||||
header: 'Ultimo uso',
|
||||
render: (apiKey) => (
|
||||
<span className="text-sm text-gray-600">
|
||||
{apiKey.lastUsed ? formatDate(apiKey.lastUsed, 'short') : 'Nunca'}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'isActive',
|
||||
header: 'Estado',
|
||||
render: (apiKey) => (
|
||||
<span
|
||||
className={`inline-flex items-center gap-1 rounded-full px-2 py-1 text-xs font-medium ${
|
||||
apiKey.isActive
|
||||
? 'bg-green-100 text-green-700'
|
||||
: 'bg-gray-100 text-gray-600'
|
||||
}`}
|
||||
>
|
||||
{apiKey.isActive ? (
|
||||
<>
|
||||
<Check className="h-3 w-3" />
|
||||
Activa
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<X className="h-3 w-3" />
|
||||
Inactiva
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'actions',
|
||||
header: '',
|
||||
render: (apiKey) => (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setApiKeyToDelete(apiKey)}
|
||||
className="text-red-600 hover:text-red-700 hover:bg-red-50"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="space-y-6 p-6">
|
||||
<Breadcrumbs
|
||||
items={[
|
||||
{ label: 'Configuracion', href: '/settings' },
|
||||
{ label: 'Sistema' },
|
||||
]}
|
||||
/>
|
||||
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">Configuracion del Sistema</h1>
|
||||
<p className="text-sm text-gray-500">
|
||||
Administra respaldos, logs, API keys y modulos
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* System 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">
|
||||
<Database className="h-5 w-5 text-blue-600" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm text-gray-500">Base de datos</div>
|
||||
<div className="text-lg font-bold text-blue-600">2.3 GB</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">
|
||||
<HardDrive className="h-5 w-5 text-green-600" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm text-gray-500">Almacenamiento</div>
|
||||
<div className="text-lg font-bold text-green-600">75%</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-purple-100">
|
||||
<Cpu className="h-5 w-5 text-purple-600" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm text-gray-500">CPU</div>
|
||||
<div className="text-lg font-bold text-purple-600">23%</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-orange-100">
|
||||
<Activity className="h-5 w-5 text-orange-600" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm text-gray-500">Uptime</div>
|
||||
<div className="text-lg font-bold text-orange-600">99.9%</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Backup Configuration */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Database className="h-5 w-5 text-blue-600" />
|
||||
Respaldos
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid gap-6 lg:grid-cols-2">
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<div className="font-medium text-gray-900">Respaldo automatico</div>
|
||||
<div className="text-sm text-gray-500">
|
||||
Realiza respaldos automaticos de la base de datos
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={backupConfig.autoBackup}
|
||||
onClick={() =>
|
||||
handleBackupConfigChange('autoBackup', !backupConfig.autoBackup)
|
||||
}
|
||||
className={`relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2 ${
|
||||
backupConfig.autoBackup ? 'bg-primary-600' : 'bg-gray-200'
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out ${
|
||||
backupConfig.autoBackup ? 'translate-x-5' : 'translate-x-0'
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{backupConfig.autoBackup && (
|
||||
<>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Frecuencia
|
||||
</label>
|
||||
<select
|
||||
value={backupConfig.frequency}
|
||||
onChange={(e) =>
|
||||
handleBackupConfigChange('frequency', e.target.value)
|
||||
}
|
||||
className="block w-full rounded-md border border-gray-300 px-3 py-2 text-sm shadow-sm focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500"
|
||||
>
|
||||
<option value="daily">Diario</option>
|
||||
<option value="weekly">Semanal</option>
|
||||
<option value="monthly">Mensual</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Retencion (dias)
|
||||
</label>
|
||||
<Input
|
||||
type="number"
|
||||
value={backupConfig.retentionDays}
|
||||
onChange={(e) =>
|
||||
handleBackupConfigChange('retentionDays', parseInt(e.target.value))
|
||||
}
|
||||
min={7}
|
||||
max={365}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-4 lg:border-l lg:pl-6">
|
||||
<div className="flex items-center gap-4 p-4 rounded-lg bg-gray-50">
|
||||
<Clock className="h-8 w-8 text-gray-400" />
|
||||
<div>
|
||||
<div className="text-sm text-gray-500">Ultimo respaldo</div>
|
||||
<div className="font-medium text-gray-900">
|
||||
{backupConfig.lastBackup
|
||||
? formatDate(backupConfig.lastBackup, 'full')
|
||||
: 'Nunca'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{backupConfig.autoBackup && backupConfig.nextBackup && (
|
||||
<div className="flex items-center gap-4 p-4 rounded-lg bg-blue-50">
|
||||
<Calendar className="h-8 w-8 text-blue-400" />
|
||||
<div>
|
||||
<div className="text-sm text-blue-600">Proximo respaldo</div>
|
||||
<div className="font-medium text-blue-900">
|
||||
{formatDate(backupConfig.nextBackup, 'full')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button onClick={handleBackupNow} isLoading={isBackingUp}>
|
||||
<Download className="mr-2 h-4 w-4" />
|
||||
Respaldar ahora
|
||||
</Button>
|
||||
<Button variant="outline" onClick={handleRestore} isLoading={isRestoring}>
|
||||
<Upload className="mr-2 h-4 w-4" />
|
||||
Restaurar
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* System Logs */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<FileText className="h-5 w-5 text-green-600" />
|
||||
Logs del Sistema
|
||||
</CardTitle>
|
||||
<Button variant="outline" size="sm">
|
||||
<Download className="mr-2 h-4 w-4" />
|
||||
Exportar logs
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
{/* Filters */}
|
||||
<div className="flex flex-wrap items-center gap-4 p-4 bg-gray-50 rounded-lg">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-700 mb-1">
|
||||
<Filter className="inline-block h-3 w-3 mr-1" />
|
||||
Nivel
|
||||
</label>
|
||||
<select
|
||||
value={logLevelFilter}
|
||||
onChange={(e) => setLogLevelFilter(e.target.value)}
|
||||
className="rounded-md border border-gray-300 px-3 py-1.5 text-sm focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500"
|
||||
>
|
||||
<option value="">Todos</option>
|
||||
<option value="info">Info</option>
|
||||
<option value="warning">Warning</option>
|
||||
<option value="error">Error</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-700 mb-1">
|
||||
Fuente
|
||||
</label>
|
||||
<select
|
||||
value={logSourceFilter}
|
||||
onChange={(e) => setLogSourceFilter(e.target.value)}
|
||||
className="rounded-md border border-gray-300 px-3 py-1.5 text-sm focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500"
|
||||
>
|
||||
<option value="">Todas</option>
|
||||
{uniqueSources.map((source) => (
|
||||
<option key={source} value={source}>
|
||||
{source}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{(logLevelFilter || logSourceFilter) && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setLogLevelFilter('');
|
||||
setLogSourceFilter('');
|
||||
}}
|
||||
>
|
||||
Limpiar filtros
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DataTable data={filteredLogs} columns={logColumns} isLoading={false} />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* API Keys */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Key className="h-5 w-5 text-purple-600" />
|
||||
API Keys
|
||||
</CardTitle>
|
||||
<Button onClick={() => setShowNewApiKeyModal(true)}>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Nueva API Key
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-sm text-gray-500 mb-4">
|
||||
Gestiona las API keys para integraciones externas
|
||||
</p>
|
||||
<DataTable data={apiKeys} columns={apiKeyColumns} isLoading={false} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Active Modules */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Package className="h-5 w-5 text-orange-600" />
|
||||
Modulos Activos
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
{modules.map((module) => (
|
||||
<div
|
||||
key={module.id}
|
||||
className={`rounded-lg border p-4 transition-colors ${
|
||||
module.isActive ? 'border-green-200 bg-green-50' : 'border-gray-200 bg-gray-50'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<div className="font-medium text-gray-900 flex items-center gap-2">
|
||||
{module.name}
|
||||
{module.isCore && (
|
||||
<span className="inline-flex items-center rounded-full bg-blue-100 px-2 py-0.5 text-xs font-medium text-blue-700">
|
||||
Core
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-xs text-gray-500 mt-1">v{module.version}</div>
|
||||
<div className="text-sm text-gray-600 mt-2">{module.description}</div>
|
||||
</div>
|
||||
{!module.isCore && (
|
||||
<button
|
||||
onClick={() => handleToggleModule(module.id)}
|
||||
className="text-gray-400 hover:text-gray-600"
|
||||
>
|
||||
{module.isActive ? (
|
||||
<ToggleRight className="h-6 w-6 text-green-600" />
|
||||
) : (
|
||||
<ToggleLeft className="h-6 w-6 text-gray-400" />
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* New API Key Modal */}
|
||||
{showNewApiKeyModal && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black bg-opacity-50">
|
||||
<Card className="w-full max-w-md">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Key className="h-5 w-5" />
|
||||
{generatedApiKey ? 'API Key Generada' : 'Nueva API Key'}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{!generatedApiKey ? (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Nombre de la API Key
|
||||
</label>
|
||||
<Input
|
||||
value={newApiKeyName}
|
||||
onChange={(e) => setNewApiKeyName(e.target.value)}
|
||||
placeholder="Ej: Integracion Shopify"
|
||||
/>
|
||||
<p className="mt-1 text-xs text-gray-500">
|
||||
Un nombre descriptivo para identificar esta API key
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<div className="flex items-start gap-3 p-4 bg-yellow-50 rounded-lg mb-4">
|
||||
<AlertTriangle className="h-5 w-5 text-yellow-600 flex-shrink-0 mt-0.5" />
|
||||
<div className="text-sm text-yellow-800">
|
||||
<p className="font-medium">Guarda esta API key de forma segura</p>
|
||||
<p className="mt-1">
|
||||
Esta es la unica vez que veras esta API key completa. No podras verla de nuevo.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Tu nueva API Key
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
<code className="flex-1 rounded-md bg-gray-100 px-4 py-2 font-mono text-sm break-all">
|
||||
{generatedApiKey}
|
||||
</code>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleCopyApiKey(generatedApiKey)}
|
||||
>
|
||||
<Copy className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
<CardFooter className="flex justify-end gap-2">
|
||||
<Button variant="outline" onClick={handleCloseNewApiKeyModal}>
|
||||
{generatedApiKey ? 'Cerrar' : 'Cancelar'}
|
||||
</Button>
|
||||
{!generatedApiKey && (
|
||||
<Button onClick={handleCreateApiKey}>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Generar API Key
|
||||
</Button>
|
||||
)}
|
||||
</CardFooter>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Delete API Key Modal */}
|
||||
<ConfirmModal
|
||||
isOpen={!!apiKeyToDelete}
|
||||
onClose={() => setApiKeyToDelete(null)}
|
||||
onConfirm={handleDeleteApiKey}
|
||||
title="Eliminar API Key"
|
||||
message={`¿Eliminar la API key "${apiKeyToDelete?.name}"? Las integraciones que usen esta key dejaran de funcionar.`}
|
||||
variant="danger"
|
||||
confirmText="Eliminar"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default SystemSettingsPage;
|
||||
@ -1,4 +1,5 @@
|
||||
import { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
Users as UsersIcon,
|
||||
Plus,
|
||||
@ -34,6 +35,7 @@ const roleColors: Record<UserRole, string> = {
|
||||
};
|
||||
|
||||
export function UsersSettingsPage() {
|
||||
const navigate = useNavigate();
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [selectedRole, setSelectedRole] = useState<UserRole | ''>('');
|
||||
const [showActiveOnly, setShowActiveOnly] = useState<boolean | undefined>(undefined);
|
||||
@ -157,13 +159,14 @@ export function UsersSettingsPage() {
|
||||
key: 'view',
|
||||
label: 'Ver detalle',
|
||||
icon: <Eye className="h-4 w-4" />,
|
||||
onClick: () => console.log('View', user.id),
|
||||
// TODO: Implement user detail modal - setShowUserDetailModal(true); setSelectedUser(user);
|
||||
onClick: () => navigate(`/settings/users/${user.id}`),
|
||||
},
|
||||
{
|
||||
key: 'edit',
|
||||
label: 'Editar',
|
||||
icon: <Edit2 className="h-4 w-4" />,
|
||||
onClick: () => console.log('Edit', user.id),
|
||||
onClick: () => navigate(`/settings/users/${user.id}/edit`),
|
||||
},
|
||||
{
|
||||
key: 'toggle',
|
||||
|
||||
118
src/shared/components/crm/KanbanCard.tsx
Normal file
118
src/shared/components/crm/KanbanCard.tsx
Normal file
@ -0,0 +1,118 @@
|
||||
import { DragEvent } from 'react';
|
||||
import { Calendar, DollarSign } from 'lucide-react';
|
||||
import { Avatar } from '@components/atoms/Avatar';
|
||||
import { formatNumber } from '@utils/formatters';
|
||||
import { cn } from '@utils/cn';
|
||||
|
||||
export interface KanbanOpportunity {
|
||||
id: string;
|
||||
name: string;
|
||||
expectedRevenue: number;
|
||||
expectedCloseDate?: string;
|
||||
assignedTo?: {
|
||||
id: string;
|
||||
name: string;
|
||||
avatarUrl?: string;
|
||||
};
|
||||
probability: number;
|
||||
partnerName?: string;
|
||||
}
|
||||
|
||||
export interface KanbanCardProps {
|
||||
opportunity: KanbanOpportunity;
|
||||
onDragStart: (e: DragEvent<HTMLDivElement>, opportunity: KanbanOpportunity) => void;
|
||||
onClick?: (opportunity: KanbanOpportunity) => void;
|
||||
}
|
||||
|
||||
const formatCurrency = (value: number): string => {
|
||||
return formatNumber(value, 'es-MX', { minimumFractionDigits: 0, maximumFractionDigits: 0 });
|
||||
};
|
||||
|
||||
const formatDate = (dateStr: string): string => {
|
||||
const date = new Date(dateStr);
|
||||
return date.toLocaleDateString('es-MX', { day: '2-digit', month: 'short' });
|
||||
};
|
||||
|
||||
export function KanbanCard({ opportunity, onDragStart, onClick }: KanbanCardProps) {
|
||||
const handleDragStart = (e: DragEvent<HTMLDivElement>) => {
|
||||
e.dataTransfer.effectAllowed = 'move';
|
||||
e.dataTransfer.setData('application/json', JSON.stringify(opportunity));
|
||||
onDragStart(e, opportunity);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
draggable
|
||||
onDragStart={handleDragStart}
|
||||
onClick={() => onClick?.(opportunity)}
|
||||
className={cn(
|
||||
'cursor-grab rounded-lg border bg-white p-3 shadow-sm transition-all',
|
||||
'hover:shadow-md hover:border-gray-300',
|
||||
'active:cursor-grabbing active:shadow-lg'
|
||||
)}
|
||||
>
|
||||
{/* Opportunity Name */}
|
||||
<div className="mb-2">
|
||||
<h4 className="font-medium text-gray-900 line-clamp-2">{opportunity.name}</h4>
|
||||
{opportunity.partnerName && (
|
||||
<p className="text-sm text-gray-500 truncate">{opportunity.partnerName}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Expected Revenue */}
|
||||
<div className="mb-2 flex items-center gap-1.5 text-sm">
|
||||
<DollarSign className="h-4 w-4 text-green-600" />
|
||||
<span className="font-semibold text-green-700">
|
||||
${formatCurrency(opportunity.expectedRevenue)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Footer: Date and Avatar */}
|
||||
<div className="flex items-center justify-between pt-2 border-t border-gray-100">
|
||||
{/* Expected Close Date */}
|
||||
{opportunity.expectedCloseDate ? (
|
||||
<div className="flex items-center gap-1.5 text-xs text-gray-500">
|
||||
<Calendar className="h-3.5 w-3.5" />
|
||||
<span>{formatDate(opportunity.expectedCloseDate)}</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-1.5 text-xs text-gray-400">
|
||||
<Calendar className="h-3.5 w-3.5" />
|
||||
<span>Sin fecha</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Assigned User Avatar */}
|
||||
{opportunity.assignedTo ? (
|
||||
<Avatar
|
||||
src={opportunity.assignedTo.avatarUrl}
|
||||
fallback={opportunity.assignedTo.name}
|
||||
size="xs"
|
||||
/>
|
||||
) : (
|
||||
<Avatar fallback="?" size="xs" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Probability indicator */}
|
||||
<div className="mt-2">
|
||||
<div className="flex items-center justify-between text-xs text-gray-500 mb-1">
|
||||
<span>Probabilidad</span>
|
||||
<span>{opportunity.probability}%</span>
|
||||
</div>
|
||||
<div className="h-1.5 w-full rounded-full bg-gray-200">
|
||||
<div
|
||||
className={cn(
|
||||
'h-1.5 rounded-full transition-all',
|
||||
opportunity.probability >= 70 ? 'bg-green-500' :
|
||||
opportunity.probability >= 40 ? 'bg-yellow-500' : 'bg-red-500'
|
||||
)}
|
||||
style={{ width: `${opportunity.probability}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default KanbanCard;
|
||||
154
src/shared/components/crm/KanbanColumn.tsx
Normal file
154
src/shared/components/crm/KanbanColumn.tsx
Normal file
@ -0,0 +1,154 @@
|
||||
import { DragEvent, useState } from 'react';
|
||||
import { DollarSign } from 'lucide-react';
|
||||
import { KanbanCard, type KanbanOpportunity } from './KanbanCard';
|
||||
import { formatNumber } from '@utils/formatters';
|
||||
import { cn } from '@utils/cn';
|
||||
|
||||
export type StageKey = 'new' | 'qualified' | 'proposition' | 'won' | 'lost';
|
||||
|
||||
export interface KanbanColumnProps {
|
||||
title: string;
|
||||
stage: StageKey;
|
||||
items: KanbanOpportunity[];
|
||||
onDrop: (opportunity: KanbanOpportunity, targetStage: StageKey) => void;
|
||||
onCardClick?: (opportunity: KanbanOpportunity) => void;
|
||||
}
|
||||
|
||||
const stageColors: Record<StageKey, { bg: string; border: string; header: string; text: string }> = {
|
||||
new: {
|
||||
bg: 'bg-blue-50',
|
||||
border: 'border-blue-200',
|
||||
header: 'bg-blue-100',
|
||||
text: 'text-blue-700',
|
||||
},
|
||||
qualified: {
|
||||
bg: 'bg-indigo-50',
|
||||
border: 'border-indigo-200',
|
||||
header: 'bg-indigo-100',
|
||||
text: 'text-indigo-700',
|
||||
},
|
||||
proposition: {
|
||||
bg: 'bg-amber-50',
|
||||
border: 'border-amber-200',
|
||||
header: 'bg-amber-100',
|
||||
text: 'text-amber-700',
|
||||
},
|
||||
won: {
|
||||
bg: 'bg-green-50',
|
||||
border: 'border-green-200',
|
||||
header: 'bg-green-100',
|
||||
text: 'text-green-700',
|
||||
},
|
||||
lost: {
|
||||
bg: 'bg-red-50',
|
||||
border: 'border-red-200',
|
||||
header: 'bg-red-100',
|
||||
text: 'text-red-700',
|
||||
},
|
||||
};
|
||||
|
||||
const formatCurrency = (value: number): string => {
|
||||
return formatNumber(value, 'es-MX', { minimumFractionDigits: 0, maximumFractionDigits: 0 });
|
||||
};
|
||||
|
||||
export function KanbanColumn({ title, stage, items, onDrop, onCardClick }: KanbanColumnProps) {
|
||||
const [isDragOver, setIsDragOver] = useState(false);
|
||||
|
||||
const colors = stageColors[stage];
|
||||
|
||||
// Calculate totals
|
||||
const totalCount = items.length;
|
||||
const totalValue = items.reduce((sum, item) => sum + (item.expectedRevenue || 0), 0);
|
||||
|
||||
// Pass-through for drag start - actual data transfer happens in KanbanCard
|
||||
const handleDragStart = (_e: DragEvent<HTMLDivElement>, _opportunity: KanbanOpportunity) => {
|
||||
// Data transfer is handled by KanbanCard
|
||||
};
|
||||
|
||||
const handleDragOver = (e: DragEvent<HTMLDivElement>) => {
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = 'move';
|
||||
setIsDragOver(true);
|
||||
};
|
||||
|
||||
const handleDragLeave = (e: DragEvent<HTMLDivElement>) => {
|
||||
e.preventDefault();
|
||||
setIsDragOver(false);
|
||||
};
|
||||
|
||||
const handleDrop = (e: DragEvent<HTMLDivElement>) => {
|
||||
e.preventDefault();
|
||||
setIsDragOver(false);
|
||||
|
||||
try {
|
||||
const data = e.dataTransfer.getData('application/json');
|
||||
const opportunity: KanbanOpportunity = JSON.parse(data);
|
||||
onDrop(opportunity, stage);
|
||||
} catch (err) {
|
||||
console.error('Error parsing dropped data:', err);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex flex-col rounded-lg border-2 transition-all min-w-[280px] max-w-[320px] w-full',
|
||||
colors.bg,
|
||||
colors.border,
|
||||
isDragOver && 'border-primary-400 ring-2 ring-primary-200'
|
||||
)}
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={handleDrop}
|
||||
>
|
||||
{/* Column Header */}
|
||||
<div className={cn('rounded-t-md p-3', colors.header)}>
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className={cn('font-semibold', colors.text)}>{title}</h3>
|
||||
<span className={cn(
|
||||
'rounded-full px-2 py-0.5 text-xs font-medium',
|
||||
colors.bg,
|
||||
colors.text
|
||||
)}>
|
||||
{totalCount}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-1 flex items-center gap-1 text-sm">
|
||||
<DollarSign className={cn('h-3.5 w-3.5', colors.text)} />
|
||||
<span className={cn('font-medium', colors.text)}>
|
||||
${formatCurrency(totalValue)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Cards Container */}
|
||||
<div
|
||||
className={cn(
|
||||
'flex-1 space-y-2 overflow-y-auto p-2',
|
||||
'min-h-[200px] max-h-[calc(100vh-320px)]'
|
||||
)}
|
||||
>
|
||||
{items.length === 0 ? (
|
||||
<div className={cn(
|
||||
'flex h-20 items-center justify-center rounded-lg border-2 border-dashed',
|
||||
colors.border,
|
||||
'text-sm text-gray-400'
|
||||
)}>
|
||||
{isDragOver ? 'Soltar aqui' : 'Sin oportunidades'}
|
||||
</div>
|
||||
) : (
|
||||
items.map((opportunity) => (
|
||||
<KanbanCard
|
||||
key={opportunity.id}
|
||||
opportunity={opportunity}
|
||||
onDragStart={handleDragStart}
|
||||
onClick={onCardClick}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default KanbanColumn;
|
||||
3
src/shared/components/crm/index.ts
Normal file
3
src/shared/components/crm/index.ts
Normal file
@ -0,0 +1,3 @@
|
||||
// CRM Components
|
||||
export { KanbanCard, type KanbanOpportunity, type KanbanCardProps } from './KanbanCard';
|
||||
export { KanbanColumn, type StageKey, type KanbanColumnProps } from './KanbanColumn';
|
||||
Loading…
Reference in New Issue
Block a user