From a8e0d9a74d36aa25208661a188c6a5b6e59200e9 Mon Sep 17 00:00:00 2001 From: Flatlogic Bot Date: Sun, 8 Feb 2026 22:41:54 +0000 Subject: [PATCH] v1 --- backend/src/db/db.config.js | 7 +- ...260208000000-add-public-read-job-offers.js | 37 ++ backend/src/index.js | 2 +- .../pages/applications/applications-new.tsx | 613 +++--------------- frontend/src/pages/index.tsx | 243 +++---- .../job_offers/[job_offersId]/public.tsx | 178 +++++ frontend/src/pages/login.tsx | 2 +- frontend/src/pages/register.tsx | 50 +- 8 files changed, 446 insertions(+), 686 deletions(-) create mode 100644 backend/src/db/migrations/20260208000000-add-public-read-job-offers.js create mode 100644 frontend/src/pages/job_offers/[job_offersId]/public.tsx diff --git a/backend/src/db/db.config.js b/backend/src/db/db.config.js index c6aac5a..6ddfba7 100644 --- a/backend/src/db/db.config.js +++ b/backend/src/db/db.config.js @@ -1,4 +1,5 @@ +require('dotenv').config(); module.exports = { production: { @@ -12,10 +13,10 @@ module.exports = { seederStorage: 'sequelize', }, development: { - username: 'postgres', + username: process.env.DB_USER || 'postgres', dialect: 'postgres', - password: '', - database: 'db_site_recrutement_ats', + password: process.env.DB_PASS || '', + database: process.env.DB_NAME || 'db_site_recrutement_ats', host: process.env.DB_HOST || 'localhost', logging: console.log, seederStorage: 'sequelize', diff --git a/backend/src/db/migrations/20260208000000-add-public-read-job-offers.js b/backend/src/db/migrations/20260208000000-add-public-read-job-offers.js new file mode 100644 index 0000000..eb86f81 --- /dev/null +++ b/backend/src/db/migrations/20260208000000-add-public-read-job-offers.js @@ -0,0 +1,37 @@ +module.exports = { + async up(queryInterface, Sequelize) { + const [roles] = await queryInterface.sequelize.query( + `SELECT id FROM "roles" WHERE name = 'Public' LIMIT 1;` + ); + const [permissions] = await queryInterface.sequelize.query( + `SELECT id FROM "permissions" WHERE name = 'READ_JOB_OFFERS' LIMIT 1;` + ); + + if (roles.length && permissions.length) { + await queryInterface.bulkInsert('rolesPermissionsPermissions', [ + { + roles_permissionsId: roles[0].id, + permissionId: permissions[0].id, + createdAt: new Date(), + updatedAt: new Date(), + }, + ]); + } + }, + + async down(queryInterface, Sequelize) { + const [roles] = await queryInterface.sequelize.query( + `SELECT id FROM "roles" WHERE name = 'Public' LIMIT 1;` + ); + const [permissions] = await queryInterface.sequelize.query( + `SELECT id FROM "permissions" WHERE name = 'READ_JOB_OFFERS' LIMIT 1;` + ); + + if (roles.length && permissions.length) { + await queryInterface.bulkDelete('rolesPermissionsPermissions', { + roles_permissionsId: roles[0].id, + permissionId: permissions[0].id, + }); + } + }, +}; \ No newline at end of file diff --git a/backend/src/index.js b/backend/src/index.js index f8107b5..39e8db7 100644 --- a/backend/src/index.js +++ b/backend/src/index.js @@ -95,7 +95,7 @@ app.use('/api/roles', passport.authenticate('jwt', {session: false}), rolesRoute app.use('/api/permissions', passport.authenticate('jwt', {session: false}), permissionsRoutes); -app.use('/api/job_offers', passport.authenticate('jwt', {session: false}), job_offersRoutes); +app.use('/api/job_offers', job_offersRoutes); app.use('/api/applications', passport.authenticate('jwt', {session: false}), applicationsRoutes); diff --git a/frontend/src/pages/applications/applications-new.tsx b/frontend/src/pages/applications/applications-new.tsx index b37f34d..97d7522 100644 --- a/frontend/src/pages/applications/applications-new.tsx +++ b/frontend/src/pages/applications/applications-new.tsx @@ -1,6 +1,6 @@ -import { mdiAccount, mdiChartTimelineVariant, mdiMail, mdiUpload } from '@mdi/js' +import { mdiAccount, mdiChartTimelineVariant, mdiMail, mdiUpload, mdiBriefcaseVariantOutline } from '@mdi/js' import Head from 'next/head' -import React, { ReactElement } from 'react' +import React, { ReactElement, useEffect, useState } from 'react' import CardBox from '../../components/CardBox' import LayoutAuthenticated from '../../layouts/Authenticated' import SectionMain from '../../components/SectionMain' @@ -12,537 +12,116 @@ import FormField from '../../components/FormField' import BaseDivider from '../../components/BaseDivider' import BaseButtons from '../../components/BaseButtons' import BaseButton from '../../components/BaseButton' -import FormCheckRadio from '../../components/FormCheckRadio' -import FormCheckRadioGroup from '../../components/FormCheckRadioGroup' import FormFilePicker from '../../components/FormFilePicker' -import FormImagePicker from '../../components/FormImagePicker' -import { SwitchField } from '../../components/SwitchField' - import { SelectField } from '../../components/SelectField' -import { SelectFieldMany } from "../../components/SelectFieldMany"; import {RichTextField} from "../../components/RichTextField"; import { create } from '../../stores/applications/applicationsSlice' -import { useAppDispatch } from '../../stores/hooks' +import { useAppDispatch, useAppSelector } from '../../stores/hooks' import { useRouter } from 'next/router' -import moment from 'moment'; - -const initialValues = { - - - - - - - - - - - - - - job_offer: '', - - - - - - - - - - - - - - - - candidate: '', - - - - - - - - - - - - - - status: 'submitted', - - - - - - - - - - - - - applied_at: '', - - - - - - - - - - - - - - - - status_changed_at: '', - - - - - - - - - - - - - cover_letter: '', - - - - - - - - - - - - - - - - - - - - - - - attachment_file: [], - - - - - - - - - notes: '', - - - - - - - - - - - - - - - - - rating: '', - - - - - - - - - - - -} - +import axios from 'axios' const ApplicationsNew = () => { const router = useRouter() const dispatch = useAppDispatch() + const { jobId } = router.query + const { currentUser } = useAppSelector((state) => state.auth) + const [job, setJob] = useState(null) - - + useEffect(() => { + if (jobId) { + axios.get(`/job_offers/${jobId}`).then(res => setJob(res.data)).catch(err => console.error(err)) + } + }, [jobId]) const handleSubmit = async (data) => { - await dispatch(create(data)) - await router.push('/applications/applications-list') + const payload = { + ...data, + job_offer: jobId || data.job_offer, + candidate: currentUser?.id, + applied_at: new Date().toISOString(), + status: 'submitted' + } + await dispatch(create(payload)) + await router.push('/') } + return ( <> - {getPageTitle('New Item')} + {getPageTitle('Nouvelle Candidature')} - - {''} + + {''} - - handleSubmit(values)} - > -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - router.push('/applications/applications-list')}/> - - -
-
+ +
+
+ + handleSubmit(values)} + > +
+ {!jobId && ( + + + + )} + + + + + + + + + + + + + router.back()}/> + + +
+
+
+ +
+ {job && ( +
+

Récapitulatif de l'offre

+

{job.company_name}

+
+

Lieu : {job.city}, {job.country}

+

Contrat : {job.contract_type?.replace('_', ' ')}

+
+
+ )} +
+
) @@ -550,14 +129,10 @@ const ApplicationsNew = () => { ApplicationsNew.getLayout = function getLayout(page: ReactElement) { return ( - + {page} ) } -export default ApplicationsNew +export default ApplicationsNew \ No newline at end of file diff --git a/frontend/src/pages/index.tsx b/frontend/src/pages/index.tsx index 1e3dae0..b256683 100644 --- a/frontend/src/pages/index.tsx +++ b/frontend/src/pages/index.tsx @@ -1,166 +1,123 @@ - import React, { useEffect, useState } from 'react'; import type { ReactElement } from 'react'; import Head from 'next/head'; import Link from 'next/link'; +import axios from 'axios'; import BaseButton from '../components/BaseButton'; -import CardBox from '../components/CardBox'; -import SectionFullScreen from '../components/SectionFullScreen'; import LayoutGuest from '../layouts/Guest'; -import BaseDivider from '../components/BaseDivider'; -import BaseButtons from '../components/BaseButtons'; +import SectionMain from '../components/SectionMain'; import { getPageTitle } from '../config'; -import { useAppSelector } from '../stores/hooks'; -import CardBoxComponentTitle from "../components/CardBoxComponentTitle"; -import { getPexelsImage, getPexelsVideo } from '../helpers/pexels'; +import { mdiBriefcaseVariantOutline, mdiMapMarkerOutline, mdiCurrencyUsd, mdiClockOutline } from '@mdi/js'; +import BaseIcon from '../components/BaseIcon'; +export default function Home() { + const [jobs, setJobs] = useState([]); + const [loading, setLoading] = useState(true); -export default function Starter() { - const [illustrationImage, setIllustrationImage] = useState({ - src: undefined, - photographer: undefined, - photographer_url: undefined, - }) - const [illustrationVideo, setIllustrationVideo] = useState({video_files: []}) - const [contentType, setContentType] = useState('image'); - const [contentPosition, setContentPosition] = useState('right'); - const textColor = useAppSelector((state) => state.style.linkColor); - - const title = 'Site Recrutement ATS' - - // Fetch Pexels image/video - useEffect(() => { - async function fetchData() { - const image = await getPexelsImage(); - const video = await getPexelsVideo(); - setIllustrationImage(image); - setIllustrationVideo(video); - } - fetchData(); - }, []); - - const imageBlock = (image) => ( -
-
- - Photo by {image?.photographer} on Pexels - -
-
- ); - - const videoBlock = (video) => { - if (video?.video_files?.length > 0) { - return ( -
- -
- - Video by {video.user.name} on Pexels - -
-
) - } + useEffect(() => { + const fetchJobs = async () => { + try { + const response = await axios.get('/job_offers'); + setJobs(response.data?.rows || []); + } catch (error) { + console.error('Error fetching jobs:', error); + } finally { + setLoading(false); + } }; + fetchJobs(); + }, []); return ( -
+
- {getPageTitle('Starter Page')} + {getPageTitle('Accueil')} - -
- {contentType === 'image' && contentPosition !== 'background' - ? imageBlock(illustrationImage) - : null} - {contentType === 'video' && contentPosition !== 'background' - ? videoBlock(illustrationVideo) - : null} -
- - - -
-

This is a React.js/Node.js app generated by the Flatlogic Web App Generator

-

For guides and documentation please check - your local README.md and the Flatlogic documentation

-
- - - - - -
+ {/* Hero Section */} +
+

+ Trouvez votre prochain job de rêve +

+

+ Découvrez des opportunités passionnantes et postulez en quelques clics. +

+
+ +
- -
-

© 2026 {title}. All rights reserved

- - Privacy Policy - -
+ {/* Jobs Section */} + +
+

Offres d'emploi récentes

+

Rejoignez les meilleures entreprises

+
+ + {loading ? ( +
+
+
+ ) : jobs.length > 0 ? ( +
+ {jobs.map((job: any) => ( +
+
+
+ +
+ + {job.contract_type?.replace('_', ' ') || 'CDI'} + +
+

{job.title}

+

{job.company_name}

+ +
+
+ + {job.city}, {job.country} +
+
+ + {job.salary_min && job.salary_max ? `${job.salary_min} - ${job.salary_max} ${job.currency || '€'}` : 'Salaire non précisé'} +
+
+ + {job.workplace_type || 'On-site'} +
+
+ + +
+ Voir les détails +
+ +
+ ))} +
+ ) : ( +
+

Aucune offre d'emploi disponible pour le moment.

+ +
+ )} +
+ + {/* Footer-ish section */} +
+

© 2026 Site Recrutement ATS. Tous droits réservés.

+
+ Politique de confidentialité + Conditions d'utilisation + Espace Recruteur +
+
); } -Starter.getLayout = function getLayout(page: ReactElement) { +Home.getLayout = function getLayout(page: ReactElement) { return {page}; -}; - +}; \ No newline at end of file diff --git a/frontend/src/pages/job_offers/[job_offersId]/public.tsx b/frontend/src/pages/job_offers/[job_offersId]/public.tsx new file mode 100644 index 0000000..964a588 --- /dev/null +++ b/frontend/src/pages/job_offers/[job_offersId]/public.tsx @@ -0,0 +1,178 @@ + +import React, { useEffect, useState } from 'react'; +import type { ReactElement } from 'react'; +import Head from 'next/head'; +import { useRouter } from 'next/router'; +import axios from 'axios'; +import BaseButton from '../../../components/BaseButton'; +import BaseDivider from '../../../components/BaseDivider'; +import LayoutGuest from '../../../layouts/Guest'; +import SectionMain from '../../../components/SectionMain'; +import { getPageTitle } from '../../../config'; +import { mdiMapMarkerOutline, mdiCurrencyUsd, mdiClockOutline, mdiArrowLeft, mdiOfficeBuildingOutline } from '@mdi/js'; +import BaseIcon from '../../../components/BaseIcon'; +import { useAppSelector } from '../../../stores/hooks'; + +export default function JobPublicView() { + const router = useRouter(); + const { job_offersId } = router.query; + const [job, setJob] = useState(null); + const [loading, setLoading] = useState(true); + const { currentUser } = useAppSelector((state) => state.auth); + + useEffect(() => { + if (job_offersId) { + const fetchJob = async () => { + try { + const response = await axios.get(`/job_offers/${job_offersId}`); + setJob(response.data); + } catch (error) { + console.error('Error fetching job:', error); + } finally { + setLoading(false); + } + }; + fetchJob(); + } + }, [job_offersId]); + + const handleApply = () => { + if (!currentUser) { + router.push(`/register?redirect=/job_offers/${job_offersId}/public&apply=true`); + } else { + router.push(`/applications/new?jobId=${job_offersId}`); + } + }; + + if (loading) { + return ( +
+
+
+ ); + } + + if (!job) { + return ( + +
+

Offre non trouvée

+ router.push('/')} /> +
+
+ ); + } + + return ( +
+ + {getPageTitle(job.title)} + + +
+
+ + +
+
+ + +
+ {/* Main Content */} +
+
+

{job.title}

+ +
+ + {job.contract_type?.replace('_', ' ').toUpperCase() || 'CDI'} + + + {job.experience_level?.toUpperCase() || 'JUNIOR'} + + + {job.workplace_type || 'On-site'} + +
+ +
+

Description du poste

+
+ {job.description || 'Aucune description fournie.'} +
+ +

Profil recherché

+
+ {job.requirements || 'Aucun pré-requis spécifié.'} +
+ +

Avantages

+
+ {job.benefits || 'Aucun avantage spécifié.'} +
+
+
+
+ + {/* Sidebar */} +
+
+

Détails de l'offre

+ +
+
+ +
+

Entreprise

+

{job.company_name}

+
+
+ +
+ +
+

Localisation

+

{job.city}, {job.country}

+
+
+ +
+ +
+

Salaire

+

+ {job.salary_min && job.salary_max ? `${job.salary_min} - ${job.salary_max} ${job.currency || '€'}` : 'Non précisé'} +

+
+
+ +
+ +
+

Type de contrat

+

{job.contract_type?.replace('_', ' ') || 'CDI'}

+
+
+
+ + + + + +

+ Référence : {job.reference_code || 'N/A'} +

+
+
+
+
+
+ ); +} + +JobPublicView.getLayout = function getLayout(page: ReactElement) { + return {page}; +}; diff --git a/frontend/src/pages/login.tsx b/frontend/src/pages/login.tsx index e6e10e1..94084c6 100644 --- a/frontend/src/pages/login.tsx +++ b/frontend/src/pages/login.tsx @@ -65,7 +65,7 @@ export default function Login() { // Redirect to dashboard if user is logged in useEffect(() => { if (currentUser?.id) { - router.push('/dashboard'); + router.push(router.query.redirect as string || '/dashboard'); } }, [currentUser?.id, router]); // Show error message if there is one diff --git a/frontend/src/pages/register.tsx b/frontend/src/pages/register.tsx index 73a3987..8ac9a10 100644 --- a/frontend/src/pages/register.tsx +++ b/frontend/src/pages/register.tsx @@ -18,32 +18,39 @@ import axios from "axios"; export default function Register() { const [loading, setLoading] = React.useState(false); const router = useRouter(); + const { redirect } = router.query; const notify = (type, msg) => toast( msg, {type, position: "bottom-center"}); const handleSubmit = async (value) => { setLoading(true) try { - - const { data: response } = await axios.post('/auth/signup',value); - await router.push('/login') - setLoading(false) - notify('success', 'Please check your email for verification link') - } catch (error) { + await axios.post('/auth/signup', value); + notify('success', 'Inscription réussie ! Veuillez vous connecter.'); + setTimeout(() => { + router.push(`/login${redirect ? `?redirect=${redirect}` : ''}`); + }, 2000); + } catch (error: any) { setLoading(false) console.log('error: ', error) - notify('error', 'Something was wrong. Try again') + const message = error.response?.data || 'Une erreur est survenue.'; + notify('error', message); } }; return ( <> - {getPageTitle('Login')} + {getPageTitle('Inscription')} +
+

Rejoignez-nous

+

Créez votre compte candidat

+
+
- - + + - + - + @@ -69,15 +76,18 @@ export default function Register() { - + +
+ Déjà un compte ?{' '} + + Se connecter + +
@@ -87,6 +97,8 @@ export default function Register() { ); } +import Link from 'next/link'; + Register.getLayout = function getLayout(page: ReactElement) { return {page}; -}; +}; \ No newline at end of file