diff --git a/web/src/pages/admin/dashboard/DashboardPage.tsx b/web/src/pages/admin/dashboard/DashboardPage.tsx index b265f37..6854bde 100644 --- a/web/src/pages/admin/dashboard/DashboardPage.tsx +++ b/web/src/pages/admin/dashboard/DashboardPage.tsx @@ -1,3 +1,8 @@ +/** + * DashboardPage - Portfolio Overview Dashboard + * Refactored to use common utilities and components + */ + import { useState, useMemo } from 'react'; import { Building2, @@ -5,7 +10,6 @@ import { DollarSign, TrendingUp, AlertTriangle, - Search, CheckCircle, Clock, XCircle, @@ -21,37 +25,12 @@ import { useAlerts, useAcknowledgeAlert, } from '../../../hooks/useReports'; -import { - EarnedValueStatus, - AlertSeverity, -} from '../../../services/reports'; +import { EarnedValueStatus, AlertSeverity } from '../../../services/reports'; +import { SearchInput, SelectField } from '../../../components/common'; +import { formatCurrency, formatPercent, formatNumber } from '../../../utils'; // ============================================================================= -// Utility Functions -// ============================================================================= - -function formatCurrency(value: number): string { - return new Intl.NumberFormat('es-MX', { - style: 'currency', - currency: 'MXN', - minimumFractionDigits: 0, - maximumFractionDigits: 0, - }).format(value); -} - -function formatPercent(value: number): string { - return `${(value * 100).toFixed(1)}%`; -} - -function formatNumber(value: number, decimals: number = 2): string { - return new Intl.NumberFormat('es-MX', { - minimumFractionDigits: decimals, - maximumFractionDigits: decimals, - }).format(value); -} - -// ============================================================================= -// Types +// Constants // ============================================================================= const statusColors: Record = { @@ -66,30 +45,14 @@ const statusLabels: Record = { red: 'Atrasado', }; -const severityConfig: Record< - AlertSeverity, - { bg: string; text: string; border: string; icon: typeof AlertTriangle } -> = { - critical: { - bg: 'bg-red-50', - text: 'text-red-700', - border: 'border-red-200', - icon: XCircle, - }, - warning: { - bg: 'bg-yellow-50', - text: 'text-yellow-700', - border: 'border-yellow-200', - icon: AlertCircle, - }, - info: { - bg: 'bg-blue-50', - text: 'text-blue-700', - border: 'border-blue-200', - icon: Info, - }, +const severityConfig: Record = { + critical: { bg: 'bg-red-50', text: 'text-red-700', border: 'border-red-200', icon: XCircle }, + warning: { bg: 'bg-yellow-50', text: 'text-yellow-700', border: 'border-yellow-200', icon: AlertCircle }, + info: { bg: 'bg-blue-50', text: 'text-blue-700', border: 'border-blue-200', icon: Info }, }; +const STATUS_OPTIONS = Object.entries(statusLabels).map(([value, label]) => ({ value, label })); + // ============================================================================= // Sub-Components // ============================================================================= @@ -104,32 +67,17 @@ interface StatCardProps { } function StatCard({ title, value, icon: Icon, color, subtitle, isWarning }: StatCardProps) { - const colorClasses = { - blue: 'bg-blue-500', - green: 'bg-green-500', - purple: 'bg-purple-500', - orange: 'bg-orange-500', - red: 'bg-red-500', - }; + const colorClasses = { blue: 'bg-blue-500', green: 'bg-green-500', purple: 'bg-purple-500', orange: 'bg-orange-500', red: 'bg-red-500' }; return (

{title}

-

- {value} -

+

{value}

{subtitle &&

{subtitle}

}
-
- -
+
); @@ -142,26 +90,10 @@ interface KPIGaugeProps { } function KPIGauge({ label, value, threshold = { warning: 0.95, danger: 0.85 } }: KPIGaugeProps) { - const getColor = (val: number) => { - if (val >= threshold.warning) return 'text-green-600'; - if (val >= threshold.danger) return 'text-yellow-600'; - return 'text-red-600'; - }; - - const getBgColor = (val: number) => { - if (val >= threshold.warning) return 'bg-green-100'; - if (val >= threshold.danger) return 'bg-yellow-100'; - return 'bg-red-100'; - }; - - const getProgressColor = (val: number) => { - if (val >= threshold.warning) return 'bg-green-500'; - if (val >= threshold.danger) return 'bg-yellow-500'; - return 'bg-red-500'; - }; - - const percentage = Math.min(Math.max(value * 100, 0), 150); - const displayPercentage = Math.min(percentage, 100); + const getColor = (val: number) => val >= threshold.warning ? 'text-green-600' : val >= threshold.danger ? 'text-yellow-600' : 'text-red-600'; + const getBgColor = (val: number) => val >= threshold.warning ? 'bg-green-100' : val >= threshold.danger ? 'bg-yellow-100' : 'bg-red-100'; + const getProgressColor = (val: number) => val >= threshold.warning ? 'bg-green-500' : val >= threshold.danger ? 'bg-yellow-500' : 'bg-red-500'; + const displayPercentage = Math.min(Math.max(value * 100, 0), 100); return (
@@ -169,30 +101,16 @@ function KPIGauge({ label, value, threshold = { warning: 0.95, danger: 0.85 } }: {label}
-
- {formatNumber(value, 2)} -
+
{formatNumber(value)}
-
-
-
- 0 - 1.0 +
+
01.0
); } -interface EVMValueCardProps { - label: string; - value: number; - description?: string; -} - -function EVMValueCard({ label, value, description }: EVMValueCardProps) { +function EVMValueCard({ label, value, description }: { label: string; value: number; description?: string }) { return (

{label}

@@ -211,35 +129,25 @@ export function DashboardPage() { const [selectedProjectId, setSelectedProjectId] = useState(null); const [statusFilter, setStatusFilter] = useState(''); - // Data hooks const { data: stats, isLoading: statsLoading } = useDashboardStats(); - const { data: projectsData, isLoading: projectsLoading } = useProjectsSummary({ - status: statusFilter || undefined, - }); + const { data: projectsData, isLoading: projectsLoading } = useProjectsSummary({ status: statusFilter || undefined }); const { data: kpis, isLoading: kpisLoading } = useProjectKPIs(selectedProjectId || ''); const { data: alertsData, isLoading: alertsLoading } = useAlerts({ acknowledged: false }); - const acknowledgeMutation = useAcknowledgeAlert(); - // Filter projects by search const filteredProjects = useMemo(() => { if (!projectsData?.items) return []; if (!search) return projectsData.items; const searchLower = search.toLowerCase(); - return projectsData.items.filter((p) => - p.nombre.toLowerCase().includes(searchLower) - ); + return projectsData.items.filter((p) => p.nombre.toLowerCase().includes(searchLower)); }, [projectsData?.items, search]); - // Get selected project name const selectedProject = useMemo(() => { if (!selectedProjectId || !projectsData?.items) return null; return projectsData.items.find((p) => p.id === selectedProjectId); }, [selectedProjectId, projectsData?.items]); - const handleAcknowledge = async (alertId: string) => { - await acknowledgeMutation.mutateAsync(alertId); - }; + const handleAcknowledge = async (alertId: string) => { await acknowledgeMutation.mutateAsync(alertId); }; return (
@@ -253,47 +161,18 @@ export function DashboardPage() {
{statsLoading ? ( Array.from({ length: 5 }).map((_, i) => ( -
+
)) ) : stats ? ( <> - - - - - 0 ? 'red' : 'blue'} - isWarning={stats.alertasActivas > 0} - /> + + + + + 0 ? 'red' : 'blue'} isWarning={stats.alertasActivas > 0} /> ) : null}
@@ -305,28 +184,13 @@ export function DashboardPage() {

Resumen de Proyectos

-
- - setSearch(e.target.value)} - /> -
- + className="sm:w-48" + />
@@ -334,102 +198,36 @@ export function DashboardPage() { {projectsLoading ? (
Cargando proyectos...
) : filteredProjects.length === 0 ? ( -
- No hay proyectos que coincidan con la busqueda -
+
No hay proyectos que coincidan con la busqueda
) : ( - - - - - - - + + + + + + + {filteredProjects.map((project) => ( - setSelectedProjectId(project.id)} - > - - - - setSelectedProjectId(project.id)}> + + + + + - @@ -446,9 +244,7 @@ export function DashboardPage() {

Alertas Recientes

{alertsData?.items && alertsData.items.length > 0 && ( - - {alertsData.items.length} - + {alertsData.items.length} )}
@@ -464,39 +260,17 @@ export function DashboardPage() { alertsData.items.slice(0, 10).map((alert) => { const config = severityConfig[alert.severity]; const IconComponent = config.icon; - return ( -
+
-

- {alert.title} -

+

{alert.title}

{alert.projectName}

-

- {alert.message} -

+

{alert.message}

- - - {new Date(alert.createdAt).toLocaleDateString()} - -
@@ -510,60 +284,38 @@ export function DashboardPage() {
- {/* KPI Indicators Section - Shows when a project is selected */} + {/* KPI Indicators Section */} {selectedProjectId && (
-

- Indicadores KPI - {selectedProject?.nombre || 'Proyecto'} -

-

- Earned Value Management (EVM) -

+

Indicadores KPI - {selectedProject?.nombre || 'Proyecto'}

+

Earned Value Management (EVM)

- +
{kpisLoading ? (
Cargando KPIs...
) : !kpis ? ( -
- No hay datos de KPIs disponibles -
+
No hay datos de KPIs disponibles
) : (
{/* Performance Indicators */}
-

- Indicadores de Desempeno -

+

Indicadores de Desempeno

- +
% Completado
-
- {formatPercent(kpis.percentComplete / 100)} -
+
{formatPercent(kpis.percentComplete / 100)}
-
+
@@ -571,25 +323,11 @@ export function DashboardPage() { {/* EV Values */}
-

- Valores de Earned Value -

+

Valores de Earned Value

- - - + + +
@@ -597,43 +335,15 @@ export function DashboardPage() {

Varianzas

-
= 0 ? 'bg-green-50' : 'bg-red-50' - )} - > +
= 0 ? 'bg-green-50' : 'bg-red-50')}>

SV (Schedule Variance)

-

= 0 ? 'text-green-700' : 'text-red-700' - )} - > - {formatCurrency(kpis.sv)} -

-

- {kpis.sv >= 0 ? 'Adelantado' : 'Atrasado'} -

+

= 0 ? 'text-green-700' : 'text-red-700')}>{formatCurrency(kpis.sv)}

+

{kpis.sv >= 0 ? 'Adelantado' : 'Atrasado'}

-
= 0 ? 'bg-green-50' : 'bg-red-50' - )} - > +
= 0 ? 'bg-green-50' : 'bg-red-50')}>

CV (Cost Variance)

-

= 0 ? 'text-green-700' : 'text-red-700' - )} - > - {formatCurrency(kpis.cv)} -

-

- {kpis.cv >= 0 ? 'Bajo presupuesto' : 'Sobre presupuesto'} -

+

= 0 ? 'text-green-700' : 'text-red-700')}>{formatCurrency(kpis.cv)}

+

{kpis.cv >= 0 ? 'Bajo presupuesto' : 'Sobre presupuesto'}

@@ -642,63 +352,26 @@ export function DashboardPage() {

Proyecciones

- - - + + +
{/* VAC */} -
-
= 0 ? 'bg-green-50' : 'bg-red-50' - )} - > -
-
-

VAC (Variance at Completion)

-

= 0 ? 'text-green-700' : 'text-red-700' - )} - > - {formatCurrency(kpis.vac)} -

-
-
= 0 ? 'bg-green-100' : 'bg-red-100' - )} - > - {kpis.vac >= 0 ? ( - - ) : ( - - )} -
+
= 0 ? 'bg-green-50' : 'bg-red-50')}> +
+
+

