feat: Add major features - Mejoras 5-10

- Mejora 5: Órdenes de Compra integration in obra detail
- Mejora 6: Portal de Cliente with JWT auth for clients
- Mejora 7: Diagrama de Gantt for project visualization
- Mejora 8: Push Notifications with service worker
- Mejora 9: Activity Log system with templates
- Mejora 10: PWA support with offline capabilities

New features include:
- Fotos gallery with upload/delete
- Bitácora de obra with daily logs
- PDF export for reports, gastos, presupuestos
- Control de asistencia for employees
- Client portal with granular permissions
- Gantt chart with task visualization
- Push notification system
- Activity timeline component
- PWA manifest, icons, and install prompt

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Mexus
2026-01-19 03:09:38 +00:00
parent 86bfbd2039
commit a08e7057e8
69 changed files with 12435 additions and 26 deletions

View File

@@ -0,0 +1,578 @@
"use client";
import { useState, useEffect } from "react";
import { useRouter, useParams } from "next/navigation";
import Link from "next/link";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Progress } from "@/components/ui/progress";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import {
ChevronLeft,
MapPin,
Calendar,
Building2,
User,
Phone,
Mail,
Loader2,
Camera,
CheckCircle2,
Clock,
DollarSign,
} from "lucide-react";
import { formatCurrency, formatDate, formatPercentage } from "@/lib/utils";
import {
ESTADO_OBRA_LABELS,
ESTADO_OBRA_COLORS,
ESTADO_TAREA_LABELS,
CATEGORIA_GASTO_LABELS,
ESTADO_GASTO_LABELS,
ESTADO_GASTO_COLORS,
type EstadoObra,
type EstadoTarea,
type CategoriaGasto,
type EstadoGasto,
} from "@/types";
interface ObraDetail {
id: string;
nombre: string;
descripcion: string | null;
direccion: string;
estado: EstadoObra;
porcentajeAvance: number;
presupuestoTotal?: number;
gastoTotal?: number;
fechaInicio: string | null;
fechaFinPrevista: string | null;
fechaFinReal: string | null;
imagenPortada: string | null;
supervisor?: { nombre: string; apellido: string; email: string | null };
empresa?: { nombre: string; telefono: string | null; email: string | null };
fases?: {
id: string;
nombre: string;
descripcion: string | null;
porcentajeAvance: number;
tareas: {
id: string;
nombre: string;
estado: EstadoTarea;
porcentajeAvance: number;
}[];
}[];
fotos?: {
id: string;
url: string;
thumbnail: string | null;
titulo: string | null;
descripcion: string | null;
fechaCaptura: string;
fase?: { nombre: string } | null;
}[];
registrosAvance?: {
id: string;
descripcion: string;
porcentaje: number;
fotos: string[];
createdAt: string;
registradoPor: { nombre: string; apellido: string };
}[];
gastos?: {
id: string;
concepto: string;
monto: number;
fecha: string;
categoria: CategoriaGasto;
estado: EstadoGasto;
}[];
permisos: {
verFotos: boolean;
verAvances: boolean;
verGastos: boolean;
verDocumentos: boolean;
descargarPDF: boolean;
};
}
export default function PortalObraDetailPage() {
const router = useRouter();
const params = useParams();
const [loading, setLoading] = useState(true);
const [obra, setObra] = useState<ObraDetail | null>(null);
const [selectedPhoto, setSelectedPhoto] = useState<string | null>(null);
useEffect(() => {
fetchObra();
}, [params.id]);
const fetchObra = async () => {
try {
const res = await fetch(`/api/portal/obras/${params.id}`);
if (!res.ok) {
if (res.status === 401) {
router.push("/portal");
return;
}
throw new Error("Error al cargar obra");
}
const data = await res.json();
setObra(data);
} catch (error) {
console.error("Error:", error);
} finally {
setLoading(false);
}
};
if (loading) {
return (
<div className="min-h-screen flex items-center justify-center">
<Loader2 className="h-8 w-8 animate-spin text-primary" />
</div>
);
}
if (!obra) {
return (
<div className="min-h-screen flex items-center justify-center">
<Card>
<CardContent className="py-12 text-center">
<p className="text-muted-foreground">Obra no encontrada</p>
<Link href="/portal/obras">
<Button variant="link">Volver a mis obras</Button>
</Link>
</CardContent>
</Card>
</div>
);
}
return (
<div className="min-h-screen bg-gray-50">
{/* Header */}
<header className="bg-white border-b">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4">
<Link
href="/portal/obras"
className="flex items-center text-sm text-muted-foreground hover:text-foreground mb-4"
>
<ChevronLeft className="h-4 w-4 mr-1" />
Volver a mis obras
</Link>
<div className="flex flex-col md:flex-row md:items-center md:justify-between gap-4">
<div>
<h1 className="text-2xl font-bold">{obra.nombre}</h1>
<div className="flex items-center gap-4 mt-2">
<Badge className={ESTADO_OBRA_COLORS[obra.estado]}>
{ESTADO_OBRA_LABELS[obra.estado]}
</Badge>
<span className="flex items-center text-sm text-muted-foreground">
<MapPin className="h-4 w-4 mr-1" />
{obra.direccion}
</span>
</div>
</div>
<div className="text-right">
<div className="text-3xl font-bold text-primary">
{formatPercentage(obra.porcentajeAvance)}
</div>
<p className="text-sm text-muted-foreground">Avance total</p>
</div>
</div>
</div>
</header>
{/* Content */}
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
{/* Progress Bar */}
<Card className="mb-6">
<CardContent className="py-4">
<Progress value={obra.porcentajeAvance} className="h-3" />
</CardContent>
</Card>
<Tabs defaultValue="general" className="space-y-6">
<TabsList className="flex-wrap">
<TabsTrigger value="general">General</TabsTrigger>
{obra.permisos.verAvances && obra.fases && (
<TabsTrigger value="avances">Avances</TabsTrigger>
)}
{obra.permisos.verFotos && obra.fotos && (
<TabsTrigger value="fotos">Fotos</TabsTrigger>
)}
{obra.permisos.verGastos && obra.gastos && (
<TabsTrigger value="finanzas">Finanzas</TabsTrigger>
)}
</TabsList>
{/* General Tab */}
<TabsContent value="general" className="space-y-6">
<div className="grid gap-6 md:grid-cols-2">
{/* Info del Proyecto */}
<Card>
<CardHeader>
<CardTitle>Informacion del Proyecto</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{obra.descripcion && (
<div>
<p className="text-sm font-medium text-muted-foreground">
Descripcion
</p>
<p>{obra.descripcion}</p>
</div>
)}
<div className="grid grid-cols-2 gap-4">
<div>
<p className="text-sm font-medium text-muted-foreground flex items-center gap-1">
<Calendar className="h-4 w-4" />
Inicio
</p>
<p>
{obra.fechaInicio
? formatDate(new Date(obra.fechaInicio))
: "Por definir"}
</p>
</div>
<div>
<p className="text-sm font-medium text-muted-foreground flex items-center gap-1">
<Calendar className="h-4 w-4" />
Fin Previsto
</p>
<p>
{obra.fechaFinPrevista
? formatDate(new Date(obra.fechaFinPrevista))
: "Por definir"}
</p>
</div>
</div>
{obra.permisos.verGastos && obra.presupuestoTotal !== undefined && (
<div className="pt-4 border-t">
<div className="grid grid-cols-2 gap-4">
<div>
<p className="text-sm font-medium text-muted-foreground">
Presupuesto
</p>
<p className="text-lg font-semibold">
{formatCurrency(obra.presupuestoTotal)}
</p>
</div>
<div>
<p className="text-sm font-medium text-muted-foreground">
Ejecutado
</p>
<p className="text-lg font-semibold">
{formatCurrency(obra.gastoTotal || 0)}
</p>
</div>
</div>
</div>
)}
</CardContent>
</Card>
{/* Contacto */}
<Card>
<CardHeader>
<CardTitle>Contacto</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{obra.empresa && (
<div>
<p className="text-sm font-medium text-muted-foreground flex items-center gap-1">
<Building2 className="h-4 w-4" />
Constructora
</p>
<p className="font-medium">{obra.empresa.nombre}</p>
{obra.empresa.telefono && (
<p className="flex items-center gap-1 text-sm text-muted-foreground">
<Phone className="h-3 w-3" />
{obra.empresa.telefono}
</p>
)}
{obra.empresa.email && (
<p className="flex items-center gap-1 text-sm text-muted-foreground">
<Mail className="h-3 w-3" />
{obra.empresa.email}
</p>
)}
</div>
)}
{obra.supervisor && (
<div className="pt-4 border-t">
<p className="text-sm font-medium text-muted-foreground flex items-center gap-1">
<User className="h-4 w-4" />
Supervisor
</p>
<p className="font-medium">
{obra.supervisor.nombre} {obra.supervisor.apellido}
</p>
{obra.supervisor.email && (
<p className="flex items-center gap-1 text-sm text-muted-foreground">
<Mail className="h-3 w-3" />
{obra.supervisor.email}
</p>
)}
</div>
)}
</CardContent>
</Card>
</div>
{/* Últimos Avances */}
{obra.permisos.verAvances && obra.registrosAvance && obra.registrosAvance.length > 0 && (
<Card>
<CardHeader>
<CardTitle>Ultimos Avances</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-4">
{obra.registrosAvance.slice(0, 3).map((registro) => (
<div
key={registro.id}
className="flex items-start gap-4 p-4 rounded-lg bg-gray-50"
>
<div className="flex-1">
<p className="font-medium">{registro.descripcion}</p>
<p className="text-sm text-muted-foreground">
{registro.registradoPor.nombre}{" "}
{registro.registradoPor.apellido} -{" "}
{formatDate(new Date(registro.createdAt))}
</p>
</div>
<Badge variant="outline">
{formatPercentage(registro.porcentaje)}
</Badge>
</div>
))}
</div>
</CardContent>
</Card>
)}
</TabsContent>
{/* Avances Tab */}
{obra.permisos.verAvances && obra.fases && (
<TabsContent value="avances" className="space-y-6">
{obra.fases.length === 0 ? (
<Card>
<CardContent className="py-12 text-center text-muted-foreground">
No hay fases definidas para esta obra
</CardContent>
</Card>
) : (
obra.fases.map((fase) => (
<Card key={fase.id}>
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle>{fase.nombre}</CardTitle>
<div className="flex items-center gap-2">
<span className="text-sm">
{formatPercentage(fase.porcentajeAvance)}
</span>
<Progress
value={fase.porcentajeAvance}
className="w-24"
/>
</div>
</div>
{fase.descripcion && (
<CardDescription>{fase.descripcion}</CardDescription>
)}
</CardHeader>
<CardContent>
{fase.tareas.length === 0 ? (
<p className="text-sm text-muted-foreground">
Sin tareas definidas
</p>
) : (
<div className="space-y-2">
{fase.tareas.map((tarea) => (
<div
key={tarea.id}
className="flex items-center justify-between rounded-lg border p-3"
>
<div className="flex items-center gap-2">
{tarea.estado === "COMPLETADA" ? (
<CheckCircle2 className="h-4 w-4 text-green-600" />
) : (
<Clock className="h-4 w-4 text-muted-foreground" />
)}
<span>{tarea.nombre}</span>
</div>
<Badge variant="outline">
{ESTADO_TAREA_LABELS[tarea.estado]}
</Badge>
</div>
))}
</div>
)}
</CardContent>
</Card>
))
)}
</TabsContent>
)}
{/* Fotos Tab */}
{obra.permisos.verFotos && obra.fotos && (
<TabsContent value="fotos">
{obra.fotos.length === 0 ? (
<Card>
<CardContent className="py-12 text-center">
<Camera className="h-12 w-12 mx-auto text-muted-foreground mb-4" />
<p className="text-muted-foreground">
No hay fotos disponibles
</p>
</CardContent>
</Card>
) : (
<div className="grid gap-4 grid-cols-2 md:grid-cols-3 lg:grid-cols-4">
{obra.fotos.map((foto) => (
<Card
key={foto.id}
className="overflow-hidden cursor-pointer hover:shadow-lg transition-shadow"
onClick={() => setSelectedPhoto(foto.url)}
>
<div className="aspect-square relative">
<img
src={foto.thumbnail || foto.url}
alt={foto.titulo || "Foto de avance"}
className="object-cover w-full h-full"
/>
</div>
<CardContent className="p-3">
{foto.titulo && (
<p className="font-medium text-sm truncate">
{foto.titulo}
</p>
)}
<p className="text-xs text-muted-foreground">
{formatDate(new Date(foto.fechaCaptura))}
</p>
{foto.fase && (
<Badge variant="outline" className="mt-1 text-xs">
{foto.fase.nombre}
</Badge>
)}
</CardContent>
</Card>
))}
</div>
)}
{/* Modal de foto */}
{selectedPhoto && (
<div
className="fixed inset-0 bg-black/80 z-50 flex items-center justify-center p-4"
onClick={() => setSelectedPhoto(null)}
>
<img
src={selectedPhoto}
alt="Foto ampliada"
className="max-w-full max-h-full object-contain"
/>
</div>
)}
</TabsContent>
)}
{/* Finanzas Tab */}
{obra.permisos.verGastos && obra.gastos && (
<TabsContent value="finanzas" className="space-y-6">
{/* Resumen */}
<div className="grid gap-4 md:grid-cols-3">
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium">
Presupuesto
</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">
{formatCurrency(obra.presupuestoTotal || 0)}
</div>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium">
Ejecutado
</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">
{formatCurrency(obra.gastoTotal || 0)}
</div>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium">
Disponible
</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold text-green-600">
{formatCurrency(
(obra.presupuestoTotal || 0) - (obra.gastoTotal || 0)
)}
</div>
</CardContent>
</Card>
</div>
{/* Lista de gastos */}
<Card>
<CardHeader>
<CardTitle>Ultimos Gastos</CardTitle>
</CardHeader>
<CardContent>
{obra.gastos.length === 0 ? (
<p className="text-center text-muted-foreground py-4">
No hay gastos registrados
</p>
) : (
<div className="space-y-2">
{obra.gastos.map((gasto) => (
<div
key={gasto.id}
className="flex items-center justify-between rounded-lg border p-3"
>
<div>
<p className="font-medium">{gasto.concepto}</p>
<p className="text-sm text-muted-foreground">
{CATEGORIA_GASTO_LABELS[gasto.categoria]} -{" "}
{formatDate(new Date(gasto.fecha))}
</p>
</div>
<div className="text-right">
<p className="font-bold">
{formatCurrency(gasto.monto)}
</p>
<Badge className={ESTADO_GASTO_COLORS[gasto.estado]}>
{ESTADO_GASTO_LABELS[gasto.estado]}
</Badge>
</div>
</div>
))}
</div>
)}
</CardContent>
</Card>
</TabsContent>
)}
</Tabs>
</main>
</div>
);
}