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 { 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 */}
|
||||
|
||||
@ -3,3 +3,4 @@ export * from './usePresupuestos';
|
||||
export * from './useReports';
|
||||
export * from './useBidding';
|
||||
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,
|
||||
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,
|
||||
|
||||
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