- 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>
579 lines
21 KiB
TypeScript
579 lines
21 KiB
TypeScript
"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>
|
|
);
|
|
}
|