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:
parent
a746b0b4df
commit
380b96e159
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -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={() => {
|
||||
|
||||
@ -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}
|
||||
>
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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>
|
||||
);
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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>
|
||||
)}
|
||||
|
||||
@ -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 (
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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 = {
|
||||
|
||||
116
web/src/components/common/Toast.tsx
Normal file
116
web/src/components/common/Toast.tsx
Normal 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 };
|
||||
@ -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';
|
||||
|
||||
69
web/src/components/theme/ThemeProvider.tsx
Normal file
69
web/src/components/theme/ThemeProvider.tsx
Normal 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;
|
||||
170
web/src/components/theme/ThemeToggle.tsx
Normal file
170
web/src/components/theme/ThemeToggle.tsx
Normal 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;
|
||||
8
web/src/components/theme/index.ts
Normal file
8
web/src/components/theme/index.ts
Normal 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';
|
||||
@ -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
68
web/src/hooks/useToast.ts
Normal 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 };
|
||||
@ -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" />
|
||||
|
||||
100
web/src/stores/themeStore.ts
Normal file
100
web/src/stores/themeStore.ts
Normal 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 });
|
||||
}
|
||||
});
|
||||
}
|
||||
46
web/src/stores/toastStore.ts
Normal file
46
web/src/stores/toastStore.ts
Normal 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: [] });
|
||||
},
|
||||
}));
|
||||
Loading…
Reference in New Issue
Block a user