missions page 2

This commit is contained in:
alma 2025-05-06 13:56:33 +02:00
parent 05465beeaa
commit aada0b8b5d
2 changed files with 373 additions and 0 deletions

View File

@ -0,0 +1,109 @@
import { NextResponse } from 'next/server';
import { getServerSession } from 'next-auth';
import { authOptions } from "@/app/api/auth/options";
import { prisma } from '@/lib/prisma';
import { getPublicUrl } from '@/lib/s3';
import { S3_CONFIG } from '@/lib/s3';
// Helper function to check authentication
async function checkAuth(request: Request) {
const session = await getServerSession(authOptions);
if (!session?.user?.id) {
console.error('Unauthorized access attempt:', {
url: request.url,
method: request.method,
headers: Object.fromEntries(request.headers)
});
return { authorized: false, userId: null };
}
return { authorized: true, userId: session.user.id };
}
// GET endpoint to list all missions (not filtered by user)
export async function GET(request: Request) {
try {
const { authorized, userId } = await checkAuth(request);
if (!authorized || !userId) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const { searchParams } = new URL(request.url);
const limit = Number(searchParams.get('limit') || '100'); // Default to 100 for "all"
const offset = Number(searchParams.get('offset') || '0');
const search = searchParams.get('search');
// Build query conditions
const where: any = {};
// Add search filter if provided
if (search) {
where.OR = [
{ name: { contains: search, mode: 'insensitive' } },
{ intention: { contains: search, mode: 'insensitive' } }
];
}
// Get all missions with basic info (no user filtering)
const missions = await prisma.mission.findMany({
where,
skip: offset,
take: limit,
orderBy: { createdAt: 'desc' },
select: {
id: true,
name: true,
logo: true,
oddScope: true,
niveau: true,
missionType: true,
projection: true,
participation: true,
services: true,
intention: true,
createdAt: true,
creator: {
select: {
id: true,
email: true
}
},
missionUsers: {
select: {
id: true,
role: true,
user: {
select: {
id: true,
email: true
}
}
}
}
}
});
// Get total count
const totalCount = await prisma.mission.count({ where });
// Transform logo paths to public URLs
const missionsWithPublicUrls = missions.map(mission => ({
...mission,
logo: mission.logo ? `/api/missions/image/${mission.logo}` : null
}));
return NextResponse.json({
missions: missionsWithPublicUrls,
pagination: {
total: totalCount,
offset,
limit
}
});
} catch (error) {
console.error('Error listing all missions:', error);
return NextResponse.json({
error: 'Internal server error',
details: error instanceof Error ? error.message : String(error)
}, { status: 500 });
}
}

264
app/mission-tab/page.tsx Normal file
View File

