feat: filtre utilisateurs analytics, améliorations dashboard + autres modifs
All checks were successful
Deploy Preprod / Build and Deploy to Preprod (push) Successful in 34s

- Multiselect Kirby pour filtrer par utilisateur(s)
- Données de test alignées sur les vrais comptes
- Suppression bloc utilisateurs les plus actifs
- Route get-data supporte le filtre emails
- Améliorations UI filtres (layout dates + users)
- Autres modifs : menu, router, dialog, deploy workflow

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
isUnknown 2026-03-03 11:27:27 +01:00
parent 8a73da920f
commit de104dc7dd
9 changed files with 243 additions and 42 deletions

View file

@ -0,0 +1,53 @@
name: Deploy Production
on:
push:
branches:
- main
jobs:
build-and-deploy:
name: Build and Deploy to Production
runs-on: docker
container:
image: forgejo-ci-node:latest
steps:
- name: Checkout code
run: |
git clone --depth 1 --branch main https://forge.studio-variable.com/${{ github.repository }}.git .
ls -la
- name: Install npm dependencies
run: npm install
- name: Build frontend (production)
run: npm run build
- name: Install composer dependencies
run: |
cd dist
composer install --no-dev --optimize-autoloader
- name: Deploy via rsync
env:
USERNAME: ${{ secrets.DEMO_USERNAME }}
PASSWORD: ${{ secrets.DEMO_PASSWORD }}
HOST: ${{ secrets.DEMO_HOST }}
run: |
cd dist
lftp -c "
set ftp:ssl-allow no;
open -u $USERNAME,$PASSWORD $PRODUCTION_HOST;
mirror --reverse --delete --verbose --ignore-time --parallel=10 \
assets assets;
mirror --reverse --delete --verbose --ignore-time --parallel=10 \
-x 'accounts/' \
-x 'cache/' \
-x 'sessions/' \
site site;
mirror --reverse --delete --verbose --ignore-time --parallel=10 \
kirby kirby;
mirror --reverse --delete --verbose --ignore-time --parallel=10 \
vendor vendor;
put index.php -o index.php;
quit"

View file

View file

@ -60,6 +60,16 @@ $menu = [
], ],
'-', '-',
'-', '-',
'analytics' => [
'label' => 'Analytics',
'icon' => 'chart',
'link' => 'pages/analytics',
'current' => function (string $current): bool {
$path = Kirby\Cms\App::instance()->path();
return Str::contains($path, 'pages/analytics');
}
],
'-',
'users', 'users',
'system' 'system'
]; ];

View file