VAC (Variance at Completion)

+

= 0 ? 'text-green-700' : 'text-red-700')}>{formatCurrency(kpis.vac)}

+
+
= 0 ? 'bg-green-100' : 'bg-red-100')}> + {kpis.vac >= 0 ? : }
-

- {kpis.vac >= 0 - ? `Se proyecta un ahorro de ${formatCurrency(kpis.vac)} al finalizar el proyecto` - : `Se proyecta un sobrecosto de ${formatCurrency(Math.abs(kpis.vac))} al finalizar el proyecto`} -

+

+ {kpis.vac >= 0 ? `Se proyecta un ahorro de ${formatCurrency(kpis.vac)} al finalizar el proyecto` : `Se proyecta un sobrecosto de ${formatCurrency(Math.abs(kpis.vac))} al finalizar el proyecto`} +

)} diff --git a/web/src/pages/admin/proyectos/LotesPage.tsx b/web/src/pages/admin/proyectos/LotesPage.tsx index 309bbfc..d2850c6 100644 --- a/web/src/pages/admin/proyectos/LotesPage.tsx +++ b/web/src/pages/admin/proyectos/LotesPage.tsx @@ -1,6 +1,11 @@ +/** + * LotesPage - Lotes Management + * Refactored to use common components + */ + import { useState } from 'react'; import { useSearchParams } from 'react-router-dom'; -import { Plus, Trash2, Search, RefreshCw } from 'lucide-react'; +import { Plus, RefreshCw, Trash2 } from 'lucide-react'; import { useLotes, useLoteStats, @@ -11,18 +16,24 @@ import { useUpdateLoteStatus, useAssignPrototipo, } from '../../../hooks/useConstruccion'; -import { LoteStatus, CreateLoteDto } from '../../../services/construccion/lotes.api'; +import type { LoteStatus, CreateLoteDto, Lote } from '../../../types'; import { HierarchyBreadcrumb, LoteStatusBadge, getStatusColor } from '../../../components/proyectos'; +import { + PageHeader, + PageHeaderAction, + DataTable, + SearchInput, + SelectField, + Modal, + ModalFooter, + TextInput, + FormGroup, + ConfirmDialog, +} from '../../../components/common'; +import type { DataTableColumn } from '../../../components/common'; +import { LOTE_STATUS_OPTIONS } from '../../../utils'; import clsx from 'clsx'; -const statusLabels: Record = { - available: 'Disponible', - reserved: 'Reservado', - sold: 'Vendido', - blocked: 'Bloqueado', - in_construction: 'En Construccion', -}; - export function LotesPage() { const [searchParams, setSearchParams] = useSearchParams(); const [search, setSearch] = useState(''); @@ -30,7 +41,7 @@ export function LotesPage() { const [showModal, setShowModal] = useState(false); const [showStatusModal, setShowStatusModal] = useState(null); const [showPrototipoModal, setShowPrototipoModal] = useState(null); - const [deleteConfirm, setDeleteConfirm] = useState(null); + const [deleteId, setDeleteId] = useState(null); const manzanaId = searchParams.get('manzanaId') || ''; @@ -52,9 +63,11 @@ export function LotesPage() { const manzanas = manzanasData?.items || []; const prototipos = prototiposData?.items || []; - const handleDelete = async (id: string) => { - await deleteMutation.mutateAsync(id); - setDeleteConfirm(null); + const handleDelete = async () => { + if (deleteId) { + await deleteMutation.mutateAsync(deleteId); + setDeleteId(null); + } }; const handleCreate = async (formData: CreateLoteDto) => { @@ -72,23 +85,38 @@ export function LotesPage() { setShowPrototipoModal(null); }; + const statusOptions = LOTE_STATUS_OPTIONS.map(o => ({ value: o.value, label: o.label })); + const manzanaOptions = manzanas.map(m => ({ value: m.id, label: `${m.code} - ${m.name}` })); + + const columns: DataTableColumn[] = [ + { key: 'code', header: 'Codigo', render: (item) => {item.code} }, + { key: 'officialNumber', header: 'No. Oficial', render: (item) => {item.officialNumber || '-'} }, + { key: 'areaM2', header: 'Area (m2)', render: (item) => {item.areaM2.toFixed(2)} }, + { key: 'prototipo', header: 'Prototipo', render: (item) => ( + item.prototipo ? ( + {item.prototipo.name} + ) : ( + + ) + )}, + { key: 'status', header: 'Estado', render: (item) => }, + { key: 'actions', header: 'Acciones', align: 'right', render: (item) => ( +
+ + +
+ )}, + ]; + return (
-
-
-

Lotes

-

Gestion de lotes y terrenos

-
- -
+ setShowModal(true)}>Nuevo Lote} + /> {/* Stats Cards */} {stats && ( @@ -98,29 +126,16 @@ export function LotesPage() { - +
)} {/* Filters */}
-
- - setSearch(e.target.value)} - /> -
- - + className="sm:w-48" + />
- {/* Table */} -
- {isLoading ? ( -
Cargando...
- ) : error ? ( -
Error al cargar los datos
- ) : lotes.length === 0 ? ( -
No hay lotes
- ) : ( -
- Nombre - - Presupuesto - - Real - - Programado - - SPI - - CPI - - Status - NombrePresupuestoRealProgramadoSPICPIStatus
- {project.nombre} - - {formatCurrency(project.presupuesto)} - - {formatPercent(project.avanceReal / 100)} - - {formatPercent(project.avanceProgramado / 100)} +
{project.nombre}{formatCurrency(project.presupuesto)}{formatPercent(project.avanceReal / 100)}{formatPercent(project.avanceProgramado / 100)} + = 0.95 ? 'text-green-600' : project.spi >= 0.85 ? 'text-yellow-600' : 'text-red-600')}>{formatNumber(project.spi)} - = 0.95 - ? 'text-green-600' - : project.spi >= 0.85 - ? 'text-yellow-600' - : 'text-red-600' - )} - > - {formatNumber(project.spi)} - + = 0.95 ? 'text-green-600' : project.cpi >= 0.85 ? 'text-yellow-600' : 'text-red-600')}>{formatNumber(project.cpi)} - = 0.95 - ? 'text-green-600' - : project.cpi >= 0.85 - ? 'text-yellow-600' - : 'text-red-600' - )} - > - {formatNumber(project.cpi)} - - - - + + {statusLabels[project.status]}
- - - - - - - - - - - - {lotes.map((item) => ( - - - - - - - - - ))} - -
- Codigo - - No. Oficial - - Area (m2) - - Prototipo - - Estado - - Acciones -
- {item.code} - - {item.officialNumber || '-'} - - {item.areaM2.toFixed(2)} - - {item.prototipo ? ( - {item.prototipo.name} - ) : ( - - )} - - - -
- - -
-
- )} -
+ {/* Create Modal */} {showModal && ( @@ -268,34 +189,21 @@ export function LotesPage() { /> )} - {/* Delete Confirmation */} - {deleteConfirm && ( -
-
-

Confirmar eliminacion

-

¿Esta seguro de eliminar este lote?

-
- - -
-
-
- )} + setDeleteId(null)} + onConfirm={handleDelete} + title="Confirmar eliminacion" + message="¿Esta seguro de eliminar este lote? Esta accion no se puede deshacer." + confirmLabel="Eliminar" + variant="danger" + isLoading={deleteMutation.isPending} + />
); } +// Stats Card Component (specific to lotes with color indicator) function StatCard({ label, value, color }: { label: string; value: number; color: string }) { return (
@@ -308,6 +216,7 @@ function StatCard({ label, value, color }: { label: string; value: number; color ); } +// Create Lote Modal interface CreateLoteModalProps { manzanas: { id: string; code: string; name: string }[]; defaultManzanaId: string; @@ -316,13 +225,7 @@ interface CreateLoteModalProps { isLoading: boolean; } -function CreateLoteModal({ - manzanas, - defaultManzanaId, - onClose, - onSubmit, - isLoading, -}: CreateLoteModalProps) { +function CreateLoteModal({ manzanas, defaultManzanaId, onClose, onSubmit, isLoading }: CreateLoteModalProps) { const [formData, setFormData] = useState({ code: '', manzanaId: defaultManzanaId || '', @@ -332,113 +235,31 @@ function CreateLoteModal({ status: 'available', }); - const handleSubmit = async (e: React.FormEvent) => { - e.preventDefault(); - await onSubmit(formData); - }; + const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); await onSubmit(formData); }; + const update = (field: keyof CreateLoteDto, value: string | number) => setFormData({ ...formData, [field]: value }); + + const manzanaOptions = manzanas.map(m => ({ value: m.id, label: `${m.code} - ${m.name}` })); return ( -
-
-

