Gannt dentro de las tareas
This commit is contained in:
parent
ae66248df9
commit
c9f48fcf59
|
|
@ -0,0 +1,276 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTheme } from "next-themes";
|
||||
import {
|
||||
Settings,
|
||||
Palette,
|
||||
Globe,
|
||||
Server,
|
||||
CheckCircle2,
|
||||
XCircle,
|
||||
RefreshCw,
|
||||
Monitor,
|
||||
Sun,
|
||||
Moon,
|
||||
Info
|
||||
} from "lucide-react";
|
||||
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { toast } from "sonner";
|
||||
import { dolibarrFetch } from "@/lib/dolibarrClient";
|
||||
|
||||
interface ConnectionStatus {
|
||||
connected: boolean;
|
||||
url: string;
|
||||
version?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export default function ConfiguracionPage() {
|
||||
const { theme, setTheme } = useTheme();
|
||||
const [mounted, setMounted] = useState(false);
|
||||
const [connectionStatus, setConnectionStatus] = useState<ConnectionStatus | null>(null);
|
||||
const [isTestingConnection, setIsTestingConnection] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
testConnection();
|
||||
}, []);
|
||||
|
||||
const testConnection = async () => {
|
||||
setIsTestingConnection(true);
|
||||
const apiUrl = process.env.NEXT_PUBLIC_API_URL || "No configurada";
|
||||
|
||||
try {
|
||||
const status = await dolibarrFetch("status");
|
||||
setConnectionStatus({
|
||||
connected: true,
|
||||
url: apiUrl,
|
||||
version: status?.success?.dolibarr_version || status?.dolibarr_version || "Desconocida",
|
||||
});
|
||||
toast.success("Conexion verificada con Dolibarr");
|
||||
} catch (err) {
|
||||
console.error("Connection test failed:", err);
|
||||
setConnectionStatus({
|
||||
connected: false,
|
||||
url: apiUrl,
|
||||
error: err instanceof Error ? err.message : "Error desconocido",
|
||||
});
|
||||
toast.error("No se pudo conectar con Dolibarr");
|
||||
} finally {
|
||||
setIsTestingConnection(false);
|
||||
}
|
||||
};
|
||||
|
||||
const themeOptions = [
|
||||
{ value: "light", label: "Claro", icon: Sun, description: "Tema claro" },
|
||||
{ value: "dark", label: "Oscuro", icon: Moon, description: "Tema oscuro" },
|
||||
{ value: "system", label: "Sistema", icon: Monitor, description: "Seguir preferencia del sistema" },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-6 max-w-3xl mx-auto">
|
||||
{/* Header */}
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight">Ajustes</h1>
|
||||
<p className="text-muted-foreground">Configuracion de la aplicacion y conexion con Dolibarr</p>
|
||||
</div>
|
||||
|
||||
{/* Theme */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg flex items-center gap-2">
|
||||
<Palette className="h-5 w-5" />
|
||||
Apariencia
|
||||
</CardTitle>
|
||||
<CardDescription>Personaliza el aspecto visual de la aplicacion</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Tema</Label>
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
{mounted && themeOptions.map((option) => {
|
||||
const isSelected = theme === option.value;
|
||||
const Icon = option.icon;
|
||||
return (
|
||||
<button
|
||||
key={option.value}
|
||||
onClick={() => setTheme(option.value)}
|
||||
className={`flex flex-col items-center gap-2 p-4 rounded-lg border-2 transition-all ${
|
||||
isSelected
|
||||
? "border-blue-500 bg-blue-50 dark:bg-blue-950"
|
||||
: "border-muted hover:border-muted-foreground/25"
|
||||
}`}
|
||||
>
|
||||
<Icon className={`h-6 w-6 ${isSelected ? "text-blue-500" : "text-muted-foreground"}`} />
|
||||
<span className={`text-sm font-medium ${isSelected ? "text-blue-600 dark:text-blue-400" : ""}`}>
|
||||
{option.label}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">{option.description}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
{!mounted && (
|
||||
<>
|
||||
{[1, 2, 3].map((i) => (
|
||||
<Skeleton key={i} className="h-24 w-full rounded-lg" />
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Dolibarr Connection */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg flex items-center gap-2">
|
||||
<Server className="h-5 w-5" />
|
||||
Conexion con Dolibarr
|
||||
</CardTitle>
|
||||
<CardDescription>Estado de la conexion con el servidor Dolibarr</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{/* Connection status */}
|
||||
<div className="flex items-center justify-between p-4 rounded-lg bg-muted/50">
|
||||
<div className="flex items-center gap-3">
|
||||
{isTestingConnection ? (
|
||||
<RefreshCw className="h-5 w-5 text-muted-foreground animate-spin" />
|
||||
) : connectionStatus?.connected ? (
|
||||
<CheckCircle2 className="h-5 w-5 text-green-500" />
|
||||
) : (
|
||||
<XCircle className="h-5 w-5 text-red-500" />
|
||||
)}
|
||||
<div>
|
||||
<p className="text-sm font-medium">
|
||||
{isTestingConnection
|
||||
? "Verificando conexion..."
|
||||
: connectionStatus?.connected
|
||||
? "Conectado"
|
||||
: "Desconectado"}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{connectionStatus?.url || "Verificando..."}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={testConnection}
|
||||
disabled={isTestingConnection}
|
||||
>
|
||||
<RefreshCw className={`h-4 w-4 mr-2 ${isTestingConnection ? "animate-spin" : ""}`} />
|
||||
Probar
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Connection details */}
|
||||
{connectionStatus && (
|
||||
<>
|
||||
<Separator />
|
||||
<div className="space-y-3">
|
||||
<DetailRow label="URL de la API" value={connectionStatus.url} />
|
||||
{connectionStatus.connected && connectionStatus.version && (
|
||||
<DetailRow label="Version de Dolibarr" value={connectionStatus.version} />
|
||||
)}
|
||||
{connectionStatus.error && (
|
||||
<div className="flex items-start gap-2 p-3 rounded-lg bg-red-50 dark:bg-red-950/30 text-red-700 dark:text-red-400 text-sm">
|
||||
<XCircle className="h-4 w-4 mt-0.5 flex-shrink-0" />
|
||||
<p>{connectionStatus.error}</p>
|
||||
</div>
|
||||
)}
|
||||
<DetailRow
|
||||
label="Estado"
|
||||
value={
|
||||
connectionStatus.connected ? "Operativo" : "Sin conexion"
|
||||
}
|
||||
badge
|
||||
badgeVariant={connectionStatus.connected ? "success" : "error"}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* App Info */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg flex items-center gap-2">
|
||||
<Info className="h-5 w-5" />
|
||||
Informacion de la Aplicacion
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<DetailRow label="Aplicacion" value="Dolibarr - Gestion de Proyectos" />
|
||||
<Separator />
|
||||
<DetailRow label="Framework" value="Next.js 16.0.7" />
|
||||
<Separator />
|
||||
<DetailRow label="React" value="19.2.0" />
|
||||
<Separator />
|
||||
<DetailRow label="UI" value="shadcn/ui + Tailwind CSS" />
|
||||
<Separator />
|
||||
<DetailRow label="Idioma" value="Espanol (es-ES)" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Locale / Language */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg flex items-center gap-2">
|
||||
<Globe className="h-5 w-5" />
|
||||
Idioma y Region
|
||||
</CardTitle>
|
||||
<CardDescription>Configuracion regional de la aplicacion</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<DetailRow label="Idioma" value="Espanol" />
|
||||
<Separator />
|
||||
<DetailRow label="Formato de fecha" value="dd/mm/aaaa (es-ES)" />
|
||||
<Separator />
|
||||
<DetailRow label="Moneda" value="Euro (EUR)" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DetailRow({
|
||||
label,
|
||||
value,
|
||||
badge,
|
||||
badgeVariant,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
badge?: boolean;
|
||||
badgeVariant?: "success" | "error";
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-muted-foreground">{label}</span>
|
||||
{badge ? (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={
|
||||
badgeVariant === "success"
|
||||
? "bg-green-100 text-green-700 border-green-200 dark:bg-green-950 dark:text-green-300 dark:border-green-800"
|
||||
: "bg-red-100 text-red-700 border-red-200 dark:bg-red-950 dark:text-red-300 dark:border-red-800"
|
||||
}
|
||||
>
|
||||
{value}
|
||||
</Badge>
|
||||
) : (
|
||||
<span className="text-sm font-medium">{value}</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -4,6 +4,7 @@ import "./globals.css";
|
|||
import { SidebarProvider, SidebarTrigger, SidebarInset } from "@/components/ui/sidebar"
|
||||
import { AppSidebar } from "@/components/app-sidebar"
|
||||
import { ThemeProvider } from "@/components/theme-provider"
|
||||
import { Toaster } from "sonner"
|
||||
|
||||
|
||||
const geistSans = Geist({
|
||||
|
|
@ -45,6 +46,7 @@ export default function RootLayout({
|
|||
</main>
|
||||
</SidebarInset>
|
||||
</SidebarProvider>
|
||||
<Toaster richColors position="bottom-right" />
|
||||
</ThemeProvider>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,311 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { User, Mail, Building, Calendar, Shield, Briefcase, Globe, Phone } from "lucide-react";
|
||||
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
|
||||
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { getUser } from "@/lib/usersService";
|
||||
import { getProjects } from "@/lib/projectsService";
|
||||
import { Project } from "@/types/project";
|
||||
|
||||
interface DolibarrUser {
|
||||
id: string;
|
||||
login: string;
|
||||
firstname: string;
|
||||
lastname: string;
|
||||
email: string;
|
||||
admin: string;
|
||||
statut: string;
|
||||
employee: string;
|
||||
job: string;
|
||||
address: string;
|
||||
zip: string;
|
||||
town: string;
|
||||
state_id: string;
|
||||
office_phone: string;
|
||||
user_mobile: string;
|
||||
fk_member: string;
|
||||
datelastlogin: number;
|
||||
datepreviouslogin: number;
|
||||
datec: string;
|
||||
datem: string;
|
||||
fk_soc: string;
|
||||
entity: string;
|
||||
}
|
||||
|
||||
export default function PerfilPage() {
|
||||
const [user, setUser] = useState<DolibarrUser | null>(null);
|
||||
const [projects, setProjects] = useState<Project[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
async function loadData() {
|
||||
try {
|
||||
const [userData, projectsData] = await Promise.all([
|
||||
getUser(),
|
||||
getProjects(),
|
||||
]);
|
||||
setUser(userData);
|
||||
setProjects(projectsData);
|
||||
} catch (err) {
|
||||
console.error("Error loading profile data:", err);
|
||||
setError("No se pudo cargar la información del perfil");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
loadData();
|
||||
}, []);
|
||||
|
||||
if (isLoading) {
|
||||
return <ProfileSkeleton />;
|
||||
}
|
||||
|
||||
if (error || !user) {
|
||||
return (
|
||||
<div className="p-6">
|
||||
<div className="flex flex-col items-center justify-center py-12 text-center">
|
||||
<User className="h-12 w-12 text-muted-foreground mb-4" />
|
||||
<h2 className="text-lg font-semibold mb-2">Error al cargar el perfil</h2>
|
||||
<p className="text-sm text-muted-foreground">{error}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const fullName = `${user.firstname} ${user.lastname}`;
|
||||
const initials = `${user.firstname?.charAt(0) || ""}${user.lastname?.charAt(0) || ""}`;
|
||||
const isAdmin = user.admin === "1";
|
||||
const isActive = user.statut === "1";
|
||||
const isEmployee = user.employee === "1";
|
||||
|
||||
// Stats from projects
|
||||
const activeProjects = projects.filter((p) => p.status === "1").length;
|
||||
const completedProjects = projects.filter((p) => p.status === "2").length;
|
||||
const totalBudget = projects.reduce((acc, p) => acc + (p.budget || 0), 0);
|
||||
|
||||
const formatDate = (timestamp: number | string) => {
|
||||
if (!timestamp) return "N/A";
|
||||
const ts = typeof timestamp === "string" ? parseInt(timestamp) : timestamp;
|
||||
const date = new Date(ts * 1000);
|
||||
return date.toLocaleDateString("es-ES", {
|
||||
day: "2-digit",
|
||||
month: "long",
|
||||
year: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-6 max-w-4xl mx-auto">
|
||||
{/* Header */}
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight">Mi Perfil</h1>
|
||||
<p className="text-muted-foreground">Información de tu cuenta de Dolibarr</p>
|
||||
</div>
|
||||
|
||||
{/* Profile card */}
|
||||
<Card>
|
||||
<CardContent className="p-6">
|
||||
<div className="flex flex-col sm:flex-row items-center sm:items-start gap-6">
|
||||
{/* Avatar */}
|
||||
<Avatar className="h-24 w-24 text-2xl">
|
||||
<AvatarFallback className="bg-gradient-to-br from-blue-500 to-purple-600 text-white text-2xl">
|
||||
{initials}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
|
||||
{/* Info */}
|
||||
<div className="flex-1 text-center sm:text-left space-y-3">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold">{fullName}</h2>
|
||||
<p className="text-muted-foreground">@{user.login}</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center justify-center sm:justify-start gap-2">
|
||||
<Badge variant={isActive ? "default" : "secondary"} className={isActive ? "bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200" : ""}>
|
||||
{isActive ? "Activo" : "Inactivo"}
|
||||
</Badge>
|
||||
{isAdmin && (
|
||||
<Badge variant="outline" className="bg-amber-50 text-amber-700 border-amber-200 dark:bg-amber-950 dark:text-amber-300 dark:border-amber-800">
|
||||
<Shield className="h-3 w-3 mr-1" />
|
||||
Administrador
|
||||
</Badge>
|
||||
)}
|
||||
{isEmployee && (
|
||||
<Badge variant="outline">
|
||||
<Briefcase className="h-3 w-3 mr-1" />
|
||||
Empleado
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
||||
<Card>
|
||||
<CardContent className="p-4 text-center">
|
||||
<p className="text-3xl font-bold text-blue-600">{activeProjects}</p>
|
||||
<p className="text-sm text-muted-foreground">Proyectos activos</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="p-4 text-center">
|
||||
<p className="text-3xl font-bold text-green-600">{completedProjects}</p>
|
||||
<p className="text-sm text-muted-foreground">Proyectos completados</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="p-4 text-center">
|
||||
<p className="text-3xl font-bold bg-gradient-to-r from-blue-500 to-purple-600 bg-clip-text text-transparent">
|
||||
{totalBudget.toLocaleString("es-ES")} €
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">Presupuesto total gestionado</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Details */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
{/* Contact info */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">Información de Contacto</CardTitle>
|
||||
<CardDescription>Datos de contacto registrados en Dolibarr</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<InfoRow icon={Mail} label="Email" value={user.email || "No configurado"} />
|
||||
<Separator />
|
||||
<InfoRow icon={Phone} label="Teléfono oficina" value={user.office_phone || "No configurado"} />
|
||||
<Separator />
|
||||
<InfoRow icon={Phone} label="Móvil" value={user.user_mobile || "No configurado"} />
|
||||
<Separator />
|
||||
<InfoRow icon={Briefcase} label="Puesto" value={user.job || "No configurado"} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Account info */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">Información de la Cuenta</CardTitle>
|
||||
<CardDescription>Detalles de tu cuenta en el sistema</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<InfoRow icon={User} label="ID de usuario" value={`#${user.id}`} />
|
||||
<Separator />
|
||||
<InfoRow icon={Globe} label="Entidad" value={user.entity || "1"} />
|
||||
<Separator />
|
||||
<InfoRow
|
||||
icon={Calendar}
|
||||
label="Último acceso"
|
||||
value={user.datelastlogin ? formatDate(user.datelastlogin) : "N/A"}
|
||||
/>
|
||||
<Separator />
|
||||
<InfoRow
|
||||
icon={Calendar}
|
||||
label="Acceso anterior"
|
||||
value={user.datepreviouslogin ? formatDate(user.datepreviouslogin) : "N/A"}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Address */}
|
||||
{(user.address || user.town || user.zip) && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">Dirección</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex items-start gap-3">
|
||||
<Building className="h-5 w-5 text-muted-foreground mt-0.5" />
|
||||
<div className="text-sm">
|
||||
{user.address && <p>{user.address}</p>}
|
||||
{(user.zip || user.town) && (
|
||||
<p className="text-muted-foreground">
|
||||
{[user.zip, user.town].filter(Boolean).join(", ")}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Reusable info row component
|
||||
function InfoRow({ icon: Icon, label, value }: { icon: React.ComponentType<{ className?: string }>; label: string; value: string }) {
|
||||
return (
|
||||
<div className="flex items-center gap-3">
|
||||
<Icon className="h-4 w-4 text-muted-foreground flex-shrink-0" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-xs text-muted-foreground">{label}</p>
|
||||
<p className="text-sm font-medium truncate">{value}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Skeleton
|
||||
function ProfileSkeleton() {
|
||||
return (
|
||||
<div className="p-6 space-y-6 max-w-4xl mx-auto">
|
||||
<div>
|
||||
<Skeleton className="h-8 w-48 mb-2" />
|
||||
<Skeleton className="h-4 w-72" />
|
||||
</div>
|
||||
<Card>
|
||||
<CardContent className="p-6">
|
||||
<div className="flex flex-col sm:flex-row items-center sm:items-start gap-6">
|
||||
<Skeleton className="h-24 w-24 rounded-full" />
|
||||
<div className="space-y-3 flex-1">
|
||||
<Skeleton className="h-8 w-48" />
|
||||
<Skeleton className="h-4 w-32" />
|
||||
<div className="flex gap-2">
|
||||
<Skeleton className="h-6 w-16" />
|
||||
<Skeleton className="h-6 w-28" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
||||
{[1, 2, 3].map((i) => (
|
||||
<Card key={i}>
|
||||
<CardContent className="p-4 text-center space-y-2">
|
||||
<Skeleton className="h-8 w-16 mx-auto" />
|
||||
<Skeleton className="h-4 w-32 mx-auto" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
{[1, 2].map((i) => (
|
||||
<Card key={i}>
|
||||
<CardHeader>
|
||||
<Skeleton className="h-6 w-48" />
|
||||
<Skeleton className="h-4 w-64" />
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{[1, 2, 3, 4].map((j) => (
|
||||
<div key={j}>
|
||||
<Skeleton className="h-10 w-full" />
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,5 +1,7 @@
|
|||
"use client";
|
||||
import { useEffect, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { getUser } from "@/lib/usersService";
|
||||
|
||||
import {
|
||||
|
|
@ -58,6 +60,7 @@ const items = [
|
|||
];
|
||||
|
||||
export function AppSidebar() {
|
||||
const pathname = usePathname();
|
||||
const [user, setUser] = useState({
|
||||
name: "Cargando...",
|
||||
email: "",
|
||||
|
|
@ -89,8 +92,8 @@ export function AppSidebar() {
|
|||
{/* Header del Sidebar */}
|
||||
<SidebarGroup>
|
||||
<div className="px-4 py-6">
|
||||
<h2 className="text-xl font-semibold text-gray-900">Dolibarr</h2>
|
||||
<p className="text-sm text-gray-500">Gestión de Proyectos</p>
|
||||
<h2 className="text-xl font-semibold text-foreground">Dolibarr</h2>
|
||||
<p className="text-sm text-muted-foreground">Gestión de Proyectos</p>
|
||||
</div>
|
||||
</SidebarGroup>
|
||||
|
||||
|
|
@ -99,16 +102,21 @@ export function AppSidebar() {
|
|||
<SidebarGroupLabel className="text-sm">Menú Principal</SidebarGroupLabel>
|
||||
<SidebarGroupContent>
|
||||
<SidebarMenu>
|
||||
{items.map((item) => (
|
||||
<SidebarMenuItem key={item.title}>
|
||||
<SidebarMenuButton asChild size="lg">
|
||||
<a href={item.url} className="text-base">
|
||||
<item.icon className="w-5 h-5" />
|
||||
<span>{item.title}</span>
|
||||
</a>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
))}
|
||||
{items.map((item) => {
|
||||
const isActive = item.url === "/"
|
||||
? pathname === "/"
|
||||
: pathname.startsWith(item.url);
|
||||
return (
|
||||
<SidebarMenuItem key={item.title}>
|
||||
<SidebarMenuButton asChild size="lg" isActive={isActive}>
|
||||
<Link href={item.url} className="text-base">
|
||||
<item.icon className="w-5 h-5" />
|
||||
<span>{item.title}</span>
|
||||
</Link>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
);
|
||||
})}
|
||||
</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
|
|
@ -132,7 +140,7 @@ export function AppSidebar() {
|
|||
</Avatar>
|
||||
<div className="grid flex-1 text-left text-sm leading-tight">
|
||||
<span className="truncate font-semibold">{user.name}</span>
|
||||
<span className="truncate text-xs text-gray-500">{user.email}</span>
|
||||
<span className="truncate text-xs text-muted-foreground">{user.email}</span>
|
||||
</div>
|
||||
<ChevronUp className="ml-auto size-4" />
|
||||
</SidebarMenuButton>
|
||||
|
|
@ -153,20 +161,22 @@ export function AppSidebar() {
|
|||
</Avatar>
|
||||
<div className="grid flex-1 text-left text-sm leading-tight">
|
||||
<span className="truncate font-semibold">{user.name}</span>
|
||||
<span className="truncate text-xs text-gray-500">{user.email}</span>
|
||||
<span className="truncate text-xs text-muted-foreground">{user.email}</span>
|
||||
</div>
|
||||
</div>
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem>
|
||||
<User className="mr-2 h-4 w-4" />
|
||||
<a href="/perfil">
|
||||
<DropdownMenuItem asChild>
|
||||
<Link href="/perfil" className="flex items-center">
|
||||
<User className="mr-2 h-4 w-4" />
|
||||
<span>Perfil</span>
|
||||
</a>
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem>
|
||||
<Settings className="mr-2 h-4 w-4" />
|
||||
<span>Ajustes</span>
|
||||
<DropdownMenuItem asChild>
|
||||
<Link href="/configuracion" className="flex items-center">
|
||||
<Settings className="mr-2 h-4 w-4" />
|
||||
<span>Ajustes</span>
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem className="text-red-600">
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
"use client";
|
||||
|
||||
import { useState } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
import ProjectCard from './project-card';
|
||||
import ProjectListItem from './project-list-item';
|
||||
import { ProjectFormSheet } from '@/components/project-form/project-form-sheet';
|
||||
|
|
@ -53,6 +54,9 @@ export default function ProjectGrid({
|
|||
setProjectToDelete(null);
|
||||
} catch (error) {
|
||||
console.error("Error deleting project:", error);
|
||||
toast.error("Error al eliminar el proyecto", {
|
||||
description: "No se pudo eliminar el proyecto. Inténtalo de nuevo.",
|
||||
});
|
||||
} finally {
|
||||
setIsDeleting(false);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
"use client";
|
||||
|
||||
import { useState, useEffect, useMemo } from "react";
|
||||
import { useState, useEffect, useMemo, useRef, useCallback } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import {
|
||||
GanttChart as GanttIcon,
|
||||
AlertCircle,
|
||||
|
|
@ -11,9 +12,8 @@ import {
|
|||
Clock,
|
||||
Filter,
|
||||
} from "lucide-react";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import {
|
||||
Tooltip,
|
||||
|
|
@ -33,16 +33,16 @@ import { Project, ProjectStatus } from "@/types/project";
|
|||
|
||||
// Paleta de colores para proyectos
|
||||
const PROJECT_COLORS = [
|
||||
{ bg: "bg-blue-500", light: "bg-blue-100", text: "text-blue-700", hex: "#3b82f6" },
|
||||
{ bg: "bg-violet-500", light: "bg-violet-100", text: "text-violet-700", hex: "#8b5cf6" },
|
||||
{ bg: "bg-cyan-500", light: "bg-cyan-100", text: "text-cyan-700", hex: "#06b6d4" },
|
||||
{ bg: "bg-emerald-500", light: "bg-emerald-100", text: "text-emerald-700", hex: "#10b981" },
|
||||
{ bg: "bg-amber-500", light: "bg-amber-100", text: "text-amber-700", hex: "#f59e0b" },
|
||||
{ bg: "bg-rose-500", light: "bg-rose-100", text: "text-rose-700", hex: "#f43f5e" },
|
||||
{ bg: "bg-pink-500", light: "bg-pink-100", text: "text-pink-700", hex: "#ec4899" },
|
||||
{ bg: "bg-indigo-500", light: "bg-indigo-100", text: "text-indigo-700", hex: "#6366f1" },
|
||||
{ bg: "bg-teal-500", light: "bg-teal-100", text: "text-teal-700", hex: "#14b8a6" },
|
||||
{ bg: "bg-orange-500", light: "bg-orange-100", text: "text-orange-700", hex: "#f97316" },
|
||||
{ bg: "bg-blue-500", light: "bg-blue-100 dark:bg-blue-900/40", hex: "#3b82f6" },
|
||||
{ bg: "bg-violet-500", light: "bg-violet-100 dark:bg-violet-900/40", hex: "#8b5cf6" },
|
||||
{ bg: "bg-cyan-500", light: "bg-cyan-100 dark:bg-cyan-900/40", hex: "#06b6d4" },
|
||||
{ bg: "bg-emerald-500", light: "bg-emerald-100 dark:bg-emerald-900/40", hex: "#10b981" },
|
||||
{ bg: "bg-amber-500", light: "bg-amber-100 dark:bg-amber-900/40", hex: "#f59e0b" },
|
||||
{ bg: "bg-rose-500", light: "bg-rose-100 dark:bg-rose-900/40", hex: "#f43f5e" },
|
||||
{ bg: "bg-pink-500", light: "bg-pink-100 dark:bg-pink-900/40", hex: "#ec4899" },
|
||||
{ bg: "bg-indigo-500", light: "bg-indigo-100 dark:bg-indigo-900/40", hex: "#6366f1" },
|
||||
{ bg: "bg-teal-500", light: "bg-teal-100 dark:bg-teal-900/40", hex: "#14b8a6" },
|
||||
{ bg: "bg-orange-500", light: "bg-orange-100 dark:bg-orange-900/40", hex: "#f97316" },
|
||||
];
|
||||
|
||||
const STATUS_LABELS: Record<ProjectStatus, string> = {
|
||||
|
|
@ -51,6 +51,8 @@ const STATUS_LABELS: Record<ProjectStatus, string> = {
|
|||
"2": "Cerrado",
|
||||
};
|
||||
|
||||
const MONTH_WIDTH_PX = 120; // Ancho fijo por mes en modo scroll
|
||||
|
||||
type ViewMode = "month" | "quarter" | "year";
|
||||
type TimeRange = "future" | "past" | "all";
|
||||
|
||||
|
|
@ -58,18 +60,15 @@ function getProjectColor(index: number) {
|
|||
return PROJECT_COLORS[index % PROJECT_COLORS.length];
|
||||
}
|
||||
|
||||
// Formatear fecha
|
||||
function formatDate(dateStr: string): string {
|
||||
const date = new Date(dateStr);
|
||||
return date.toLocaleDateString("es-ES", { day: "2-digit", month: "short", year: "numeric" });
|
||||
}
|
||||
|
||||
// Obtener días entre dos fechas
|
||||
function getDaysBetween(start: Date, end: Date): number {
|
||||
return Math.ceil((end.getTime() - start.getTime()) / (1000 * 60 * 60 * 24));
|
||||
}
|
||||
|
||||
// Generar array de meses entre dos fechas
|
||||
function getMonthsBetween(start: Date, end: Date): { month: number; year: number; label: string }[] {
|
||||
const months: { month: number; year: number; label: string }[] = [];
|
||||
const current = new Date(start.getFullYear(), start.getMonth(), 1);
|
||||
|
|
@ -87,20 +86,32 @@ function getMonthsBetween(start: Date, end: Date): { month: number; year: number
|
|||
return months;
|
||||
}
|
||||
|
||||
// Obtener días en un mes
|
||||
function getDaysInMonth(month: number, year: number): number {
|
||||
return new Date(year, month + 1, 0).getDate();
|
||||
}
|
||||
|
||||
export default function GanttPage() {
|
||||
const router = useRouter();
|
||||
const [projects, setProjects] = useState<Project[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [viewMode, setViewMode] = useState<ViewMode>("month");
|
||||
const [statusFilter, setStatusFilter] = useState<string>("all");
|
||||
const [timeRange, setTimeRange] = useState<TimeRange>("future");
|
||||
const [timeRange, setTimeRange] = useState<TimeRange>("all");
|
||||
const [viewOffset, setViewOffset] = useState(0);
|
||||
|
||||
// Refs para sincronizar scroll vertical entre nombres y timeline
|
||||
const namesRef = useRef<HTMLDivElement>(null);
|
||||
const timelineRef = useRef<HTMLDivElement>(null);
|
||||
const isSyncing = useRef(false);
|
||||
|
||||
const syncScroll = useCallback((source: "names" | "timeline") => {
|
||||
if (isSyncing.current) return;
|
||||
isSyncing.current = true;
|
||||
const from = source === "names" ? namesRef.current : timelineRef.current;
|
||||
const to = source === "names" ? timelineRef.current : namesRef.current;
|
||||
if (from && to) {
|
||||
to.scrollTop = from.scrollTop;
|
||||
}
|
||||
requestAnimationFrame(() => { isSyncing.current = false; });
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
async function loadProjects() {
|
||||
try {
|
||||
|
|
@ -118,150 +129,101 @@ export default function GanttPage() {
|
|||
loadProjects();
|
||||
}, []);
|
||||
|
||||
// Filtrar proyectos
|
||||
// Filtrar proyectos por estado
|
||||
const statusFiltered = useMemo(() => {
|
||||
if (statusFilter === "all") return projects;
|
||||
return projects.filter(p => p.status === statusFilter);
|
||||
}, [projects, statusFilter]);
|
||||
|
||||
// Filtrar por rango temporal
|
||||
const filteredProjects = useMemo(() => {
|
||||
let filtered = projects;
|
||||
|
||||
// Filtrar por estado
|
||||
if (statusFilter !== "all") {
|
||||
filtered = filtered.filter(p => p.status === statusFilter);
|
||||
}
|
||||
|
||||
// Filtrar por rango temporal
|
||||
const now = new Date();
|
||||
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
||||
|
||||
if (timeRange === "future") {
|
||||
// Proyectos que terminan hoy o en el futuro
|
||||
filtered = filtered.filter(p => new Date(p.endDate) >= today);
|
||||
return statusFiltered.filter(p => new Date(p.endDate) >= today);
|
||||
} else if (timeRange === "past") {
|
||||
// Proyectos que ya terminaron
|
||||
filtered = filtered.filter(p => new Date(p.endDate) < today);
|
||||
return statusFiltered.filter(p => new Date(p.endDate) < today);
|
||||
}
|
||||
// "all" no filtra
|
||||
return statusFiltered; // "all"
|
||||
}, [statusFiltered, timeRange]);
|
||||
|
||||
return filtered;
|
||||
}, [projects, statusFilter, timeRange]);
|
||||
// Calcular meses del timeline completo
|
||||
const allMonths = useMemo(() => {
|
||||
if (filteredProjects.length === 0) {
|
||||
const now = new Date();
|
||||
const start = new Date(now.getFullYear(), now.getMonth() - 3, 1);
|
||||
const end = new Date(now.getFullYear(), now.getMonth() + 3, 0);
|
||||
return getMonthsBetween(start, end);
|
||||
}
|
||||
|
||||
const allStartDates = filteredProjects.map(p => new Date(p.startDate));
|
||||
const allEndDates = filteredProjects.map(p => new Date(p.endDate));
|
||||
const minStart = new Date(Math.min(...allStartDates.map(d => d.getTime())));
|
||||
const maxEnd = new Date(Math.max(...allEndDates.map(d => d.getTime())));
|
||||
|
||||
// Calcular rango de fechas del timeline basado en los proyectos
|
||||
// - Futuro: desde el mes actual hasta el fin del proyecto más tardío
|
||||
// - Pasado: desde el inicio del proyecto más antiguo hasta el mes actual
|
||||
// - Todo: desde el inicio del proyecto más antiguo hasta el fin del más tardío
|
||||
const { timelineStart, timelineEnd, months } = useMemo(() => {
|
||||
const now = new Date();
|
||||
const currentMonthStart = new Date(now.getFullYear(), now.getMonth(), 1);
|
||||
const currentMonthEnd = new Date(now.getFullYear(), now.getMonth() + 1, 0);
|
||||
|
||||
// Si no hay proyectos, mostrar 12 meses desde el actual
|
||||
if (filteredProjects.length === 0) {
|
||||
const start = new Date(now.getFullYear(), now.getMonth() - 6, 1);
|
||||
const end = new Date(now.getFullYear(), now.getMonth() + 6, 0);
|
||||
return {
|
||||
timelineStart: start,
|
||||
timelineEnd: end,
|
||||
months: getMonthsBetween(start, end),
|
||||
};
|
||||
}
|
||||
|
||||
// Calcular fechas extremas de los proyectos
|
||||
const allStartDates = filteredProjects.map(p => new Date(p.startDate));
|
||||
const allEndDates = filteredProjects.map(p => new Date(p.endDate));
|
||||
const minProjectStart = new Date(Math.min(...allStartDates.map(d => d.getTime())));
|
||||
const maxProjectEnd = new Date(Math.max(...allEndDates.map(d => d.getTime())));
|
||||
|
||||
if (timeRange === "future") {
|
||||
// Desde el mes actual hasta el fin del proyecto más tardío
|
||||
const start = currentMonthStart;
|
||||
const end = new Date(maxProjectEnd.getFullYear(), maxProjectEnd.getMonth() + 1, 0);
|
||||
|
||||
return {
|
||||
timelineStart: start,
|
||||
timelineEnd: end,
|
||||
months: getMonthsBetween(start, end),
|
||||
};
|
||||
return getMonthsBetween(currentMonthStart, new Date(maxEnd.getFullYear(), maxEnd.getMonth() + 1, 0));
|
||||
} else if (timeRange === "past") {
|
||||
// Desde el inicio del proyecto más antiguo hasta el mes actual
|
||||
const start = new Date(minProjectStart.getFullYear(), minProjectStart.getMonth(), 1);
|
||||
const end = currentMonthEnd;
|
||||
|
||||
return {
|
||||
timelineStart: start,
|
||||
timelineEnd: end,
|
||||
months: getMonthsBetween(start, end),
|
||||
};
|
||||
} else {
|
||||
// "all" - Desde el inicio del proyecto más antiguo hasta el fin del más tardío
|
||||
const start = new Date(minProjectStart.getFullYear(), minProjectStart.getMonth(), 1);
|
||||
const end = new Date(maxProjectEnd.getFullYear(), maxProjectEnd.getMonth() + 1, 0);
|
||||
|
||||
return {
|
||||
timelineStart: start,
|
||||
timelineEnd: end,
|
||||
months: getMonthsBetween(start, end),
|
||||
};
|
||||
return getMonthsBetween(new Date(minStart.getFullYear(), minStart.getMonth(), 1), currentMonthEnd);
|
||||
}
|
||||
// "all": desde el primer proyecto hasta el último
|
||||
return getMonthsBetween(
|
||||
new Date(minStart.getFullYear(), minStart.getMonth(), 1),
|
||||
new Date(maxEnd.getFullYear(), maxEnd.getMonth() + 1, 0)
|
||||
);
|
||||
}, [filteredProjects, timeRange]);
|
||||
|
||||
// Calcular meses visibles según el modo de vista
|
||||
// En modo "todo", mostrar TODOS los meses (scroll horizontal en el timeline)
|
||||
// En modo "pasado", empezamos desde los meses más recientes
|
||||
// Meses visibles: en modo "all" se muestran todos (scroll), en otros modos se pagina
|
||||
const visibleMonths = useMemo(() => {
|
||||
// En modo "todo", mostrar todos los meses
|
||||
if (timeRange === "all") {
|
||||
return months;
|
||||
}
|
||||
if (timeRange === "all") return allMonths;
|
||||
|
||||
const monthsToShow = viewMode === "month" ? 3 : viewMode === "quarter" ? 6 : 12;
|
||||
|
||||
if (timeRange === "past") {
|
||||
// En pasado, viewOffset 0 = meses más recientes (final del array)
|
||||
const maxOffset = Math.max(0, months.length - monthsToShow);
|
||||
const startIdx = Math.max(0, maxOffset - viewOffset);
|
||||
return months.slice(startIdx, startIdx + monthsToShow);
|
||||
} else {
|
||||
// En futuro, viewOffset 0 = primeros meses
|
||||
const startIdx = Math.max(0, Math.min(viewOffset, months.length - monthsToShow));
|
||||
return months.slice(startIdx, startIdx + monthsToShow);
|
||||
const maxOff = Math.max(0, allMonths.length - monthsToShow);
|
||||
const startIdx = Math.max(0, maxOff - viewOffset);
|
||||
return allMonths.slice(startIdx, startIdx + monthsToShow);
|
||||
}
|
||||
}, [months, viewMode, viewOffset, timeRange]);
|
||||
const startIdx = Math.max(0, Math.min(viewOffset, allMonths.length - monthsToShow));
|
||||
return allMonths.slice(startIdx, startIdx + monthsToShow);
|
||||
}, [allMonths, viewMode, viewOffset, timeRange]);
|
||||
|
||||
// Calcular el ancho total en días para los meses visibles
|
||||
// Rango visible en días
|
||||
const { totalDays, visibleStart, visibleEnd } = useMemo(() => {
|
||||
if (visibleMonths.length === 0) {
|
||||
return { totalDays: 30, visibleStart: new Date(), visibleEnd: new Date() };
|
||||
}
|
||||
|
||||
const first = visibleMonths[0];
|
||||
const last = visibleMonths[visibleMonths.length - 1];
|
||||
const start = new Date(first.year, first.month, 1);
|
||||
const end = new Date(last.year, last.month + 1, 0);
|
||||
|
||||
return {
|
||||
totalDays: getDaysBetween(start, end),
|
||||
visibleStart: start,
|
||||
visibleEnd: end,
|
||||
};
|
||||
return { totalDays: getDaysBetween(start, end), visibleStart: start, visibleEnd: end };
|
||||
}, [visibleMonths]);
|
||||
|
||||
// Navegación
|
||||
// Navegación (solo para modos paginados, no "all")
|
||||
const monthsToShow = viewMode === "month" ? 3 : viewMode === "quarter" ? 6 : 12;
|
||||
const maxOffset = Math.max(0, months.length - monthsToShow);
|
||||
const canGoBack = viewOffset > 0;
|
||||
const canGoForward = viewOffset < maxOffset;
|
||||
const maxOffset = Math.max(0, allMonths.length - monthsToShow);
|
||||
const canGoBack = timeRange !== "all" && viewOffset > 0;
|
||||
const canGoForward = timeRange !== "all" && viewOffset < maxOffset;
|
||||
|
||||
const goBack = () => {
|
||||
const step = viewMode === "month" ? 1 : viewMode === "quarter" ? 3 : 6;
|
||||
setViewOffset(Math.max(0, viewOffset - step));
|
||||
};
|
||||
|
||||
const goForward = () => {
|
||||
const step = viewMode === "month" ? 1 : viewMode === "quarter" ? 3 : 6;
|
||||
setViewOffset(Math.min(maxOffset, viewOffset + step));
|
||||
};
|
||||
const goToToday = () => setViewOffset(0);
|
||||
|
||||
const goToToday = () => {
|
||||
setViewOffset(0); // Volver al mes actual
|
||||
};
|
||||
// ¿El timeline usa ancho fijo (scroll horizontal) o flexible (fill)?
|
||||
const useFixedWidth = timeRange === "all";
|
||||
const timelineInnerWidth = useFixedWidth ? visibleMonths.length * MONTH_WIDTH_PX : undefined;
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
|
|
@ -282,7 +244,7 @@ export default function GanttPage() {
|
|||
|
||||
return (
|
||||
<div className="h-screen flex flex-col bg-background">
|
||||
{/* Header - siempre visible */}
|
||||
{/* Header */}
|
||||
<header className="bg-card border-b flex-shrink-0 z-10">
|
||||
<div className="px-4 sm:px-6 lg:px-8 py-4">
|
||||
<div className="flex items-center justify-between flex-wrap gap-4">
|
||||
|
|
@ -293,7 +255,7 @@ export default function GanttPage() {
|
|||
<div>
|
||||
<h1 className="text-2xl font-bold text-foreground">Diagrama de Gantt</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Timeline de {filteredProjects.length} proyectos
|
||||
Timeline de {filteredProjects.length} proyecto{filteredProjects.length !== 1 ? "s" : ""}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -301,7 +263,7 @@ export default function GanttPage() {
|
|||
{/* Controles */}
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
{/* Filtro de estado */}
|
||||
<Select value={statusFilter} onValueChange={setStatusFilter}>
|
||||
<Select value={statusFilter} onValueChange={(v) => { setStatusFilter(v); setViewOffset(0); }}>
|
||||
<SelectTrigger className="w-40">
|
||||
<Filter className="w-4 h-4 mr-2" />
|
||||
<SelectValue placeholder="Estado" />
|
||||
|
|
@ -338,42 +300,45 @@ export default function GanttPage() {
|
|||
onClick={() => { setTimeRange("all"); setViewOffset(0); }}
|
||||
>
|
||||
<Calendar className="w-4 h-4 mr-1" />
|
||||
Todo
|
||||
Todos
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Selector de vista */}
|
||||
<Select value={viewMode} onValueChange={(v: ViewMode) => setViewMode(v)}>
|
||||
<SelectTrigger className="w-32">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="month">3 meses</SelectItem>
|
||||
<SelectItem value="quarter">6 meses</SelectItem>
|
||||
<SelectItem value="year">12 meses</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{/* Selector de vista y navegación - solo en modos paginados */}
|
||||
{timeRange !== "all" && (
|
||||
<>
|
||||
<Select value={viewMode} onValueChange={(v: ViewMode) => { setViewMode(v); setViewOffset(0); }}>
|
||||
<SelectTrigger className="w-32">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="month">3 meses</SelectItem>
|
||||
<SelectItem value="quarter">6 meses</SelectItem>
|
||||
<SelectItem value="year">12 meses</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{/* Navegación */}
|
||||
<div className="flex items-center gap-1">
|
||||
<Button variant="outline" size="icon" onClick={goBack} disabled={!canGoBack}>
|
||||
<ChevronLeft className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={goToToday} disabled={viewOffset === 0}>
|
||||
<Calendar className="w-4 h-4 mr-1" />
|
||||
Inicio
|
||||
</Button>
|
||||
<Button variant="outline" size="icon" onClick={goForward} disabled={!canGoForward}>
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button variant="outline" size="icon" onClick={goBack} disabled={!canGoBack}>
|
||||
<ChevronLeft className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={goToToday} disabled={viewOffset === 0}>
|
||||
<Calendar className="w-4 h-4 mr-1" />
|
||||
Inicio
|
||||
</Button>
|
||||
<Button variant="outline" size="icon" onClick={goForward} disabled={!canGoForward}>
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Content - área scrollable */}
|
||||
<main className="flex-1 overflow-auto px-4 sm:px-6 lg:px-8 py-6">
|
||||
{/* Content */}
|
||||
<main className="flex-1 overflow-hidden px-4 sm:px-6 lg:px-8 py-6">
|
||||
{loading ? (
|
||||
<GanttSkeleton />
|
||||
) : filteredProjects.length === 0 ? (
|
||||
|
|
@ -385,54 +350,62 @@ export default function GanttPage() {
|
|||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<Card className="border w-full">
|
||||
<CardContent className="p-0 overflow-hidden">
|
||||
<div className="flex w-full overflow-hidden">
|
||||
{/* Columna de nombres de proyectos - fija */}
|
||||
<div className="w-64 flex-shrink-0 border-r bg-muted/50">
|
||||
{/* Header */}
|
||||
<div className="h-16 border-b flex items-center px-4">
|
||||
<span className="font-semibold text-foreground">Proyecto</span>
|
||||
<Card className="border h-full flex flex-col overflow-hidden">
|
||||
<CardContent className="p-0 flex-1 overflow-hidden">
|
||||
<div className="flex h-full overflow-hidden">
|
||||
{/* Columna de nombres - fija, scroll vertical */}
|
||||
<div className="w-56 flex-shrink-0 border-r bg-muted/50 flex flex-col">
|
||||
{/* Header nombres */}
|
||||
<div className="h-12 border-b flex items-center px-4 flex-shrink-0">
|
||||
<span className="font-semibold text-sm text-foreground">Proyecto</span>
|
||||
</div>
|
||||
{/* Lista de proyectos */}
|
||||
{filteredProjects.map((project, index) => {
|
||||
const color = getProjectColor(index);
|
||||
return (
|
||||
<div
|
||||
key={project.id}
|
||||
className="h-14 border-b flex items-center px-4 hover:bg-muted transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
<div className={`w-3 h-3 rounded-full ${color.bg} flex-shrink-0`} />
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium text-foreground truncate" title={project.name}>
|
||||
{project.name}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">{project.progress}%</p>
|
||||
{/* Lista nombres - scroll vertical */}
|
||||
<div
|
||||
ref={namesRef}
|
||||
className="flex-1 overflow-y-auto overflow-x-hidden"
|
||||
onScroll={() => syncScroll("names")}
|
||||
>
|
||||
{filteredProjects.map((project, index) => {
|
||||
const color = getProjectColor(index);
|
||||
return (
|
||||
<div
|
||||
key={project.id}
|
||||
className="h-12 border-b flex items-center px-4 hover:bg-muted transition-colors cursor-pointer"
|
||||
onClick={() => router.push(`/proyectos/${project.id}`)}
|
||||
>
|
||||
<div className="flex items-center gap-2.5 min-w-0">
|
||||
<div className={`w-2.5 h-2.5 rounded-full ${color.bg} flex-shrink-0`} />
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium text-foreground truncate" title={project.name}>
|
||||
{project.name}
|
||||
</p>
|
||||
<p className="text-[11px] text-muted-foreground">{STATUS_LABELS[project.status]} · {project.progress}%</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Timeline - scroll horizontal en modo "todo" */}
|
||||
{/* Timeline - scroll horizontal (en modo "all") + scroll vertical sincronizado */}
|
||||
<div
|
||||
className={`${timeRange === "all" ? "overflow-x-auto overflow-y-hidden" : "overflow-hidden"}`}
|
||||
style={{ width: "calc(100% - 256px)" }}
|
||||
ref={timelineRef}
|
||||
className="flex-1 min-w-0 overflow-auto"
|
||||
onScroll={() => syncScroll("timeline")}
|
||||
>
|
||||
<div style={{ width: timeRange === "all" ? `${visibleMonths.length * 100}px` : "100%" }}>
|
||||
{/* Header con meses */}
|
||||
<div className="h-16 border-b flex">
|
||||
<div style={{ width: timelineInnerWidth ? `${timelineInnerWidth}px` : "100%", minWidth: useFixedWidth ? undefined : "100%" }}>
|
||||
{/* Header meses */}
|
||||
<div className="h-12 border-b flex sticky top-0 z-10 bg-card">
|
||||
{visibleMonths.map((month) => {
|
||||
const isCurrentMonth = new Date().getMonth() === month.month && new Date().getFullYear() === month.year;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={`${month.year}-${month.month}`}
|
||||
className={`flex-1 border-r flex flex-col justify-center px-2 ${
|
||||
className={`border-r flex items-center justify-center ${
|
||||
isCurrentMonth ? "bg-blue-50 dark:bg-blue-950/30" : "bg-card"
|
||||
}`}
|
||||
style={{ width: useFixedWidth ? `${MONTH_WIDTH_PX}px` : undefined, flex: useFixedWidth ? "none" : 1 }}
|
||||
>
|
||||
<span className={`text-xs font-medium ${isCurrentMonth ? "text-blue-700 dark:text-blue-400" : "text-muted-foreground"}`}>
|
||||
{month.label}
|
||||
|
|
@ -449,33 +422,26 @@ export default function GanttPage() {
|
|||
const projectStart = new Date(project.startDate);
|
||||
const projectEnd = new Date(project.endDate);
|
||||
|
||||
// Calcular posición y ancho de la barra basado en días
|
||||
const startOffset = Math.max(0, getDaysBetween(visibleStart, projectStart));
|
||||
const endOffset = Math.min(totalDays, getDaysBetween(visibleStart, projectEnd));
|
||||
|
||||
const leftPercent = (startOffset / totalDays) * 100;
|
||||
const widthPercent = Math.max(1, ((endOffset - startOffset) / totalDays) * 100);
|
||||
const widthPercent = Math.max(0.5, ((endOffset - startOffset) / totalDays) * 100);
|
||||
|
||||
// Verificar si el proyecto está visible en el rango actual
|
||||
const isVisible = projectEnd >= visibleStart && projectStart <= visibleEnd;
|
||||
const isOverdue = projectEnd < new Date() && project.progress < 100 && project.status === "1";
|
||||
|
||||
return (
|
||||
<div
|
||||
key={project.id}
|
||||
className="h-14 border-b relative"
|
||||
>
|
||||
<div key={project.id} className="h-12 border-b relative">
|
||||
{/* Grid de meses */}
|
||||
<div className="absolute inset-0 flex">
|
||||
{visibleMonths.map((month) => {
|
||||
const isCurrentMonth = new Date().getMonth() === month.month && new Date().getFullYear() === month.year;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={`grid-${month.year}-${month.month}`}
|
||||
className={`flex-1 border-r ${
|
||||
isCurrentMonth ? "bg-blue-50/30 dark:bg-blue-950/20" : ""
|
||||
}`}
|
||||
className={`border-r ${isCurrentMonth ? "bg-blue-50/30 dark:bg-blue-950/20" : ""}`}
|
||||
style={{ width: useFixedWidth ? `${MONTH_WIDTH_PX}px` : undefined, flex: useFixedWidth ? "none" : 1 }}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
|
@ -486,52 +452,48 @@ export default function GanttPage() {
|
|||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div
|
||||
className={`absolute top-2 h-10 rounded-md cursor-pointer transition-all hover:scale-[1.02] hover:shadow-md ${
|
||||
className={`absolute top-1.5 h-9 rounded-md cursor-pointer transition-all hover:scale-[1.02] hover:shadow-md ${
|
||||
isOverdue ? "ring-2 ring-red-400" : ""
|
||||
}`}
|
||||
style={{
|
||||
left: `${leftPercent}%`,
|
||||
width: `${widthPercent}%`,
|
||||
minWidth: "20px",
|
||||
minWidth: "16px",
|
||||
}}
|
||||
onClick={() => router.push(`/proyectos/${project.id}`)}
|
||||
>
|
||||
{/* Fondo de la barra */}
|
||||
<div className={`absolute inset-0 ${color.light} rounded-md`} />
|
||||
|
||||
{/* Progreso */}
|
||||
<div
|
||||
className={`absolute inset-y-0 left-0 ${color.bg} rounded-md transition-all`}
|
||||
style={{ width: `${project.progress}%` }}
|
||||
/>
|
||||
|
||||
{/* Contenido de la barra */}
|
||||
<div className="relative h-full flex items-center px-2 z-10">
|
||||
<span className="text-xs font-medium text-white truncate drop-shadow-sm">
|
||||
{project.progress >= 30 ? `${project.progress}%` : ""}
|
||||
</span>
|
||||
<div className={`absolute inset-0 ${color.light} rounded-md`} />
|
||||
<div
|
||||
className={`absolute inset-y-0 left-0 ${color.bg} rounded-md transition-all`}
|
||||
style={{ width: `${project.progress}%` }}
|
||||
/>
|
||||
<div className="relative h-full flex items-center px-2 z-10">
|
||||
<span className="text-xs font-medium text-white truncate drop-shadow-sm">
|
||||
{project.progress >= 30 ? `${project.progress}%` : ""}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" className="max-w-xs">
|
||||
<div className="space-y-1">
|
||||
<p className="font-semibold">{project.name}</p>
|
||||
<div className="text-xs space-y-0.5">
|
||||
<p><span className="text-gray-500">Estado:</span> {STATUS_LABELS[project.status]}</p>
|
||||
<p><span className="text-gray-500">Progreso:</span> {project.progress}%</p>
|
||||
<p><span className="text-gray-500">Inicio:</span> {formatDate(project.startDate)}</p>
|
||||
<p><span className="text-gray-500">Fin:</span> {formatDate(project.endDate)}</p>
|
||||
<p><span className="text-gray-500">Cliente:</span> {project.client}</p>
|
||||
{isOverdue && (
|
||||
<p className="text-red-500 font-medium">Proyecto retrasado</p>
|
||||
)}
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" className="max-w-xs">
|
||||
<div className="space-y-1">
|
||||
<p className="font-semibold">{project.name}</p>
|
||||
<div className="text-xs space-y-0.5">
|
||||
<p><span className="text-muted-foreground">Estado:</span> {STATUS_LABELS[project.status]}</p>
|
||||
<p><span className="text-muted-foreground">Progreso:</span> {project.progress}%</p>
|
||||
<p><span className="text-muted-foreground">Inicio:</span> {formatDate(project.startDate)}</p>
|
||||
<p><span className="text-muted-foreground">Fin:</span> {formatDate(project.endDate)}</p>
|
||||
<p><span className="text-muted-foreground">Cliente:</span> {project.client}</p>
|
||||
{isOverdue && (
|
||||
<p className="text-red-500 font-medium">Proyecto retrasado</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -546,36 +508,36 @@ export default function GanttPage() {
|
|||
|
||||
function GanttSkeleton() {
|
||||
return (
|
||||
<Card className="border">
|
||||
<CardContent className="p-0">
|
||||
<div className="flex">
|
||||
<div className="w-64 flex-shrink-0 border-r bg-muted/50">
|
||||
<div className="h-16 border-b flex items-center px-4">
|
||||
<Card className="border h-full">
|
||||
<CardContent className="p-0 h-full">
|
||||
<div className="flex h-full">
|
||||
<div className="w-56 flex-shrink-0 border-r bg-muted/50">
|
||||
<div className="h-12 border-b flex items-center px-4">
|
||||
<Skeleton className="h-4 w-20" />
|
||||
</div>
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
<div key={i} className="h-14 border-b flex items-center px-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<Skeleton className="w-3 h-3 rounded-full" />
|
||||
{Array.from({ length: 8 }).map((_, i) => (
|
||||
<div key={i} className="h-12 border-b flex items-center px-4">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<Skeleton className="w-2.5 h-2.5 rounded-full" />
|
||||
<div>
|
||||
<Skeleton className="h-4 w-32 mb-1" />
|
||||
<Skeleton className="h-3 w-12" />
|
||||
<Skeleton className="h-3.5 w-28 mb-1" />
|
||||
<Skeleton className="h-2.5 w-16" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div className="h-16 border-b flex">
|
||||
{Array.from({ length: 3 }).map((_, i) => (
|
||||
<div className="h-12 border-b flex">
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<div key={i} className="flex-1 border-r flex items-center justify-center">
|
||||
<Skeleton className="h-4 w-20" />
|
||||
<Skeleton className="h-3.5 w-16" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
<div key={i} className="h-14 border-b px-4 flex items-center">
|
||||
<Skeleton className="h-8 w-full max-w-md rounded-md" />
|
||||
{Array.from({ length: 8 }).map((_, i) => (
|
||||
<div key={i} className="h-12 border-b px-4 flex items-center">
|
||||
<Skeleton className="h-7 w-full max-w-md rounded-md" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -2,5 +2,6 @@ export { ProjectHeader } from "./project-header";
|
|||
export { ProjectStats } from "./project-stats";
|
||||
export { ProjectInfo } from "./project-info";
|
||||
export { TasksSection } from "./tasks-section";
|
||||
export { TaskGantt } from "./task-gantt";
|
||||
export { ProjectDetailView, ProjectDetailSkeleton } from "./project-detail-view";
|
||||
export { taskColumns } from "./task-columns";
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { FolderKanban, RefreshCw, LayoutDashboard, ListTodo, FileText } from "lucide-react";
|
||||
import { FolderKanban, RefreshCw, LayoutDashboard, ListTodo, GanttChart } from "lucide-react";
|
||||
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
|
@ -12,6 +12,7 @@ import { ProjectHeader } from "./project-header";
|
|||
import { ProjectStats } from "./project-stats";
|
||||
import { ProjectInfo } from "./project-info";
|
||||
import { TasksSection } from "./tasks-section";
|
||||
import { TaskGantt } from "./task-gantt";
|
||||
|
||||
import { Project } from "@/types/project";
|
||||
import { Task } from "@/types/task";
|
||||
|
|
@ -159,9 +160,9 @@ export function ProjectDetailView({ project: initialProject }: ProjectDetailView
|
|||
</span>
|
||||
)}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="details" className="gap-2">
|
||||
<FileText className="h-4 w-4" />
|
||||
Detalles
|
||||
<TabsTrigger value="gantt" className="gap-2">
|
||||
<GanttChart className="h-4 w-4" />
|
||||
Gantt
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
|
|
@ -222,9 +223,28 @@ export function ProjectDetailView({ project: initialProject }: ProjectDetailView
|
|||
)}
|
||||
</TabsContent>
|
||||
|
||||
{/* Tab: Detalles */}
|
||||
<TabsContent value="details">
|
||||
<ProjectInfo project={project} />
|
||||
{/* Tab: Gantt */}
|
||||
<TabsContent value="gantt">
|
||||
{isLoadingTasks ? (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Skeleton className="h-9 w-[120px]" />
|
||||
<Skeleton className="h-9 w-[100px]" />
|
||||
<Skeleton className="h-9 w-[100px]" />
|
||||
</div>
|
||||
<Skeleton className="h-[400px] w-full rounded-lg" />
|
||||
</div>
|
||||
) : tasksError ? (
|
||||
<ErrorState onRetry={fetchTasks} />
|
||||
) : (
|
||||
<TaskGantt
|
||||
tasks={tasks}
|
||||
projectId={project.id}
|
||||
onTaskCreated={handleTaskCreated}
|
||||
onTaskUpdated={handleTaskUpdated}
|
||||
onTaskDeleted={handleTaskDeleted}
|
||||
/>
|
||||
)}
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import { useState } from "react";
|
|||
import { ArrowLeft, MoreHorizontal, Pencil, Trash2, Share2, Copy } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
|
||||
|
|
@ -59,6 +60,9 @@ export function ProjectHeader({ project, onProjectUpdated, onProjectDeleted }: P
|
|||
router.push('/proyectos');
|
||||
} catch (error) {
|
||||
console.error("Error deleting project:", error);
|
||||
toast.error("Error al eliminar el proyecto", {
|
||||
description: "No se pudo eliminar el proyecto. Inténtalo de nuevo.",
|
||||
});
|
||||
} finally {
|
||||
setIsDeleting(false);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,647 @@
|
|||
"use client";
|
||||
|
||||
import { useState, useMemo, useRef, useEffect, useCallback } from "react";
|
||||
import {
|
||||
GanttChart as GanttIcon,
|
||||
Plus,
|
||||
Link2,
|
||||
AlertTriangle,
|
||||
CheckCircle2,
|
||||
Clock,
|
||||
History,
|
||||
} from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { Task, TASK_STATUS_CONFIG, TASK_PRIORITY_CONFIG } from "@/types/task";
|
||||
import { setTaskDependencies } from "@/lib/tasksService";
|
||||
import { TaskFormSheet } from "@/components/task-form/task-form-sheet";
|
||||
|
||||
// Paleta de colores para tareas
|
||||
const TASK_COLORS = [
|
||||
{ bg: "bg-blue-500", light: "bg-blue-100 dark:bg-blue-900/40", hex: "#3b82f6" },
|
||||
{ bg: "bg-violet-500", light: "bg-violet-100 dark:bg-violet-900/40", hex: "#8b5cf6" },
|
||||
{ bg: "bg-cyan-500", light: "bg-cyan-100 dark:bg-cyan-900/40", hex: "#06b6d4" },
|
||||
{ bg: "bg-emerald-500", light: "bg-emerald-100 dark:bg-emerald-900/40", hex: "#10b981" },
|
||||
{ bg: "bg-amber-500", light: "bg-amber-100 dark:bg-amber-900/40", hex: "#f59e0b" },
|
||||
{ bg: "bg-rose-500", light: "bg-rose-100 dark:bg-rose-900/40", hex: "#f43f5e" },
|
||||
{ bg: "bg-pink-500", light: "bg-pink-100 dark:bg-pink-900/40", hex: "#ec4899" },
|
||||
{ bg: "bg-indigo-500", light: "bg-indigo-100 dark:bg-indigo-900/40", hex: "#6366f1" },
|
||||
{ bg: "bg-teal-500", light: "bg-teal-100 dark:bg-teal-900/40", hex: "#14b8a6" },
|
||||
{ bg: "bg-orange-500", light: "bg-orange-100 dark:bg-orange-900/40", hex: "#f97316" },
|
||||
];
|
||||
|
||||
function getTaskColor(index: number) {
|
||||
return TASK_COLORS[index % TASK_COLORS.length];
|
||||
}
|
||||
|
||||
function formatDate(dateStr: string): string {
|
||||
const date = new Date(dateStr);
|
||||
return date.toLocaleDateString("es-ES", { day: "2-digit", month: "short", year: "numeric" });
|
||||
}
|
||||
|
||||
function getDaysBetween(start: Date, end: Date): number {
|
||||
return Math.ceil((end.getTime() - start.getTime()) / (1000 * 60 * 60 * 24));
|
||||
}
|
||||
|
||||
function getMonthsBetween(start: Date, end: Date): { month: number; year: number; label: string }[] {
|
||||
const months: { month: number; year: number; label: string }[] = [];
|
||||
const current = new Date(start.getFullYear(), start.getMonth(), 1);
|
||||
const endMonth = new Date(end.getFullYear(), end.getMonth(), 1);
|
||||
|
||||
while (current <= endMonth) {
|
||||
months.push({
|
||||
month: current.getMonth(),
|
||||
year: current.getFullYear(),
|
||||
label: current.toLocaleDateString("es-ES", { month: "short", year: "numeric" }),
|
||||
});
|
||||
current.setMonth(current.getMonth() + 1);
|
||||
}
|
||||
|
||||
return months;
|
||||
}
|
||||
|
||||
type TimeRange = "future" | "past";
|
||||
|
||||
const ROW_HEIGHT = 48;
|
||||
const HEADER_HEIGHT = 56;
|
||||
const NAME_COL_WIDTH = 240;
|
||||
const MONTH_WIDTH = 120; // px fijo por mes para scroll horizontal
|
||||
|
||||
interface TaskGanttProps {
|
||||
tasks: Task[];
|
||||
projectId: number;
|
||||
onTaskCreated?: (task: Task) => void;
|
||||
onTaskUpdated?: (task: Task) => void;
|
||||
onTaskDeleted?: (taskId: number) => void;
|
||||
}
|
||||
|
||||
type InteractionMode = "normal" | "linking";
|
||||
|
||||
export function TaskGantt({ tasks, projectId, onTaskCreated, onTaskUpdated }: TaskGanttProps) {
|
||||
const [timeRange, setTimeRange] = useState<TimeRange>("future");
|
||||
const [isCreateSheetOpen, setIsCreateSheetOpen] = useState(false);
|
||||
const [interactionMode, setInteractionMode] = useState<InteractionMode>("normal");
|
||||
const [linkSource, setLinkSource] = useState<number | null>(null);
|
||||
const timelineRef = useRef<HTMLDivElement>(null);
|
||||
const timelineScrollRef = useRef<HTMLDivElement>(null);
|
||||
const [svgSize, setSvgSize] = useState({ width: 0, height: 0 });
|
||||
|
||||
// TODAS las tareas, ordenadas por fecha de inicio (las sin fecha al final)
|
||||
const ganttTasks = useMemo(() => {
|
||||
return [...tasks].sort((a, b) => {
|
||||
const aStart = a.startDate || a.plannedStartDate;
|
||||
const bStart = b.startDate || b.plannedStartDate;
|
||||
if (!aStart && !bStart) return 0;
|
||||
if (!aStart) return 1;
|
||||
if (!bStart) return -1;
|
||||
return new Date(aStart).getTime() - new Date(bStart).getTime();
|
||||
});
|
||||
}, [tasks]);
|
||||
|
||||
// Mapa de índices
|
||||
const taskIndexMap = useMemo(() => {
|
||||
const map = new Map<number, number>();
|
||||
ganttTasks.forEach((t, i) => map.set(t.id, i));
|
||||
return map;
|
||||
}, [ganttTasks]);
|
||||
|
||||
// Calcular rango del timeline según timeRange
|
||||
const { months, timelineStart, timelineEnd } = useMemo(() => {
|
||||
const now = new Date();
|
||||
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
||||
const currentMonthStart = new Date(now.getFullYear(), now.getMonth(), 1);
|
||||
|
||||
// Recoger las fechas de tareas que tienen fechas
|
||||
const tasksWithDates = ganttTasks.filter(t => (t.startDate || t.plannedStartDate) && (t.endDate || t.plannedEndDate));
|
||||
|
||||
if (tasksWithDates.length === 0) {
|
||||
// Sin tareas con fechas: mostrar 6 meses desde hoy
|
||||
const start = currentMonthStart;
|
||||
const end = new Date(now.getFullYear(), now.getMonth() + 6, 0);
|
||||
return { months: getMonthsBetween(start, end), timelineStart: start, timelineEnd: end };
|
||||
}
|
||||
|
||||
const allStartDates = tasksWithDates.map(t => new Date(t.startDate || t.plannedStartDate!));
|
||||
const allEndDates = tasksWithDates.map(t => new Date(t.endDate || t.plannedEndDate!));
|
||||
const minTaskStart = new Date(Math.min(...allStartDates.map(d => d.getTime())));
|
||||
const maxTaskEnd = new Date(Math.max(...allEndDates.map(d => d.getTime())));
|
||||
|
||||
if (timeRange === "future") {
|
||||
// Desde el mes actual hasta el fin de la última tarea (+1 mes margen)
|
||||
const start = currentMonthStart;
|
||||
const end = new Date(maxTaskEnd.getFullYear(), maxTaskEnd.getMonth() + 2, 0);
|
||||
return { months: getMonthsBetween(start, end), timelineStart: start, timelineEnd: end };
|
||||
} else {
|
||||
// Pasado: desde el inicio de la primera tarea hasta el mes actual (+1 mes margen)
|
||||
const start = new Date(minTaskStart.getFullYear(), minTaskStart.getMonth() - 1, 1);
|
||||
const currentMonthEnd = new Date(now.getFullYear(), now.getMonth() + 1, 0);
|
||||
return { months: getMonthsBetween(start, currentMonthEnd), timelineStart: start, timelineEnd: currentMonthEnd };
|
||||
}
|
||||
}, [ganttTasks, timeRange]);
|
||||
|
||||
// Total days del timeline completo
|
||||
const totalDays = useMemo(() => {
|
||||
if (months.length === 0) return 30;
|
||||
const first = months[0];
|
||||
const last = months[months.length - 1];
|
||||
const start = new Date(first.year, first.month, 1);
|
||||
const end = new Date(last.year, last.month + 1, 0);
|
||||
return getDaysBetween(start, end);
|
||||
}, [months]);
|
||||
|
||||
// Visión completa: start/end del timeline
|
||||
const visibleStart = useMemo(() => {
|
||||
if (months.length === 0) return new Date();
|
||||
return new Date(months[0].year, months[0].month, 1);
|
||||
}, [months]);
|
||||
|
||||
const visibleEnd = useMemo(() => {
|
||||
if (months.length === 0) return new Date();
|
||||
const last = months[months.length - 1];
|
||||
return new Date(last.year, last.month + 1, 0);
|
||||
}, [months]);
|
||||
|
||||
// Ancho total del timeline en px
|
||||
const timelineWidth = months.length * MONTH_WIDTH;
|
||||
|
||||
// Calcular posición de barra (en px, no %)
|
||||
const getBarPosition = useCallback(
|
||||
(task: Task) => {
|
||||
const taskStart = new Date(task.startDate || task.plannedStartDate!);
|
||||
const taskEnd = new Date(task.endDate || task.plannedEndDate!);
|
||||
|
||||
const startOffset = Math.max(0, getDaysBetween(visibleStart, taskStart));
|
||||
const endOffset = Math.min(totalDays, getDaysBetween(visibleStart, taskEnd));
|
||||
|
||||
const leftPx = (startOffset / totalDays) * timelineWidth;
|
||||
const widthPx = Math.max(8, ((endOffset - startOffset) / totalDays) * timelineWidth);
|
||||
|
||||
return { leftPx, widthPx };
|
||||
},
|
||||
[visibleStart, totalDays, timelineWidth]
|
||||
);
|
||||
|
||||
// Medir SVG
|
||||
useEffect(() => {
|
||||
setSvgSize({
|
||||
width: timelineWidth,
|
||||
height: Math.max(ganttTasks.length * ROW_HEIGHT, 100),
|
||||
});
|
||||
}, [ganttTasks.length, timelineWidth]);
|
||||
|
||||
// Flechas de dependencias
|
||||
const dependencyArrows = useMemo(() => {
|
||||
const arrows: { fromX: number; fromY: number; toX: number; toY: number; key: string }[] = [];
|
||||
if (timelineWidth === 0) return arrows;
|
||||
|
||||
for (const task of ganttTasks) {
|
||||
if (!task.dependencies || task.dependencies.length === 0) continue;
|
||||
const hasDate = (task.startDate || task.plannedStartDate) && (task.endDate || task.plannedEndDate);
|
||||
if (!hasDate) continue;
|
||||
|
||||
const toIndex = taskIndexMap.get(task.id);
|
||||
if (toIndex === undefined) continue;
|
||||
|
||||
for (const depId of task.dependencies) {
|
||||
const fromIndex = taskIndexMap.get(depId);
|
||||
if (fromIndex === undefined) continue;
|
||||
|
||||
const depTask = ganttTasks[fromIndex];
|
||||
const depHasDate = (depTask.startDate || depTask.plannedStartDate) && (depTask.endDate || depTask.plannedEndDate);
|
||||
if (!depHasDate) continue;
|
||||
|
||||
const fromBar = getBarPosition(depTask);
|
||||
const toBar = getBarPosition(task);
|
||||
|
||||
const fromX = fromBar.leftPx + fromBar.widthPx;
|
||||
const fromY = fromIndex * ROW_HEIGHT + ROW_HEIGHT / 2;
|
||||
const toX = toBar.leftPx;
|
||||
const toY = toIndex * ROW_HEIGHT + ROW_HEIGHT / 2;
|
||||
|
||||
arrows.push({ fromX, fromY, toX, toY, key: `${depId}-${task.id}` });
|
||||
}
|
||||
}
|
||||
return arrows;
|
||||
}, [ganttTasks, taskIndexMap, getBarPosition, timelineWidth]);
|
||||
|
||||
// Click en modo linking
|
||||
const handleBarClick = (taskId: number) => {
|
||||
if (interactionMode !== "linking") return;
|
||||
|
||||
if (linkSource === null) {
|
||||
setLinkSource(taskId);
|
||||
} else {
|
||||
if (taskId !== linkSource) {
|
||||
const targetTask = ganttTasks.find(t => t.id === taskId);
|
||||
if (targetTask) {
|
||||
const wouldCreateCycle = checkCircularDependency(taskId, linkSource, ganttTasks);
|
||||
if (!wouldCreateCycle) {
|
||||
const newDeps = [...(targetTask.dependencies || [])];
|
||||
if (!newDeps.includes(linkSource)) {
|
||||
newDeps.push(linkSource);
|
||||
setTaskDependencies(taskId, newDeps);
|
||||
targetTask.dependencies = newDeps;
|
||||
onTaskUpdated?.({ ...targetTask, dependencies: newDeps });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
setLinkSource(null);
|
||||
setInteractionMode("normal");
|
||||
}
|
||||
};
|
||||
|
||||
function checkCircularDependency(targetId: number, sourceId: number, allTasks: Task[]): boolean {
|
||||
const visited = new Set<number>();
|
||||
function hasDependency(taskId: number, searchId: number): boolean {
|
||||
if (taskId === searchId) return true;
|
||||
if (visited.has(taskId)) return false;
|
||||
visited.add(taskId);
|
||||
const task = allTasks.find(t => t.id === taskId);
|
||||
if (!task || !task.dependencies) return false;
|
||||
return task.dependencies.some(depId => hasDependency(depId, searchId));
|
||||
}
|
||||
return hasDependency(sourceId, targetId);
|
||||
}
|
||||
|
||||
const cancelLinking = () => {
|
||||
setInteractionMode("normal");
|
||||
setLinkSource(null);
|
||||
};
|
||||
|
||||
if (ganttTasks.length === 0) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-16 text-center">
|
||||
<div className="p-3 rounded-full bg-muted mb-4">
|
||||
<GanttIcon className="h-8 w-8 text-muted-foreground" />
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold mb-1">Sin tareas para el Gantt</h3>
|
||||
<p className="text-sm text-muted-foreground mb-4 max-w-sm">
|
||||
Crea tareas con fechas de inicio y fin para verlas en el diagrama.
|
||||
</p>
|
||||
<Button onClick={() => setIsCreateSheetOpen(true)}>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
Crear tarea
|
||||
</Button>
|
||||
<TaskFormSheet
|
||||
open={isCreateSheetOpen}
|
||||
onOpenChange={setIsCreateSheetOpen}
|
||||
mode="create"
|
||||
projectId={projectId}
|
||||
availableTasks={tasks}
|
||||
onSuccess={(task) => onTaskCreated?.(task)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Controles */}
|
||||
<div className="flex items-center justify-between flex-wrap gap-3">
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
{/* Toggle Futuro / Pasado */}
|
||||
<div className="flex items-center gap-1 border rounded-lg p-1">
|
||||
<Button
|
||||
variant={timeRange === "future" ? "default" : "ghost"}
|
||||
size="sm"
|
||||
onClick={() => setTimeRange("future")}
|
||||
>
|
||||
<Clock className="w-4 h-4 mr-1" />
|
||||
Futuro
|
||||
</Button>
|
||||
<Button
|
||||
variant={timeRange === "past" ? "default" : "ghost"}
|
||||
size="sm"
|
||||
onClick={() => setTimeRange("past")}
|
||||
>
|
||||
<History className="w-4 h-4 mr-1" />
|
||||
Pasado
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{months.length} meses · {ganttTasks.length} tareas
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Modo enlazar dependencias */}
|
||||
{interactionMode === "linking" ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="outline" className="bg-blue-50 dark:bg-blue-950/50 text-blue-700 dark:text-blue-400 border-blue-200 dark:border-blue-800 animate-pulse">
|
||||
<Link2 className="h-3 w-3 mr-1" />
|
||||
{linkSource === null
|
||||
? "Click en la tarea predecesora"
|
||||
: "Ahora click en la dependiente"}
|
||||
</Badge>
|
||||
<Button variant="ghost" size="sm" onClick={cancelLinking}>
|
||||
Cancelar
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-9"
|
||||
onClick={() => setInteractionMode("linking")}
|
||||
>
|
||||
<Link2 className="h-4 w-4 mr-2" />
|
||||
Enlazar tareas
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<Button size="sm" className="h-9" onClick={() => setIsCreateSheetOpen(true)}>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
Nueva tarea
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Leyenda dependencias */}
|
||||
{dependencyArrows.length > 0 && (
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<svg width="20" height="10">
|
||||
<defs>
|
||||
<marker id="legend-arrow" markerWidth="6" markerHeight="6" refX="5" refY="3" orient="auto">
|
||||
<path d="M0,0 L6,3 L0,6 Z" fill="#6366f1" />
|
||||
</marker>
|
||||
</defs>
|
||||
<line x1="0" y1="5" x2="14" y2="5" stroke="#6366f1" strokeWidth="1.5" markerEnd="url(#legend-arrow)" />
|
||||
</svg>
|
||||
<span>{dependencyArrows.length} dependencia{dependencyArrows.length !== 1 ? "s" : ""}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Gantt Chart */}
|
||||
<div className="border rounded-lg overflow-hidden bg-card">
|
||||
<div className="flex w-full overflow-hidden">
|
||||
{/* Columna de nombres - fija */}
|
||||
<div className="flex-shrink-0 border-r bg-muted/50" style={{ width: `${NAME_COL_WIDTH}px` }}>
|
||||
<div className="border-b flex items-center px-4" style={{ height: `${HEADER_HEIGHT}px` }}>
|
||||
<span className="font-semibold text-foreground text-sm">Tarea</span>
|
||||
</div>
|
||||
{ganttTasks.map((task, index) => {
|
||||
const color = getTaskColor(index);
|
||||
const statusConfig = TASK_STATUS_CONFIG[task.status];
|
||||
const hasDate = (task.startDate || task.plannedStartDate) && (task.endDate || task.plannedEndDate);
|
||||
const isOverdue = (() => {
|
||||
const endDate = task.endDate || task.plannedEndDate;
|
||||
return endDate && new Date(endDate) < new Date() && task.status !== "2";
|
||||
})();
|
||||
const isLinkSource = interactionMode === "linking" && linkSource === task.id;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={task.id}
|
||||
className={`border-b flex items-center px-3 hover:bg-muted transition-colors ${
|
||||
isLinkSource ? "bg-blue-50 dark:bg-blue-950/30" : ""
|
||||
} ${interactionMode === "linking" ? "cursor-pointer" : ""}`}
|
||||
style={{ height: `${ROW_HEIGHT}px` }}
|
||||
onClick={() => interactionMode === "linking" && handleBarClick(task.id)}
|
||||
>
|
||||
<div className="flex items-center gap-2 min-w-0 w-full">
|
||||
<div className={`w-2.5 h-2.5 rounded-full ${hasDate ? color.bg : "bg-gray-300 dark:bg-gray-600"} flex-shrink-0`} />
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-xs font-medium text-foreground truncate" title={task.title}>
|
||||
{task.title}
|
||||
</p>
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="text-[10px] text-muted-foreground">{task.progress}%</span>
|
||||
{task.status === "2" && <CheckCircle2 className="h-2.5 w-2.5 text-green-500" />}
|
||||
{isOverdue && <AlertTriangle className="h-2.5 w-2.5 text-red-500" />}
|
||||
{task.dependencies && task.dependencies.length > 0 && <Link2 className="h-2.5 w-2.5 text-indigo-500" />}
|
||||
{!hasDate && <span className="text-[9px] text-amber-500">Sin fechas</span>}
|
||||
</div>
|
||||
</div>
|
||||
<Badge variant="outline" className={`${statusConfig.bgClass} text-[9px] px-1 py-0 h-4 flex-shrink-0`}>
|
||||
{statusConfig.label}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Timeline con scroll horizontal */}
|
||||
<div
|
||||
ref={timelineScrollRef}
|
||||
className="overflow-x-auto overflow-y-hidden"
|
||||
style={{ width: `calc(100% - ${NAME_COL_WIDTH}px)` }}
|
||||
>
|
||||
<div style={{ width: `${timelineWidth}px`, minWidth: "100%" }}>
|
||||
{/* Header con meses */}
|
||||
<div className="border-b flex" style={{ height: `${HEADER_HEIGHT}px` }}>
|
||||
{months.map((month) => {
|
||||
const isCurrentMonth = new Date().getMonth() === month.month && new Date().getFullYear() === month.year;
|
||||
return (
|
||||
<div
|
||||
key={`${month.year}-${month.month}`}
|
||||
className={`border-r flex flex-col justify-center px-2 ${
|
||||
isCurrentMonth ? "bg-blue-50 dark:bg-blue-950/30" : "bg-card"
|
||||
}`}
|
||||
style={{ width: `${MONTH_WIDTH}px`, flexShrink: 0 }}
|
||||
>
|
||||
<span className={`text-xs font-medium ${isCurrentMonth ? "text-blue-700 dark:text-blue-400" : "text-muted-foreground"}`}>
|
||||
{month.label}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Barras + SVG de flechas */}
|
||||
<div className="relative" ref={timelineRef}>
|
||||
{/* SVG flechas */}
|
||||
<svg
|
||||
className="absolute inset-0 pointer-events-none z-10"
|
||||
width={timelineWidth}
|
||||
height={ganttTasks.length * ROW_HEIGHT}
|
||||
style={{ overflow: "visible" }}
|
||||
>
|
||||
<defs>
|
||||
<marker id="dependency-arrow" markerWidth="8" markerHeight="8" refX="7" refY="4" orient="auto" markerUnits="userSpaceOnUse">
|
||||
<path d="M0,0 L8,4 L0,8 Z" fill="#6366f1" />
|
||||
</marker>
|
||||
</defs>
|
||||
{dependencyArrows.map((arrow) => {
|
||||
const dx = arrow.toX - arrow.fromX;
|
||||
let pathD: string;
|
||||
if (dx > 20) {
|
||||
const midX = arrow.fromX + dx * 0.5;
|
||||
pathD = `M${arrow.fromX},${arrow.fromY} C${midX},${arrow.fromY} ${midX},${arrow.toY} ${arrow.toX},${arrow.toY}`;
|
||||
} else {
|
||||
const offset = 15;
|
||||
const belowY = Math.max(arrow.fromY, arrow.toY) + ROW_HEIGHT * 0.6;
|
||||
pathD = `M${arrow.fromX},${arrow.fromY} L${arrow.fromX + offset},${arrow.fromY} L${arrow.fromX + offset},${belowY} L${arrow.toX - offset},${belowY} L${arrow.toX - offset},${arrow.toY} L${arrow.toX},${arrow.toY}`;
|
||||
}
|
||||
return (
|
||||
<path
|
||||
key={arrow.key}
|
||||
d={pathD}
|
||||
fill="none"
|
||||
stroke="#6366f1"
|
||||
strokeWidth="1.5"
|
||||
strokeDasharray={dx <= 20 ? "4,3" : "none"}
|
||||
markerEnd="url(#dependency-arrow)"
|
||||
opacity="0.7"
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</svg>
|
||||
|
||||
{/* Filas */}
|
||||
<TooltipProvider>
|
||||
{ganttTasks.map((task, index) => {
|
||||
const color = getTaskColor(index);
|
||||
const hasDate = (task.startDate || task.plannedStartDate) && (task.endDate || task.plannedEndDate);
|
||||
|
||||
const isOverdue = (() => {
|
||||
const endDate = task.endDate || task.plannedEndDate;
|
||||
return endDate && new Date(endDate) < new Date() && task.status !== "2";
|
||||
})();
|
||||
const isLinkSource = interactionMode === "linking" && linkSource === task.id;
|
||||
const priorityConfig = TASK_PRIORITY_CONFIG[task.priority];
|
||||
const hasDeps = task.dependencies && task.dependencies.length > 0;
|
||||
|
||||
// Posición de barra solo si tiene fechas
|
||||
let barPos: { leftPx: number; widthPx: number } | null = null;
|
||||
let isVisible = false;
|
||||
if (hasDate) {
|
||||
barPos = getBarPosition(task);
|
||||
const taskStart = new Date(task.startDate || task.plannedStartDate!);
|
||||
const taskEnd = new Date(task.endDate || task.plannedEndDate!);
|
||||
isVisible = taskEnd >= visibleStart && taskStart <= visibleEnd;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
key={task.id}
|
||||
className={`border-b relative ${interactionMode === "linking" ? "cursor-pointer" : ""}`}
|
||||
style={{ height: `${ROW_HEIGHT}px` }}
|
||||
>
|
||||
{/* Grid de meses */}
|
||||
<div className="absolute inset-0 flex">
|
||||
{months.map((month) => {
|
||||
const isCurrentMonth = new Date().getMonth() === month.month && new Date().getFullYear() === month.year;
|
||||
return (
|
||||
<div
|
||||
key={`grid-${month.year}-${month.month}`}
|
||||
className={`border-r ${isCurrentMonth ? "bg-blue-50/30 dark:bg-blue-950/20" : ""}`}
|
||||
style={{ width: `${MONTH_WIDTH}px`, flexShrink: 0 }}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Línea del día actual */}
|
||||
{(() => {
|
||||
const now = new Date();
|
||||
if (now >= visibleStart && now <= visibleEnd) {
|
||||
const todayOffset = getDaysBetween(visibleStart, now);
|
||||
const todayPx = (todayOffset / totalDays) * timelineWidth;
|
||||
return (
|
||||
<div
|
||||
className="absolute top-0 bottom-0 w-px bg-red-400 z-[5]"
|
||||
style={{ left: `${todayPx}px` }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
})()}
|
||||
|
||||
{/* Barra de la tarea */}
|
||||
{isVisible && barPos && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div
|
||||
className={`absolute top-2 rounded-md transition-all z-[2] ${
|
||||
interactionMode === "linking"
|
||||
? "cursor-pointer hover:ring-2 hover:ring-blue-400"
|
||||
: "cursor-default hover:scale-[1.02] hover:shadow-md"
|
||||
} ${isLinkSource ? "ring-2 ring-blue-500" : ""} ${isOverdue ? "ring-2 ring-red-400" : ""}`}
|
||||
style={{
|
||||
left: `${barPos.leftPx}px`,
|
||||
width: `${barPos.widthPx}px`,
|
||||
height: `${ROW_HEIGHT - 16}px`,
|
||||
minWidth: "8px",
|
||||
}}
|
||||
onClick={() => interactionMode === "linking" && handleBarClick(task.id)}
|
||||
>
|
||||
<div className={`absolute inset-0 ${color.light} rounded-md`} />
|
||||
<div
|
||||
className={`absolute inset-y-0 left-0 ${color.bg} rounded-md transition-all`}
|
||||
style={{ width: `${task.progress}%` }}
|
||||
/>
|
||||
<div className="relative h-full flex items-center px-2 z-10">
|
||||
<span className="text-[10px] font-medium text-white truncate drop-shadow-sm">
|
||||
{barPos.widthPx > 40 ? `${task.progress}%` : ""}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" className="max-w-xs">
|
||||
<div className="space-y-1.5">
|
||||
<p className="font-semibold text-sm">{task.title}</p>
|
||||
<div className="text-xs space-y-0.5">
|
||||
<p><span className="text-muted-foreground">Estado:</span> {TASK_STATUS_CONFIG[task.status].label}</p>
|
||||
<p><span className="text-muted-foreground">Prioridad:</span> {priorityConfig.label}</p>
|
||||
<p><span className="text-muted-foreground">Progreso:</span> {task.progress}%</p>
|
||||
<p><span className="text-muted-foreground">Inicio:</span> {formatDate(task.startDate || task.plannedStartDate!)}</p>
|
||||
<p><span className="text-muted-foreground">Fin:</span> {formatDate(task.endDate || task.plannedEndDate!)}</p>
|
||||
{task.plannedHours > 0 && (
|
||||
<p><span className="text-muted-foreground">Horas:</span> {task.workedHours}h / {task.plannedHours}h</p>
|
||||
)}
|
||||
{hasDeps && (
|
||||
<div className="pt-1 border-t">
|
||||
<p className="text-muted-foreground flex items-center gap-1">
|
||||
<Link2 className="h-3 w-3" />
|
||||
Depende de:
|
||||
</p>
|
||||
{task.dependencies.map((depId) => {
|
||||
const depTask = ganttTasks.find(t => t.id === depId);
|
||||
return <p key={depId} className="pl-4 text-[11px]">{depTask?.title || `Tarea #${depId}`}</p>;
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
{isOverdue && <p className="text-red-500 font-medium pt-1">Tarea retrasada</p>}
|
||||
</div>
|
||||
</div>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
{/* Indicador para tareas sin fecha */}
|
||||
{!hasDate && (
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<span className="text-[10px] text-muted-foreground/60 italic">Sin fechas asignadas</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Sheet de creación */}
|
||||
<TaskFormSheet
|
||||
open={isCreateSheetOpen}
|
||||
onOpenChange={setIsCreateSheetOpen}
|
||||
mode="create"
|
||||
projectId={projectId}
|
||||
availableTasks={tasks}
|
||||
onSuccess={(task) => onTaskCreated?.(task)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -13,6 +13,7 @@ import {
|
|||
List
|
||||
} from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
|
|
@ -180,7 +181,9 @@ export function TasksSection({ tasks, projectId, isLoading, onTaskCreated, onTas
|
|||
setTaskToDelete(null);
|
||||
} catch (error) {
|
||||
console.error("Error deleting task:", error);
|
||||
// TODO: Mostrar toast de error
|
||||
toast.error("Error al eliminar la tarea", {
|
||||
description: "No se pudo eliminar la tarea. Inténtalo de nuevo.",
|
||||
});
|
||||
} finally {
|
||||
setIsDeleting(false);
|
||||
}
|
||||
|
|
@ -246,6 +249,7 @@ export function TasksSection({ tasks, projectId, isLoading, onTaskCreated, onTas
|
|||
onOpenChange={setIsCreateSheetOpen}
|
||||
mode="create"
|
||||
projectId={projectId}
|
||||
availableTasks={tasks}
|
||||
onSuccess={handleCreateSuccess}
|
||||
/>
|
||||
</>
|
||||
|
|
@ -388,6 +392,7 @@ export function TasksSection({ tasks, projectId, isLoading, onTaskCreated, onTas
|
|||
onOpenChange={setIsCreateSheetOpen}
|
||||
mode="create"
|
||||
projectId={projectId}
|
||||
availableTasks={tasks}
|
||||
onSuccess={handleCreateSuccess}
|
||||
/>
|
||||
|
||||
|
|
@ -401,6 +406,7 @@ export function TasksSection({ tasks, projectId, isLoading, onTaskCreated, onTas
|
|||
mode="edit"
|
||||
projectId={projectId}
|
||||
task={taskToEdit}
|
||||
availableTasks={tasks}
|
||||
onSuccess={handleEditSuccess}
|
||||
/>
|
||||
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
import { useState, useEffect, useMemo } from "react";
|
||||
import { FolderKanban, RefreshCw, Plus } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Project } from "@/types/project";
|
||||
import { getProjects, deleteProject } from "@/lib/projectsService";
|
||||
|
|
@ -81,6 +82,9 @@ export function ProjectsTable() {
|
|||
setProjectToDelete(null);
|
||||
} catch (error) {
|
||||
console.error("Error deleting project:", error);
|
||||
toast.error("Error al eliminar el proyecto", {
|
||||
description: "No se pudo eliminar el proyecto. Inténtalo de nuevo.",
|
||||
});
|
||||
} finally {
|
||||
setIsDeleting(false);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -222,52 +222,6 @@ function ProgressDonut({ data, title, centerValue, centerLabel }: ProgressDonutP
|
|||
}
|
||||
|
||||
// Radial Bar Chart para metricas
|
||||
interface RadialMetricProps {
|
||||
value: number;
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
color?: string;
|
||||
}
|
||||
|
||||
function RadialMetric({ value, title, subtitle, color = COLORS.primary }: RadialMetricProps) {
|
||||
const data = [{ name: title, value: Math.min(value, 100), fill: color }];
|
||||
|
||||
return (
|
||||
<Card className="border">
|
||||
<CardHeader className="pb-0">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">{title}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="h-40 relative">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<RadialBarChart
|
||||
cx="50%"
|
||||
cy="50%"
|
||||
innerRadius="60%"
|
||||
outerRadius="90%"
|
||||
data={data}
|
||||
startAngle={180}
|
||||
endAngle={0}
|
||||
>
|
||||
<RadialBar
|
||||
background={{ fill: "hsl(var(--muted))" }}
|
||||
dataKey="value"
|
||||
cornerRadius={10}
|
||||
/>
|
||||
</RadialBarChart>
|
||||
</ResponsiveContainer>
|
||||
<div className="absolute inset-0 flex items-center justify-center pointer-events-none" style={{ marginTop: "-15px" }}>
|
||||
<div className="text-center">
|
||||
<p className="text-2xl font-bold text-foreground">{value}%</p>
|
||||
{subtitle && <p className="text-xs text-muted-foreground">{subtitle}</p>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// Bar Chart horizontal para presupuestos por proyecto
|
||||
interface BudgetBarChartProps {
|
||||
projects: Project[];
|
||||
|
|
@ -325,43 +279,6 @@ function BudgetBarChart({ projects, title, maxItems = 6 }: BudgetBarChartProps)
|
|||
);
|
||||
}
|
||||
|
||||
// Bar Chart vertical para comparativas
|
||||
interface ComparisonBarChartProps {
|
||||
data: { name: string; value: number; color?: string }[];
|
||||
title: string;
|
||||
formatter?: (value: number) => string;
|
||||
}
|
||||
|
||||
function ComparisonBarChart({ data, title, formatter }: ComparisonBarChartProps) {
|
||||
return (
|
||||
<Card className="border">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">{title}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="h-64">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart data={data} margin={{ top: 10, right: 10, left: -10, bottom: 0 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" vertical={false} />
|
||||
<XAxis dataKey="name" fontSize={11} />
|
||||
<YAxis tickFormatter={formatter} fontSize={11} />
|
||||
<Tooltip
|
||||
formatter={(value) => [formatter ? formatter(Number(value) || 0) : (value ?? 0), '']}
|
||||
contentStyle={{ fontSize: '12px' }}
|
||||
/>
|
||||
<Bar dataKey="value" radius={[4, 4, 0, 0]}>
|
||||
{data.map((entry, index) => (
|
||||
<Cell key={`cell-${index}`} fill={entry.color || COLORS.primary} />
|
||||
))}
|
||||
</Bar>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// Multi Radial para comparar metricas
|
||||
interface MultiRadialProps {
|
||||
data: { name: string; value: number; fill: string }[];
|
||||
|
|
@ -486,9 +403,8 @@ function AllProjectsStats({ projects }: { projects: Project[] }) {
|
|||
const abiertos = projects.filter(p => p.status === "1").length;
|
||||
const cerrados = projects.filter(p => p.status === "2").length;
|
||||
|
||||
// Nuevas métricas
|
||||
const overdueProjects = getOverdueProjects(projects);
|
||||
const upcomingDeadlines = getUpcomingDeadlines(projects, 14); // próximos 14 días
|
||||
const upcomingDeadlines = getUpcomingDeadlines(projects, 14);
|
||||
|
||||
const progressData = [
|
||||
{ name: "0-25%", value: projects.filter(p => p.progress < 25).length, color: PROGRESS_COLORS[0] },
|
||||
|
|
@ -497,186 +413,123 @@ function AllProjectsStats({ projects }: { projects: Project[] }) {
|
|||
{ name: "75-100%", value: projects.filter(p => p.progress >= 75).length, color: PROGRESS_COLORS[3] },
|
||||
];
|
||||
|
||||
const budgetByStatus = [
|
||||
{ name: "Borrador", value: projects.filter(p => p.status === "0").reduce((a, p) => a + p.budget, 0), color: COLORS.gray },
|
||||
{ name: "Abierto", value: projects.filter(p => p.status === "1").reduce((a, p) => a + p.budget, 0), color: COLORS.primary },
|
||||
{ name: "Cerrado", value: projects.filter(p => p.status === "2").reduce((a, p) => a + p.budget, 0), color: COLORS.success },
|
||||
];
|
||||
|
||||
// Top proyectos por presupuesto con colores únicos
|
||||
const topProjects = [...projects]
|
||||
.sort((a, b) => b.budget - a.budget)
|
||||
.slice(0, 5)
|
||||
.map((p, index) => ({
|
||||
name: p.name.length > 12 ? p.name.substring(0, 12) + "..." : p.name,
|
||||
value: p.progress,
|
||||
fill: getProjectColor(index),
|
||||
}));
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* KPIs principales */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<StatCard title="Total Proyectos" value={stats.total} subtitle={`${abiertos} activos, ${cerrados} cerrados`} icon={<LayoutGrid className="w-4 h-4 text-gray-400" />} highlight />
|
||||
<StatCard title="Presupuesto Total" value={formatCurrency(stats.totalBudget)} subtitle={`Media: ${formatCurrency(stats.avgBudget)}`} icon={<DollarSign className="w-4 h-4 text-green-500" />} />
|
||||
<StatCard title="Progreso Medio" value={`${stats.avgProgress}%`} subtitle={`${stats.budgetConsumed}% presupuesto usado`} icon={<TrendingUp className="w-4 h-4 text-blue-500" />} />
|
||||
<StatCard title="Clientes" value={stats.uniqueClients} subtitle={`${stats.avgDuration} días duración media`} icon={<Users className="w-4 h-4 text-indigo-500" />} />
|
||||
<StatCard
|
||||
title="Total Proyectos"
|
||||
value={stats.total}
|
||||
subtitle={`${abiertos} activos, ${cerrados} cerrados`}
|
||||
icon={<LayoutGrid className="w-4 h-4 text-gray-400" />}
|
||||
highlight
|
||||
/>
|
||||
<StatCard
|
||||
title="Presupuesto Total"
|
||||
value={formatCurrency(stats.totalBudget)}
|
||||
subtitle={`Gastado: ${formatCurrency(stats.totalSpent)}`}
|
||||
icon={<DollarSign className="w-4 h-4 text-green-500" />}
|
||||
/>
|
||||
<StatCard
|
||||
title="Progreso Medio"
|
||||
value={`${stats.avgProgress}%`}
|
||||
subtitle={`${stats.uniqueClients} clientes`}
|
||||
icon={<TrendingUp className="w-4 h-4 text-blue-500" />}
|
||||
/>
|
||||
<StatCard
|
||||
title="Requieren Atencion"
|
||||
value={overdueProjects.length}
|
||||
subtitle={overdueProjects.length > 0 ? "Proyectos retrasados" : "Todo en orden"}
|
||||
icon={<AlertCircle className={`w-4 h-4 ${overdueProjects.length > 0 ? "text-red-500" : "text-green-500"}`} />}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Alertas de fechas */}
|
||||
{/* Graficos: Estado + Progreso */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||
<StatusPieChart borradores={borradores} abiertos={abiertos} cerrados={cerrados} />
|
||||
<ProgressDonut
|
||||
data={progressData}
|
||||
title="Distribucion por Progreso"
|
||||
centerValue={`${stats.avgProgress}%`}
|
||||
centerLabel="Promedio"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Presupuesto por proyecto */}
|
||||
<BudgetBarChart
|
||||
projects={[...projects].sort((a, b) => b.budget - a.budget)}
|
||||
title="Presupuesto vs Gastado por Proyecto"
|
||||
maxItems={8}
|
||||
/>
|
||||
|
||||
{/* Alertas: retrasados y proximos a vencer */}
|
||||
{(overdueProjects.length > 0 || upcomingDeadlines.length > 0) && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{overdueProjects.length > 0 && (
|
||||
<Card className="border-red-200 bg-red-50 dark:bg-red-950/30 dark:border-red-900">
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-red-700 dark:text-red-400 flex items-center gap-2">
|
||||
<AlertCircle className="w-4 h-4" /> Proyectos Retrasados
|
||||
</p>
|
||||
<p className="text-3xl font-bold text-red-600 dark:text-red-400 mt-2">{overdueProjects.length}</p>
|
||||
<p className="text-xs text-red-600 dark:text-red-400">Fecha límite superada</p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
{overdueProjects.slice(0, 2).map(p => (
|
||||
<p key={p.id} className="text-xs text-red-500 dark:text-red-400 truncate max-w-32">{p.name}</p>
|
||||
))}
|
||||
</div>
|
||||
<Card className="border-red-200 dark:border-red-900">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-sm font-medium text-red-700 dark:text-red-400 flex items-center gap-2">
|
||||
<AlertCircle className="w-4 h-4" />
|
||||
Retrasados ({overdueProjects.length})
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-0">
|
||||
<div className="space-y-2">
|
||||
{overdueProjects.slice(0, 4).map(p => {
|
||||
const daysOverdue = Math.abs(getDaysRemaining(p.endDate));
|
||||
return (
|
||||
<div key={p.id} className="flex items-center justify-between gap-2 p-2 rounded-md bg-red-50 dark:bg-red-950/20">
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium truncate">{p.name}</p>
|
||||
<p className="text-xs text-muted-foreground">{p.progress}%</p>
|
||||
</div>
|
||||
<Badge variant="outline" className="border-red-300 dark:border-red-700 text-red-600 dark:text-red-400 text-xs shrink-0">
|
||||
-{daysOverdue}d
|
||||
</Badge>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
{upcomingDeadlines.length > 0 && (
|
||||
<Card className="border-amber-200 bg-amber-50 dark:bg-amber-950/30 dark:border-amber-900">
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-amber-700 dark:text-amber-400 flex items-center gap-2">
|
||||
<Clock className="w-4 h-4" /> Próximos a Vencer
|
||||
</p>
|
||||
<p className="text-3xl font-bold text-amber-600 dark:text-amber-400 mt-2">{upcomingDeadlines.length}</p>
|
||||
<p className="text-xs text-amber-600 dark:text-amber-400">En los próximos 14 días</p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
{upcomingDeadlines.slice(0, 2).map(p => (
|
||||
<p key={p.id} className="text-xs text-amber-600 dark:text-amber-400 truncate max-w-32">
|
||||
{p.name} ({getDaysRemaining(p.endDate)}d)
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
<Card className="border-amber-200 dark:border-amber-900">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-sm font-medium text-amber-700 dark:text-amber-400 flex items-center gap-2">
|
||||
<Clock className="w-4 h-4" />
|
||||
Proximos a vencer ({upcomingDeadlines.length})
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-0">
|
||||
<div className="space-y-2">
|
||||
{upcomingDeadlines.slice(0, 4).map(p => {
|
||||
const days = getDaysRemaining(p.endDate);
|
||||
return (
|
||||
<div key={p.id} className="flex items-center justify-between gap-2 p-2 rounded-md bg-amber-50 dark:bg-amber-950/20">
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium truncate">{p.name}</p>
|
||||
<p className="text-xs text-muted-foreground">{p.progress}%</p>
|
||||
</div>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={`text-xs shrink-0 ${
|
||||
days <= 3
|
||||
? "border-red-300 dark:border-red-700 text-red-600 dark:text-red-400"
|
||||
: "border-amber-300 dark:border-amber-700 text-amber-600 dark:text-amber-400"
|
||||
}`}
|
||||
>
|
||||
{days}d
|
||||
</Badge>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Gráficos principales */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-4">
|
||||
<StatusPieChart borradores={borradores} abiertos={abiertos} cerrados={cerrados} />
|
||||
<ProgressDonut data={progressData} title="Distribución por Progreso" centerValue={`${stats.avgProgress}%`} centerLabel="Promedio" />
|
||||
<RadialMetric value={stats.budgetConsumed} title="Consumo de Presupuesto" subtitle={formatCurrency(stats.totalSpent)} color={stats.budgetConsumed > 80 ? COLORS.danger : COLORS.primary} />
|
||||
</div>
|
||||
|
||||
{/* Comparativas */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||
<ComparisonBarChart data={budgetByStatus} title="Presupuesto por Estado" formatter={(v) => formatCurrency(v)} />
|
||||
<MultiRadialChart data={topProjects} title="Progreso de Top 5 Proyectos" valueLabel="%" />
|
||||
</div>
|
||||
|
||||
{/* Timeline de fechas - Próximos vencimientos y retrasados */}
|
||||
{(upcomingDeadlines.length > 0 || overdueProjects.length > 0) && (
|
||||
<Card className="border">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground flex items-center gap-2">
|
||||
<Calendar className="w-4 h-4" /> Timeline de Proyectos
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
{/* Proyectos retrasados */}
|
||||
{overdueProjects.length > 0 && (
|
||||
<div>
|
||||
<h4 className="text-xs font-semibold text-red-600 dark:text-red-400 uppercase tracking-wide mb-2 flex items-center gap-1">
|
||||
<AlertCircle className="w-3 h-3" /> Retrasados ({overdueProjects.length})
|
||||
</h4>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-2">
|
||||
{overdueProjects.slice(0, 6).map((p, index) => {
|
||||
const daysOverdue = Math.abs(getDaysRemaining(p.endDate));
|
||||
return (
|
||||
<div key={p.id} className="flex items-center gap-3 p-3 bg-red-50 dark:bg-red-950/30 border border-red-100 dark:border-red-900 rounded-lg">
|
||||
<div className="w-2 h-2 rounded-full bg-red-500" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-foreground truncate">{p.name}</p>
|
||||
<p className="text-xs text-muted-foreground">{p.progress}% completado</p>
|
||||
</div>
|
||||
<Badge variant="outline" className="border-red-300 dark:border-red-700 text-red-600 dark:text-red-400 text-xs">
|
||||
-{daysOverdue}d
|
||||
</Badge>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Próximos vencimientos */}
|
||||
{upcomingDeadlines.length > 0 && (
|
||||
<div>
|
||||
<h4 className="text-xs font-semibold text-amber-600 dark:text-amber-400 uppercase tracking-wide mb-2 flex items-center gap-1">
|
||||
<Clock className="w-3 h-3" /> Próximos 14 días ({upcomingDeadlines.length})
|
||||
</h4>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-2">
|
||||
{upcomingDeadlines.slice(0, 6).map((p, index) => {
|
||||
const days = getDaysRemaining(p.endDate);
|
||||
return (
|
||||
<div key={p.id} className="flex items-center gap-3 p-3 bg-muted/50 border rounded-lg">
|
||||
<div className="w-2 h-2 rounded-full" style={{ backgroundColor: getProjectColor(index) }} />
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-foreground truncate">{p.name}</p>
|
||||
<p className="text-xs text-muted-foreground">{p.progress}% completado</p>
|
||||
</div>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={`text-xs ${
|
||||
days <= 3 ? 'border-red-300 dark:border-red-700 text-red-600 dark:text-red-400' :
|
||||
days <= 7 ? 'border-amber-300 dark:border-amber-700 text-amber-600 dark:text-amber-400' :
|
||||
'border text-muted-foreground'
|
||||
}`}
|
||||
>
|
||||
{days}d
|
||||
</Badge>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Resumen de fechas */}
|
||||
<div className="pt-3 border-t">
|
||||
<div className="flex flex-wrap gap-4 text-xs text-muted-foreground">
|
||||
<span className="flex items-center gap-1">
|
||||
<div className="w-2 h-2 rounded-full bg-red-500" />
|
||||
Retrasados: {overdueProjects.length}
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<div className="w-2 h-2 rounded-full bg-amber-500" />
|
||||
Vencen en 7 días: {upcomingDeadlines.filter(p => getDaysRemaining(p.endDate) <= 7).length}
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<div className="w-2 h-2 rounded-full bg-green-500" />
|
||||
En tiempo: {projects.filter(p => p.status === "1" && getDaysRemaining(p.endDate) > 14).length}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Presupuesto detallado */}
|
||||
<BudgetBarChart projects={[...projects].sort((a, b) => b.budget - a.budget)} title="Presupuesto vs Gastado por Proyecto" maxItems={6} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -708,16 +561,6 @@ function ActiveProjectsStats({ projects }: { projects: Project[] }) {
|
|||
{ name: "Casi listo (75-99%)", value: activeProjects.filter(p => p.progress >= 75).length, color: PROGRESS_COLORS[3] },
|
||||
];
|
||||
|
||||
// Top proyectos con colores únicos
|
||||
const topByProgress = activeProjects
|
||||
.sort((a, b) => b.progress - a.progress)
|
||||
.slice(0, 5)
|
||||
.map((p, index) => ({
|
||||
name: p.name.length > 12 ? p.name.substring(0, 12) + "..." : p.name,
|
||||
value: p.progress,
|
||||
fill: getProjectColor(index),
|
||||
}));
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* KPIs */}
|
||||
|
|
@ -725,56 +568,13 @@ function ActiveProjectsStats({ projects }: { projects: Project[] }) {
|
|||
<StatCard title="Proyectos Activos" value={stats.total} subtitle="En progreso" icon={<TrendingUp className="w-4 h-4 text-blue-500" />} highlight />
|
||||
<StatCard title="Presupuesto Activo" value={formatCurrency(stats.totalBudget)} subtitle={`${stats.budgetConsumed}% consumido`} icon={<DollarSign className="w-4 h-4 text-green-500" />} />
|
||||
<StatCard title="Progreso Medio" value={`${stats.avgProgress}%`} subtitle={`${nearCompletion} casi listos`} icon={<Target className="w-4 h-4 text-purple-500" />} />
|
||||
<StatCard title="Clientes Activos" value={stats.uniqueClients} subtitle="Con proyectos en curso" icon={<Users className="w-4 h-4 text-indigo-500" />} />
|
||||
</div>
|
||||
|
||||
{/* Alertas */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<Card className={`${overdueProjects.length > 0 ? 'border-red-200 dark:border-red-900 bg-red-50 dark:bg-red-950/30' : 'border bg-muted/30'}`}>
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className={`text-sm font-medium flex items-center gap-2 ${overdueProjects.length > 0 ? 'text-red-700 dark:text-red-400' : 'text-muted-foreground'}`}>
|
||||
<AlertCircle className="w-4 h-4" /> Retrasados
|
||||
</p>
|
||||
<p className={`text-3xl font-bold mt-2 ${overdueProjects.length > 0 ? 'text-red-600 dark:text-red-400' : 'text-muted-foreground/50'}`}>{overdueProjects.length}</p>
|
||||
<p className={`text-xs ${overdueProjects.length > 0 ? 'text-red-600 dark:text-red-400' : 'text-muted-foreground/50'}`}>Fecha superada</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className={`${needsAttention > 0 ? 'border-orange-200 dark:border-orange-900 bg-orange-50 dark:bg-orange-950/30' : 'border bg-muted/30'}`}>
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className={`text-sm font-medium flex items-center gap-2 ${needsAttention > 0 ? 'text-orange-700 dark:text-orange-400' : 'text-muted-foreground'}`}>
|
||||
<Clock className="w-4 h-4" /> Requieren Atención
|
||||
</p>
|
||||
<p className={`text-3xl font-bold mt-2 ${needsAttention > 0 ? 'text-orange-600 dark:text-orange-400' : 'text-muted-foreground/50'}`}>{needsAttention}</p>
|
||||
<p className={`text-xs ${needsAttention > 0 ? 'text-orange-600 dark:text-orange-400' : 'text-muted-foreground/50'}`}>Menos del 25%</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="border-green-200 dark:border-green-900 bg-green-50 dark:bg-green-950/30">
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-green-700 dark:text-green-400 flex items-center gap-2">
|
||||
<CheckCircle2 className="w-4 h-4" /> Próximos a Completar
|
||||
</p>
|
||||
<p className="text-3xl font-bold text-green-600 dark:text-green-400 mt-2">{nearCompletion}</p>
|
||||
<p className="text-xs text-green-600 dark:text-green-400">75% o más</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<StatCard title="Requieren Atención" value={overdueProjects.length + needsAttention} subtitle={`${overdueProjects.length} retrasados, ${needsAttention} < 25%`} icon={<AlertCircle className="w-4 h-4 text-red-500" />} />
|
||||
</div>
|
||||
|
||||
{/* Gráficos */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||
<ProgressDonut data={progressData} title="Estado de Avance" centerValue={stats.total} centerLabel="Activos" />
|
||||
<MultiRadialChart data={topByProgress} title="Top 5 Proyectos por Progreso" valueLabel="%" />
|
||||
<BudgetBarChart projects={activeProjects.sort((a, b) => b.budget - a.budget)} title="Presupuesto vs Gastado" maxItems={6} />
|
||||
</div>
|
||||
|
||||
{/* Próximos vencimientos */}
|
||||
|
|
@ -806,9 +606,6 @@ function ActiveProjectsStats({ projects }: { projects: Project[] }) {
|
|||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Presupuesto */}
|
||||
<BudgetBarChart projects={activeProjects.sort((a, b) => b.budget - a.budget)} title="Presupuesto vs Gastado por Proyecto" maxItems={6} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -838,9 +635,26 @@ function ClosedProjectsStats({ projects }: { projects: Project[] }) {
|
|||
{ name: "Sobre presupuesto", value: overBudget, color: COLORS.danger },
|
||||
].filter(d => d.value > 0);
|
||||
|
||||
const completionData = [
|
||||
{ name: "100% completado", value: fullyCompleted, color: COLORS.success },
|
||||
{ name: "Cerrado parcial", value: closedProjects.length - fullyCompleted, color: COLORS.warning },
|
||||
// Distribución de duración de los proyectos cerrados
|
||||
const durationRanges = closedProjects.reduce(
|
||||
(acc, p) => {
|
||||
const start = new Date(p.startDate);
|
||||
const end = new Date(p.endDate);
|
||||
const months = (end.getTime() - start.getTime()) / (1000 * 60 * 60 * 24 * 30.44);
|
||||
if (months < 3) acc.short++;
|
||||
else if (months < 6) acc.medium++;
|
||||
else if (months < 12) acc.long++;
|
||||
else acc.veryLong++;
|
||||
return acc;
|
||||
},
|
||||
{ short: 0, medium: 0, long: 0, veryLong: 0 }
|
||||
);
|
||||
|
||||
const durationData = [
|
||||
{ name: "< 3 meses", value: durationRanges.short, color: COLORS.success },
|
||||
{ name: "3-6 meses", value: durationRanges.medium, color: COLORS.primary },
|
||||
{ name: "6-12 meses", value: durationRanges.long, color: COLORS.warning },
|
||||
{ name: "> 12 meses", value: durationRanges.veryLong, color: COLORS.danger },
|
||||
].filter(d => d.value > 0);
|
||||
|
||||
// Eficiencia por proyecto con colores únicos
|
||||
|
|
@ -898,10 +712,9 @@ function ClosedProjectsStats({ projects }: { projects: Project[] }) {
|
|||
</div>
|
||||
|
||||
{/* Gráficos */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-4">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||
<ProgressDonut data={budgetComplianceData} title="Cumplimiento Presupuestario" centerValue={`${stats.total > 0 ? Math.round((withinBudget / stats.total) * 100) : 0}%`} centerLabel="Éxito" />
|
||||
<ProgressDonut data={completionData} title="Estado de Completitud" centerValue={fullyCompleted} centerLabel="Completos" />
|
||||
<RadialMetric value={Math.min(100, 100 - stats.budgetConsumed)} title="Eficiencia Global" subtitle="ahorro promedio" color={COLORS.success} />
|
||||
<ProgressDonut data={durationData} title="Duración de Proyectos" centerValue={closedProjects.length} centerLabel="Proyectos" />
|
||||
</div>
|
||||
|
||||
{/* Eficiencia por proyecto */}
|
||||
|
|
@ -937,56 +750,21 @@ function CompletedProjectsStats({ projects }: { projects: Project[] }) {
|
|||
{ name: "Cerrado", value: completedProjects.filter(p => p.status === "2").length, color: COLORS.success },
|
||||
].filter(d => d.value > 0);
|
||||
|
||||
// Proyectos completados con colores únicos para el gráfico
|
||||
const budgetData = completedProjects
|
||||
.sort((a, b) => b.budget - a.budget)
|
||||
.slice(0, 6)
|
||||
.map((p, index) => ({
|
||||
name: p.name.length > 15 ? p.name.substring(0, 15) + "..." : p.name,
|
||||
value: p.budget,
|
||||
color: getProjectColor(index),
|
||||
}));
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Banner de éxito */}
|
||||
<Card className="border-green-300 bg-gradient-to-r from-green-500 to-emerald-600 text-white">
|
||||
<CardContent className="py-8">
|
||||
<div className="flex items-center justify-center gap-8 md:gap-16 flex-wrap">
|
||||
<div className="text-center">
|
||||
<p className="text-5xl font-bold">{stats.total}</p>
|
||||
<p className="text-sm text-green-100 mt-2">Proyectos al 100%</p>
|
||||
</div>
|
||||
<div className="h-16 w-px bg-green-400 hidden md:block" />
|
||||
<div className="text-center">
|
||||
<p className="text-5xl font-bold">{successRate}%</p>
|
||||
<p className="text-sm text-green-100 mt-2">Tasa de éxito</p>
|
||||
</div>
|
||||
<div className="h-16 w-px bg-green-400 hidden md:block" />
|
||||
<div className="text-center">
|
||||
<p className="text-5xl font-bold">{formatCurrency(savings)}</p>
|
||||
<p className="text-sm text-green-100 mt-2">Ahorro total</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* KPIs */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<StatCard title="Presupuesto Total" value={formatCurrency(stats.totalBudget)} subtitle="Gestionado" icon={<DollarSign className="w-4 h-4 text-green-500" />} />
|
||||
<StatCard title="Total Gastado" value={formatCurrency(stats.totalSpent)} subtitle={`${stats.budgetConsumed}% usado`} icon={<PiggyBank className="w-4 h-4 text-blue-500" />} />
|
||||
<StatCard title="Dentro Presupuesto" value={withinBudget} subtitle={`de ${stats.total} completados`} icon={<CheckCircle2 className="w-4 h-4 text-green-500" />} />
|
||||
<StatCard title="Clientes Satisfechos" value={stats.uniqueClients} subtitle="Proyectos entregados" icon={<Users className="w-4 h-4 text-indigo-500" />} />
|
||||
<StatCard title="Completados" value={stats.total} subtitle="Proyectos al 100%" icon={<CheckCircle2 className="w-4 h-4 text-green-500" />} highlight />
|
||||
<StatCard title="Presupuesto Total" value={formatCurrency(stats.totalBudget)} subtitle={`Gastado: ${formatCurrency(stats.totalSpent)}`} icon={<DollarSign className="w-4 h-4 text-green-500" />} />
|
||||
<StatCard title="Tasa de Éxito" value={`${successRate}%`} subtitle={`${withinBudget} dentro de presupuesto`} icon={<Target className="w-4 h-4 text-emerald-500" />} />
|
||||
<StatCard title="Ahorro Total" value={formatCurrency(savings)} subtitle={`${100 - stats.budgetConsumed}% no usado`} icon={<PiggyBank className="w-4 h-4 text-blue-500" />} />
|
||||
</div>
|
||||
|
||||
{/* Gráficos */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||
<ProgressDonut data={statusData} title="Estado Administrativo" centerValue={stats.total} centerLabel="Completados" />
|
||||
<ComparisonBarChart data={budgetData} title="Presupuesto por Proyecto Completado" formatter={(v) => formatCurrency(v)} />
|
||||
<BudgetBarChart projects={completedProjects.sort((a, b) => b.budget - a.budget)} title="Presupuesto vs Gastado" maxItems={6} />
|
||||
</div>
|
||||
|
||||
{/* Detalle de presupuesto */}
|
||||
<BudgetBarChart projects={completedProjects.sort((a, b) => b.budget - a.budget)} title="Detalle: Presupuesto vs Gastado" maxItems={6} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import { useState } from "react";
|
|||
import { ArrowLeft, MoreHorizontal, Pencil, Trash2, Clock, Copy, ExternalLink } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
|
|
@ -82,7 +83,9 @@ export function TaskHeader({ task, project, onTaskUpdated, onTaskDeleted }: Task
|
|||
}
|
||||
} catch (error) {
|
||||
console.error("Error deleting task:", error);
|
||||
// TODO: Mostrar toast de error
|
||||
toast.error("Error al eliminar la tarea", {
|
||||
description: "No se pudo eliminar la tarea. Inténtalo de nuevo.",
|
||||
});
|
||||
} finally {
|
||||
setIsDeleting(false);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,12 +1,13 @@
|
|||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { Loader2, X, Link2 } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
|
|
@ -34,7 +35,7 @@ import {
|
|||
formDataToCreateTask,
|
||||
formDataToUpdateTask,
|
||||
} from "@/types/task";
|
||||
import { createTask, updateTask } from "@/lib/tasksService";
|
||||
import { createTask, updateTask, setTaskDependencies } from "@/lib/tasksService";
|
||||
|
||||
interface TaskFormSheetProps {
|
||||
open: boolean;
|
||||
|
|
@ -42,6 +43,7 @@ interface TaskFormSheetProps {
|
|||
mode: "create" | "edit";
|
||||
projectId: number;
|
||||
task?: Task | null;
|
||||
availableTasks?: Task[]; // Tareas del mismo proyecto para seleccionar como dependencias
|
||||
onSuccess?: (task: Task) => void;
|
||||
}
|
||||
|
||||
|
|
@ -57,6 +59,7 @@ const defaultFormData: TaskFormData = {
|
|||
startDate: "",
|
||||
endDate: "",
|
||||
budget: 0,
|
||||
dependencies: [],
|
||||
};
|
||||
|
||||
export function TaskFormSheet({
|
||||
|
|
@ -65,6 +68,7 @@ export function TaskFormSheet({
|
|||
mode,
|
||||
projectId,
|
||||
task,
|
||||
availableTasks = [],
|
||||
onSuccess,
|
||||
}: TaskFormSheetProps) {
|
||||
const [formData, setFormData] = useState<TaskFormData>({
|
||||
|
|
@ -74,6 +78,11 @@ export function TaskFormSheet({
|
|||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Tareas seleccionables como dependencia (excluir la tarea actual en modo edición)
|
||||
const selectableTasks = availableTasks.filter(
|
||||
(t) => !(mode === "edit" && task && t.id === task.id)
|
||||
);
|
||||
|
||||
// Initialize form data when task changes (for edit mode)
|
||||
useEffect(() => {
|
||||
if (mode === "edit" && task) {
|
||||
|
|
@ -123,6 +132,10 @@ export function TaskFormSheet({
|
|||
result = await updateTask(task.id, updateData);
|
||||
}
|
||||
|
||||
// Guardar dependencias
|
||||
setTaskDependencies(result.id, formData.dependencies);
|
||||
result.dependencies = formData.dependencies;
|
||||
|
||||
onSuccess?.(result);
|
||||
onOpenChange(false);
|
||||
} catch (err) {
|
||||
|
|
@ -296,6 +309,75 @@ export function TaskFormSheet({
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{/* Dependencias (tareas predecesoras) */}
|
||||
{selectableTasks.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<Label className="flex items-center gap-2">
|
||||
<Link2 className="h-4 w-4" />
|
||||
Depende de (predecesoras)
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Selecciona las tareas que deben completarse antes de esta.
|
||||
</p>
|
||||
<Select
|
||||
value=""
|
||||
onValueChange={(value) => {
|
||||
const taskId = parseInt(value);
|
||||
if (!formData.dependencies.includes(taskId)) {
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
dependencies: [...prev.dependencies, taskId],
|
||||
}));
|
||||
}
|
||||
}}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Añadir tarea predecesora..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{selectableTasks
|
||||
.filter(t => !formData.dependencies.includes(t.id))
|
||||
.map((t) => (
|
||||
<SelectItem key={t.id} value={String(t.id)}>
|
||||
{t.title} ({t.ref})
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{/* Lista de dependencias seleccionadas */}
|
||||
{formData.dependencies.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2 mt-2">
|
||||
{formData.dependencies.map((depId) => {
|
||||
const depTask = availableTasks.find(t => t.id === depId);
|
||||
return (
|
||||
<Badge
|
||||
key={depId}
|
||||
variant="secondary"
|
||||
className="flex items-center gap-1 pl-2 pr-1"
|
||||
>
|
||||
<span className="text-xs truncate max-w-[150px]">
|
||||
{depTask?.title || `Tarea #${depId}`}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
dependencies: prev.dependencies.filter(id => id !== depId),
|
||||
}));
|
||||
}}
|
||||
className="ml-1 rounded-full p-0.5 hover:bg-muted-foreground/20 transition-colors"
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
</Badge>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error message */}
|
||||
{error && (
|
||||
<div className="p-3 text-sm text-red-600 bg-red-50 dark:bg-red-950/50 dark:text-red-400 rounded-md">
|
||||
|
|
|
|||
|
|
@ -8,6 +8,99 @@ import {
|
|||
UpdateTaskData
|
||||
} from "@/types/task";
|
||||
|
||||
// ==================== GESTIÓN DE DEPENDENCIAS ====================
|
||||
// Las dependencias entre tareas se almacenan en localStorage ya que
|
||||
// la API REST de Dolibarr no expone la tabla llx_projet_task_dependency.
|
||||
// Formato: { [taskId]: number[] } donde el array contiene IDs de tareas predecesoras.
|
||||
|
||||
const DEPENDENCIES_STORAGE_KEY = "task_dependencies";
|
||||
|
||||
/**
|
||||
* Obtener todas las dependencias almacenadas
|
||||
*/
|
||||
function getAllDependencies(): Record<string, number[]> {
|
||||
if (typeof window === "undefined") return {};
|
||||
try {
|
||||
const stored = localStorage.getItem(DEPENDENCIES_STORAGE_KEY);
|
||||
return stored ? JSON.parse(stored) : {};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Guardar todas las dependencias
|
||||
*/
|
||||
function saveAllDependencies(deps: Record<string, number[]>): void {
|
||||
if (typeof window === "undefined") return;
|
||||
localStorage.setItem(DEPENDENCIES_STORAGE_KEY, JSON.stringify(deps));
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtener dependencias (predecesoras) de una tarea específica
|
||||
*/
|
||||
export function getTaskDependencies(taskId: number): number[] {
|
||||
const all = getAllDependencies();
|
||||
return all[String(taskId)] || [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Establecer dependencias (predecesoras) de una tarea
|
||||
*/
|
||||
export function setTaskDependencies(taskId: number, dependencies: number[]): void {
|
||||
const all = getAllDependencies();
|
||||
if (dependencies.length === 0) {
|
||||
delete all[String(taskId)];
|
||||
} else {
|
||||
all[String(taskId)] = dependencies;
|
||||
}
|
||||
saveAllDependencies(all);
|
||||
}
|
||||
|
||||
/**
|
||||
* Eliminar todas las dependencias de una tarea (como predecesora y como dependiente)
|
||||
*/
|
||||
export function removeTaskDependencies(taskId: number): void {
|
||||
const all = getAllDependencies();
|
||||
// Eliminar la entrada de esta tarea
|
||||
delete all[String(taskId)];
|
||||
// Eliminar esta tarea de las dependencias de otras tareas
|
||||
for (const key of Object.keys(all)) {
|
||||
all[key] = all[key].filter(id => id !== taskId);
|
||||
if (all[key].length === 0) delete all[key];
|
||||
}
|
||||
saveAllDependencies(all);
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtener las dependencias de todas las tareas de un proyecto
|
||||
* Devuelve un Map de taskId -> array de IDs de predecesoras
|
||||
*/
|
||||
export function getProjectTaskDependencies(taskIds: number[]): Map<number, number[]> {
|
||||
const all = getAllDependencies();
|
||||
const result = new Map<number, number[]>();
|
||||
for (const taskId of taskIds) {
|
||||
const deps = all[String(taskId)];
|
||||
if (deps && deps.length > 0) {
|
||||
// Solo incluir dependencias que pertenecen al mismo proyecto
|
||||
result.set(taskId, deps.filter(depId => taskIds.includes(depId)));
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Enriquecer tareas con sus dependencias
|
||||
*/
|
||||
function enrichTasksWithDependencies(tasks: Task[]): Task[] {
|
||||
const taskIds = tasks.map(t => t.id);
|
||||
const depsMap = getProjectTaskDependencies(taskIds);
|
||||
return tasks.map(task => ({
|
||||
...task,
|
||||
dependencies: depsMap.get(task.id) || [],
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtener todas las tareas de un proyecto específico
|
||||
*
|
||||
|
|
@ -29,8 +122,9 @@ export async function getTasksByProjectId(projectId: number): Promise<Task[]> {
|
|||
task => String(task.fk_project) === String(projectId)
|
||||
);
|
||||
|
||||
// Mapear las tareas al formato de la UI
|
||||
return projectTasks.map(mapDolibarrTask);
|
||||
// Mapear las tareas al formato de la UI y enriquecer con dependencias
|
||||
const mappedTasks = projectTasks.map(mapDolibarrTask);
|
||||
return enrichTasksWithDependencies(mappedTasks);
|
||||
} catch (error) {
|
||||
console.error('Error fetching tasks for project:', projectId, error);
|
||||
// Si el error es 404 (no hay tareas), devolver array vacío
|
||||
|
|
@ -53,7 +147,8 @@ export async function getAllTasks(): Promise<Task[]> {
|
|||
return [];
|
||||
}
|
||||
|
||||
return dolibarrTasks.map(mapDolibarrTask);
|
||||
const mappedTasks = dolibarrTasks.map(mapDolibarrTask);
|
||||
return enrichTasksWithDependencies(mappedTasks);
|
||||
} catch (error) {
|
||||
console.error('Error fetching all tasks:', error);
|
||||
throw error;
|
||||
|
|
@ -67,7 +162,9 @@ export async function getAllTasks(): Promise<Task[]> {
|
|||
export async function getTaskById(taskId: number): Promise<Task | null> {
|
||||
try {
|
||||
const dolibarrTask: DolibarrTask = await dolibarrFetch(`tasks/${taskId}`);
|
||||
return mapDolibarrTask(dolibarrTask);
|
||||
const task = mapDolibarrTask(dolibarrTask);
|
||||
task.dependencies = getTaskDependencies(taskId);
|
||||
return task;
|
||||
} catch (error) {
|
||||
console.error('Error fetching task:', taskId, error);
|
||||
return null;
|
||||
|
|
@ -197,6 +294,8 @@ export async function deleteTask(taskId: number): Promise<void> {
|
|||
await dolibarrFetch(`tasks/${taskId}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
// Limpiar dependencias asociadas a esta tarea
|
||||
removeTaskDependencies(taskId);
|
||||
} catch (error) {
|
||||
console.error('Error deleting task:', taskId, error);
|
||||
throw error;
|
||||
|
|
|
|||
|
|
@ -30,6 +30,7 @@
|
|||
"react": "19.2.0",
|
||||
"react-dom": "19.2.0",
|
||||
"recharts": "^3.7.0",
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^3.4.0",
|
||||
"tailwindcss-animate": "^1.0.7"
|
||||
},
|
||||
|
|
@ -8595,6 +8596,16 @@
|
|||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/sonner": {
|
||||
"version": "2.0.7",
|
||||
"resolved": "https://registry.npmjs.org/sonner/-/sonner-2.0.7.tgz",
|
||||
"integrity": "sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"react": "^18.0.0 || ^19.0.0 || ^19.0.0-rc",
|
||||
"react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-rc"
|
||||
}
|
||||
},
|
||||
"node_modules/source-map-js": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
||||
|
|
|
|||
|
|
@ -35,6 +35,7 @@
|
|||
"react": "19.2.0",
|
||||
"react-dom": "19.2.0",
|
||||
"recharts": "^3.7.0",
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^3.4.0",
|
||||
"tailwindcss-animate": "^1.0.7"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -77,6 +77,7 @@ export interface Task {
|
|||
updatedAt: string;
|
||||
createdBy: number;
|
||||
order: number;
|
||||
dependencies: number[]; // IDs de tareas que deben completarse antes de esta
|
||||
}
|
||||
|
||||
// Configuración de estados de tarea
|
||||
|
|
@ -194,6 +195,7 @@ export function mapDolibarrTask(dolibarr: DolibarrTask): Task {
|
|||
? parseInt(dolibarr.fk_user_creat)
|
||||
: (dolibarr.fk_user_creat || 0),
|
||||
order: toNumber(dolibarr.rang),
|
||||
dependencies: [], // Se cargan por separado desde el servicio de dependencias
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -264,6 +266,7 @@ export interface TaskFormData {
|
|||
startDate: string; // YYYY-MM-DD
|
||||
endDate: string; // YYYY-MM-DD
|
||||
budget: number;
|
||||
dependencies: number[]; // IDs de tareas predecesoras
|
||||
}
|
||||
|
||||
// Helper para convertir fecha string a timestamp (segundos)
|
||||
|
|
@ -355,5 +358,6 @@ export function taskToFormData(task: Task): TaskFormData {
|
|||
startDate: task.startDate || task.plannedStartDate || '',
|
||||
endDate: task.endDate || task.plannedEndDate || '',
|
||||
budget: task.budget,
|
||||
dependencies: task.dependencies || [],
|
||||
};
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue