From 43b0364a5a7e0f872d8547ca5eba7df9d9a6e340 Mon Sep 17 00:00:00 2001 From: tristan Date: Tue, 3 Feb 2026 17:59:39 +0100 Subject: [PATCH] feat : first commit --- .gitignore | 31 ++++++ .nvmrc | 1 + README.md | 2 + frontend/composable/useApi.ts | 183 ++++++++++++++++++++++++++++++++++ frontend/i18n/locales/fr.json | 64 ++++++++++++ frontend/layouts/auth.vue | 7 ++ frontend/layouts/default.vue | 11 ++ frontend/pages/index.vue | 11 ++ frontend/public/malio.png | Bin 0 -> 6529 bytes frontend/tailwind.config.ts | 25 +++++ 10 files changed, 335 insertions(+) create mode 100644 .gitignore create mode 100644 .nvmrc create mode 100644 README.md create mode 100644 frontend/composable/useApi.ts create mode 100644 frontend/i18n/locales/fr.json create mode 100644 frontend/layouts/auth.vue create mode 100644 frontend/layouts/default.vue create mode 100644 frontend/pages/index.vue create mode 100644 frontend/public/malio.png create mode 100644 frontend/tailwind.config.ts diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..260ef0b --- /dev/null +++ b/.gitignore @@ -0,0 +1,31 @@ + +###> symfony/framework-bundle ### +/.env.local +/.env.prod +/.env.local.php +/.env.*.local +/config/secrets/prod/prod.decrypt.private.php +/public/bundles/ +/var/ +/vendor/ +/LOG/ +/config/jwt/*.pem +###< symfony/framework-bundle ### + +###> friendsofphp/php-cs-fixer ### +/.php-cs-fixer.php +/.php-cs-fixer.cache +###< friendsofphp/php-cs-fixer ### + +###> phpunit/phpunit ### +/phpunit.xml +/.phpunit.cache/ +###< phpunit/phpunit ### + +###> docker ### +docker/.env.docker.local +###< docker ### + +###> lexik/jwt-authentication-bundle ### +/config/jwt/*.pem +###< lexik/jwt-authentication-bundle ### diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 0000000..248216a --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +24.12.0 diff --git a/README.md b/README.md new file mode 100644 index 0000000..69c16cb --- /dev/null +++ b/README.md @@ -0,0 +1,2 @@ +# SIRH +Application de gestion des absences employée diff --git a/frontend/composable/useApi.ts b/frontend/composable/useApi.ts new file mode 100644 index 0000000..0d5b79a --- /dev/null +++ b/frontend/composable/useApi.ts @@ -0,0 +1,183 @@ +import type { FetchOptions } from 'ofetch' +import { $fetch, FetchError } from 'ofetch' +import { useAuthStore } from '~/stores/auth' + +export type AnyObject = Record + +export type ApiClient = { + get(url: string, query?: AnyObject, options?: ApiFetchOptions<'json'>): Promise + getBlob(url: string, query?: AnyObject, options?: ApiFetchOptions<'blob'>): Promise + post(url: string, body?: AnyObject, options?: ApiFetchOptions<'json'>): Promise + put(url: string, body?: AnyObject, options?: ApiFetchOptions<'json'>): Promise + patch(url: string, body?: AnyObject, options?: ApiFetchOptions<'json'>): Promise + delete(url: string, query?: AnyObject, options?: ApiFetchOptions<'json'>): Promise +} + +export type ApiFetchOptions = + FetchOptions & { + toast?: boolean + toastTitle?: string + toastErrorMessage?: string + toastSuccessMessage?: string + toastErrorKey?: string + toastSuccessKey?: string + } + +export const useApi = (): ApiClient => { + const config = useRuntimeConfig() + const baseURL = config.public.apiBase ?? '/api' + const toast = useToast() + const auth = useAuthStore() + const nuxtApp = useNuxtApp() + let isHandlingUnauthorized = false + const i18n = nuxtApp.$i18n as + | { + t: (key: string) => string + te?: (key: string) => boolean + } + | undefined + const t = (key: string) => (i18n?.t ? String(i18n.t(key)) : key) + const te = (key: string) => (i18n?.te ? i18n.te(key) : false) + + const extractErrorMessage = (error: unknown, responseData?: unknown): string => { + const data = responseData ?? (error as FetchError)?.data + + if (typeof data === 'string') { + return data + } + + if (data && typeof data === 'object') { + const record = data as Record + return ( + (record['hydra:description'] as string) || + (record.detail as string) || + (record.message as string) || + (record.error as string) || + (record.title as string) || + (record['hydra:title'] as string) || + '' + ) + } + + return (error as FetchError)?.message ?? 'Erreur inconnue.' + } + + const methodErrorKeys: Record = { + GET: 'errors.http.get', + POST: 'errors.http.post', + PUT: 'errors.http.put', + PATCH: 'errors.http.patch', + DELETE: 'errors.http.delete' + } + + const client = $fetch.create({ + baseURL, + retry: 0, + credentials: 'include', + onResponse({ options, response }) { + const apiOptions = options as ApiFetchOptions<'json'> + if (apiOptions?.toast === false) { + return + } + + if (response?.status && response.status >= 400) { + return + } + + const successKey = apiOptions?.toastSuccessKey + const successMessage = + apiOptions?.toastSuccessMessage || + (successKey ? (te(successKey) ? t(successKey) : successKey) : '') + + if (successMessage) { + toast.success({ + title: 'Succès', + message: successMessage + }) + } + }, + async onResponseError({ response, error, options }) { + if (response?.status === 401) { + const requestUrl = typeof options?.url === 'string' ? options.url : '' + if (!requestUrl.includes('login_check') && !requestUrl.includes('logout')) { + if (!isHandlingUnauthorized) { + isHandlingUnauthorized = true + auth.clearSession() + const route = useRoute() + if (route.path !== '/login') { + await navigateTo('/login') + } + isHandlingUnauthorized = false + } + } + return + } + + const apiOptions = options as ApiFetchOptions<'json'> + if (apiOptions?.toast === false) { + return + } + + const method = + typeof options?.method === 'string' ? options.method.toUpperCase() : 'GET' + const defaultKey = methodErrorKeys[method] + const defaultMessage = + defaultKey && te(defaultKey) ? t(defaultKey) : '' + const errorKey = apiOptions?.toastErrorKey + const errorMessage = + errorKey ? (te(errorKey) ? t(errorKey) : errorKey) : '' + const extractedMessage = extractErrorMessage(error, response?._data) + const message = + apiOptions?.toastErrorMessage || + errorMessage || + defaultMessage || + extractedMessage || + 'Une erreur est survenue.' + + toast.error({ + title: apiOptions?.toastTitle ?? 'Erreur', + message + }) + } + }) + + const request = ( + method: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE', + url: string, + options: ApiFetchOptions<'json'> = {} + ) => { + const needsJsonBody = method === 'POST' || method === 'PUT' + const needsMergePatch = method === 'PATCH' + + const headers = new Headers(options.headers as HeadersInit | undefined) + + if (needsMergePatch && !headers.has('Content-Type')) { + headers.set('Content-Type', 'application/merge-patch+json') + } else if (needsJsonBody && !headers.has('Content-Type')) { + headers.set('Content-Type', 'application/json') + } + + return client(url, { ...options, method, headers }) + } + + return { + get(url: string, query: AnyObject = {}, options: ApiFetchOptions<'json'> = {}) { + return request('GET', url, { ...options, query }) + }, + getBlob(url: string, query: AnyObject = {}, options: ApiFetchOptions<'blob'> = {}) { + return client(url, { ...options, method: 'GET', query, responseType: 'blob' }) + }, + post(url: string, body: AnyObject = {}, options: ApiFetchOptions<'json'> = {}) { + return request('POST', url, { ...options, body }) + }, + put(url: string, body: AnyObject = {}, options: ApiFetchOptions<'json'> = {}) { + return request('PUT', url, { ...options, body }) + }, + patch(url: string, body: AnyObject = {}, options: ApiFetchOptions<'json'> = {}) { + return request('PATCH', url, { ...options, body }) + }, + delete(url: string, query: AnyObject = {}, options: ApiFetchOptions<'json'> = {}) { + return request('DELETE', url, { ...options, query }) + } + } +} diff --git a/frontend/i18n/locales/fr.json b/frontend/i18n/locales/fr.json new file mode 100644 index 0000000..6f9026f --- /dev/null +++ b/frontend/i18n/locales/fr.json @@ -0,0 +1,64 @@ +{ + "errors": { + "http": { + "get": "Impossible de récupérer les données.", + "post": "Impossible de créer la ressource.", + "put": "Impossible de mettre à jour la ressource.", + "patch": "Impossible de mettre à jour la ressource.", + "delete": "Impossible de supprimer la ressource." + }, + "reception": { + "list": "Impossible de récupérer la liste des réceptions.", + "fetch": "Impossible de récupérer la réception.", + "create": "Impossible de créer la réception.", + "update": "Impossible de mettre à jour la réception.", + "weigh": "Impossible de récupérer la pesée." + }, + "receptionType": { + "list": "Impossible de récupérer la liste des types de réception." + }, + "merchandiseType": { + "list": "Impossible de récupérer la liste des types de marchandises." + }, + "building": { + "list": "Impossible de récupérer la liste des bâtiments." + }, + "pelletType": { + "list": "Impossible de récupérer la liste des types de granulés." + }, + "receptionPelletBuilding": { + "list": "Impossible de récupérer la liste des dépôts de granulés.", + "create": "Impossible d'enregistrer le dépôt de granulés.", + "delete": "Impossible de supprimer le dépôt de granulés." + }, + "supplier": { + "list": "Impossible de récupérer la liste des fournisseurs." + }, + "truck": { + "list": "Impossible de récupérer la liste des camions." + }, + "carrier": { + "list": "Impossible de récupérer la liste des transporteurs." + }, + "driver": { + "list": "Impossible de récupérer la liste des chauffeurs." + }, + "vehicle": { + "list": "Impossible de récupérer la liste des immatriculations." + }, + "auth": { + "login": "Identifiants invalides.", + "users": "Impossible de récupérer les utilisateurs.", + "logout": "Impossible de se déconnecter." + } + }, + "success": { + "reception": { + "update": "Réception mise à jour avec succès." + }, + "auth": { + "login": "Connexion réussie.", + "logout": "Déconnexion réussie." + } + } +} diff --git a/frontend/layouts/auth.vue b/frontend/layouts/auth.vue new file mode 100644 index 0000000..d1acf22 --- /dev/null +++ b/frontend/layouts/auth.vue @@ -0,0 +1,7 @@ + diff --git a/frontend/layouts/default.vue b/frontend/layouts/default.vue new file mode 100644 index 0000000..9c011bc --- /dev/null +++ b/frontend/layouts/default.vue @@ -0,0 +1,11 @@ + + + + + diff --git a/frontend/pages/index.vue b/frontend/pages/index.vue new file mode 100644 index 0000000..9c011bc --- /dev/null +++ b/frontend/pages/index.vue @@ -0,0 +1,11 @@ + + + + + diff --git a/frontend/public/malio.png b/frontend/public/malio.png new file mode 100644 index 0000000000000000000000000000000000000000..dc520e52d5c49237fa6ace3d278c048bf950f12c GIT binary patch literal 6529 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 + theme: { + extend: { + fontFamily: { + sans: ['"Helvetica Neue"', 'Helvetica', 'Arial', 'sans-serif'] + }, + colors: { + primary: { + 50: '#f6f9ea', + 100: '#eaf2cf', + 200: '#d6e3a4', + 300: '#c1d47a', + 400: '#afc85a', + 500: '#9ebb43', + 600: '#7e9735', + 700: '#607228', + 800: '#414d1a', + 900: '#24290d' + } + } + } + } +}