feat(theme): Implement Dark Mode and Toast notifications

- Add dark:* classes to 10 common components
- Create ThemeProvider with Zustand persistence
- Create ThemeToggle component (simple and full modes)
- Implement Toast notification system (toastStore, useToast, ToastContainer)
- Support success, error, warning, info toast types
- Integrate ToastContainer in App.tsx

Components updated:
- Modal, EmptyState, StatusBadge, SearchInput
- ConfirmDialog, PageHeader, FormField, ActionButtons
- DataTable, LoadingSpinner

Closes: G-005, G-008

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Adrian Flores Cortes 2026-02-04 01:14:11 -06:00
parent a746b0b4df
commit 380b96e159
21 changed files with 756 additions and 119 deletions

View File

@ -5,6 +5,9 @@
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
import { AdminLayout } from './layouts/AdminLayout';
import { ProtectedRoute } from './components/auth/ProtectedRoute';
import { ThemeProvider } from './components/theme';
import { ToastContainer } from './components/common';
import {
FraccionamientosPage,
FraccionamientoDetailPage,
@ -18,18 +21,32 @@ import { ConceptosPage, PresupuestosPage, PresupuestoDetailPage, EstimacionesPag
import { OpportunitiesPage, TendersPage, ProposalsPage, VendorsPage } from './pages/admin/bidding';
import { IncidentesPage, CapacitacionesPage, InspeccionesPage, InspeccionDetailPage } from './pages/admin/hse';
import { AvancesObraPage, BitacoraObraPage, ProgramaObraPage, ControlAvancePage } from './pages/admin/obras';
import {
CuentasContablesPage,
CuentasPorCobrarPage,
CuentasPorPagarPage,
PolizasPage,
FlujoEfectivoPage,
FacturasPage,
} from './pages/admin/finanzas';
import { LoginPage } from './pages/auth';
function App() {
return (
<BrowserRouter>
<div className="app">
<Routes>
<ThemeProvider>
<BrowserRouter
future={{
v7_startTransition: true,
v7_relativeSplatPath: true,
}}
>
<div className="app min-h-screen bg-background text-foreground transition-colors duration-200">
<Routes>
{/* Ruta principal - redirect to admin dashboard */}
<Route path="/" element={<Navigate to="/admin/dashboard" replace />} />
{/* Portal Admin */}
<Route path="/admin" element={<AdminLayout />}>
{/* Portal Admin - Protected */}
<Route path="/admin" element={<ProtectedRoute><AdminLayout /></ProtectedRoute>}>
<Route index element={<Navigate to="dashboard" replace />} />
{/* Dashboard */}
@ -81,6 +98,17 @@ function App() {
<Route path="programa" element={<ProgramaObraPage />} />
<Route path="bitacora" element={<BitacoraObraPage />} />
</Route>
{/* Finanzas */}
<Route path="finanzas">
<Route index element={<Navigate to="flujo-efectivo" replace />} />
<Route path="cuentas" element={<CuentasContablesPage />} />
<Route path="polizas" element={<PolizasPage />} />
<Route path="cxc" element={<CuentasPorCobrarPage />} />
<Route path="cxp" element={<CuentasPorPagarPage />} />
<Route path="flujo-efectivo" element={<FlujoEfectivoPage />} />
<Route path="facturas" element={<FacturasPage />} />
</Route>
</Route>
{/* Portal Supervisor */}
@ -95,8 +123,10 @@ function App() {
{/* 404 */}
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
</div>
</BrowserRouter>
<ToastContainer />
</div>
</BrowserRouter>
</ThemeProvider>
);
}

View File

@ -15,10 +15,10 @@ interface ActionButtonProps {
}
const variantClasses = {
default: 'text-gray-500 hover:text-blue-600 hover:bg-blue-50',
danger: 'text-gray-500 hover:text-red-600 hover:bg-red-50',
success: 'text-gray-500 hover:text-green-600 hover:bg-green-50',
warning: 'text-gray-500 hover:text-yellow-600 hover:bg-yellow-50',
default: 'text-foreground-muted hover:text-primary hover:bg-primary/10 dark:hover:bg-primary/20',
danger: 'text-foreground-muted hover:text-red-600 dark:hover:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20',
success: 'text-foreground-muted hover:text-green-600 dark:hover:text-green-400 hover:bg-green-50 dark:hover:bg-green-900/20',
warning: 'text-foreground-muted hover:text-yellow-600 dark:hover:text-yellow-400 hover:bg-yellow-50 dark:hover:bg-yellow-900/20',
};
export function ActionButton({
@ -127,14 +127,14 @@ export function ActionMenu({ items, className }: ActionMenuProps) {
<div className={clsx('relative', className)} ref={menuRef}>
<button
type="button"
className="p-2 text-gray-500 hover:text-gray-700 hover:bg-gray-100 rounded-lg"
className="p-2 text-foreground-muted hover:text-foreground hover:bg-background-muted dark:hover:bg-background-emphasis rounded-lg transition-colors"
onClick={() => setIsOpen(!isOpen)}
>
<MoreVertical className="w-4 h-4" />
</button>
{isOpen && (
<div className="absolute right-0 mt-1 w-48 bg-white rounded-lg shadow-lg border py-1 z-10">
<div className="absolute right-0 mt-1 w-48 bg-surface-popover dark:bg-surface-popover rounded-lg shadow-lg border border-border dark:border-border py-1 z-dropdown">
{items.map((item, index) => {
const Icon = item.icon;
return (
@ -142,10 +142,10 @@ export function ActionMenu({ items, className }: ActionMenuProps) {
key={index}
type="button"
className={clsx(
'w-full px-4 py-2 text-left text-sm flex items-center gap-2',
'w-full px-4 py-2 text-left text-sm flex items-center gap-2 transition-colors',
item.variant === 'danger'
? 'text-red-600 hover:bg-red-50'
: 'text-gray-700 hover:bg-gray-50',
? 'text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20'
: 'text-foreground hover:bg-background-muted dark:hover:bg-background-emphasis',
item.disabled && 'opacity-50 cursor-not-allowed'
)}
onClick={() => {

View File

@ -21,21 +21,21 @@ interface ConfirmDialogProps {
const variantConfig = {
danger: {
icon: AlertTriangle,
iconBg: 'bg-red-100',
iconColor: 'text-red-600',
buttonClass: 'bg-red-600 hover:bg-red-700',
iconBg: 'bg-red-100 dark:bg-red-900/30',
iconColor: 'text-red-600 dark:text-red-400',
buttonClass: 'bg-red-600 hover:bg-red-700 dark:bg-red-700 dark:hover:bg-red-600',
},
warning: {
icon: AlertCircle,
iconBg: 'bg-yellow-100',
iconColor: 'text-yellow-600',
buttonClass: 'bg-yellow-600 hover:bg-yellow-700',
iconBg: 'bg-yellow-100 dark:bg-yellow-900/30',
iconColor: 'text-yellow-600 dark:text-yellow-400',
buttonClass: 'bg-yellow-600 hover:bg-yellow-700 dark:bg-yellow-700 dark:hover:bg-yellow-600',
},
info: {
icon: Info,
iconBg: 'bg-blue-100',
iconColor: 'text-blue-600',
buttonClass: 'bg-blue-600 hover:bg-blue-700',
iconBg: 'bg-blue-100 dark:bg-blue-900/30',
iconColor: 'text-blue-600 dark:text-blue-400',
buttonClass: 'bg-primary hover:bg-primary-hover',
},
};
@ -75,12 +75,12 @@ export function ConfirmDialog({
>
<Icon className={clsx('w-6 h-6', config.iconColor)} />
</div>
<h3 className="text-lg font-semibold text-gray-900 mb-2">{title}</h3>
<p className="text-gray-600 mb-6">{message}</p>
<h3 className="text-lg font-semibold text-foreground mb-2">{title}</h3>
<p className="text-foreground-muted mb-6">{message}</p>
<ModalFooter className="w-full">
<button
type="button"
className="flex-1 px-4 py-2 text-gray-700 border rounded-lg hover:bg-gray-50 disabled:opacity-50"
className="flex-1 px-4 py-2 text-foreground border border-border dark:border-border rounded-lg hover:bg-background-muted dark:hover:bg-background-emphasis disabled:opacity-50 transition-colors"
onClick={onClose}
disabled={isLoading}
>

View File

@ -91,13 +91,13 @@ export function DataTable<T>({
: 0;
return (
<div className={clsx('bg-white rounded-lg shadow-sm overflow-hidden', className)}>
<div className={clsx('bg-surface-card dark:bg-surface-card rounded-lg shadow-sm overflow-hidden', className)}>
{/* Loading State */}
{isLoading && <LoadingOverlay />}
{/* Error State */}
{!isLoading && error && (
<div className="p-8 text-center text-red-500">{error}</div>
<div className="p-8 text-center text-red-500 dark:text-red-400">{error}</div>
)}
{/* Empty State */}
@ -114,14 +114,14 @@ export function DataTable<T>({
{!isLoading && !error && data.length > 0 && (
<>
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<table className="min-w-full divide-y divide-border dark:divide-border">
<thead className="bg-background-subtle dark:bg-background-emphasis">
<tr>
{columns.map((column) => (
<th
key={column.key}
className={clsx(
'px-6 py-3 text-xs font-medium text-gray-500 uppercase tracking-wider',
'px-6 py-3 text-xs font-medium text-foreground-muted uppercase tracking-wider',
alignClasses[column.align || 'left'],
column.className
)}
@ -132,12 +132,12 @@ export function DataTable<T>({
))}
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
<tbody className="bg-surface-card dark:bg-surface-card divide-y divide-border dark:divide-border">
{data.map((item, index) => (
<tr
key={getRowKey(item, index)}
className={clsx(
'hover:bg-gray-50 transition-colors',
'hover:bg-background-muted dark:hover:bg-background-emphasis transition-colors',
onRowClick && 'cursor-pointer',
getRowClassName(item, index)
)}
@ -147,7 +147,7 @@ export function DataTable<T>({
<td
key={column.key}
className={clsx(
'px-6 py-4 whitespace-nowrap text-sm',
'px-6 py-4 whitespace-nowrap text-sm text-foreground',
alignClasses[column.align || 'left'],
column.className
)}
@ -165,34 +165,34 @@ export function DataTable<T>({
{/* Pagination */}
{pagination && totalPages > 1 && (
<div className="px-6 py-3 border-t bg-gray-50 flex items-center justify-between">
<div className="text-sm text-gray-500">
<div className="px-6 py-3 border-t border-border dark:border-border bg-background-subtle dark:bg-background-emphasis flex items-center justify-between">
<div className="text-sm text-foreground-muted">
Mostrando {startItem} a {endItem} de {pagination.total} resultados
</div>
<div className="flex items-center gap-2">
<button
type="button"
className={clsx(
'p-2 rounded-lg border',
'p-2 rounded-lg border border-border dark:border-border transition-colors',
pagination.page === 1
? 'text-gray-300 cursor-not-allowed'
: 'text-gray-600 hover:bg-gray-100'
? 'text-foreground-disabled cursor-not-allowed'
: 'text-foreground hover:bg-background-muted dark:hover:bg-background-emphasis'
)}
onClick={() => onPageChange?.(pagination.page - 1)}
disabled={pagination.page === 1}
>
<ChevronLeft className="w-5 h-5" />
</button>
<span className="text-sm text-gray-600">
<span className="text-sm text-foreground">
Página {pagination.page} de {totalPages}
</span>
<button
type="button"
className={clsx(
'p-2 rounded-lg border',
'p-2 rounded-lg border border-border dark:border-border transition-colors',
pagination.page === totalPages
? 'text-gray-300 cursor-not-allowed'
: 'text-gray-600 hover:bg-gray-100'
? 'text-foreground-disabled cursor-not-allowed'
: 'text-foreground hover:bg-background-muted dark:hover:bg-background-emphasis'
)}
onClick={() => onPageChange?.(pagination.page + 1)}
disabled={pagination.page === totalPages}

View File

@ -20,11 +20,11 @@ export function EmptyState({
}: EmptyStateProps) {
return (
<div className="flex flex-col items-center justify-center p-8 text-center">
<div className="w-16 h-16 flex items-center justify-center rounded-full bg-gray-100 mb-4">
{icon ?? <FileX className="w-8 h-8 text-gray-400" />}
<div className="w-16 h-16 flex items-center justify-center rounded-full bg-background-muted dark:bg-background-emphasis mb-4">
{icon ?? <FileX className="w-8 h-8 text-foreground-muted" />}
</div>
<h3 className="text-lg font-medium text-gray-900 mb-1">{title}</h3>
<p className="text-gray-500 mb-4 max-w-sm">{description}</p>
<h3 className="text-lg font-medium text-foreground mb-1">{title}</h3>
<p className="text-foreground-muted mb-4 max-w-sm">{description}</p>
{action}
</div>
);

View File

@ -25,27 +25,27 @@ export const TextInput = forwardRef<HTMLInputElement, TextInputProps>(
{label && (
<label
htmlFor={inputId}
className="block text-sm font-medium text-gray-700 mb-1"
className="block text-sm font-medium text-foreground mb-1"
>
{label}
{required && <span className="text-red-500 ml-1">*</span>}
{required && <span className="text-red-500 dark:text-red-400 ml-1">*</span>}
</label>
)}
<input
ref={ref}
id={inputId}
className={clsx(
'w-full px-3 py-2 border rounded-lg transition-colors',
'focus:ring-2 focus:ring-blue-500 focus:border-blue-500',
error ? 'border-red-500' : 'border-gray-300',
'w-full px-3 py-2 border rounded-lg transition-colors bg-surface-card dark:bg-surface-card text-foreground placeholder:text-foreground-muted',
'focus:ring-2 focus:ring-primary focus:border-primary',
error ? 'border-red-500 dark:border-red-400' : 'border-border dark:border-border',
className
)}
{...props}
/>
{hint && !error && (
<p className="mt-1 text-sm text-gray-500">{hint}</p>
<p className="mt-1 text-sm text-foreground-muted">{hint}</p>
)}
{error && <p className="mt-1 text-sm text-red-600">{error}</p>}
{error && <p className="mt-1 text-sm text-red-600 dark:text-red-400">{error}</p>}
</div>
);
}
@ -68,19 +68,19 @@ export const SelectField = forwardRef<HTMLSelectElement, SelectFieldProps>(
{label && (
<label
htmlFor={selectId}
className="block text-sm font-medium text-gray-700 mb-1"
className="block text-sm font-medium text-foreground mb-1"
>
{label}
{required && <span className="text-red-500 ml-1">*</span>}
{required && <span className="text-red-500 dark:text-red-400 ml-1">*</span>}
</label>
)}
<select
ref={ref}
id={selectId}
className={clsx(
'w-full px-3 py-2 border rounded-lg transition-colors',
'focus:ring-2 focus:ring-blue-500 focus:border-blue-500',
error ? 'border-red-500' : 'border-gray-300',
'w-full px-3 py-2 border rounded-lg transition-colors bg-surface-card dark:bg-surface-card text-foreground',
'focus:ring-2 focus:ring-primary focus:border-primary',
error ? 'border-red-500 dark:border-red-400' : 'border-border dark:border-border',
className
)}
{...props}
@ -101,9 +101,9 @@ export const SelectField = forwardRef<HTMLSelectElement, SelectFieldProps>(
))}
</select>
{hint && !error && (
<p className="mt-1 text-sm text-gray-500">{hint}</p>
<p className="mt-1 text-sm text-foreground-muted">{hint}</p>
)}
{error && <p className="mt-1 text-sm text-red-600">{error}</p>}
{error && <p className="mt-1 text-sm text-red-600 dark:text-red-400">{error}</p>}
</div>
);
}
@ -123,10 +123,10 @@ export const TextareaField = forwardRef<HTMLTextAreaElement, TextareaFieldProps>
{label && (
<label
htmlFor={textareaId}
className="block text-sm font-medium text-gray-700 mb-1"
className="block text-sm font-medium text-foreground mb-1"
>
{label}
{required && <span className="text-red-500 ml-1">*</span>}
{required && <span className="text-red-500 dark:text-red-400 ml-1">*</span>}
</label>
)}
<textarea
@ -134,17 +134,17 @@ export const TextareaField = forwardRef<HTMLTextAreaElement, TextareaFieldProps>
id={textareaId}
rows={rows}
className={clsx(
'w-full px-3 py-2 border rounded-lg transition-colors',
'focus:ring-2 focus:ring-blue-500 focus:border-blue-500',
error ? 'border-red-500' : 'border-gray-300',
'w-full px-3 py-2 border rounded-lg transition-colors bg-surface-card dark:bg-surface-card text-foreground placeholder:text-foreground-muted',
'focus:ring-2 focus:ring-primary focus:border-primary',
error ? 'border-red-500 dark:border-red-400' : 'border-border dark:border-border',
className
)}
{...props}
/>
{hint && !error && (
<p className="mt-1 text-sm text-gray-500">{hint}</p>
<p className="mt-1 text-sm text-foreground-muted">{hint}</p>
)}
{error && <p className="mt-1 text-sm text-red-600">{error}</p>}
{error && <p className="mt-1 text-sm text-red-600 dark:text-red-400">{error}</p>}
</div>
);
}
@ -169,8 +169,8 @@ export const CheckboxField = forwardRef<HTMLInputElement, CheckboxFieldProps>(
id={checkboxId}
type="checkbox"
className={clsx(
'h-4 w-4 rounded border-gray-300 text-blue-600',
'focus:ring-2 focus:ring-blue-500',
'h-4 w-4 rounded border-border dark:border-border text-primary',
'focus:ring-2 focus:ring-primary',
className
)}
{...props}
@ -181,17 +181,17 @@ export const CheckboxField = forwardRef<HTMLInputElement, CheckboxFieldProps>(
{label && (
<label
htmlFor={checkboxId}
className="text-sm font-medium text-gray-700"
className="text-sm font-medium text-foreground"
>
{label}
</label>
)}
{description && (
<p className="text-sm text-gray-500">{description}</p>
<p className="text-sm text-foreground-muted">{description}</p>
)}
</div>
)}
{error && <p className="mt-1 text-sm text-red-600">{error}</p>}
{error && <p className="mt-1 text-sm text-red-600 dark:text-red-400">{error}</p>}
</div>
);
}

View File

@ -19,7 +19,7 @@ export function LoadingSpinner({ size = 'md', className }: LoadingSpinnerProps)
return (
<div
className={clsx(
'animate-spin rounded-full border-2 border-gray-300 border-t-blue-600',
'animate-spin rounded-full border-2 border-border dark:border-border-emphasis border-t-primary',
sizeClasses[size],
className
)}
@ -39,7 +39,7 @@ export function LoadingOverlay({ message = 'Cargando...' }: LoadingOverlayProps)
return (
<div className="flex flex-col items-center justify-center p-8">
<LoadingSpinner size="lg" />
<p className="mt-4 text-gray-500">{message}</p>
<p className="mt-4 text-foreground-muted">{message}</p>
</div>
);
}

View File

@ -64,7 +64,7 @@ export function Modal({
<div className="fixed inset-0 z-50 overflow-y-auto">
{/* Overlay */}
<div
className="fixed inset-0 bg-black bg-opacity-50 transition-opacity"
className="fixed inset-0 bg-foreground/50 dark:bg-black/70 transition-opacity"
onClick={closeOnOverlayClick ? onClose : undefined}
aria-hidden="true"
/>
@ -73,21 +73,21 @@ export function Modal({
<div className="flex min-h-full items-center justify-center p-4">
<div
className={clsx(
'relative w-full bg-white rounded-lg shadow-xl',
'relative w-full bg-surface-card dark:bg-surface-card rounded-lg shadow-xl',
sizeClasses[size]
)}
onClick={(e) => e.stopPropagation()}
>
{/* Header */}
{(title || showCloseButton) && (
<div className="flex items-center justify-between px-6 py-4 border-b">
<div className="flex items-center justify-between px-6 py-4 border-b border-border dark:border-border">
{title && (
<h3 className="text-lg font-semibold text-gray-900">{title}</h3>
<h3 className="text-lg font-semibold text-foreground">{title}</h3>
)}
{showCloseButton && (
<button
type="button"
className="p-2 -mr-2 text-gray-400 hover:text-gray-500 hover:bg-gray-100 rounded-lg"
className="p-2 -mr-2 text-foreground-muted hover:text-foreground hover:bg-background-muted dark:hover:bg-background-emphasis rounded-lg transition-colors"
onClick={onClose}
>
<X className="w-5 h-5" />
@ -97,11 +97,11 @@ export function Modal({
)}
{/* Body */}
<div className="px-6 py-4 max-h-[70vh] overflow-y-auto">{children}</div>
<div className="px-6 py-4 max-h-[70vh] overflow-y-auto text-foreground">{children}</div>
{/* Footer */}
{footer && (
<div className="px-6 py-4 border-t bg-gray-50 rounded-b-lg">
<div className="px-6 py-4 border-t border-border dark:border-border bg-background-subtle dark:bg-background-emphasis rounded-b-lg">
{footer}
</div>
)}

View File

@ -25,8 +25,8 @@ export function PageHeader({
{breadcrumbs && <div className="mb-2">{breadcrumbs}</div>}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-gray-900">{title}</h1>
{description && <p className="text-gray-600 mt-1">{description}</p>}
<h1 className="text-2xl font-bold text-foreground">{title}</h1>
{description && <p className="text-foreground-muted mt-1">{description}</p>}
</div>
{actions && <div className="flex items-center gap-3">{actions}</div>}
</div>
@ -52,8 +52,8 @@ export function PageHeaderAction({
type = 'button',
}: PageHeaderActionProps) {
const variantClasses = {
primary: 'bg-blue-600 text-white hover:bg-blue-700',
secondary: 'bg-white text-gray-700 border hover:bg-gray-50',
primary: 'bg-primary text-white hover:bg-primary-hover',
secondary: 'bg-surface-card dark:bg-surface-card text-foreground border border-border dark:border-border hover:bg-background-muted dark:hover:bg-background-emphasis',
};
return (

View File

@ -47,11 +47,11 @@ export function SearchInput({
return (
<div className={clsx('relative', className)}>
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-400" />
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-foreground-muted" />
<input
type="text"
placeholder={placeholder}
className="w-full pl-10 pr-10 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
className="w-full pl-10 pr-10 py-2 border border-border dark:border-border rounded-lg bg-surface-card dark:bg-surface-card text-foreground placeholder:text-foreground-muted focus:ring-2 focus:ring-primary focus:border-primary transition-colors"
value={localValue}
onChange={(e) => setLocalValue(e.target.value)}
autoFocus={autoFocus}
@ -60,7 +60,7 @@ export function SearchInput({
<button
type="button"
onClick={handleClear}
className="absolute right-3 top-1/2 -translate-y-1/2 p-1 text-gray-400 hover:text-gray-600"
className="absolute right-3 top-1/2 -translate-y-1/2 p-1 text-foreground-muted hover:text-foreground transition-colors"
>
<X className="w-4 h-4" />
</button>

View File

@ -14,15 +14,15 @@ interface StatusBadgeProps {
}
const colorClasses: Record<StatusColor, { bg: string; text: string; dot: string }> = {
gray: { bg: 'bg-gray-100', text: 'text-gray-800', dot: 'bg-gray-500' },
green: { bg: 'bg-green-100', text: 'text-green-800', dot: 'bg-green-500' },
yellow: { bg: 'bg-yellow-100', text: 'text-yellow-800', dot: 'bg-yellow-500' },
red: { bg: 'bg-red-100', text: 'text-red-800', dot: 'bg-red-500' },
blue: { bg: 'bg-blue-100', text: 'text-blue-800', dot: 'bg-blue-500' },
purple: { bg: 'bg-purple-100', text: 'text-purple-800', dot: 'bg-purple-500' },
orange: { bg: 'bg-orange-100', text: 'text-orange-800', dot: 'bg-orange-500' },
pink: { bg: 'bg-pink-100', text: 'text-pink-800', dot: 'bg-pink-500' },
indigo: { bg: 'bg-indigo-100', text: 'text-indigo-800', dot: 'bg-indigo-500' },
gray: { bg: 'bg-gray-100 dark:bg-gray-800', text: 'text-gray-800 dark:text-gray-200', dot: 'bg-gray-500' },
green: { bg: 'bg-green-100 dark:bg-green-900/30', text: 'text-green-800 dark:text-green-300', dot: 'bg-green-500' },
yellow: { bg: 'bg-yellow-100 dark:bg-yellow-900/30', text: 'text-yellow-800 dark:text-yellow-300', dot: 'bg-yellow-500' },
red: { bg: 'bg-red-100 dark:bg-red-900/30', text: 'text-red-800 dark:text-red-300', dot: 'bg-red-500' },
blue: { bg: 'bg-blue-100 dark:bg-blue-900/30', text: 'text-blue-800 dark:text-blue-300', dot: 'bg-blue-500' },
purple: { bg: 'bg-purple-100 dark:bg-purple-900/30', text: 'text-purple-800 dark:text-purple-300', dot: 'bg-purple-500' },
orange: { bg: 'bg-orange-100 dark:bg-orange-900/30', text: 'text-orange-800 dark:text-orange-300', dot: 'bg-orange-500' },
pink: { bg: 'bg-pink-100 dark:bg-pink-900/30', text: 'text-pink-800 dark:text-pink-300', dot: 'bg-pink-500' },
indigo: { bg: 'bg-indigo-100 dark:bg-indigo-900/30', text: 'text-indigo-800 dark:text-indigo-300', dot: 'bg-indigo-500' },
};
const sizeClasses = {

View File

@ -0,0 +1,116 @@
/**
* Toast - Global notification component
*/
import { useEffect, useCallback } from 'react';
import { X, CheckCircle, AlertTriangle, AlertCircle, Info } from 'lucide-react';
import clsx from 'clsx';
import { useToastStore, type ToastType, type Toast as ToastItem } from '../../stores/toastStore';
const toastConfig: Record<ToastType, {
icon: typeof CheckCircle;
bgClass: string;
iconClass: string;
borderClass: string;
}> = {
success: {
icon: CheckCircle,
bgClass: 'bg-green-50 dark:bg-green-900/20',
iconClass: 'text-green-500 dark:text-green-400',
borderClass: 'border-green-200 dark:border-green-800',
},
error: {
icon: AlertCircle,
bgClass: 'bg-red-50 dark:bg-red-900/20',
iconClass: 'text-red-500 dark:text-red-400',
borderClass: 'border-red-200 dark:border-red-800',
},
warning: {
icon: AlertTriangle,
bgClass: 'bg-yellow-50 dark:bg-yellow-900/20',
iconClass: 'text-yellow-500 dark:text-yellow-400',
borderClass: 'border-yellow-200 dark:border-yellow-800',
},
info: {
icon: Info,
bgClass: 'bg-blue-50 dark:bg-blue-900/20',
iconClass: 'text-blue-500 dark:text-blue-400',
borderClass: 'border-blue-200 dark:border-blue-800',
},
};
interface ToastItemProps {
toast: ToastItem;
onDismiss: (id: string) => void;
}
function ToastItemComponent({ toast, onDismiss }: ToastItemProps) {
const config = toastConfig[toast.type];
const Icon = config.icon;
useEffect(() => {
if (toast.duration && toast.duration > 0) {
const timer = setTimeout(() => {
onDismiss(toast.id);
}, toast.duration);
return () => clearTimeout(timer);
}
}, [toast.id, toast.duration, onDismiss]);
return (
<div
className={clsx(
'flex items-start gap-3 p-4 rounded-lg border shadow-lg max-w-sm w-full',
'transform transition-all duration-300 ease-out',
config.bgClass,
config.borderClass
)}
role="alert"
>
<Icon className={clsx('w-5 h-5 flex-shrink-0 mt-0.5', config.iconClass)} />
<div className="flex-1 min-w-0">
{toast.title && (
<p className="text-sm font-semibold text-foreground">{toast.title}</p>
)}
<p className="text-sm text-foreground-muted">{toast.message}</p>
</div>
<button
type="button"
onClick={() => onDismiss(toast.id)}
className="flex-shrink-0 p-1 text-foreground-muted hover:text-foreground rounded transition-colors"
aria-label="Cerrar notificación"
>
<X className="w-4 h-4" />
</button>
</div>
);
}
export function ToastContainer() {
const { toasts, removeToast } = useToastStore();
const handleDismiss = useCallback(
(id: string) => removeToast(id),
[removeToast]
);
if (toasts.length === 0) return null;
return (
<div
className="fixed bottom-4 right-4 z-toast flex flex-col gap-2"
aria-live="polite"
aria-label="Notificaciones"
>
{toasts.map((toast) => (
<ToastItemComponent
key={toast.id}
toast={toast}
onDismiss={handleDismiss}
/>
))}
</div>
);
}
export { ToastItemComponent as ToastItem };

View File

@ -34,3 +34,6 @@ export { ActionButton, ActionButtons, ActionMenu } from './ActionButtons';
// Data Display
export { DataTable } from './DataTable';
export type { DataTableColumn, DataTablePagination } from './DataTable';
// Toast Notifications
export { ToastContainer, ToastItem } from './Toast';

View File

@ -0,0 +1,69 @@
/**
* ThemeProvider Component
* Inicializa el tema y proporciona el contexto
*/
import { useEffect, type ReactNode } from 'react';
import { useThemeStore, type Theme } from '../../stores/themeStore';
interface ThemeProviderProps {
children: ReactNode;
defaultTheme?: Theme;
}
/**
* Hook para acceder al tema actual y funciones de cambio
*/
export function useTheme() {
const theme = useThemeStore((state) => state.theme);
const resolvedTheme = useThemeStore((state) => state.resolvedTheme);
const setTheme = useThemeStore((state) => state.setTheme);
const toggleTheme = useThemeStore((state) => state.toggleTheme);
return {
theme,
resolvedTheme,
setTheme,
toggleTheme,
isDark: resolvedTheme === 'dark',
isLight: resolvedTheme === 'light',
isSystem: theme === 'system',
};
}
/**
* ThemeProvider - Envuelve la aplicación para gestionar el tema
*/
export function ThemeProvider({ children, defaultTheme = 'system' }: ThemeProviderProps) {
const { theme, setTheme } = useTheme();
// Inicializar tema por defecto si no hay uno guardado
useEffect(() => {
// Solo establecer el tema por defecto en la primera carga
// si no hay tema guardado en localStorage
const stored = localStorage.getItem('erp-construccion-theme');
if (!stored && theme === 'system') {
setTheme(defaultTheme);
}
}, []);
// Aplicar tema inicial
useEffect(() => {
const resolvedTheme = theme === 'system'
? (window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light')
: theme;
const root = document.documentElement;
if (resolvedTheme === 'dark') {
root.classList.add('dark');
root.setAttribute('data-theme', 'dark');
} else {
root.classList.remove('dark');
root.setAttribute('data-theme', 'light');
}
}, [theme]);
return <>{children}</>;
}
export default ThemeProvider;

View File

@ -0,0 +1,170 @@
/**
* ThemeToggle Component
* Botón para cambiar entre temas light/dark/system
*/
import { Sun, Moon, Monitor } from 'lucide-react';
import { useTheme } from './ThemeProvider';
import type { Theme } from '../../stores/themeStore';
interface ThemeToggleProps {
/** Mostrar solo toggle light/dark (sin opción system) */
simple?: boolean;
/** Tamaño del icono */
size?: 'sm' | 'md' | 'lg';
/** Clase adicional */
className?: string;
}
const iconSizes = {
sm: 16,
md: 20,
lg: 24,
};
/**
* ThemeToggle simple (solo light/dark)
*/
export function ThemeToggle({ simple = false, size = 'md', className = '' }: ThemeToggleProps) {
const { theme, resolvedTheme, setTheme, toggleTheme } = useTheme();
const iconSize = iconSizes[size];
if (simple) {
return (
<button
type="button"
onClick={toggleTheme}
className={`
inline-flex items-center justify-center
p-2 rounded-md
text-foreground-muted hover:text-foreground
hover:bg-background-muted
focus:outline-none focus:ring-2 focus:ring-primary focus:ring-offset-2
transition-colors duration-150
${className}
`}
aria-label={resolvedTheme === 'dark' ? 'Cambiar a tema claro' : 'Cambiar a tema oscuro'}
>
{resolvedTheme === 'dark' ? (
<Sun size={iconSize} aria-hidden="true" />
) : (
<Moon size={iconSize} aria-hidden="true" />
)}
</button>
);
}
const options: { value: Theme; label: string; icon: typeof Sun }[] = [
{ value: 'light', label: 'Claro', icon: Sun },
{ value: 'dark', label: 'Oscuro', icon: Moon },
{ value: 'system', label: 'Sistema', icon: Monitor },
];
return (
<div className={`inline-flex rounded-md shadow-sm ${className}`} role="group">
{options.map(({ value, label, icon: Icon }) => (
<button
key={value}
type="button"
onClick={() => setTheme(value)}
className={`
inline-flex items-center justify-center gap-1.5
px-3 py-2
text-sm font-medium
border
first:rounded-l-md last:rounded-r-md
focus:outline-none focus:ring-2 focus:ring-primary focus:ring-offset-2
transition-colors duration-150
${
theme === value
? 'bg-primary text-white border-primary'
: 'bg-surface-card text-foreground border-border hover:bg-background-muted'
}
`}
aria-pressed={theme === value}
aria-label={`Tema ${label}`}
>
<Icon size={16} aria-hidden="true" />
<span className="hidden sm:inline">{label}</span>
</button>
))}
</div>
);
}
/**
* ThemeDropdown - Menú desplegable para seleccionar tema
*/
export function ThemeDropdown({ size = 'md', className = '' }: Omit<ThemeToggleProps, 'simple'>) {
const { theme, resolvedTheme, setTheme } = useTheme();
const iconSize = iconSizes[size];
const CurrentIcon = resolvedTheme === 'dark' ? Moon : Sun;
return (
<div className={`relative inline-block ${className}`}>
<button
type="button"
className={`
inline-flex items-center justify-center
p-2 rounded-md
text-foreground-muted hover:text-foreground
hover:bg-background-muted
focus:outline-none focus:ring-2 focus:ring-primary focus:ring-offset-2
transition-colors duration-150
peer
`}
aria-label="Seleccionar tema"
aria-haspopup="true"
>
<CurrentIcon size={iconSize} aria-hidden="true" />
</button>
{/* Dropdown menu (visible on hover/focus) */}
<div
className={`
absolute right-0 top-full mt-1
w-36 py-1
bg-surface-popover border border-border rounded-md shadow-lg
opacity-0 invisible
peer-hover:opacity-100 peer-hover:visible
peer-focus:opacity-100 peer-focus:visible
hover:opacity-100 hover:visible
transition-all duration-150
z-dropdown
`}
role="menu"
>
{[
{ value: 'light' as Theme, label: 'Claro', icon: Sun },
{ value: 'dark' as Theme, label: 'Oscuro', icon: Moon },
{ value: 'system' as Theme, label: 'Sistema', icon: Monitor },
].map(({ value, label, icon: Icon }) => (
<button
key={value}
type="button"
onClick={() => setTheme(value)}
className={`
w-full flex items-center gap-2 px-3 py-2 text-sm
${
theme === value
? 'bg-primary/10 text-primary'
: 'text-foreground hover:bg-background-muted'
}
transition-colors duration-150
`}
role="menuitem"
>
<Icon size={16} aria-hidden="true" />
{label}
{theme === value && (
<span className="ml-auto text-primary"></span>
)}
</button>
))}
</div>
</div>
);
}
export default ThemeToggle;

View File

@ -0,0 +1,8 @@
/**
* Theme Components
* Exportaciones para gestión de tema
*/
export { ThemeProvider, useTheme } from './ThemeProvider';
export { ThemeToggle, ThemeDropdown } from './ThemeToggle';
export type { Theme } from '../../stores/themeStore';

View File

@ -5,3 +5,5 @@ export * from './useReports';
export * from './useBidding';
export * from './useHSE';
export * from './useProgress';
export * from './useFinance';
export * from './useToast';

68
web/src/hooks/useToast.ts Normal file
View File

@ -0,0 +1,68 @@
/**
* useToast Hook - Simplified interface for toast notifications
*/
import { useCallback } from 'react';
import { useToastStore, type ToastType } from '../stores/toastStore';
const DEFAULT_DURATION = 5000;
interface ToastOptions {
title?: string;
duration?: number;
}
export function useToast() {
const { addToast, removeToast, clearToasts } = useToastStore();
const toast = useCallback(
(type: ToastType, message: string, options?: ToastOptions) => {
return addToast({
type,
message,
title: options?.title,
duration: options?.duration ?? DEFAULT_DURATION,
});
},
[addToast]
);
const success = useCallback(
(message: string, options?: ToastOptions) => toast('success', message, options),
[toast]
);
const error = useCallback(
(message: string, options?: ToastOptions) => toast('error', message, options),
[toast]
);
const warning = useCallback(
(message: string, options?: ToastOptions) => toast('warning', message, options),
[toast]
);
const info = useCallback(
(message: string, options?: ToastOptions) => toast('info', message, options),
[toast]
);
const dismiss = useCallback(
(id: string) => removeToast(id),
[removeToast]
);
const dismissAll = useCallback(() => clearToasts(), [clearToasts]);
return {
toast,
success,
error,
warning,
info,
dismiss,
dismissAll,
};
}
export type { ToastOptions };

View File

@ -27,9 +27,14 @@ import {
BookOpen,
Gauge,
CalendarDays,
CreditCard,
Wallet,
PiggyBank,
ArrowLeftRight,
} from 'lucide-react';
import clsx from 'clsx';
import { useAuthStore } from '../stores/authStore';
import { ThemeToggle } from '../components/theme';
interface NavItem {
label: string;
@ -100,6 +105,18 @@ const navSections: NavSection[] = [
{ label: 'Inspecciones', href: '/admin/hse/inspecciones', icon: ClipboardCheck },
],
},
{
title: 'Finanzas',
defaultOpen: false,
items: [
{ label: 'Flujo de Efectivo', href: '/admin/finanzas/flujo-efectivo', icon: ArrowLeftRight },
{ label: 'Cuentas por Cobrar', href: '/admin/finanzas/cxc', icon: Wallet },
{ label: 'Cuentas por Pagar', href: '/admin/finanzas/cxp', icon: CreditCard },
{ label: 'Facturas', href: '/admin/finanzas/facturas', icon: Receipt },
{ label: 'Pólizas', href: '/admin/finanzas/polizas', icon: FileText },
{ label: 'Cuentas Contables', href: '/admin/finanzas/cuentas', icon: PiggyBank },
],
},
];
export function AdminLayout() {
@ -132,11 +149,11 @@ export function AdminLayout() {
};
return (
<div className="min-h-screen bg-gray-100">
<div className="min-h-screen bg-background-subtle dark:bg-background transition-colors duration-200">
{/* Mobile sidebar backdrop */}
{sidebarOpen && (
<div
className="fixed inset-0 bg-gray-600 bg-opacity-75 z-20 lg:hidden"
className="fixed inset-0 bg-foreground/50 dark:bg-black/70 z-20 lg:hidden"
onClick={() => setSidebarOpen(false)}
/>
)}
@ -144,17 +161,17 @@ export function AdminLayout() {
{/* Sidebar */}
<aside
className={clsx(
'fixed inset-y-0 left-0 z-30 w-64 bg-white shadow-lg transform transition-transform duration-300 lg:translate-x-0 lg:static',
'fixed inset-y-0 left-0 z-30 w-64 bg-surface-card dark:bg-surface-card shadow-lg transform transition-all duration-300 lg:translate-x-0 lg:static',
sidebarOpen ? 'translate-x-0' : '-translate-x-full'
)}
>
<div className="flex items-center justify-between h-16 px-4 border-b">
<div className="flex items-center justify-between h-16 px-4 border-b border-border dark:border-border">
<Link to="/admin" className="flex items-center space-x-2">
<Building2 className="w-8 h-8 text-blue-600" />
<span className="text-xl font-bold text-gray-900">ERP Construccion</span>
<Building2 className="w-8 h-8 text-primary" />
<span className="text-xl font-bold text-foreground">ERP Construccion</span>
</Link>
<button
className="lg:hidden p-2 rounded-md text-gray-500 hover:bg-gray-100"
className="lg:hidden p-2 rounded-md text-foreground-muted hover:bg-background-muted dark:hover:bg-background-emphasis"
onClick={() => setSidebarOpen(false)}
>
<X className="w-5 h-5" />
@ -172,7 +189,9 @@ export function AdminLayout() {
onClick={() => toggleSection(section.title)}
className={clsx(
'flex items-center justify-between w-full px-3 py-2 text-xs font-semibold uppercase tracking-wider rounded-lg transition-colors',
isActive ? 'text-blue-700 bg-blue-50' : 'text-gray-500 hover:bg-gray-50'
isActive
? 'text-primary bg-primary/10 dark:bg-primary/20'
: 'text-foreground-muted hover:bg-background-muted dark:hover:bg-background-emphasis'
)}
>
{section.title}
@ -195,8 +214,8 @@ export function AdminLayout() {
className={clsx(
'flex items-center px-3 py-2 rounded-lg transition-colors text-sm',
isItemActive
? 'bg-blue-100 text-blue-700 font-medium'
: 'text-gray-700 hover:bg-gray-100'
? 'bg-primary/10 text-primary dark:bg-primary/20 font-medium'
: 'text-foreground hover:bg-background-muted dark:hover:bg-background-emphasis'
)}
>
<Icon className="w-4 h-4 mr-3" />
@ -216,36 +235,42 @@ export function AdminLayout() {
{/* Main content */}
<div className="lg:pl-64">
{/* Top bar */}
<header className="sticky top-0 z-10 bg-white shadow-sm">
<header className="sticky top-0 z-10 bg-surface-card dark:bg-surface-card shadow-sm border-b border-border dark:border-border">
<div className="flex items-center justify-between h-16 px-4">
<button
className="lg:hidden p-2 rounded-md text-gray-500 hover:bg-gray-100"
className="lg:hidden p-2 rounded-md text-foreground-muted hover:bg-background-muted dark:hover:bg-background-emphasis"
onClick={() => setSidebarOpen(true)}
aria-label="Abrir menú"
>
<Menu className="w-6 h-6" />
</button>
<div className="flex-1" />
{/* Theme toggle */}
<ThemeToggle simple size="md" className="mr-2" />
{/* User menu */}
<div className="relative">
<button
className="flex items-center space-x-2 p-2 rounded-lg hover:bg-gray-100"
className="flex items-center space-x-2 p-2 rounded-lg hover:bg-background-muted dark:hover:bg-background-emphasis transition-colors"
onClick={() => setUserMenuOpen(!userMenuOpen)}
aria-label="Menú de usuario"
aria-expanded={userMenuOpen}
>
<div className="w-8 h-8 bg-blue-100 rounded-full flex items-center justify-center">
<User className="w-5 h-5 text-blue-600" />
<div className="w-8 h-8 bg-primary/10 dark:bg-primary/20 rounded-full flex items-center justify-center">
<User className="w-5 h-5 text-primary" />
</div>
<span className="hidden sm:block text-sm font-medium text-gray-700">
<span className="hidden sm:block text-sm font-medium text-foreground">
{user?.firstName || user?.email || 'Usuario'}
</span>
<ChevronDown className="w-4 h-4 text-gray-500" />
<ChevronDown className="w-4 h-4 text-foreground-muted" />
</button>
{userMenuOpen && (
<div className="absolute right-0 mt-2 w-48 bg-white rounded-lg shadow-lg border py-1">
<div className="absolute right-0 mt-2 w-48 bg-surface-popover dark:bg-surface-popover rounded-lg shadow-lg border border-border py-1 z-dropdown">
<button
className="flex items-center w-full px-4 py-2 text-sm text-gray-700 hover:bg-gray-100"
className="flex items-center w-full px-4 py-2 text-sm text-foreground hover:bg-background-muted dark:hover:bg-background-emphasis transition-colors"
onClick={handleLogout}
>
<LogOut className="w-4 h-4 mr-2" />

View File

@ -0,0 +1,100 @@
/**
* Theme Store
* Gestión del tema (light/dark/system) con persistencia
*/
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
export type Theme = 'light' | 'dark' | 'system';
interface ThemeState {
theme: Theme;
resolvedTheme: 'light' | 'dark';
setTheme: (theme: Theme) => void;
toggleTheme: () => void;
}
/**
* Resuelve el tema actual basado en la preferencia del sistema
*/
const getSystemTheme = (): 'light' | 'dark' => {
if (typeof window === 'undefined') return 'light';
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
};
/**
* Resuelve el tema efectivo
*/
const resolveTheme = (theme: Theme): 'light' | 'dark' => {
if (theme === 'system') {
return getSystemTheme();
}
return theme;
};
/**
* Aplica el tema al documento
*/
const applyTheme = (resolvedTheme: 'light' | 'dark') => {
if (typeof document === 'undefined') return;
const root = document.documentElement;
if (resolvedTheme === 'dark') {
root.classList.add('dark');
root.setAttribute('data-theme', 'dark');
} else {
root.classList.remove('dark');
root.setAttribute('data-theme', 'light');
}
};
export const useThemeStore = create<ThemeState>()(
persist(
(set, get) => ({
theme: 'system',
resolvedTheme: getSystemTheme(),
setTheme: (theme: Theme) => {
const resolvedTheme = resolveTheme(theme);
applyTheme(resolvedTheme);
set({ theme, resolvedTheme });
},
toggleTheme: () => {
const { theme } = get();
const newTheme: Theme = theme === 'light' ? 'dark' : 'light';
const resolvedTheme = resolveTheme(newTheme);
applyTheme(resolvedTheme);
set({ theme: newTheme, resolvedTheme });
},
}),
{
name: 'erp-construccion-theme',
partialize: (state) => ({ theme: state.theme }),
onRehydrateStorage: () => (state) => {
// Aplicar tema al rehidratar desde localStorage
if (state) {
const resolvedTheme = resolveTheme(state.theme);
applyTheme(resolvedTheme);
state.resolvedTheme = resolvedTheme;
}
},
}
)
);
// Listener para cambios en preferencia del sistema
if (typeof window !== 'undefined') {
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
mediaQuery.addEventListener('change', (e) => {
const state = useThemeStore.getState();
if (state.theme === 'system') {
const resolvedTheme = e.matches ? 'dark' : 'light';
applyTheme(resolvedTheme);
useThemeStore.setState({ resolvedTheme });
}
});
}

View File

@ -0,0 +1,46 @@
/**
* Toast Store - Global state for toast notifications
*/
import { create } from 'zustand';
export type ToastType = 'success' | 'error' | 'warning' | 'info';
export interface Toast {
id: string;
type: ToastType;
message: string;
title?: string;
duration?: number;
}
interface ToastState {
toasts: Toast[];
addToast: (toast: Omit<Toast, 'id'>) => string;
removeToast: (id: string) => void;
clearToasts: () => void;
}
let toastId = 0;
export const useToastStore = create<ToastState>((set) => ({
toasts: [],
addToast: (toast) => {
const id = `toast-${++toastId}`;
set((state) => ({
toasts: [...state.toasts, { ...toast, id }],
}));
return id;
},
removeToast: (id) => {
set((state) => ({
toasts: state.toasts.filter((t) => t.id !== id),
}));
},
clearToasts: () => {
set({ toasts: [] });
},
}));