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:
parent
5db3ef247d
commit
e3b33f9caf
@ -17,6 +17,7 @@ import { DashboardPage } from './pages/admin/dashboard';
|
|||||||
import { ConceptosPage, PresupuestosPage, EstimacionesPage } from './pages/admin/presupuestos';
|
import { ConceptosPage, PresupuestosPage, EstimacionesPage } from './pages/admin/presupuestos';
|
||||||
import { OpportunitiesPage, TendersPage, ProposalsPage, VendorsPage } from './pages/admin/bidding';
|
import { OpportunitiesPage, TendersPage, ProposalsPage, VendorsPage } from './pages/admin/bidding';
|
||||||
import { IncidentesPage, CapacitacionesPage, InspeccionesPage, InspeccionDetailPage } from './pages/admin/hse';
|
import { IncidentesPage, CapacitacionesPage, InspeccionesPage, InspeccionDetailPage } from './pages/admin/hse';
|
||||||
|
import { AvancesObraPage, BitacoraObraPage } from './pages/admin/obras';
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
return (
|
return (
|
||||||
@ -69,6 +70,13 @@ function App() {
|
|||||||
<Route path="inspecciones" element={<InspeccionesPage />} />
|
<Route path="inspecciones" element={<InspeccionesPage />} />
|
||||||
<Route path="inspecciones/:id" element={<InspeccionDetailPage />} />
|
<Route path="inspecciones/:id" element={<InspeccionDetailPage />} />
|
||||||
</Route>
|
</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>
|
</Route>
|
||||||
|
|
||||||
{/* Portal Supervisor */}
|
{/* Portal Supervisor */}
|
||||||
|
|||||||
@ -3,3 +3,4 @@ export * from './usePresupuestos';
|
|||||||
export * from './useReports';
|
export * from './useReports';
|
||||||
export * from './useBidding';
|
export * from './useBidding';
|
||||||
export * from './useHSE';
|
export * from './useHSE';
|
||||||
|
export * from './useProgress';
|
||||||
|
|||||||
260
web/src/hooks/useProgress.ts
Normal file
260
web/src/hooks/useProgress.ts
Normal 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,
|
||||||
|
});
|
||||||
|
}
|
||||||
@ -23,6 +23,8 @@ import {
|
|||||||
AlertTriangle,
|
AlertTriangle,
|
||||||
GraduationCap,
|
GraduationCap,
|
||||||
ClipboardCheck,
|
ClipboardCheck,
|
||||||
|
TrendingUp,
|
||||||
|
BookOpen,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import clsx from 'clsx';
|
import clsx from 'clsx';
|
||||||
import { useAuthStore } from '../stores/authStore';
|
import { useAuthStore } from '../stores/authStore';
|
||||||
@ -58,6 +60,14 @@ const navSections: NavSection[] = [
|
|||||||
{ label: 'Prototipos', href: '/admin/proyectos/prototipos', icon: Home },
|
{ 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',
|
title: 'Presupuestos',
|
||||||
defaultOpen: false,
|
defaultOpen: false,
|
||||||
|
|||||||
663
web/src/pages/admin/obras/AvancesObraPage.tsx
Normal file
663
web/src/pages/admin/obras/AvancesObraPage.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
606
web/src/pages/admin/obras/BitacoraObraPage.tsx
Normal file
606
web/src/pages/admin/obras/BitacoraObraPage.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
2
web/src/pages/admin/obras/index.ts
Normal file
2
web/src/pages/admin/obras/index.ts
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
export { AvancesObraPage } from './AvancesObraPage';
|
||||||
|
export { BitacoraObraPage } from './BitacoraObraPage';
|
||||||
234
web/src/services/progress/avances-obra.api.ts
Normal file
234
web/src/services/progress/avances-obra.api.ts
Normal 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;
|
||||||
|
},
|
||||||
|
};
|
||||||
99
web/src/services/progress/bitacora-obra.api.ts
Normal file
99
web/src/services/progress/bitacora-obra.api.ts
Normal 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}`);
|
||||||
|
},
|
||||||
|
};
|
||||||
1
web/src/services/progress/index.ts
Normal file
1
web/src/services/progress/index.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export * from './avances-obra.api';
|
||||||
Loading…
Reference in New Issue
Block a user