Änderung: Erweiterung der Benutzerstatistiken im Admin-Bereich
Änderungen: - Neue Methode `getUserStatistics` im `AdminController` hinzugefügt, um Benutzerstatistiken abzurufen. - Implementierung der Logik zur Berechnung der Gesamtanzahl aktiver Benutzer, Geschlechterverteilung und Altersverteilung im `AdminService`. - Neue Route `/users/statistics` im `adminRouter` definiert, um auf die Benutzerstatistiken zuzugreifen. - Anpassungen der Navigationsstruktur und Übersetzungen für Benutzerstatistiken in den Sprachdateien aktualisiert. Diese Anpassungen verbessern die Analyse der Benutzerbasis und erweitern die Funktionalität des Admin-Bereichs.
This commit is contained in:
@@ -34,6 +34,9 @@ class AdminController {
|
|||||||
this.listUserRights = this.listUserRights.bind(this);
|
this.listUserRights = this.listUserRights.bind(this);
|
||||||
this.addUserRight = this.addUserRight.bind(this);
|
this.addUserRight = this.addUserRight.bind(this);
|
||||||
this.removeUserRight = this.removeUserRight.bind(this);
|
this.removeUserRight = this.removeUserRight.bind(this);
|
||||||
|
|
||||||
|
// Statistics
|
||||||
|
this.getUserStatistics = this.getUserStatistics.bind(this);
|
||||||
}
|
}
|
||||||
|
|
||||||
async getOpenInterests(req, res) {
|
async getOpenInterests(req, res) {
|
||||||
@@ -778,6 +781,20 @@ class AdminController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getUserStatistics(req, res) {
|
||||||
|
try {
|
||||||
|
const { userid: userId } = req.headers;
|
||||||
|
const statistics = await AdminService.getUserStatistics(userId);
|
||||||
|
res.status(200).json(statistics);
|
||||||
|
} catch (error) {
|
||||||
|
if (error.message === 'noaccess') {
|
||||||
|
res.status(403).json({ error: 'Keine Berechtigung' });
|
||||||
|
} else {
|
||||||
|
res.status(500).json({ error: error.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export default AdminController;
|
export default AdminController;
|
||||||
|
|||||||
@@ -222,9 +222,22 @@ const menuStructure = {
|
|||||||
visible: ["mainadmin", "contactrequests"],
|
visible: ["mainadmin", "contactrequests"],
|
||||||
path: "/admin/contacts"
|
path: "/admin/contacts"
|
||||||
},
|
},
|
||||||
useradministration: {
|
users: {
|
||||||
visible: ["mainadmin", "useradministration"],
|
visible: ["mainadmin", "useradministration"],
|
||||||
path: "/admin/users"
|
children: {
|
||||||
|
userlist: {
|
||||||
|
visible: ["mainadmin", "useradministration"],
|
||||||
|
path: "/admin/users"
|
||||||
|
},
|
||||||
|
userstatistics: {
|
||||||
|
visible: ["mainadmin"],
|
||||||
|
path: "/admin/users/statistics"
|
||||||
|
},
|
||||||
|
userrights: {
|
||||||
|
visible: ["mainadmin", "rights"],
|
||||||
|
path: "/admin/rights"
|
||||||
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
forum: {
|
forum: {
|
||||||
visible: ["mainadmin", "forum"],
|
visible: ["mainadmin", "forum"],
|
||||||
@@ -234,10 +247,6 @@ const menuStructure = {
|
|||||||
visible: ["mainadmin", "chatrooms"],
|
visible: ["mainadmin", "chatrooms"],
|
||||||
path: "/admin/chatrooms"
|
path: "/admin/chatrooms"
|
||||||
},
|
},
|
||||||
userrights: {
|
|
||||||
visible: ["mainadmin", "rights"],
|
|
||||||
path: "/admin/rights"
|
|
||||||
},
|
|
||||||
interests: {
|
interests: {
|
||||||
visible: ["mainadmin", "interests"],
|
visible: ["mainadmin", "interests"],
|
||||||
path: "/admin/interests"
|
path: "/admin/interests"
|
||||||
|
|||||||
@@ -25,11 +25,9 @@ const UserParam = sequelize.define('user_param', {
|
|||||||
type: DataTypes.STRING,
|
type: DataTypes.STRING,
|
||||||
allowNull: false,
|
allowNull: false,
|
||||||
set(value) {
|
set(value) {
|
||||||
console.log('.... [set param value]', value);
|
|
||||||
if (value) {
|
if (value) {
|
||||||
try {
|
try {
|
||||||
const encrypted = encrypt(value.toString());
|
const encrypted = encrypt(value.toString());
|
||||||
console.log('.... [encrypted param value]', encrypted);
|
|
||||||
this.setDataValue('value', encrypted);
|
this.setDataValue('value', encrypted);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('.... Error encrypting param value:', error);
|
console.error('.... Error encrypting param value:', error);
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ router.delete('/chat/rooms/:id', authenticate, adminController.deleteRoom);
|
|||||||
|
|
||||||
// --- Users Admin ---
|
// --- Users Admin ---
|
||||||
router.get('/users/search', authenticate, adminController.searchUsers);
|
router.get('/users/search', authenticate, adminController.searchUsers);
|
||||||
|
router.get('/users/statistics', authenticate, adminController.getUserStatistics);
|
||||||
router.get('/users/:id', authenticate, adminController.getUser);
|
router.get('/users/:id', authenticate, adminController.getUser);
|
||||||
router.put('/users/:id', authenticate, adminController.updateUser);
|
router.put('/users/:id', authenticate, adminController.updateUser);
|
||||||
|
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import FalukantStockType from "../models/falukant/type/stock.js";
|
|||||||
import RegionData from "../models/falukant/data/region.js";
|
import RegionData from "../models/falukant/data/region.js";
|
||||||
import BranchType from "../models/falukant/type/branch.js";
|
import BranchType from "../models/falukant/type/branch.js";
|
||||||
import Room from '../models/chat/room.js';
|
import Room from '../models/chat/room.js';
|
||||||
|
import UserParam from '../models/community/user_param.js';
|
||||||
|
|
||||||
class AdminService {
|
class AdminService {
|
||||||
async hasUserAccess(userId, section) {
|
async hasUserAccess(userId, section) {
|
||||||
@@ -855,6 +856,111 @@ class AdminService {
|
|||||||
const Match3TileType = (await import('../models/match3/tileType.js')).default;
|
const Match3TileType = (await import('../models/match3/tileType.js')).default;
|
||||||
return await Match3TileType.destroy({ where: { id } });
|
return await Match3TileType.destroy({ where: { id } });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getUserStatistics(userId) {
|
||||||
|
if (!(await this.hasUserAccess(userId, 'mainadmin'))) {
|
||||||
|
throw new Error('noaccess');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Gesamtanzahl angemeldeter Benutzer
|
||||||
|
const totalUsers = await User.count({
|
||||||
|
where: { active: true }
|
||||||
|
});
|
||||||
|
|
||||||
|
// Geschlechterverteilung - ohne raw: true um Entschlüsselung zu ermöglichen
|
||||||
|
const genderStats = await UserParam.findAll({
|
||||||
|
include: [{
|
||||||
|
model: UserParamType,
|
||||||
|
as: 'paramType',
|
||||||
|
where: { description: 'gender' }
|
||||||
|
}]
|
||||||
|
});
|
||||||
|
|
||||||
|
const genderDistribution = {};
|
||||||
|
for (const stat of genderStats) {
|
||||||
|
const genderId = stat.value; // Dies ist die ID des Geschlechts
|
||||||
|
if (genderId) {
|
||||||
|
const genderValue = await UserParamValue.findOne({
|
||||||
|
where: { id: genderId }
|
||||||
|
});
|
||||||
|
if (genderValue) {
|
||||||
|
const gender = genderValue.value; // z.B. 'male', 'female'
|
||||||
|
genderDistribution[gender] = (genderDistribution[gender] || 0) + 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Altersverteilung basierend auf Geburtsdatum - ohne raw: true um Entschlüsselung zu ermöglichen
|
||||||
|
const birthdateStats = await UserParam.findAll({
|
||||||
|
include: [{
|
||||||
|
model: UserParamType,
|
||||||
|
as: 'paramType',
|
||||||
|
where: { description: 'birthdate' }
|
||||||
|
}]
|
||||||
|
});
|
||||||
|
|
||||||
|
const ageGroups = {
|
||||||
|
'unter 12': 0,
|
||||||
|
'12-14': 0,
|
||||||
|
'14-16': 0,
|
||||||
|
'16-18': 0,
|
||||||
|
'18-21': 0,
|
||||||
|
'21-25': 0,
|
||||||
|
'25-30': 0,
|
||||||
|
'30-40': 0,
|
||||||
|
'40-50': 0,
|
||||||
|
'50-60': 0,
|
||||||
|
'über 60': 0
|
||||||
|
};
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
for (const stat of birthdateStats) {
|
||||||
|
try {
|
||||||
|
const birthdate = new Date(stat.value);
|
||||||
|
if (isNaN(birthdate.getTime())) continue;
|
||||||
|
|
||||||
|
const age = now.getFullYear() - birthdate.getFullYear();
|
||||||
|
const monthDiff = now.getMonth() - birthdate.getMonth();
|
||||||
|
|
||||||
|
let actualAge = age;
|
||||||
|
if (monthDiff < 0 || (monthDiff === 0 && now.getDate() < birthdate.getDate())) {
|
||||||
|
actualAge--;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (actualAge < 12) {
|
||||||
|
ageGroups['unter 12']++;
|
||||||
|
} else if (actualAge >= 12 && actualAge < 14) {
|
||||||
|
ageGroups['12-14']++;
|
||||||
|
} else if (actualAge >= 14 && actualAge < 16) {
|
||||||
|
ageGroups['14-16']++;
|
||||||
|
} else if (actualAge >= 16 && actualAge < 18) {
|
||||||
|
ageGroups['16-18']++;
|
||||||
|
} else if (actualAge >= 18 && actualAge < 21) {
|
||||||
|
ageGroups['18-21']++;
|
||||||
|
} else if (actualAge >= 21 && actualAge < 25) {
|
||||||
|
ageGroups['21-25']++;
|
||||||
|
} else if (actualAge >= 25 && actualAge < 30) {
|
||||||
|
ageGroups['25-30']++;
|
||||||
|
} else if (actualAge >= 30 && actualAge < 40) {
|
||||||
|
ageGroups['30-40']++;
|
||||||
|
} else if (actualAge >= 40 && actualAge < 50) {
|
||||||
|
ageGroups['40-50']++;
|
||||||
|
} else if (actualAge >= 50 && actualAge < 60) {
|
||||||
|
ageGroups['50-60']++;
|
||||||
|
} else {
|
||||||
|
ageGroups['über 60']++;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Fehler beim Verarbeiten des Geburtsdatums:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
totalUsers,
|
||||||
|
genderDistribution,
|
||||||
|
ageGroups
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export default new AdminService();
|
export default new AdminService();
|
||||||
@@ -186,6 +186,12 @@
|
|||||||
"objectiveDescriptionPlaceholder": "z.B. Sammle 100 Punkte",
|
"objectiveDescriptionPlaceholder": "z.B. Sammle 100 Punkte",
|
||||||
"objectiveRequired": "Erforderlich für Level-Abschluss",
|
"objectiveRequired": "Erforderlich für Level-Abschluss",
|
||||||
"noObjectives": "Keine Siegvoraussetzungen definiert. Klicke auf 'Objektiv hinzufügen' um welche zu erstellen."
|
"noObjectives": "Keine Siegvoraussetzungen definiert. Klicke auf 'Objektiv hinzufügen' um welche zu erstellen."
|
||||||
|
},
|
||||||
|
"userStatistics": {
|
||||||
|
"title": "[Admin] - Benutzerstatistiken",
|
||||||
|
"totalUsers": "Gesamtanzahl Benutzer",
|
||||||
|
"genderDistribution": "Geschlechterverteilung",
|
||||||
|
"ageDistribution": "Altersverteilung"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -44,9 +44,13 @@
|
|||||||
},
|
},
|
||||||
"m-administration": {
|
"m-administration": {
|
||||||
"contactrequests": "Kontaktanfragen",
|
"contactrequests": "Kontaktanfragen",
|
||||||
"useradministration": "Benutzerverwaltung",
|
"users": "Benutzer",
|
||||||
|
"m-users": {
|
||||||
|
"userlist": "Benutzerliste",
|
||||||
|
"userstatistics": "Benutzerstatistiken",
|
||||||
|
"userrights": "Benutzerrechte"
|
||||||
|
},
|
||||||
"forum": "Forum",
|
"forum": "Forum",
|
||||||
"userrights": "Benutzerrechte",
|
|
||||||
"interests": "Interessen",
|
"interests": "Interessen",
|
||||||
"falukant": "Falukant",
|
"falukant": "Falukant",
|
||||||
"m-falukant": {
|
"m-falukant": {
|
||||||
|
|||||||
@@ -98,6 +98,12 @@
|
|||||||
"stockAdded": "Warehouse successfully added.",
|
"stockAdded": "Warehouse successfully added.",
|
||||||
"invalidStockData": "Please enter valid warehouse type and quantity."
|
"invalidStockData": "Please enter valid warehouse type and quantity."
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"userStatistics": {
|
||||||
|
"title": "[Admin] - User Statistics",
|
||||||
|
"totalUsers": "Total Users",
|
||||||
|
"genderDistribution": "Gender Distribution",
|
||||||
|
"ageDistribution": "Age Distribution"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -41,16 +41,25 @@
|
|||||||
},
|
},
|
||||||
"m-administration": {
|
"m-administration": {
|
||||||
"contactrequests": "Contact requests",
|
"contactrequests": "Contact requests",
|
||||||
"useradministration": "User administration",
|
"users": "Users",
|
||||||
|
"m-users": {
|
||||||
|
"userlist": "User list",
|
||||||
|
"userstatistics": "User statistics",
|
||||||
|
"userrights": "User rights"
|
||||||
|
},
|
||||||
"forum": "Forum",
|
"forum": "Forum",
|
||||||
"userrights": "User rights",
|
|
||||||
"interests": "Interests",
|
"interests": "Interests",
|
||||||
"falukant": "Falukant",
|
"falukant": "Falukant",
|
||||||
"m-falukant": {
|
"m-falukant": {
|
||||||
"logentries": "Log entries",
|
"logentries": "Log entries",
|
||||||
"edituser": "Edit user",
|
"edituser": "Edit user",
|
||||||
"database": "Database"
|
"database": "Database"
|
||||||
}
|
},
|
||||||
|
"minigames": "Mini games",
|
||||||
|
"m-minigames": {
|
||||||
|
"match3": "Match3 Levels"
|
||||||
|
},
|
||||||
|
"chatrooms": "Chat rooms"
|
||||||
},
|
},
|
||||||
"m-friends": {
|
"m-friends": {
|
||||||
"manageFriends": "Manage friends",
|
"manageFriends": "Manage friends",
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import ForumAdminView from '../dialogues/admin/ForumAdminView.vue';
|
|||||||
import AdminFalukantEditUserView from '../views/admin/falukant/EditUserView.vue';
|
import AdminFalukantEditUserView from '../views/admin/falukant/EditUserView.vue';
|
||||||
import AdminMinigamesView from '../views/admin/MinigamesView.vue';
|
import AdminMinigamesView from '../views/admin/MinigamesView.vue';
|
||||||
import AdminUsersView from '../views/admin/UsersView.vue';
|
import AdminUsersView from '../views/admin/UsersView.vue';
|
||||||
|
import UserStatisticsView from '../views/admin/UserStatisticsView.vue';
|
||||||
|
|
||||||
const adminRoutes = [
|
const adminRoutes = [
|
||||||
{
|
{
|
||||||
@@ -20,6 +21,12 @@ const adminRoutes = [
|
|||||||
component: AdminUsersView,
|
component: AdminUsersView,
|
||||||
meta: { requiresAuth: true }
|
meta: { requiresAuth: true }
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: '/admin/users/statistics',
|
||||||
|
name: 'AdminUserStatistics',
|
||||||
|
component: UserStatisticsView,
|
||||||
|
meta: { requiresAuth: true }
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: '/admin/contacts',
|
path: '/admin/contacts',
|
||||||
name: 'AdminContacts',
|
name: 'AdminContacts',
|
||||||
|
|||||||
218
frontend/src/views/admin/UserStatisticsView.vue
Normal file
218
frontend/src/views/admin/UserStatisticsView.vue
Normal file
@@ -0,0 +1,218 @@
|
|||||||
|
<template>
|
||||||
|
<div class="user-statistics-view">
|
||||||
|
<h2>{{ $t('admin.userStatistics.title') }}</h2>
|
||||||
|
|
||||||
|
<div v-if="loading" class="loading">
|
||||||
|
{{ $t('general.loading') }}...
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else-if="error" class="error">
|
||||||
|
{{ error }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="statistics-container">
|
||||||
|
<!-- Gesamtstatistik -->
|
||||||
|
<div class="stat-card total-users">
|
||||||
|
<h3>{{ $t('admin.userStatistics.totalUsers') }}</h3>
|
||||||
|
<div class="stat-number">{{ statistics.totalUsers }}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Geschlechterverteilung -->
|
||||||
|
<div class="stat-card gender-distribution">
|
||||||
|
<h3>{{ $t('admin.userStatistics.genderDistribution') }}</h3>
|
||||||
|
<div class="gender-chart">
|
||||||
|
<div v-for="(count, gender) in statistics.genderDistribution" :key="gender" class="gender-item">
|
||||||
|
<span class="gender-label">{{ $t(`gender.${gender}`) }}</span>
|
||||||
|
<div class="gender-bar">
|
||||||
|
<div class="gender-fill" :style="{ width: getGenderPercentage(count) + '%' }"></div>
|
||||||
|
</div>
|
||||||
|
<span class="gender-count">{{ count }} ({{ getGenderPercentage(count) }}%)</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Altersverteilung -->
|
||||||
|
<div class="stat-card age-distribution">
|
||||||
|
<h3>{{ $t('admin.userStatistics.ageDistribution') }}</h3>
|
||||||
|
<div class="age-chart">
|
||||||
|
<div v-for="(count, ageGroup) in statistics.ageGroups" :key="ageGroup" class="age-item">
|
||||||
|
<span class="age-label">{{ ageGroup }}</span>
|
||||||
|
<div class="age-bar">
|
||||||
|
<div class="age-fill" :style="{ width: getAgePercentage(count) + '%' }"></div>
|
||||||
|
</div>
|
||||||
|
<span class="age-count">{{ count }} ({{ getAgePercentage(count) }}%)</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import apiClient from '@/utils/axios.js';
|
||||||
|
|
||||||
|
export default {
|
||||||
|
name: 'UserStatisticsView',
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
statistics: {
|
||||||
|
totalUsers: 0,
|
||||||
|
genderDistribution: {},
|
||||||
|
ageGroups: {}
|
||||||
|
},
|
||||||
|
loading: true,
|
||||||
|
error: null
|
||||||
|
};
|
||||||
|
},
|
||||||
|
async mounted() {
|
||||||
|
await this.loadStatistics();
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
async loadStatistics() {
|
||||||
|
try {
|
||||||
|
this.loading = true;
|
||||||
|
this.error = null;
|
||||||
|
const response = await apiClient.get('/api/admin/users/statistics');
|
||||||
|
this.statistics = response.data;
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Fehler beim Laden der Benutzerstatistiken:', err);
|
||||||
|
this.error = err.response?.data?.error || 'Fehler beim Laden der Statistiken';
|
||||||
|
} finally {
|
||||||
|
this.loading = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
getGenderPercentage(count) {
|
||||||
|
const total = Object.values(this.statistics.genderDistribution).reduce((sum, val) => sum + val, 0);
|
||||||
|
return total > 0 ? Math.round((count / total) * 100) : 0;
|
||||||
|
},
|
||||||
|
getAgePercentage(count) {
|
||||||
|
const total = Object.values(this.statistics.ageGroups).reduce((sum, val) => sum + val, 0);
|
||||||
|
return total > 0 ? Math.round((count / total) * 100) : 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.user-statistics-view {
|
||||||
|
padding: 20px;
|
||||||
|
max-width: 1200px;
|
||||||
|
margin: 0 auto;
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
margin-bottom: 30px;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading, .error {
|
||||||
|
text-align: center;
|
||||||
|
padding: 40px;
|
||||||
|
font-size: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error {
|
||||||
|
color: #d32f2f;
|
||||||
|
background-color: #ffebee;
|
||||||
|
border: 1px solid #ffcdd2;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.statistics-container {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: 30px;
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card {
|
||||||
|
background: white;
|
||||||
|
border: 1px solid #e0e0e0;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 24px;
|
||||||
|
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card h3 {
|
||||||
|
margin: 0 0 20px 0;
|
||||||
|
color: #333;
|
||||||
|
font-size: 20px;
|
||||||
|
border-bottom: 2px solid #f5f5f5;
|
||||||
|
padding-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.total-users {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-number {
|
||||||
|
font-size: 48px;
|
||||||
|
font-weight: bold;
|
||||||
|
color: #1976d2;
|
||||||
|
margin: 20px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gender-chart, .age-chart {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gender-item, .age-item {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 120px 1fr 100px;
|
||||||
|
align-items: center;
|
||||||
|
gap: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gender-label, .age-label {
|
||||||
|
font-weight: 500;
|
||||||
|
color: #555;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gender-bar, .age-bar {
|
||||||
|
height: 24px;
|
||||||
|
background-color: #f0f0f0;
|
||||||
|
border-radius: 12px;
|
||||||
|
overflow: hidden;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gender-fill, .age-fill {
|
||||||
|
height: 100%;
|
||||||
|
background: linear-gradient(90deg, #1976d2, #42a5f5);
|
||||||
|
border-radius: 12px;
|
||||||
|
transition: width 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gender-count, .age-count {
|
||||||
|
text-align: right;
|
||||||
|
font-weight: bold;
|
||||||
|
color: #333;
|
||||||
|
min-width: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 768px) {
|
||||||
|
.statistics-container {
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.total-users {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 1024px) {
|
||||||
|
.statistics-container {
|
||||||
|
grid-template-columns: 1fr 1fr 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.total-users {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
Reference in New Issue
Block a user