@ -1 +1 @@
.k-analytics-dashboard{padding:1.5rem 0}.k-analytics-filters{display:flex;gap:1rem;margin-bottom:1.5rem}.k-analytics-filters label{display:flex;align-items:center;gap:.5rem;font-size:.875rem;color:var(--color-text-light)}.k-analytics-filters input[type=date]{padding:.375rem .5rem;border:1px solid var(--color-border);border-radius:var(--rounded);font-size:.875rem;background:var(--color-background)}.k-analytics-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:1.5rem;margin-bottom:1.5rem}.k-analytics-grid--2col{grid-template-columns:repeat(2,1fr)}.k-analytics-card{background:var(--color-background);border-radius:var(--rounded);padding:1.5rem;box-shadow:var(--shadow)}.k-analytics-card h3{font-size:.75rem;font-weight:600;color:var(--color-text-light);margin:0 0 .5rem;text-transform:uppercase;letter-spacing:.5px}.k-analytics-metric{font-size:2.5rem;font-weight:700;color:var(--color-text);line-height:1}.k-analytics-chart-container{background:var(--color-background);border-radius:var(--rounded);padding:1.5rem;margin-bottom:1.5rem;box-shadow:var(--shadow)}.k-analytics-chart-container h3{font-size:.875rem;font-weight:600;color:var(--color-text);margin:0 0 1rem}.k-analytics-chart-container canvas{max-height:300px}.k-analytics-empty{background:var(--color-background);border-radius:var(--rounded);padding:3rem;text-align:center;box-shadow:var(--shadow)}.k-analytics-empty p{margin:0;color:var(--color-text-light)}.k-analytics-list{list-style:none;margin:0;padding:0}.k-analytics-list li{display:flex;justify-content:space-between;padding:.375rem 0;border-bottom:1px solid var(--color-border);font-size:.875rem}.k-analytics-list li:last-child{border-bottom:none}.k-analytics-list-label{color:var(--color-text);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;margin-right:1rem}.k-analytics-list-value{font-weight:600;color:var(--color-text);flex-shrink:0} .k-analytics-dashboard{padding:1.5rem 0}.k-analytics-filters{display:flex;gap:1rem;margin-bottom:1.5rem}.k-analytics-filters label{display:flex;align-items:center;gap:.5rem;font-size:.875rem;color:var(--color-text-light)}.k-date-inputs-wrapper{display:flex;column-gap:1rem}.k-analytics-filters input[type=date]{padding:.375rem .5rem;border:1px solid var(--color-border);border-radius:var(--rounded);font-size:.875rem;background:var(--color-background)}.k-analytics-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:1.5rem;margin-bottom:1.5rem}.k-analytics-user-filter{display:flex;align-items:center;gap:.5rem;font-size:.875rem;color:var(--color-text-light);margin-left:2rem}.k-field-name-user{min-width:15rem}.k-analytics-card{background:var(--color-background);border-radius:var(--rounded);padding:1.5rem;box-shadow:var(--shadow)}.k-analytics-card h3{font-size:.75rem;font-weight:600;color:var(--color-text-light);margin:0 0 .5rem;text-transform:uppercase;letter-spacing:.5px}.k-analytics-metric{font-size:2.5rem;font-weight:700;color:var(--color-text);line-height:1}.k-analytics-chart-container{background:var(--color-background);border-radius:var(--rounded);padding:1.5rem;margin-bottom:1.5rem;box-shadow:var(--shadow)}.k-analytics-chart-container h3{font-size:.875rem;font-weight:600;color:var(--color-text);margin:0 0 1rem}.k-analytics-chart-container canvas{max-height:300px}.k-analytics-empty{background:var(--color-background);border-radius:var(--rounded);padding:3rem;text-align:center;box-shadow:var(--shadow)}.k-analytics-empty p{margin:0;color:var(--color-text-light)}.k-analytics-list{list-style:none;margin:0;padding:0}.k-analytics-list li{display:flex;justify-content:space-between;padding:.375rem 0;border-bottom:1px solid var(--color-border);font-size:.875rem}.k-analytics-list li:last-child{border-bottom:none}.k-analytics-list-label{color:var(--color-text);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;margin-right:1rem}.k-analytics-list-value{font-weight:600;color:var(--color-text);flex-shrink:0}

File diff suppressed because one or more lines are too long

View file

@ -27,7 +27,6 @@ return [
$request = $kirby->request(); $request = $kirby->request();
$filters = []; $filters = [];
// Récupérer les filtres depuis query params
if ($startDate = $request->query()->get('startDate')) { if ($startDate = $request->query()->get('startDate')) {
$filters['startDate'] = $startDate; $filters['startDate'] = $startDate;
} }
@ -40,11 +39,38 @@ return [
$filters['project'] = $project; $filters['project'] = $project;
} }
if (!empty($_GET['emails'])) {
$filters['emails'] = explode(',', $_GET['emails']);
}
$data = $analyticsPage->getAnalyticsData($filters); $data = $analyticsPage->getAnalyticsData($filters);
$users = [];
foreach ($kirby->users() as $u) {
$email = (string) $u->email();
$name = $u->name()->isNotEmpty() ? (string) $u->name() : $email;
$label = $name;
$clientField = $u->content()->get('client');
if ($clientField && $clientField->isNotEmpty()) {
$clientPage = $clientField->toPage();
if ($clientPage) {
$label .= ' (' . $clientPage->title() . ')';
}
}
$users[] = [
'email' => $email,
'label' => $label,
];
}
usort($users, fn($a, $b) => strcasecmp($a['label'], $b['label']));
return [ return [
'status' => 'success', 'status' => 'success',
'data' => $data 'data' => $data,
'users' => $users
]; ];
} }
]; ];

View file

