feat(FASE-5A): Frontend modules Dashboard, Presupuestos, Bidding
New modules implemented: - Dashboard: EVM visualization, Curva S, KPIs, alerts - Presupuestos: Conceptos tree, presupuestos list, estimaciones workflow - Bidding: Opportunities, tenders, proposals, vendors pages Files created: - services/reports/ - Reports API service (6 types, 8 methods) - services/presupuestos/ - Budget/estimates API (presupuestos.api, estimaciones.api) - services/bidding/ - Bidding API (opportunities, tenders, proposals, vendors) - hooks/useReports.ts - 8 query hooks, 2 mutation hooks - hooks/usePresupuestos.ts - 27 hooks for conceptos, presupuestos, estimaciones - hooks/useBidding.ts - 24 hooks for bidding module - pages/admin/dashboard/ - DashboardPage with EVM metrics - pages/admin/presupuestos/ - 3 pages (Conceptos, Presupuestos, Estimaciones) - pages/admin/bidding/ - 4 pages (Opportunities, Tenders, Proposals, Vendors) Updated: - App.tsx: Added routes for new modules - AdminLayout.tsx: Collapsible sidebar with 4 sections - hooks/index.ts: Export new hooks Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
f3d91433fe
commit
e4cfe62b1b
@ -13,18 +13,26 @@ import {
|
||||
PrototiposPage,
|
||||
} from './pages/admin/proyectos';
|
||||
import { ManzanasPage } from './pages/admin/proyectos/ManzanasPage';
|
||||
import { DashboardPage } from './pages/admin/dashboard';
|
||||
import { ConceptosPage, PresupuestosPage, EstimacionesPage } from './pages/admin/presupuestos';
|
||||
import { OpportunitiesPage, TendersPage, ProposalsPage, VendorsPage } from './pages/admin/bidding';
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<BrowserRouter>
|
||||
<div className="app">
|
||||
<Routes>
|
||||
{/* Ruta principal - redirect to admin */}
|
||||
<Route path="/" element={<Navigate to="/admin/proyectos/fraccionamientos" replace />} />
|
||||
{/* Ruta principal - redirect to admin dashboard */}
|
||||
<Route path="/" element={<Navigate to="/admin/dashboard" replace />} />
|
||||
|
||||
{/* Portal Admin */}
|
||||
<Route path="/admin" element={<AdminLayout />}>
|
||||
<Route index element={<Navigate to="proyectos/fraccionamientos" replace />} />
|
||||
<Route index element={<Navigate to="dashboard" replace />} />
|
||||
|
||||
{/* Dashboard */}
|
||||
<Route path="dashboard" element={<DashboardPage />} />
|
||||
|
||||
{/* Proyectos */}
|
||||
<Route path="proyectos">
|
||||
<Route index element={<Navigate to="fraccionamientos" replace />} />
|
||||
<Route path="fraccionamientos" element={<FraccionamientosPage />} />
|
||||
@ -34,6 +42,23 @@ function App() {
|
||||
<Route path="lotes" element={<LotesPage />} />
|
||||
<Route path="prototipos" element={<PrototiposPage />} />
|
||||
</Route>
|
||||
|
||||
{/* Presupuestos */}
|
||||
<Route path="presupuestos">
|
||||
<Route index element={<Navigate to="catalogo" replace />} />
|
||||
<Route path="catalogo" element={<ConceptosPage />} />
|
||||
<Route path="presupuestos" element={<PresupuestosPage />} />
|
||||
<Route path="estimaciones" element={<EstimacionesPage />} />
|
||||
</Route>
|
||||
|
||||
{/* Licitaciones */}
|
||||
<Route path="licitaciones">
|
||||
<Route index element={<Navigate to="oportunidades" replace />} />
|
||||
<Route path="oportunidades" element={<OpportunitiesPage />} />
|
||||
<Route path="concursos" element={<TendersPage />} />
|
||||
<Route path="propuestas" element={<ProposalsPage />} />
|
||||
<Route path="proveedores" element={<VendorsPage />} />
|
||||
</Route>
|
||||
</Route>
|
||||
|
||||
{/* Portal Supervisor */}
|
||||
|
||||
@ -1 +1,4 @@
|
||||
export * from './useConstruccion';
|
||||
export * from './usePresupuestos';
|
||||
export * from './useReports';
|
||||
export * from './useBidding';
|
||||
|
||||
343
web/src/hooks/useBidding.ts
Normal file
343
web/src/hooks/useBidding.ts
Normal file
@ -0,0 +1,343 @@
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { AxiosError } from 'axios';
|
||||
import toast from 'react-hot-toast';
|
||||
import { ApiError } from '../services/api';
|
||||
import {
|
||||
opportunitiesApi,
|
||||
tendersApi,
|
||||
proposalsApi,
|
||||
vendorsApi,
|
||||
OpportunityFilters,
|
||||
TenderFilters,
|
||||
ProposalFilters,
|
||||
VendorFilters,
|
||||
OpportunityStatus,
|
||||
TenderStatus,
|
||||
CreateOpportunityDto,
|
||||
UpdateOpportunityDto,
|
||||
CreateTenderDto,
|
||||
UpdateTenderDto,
|
||||
CreateProposalDto,
|
||||
UpdateProposalDto,
|
||||
CreateVendorDto,
|
||||
UpdateVendorDto,
|
||||
} from '../services/bidding';
|
||||
|
||||
// ============================================================================
|
||||
// QUERY KEYS
|
||||
// ============================================================================
|
||||
|
||||
export const biddingKeys = {
|
||||
opportunities: {
|
||||
all: ['bidding', 'opportunities'] as const,
|
||||
list: (filters?: OpportunityFilters) =>
|
||||
[...biddingKeys.opportunities.all, 'list', filters] as const,
|
||||
detail: (id: string) => [...biddingKeys.opportunities.all, 'detail', id] as const,
|
||||
},
|
||||
tenders: {
|
||||
all: ['bidding', 'tenders'] as const,
|
||||
list: (filters?: TenderFilters) => [...biddingKeys.tenders.all, 'list', filters] as const,
|
||||
detail: (id: string) => [...biddingKeys.tenders.all, 'detail', id] as const,
|
||||
},
|
||||
proposals: {
|
||||
all: ['bidding', 'proposals'] as const,
|
||||
list: (filters?: ProposalFilters) => [...biddingKeys.proposals.all, 'list', filters] as const,
|
||||
detail: (id: string) => [...biddingKeys.proposals.all, 'detail', id] as const,
|
||||
},
|
||||
vendors: {
|
||||
all: ['bidding', 'vendors'] as const,
|
||||
list: (filters?: VendorFilters) => [...biddingKeys.vendors.all, 'list', filters] as const,
|
||||
detail: (id: string) => [...biddingKeys.vendors.all, 'detail', id] as const,
|
||||
},
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// ERROR HANDLER
|
||||
// ============================================================================
|
||||
|
||||
const handleError = (error: AxiosError<ApiError>) => {
|
||||
const message = error.response?.data?.message || 'Ha ocurrido un error';
|
||||
toast.error(message);
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// OPPORTUNITIES HOOKS
|
||||
// ============================================================================
|
||||
|
||||
export function useOpportunities(filters?: OpportunityFilters) {
|
||||
return useQuery({
|
||||
queryKey: biddingKeys.opportunities.list(filters),
|
||||
queryFn: () => opportunitiesApi.list(filters),
|
||||
});
|
||||
}
|
||||
|
||||
export function useOpportunity(id: string) {
|
||||
return useQuery({
|
||||
queryKey: biddingKeys.opportunities.detail(id),
|
||||
queryFn: () => opportunitiesApi.get(id),
|
||||
enabled: !!id,
|
||||
});
|
||||
}
|
||||
|
||||
export function useCreateOpportunity() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (data: CreateOpportunityDto) => opportunitiesApi.create(data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: biddingKeys.opportunities.all });
|
||||
toast.success('Oportunidad creada exitosamente');
|
||||
},
|
||||
onError: handleError,
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdateOpportunity() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({ id, data }: { id: string; data: UpdateOpportunityDto }) =>
|
||||
opportunitiesApi.update(id, data),
|
||||
onSuccess: (_, { id }) => {
|
||||
queryClient.invalidateQueries({ queryKey: biddingKeys.opportunities.all });
|
||||
queryClient.invalidateQueries({ queryKey: biddingKeys.opportunities.detail(id) });
|
||||
toast.success('Oportunidad actualizada');
|
||||
},
|
||||
onError: handleError,
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteOpportunity() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (id: string) => opportunitiesApi.delete(id),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: biddingKeys.opportunities.all });
|
||||
toast.success('Oportunidad eliminada');
|
||||
},
|
||||
onError: handleError,
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdateOpportunityStatus() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({ id, status }: { id: string; status: OpportunityStatus }) =>
|
||||
opportunitiesApi.updateStatus(id, status),
|
||||
onSuccess: (_, { id }) => {
|
||||
queryClient.invalidateQueries({ queryKey: biddingKeys.opportunities.all });
|
||||
queryClient.invalidateQueries({ queryKey: biddingKeys.opportunities.detail(id) });
|
||||
toast.success('Estado de oportunidad actualizado');
|
||||
},
|
||||
onError: handleError,
|
||||
});
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// TENDERS HOOKS
|
||||
// ============================================================================
|
||||
|
||||
export function useTenders(filters?: TenderFilters) {
|
||||
return useQuery({
|
||||
queryKey: biddingKeys.tenders.list(filters),
|
||||
queryFn: () => tendersApi.list(filters),
|
||||
});
|
||||
}
|
||||
|
||||
export function useTender(id: string) {
|
||||
return useQuery({
|
||||
queryKey: biddingKeys.tenders.detail(id),
|
||||
queryFn: () => tendersApi.get(id),
|
||||
enabled: !!id,
|
||||
});
|
||||
}
|
||||
|
||||
export function useCreateTender() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (data: CreateTenderDto) => tendersApi.create(data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: biddingKeys.tenders.all });
|
||||
toast.success('Licitacion creada exitosamente');
|
||||
},
|
||||
onError: handleError,
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdateTender() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({ id, data }: { id: string; data: UpdateTenderDto }) =>
|
||||
tendersApi.update(id, data),
|
||||
onSuccess: (_, { id }) => {
|
||||
queryClient.invalidateQueries({ queryKey: biddingKeys.tenders.all });
|
||||
queryClient.invalidateQueries({ queryKey: biddingKeys.tenders.detail(id) });
|
||||
toast.success('Licitacion actualizada');
|
||||
},
|
||||
onError: handleError,
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteTender() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (id: string) => tendersApi.delete(id),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: biddingKeys.tenders.all });
|
||||
toast.success('Licitacion eliminada');
|
||||
},
|
||||
onError: handleError,
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdateTenderStatus() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({ id, status }: { id: string; status: TenderStatus }) =>
|
||||
tendersApi.updateStatus(id, status),
|
||||
onSuccess: (_, { id }) => {
|
||||
queryClient.invalidateQueries({ queryKey: biddingKeys.tenders.all });
|
||||
queryClient.invalidateQueries({ queryKey: biddingKeys.tenders.detail(id) });
|
||||
toast.success('Estado de licitacion actualizado');
|
||||
},
|
||||
onError: handleError,
|
||||
});
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// PROPOSALS HOOKS
|
||||
// ============================================================================
|
||||
|
||||
export function useProposals(filters?: ProposalFilters) {
|
||||
return useQuery({
|
||||
queryKey: biddingKeys.proposals.list(filters),
|
||||
queryFn: () => proposalsApi.list(filters),
|
||||
});
|
||||
}
|
||||
|
||||
export function useProposal(id: string) {
|
||||
return useQuery({
|
||||
queryKey: biddingKeys.proposals.detail(id),
|
||||
queryFn: () => proposalsApi.get(id),
|
||||
enabled: !!id,
|
||||
});
|
||||
}
|
||||
|
||||
export function useCreateProposal() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (data: CreateProposalDto) => proposalsApi.create(data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: biddingKeys.proposals.all });
|
||||
toast.success('Propuesta creada exitosamente');
|
||||
},
|
||||
onError: handleError,
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdateProposal() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({ id, data }: { id: string; data: UpdateProposalDto }) =>
|
||||
proposalsApi.update(id, data),
|
||||
onSuccess: (_, { id }) => {
|
||||
queryClient.invalidateQueries({ queryKey: biddingKeys.proposals.all });
|
||||
queryClient.invalidateQueries({ queryKey: biddingKeys.proposals.detail(id) });
|
||||
toast.success('Propuesta actualizada');
|
||||
},
|
||||
onError: handleError,
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteProposal() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (id: string) => proposalsApi.delete(id),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: biddingKeys.proposals.all });
|
||||
toast.success('Propuesta eliminada');
|
||||
},
|
||||
onError: handleError,
|
||||
});
|
||||
}
|
||||
|
||||
export function useSubmitProposal() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (id: string) => proposalsApi.submit(id),
|
||||
onSuccess: (_, id) => {
|
||||
queryClient.invalidateQueries({ queryKey: biddingKeys.proposals.all });
|
||||
queryClient.invalidateQueries({ queryKey: biddingKeys.proposals.detail(id) });
|
||||
toast.success('Propuesta enviada exitosamente');
|
||||
},
|
||||
onError: handleError,
|
||||
});
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// VENDORS HOOKS
|
||||
// ============================================================================
|
||||
|
||||
export function useVendors(filters?: VendorFilters) {
|
||||
return useQuery({
|
||||
queryKey: biddingKeys.vendors.list(filters),
|
||||
queryFn: () => vendorsApi.list(filters),
|
||||
});
|
||||
}
|
||||
|
||||
export function useVendor(id: string) {
|
||||
return useQuery({
|
||||
queryKey: biddingKeys.vendors.detail(id),
|
||||
queryFn: () => vendorsApi.get(id),
|
||||
enabled: !!id,
|
||||
});
|
||||
}
|
||||
|
||||
export function useCreateVendor() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (data: CreateVendorDto) => vendorsApi.create(data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: biddingKeys.vendors.all });
|
||||
toast.success('Proveedor creado exitosamente');
|
||||
},
|
||||
onError: handleError,
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdateVendor() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({ id, data }: { id: string; data: UpdateVendorDto }) =>
|
||||
vendorsApi.update(id, data),
|
||||
onSuccess: (_, { id }) => {
|
||||
queryClient.invalidateQueries({ queryKey: biddingKeys.vendors.all });
|
||||
queryClient.invalidateQueries({ queryKey: biddingKeys.vendors.detail(id) });
|
||||
toast.success('Proveedor actualizado');
|
||||
},
|
||||
onError: handleError,
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteVendor() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (id: string) => vendorsApi.delete(id),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: biddingKeys.vendors.all });
|
||||
toast.success('Proveedor eliminado');
|
||||
},
|
||||
onError: handleError,
|
||||
});
|
||||
}
|
||||
|
||||
export function useToggleVendorActive() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (id: string) => vendorsApi.toggleActive(id),
|
||||
onSuccess: (_, id) => {
|
||||
queryClient.invalidateQueries({ queryKey: biddingKeys.vendors.all });
|
||||
queryClient.invalidateQueries({ queryKey: biddingKeys.vendors.detail(id) });
|
||||
toast.success('Estado del proveedor actualizado');
|
||||
},
|
||||
onError: handleError,
|
||||
});
|
||||
}
|
||||
430
web/src/hooks/usePresupuestos.ts
Normal file
430
web/src/hooks/usePresupuestos.ts
Normal file
@ -0,0 +1,430 @@
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { AxiosError } from 'axios';
|
||||
import toast from 'react-hot-toast';
|
||||
import { ApiError } from '../services/api';
|
||||
import {
|
||||
conceptosApi,
|
||||
ConceptoFilters,
|
||||
CreateConceptoDto,
|
||||
UpdateConceptoDto,
|
||||
presupuestosApi,
|
||||
PresupuestoFilters,
|
||||
CreatePresupuestoDto,
|
||||
UpdatePresupuestoDto,
|
||||
estimacionesApi,
|
||||
EstimacionFilters,
|
||||
CreateEstimacionDto,
|
||||
UpdateEstimacionDto,
|
||||
CreateEstimacionPartidaDto,
|
||||
UpdateEstimacionPartidaDto,
|
||||
CreateGeneradorDto,
|
||||
UpdateGeneradorDto,
|
||||
} from '../services/presupuestos';
|
||||
|
||||
// ==================== QUERY KEYS ====================
|
||||
|
||||
export const presupuestosKeys = {
|
||||
conceptos: {
|
||||
all: ['presupuestos', 'conceptos'] as const,
|
||||
list: (filters?: ConceptoFilters) => [...presupuestosKeys.conceptos.all, 'list', filters] as const,
|
||||
detail: (id: string) => [...presupuestosKeys.conceptos.all, 'detail', id] as const,
|
||||
tree: (rootId?: string) => [...presupuestosKeys.conceptos.all, 'tree', rootId] as const,
|
||||
},
|
||||
presupuestos: {
|
||||
all: ['presupuestos', 'presupuestos'] as const,
|
||||
list: (filters?: PresupuestoFilters) => [...presupuestosKeys.presupuestos.all, 'list', filters] as const,
|
||||
detail: (id: string) => [...presupuestosKeys.presupuestos.all, 'detail', id] as const,
|
||||
},
|
||||
estimaciones: {
|
||||
all: ['presupuestos', 'estimaciones'] as const,
|
||||
list: (filters?: EstimacionFilters) => [...presupuestosKeys.estimaciones.all, 'list', filters] as const,
|
||||
detail: (id: string) => [...presupuestosKeys.estimaciones.all, 'detail', id] as const,
|
||||
},
|
||||
};
|
||||
|
||||
// ==================== ERROR HANDLER ====================
|
||||
|
||||
const handleError = (error: AxiosError<ApiError>) => {
|
||||
const message = error.response?.data?.message || 'Ha ocurrido un error';
|
||||
toast.error(message);
|
||||
};
|
||||
|
||||
// ==================== CONCEPTOS ====================
|
||||
|
||||
export function useConceptos(filters?: ConceptoFilters) {
|
||||
return useQuery({
|
||||
queryKey: presupuestosKeys.conceptos.list(filters),
|
||||
queryFn: () => conceptosApi.list(filters),
|
||||
});
|
||||
}
|
||||
|
||||
export function useConcepto(id: string) {
|
||||
return useQuery({
|
||||
queryKey: presupuestosKeys.conceptos.detail(id),
|
||||
queryFn: () => conceptosApi.get(id),
|
||||
enabled: !!id,
|
||||
});
|
||||
}
|
||||
|
||||
export function useConceptosTree(rootId?: string) {
|
||||
return useQuery({
|
||||
queryKey: presupuestosKeys.conceptos.tree(rootId),
|
||||
queryFn: () => conceptosApi.getTree(rootId),
|
||||
});
|
||||
}
|
||||
|
||||
export function useCreateConcepto() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (data: CreateConceptoDto) => conceptosApi.create(data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: presupuestosKeys.conceptos.all });
|
||||
toast.success('Concepto creado exitosamente');
|
||||
},
|
||||
onError: handleError,
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdateConcepto() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({ id, data }: { id: string; data: UpdateConceptoDto }) =>
|
||||
conceptosApi.update(id, data),
|
||||
onSuccess: (_, { id }) => {
|
||||
queryClient.invalidateQueries({ queryKey: presupuestosKeys.conceptos.all });
|
||||
queryClient.invalidateQueries({ queryKey: presupuestosKeys.conceptos.detail(id) });
|
||||
toast.success('Concepto actualizado');
|
||||
},
|
||||
onError: handleError,
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteConcepto() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (id: string) => conceptosApi.delete(id),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: presupuestosKeys.conceptos.all });
|
||||
toast.success('Concepto eliminado');
|
||||
},
|
||||
onError: handleError,
|
||||
});
|
||||
}
|
||||
|
||||
// ==================== PRESUPUESTOS ====================
|
||||
|
||||
export function usePresupuestos(filters?: PresupuestoFilters) {
|
||||
return useQuery({
|
||||
queryKey: presupuestosKeys.presupuestos.list(filters),
|
||||
queryFn: () => presupuestosApi.list(filters),
|
||||
});
|
||||
}
|
||||
|
||||
export function usePresupuesto(id: string) {
|
||||
return useQuery({
|
||||
queryKey: presupuestosKeys.presupuestos.detail(id),
|
||||
queryFn: () => presupuestosApi.get(id),
|
||||
enabled: !!id,
|
||||
});
|
||||
}
|
||||
|
||||
export function useCreatePresupuesto() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (data: CreatePresupuestoDto) => presupuestosApi.create(data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: presupuestosKeys.presupuestos.all });
|
||||
toast.success('Presupuesto creado exitosamente');
|
||||
},
|
||||
onError: handleError,
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdatePresupuesto() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({ id, data }: { id: string; data: UpdatePresupuestoDto }) =>
|
||||
presupuestosApi.update(id, data),
|
||||
onSuccess: (_, { id }) => {
|
||||
queryClient.invalidateQueries({ queryKey: presupuestosKeys.presupuestos.all });
|
||||
queryClient.invalidateQueries({ queryKey: presupuestosKeys.presupuestos.detail(id) });
|
||||
toast.success('Presupuesto actualizado');
|
||||
},
|
||||
onError: handleError,
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeletePresupuesto() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (id: string) => presupuestosApi.delete(id),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: presupuestosKeys.presupuestos.all });
|
||||
toast.success('Presupuesto eliminado');
|
||||
},
|
||||
onError: handleError,
|
||||
});
|
||||
}
|
||||
|
||||
export function useApprovePresupuesto() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (id: string) => presupuestosApi.approve(id),
|
||||
onSuccess: (_, id) => {
|
||||
queryClient.invalidateQueries({ queryKey: presupuestosKeys.presupuestos.all });
|
||||
queryClient.invalidateQueries({ queryKey: presupuestosKeys.presupuestos.detail(id) });
|
||||
toast.success('Presupuesto aprobado');
|
||||
},
|
||||
onError: handleError,
|
||||
});
|
||||
}
|
||||
|
||||
export function useDuplicatePresupuesto() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (id: string) => presupuestosApi.duplicate(id),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: presupuestosKeys.presupuestos.all });
|
||||
toast.success('Presupuesto duplicado exitosamente');
|
||||
},
|
||||
onError: handleError,
|
||||
});
|
||||
}
|
||||
|
||||
// ==================== ESTIMACIONES ====================
|
||||
|
||||
export function useEstimaciones(filters?: EstimacionFilters) {
|
||||
return useQuery({
|
||||
queryKey: presupuestosKeys.estimaciones.list(filters),
|
||||
queryFn: () => estimacionesApi.list(filters),
|
||||
});
|
||||
}
|
||||
|
||||
export function useEstimacion(id: string) {
|
||||
return useQuery({
|
||||
queryKey: presupuestosKeys.estimaciones.detail(id),
|
||||
queryFn: () => estimacionesApi.get(id),
|
||||
enabled: !!id,
|
||||
});
|
||||
}
|
||||
|
||||
export function useCreateEstimacion() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (data: CreateEstimacionDto) => estimacionesApi.create(data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: presupuestosKeys.estimaciones.all });
|
||||
toast.success('Estimacion creada exitosamente');
|
||||
},
|
||||
onError: handleError,
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdateEstimacion() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({ id, data }: { id: string; data: UpdateEstimacionDto }) =>
|
||||
estimacionesApi.update(id, data),
|
||||
onSuccess: (_, { id }) => {
|
||||
queryClient.invalidateQueries({ queryKey: presupuestosKeys.estimaciones.all });
|
||||
queryClient.invalidateQueries({ queryKey: presupuestosKeys.estimaciones.detail(id) });
|
||||
toast.success('Estimacion actualizada');
|
||||
},
|
||||
onError: handleError,
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteEstimacion() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (id: string) => estimacionesApi.delete(id),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: presupuestosKeys.estimaciones.all });
|
||||
toast.success('Estimacion eliminada');
|
||||
},
|
||||
onError: handleError,
|
||||
});
|
||||
}
|
||||
|
||||
// ==================== ESTIMACIONES WORKFLOW ====================
|
||||
|
||||
export function useSubmitEstimacion() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (id: string) => estimacionesApi.submit(id),
|
||||
onSuccess: (_, id) => {
|
||||
queryClient.invalidateQueries({ queryKey: presupuestosKeys.estimaciones.all });
|
||||
queryClient.invalidateQueries({ queryKey: presupuestosKeys.estimaciones.detail(id) });
|
||||
toast.success('Estimacion enviada a revision');
|
||||
},
|
||||
onError: handleError,
|
||||
});
|
||||
}
|
||||
|
||||
export function useApproveEstimacion() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({ id, montoAprobado }: { id: string; montoAprobado?: number }) =>
|
||||
estimacionesApi.approve(id, montoAprobado),
|
||||
onSuccess: (_, { id }) => {
|
||||
queryClient.invalidateQueries({ queryKey: presupuestosKeys.estimaciones.all });
|
||||
queryClient.invalidateQueries({ queryKey: presupuestosKeys.estimaciones.detail(id) });
|
||||
toast.success('Estimacion aprobada');
|
||||
},
|
||||
onError: handleError,
|
||||
});
|
||||
}
|
||||
|
||||
export function useRejectEstimacion() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({ id, motivo }: { id: string; motivo: string }) =>
|
||||
estimacionesApi.reject(id, motivo),
|
||||
onSuccess: (_, { id }) => {
|
||||
queryClient.invalidateQueries({ queryKey: presupuestosKeys.estimaciones.all });
|
||||
queryClient.invalidateQueries({ queryKey: presupuestosKeys.estimaciones.detail(id) });
|
||||
toast.success('Estimacion rechazada');
|
||||
},
|
||||
onError: handleError,
|
||||
});
|
||||
}
|
||||
|
||||
export function useMarkEstimacionInvoiced() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({ id, montoFacturado }: { id: string; montoFacturado: number }) =>
|
||||
estimacionesApi.markAsInvoiced(id, montoFacturado),
|
||||
onSuccess: (_, { id }) => {
|
||||
queryClient.invalidateQueries({ queryKey: presupuestosKeys.estimaciones.all });
|
||||
queryClient.invalidateQueries({ queryKey: presupuestosKeys.estimaciones.detail(id) });
|
||||
toast.success('Estimacion marcada como facturada');
|
||||
},
|
||||
onError: handleError,
|
||||
});
|
||||
}
|
||||
|
||||
export function useMarkEstimacionPaid() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({ id, montoCobrado }: { id: string; montoCobrado: number }) =>
|
||||
estimacionesApi.markAsPaid(id, montoCobrado),
|
||||
onSuccess: (_, { id }) => {
|
||||
queryClient.invalidateQueries({ queryKey: presupuestosKeys.estimaciones.all });
|
||||
queryClient.invalidateQueries({ queryKey: presupuestosKeys.estimaciones.detail(id) });
|
||||
toast.success('Estimacion marcada como cobrada');
|
||||
},
|
||||
onError: handleError,
|
||||
});
|
||||
}
|
||||
|
||||
// ==================== ESTIMACIONES PARTIDAS ====================
|
||||
|
||||
export function useAddEstimacionPartida() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({ estimacionId, data }: { estimacionId: string; data: CreateEstimacionPartidaDto }) =>
|
||||
estimacionesApi.addPartida(estimacionId, data),
|
||||
onSuccess: (_, { estimacionId }) => {
|
||||
queryClient.invalidateQueries({ queryKey: presupuestosKeys.estimaciones.detail(estimacionId) });
|
||||
toast.success('Partida agregada a la estimacion');
|
||||
},
|
||||
onError: handleError,
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdateEstimacionPartida() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({
|
||||
estimacionId,
|
||||
partidaId,
|
||||
data,
|
||||
}: {
|
||||
estimacionId: string;
|
||||
partidaId: string;
|
||||
data: UpdateEstimacionPartidaDto;
|
||||
}) => estimacionesApi.updatePartida(estimacionId, partidaId, data),
|
||||
onSuccess: (_, { estimacionId }) => {
|
||||
queryClient.invalidateQueries({ queryKey: presupuestosKeys.estimaciones.detail(estimacionId) });
|
||||
toast.success('Partida actualizada');
|
||||
},
|
||||
onError: handleError,
|
||||
});
|
||||
}
|
||||
|
||||
export function useRemoveEstimacionPartida() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({ estimacionId, partidaId }: { estimacionId: string; partidaId: string }) =>
|
||||
estimacionesApi.removePartida(estimacionId, partidaId),
|
||||
onSuccess: (_, { estimacionId }) => {
|
||||
queryClient.invalidateQueries({ queryKey: presupuestosKeys.estimaciones.detail(estimacionId) });
|
||||
toast.success('Partida eliminada de la estimacion');
|
||||
},
|
||||
onError: handleError,
|
||||
});
|
||||
}
|
||||
|
||||
// ==================== ESTIMACIONES GENERADORES ====================
|
||||
|
||||
export function useAddGenerador() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({
|
||||
estimacionId,
|
||||
partidaId,
|
||||
data,
|
||||
}: {
|
||||
estimacionId: string;
|
||||
partidaId: string;
|
||||
data: CreateGeneradorDto;
|
||||
}) => estimacionesApi.addGenerador(estimacionId, partidaId, data),
|
||||
onSuccess: (_, { estimacionId }) => {
|
||||
queryClient.invalidateQueries({ queryKey: presupuestosKeys.estimaciones.detail(estimacionId) });
|
||||
toast.success('Generador agregado');
|
||||
},
|
||||
onError: handleError,
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdateGenerador() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({
|
||||
estimacionId,
|
||||
partidaId,
|
||||
generadorId,
|
||||
data,
|
||||
}: {
|
||||
estimacionId: string;
|
||||
partidaId: string;
|
||||
generadorId: string;
|
||||
data: UpdateGeneradorDto;
|
||||
}) => estimacionesApi.updateGenerador(estimacionId, partidaId, generadorId, data),
|
||||
onSuccess: (_, { estimacionId }) => {
|
||||
queryClient.invalidateQueries({ queryKey: presupuestosKeys.estimaciones.detail(estimacionId) });
|
||||
toast.success('Generador actualizado');
|
||||
},
|
||||
onError: handleError,
|
||||
});
|
||||
}
|
||||
|
||||
export function useRemoveGenerador() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({
|
||||
estimacionId,
|
||||
partidaId,
|
||||
generadorId,
|
||||
}: {
|
||||
estimacionId: string;
|
||||
partidaId: string;
|
||||
generadorId: string;
|
||||
}) => estimacionesApi.removeGenerador(estimacionId, partidaId, generadorId),
|
||||
onSuccess: (_, { estimacionId }) => {
|
||||
queryClient.invalidateQueries({ queryKey: presupuestosKeys.estimaciones.detail(estimacionId) });
|
||||
toast.success('Generador eliminado');
|
||||
},
|
||||
onError: handleError,
|
||||
});
|
||||
}
|
||||
154
web/src/hooks/useReports.ts
Normal file
154
web/src/hooks/useReports.ts
Normal file
@ -0,0 +1,154 @@
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { AxiosError } from 'axios';
|
||||
import toast from 'react-hot-toast';
|
||||
import { ApiError } from '../services/api';
|
||||
import {
|
||||
reportsApi,
|
||||
DateRangeParams,
|
||||
ProjectSummaryFilters,
|
||||
AlertFilters,
|
||||
} from '../services/reports';
|
||||
|
||||
// =============================================================================
|
||||
// Query Keys Factory
|
||||
// =============================================================================
|
||||
|
||||
export const reportsKeys = {
|
||||
all: ['reports'] as const,
|
||||
earnedValue: (projectId: string, params?: DateRangeParams) =>
|
||||
[...reportsKeys.all, 'earnedValue', projectId, params] as const,
|
||||
sCurve: (projectId: string, params?: DateRangeParams) =>
|
||||
[...reportsKeys.all, 'sCurve', projectId, params] as const,
|
||||
projectsSummary: (filters?: ProjectSummaryFilters) =>
|
||||
[...reportsKeys.all, 'projectsSummary', filters] as const,
|
||||
dashboardStats: () => [...reportsKeys.all, 'dashboardStats'] as const,
|
||||
projectKPIs: (projectId: string, params?: DateRangeParams) =>
|
||||
[...reportsKeys.all, 'projectKPIs', projectId, params] as const,
|
||||
alerts: (filters?: AlertFilters) => [...reportsKeys.all, 'alerts', filters] as const,
|
||||
};
|
||||
|
||||
// =============================================================================
|
||||
// Error Handler
|
||||
// =============================================================================
|
||||
|
||||
const handleError = (error: AxiosError<ApiError>) => {
|
||||
const message = error.response?.data?.message || 'Ha ocurrido un error';
|
||||
toast.error(message);
|
||||
};
|
||||
|
||||
// =============================================================================
|
||||
// Query Hooks - Earned Value Management
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Get Earned Value metrics for a specific project
|
||||
* Returns SPI, CPI, EV, PV, AC, and other EVM indicators
|
||||
*/
|
||||
export function useEarnedValue(projectId: string, params?: DateRangeParams) {
|
||||
return useQuery({
|
||||
queryKey: reportsKeys.earnedValue(projectId, params),
|
||||
queryFn: () => reportsApi.getEarnedValue(projectId, params),
|
||||
enabled: !!projectId,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get S-Curve data for a specific project
|
||||
* Returns time series data for planned vs actual progress
|
||||
*/
|
||||
export function useSCurveData(projectId: string, params?: DateRangeParams) {
|
||||
return useQuery({
|
||||
queryKey: reportsKeys.sCurve(projectId, params),
|
||||
queryFn: () => reportsApi.getSCurveData(projectId, params),
|
||||
enabled: !!projectId,
|
||||
});
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Query Hooks - Dashboard
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Get summary of all projects with their KPIs
|
||||
* Supports filtering by status and search
|
||||
*/
|
||||
export function useProjectsSummary(filters?: ProjectSummaryFilters) {
|
||||
return useQuery({
|
||||
queryKey: reportsKeys.projectsSummary(filters),
|
||||
queryFn: () => reportsApi.getProjectsSummary(filters),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get general dashboard statistics
|
||||
* Returns aggregate metrics for all projects
|
||||
*/
|
||||
export function useDashboardStats() {
|
||||
return useQuery({
|
||||
queryKey: reportsKeys.dashboardStats(),
|
||||
queryFn: () => reportsApi.getDashboardStats(),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get KPIs for a specific project
|
||||
*/
|
||||
export function useProjectKPIs(projectId: string, params?: DateRangeParams) {
|
||||
return useQuery({
|
||||
queryKey: reportsKeys.projectKPIs(projectId, params),
|
||||
queryFn: () => reportsApi.getProjectKPIs(projectId, params),
|
||||
enabled: !!projectId,
|
||||
});
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Query Hooks - Alerts
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Get active alerts
|
||||
* Supports filtering by severity, project, and type
|
||||
*/
|
||||
export function useAlerts(filters?: AlertFilters) {
|
||||
return useQuery({
|
||||
queryKey: reportsKeys.alerts(filters),
|
||||
queryFn: () => reportsApi.getAlerts(filters),
|
||||
});
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Mutation Hooks - Alerts
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Acknowledge an alert
|
||||
* Marks the alert as seen without resolving it
|
||||
*/
|
||||
export function useAcknowledgeAlert() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (alertId: string) => reportsApi.acknowledgeAlert(alertId),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: reportsKeys.all });
|
||||
toast.success('Alerta reconocida');
|
||||
},
|
||||
onError: handleError,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve an alert
|
||||
* Marks the alert as resolved
|
||||
*/
|
||||
export function useResolveAlert() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (alertId: string) => reportsApi.resolveAlert(alertId),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: reportsKeys.all });
|
||||
queryClient.invalidateQueries({ queryKey: reportsKeys.dashboardStats() });
|
||||
toast.success('Alerta resuelta');
|
||||
},
|
||||
onError: handleError,
|
||||
});
|
||||
}
|
||||
@ -11,6 +11,15 @@ import {
|
||||
LogOut,
|
||||
User,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
LayoutDashboard,
|
||||
Calculator,
|
||||
FileText,
|
||||
Receipt,
|
||||
Target,
|
||||
FileCheck,
|
||||
Send,
|
||||
Users,
|
||||
} from 'lucide-react';
|
||||
import clsx from 'clsx';
|
||||
import { useAuthStore } from '../stores/authStore';
|
||||
@ -21,17 +30,62 @@ interface NavItem {
|
||||
icon: React.ComponentType<{ className?: string }>;
|
||||
}
|
||||
|
||||
const navItems: NavItem[] = [
|
||||
{ label: 'Fraccionamientos', href: '/admin/proyectos/fraccionamientos', icon: Building2 },
|
||||
{ label: 'Etapas', href: '/admin/proyectos/etapas', icon: Layers },
|
||||
{ label: 'Manzanas', href: '/admin/proyectos/manzanas', icon: LayoutGrid },
|
||||
{ label: 'Lotes', href: '/admin/proyectos/lotes', icon: Map },
|
||||
{ label: 'Prototipos', href: '/admin/proyectos/prototipos', icon: Home },
|
||||
interface NavSection {
|
||||
title: string;
|
||||
items: NavItem[];
|
||||
defaultOpen?: boolean;
|
||||
}
|
||||
|
||||
const navSections: NavSection[] = [
|
||||
{
|
||||
title: 'General',
|
||||
defaultOpen: true,
|
||||
items: [
|
||||
{ label: 'Dashboard', href: '/admin/dashboard', icon: LayoutDashboard },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Proyectos',
|
||||
defaultOpen: true,
|
||||
items: [
|
||||
{ label: 'Fraccionamientos', href: '/admin/proyectos/fraccionamientos', icon: Building2 },
|
||||
{ label: 'Etapas', href: '/admin/proyectos/etapas', icon: Layers },
|
||||
{ label: 'Manzanas', href: '/admin/proyectos/manzanas', icon: LayoutGrid },
|
||||
{ label: 'Lotes', href: '/admin/proyectos/lotes', icon: Map },
|
||||
{ label: 'Prototipos', href: '/admin/proyectos/prototipos', icon: Home },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Presupuestos',
|
||||
defaultOpen: false,
|
||||
items: [
|
||||
{ label: 'Catalogo Conceptos', href: '/admin/presupuestos/catalogo', icon: Calculator },
|
||||
{ label: 'Presupuestos', href: '/admin/presupuestos/presupuestos', icon: FileText },
|
||||
{ label: 'Estimaciones', href: '/admin/presupuestos/estimaciones', icon: Receipt },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Licitaciones',
|
||||
defaultOpen: false,
|
||||
items: [
|
||||
{ label: 'Oportunidades', href: '/admin/licitaciones/oportunidades', icon: Target },
|
||||
{ label: 'Concursos', href: '/admin/licitaciones/concursos', icon: FileCheck },
|
||||
{ label: 'Propuestas', href: '/admin/licitaciones/propuestas', icon: Send },
|
||||
{ label: 'Proveedores', href: '/admin/licitaciones/proveedores', icon: Users },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export function AdminLayout() {
|
||||
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||
const [userMenuOpen, setUserMenuOpen] = useState(false);
|
||||
const [expandedSections, setExpandedSections] = useState<Record<string, boolean>>(() => {
|
||||
const initial: Record<string, boolean> = {};
|
||||
navSections.forEach(section => {
|
||||
initial[section.title] = section.defaultOpen ?? false;
|
||||
});
|
||||
return initial;
|
||||
});
|
||||
const location = useLocation();
|
||||
const { user, logout } = useAuthStore();
|
||||
|
||||
@ -40,6 +94,17 @@ export function AdminLayout() {
|
||||
window.location.href = '/auth/login';
|
||||
};
|
||||
|
||||
const toggleSection = (title: string) => {
|
||||
setExpandedSections(prev => ({
|
||||
...prev,
|
||||
[title]: !prev[title],
|
||||
}));
|
||||
};
|
||||
|
||||
const isSectionActive = (section: NavSection) => {
|
||||
return section.items.some(item => location.pathname.startsWith(item.href));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-100">
|
||||
{/* Mobile sidebar backdrop */}
|
||||
@ -70,34 +135,55 @@ export function AdminLayout() {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<nav className="px-4 py-6">
|
||||
<div className="mb-4">
|
||||
<h3 className="px-3 text-xs font-semibold text-gray-500 uppercase tracking-wider">
|
||||
Proyectos
|
||||
</h3>
|
||||
</div>
|
||||
<ul className="space-y-1">
|
||||
{navItems.map((item) => {
|
||||
const isActive = location.pathname === item.href;
|
||||
const Icon = item.icon;
|
||||
return (
|
||||
<li key={item.href}>
|
||||
<Link
|
||||
to={item.href}
|
||||
className={clsx(
|
||||
'flex items-center px-3 py-2 rounded-lg transition-colors',
|
||||
isActive
|
||||
? 'bg-blue-50 text-blue-700'
|
||||
: 'text-gray-700 hover:bg-gray-100'
|
||||
)}
|
||||
>
|
||||
<Icon className="w-5 h-5 mr-3" />
|
||||
{item.label}
|
||||
</Link>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
<nav className="px-4 py-4 overflow-y-auto h-[calc(100vh-4rem)]">
|
||||
{navSections.map((section) => {
|
||||
const isExpanded = expandedSections[section.title];
|
||||
const isActive = isSectionActive(section);
|
||||
|
||||
return (
|
||||
<div key={section.title} className="mb-2">
|
||||
<button
|
||||
onClick={() => toggleSection(section.title)}
|
||||
className={clsx(
|
||||
'flex items-center justify-between w-full px-3 py-2 text-xs font-semibold uppercase tracking-wider rounded-lg transition-colors',
|
||||
isActive ? 'text-blue-700 bg-blue-50' : 'text-gray-500 hover:bg-gray-50'
|
||||
)}
|
||||
>
|
||||
{section.title}
|
||||
{isExpanded ? (
|
||||
<ChevronDown className="w-4 h-4" />
|
||||
) : (
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
{isExpanded && (
|
||||
<ul className="mt-1 space-y-1">
|
||||
{section.items.map((item) => {
|
||||
const isItemActive = location.pathname === item.href;
|
||||
const Icon = item.icon;
|
||||
return (
|
||||
<li key={item.href}>
|
||||
<Link
|
||||
to={item.href}
|
||||
className={clsx(
|
||||
'flex items-center px-3 py-2 rounded-lg transition-colors text-sm',
|
||||
isItemActive
|
||||
? 'bg-blue-100 text-blue-700 font-medium'
|
||||
: 'text-gray-700 hover:bg-gray-100'
|
||||
)}
|
||||
>
|
||||
<Icon className="w-4 h-4 mr-3" />
|
||||
{item.label}
|
||||
</Link>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
</aside>
|
||||
|
||||
|
||||
451
web/src/pages/admin/bidding/OpportunitiesPage.tsx
Normal file
451
web/src/pages/admin/bidding/OpportunitiesPage.tsx
Normal file
@ -0,0 +1,451 @@
|
||||
import { useState } from 'react';
|
||||
import { Plus, Pencil, Trash2, Search, ChevronDown } from 'lucide-react';
|
||||
import {
|
||||
useOpportunities,
|
||||
useDeleteOpportunity,
|
||||
useCreateOpportunity,
|
||||
useUpdateOpportunity,
|
||||
useUpdateOpportunityStatus,
|
||||
} from '../../../hooks/useBidding';
|
||||
import {
|
||||
Opportunity,
|
||||
OpportunityStatus,
|
||||
CreateOpportunityDto,
|
||||
} from '../../../services/bidding';
|
||||
import clsx from 'clsx';
|
||||
|
||||
const statusColors: Record<OpportunityStatus, string> = {
|
||||
lead: 'bg-gray-100 text-gray-800',
|
||||
qualified: 'bg-blue-100 text-blue-800',
|
||||
proposal: 'bg-purple-100 text-purple-800',
|
||||
negotiation: 'bg-yellow-100 text-yellow-800',
|
||||
won: 'bg-green-100 text-green-800',
|
||||
lost: 'bg-red-100 text-red-800',
|
||||
};
|
||||
|
||||
const statusLabels: Record<OpportunityStatus, string> = {
|
||||
lead: 'Lead',
|
||||
qualified: 'Calificado',
|
||||
proposal: 'Propuesta',
|
||||
negotiation: 'Negociacion',
|
||||
won: 'Ganado',
|
||||
lost: 'Perdido',
|
||||
};
|
||||
|
||||
const allStatuses: OpportunityStatus[] = ['lead', 'qualified', 'proposal', 'negotiation', 'won', 'lost'];
|
||||
|
||||
export function OpportunitiesPage() {
|
||||
const [search, setSearch] = useState('');
|
||||
const [statusFilter, setStatusFilter] = useState<OpportunityStatus | ''>('');
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
const [editingItem, setEditingItem] = useState<Opportunity | null>(null);
|
||||
const [deleteConfirm, setDeleteConfirm] = useState<string | null>(null);
|
||||
const [statusDropdown, setStatusDropdown] = useState<string | null>(null);
|
||||
|
||||
const { data, isLoading, error } = useOpportunities({
|
||||
status: statusFilter || undefined,
|
||||
clientName: search || undefined,
|
||||
});
|
||||
|
||||
const deleteMutation = useDeleteOpportunity();
|
||||
const createMutation = useCreateOpportunity();
|
||||
const updateMutation = useUpdateOpportunity();
|
||||
const updateStatusMutation = useUpdateOpportunityStatus();
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
await deleteMutation.mutateAsync(id);
|
||||
setDeleteConfirm(null);
|
||||
};
|
||||
|
||||
const handleSubmit = async (formData: CreateOpportunityDto) => {
|
||||
if (editingItem) {
|
||||
await updateMutation.mutateAsync({ id: editingItem.id, data: formData });
|
||||
} else {
|
||||
await createMutation.mutateAsync(formData);
|
||||
}
|
||||
setShowModal(false);
|
||||
setEditingItem(null);
|
||||
};
|
||||
|
||||
const handleStatusChange = async (id: string, status: OpportunityStatus) => {
|
||||
await updateStatusMutation.mutateAsync({ id, status });
|
||||
setStatusDropdown(null);
|
||||
};
|
||||
|
||||
const opportunities = data?.items || [];
|
||||
|
||||
const formatCurrency = (value: number) => {
|
||||
return new Intl.NumberFormat('es-MX', {
|
||||
style: 'currency',
|
||||
currency: 'MXN',
|
||||
}).format(value);
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">Oportunidades</h1>
|
||||
<p className="text-gray-600">Gestion del pipeline de ventas</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={() => {
|
||||
setEditingItem(null);
|
||||
setShowModal(true);
|
||||
}}
|
||||
>
|
||||
<Plus className="w-5 h-5 mr-2" />
|
||||
Nueva Oportunidad
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="bg-white rounded-lg shadow-sm p-4 mb-6">
|
||||
<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 por cliente..."
|
||||
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={statusFilter}
|
||||
onChange={(e) => setStatusFilter(e.target.value as OpportunityStatus | '')}
|
||||
>
|
||||
<option value="">Todos los estados</option>
|
||||
{allStatuses.map((status) => (
|
||||
<option key={status} value={status}>
|
||||
{statusLabels[status]}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Table */}
|
||||
<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>
|
||||
) : opportunities.length === 0 ? (
|
||||
<div className="p-8 text-center text-gray-500">No hay oportunidades</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">
|
||||
Titulo
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
|
||||
Cliente
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
|
||||
Valor Estimado
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
|
||||
Probabilidad
|
||||
</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-left text-xs font-medium text-gray-500 uppercase">
|
||||
Cierre Esperado
|
||||
</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">
|
||||
{opportunities.map((item) => (
|
||||
<tr key={item.id} className="hover:bg-gray-50">
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">
|
||||
{item.title}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
|
||||
{item.clientName}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
|
||||
{formatCurrency(item.estimatedValue)}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
|
||||
{item.probability}%
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap relative">
|
||||
<div className="relative inline-block">
|
||||
<button
|
||||
onClick={() => setStatusDropdown(statusDropdown === item.id ? null : item.id)}
|
||||
className={clsx(
|
||||
'px-2 py-1 text-xs font-medium rounded-full flex items-center gap-1',
|
||||
statusColors[item.status]
|
||||
)}
|
||||
>
|
||||
{statusLabels[item.status]}
|
||||
<ChevronDown className="w-3 h-3" />
|
||||
</button>
|
||||
{statusDropdown === item.id && (
|
||||
<div className="absolute z-10 mt-1 bg-white border rounded-lg shadow-lg py-1 min-w-[140px]">
|
||||
{allStatuses.map((status) => (
|
||||
<button
|
||||
key={status}
|
||||
className={clsx(
|
||||
'w-full px-3 py-1.5 text-left text-sm hover:bg-gray-100',
|
||||
status === item.status && 'bg-gray-50 font-medium'
|
||||
)}
|
||||
onClick={() => handleStatusChange(item.id, status)}
|
||||
>
|
||||
<span
|
||||
className={clsx(
|
||||
'inline-block w-2 h-2 rounded-full mr-2',
|
||||
statusColors[status].replace('bg-', 'bg-').split(' ')[0]
|
||||
)}
|
||||
/>
|
||||
{statusLabels[status]}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
{item.expectedCloseDate
|
||||
? new Date(item.expectedCloseDate).toLocaleDateString()
|
||||
: '-'}
|
||||
</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="Editar"
|
||||
onClick={() => {
|
||||
setEditingItem(item);
|
||||
setShowModal(true);
|
||||
}}
|
||||
>
|
||||
<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={() => setDeleteConfirm(item.id)}
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Modal */}
|
||||
{showModal && (
|
||||
<OpportunityModal
|
||||
item={editingItem}
|
||||
onClose={() => {
|
||||
setShowModal(false);
|
||||
setEditingItem(null);
|
||||
}}
|
||||
onSubmit={handleSubmit}
|
||||
isLoading={createMutation.isPending || updateMutation.isPending}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Delete Confirmation */}
|
||||
{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 oportunidad? 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"
|
||||
onClick={() => handleDelete(deleteConfirm)}
|
||||
disabled={deleteMutation.isPending}
|
||||
>
|
||||
{deleteMutation.isPending ? 'Eliminando...' : 'Eliminar'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Modal Component
|
||||
interface OpportunityModalProps {
|
||||
item: Opportunity | null;
|
||||
onClose: () => void;
|
||||
onSubmit: (data: CreateOpportunityDto) => Promise<void>;
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
function OpportunityModal({ item, onClose, onSubmit, isLoading }: OpportunityModalProps) {
|
||||
const [formData, setFormData] = useState<CreateOpportunityDto>({
|
||||
title: item?.title || '',
|
||||
clientName: item?.clientName || '',
|
||||
estimatedValue: item?.estimatedValue || 0,
|
||||
probability: item?.probability || 50,
|
||||
description: item?.description || '',
|
||||
status: item?.status || 'lead',
|
||||
expectedCloseDate: item?.expectedCloseDate?.split('T')[0] || '',
|
||||
source: item?.source || '',
|
||||
});
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
await onSubmit(formData);
|
||||
};
|
||||
|
||||
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-lg w-full mx-4 max-h-[90vh] overflow-y-auto">
|
||||
<h3 className="text-lg font-semibold mb-4">
|
||||
{item ? 'Editar Oportunidad' : 'Nueva Oportunidad'}
|
||||
</h3>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Titulo *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500"
|
||||
value={formData.title}
|
||||
onChange={(e) => setFormData({ ...formData, title: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Cliente *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500"
|
||||
value={formData.clientName}
|
||||
onChange={(e) => setFormData({ ...formData, clientName: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Valor Estimado *
|
||||
</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.estimatedValue}
|
||||
onChange={(e) => setFormData({ ...formData, estimatedValue: parseFloat(e.target.value) || 0 })}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Probabilidad (%) *
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
required
|
||||
min="0"
|
||||
max="100"
|
||||
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500"
|
||||
value={formData.probability}
|
||||
onChange={(e) => setFormData({ ...formData, probability: parseInt(e.target.value) || 0 })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Estado
|
||||
</label>
|
||||
<select
|
||||
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500"
|
||||
value={formData.status}
|
||||
onChange={(e) => setFormData({ ...formData, status: e.target.value as OpportunityStatus })}
|
||||
>
|
||||
{allStatuses.map((status) => (
|
||||
<option key={status} value={status}>
|
||||
{statusLabels[status]}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Fecha Cierre Esperado
|
||||
</label>
|
||||
<input
|
||||
type="date"
|
||||
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500"
|
||||
value={formData.expectedCloseDate}
|
||||
onChange={(e) => setFormData({ ...formData, expectedCloseDate: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Fuente
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500"
|
||||
placeholder="Referido, Web, Llamada, etc."
|
||||
value={formData.source}
|
||||
onChange={(e) => setFormData({ ...formData, source: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Descripcion
|
||||
</label>
|
||||
<textarea
|
||||
rows={3}
|
||||
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500"
|
||||
value={formData.description}
|
||||
onChange={(e) => setFormData({ ...formData, description: 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...' : item ? 'Guardar Cambios' : 'Crear'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
451
web/src/pages/admin/bidding/ProposalsPage.tsx
Normal file
451
web/src/pages/admin/bidding/ProposalsPage.tsx
Normal file
@ -0,0 +1,451 @@
|
||||
import { useState } from 'react';
|
||||
import { Plus, Pencil, Trash2, Search, Send } from 'lucide-react';
|
||||
import {
|
||||
useProposals,
|
||||
useDeleteProposal,
|
||||
useCreateProposal,
|
||||
useUpdateProposal,
|
||||
useSubmitProposal,
|
||||
useTenders,
|
||||
} from '../../../hooks/useBidding';
|
||||
import {
|
||||
Proposal,
|
||||
ProposalStatus,
|
||||
CreateProposalDto,
|
||||
} from '../../../services/bidding';
|
||||
import clsx from 'clsx';
|
||||
|
||||
const statusColors: Record<ProposalStatus, string> = {
|
||||
draft: 'bg-gray-100 text-gray-800',
|
||||
submitted: 'bg-blue-100 text-blue-800',
|
||||
under_review: 'bg-yellow-100 text-yellow-800',
|
||||
accepted: 'bg-green-100 text-green-800',
|
||||
rejected: 'bg-red-100 text-red-800',
|
||||
};
|
||||
|
||||
const statusLabels: Record<ProposalStatus, string> = {
|
||||
draft: 'Borrador',
|
||||
submitted: 'Enviada',
|
||||
under_review: 'En Revision',
|
||||
accepted: 'Aceptada',
|
||||
rejected: 'Rechazada',
|
||||
};
|
||||
|
||||
const allStatuses: ProposalStatus[] = ['draft', 'submitted', 'under_review', 'accepted', 'rejected'];
|
||||
|
||||
export function ProposalsPage() {
|
||||
const [search, setSearch] = useState('');
|
||||
const [statusFilter, setStatusFilter] = useState<ProposalStatus | ''>('');
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
const [editingItem, setEditingItem] = useState<Proposal | null>(null);
|
||||
const [deleteConfirm, setDeleteConfirm] = useState<string | null>(null);
|
||||
const [submitConfirm, setSubmitConfirm] = useState<string | null>(null);
|
||||
|
||||
const { data, isLoading, error } = useProposals({
|
||||
status: statusFilter || undefined,
|
||||
});
|
||||
|
||||
const deleteMutation = useDeleteProposal();
|
||||
const createMutation = useCreateProposal();
|
||||
const updateMutation = useUpdateProposal();
|
||||
const submitMutation = useSubmitProposal();
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
await deleteMutation.mutateAsync(id);
|
||||
setDeleteConfirm(null);
|
||||
};
|
||||
|
||||
const handleSubmit = async (formData: CreateProposalDto) => {
|
||||
if (editingItem) {
|
||||
await updateMutation.mutateAsync({ id: editingItem.id, data: formData });
|
||||
} else {
|
||||
await createMutation.mutateAsync(formData);
|
||||
}
|
||||
setShowModal(false);
|
||||
setEditingItem(null);
|
||||
};
|
||||
|
||||
const handleSubmitProposal = async (id: string) => {
|
||||
await submitMutation.mutateAsync(id);
|
||||
setSubmitConfirm(null);
|
||||
};
|
||||
|
||||
const proposals = data?.items || [];
|
||||
const filteredProposals = search
|
||||
? proposals.filter((p) =>
|
||||
p.proposalNumber.toLowerCase().includes(search.toLowerCase()) ||
|
||||
p.tender?.title.toLowerCase().includes(search.toLowerCase())
|
||||
)
|
||||
: proposals;
|
||||
|
||||
const formatCurrency = (value: number) => {
|
||||
return new Intl.NumberFormat('es-MX', {
|
||||
style: 'currency',
|
||||
currency: 'MXN',
|
||||
}).format(value);
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">Propuestas</h1>
|
||||
<p className="text-gray-600">Gestion de propuestas tecnicas y economicas</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={() => {
|
||||
setEditingItem(null);
|
||||
setShowModal(true);
|
||||
}}
|
||||
>
|
||||
<Plus className="w-5 h-5 mr-2" />
|
||||
Nueva Propuesta
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="bg-white rounded-lg shadow-sm p-4 mb-6">
|
||||
<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 por numero o licitacion..."
|
||||
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={statusFilter}
|
||||
onChange={(e) => setStatusFilter(e.target.value as ProposalStatus | '')}
|
||||
>
|
||||
<option value="">Todos los estados</option>
|
||||
{allStatuses.map((status) => (
|
||||
<option key={status} value={status}>
|
||||
{statusLabels[status]}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Table */}
|
||||
<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>
|
||||
) : filteredProposals.length === 0 ? (
|
||||
<div className="p-8 text-center text-gray-500">No hay propuestas</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">
|
||||
No. Propuesta
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
|
||||
Licitacion
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
|
||||
Monto Total
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
|
||||
Punt. Tecnico
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
|
||||
Punt. Economico
|
||||
</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">
|
||||
{filteredProposals.map((item) => (
|
||||
<tr key={item.id} className="hover:bg-gray-50">
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">
|
||||
{item.proposalNumber}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900 max-w-xs truncate">
|
||||
{item.tender?.title || '-'}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
|
||||
{formatCurrency(item.totalAmount)}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
|
||||
{item.technicalScore !== undefined ? `${item.technicalScore} pts` : '-'}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
|
||||
{item.economicScore !== undefined ? `${item.economicScore} pts` : '-'}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<span
|
||||
className={clsx(
|
||||
'px-2 py-1 text-xs font-medium rounded-full',
|
||||
statusColors[item.status]
|
||||
)}
|
||||
>
|
||||
{statusLabels[item.status]}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-right">
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
{item.status === 'draft' && (
|
||||
<button
|
||||
className="p-2 text-gray-500 hover:text-green-600 hover:bg-green-50 rounded-lg"
|
||||
title="Enviar propuesta"
|
||||
onClick={() => setSubmitConfirm(item.id)}
|
||||
>
|
||||
<Send className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
className="p-2 text-gray-500 hover:text-blue-600 hover:bg-blue-50 rounded-lg"
|
||||
title="Editar"
|
||||
onClick={() => {
|
||||
setEditingItem(item);
|
||||
setShowModal(true);
|
||||
}}
|
||||
>
|
||||
<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={() => setDeleteConfirm(item.id)}
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Modal */}
|
||||
{showModal && (
|
||||
<ProposalModal
|
||||
item={editingItem}
|
||||
onClose={() => {
|
||||
setShowModal(false);
|
||||
setEditingItem(null);
|
||||
}}
|
||||
onSubmit={handleSubmit}
|
||||
isLoading={createMutation.isPending || updateMutation.isPending}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Submit Confirmation */}
|
||||
{submitConfirm && (
|
||||
<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 envio</h3>
|
||||
<p className="text-gray-600 mb-6">
|
||||
¿Esta seguro de enviar esta propuesta? Una vez enviada no podra modificarse.
|
||||
</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={() => setSubmitConfirm(null)}
|
||||
>
|
||||
Cancelar
|
||||
</button>
|
||||
<button
|
||||
className="px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700"
|
||||
onClick={() => handleSubmitProposal(submitConfirm)}
|
||||
disabled={submitMutation.isPending}
|
||||
>
|
||||
{submitMutation.isPending ? 'Enviando...' : 'Enviar Propuesta'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Delete Confirmation */}
|
||||
{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 propuesta? 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"
|
||||
onClick={() => handleDelete(deleteConfirm)}
|
||||
disabled={deleteMutation.isPending}
|
||||
>
|
||||
{deleteMutation.isPending ? 'Eliminando...' : 'Eliminar'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Modal Component
|
||||
interface ProposalModalProps {
|
||||
item: Proposal | null;
|
||||
onClose: () => void;
|
||||
onSubmit: (data: CreateProposalDto) => Promise<void>;
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
function ProposalModal({ item, onClose, onSubmit, isLoading }: ProposalModalProps) {
|
||||
const { data: tendersData } = useTenders({ status: 'published' });
|
||||
const tenders = tendersData?.items || [];
|
||||
|
||||
const [formData, setFormData] = useState<CreateProposalDto>({
|
||||
tenderId: item?.tenderId || '',
|
||||
proposalNumber: item?.proposalNumber || '',
|
||||
totalAmount: item?.totalAmount || 0,
|
||||
technicalScore: item?.technicalScore,
|
||||
economicScore: item?.economicScore,
|
||||
status: item?.status || 'draft',
|
||||
});
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
await onSubmit(formData);
|
||||
};
|
||||
|
||||
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-lg w-full mx-4 max-h-[90vh] overflow-y-auto">
|
||||
<h3 className="text-lg font-semibold mb-4">
|
||||
{item ? 'Editar Propuesta' : 'Nueva Propuesta'}
|
||||
</h3>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Licitacion *
|
||||
</label>
|
||||
<select
|
||||
required
|
||||
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500"
|
||||
value={formData.tenderId}
|
||||
onChange={(e) => setFormData({ ...formData, tenderId: e.target.value })}
|
||||
disabled={!!item}
|
||||
>
|
||||
<option value="">Seleccionar licitacion</option>
|
||||
{tenders.map((tender) => (
|
||||
<option key={tender.id} value={tender.id}>
|
||||
{tender.referenceNumber} - {tender.title}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
No. Propuesta *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500"
|
||||
value={formData.proposalNumber}
|
||||
onChange={(e) => setFormData({ ...formData, proposalNumber: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Estado
|
||||
</label>
|
||||
<select
|
||||
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500"
|
||||
value={formData.status}
|
||||
onChange={(e) => setFormData({ ...formData, status: e.target.value as ProposalStatus })}
|
||||
>
|
||||
{allStatuses.map((status) => (
|
||||
<option key={status} value={status}>
|
||||
{statusLabels[status]}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Monto Total *
|
||||
</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.totalAmount}
|
||||
onChange={(e) => setFormData({ ...formData, totalAmount: parseFloat(e.target.value) || 0 })}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Puntaje Tecnico
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
max="100"
|
||||
step="0.01"
|
||||
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500"
|
||||
value={formData.technicalScore || ''}
|
||||
onChange={(e) => setFormData({ ...formData, technicalScore: e.target.value ? parseFloat(e.target.value) : undefined })}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Puntaje Economico
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
max="100"
|
||||
step="0.01"
|
||||
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500"
|
||||
value={formData.economicScore || ''}
|
||||
onChange={(e) => setFormData({ ...formData, economicScore: e.target.value ? parseFloat(e.target.value) : undefined })}
|
||||
/>
|
||||
</div>
|
||||
</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'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
451
web/src/pages/admin/bidding/TendersPage.tsx
Normal file
451
web/src/pages/admin/bidding/TendersPage.tsx
Normal file
@ -0,0 +1,451 @@
|
||||
import { useState } from 'react';
|
||||
import { Plus, Pencil, Trash2, Search, ChevronDown } from 'lucide-react';
|
||||
import {
|
||||
useTenders,
|
||||
useDeleteTender,
|
||||
useCreateTender,
|
||||
useUpdateTender,
|
||||
useUpdateTenderStatus,
|
||||
} from '../../../hooks/useBidding';
|
||||
import {
|
||||
Tender,
|
||||
TenderStatus,
|
||||
CreateTenderDto,
|
||||
} from '../../../services/bidding';
|
||||
import clsx from 'clsx';
|
||||
|
||||
const statusColors: Record<TenderStatus, string> = {
|
||||
draft: 'bg-gray-100 text-gray-800',
|
||||
published: 'bg-blue-100 text-blue-800',
|
||||
evaluation: 'bg-yellow-100 text-yellow-800',
|
||||
awarded: 'bg-green-100 text-green-800',
|
||||
cancelled: 'bg-red-100 text-red-800',
|
||||
};
|
||||
|
||||
const statusLabels: Record<TenderStatus, string> = {
|
||||
draft: 'Borrador',
|
||||
published: 'Publicada',
|
||||
evaluation: 'En Evaluacion',
|
||||
awarded: 'Adjudicada',
|
||||
cancelled: 'Cancelada',
|
||||
};
|
||||
|
||||
const allStatuses: TenderStatus[] = ['draft', 'published', 'evaluation', 'awarded', 'cancelled'];
|
||||
|
||||
export function TendersPage() {
|
||||
const [search, setSearch] = useState('');
|
||||
const [statusFilter, setStatusFilter] = useState<TenderStatus | ''>('');
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
const [editingItem, setEditingItem] = useState<Tender | null>(null);
|
||||
const [deleteConfirm, setDeleteConfirm] = useState<string | null>(null);
|
||||
const [statusDropdown, setStatusDropdown] = useState<string | null>(null);
|
||||
|
||||
const { data, isLoading, error } = useTenders({
|
||||
status: statusFilter || undefined,
|
||||
issuingEntity: search || undefined,
|
||||
});
|
||||
|
||||
const deleteMutation = useDeleteTender();
|
||||
const createMutation = useCreateTender();
|
||||
const updateMutation = useUpdateTender();
|
||||
const updateStatusMutation = useUpdateTenderStatus();
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
await deleteMutation.mutateAsync(id);
|
||||
setDeleteConfirm(null);
|
||||
};
|
||||
|
||||
const handleSubmit = async (formData: CreateTenderDto) => {
|
||||
if (editingItem) {
|
||||
await updateMutation.mutateAsync({ id: editingItem.id, data: formData });
|
||||
} else {
|
||||
await createMutation.mutateAsync(formData);
|
||||
}
|
||||
setShowModal(false);
|
||||
setEditingItem(null);
|
||||
};
|
||||
|
||||
const handleStatusChange = async (id: string, status: TenderStatus) => {
|
||||
await updateStatusMutation.mutateAsync({ id, status });
|
||||
setStatusDropdown(null);
|
||||
};
|
||||
|
||||
const tenders = data?.items || [];
|
||||
|
||||
const formatCurrency = (value: number | undefined) => {
|
||||
if (value === undefined || value === null) return '-';
|
||||
return new Intl.NumberFormat('es-MX', {
|
||||
style: 'currency',
|
||||
currency: 'MXN',
|
||||
}).format(value);
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">Licitaciones</h1>
|
||||
<p className="text-gray-600">Gestion de licitaciones y concursos</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={() => {
|
||||
setEditingItem(null);
|
||||
setShowModal(true);
|
||||
}}
|
||||
>
|
||||
<Plus className="w-5 h-5 mr-2" />
|
||||
Nueva Licitacion
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="bg-white rounded-lg shadow-sm p-4 mb-6">
|
||||
<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 por entidad emisora..."
|
||||
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={statusFilter}
|
||||
onChange={(e) => setStatusFilter(e.target.value as TenderStatus | '')}
|
||||
>
|
||||
<option value="">Todos los estados</option>
|
||||
{allStatuses.map((status) => (
|
||||
<option key={status} value={status}>
|
||||
{statusLabels[status]}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Table */}
|
||||
<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>
|
||||
) : tenders.length === 0 ? (
|
||||
<div className="p-8 text-center text-gray-500">No hay licitaciones</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">
|
||||
No. Referencia
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
|
||||
Titulo
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
|
||||
Entidad Emisora
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
|
||||
Presupuesto Est.
|
||||
</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-left text-xs font-medium text-gray-500 uppercase">
|
||||
Fecha Limite
|
||||
</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">
|
||||
{tenders.map((item) => (
|
||||
<tr key={item.id} className="hover:bg-gray-50">
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">
|
||||
{item.referenceNumber}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900 max-w-xs truncate">
|
||||
{item.title}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
|
||||
{item.issuingEntity}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
|
||||
{formatCurrency(item.estimatedBudget)}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap relative">
|
||||
<div className="relative inline-block">
|
||||
<button
|
||||
onClick={() => setStatusDropdown(statusDropdown === item.id ? null : item.id)}
|
||||
className={clsx(
|
||||
'px-2 py-1 text-xs font-medium rounded-full flex items-center gap-1',
|
||||
statusColors[item.status]
|
||||
)}
|
||||
>
|
||||
{statusLabels[item.status]}
|
||||
<ChevronDown className="w-3 h-3" />
|
||||
</button>
|
||||
{statusDropdown === item.id && (
|
||||
<div className="absolute z-10 mt-1 bg-white border rounded-lg shadow-lg py-1 min-w-[140px]">
|
||||
{allStatuses.map((status) => (
|
||||
<button
|
||||
key={status}
|
||||
className={clsx(
|
||||
'w-full px-3 py-1.5 text-left text-sm hover:bg-gray-100',
|
||||
status === item.status && 'bg-gray-50 font-medium'
|
||||
)}
|
||||
onClick={() => handleStatusChange(item.id, status)}
|
||||
>
|
||||
{statusLabels[status]}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
{new Date(item.submissionDeadline).toLocaleDateString()}
|
||||
</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="Editar"
|
||||
onClick={() => {
|
||||
setEditingItem(item);
|
||||
setShowModal(true);
|
||||
}}
|
||||
>
|
||||
<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={() => setDeleteConfirm(item.id)}
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Modal */}
|
||||
{showModal && (
|
||||
<TenderModal
|
||||
item={editingItem}
|
||||
onClose={() => {
|
||||
setShowModal(false);
|
||||
setEditingItem(null);
|
||||
}}
|
||||
onSubmit={handleSubmit}
|
||||
isLoading={createMutation.isPending || updateMutation.isPending}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Delete Confirmation */}
|
||||
{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 licitacion? 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"
|
||||
onClick={() => handleDelete(deleteConfirm)}
|
||||
disabled={deleteMutation.isPending}
|
||||
>
|
||||
{deleteMutation.isPending ? 'Eliminando...' : 'Eliminar'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Modal Component
|
||||
interface TenderModalProps {
|
||||
item: Tender | null;
|
||||
onClose: () => void;
|
||||
onSubmit: (data: CreateTenderDto) => Promise<void>;
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
function TenderModal({ item, onClose, onSubmit, isLoading }: TenderModalProps) {
|
||||
const [formData, setFormData] = useState<CreateTenderDto>({
|
||||
referenceNumber: item?.referenceNumber || '',
|
||||
title: item?.title || '',
|
||||
issuingEntity: item?.issuingEntity || '',
|
||||
submissionDeadline: item?.submissionDeadline?.split('T')[0] || '',
|
||||
description: item?.description || '',
|
||||
estimatedBudget: item?.estimatedBudget || undefined,
|
||||
status: item?.status || 'draft',
|
||||
publicationDate: item?.publicationDate?.split('T')[0] || '',
|
||||
openingDate: item?.openingDate?.split('T')[0] || '',
|
||||
});
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
await onSubmit(formData);
|
||||
};
|
||||
|
||||
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-lg w-full mx-4 max-h-[90vh] overflow-y-auto">
|
||||
<h3 className="text-lg font-semibold mb-4">
|
||||
{item ? 'Editar Licitacion' : 'Nueva Licitacion'}
|
||||
</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">
|
||||
No. Referencia *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500"
|
||||
value={formData.referenceNumber}
|
||||
onChange={(e) => setFormData({ ...formData, referenceNumber: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Estado
|
||||
</label>
|
||||
<select
|
||||
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500"
|
||||
value={formData.status}
|
||||
onChange={(e) => setFormData({ ...formData, status: e.target.value as TenderStatus })}
|
||||
>
|
||||
{allStatuses.map((status) => (
|
||||
<option key={status} value={status}>
|
||||
{statusLabels[status]}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Titulo *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500"
|
||||
value={formData.title}
|
||||
onChange={(e) => setFormData({ ...formData, title: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Entidad Emisora *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500"
|
||||
value={formData.issuingEntity}
|
||||
onChange={(e) => setFormData({ ...formData, issuingEntity: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Presupuesto Estimado
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
step="0.01"
|
||||
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500"
|
||||
value={formData.estimatedBudget || ''}
|
||||
onChange={(e) => setFormData({ ...formData, estimatedBudget: e.target.value ? parseFloat(e.target.value) : undefined })}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Fecha Publicacion
|
||||
</label>
|
||||
<input
|
||||
type="date"
|
||||
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500"
|
||||
value={formData.publicationDate}
|
||||
onChange={(e) => setFormData({ ...formData, publicationDate: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Fecha Limite *
|
||||
</label>
|
||||
<input
|
||||
type="date"
|
||||
required
|
||||
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500"
|
||||
value={formData.submissionDeadline}
|
||||
onChange={(e) => setFormData({ ...formData, submissionDeadline: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Fecha Apertura
|
||||
</label>
|
||||
<input
|
||||
type="date"
|
||||
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500"
|
||||
value={formData.openingDate}
|
||||
onChange={(e) => setFormData({ ...formData, openingDate: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Descripcion
|
||||
</label>
|
||||
<textarea
|
||||
rows={3}
|
||||
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500"
|
||||
value={formData.description}
|
||||
onChange={(e) => setFormData({ ...formData, description: 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...' : item ? 'Guardar Cambios' : 'Crear'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
370
web/src/pages/admin/bidding/VendorsPage.tsx
Normal file
370
web/src/pages/admin/bidding/VendorsPage.tsx
Normal file
@ -0,0 +1,370 @@
|
||||
import { useState } from 'react';
|
||||
import { Plus, Pencil, Trash2, Search, ToggleLeft, ToggleRight } from 'lucide-react';
|
||||
import {
|
||||
useVendors,
|
||||
useDeleteVendor,
|
||||
useCreateVendor,
|
||||
useUpdateVendor,
|
||||
useToggleVendorActive,
|
||||
} from '../../../hooks/useBidding';
|
||||
import {
|
||||
Vendor,
|
||||
CreateVendorDto,
|
||||
} from '../../../services/bidding';
|
||||
import clsx from 'clsx';
|
||||
|
||||
export function VendorsPage() {
|
||||
const [search, setSearch] = useState('');
|
||||
const [activeFilter, setActiveFilter] = useState<boolean | ''>('');
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
const [editingItem, setEditingItem] = useState<Vendor | null>(null);
|
||||
const [deleteConfirm, setDeleteConfirm] = useState<string | null>(null);
|
||||
|
||||
const { data, isLoading, error } = useVendors({
|
||||
isActive: activeFilter === '' ? undefined : activeFilter,
|
||||
businessName: search || undefined,
|
||||
});
|
||||
|
||||
const deleteMutation = useDeleteVendor();
|
||||
const createMutation = useCreateVendor();
|
||||
const updateMutation = useUpdateVendor();
|
||||
const toggleActiveMutation = useToggleVendorActive();
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
await deleteMutation.mutateAsync(id);
|
||||
setDeleteConfirm(null);
|
||||
};
|
||||
|
||||
const handleSubmit = async (formData: CreateVendorDto) => {
|
||||
if (editingItem) {
|
||||
await updateMutation.mutateAsync({ id: editingItem.id, data: formData });
|
||||
} else {
|
||||
await createMutation.mutateAsync(formData);
|
||||
}
|
||||
setShowModal(false);
|
||||
setEditingItem(null);
|
||||
};
|
||||
|
||||
const handleToggleActive = async (id: string) => {
|
||||
await toggleActiveMutation.mutateAsync(id);
|
||||
};
|
||||
|
||||
const vendors = data?.items || [];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">Proveedores</h1>
|
||||
<p className="text-gray-600">Gestion de proveedores y subcontratistas</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={() => {
|
||||
setEditingItem(null);
|
||||
setShowModal(true);
|
||||
}}
|
||||
>
|
||||
<Plus className="w-5 h-5 mr-2" />
|
||||
Nuevo Proveedor
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="bg-white rounded-lg shadow-sm p-4 mb-6">
|
||||
<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 por razon social..."
|
||||
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={activeFilter === '' ? '' : activeFilter.toString()}
|
||||
onChange={(e) => {
|
||||
const val = e.target.value;
|
||||
setActiveFilter(val === '' ? '' : val === 'true');
|
||||
}}
|
||||
>
|
||||
<option value="">Todos</option>
|
||||
<option value="true">Activos</option>
|
||||
<option value="false">Inactivos</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Table */}
|
||||
<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>
|
||||
) : vendors.length === 0 ? (
|
||||
<div className="p-8 text-center text-gray-500">No hay proveedores</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">
|
||||
Razon Social
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
|
||||
RFC
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
|
||||
Contacto
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
|
||||
Email
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
|
||||
Telefono
|
||||
</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">
|
||||
{vendors.map((item) => (
|
||||
<tr key={item.id} className="hover:bg-gray-50">
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">
|
||||
{item.businessName}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
|
||||
{item.rfc || '-'}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
|
||||
{item.contactName || '-'}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
{item.email || '-'}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
{item.phone || '-'}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<button
|
||||
onClick={() => handleToggleActive(item.id)}
|
||||
disabled={toggleActiveMutation.isPending}
|
||||
className={clsx(
|
||||
'flex items-center gap-2 px-2 py-1 rounded-full text-xs font-medium transition-colors',
|
||||
item.isActive
|
||||
? 'bg-green-100 text-green-800 hover:bg-green-200'
|
||||
: 'bg-gray-100 text-gray-800 hover:bg-gray-200'
|
||||
)}
|
||||
>
|
||||
{item.isActive ? (
|
||||
<>
|
||||
<ToggleRight className="w-4 h-4" />
|
||||
Activo
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<ToggleLeft className="w-4 h-4" />
|
||||
Inactivo
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</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="Editar"
|
||||
onClick={() => {
|
||||
setEditingItem(item);
|
||||
setShowModal(true);
|
||||
}}
|
||||
>
|
||||
<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={() => setDeleteConfirm(item.id)}
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Modal */}
|
||||
{showModal && (
|
||||
<VendorModal
|
||||
item={editingItem}
|
||||
onClose={() => {
|
||||
setShowModal(false);
|
||||
setEditingItem(null);
|
||||
}}
|
||||
onSubmit={handleSubmit}
|
||||
isLoading={createMutation.isPending || updateMutation.isPending}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Delete Confirmation */}
|
||||
{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 este proveedor? 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"
|
||||
onClick={() => handleDelete(deleteConfirm)}
|
||||
disabled={deleteMutation.isPending}
|
||||
>
|
||||
{deleteMutation.isPending ? 'Eliminando...' : 'Eliminar'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Modal Component
|
||||
interface VendorModalProps {
|
||||
item: Vendor | null;
|
||||
onClose: () => void;
|
||||
onSubmit: (data: CreateVendorDto) => Promise<void>;
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
function VendorModal({ item, onClose, onSubmit, isLoading }: VendorModalProps) {
|
||||
const [formData, setFormData] = useState<CreateVendorDto>({
|
||||
businessName: item?.businessName || '',
|
||||
rfc: item?.rfc || '',
|
||||
contactName: item?.contactName || '',
|
||||
email: item?.email || '',
|
||||
phone: item?.phone || '',
|
||||
isActive: item?.isActive ?? true,
|
||||
});
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
await onSubmit(formData);
|
||||
};
|
||||
|
||||
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-lg w-full mx-4 max-h-[90vh] overflow-y-auto">
|
||||
<h3 className="text-lg font-semibold mb-4">
|
||||
{item ? 'Editar Proveedor' : 'Nuevo Proveedor'}
|
||||
</h3>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Razon Social *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500"
|
||||
value={formData.businessName}
|
||||
onChange={(e) => setFormData({ ...formData, businessName: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
RFC
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500"
|
||||
placeholder="XAXX010101000"
|
||||
value={formData.rfc}
|
||||
onChange={(e) => setFormData({ ...formData, rfc: e.target.value.toUpperCase() })}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Nombre de Contacto
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500"
|
||||
value={formData.contactName}
|
||||
onChange={(e) => setFormData({ ...formData, contactName: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Email
|
||||
</label>
|
||||
<input
|
||||
type="email"
|
||||
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500"
|
||||
value={formData.email}
|
||||
onChange={(e) => setFormData({ ...formData, email: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Telefono
|
||||
</label>
|
||||
<input
|
||||
type="tel"
|
||||
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500"
|
||||
value={formData.phone}
|
||||
onChange={(e) => setFormData({ ...formData, phone: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="isActive"
|
||||
className="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded"
|
||||
checked={formData.isActive}
|
||||
onChange={(e) => setFormData({ ...formData, isActive: e.target.checked })}
|
||||
/>
|
||||
<label htmlFor="isActive" className="ml-2 block text-sm text-gray-900">
|
||||
Proveedor activo
|
||||
</label>
|
||||
</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'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
4
web/src/pages/admin/bidding/index.ts
Normal file
4
web/src/pages/admin/bidding/index.ts
Normal file
@ -0,0 +1,4 @@
|
||||
export { OpportunitiesPage } from './OpportunitiesPage';
|
||||
export { TendersPage } from './TendersPage';
|
||||
export { ProposalsPage } from './ProposalsPage';
|
||||
export { VendorsPage } from './VendorsPage';
|
||||
709
web/src/pages/admin/dashboard/DashboardPage.tsx
Normal file
709
web/src/pages/admin/dashboard/DashboardPage.tsx
Normal file
@ -0,0 +1,709 @@
|
||||
import { useState, useMemo } from 'react';
|
||||
import {
|
||||
Building2,
|
||||
Activity,
|
||||
DollarSign,
|
||||
TrendingUp,
|
||||
AlertTriangle,
|
||||
Search,
|
||||
CheckCircle,
|
||||
Clock,
|
||||
XCircle,
|
||||
AlertCircle,
|
||||
Info,
|
||||
Gauge,
|
||||
} from 'lucide-react';
|
||||
import clsx from 'clsx';
|
||||
import {
|
||||
useDashboardStats,
|
||||
useProjectsSummary,
|
||||
useProjectKPIs,
|
||||
useAlerts,
|
||||
useAcknowledgeAlert,
|
||||
} from '../../../hooks/useReports';
|
||||
import {
|
||||
EarnedValueStatus,
|
||||
AlertSeverity,
|
||||
} from '../../../services/reports';
|
||||
|
||||
// =============================================================================
|
||||
// Utility Functions
|
||||
// =============================================================================
|
||||
|
||||
function formatCurrency(value: number): string {
|
||||
return new Intl.NumberFormat('es-MX', {
|
||||
style: 'currency',
|
||||
currency: 'MXN',
|
||||
minimumFractionDigits: 0,
|
||||
maximumFractionDigits: 0,
|
||||
}).format(value);
|
||||
}
|
||||
|
||||
function formatPercent(value: number): string {
|
||||
return `${(value * 100).toFixed(1)}%`;
|
||||
}
|
||||
|
||||
function formatNumber(value: number, decimals: number = 2): string {
|
||||
return new Intl.NumberFormat('es-MX', {
|
||||
minimumFractionDigits: decimals,
|
||||
maximumFractionDigits: decimals,
|
||||
}).format(value);
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Types
|
||||
// =============================================================================
|
||||
|
||||
const statusColors: Record<EarnedValueStatus, { bg: string; text: string; dot: string }> = {
|
||||
green: { bg: 'bg-green-100', text: 'text-green-800', dot: 'bg-green-500' },
|
||||
yellow: { bg: 'bg-yellow-100', text: 'text-yellow-800', dot: 'bg-yellow-500' },
|
||||
red: { bg: 'bg-red-100', text: 'text-red-800', dot: 'bg-red-500' },
|
||||
};
|
||||
|
||||
const statusLabels: Record<EarnedValueStatus, string> = {
|
||||
green: 'En Tiempo',
|
||||
yellow: 'En Riesgo',
|
||||
red: 'Atrasado',
|
||||
};
|
||||
|
||||
const severityConfig: Record<
|
||||
AlertSeverity,
|
||||
{ bg: string; text: string; border: string; icon: typeof AlertTriangle }
|
||||
> = {
|
||||
critical: {
|
||||
bg: 'bg-red-50',
|
||||
text: 'text-red-700',
|
||||
border: 'border-red-200',
|
||||
icon: XCircle,
|
||||
},
|
||||
warning: {
|
||||
bg: 'bg-yellow-50',
|
||||
text: 'text-yellow-700',
|
||||
border: 'border-yellow-200',
|
||||
icon: AlertCircle,
|
||||
},
|
||||
info: {
|
||||
bg: 'bg-blue-50',
|
||||
text: 'text-blue-700',
|
||||
border: 'border-blue-200',
|
||||
icon: Info,
|
||||
},
|
||||
};
|
||||
|
||||
// =============================================================================
|
||||
// Sub-Components
|
||||
// =============================================================================
|
||||
|
||||
interface StatCardProps {
|
||||
title: string;
|
||||
value: string | number;
|
||||
icon: typeof Building2;
|
||||
color: 'blue' | 'green' | 'purple' | 'orange' | 'red';
|
||||
subtitle?: string;
|
||||
isWarning?: boolean;
|
||||
}
|
||||
|
||||
function StatCard({ title, value, icon: Icon, color, subtitle, isWarning }: StatCardProps) {
|
||||
const colorClasses = {
|
||||
blue: 'bg-blue-500',
|
||||
green: 'bg-green-500',
|
||||
purple: 'bg-purple-500',
|
||||
orange: 'bg-orange-500',
|
||||
red: 'bg-red-500',
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-lg shadow-sm p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-500">{title}</p>
|
||||
<p
|
||||
className={clsx(
|
||||
'text-2xl font-bold mt-1',
|
||||
isWarning ? 'text-red-600' : 'text-gray-900'
|
||||
)}
|
||||
>
|
||||
{value}
|
||||
</p>
|
||||
{subtitle && <p className="text-xs text-gray-400 mt-1">{subtitle}</p>}
|
||||
</div>
|
||||
<div className={clsx('p-3 rounded-lg', colorClasses[color])}>
|
||||
<Icon className="w-6 h-6 text-white" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface KPIGaugeProps {
|
||||
label: string;
|
||||
value: number;
|
||||
threshold?: { warning: number; danger: number };
|
||||
}
|
||||
|
||||
function KPIGauge({ label, value, threshold = { warning: 0.95, danger: 0.85 } }: KPIGaugeProps) {
|
||||
const getColor = (val: number) => {
|
||||
if (val >= threshold.warning) return 'text-green-600';
|
||||
if (val >= threshold.danger) return 'text-yellow-600';
|
||||
return 'text-red-600';
|
||||
};
|
||||
|
||||
const getBgColor = (val: number) => {
|
||||
if (val >= threshold.warning) return 'bg-green-100';
|
||||
if (val >= threshold.danger) return 'bg-yellow-100';
|
||||
return 'bg-red-100';
|
||||
};
|
||||
|
||||
const getProgressColor = (val: number) => {
|
||||
if (val >= threshold.warning) return 'bg-green-500';
|
||||
if (val >= threshold.danger) return 'bg-yellow-500';
|
||||
return 'bg-red-500';
|
||||
};
|
||||
|
||||
const percentage = Math.min(Math.max(value * 100, 0), 150);
|
||||
const displayPercentage = Math.min(percentage, 100);
|
||||
|
||||
return (
|
||||
<div className={clsx('rounded-lg p-4', getBgColor(value))}>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-sm font-medium text-gray-700">{label}</span>
|
||||
<Gauge className={clsx('w-5 h-5', getColor(value))} />
|
||||
</div>
|
||||
<div className={clsx('text-3xl font-bold', getColor(value))}>
|
||||
{formatNumber(value, 2)}
|
||||
</div>
|
||||
<div className="mt-2 h-2 bg-white rounded-full overflow-hidden">
|
||||
<div
|
||||
className={clsx('h-full rounded-full transition-all', getProgressColor(value))}
|
||||
style={{ width: `${displayPercentage}%` }}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex justify-between text-xs text-gray-500 mt-1">
|
||||
<span>0</span>
|
||||
<span>1.0</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface EVMValueCardProps {
|
||||
label: string;
|
||||
value: number;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
function EVMValueCard({ label, value, description }: EVMValueCardProps) {
|
||||
return (
|
||||
<div className="bg-gray-50 rounded-lg p-4">
|
||||
<p className="text-sm text-gray-500">{label}</p>
|
||||
<p className="text-xl font-semibold text-gray-900 mt-1">{formatCurrency(value)}</p>
|
||||
{description && <p className="text-xs text-gray-400 mt-1">{description}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Main Component
|
||||
// =============================================================================
|
||||
|
||||
export function DashboardPage() {
|
||||
const [search, setSearch] = useState('');
|
||||
const [selectedProjectId, setSelectedProjectId] = useState<string | null>(null);
|
||||
const [statusFilter, setStatusFilter] = useState<EarnedValueStatus | ''>('');
|
||||
|
||||
// Data hooks
|
||||
const { data: stats, isLoading: statsLoading } = useDashboardStats();
|
||||
const { data: projectsData, isLoading: projectsLoading } = useProjectsSummary({
|
||||
status: statusFilter || undefined,
|
||||
});
|
||||
const { data: kpis, isLoading: kpisLoading } = useProjectKPIs(selectedProjectId || '');
|
||||
const { data: alertsData, isLoading: alertsLoading } = useAlerts({ acknowledged: false });
|
||||
|
||||
const acknowledgeMutation = useAcknowledgeAlert();
|
||||
|
||||
// Filter projects by search
|
||||
const filteredProjects = useMemo(() => {
|
||||
if (!projectsData?.items) return [];
|
||||
if (!search) return projectsData.items;
|
||||
const searchLower = search.toLowerCase();
|
||||
return projectsData.items.filter((p) =>
|
||||
p.nombre.toLowerCase().includes(searchLower)
|
||||
);
|
||||
}, [projectsData?.items, search]);
|
||||
|
||||
// Get selected project name
|
||||
const selectedProject = useMemo(() => {
|
||||
if (!selectedProjectId || !projectsData?.items) return null;
|
||||
return projectsData.items.find((p) => p.id === selectedProjectId);
|
||||
}, [selectedProjectId, projectsData?.items]);
|
||||
|
||||
const handleAcknowledge = async (alertId: string) => {
|
||||
await acknowledgeMutation.mutateAsync(alertId);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">Dashboard</h1>
|
||||
<p className="text-gray-600">Vision general del portafolio de proyectos</p>
|
||||
</div>
|
||||
|
||||
{/* Stats Cards Row */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-5 gap-4">
|
||||
{statsLoading ? (
|
||||
Array.from({ length: 5 }).map((_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="bg-white rounded-lg shadow-sm p-6 animate-pulse"
|
||||
>
|
||||
<div className="h-4 bg-gray-200 rounded w-24 mb-2" />
|
||||
<div className="h-8 bg-gray-200 rounded w-16" />
|
||||
</div>
|
||||
))
|
||||
) : stats ? (
|
||||
<>
|
||||
<StatCard
|
||||
title="Total Proyectos"
|
||||
value={stats.totalProyectos}
|
||||
icon={Building2}
|
||||
color="blue"
|
||||
/>
|
||||
<StatCard
|
||||
title="Proyectos Activos"
|
||||
value={stats.proyectosActivos}
|
||||
icon={Activity}
|
||||
color="green"
|
||||
/>
|
||||
<StatCard
|
||||
title="Presupuesto Total"
|
||||
value={formatCurrency(stats.presupuestoTotal)}
|
||||
icon={DollarSign}
|
||||
color="purple"
|
||||
/>
|
||||
<StatCard
|
||||
title="Avance Promedio"
|
||||
value={formatPercent(stats.avancePromedio / 100)}
|
||||
icon={TrendingUp}
|
||||
color="orange"
|
||||
/>
|
||||
<StatCard
|
||||
title="Alertas Activas"
|
||||
value={stats.alertasActivas}
|
||||
icon={AlertTriangle}
|
||||
color={stats.alertasActivas > 0 ? 'red' : 'blue'}
|
||||
isWarning={stats.alertasActivas > 0}
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{/* Main Content Grid */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
{/* Projects Summary Table */}
|
||||
<div className="lg:col-span-2 bg-white rounded-lg shadow-sm">
|
||||
<div className="p-4 border-b">
|
||||
<h2 className="text-lg font-semibold text-gray-900">Resumen de Proyectos</h2>
|
||||
<div className="mt-3 flex flex-col sm:flex-row gap-3">
|
||||
<div className="flex-1 relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Buscar proyecto..."
|
||||
className="w-full pl-9 pr-4 py-2 text-sm 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-3 py-2 text-sm border rounded-lg focus:ring-2 focus:ring-blue-500"
|
||||
value={statusFilter}
|
||||
onChange={(e) => setStatusFilter(e.target.value as EarnedValueStatus | '')}
|
||||
>
|
||||
<option value="">Todos los estados</option>
|
||||
{Object.entries(statusLabels).map(([value, label]) => (
|
||||
<option key={value} value={value}>
|
||||
{label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="overflow-x-auto">
|
||||
{projectsLoading ? (
|
||||
<div className="p-8 text-center text-gray-500">Cargando proyectos...</div>
|
||||
) : filteredProjects.length === 0 ? (
|
||||
<div className="p-8 text-center text-gray-500">
|
||||
No hay proyectos que coincidan con la busqueda
|
||||
</div>
|
||||
) : (
|
||||
<table className="min-w-full divide-y divide-gray-200">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">
|
||||
Nombre
|
||||
</th>
|
||||
<th className="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase">
|
||||
Presupuesto
|
||||
</th>
|
||||
<th className="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase">
|
||||
Real
|
||||
</th>
|
||||
<th className="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase">
|
||||
Programado
|
||||
</th>
|
||||
<th className="px-4 py-3 text-center text-xs font-medium text-gray-500 uppercase">
|
||||
SPI
|
||||
</th>
|
||||
<th className="px-4 py-3 text-center text-xs font-medium text-gray-500 uppercase">
|
||||
CPI
|
||||
</th>
|
||||
<th className="px-4 py-3 text-center text-xs font-medium text-gray-500 uppercase">
|
||||
Status
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-200">
|
||||
{filteredProjects.map((project) => (
|
||||
<tr
|
||||
key={project.id}
|
||||
className={clsx(
|
||||
'cursor-pointer transition-colors',
|
||||
selectedProjectId === project.id
|
||||
? 'bg-blue-50'
|
||||
: 'hover:bg-gray-50'
|
||||
)}
|
||||
onClick={() => setSelectedProjectId(project.id)}
|
||||
>
|
||||
<td className="px-4 py-3 text-sm font-medium text-gray-900">
|
||||
{project.nombre}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-gray-600 text-right">
|
||||
{formatCurrency(project.presupuesto)}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-gray-600 text-right">
|
||||
{formatPercent(project.avanceReal / 100)}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-gray-600 text-right">
|
||||
{formatPercent(project.avanceProgramado / 100)}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-center">
|
||||
<span
|
||||
className={clsx(
|
||||
'text-sm font-medium',
|
||||
project.spi >= 0.95
|
||||
? 'text-green-600'
|
||||
: project.spi >= 0.85
|
||||
? 'text-yellow-600'
|
||||
: 'text-red-600'
|
||||
)}
|
||||
>
|
||||
{formatNumber(project.spi)}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-center">
|
||||
<span
|
||||
className={clsx(
|
||||
'text-sm font-medium',
|
||||
project.cpi >= 0.95
|
||||
? 'text-green-600'
|
||||
: project.cpi >= 0.85
|
||||
? 'text-yellow-600'
|
||||
: 'text-red-600'
|
||||
)}
|
||||
>
|
||||
{formatNumber(project.cpi)}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-center">
|
||||
<span
|
||||
className={clsx(
|
||||
'inline-flex items-center px-2 py-1 text-xs font-medium rounded-full',
|
||||
statusColors[project.status].bg,
|
||||
statusColors[project.status].text
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={clsx(
|
||||
'w-1.5 h-1.5 rounded-full mr-1.5',
|
||||
statusColors[project.status].dot
|
||||
)}
|
||||
/>
|
||||
{statusLabels[project.status]}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Alerts Section */}
|
||||
<div className="bg-white rounded-lg shadow-sm">
|
||||
<div className="p-4 border-b flex items-center justify-between">
|
||||
<h2 className="text-lg font-semibold text-gray-900">Alertas Recientes</h2>
|
||||
{alertsData?.items && alertsData.items.length > 0 && (
|
||||
<span className="px-2 py-1 text-xs font-medium bg-red-100 text-red-700 rounded-full">
|
||||
{alertsData.items.length}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="p-4 space-y-3 max-h-96 overflow-y-auto">
|
||||
{alertsLoading ? (
|
||||
<div className="text-center text-gray-500 py-4">Cargando alertas...</div>
|
||||
) : !alertsData?.items || alertsData.items.length === 0 ? (
|
||||
<div className="text-center py-8">
|
||||
<CheckCircle className="w-12 h-12 text-green-500 mx-auto mb-2" />
|
||||
<p className="text-gray-500">No hay alertas pendientes</p>
|
||||
</div>
|
||||
) : (
|
||||
alertsData.items.slice(0, 10).map((alert) => {
|
||||
const config = severityConfig[alert.severity];
|
||||
const IconComponent = config.icon;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={alert.id}
|
||||
className={clsx(
|
||||
'p-3 rounded-lg border',
|
||||
config.bg,
|
||||
config.border
|
||||
)}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<IconComponent className={clsx('w-5 h-5 mt-0.5', config.text)} />
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className={clsx('text-sm font-medium', config.text)}>
|
||||
{alert.title}
|
||||
</p>
|
||||
<p className="text-xs text-gray-600 mt-1">{alert.projectName}</p>
|
||||
<p className="text-xs text-gray-500 mt-1 line-clamp-2">
|
||||
{alert.message}
|
||||
</p>
|
||||
<div className="flex items-center justify-between mt-2">
|
||||
<span className="text-xs text-gray-400 flex items-center">
|
||||
<Clock className="w-3 h-3 mr-1" />
|
||||
{new Date(alert.createdAt).toLocaleDateString()}
|
||||
</span>
|
||||
<button
|
||||
className="text-xs text-blue-600 hover:text-blue-800 font-medium"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleAcknowledge(alert.id);
|
||||
}}
|
||||
disabled={acknowledgeMutation.isPending}
|
||||
>
|
||||
{acknowledgeMutation.isPending ? 'Procesando...' : 'Reconocer'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* KPI Indicators Section - Shows when a project is selected */}
|
||||
{selectedProjectId && (
|
||||
<div className="bg-white rounded-lg shadow-sm p-6">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-gray-900">
|
||||
Indicadores KPI - {selectedProject?.nombre || 'Proyecto'}
|
||||
</h2>
|
||||
<p className="text-sm text-gray-500">
|
||||
Earned Value Management (EVM)
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
className="text-sm text-gray-500 hover:text-gray-700"
|
||||
onClick={() => setSelectedProjectId(null)}
|
||||
>
|
||||
Cerrar
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{kpisLoading ? (
|
||||
<div className="text-center text-gray-500 py-8">Cargando KPIs...</div>
|
||||
) : !kpis ? (
|
||||
<div className="text-center text-gray-500 py-8">
|
||||
No hay datos de KPIs disponibles
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-6">
|
||||
{/* Performance Indicators */}
|
||||
<div>
|
||||
<h3 className="text-sm font-medium text-gray-700 mb-3">
|
||||
Indicadores de Desempeno
|
||||
</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<KPIGauge label="SPI (Schedule)" value={kpis.spi} />
|
||||
<KPIGauge label="CPI (Cost)" value={kpis.cpi} />
|
||||
<KPIGauge
|
||||
label="TCPI (To Complete)"
|
||||
value={kpis.tcpi}
|
||||
threshold={{ warning: 1.0, danger: 1.1 }}
|
||||
/>
|
||||
<div className="bg-blue-50 rounded-lg p-4">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-sm font-medium text-gray-700">% Completado</span>
|
||||
<TrendingUp className="w-5 h-5 text-blue-600" />
|
||||
</div>
|
||||
<div className="text-3xl font-bold text-blue-600">
|
||||
{formatPercent(kpis.percentComplete / 100)}
|
||||
</div>
|
||||
<div className="mt-2 h-2 bg-white rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-blue-500 rounded-full transition-all"
|
||||
style={{ width: `${Math.min(kpis.percentComplete, 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* EV Values */}
|
||||
<div>
|
||||
<h3 className="text-sm font-medium text-gray-700 mb-3">
|
||||
Valores de Earned Value
|
||||
</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<EVMValueCard
|
||||
label="PV (Planned Value)"
|
||||
value={kpis.pv}
|
||||
description="Valor planeado a la fecha"
|
||||
/>
|
||||
<EVMValueCard
|
||||
label="EV (Earned Value)"
|
||||
value={kpis.ev}
|
||||
description="Valor ganado (trabajo realizado)"
|
||||
/>
|
||||
<EVMValueCard
|
||||
label="AC (Actual Cost)"
|
||||
value={kpis.ac}
|
||||
description="Costo real incurrido"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Variances */}
|
||||
<div>
|
||||
<h3 className="text-sm font-medium text-gray-700 mb-3">Varianzas</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div
|
||||
className={clsx(
|
||||
'rounded-lg p-4',
|
||||
kpis.sv >= 0 ? 'bg-green-50' : 'bg-red-50'
|
||||
)}
|
||||
>
|
||||
<p className="text-sm text-gray-600">SV (Schedule Variance)</p>
|
||||
<p
|
||||
className={clsx(
|
||||
'text-xl font-semibold mt-1',
|
||||
kpis.sv >= 0 ? 'text-green-700' : 'text-red-700'
|
||||
)}
|
||||
>
|
||||
{formatCurrency(kpis.sv)}
|
||||
</p>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
{kpis.sv >= 0 ? 'Adelantado' : 'Atrasado'}
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
className={clsx(
|
||||
'rounded-lg p-4',
|
||||
kpis.cv >= 0 ? 'bg-green-50' : 'bg-red-50'
|
||||
)}
|
||||
>
|
||||
<p className="text-sm text-gray-600">CV (Cost Variance)</p>
|
||||
<p
|
||||
className={clsx(
|
||||
'text-xl font-semibold mt-1',
|
||||
kpis.cv >= 0 ? 'text-green-700' : 'text-red-700'
|
||||
)}
|
||||
>
|
||||
{formatCurrency(kpis.cv)}
|
||||
</p>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
{kpis.cv >= 0 ? 'Bajo presupuesto' : 'Sobre presupuesto'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Projections */}
|
||||
<div>
|
||||
<h3 className="text-sm font-medium text-gray-700 mb-3">Proyecciones</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<EVMValueCard
|
||||
label="BAC (Budget at Completion)"
|
||||
value={kpis.bac}
|
||||
description="Presupuesto total del proyecto"
|
||||
/>
|
||||
<EVMValueCard
|
||||
label="EAC (Estimate at Completion)"
|
||||
value={kpis.eac}
|
||||
description="Costo estimado final"
|
||||
/>
|
||||
<EVMValueCard
|
||||
label="ETC (Estimate to Complete)"
|
||||
value={kpis.etc}
|
||||
description="Costo estimado para terminar"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* VAC */}
|
||||
<div>
|
||||
<div
|
||||
className={clsx(
|
||||
'rounded-lg p-4',
|
||||
kpis.vac >= 0 ? 'bg-green-50' : 'bg-red-50'
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-gray-600">VAC (Variance at Completion)</p>
|
||||
<p
|
||||
className={clsx(
|
||||
'text-2xl font-bold mt-1',
|
||||
kpis.vac >= 0 ? 'text-green-700' : 'text-red-700'
|
||||
)}
|
||||
>
|
||||
{formatCurrency(kpis.vac)}
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
className={clsx(
|
||||
'p-3 rounded-full',
|
||||
kpis.vac >= 0 ? 'bg-green-100' : 'bg-red-100'
|
||||
)}
|
||||
>
|
||||
{kpis.vac >= 0 ? (
|
||||
<TrendingUp className="w-8 h-8 text-green-600" />
|
||||
) : (
|
||||
<AlertTriangle className="w-8 h-8 text-red-600" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-sm text-gray-500 mt-2">
|
||||
{kpis.vac >= 0
|
||||
? `Se proyecta un ahorro de ${formatCurrency(kpis.vac)} al finalizar el proyecto`
|
||||
: `Se proyecta un sobrecosto de ${formatCurrency(Math.abs(kpis.vac))} al finalizar el proyecto`}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
1
web/src/pages/admin/dashboard/index.ts
Normal file
1
web/src/pages/admin/dashboard/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export { DashboardPage } from './DashboardPage';
|
||||
547
web/src/pages/admin/presupuestos/ConceptosPage.tsx
Normal file
547
web/src/pages/admin/presupuestos/ConceptosPage.tsx
Normal file
@ -0,0 +1,547 @@
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
Plus,
|
||||
Pencil,
|
||||
Trash2,
|
||||
Search,
|
||||
ChevronRight,
|
||||
ChevronDown,
|
||||
FolderTree,
|
||||
FileText,
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
useConceptosTree,
|
||||
useCreateConcepto,
|
||||
useUpdateConcepto,
|
||||
useDeleteConcepto,
|
||||
} from '../../../hooks/usePresupuestos';
|
||||
import {
|
||||
Concepto,
|
||||
ConceptoTipo,
|
||||
CreateConceptoDto,
|
||||
} from '../../../services/presupuestos';
|
||||
import clsx from 'clsx';
|
||||
|
||||
const tipoLabels: Record<ConceptoTipo, string> = {
|
||||
capitulo: 'Capitulo',
|
||||
partida: 'Partida',
|
||||
subpartida: 'Subpartida',
|
||||
concepto: 'Concepto',
|
||||
};
|
||||
|
||||
const tipoIcons: Record<ConceptoTipo, React.ReactNode> = {
|
||||
capitulo: <FolderTree className="w-4 h-4 text-blue-600" />,
|
||||
partida: <FolderTree className="w-4 h-4 text-green-600" />,
|
||||
subpartida: <FolderTree className="w-4 h-4 text-yellow-600" />,
|
||||
concepto: <FileText className="w-4 h-4 text-gray-600" />,
|
||||
};
|
||||
|
||||
interface TreeNodeProps {
|
||||
concepto: Concepto;
|
||||
level: number;
|
||||
expandedNodes: Set<string>;
|
||||
onToggle: (id: string) => void;
|
||||
onEdit: (concepto: Concepto) => void;
|
||||
onDelete: (id: string) => void;
|
||||
onAddChild: (parentId: string, parentTipo: ConceptoTipo) => void;
|
||||
searchTerm: string;
|
||||
}
|
||||
|
||||
function TreeNode({
|
||||
concepto,
|
||||
level,
|
||||
expandedNodes,
|
||||
onToggle,
|
||||
onEdit,
|
||||
onDelete,
|
||||
onAddChild,
|
||||
searchTerm,
|
||||
}: TreeNodeProps) {
|
||||
const isExpanded = expandedNodes.has(concepto.id);
|
||||
const hasChildren = concepto.children && concepto.children.length > 0;
|
||||
const matchesSearch =
|
||||
!searchTerm ||
|
||||
concepto.codigo.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
concepto.descripcion.toLowerCase().includes(searchTerm.toLowerCase());
|
||||
|
||||
const childrenMatch =
|
||||
concepto.children?.some(
|
||||
(child) =>
|
||||
child.codigo.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
child.descripcion.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
hasMatchingDescendant(child, searchTerm)
|
||||
) ?? false;
|
||||
|
||||
if (searchTerm && !matchesSearch && !childrenMatch) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const canAddChild = concepto.tipo !== 'concepto';
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div
|
||||
className={clsx(
|
||||
'flex items-center py-2 px-3 hover:bg-gray-50 border-b border-gray-100',
|
||||
matchesSearch && searchTerm && 'bg-yellow-50'
|
||||
)}
|
||||
style={{ paddingLeft: `${level * 24 + 12}px` }}
|
||||
>
|
||||
<button
|
||||
className="mr-2 p-1 hover:bg-gray-200 rounded"
|
||||
onClick={() => hasChildren && onToggle(concepto.id)}
|
||||
disabled={!hasChildren}
|
||||
>
|
||||
{hasChildren ? (
|
||||
isExpanded ? (
|
||||
<ChevronDown className="w-4 h-4 text-gray-500" />
|
||||
) : (
|
||||
<ChevronRight className="w-4 h-4 text-gray-500" />
|
||||
)
|
||||
) : (
|
||||
<span className="w-4 h-4 inline-block" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
<span className="mr-2">{tipoIcons[concepto.tipo]}</span>
|
||||
|
||||
<span className="font-mono text-sm text-gray-600 mr-3 min-w-[100px]">
|
||||
{concepto.codigo}
|
||||
</span>
|
||||
|
||||
<span className="flex-1 text-sm text-gray-900 truncate">
|
||||
{concepto.descripcion}
|
||||
</span>
|
||||
|
||||
{concepto.unidad && (
|
||||
<span className="text-xs text-gray-500 mr-4 min-w-[60px] text-center">
|
||||
{concepto.unidad}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{concepto.precioUnitario !== undefined && concepto.precioUnitario !== null && (
|
||||
<span className="text-sm text-gray-700 mr-4 min-w-[100px] text-right font-medium">
|
||||
${concepto.precioUnitario.toLocaleString('es-MX', { minimumFractionDigits: 2 })}
|
||||
</span>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-1">
|
||||
{canAddChild && (
|
||||
<button
|
||||
className="p-1.5 text-gray-500 hover:text-green-600 hover:bg-green-50 rounded"
|
||||
title="Agregar hijo"
|
||||
onClick={() => onAddChild(concepto.id, concepto.tipo)}
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
className="p-1.5 text-gray-500 hover:text-blue-600 hover:bg-blue-50 rounded"
|
||||
title="Editar"
|
||||
onClick={() => onEdit(concepto)}
|
||||
>
|
||||
<Pencil className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
className="p-1.5 text-gray-500 hover:text-red-600 hover:bg-red-50 rounded"
|
||||
title="Eliminar"
|
||||
onClick={() => onDelete(concepto.id)}
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isExpanded && hasChildren && (
|
||||
<div>
|
||||
{concepto.children!.map((child) => (
|
||||
<TreeNode
|
||||
key={child.id}
|
||||
concepto={child}
|
||||
level={level + 1}
|
||||
expandedNodes={expandedNodes}
|
||||
onToggle={onToggle}
|
||||
onEdit={onEdit}
|
||||
onDelete={onDelete}
|
||||
onAddChild={onAddChild}
|
||||
searchTerm={searchTerm}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function hasMatchingDescendant(concepto: Concepto, searchTerm: string): boolean {
|
||||
if (!concepto.children) return false;
|
||||
return concepto.children.some(
|
||||
(child) =>
|
||||
child.codigo.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
child.descripcion.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
hasMatchingDescendant(child, searchTerm)
|
||||
);
|
||||
}
|
||||
|
||||
export function ConceptosPage() {
|
||||
const [search, setSearch] = useState('');
|
||||
const [expandedNodes, setExpandedNodes] = useState<Set<string>>(new Set());
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
const [editingItem, setEditingItem] = useState<Concepto | null>(null);
|
||||
const [parentIdForNew, setParentIdForNew] = useState<string | undefined>(undefined);
|
||||
const [parentTipoForNew, setParentTipoForNew] = useState<ConceptoTipo | undefined>(undefined);
|
||||
const [deleteConfirm, setDeleteConfirm] = useState<string | null>(null);
|
||||
|
||||
const { data: conceptosTree, isLoading, error } = useConceptosTree();
|
||||
|
||||
const deleteMutation = useDeleteConcepto();
|
||||
const createMutation = useCreateConcepto();
|
||||
const updateMutation = useUpdateConcepto();
|
||||
|
||||
const handleToggle = (id: string) => {
|
||||
setExpandedNodes((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) {
|
||||
next.delete(id);
|
||||
} else {
|
||||
next.add(id);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const handleExpandAll = () => {
|
||||
if (!conceptosTree) return;
|
||||
const allIds = new Set<string>();
|
||||
const collectIds = (conceptos: Concepto[]) => {
|
||||
conceptos.forEach((c) => {
|
||||
allIds.add(c.id);
|
||||
if (c.children) collectIds(c.children);
|
||||
});
|
||||
};
|
||||
collectIds(conceptosTree);
|
||||
setExpandedNodes(allIds);
|
||||
};
|
||||
|
||||
const handleCollapseAll = () => {
|
||||
setExpandedNodes(new Set());
|
||||
};
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
await deleteMutation.mutateAsync(id);
|
||||
setDeleteConfirm(null);
|
||||
};
|
||||
|
||||
const handleSubmit = async (formData: CreateConceptoDto) => {
|
||||
if (editingItem) {
|
||||
await updateMutation.mutateAsync({ id: editingItem.id, data: formData });
|
||||
} else {
|
||||
await createMutation.mutateAsync(formData);
|
||||
}
|
||||
setShowModal(false);
|
||||
setEditingItem(null);
|
||||
setParentIdForNew(undefined);
|
||||
setParentTipoForNew(undefined);
|
||||
};
|
||||
|
||||
const handleAddChild = (parentId: string, parentTipo: ConceptoTipo) => {
|
||||
setParentIdForNew(parentId);
|
||||
setParentTipoForNew(parentTipo);
|
||||
setEditingItem(null);
|
||||
setShowModal(true);
|
||||
setExpandedNodes((prev) => new Set([...prev, parentId]));
|
||||
};
|
||||
|
||||
const handleOpenNewRoot = () => {
|
||||
setParentIdForNew(undefined);
|
||||
setParentTipoForNew(undefined);
|
||||
setEditingItem(null);
|
||||
setShowModal(true);
|
||||
};
|
||||
|
||||
const conceptos = conceptosTree || [];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">Catalogo de Conceptos</h1>
|
||||
<p className="text-gray-600">Gestion jerarquica de capitulos, partidas y conceptos</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={handleOpenNewRoot}
|
||||
>
|
||||
<Plus className="w-5 h-5 mr-2" />
|
||||
Nuevo Capitulo
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-lg shadow-sm p-4 mb-6">
|
||||
<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 por codigo o descripcion..."
|
||||
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>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
className="px-4 py-2 text-gray-700 border rounded-lg hover:bg-gray-50"
|
||||
onClick={handleExpandAll}
|
||||
>
|
||||
Expandir Todo
|
||||
</button>
|
||||
<button
|
||||
className="px-4 py-2 text-gray-700 border rounded-lg hover:bg-gray-50"
|
||||
onClick={handleCollapseAll}
|
||||
>
|
||||
Colapsar Todo
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-lg shadow-sm overflow-hidden">
|
||||
<div className="bg-gray-50 px-4 py-3 border-b flex items-center text-xs font-medium text-gray-500 uppercase">
|
||||
<span className="w-8" />
|
||||
<span className="w-8" />
|
||||
<span className="min-w-[100px] mr-3">Codigo</span>
|
||||
<span className="flex-1">Descripcion</span>
|
||||
<span className="min-w-[60px] text-center mr-4">Unidad</span>
|
||||
<span className="min-w-[100px] text-right mr-4">Precio Unitario</span>
|
||||
<span className="w-28 text-right">Acciones</span>
|
||||
</div>
|
||||
|
||||
{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>
|
||||
) : conceptos.length === 0 ? (
|
||||
<div className="p-8 text-center text-gray-500">
|
||||
No hay conceptos. Crea el primer capitulo para comenzar.
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
{conceptos.map((concepto) => (
|
||||
<TreeNode
|
||||
key={concepto.id}
|
||||
concepto={concepto}
|
||||
level={0}
|
||||
expandedNodes={expandedNodes}
|
||||
onToggle={handleToggle}
|
||||
onEdit={(c) => {
|
||||
setEditingItem(c);
|
||||
setParentIdForNew(c.parentId);
|
||||
setShowModal(true);
|
||||
}}
|
||||
onDelete={setDeleteConfirm}
|
||||
onAddChild={handleAddChild}
|
||||
searchTerm={search}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{showModal && (
|
||||
<ConceptoModal
|
||||
item={editingItem}
|
||||
parentId={parentIdForNew}
|
||||
parentTipo={parentTipoForNew}
|
||||
onClose={() => {
|
||||
setShowModal(false);
|
||||
setEditingItem(null);
|
||||
setParentIdForNew(undefined);
|
||||
setParentTipoForNew(undefined);
|
||||
}}
|
||||
onSubmit={handleSubmit}
|
||||
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 este concepto? Si tiene elementos hijos, tambien seran eliminados.
|
||||
</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"
|
||||
onClick={() => handleDelete(deleteConfirm)}
|
||||
disabled={deleteMutation.isPending}
|
||||
>
|
||||
{deleteMutation.isPending ? 'Eliminando...' : 'Eliminar'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface ConceptoModalProps {
|
||||
item: Concepto | null;
|
||||
parentId?: string;
|
||||
parentTipo?: ConceptoTipo;
|
||||
onClose: () => void;
|
||||
onSubmit: (data: CreateConceptoDto) => Promise<void>;
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
function getChildTipo(parentTipo?: ConceptoTipo): ConceptoTipo {
|
||||
if (!parentTipo) return 'capitulo';
|
||||
switch (parentTipo) {
|
||||
case 'capitulo':
|
||||
return 'partida';
|
||||
case 'partida':
|
||||
return 'subpartida';
|
||||
case 'subpartida':
|
||||
return 'concepto';
|
||||
default:
|
||||
return 'concepto';
|
||||
}
|
||||
}
|
||||
|
||||
function ConceptoModal({
|
||||
item,
|
||||
parentId,
|
||||
parentTipo,
|
||||
onClose,
|
||||
onSubmit,
|
||||
isLoading,
|
||||
}: ConceptoModalProps) {
|
||||
const defaultTipo = item?.tipo || getChildTipo(parentTipo);
|
||||
|
||||
const [formData, setFormData] = useState<CreateConceptoDto>({
|
||||
codigo: item?.codigo || '',
|
||||
descripcion: item?.descripcion || '',
|
||||
tipo: defaultTipo,
|
||||
parentId: item?.parentId || parentId,
|
||||
unidad: item?.unidad || '',
|
||||
precioUnitario: item?.precioUnitario,
|
||||
});
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
await onSubmit({
|
||||
...formData,
|
||||
precioUnitario: formData.precioUnitario || undefined,
|
||||
unidad: formData.unidad || undefined,
|
||||
});
|
||||
};
|
||||
|
||||
const showPriceFields = formData.tipo === 'concepto' || formData.tipo === 'subpartida';
|
||||
|
||||
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-lg w-full mx-4 max-h-[90vh] overflow-y-auto">
|
||||
<h3 className="text-lg font-semibold mb-4">
|
||||
{item ? `Editar ${tipoLabels[item.tipo]}` : `Nuevo ${tipoLabels[defaultTipo]}`}
|
||||
</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">Codigo *</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500"
|
||||
value={formData.codigo}
|
||||
onChange={(e) => setFormData({ ...formData, codigo: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Tipo</label>
|
||||
<select
|
||||
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500 bg-gray-50"
|
||||
value={formData.tipo}
|
||||
disabled
|
||||
>
|
||||
{Object.entries(tipoLabels).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">Descripcion *</label>
|
||||
<textarea
|
||||
required
|
||||
rows={3}
|
||||
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500"
|
||||
value={formData.descripcion}
|
||||
onChange={(e) => setFormData({ ...formData, descripcion: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{showPriceFields && (
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Unidad</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="m2, m3, pza, kg..."
|
||||
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500"
|
||||
value={formData.unidad || ''}
|
||||
onChange={(e) => setFormData({ ...formData, unidad: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Precio Unitario
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500"
|
||||
value={formData.precioUnitario || ''}
|
||||
onChange={(e) =>
|
||||
setFormData({
|
||||
...formData,
|
||||
precioUnitario: e.target.value ? parseFloat(e.target.value) : undefined,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</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'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
837
web/src/pages/admin/presupuestos/EstimacionesPage.tsx
Normal file
837
web/src/pages/admin/presupuestos/EstimacionesPage.tsx
Normal file
@ -0,0 +1,837 @@
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
Plus,
|
||||
Pencil,
|
||||
Trash2,
|
||||
Search,
|
||||
Send,
|
||||
CheckCircle,
|
||||
XCircle,
|
||||
Eye,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
FileText,
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
useEstimaciones,
|
||||
useEstimacion,
|
||||
useCreateEstimacion,
|
||||
useUpdateEstimacion,
|
||||
useDeleteEstimacion,
|
||||
useSubmitEstimacion,
|
||||
useApproveEstimacion,
|
||||
useRejectEstimacion,
|
||||
} from '../../../hooks/usePresupuestos';
|
||||
import {
|
||||
Estimacion,
|
||||
EstimacionEstado,
|
||||
EstimacionPartida,
|
||||
CreateEstimacionDto,
|
||||
} from '../../../services/presupuestos';
|
||||
import clsx from 'clsx';
|
||||
|
||||
const estadoColors: Record<EstimacionEstado, string> = {
|
||||
borrador: 'bg-gray-100 text-gray-800',
|
||||
revision: 'bg-yellow-100 text-yellow-800',
|
||||
aprobado: 'bg-green-100 text-green-800',
|
||||
facturado: 'bg-blue-100 text-blue-800',
|
||||
cobrado: 'bg-purple-100 text-purple-800',
|
||||
rechazado: 'bg-red-100 text-red-800',
|
||||
};
|
||||
|
||||
const estadoLabels: Record<EstimacionEstado, string> = {
|
||||
borrador: 'Borrador',
|
||||
revision: 'En Revision',
|
||||
aprobado: 'Aprobado',
|
||||
facturado: 'Facturado',
|
||||
cobrado: 'Cobrado',
|
||||
rechazado: 'Rechazado',
|
||||
};
|
||||
|
||||
export function EstimacionesPage() {
|
||||
const [search, setSearch] = useState('');
|
||||
const [estadoFilter, setEstadoFilter] = useState<EstimacionEstado | ''>('');
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
const [editingItem, setEditingItem] = useState<Estimacion | null>(null);
|
||||
const [deleteConfirm, setDeleteConfirm] = useState<string | null>(null);
|
||||
const [detailId, setDetailId] = useState<string | null>(null);
|
||||
const [rejectModal, setRejectModal] = useState<string | null>(null);
|
||||
const [approveModal, setApproveModal] = useState<string | null>(null);
|
||||
|
||||
const { data, isLoading, error } = useEstimaciones({
|
||||
estado: estadoFilter || undefined,
|
||||
});
|
||||
|
||||
const deleteMutation = useDeleteEstimacion();
|
||||
const createMutation = useCreateEstimacion();
|
||||
const updateMutation = useUpdateEstimacion();
|
||||
const submitMutation = useSubmitEstimacion();
|
||||
const approveMutation = useApproveEstimacion();
|
||||
const rejectMutation = useRejectEstimacion();
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
await deleteMutation.mutateAsync(id);
|
||||
setDeleteConfirm(null);
|
||||
};
|
||||
|
||||
const handleSubmitEstimacion = async (id: string) => {
|
||||
await submitMutation.mutateAsync(id);
|
||||
};
|
||||
|
||||
const handleApprove = async (id: string, montoAprobado?: number) => {
|
||||
await approveMutation.mutateAsync({ id, montoAprobado });
|
||||
setApproveModal(null);
|
||||
};
|
||||
|
||||
const handleReject = async (id: string, motivo: string) => {
|
||||
await rejectMutation.mutateAsync({ id, motivo });
|
||||
setRejectModal(null);
|
||||
};
|
||||
|
||||
const handleSubmit = async (formData: CreateEstimacionDto) => {
|
||||
if (editingItem) {
|
||||
await updateMutation.mutateAsync({
|
||||
id: editingItem.id,
|
||||
data: {
|
||||
periodo: formData.periodo,
|
||||
fechaInicio: formData.fechaInicio,
|
||||
fechaFin: formData.fechaFin,
|
||||
anticipoPorcentaje: formData.anticipoPorcentaje,
|
||||
retencionPorcentaje: formData.retencionPorcentaje,
|
||||
observaciones: formData.observaciones,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
await createMutation.mutateAsync(formData);
|
||||
}
|
||||
setShowModal(false);
|
||||
setEditingItem(null);
|
||||
};
|
||||
|
||||
const estimaciones = (data?.items || []).filter(
|
||||
(e) =>
|
||||
!search ||
|
||||
e.numero.toString().includes(search) ||
|
||||
e.periodo.toLowerCase().includes(search.toLowerCase())
|
||||
);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">Estimaciones</h1>
|
||||
<p className="text-gray-600">Gestion de estimaciones de obra</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={() => {
|
||||
setEditingItem(null);
|
||||
setShowModal(true);
|
||||
}}
|
||||
>
|
||||
<Plus className="w-5 h-5 mr-2" />
|
||||
Nueva Estimacion
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-lg shadow-sm p-4 mb-6">
|
||||
<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 por numero o periodo..."
|
||||
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={estadoFilter}
|
||||
onChange={(e) => setEstadoFilter(e.target.value as EstimacionEstado | '')}
|
||||
>
|
||||
<option value="">Todos los estados</option>
|
||||
{Object.entries(estadoLabels).map(([value, label]) => (
|
||||
<option key={value} value={value}>
|
||||
{label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</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>
|
||||
) : estimaciones.length === 0 ? (
|
||||
<div className="p-8 text-center text-gray-500">No hay estimaciones</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">
|
||||
Numero
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
|
||||
Periodo
|
||||
</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">
|
||||
Monto Estimado
|
||||
</th>
|
||||
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase">
|
||||
Monto Aprobado
|
||||
</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">
|
||||
{estimaciones.map((item) => (
|
||||
<tr key={item.id} className="hover:bg-gray-50">
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">
|
||||
Est-{item.numero.toString().padStart(3, '0')}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
|
||||
{item.periodo}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<span
|
||||
className={clsx(
|
||||
'px-2 py-1 text-xs font-medium rounded-full',
|
||||
estadoColors[item.estado]
|
||||
)}
|
||||
>
|
||||
{estadoLabels[item.estado]}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900 text-right">
|
||||
${item.montoEstimado.toLocaleString('es-MX', { minimumFractionDigits: 2 })}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900 text-right font-medium">
|
||||
{item.montoAprobado !== undefined && item.montoAprobado !== null
|
||||
? `$${item.montoAprobado.toLocaleString('es-MX', { minimumFractionDigits: 2 })}`
|
||||
: '-'}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-right">
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
<button
|
||||
className="p-2 text-gray-500 hover:text-blue-600 hover:bg-blue-50 rounded-lg"
|
||||
title="Ver detalle"
|
||||
onClick={() => setDetailId(item.id)}
|
||||
>
|
||||
<Eye className="w-4 h-4" />
|
||||
</button>
|
||||
|
||||
{item.estado === 'borrador' && (
|
||||
<button
|
||||
className="p-2 text-gray-500 hover:text-yellow-600 hover:bg-yellow-50 rounded-lg"
|
||||
title="Enviar a revision"
|
||||
onClick={() => handleSubmitEstimacion(item.id)}
|
||||
disabled={submitMutation.isPending}
|
||||
>
|
||||
<Send className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{item.estado === 'revision' && (
|
||||
<>
|
||||
<button
|
||||
className="p-2 text-gray-500 hover:text-green-600 hover:bg-green-50 rounded-lg"
|
||||
title="Aprobar"
|
||||
onClick={() => setApproveModal(item.id)}
|
||||
>
|
||||
<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"
|
||||
onClick={() => setRejectModal(item.id)}
|
||||
>
|
||||
<XCircle className="w-4 h-4" />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{item.estado === 'borrador' && (
|
||||
<button
|
||||
className="p-2 text-gray-500 hover:text-blue-600 hover:bg-blue-50 rounded-lg"
|
||||
title="Editar"
|
||||
onClick={() => {
|
||||
setEditingItem(item);
|
||||
setShowModal(true);
|
||||
}}
|
||||
>
|
||||
<Pencil className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{item.estado === 'borrador' && (
|
||||
<button
|
||||
className="p-2 text-gray-500 hover:text-red-600 hover:bg-red-50 rounded-lg"
|
||||
title="Eliminar"
|
||||
onClick={() => setDeleteConfirm(item.id)}
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{showModal && (
|
||||
<EstimacionModal
|
||||
item={editingItem}
|
||||
onClose={() => {
|
||||
setShowModal(false);
|
||||
setEditingItem(null);
|
||||
}}
|
||||
onSubmit={handleSubmit}
|
||||
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 estimacion? 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"
|
||||
onClick={() => handleDelete(deleteConfirm)}
|
||||
disabled={deleteMutation.isPending}
|
||||
>
|
||||
{deleteMutation.isPending ? 'Eliminando...' : 'Eliminar'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{approveModal && (
|
||||
<ApproveModal
|
||||
estimacionId={approveModal}
|
||||
onClose={() => setApproveModal(null)}
|
||||
onApprove={handleApprove}
|
||||
isLoading={approveMutation.isPending}
|
||||
/>
|
||||
)}
|
||||
|
||||
{rejectModal && (
|
||||
<RejectModal
|
||||
estimacionId={rejectModal}
|
||||
onClose={() => setRejectModal(null)}
|
||||
onReject={handleReject}
|
||||
isLoading={rejectMutation.isPending}
|
||||
/>
|
||||
)}
|
||||
|
||||
{detailId && (
|
||||
<EstimacionDetailModal
|
||||
estimacionId={detailId}
|
||||
onClose={() => setDetailId(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface EstimacionModalProps {
|
||||
item: Estimacion | null;
|
||||
onClose: () => void;
|
||||
onSubmit: (data: CreateEstimacionDto) => Promise<void>;
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
function EstimacionModal({ item, onClose, onSubmit, isLoading }: EstimacionModalProps) {
|
||||
const [formData, setFormData] = useState<CreateEstimacionDto>({
|
||||
presupuestoId: item?.presupuestoId || 'default-presupuesto-id',
|
||||
proyectoId: item?.proyectoId || 'default-project-id',
|
||||
periodo: item?.periodo || '',
|
||||
fechaInicio: item?.fechaInicio?.split('T')[0] || '',
|
||||
fechaFin: item?.fechaFin?.split('T')[0] || '',
|
||||
anticipoPorcentaje: item?.anticipoPorcentaje,
|
||||
retencionPorcentaje: item?.retencionPorcentaje,
|
||||
observaciones: item?.observaciones || '',
|
||||
});
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
await onSubmit(formData);
|
||||
};
|
||||
|
||||
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-lg w-full mx-4 max-h-[90vh] overflow-y-auto">
|
||||
<h3 className="text-lg font-semibold mb-4">
|
||||
{item ? 'Editar Estimacion' : 'Nueva Estimacion'}
|
||||
</h3>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Periodo *</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
placeholder="Ej: Enero 2026, Semana 1-4"
|
||||
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500"
|
||||
value={formData.periodo}
|
||||
onChange={(e) => setFormData({ ...formData, periodo: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Fecha Inicio *
|
||||
</label>
|
||||
<input
|
||||
type="date"
|
||||
required
|
||||
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500"
|
||||
value={formData.fechaInicio}
|
||||
onChange={(e) => setFormData({ ...formData, fechaInicio: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Fecha Fin *</label>
|
||||
<input
|
||||
type="date"
|
||||
required
|
||||
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500"
|
||||
value={formData.fechaFin}
|
||||
onChange={(e) => setFormData({ ...formData, fechaFin: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Anticipo (%)
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
max="100"
|
||||
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500"
|
||||
value={formData.anticipoPorcentaje || ''}
|
||||
onChange={(e) =>
|
||||
setFormData({
|
||||
...formData,
|
||||
anticipoPorcentaje: e.target.value ? parseFloat(e.target.value) : undefined,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Retencion (%)
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
max="100"
|
||||
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500"
|
||||
value={formData.retencionPorcentaje || ''}
|
||||
onChange={(e) =>
|
||||
setFormData({
|
||||
...formData,
|
||||
retencionPorcentaje: e.target.value ? parseFloat(e.target.value) : undefined,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Observaciones</label>
|
||||
<textarea
|
||||
rows={3}
|
||||
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500"
|
||||
value={formData.observaciones}
|
||||
onChange={(e) => setFormData({ ...formData, observaciones: 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...' : item ? 'Guardar Cambios' : 'Crear'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface ApproveModalProps {
|
||||
estimacionId: string;
|
||||
onClose: () => void;
|
||||
onApprove: (id: string, montoAprobado?: number) => Promise<void>;
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
function ApproveModal({ estimacionId, onClose, onApprove, isLoading }: ApproveModalProps) {
|
||||
const [montoAprobado, setMontoAprobado] = useState<string>('');
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
await onApprove(
|
||||
estimacionId,
|
||||
montoAprobado ? parseFloat(montoAprobado) : 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-md w-full mx-4">
|
||||
<h3 className="text-lg font-semibold mb-4">Aprobar Estimacion</h3>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Monto Aprobado (opcional)
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
placeholder="Dejar vacio para usar monto estimado"
|
||||
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500"
|
||||
value={montoAprobado}
|
||||
onChange={(e) => setMontoAprobado(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-green-600 text-white rounded-lg hover:bg-green-700 disabled:opacity-50"
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading ? 'Aprobando...' : 'Aprobar'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface RejectModalProps {
|
||||
estimacionId: string;
|
||||
onClose: () => void;
|
||||
onReject: (id: string, motivo: string) => Promise<void>;
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
function RejectModal({ estimacionId, onClose, onReject, isLoading }: RejectModalProps) {
|
||||
const [motivo, setMotivo] = useState('');
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
await onReject(estimacionId, motivo);
|
||||
};
|
||||
|
||||
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">
|
||||
<h3 className="text-lg font-semibold mb-4">Rechazar Estimacion</h3>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Motivo *</label>
|
||||
<textarea
|
||||
required
|
||||
rows={3}
|
||||
placeholder="Ingrese el motivo del rechazo..."
|
||||
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500"
|
||||
value={motivo}
|
||||
onChange={(e) => setMotivo(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}
|
||||
>
|
||||
{isLoading ? 'Rechazando...' : 'Rechazar'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface EstimacionDetailModalProps {
|
||||
estimacionId: string;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
function EstimacionDetailModal({ estimacionId, onClose }: EstimacionDetailModalProps) {
|
||||
const { data: estimacion, isLoading } = useEstimacion(estimacionId);
|
||||
const [expandedPartidas, setExpandedPartidas] = useState<Set<string>>(new Set());
|
||||
|
||||
const togglePartida = (id: string) => {
|
||||
setExpandedPartidas((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) {
|
||||
next.delete(id);
|
||||
} else {
|
||||
next.add(id);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
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-4xl w-full mx-4 max-h-[90vh] overflow-y-auto">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h3 className="text-lg font-semibold">Detalle de Estimacion</h3>
|
||||
<button
|
||||
className="p-2 text-gray-500 hover:text-gray-700 hover:bg-gray-100 rounded-lg"
|
||||
onClick={onClose}
|
||||
>
|
||||
<XCircle className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="p-8 text-center text-gray-500">Cargando...</div>
|
||||
) : !estimacion ? (
|
||||
<div className="p-8 text-center text-red-500">Error al cargar la estimacion</div>
|
||||
) : (
|
||||
<div className="space-y-6">
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<div className="bg-gray-50 p-4 rounded-lg">
|
||||
<p className="text-sm text-gray-500">Numero</p>
|
||||
<p className="text-lg font-semibold">
|
||||
Est-{estimacion.numero.toString().padStart(3, '0')}
|
||||
</p>
|
||||
</div>
|
||||
<div className="bg-gray-50 p-4 rounded-lg">
|
||||
<p className="text-sm text-gray-500">Periodo</p>
|
||||
<p className="text-lg font-semibold">{estimacion.periodo}</p>
|
||||
</div>
|
||||
<div className="bg-gray-50 p-4 rounded-lg">
|
||||
<p className="text-sm text-gray-500">Estado</p>
|
||||
<span
|
||||
className={clsx(
|
||||
'px-2 py-1 text-xs font-medium rounded-full',
|
||||
estadoColors[estimacion.estado]
|
||||
)}
|
||||
>
|
||||
{estadoLabels[estimacion.estado]}
|
||||
</span>
|
||||
</div>
|
||||
<div className="bg-gray-50 p-4 rounded-lg">
|
||||
<p className="text-sm text-gray-500">Monto Estimado</p>
|
||||
<p className="text-lg font-semibold text-green-600">
|
||||
${estimacion.montoEstimado.toLocaleString('es-MX', { minimumFractionDigits: 2 })}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{estimacion.montoAprobado !== undefined && (
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<div className="bg-green-50 p-4 rounded-lg">
|
||||
<p className="text-sm text-green-700">Monto Aprobado</p>
|
||||
<p className="text-lg font-semibold text-green-800">
|
||||
${estimacion.montoAprobado.toLocaleString('es-MX', { minimumFractionDigits: 2 })}
|
||||
</p>
|
||||
</div>
|
||||
{estimacion.montoFacturado !== undefined && (
|
||||
<div className="bg-blue-50 p-4 rounded-lg">
|
||||
<p className="text-sm text-blue-700">Monto Facturado</p>
|
||||
<p className="text-lg font-semibold text-blue-800">
|
||||
${estimacion.montoFacturado.toLocaleString('es-MX', { minimumFractionDigits: 2 })}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{estimacion.montoCobrado !== undefined && (
|
||||
<div className="bg-purple-50 p-4 rounded-lg">
|
||||
<p className="text-sm text-purple-700">Monto Cobrado</p>
|
||||
<p className="text-lg font-semibold text-purple-800">
|
||||
${estimacion.montoCobrado.toLocaleString('es-MX', { minimumFractionDigits: 2 })}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<h4 className="font-semibold text-gray-900 mb-3 flex items-center">
|
||||
<FileText className="w-5 h-5 mr-2" />
|
||||
Partidas ({estimacion.partidas?.length || 0})
|
||||
</h4>
|
||||
|
||||
{!estimacion.partidas || estimacion.partidas.length === 0 ? (
|
||||
<p className="text-gray-500 text-center py-4">No hay partidas en esta estimacion</p>
|
||||
) : (
|
||||
<div className="border rounded-lg overflow-hidden">
|
||||
<table className="min-w-full divide-y divide-gray-200">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th className="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase w-8"></th>
|
||||
<th className="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase">
|
||||
Concepto
|
||||
</th>
|
||||
<th className="px-4 py-2 text-right text-xs font-medium text-gray-500 uppercase">
|
||||
Cant. Est.
|
||||
</th>
|
||||
<th className="px-4 py-2 text-right text-xs font-medium text-gray-500 uppercase">
|
||||
P.U.
|
||||
</th>
|
||||
<th className="px-4 py-2 text-right text-xs font-medium text-gray-500 uppercase">
|
||||
Importe Est.
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-200">
|
||||
{estimacion.partidas.map((partida) => (
|
||||
<PartidaRow
|
||||
key={partida.id}
|
||||
partida={partida}
|
||||
isExpanded={expandedPartidas.has(partida.id)}
|
||||
onToggle={() => togglePartida(partida.id)}
|
||||
/>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{estimacion.observaciones && (
|
||||
<div className="bg-yellow-50 p-4 rounded-lg">
|
||||
<p className="text-sm font-medium text-yellow-800">Observaciones</p>
|
||||
<p className="text-yellow-700">{estimacion.observaciones}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface PartidaRowProps {
|
||||
partida: EstimacionPartida;
|
||||
isExpanded: boolean;
|
||||
onToggle: () => void;
|
||||
}
|
||||
|
||||
function PartidaRow({ partida, isExpanded, onToggle }: PartidaRowProps) {
|
||||
const hasGeneradores = partida.generadores && partida.generadores.length > 0;
|
||||
|
||||
return (
|
||||
<>
|
||||
<tr className="hover:bg-gray-50">
|
||||
<td className="px-4 py-2">
|
||||
{hasGeneradores && (
|
||||
<button onClick={onToggle} className="p-1 hover:bg-gray-200 rounded">
|
||||
{isExpanded ? (
|
||||
<ChevronDown className="w-4 h-4 text-gray-500" />
|
||||
) : (
|
||||
<ChevronRight className="w-4 h-4 text-gray-500" />
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-2 text-sm">
|
||||
<span className="font-mono text-gray-500 mr-2">{partida.concepto?.codigo}</span>
|
||||
{partida.concepto?.descripcion}
|
||||
</td>
|
||||
<td className="px-4 py-2 text-sm text-right">
|
||||
{partida.cantidadEstimada.toLocaleString('es-MX')} {partida.concepto?.unidad}
|
||||
</td>
|
||||
<td className="px-4 py-2 text-sm text-right">
|
||||
${partida.precioUnitario.toLocaleString('es-MX', { minimumFractionDigits: 2 })}
|
||||
</td>
|
||||
<td className="px-4 py-2 text-sm text-right font-medium">
|
||||
${partida.importeEstimado.toLocaleString('es-MX', { minimumFractionDigits: 2 })}
|
||||
</td>
|
||||
</tr>
|
||||
{isExpanded && hasGeneradores && (
|
||||
<tr>
|
||||
<td colSpan={5} className="px-4 py-2 bg-gray-50">
|
||||
<div className="pl-8">
|
||||
<p className="text-xs font-medium text-gray-500 mb-2">Generadores:</p>
|
||||
<table className="w-full text-xs">
|
||||
<thead>
|
||||
<tr className="text-gray-500">
|
||||
<th className="text-left py-1">Descripcion</th>
|
||||
<th className="text-right py-1">Largo</th>
|
||||
<th className="text-right py-1">Ancho</th>
|
||||
<th className="text-right py-1">Alto</th>
|
||||
<th className="text-right py-1">Cantidad</th>
|
||||
<th className="text-right py-1">Parcial</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{partida.generadores!.map((gen) => (
|
||||
<tr key={gen.id} className="border-t border-gray-200">
|
||||
<td className="py-1">{gen.descripcion}</td>
|
||||
<td className="text-right py-1">{gen.largo || '-'}</td>
|
||||
<td className="text-right py-1">{gen.ancho || '-'}</td>
|
||||
<td className="text-right py-1">{gen.alto || '-'}</td>
|
||||
<td className="text-right py-1">{gen.cantidad}</td>
|
||||
<td className="text-right py-1 font-medium">{gen.parcial.toFixed(2)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
409
web/src/pages/admin/presupuestos/PresupuestosPage.tsx
Normal file
409
web/src/pages/admin/presupuestos/PresupuestosPage.tsx
Normal file
@ -0,0 +1,409 @@
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
Plus,
|
||||
Pencil,
|
||||
Trash2,
|
||||
Search,
|
||||
Copy,
|
||||
CheckCircle,
|
||||
Eye,
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
usePresupuestos,
|
||||
useCreatePresupuesto,
|
||||
useUpdatePresupuesto,
|
||||
useDeletePresupuesto,
|
||||
useApprovePresupuesto,
|
||||
useDuplicatePresupuesto,
|
||||
} from '../../../hooks/usePresupuestos';
|
||||
import {
|
||||
Presupuesto,
|
||||
PresupuestoEstado,
|
||||
CreatePresupuestoDto,
|
||||
} from '../../../services/presupuestos';
|
||||
import clsx from 'clsx';
|
||||
|
||||
const estadoColors: Record<PresupuestoEstado, string> = {
|
||||
borrador: 'bg-gray-100 text-gray-800',
|
||||
revision: 'bg-yellow-100 text-yellow-800',
|
||||
aprobado: 'bg-green-100 text-green-800',
|
||||
cerrado: 'bg-blue-100 text-blue-800',
|
||||
};
|
||||
|
||||
const estadoLabels: Record<PresupuestoEstado, string> = {
|
||||
borrador: 'Borrador',
|
||||
revision: 'En Revision',
|
||||
aprobado: 'Aprobado',
|
||||
cerrado: 'Cerrado',
|
||||
};
|
||||
|
||||
export function PresupuestosPage() {
|
||||
const [search, setSearch] = useState('');
|
||||
const [estadoFilter, setEstadoFilter] = useState<PresupuestoEstado | ''>('');
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
const [editingItem, setEditingItem] = useState<Presupuesto | null>(null);
|
||||
const [deleteConfirm, setDeleteConfirm] = useState<string | null>(null);
|
||||
const [approveConfirm, setApproveConfirm] = useState<string | null>(null);
|
||||
|
||||
const { data, isLoading, error } = usePresupuestos({
|
||||
estado: estadoFilter || undefined,
|
||||
});
|
||||
|
||||
const deleteMutation = useDeletePresupuesto();
|
||||
const createMutation = useCreatePresupuesto();
|
||||
const updateMutation = useUpdatePresupuesto();
|
||||
const approveMutation = useApprovePresupuesto();
|
||||
const duplicateMutation = useDuplicatePresupuesto();
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
await deleteMutation.mutateAsync(id);
|
||||
setDeleteConfirm(null);
|
||||
};
|
||||
|
||||
const handleApprove = async (id: string) => {
|
||||
await approveMutation.mutateAsync(id);
|
||||
setApproveConfirm(null);
|
||||
};
|
||||
|
||||
const handleDuplicate = async (id: string) => {
|
||||
await duplicateMutation.mutateAsync(id);
|
||||
};
|
||||
|
||||
const handleSubmit = async (formData: CreatePresupuestoDto) => {
|
||||
if (editingItem) {
|
||||
await updateMutation.mutateAsync({
|
||||
id: editingItem.id,
|
||||
data: {
|
||||
codigo: formData.codigo,
|
||||
nombre: formData.nombre,
|
||||
estado: formData.estado,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
await createMutation.mutateAsync(formData);
|
||||
}
|
||||
setShowModal(false);
|
||||
setEditingItem(null);
|
||||
};
|
||||
|
||||
const presupuestos = (data?.items || []).filter(
|
||||
(p) =>
|
||||
!search ||
|
||||
p.codigo.toLowerCase().includes(search.toLowerCase()) ||
|
||||
p.nombre.toLowerCase().includes(search.toLowerCase())
|
||||
);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">Presupuestos</h1>
|
||||
<p className="text-gray-600">Gestion de presupuestos de obra</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={() => {
|
||||
setEditingItem(null);
|
||||
setShowModal(true);
|
||||
}}
|
||||
>
|
||||
<Plus className="w-5 h-5 mr-2" />
|
||||
Nuevo Presupuesto
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-lg shadow-sm p-4 mb-6">
|
||||
<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 por codigo o nombre..."
|
||||
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={estadoFilter}
|
||||
onChange={(e) => setEstadoFilter(e.target.value as PresupuestoEstado | '')}
|
||||
>
|
||||
<option value="">Todos los estados</option>
|
||||
{Object.entries(estadoLabels).map(([value, label]) => (
|
||||
<option key={value} value={value}>
|
||||
{label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</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>
|
||||
) : presupuestos.length === 0 ? (
|
||||
<div className="p-8 text-center text-gray-500">No hay presupuestos</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">
|
||||
Codigo
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
|
||||
Nombre
|
||||
</th>
|
||||
<th className="px-6 py-3 text-center text-xs font-medium text-gray-500 uppercase">
|
||||
Version
|
||||
</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">
|
||||
Monto Total
|
||||
</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">
|
||||
{presupuestos.map((item) => (
|
||||
<tr key={item.id} className="hover:bg-gray-50">
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">
|
||||
{item.codigo}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
|
||||
{item.nombre}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500 text-center">
|
||||
v{item.version}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<span
|
||||
className={clsx(
|
||||
'px-2 py-1 text-xs font-medium rounded-full',
|
||||
estadoColors[item.estado]
|
||||
)}
|
||||
>
|
||||
{estadoLabels[item.estado]}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900 text-right font-medium">
|
||||
${item.montoTotal.toLocaleString('es-MX', { minimumFractionDigits: 2 })}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-right">
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
<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>
|
||||
{item.estado === 'revision' && (
|
||||
<button
|
||||
className="p-2 text-gray-500 hover:text-green-600 hover:bg-green-50 rounded-lg"
|
||||
title="Aprobar"
|
||||
onClick={() => setApproveConfirm(item.id)}
|
||||
>
|
||||
<CheckCircle className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
className="p-2 text-gray-500 hover:text-purple-600 hover:bg-purple-50 rounded-lg"
|
||||
title="Duplicar"
|
||||
onClick={() => handleDuplicate(item.id)}
|
||||
disabled={duplicateMutation.isPending}
|
||||
>
|
||||
<Copy className="w-4 h-4" />
|
||||
</button>
|
||||
{(item.estado === 'borrador' || item.estado === 'revision') && (
|
||||
<button
|
||||
className="p-2 text-gray-500 hover:text-blue-600 hover:bg-blue-50 rounded-lg"
|
||||
title="Editar"
|
||||
onClick={() => {
|
||||
setEditingItem(item);
|
||||
setShowModal(true);
|
||||
}}
|
||||
>
|
||||
<Pencil className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
{item.estado === 'borrador' && (
|
||||
<button
|
||||
className="p-2 text-gray-500 hover:text-red-600 hover:bg-red-50 rounded-lg"
|
||||
title="Eliminar"
|
||||
onClick={() => setDeleteConfirm(item.id)}
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{showModal && (
|
||||
<PresupuestoModal
|
||||
item={editingItem}
|
||||
onClose={() => {
|
||||
setShowModal(false);
|
||||
setEditingItem(null);
|
||||
}}
|
||||
onSubmit={handleSubmit}
|
||||
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 este presupuesto? 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"
|
||||
onClick={() => handleDelete(deleteConfirm)}
|
||||
disabled={deleteMutation.isPending}
|
||||
>
|
||||
{deleteMutation.isPending ? 'Eliminando...' : 'Eliminar'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{approveConfirm && (
|
||||
<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 aprobacion</h3>
|
||||
<p className="text-gray-600 mb-6">
|
||||
¿Esta seguro de aprobar este presupuesto? Una vez aprobado, no podra ser editado.
|
||||
</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={() => setApproveConfirm(null)}
|
||||
>
|
||||
Cancelar
|
||||
</button>
|
||||
<button
|
||||
className="px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700"
|
||||
onClick={() => handleApprove(approveConfirm)}
|
||||
disabled={approveMutation.isPending}
|
||||
>
|
||||
{approveMutation.isPending ? 'Aprobando...' : 'Aprobar'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface PresupuestoModalProps {
|
||||
item: Presupuesto | null;
|
||||
onClose: () => void;
|
||||
onSubmit: (data: CreatePresupuestoDto) => Promise<void>;
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
function PresupuestoModal({ item, onClose, onSubmit, isLoading }: PresupuestoModalProps) {
|
||||
const [formData, setFormData] = useState<CreatePresupuestoDto>({
|
||||
codigo: item?.codigo || '',
|
||||
nombre: item?.nombre || '',
|
||||
proyectoId: item?.proyectoId || 'default-project-id',
|
||||
estado: item?.estado || 'borrador',
|
||||
});
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
await onSubmit(formData);
|
||||
};
|
||||
|
||||
const editableStates: PresupuestoEstado[] = ['borrador', 'revision'];
|
||||
|
||||
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-lg w-full mx-4 max-h-[90vh] overflow-y-auto">
|
||||
<h3 className="text-lg font-semibold mb-4">
|
||||
{item ? 'Editar Presupuesto' : 'Nuevo Presupuesto'}
|
||||
</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">Codigo *</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500"
|
||||
value={formData.codigo}
|
||||
onChange={(e) => setFormData({ ...formData, codigo: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Estado</label>
|
||||
<select
|
||||
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500"
|
||||
value={formData.estado}
|
||||
onChange={(e) =>
|
||||
setFormData({ ...formData, estado: e.target.value as PresupuestoEstado })
|
||||
}
|
||||
>
|
||||
{editableStates.map((estado) => (
|
||||
<option key={estado} value={estado}>
|
||||
{estadoLabels[estado]}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Nombre *</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500"
|
||||
value={formData.nombre}
|
||||
onChange={(e) => setFormData({ ...formData, nombre: 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...' : item ? 'Guardar Cambios' : 'Crear'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
3
web/src/pages/admin/presupuestos/index.ts
Normal file
3
web/src/pages/admin/presupuestos/index.ts
Normal file
@ -0,0 +1,3 @@
|
||||
export { ConceptosPage } from './ConceptosPage';
|
||||
export { PresupuestosPage } from './PresupuestosPage';
|
||||
export { EstimacionesPage } from './EstimacionesPage';
|
||||
334
web/src/services/bidding/bidding.api.ts
Normal file
334
web/src/services/bidding/bidding.api.ts
Normal file
@ -0,0 +1,334 @@
|
||||
import api, { PaginatedResponse, PaginationParams } from '../api';
|
||||
|
||||
// ============================================================================
|
||||
// STATUS TYPES
|
||||
// ============================================================================
|
||||
|
||||
export type OpportunityStatus = 'lead' | 'qualified' | 'proposal' | 'negotiation' | 'won' | 'lost';
|
||||
export type TenderStatus = 'draft' | 'published' | 'evaluation' | 'awarded' | 'cancelled';
|
||||
export type ProposalStatus = 'draft' | 'submitted' | 'under_review' | 'accepted' | 'rejected';
|
||||
|
||||
// ============================================================================
|
||||
// ENTITY INTERFACES
|
||||
// ============================================================================
|
||||
|
||||
export interface Opportunity {
|
||||
id: string;
|
||||
tenantId: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
clientName: string;
|
||||
estimatedValue: number;
|
||||
probability: number;
|
||||
status: OpportunityStatus;
|
||||
expectedCloseDate?: string;
|
||||
source?: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface Tender {
|
||||
id: string;
|
||||
tenantId: string;
|
||||
opportunityId?: string;
|
||||
referenceNumber: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
issuingEntity: string;
|
||||
estimatedBudget?: number;
|
||||
status: TenderStatus;
|
||||
publicationDate?: string;
|
||||
submissionDeadline: string;
|
||||
openingDate?: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface Proposal {
|
||||
id: string;
|
||||
tenantId: string;
|
||||
tenderId: string;
|
||||
tender?: Tender;
|
||||
proposalNumber: string;
|
||||
technicalScore?: number;
|
||||
economicScore?: number;
|
||||
totalAmount: number;
|
||||
status: ProposalStatus;
|
||||
submittedAt?: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface Vendor {
|
||||
id: string;
|
||||
tenantId: string;
|
||||
businessName: string;
|
||||
rfc?: string;
|
||||
contactName?: string;
|
||||
email?: string;
|
||||
phone?: string;
|
||||
isActive: boolean;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// FILTER INTERFACES
|
||||
// ============================================================================
|
||||
|
||||
export interface OpportunityFilters extends PaginationParams {
|
||||
status?: OpportunityStatus;
|
||||
clientName?: string;
|
||||
minValue?: number;
|
||||
maxValue?: number;
|
||||
}
|
||||
|
||||
export interface TenderFilters extends PaginationParams {
|
||||
status?: TenderStatus;
|
||||
opportunityId?: string;
|
||||
issuingEntity?: string;
|
||||
}
|
||||
|
||||
export interface ProposalFilters extends PaginationParams {
|
||||
status?: ProposalStatus;
|
||||
tenderId?: string;
|
||||
}
|
||||
|
||||
export interface VendorFilters extends PaginationParams {
|
||||
isActive?: boolean;
|
||||
businessName?: string;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// DTO INTERFACES
|
||||
// ============================================================================
|
||||
|
||||
export interface CreateOpportunityDto {
|
||||
title: string;
|
||||
clientName: string;
|
||||
estimatedValue: number;
|
||||
probability: number;
|
||||
description?: string;
|
||||
status?: OpportunityStatus;
|
||||
expectedCloseDate?: string;
|
||||
source?: string;
|
||||
}
|
||||
|
||||
export interface UpdateOpportunityDto {
|
||||
title?: string;
|
||||
description?: string;
|
||||
clientName?: string;
|
||||
estimatedValue?: number;
|
||||
probability?: number;
|
||||
status?: OpportunityStatus;
|
||||
expectedCloseDate?: string;
|
||||
source?: string;
|
||||
}
|
||||
|
||||
export interface CreateTenderDto {
|
||||
referenceNumber: string;
|
||||
title: string;
|
||||
issuingEntity: string;
|
||||
submissionDeadline: string;
|
||||
opportunityId?: string;
|
||||
description?: string;
|
||||
estimatedBudget?: number;
|
||||
status?: TenderStatus;
|
||||
publicationDate?: string;
|
||||
openingDate?: string;
|
||||
}
|
||||
|
||||
export interface UpdateTenderDto {
|
||||
referenceNumber?: string;
|
||||
title?: string;
|
||||
description?: string;
|
||||
issuingEntity?: string;
|
||||
estimatedBudget?: number;
|
||||
status?: TenderStatus;
|
||||
publicationDate?: string;
|
||||
submissionDeadline?: string;
|
||||
openingDate?: string;
|
||||
}
|
||||
|
||||
export interface CreateProposalDto {
|
||||
tenderId: string;
|
||||
proposalNumber: string;
|
||||
totalAmount: number;
|
||||
technicalScore?: number;
|
||||
economicScore?: number;
|
||||
status?: ProposalStatus;
|
||||
}
|
||||
|
||||
export interface UpdateProposalDto {
|
||||
proposalNumber?: string;
|
||||
technicalScore?: number;
|
||||
economicScore?: number;
|
||||
totalAmount?: number;
|
||||
status?: ProposalStatus;
|
||||
}
|
||||
|
||||
export interface CreateVendorDto {
|
||||
businessName: string;
|
||||
rfc?: string;
|
||||
contactName?: string;
|
||||
email?: string;
|
||||
phone?: string;
|
||||
isActive?: boolean;
|
||||
}
|
||||
|
||||
export interface UpdateVendorDto {
|
||||
businessName?: string;
|
||||
rfc?: string;
|
||||
contactName?: string;
|
||||
email?: string;
|
||||
phone?: string;
|
||||
isActive?: boolean;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// OPPORTUNITIES API
|
||||
// ============================================================================
|
||||
|
||||
export const opportunitiesApi = {
|
||||
list: async (filters?: OpportunityFilters): Promise<PaginatedResponse<Opportunity>> => {
|
||||
const response = await api.get<PaginatedResponse<Opportunity>>('/bidding/opportunities', {
|
||||
params: filters,
|
||||
});
|
||||
return response.data;
|
||||
},
|
||||
|
||||
get: async (id: string): Promise<Opportunity> => {
|
||||
const response = await api.get<Opportunity>(`/bidding/opportunities/${id}`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
create: async (data: CreateOpportunityDto): Promise<Opportunity> => {
|
||||
const response = await api.post<Opportunity>('/bidding/opportunities', data);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
update: async (id: string, data: UpdateOpportunityDto): Promise<Opportunity> => {
|
||||
const response = await api.patch<Opportunity>(`/bidding/opportunities/${id}`, data);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
delete: async (id: string): Promise<void> => {
|
||||
await api.delete(`/bidding/opportunities/${id}`);
|
||||
},
|
||||
|
||||
updateStatus: async (id: string, status: OpportunityStatus): Promise<Opportunity> => {
|
||||
const response = await api.patch<Opportunity>(`/bidding/opportunities/${id}/status`, { status });
|
||||
return response.data;
|
||||
},
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// TENDERS API
|
||||
// ============================================================================
|
||||
|
||||
export const tendersApi = {
|
||||
list: async (filters?: TenderFilters): Promise<PaginatedResponse<Tender>> => {
|
||||
const response = await api.get<PaginatedResponse<Tender>>('/bidding/tenders', {
|
||||
params: filters,
|
||||
});
|
||||
return response.data;
|
||||
},
|
||||
|
||||
get: async (id: string): Promise<Tender> => {
|
||||
const response = await api.get<Tender>(`/bidding/tenders/${id}`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
create: async (data: CreateTenderDto): Promise<Tender> => {
|
||||
const response = await api.post<Tender>('/bidding/tenders', data);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
update: async (id: string, data: UpdateTenderDto): Promise<Tender> => {
|
||||
const response = await api.patch<Tender>(`/bidding/tenders/${id}`, data);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
delete: async (id: string): Promise<void> => {
|
||||
await api.delete(`/bidding/tenders/${id}`);
|
||||
},
|
||||
|
||||
updateStatus: async (id: string, status: TenderStatus): Promise<Tender> => {
|
||||
const response = await api.patch<Tender>(`/bidding/tenders/${id}/status`, { status });
|
||||
return response.data;
|
||||
},
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// PROPOSALS API
|
||||
// ============================================================================
|
||||
|
||||
export const proposalsApi = {
|
||||
list: async (filters?: ProposalFilters): Promise<PaginatedResponse<Proposal>> => {
|
||||
const response = await api.get<PaginatedResponse<Proposal>>('/bidding/proposals', {
|
||||
params: filters,
|
||||
});
|
||||
return response.data;
|
||||
},
|
||||
|
||||
get: async (id: string): Promise<Proposal> => {
|
||||
const response = await api.get<Proposal>(`/bidding/proposals/${id}`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
create: async (data: CreateProposalDto): Promise<Proposal> => {
|
||||
const response = await api.post<Proposal>('/bidding/proposals', data);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
update: async (id: string, data: UpdateProposalDto): Promise<Proposal> => {
|
||||
const response = await api.patch<Proposal>(`/bidding/proposals/${id}`, data);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
delete: async (id: string): Promise<void> => {
|
||||
await api.delete(`/bidding/proposals/${id}`);
|
||||
},
|
||||
|
||||
submit: async (id: string): Promise<Proposal> => {
|
||||
const response = await api.post<Proposal>(`/bidding/proposals/${id}/submit`);
|
||||
return response.data;
|
||||
},
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// VENDORS API
|
||||
// ============================================================================
|
||||
|
||||
export const vendorsApi = {
|
||||
list: async (filters?: VendorFilters): Promise<PaginatedResponse<Vendor>> => {
|
||||
const response = await api.get<PaginatedResponse<Vendor>>('/bidding/vendors', {
|
||||
params: filters,
|
||||
});
|
||||
return response.data;
|
||||
},
|
||||
|
||||
get: async (id: string): Promise<Vendor> => {
|
||||
const response = await api.get<Vendor>(`/bidding/vendors/${id}`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
create: async (data: CreateVendorDto): Promise<Vendor> => {
|
||||
const response = await api.post<Vendor>('/bidding/vendors', data);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
update: async (id: string, data: UpdateVendorDto): Promise<Vendor> => {
|
||||
const response = await api.patch<Vendor>(`/bidding/vendors/${id}`, data);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
delete: async (id: string): Promise<void> => {
|
||||
await api.delete(`/bidding/vendors/${id}`);
|
||||
},
|
||||
|
||||
toggleActive: async (id: string): Promise<Vendor> => {
|
||||
const response = await api.patch<Vendor>(`/bidding/vendors/${id}/toggle-active`);
|
||||
return response.data;
|
||||
},
|
||||
};
|
||||
1
web/src/services/bidding/index.ts
Normal file
1
web/src/services/bidding/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export * from './bidding.api';
|
||||
259
web/src/services/presupuestos/estimaciones.api.ts
Normal file
259
web/src/services/presupuestos/estimaciones.api.ts
Normal file
@ -0,0 +1,259 @@
|
||||
import api, { PaginatedResponse, PaginationParams } from '../api';
|
||||
import { Concepto } from './presupuestos.api';
|
||||
|
||||
// ===========================
|
||||
// TIPOS PARA ESTIMACIONES
|
||||
// ===========================
|
||||
|
||||
export type EstimacionEstado =
|
||||
| 'borrador'
|
||||
| 'revision'
|
||||
| 'aprobado'
|
||||
| 'facturado'
|
||||
| 'cobrado'
|
||||
| 'rechazado';
|
||||
|
||||
export interface Estimacion {
|
||||
id: string;
|
||||
tenantId: string;
|
||||
presupuestoId: string;
|
||||
proyectoId: string;
|
||||
numero: number;
|
||||
periodo: string;
|
||||
fechaInicio: string;
|
||||
fechaFin: string;
|
||||
estado: EstimacionEstado;
|
||||
montoEstimado: number;
|
||||
montoAprobado?: number;
|
||||
montoFacturado?: number;
|
||||
montoCobrado?: number;
|
||||
anticipoPorcentaje?: number;
|
||||
retencionPorcentaje?: number;
|
||||
deductivas?: number;
|
||||
observaciones?: string;
|
||||
fechaAprobacion?: string;
|
||||
fechaFacturacion?: string;
|
||||
fechaCobro?: string;
|
||||
partidas?: EstimacionPartida[];
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface EstimacionPartida {
|
||||
id: string;
|
||||
estimacionId: string;
|
||||
conceptoId: string;
|
||||
concepto?: Concepto;
|
||||
cantidadEstimada: number;
|
||||
cantidadAprobada?: number;
|
||||
precioUnitario: number;
|
||||
importeEstimado: number;
|
||||
importeAprobado?: number;
|
||||
observaciones?: string;
|
||||
generadores?: Generador[];
|
||||
}
|
||||
|
||||
export interface Generador {
|
||||
id: string;
|
||||
partidaId: string;
|
||||
descripcion: string;
|
||||
croquis?: string;
|
||||
largo?: number;
|
||||
ancho?: number;
|
||||
alto?: number;
|
||||
cantidad: number;
|
||||
parcial: number;
|
||||
ubicacion?: string;
|
||||
fechaEjecucion?: string;
|
||||
}
|
||||
|
||||
export interface EstimacionFilters extends PaginationParams {
|
||||
presupuestoId?: string;
|
||||
proyectoId?: string;
|
||||
estado?: EstimacionEstado;
|
||||
periodo?: string;
|
||||
}
|
||||
|
||||
export interface CreateEstimacionDto {
|
||||
presupuestoId: string;
|
||||
proyectoId: string;
|
||||
periodo: string;
|
||||
fechaInicio: string;
|
||||
fechaFin: string;
|
||||
anticipoPorcentaje?: number;
|
||||
retencionPorcentaje?: number;
|
||||
observaciones?: string;
|
||||
}
|
||||
|
||||
export interface UpdateEstimacionDto {
|
||||
periodo?: string;
|
||||
fechaInicio?: string;
|
||||
fechaFin?: string;
|
||||
anticipoPorcentaje?: number;
|
||||
retencionPorcentaje?: number;
|
||||
deductivas?: number;
|
||||
observaciones?: string;
|
||||
}
|
||||
|
||||
export interface CreateEstimacionPartidaDto {
|
||||
conceptoId: string;
|
||||
cantidadEstimada: number;
|
||||
precioUnitario: number;
|
||||
observaciones?: string;
|
||||
}
|
||||
|
||||
export interface UpdateEstimacionPartidaDto {
|
||||
cantidadEstimada?: number;
|
||||
cantidadAprobada?: number;
|
||||
precioUnitario?: number;
|
||||
observaciones?: string;
|
||||
}
|
||||
|
||||
export interface CreateGeneradorDto {
|
||||
descripcion: string;
|
||||
croquis?: string;
|
||||
largo?: number;
|
||||
ancho?: number;
|
||||
alto?: number;
|
||||
cantidad: number;
|
||||
ubicacion?: string;
|
||||
fechaEjecucion?: string;
|
||||
}
|
||||
|
||||
export interface UpdateGeneradorDto {
|
||||
descripcion?: string;
|
||||
croquis?: string;
|
||||
largo?: number;
|
||||
ancho?: number;
|
||||
alto?: number;
|
||||
cantidad?: number;
|
||||
ubicacion?: string;
|
||||
fechaEjecucion?: string;
|
||||
}
|
||||
|
||||
// ===========================
|
||||
// API DE ESTIMACIONES
|
||||
// ===========================
|
||||
|
||||
export const estimacionesApi = {
|
||||
list: async (filters?: EstimacionFilters): Promise<PaginatedResponse<Estimacion>> => {
|
||||
const response = await api.get<PaginatedResponse<Estimacion>>('/estimaciones', {
|
||||
params: filters,
|
||||
});
|
||||
return response.data;
|
||||
},
|
||||
|
||||
get: async (id: string): Promise<Estimacion> => {
|
||||
const response = await api.get<Estimacion>(`/estimaciones/${id}`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
create: async (data: CreateEstimacionDto): Promise<Estimacion> => {
|
||||
const response = await api.post<Estimacion>('/estimaciones', data);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
update: async (id: string, data: UpdateEstimacionDto): Promise<Estimacion> => {
|
||||
const response = await api.patch<Estimacion>(`/estimaciones/${id}`, data);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
delete: async (id: string): Promise<void> => {
|
||||
await api.delete(`/estimaciones/${id}`);
|
||||
},
|
||||
|
||||
// Workflow actions
|
||||
submit: async (id: string): Promise<Estimacion> => {
|
||||
const response = await api.post<Estimacion>(`/estimaciones/${id}/submit`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
approve: async (id: string, montoAprobado?: number): Promise<Estimacion> => {
|
||||
const response = await api.post<Estimacion>(`/estimaciones/${id}/approve`, {
|
||||
montoAprobado,
|
||||
});
|
||||
return response.data;
|
||||
},
|
||||
|
||||
reject: async (id: string, motivo: string): Promise<Estimacion> => {
|
||||
const response = await api.post<Estimacion>(`/estimaciones/${id}/reject`, {
|
||||
motivo,
|
||||
});
|
||||
return response.data;
|
||||
},
|
||||
|
||||
markAsInvoiced: async (id: string, montoFacturado: number): Promise<Estimacion> => {
|
||||
const response = await api.post<Estimacion>(`/estimaciones/${id}/invoice`, {
|
||||
montoFacturado,
|
||||
});
|
||||
return response.data;
|
||||
},
|
||||
|
||||
markAsPaid: async (id: string, montoCobrado: number): Promise<Estimacion> => {
|
||||
const response = await api.post<Estimacion>(`/estimaciones/${id}/pay`, {
|
||||
montoCobrado,
|
||||
});
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Partidas
|
||||
addPartida: async (estimacionId: string, data: CreateEstimacionPartidaDto): Promise<EstimacionPartida> => {
|
||||
const response = await api.post<EstimacionPartida>(
|
||||
`/estimaciones/${estimacionId}/partidas`,
|
||||
data
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
updatePartida: async (
|
||||
estimacionId: string,
|
||||
partidaId: string,
|
||||
data: UpdateEstimacionPartidaDto
|
||||
): Promise<EstimacionPartida> => {
|
||||
const response = await api.patch<EstimacionPartida>(
|
||||
`/estimaciones/${estimacionId}/partidas/${partidaId}`,
|
||||
data
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
removePartida: async (estimacionId: string, partidaId: string): Promise<void> => {
|
||||
await api.delete(`/estimaciones/${estimacionId}/partidas/${partidaId}`);
|
||||
},
|
||||
|
||||
// Generadores
|
||||
addGenerador: async (
|
||||
estimacionId: string,
|
||||
partidaId: string,
|
||||
data: CreateGeneradorDto
|
||||
): Promise<Generador> => {
|
||||
const response = await api.post<Generador>(
|
||||
`/estimaciones/${estimacionId}/partidas/${partidaId}/generadores`,
|
||||
data
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
updateGenerador: async (
|
||||
estimacionId: string,
|
||||
partidaId: string,
|
||||
generadorId: string,
|
||||
data: UpdateGeneradorDto
|
||||
): Promise<Generador> => {
|
||||
const response = await api.patch<Generador>(
|
||||
`/estimaciones/${estimacionId}/partidas/${partidaId}/generadores/${generadorId}`,
|
||||
data
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
removeGenerador: async (
|
||||
estimacionId: string,
|
||||
partidaId: string,
|
||||
generadorId: string
|
||||
): Promise<void> => {
|
||||
await api.delete(
|
||||
`/estimaciones/${estimacionId}/partidas/${partidaId}/generadores/${generadorId}`
|
||||
);
|
||||
},
|
||||
};
|
||||
2
web/src/services/presupuestos/index.ts
Normal file
2
web/src/services/presupuestos/index.ts
Normal file
@ -0,0 +1,2 @@
|
||||
export * from './presupuestos.api';
|
||||
export * from './estimaciones.api';
|
||||
178
web/src/services/presupuestos/presupuestos.api.ts
Normal file
178
web/src/services/presupuestos/presupuestos.api.ts
Normal file
@ -0,0 +1,178 @@
|
||||
import api, { PaginatedResponse, PaginationParams } from '../api';
|
||||
|
||||
// ===========================
|
||||
// TIPOS PARA CONCEPTOS
|
||||
// ===========================
|
||||
|
||||
export type ConceptoTipo = 'capitulo' | 'partida' | 'subpartida' | 'concepto';
|
||||
|
||||
export interface Concepto {
|
||||
id: string;
|
||||
tenantId: string;
|
||||
parentId?: string;
|
||||
codigo: string;
|
||||
descripcion: string;
|
||||
unidad?: string;
|
||||
precioUnitario?: number;
|
||||
tipo: ConceptoTipo;
|
||||
nivel: number;
|
||||
ruta?: string;
|
||||
children?: Concepto[];
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface ConceptoFilters extends PaginationParams {
|
||||
parentId?: string;
|
||||
tipo?: ConceptoTipo;
|
||||
}
|
||||
|
||||
export interface CreateConceptoDto {
|
||||
codigo: string;
|
||||
descripcion: string;
|
||||
tipo: ConceptoTipo;
|
||||
parentId?: string;
|
||||
unidad?: string;
|
||||
precioUnitario?: number;
|
||||
}
|
||||
|
||||
export interface UpdateConceptoDto {
|
||||
codigo?: string;
|
||||
descripcion?: string;
|
||||
tipo?: ConceptoTipo;
|
||||
parentId?: string;
|
||||
unidad?: string;
|
||||
precioUnitario?: number;
|
||||
}
|
||||
|
||||
// ===========================
|
||||
// TIPOS PARA PRESUPUESTOS
|
||||
// ===========================
|
||||
|
||||
export type PresupuestoEstado = 'borrador' | 'revision' | 'aprobado' | 'cerrado';
|
||||
|
||||
export interface Presupuesto {
|
||||
id: string;
|
||||
tenantId: string;
|
||||
proyectoId: string;
|
||||
codigo: string;
|
||||
nombre: string;
|
||||
version: number;
|
||||
estado: PresupuestoEstado;
|
||||
montoTotal: number;
|
||||
fechaCreacion: string;
|
||||
fechaAprobacion?: string;
|
||||
partidas?: PresupuestoPartida[];
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface PresupuestoPartida {
|
||||
id: string;
|
||||
presupuestoId: string;
|
||||
conceptoId: string;
|
||||
concepto?: Concepto;
|
||||
cantidad: number;
|
||||
precioUnitario: number;
|
||||
importe: number;
|
||||
orden: number;
|
||||
}
|
||||
|
||||
export interface PresupuestoFilters extends PaginationParams {
|
||||
proyectoId?: string;
|
||||
estado?: PresupuestoEstado;
|
||||
}
|
||||
|
||||
export interface CreatePresupuestoDto {
|
||||
codigo: string;
|
||||
nombre: string;
|
||||
proyectoId: string;
|
||||
estado?: PresupuestoEstado;
|
||||
}
|
||||
|
||||
export interface UpdatePresupuestoDto {
|
||||
codigo?: string;
|
||||
nombre?: string;
|
||||
estado?: PresupuestoEstado;
|
||||
}
|
||||
|
||||
// ===========================
|
||||
// API DE CONCEPTOS
|
||||
// ===========================
|
||||
|
||||
export const conceptosApi = {
|
||||
list: async (filters?: ConceptoFilters): Promise<PaginatedResponse<Concepto>> => {
|
||||
const response = await api.get<PaginatedResponse<Concepto>>('/conceptos', {
|
||||
params: filters,
|
||||
});
|
||||
return response.data;
|
||||
},
|
||||
|
||||
get: async (id: string): Promise<Concepto> => {
|
||||
const response = await api.get<Concepto>(`/conceptos/${id}`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
getTree: async (rootId?: string): Promise<Concepto[]> => {
|
||||
const response = await api.get<Concepto[]>('/conceptos/tree', {
|
||||
params: rootId ? { rootId } : undefined,
|
||||
});
|
||||
return response.data;
|
||||
},
|
||||
|
||||
create: async (data: CreateConceptoDto): Promise<Concepto> => {
|
||||
const response = await api.post<Concepto>('/conceptos', data);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
update: async (id: string, data: UpdateConceptoDto): Promise<Concepto> => {
|
||||
const response = await api.patch<Concepto>(`/conceptos/${id}`, data);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
delete: async (id: string): Promise<void> => {
|
||||
await api.delete(`/conceptos/${id}`);
|
||||
},
|
||||
};
|
||||
|
||||
// ===========================
|
||||
// API DE PRESUPUESTOS
|
||||
// ===========================
|
||||
|
||||
export const presupuestosApi = {
|
||||
list: async (filters?: PresupuestoFilters): Promise<PaginatedResponse<Presupuesto>> => {
|
||||
const response = await api.get<PaginatedResponse<Presupuesto>>('/presupuestos', {
|
||||
params: filters,
|
||||
});
|
||||
return response.data;
|
||||
},
|
||||
|
||||
get: async (id: string): Promise<Presupuesto> => {
|
||||
const response = await api.get<Presupuesto>(`/presupuestos/${id}`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
create: async (data: CreatePresupuestoDto): Promise<Presupuesto> => {
|
||||
const response = await api.post<Presupuesto>('/presupuestos', data);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
update: async (id: string, data: UpdatePresupuestoDto): Promise<Presupuesto> => {
|
||||
const response = await api.patch<Presupuesto>(`/presupuestos/${id}`, data);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
delete: async (id: string): Promise<void> => {
|
||||
await api.delete(`/presupuestos/${id}`);
|
||||
},
|
||||
|
||||
approve: async (id: string): Promise<Presupuesto> => {
|
||||
const response = await api.post<Presupuesto>(`/presupuestos/${id}/approve`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
duplicate: async (id: string): Promise<Presupuesto> => {
|
||||
const response = await api.post<Presupuesto>(`/presupuestos/${id}/duplicate`);
|
||||
return response.data;
|
||||
},
|
||||
};
|
||||
14
web/src/services/reports/index.ts
Normal file
14
web/src/services/reports/index.ts
Normal file
@ -0,0 +1,14 @@
|
||||
export {
|
||||
reportsApi,
|
||||
type EarnedValueStatus,
|
||||
type EarnedValueMetrics,
|
||||
type SCurveDataPoint,
|
||||
type ProjectSummary,
|
||||
type DashboardStats,
|
||||
type AlertSeverity,
|
||||
type AlertType,
|
||||
type Alert,
|
||||
type DateRangeParams,
|
||||
type ProjectSummaryFilters,
|
||||
type AlertFilters,
|
||||
} from './reports.api';
|
||||
209
web/src/services/reports/reports.api.ts
Normal file
209
web/src/services/reports/reports.api.ts
Normal file
@ -0,0 +1,209 @@
|
||||
import api, { PaginatedResponse, PaginationParams } from '../api';
|
||||
|
||||
// =============================================================================
|
||||
// Types - Earned Value Management
|
||||
// =============================================================================
|
||||
|
||||
export type EarnedValueStatus = 'green' | 'yellow' | 'red';
|
||||
|
||||
export interface EarnedValueMetrics {
|
||||
date: string;
|
||||
bac: number; // Budget at Completion
|
||||
pv: number; // Planned Value
|
||||
ev: number; // Earned Value
|
||||
ac: number; // Actual Cost
|
||||
spi: number; // Schedule Performance Index
|
||||
cpi: number; // Cost Performance Index
|
||||
sv: number; // Schedule Variance
|
||||
cv: number; // Cost Variance
|
||||
eac: number; // Estimate at Completion
|
||||
etc: number; // Estimate to Complete
|
||||
vac: number; // Variance at Completion
|
||||
tcpi: number; // To Complete Performance Index
|
||||
percentComplete: number;
|
||||
status: EarnedValueStatus;
|
||||
}
|
||||
|
||||
export interface SCurveDataPoint {
|
||||
date: string;
|
||||
plannedValue: number;
|
||||
earnedValue: number;
|
||||
actualCost: number;
|
||||
plannedCumulative: number;
|
||||
earnedCumulative: number;
|
||||
actualCumulative: number;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Types - Dashboard
|
||||
// =============================================================================
|
||||
|
||||
export interface ProjectSummary {
|
||||
id: string;
|
||||
nombre: string;
|
||||
presupuesto: number;
|
||||
avanceReal: number;
|
||||
avanceProgramado: number;
|
||||
spi: number;
|
||||
cpi: number;
|
||||
status: EarnedValueStatus;
|
||||
}
|
||||
|
||||
export interface DashboardStats {
|
||||
totalProyectos: number;
|
||||
proyectosActivos: number;
|
||||
presupuestoTotal: number;
|
||||
avancePromedio: number;
|
||||
alertasActivas: number;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Types - Alerts
|
||||
// =============================================================================
|
||||
|
||||
export type AlertSeverity = 'critical' | 'warning' | 'info';
|
||||
export type AlertType = 'schedule' | 'cost' | 'quality' | 'safety' | 'general';
|
||||
|
||||
export interface Alert {
|
||||
id: string;
|
||||
projectId: string;
|
||||
projectName: string;
|
||||
type: AlertType;
|
||||
severity: AlertSeverity;
|
||||
title: string;
|
||||
message: string;
|
||||
metric?: string;
|
||||
value?: number;
|
||||
threshold?: number;
|
||||
createdAt: string;
|
||||
acknowledgedAt?: string;
|
||||
resolvedAt?: string;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Types - Filters and Params
|
||||
// =============================================================================
|
||||
|
||||
export interface DateRangeParams {
|
||||
startDate?: string;
|
||||
endDate?: string;
|
||||
}
|
||||
|
||||
export interface ProjectSummaryFilters extends PaginationParams {
|
||||
status?: EarnedValueStatus;
|
||||
}
|
||||
|
||||
export interface AlertFilters extends PaginationParams {
|
||||
severity?: AlertSeverity;
|
||||
projectId?: string;
|
||||
type?: AlertType;
|
||||
acknowledged?: boolean;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// API
|
||||
// =============================================================================
|
||||
|
||||
export const reportsApi = {
|
||||
/**
|
||||
* Get Earned Value metrics for a specific project
|
||||
* Returns SPI, CPI, EV, PV, AC, and other EVM indicators
|
||||
*/
|
||||
getEarnedValue: async (
|
||||
projectId: string,
|
||||
params?: DateRangeParams
|
||||
): Promise<EarnedValueMetrics> => {
|
||||
const response = await api.get<EarnedValueMetrics>(
|
||||
`/reports/projects/${projectId}/earned-value`,
|
||||
{ params }
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
/**
|
||||
* Get S-Curve data for a specific project
|
||||
* Returns time series data for planned vs actual progress
|
||||
*/
|
||||
getSCurveData: async (
|
||||
projectId: string,
|
||||
params?: DateRangeParams
|
||||
): Promise<SCurveDataPoint[]> => {
|
||||
const response = await api.get<SCurveDataPoint[]>(
|
||||
`/reports/projects/${projectId}/s-curve`,
|
||||
{ params }
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
/**
|
||||
* Get summary of all projects with their KPIs
|
||||
* Supports filtering by status and search
|
||||
*/
|
||||
getProjectsSummary: async (
|
||||
filters?: ProjectSummaryFilters
|
||||
): Promise<PaginatedResponse<ProjectSummary>> => {
|
||||
const response = await api.get<PaginatedResponse<ProjectSummary>>(
|
||||
'/reports/projects/summary',
|
||||
{ params: filters }
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
/**
|
||||
* Get general dashboard statistics
|
||||
* Returns aggregate metrics for all projects
|
||||
*/
|
||||
getDashboardStats: async (): Promise<DashboardStats> => {
|
||||
const response = await api.get<DashboardStats>('/reports/dashboard/stats');
|
||||
return response.data;
|
||||
},
|
||||
|
||||
/**
|
||||
* Get KPIs for a specific project
|
||||
* Alias for getEarnedValue for semantic clarity
|
||||
*/
|
||||
getProjectKPIs: async (
|
||||
projectId: string,
|
||||
params?: DateRangeParams
|
||||
): Promise<EarnedValueMetrics> => {
|
||||
const response = await api.get<EarnedValueMetrics>(
|
||||
`/reports/projects/${projectId}/kpis`,
|
||||
{ params }
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
/**
|
||||
* Get active alerts
|
||||
* Supports filtering by severity and project
|
||||
*/
|
||||
getAlerts: async (
|
||||
filters?: AlertFilters
|
||||
): Promise<PaginatedResponse<Alert>> => {
|
||||
const response = await api.get<PaginatedResponse<Alert>>(
|
||||
'/reports/alerts',
|
||||
{ params: filters }
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
/**
|
||||
* Acknowledge an alert
|
||||
*/
|
||||
acknowledgeAlert: async (alertId: string): Promise<Alert> => {
|
||||
const response = await api.patch<Alert>(
|
||||
`/reports/alerts/${alertId}/acknowledge`
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
/**
|
||||
* Resolve an alert
|
||||
*/
|
||||
resolveAlert: async (alertId: string): Promise<Alert> => {
|
||||
const response = await api.patch<Alert>(
|
||||
`/reports/alerts/${alertId}/resolve`
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
};
|
||||
Loading…
Reference in New Issue
Block a user