Nuevo Lote

-
-
- - -
-
-
- - setFormData({ ...formData, code: e.target.value })} - /> -
-
- - setFormData({ ...formData, officialNumber: e.target.value })} - /> -
-
-
-
- - setFormData({ ...formData, areaM2: parseFloat(e.target.value) })} - /> -
-
- - setFormData({ ...formData, frontM: parseFloat(e.target.value) })} - /> -
-
- - setFormData({ ...formData, depthM: parseFloat(e.target.value) })} - /> -
-
-
- - -
-
-
-
+ }> +
+ update('manzanaId', e.target.value)} /> + + update('code', e.target.value)} /> + update('officialNumber', e.target.value)} /> + + + update('areaM2', parseFloat(e.target.value))} /> + update('frontM', parseFloat(e.target.value))} /> + update('depthM', parseFloat(e.target.value))} /> + + +
); } +// Status Change Modal interface StatusChangeModalProps { currentStatus: LoteStatus; onClose: () => void; @@ -450,51 +271,22 @@ function StatusChangeModal({ currentStatus, onClose, onSubmit, isLoading }: Stat const [status, setStatus] = useState(currentStatus); return ( -
-
-

Cambiar Estado

-
- {(Object.entries(statusLabels) as [LoteStatus, string][]).map(([value, label]) => ( - - ))} -
-
- - -
+ }> +
+ {LOTE_STATUS_OPTIONS.map((option) => ( + + ))}
-
+ ); } +// Assign Prototipo Modal interface AssignPrototipoModalProps { prototipos: { id: string; code: string; name: string }[]; onClose: () => void; @@ -502,46 +294,14 @@ interface AssignPrototipoModalProps { isLoading: boolean; } -function AssignPrototipoModal({ - prototipos, - onClose, - onSubmit, - isLoading, -}: AssignPrototipoModalProps) { +function AssignPrototipoModal({ prototipos, onClose, onSubmit, isLoading }: AssignPrototipoModalProps) { const [prototipoId, setPrototipoId] = useState(''); + const prototipoOptions = prototipos.map(p => ({ value: p.id, label: `${p.code} - ${p.name}` })); return ( -
-
-

Asignar Prototipo

- -
- - -
-
-
+ }> + setPrototipoId(e.target.value)} /> + ); }