summaryrefslogtreecommitdiff
path: root/ping/frontend/src
diff options
context:
space:
mode:
Diffstat (limited to 'ping/frontend/src')
-rw-r--r--ping/frontend/src/app.css58
-rw-r--r--ping/frontend/src/app.d.ts13
-rw-r--r--ping/frontend/src/app.html13
-rw-r--r--ping/frontend/src/lib/components/Avatar.svelte48
-rw-r--r--ping/frontend/src/lib/components/Button.svelte17
-rw-r--r--ping/frontend/src/lib/components/NavBar.svelte44
-rw-r--r--ping/frontend/src/lib/components/NumberStatList.svelte55
-rw-r--r--ping/frontend/src/lib/components/SideBar.svelte61
-rw-r--r--ping/frontend/src/lib/components/SteppedLineChart.svelte76
-rw-r--r--ping/frontend/src/lib/components/ToastList.svelte67
-rw-r--r--ping/frontend/src/lib/components/UserItem.svelte42
-rw-r--r--ping/frontend/src/lib/components/dashboard/RiskAnalysis.svelte10
-rw-r--r--ping/frontend/src/lib/components/dashboard/StockGraph.svelte136
-rw-r--r--ping/frontend/src/lib/components/dashboard/TrendingSymbols.svelte85
-rw-r--r--ping/frontend/src/lib/components/dashboard/transactions/TransactionModal.svelte165
-rw-r--r--ping/frontend/src/lib/components/input/StockSelector.svelte231
-rw-r--r--ping/frontend/src/lib/components/input/UserSelector.svelte35
-rw-r--r--ping/frontend/src/lib/pages.ts54
-rw-r--r--ping/frontend/src/lib/stores/auth.ts66
-rw-r--r--ping/frontend/src/lib/stores/toast.ts22
-rw-r--r--ping/frontend/src/routes/+layout.svelte9
-rw-r--r--ping/frontend/src/routes/+page.svelte59
-rw-r--r--ping/frontend/src/routes/dashboard/+layout.svelte44
-rw-r--r--ping/frontend/src/routes/dashboard/+page.svelte81
-rw-r--r--ping/frontend/src/routes/dashboard/analyses/+page.svelte409
-rw-r--r--ping/frontend/src/routes/dashboard/messages/+page.svelte403
-rw-r--r--ping/frontend/src/routes/dashboard/models/+page.svelte568
-rw-r--r--ping/frontend/src/routes/dashboard/personnel/+page.svelte290
-rw-r--r--ping/frontend/src/routes/dashboard/settings/+page.svelte452
-rw-r--r--ping/frontend/src/routes/dashboard/transactions/+page.svelte360
-rw-r--r--ping/frontend/src/routes/login/+page.svelte149
-rw-r--r--ping/frontend/src/routes/stocksapi/chart/+server.ts21
-rw-r--r--ping/frontend/src/routes/stocksapi/insights/+server.ts19
-rw-r--r--ping/frontend/src/routes/stocksapi/quote/+server.ts18
-rw-r--r--ping/frontend/src/routes/stocksapi/search/+server.ts18
-rw-r--r--ping/frontend/src/routes/stocksapi/trendingSymbols/+server.ts16
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