921 lines
34 KiB
TypeScript
921 lines
34 KiB
TypeScript
"use client";
|
|
|
|
import { useState, useEffect } from "react";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
|
import { Textarea } from "@/components/ui/textarea";
|
|
import {
|
|
FileIcon,
|
|
Calendar,
|
|
MapPin,
|
|
Users,
|
|
Clock,
|
|
ThumbsUp,
|
|
Languages,
|
|
Trash2,
|
|
Sparkles,
|
|
Save,
|
|
Loader2,
|
|
FileText,
|
|
Lock,
|
|
CheckCircle
|
|
} from "lucide-react";
|
|
import { useToast } from "@/components/ui/use-toast";
|
|
import { useParams, useRouter } from "next/navigation";
|
|
|
|
// Define types for mission details
|
|
interface User {
|
|
id: string;
|
|
email: string;
|
|
}
|
|
|
|
interface Attachment {
|
|
id: string;
|
|
filename: string;
|
|
filePath: string;
|
|
fileType: string;
|
|
fileSize: number;
|
|
publicUrl: string;
|
|
createdAt: string;
|
|
}
|
|
|
|
interface Mission {
|
|
id: string;
|
|
name: string;
|
|
logo?: string | null;
|
|
logoUrl?: string | null;
|
|
oddScope: string[];
|
|
niveau: string;
|
|
missionType: string;
|
|
projection: string;
|
|
intention?: string;
|
|
donneurDOrdre?: string;
|
|
participation?: string;
|
|
services?: string[];
|
|
profils?: string[];
|
|
attachments?: Attachment[];
|
|
actionPlan?: string | null;
|
|
actionPlanGeneratedAt?: string | null;
|
|
isClosed?: boolean;
|
|
closedAt?: string | null;
|
|
createdAt: string;
|
|
creator: User;
|
|
missionUsers: any[];
|
|
}
|
|
|
|
export default function MissionDetailPage() {
|
|
const [mission, setMission] = useState<Mission | null>(null);
|
|
const [loading, setLoading] = useState(true);
|
|
const [deleting, setDeleting] = useState(false);
|
|
const [closing, setClosing] = useState(false);
|
|
const [generatingPlan, setGeneratingPlan] = useState(false);
|
|
const [savingPlan, setSavingPlan] = useState(false);
|
|
const [editedPlan, setEditedPlan] = useState<string>("");
|
|
const [isPlanModified, setIsPlanModified] = useState(false);
|
|
const [activeTab, setActiveTab] = useState("general");
|
|
const { toast } = useToast();
|
|
const params = useParams();
|
|
const router = useRouter();
|
|
const missionId = params.missionId as string;
|
|
|
|
// Fetch mission details
|
|
useEffect(() => {
|
|
const fetchMissionDetails = async () => {
|
|
try {
|
|
setLoading(true);
|
|
const response = await fetch(`/api/missions/${missionId}`);
|
|
if (!response.ok) {
|
|
throw new Error('Failed to fetch mission details');
|
|
}
|
|
const data = await response.json();
|
|
console.log("Mission details:", data);
|
|
setMission(data);
|
|
setEditedPlan(data.actionPlan || "");
|
|
} catch (error) {
|
|
console.error('Error fetching mission details:', error);
|
|
toast({
|
|
title: "Erreur",
|
|
description: "Impossible de charger les détails de la mission",
|
|
variant: "destructive",
|
|
});
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
if (missionId) {
|
|
fetchMissionDetails();
|
|
}
|
|
}, [missionId, toast]);
|
|
|
|
// Track if plan has been modified
|
|
useEffect(() => {
|
|
if (mission) {
|
|
setIsPlanModified(editedPlan !== (mission.actionPlan || ""));
|
|
}
|
|
}, [editedPlan, mission]);
|
|
|
|
// Helper function to format date
|
|
const formatDate = (dateString: string) => {
|
|
const date = new Date(dateString);
|
|
return date.toLocaleDateString('fr-FR', {
|
|
day: '2-digit',
|
|
month: 'long',
|
|
year: 'numeric'
|
|
});
|
|
};
|
|
|
|
// Helper functions to get labels
|
|
const getMissionTypeLabel = (type: string) => {
|
|
switch(type) {
|
|
case 'remote': return 'À distance';
|
|
case 'onsite': return 'Sur site';
|
|
case 'hybrid': return 'Hybride';
|
|
default: return type;
|
|
}
|
|
};
|
|
|
|
const getDurationLabel = (projection: string) => {
|
|
switch(projection) {
|
|
case 'short': return '< 1 mois';
|
|
case 'medium': return '1-3 mois';
|
|
case 'long': return '> 3 mois';
|
|
default: return projection;
|
|
}
|
|
};
|
|
|
|
const getNiveauLabel = (niveau: string) => {
|
|
switch(niveau) {
|
|
case 'a': return 'Apprentissage';
|
|
case 'b': return 'Basique';
|
|
case 'c': return 'Complexe';
|
|
case 's': return 'Spécial';
|
|
default: return niveau;
|
|
}
|
|
};
|
|
|
|
const getDonneurDOrdreLabel = (donneurDOrdre: string) => {
|
|
switch(donneurDOrdre) {
|
|
case 'individual': return 'Individu';
|
|
case 'group': return 'ONG';
|
|
case 'organization': return 'Start-ups';
|
|
default: return donneurDOrdre;
|
|
}
|
|
};
|
|
|
|
const getParticipationLabel = (participation: string) => {
|
|
switch(participation) {
|
|
case 'ouvert': return 'Ouverte';
|
|
case 'volontaire': return 'Ouverte'; // Legacy support
|
|
case 'cooptation': return 'Cooptation';
|
|
default: return participation;
|
|
}
|
|
};
|
|
|
|
// Function to get odd info
|
|
const getODDInfo = (oddScope: string[]) => {
|
|
const oddCode = oddScope && oddScope.length > 0
|
|
? oddScope[0]
|
|
: null;
|
|
|
|
const oddNumber = oddCode ? oddCode.replace('odd-', '') : null;
|
|
|
|
return {
|
|
number: oddNumber,
|
|
label: oddNumber ? `ODD ${oddNumber}` : "Non catégorisé",
|
|
iconPath: oddNumber ? `/F SDG Icons 2019 WEB/F-WEB-Goal-${oddNumber.padStart(2, '0')}.png` : ""
|
|
};
|
|
};
|
|
|
|
// Handle delete mission
|
|
const handleDeleteMission = async () => {
|
|
if (!confirm("Êtes-vous sûr de vouloir supprimer cette mission ? Cette action est irréversible.")) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
setDeleting(true);
|
|
const response = await fetch(`/api/missions/${missionId}`, {
|
|
method: 'DELETE',
|
|
});
|
|
|
|
if (!response.ok) {
|
|
throw new Error('Failed to delete mission');
|
|
}
|
|
|
|
toast({
|
|
title: "Mission supprimée",
|
|
description: "La mission a été supprimée avec succès",
|
|
});
|
|
|
|
router.push('/missions');
|
|
} catch (error) {
|
|
console.error('Error deleting mission:', error);
|
|
toast({
|
|
title: "Erreur",
|
|
description: "Impossible de supprimer la mission",
|
|
variant: "destructive",
|
|
});
|
|
} finally {
|
|
setDeleting(false);
|
|
}
|
|
};
|
|
|
|
// Handle close mission
|
|
const handleCloseMission = async () => {
|
|
if (!confirm("Êtes-vous sûr de vouloir clôturer cette mission ? Cette action fermera la mission dans tous les services externes.")) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
setClosing(true);
|
|
const response = await fetch(`/api/missions/${missionId}/close`, {
|
|
method: 'POST',
|
|
});
|
|
|
|
if (!response.ok) {
|
|
const errorData = await response.json();
|
|
throw new Error(errorData.error || 'Failed to close mission');
|
|
}
|
|
|
|
const data = await response.json();
|
|
|
|
// Update local state
|
|
setMission(prev => prev ? {
|
|
...prev,
|
|
isClosed: true,
|
|
closedAt: data.mission.closedAt
|
|
} : null);
|
|
|
|
toast({
|
|
title: "Mission clôturée",
|
|
description: "La mission a été clôturée avec succès dans tous les services",
|
|
});
|
|
} catch (error) {
|
|
console.error('Error closing mission:', error);
|
|
toast({
|
|
title: "Erreur",
|
|
description: error instanceof Error ? error.message : "Impossible de clôturer la mission",
|
|
variant: "destructive",
|
|
});
|
|
} finally {
|
|
setClosing(false);
|
|
}
|
|
};
|
|
|
|
// Handle generate action plan
|
|
const handleGeneratePlan = async () => {
|
|
try {
|
|
setGeneratingPlan(true);
|
|
|
|
const response = await fetch(`/api/missions/${missionId}/generate-plan`, {
|
|
method: 'POST',
|
|
});
|
|
|
|
if (!response.ok) {
|
|
const errorData = await response.json();
|
|
throw new Error(errorData.error || 'Failed to generate plan');
|
|
}
|
|
|
|
const data = await response.json();
|
|
|
|
setEditedPlan(data.actionPlan || "");
|
|
setMission(prev => prev ? {
|
|
...prev,
|
|
actionPlan: data.actionPlan,
|
|
actionPlanGeneratedAt: data.generatedAt
|
|
} : null);
|
|
|
|
toast({
|
|
title: "Plan d'action généré",
|
|
description: "Le plan d'action a été généré avec succès par l'IA",
|
|
});
|
|
} catch (error) {
|
|
console.error('Error generating plan:', error);
|
|
toast({
|
|
title: "Erreur",
|
|
description: error instanceof Error ? error.message : "Impossible de générer le plan d'action",
|
|
variant: "destructive",
|
|
});
|
|
} finally {
|
|
setGeneratingPlan(false);
|
|
}
|
|
};
|
|
|
|
// Handle save action plan
|
|
const handleSavePlan = async () => {
|
|
try {
|
|
setSavingPlan(true);
|
|
|
|
const response = await fetch(`/api/missions/${missionId}/generate-plan`, {
|
|
method: 'PUT',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
},
|
|
body: JSON.stringify({ actionPlan: editedPlan }),
|
|
});
|
|
|
|
if (!response.ok) {
|
|
const errorData = await response.json();
|
|
throw new Error(errorData.error || 'Failed to save plan');
|
|
}
|
|
|
|
setMission(prev => prev ? {
|
|
...prev,
|
|
actionPlan: editedPlan
|
|
} : null);
|
|
|
|
setIsPlanModified(false);
|
|
|
|
toast({
|
|
title: "Plan sauvegardé",
|
|
description: "Le plan d'action a été sauvegardé avec succès",
|
|
});
|
|
} catch (error) {
|
|
console.error('Error saving plan:', error);
|
|
toast({
|
|
title: "Erreur",
|
|
description: error instanceof Error ? error.message : "Impossible de sauvegarder le plan d'action",
|
|
variant: "destructive",
|
|
});
|
|
} finally {
|
|
setSavingPlan(false);
|
|
}
|
|
};
|
|
|
|
// Loading state
|
|
if (loading) {
|
|
return (
|
|
<div className="flex justify-center items-center min-h-screen bg-gray-50">
|
|
<div className="animate-spin rounded-full h-12 w-12 border-t-2 border-b-2 border-blue-600"></div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// Error state if mission not found
|
|
if (!mission) {
|
|
return (
|
|
<div className="flex justify-center items-center min-h-screen bg-gray-50 px-4">
|
|
<div className="text-center bg-white p-8 rounded-lg shadow-sm border border-gray-200 max-w-md">
|
|
<h2 className="text-xl font-semibold text-gray-800 mb-2">Mission non trouvée</h2>
|
|
<p className="text-gray-600 mb-6">Cette mission n'existe pas ou a été supprimée.</p>
|
|
<Button
|
|
onClick={() => window.history.back()}
|
|
className="bg-blue-600 hover:bg-blue-700 text-white"
|
|
>
|
|
Retour aux missions
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const oddInfo = getODDInfo(mission.oddScope);
|
|
|
|
return (
|
|
<div className="bg-gray-50 min-h-screen p-6">
|
|
{/* Header with Name and Logo */}
|
|
<div className="bg-white rounded-lg shadow-sm border border-gray-100 mb-6 p-6">
|
|
<div className="flex justify-between items-start">
|
|
<div>
|
|
<h1 className="text-2xl font-bold text-gray-900 mb-2">{mission.name}</h1>
|
|
<div className="flex items-center text-gray-500 text-sm gap-4">
|
|
<div className="flex items-center">
|
|
<Calendar className="h-4 w-4 mr-1" />
|
|
{formatDate(mission.createdAt)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Logo */}
|
|
<div className="w-24 h-24 rounded-md overflow-hidden flex-shrink-0">
|
|
{mission.logoUrl ? (
|
|
<img
|
|
src={mission.logoUrl}
|
|
alt={mission.name}
|
|
className="w-full h-full object-cover rounded-md"
|
|
onError={(e) => {
|
|
(e.currentTarget as HTMLImageElement).style.display = 'none';
|
|
const parent = e.currentTarget.parentElement;
|
|
if (parent) {
|
|
parent.classList.add('bg-gray-100', 'flex', 'items-center', 'justify-center');
|
|
parent.innerHTML = `<span class="text-2xl font-medium text-gray-400">${mission.name.slice(0, 2).toUpperCase()}</span>`;
|
|
}
|
|
}}
|
|
/>
|
|
) : (
|
|
<div className="w-full h-full bg-gray-100 flex items-center justify-center">
|
|
<span className="text-2xl font-medium text-gray-400">{mission.name.slice(0, 2).toUpperCase()}</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Tabs */}
|
|
<Tabs value={activeTab} onValueChange={setActiveTab} className="w-full">
|
|
<TabsList className="mb-6 bg-white border border-gray-200 p-1 rounded-lg">
|
|
<TabsTrigger
|
|
value="general"
|
|
className="data-[state=active]:bg-blue-600 data-[state=active]:text-white px-6 py-2"
|
|
>
|
|
<FileText className="h-4 w-4 mr-2" />
|
|
Général
|
|
</TabsTrigger>
|
|
<TabsTrigger
|
|
value="plan"
|
|
className="data-[state=active]:bg-blue-600 data-[state=active]:text-white px-6 py-2"
|
|
>
|
|
<Sparkles className="h-4 w-4 mr-2" />
|
|
Plan d'actions
|
|
</TabsTrigger>
|
|
<TabsTrigger
|
|
value="equipe"
|
|
className="data-[state=active]:bg-blue-600 data-[state=active]:text-white px-6 py-2"
|
|
>
|
|
<Users className="h-4 w-4 mr-2" />
|
|
Équipe
|
|
{mission.missionUsers && mission.missionUsers.length > 0 && (
|
|
<span className="ml-2 bg-blue-100 text-blue-800 text-xs font-medium px-2 py-0.5 rounded-full">
|
|
{mission.missionUsers.length + 1}
|
|
</span>
|
|
)}
|
|
</TabsTrigger>
|
|
<TabsTrigger
|
|
value="documents"
|
|
className="data-[state=active]:bg-blue-600 data-[state=active]:text-white px-6 py-2"
|
|
>
|
|
<FileIcon className="h-4 w-4 mr-2" />
|
|
Ressources
|
|
{mission.attachments && mission.attachments.length > 0 && (
|
|
<span className="ml-2 bg-blue-100 text-blue-800 text-xs font-medium px-2 py-0.5 rounded-full">
|
|
{mission.attachments.length}
|
|
</span>
|
|
)}
|
|
</TabsTrigger>
|
|
</TabsList>
|
|
|
|
{/* General Tab */}
|
|
<TabsContent value="general" className="space-y-6">
|
|
{/* Info Grid */}
|
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
|
<div className="bg-white rounded-lg p-4 flex items-center shadow-sm border border-gray-100">
|
|
<div className="bg-amber-50 p-3 rounded-full mr-3">
|
|
<MapPin className="h-5 w-5 text-amber-600" />
|
|
</div>
|
|
<div>
|
|
<p className="text-sm text-gray-500 font-medium">Type de mission</p>
|
|
<p className="text-gray-800 font-medium">{getMissionTypeLabel(mission.missionType)}</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="bg-white rounded-lg p-4 flex items-center shadow-sm border border-gray-100">
|
|
<div className="bg-blue-50 p-3 rounded-full mr-3">
|
|
<Users className="h-5 w-5 text-blue-600" />
|
|
</div>
|
|
<div>
|
|
<p className="text-sm text-gray-500 font-medium">Donneur d'ordre</p>
|
|
<p className="text-gray-800 font-medium">{getDonneurDOrdreLabel(mission.donneurDOrdre || "")}</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="bg-white rounded-lg p-4 flex items-center shadow-sm border border-gray-100">
|
|
<div className="bg-green-50 p-3 rounded-full mr-3">
|
|
<Clock className="h-5 w-5 text-green-600" />
|
|
</div>
|
|
<div>
|
|
<p className="text-sm text-gray-500 font-medium">Durée</p>
|
|
<p className="text-gray-800 font-medium">{getDurationLabel(mission.projection)}</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="bg-white rounded-lg p-4 flex items-center shadow-sm border border-gray-100">
|
|
<div className="bg-purple-50 p-3 rounded-full mr-3">
|
|
<ThumbsUp className="h-5 w-5 text-purple-600" />
|
|
</div>
|
|
<div>
|
|
<p className="text-sm text-gray-500 font-medium">Niveau</p>
|
|
<p className="text-gray-800 font-medium">{getNiveauLabel(mission.niveau)}</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="bg-white rounded-lg p-4 flex items-center shadow-sm border border-gray-100">
|
|
<div className="bg-indigo-50 p-3 rounded-full mr-3">
|
|
<Languages className="h-5 w-5 text-indigo-600" />
|
|
</div>
|
|
<div>
|
|
<p className="text-sm text-gray-500 font-medium">Participation</p>
|
|
<p className="text-gray-800 font-medium">{getParticipationLabel(mission.participation || "")}</p>
|
|
</div>
|
|
</div>
|
|
|
|
{oddInfo.number && (
|
|
<div className="bg-white rounded-lg p-4 flex items-center shadow-sm border border-gray-100">
|
|
<div className="bg-red-50 p-3 rounded-full mr-3 flex items-center justify-center">
|
|
<img
|
|
src={oddInfo.iconPath}
|
|
alt={oddInfo.label}
|
|
className="h-8 w-8"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<p className="text-sm text-gray-500 font-medium">Objectif</p>
|
|
<p className="text-gray-800 font-medium">{oddInfo.label}</p>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Description */}
|
|
<div className="bg-white rounded-lg shadow-sm border border-gray-100 p-6">
|
|
<h2 className="text-xl font-semibold text-gray-800 mb-4">Description de la mission</h2>
|
|
<div className="text-gray-700 whitespace-pre-wrap">
|
|
{mission.intention || "Aucune description disponible pour cette mission."}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Services */}
|
|
{mission.services && mission.services.length > 0 && (
|
|
<div className="bg-white rounded-lg shadow-sm border border-gray-100 p-6">
|
|
<h2 className="text-xl font-semibold text-gray-800 mb-4">Services</h2>
|
|
<div className="flex flex-wrap gap-2">
|
|
{mission.services.map((service, index) => (
|
|
<span
|
|
key={index}
|
|
className="bg-blue-50 text-blue-800 px-3 py-2 rounded-full text-sm font-medium"
|
|
>
|
|
{service}
|
|
</span>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Profils */}
|
|
{mission.profils && mission.profils.length > 0 && (
|
|
<div className="bg-white rounded-lg shadow-sm border border-gray-100 p-6">
|
|
<h2 className="text-xl font-semibold text-gray-800 mb-4">Profils recherchés</h2>
|
|
<div className="flex flex-wrap gap-2">
|
|
{mission.profils.map((profil, index) => (
|
|
<span
|
|
key={index}
|
|
className="bg-red-50 text-red-800 px-3 py-2 rounded-full text-sm font-medium"
|
|
>
|
|
{profil}
|
|
</span>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Mission Status & Action Buttons */}
|
|
<div className="flex justify-between items-center">
|
|
{/* Closed Status */}
|
|
{mission.isClosed && (
|
|
<div className="flex items-center gap-2 text-amber-600 bg-amber-50 px-4 py-2 rounded-lg border border-amber-200">
|
|
<Lock className="h-4 w-4" />
|
|
<span className="font-medium">Mission clôturée</span>
|
|
{mission.closedAt && (
|
|
<span className="text-sm text-amber-500">
|
|
le {formatDate(mission.closedAt)}
|
|
</span>
|
|
)}
|
|
</div>
|
|
)}
|
|
{!mission.isClosed && <div></div>}
|
|
|
|
{/* Action Buttons */}
|
|
<div className="flex gap-3">
|
|
{/* Delete Button */}
|
|
<Button
|
|
variant="outline"
|
|
className="flex items-center gap-2 border-red-600 text-red-600 hover:bg-red-50 bg-white"
|
|
onClick={handleDeleteMission}
|
|
disabled={deleting}
|
|
>
|
|
{deleting ? (
|
|
<Loader2 className="h-4 w-4 animate-spin" />
|
|
) : (
|
|
<Trash2 className="h-4 w-4" />
|
|
)}
|
|
Supprimer
|
|
</Button>
|
|
|
|
{/* Close Button - only show if not already closed */}
|
|
{!mission.isClosed && (
|
|
<Button
|
|
variant="outline"
|
|
className="flex items-center gap-2 border-amber-600 text-amber-600 hover:bg-amber-50 bg-white"
|
|
onClick={handleCloseMission}
|
|
disabled={closing}
|
|
>
|
|
{closing ? (
|
|
<Loader2 className="h-4 w-4 animate-spin" />
|
|
) : (
|
|
<Lock className="h-4 w-4" />
|
|
)}
|
|
Clôturer
|
|
</Button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</TabsContent>
|
|
|
|
{/* Plan d'actions Tab */}
|
|
<TabsContent value="plan" className="space-y-6">
|
|
<div className="bg-white rounded-lg shadow-sm border border-gray-100 p-6">
|
|
<div className="flex justify-between items-center mb-6">
|
|
<div>
|
|
<h2 className="text-xl font-semibold text-gray-800">Plan d'actions</h2>
|
|
{mission.actionPlanGeneratedAt && (
|
|
<p className="text-sm text-gray-500 mt-1">
|
|
Généré le {formatDate(mission.actionPlanGeneratedAt)}
|
|
</p>
|
|
)}
|
|
</div>
|
|
<div className="flex gap-3">
|
|
{isPlanModified && (
|
|
<Button
|
|
onClick={handleSavePlan}
|
|
disabled={savingPlan}
|
|
className="bg-green-600 hover:bg-green-700 text-white"
|
|
>
|
|
{savingPlan ? (
|
|
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
|
) : (
|
|
<Save className="h-4 w-4 mr-2" />
|
|
)}
|
|
Sauvegarder
|
|
</Button>
|
|
)}
|
|
{/* Only show Generate button if no plan has been saved yet */}
|
|
{!mission.actionPlan && (
|
|
<Button
|
|
onClick={handleGeneratePlan}
|
|
disabled={generatingPlan}
|
|
className="bg-blue-600 hover:bg-blue-700 text-white"
|
|
>
|
|
{generatingPlan ? (
|
|
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
|
) : (
|
|
<Sparkles className="h-4 w-4 mr-2" />
|
|
)}
|
|
Générer
|
|
</Button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Plan Content */}
|
|
{editedPlan || mission.actionPlan ? (
|
|
<div className="space-y-4">
|
|
<Textarea
|
|
value={editedPlan}
|
|
onChange={(e) => {
|
|
setEditedPlan(e.target.value);
|
|
// Auto-resize textarea
|
|
e.target.style.height = 'auto';
|
|
e.target.style.height = e.target.scrollHeight + 'px';
|
|
}}
|
|
placeholder="Le plan d'action généré apparaîtra ici. Vous pouvez le modifier après génération."
|
|
className="min-h-[600px] font-mono text-sm bg-white text-gray-900 border-gray-300 focus:border-blue-500 focus:ring-blue-500 resize-none overflow-hidden"
|
|
style={{ height: 'auto' }}
|
|
ref={(textarea) => {
|
|
// Auto-resize on mount
|
|
if (textarea) {
|
|
textarea.style.height = 'auto';
|
|
textarea.style.height = Math.max(600, textarea.scrollHeight) + 'px';
|
|
}
|
|
}}
|
|
/>
|
|
|
|
{/* Bottom action bar */}
|
|
<div className="flex items-center justify-between pt-4 border-t border-gray-200">
|
|
{isPlanModified ? (
|
|
<>
|
|
<p className="text-sm text-amber-600 flex items-center gap-1">
|
|
<span className="w-2 h-2 bg-amber-500 rounded-full"></span>
|
|
Modifications non sauvegardées
|
|
</p>
|
|
<Button
|
|
onClick={handleSavePlan}
|
|
disabled={savingPlan}
|
|
className="bg-green-600 hover:bg-green-700 text-white"
|
|
>
|
|
{savingPlan ? (
|
|
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
|
) : (
|
|
<Save className="h-4 w-4 mr-2" />
|
|
)}
|
|
Sauvegarder les modifications
|
|
</Button>
|
|
</>
|
|
) : (
|
|
<p className="text-sm text-green-600 flex items-center gap-1">
|
|
<span className="w-2 h-2 bg-green-500 rounded-full"></span>
|
|
Plan sauvegardé
|
|
</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<div className="flex flex-col items-center justify-center py-16 text-center">
|
|
<div className="w-20 h-20 bg-blue-50 rounded-full flex items-center justify-center mb-6">
|
|
<Sparkles className="h-10 w-10 text-blue-500" />
|
|
</div>
|
|
<h3 className="text-lg font-medium text-gray-900 mb-2">
|
|
Aucun plan d'action généré
|
|
</h3>
|
|
<p className="text-gray-500 mb-6 max-w-md">
|
|
Cliquez sur le bouton "Générer" pour créer un plan d'action basé sur les informations de votre mission grâce à l'IA.
|
|
</p>
|
|
<Button
|
|
onClick={handleGeneratePlan}
|
|
disabled={generatingPlan}
|
|
size="lg"
|
|
className="bg-blue-600 hover:bg-blue-700 text-white"
|
|
>
|
|
{generatingPlan ? (
|
|
<Loader2 className="h-5 w-5 mr-2 animate-spin" />
|
|
) : (
|
|
<Sparkles className="h-5 w-5 mr-2" />
|
|
)}
|
|
Générer le plan d'action
|
|
</Button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</TabsContent>
|
|
|
|
{/* Equipe Tab */}
|
|
<TabsContent value="equipe" className="space-y-6">
|
|
{/* Creator Section */}
|
|
<div className="bg-white rounded-lg shadow-sm border border-gray-100 p-6">
|
|
<h2 className="text-xl font-semibold text-gray-800 mb-4">Créateur de la mission</h2>
|
|
<div className="flex items-center p-4 bg-blue-50 rounded-lg border border-blue-100">
|
|
<div className="h-12 w-12 rounded-full bg-blue-600 flex items-center justify-center text-white font-medium text-lg mr-4">
|
|
{mission.creator.email.slice(0, 2).toUpperCase()}
|
|
</div>
|
|
<div>
|
|
<p className="font-medium text-gray-900">{mission.creator.email}</p>
|
|
<p className="text-sm text-blue-600 font-medium">Créateur</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Guardians Section */}
|
|
{(() => {
|
|
const guardians = mission.missionUsers.filter(mu =>
|
|
mu.role === 'gardien-temps' || mu.role === 'gardien-parole' || mu.role === 'gardien-memoire'
|
|
);
|
|
|
|
if (guardians.length === 0) return null;
|
|
|
|
const getRoleLabel = (role: string) => {
|
|
switch(role) {
|
|
case 'gardien-temps': return 'Gardien du Temps';
|
|
case 'gardien-parole': return 'Gardien de la Parole';
|
|
case 'gardien-memoire': return 'Gardien de la Mémoire';
|
|
default: return role;
|
|
}
|
|
};
|
|
|
|
const getRoleColor = (role: string) => {
|
|
switch(role) {
|
|
case 'gardien-temps': return 'bg-amber-50 border-amber-100 text-amber-600';
|
|
case 'gardien-parole': return 'bg-purple-50 border-purple-100 text-purple-600';
|
|
case 'gardien-memoire': return 'bg-green-50 border-green-100 text-green-600';
|
|
default: return 'bg-gray-50 border-gray-100 text-gray-600';
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div className="bg-white rounded-lg shadow-sm border border-gray-100 p-6">
|
|
<h2 className="text-xl font-semibold text-gray-800 mb-4">Les Gardiens de l'Intention</h2>
|
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
|
{guardians.map((guardian) => (
|
|
<div
|
|
key={guardian.id}
|
|
className={`flex items-center p-4 rounded-lg border ${getRoleColor(guardian.role)}`}
|
|
>
|
|
<div className={`h-12 w-12 rounded-full flex items-center justify-center font-medium text-lg mr-4 ${
|
|
guardian.role === 'gardien-temps' ? 'bg-amber-600 text-white' :
|
|
guardian.role === 'gardien-parole' ? 'bg-purple-600 text-white' :
|
|
'bg-green-600 text-white'
|
|
}`}>
|
|
{guardian.user.email.slice(0, 2).toUpperCase()}
|
|
</div>
|
|
<div>
|
|
<p className="font-medium text-gray-900">{guardian.user.email}</p>
|
|
<p className={`text-sm font-medium ${
|
|
guardian.role === 'gardien-temps' ? 'text-amber-600' :
|
|
guardian.role === 'gardien-parole' ? 'text-purple-600' :
|
|
'text-green-600'
|
|
}`}>
|
|
{getRoleLabel(guardian.role)}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
);
|
|
})()}
|
|
|
|
{/* Volunteers/Other Members Section */}
|
|
{(() => {
|
|
const volunteers = mission.missionUsers.filter(mu => mu.role === 'volontaire');
|
|
|
|
if (volunteers.length === 0) return null;
|
|
|
|
return (
|
|
<div className="bg-white rounded-lg shadow-sm border border-gray-100 p-6">
|
|
<h2 className="text-xl font-semibold text-gray-800 mb-4">Volontaires</h2>
|
|
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
|
|
{volunteers.map((volunteer) => (
|
|
<div
|
|
key={volunteer.id}
|
|
className="flex items-center p-3 bg-gray-50 rounded-lg border border-gray-100"
|
|
>
|
|
<div className="h-10 w-10 rounded-full bg-gray-400 flex items-center justify-center text-white font-medium mr-3">
|
|
{volunteer.user.email.slice(0, 2).toUpperCase()}
|
|
</div>
|
|
<div>
|
|
<p className="font-medium text-gray-900 text-sm">{volunteer.user.email}</p>
|
|
<p className="text-xs text-gray-500">Volontaire</p>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
);
|
|
})()}
|
|
|
|
{/* Empty state if no team members */}
|
|
{mission.missionUsers.length === 0 && (
|
|
<div className="bg-white rounded-lg shadow-sm border border-gray-100 p-6">
|
|
<div className="flex flex-col items-center justify-center py-12 text-center">
|
|
<div className="w-16 h-16 bg-gray-100 rounded-full flex items-center justify-center mb-4">
|
|
<Users className="h-8 w-8 text-gray-400" />
|
|
</div>
|
|
<h3 className="text-lg font-medium text-gray-900 mb-2">
|
|
Aucun membre assigné
|
|
</h3>
|
|
<p className="text-gray-500 max-w-md">
|
|
Cette mission n'a pas encore de membres assignés en dehors du créateur.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</TabsContent>
|
|
|
|
{/* Documents/Ressources Tab */}
|
|
<TabsContent value="documents" className="space-y-6">
|
|
<div className="bg-white rounded-lg shadow-sm border border-gray-100 p-6">
|
|
<h2 className="text-xl font-semibold text-gray-800 mb-4">Ressources</h2>
|
|
|
|
{mission.attachments && mission.attachments.length > 0 ? (
|
|
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
|
|
{mission.attachments.map((attachment) => (
|
|
<a
|
|
key={attachment.id}
|
|
href={attachment.publicUrl}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
className="bg-green-50 p-4 rounded-lg flex flex-col hover:bg-green-100 transition-colors border border-green-100"
|
|
>
|
|
<div className="text-green-700 mb-2">
|
|
<FileIcon className="h-10 w-10" />
|
|
</div>
|
|
<div>
|
|
<p className="font-medium text-gray-800 mb-1 truncate">{attachment.filename}</p>
|
|
<p className="text-sm text-gray-500">
|
|
{attachment.fileType.split('/')[1]?.toUpperCase() || 'Fichier'}
|
|
</p>
|
|
<p className="text-xs text-gray-400 mt-1">
|
|
{(attachment.fileSize / 1024).toFixed(1)} KB
|
|
</p>
|
|
</div>
|
|
</a>
|
|
))}
|
|
</div>
|
|
) : (
|
|
<div className="flex flex-col items-center justify-center py-12 text-center">
|
|
<div className="w-16 h-16 bg-gray-100 rounded-full flex items-center justify-center mb-4">
|
|
<FileIcon className="h-8 w-8 text-gray-400" />
|
|
</div>
|
|
<h3 className="text-lg font-medium text-gray-900 mb-2">
|
|
Aucune ressource
|
|
</h3>
|
|
<p className="text-gray-500 max-w-md">
|
|
Cette mission n'a pas de ressources attachées.
|
|
</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</TabsContent>
|
|
</Tabs>
|
|
</div>
|
|
);
|
|
}
|