diff options
Diffstat (limited to 'ping/frontend/src')
36 files changed, 4214 insertions, 0 deletions
diff --git a/ping/frontend/src/app.css b/ping/frontend/src/app.css new file mode 100644 index 0000000..42e2b32 --- /dev/null +++ b/ping/frontend/src/app.css @@ -0,0 +1,58 @@ +@import 'tailwindcss'; +@plugin '@tailwindcss/forms'; +@plugin '@tailwindcss/typography'; + +:root { + --bg-primary: #1A1C22; + --bg-secondary: #282C35; + --btn-primary: #343844; + --btn-primary-hover: #2d8f3a; + --btn-secondary: #454b5a; + --text-lime: #00FF77; +} + +body { + background-color: var(--bg-secondary); +} + +input,select,textarea { + background-color: var(--bg-secondary); + border:none; + border-bottom: 2px solid var(--text-lime); + padding: 8px; + border-radius: 8px 8px 0 0; +} + +input[type="checkbox"], +input[type="radio"] { + accent-color: var(--text-lime); + border-radius: 8px; + border:none; +} + +.btn { + background-color: var(--btn-primary); + color: white; + border: none; + padding: 8px 16px; + border-radius: 4px; + cursor: pointer; + text-decoration: none; + transition-duration: 0.2s; + font-weight: 600; +} + +.btn:hover { + background-color: var(--btn-primary-hover); + transform: scale(1.025); +} + +.card { + background-color: var(--bg-primary); + border-radius: 8px; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); + padding: 16px; + margin: 16px; + color: white; + width: 100%; +}
\ No newline at end of file diff --git a/ping/frontend/src/app.d.ts b/ping/frontend/src/app.d.ts new file mode 100644 index 0000000..da08e6d --- /dev/null +++ b/ping/frontend/src/app.d.ts @@ -0,0 +1,13 @@ +// See https://svelte.dev/docs/kit/types#app.d.ts +// for information about these interfaces +declare global { + namespace App { + // interface Error {} + // interface Locals {} + // interface PageData {} + // interface PageState {} + // interface Platform {} + } +} + +export {}; diff --git a/ping/frontend/src/app.html b/ping/frontend/src/app.html new file mode 100644 index 0000000..e37ecf4 --- /dev/null +++ b/ping/frontend/src/app.html @@ -0,0 +1,13 @@ +<!doctype html> +<html lang="en"> + <head> + <meta charset="utf-8" /> + <link rel="icon" href="%sveltekit.assets%/favicon.png" /> + <title>Patapimvest</title> + <meta name="viewport" content="width=device-width, initial-scale=1" /> + %sveltekit.head% + </head> + <body data-sveltekit-preload-data="hover"> + <div style="display: contents">%sveltekit.body%</div> + </body> +</html> diff --git a/ping/frontend/src/lib/components/Avatar.svelte b/ping/frontend/src/lib/components/Avatar.svelte new file mode 100644 index 0000000..c04b563 --- /dev/null +++ b/ping/frontend/src/lib/components/Avatar.svelte @@ -0,0 +1,48 @@ +<script lang="ts"> + let showTooltip = $state(false); + + interface Props { + username?: string; + url?: string; + onclick?: () => void; + } + + let { username = 'lolo', url = '/img/default-avatar.png', onclick = () => {} }: Props = $props(); +</script> + +<!-- svelte-ignore a11y_no_noninteractive_element_interactions --> +<div style="position: relative; display: inline-block;"> + <!-- svelte-ignore a11y_click_events_have_key_events --> + <img + src={url ?? '/img/default-avatar.png'} + onmouseenter={() => (showTooltip = true)} + onmouseleave={() => (showTooltip = false)} + {onclick} + alt="User Avatar" + /> + {#if showTooltip} + <div class="tooltip">{username}</div> + {/if} +</div> + +<style> + img { + width: 48px; + height: 48px; + border-radius: 50%; + } + .tooltip { + position: absolute; + top: 110%; + left: 50%; + transform: translateX(-50%); + background: #333; + color: #fff; + padding: 4px 8px; + border-radius: 4px; + white-space: nowrap; + font-size: 0.9em; + z-index: 1; + pointer-events: none; + } +</style> diff --git a/ping/frontend/src/lib/components/Button.svelte b/ping/frontend/src/lib/components/Button.svelte new file mode 100644 index 0000000..54b26ab --- /dev/null +++ b/ping/frontend/src/lib/components/Button.svelte @@ -0,0 +1,17 @@ +<script lang="ts"> + // Accept props and ensure onclick matches the expected event handler signature + const { + href = undefined as string | undefined, + onclick = undefined as + | ((event: MouseEvent & { currentTarget: EventTarget & HTMLButtonElement }) => void) + | undefined, + disabled = false, + children + } = $props(); +</script> + +{#if href} + <a class="btn" {href}>{@render children?.()}</a> +{:else} + <button class="btn" {onclick} {disabled}>{@render children?.()}</button> +{/if} diff --git a/ping/frontend/src/lib/components/NavBar.svelte b/ping/frontend/src/lib/components/NavBar.svelte new file mode 100644 index 0000000..1a76876 --- /dev/null +++ b/ping/frontend/src/lib/components/NavBar.svelte @@ -0,0 +1,44 @@ +<script lang="ts"> + const { pageTitle, rightComponent } = $props(); +</script> + +<nav> + <a id="logo" href="/"> + <img src="/img/logo.svg" alt="" style="height:50px" /> + </a> + <div id="middletitle">{pageTitle}</div> + <div id="right">{@render rightComponent()}</div> +</nav> + +<style> + nav { + position: fixed; + width: 100%; + background-color: var(--bg-primary); + color: white; + display: flex; + justify-content: space-between; + align-items: center; + padding: 8px; + height: 64px; + z-index: 2; + } + + #logo { + transition-duration: 0.2s; + } + + #logo:hover { + transform: scale(1.025); + filter: saturate(2); + } + + #middletitle { + font-weight: bold; + font-size: 24px; + } + + #right { + display: flex; + } +</style> diff --git a/ping/frontend/src/lib/components/NumberStatList.svelte b/ping/frontend/src/lib/components/NumberStatList.svelte new file mode 100644 index 0000000..b6444e8 --- /dev/null +++ b/ping/frontend/src/lib/components/NumberStatList.svelte @@ -0,0 +1,55 @@ +<script lang="ts"> + export interface INumberStatList { + name: string; + value: string; + icon: string; + color: string; + } + + const { statsList = [] }: { statsList?: INumberStatList[] } = $props(); +</script> + +<section class="numberStatList"> + {#each statsList as stat} + <div class="card"> + <img src={stat.icon} alt="" class="stat-icon" /> + <span class="stat-name">{stat.name}</span> + <span class="stat-value" style:color={stat.color}>{stat.value}</span> + </div> + {/each} +</section> + +<style> + .numberStatList { + color: white; + display: flex; + width: 100%; + align-items: stretch; + justify-content: stretch; + } + + .numberStatList :global(.card) { + flex: 1; + display: grid; + grid-template-areas: 'icon name' 'icon amount'; + align-items: center; + justify-content: space-around; + } + + .stat-icon { + width: 24px; + height: 24px; + grid-area: icon; + } + + .stat-name { + grid-area: name; + font-weight: bold; + } + + .stat-value { + grid-area: amount; + font-size: 1.2em; + font-weight: bold; + } +</style> diff --git a/ping/frontend/src/lib/components/SideBar.svelte b/ping/frontend/src/lib/components/SideBar.svelte new file mode 100644 index 0000000..9ea8a04 --- /dev/null +++ b/ping/frontend/src/lib/components/SideBar.svelte @@ -0,0 +1,61 @@ +<script lang="ts"> + import { pages } from '$lib/pages'; + + const { + selectedIndex = $bindable(0) + }: { + selectedIndex: number; + } = $props(); + + const items = pages; +</script> + +<section id="sidebar"> + {#each items as item, index} + <!-- svelte-ignore a11y_consider_explicit_label --> + <a href={item.href} class:selected={index == selectedIndex} class="flex items-center gap-2"> + <img + src={item.icon} + alt=" " + class="w-6" + style="filter: {index == selectedIndex + ? 'invert(68%) sepia(97%) saturate(749%) hue-rotate(97deg) brightness(154%) contrast(101%)' + : 'none'};" + /> + <span>{item.name}</span> + </a> + {/each} +</section> + +<style> + #sidebar { + position: fixed; + background-color: var(--bg-primary); + height: 100vh; + width: 200px; + padding-top: 72px; + color: white; + display: flex; + flex-direction: column; + gap: 8px; + } + + #sidebar > a { + padding: 8px; + padding-right: 0; + transition-duration: 0.2s; + border-color: var(--text-lime); + } + + #sidebar > a:is(:hover, :focus) { + background-color: var(--bg-secondary); + } + + .selected { + color: var(--text-lime); + background-color: var(--bg-secondary); + font-weight: bold; + border-right: 6px var(--text-lime) solid; + width: 100%; + } +</style> diff --git a/ping/frontend/src/lib/components/SteppedLineChart.svelte b/ping/frontend/src/lib/components/SteppedLineChart.svelte new file mode 100644 index 0000000..d393b44 --- /dev/null +++ b/ping/frontend/src/lib/components/SteppedLineChart.svelte @@ -0,0 +1,76 @@ +<script lang="ts"> + import { + Chart, + CategoryScale, + LinearScale, + PointElement, + LineElement, + Title, + Tooltip, + Legend, + LineController + } from 'chart.js'; + import { onMount } from 'svelte'; + + Chart.register( + CategoryScale, + LinearScale, + PointElement, + LineElement, + Title, + Tooltip, + Legend, + LineController + ); + + interface IProps { + color?: string; + data?: any[]; + title?: string; + legend?: string; + } + + const { color = 'green', data = [], title = '', legend = '' }: IProps = $props(); + + function updateGraph() { + const ctx = document.getElementById(id) as HTMLCanvasElement; + const chart = new Chart(ctx, { + type: 'line', + data: { + labels: data.map((d) => d.label), + datasets: [ + { + label: legend, + data: data.map((d) => d.value), + borderColor: color, + backgroundColor: color, + fill: false, + tension: 0.1 + } + ] + }, + options: { + responsive: true, + plugins: { + title: { + display: true, + text: title + }, + legend: { + display: true + } + } + } + }); + } + + const id = 'graph-' + crypto.randomUUID(); + + onMount(() => { + updateGraph(); + }); +</script> + +<div> + <canvas {id}></canvas> +</div> diff --git a/ping/frontend/src/lib/components/ToastList.svelte b/ping/frontend/src/lib/components/ToastList.svelte new file mode 100644 index 0000000..5351ef7 --- /dev/null +++ b/ping/frontend/src/lib/components/ToastList.svelte @@ -0,0 +1,67 @@ +<script lang="ts"> + import { toastList } from '$lib/stores/toast'; +</script> + +<div class="toast"> + {#each $toastList as toast} + <div class="toast-item" style="border-top: 4px {toast.color} solid"> + <h3 style="color: {toast.color}">{toast.title}</h3> + <p>{toast.message}</p> + </div> + {/each} +</div> + +<style> + .toast { + position: fixed; + bottom: 20px; + right: 20px; + z-index: 1000; + display: flex; + flex-direction: column; + gap: 10px; + } + + .toast-item { + background-color: var(--bg-secondary); + padding: 10px; + color: white; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15); + transition: + transform 0.3s, + opacity 0.3s; + transform: translateY(30px); + opacity: 0; + animation-delay: 0s, 4.7s; + animation-name: toast-in, toast-out; + animation-duration: 0.3s, 0.3s; + animation-fill-mode: forwards, forwards; + } + + @keyframes toast-in { + from { + transform: translateY(30px); + opacity: 0; + } + to { + transform: translateY(0); + opacity: 1; + } + } + + @keyframes toast-out { + from { + transform: translateY(0); + opacity: 1; + } + to { + transform: translateY(30px); + opacity: 0; + } + } + + .toast-item h3 { + margin: 0; + font-weight: bold; + } +</style> diff --git a/ping/frontend/src/lib/components/UserItem.svelte b/ping/frontend/src/lib/components/UserItem.svelte new file mode 100644 index 0000000..44add60 --- /dev/null +++ b/ping/frontend/src/lib/components/UserItem.svelte @@ -0,0 +1,42 @@ +<script lang="ts"> + import Avatar from './Avatar.svelte'; + import type { IUser } from '$lib/stores/auth'; + + const { user, onEdit, onDelete }: { user: IUser; onEdit: any; onDelete: any } = $props(); +</script> + +<div class="useritem"> + <div class="useritem-avatar"> + <Avatar username={user.displayName} url={user.avatar || '/img/default-avatar.png'} /> + <button class="btn" onclick={onEdit}> Modifier </button> + <button class="btn" onclick={onDelete}> Supprimer </button> + </div> + <div class="useritem-details"> + <p><b>Nom</b>: {user.displayName}</p> + <p><b>Login</b>: {user.login}</p> + <p><b>Role</b>: {user.isAdmin ? 'ADMIN' : 'USER'}</p> + <p><b>Id</b>: {user.id}</p> + </div> +</div> + +<style> + .useritem { + display: flex; + background-color: var(--bg-secondary); + margin: 16px 0; + padding: 8px; + border-radius: 8px; + gap: 8px; + } + + .useritem-avatar, + .useritem-details { + display: flex; + flex-direction: column; + gap: 4px; + } + + .useritem-avatar { + align-items: center; + } +</style> diff --git a/ping/frontend/src/lib/components/dashboard/RiskAnalysis.svelte b/ping/frontend/src/lib/components/dashboard/RiskAnalysis.svelte new file mode 100644 index 0000000..3844abc --- /dev/null +++ b/ping/frontend/src/lib/components/dashboard/RiskAnalysis.svelte @@ -0,0 +1,10 @@ +<h2>Analyse de risque</h2> +<i>tkt t safe c hardcodé chef</i> + +<style> + h2 { + font-weight: bold; + color: var(--text-lime); + font-size: 24px; + } +</style> diff --git a/ping/frontend/src/lib/components/dashboard/StockGraph.svelte b/ping/frontend/src/lib/components/dashboard/StockGraph.svelte new file mode 100644 index 0000000..beefed9 --- /dev/null +++ b/ping/frontend/src/lib/components/dashboard/StockGraph.svelte @@ -0,0 +1,136 @@ +<script lang="ts"> + import Chart from 'chart.js/auto'; + import { onMount } from 'svelte'; + import StockSelector from '../input/StockSelector.svelte'; + + async function fetchChartData(stock: string, startDate: string, endDate: string) { + const res = await fetch( + `/stocksapi/chart?query=${stock}&startDate=${startDate}&endDate=${endDate}&interval=${range}`, + { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + 'Cache-Control': 'no-cache' + } + } + ); + return await res.json(); + } + + function updateGraph() { + fetchChartData(selectedStock, startDate, endDate).then((data) => { + if (chart) { + chart.destroy(); + chart = null; + } + + const { quotes, meta } = data; + + validRanges = meta.validRanges; + + const labels = quotes.map((item: any) => item.date.split('T')[0]); + const datasets = ['low', 'high', 'open', 'close'].map((key) => ({ + label: key.charAt(0).toUpperCase() + key.slice(1), + data: quotes.map((item: any) => item[key]), + borderColor: + key === 'low' + ? 'rgb(255, 99, 132)' + : key === 'high' + ? 'rgb(54, 162, 235)' + : key === 'open' + ? 'rgb(255, 205, 86)' + : 'rgb(75, 192, 192)', + fill: false + })); + + // @ts-ignore + chart = new Chart(document.getElementById('stockgraph'), { + type: 'line', + data: { + labels, + datasets + }, + options: { + responsive: true, + maintainAspectRatio: true, + plugins: { + title: { + display: true, + text: meta.longName + } + } + } + }); + }); + } + + let today = $state(new Date().toISOString().split('T')[0]); + + let selectedStock = $state('2223.SR'); + let startDate = $state(''); + let endDate = $state(''); + let chart = $state<Chart | null>(null); + let range = $state('1d'); + let validRanges = $state(['1d']); + + onMount(() => { + endDate = new Date().toISOString().split('T')[0]; + startDate = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString().split('T')[0]; + + updateGraph(); + }); + + $effect(() => { + if (selectedStock && startDate && endDate && range) { + updateGraph(); + } + }); + + $effect(() => { + if (startDate && endDate && startDate > endDate) { + let temp = startDate; + startDate = endDate; + endDate = temp; + } + }); +</script> + +<div> + <div class="header"> + <h2>Vue d'ensemble : {selectedStock}</h2> + <div class="controls"> + <input type="date" name="startDate" id="startDate" bind:value={startDate} max={today} /> + <input type="date" name="endDate" id="endDate" bind:value={endDate} max={today} /> + <select name="validRanges" id="validRanges" bind:value={range}> + {#each validRanges as r} + <option value={r}> + {r} + </option> + {/each} + </select> + + <StockSelector bind:selectedStock /> + </div> + </div> + <canvas id="stockgraph"></canvas> +</div> + +<style> + .header { + display: flex; + align-items: center; + justify-content: space-between; + max-height: 512px !important; + } + + h2 { + font-weight: bold; + color: var(--text-lime); + font-size: 24px; + } + + #stockgraph { + width: 100% !important; + max-height: 512px !important; + } +</style> diff --git a/ping/frontend/src/lib/components/dashboard/TrendingSymbols.svelte b/ping/frontend/src/lib/components/dashboard/TrendingSymbols.svelte new file mode 100644 index 0000000..3d947ad --- /dev/null +++ b/ping/frontend/src/lib/components/dashboard/TrendingSymbols.svelte @@ -0,0 +1,85 @@ +<script lang="ts"> + import { Chart } from 'chart.js'; + import { onMount } from 'svelte'; + + function updateGraph() { + getTrendingSymbols().then((trendingSymbols) => { + const labels = trendingSymbols.map((d) => d.longName); + + const datasets = [ + { + label: '50 day performance', + data: trendingSymbols.map((ts) => ts.fiftyDayAverageChange), + backgroundColor: 'green' + }, + { + label: '52 week performance', + data: trendingSymbols.map((ts) => ts.fiftyTwoWeekLowChange), + backgroundColor: 'orange' + } + ]; + + const data = { labels, datasets }; + + // @ts-ignore + new Chart(document.getElementById('graph'), { + type: 'bar', + data: data, + options: { + indexAxis: 'y', + elements: { + bar: { + borderWidth: 2 + } + }, + responsive: true, + plugins: { + legend: { + position: 'bottom' + } + } + } + }); + }); + } + + async function getTrendingSymbols() { + const trendingRes = await fetch('/stocksapi/trendingSymbols'); + if (!trendingRes.ok) { + throw new Error('Failed to fetch trending symbols'); + } + const trendingJson = await trendingRes.json(); + + const { quotes } = trendingJson; + + const trendingSymbols = await Promise.all( + quotes.map(async (quote: any) => { + const quoteRes = await fetch(`/stocksapi/quote?query=${quote.symbol}`); + const quoteJson = await quoteRes.json(); + return quoteJson; + }) + ); + + return trendingSymbols; + } + + onMount(() => { + updateGraph(); + }); +</script> + +<h2>Tendances</h2> +<canvas id="graph"></canvas> + +<style> + h2 { + font-weight: bold; + color: var(--text-lime); + font-size: 24px; + } + + #graph { + max-height: 40vh; + max-width: 40vw; + } +</style> diff --git a/ping/frontend/src/lib/components/dashboard/transactions/TransactionModal.svelte b/ping/frontend/src/lib/components/dashboard/transactions/TransactionModal.svelte new file mode 100644 index 0000000..b9f0224 --- /dev/null +++ b/ping/frontend/src/lib/components/dashboard/transactions/TransactionModal.svelte @@ -0,0 +1,165 @@ +<script lang="ts"> + import { authFetch } from '$lib/stores/auth'; + import { onMount } from 'svelte'; + + interface IProps { + isOpen: boolean; + onCreate: (transaction: any) => void; + } + + let { isOpen = $bindable(false), onCreate = () => {} }: IProps = $props(); // Changed default to false + + let amount: number = $state(0); + let currency: string = $state('USD'); + let label: string = $state(''); + let receiverLabel: string = $state(''); + let receiverIban: string = $state(''); + let operationDate: string = $state(''); + + function closeDialog() { + isOpen = false; + } + + async function createTransaction(event: Event) { + event.preventDefault(); + + const transaction = { + amount, + currency, + label, + receiverLabel, + receiverIban, + operationDate + }; + + try { + const response = await authFetch('/api/transactions', { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify(transaction) + }); + + if (!response.ok) { + const error = await response.json(); + alert('Erreur lors de la création de la transaction: ' + error); + return; + } + + onCreate(await response.json()); + + closeDialog(); + } catch (err) { + alert('Erreur réseau lors de la création de la transaction'); + } + } +</script> + +<!-- svelte-ignore a11y_click_events_have_key_events --> +<!-- svelte-ignore a11y_no_static_element_interactions --> +{#if isOpen} + <div class="backdrop" onclick={closeDialog}> + <dialog open onclick={(e) => e.stopPropagation()}> + <h2>Créer une nouvelle transaction</h2> + <form class="flex flex-col gap-2"> + <label> + <span>Montant</span> + <input type="number" step="0.01" min="0" bind:value={amount} required /> + </label> + <label> + <span>Devise</span> + <select bind:value={currency} required> + <option selected value="USD" data-symbol="$" data-name="Dollar américain" + >USD - Dollar américain</option + > + <option value="EUR" data-symbol="€" data-name="Euro">EUR - Euro</option> + <option value="GBP" data-symbol="£" data-name="Livre sterling" + >GBP - Livre sterling</option + > + <option value="JPY" data-symbol="¥" data-name="Yen japonais">JPY - Yen japonais</option> + <option value="CHF" data-symbol="Fr" data-name="Franc suisse">CHF - Franc suisse</option + > + <option value="CAD" data-symbol="$" data-name="Dollar canadien" + >CAD - Dollar canadien</option + > + <option value="AUD" data-symbol="$" data-name="Dollar australien" + >AUD - Dollar australien</option + > + <option value="CNY" data-symbol="¥" data-name="Yuan chinois">CNY - Yuan chinois</option> + </select> + </label> + <label> + <span>Libellé</span> + <input type="text" bind:value={label} required /> + </label> + <label> + <span>Libellé du bénéficiaire</span> + <input type="text" bind:value={receiverLabel} required /> + </label> + <label> + <span>IBAN du bénéficiaire</span> + <input type="text" bind:value={receiverIban} required /> + </label> + <label> + <span>Date de l'opération</span> + <input type="datetime-local" bind:value={operationDate} required /> + </label> + <button class="btn" onclick={createTransaction}>➕ Créer</button> + </form> + </dialog> + </div> +{/if} + +<style> + .backdrop { + position: fixed; + top: 0; + left: 0; + width: 100vw; + height: 100vh; + background: rgba(0, 0, 0, 0.4); + display: flex; + align-items: center; + justify-content: center; + z-index: 1000; + } + + dialog { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + max-width: 90vw; + max-height: 90vh; + border: none; + border-radius: 8px; + box-shadow: 0 2px 16px rgba(0, 0, 0, 0.2); + padding: 2rem; + background: var(--bg-primary); + outline: none; + display: flex; + flex-direction: column; + gap: 16px; + align-items: center; + justify-content: center; + color: white; + } + + h2 { + font-weight: 600; + font-size: 32px; + color: var(--text-lime); + } + + form { + display: flex; + flex-direction: column; + gap: 8px; + } + + label { + display: flex; + flex-direction: column; + } +</style> diff --git a/ping/frontend/src/lib/components/input/StockSelector.svelte b/ping/frontend/src/lib/components/input/StockSelector.svelte new file mode 100644 index 0000000..1237128 --- /dev/null +++ b/ping/frontend/src/lib/components/input/StockSelector.svelte @@ -0,0 +1,231 @@ +<script lang="ts"> + import { onMount } from 'svelte'; + import Button from '../Button.svelte'; + + let { selectedStock = $bindable('AAPL') } = $props(); + + let stocks: { + symbol: string; + shortname: string; + quoteType: string; + isYahooFinance: boolean; + }[] = $state([]); + let news: { + link: string; + title: string; + thumbnail: { resolutions: { url: string; width: number; height: number }[] }; + }[] = $state([]); + + function openDialog() { + const dialog = document.querySelector('dialog'); + const backdrop = document.getElementById('backdrop'); + // @ts-ignore + dialog.open = true; + // @ts-ignore + backdrop.style.display = 'block'; + } + + function closeDialog() { + const dialog = document.querySelector('dialog'); + const backdrop = document.getElementById('backdrop'); + // @ts-ignore + dialog.open = false; + // @ts-ignore + backdrop.style.display = 'none'; + } + + function onSearch(event: SubmitEvent) { + event.preventDefault(); + // @ts-ignore + const fd = new FormData(event.target); + const searchQuery = fd.get('search'); + + fetch(`/stocksapi/search?query=${searchQuery}`, { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + 'Cache-Control': 'no-cache' + } + }) + .then((response) => response.json()) + .then((data) => { + stocks = data.quotes.filter((q: any) => q.isYahooFinance); + news = data.news; + }) + .catch((error) => { + console.error('Error fetching stock data:', error); + }); + } + + onMount(() => { + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape') { + closeDialog(); + } + }; + window.addEventListener('keydown', handleKeyDown); + return () => { + window.removeEventListener('keydown', handleKeyDown); + }; + }); +</script> + +<Button onclick={openDialog}>{selectedStock}</Button> + +<dialog> + <form onsubmit={onSearch}> + <input + type="search" + name="search" + id="search" + placeholder="Recherche d'actions, ETFs, entreprises" + value={selectedStock || ''} + /> + <button type="submit" class="btn">Rechercher</button> + </form> + <div class="results"> + <div class="stocks"> + {#if stocks.length === 0} + <p>Aucune action, ETFs trouvés.</p> + {:else} + <p>{stocks.length} actions, ETFs trouvés</p> + {#each stocks as stock} + <button + class="btn stock" + onclick={() => { + selectedStock = stock.symbol; + closeDialog(); + }} + disabled={!stock.isYahooFinance} + > + <pre>{stock.symbol}</pre> + <span>{stock.shortname}</span> + <i>{stock.quoteType}</i> + </button> + {/each} + {/if} + </div> + <div class="news"> + {#if news.length === 0} + <p>Pas de news trouvés.</p> + {:else} + <p>{news.length} news trouvées</p> + {#each news as newsItem} + <a class="newsItem" href={newsItem.link}> + {#if newsItem.thumbnail?.resolutions?.length > 0} + <img + src={newsItem.thumbnail.resolutions[0].url} + alt="News Thumbnail" + width={newsItem.thumbnail.resolutions[0].width} + height={newsItem.thumbnail.resolutions[0].height} + /> + {/if} + <h1>{newsItem.title}</h1></a + > + {/each} + {/if} + </div> + </div> +</dialog> +<!-- svelte-ignore a11y_click_events_have_key_events --> +<!-- svelte-ignore a11y_no_static_element_interactions --> +<div id="backdrop" onclick={closeDialog}></div> + +<style> + dialog { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + width: 300px; + padding: 20px; + background-color: var(--bg-primary); + border: 4px solid #ccc; + border-radius: 16px; + box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1); + height: 90vh; + width: 90vw; + z-index: 1000; + color: white; + } + + #backdrop { + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + background-color: rgba(0, 0, 0, 0.5); + z-index: 999; + display: none; + } + + form { + display: flex; + gap: 10px; + } + + input[type='search'] { + width: 300px; + } + + .results { + display: flex; + flex-direction: row; + gap: 20px; + justify-content: space-evenly; + } + + .stocks, + .news { + display: flex; + flex-direction: column; + gap: 10px; + overflow-y: auto; + max-height: 70vh; + padding: 16px; + flex: 1; + } + + .stock { + display: flex; + align-items: center; + gap: 8px; + } + + .stock pre { + font-weight: bold; + padding: 8px; + background: var(--bg-primary); + border-radius: 8px; + } + + .stock span { + font-weight: bold; + color: var(--text-lime); + } + + .newsItem { + display: flex; + flex-direction: column; + background-color: var(--bg-secondary); + padding-bottom: 4px; + border-radius: 8px; + transition-duration: 0.2s; + } + + .newsItem h1 { + margin: 16px; + } + + .newsItem img { + border-radius: 8px; + width: 100%; + height: auto; + } + + .newsItem:hover { + transform: scale(1.025); + background-color: #333; + } +</style> diff --git a/ping/frontend/src/lib/components/input/UserSelector.svelte b/ping/frontend/src/lib/components/input/UserSelector.svelte new file mode 100644 index 0000000..a0c80c7 --- /dev/null +++ b/ping/frontend/src/lib/components/input/UserSelector.svelte @@ -0,0 +1,35 @@ +<script lang="ts"> + import type { IUser } from '$lib/stores/auth'; + import { addToast } from '$lib/stores/toast'; + import Avatar from '../Avatar.svelte'; + + let { users = $bindable<IUser[]>([]) }: { users: IUser[] } = $props(); +</script> + +<div> + {#each users as u, i} + <Avatar + username={u.displayName} + url={u.avatar} + onclick={() => { + users = users.filter((_, index) => index !== i); + }} + /> + {/each} + <Avatar + username={'Ajouter un utilisateur'} + url={'/icons/add-green.svg'} + onclick={() => { + let userId = prompt("Entrez l'ID de l'utilisateur à ajouter :"); + if (userId === null || userId.trim() === '') { + addToast({ title: 'ID utilisateur invalide.' }); + return; + } + if (users.map((u) => u.displayName).includes(userId)) { + addToast({ title: 'Cet utilisateur est déjà ajouté.' }); + return; + } + users = [...users, { displayName: userId, avatar: '/img/default-avatar.png' } as IUser]; + }} + /> +</div> diff --git a/ping/frontend/src/lib/pages.ts b/ping/frontend/src/lib/pages.ts new file mode 100644 index 0000000..e5d424e --- /dev/null +++ b/ping/frontend/src/lib/pages.ts @@ -0,0 +1,54 @@ +interface SideBarItem { + icon: string; + name: string; + href: string; +} + +export const pages: SideBarItem[] = [ + { + name: 'Dashboard', + icon: '/icons/dashboard.svg', + href: '/dashboard' + }, + { + name: 'Transactions', + icon: '/icons/credit-card.svg', + href: '/dashboard/transactions' + }, + { + name: 'Modèles', + icon: '/icons/floppy-disk.svg', + href: '/dashboard/models' + }, + { + name: 'Analyses', + icon: '/icons/magnifying_glass_icon.svg', + href: '/dashboard/analyses' + }, + { + name: 'Personnel', + icon: '/icons/people.svg', + href: '/dashboard/personnel' + }, + { + name: 'Messages', + icon: '/icons/msg.svg', + href: '/dashboard/messages' + }, + { + name: 'Paramètres', + icon: '/icons/settings.svg', + href: '/dashboard/settings' + } +]; + +export function getPageIndex(pathname: string) { + if (pathname === '/dashboard') return 0; + if (pathname.startsWith('/dashboard/transactions')) return 1; + if (pathname.startsWith('/dashboard/models')) return 2; + if (pathname.startsWith('/dashboard/analyses')) return 3; + if (pathname.startsWith('/dashboard/personnel')) return 4; + if (pathname.startsWith('/dashboard/messages')) return 5; + if (pathname.startsWith('/dashboard/settings')) return 6; + return -1; // Not found +}
\ No newline at end of file diff --git a/ping/frontend/src/lib/stores/auth.ts b/ping/frontend/src/lib/stores/auth.ts new file mode 100644 index 0000000..97acaf2 --- /dev/null +++ b/ping/frontend/src/lib/stores/auth.ts @@ -0,0 +1,66 @@ +import { error } from "@sveltejs/kit"; +import { get, writable } from "svelte/store"; + +export interface IUser { + id: string; + login: string; + displayName: string; + avatar: string; + isAdmin: boolean; +} + +export const user = writable<IUser | null>(null); + +export async function getUser() { + const userVal = get(user); + if (userVal) { + return userVal; + } + return getUpdatedUser(); +} + +export async function getUpdatedUser() { + const token = localStorage.getItem("token"); + if (!token) { + localStorage.clear(); + window.location.replace("/login"); + throw new Error("Pas de token"); + } + try { + const userId = JSON.parse(atob(token.split(".")[1])).sub; + + const res = await authFetch(`/api/user/${userId}`); + if (!res.ok) { + throw error(res.status, "Erreur lors de la récupération de l'utilisateur"); + } + const data = await res.json(); + user.set({ ...data }); + return data; + } + catch (e) { + console.error("Erreur lors de la récupération de l'ID utilisateur depuis le token", e); + localStorage.clear(); + window.location.replace("/login"); + throw new Error("Token invalide " + e); + } +} + +// place files you want to import through the `$lib` alias in this folder. +export function authFetch(url: string | URL, options: RequestInit = {}) { + const token = localStorage.getItem("token"); + if (!token) { + localStorage.clear(); + window.location.replace("/login"); + throw new Error("Pas de token"); + } + + const mergedOptions: RequestInit = { + ...options, + headers: { + Authorization: `Bearer ${token}`, + ...(options.headers ?? {}) + } + }; + + return fetch(url, mergedOptions); +}
\ No newline at end of file diff --git a/ping/frontend/src/lib/stores/toast.ts b/ping/frontend/src/lib/stores/toast.ts new file mode 100644 index 0000000..454461b --- /dev/null +++ b/ping/frontend/src/lib/stores/toast.ts @@ -0,0 +1,22 @@ +import { writable } from "svelte/store"; + +export interface Toast { + color?: string | undefined; + title?: string | undefined; + message?: string | undefined; +} + +export const toastList = writable<Toast[]>([]); + +export function addToast(toast: Toast) { + toast ??= { color: "red", title: "Error", message: "An error occurred" }; + toast.color ??= "red"; + toast.title ??= "Error"; + toast.message ??= "An error occurred"; + + toastList.update((list) => [...list, toast]); + + setTimeout(() => { + toastList.update((list) => list.filter((t) => t !== toast)); + }, 5000); +}
\ No newline at end of file diff --git a/ping/frontend/src/routes/+layout.svelte b/ping/frontend/src/routes/+layout.svelte new file mode 100644 index 0000000..ba995cc --- /dev/null +++ b/ping/frontend/src/routes/+layout.svelte @@ -0,0 +1,9 @@ +<script lang="ts"> + import ToastList from '$lib/components/ToastList.svelte'; + import '../app.css'; + + let { children } = $props(); +</script> + +{@render children()} +<ToastList /> diff --git a/ping/frontend/src/routes/+page.svelte b/ping/frontend/src/routes/+page.svelte new file mode 100644 index 0000000..7088945 --- /dev/null +++ b/ping/frontend/src/routes/+page.svelte @@ -0,0 +1,59 @@ +<script lang="ts"> + import Button from '$lib/components/Button.svelte'; + import NavBar from '$lib/components/NavBar.svelte'; +</script> + +<NavBar pageTitle=""> + <!-- Composant gauche de la navbar --> + {#snippet rightComponent()} + <Button href="/login">Connexion</Button> + {/snippet} +</NavBar> +<header></header> +<section class="colored"></section> +<section id="bottomcard"> + <div class="card"> + <h1>Investissons <b>plus mieux</b> et <b>plus vert</b></h1> + <ul> + <li>Investissez facilement</li> + <li>Analysez les risques et les performances</li> + <li>Soulagez votre éco-conscience</li> + </ul> + </div> +</section> + +<style> + header { + background-image: url('/img/header-bg.jpg'); + height: 80vh; + background-size: cover; + background-position: center; + } + + .colored { + background-color: var(--bg-secondary); + height: 20vh; + } + + #bottomcard { + position: fixed; + bottom: 10vh; + display: flex; + justify-content: center; + align-items: center; + color: white; + } + + #bottomcard h1 { + font-size: 2em; + margin-bottom: 10px; + color: var(--text-lime); + } + + #bottomcard ul { + list-style: square; + padding: 0; + margin: 0; + margin-left: 20px; + } +</style> diff --git a/ping/frontend/src/routes/dashboard/+layout.svelte b/ping/frontend/src/routes/dashboard/+layout.svelte new file mode 100644 index 0000000..a2f7efe --- /dev/null +++ b/ping/frontend/src/routes/dashboard/+layout.svelte @@ -0,0 +1,44 @@ +<script lang="ts"> + import ToastList from '$lib/components/ToastList.svelte'; + import Avatar from '$lib/components/Avatar.svelte'; + import NavBar from '$lib/components/NavBar.svelte'; + import NumberStatList from '$lib/components/NumberStatList.svelte'; + import SideBar from '$lib/components/SideBar.svelte'; + + import '../../app.css'; + import { getPageIndex, pages } from '$lib/pages'; + import { onMount } from 'svelte'; + import { page } from '$app/stores'; + import { user } from '$lib/stores/auth'; + + let { children } = $props(); + + let selectedIndex = $state(0); + + $effect(() => { + selectedIndex = getPageIndex($page.url.pathname); + }); +</script> + +<ToastList /> +<NavBar pageTitle={pages[selectedIndex].name}> + {#snippet rightComponent()} + <div class="flex items-center gap-4"> + <form> + <input type="search" name="search" id="search" placeholder="Search..." /> + </form> + <Avatar username={$user?.login ?? 'No auth'} url={$user?.avatar} /> + </div> + {/snippet} +</NavBar> +<SideBar bind:selectedIndex /> +<section class="dashboard">{@render children()}</section> + +<style> + section.dashboard { + padding-left: 208px; + padding-top: 72px; + width: 100%; + height: calc(100vh); + } +</style> diff --git a/ping/frontend/src/routes/dashboard/+page.svelte b/ping/frontend/src/routes/dashboard/+page.svelte new file mode 100644 index 0000000..c38e281 --- /dev/null +++ b/ping/frontend/src/routes/dashboard/+page.svelte @@ -0,0 +1,81 @@ +<script lang="ts"> + import NumberStatList from '$lib/components/NumberStatList.svelte'; + import type { INumberStatList } from '$lib/components/NumberStatList.svelte'; + import RiskAnalysis from '$lib/components/dashboard/RiskAnalysis.svelte'; + import StockGraph from '$lib/components/dashboard/StockGraph.svelte'; + import TrendingSymbols from '$lib/components/dashboard/TrendingSymbols.svelte'; + import { onMount } from 'svelte'; + + const statsList: INumberStatList[] = [ + { + name: 'Portefeuille', + color: 'aqua', + value: `${Math.random().toFixed(3) * 1000}€`, + icon: '/icons/wallet.svg' + }, + { + name: 'Revenus', + color: '#1FCB4F', + value: `${Math.random().toFixed(3) * 1000}€`, + icon: '/icons/money-bills.svg' + }, + { + name: 'Depenses', + color: 'orange', + value: `${Math.random().toFixed(3) * 1000}€`, + icon: '/icons/credit-card.svg' + }, + { + name: 'Eco score', + color: '#1FCB4F', + value: `${Math.random().toFixed(3) * 1000}`, + icon: '/icons/leaf.svg' + } + ]; + + onMount(() => {}); +</script> + +<section class="dashboard-home"> + <NumberStatList {statsList} /> + <div class="home-grid"> + <div class="card overviewGraph"> + <StockGraph /> + </div> + <!-- <div class="card riskAnalysis"> + <RiskAnalysis /> + </div> --> + <!-- <div class="card activity">c</div> --> + <div class="card tendencies"> + <TrendingSymbols /> + </div> + <!-- <div class="card recentInvestments">e</div> --> + </div> +</section> + +<style> + .home-grid { + display: grid; + grid-template-areas: + 'overviewGraph overviewGraph overviewGraph' + 'tendencies tendencies tendencies'; + gap: 16px; + width: calc(100% - 32px); + } + + .overviewGraph { + grid-area: overviewGraph; + } + /* .riskAnalysis { + grid-area: riskAnalysis; + } + .activity { + grid-area: activity; + } */ + .tendencies { + grid-area: tendencies; + } + /* .recentInvestments { + grid-area: recentInvestments; + } */ +</style> diff --git a/ping/frontend/src/routes/dashboard/analyses/+page.svelte b/ping/frontend/src/routes/dashboard/analyses/+page.svelte new file mode 100644 index 0000000..1a8ed47 --- /dev/null +++ b/ping/frontend/src/routes/dashboard/analyses/+page.svelte @@ -0,0 +1,409 @@ +<script lang="ts"> + import type { INumberStatList } from '$lib/components/NumberStatList.svelte'; + import NumberStatList from '$lib/components/NumberStatList.svelte'; + import SteppedLineChart from '$lib/components/SteppedLineChart.svelte'; + import { onMount } from 'svelte'; + + interface Transaction { + id: number; + date: Date; + amount: number; + type: 'buy' | 'sell'; + recipient: string; + company: string; + co2Impact: number; + } + + const mockTransactions: Transaction[] = [ + { id: 1, date: new Date('2025-06-20'), amount: 1200, type: 'buy', recipient: 'Microsoft Corp', company: 'MSFT', co2Impact: 45 }, + { id: 2, date: new Date('2025-06-22'), amount: -800, type: 'sell', recipient: 'Apple Inc', company: 'AAPL', co2Impact: -30 }, + { id: 3, date: new Date('2025-06-24'), amount: 2500, type: 'buy', recipient: 'Tesla Inc', company: 'TSLA', co2Impact: 15 }, + { id: 4, date: new Date('2025-06-26'), amount: 950, type: 'buy', recipient: 'Amazon', company: 'AMZN', co2Impact: 60 }, + { id: 5, date: new Date('2025-06-28'), amount: -1500, type: 'sell', recipient: 'Google', company: 'GOOGL', co2Impact: -40 }, + { id: 6, date: new Date('2025-06-30'), amount: 750, type: 'buy', recipient: 'Netflix', company: 'NFLX', co2Impact: 25 }, + { id: 7, date: new Date('2025-07-01'), amount: 1800, type: 'buy', recipient: 'Microsoft Corp', company: 'MSFT', co2Impact: 50 }, + { id: 8, date: new Date('2025-06-15'), amount: -600, type: 'sell', recipient: 'Tesla Inc', company: 'TSLA', co2Impact: -10 }, + { id: 9, date: new Date('2025-06-18'), amount: 3200, type: 'buy', recipient: 'NVIDIA', company: 'NVDA', co2Impact: 80 }, + { id: 10, date: new Date('2025-06-25'), amount: 450, type: 'buy', recipient: 'Apple Inc', company: 'AAPL', co2Impact: 20 } + ]; + + let transactions = mockTransactions; + let selectedPeriod = 30; + let analysisData: any = {}; + + const thresholds = { + dailySpendingLimit: 1000, + monthlySpendingLimit: 5000, + co2Limit: 100, + profitTarget: 2000 + }; + + function calculateStats() { + const now = new Date(); + const cutoffDate = new Date(now.getTime() - selectedPeriod * 24 * 60 * 60 * 1000); + const filteredTransactions = transactions.filter(t => t.date >= cutoffDate); + + const totalAmount = filteredTransactions.reduce((sum, t) => sum + t.amount, 0); + const averageAmount = filteredTransactions.length > 0 ? totalAmount / filteredTransactions.length : 0; + const uniqueRecipients = new Set(filteredTransactions.map(t => t.recipient)).size; + const totalCO2 = filteredTransactions.reduce((sum, t) => sum + t.co2Impact, 0); + + const recipientTotals = filteredTransactions.reduce((acc, t) => { + acc[t.recipient] = (acc[t.recipient] || 0) + t.amount; + return acc; + }, {} as Record<string, number>); + + const dailyData = filteredTransactions.reduce((acc, t) => { + const date = t.date.toISOString().split('T')[0]; + acc[date] = (acc[date] || 0) + t.amount; + return acc; + }, {} as Record<string, number>); + + const chartData = Object.entries(dailyData) + .map(([date, amount]) => ({ + label: new Date(date).toLocaleDateString('fr-FR', { month: 'short', day: 'numeric' }), + value: amount + })) + .sort((a, b) => a.label.localeCompare(b.label)); + + const recipientChartData = Object.entries(recipientTotals).map(([recipient, total]) => ({ + x: recipient, + y: Math.abs(total) + })).sort((a, b) => b.y - a.y); + + const thresholdAlerts = { + dailySpending: Math.abs(totalAmount / selectedPeriod) > thresholds.dailySpendingLimit, + monthlySpending: Math.abs(totalAmount * (30 / selectedPeriod)) > thresholds.monthlySpendingLimit, + co2Impact: totalCO2 > thresholds.co2Limit, + profitTarget: totalAmount >= thresholds.profitTarget + }; + + analysisData = { + totalAmount, + averageAmount, + uniqueRecipients, + totalCO2, + chartData, + recipientChartData, + thresholdAlerts, + filteredTransactions + }; + } + + $: statsList = [ + { + name: 'Total Transactions', + color: 'aqua', + value: `${analysisData.totalAmount ? analysisData.totalAmount.toFixed(0) : '0'}€`, + icon: '/icons/wallet.svg' + }, + { + name: 'Moyenne', + color: '#1FCB4F', + value: `${analysisData.averageAmount ? analysisData.averageAmount.toFixed(0) : '0'}€`, + icon: '/icons/money-bills.svg' + }, + { + name: 'Destinataires', + color: 'orange', + value: `${analysisData.uniqueRecipients || 0}`, + icon: '/icons/people.svg' + }, + { + name: 'Impact CO2', + color: analysisData.totalCO2 > 0 ? '#ff6b6b' : '#1FCB4F', + value: `${analysisData.totalCO2 ? analysisData.totalCO2.toFixed(0) : '0'}g`, + icon: '/icons/leaf.svg' + } + ]; + + onMount(() => calculateStats()); + $: selectedPeriod && calculateStats(); +</script> + +<section id="analyses"> + <div class="controls"> + <h1>Analyses des Transactions</h1> + <div class="period-selector"> + <label for="period">Période d'analyse:</label> + <select bind:value={selectedPeriod} id="period"> + <option value={7}>7 derniers jours</option> + <option value={30}>30 derniers jours</option> + <option value={90}>3 derniers mois</option> + <option value={365}>1 an</option> + </select> + </div> + </div> + + <NumberStatList {statsList} /> + + {#if analysisData.thresholdAlerts} + <div class="threshold-alerts"> + {#if analysisData.thresholdAlerts.dailySpending} + <div class="alert alert-warning"> + ⚠️ Dépenses quotidiennes élevées ({(Math.abs(analysisData.totalAmount) / selectedPeriod).toFixed(0)}€/jour) + </div> + {/if} + {#if analysisData.thresholdAlerts.co2Impact} + <div class="alert alert-danger"> + 🌍 Impact CO2 élevé ({analysisData.totalCO2}g) + </div> + {/if} + {#if analysisData.thresholdAlerts.profitTarget} + <div class="alert alert-success"> + 🎯 Objectif de profit atteint ({analysisData.totalAmount.toFixed(0)}€) + </div> + {/if} + </div> + {/if} + + <div id="analysis-grid"> + <div class="card"> + <h3>Transactions dans le temps</h3> + {#if analysisData.chartData?.length > 0} + <SteppedLineChart color="#1FCB4F" data={analysisData.chartData} title="Évolution" legend="Montant (€)" /> + {:else} + <p style="color: #888; text-align: center; padding: 20px;">Aucune donnée</p> + {/if} + </div> + + <div class="card"> + <h3>Top destinataires</h3> + <div class="recipients-chart"> + {#each (analysisData.recipientChartData || []).slice(0, 5) as recipient} + <div class="recipient-bar"> + <span class="recipient-name">{recipient.x}</span> + <div class="bar-container"> + {#if analysisData.recipientChartData?.length > 0} + {@const maxValue = Math.max(...analysisData.recipientChartData.map(r => r.y))} + <div class="bar" style="width: {(recipient.y / maxValue) * 100}%"></div> + {:else} + <div class="bar" style="width: 0%"></div> + {/if} + <span class="recipient-amount">{recipient.y.toFixed(0)}€</span> + </div> + </div> + {/each} + </div> + </div> + + <div class="card"> + <h3>Répartition des transactions</h3> + <div class="transaction-breakdown"> + <div class="breakdown-item"> + <span class="breakdown-label">Achats:</span> + <span class="breakdown-value positive"> + {(analysisData.filteredTransactions || []).filter((t: Transaction) => t.type === 'buy').length} + </span> + </div> + <div class="breakdown-item"> + <span class="breakdown-label">Ventes:</span> + <span class="breakdown-value negative"> + {(analysisData.filteredTransactions || []).filter((t: Transaction) => t.type === 'sell').length} + </span> + </div> + <div class="breakdown-item"> + <span class="breakdown-label">Volume total:</span> + <span class="breakdown-value"> + {Math.abs(analysisData.totalAmount || 0).toFixed(0)}€ + </span> + </div> + </div> + </div> + + <div class="card"> + <h3>Métriques CO2</h3> + <div class="co2-metrics"> + <div class="co2-item"> + <span class="co2-label">Impact total:</span> + <span class="co2-value {analysisData.totalCO2 > 0 ? 'negative' : 'positive'}"> + {analysisData.totalCO2 || 0}g CO2 + </span> + </div> + <div class="co2-item"> + <span class="co2-label">Par transaction:</span> + <span class="co2-value"> + {(analysisData.filteredTransactions?.length ? analysisData.totalCO2 / analysisData.filteredTransactions.length : 0).toFixed(1)}g + </span> + </div> + </div> + </div> + </div> +</section> + +<style> + .card { + margin: 0; + background-color: var(--bg-primary); + border-radius: 8px; + padding: 16px; + color: white; + } + + .card h3 { + margin: 0 0 16px 0; + color: var(--text-lime); + font-size: 18px; + font-weight: 600; + } + + #analyses { + display: flex; + flex-direction: column; + padding-right: 12px; + background-color: var(--bg-secondary); + min-height: calc(100vh - 72px); + color: white; + } + + .controls { + display: flex; + justify-content: space-between; + align-items: center; + padding: 16px 12px; + margin-bottom: 8px; + } + + .controls h1 { + color: var(--text-lime); + font-size: 32px; + margin: 0; + font-weight: 600; + } + + .period-selector { + display: flex; + align-items: center; + gap: 8px; + } + + .period-selector label { + font-size: 14px; + color: white; + } + + .period-selector select { + background-color: var(--bg-primary); + color: white; + border: 1px solid #444; + border-radius: 4px; + padding: 8px 12px; + font-size: 14px; + } + + .threshold-alerts { + padding: 0 12px; + margin-bottom: 16px; + } + + .alert { + padding: 12px 16px; + border-radius: 8px; + margin-bottom: 8px; + font-size: 14px; + font-weight: 500; + } + + .alert-warning { + background-color: rgba(255, 193, 7, 0.2); + border-left: 4px solid #ffc107; + color: #ffc107; + } + + .alert-danger { + background-color: rgba(220, 53, 69, 0.2); + border-left: 4px solid #dc3545; + color: #dc3545; + } + + .alert-success { + background-color: rgba(40, 167, 69, 0.2); + border-left: 4px solid #28a745; + color: #28a745; + } + + #analysis-grid { + padding: 0 12px; + width: 100%; + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 1rem; + } + + .recipients-chart { + display: flex; + flex-direction: column; + gap: 12px; + } + + .recipient-bar { + display: flex; + flex-direction: column; + gap: 4px; + } + + .recipient-name { + font-size: 12px; + color: #888; + font-weight: 500; + } + + .bar-container { + display: flex; + align-items: center; + gap: 8px; + position: relative; + } + + .bar { + height: 20px; + background: linear-gradient(90deg, var(--text-lime), #1FCB4F); + border-radius: 4px; + transition: width 0.3s ease; + } + + .recipient-amount { + font-size: 12px; + color: white; + font-weight: 600; + min-width: 60px; + text-align: right; + } + + .transaction-breakdown, .co2-metrics { + display: flex; + flex-direction: column; + gap: 12px; + } + + .breakdown-item, .co2-item { + display: flex; + justify-content: space-between; + align-items: center; + padding: 8px 0; + border-bottom: 1px solid #333; + } + + .breakdown-item:last-child, .co2-item:last-child { + border-bottom: none; + } + + .breakdown-label, .co2-label { + font-size: 14px; + color: #888; + } + + .breakdown-value, .co2-value { + font-size: 16px; + font-weight: 600; + color: white; + } + + .breakdown-value.positive, .co2-value.positive { + color: var(--text-lime); + } + + .breakdown-value.negative, .co2-value.negative { + color: #ff6b6b; + } +</style> diff --git a/ping/frontend/src/routes/dashboard/messages/+page.svelte b/ping/frontend/src/routes/dashboard/messages/+page.svelte new file mode 100644 index 0000000..52869e4 --- /dev/null +++ b/ping/frontend/src/routes/dashboard/messages/+page.svelte @@ -0,0 +1,403 @@ +<script lang="ts"> + import type { PageData } from './$types'; + + let { data }: { data: PageData } = $props(); + + let searchQuery = ''; + let selectedMessage = null; + let newMessage = ''; + let messages = [ + { + id: 1, + sender: 'Équipe Ping', + subject: 'Nouvelle analyse de portefeuille disponible', + content: 'Votre analyse mensuelle de performance ESG est maintenant disponible. Consultez les dernières métriques de votre portefeuille.', + date: new Date('2024-06-28'), + unread: true + }, + { + id: 2, + sender: 'Système', + subject: 'Alerte: Score ESG en baisse', + content: 'Le score ESG de votre portefeuille a diminué de 5% ce mois-ci. Nous recommandons de réviser vos investissements.', + date: new Date('2024-06-25'), + unread: true + }, + { + id: 3, + sender: 'Support', + subject: 'Mise à jour des modèles prédictifs', + content: 'Nos modèles d\'analyse ont été mis à jour avec les dernières données du marché. Vos prédictions sont maintenant plus précises.', + date: new Date('2024-06-20'), + unread: false + } + ]; + + function handleSearch() { + console.log('Searching messages:', searchQuery); + } + + function selectMessage(message: any) { + selectedMessage = message; + if (message.unread) { + message.unread = false; + messages = [...messages]; + } + } + + function sendMessage() { + if (newMessage.trim()) { + console.log('Sending message:', newMessage); + newMessage = ''; + } + } + + function deleteMessage(messageId: number) { + messages = messages.filter(m => m.id !== messageId); + if (selectedMessage?.id === messageId) { + selectedMessage = null; + } + } +</script> + +<section id="messages"> + <div class="messages-container"> + <!-- Header --> + <div class="messages-header"> + <h1>Messages</h1> + <div class="search-bar"> + <input + type="text" + placeholder="Rechercher dans les messages..." + bind:value={searchQuery} + on:input={handleSearch} + /> + <button class="search-btn">🔍</button> + </div> + </div> + + <div class="messages-layout"> + <!-- Messages List --> + <div class="messages-list card"> + <h2>Boîte de réception</h2> + <div class="message-items"> + {#each messages as message} + <div + class="message-item {message.unread ? 'unread' : ''} {selectedMessage?.id === message.id ? 'selected' : ''}" + on:click={() => selectMessage(message)} + > + <div class="message-header"> + <span class="message-sender">{message.sender}</span> + <span class="message-date">{message.date.toLocaleDateString()}</span> + </div> + <div class="message-subject">{message.subject}</div> + <div class="message-preview">{message.content.substring(0, 80)}...</div> + {#if message.unread} + <div class="unread-indicator"></div> + {/if} + </div> + {/each} + </div> + </div> + + <!-- Message Detail --> + <div class="message-detail card"> + {#if selectedMessage} + <div class="message-detail-header"> + <h3>{selectedMessage.subject}</h3> + <div class="message-actions"> + <button class="btn" on:click={() => deleteMessage(selectedMessage.id)}> + 🗑️ Supprimer + </button> + </div> + </div> + <div class="message-meta"> + <span>De: {selectedMessage.sender}</span> + <span>Date: {selectedMessage.date.toLocaleDateString()}</span> + </div> + <div class="message-content"> + {selectedMessage.content} + </div> + + <!-- Reply Section --> + <div class="reply-section"> + <h4>Répondre</h4> + <textarea + bind:value={newMessage} + placeholder="Tapez votre réponse..." + rows="4" + ></textarea> + <button class="btn" on:click={sendMessage}> + 📤 Envoyer + </button> + </div> + {:else} + <div class="no-message-selected"> + <h3>Sélectionnez un message</h3> + <p>Choisissez un message dans la liste pour le lire</p> + </div> + {/if} + </div> + </div> + </div> +</section> + +<style> + #messages { + padding: 16px; + background-color: var(--bg-secondary); + color: white; + min-height: calc(100vh - 72px); + } + + .messages-container { + max-width: 1400px; + } + + /* Header */ + .messages-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 16px; + } + + h1 { + color: var(--text-lime); + font-size: 32px; + margin: 0; + font-weight: 600; + } + + h2 { + color: var(--text-lime); + font-size: 24px; + margin: 0 0 16px 0; + font-weight: 600; + } + + .search-bar { + display: flex; + align-items: center; + background-color: var(--bg-primary); + border-radius: 8px; + padding: 4px; + gap: 8px; + } + + .search-bar input { + background: transparent; + border: none; + color: white; + padding: 12px 16px; + font-size: 14px; + outline: none; + width: 300px; + } + + .search-bar input::placeholder { + color: #888888; + } + + .search-btn { + background: transparent; + border: none; + color: #888888; + padding: 8px; + cursor: pointer; + font-size: 16px; + } + + /* Layout */ + .messages-layout { + display: flex; + gap: 16px; + height: calc(100vh - 200px); + } + + .card { + background-color: var(--bg-primary); + border-radius: 8px; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); + padding: 16px; + margin: 16px; + color: white; + } + + /* Messages List */ + .messages-list { + flex: 1; + max-width: 400px; + } + + .message-items { + display: flex; + flex-direction: column; + gap: 8px; + max-height: calc(100vh - 300px); + overflow-y: auto; + } + + .message-item { + padding: 12px; + border-radius: 8px; + background-color: var(--bg-secondary); + cursor: pointer; + transition-duration: 0.2s; + position: relative; + border-left: 3px solid transparent; + } + + .message-item:hover { + background-color: var(--btn-primary); + transform: scale(1.02); + } + + .message-item.selected { + border-left-color: var(--text-lime); + background-color: var(--btn-primary); + } + + .message-item.unread { + background-color: rgba(0, 255, 119, 0.1); + } + + .message-header { + display: flex; + justify-content: space-between; + margin-bottom: 4px; + } + + .message-sender { + font-weight: 600; + color: var(--text-lime); + } + + .message-date { + font-size: 12px; + color: #888888; + } + + .message-subject { + font-weight: 500; + margin-bottom: 4px; + font-size: 14px; + } + + .message-preview { + font-size: 13px; + color: #888888; + } + + .unread-indicator { + position: absolute; + top: 8px; + right: 8px; + width: 8px; + height: 8px; + background-color: var(--text-lime); + border-radius: 50%; + } + + /* Message Detail */ + .message-detail { + flex: 2; + } + + .message-detail-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 16px; + padding-bottom: 16px; + border-bottom: 1px solid var(--btn-primary); + } + + .message-detail-header h3 { + margin: 0; + color: white; + font-size: 20px; + } + + .message-actions { + display: flex; + gap: 8px; + } + + .message-meta { + display: flex; + gap: 20px; + margin-bottom: 20px; + font-size: 14px; + color: #888888; + } + + .message-content { + line-height: 1.6; + margin-bottom: 30px; + padding: 20px; + background-color: var(--bg-secondary); + border-radius: 8px; + } + + .reply-section { + border-top: 1px solid var(--btn-primary); + padding-top: 20px; + } + + .reply-section h4 { + margin: 0 0 12px 0; + color: var(--text-lime); + } + + textarea { + width: 100%; + background-color: var(--bg-secondary); + border: none; + border-bottom: 2px solid var(--text-lime); + padding: 8px; + border-radius: 8px 8px 0 0; + color: white; + font-size: 14px; + resize: vertical; + box-sizing: border-box; + margin-bottom: 12px; + } + + textarea::placeholder { + color: #888888; + } + + textarea:focus { + outline: none; + border-bottom-color: var(--text-lime); + } + + .no-message-selected { + text-align: center; + padding: 60px 20px; + color: #888888; + } + + .no-message-selected h3 { + margin: 0 0 12px 0; + color: #888888; + } + + .btn { + background-color: var(--btn-primary); + color: white; + border: none; + padding: 8px 16px; + border-radius: 4px; + cursor: pointer; + text-decoration: none; + transition-duration: 0.2s; + font-weight: 600; + } + + .btn:hover { + background-color: var(--btn-primary-hover); + transform: scale(1.025); + } +</style> diff --git a/ping/frontend/src/routes/dashboard/models/+page.svelte b/ping/frontend/src/routes/dashboard/models/+page.svelte new file mode 100644 index 0000000..3be6f00 --- /dev/null +++ b/ping/frontend/src/routes/dashboard/models/+page.svelte @@ -0,0 +1,568 @@ +<script lang="ts"> + import Avatar from '$lib/components/Avatar.svelte'; + import UserSelector from '$lib/components/input/UserSelector.svelte'; + import { authFetch, getUser, type IUser } from '$lib/stores/auth'; + import { addToast } from '$lib/stores/toast'; + import { onMount } from 'svelte'; + import { get } from 'svelte/store'; + + interface IModel { + id: string; + members: IUser[]; + name: string; + owner: IUser; + } + + let modelName = $state(''); + let sources = $state([]); + let models = $state<IModel[]>([]); + let modelToEdit = $state<number | null>(null); + let modelOwner = $state<IUser | null>(null); + + let users = $state<IUser[]>([]); + + function addSource() { + sources = [...sources, '']; + } + + function removeSource(index: number) { + sources = sources.filter((_, i) => i !== index); + } + + function handleSubmit(e: SubmitEvent) { + e.preventDefault(); + if (modelToEdit === null) { + createModel(); + } else { + updateModel(); + } + } + + async function createModel() { + try { + const response = await authFetch('/api/projects', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + accept: 'application/json' + }, + body: JSON.stringify({ + name: modelName + }) + }); + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + const data = await response.json(); + models = [...models, data]; + await Promise.all( + users.map((user) => + authFetch(`/api/projects/${data.id}/add-user`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + accept: '*/*' + }, + body: JSON.stringify({ userId: user.id }) + }) + .then((res) => { + if (!res.ok) { + addToast({ + color: 'red', + title: 'Erreur', + message: `Impossible d'ajouter l'utilisateur ${user.displayName} au modèle "${modelName}".` + }); + console.error(`HTTP error! status: ${res.status}`); + } + return res.json().catch(() => null); + }) + .then((memberData) => { + console.log('Member added:', memberData); + }) + .catch((err) => { + console.error('Error adding member:', err); + }) + ) + ); + addToast({ + color: 'green', + title: 'Modèle créé', + message: `Le modèle "${modelName}" a été créé avec succès.` + }); + modelName = ''; + return data; + } catch (error) { + console.error('Error creating model:', error); + addToast({ + color: 'red', + title: 'Erreur', + message: `Une erreur est survenue lors de la création du modèle "${modelName}".` + }); + throw error; + } + } + + async function updateModel() { + try { + if (modelToEdit === null || models.length <= modelToEdit) { + addToast({ + color: 'red', + title: 'Erreur', + message: 'Aucun modèle sélectionné pour la mise à jour.' + }); + return; + } + const model = models[modelToEdit]; + console.log('Updating model:', model); + const response = await authFetch(`/api/projects/${model.id}`, { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + accept: 'application/json' + }, + body: JSON.stringify({ + name: modelName + }) + }); + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + const updatedModel = await response.json(); + models = models.map((m, i) => (i === modelToEdit ? updatedModel : m)); + modelToEdit = null; + modelName = ''; + addToast({ + color: 'green', + title: 'Modèle mis à jour', + message: `Le modèle "${updatedModel.name}" a été mis à jour avec succès.` + }); + } catch (error) { + console.error('Error updating model:', error); + addToast({ + color: 'red', + title: 'Erreur', + message: `Une erreur est survenue lors de la mise à jour du modèle "${modelName}".` + }); + throw error; + } + } + + async function getMyModels() { + try { + const response = await authFetch('/api/projects', { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + accept: 'application/json' + } + }); + const data = await response.json(); + console.log('My models:', data); + return data; + } catch (error) { + console.error('Error fetching models:', error); + addToast({ + color: 'red', + title: 'Erreur', + message: 'Impossible de charger les modèles.' + }); + throw error; + } + } + + function deleteModel(i: number) { + if (models.length <= i || i < 0) { + addToast({ + color: 'red', + title: 'Erreur', + message: 'Modèle introuvable.' + }); + return; + } + + modelToEdit = null; + + const modelToDelete = models[i]; + authFetch(`/api/projects/${modelToDelete.id}`, { + method: 'DELETE', + headers: { + 'Content-Type': 'application/json', + accept: 'application/json' + } + }) + .then(() => { + models = models.filter((_, index) => index !== i); + addToast({ + color: 'green', + title: 'Modèle supprimé', + message: `Le modèle "${modelToDelete.name}" a été supprimé avec succès.` + }); + }) + .catch((err) => { + console.error('Error deleting model:', err); + addToast({ + color: 'red', + title: 'Erreur', + message: `Impossible de supprimer le modèle "${modelToDelete.name}".` + }); + }); + } + + function onEditButtonPressed(i: number) { + if (modelToEdit === i) { + modelToEdit = null; + modelName = ''; + users = []; + modelOwner = null; + } else { + modelToEdit = i; + modelName = models[i].name; + users = models[i].members || []; + modelOwner = models[i].owner || null; + } + } + + let currentUser = $state<IUser | null>(null); + + onMount(() => { + getMyModels() + .then((lsModels) => { + models = lsModels; + console.log('Models loaded:', models); + }) + .catch((err) => { + console.error('Error loading models:', err); + }); + getUser() + .then((user) => { + currentUser = user; + console.log('Current user:', currentUser); + }) + .catch((err) => { + console.error('Error fetching current user:', err); + }); + }); +</script> + +<section id="models"> + <div class="models-container"> + <div class="active-models"> + <h2>Modèles actifs</h2> + <div class="models-list"> + {#each models as m, i} + <div class="model-item" class:selected={modelToEdit === i}> + <div class="model-name"> + <span>{m.name}</span><br /> + <code style="font-size:8px">{m.id}</code> + </div> + <div class="model-metrics"> + <div class="metric"> + <span class="metric-label">Eco score</span> + <span class="metric-value green">{69} %</span> + </div> + <div class="metric"> + <span class="metric-label">Efficacité</span> + <span class="metric-value green">{69} %</span> + </div> + </div> + <button class="modify-btn" onclick={() => onEditButtonPressed(i)}>✏️</button> + <button class="delete-btn btn" onclick={() => deleteModel(i)}>🗑️</button> + </div> + {/each} + </div> + </div> + + <div class="new-model"> + <form onsubmit={handleSubmit}> + <div class="new-model-header"> + {#if modelToEdit !== null} + <h2>Modifier un modèle</h2> + {:else} + <h2>Nouveau modèle</h2> + {/if} + <button class="register-btn" type="submit">📁 Enregistrer</button> + </div> + <div class="form-group"> + <label for="name">Nom du modèle</label> + <input type="text" id="name" bind:value={modelName} placeholder="Modèle X" /> + <div class="flex gap-2 p-2"> + {#if modelToEdit !== null} + <Avatar username={modelOwner?.displayName} url={modelOwner?.avatar} /> + {:else if currentUser} + <Avatar username={currentUser.displayName} url={currentUser.avatar} /> + {:else} + Chargement en cours... + {/if} + <UserSelector bind:users /> + </div> + </div> + + <div class="sources-section"> + <div class="sources-header"> + <span>Sources de données</span> + <button type="button" class="add-source-btn" onclick={addSource}> + ➕ Ajouter une source de données + </button> + </div> + + {#each sources as source, index} + <div class="source-item"> + <!-- svelte-ignore a11y_label_has_associated_control --> + <label>Source {index + 1}</label> + <div class="source-input-group"> + <input type="text" bind:value={sources[index]} placeholder="Source" /> + <button type="button" class="remove-source-btn" onclick={() => removeSource(index)}> + 🗑️ + </button> + </div> + </div> + {/each} + </div> + </form> + </div> + </div> +</section> + +<style> + #models { + padding: 16px; + background-color: var(--bg-secondary); + color: white; + min-height: calc(100vh - 72px); + } + + .models-container { + display: flex; + gap: 16px; + max-width: 1400px; + } + + h2 { + color: var(--text-lime); + font-size: 24px; + margin: 0 0 16px 0; + font-weight: 600; + } + + /* Modèles actifs */ + .active-models { + flex: 1; + background-color: var(--bg-primary); + border-radius: 8px; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); + padding: 16px; + margin: 16px; + color: white; + } + + .models-list { + display: flex; + flex-direction: column; + gap: 15px; + } + + .model-item { + background-color: var(--bg-secondary); + padding: 16px; + border-radius: 8px; + display: flex; + align-items: center; + justify-content: space-between; + border: 2px solid transparent; + } + + .model-item.selected { + border: 2px solid var(--text-lime); + } + + .model-name { + font-weight: 500; + font-size: 16px; + color: white; + } + + .model-metrics { + display: flex; + gap: 30px; + } + + .metric { + display: flex; + flex-direction: column; + align-items: center; + gap: 5px; + } + + .metric-label { + font-size: 12px; + color: #888888; + } + + .metric-value { + font-size: 18px; + font-weight: bold; + } + + .metric-value.green { + color: var(--text-lime); + } + + .modify-btn { + background-color: var(--btn-primary); + color: white; + border: none; + padding: 8px 16px; + border-radius: 4px; + cursor: pointer; + font-size: 14px; + font-weight: 600; + transition-duration: 0.2s; + } + + .modify-btn:hover { + background-color: var(--btn-primary-hover); + transform: scale(1.025); + } + + /* Nouveau modèle */ + .new-model { + flex: 1; + background-color: var(--bg-primary); + border-radius: 8px; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); + padding: 16px; + margin: 16px; + color: white; + } + + .new-model-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 16px; + } + + .register-btn { + background-color: var(--btn-primary); + color: white; + border: none; + padding: 8px 16px; + border-radius: 4px; + cursor: pointer; + font-weight: 600; + font-size: 14px; + transition-duration: 0.2s; + } + + .register-btn:hover { + background-color: var(--btn-primary-hover); + transform: scale(1.025); + } + + .form-group { + margin-bottom: 16px; + } + + label { + display: block; + margin-bottom: 8px; + font-size: 14px; + color: white; + font-weight: 500; + } + + input[type='text'] { + width: 100%; + background-color: var(--bg-secondary); + border: none; + border-bottom: 2px solid var(--text-lime); + padding: 8px; + border-radius: 8px 8px 0 0; + color: white; + font-size: 14px; + box-sizing: border-box; + } + + input[type='text']:focus { + outline: none; + border-bottom-color: var(--text-lime); + } + + input[type='text']::placeholder { + color: #888888; + } + + /* Sources section */ + .sources-section { + margin-top: 16px; + } + + .sources-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 15px; + } + + .sources-header span { + font-size: 14px; + font-weight: 500; + color: white; + } + + .add-source-btn { + background-color: var(--btn-secondary); + color: var(--text-lime); + border: 1px solid var(--text-lime); + padding: 8px 16px; + border-radius: 4px; + cursor: pointer; + font-size: 12px; + font-weight: 600; + transition-duration: 0.2s; + } + + .add-source-btn:hover { + background-color: var(--text-lime); + color: var(--bg-primary); + transform: scale(1.025); + } + + .source-item { + margin-bottom: 15px; + } + + .source-item label { + margin-bottom: 6px; + font-size: 12px; + color: #888888; + } + + .source-input-group { + display: flex; + gap: 10px; + align-items: center; + } + + .source-input-group input { + flex: 1; + } + + .remove-source-btn { + background-color: var(--btn-secondary); + color: #888888; + border: none; + padding: 8px; + border-radius: 4px; + cursor: pointer; + font-size: 16px; + transition-duration: 0.2s; + min-width: 40px; + } + + .remove-source-btn:hover { + color: #ef4444; + background-color: var(--btn-primary); + transform: scale(1.025); + } +</style> diff --git a/ping/frontend/src/routes/dashboard/personnel/+page.svelte b/ping/frontend/src/routes/dashboard/personnel/+page.svelte new file mode 100644 index 0000000..a1722c1 --- /dev/null +++ b/ping/frontend/src/routes/dashboard/personnel/+page.svelte @@ -0,0 +1,290 @@ +<script lang="ts"> + import Avatar from '$lib/components/Avatar.svelte'; + import UserItem from '$lib/components/UserItem.svelte'; + import { authFetch, getUpdatedUser, getUser, type IUser } from '$lib/stores/auth'; + import { addToast } from '$lib/stores/toast'; + import { onMount } from 'svelte'; + + let user = $state<IUser | null>(null); + let allUsers = $state<IUser[]>([]); + + let idValue = $state<string>(''); + let urlValue = $state<string>(''); + let nameValue = $state<string>(''); + let passwordValue = $state<string>(''); + + async function loadUser() { + try { + user = await getUser(); + if (!user) { + addToast({ title: 'Erreur', message: 'Utilisateur non trouvé.' }); + return null; + } + idValue = user.id; + urlValue = user.avatar || ''; + nameValue = user.displayName || ''; + return user; + } catch (error) { + user = null; + addToast({ title: 'Erreur', message: 'Impossible de charger les informations utilisateur.' }); + return null; + } + } + + async function loadAllUsers() { + try { + const response = await authFetch('/api/user/all'); + if (!response.ok) { + throw new Error('Erreur lors du chargement des utilisateurs'); + } + const users = await response.json(); + return users; + } catch (error) { + addToast({ title: 'Erreur', message: 'Impossible de charger la liste des utilisateurs.' }); + return []; + } + } + + function onEditUser(event: Event) { + event.preventDefault(); + const form = event.target as HTMLFormElement; + const formData = new FormData(form); + + const { id, avatar, username, password } = Object.fromEntries(formData.entries()); + + authFetch(`/api/user/${id}`, { + method: 'PUT', + body: JSON.stringify({ + avatar: avatar || user?.avatar, + displayName: username || user?.displayName, + password: password || undefined + }), + headers: { + 'Content-Type': 'application/json' + } + }) + .then((res) => res.json()) + .then((data) => { + getUpdatedUser().then((updatedUser) => { + user = updatedUser; + }); + console.log('Utilisateur mis à jour', data); + addToast({ + title: 'Succès', + message: 'Informations utilisateur mises à jour avec succès.', + color: 'green' + }); + }) + .catch((error) => { + addToast({ + title: 'Erreur', + message: 'Échec de la mise à jour des informations utilisateur.' + }); + }); + } + + function onCreateUser(event: Event) { + event.preventDefault(); + const form = event.target as HTMLFormElement; + const formData = new FormData(form); + const { login, password, isAdmin } = Object.fromEntries(formData.entries()); + + authFetch('/api/user', { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + login, + password, + isAdmin: isAdmin === 'on' + }) + }) + .then(async (res) => { + if (!res.ok) { + addToast({ + title: 'Erreur', + message: "Impossible de créer l'utilisateur." + }); + throw new Error("Erreur lors de la création de l'utilisateur"); + } + const newUser = await res.json(); + allUsers = [...allUsers, newUser]; + addToast({ + title: 'Succès', + message: 'Nouvel utilisateur créé avec succès.', + color: 'green' + }); + form.reset(); + }) + .catch(() => { + addToast({ + title: 'Erreur', + message: "Impossible de créer l'utilisateur." + }); + }); + } + + onMount(async () => { + loadUser().then((user) => { + if (user && user.isAdmin) + loadAllUsers().then((users) => { + allUsers = users; + }); + }); + }); +</script> + +{#if user} + <div id="personnel"> + <div> + <section id="myUser" class="card"> + <h2>Mon compte</h2> + <Avatar username={user.displayName} url={user.avatar || '/img/default-avatar.png'} /> + <p><b>Nom</b>: {user.displayName}</p> + <p><b>Login</b>: {user.login}</p> + <p><b>Role</b>: {user.isAdmin ? 'ADMIN' : 'USER'}</p> + <p><b>Id</b>: {user.id}</p> + </section> + <section id="userEdit" class="card"> + <h2>Modifier</h2> + <i + >Laisser vide pour ne pas modifier<br /> + {#if user.isAdmin} + <p>Vous pouvez modifier les informations de n'importe quel utilisateur.</p> + {:else} + <p>Vous ne pouvez modifier que vos propres informations.</p> + {/if} + </i><br /> + <form onsubmit={onEditUser}> + {#if user.isAdmin} + <label for="id">ID de l'utilisateur</label> + <input type="text" placeholder="ID de l'utilisateur" name="id" bind:value={idValue} /> + {/if} + <label for="id">URL de l'avatar</label> + <input type="text" placeholder="URL de l'avatar" name="avatar" bind:value={urlValue} /> + <label for="id">Nom d'utilisateur</label> + <input + type="text" + placeholder="Nom d'utilisateur" + name="username" + bind:value={nameValue} + /> + <label for="id">Mot de passe</label> + <input + type="password" + placeholder="Mot de passe" + name="password" + bind:value={passwordValue} + /> + <button type="submit" class="btn">Modifier</button> + </form> + </section> + </div> + + {#if user.isAdmin} + <section id="adminActions"> + <div class="card"> + <h2>Créer un nouvel utilisateur</h2> + <form class="flex flex-col gap-2" onsubmit={onCreateUser}> + <label for="new-login">Login</label> + <input type="text" id="new-login" name="login" placeholder="Login" required /> + + <label for="new-password">Mot de passe</label> + <input + type="password" + id="new-password" + name="password" + placeholder="Mot de passe" + required + /> + + <label for="new-isAdmin"> + <input type="checkbox" id="new-isAdmin" name="isAdmin" /> + Administrateur + </label> + + <button type="submit" class="btn">Créer</button> + </form> + </div> + <div class="card"> + <h2>Liste des utilisateurs</h2> + <div> + {#each allUsers as u} + <UserItem + user={u} + onEdit={() => { + idValue = u.id; + urlValue = u.avatar || ''; + nameValue = u.displayName || ''; + }} + onDelete={() => { + authFetch(`/api/user/${u.id}`, { + method: 'DELETE' + }) + .then(async (res) => { + if (!res.ok) { + addToast({ + title: 'Erreur', + message: "Impossible de supprimer l'utilisateur." + }); + throw new Error("Erreur lors de la suppression de l'utilisateur"); + } + allUsers = allUsers.filter((user) => user.id !== u.id); + addToast({ + title: 'Succès', + message: 'Utilisateur supprimé avec succès.', + color: 'green' + }); + }) + .catch(() => { + addToast({ + title: 'Erreur', + message: "Impossible de supprimer l'utilisateur." + }); + }); + }} + /> + {/each} + </div> + </div> + </section> + {/if} + </div> +{:else} + <p>Chargement des informations utilisateur...</p> +{/if} + +<style> + * { + color: white; + } + + #personnel { + width: calc(100% - 32px); + display: flex; + gap: 16px; + } + + #personnel > * { + flex: 1; + } + + #myUser { + display: flex; + flex-direction: column; + } + + #userEdit form { + display: flex; + flex-direction: column; + gap: 8px; + } + + h2 { + margin: 0; + font-size: 1.5em; + font-weight: 600; + color: var(--text-lime); + } +</style> diff --git a/ping/frontend/src/routes/dashboard/settings/+page.svelte b/ping/frontend/src/routes/dashboard/settings/+page.svelte new file mode 100644 index 0000000..99c5d8f --- /dev/null +++ b/ping/frontend/src/routes/dashboard/settings/+page.svelte @@ -0,0 +1,452 @@ +<script lang="ts"> + import type { PageData } from './$types'; + + let { data }: { data: PageData } = $props(); + + let userProfile = { + name: 'Baptiste', + email: 'baptiste@ping.com', + company: 'Ping Analytics', + phone: '+33 6 12 34 56 78' + }; + + let notifications = { + emailAlerts: true, + portfolioUpdates: true, + esgAlerts: true, + marketNews: false, + weeklyReports: true + }; + + let security = { + twoFactorAuth: false, + sessionTimeout: '30', + dataEncryption: true + }; + + let display = { + theme: 'dark', + language: 'fr', + currency: 'EUR', + dateFormat: 'DD/MM/YYYY' + }; + + let currentPassword = ''; + let newPassword = ''; + let confirmPassword = ''; + + function saveProfile() { + console.log('Saving profile:', userProfile); + } + + function saveNotifications() { + console.log('Saving notifications:', notifications); + } + + function saveSecurity() { + console.log('Saving security settings:', security); + } + + function saveDisplay() { + console.log('Saving display settings:', display); + } + + function changePassword() { + if (newPassword !== confirmPassword) { + alert('Les mots de passe ne correspondent pas'); + return; + } + console.log('Changing password'); + currentPassword = ''; + newPassword = ''; + confirmPassword = ''; + } + + function exportData() { + console.log('Exporting user data'); + } + + function deleteAccount() { + if (confirm('Êtes-vous sûr de vouloir supprimer votre compte ? Cette action est irréversible.')) { + console.log('Deleting account'); + } + } +</script> + +<section id="settings"> + <div class="settings-container"> + <div class="settings-header"> + <h1>Paramètres</h1> + </div> + + <div class="settings-layout"> + <div class="settings-section card"> + <h2>👤 Profil utilisateur</h2> + <form on:submit|preventDefault={saveProfile}> + <div class="form-row"> + <div class="form-group"> + <label for="name">Nom complet</label> + <input type="text" id="name" bind:value={userProfile.name} /> + </div> + <div class="form-group"> + <label for="email">Email</label> + <input type="email" id="email" bind:value={userProfile.email} /> + </div> + </div> + <div class="form-row"> + <div class="form-group"> + <label for="company">Entreprise</label> + <input type="text" id="company" bind:value={userProfile.company} /> + </div> + <div class="form-group"> + <label for="phone">Téléphone</label> + <input type="tel" id="phone" bind:value={userProfile.phone} /> + </div> + </div> + <button type="submit" class="btn">💾 Sauvegarder le profil</button> + </form> + </div> + + <div class="settings-section card"> + <h2>🔔 Notifications</h2> + <form on:submit|preventDefault={saveNotifications}> + <div class="checkbox-group"> + <label class="checkbox-label"> + <input type="checkbox" bind:checked={notifications.emailAlerts} /> + <span>Alertes par email</span> + </label> + <label class="checkbox-label"> + <input type="checkbox" bind:checked={notifications.portfolioUpdates} /> + <span>Mises à jour du portefeuille</span> + </label> + <label class="checkbox-label"> + <input type="checkbox" bind:checked={notifications.esgAlerts} /> + <span>Alertes ESG</span> + </label> + <label class="checkbox-label"> + <input type="checkbox" bind:checked={notifications.marketNews} /> + <span>Actualités du marché</span> + </label> + <label class="checkbox-label"> + <input type="checkbox" bind:checked={notifications.weeklyReports} /> + <span>Rapports hebdomadaires</span> + </label> + </div> + <button type="submit" class="btn">💾 Sauvegarder les notifications</button> + </form> + </div> + + <div class="settings-section card"> + <h2>🔒 Sécurité</h2> + <form on:submit|preventDefault={saveSecurity}> + <div class="form-group"> + <label class="checkbox-label"> + <input type="checkbox" bind:checked={security.twoFactorAuth} /> + <span>Authentification à deux facteurs</span> + </label> + </div> + <div class="form-group"> + <label for="sessionTimeout">Délai d'expiration de session (minutes)</label> + <select id="sessionTimeout" bind:value={security.sessionTimeout}> + <option value="15">15 minutes</option> + <option value="30">30 minutes</option> + <option value="60">1 heure</option> + <option value="120">2 heures</option> + </select> + </div> + <div class="form-group"> + <label class="checkbox-label"> + <input type="checkbox" bind:checked={security.dataEncryption} disabled /> + <span>Chiffrement des données (toujours activé)</span> + </label> + </div> + <button type="submit" class="btn">💾 Sauvegarder la sécurité</button> + </form> + + <div class="password-section"> + <h3>Changer le mot de passe</h3> + <form on:submit|preventDefault={changePassword}> + <div class="form-group"> + <label for="currentPassword">Mot de passe actuel</label> + <input type="password" id="currentPassword" bind:value={currentPassword} /> + </div> + <div class="form-group"> + <label for="newPassword">Nouveau mot de passe</label> + <input type="password" id="newPassword" bind:value={newPassword} /> + </div> + <div class="form-group"> + <label for="confirmPassword">Confirmer le nouveau mot de passe</label> + <input type="password" id="confirmPassword" bind:value={confirmPassword} /> + </div> + <button type="submit" class="btn">🔑 Changer le mot de passe</button> + </form> + </div> + </div> + + <div class="settings-section card"> + <h2>🎨 Affichage</h2> + <form on:submit|preventDefault={saveDisplay}> + <div class="form-row"> + <div class="form-group"> + <label for="theme">Thème</label> + <select id="theme" bind:value={display.theme}> + <option value="dark">Sombre</option> + <option value="light">Clair</option> + <option value="auto">Automatique</option> + </select> + </div> + <div class="form-group"> + <label for="language">Langue</label> + <select id="language" bind:value={display.language}> + <option value="fr">Français</option> + <option value="en">English</option> + <option value="es">Español</option> + </select> + </div> + </div> + <div class="form-row"> + <div class="form-group"> + <label for="currency">Devise</label> + <select id="currency" bind:value={display.currency}> + <option value="EUR">EUR (€)</option> + <option value="USD">USD ($)</option> + <option value="GBP">GBP (£)</option> + </select> + </div> + <div class="form-group"> + <label for="dateFormat">Format de date</label> + <select id="dateFormat" bind:value={display.dateFormat}> + <option value="DD/MM/YYYY">DD/MM/YYYY</option> + <option value="MM/DD/YYYY">MM/DD/YYYY</option> + <option value="YYYY-MM-DD">YYYY-MM-DD</option> + </select> + </div> + </div> + <button type="submit" class="btn">💾 Sauvegarder l'affichage</button> + </form> + </div> + + <div class="settings-section card"> + <h2>📊 Gestion des données</h2> + <div class="data-actions"> + <div class="action-item"> + <div class="action-info"> + <h3>Exporter mes données</h3> + <p>Téléchargez toutes vos données personnelles dans un fichier JSON</p> + </div> + <button class="btn" on:click={exportData}>📥 Exporter</button> + </div> + <div class="action-item danger"> + <div class="action-info"> + <h3>Supprimer mon compte</h3> + <p>Supprimez définitivement votre compte et toutes vos données</p> + </div> + <button class="btn danger" on:click={deleteAccount}>🗑️ Supprimer</button> + </div> + </div> + </div> + </div> + </div> +</section> + +<style> + #settings { + padding: 16px; + background-color: var(--bg-secondary); + color: white; + min-height: calc(100vh - 72px); + } + + .settings-container { + max-width: 1200px; + } + + .settings-header { + margin-bottom: 16px; + } + + h1 { + color: var(--text-lime); + font-size: 32px; + margin: 0; + font-weight: 600; + } + + h2 { + color: var(--text-lime); + font-size: 24px; + margin: 0 0 20px 0; + font-weight: 600; + display: flex; + align-items: center; + gap: 8px; + } + + h3 { + color: white; + font-size: 18px; + margin: 20px 0 12px 0; + font-weight: 500; + } + + .settings-layout { + display: flex; + flex-direction: column; + gap: 0; + } + + .settings-section { + background-color: var(--bg-primary); + border-radius: 8px; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); + padding: 16px; + margin: 16px; + color: white; + width: calc(100% - 32px); + } + + .form-row { + display: flex; + gap: 20px; + margin-bottom: 16px; + } + + .form-group { + flex: 1; + margin-bottom: 16px; + } + + label { + display: block; + margin-bottom: 8px; + font-size: 14px; + color: white; + font-weight: 500; + } + + input[type="text"], + input[type="email"], + input[type="tel"], + input[type="password"], + select { + width: 100%; + background-color: var(--bg-secondary); + border: none; + border-bottom: 2px solid var(--text-lime); + padding: 8px; + border-radius: 8px 8px 0 0; + color: white; + font-size: 14px; + box-sizing: border-box; + } + + input:focus, + select:focus { + outline: none; + border-bottom-color: var(--text-lime); + } + + input::placeholder { + color: #888888; + } + + .checkbox-group { + display: flex; + flex-direction: column; + gap: 12px; + margin-bottom: 20px; + } + + .checkbox-label { + display: flex; + align-items: center; + gap: 12px; + cursor: pointer; + font-size: 14px; + } + + input[type="checkbox"] { + width: 18px; + height: 18px; + accent-color: var(--text-lime); + } + + input[type="checkbox"]:disabled { + opacity: 0.5; + cursor: not-allowed; + } + + .btn { + background-color: var(--btn-primary); + color: white; + border: none; + padding: 8px 16px; + border-radius: 4px; + cursor: pointer; + text-decoration: none; + transition-duration: 0.2s; + font-weight: 600; + font-size: 14px; + } + + .btn:hover { + background-color: var(--btn-primary-hover); + transform: scale(1.025); + } + + .btn.danger { + background-color: #dc2626; + } + + .btn.danger:hover { + background-color: #b91c1c; + } + + .password-section { + border-top: 1px solid var(--btn-primary); + padding-top: 20px; + margin-top: 20px; + } + + .data-actions { + display: flex; + flex-direction: column; + gap: 20px; + } + + .action-item { + display: flex; + justify-content: space-between; + align-items: center; + padding: 16px; + background-color: var(--bg-secondary); + border-radius: 8px; + } + + .action-item.danger { + border-left: 4px solid #dc2626; + } + + .action-info h3 { + margin: 0 0 4px 0; + font-size: 16px; + } + + .action-info p { + margin: 0; + font-size: 14px; + color: #888888; + } + + @media (max-width: 768px) { + .form-row { + flex-direction: column; + } + + .action-item { + flex-direction: column; + align-items: flex-start; + gap: 12px; + } + } +</style> diff --git a/ping/frontend/src/routes/dashboard/transactions/+page.svelte b/ping/frontend/src/routes/dashboard/transactions/+page.svelte new file mode 100644 index 0000000..86f9292 --- /dev/null +++ b/ping/frontend/src/routes/dashboard/transactions/+page.svelte @@ -0,0 +1,360 @@ +<script lang="ts"> + import TransactionModal from '$lib/components/dashboard/transactions/TransactionModal.svelte'; + import { authFetch } from '$lib/stores/auth'; + import { addToast } from '$lib/stores/toast'; + import { onMount } from 'svelte'; + + interface ITransaction { + id: string; + label: string | null; + amount: number; + currency: string; + receiverLabel: string | null; + receiverIban: string; + operationDate: Date; + eco_score: number; + creationDate: Date; + createrId: string; + } + + let searchQuery = $state(''); + let sortBy = $state('Date'); + let sortOrder = $state('Montant'); + let transactions: ITransaction[] = $state([]); + let isCreateDialogOpened = $state(false); + + let visibleTransactions = $state<ITransaction[]>([]); + + $effect(() => { + const query = searchQuery.toLowerCase(); + visibleTransactions = transactions.filter((t) => { + const matchesLabel = t.label?.toLowerCase().includes(query); + const matchesReceiver = t.receiverLabel?.toLowerCase().includes(query); + const receiverIban = t.receiverIban?.toLowerCase().includes(query); + return matchesLabel || matchesReceiver; + }); + }); + + function handleSearch() { + console.log('Searching for:', searchQuery); + } + + function applyFilters() { + console.log('Applying filters:', { sortBy, sortOrder }); + } + + function analyzeTransaction(transaction: any) { + console.log('Analyzing transaction', transaction); + } + + async function getAllTransactions(): Promise<ITransaction[]> { + const res = await authFetch('/api/transactions', { + method: 'GET', + headers: { + 'Content-Type': 'application/json' + } + }); + const data = await res.json(); + + if (!res.ok) { + addToast({ message: data.message, color: 'red', title: 'Erreur' }); + return []; + } + + return data.transactions as ITransaction[]; + } + + function onCreate(tr: ITransaction) { + getAllTransactions().then((transactionsData) => { + if (transactionsData) { + transactions = transactionsData; + } + }); + } + + onMount(() => { + getAllTransactions().then((transactionsData) => { + if (transactionsData) { + transactions = transactionsData; + visibleTransactions = transactionsData; + console.log(transactions); + } + }); + }); +</script> + +<section id="transactions"> + <div class="transactions-container"> + <div class="transactions-header"> + <h1>Transactions</h1> + <div class="search-bar"> + <input + type="text" + placeholder="Recherche..." + bind:value={searchQuery} + oninput={handleSearch} + /> + <button class="search-btn">🔍</button> + </div> + </div> + + <div class="filters-section"> + <div class="filters-left"> + <span class="filter-label">Trier</span> + <div class="filter-group"> + <select bind:value={sortBy} class="filter-select"> + <option value="Date">Date</option> + <option value="Amount">Montant</option> + <option value="Company">Entreprise</option> + </select> + <select bind:value={sortOrder} class="filter-select"> + <option value="Montant">Montant</option> + <option value="ASC">Croissant</option> + <option value="DESC">Décroissant</option> + </select> + </div> + </div> + <div> + <button + class="apply-btn" + onclick={() => { + isCreateDialogOpened = true; + }} + > + ➕ Créer + </button> + <button class="apply-btn" onclick={applyFilters}> 📁 Appliquer </button> + </div> + </div> + + <div class="transactions-list"> + {#each visibleTransactions as transaction} + <div class="transaction-card"> + <div class="transaction-main"> + <div class="transaction-info"> + <h3 class="transaction-type">{transaction.label}</h3> + <p class="transaction-company"> + {transaction.receiverIban} • {transaction.receiverLabel} + </p> + </div> + <div class="transaction-metrics"> + <div class="metric"> + <span class="metric-label">CO2</span> + <span class="metric-value co2">{transaction.eco_score}</span> + </div> + <div class="metric"> + <span class="metric-label">{transaction.currency}</span> + <span class="metric-value amount">{transaction.amount}</span> + </div> + </div> + <button class="analyze-btn" onclick={() => analyzeTransaction(transaction)}> + ✏️ Analyser + </button> + </div> + </div> + {/each} + </div> + </div> +</section> +<TransactionModal bind:isOpen={isCreateDialogOpened} {onCreate} /> + +<style> + #transactions { + padding: 16px; + background-color: var(--bg-secondary); + color: white; + min-height: calc(100vh - 72px); + } + + .transactions-container { + max-width: 1200px; + } + + .transactions-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 16px; + } + + h1 { + color: var(--text-lime); + font-size: 32px; + margin: 0; + font-weight: 600; + } + + .search-bar { + display: flex; + align-items: center; + background-color: var(--bg-primary); + border-radius: 8px; + padding: 4px; + gap: 8px; + } + + .search-bar input { + background: transparent; + border: none; + color: white; + padding: 12px 16px; + font-size: 14px; + outline: none; + width: 300px; + } + + .search-bar input::placeholder { + color: #888888; + } + + .search-btn { + background: transparent; + border: none; + color: #888888; + padding: 8px; + cursor: pointer; + font-size: 16px; + } + + /* Filters */ + .filters-section { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 16px; + padding: 16px; + background-color: var(--bg-primary); + border-radius: 8px; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); + } + + .filters-left { + display: flex; + align-items: center; + gap: 15px; + } + + .filter-label { + color: white; + font-weight: 500; + font-size: 14px; + } + + .filter-group { + display: flex; + gap: 10px; + } + + .filter-select { + background-color: var(--bg-secondary); + color: white; + border: none; + border-bottom: 2px solid var(--text-lime); + border-radius: 8px 8px 0 0; + padding: 8px 12px; + font-size: 14px; + cursor: pointer; + } + + .filter-select:focus { + outline: none; + border-bottom-color: var(--text-lime); + } + + .apply-btn { + background-color: var(--btn-primary); + color: white; + border: none; + padding: 8px 16px; + border-radius: 4px; + cursor: pointer; + font-weight: 600; + font-size: 14px; + transition-duration: 0.2s; + } + + .apply-btn:hover { + background-color: var(--btn-primary-hover); + transform: scale(1.025); + } + + .transactions-list { + display: flex; + flex-direction: column; + gap: 0; + } + + .transaction-card { + background-color: var(--bg-primary); + border-radius: 8px; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); + padding: 16px; + margin: 16px; + color: white; + width: calc(100% - 32px); + } + + .transaction-main { + display: flex; + align-items: center; + justify-content: space-between; + } + + .transaction-info h3 { + margin: 0 0 5px 0; + font-size: 18px; + font-weight: 600; + color: white; + } + + .transaction-company { + margin: 0; + font-size: 14px; + color: #888888; + } + + .transaction-metrics { + display: flex; + gap: 40px; + } + + .metric { + display: flex; + flex-direction: column; + align-items: center; + gap: 5px; + } + + .metric-label { + font-size: 12px; + color: #888888; + font-weight: 500; + } + + .metric-value { + font-size: 24px; + font-weight: bold; + } + + .metric-value.co2, + .metric-value.amount { + color: var(--text-lime); + } + + .analyze-btn { + background-color: var(--btn-primary); + color: white; + border: none; + padding: 8px 16px; + border-radius: 4px; + cursor: pointer; + font-size: 14px; + font-weight: 600; + transition-duration: 0.2s; + } + + .analyze-btn:hover { + background-color: var(--btn-primary-hover); + transform: scale(1.025); + } +</style> diff --git a/ping/frontend/src/routes/login/+page.svelte b/ping/frontend/src/routes/login/+page.svelte new file mode 100644 index 0000000..0d66f8d --- /dev/null +++ b/ping/frontend/src/routes/login/+page.svelte @@ -0,0 +1,149 @@ +<script lang="ts"> + import { addToast } from '$lib/stores/toast'; + import { json } from '@sveltejs/kit'; + + let isConnecting = $state(false); + + function onSubmit(event: Event) { + event.preventDefault(); + + isConnecting = true; + const fd = new FormData(event.target as HTMLFormElement); + const { login, password } = Object.fromEntries(fd.entries()); + + fetch('/api/user/login', { + body: JSON.stringify({ + login, + password + }), + headers: { + Accept: '*/*', + 'Content-Type': 'application/json' + }, + method: 'POST' + }) + .then(async (res) => { + const jsonData = await res.json(); + + if (!res.ok) { + isConnecting = false; + addToast({ message: jsonData.message }); + return; + } + + const { token } = jsonData; + localStorage.setItem('token', token); + window.location.href = '/dashboard'; + }) + .catch((err) => { + isConnecting = false; + console.error('Error during login:', err); + addToast({ + message: "Une erreur est survenue lors de la connexion. (Plus d'infos dans la console)" + }); + }); + } + + function createDebugUsers() { + fetch('/api/dbdebuguser') + .then((res) => { + if (res.ok) { + res.json().then((data) => { + if (data.created) { + addToast({ + message: 'Utilisateurs de debug créés avec succès.', + color: 'green', + title: 'Succès' + }); + return data; + } else { + addToast({ + message: 'Utilisateurs de debug déjà créés.', + color: 'green', + title: 'Succès' + }); + } + }); + } else { + addToast({ message: 'Il semblerait que les utilisateurs de debug soient déjà crées.' }); + } + }) + .catch((err) => { + console.error('Erreur lors de la création des utilisateurs de debug:', err); + addToast({ + message: + "Erreur lors de la création des utilisateurs de debug. (Plus d'infos dans la console)" + }); + }); + } +</script> + +<div class="bg"> + <div class="card"> + <h1>Connectez-vous</h1> + <form onsubmit={onSubmit}> + <label for="login">Nom d'utilisateur</label> + <input type="text" id="login" name="login" placeholder="lirili.larila" required /> + <label for="password">Mot de passe</label> + <input type="password" id="password" name="password" placeholder="Mot de passe" required /> + <button class="btn" type="submit" disabled={isConnecting}>Connexion</button> + </form> + </div> +</div> +<button id="fab" class="btn" onclick={createDebugUsers} + ><img src="/icons/debug.svg" width="100%" alt="+" /></button +> + +<style> + .card { + width: auto; + } + + .bg { + background-image: url('/img/header-bg.jpg'); + height: 100vh; + background-size: cover; + background-position: center; + position: relative; + margin: 0; + padding: 0; + display: flex; + justify-content: center; + align-items: center; + } + + h1 { + color: var(--text-lime); + font-size: 36px; + text-align: center; + margin: 0; + font-weight: 600; + } + + form { + display: flex; + flex-direction: column; + color: white; + gap: 8px; + min-width: 360px; + } + + #fab { + position: fixed; + bottom: 20px; + right: 20px; + z-index: 1000; + border-radius: 50%; + width: 56px; + height: 56px; + display: flex; + justify-content: center; + align-items: center; + font-weight: bolder; + font-size: 32px; + } + + #fab img { + transform: scale(2); + } +</style> diff --git a/ping/frontend/src/routes/stocksapi/chart/+server.ts b/ping/frontend/src/routes/stocksapi/chart/+server.ts new file mode 100644 index 0000000..3271e83 --- /dev/null +++ b/ping/frontend/src/routes/stocksapi/chart/+server.ts @@ -0,0 +1,21 @@ +import type { RequestHandler } from '@sveltejs/kit'; +import yahooFinance from 'yahoo-finance2'; + +/** + * Example request : GET /stocksapi/chart?query=AAPL&startDate=2025-01-01& + */ +export const GET: RequestHandler = async ({ request }) => { + const sp = new URL(request.url).searchParams; + const query = sp.get('query') || 'AAPL'; + const startDate = sp.get('startDate') || '2025-01-01'; + const endDate = sp.get('endDate') || new Date().toISOString().split('T')[0]; + const interval = sp.get('interval') || '1d'; + + const data = await yahooFinance.chart(query, { period1: startDate, period2: endDate, interval: interval }); + return new Response(JSON.stringify(data), { + headers: { + 'Content-Type': 'application/json', + 'Cache-Control': 'no-cache', + }, + }); +};
\ No newline at end of file diff --git a/ping/frontend/src/routes/stocksapi/insights/+server.ts b/ping/frontend/src/routes/stocksapi/insights/+server.ts new file mode 100644 index 0000000..6be76e9 --- /dev/null +++ b/ping/frontend/src/routes/stocksapi/insights/+server.ts @@ -0,0 +1,19 @@ +import type { RequestHandler } from '@sveltejs/kit'; +import yahooFinance from 'yahoo-finance2'; + +/** + * Example request : GET /stocksapi/insights?query=AAPL + */ +export const GET: RequestHandler = async ({ request }) => { + const sp = new URL(request.url).searchParams; + const query = sp.get('query') || 'AAPL'; + + const queryOptions = { lang: 'en-US', reportsCount: 2, region: 'US' }; + const data = await yahooFinance.insights(query, queryOptions); + return new Response(JSON.stringify(data), { + headers: { + 'Content-Type': 'application/json', + 'Cache-Control': 'no-cache', + }, + }); +};
\ No newline at end of file diff --git a/ping/frontend/src/routes/stocksapi/quote/+server.ts b/ping/frontend/src/routes/stocksapi/quote/+server.ts new file mode 100644 index 0000000..96619fc --- /dev/null +++ b/ping/frontend/src/routes/stocksapi/quote/+server.ts @@ -0,0 +1,18 @@ +import type { RequestHandler } from '@sveltejs/kit'; +import yahooFinance from 'yahoo-finance2'; + +/** + * Example request : GET /stocksapi/quote?query=AAPL + */ +export const GET: RequestHandler = async ({ request }) => { + const sp = new URL(request.url).searchParams; + const query = sp.get('query') || 'AAPL'; + + const data = await yahooFinance.quote(query); + return new Response(JSON.stringify(data), { + headers: { + 'Content-Type': 'application/json', + 'Cache-Control': 'no-cache', + }, + }); +};
\ No newline at end of file diff --git a/ping/frontend/src/routes/stocksapi/search/+server.ts b/ping/frontend/src/routes/stocksapi/search/+server.ts new file mode 100644 index 0000000..e58fc6b --- /dev/null +++ b/ping/frontend/src/routes/stocksapi/search/+server.ts @@ -0,0 +1,18 @@ +import type { RequestHandler } from '@sveltejs/kit'; +import yahooFinance from 'yahoo-finance2'; + +/** + * Example request : GET /stocksapi/search?query=AAPL + */ +export const GET: RequestHandler = async ({ request }) => { + const sp = new URL(request.url).searchParams; + const query = sp.get('query') || 'AAPL'; + + const data = await yahooFinance.search(query, { region: 'US' }) + return new Response(JSON.stringify(data), { + headers: { + 'Content-Type': 'application/json', + 'Cache-Control': 'no-cache', + }, + }); +};
\ No newline at end of file diff --git a/ping/frontend/src/routes/stocksapi/trendingSymbols/+server.ts b/ping/frontend/src/routes/stocksapi/trendingSymbols/+server.ts new file mode 100644 index 0000000..3dac574 --- /dev/null +++ b/ping/frontend/src/routes/stocksapi/trendingSymbols/+server.ts @@ -0,0 +1,16 @@ +import type { RequestHandler } from '@sveltejs/kit'; +import yahooFinance from 'yahoo-finance2'; + +/** + * Example request : GET /stocksapi/trendingSymbols + */ +export const GET: RequestHandler = async () => { + const queryOptions = { count: 5, lang: 'en-US' }; + const data = await yahooFinance.trendingSymbols('US', queryOptions); + return new Response(JSON.stringify(data), { + headers: { + 'Content-Type': 'application/json', + 'Cache-Control': 'no-cache', + }, + }); +};
\ No newline at end of file |
