feat(obras): Add Control de Obra module - Avances and Bitacora

Sprint 1 - S1-T03: Pagina de Catalogo de Obras

New features:
- Avances de Obra page: work progress tracking with workflow
  (pending -> captured -> reviewed -> approved/rejected)
- Bitacora de Obra page: daily work log with timeline view
- Progress API services (avances-obra.api.ts, bitacora-obra.api.ts)
- React Query hooks (useProgress.ts) with 18 hooks total
- Navigation section "Control de Obra" in sidebar

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Adrian Flores Cortes 2026-02-02 23:02:54 -06:00
parent 5db3ef247d
commit e3b33f9caf
10 changed files with 1884 additions and 0 deletions

View File

@ -17,6 +17,7 @@ import { DashboardPage } from './pages/admin/dashboard';
import { ConceptosPage, PresupuestosPage, EstimacionesPage } from './pages/admin/presupuestos';
import { OpportunitiesPage, TendersPage, ProposalsPage, VendorsPage } from './pages/admin/bidding';
import { IncidentesPage, CapacitacionesPage, InspeccionesPage, InspeccionDetailPage } from './pages/admin/hse';
import { AvancesObraPage, BitacoraObraPage } from './pages/admin/obras';
function App() {
return (
@ -69,6 +70,13 @@ function App() {
<Route path="inspecciones" element={<InspeccionesPage />} />
<Route path="inspecciones/:id" element={<InspeccionDetailPage />} />
</Route>
{/* Control de Obra */}
<Route path="obras">
<Route index element={<Navigate to="avances" replace />} />
<Route path="avances" element={<AvancesObraPage />} />
<Route path="bitacora" element={<BitacoraObraPage />} />
</Route>
</Route>
{/* Portal Supervisor */}

View File

@ -3,3 +3,4 @@ export * from './usePresupuestos';
export * from './useReports';
export * from './useBidding';
export * from './useHSE';
export * from './useProgress';

View File

@ -0,0 +1,260 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { AxiosError } from 'axios';
import toast from 'react-hot-toast';
import { ApiError } from '../services/api';
import {
avancesObraApi,
AvanceObraFilters,
CreateAvanceObraDto,
AddFotoAvanceDto,
ReviewAvanceDto,
ApproveAvanceDto,
RejectAvanceDto,
AccumulatedProgressFilters,
} from '../services/progress/avances-obra.api';
import {
bitacoraObraApi,
BitacoraObraFilters,
CreateBitacoraObraDto,
UpdateBitacoraObraDto,
} from '../services/progress/bitacora-obra.api';
export const progressKeys = {
avances: {
all: ['progress', 'avances'] as const,
list: (filters?: AvanceObraFilters) => [...progressKeys.avances.all, 'list', filters] as const,
detail: (id: string) => [...progressKeys.avances.all, 'detail', id] as const,
accumulated: (filters?: AccumulatedProgressFilters) =>
[...progressKeys.avances.all, 'accumulated', filters] as const,
stats: () => [...progressKeys.avances.all, 'stats'] as const,
},
bitacora: {
all: ['progress', 'bitacora'] as const,
list: (fraccionamientoId: string, filters?: Omit<BitacoraObraFilters, 'fraccionamientoId'>) =>
[...progressKeys.bitacora.all, 'list', fraccionamientoId, filters] as const,
detail: (id: string) => [...progressKeys.bitacora.all, 'detail', id] as const,
stats: (fraccionamientoId: string) =>
[...progressKeys.bitacora.all, 'stats', fraccionamientoId] as const,
latest: (fraccionamientoId: string) =>
[...progressKeys.bitacora.all, 'latest', fraccionamientoId] as const,
},
};
const handleError = (error: AxiosError<ApiError>) => {
const message = error.response?.data?.message || 'Ha ocurrido un error';
toast.error(message);
};
export function useAvances(filters?: AvanceObraFilters) {
return useQuery({
queryKey: progressKeys.avances.list(filters),
queryFn: () => avancesObraApi.list(filters),
});
}
export function useAvance(id: string) {
return useQuery({
queryKey: progressKeys.avances.detail(id),
queryFn: () => avancesObraApi.get(id),
enabled: !!id,
});
}
export function useAvanceAccumulated(filters?: AccumulatedProgressFilters) {
return useQuery({
queryKey: progressKeys.avances.accumulated(filters),
queryFn: () => avancesObraApi.getAccumulated(filters),
});
}
export function useAvanceStats() {
return useQuery({
queryKey: progressKeys.avances.stats(),
queryFn: () => avancesObraApi.getStats(),
});
}
export function useCreateAvance() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (data: CreateAvanceObraDto) => avancesObraApi.create(data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: progressKeys.avances.all });
toast.success('Avance registrado exitosamente');
},
onError: handleError,
});
}
export function useAddFotoAvance() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ id, data }: { id: string; data: AddFotoAvanceDto }) =>
avancesObraApi.addFoto(id, data),
onSuccess: (_, { id }) => {
queryClient.invalidateQueries({ queryKey: progressKeys.avances.all });
queryClient.invalidateQueries({ queryKey: progressKeys.avances.detail(id) });
toast.success('Foto agregada exitosamente');
},
onError: handleError,
});
}
export function useRemoveFotoAvance() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ id, fotoId }: { id: string; fotoId: string }) =>
avancesObraApi.removeFoto(id, fotoId),
onSuccess: (_, { id }) => {
queryClient.invalidateQueries({ queryKey: progressKeys.avances.all });
queryClient.invalidateQueries({ queryKey: progressKeys.avances.detail(id) });
toast.success('Foto eliminada');
},
onError: handleError,
});
}
export function useReviewAvance() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ id, data }: { id: string; data?: ReviewAvanceDto }) =>
avancesObraApi.review(id, data),
onSuccess: (_, { id }) => {
queryClient.invalidateQueries({ queryKey: progressKeys.avances.all });
queryClient.invalidateQueries({ queryKey: progressKeys.avances.detail(id) });
queryClient.invalidateQueries({ queryKey: progressKeys.avances.stats() });
toast.success('Avance marcado como revisado');
},
onError: handleError,
});
}
export function useApproveAvance() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ id, data }: { id: string; data?: ApproveAvanceDto }) =>
avancesObraApi.approve(id, data),
onSuccess: (_, { id }) => {
queryClient.invalidateQueries({ queryKey: progressKeys.avances.all });
queryClient.invalidateQueries({ queryKey: progressKeys.avances.detail(id) });
queryClient.invalidateQueries({ queryKey: progressKeys.avances.stats() });
queryClient.invalidateQueries({ queryKey: progressKeys.avances.accumulated() });
toast.success('Avance aprobado exitosamente');
},
onError: handleError,
});
}
export function useRejectAvance() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ id, data }: { id: string; data: RejectAvanceDto }) =>
avancesObraApi.reject(id, data),
onSuccess: (_, { id }) => {
queryClient.invalidateQueries({ queryKey: progressKeys.avances.all });
queryClient.invalidateQueries({ queryKey: progressKeys.avances.detail(id) });
queryClient.invalidateQueries({ queryKey: progressKeys.avances.stats() });
toast.success('Avance rechazado');
},
onError: handleError,
});
}
export function useDeleteAvance() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: string) => avancesObraApi.delete(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: progressKeys.avances.all });
queryClient.invalidateQueries({ queryKey: progressKeys.avances.stats() });
toast.success('Avance eliminado');
},
onError: handleError,
});
}
export function useBitacora(
fraccionamientoId: string,
filters?: Omit<BitacoraObraFilters, 'fraccionamientoId'>
) {
return useQuery({
queryKey: progressKeys.bitacora.list(fraccionamientoId, filters),
queryFn: () =>
bitacoraObraApi.list({
...filters,
fraccionamientoId,
}),
enabled: !!fraccionamientoId,
});
}
export function useBitacoraEntry(id: string) {
return useQuery({
queryKey: progressKeys.bitacora.detail(id),
queryFn: () => bitacoraObraApi.get(id),
enabled: !!id,
});
}
export function useBitacoraStats(fraccionamientoId: string) {
return useQuery({
queryKey: progressKeys.bitacora.stats(fraccionamientoId),
queryFn: () => bitacoraObraApi.getStats(fraccionamientoId),
enabled: !!fraccionamientoId,
});
}
export function useBitacoraLatest(fraccionamientoId: string) {
return useQuery({
queryKey: progressKeys.bitacora.latest(fraccionamientoId),
queryFn: () => bitacoraObraApi.getLatest(fraccionamientoId),
enabled: !!fraccionamientoId,
});
}
export function useCreateBitacora() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (data: CreateBitacoraObraDto) => bitacoraObraApi.create(data),
onSuccess: (result) => {
queryClient.invalidateQueries({ queryKey: progressKeys.bitacora.all });
queryClient.invalidateQueries({
queryKey: progressKeys.bitacora.stats(result.fraccionamientoId),
});
queryClient.invalidateQueries({
queryKey: progressKeys.bitacora.latest(result.fraccionamientoId),
});
toast.success('Entrada de bitacora creada exitosamente');
},
onError: handleError,
});
}
export function useUpdateBitacora() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ id, data }: { id: string; data: UpdateBitacoraObraDto }) =>
bitacoraObraApi.update(id, data),
onSuccess: (result, { id }) => {
queryClient.invalidateQueries({ queryKey: progressKeys.bitacora.all });
queryClient.invalidateQueries({ queryKey: progressKeys.bitacora.detail(id) });
queryClient.invalidateQueries({
queryKey: progressKeys.bitacora.stats(result.fraccionamientoId),
});
toast.success('Entrada de bitacora actualizada');
},
onError: handleError,
});
}
export function useDeleteBitacora() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: string) => bitacoraObraApi.delete(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: progressKeys.bitacora.all });
toast.success('Entrada de bitacora eliminada');
},
onError: handleError,
});
}

