diff --git a/backend/src/controllers/jobSearchController.ts b/backend/src/controllers/jobSearchController.ts index 1c1f8e6..70e3a9a 100644 --- a/backend/src/controllers/jobSearchController.ts +++ b/backend/src/controllers/jobSearchController.ts @@ -71,4 +71,31 @@ export const searchLocalJobOffers = async (req: Request, res: Response) => { console.error('Error searching job offers:', error); res.status(500).json({ error: 'Failed to search job offers' }); } -}; \ No newline at end of file +}; + +export const getJobOfferById = async (req: Request, res: Response): Promise => { + try { + const { id } = req.params; // Récupère l'ID depuis les paramètres de l'URL + + if (!id) { + res.status(400).json({ error: 'Job offer ID is required.' }); + return; + } + + const job = await prisma.jobOffer.findUnique({ + where: { id: id }, + }); + + if (!job) { + res.status(404).json({ error: 'Job offer not found.' }); + return; + } + + res.status(200).json(job); + } catch (error) { + console.error('Error fetching job offer by ID:', error); + res.status(500).json({ error: 'Failed to fetch job offer details.' }); + } finally { + await prisma.$disconnect(); // Assurez-vous de déconnecter Prisma + } +}; diff --git a/backend/src/routes/jobSearchRoutes.ts b/backend/src/routes/jobSearchRoutes.ts index e8c97c5..d2c2903 100644 --- a/backend/src/routes/jobSearchRoutes.ts +++ b/backend/src/routes/jobSearchRoutes.ts @@ -1,8 +1,12 @@ -import express from 'express'; -import { searchLocalJobOffers } from '../controllers/jobSearchController'; +import { Router } from 'express'; +import { searchLocalJobOffers, getJobOfferById } from '../controllers/jobSearchController'; -const router = express.Router(); +const router = Router(); +// Route pour la recherche d'offres (existante) router.get('/api/jobs', searchLocalJobOffers); +// Nouvelle route pour récupérer une offre par ID +router.get('/api/jobs/:id', getJobOfferById); + export default router; diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 26b84b5..2e239a7 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -10,13 +10,15 @@ "dependencies": { "axios": "^1.6.8", "react": "^19.1.0", - "react-dom": "^19.1.0" + "react-dom": "^19.1.0", + "react-router-dom": "^7.6.1" }, "devDependencies": { "@eslint/js": "^9.25.0", "@types/axios": "^0.14.0", "@types/react": "^19.1.2", "@types/react-dom": "^19.1.2", + "@types/react-router-dom": "^5.3.3", "@vitejs/plugin-react": "^4.4.1", "eslint": "^9.25.0", "eslint-plugin-react-hooks": "^5.2.0", @@ -1409,6 +1411,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/history": { + "version": "4.7.11", + "resolved": "https://registry.npmjs.org/@types/history/-/history-4.7.11.tgz", + "integrity": "sha512-qjDJRrmvBMiTx+jyLxvLfJU7UznFuokDv4f3WRuriHKERccVpFU+8XMQUAbDzoiJCsmexxRExQeMwwCdamSKDA==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/json-schema": { "version": "7.0.15", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", @@ -1436,6 +1445,29 @@ "@types/react": "^19.0.0" } }, + "node_modules/@types/react-router": { + "version": "5.1.20", + "resolved": "https://registry.npmjs.org/@types/react-router/-/react-router-5.1.20.tgz", + "integrity": "sha512-jGjmu/ZqS7FjSH6owMcD5qpq19+1RS9DeVRqfl1FeBMxTDQAGwlMWOcs52NDoXaNKyG3d1cYQFMs9rCrb88o9Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/history": "^4.7.11", + "@types/react": "*" + } + }, + "node_modules/@types/react-router-dom": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/@types/react-router-dom/-/react-router-dom-5.3.3.tgz", + "integrity": "sha512-kpqnYK4wcdm5UaWI3fLcELopqLrHgLqNsdpHauzlQktfkHL3npOSwtj1Uz9oKBAzs7lFtVkV8j83voAz2D8fhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/history": "^4.7.11", + "@types/react": "*", + "@types/react-router": "*" + } + }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "8.32.1", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.32.1.tgz", @@ -1937,6 +1969,15 @@ "dev": true, "license": "MIT" }, + "node_modules/cookie": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.0.2.tgz", + "integrity": "sha512-9Kr/j4O16ISv8zBBhJoi4bXOYNTkFLOqSL3UDB0njXxCXNezjeyVrJyGOWtgfs/q2km1gwBcfH8q1yEGoMYunA==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -3144,6 +3185,44 @@ "node": ">=0.10.0" } }, + "node_modules/react-router": { + "version": "7.6.1", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.6.1.tgz", + "integrity": "sha512-hPJXXxHJZEsPFNVbtATH7+MMX43UDeOauz+EAU4cgqTn7ojdI9qQORqS8Z0qmDlL1TclO/6jLRYUEtbWidtdHQ==", + "license": "MIT", + "dependencies": { + "cookie": "^1.0.1", + "set-cookie-parser": "^2.6.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + } + } + }, + "node_modules/react-router-dom": { + "version": "7.6.1", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.6.1.tgz", + "integrity": "sha512-vxU7ei//UfPYQ3iZvHuO1D/5fX3/JOqhNTbRR+WjSBWxf9bIvpWK+ftjmdfJHzPOuMQKe2fiEdG+dZX6E8uUpA==", + "license": "MIT", + "dependencies": { + "react-router": "7.6.1" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + } + }, "node_modules/resolve-from": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", @@ -3245,6 +3324,12 @@ "semver": "bin/semver.js" } }, + "node_modules/set-cookie-parser": { + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.1.tgz", + "integrity": "sha512-IOc8uWeOZgnb3ptbCURJWNjWUPcO3ZnTTdzsurqERrP6nPyv+paC55vJM0LpOlT2ne+Ix+9+CRG1MNLlyZ4GjQ==", + "license": "MIT" + }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", diff --git a/frontend/package.json b/frontend/package.json index 7fdfcad..0e9107d 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -9,16 +9,18 @@ "lint": "eslint .", "preview": "vite preview" }, -"dependencies": { + "dependencies": { "axios": "^1.6.8", "react": "^19.1.0", - "react-dom": "^19.1.0" + "react-dom": "^19.1.0", + "react-router-dom": "^7.6.1" }, -"devDependencies": { + "devDependencies": { "@eslint/js": "^9.25.0", "@types/axios": "^0.14.0", "@types/react": "^19.1.2", "@types/react-dom": "^19.1.2", + "@types/react-router-dom": "^5.3.3", "@vitejs/plugin-react": "^4.4.1", "eslint": "^9.25.0", "eslint-plugin-react-hooks": "^5.2.0", diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index b52970a..bc58791 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,11 +1,17 @@ -import React from 'react'; -import JobSearch from './components/JobSearch'; // Importe le nouveau composant +import { BrowserRouter as Router, Routes, Route } from 'react-router-dom'; // Importe les composants de routage +import JobSearch from './components/JobSearch'; +import JobDetail from './components/JobDetail'; // Importe le nouveau composant de détail function App() { return ( -
- {/* Utilise le composant de recherche d'emploi */} -
+ {/* Enveloppe toute l'application dans le routeur */} +
+ {/* Définit les routes */} + } /> {/* Route pour la recherche d'offres */} + } /> {/* Route pour le détail d'une offre */} + +
+
); } diff --git a/frontend/src/components/JobDetail.tsx b/frontend/src/components/JobDetail.tsx new file mode 100644 index 0000000..d5cddd1 --- /dev/null +++ b/frontend/src/components/JobDetail.tsx @@ -0,0 +1,122 @@ +import React, { useState, useEffect } from 'react'; +import { useParams, Link } from 'react-router-dom'; +import axios from 'axios'; +import type { JobOffer } from '../types'; // Use type-only import + +const API_BASE_URL = 'http://localhost:3000/api/jobs'; + +const JobDetail: React.FC = () => { + const { id } = useParams<{ id: string }>(); + const [job, setJob] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + const fetchJobDetail = async () => { + setLoading(true); + setError(null); + try { + const response = await axios.get(`${API_BASE_URL}/${id}`); + setJob(response.data); + } catch (err) { + console.error('Error fetching job detail:', err); + if (axios.isAxiosError(err) && err.response?.status === 404) { + setError('Job offer not found.'); + } else { + setError('Failed to load job offer details. Please try again.'); + } + } finally { + setLoading(false); + } + }; + + if (id) { + fetchJobDetail(); + } + }, [id]); + + const handleApplyClick = () => { + // Logique pour trouver la meilleure URL de candidature + const applyUrl = job?.urlOffre || job?.contact?.urlPostulation || job?.origineOffre?.urlOrigine; + + if (applyUrl) { + window.open(applyUrl, '_blank'); // Ouvre l'URL dans un nouvel onglet + } else { + alert("No application URL available for this job offer."); + } + }; + + if (loading) { + return