@ -1,14 +1,33 @@
<template> <template>
<div class="k-analytics-dashboard"> <div class="k-analytics-dashboard">
<div class="k-analytics-filters"> <div class="k-analytics-filters">
<label> <div class="k-analytics-date-filter">
Du <header class="k-field-header">
<input type="date" v-model="startDate" @change="fetchData" /> <label class="k-label k-field-label" title="Filtrer par dates">
</label> <span class="k-label-text">Filtrer par dates </span>
<label> </label>
Au </header>
<input type="date" v-model="endDate" @change="fetchData" /> <div class="k-date-inputs-wrapper">
</label> <label>
Du
<input type="date" v-model="startDate" @change="fetchData" />
</label>
<label>
Au
<input type="date" v-model="endDate" @change="fetchData" />
</label>
</div>
</div>
<div class="k-analytics-user-filter">
<k-multiselect-field
:options="userOptions"
:value="selectedEmails"
label="Filtrer par utilisateur(s)"
search="true"
name="user"
@input="onUserSelectionChange"
/>
</div>
</div> </div>
<div v-if="!hasData" class="k-analytics-empty"> <div v-if="!hasData" class="k-analytics-empty">
@ -36,31 +55,17 @@
<canvas ref="chartCanvas"></canvas> <canvas ref="chartCanvas"></canvas>
</div> </div>
<div class="k-analytics-grid k-analytics-grid--2col"> <div
<div class="k-analytics-card"
class="k-analytics-card" v-if="data.visitsByPage && Object.keys(data.visitsByPage).length"
v-if="data.visitsByPage && Object.keys(data.visitsByPage).length" >
> <h3>Pages les plus visitées</h3>
<h3>Pages les plus visitées</h3> <ul class="k-analytics-list">
<ul class="k-analytics-list"> <li v-for="(count, page) in data.visitsByPage" :key="page">
<li v-for="(count, page) in data.visitsByPage" :key="page"> <span class="k-analytics-list-label">{{ page }}</span>
<span class="k-analytics-list-label">{{ page }}</span> <span class="k-analytics-list-value">{{ count }}</span>
<span class="k-analytics-list-value">{{ count }}</span> </li>
</li> </ul>
</ul>
</div>
<div
class="k-analytics-card"
v-if="data.visitsByUser && Object.keys(data.visitsByUser).length"
>
<h3>Utilisateurs les plus actifs</h3>
<ul class="k-analytics-list">
<li v-for="(count, user) in data.visitsByUser" :key="user">
<span class="k-analytics-list-label">{{ user }}</span>
<span class="k-analytics-list-value">{{ count }}</span>
</li>
</ul>
</div>
</div> </div>
</template> </template>
</div> </div>
@ -102,6 +107,8 @@ export default {
endDate: '', endDate: '',
data: this.analyticsData || {}, data: this.analyticsData || {},
chart: null, chart: null,
users: [],
selectedEmails: [],
}; };
}, },
@ -113,6 +120,9 @@ export default {
if (!this.data?.uniqueSessions) return '0'; if (!this.data?.uniqueSessions) return '0';
return (this.data.totalVisits / this.data.uniqueSessions).toFixed(1); return (this.data.totalVisits / this.data.uniqueSessions).toFixed(1);
}, },
userOptions() {
return this.users.map((u) => ({ value: u.email, text: u.label }));
},
}, },
watch: { watch: {
@ -141,19 +151,33 @@ export default {
this.startDate = thirtyDaysAgo.toISOString().split('T')[0]; this.startDate = thirtyDaysAgo.toISOString().split('T')[0];
}, },
onUserSelectionChange(emails) {
this.selectedEmails = emails;
this.fetchData();
},
async fetchData() { async fetchData() {
const params = new URLSearchParams(); const params = new URLSearchParams();
if (this.startDate) params.set('startDate', this.startDate); if (this.startDate) params.set('startDate', this.startDate);
if (this.endDate) params.set('endDate', this.endDate); if (this.endDate) params.set('endDate', this.endDate);
if (this.selectedEmails.length) {
params.set('emails', this.selectedEmails.join(','));
}
try { try {
const response = await fetch(`/analytics-data.json?${params}`); const response = await fetch(`/analytics-data.json?${params}`);
const json = await response.json(); const json = await response.json();
console.log(json.data);
if (json.status === 'success') { if (json.status === 'success') {
this.data = json.data; this.data = json.data;
} }
if (json.users && !this.users.length) {
this.users = json.users;
}
this.renderChart(); this.renderChart();
} catch (e) { } catch (e) {
console.error('Analytics fetch error:', e); console.error('Analytics fetch error:', e);
@ -247,6 +271,11 @@ export default {
color: var(--color-text-light); color: var(--color-text-light);
} }
.k-date-inputs-wrapper {
display: flex;
column-gap: 1rem;
}
.k-analytics-filters input[type='date'] { .k-analytics-filters input[type='date'] {
padding: 0.375rem 0.5rem; padding: 0.375rem 0.5rem;
border: 1px solid var(--color-border); border: 1px solid var(--color-border);
@ -262,8 +291,17 @@ export default {
margin-bottom: 1.5rem; margin-bottom: 1.5rem;
} }
.k-analytics-grid--2col { .k-analytics-user-filter {
grid-template-columns: repeat(2, 1fr); display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.875rem;
color: var(--color-text-light);
margin-left: 2rem;
}
.k-field-name-user {
min-width: 15rem;
} }
.k-analytics-card { .k-analytics-card {

View file

@ -4,6 +4,7 @@ import { useApiStore } from '../stores/api';
import { usePageStore } from '../stores/page'; import { usePageStore } from '../stores/page';
import { useUserStore } from '../stores/user'; import { useUserStore } from '../stores/user';
import { useLocaleStore } from '../stores/locale'; import { useLocaleStore } from '../stores/locale';
import { useAnalyticsStore } from '../stores/analytics';
const router = createRouter({ const router = createRouter({
history: createWebHistory(), history: createWebHistory(),
@ -41,6 +42,65 @@ router.beforeEach(async (to, from, next) => {
} }
}); });
router.afterEach((to) => {
const userStore = useUserStore();
const pageStore = usePageStore();
if (userStore.user) {
const analytics = useAnalyticsStore();
analytics.initSession();
const { pageType, pageName } = getPageInfo(to, pageStore.page);
analytics.trackVisit(to.path, pageType, pageName);
}
});
function getPageInfo(route, page) {
const path = route.path;
if (path === '/' || path === '/en') {
return { pageType: 'home', pageName: 'Accueil' };
}
if (path.includes('/login')) {
return { pageType: 'login', pageName: 'Connexion' };
}
if (path.includes('/account')) {
return { pageType: 'account', pageName: 'Compte' };
}
if (path.includes('/notifications')) {
return { pageType: 'notifications', pageName: 'Notifications' };
}
if (path.includes('/reunions')) {
return { pageType: 'reunions', pageName: 'Réunions' };
}
if (path.includes('/inspirations')) {
return { pageType: 'inspirations', pageName: 'Inspirations' };
}
if (path.includes('/design-to-light')) {
return { pageType: 'design-to-light', pageName: 'Design to Light' };
}
if (path.includes('/client-brief')) {
return { pageType: 'client-brief', pageName: page?.title || 'Brief Client' };
}
if (path.includes('/extended-brief')) {
return { pageType: 'extended-brief', pageName: page?.title || 'Brief Étendu' };
}
if (path.includes('/projects/')) {
return { pageType: 'project', pageName: page?.title || 'Projet' };
}
return { pageType: 'unknown', pageName: path };
}
export function setI18nLocale(i18n) { export function setI18nLocale(i18n) {
router.afterEach(() => { router.afterEach(() => {
const localeStore = useLocaleStore(); const localeStore = useLocaleStore();

View file

@ -1,6 +1,7 @@
import { defineStore } from 'pinia'; import { defineStore } from 'pinia';
import { ref, computed, watch } from 'vue'; import { ref, computed, watch } from 'vue';
import { useRoute } from 'vue-router'; import { useRoute } from 'vue-router';
import { useAnalyticsStore } from './analytics';
export const useDialogStore = defineStore('dialog', () => { export const useDialogStore = defineStore('dialog', () => {
const content = ref(null); const content = ref(null);
@ -148,6 +149,19 @@ export const useDialogStore = defineStore('dialog', () => {
} }
} }
// Analytics tracking pour ouverture de fichiers
watch(openedFile, (newFile) => {
if (newFile) {
const analytics = useAnalyticsStore();
const currentPath = route.path;
analytics.trackVisit(
`${currentPath}#file-${newFile.uuid}`,
'modal-file',
newFile.name || newFile.filename
);
}
});
return { return {
content, content,
activeTracks, activeTracks,