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:
578
src/app/portal/obras/[id]/page.tsx
Normal file
578
src/app/portal/obras/[id]/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user