Loading job details...

; + } + + if (error) { + return ( +
+

Error: {error}

+ Back to Search +
+ ); + } + + if (!job) { + return ( +
+

No job details available.

+ Back to Search +
+ ); + } + + return ( +
+ + ← Back to Job Search + +

{job.title}

+

+ Company: {job.companyName || 'N/A'} +

+

+ Location: {job.locationLabel || job.cityName || 'N/A'} +

+

+ Contract: {job.contractLabel || job.contractType || 'N/A'} +

+

+ Published: {new Date(job.publicationDate).toLocaleDateString()} +

+ {job.romeLabel &&

ROME: {job.romeLabel}

} + {job.postalCode &&

Postal Code: {job.postalCode}

} + {job.departmentCode &&

Department Code: {job.departmentCode}

} + +

Description

+

{job.description}

+ + {/* Bouton Postuler */} + {(job.urlOffre || job.contact?.urlPostulation || job.origineOffre?.urlOrigine) && ( + + )} +
+ ); +}; + +export default JobDetail; diff --git a/frontend/src/components/JobSearch.tsx b/frontend/src/components/JobSearch.tsx index 1cb7712..8e21c03 100644 --- a/frontend/src/components/JobSearch.tsx +++ b/frontend/src/components/JobSearch.tsx @@ -1,8 +1,9 @@ import React, { useState, useEffect } from 'react'; import axios from 'axios'; +import { Link } from 'react-router-dom'; import type { JobOffer, JobSearchResponse } from '../types'; // Importe les types définis -const API_BASE_URL = 'http://localhost:3000/api/jobs'; // L'URL de votre API backend +const API_BASE_URL = 'http://localhost:3000/api/jobs'; // L'URL de notre API backend const JobSearch: React.FC = () => { const [jobs, setJobs] = useState([]); @@ -97,8 +98,6 @@ const JobSearch: React.FC = () => { - {/* ... (le reste de votre JSX pour l'affichage des résultats et la pagination reste inchangé) ... */} - {loading &&