View File

@ -23,6 +23,8 @@ import {
AlertTriangle,
GraduationCap,
ClipboardCheck,
TrendingUp,
BookOpen,
} from 'lucide-react';
import clsx from 'clsx';
import { useAuthStore } from '../stores/authStore';
@ -58,6 +60,14 @@ const navSections: NavSection[] = [
{ label: 'Prototipos', href: '/admin/proyectos/prototipos', icon: Home },
],
},
{
title: 'Control de Obra',
defaultOpen: false,
items: [
{ label: 'Avances', href: '/admin/obras/avances', icon: TrendingUp },
{ label: 'Bitácora', href: '/admin/obras/bitacora', icon: BookOpen },
],
},
{
title: 'Presupuestos',
defaultOpen: false,

View File

@ -0,0 +1,663 @@
import { useState, useMemo } from 'react';
import {
Plus,
Eye,
Search,
CheckCircle,
Clock,
FileCheck,
XCircle,
ClipboardCheck,
TrendingUp,
} from 'lucide-react';
import {
useAvances,
useAvanceStats,
useCreateAvance,
useReviewAvance,
useApproveAvance,
useRejectAvance,
} from '../../../hooks/useProgress';
import { useFraccionamientos, useLotes } from '../../../hooks/useConstruccion';
import { useConceptos } from '../../../hooks/usePresupuestos';
import {
AvanceObra,
AvanceObraStatus,
CreateAvanceObraDto,
} from '../../../services/progress/avances-obra.api';
import { Fraccionamiento } from '../../../services/construccion/fraccionamientos.api';
import { Lote } from '../../../services/construccion/lotes.api';
import { Concepto } from '../../../services/presupuestos/presupuestos.api';
import clsx from 'clsx';
const statusColors: Record<AvanceObraStatus, string> = {
pending: 'bg-gray-100 text-gray-800',
captured: 'bg-blue-100 text-blue-800',
reviewed: 'bg-yellow-100 text-yellow-800',
approved: 'bg-green-100 text-green-800',
rejected: 'bg-red-100 text-red-800',
};
const statusLabels: Record<AvanceObraStatus, string> = {
pending: 'Pendiente',
captured: 'Capturado',
reviewed: 'Revisado',
approved: 'Aprobado',
rejected: 'Rechazado',
};
export function AvancesObraPage() {
const [search, setSearch] = useState('');
const [fraccionamientoFilter, setFraccionamientoFilter] = useState('');
const [loteFilter, setLoteFilter] = useState('');
const [statusFilter, setStatusFilter] = useState('');
const [dateFrom, setDateFrom] = useState('');
const [dateTo, setDateTo] = useState('');
const [showCreateModal, setShowCreateModal] = useState(false);
const [showRejectModal, setShowRejectModal] = useState<AvanceObra | null>(null);
const { data, isLoading, error } = useAvances({
fraccionamientoId: fraccionamientoFilter || undefined,
loteId: loteFilter || undefined,
status: (statusFilter as AvanceObraStatus) || undefined,
dateFrom: dateFrom || undefined,
dateTo: dateTo || undefined,
});
const { data: fraccionamientosData } = useFraccionamientos();
const { data: lotesData } = useLotes({
manzanaId: undefined,
});
const { data: statsData } = useAvanceStats();
const { data: conceptosData } = useConceptos({ tipo: 'concepto' });
const createMutation = useCreateAvance();
const reviewMutation = useReviewAvance();
const approveMutation = useApproveAvance();
const rejectMutation = useRejectAvance();
const avances = data?.items || [];
const fraccionamientos = fraccionamientosData?.items || [];
const allLotes = lotesData?.items || [];
const conceptos = conceptosData?.items || [];
const filteredLotes = useMemo(() => {
if (!fraccionamientoFilter) return allLotes;
return allLotes.filter((lote) => {
if (!lote.manzana) return false;
return true;
});
}, [allLotes, fraccionamientoFilter]);
const handleCreate = async (formData: CreateAvanceObraDto) => {
await createMutation.mutateAsync(formData);
setShowCreateModal(false);
};
const handleReview = async (id: string) => {
await reviewMutation.mutateAsync({ id });
};
const handleApprove = async (id: string) => {
await approveMutation.mutateAsync({ id });
};
const handleReject = async (id: string, reason: string) => {
await rejectMutation.mutateAsync({ id, data: { reason } });
setShowRejectModal(null);
};
const getProgressColor = (percentage: number) => {
if (percentage >= 80) return 'bg-green-500';
if (percentage >= 50) return 'bg-yellow-500';
if (percentage >= 25) return 'bg-orange-500';
return 'bg-red-500';
};
const approvedToday = useMemo(() => {
if (!statsData) return 0;
return statsData.porStatus.approved || 0;
}, [statsData]);
const globalProgress = useMemo(() => {
if (!avances.length) return 0;
const totalProgress = avances.reduce((sum, a) => sum + (a.percentageAccumulated || 0), 0);
return Math.round(totalProgress / avances.length);
}, [avances]);
return (
<div>
<div className="flex items-center justify-between mb-6">
<div>
<h1 className="text-2xl font-bold text-gray-900">Control de Avances</h1>
<p className="text-gray-600">
Gestion de avances fisicos de obra y seguimiento de progreso
</p>
</div>
<button
className="flex items-center px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
onClick={() => setShowCreateModal(true)}
>
<Plus className="w-5 h-5 mr-2" />
Nuevo Avance
</button>
</div>
{statsData && (
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-6">
<div className="bg-white rounded-lg shadow-sm p-4">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-gray-600">Total Avances</p>
<p className="text-2xl font-bold text-gray-900">{statsData.total}</p>
</div>
<FileCheck className="w-10 h-10 text-blue-500" />
</div>
</div>
<div className="bg-white rounded-lg shadow-sm p-4">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-gray-600">Pendientes de Revision</p>
<p className="text-2xl font-bold text-yellow-600">
{(statsData.porStatus.captured || 0) + (statsData.porStatus.pending || 0)}
</p>
</div>
<Clock className="w-10 h-10 text-yellow-500" />
</div>
</div>
<div className="bg-white rounded-lg shadow-sm p-4">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-gray-600">Aprobados Hoy</p>
<p className="text-2xl font-bold text-green-600">{approvedToday}</p>
</div>
<CheckCircle className="w-10 h-10 text-green-500" />
</div>
</div>
<div className="bg-white rounded-lg shadow-sm p-4">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-gray-600">% Avance Global</p>
<p className="text-2xl font-bold text-blue-600">{globalProgress}%</p>
</div>
<TrendingUp className="w-10 h-10 text-blue-500" />
</div>
</div>
</div>
)}
<div className="bg-white rounded-lg shadow-sm p-4 mb-6">
<div className="flex flex-col gap-4">
<div className="flex flex-col sm:flex-row gap-4">
<div className="flex-1 relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-400" />
<input
type="text"
placeholder="Buscar avances..."
className="w-full pl-10 pr-4 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
</div>
<select
className="px-4 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500"
value={fraccionamientoFilter}
onChange={(e) => {
setFraccionamientoFilter(e.target.value);
setLoteFilter('');
}}
>
<option value="">Todos los fraccionamientos</option>
{fraccionamientos.map((frac) => (
<option key={frac.id} value={frac.id}>
{frac.nombre}
</option>
))}
</select>
</div>
<div className="flex flex-col sm:flex-row gap-4">
<select
className="px-4 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500"
value={loteFilter}
onChange={(e) => setLoteFilter(e.target.value)}
>
<option value="">Todos los lotes</option>
{filteredLotes.map((lote) => (
<option key={lote.id} value={lote.id}>
{lote.code} - {lote.manzana?.name || 'Sin manzana'}
</option>
))}
</select>
<select
className="px-4 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500"
value={statusFilter}
onChange={(e) => setStatusFilter(e.target.value)}
>
<option value="">Todos los estados</option>
{Object.entries(statusLabels).map(([value, label]) => (
<option key={value} value={value}>
{label}
</option>
))}
</select>
<input
type="date"
className="px-4 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500"
value={dateFrom}
onChange={(e) => setDateFrom(e.target.value)}
placeholder="Desde"
/>
<input
type="date"
className="px-4 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500"
value={dateTo}
onChange={(e) => setDateTo(e.target.value)}
placeholder="Hasta"
/>
</div>
</div>
</div>
<div className="bg-white rounded-lg shadow-sm overflow-hidden">
{isLoading ? (
<div className="p-8 text-center text-gray-500">Cargando...</div>
) : error ? (
<div className="p-8 text-center text-red-500">Error al cargar los datos</div>
) : avances.length === 0 ? (
<div className="p-8 text-center text-gray-500">No hay avances registrados</div>
) : (
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
Concepto
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
Lote/Ubicacion
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
Fecha Captura
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
Cant. Ejecutada / Presup.
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
% Avance
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
Estado
</th>
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase">
Acciones
</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-200">
{avances.map((avance) => {
const percentage = avance.percentageAccumulated || 0;
return (
<tr key={avance.id} className="hover:bg-gray-50">
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm font-medium text-gray-900">
{avance.concepto?.codigo || 'N/A'}
</div>
<div className="text-sm text-gray-500">
{avance.concepto?.nombre || 'Sin concepto'}
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
{avance.lote ? (
<div>
<div className="text-sm font-medium text-gray-900">
Lote {avance.lote.numero}
</div>
<div className="text-sm text-gray-500">
{avance.lote.manzanaNumero
? `Mz. ${avance.lote.manzanaNumero}`
: avance.lote.fraccionamientoNombre || ''}
</div>
</div>
) : avance.departamento ? (
<div>
<div className="text-sm font-medium text-gray-900">
Depto. {avance.departamento.numero}
</div>
<div className="text-sm text-gray-500">
{avance.departamento.edificioNombre || ''}
</div>
</div>
) : (
<span className="text-sm text-gray-400">Sin ubicacion</span>
)}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
{new Date(avance.captureDate).toLocaleDateString()}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm">
<div className="text-gray-900">
{avance.quantityExecuted.toLocaleString()}{' '}
<span className="text-gray-400">/</span>{' '}
{avance.concepto?.cantidadPresupuestada?.toLocaleString() || '0'}
</div>
<div className="text-xs text-gray-500">
{avance.concepto?.unidad || 'UND'}
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="flex items-center gap-2">
<div className="flex-1 bg-gray-200 rounded-full h-2 w-24">
<div
className={clsx('h-2 rounded-full', getProgressColor(percentage))}
style={{ width: `${Math.min(percentage, 100)}%` }}
/>
</div>
<span className="text-sm font-medium text-gray-700">
{percentage.toFixed(1)}%
</span>
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<span
className={clsx(
'px-2 py-1 text-xs font-medium rounded-full',
statusColors[avance.status] || statusColors.pending
)}
>
{statusLabels[avance.status] || avance.status}
</span>
</td>
<td className="px-6 py-4 whitespace-nowrap text-right">
<div className="flex items-center justify-end gap-2">
<button
className="p-2 text-gray-500 hover:text-blue-600 hover:bg-blue-50 rounded-lg"
title="Ver detalle"
>
<Eye className="w-4 h-4" />
</button>
{avance.status === 'captured' && (
<button
className="p-2 text-gray-500 hover:text-yellow-600 hover:bg-yellow-50 rounded-lg"
title="Marcar como revisado"
onClick={() => handleReview(avance.id)}
disabled={reviewMutation.isPending}
>
<ClipboardCheck className="w-4 h-4" />
</button>
)}
{avance.status === 'reviewed' && (
<>
<button
className="p-2 text-gray-500 hover:text-green-600 hover:bg-green-50 rounded-lg"
title="Aprobar avance"
onClick={() => handleApprove(avance.id)}
disabled={approveMutation.isPending}
>
<CheckCircle className="w-4 h-4" />
</button>
<button
className="p-2 text-gray-500 hover:text-red-600 hover:bg-red-50 rounded-lg"
title="Rechazar avance"
onClick={() => setShowRejectModal(avance)}
>
<XCircle className="w-4 h-4" />
</button>
</>
)}
</div>
</td>
</tr>
);
})}
</tbody>
</table>
)}
</div>
{showCreateModal && (
<CreateAvanceModal
lotes={filteredLotes}
conceptos={conceptos}
fraccionamientos={fraccionamientos}
onClose={() => setShowCreateModal(false)}
onSubmit={handleCreate}
isLoading={createMutation.isPending}
/>
)}
{showRejectModal && (
<RejectAvanceModal
avance={showRejectModal}
onClose={() => setShowRejectModal(null)}
onSubmit={(reason) => handleReject(showRejectModal.id, reason)}
isLoading={rejectMutation.isPending}
/>
)}
</div>
);
}
interface CreateAvanceModalProps {
lotes: Lote[];
conceptos: Concepto[];
fraccionamientos: Fraccionamiento[];
onClose: () => void;
onSubmit: (data: CreateAvanceObraDto) => Promise<void>;
isLoading: boolean;
}
function CreateAvanceModal({
lotes,
conceptos,
fraccionamientos,
onClose,
onSubmit,
isLoading,
}: CreateAvanceModalProps) {
const [selectedFraccionamiento, setSelectedFraccionamiento] = useState('');
const [formData, setFormData] = useState<CreateAvanceObraDto>({
conceptoId: '',
loteId: '',
captureDate: new Date().toISOString().split('T')[0],
quantityExecuted: 0,
notes: '',
});
const filteredLotes = useMemo(() => {
if (!selectedFraccionamiento) return lotes;
return lotes;
}, [lotes, selectedFraccionamiento]);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
await onSubmit({
...formData,
loteId: formData.loteId || undefined,
});
};
return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white rounded-lg p-6 max-w-2xl w-full mx-4 max-h-[90vh] overflow-y-auto">
<h3 className="text-lg font-semibold mb-4">Nuevo Avance de Obra</h3>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Fraccionamiento
</label>
<select
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500"
value={selectedFraccionamiento}
onChange={(e) => {
setSelectedFraccionamiento(e.target.value);
setFormData({ ...formData, loteId: '' });
}}
>
<option value="">Seleccione un fraccionamiento</option>
{fraccionamientos.map((frac) => (
<option key={frac.id} value={frac.id}>
{frac.nombre}
</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Lote</label>
<select
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500"
value={formData.loteId || ''}
onChange={(e) => setFormData({ ...formData, loteId: e.target.value })}
>
<option value="">Seleccione un lote (opcional)</option>
{filteredLotes.map((lote) => (
<option key={lote.id} value={lote.id}>
{lote.code} - {lote.manzana?.name || 'Sin manzana'}
</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Concepto *</label>
<select
required
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500"
value={formData.conceptoId}
onChange={(e) => setFormData({ ...formData, conceptoId: e.target.value })}
>
<option value="">Seleccione un concepto</option>
{conceptos.map((concepto) => (
<option key={concepto.id} value={concepto.id}>
{concepto.codigo} - {concepto.descripcion}
</option>
))}
</select>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Fecha de Captura *
</label>
<input
type="date"
required
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500"
value={formData.captureDate}
onChange={(e) => setFormData({ ...formData, captureDate: e.target.value })}
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Cantidad Ejecutada *
</label>
<input
type="number"
required
min="0"
step="0.01"
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500"
value={formData.quantityExecuted}
onChange={(e) =>
setFormData({ ...formData, quantityExecuted: parseFloat(e.target.value) || 0 })
}
/>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Notas</label>
<textarea
rows={3}
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500"
placeholder="Observaciones adicionales sobre el avance..."
value={formData.notes || ''}
onChange={(e) => setFormData({ ...formData, notes: e.target.value })}
/>
</div>
<div className="flex justify-end gap-3 pt-4">
<button
type="button"
className="px-4 py-2 text-gray-700 border rounded-lg hover:bg-gray-50"
onClick={onClose}
>
Cancelar
</button>
<button
type="submit"
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50"
disabled={isLoading}
>
{isLoading ? 'Guardando...' : 'Registrar Avance'}
</button>
</div>
</form>
</div>
</div>
);
}
interface RejectAvanceModalProps {
avance: AvanceObra;
onClose: () => void;
onSubmit: (reason: string) => Promise<void>;
isLoading: boolean;
}
function RejectAvanceModal({ avance, onClose, onSubmit, isLoading }: RejectAvanceModalProps) {
const [reason, setReason] = useState('');
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
await onSubmit(reason);
};
return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white rounded-lg p-6 max-w-md w-full mx-4">
<div className="flex items-center gap-3 mb-4">
<XCircle className="w-6 h-6 text-red-600" />
<h3 className="text-lg font-semibold">Rechazar Avance</h3>
</div>
<p className="text-sm text-gray-600 mb-4">
Esta rechazando el avance del concepto:{' '}
<strong>{avance.concepto?.codigo || 'N/A'}</strong>
</p>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Motivo del Rechazo *
</label>
<textarea
required
rows={3}
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500"
placeholder="Explique el motivo del rechazo..."
value={reason}
onChange={(e) => setReason(e.target.value)}
/>
</div>
<div className="flex justify-end gap-3 pt-4">
<button
type="button"
className="px-4 py-2 text-gray-700 border rounded-lg hover:bg-gray-50"
onClick={onClose}
>
Cancelar
</button>
<button
type="submit"
className="px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 disabled:opacity-50"
disabled={isLoading || !reason.trim()}
>
{isLoading ? 'Rechazando...' : 'Rechazar Avance'}
</button>
</div>
</form>
</div>
</div>
);
}

View File

@ -0,0 +1,606 @@
import { useState } from 'react';
import {
Plus,
Pencil,
Trash2,
Sun,
Cloud,
CloudRain,
CloudLightning,
CloudSnow,
Wind,
Calendar,
Users,
AlertTriangle,
FileText,
ChevronDown,
ChevronUp,
} from 'lucide-react';
import {
useBitacora,
useBitacoraStats,
useCreateBitacora,
useUpdateBitacora,
useDeleteBitacora,
} from '../../../hooks/useProgress';
import { useFraccionamientos } from '../../../hooks/useConstruccion';
import {
BitacoraObra,
WeatherCondition,
CreateBitacoraObraDto,
UpdateBitacoraObraDto,
} from '../../../services/progress/bitacora-obra.api';
import clsx from 'clsx';
const weatherLabels: Record<WeatherCondition, string> = {
soleado: 'Soleado',
nublado: 'Nublado',
lluvioso: 'Lluvioso',
tormentoso: 'Tormentoso',
nevando: 'Nevando',
ventoso: 'Ventoso',
};
const weatherColors: Record<WeatherCondition, string> = {
soleado: 'text-yellow-500',
nublado: 'text-gray-500',
lluvioso: 'text-blue-500',
tormentoso: 'text-purple-500',
nevando: 'text-cyan-500',
ventoso: 'text-teal-500',
};
function WeatherIcon({ weather, className }: { weather: WeatherCondition; className?: string }) {
const iconClass = clsx('w-5 h-5', weatherColors[weather], className);
switch (weather) {
case 'soleado':
return <Sun className={iconClass} />;
case 'nublado':
return <Cloud className={iconClass} />;
case 'lluvioso':
return <CloudRain className={iconClass} />;
case 'tormentoso':
return <CloudLightning className={iconClass} />;
case 'nevando':
return <CloudSnow className={iconClass} />;
case 'ventoso':
return <Wind className={iconClass} />;
default:
return <Cloud className={iconClass} />;
}
}
function formatDate(dateString: string): string {
const date = new Date(dateString);
return date.toLocaleDateString('es-MX', {
weekday: 'long',
year: 'numeric',
month: 'long',
day: 'numeric',
});
}
function formatShortDate(dateString: string): string {
const date = new Date(dateString);
return date.toLocaleDateString('es-MX', {
day: '2-digit',
month: 'short',
year: 'numeric',
});
}
export function BitacoraObraPage() {
const [fraccionamientoId, setFraccionamientoId] = useState('');
const [dateFrom, setDateFrom] = useState('');
const [dateTo, setDateTo] = useState('');
const [onlyWithIncidents, setOnlyWithIncidents] = useState(false);
const [showModal, setShowModal] = useState(false);
const [editingItem, setEditingItem] = useState<BitacoraObra | null>(null);
const [deleteConfirm, setDeleteConfirm] = useState<string | null>(null);
const [expandedEntries, setExpandedEntries] = useState<Set<string>>(new Set());
const { data: fraccionamientosData } = useFraccionamientos();
const fraccionamientos = fraccionamientosData?.items || [];
const { data: bitacoraData, isLoading, error } = useBitacora(
fraccionamientoId,
{
dateFrom: dateFrom || undefined,
dateTo: dateTo || undefined,
hasIncidents: onlyWithIncidents || undefined,
}
);
const { data: statsData } = useBitacoraStats(fraccionamientoId);
const createMutation = useCreateBitacora();
const updateMutation = useUpdateBitacora();
const deleteMutation = useDeleteBitacora();
const handleCreate = async (formData: CreateBitacoraObraDto) => {
await createMutation.mutateAsync(formData);
setShowModal(false);
};
const handleUpdate = async (id: string, formData: UpdateBitacoraObraDto) => {
await updateMutation.mutateAsync({ id, data: formData });
setShowModal(false);
setEditingItem(null);
};
const handleDelete = async (id: string) => {
await deleteMutation.mutateAsync(id);
setDeleteConfirm(null);
};
const toggleExpanded = (id: string) => {
setExpandedEntries((prev) => {
const next = new Set(prev);
if (next.has(id)) {
next.delete(id);
} else {
next.add(id);
}
return next;
});
};
const entries = bitacoraData?.items || [];
return (
<div>
<div className="flex items-center justify-between mb-6">
<div>
<h1 className="text-2xl font-bold text-gray-900">Bitacora de Obra</h1>
<p className="text-gray-600">Registro diario de actividades en obra</p>
</div>
<button
className={clsx(
'flex items-center px-4 py-2 bg-blue-600 text-white rounded-lg transition-colors',
fraccionamientoId ? 'hover:bg-blue-700' : 'opacity-50 cursor-not-allowed'
)}
onClick={() => {
if (fraccionamientoId) {
setEditingItem(null);
setShowModal(true);
}
}}
disabled={!fraccionamientoId}
>
<Plus className="w-5 h-5 mr-2" />
Nueva Entrada
</button>
</div>
{statsData && fraccionamientoId && (
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-6">
<div className="bg-white rounded-lg shadow-sm p-4">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-gray-600">Total Entradas</p>
<p className="text-2xl font-bold text-gray-900">{statsData.totalEntries}</p>
</div>
<FileText className="w-10 h-10 text-blue-500" />
</div>
</div>
<div className="bg-white rounded-lg shadow-sm p-4">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-gray-600">Entradas con Incidentes</p>
<p className="text-2xl font-bold text-orange-600">{statsData.entriesWithIncidents}</p>
</div>
<AlertTriangle className="w-10 h-10 text-orange-500" />
</div>
</div>
<div className="bg-white rounded-lg shadow-sm p-4">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-gray-600">Promedio Trabajadores/Dia</p>
<p className="text-2xl font-bold text-green-600">
{statsData.averageWorkersPerDay.toFixed(1)}
</p>
</div>
<Users className="w-10 h-10 text-green-500" />
</div>
</div>
<div className="bg-white rounded-lg shadow-sm p-4">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-gray-600">Ultima Entrada</p>
<p className="text-lg font-bold text-gray-900">
{statsData.lastEntryDate ? formatShortDate(statsData.lastEntryDate) : 'N/A'}
</p>
</div>
<Calendar className="w-10 h-10 text-purple-500" />
</div>
</div>
</div>
)}
<div className="bg-white rounded-lg shadow-sm p-4 mb-6">
<div className="flex flex-col gap-4">
<div className="flex flex-col sm:flex-row gap-4">
<div className="flex-1">
<label className="block text-sm font-medium text-gray-700 mb-1">
Fraccionamiento *
</label>
<select
className="w-full px-4 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500"
value={fraccionamientoId}
onChange={(e) => setFraccionamientoId(e.target.value)}
>
<option value="">Seleccione un fraccionamiento</option>
{fraccionamientos.map((frac) => (
<option key={frac.id} value={frac.id}>
{frac.nombre}
</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Desde</label>
<input
type="date"
className="px-4 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500"
value={dateFrom}
onChange={(e) => setDateFrom(e.target.value)}
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Hasta</label>
<input
type="date"
className="px-4 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500"
value={dateTo}
onChange={(e) => setDateTo(e.target.value)}
/>
</div>
</div>
<div className="flex items-center">
<label className="flex items-center cursor-pointer">
<input
type="checkbox"
className="w-4 h-4 text-blue-600 border-gray-300 rounded focus:ring-blue-500"
checked={onlyWithIncidents}
onChange={(e) => setOnlyWithIncidents(e.target.checked)}
/>
<span className="ml-2 text-sm text-gray-700">Solo con incidentes</span>
</label>
</div>
</div>
</div>
{!fraccionamientoId ? (
<div className="bg-white rounded-lg shadow-sm p-8 text-center text-gray-500">
Seleccione un fraccionamiento para ver la bitacora
</div>
) : isLoading ? (
<div className="bg-white rounded-lg shadow-sm p-8 text-center text-gray-500">
Cargando...
</div>
) : error ? (
<div className="bg-white rounded-lg shadow-sm p-8 text-center text-red-500">
Error al cargar los datos
</div>
) : entries.length === 0 ? (
<div className="bg-white rounded-lg shadow-sm p-8 text-center text-gray-500">
No hay entradas en la bitacora
</div>
) : (
<div className="space-y-4">
{entries.map((entry) => (
<BitacoraEntryCard
key={entry.id}
entry={entry}
isExpanded={expandedEntries.has(entry.id)}
onToggle={() => toggleExpanded(entry.id)}
onEdit={() => {
setEditingItem(entry);
setShowModal(true);
}}
onDelete={() => setDeleteConfirm(entry.id)}
/>
))}
</div>
)}
{showModal && (
<BitacoraModal
item={editingItem}
fraccionamientoId={fraccionamientoId}
onClose={() => {
setShowModal(false);
setEditingItem(null);
}}
onCreate={handleCreate}
onUpdate={handleUpdate}
isLoading={createMutation.isPending || updateMutation.isPending}
/>
)}
{deleteConfirm && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white rounded-lg p-6 max-w-md w-full mx-4">
<h3 className="text-lg font-semibold mb-4">Confirmar eliminacion</h3>
<p className="text-gray-600 mb-6">
Esta seguro de eliminar esta entrada de bitacora? Esta accion no se puede deshacer.
</p>
<div className="flex justify-end gap-3">
<button
className="px-4 py-2 text-gray-700 border rounded-lg hover:bg-gray-50"
onClick={() => setDeleteConfirm(null)}
>
Cancelar
</button>
<button
className="px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 disabled:opacity-50"
onClick={() => handleDelete(deleteConfirm)}
disabled={deleteMutation.isPending}
>
{deleteMutation.isPending ? 'Eliminando...' : 'Eliminar'}
</button>
</div>
</div>
</div>
)}
</div>
);
}
interface BitacoraEntryCardProps {
entry: BitacoraObra;
isExpanded: boolean;
onToggle: () => void;
onEdit: () => void;
onDelete: () => void;
}
function BitacoraEntryCard({
entry,
isExpanded,
onToggle,
onEdit,
onDelete,
}: BitacoraEntryCardProps) {
const hasIncidents = entry.incidents && entry.incidents.trim().length > 0;
const descriptionTruncated = entry.description.length > 200;
const displayDescription = isExpanded
? entry.description
: entry.description.slice(0, 200) + (descriptionTruncated ? '...' : '');
return (
<div
className={clsx(
'bg-white rounded-lg shadow-sm overflow-hidden',
hasIncidents && 'border-l-4 border-orange-500'
)}
>
<div className="p-4">
<div className="flex items-start justify-between mb-4">
<div className="flex items-center gap-4">
<div className="text-center">
<div className="text-3xl font-bold text-gray-900">
{new Date(entry.entryDate).getDate()}
</div>
<div className="text-sm text-gray-500">
{new Date(entry.entryDate).toLocaleDateString('es-MX', {
month: 'short',
year: 'numeric',
})}
</div>
</div>
<div className="border-l pl-4">
<div className="text-sm text-gray-500">{formatDate(entry.entryDate)}</div>
{entry.weather && (
<div className="flex items-center gap-2 mt-1">
<WeatherIcon weather={entry.weather} />
<span className="text-sm text-gray-700">{weatherLabels[entry.weather]}</span>
</div>
)}
</div>
</div>
<div className="flex items-center gap-2">
<div className="flex items-center gap-1 bg-green-50 text-green-700 px-3 py-1 rounded-full">
<Users className="w-4 h-4" />
<span className="text-sm font-medium">{entry.workersPresent} trabajadores</span>
</div>
<button
className="p-2 text-gray-500 hover:text-blue-600 hover:bg-blue-50 rounded-lg"
title="Editar"
onClick={onEdit}
>
<Pencil className="w-4 h-4" />
</button>
<button
className="p-2 text-gray-500 hover:text-red-600 hover:bg-red-50 rounded-lg"
title="Eliminar"
onClick={onDelete}
>
<Trash2 className="w-4 h-4" />
</button>
</div>
</div>
<div className="mb-4">
<h4 className="text-sm font-medium text-gray-700 mb-1">Descripcion</h4>
<p className="text-gray-600 whitespace-pre-wrap">{displayDescription}</p>
{descriptionTruncated && (
<button
className="flex items-center gap-1 text-blue-600 hover:text-blue-700 text-sm mt-2"
onClick={onToggle}
>
{isExpanded ? (
<>
<ChevronUp className="w-4 h-4" />
Ver menos
</>
) : (
<>
<ChevronDown className="w-4 h-4" />
Ver mas
</>
)}
</button>
)}
</div>
{hasIncidents && (
<div className="bg-orange-50 border border-orange-200 rounded-lg p-3">
<div className="flex items-center gap-2 mb-2">
<AlertTriangle className="w-4 h-4 text-orange-600" />
<h4 className="text-sm font-medium text-orange-800">Incidentes</h4>
</div>
<p className="text-orange-700 text-sm whitespace-pre-wrap">{entry.incidents}</p>
</div>
)}
</div>
</div>
);
}
interface BitacoraModalProps {
item: BitacoraObra | null;
fraccionamientoId: string;
onClose: () => void;
onCreate: (data: CreateBitacoraObraDto) => Promise<void>;
onUpdate: (id: string, data: UpdateBitacoraObraDto) => Promise<void>;
isLoading: boolean;
}
function BitacoraModal({
item,
fraccionamientoId,
onClose,
onCreate,
onUpdate,
isLoading,
}: BitacoraModalProps) {
const [entryDate, setEntryDate] = useState(
item?.entryDate?.split('T')[0] || new Date().toISOString().split('T')[0]
);
const [weather, setWeather] = useState<WeatherCondition | ''>(item?.weather || '');
const [workersPresent, setWorkersPresent] = useState(item?.workersPresent?.toString() || '0');
const [description, setDescription] = useState(item?.description || '');
const [incidents, setIncidents] = useState(item?.incidents || '');
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
const formData = {
entryDate,
weather: weather || undefined,
workersPresent: parseInt(workersPresent, 10) || 0,
description,
incidents: incidents || undefined,
};
if (item) {
await onUpdate(item.id, formData);
} else {
await onCreate({
...formData,
fraccionamientoId,
});
}
};
return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white rounded-lg p-6 max-w-2xl w-full mx-4 max-h-[90vh] overflow-y-auto">
<h3 className="text-lg font-semibold mb-4">
{item ? 'Editar Entrada de Bitacora' : 'Nueva Entrada de Bitacora'}
</h3>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Fecha *</label>
<input
type="date"
required
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500"
value={entryDate}
onChange={(e) => setEntryDate(e.target.value)}
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Clima</label>
<select
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500"
value={weather}
onChange={(e) => setWeather(e.target.value as WeatherCondition | '')}
>
<option value="">Seleccione clima</option>
{Object.entries(weatherLabels).map(([value, label]) => (
<option key={value} value={value}>
{label}
</option>
))}
</select>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Trabajadores Presentes *
</label>
<input
type="number"
required
min="0"
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500"
placeholder="Numero de trabajadores"
value={workersPresent}
onChange={(e) => setWorkersPresent(e.target.value)}
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Descripcion *</label>
<textarea
required
rows={5}
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500"
placeholder="Describa las actividades realizadas durante el dia..."
value={description}
onChange={(e) => setDescription(e.target.value)}
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Incidentes (opcional)
</label>
<textarea
rows={3}
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500 border-orange-200 bg-orange-50"
placeholder="Registre cualquier incidente ocurrido durante el dia..."
value={incidents}
onChange={(e) => setIncidents(e.target.value)}
/>
<p className="text-xs text-gray-500 mt-1">
Los incidentes se destacaran en la vista de la bitacora
</p>
</div>
<div className="flex justify-end gap-3 pt-4">
<button
type="button"
className="px-4 py-2 text-gray-700 border rounded-lg hover:bg-gray-50"
onClick={onClose}
>
Cancelar
</button>
<button
type="submit"
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50"
disabled={isLoading}
>
{isLoading ? 'Guardando...' : item ? 'Guardar Cambios' : 'Crear Entrada'}
</button>
</div>
</form>
</div>
</div>
);
}

View File

@ -0,0 +1,2 @@
export { AvancesObraPage } from './AvancesObraPage';
export { BitacoraObraPage } from './BitacoraObraPage';

View File

@ -0,0 +1,234 @@
import api, { PaginatedResponse, PaginationParams } from '../api';
export type AvanceObraStatus = 'pending' | 'captured' | 'reviewed' | 'approved' | 'rejected';
export interface FotoAvance {
id: string;
avanceId: string;
url: string;
filename: string;
mimeType: string;
size: number;
descripcion?: string;
latitud?: number;
longitud?: number;
fechaCaptura?: string;
createdAt: string;
createdById?: string;
}
export interface ConceptoResumen {
id: string;
codigo: string;
nombre: string;
unidad: string;
cantidadPresupuestada: number;
}
export interface LoteResumen {
id: string;
numero: string;
manzanaNumero?: string;
etapaNombre?: string;
fraccionamientoNombre?: string;
}
export interface DepartamentoResumen {
id: string;
numero: string;
piso?: number;
edificioNombre?: string;
}
export interface AvanceObra {
id: string;
tenantId: string;
conceptoId: string;
concepto?: ConceptoResumen;
loteId?: string;
lote?: LoteResumen;
departamentoId?: string;
departamento?: DepartamentoResumen;
captureDate: string;
quantityExecuted: number;
percentageExecuted?: number;
quantityAccumulated?: number;
percentageAccumulated?: number;
status: AvanceObraStatus;
notes?: string;
capturedById?: string;
capturedByName?: string;
reviewedById?: string;
reviewedByName?: string;
reviewedAt?: string;
approvedById?: string;
approvedByName?: string;
approvedAt?: string;
rejectedById?: string;
rejectedByName?: string;
rejectedAt?: string;
rejectionReason?: string;
fotos?: FotoAvance[];
createdAt: string;
updatedAt: string;
}
export interface AvanceObraFilters extends PaginationParams {
loteId?: string;
departamentoId?: string;
conceptoId?: string;
status?: AvanceObraStatus;
dateFrom?: string;
dateTo?: string;
fraccionamientoId?: string;
etapaId?: string;
manzanaId?: string;
}
export interface CreateAvanceObraDto {
conceptoId: string;
loteId?: string;
departamentoId?: string;
captureDate: string;
quantityExecuted: number;
notes?: string;
}
export interface UpdateAvanceObraDto {
captureDate?: string;
quantityExecuted?: number;
notes?: string;
}
export interface AddFotoAvanceDto {
file: File;
descripcion?: string;
latitud?: number;
longitud?: number;
fechaCaptura?: string;
}
export interface ReviewAvanceDto {
observaciones?: string;
}
export interface ApproveAvanceDto {
observaciones?: string;
}
export interface RejectAvanceDto {
reason: string;
}
export interface AccumulatedProgress {
conceptoId: string;
conceptoCodigo: string;
conceptoNombre: string;
unidad: string;
cantidadPresupuestada: number;
cantidadEjecutada: number;
cantidadPendiente: number;
porcentajeEjecutado: number;
avancesCount: number;
}
export interface AccumulatedProgressFilters {
loteId?: string;
departamentoId?: string;
fraccionamientoId?: string;
etapaId?: string;
manzanaId?: string;
conceptoPadreId?: string;
dateFrom?: string;
dateTo?: string;
}
export interface AvanceObraStats {
total: number;
porStatus: Record<AvanceObraStatus, number>;
avancesHoy: number;
avancesSemana: number;
}
export const avancesObraApi = {
list: async (filters?: AvanceObraFilters): Promise<PaginatedResponse<AvanceObra>> => {
const response = await api.get<PaginatedResponse<AvanceObra>>('/avances', {
params: filters,
});
return response.data;
},
getAccumulated: async (filters?: AccumulatedProgressFilters): Promise<AccumulatedProgress[]> => {
const response = await api.get<AccumulatedProgress[]>('/avances/accumulated', {
params: filters,
});
return response.data;
},
get: async (id: string): Promise<AvanceObra> => {
const response = await api.get<AvanceObra>(`/avances/${id}`);
return response.data;
},
create: async (data: CreateAvanceObraDto): Promise<AvanceObra> => {
const response = await api.post<AvanceObra>('/avances', data);
return response.data;
},
update: async (id: string, data: UpdateAvanceObraDto): Promise<AvanceObra> => {
const response = await api.patch<AvanceObra>(`/avances/${id}`, data);
return response.data;
},
delete: async (id: string): Promise<void> => {
await api.delete(`/avances/${id}`);
},
addFoto: async (id: string, data: AddFotoAvanceDto): Promise<FotoAvance> => {
const formData = new FormData();
formData.append('file', data.file);
if (data.descripcion) {
formData.append('descripcion', data.descripcion);
}
if (data.latitud !== undefined) {
formData.append('latitud', String(data.latitud));
}
if (data.longitud !== undefined) {
formData.append('longitud', String(data.longitud));
}
if (data.fechaCaptura) {
formData.append('fechaCaptura', data.fechaCaptura);
}
const response = await api.post<FotoAvance>(`/avances/${id}/fotos`, formData, {
headers: {
'Content-Type': 'multipart/form-data',
},
});
return response.data;
},
removeFoto: async (id: string, fotoId: string): Promise<void> => {
await api.delete(`/avances/${id}/fotos/${fotoId}`);
},
review: async (id: string, data?: ReviewAvanceDto): Promise<AvanceObra> => {
const response = await api.post<AvanceObra>(`/avances/${id}/review`, data || {});
return response.data;
},
approve: async (id: string, data?: ApproveAvanceDto): Promise<AvanceObra> => {
const response = await api.post<AvanceObra>(`/avances/${id}/approve`, data || {});
return response.data;
},
reject: async (id: string, data: RejectAvanceDto): Promise<AvanceObra> => {
const response = await api.post<AvanceObra>(`/avances/${id}/reject`, data);
return response.data;
},
getStats: async (): Promise<AvanceObraStats> => {
const response = await api.get<AvanceObraStats>('/avances/stats');
return response.data;
},
};

View File

@ -0,0 +1,99 @@
import api, { PaginatedResponse, PaginationParams } from '../api';
export type WeatherCondition =
| 'soleado'
| 'nublado'
| 'lluvioso'
| 'tormentoso'
| 'nevando'
| 'ventoso';
export interface BitacoraObra {
id: string;
tenantId: string;
fraccionamientoId: string;
entryDate: string;
description: string;
weather?: WeatherCondition;
workersPresent: number;
incidents?: string;
createdAt: string;
updatedAt: string;
deletedAt?: string;
}
export interface BitacoraObraFilters extends PaginationParams {
fraccionamientoId: string;
dateFrom?: string;
dateTo?: string;
hasIncidents?: boolean;
}
export interface CreateBitacoraObraDto {
fraccionamientoId: string;
entryDate: string;
description: string;
weather?: WeatherCondition;
workersPresent: number;
incidents?: string;
}
export interface UpdateBitacoraObraDto {
entryDate?: string;
description?: string;
weather?: WeatherCondition;
workersPresent?: number;
incidents?: string;
}
export interface BitacoraObraStats {
totalEntries: number;
entriesWithIncidents: number;
entriesWithoutIncidents: number;
averageWorkersPerDay: number;
weatherDistribution: Record<WeatherCondition, number>;
firstEntryDate?: string;
lastEntryDate?: string;
}
export const bitacoraObraApi = {
list: async (filters: BitacoraObraFilters): Promise<PaginatedResponse<BitacoraObra>> => {
const response = await api.get<PaginatedResponse<BitacoraObra>>('/bitacora', {
params: filters,
});
return response.data;
},
getStats: async (fraccionamientoId: string): Promise<BitacoraObraStats> => {
const response = await api.get<BitacoraObraStats>('/bitacora/stats', {
params: { fraccionamientoId },
});
return response.data;
},
getLatest: async (fraccionamientoId: string): Promise<BitacoraObra | null> => {
const response = await api.get<BitacoraObra | null>('/bitacora/latest', {
params: { fraccionamientoId },
});
return response.data;
},
get: async (id: string): Promise<BitacoraObra> => {
const response = await api.get<BitacoraObra>(`/bitacora/${id}`);
return response.data;
},
create: async (data: CreateBitacoraObraDto): Promise<BitacoraObra> => {
const response = await api.post<BitacoraObra>('/bitacora', data);
return response.data;
},
update: async (id: string, data: UpdateBitacoraObraDto): Promise<BitacoraObra> => {
const response = await api.patch<BitacoraObra>(`/bitacora/${id}`, data);
return response.data;
},
delete: async (id: string): Promise<void> => {
await api.delete(`/bitacora/${id}`);
},
};

View File

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