@ -0,0 +1,264 @@
"use client";
import { useState, useEffect } from "react";
import { Search } from "lucide-react";
import { Input } from "@/components/ui/input";
import Link from "next/link";
import { Button } from "@/components/ui/button";
import { useToast } from "@/components/ui/use-toast";
// Define Mission interface
interface User {
id: string;
email: string;
}
interface MissionUser {
id: string;
role: string;
user: User;
}
interface Mission {
id: string;
name: string;
logo?: string;
oddScope: string[];
niveau: string;
missionType: string;
projection: string;
participation?: string;
services?: string[];
intention?: string;
createdAt: string;
creator: User;
missionUsers: MissionUser[];
}
export default function MissionTabPage() {
const [searchTerm, setSearchTerm] = useState("");
const [missions, setMissions] = useState<Mission[]>([]);
const [loading, setLoading] = useState(true);
const { toast } = useToast();
// Fetch all missions from API
useEffect(() => {
const fetchAllMissions = async () => {
try {
setLoading(true);
const response = await fetch('/api/missions/all'); // This will need a new API endpoint
if (!response.ok) {
throw new Error('Failed to fetch missions');
}
const data = await response.json();
console.log("All missions data:", data.missions);
setMissions(data.missions || []);
} catch (error) {
console.error('Error fetching missions:', error);
toast({
title: "Erreur",
description: "Impossible de charger les missions",
variant: "destructive",
});
} finally {
setLoading(false);
}
};
fetchAllMissions();
}, []);
// Filter missions based on search term
const filteredMissions = missions.filter(mission =>
mission.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
mission.niveau.toLowerCase().includes(searchTerm.toLowerCase()) ||
mission.missionType.toLowerCase().includes(searchTerm.toLowerCase()) ||
mission.oddScope.some(scope => scope.toLowerCase().includes(searchTerm.toLowerCase()))
);
// Function to format date
const formatDate = (dateString: string) => {
const date = new Date(dateString);
return date.toLocaleDateString('fr-FR', {
day: '2-digit',
month: '2-digit',
year: 'numeric'
});
};
// Function to get mission category and icon
const getODDInfo = (mission: Mission) => {
const oddCode = mission.oddScope && mission.oddScope.length > 0
? mission.oddScope[0]
: null;
// Extract number from odd code (e.g., "odd-3" -> "3")
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` : ""
};
};
// Function to get appropriate badge color based on niveau
const getNiveauBadgeColor = (niveau: string) => {
switch(niveau) {
case 'a': return 'bg-green-100 text-green-800';
case 'b': return 'bg-blue-100 text-blue-800';
case 'c': return 'bg-purple-100 text-purple-800';
case 's': return 'bg-amber-100 text-amber-800';
default: return 'bg-gray-100 text-gray-800';
}
};
// Function to get full niveau label
const getNiveauLabel = (niveau: string) => {
switch(niveau) {
case 'a': return 'A';
case 'b': return 'B';
case 'c': return 'C';
case 's': return 'S';
default: return niveau.toUpperCase();
}
};
return (
<div className="w-full bg-white min-h-screen">
<div className="bg-white border-b border-gray-100 py-4 px-6">
<div className="flex items-center justify-between">
<h1 className="text-gray-800 text-xl font-medium">Toutes les missions disponibles</h1>
<div className="relative">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-gray-500" />
<Input
placeholder="Rechercher une mission..."
className="h-9 pl-9 pr-3 py-2 text-sm bg-white text-gray-800 border-gray-200 rounded-md w-60"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
/>
</div>
</div>
</div>
<div className="p-6 bg-gray-50">
{loading ? (
<div className="flex justify-center items-center h-40">
<div className="animate-spin rounded-full h-10 w-10 border-t-2 border-b-2 border-blue-600"></div>
</div>
) : filteredMissions.length > 0 ? (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
{filteredMissions.map((mission) => {
const oddInfo = getODDInfo(mission);
const niveauColor = getNiveauBadgeColor(mission.niveau);
return (
<div key={mission.id} className="bg-white shadow-sm hover:shadow-md transition-shadow duration-200 border border-gray-200 overflow-hidden h-full rounded-lg flex flex-col">
{/* Card Header with Name and Level */}
<div className="px-5 pt-4 pb-3 flex justify-between items-center border-b border-gray-100">
<h2 className="text-base font-medium text-gray-900 line-clamp-2 flex-1">{mission.name}</h2>
<div className="flex items-center gap-2 ml-2">
{/* ODD scope icon */}
{oddInfo.number && (
<div className="flex items-center bg-gray-100 p-1 rounded-md">
<img
src={oddInfo.iconPath}
alt={oddInfo.label}
className="w-8 h-8"
onError={(e) => {
// Fallback if image fails to load
(e.target as HTMLImageElement).style.display = 'none';
}}
/>
</div>
)}
<span className={`flex-shrink-0 text-sm font-bold px-2.5 py-1.5 rounded-md ${niveauColor}`}>
{getNiveauLabel(mission.niveau)}
</span>
</div>
</div>
{/* Centered Logo */}
<div className="flex justify-center items-center p-6 flex-grow">
<div className="w-48 h-48 relative">
{mission.logo ? (
<img
src={mission.logo || ''}
alt={mission.name}
className="w-full h-full object-cover rounded-md"
onError={(e) => {
console.log("Logo failed to load:", mission.logo);
console.log("Full URL attempted:", mission.logo);
// If the image fails to load, show the fallback
(e.currentTarget as HTMLImageElement).style.display = 'none';
// Show the fallback div
const fallbackDiv = e.currentTarget.parentElement?.querySelector('.logo-fallback');
if (fallbackDiv) {
(fallbackDiv as HTMLElement).style.display = 'flex';
}
}}
/>
) : null}
<div
className={`logo-fallback w-full h-full flex items-center justify-center bg-gray-100 rounded-md text-gray-500 text-4xl font-medium ${mission.logo ? 'hidden' : ''}`}
>
{mission.name.slice(0, 2).toUpperCase()}
</div>
</div>
</div>
{/* Card Content - Services and Description */}
<div className="px-5 pb-3">
{/* Services section */}
{mission.services && mission.services.length > 0 && (
<div>
<span className="text-sm font-medium text-gray-700 block mb-1">Services:</span>
<div className="flex flex-wrap gap-1.5 mb-3">
{mission.services.map(service => (
<span key={service} className="bg-blue-50 text-blue-700 px-2 py-1 rounded-md text-xs font-medium">
{service}
</span>
))}
</div>
</div>
)}
{/* Description text */}
<div className="mt-2 text-sm text-gray-600 line-clamp-2">
{mission.intention ?
(mission.intention.substring(0, 100) + (mission.intention.length > 100 ? '...' : '')) :
'Pas de description disponible.'}
</div>
</div>
{/* Card Footer */}
<div className="mt-auto px-5 py-3 border-t border-gray-100 bg-gray-50 flex justify-between items-center">
<span className="text-xs text-gray-500">
Créée le {formatDate(mission.createdAt)}
</span>
<Link href={`/missions/${mission.id}`}>
<Button className="bg-blue-600 hover:bg-blue-700 text-white text-xs px-3 py-1 h-7 rounded-md">
Voir détails
</Button>
</Link>
</div>
</div>
);
})}
</div>
) : (
<div className="text-center py-16 px-6 bg-white rounded-lg border border-gray-200 shadow-sm">
<div className="w-16 h-16 bg-gray-100 rounded-full flex items-center justify-center mx-auto mb-4">
<Search className="h-8 w-8 text-gray-400" />
</div>
<h3 className="text-lg font-medium text-gray-900 mb-2">Aucune mission trouvée</h3>
<p className="text-gray-500 mb-6 max-w-md mx-auto">
Essayez de modifier vos critères de recherche.
</p>
</div>
)}
</div>
</div>
);
}