Loading job offers...

} {error &&

Error: {error}

} @@ -111,16 +110,19 @@ const JobSearch: React.FC = () => {

Total offers: {total}

{jobs.map((job) => ( -
-

{job.title}

-

Company: {job.companyName || 'N/A'}

-

Location: {job.locationLabel || job.cityName || 'N/A'}

-

Contract: {job.contractLabel || job.contractType || 'N/A'}

-

Published: {new Date(job.publicationDate).toLocaleDateString()}

-

- {job.description?.substring(0, 150)}... -

-
+ // Enveloppe la carte de l'offre avec un lien + +
+

{job.title}

+

Company: {job.companyName || 'N/A'}

+

Location: {job.locationLabel || job.cityName || 'N/A'}

+

Contract: {job.contractLabel || job.contractType || 'N/A'}

+

Published: {new Date(job.publicationDate).toLocaleDateString()}

+

+ {job.description?.substring(0, 150)}... +

+
+ ))}
diff --git a/frontend/src/types.ts b/frontend/src/types.ts index 20eae1c..2f79411 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -12,6 +12,13 @@ export interface JobOffer { companyName?: string; contractType?: string; contractLabel?: string; + urlOffre?: string; // <-- Assurez-vous que ce champ existe + contact?: { // Si vous avez ingéré les infos de contact + urlPostulation?: string; + }; + origineOffre?: { // Si vous avez ingéré les infos d'origine + urlOrigine?: string; + }; // Ajoutez d'autres champs si votre modèle Prisma JobOffer en contient }