Compare commits

...

2 Commits

Author SHA1 Message Date
b8aae00ea7 fix: add scatterplots
All checks were successful
Build and Publish Docker Images / Build Frontend Image (push) Successful in 12m22s
Build and Publish Docker Images / Build Backend Image (push) Successful in 12m31s
Build and Publish Docker Images / Build Summary (push) Successful in 3s
2026-03-02 17:12:33 +01:00
6cca179346 refactor(ui): unify frontend around compact, desktop-first design
All checks were successful
Build and Publish Docker Images / Build Frontend Image (push) Successful in 3m3s
Build and Publish Docker Images / Build Backend Image (push) Successful in 3m14s
Build and Publish Docker Images / Build Summary (push) Successful in 3s
Extract shared utilities (color functions, icon registry), replace hero
banners with compact PageHeader, add TrimesterSelector/ConfirmDialog/
Breadcrumb components, consolidate off-palette colors to design tokens,
convert AssessmentListView to table layout, compress ResultsView stats
into horizontal bar, and inline ClassFormView as a modal in ClassListView.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-24 09:37:46 +01:00
32 changed files with 756 additions and 845 deletions

View File

@@ -1,16 +1,14 @@
<template>
<div class="min-h-screen flex flex-col">
<div class="h-screen flex flex-col overflow-hidden">
<AppHeader />
<main class="flex-1">
<main class="flex-1 overflow-auto">
<router-view />
</main>
<AppFooter />
<NotificationContainer />
</div>
</template>
<script setup>
import AppHeader from '@/components/common/AppHeader.vue'
import AppFooter from '@/components/common/AppFooter.vue'
import NotificationContainer from '@/components/common/NotificationContainer.vue'
</script>

View File

@@ -106,6 +106,14 @@
.table tbody tr {
@apply hover:bg-gray-50 transition-colors;
}
.btn-modal-cancel {
@apply px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-md hover:bg-gray-50;
}
.btn-modal-confirm {
@apply px-4 py-2 text-sm font-medium text-white bg-primary-600 rounded-md hover:bg-primary-700;
}
}
/* Progress indicator colors */

View File

@@ -36,14 +36,14 @@
<!-- Create new option -->
<div
v-if="canCreate"
class="px-3 py-2 text-sm cursor-pointer hover:bg-purple-100 border-t border-gray-200 flex items-center"
:class="{ 'bg-purple-100': selectedIndex === suggestions.length }"
class="px-3 py-2 text-sm cursor-pointer hover:bg-accent-100 border-t border-gray-200 flex items-center"
:class="{ 'bg-accent-100': selectedIndex === suggestions.length }"
@mousedown.prevent="openCreateModal"
>
<svg class="w-4 h-4 text-purple-600 mr-2" fill="currentColor" viewBox="0 0 20 20">
<svg class="w-4 h-4 text-accent-600 mr-2" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M10 3a1 1 0 011 1v5h5a1 1 0 110 2h-5v5a1 1 0 11-2 0v-5H4a1 1 0 110-2h5V4a1 1 0 011-1z" clip-rule="evenodd"/>
</svg>
<span class="text-purple-600 font-medium">Créer "{{ searchQuery }}"</span>
<span class="text-accent-600 font-medium">Créer "{{ searchQuery }}"</span>
</div>
</div>
@@ -68,7 +68,7 @@
:key="color"
type="button"
class="w-8 h-8 rounded-full border-2 transition-all"
:class="newDomain.color === color ? 'ring-2 ring-purple-500 border-white' : 'border-gray-300 hover:border-gray-400'"
:class="newDomain.color === color ? 'ring-2 ring-accent-500 border-white' : 'border-gray-300 hover:border-gray-400'"
:style="{ backgroundColor: color }"
@click="newDomain.color = color"
></button>

View File

@@ -1,14 +0,0 @@
<template>
<footer class="bg-white border-t border-gray-200 mt-auto">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4">
<div class="flex flex-col sm:flex-row justify-between items-center text-sm text-gray-500">
<p>&copy; {{ currentYear }} Notytex - Gestion Scolaire</p>
<p class="mt-2 sm:mt-0">Version 2.0</p>
</div>
</div>
</footer>
</template>
<script setup>
const currentYear = new Date().getFullYear()
</script>

View File

@@ -66,15 +66,7 @@
<script setup>
import { ref } from 'vue'
import { useRoute } from 'vue-router'
// Simple icon components
const HomeIcon = { template: '<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="m2.25 12 8.954-8.955c.44-.439 1.152-.439 1.591 0L21.75 12M4.5 9.75v10.125c0 .621.504 1.125 1.125 1.125H9.75v-4.875c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125V21h4.125c.621 0 1.125-.504 1.125-1.125V9.75M8.25 21h8.25" /></svg>' }
const UsersIcon = { template: '<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M18 18.72a9.094 9.094 0 0 0 3.741-.479 3 3 0 0 0-4.682-2.72m.94 3.198.001.031c0 .225-.012.447-.037.666A11.944 11.944 0 0 1 12 21c-2.17 0-4.207-.576-5.963-1.584A6.062 6.062 0 0 1 6 18.719m12 0a5.971 5.971 0 0 0-.941-3.197m0 0A5.995 5.995 0 0 0 12 12.75a5.995 5.995 0 0 0-5.058 2.772m0 0a3 3 0 0 0-4.681 2.72 8.986 8.986 0 0 0 3.74.477m.94-3.197a5.971 5.971 0 0 0-.94 3.197M15 6.75a3 3 0 1 1-6 0 3 3 0 0 1 6 0Zm6 3a2.25 2.25 0 1 1-4.5 0 2.25 2.25 0 0 1 4.5 0Zm-13.5 0a2.25 2.25 0 1 1-4.5 0 2.25 2.25 0 0 1 4.5 0Z" /></svg>' }
const ClipboardIcon = { template: '<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M9 12h3.75M9 15h3.75M9 18h3.75m3 .75H18a2.25 2.25 0 0 0 2.25-2.25V6.108c0-1.135-.845-2.098-1.976-2.192a48.424 48.424 0 0 0-1.123-.08m-5.801 0c-.065.21-.1.433-.1.664 0 .414.336.75.75.75h4.5a.75.75 0 0 0 .75-.75 2.25 2.25 0 0 0-.1-.664m-5.8 0A2.251 2.251 0 0 1 13.5 2.25H15c1.012 0 1.867.668 2.15 1.586m-5.8 0c-.376.023-.75.05-1.124.08C9.095 4.01 8.25 4.973 8.25 6.108V8.25m0 0H4.875c-.621 0-1.125.504-1.125 1.125v11.25c0 .621.504 1.125 1.125 1.125h9.75c.621 0 1.125-.504 1.125-1.125V9.375c0-.621-.504-1.125-1.125-1.125H8.25ZM6.75 12h.008v.008H6.75V12Zm0 3h.008v.008H6.75V15Zm0 3h.008v.008H6.75V18Z" /></svg>' }
const AcademicCapIcon = { template: '<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M4.26 10.147a60.438 60.438 0 0 0-.491 6.347A48.62 48.62 0 0 1 12 20.904a48.62 48.62 0 0 1 8.232-4.41 60.46 60.46 0 0 0-.491-6.347m-15.482 0a50.636 50.636 0 0 0-2.658-.813A59.906 59.906 0 0 1 12 3.493a59.903 59.903 0 0 1 10.399 5.84c-.896.248-1.783.52-2.658.814m-15.482 0A50.717 50.717 0 0 1 12 13.489a50.702 50.702 0 0 1 7.74-3.342M6.75 15a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5Zm0 0v-3.675A55.378 55.378 0 0 1 12 8.443m-7.007 11.55A5.981 5.981 0 0 0 6.75 15.75v-1.5" /></svg>' }
const CogIcon = { template: '<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.325.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 0 1 1.37.49l1.296 2.247a1.125 1.125 0 0 1-.26 1.431l-1.003.827c-.293.241-.438.613-.43.992a7.723 7.723 0 0 1 0 .255c-.008.378.137.75.43.991l1.004.827c.424.35.534.955.26 1.43l-1.298 2.247a1.125 1.125 0 0 1-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.47 6.47 0 0 1-.22.128c-.331.183-.581.495-.644.869l-.213 1.281c-.09.543-.56.94-1.11.94h-2.594c-.55 0-1.019-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 0 1-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 0 1-1.369-.49l-1.297-2.247a1.125 1.125 0 0 1 .26-1.431l1.004-.827c.292-.24.437-.613.43-.991a6.932 6.932 0 0 1 0-.255c.007-.38-.138-.751-.43-.992l-1.004-.827a1.125 1.125 0 0 1-.26-1.43l1.297-2.247a1.125 1.125 0 0 1 1.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.086.22-.128.332-.183.582-.495.644-.869l.214-1.28Z" /><path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z" /></svg>' }
const MenuIcon = { template: '<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M3.75 6.75h16.5M3.75 12h16.5m-16.5 5.25h16.5" /></svg>' }
const XIcon = { template: '<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M6 18 18 6M6 6l12 12" /></svg>' }
import { HomeIcon, UsersIcon, ClipboardIcon, AcademicCapIcon, CogIcon, MenuIcon, XIcon } from '@/components/icons'
const route = useRoute()
const mobileMenuOpen = ref(false)

View File

@@ -0,0 +1,23 @@
<template>
<nav class="flex items-center text-sm text-gray-500 mb-1" aria-label="Breadcrumb">
<template v-for="(crumb, index) in crumbs" :key="index">
<router-link v-if="crumb.to" :to="crumb.to" class="hover:text-gray-700 transition-colors">
{{ crumb.label }}
</router-link>
<span v-else class="text-gray-900 font-medium">{{ crumb.label }}</span>
<svg v-if="index < crumbs.length - 1" class="w-4 h-4 mx-1.5 text-gray-400 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
</svg>
</template>
</nav>
</template>
<script setup>
defineProps({
crumbs: {
type: Array,
required: true,
validator: (v) => v.every(c => c.label)
}
})
</script>

View File

@@ -0,0 +1,73 @@
<template>
<Modal v-model="visible" :title="title" size="sm">
<p class="text-gray-600">{{ message }}</p>
<template #footer>
<div class="flex justify-end gap-3">
<button @click="cancel" class="btn-modal-cancel">
Annuler
</button>
<button
@click="confirm"
class="px-4 py-2 text-sm font-medium text-white rounded-md"
:class="confirmClasses"
>
{{ confirmLabel }}
</button>
</div>
</template>
</Modal>
</template>
<script setup>
import { computed } from 'vue'
import Modal from './Modal.vue'
const props = defineProps({
modelValue: {
type: Boolean,
default: false
},
title: {
type: String,
default: 'Confirmer'
},
message: {
type: String,
default: 'Êtes-vous sûr ?'
},
confirmLabel: {
type: String,
default: 'Confirmer'
},
variant: {
type: String,
default: 'danger',
validator: (v) => ['danger', 'primary', 'warning'].includes(v)
}
})
const emit = defineEmits(['update:modelValue', 'confirm'])
const visible = computed({
get: () => props.modelValue,
set: (val) => emit('update:modelValue', val)
})
const confirmClasses = computed(() => {
const map = {
danger: 'bg-danger-600 hover:bg-danger-700',
primary: 'bg-primary-600 hover:bg-primary-700',
warning: 'bg-warning-600 hover:bg-warning-700'
}
return map[props.variant]
})
function cancel() {
visible.value = false
}
function confirm() {
emit('confirm')
visible.value = false
}
</script>

View File

@@ -27,6 +27,7 @@
<script setup>
import { computed } from 'vue'
import { FolderIcon, UsersIcon, ClipboardIcon, ChartIcon, SearchIcon } from '@/components/icons'
const props = defineProps({
title: {
@@ -58,13 +59,6 @@ const props = defineProps({
defineEmits(['secondary'])
// Icons components
const FolderIcon = { template: '<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M2.25 12.75V12A2.25 2.25 0 0 1 4.5 9.75h15A2.25 2.25 0 0 1 21.75 12v.75m-8.69-6.44-2.12-2.12a1.5 1.5 0 0 0-1.061-.44H4.5A2.25 2.25 0 0 0 2.25 6v12a2.25 2.25 0 0 0 2.25 2.25h15A2.25 2.25 0 0 0 21.75 18V9a2.25 2.25 0 0 0-2.25-2.25h-5.379a1.5 1.5 0 0 1-1.06-.44Z" /></svg>' }
const UsersIcon = { template: '<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M18 18.72a9.094 9.094 0 0 0 3.741-.479 3 3 0 0 0-4.682-2.72m.94 3.198.001.031c0 .225-.012.447-.037.666A11.944 11.944 0 0 1 12 21c-2.17 0-4.207-.576-5.963-1.584A6.062 6.062 0 0 1 6 18.719m12 0a5.971 5.971 0 0 0-.941-3.197m0 0A5.995 5.995 0 0 0 12 12.75a5.995 5.995 0 0 0-5.058 2.772m0 0a3 3 0 0 0-4.681 2.72 8.986 8.986 0 0 0 3.74.477m.94-3.197a5.971 5.971 0 0 0-.94 3.197M15 6.75a3 3 0 1 1-6 0 3 3 0 0 1 6 0Zm6 3a2.25 2.25 0 1 1-4.5 0 2.25 2.25 0 0 1 4.5 0Zm-13.5 0a2.25 2.25 0 1 1-4.5 0 2.25 2.25 0 0 1 4.5 0Z" /></svg>' }
const ClipboardIcon = { template: '<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M9 12h3.75M9 15h3.75M9 18h3.75m3 .75H18a2.25 2.25 0 0 0 2.25-2.25V6.108c0-1.135-.845-2.098-1.976-2.192a48.424 48.424 0 0 0-1.123-.08m-5.801 0c-.065.21-.1.433-.1.664 0 .414.336.75.75.75h4.5a.75.75 0 0 0 .75-.75 2.25 2.25 0 0 0-.1-.664m-5.8 0A2.251 2.251 0 0 1 13.5 2.25H15c1.012 0 1.867.668 2.15 1.586m-5.8 0c-.376.023-.75.05-1.124.08C9.095 4.01 8.25 4.973 8.25 6.108V8.25m0 0H4.875c-.621 0-1.125.504-1.125 1.125v11.25c0 .621.504 1.125 1.125 1.125h9.75c.621 0 1.125-.504 1.125-1.125V9.375c0-.621-.504-1.125-1.125-1.125H8.25ZM6.75 12h.008v.008H6.75V12Zm0 3h.008v.008H6.75V15Zm0 3h.008v.008H6.75V18Z" /></svg>' }
const ChartIcon = { template: '<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M3 13.125C3 12.504 3.504 12 4.125 12h2.25c.621 0 1.125.504 1.125 1.125v6.75C7.5 20.496 6.996 21 6.375 21h-2.25A1.125 1.125 0 0 1 3 19.875v-6.75ZM9.75 8.625c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125v11.25c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 0 1-1.125-1.125V8.625ZM16.5 4.125c0-.621.504-1.125 1.125-1.125h2.25C20.496 3 21 3.504 21 4.125v15.75c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 0 1-1.125-1.125V4.125Z" /></svg>' }
const SearchIcon = { template: '<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="m21 21-5.197-5.197m0 0A7.5 7.5 0 1 0 5.196 5.196a7.5 7.5 0 0 0 10.607 10.607Z" /></svg>' }
const icons = {
folder: FolderIcon,
users: UsersIcon,

View File

@@ -49,8 +49,7 @@
<script setup>
import { computed, watch } from 'vue'
const XIcon = { template: '<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M6 18 18 6M6 6l12 12" /></svg>' }
import { XIcon } from '@/components/icons'
const props = defineProps({
modelValue: {

View File

@@ -29,11 +29,7 @@ const notificationsStore = useNotificationsStore()
const { notifications } = storeToRefs(notificationsStore)
const { remove, error } = notificationsStore
// Icon components
const CheckIcon = { template: '<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M9 12.75 11.25 15 15 9.75M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z" /></svg>' }
const ExclamationIcon = { template: '<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M12 9v3.75m9-.75a9 9 0 1 1-18 0 9 9 0 0 1 18 0Zm-9 3.75h.008v.008H12v-.008Z" /></svg>' }
const InfoIcon = { template: '<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="m11.25 11.25.041-.02a.75.75 0 0 1 1.063.852l-.708 2.836a.75.75 0 0 0 1.063.853l.041-.021M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Zm-9-3.75h.008v.008H12V8.25Z" /></svg>' }
const XIcon = { template: '<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M6 18 18 6M6 6l12 12" /></svg>' }
import { CheckIcon, ExclamationIcon, InfoIcon, XIcon } from '@/components/icons'
function notificationClasses(type) {
const classes = {

View File

@@ -0,0 +1,47 @@
<template>
<div class="mb-6">
<Breadcrumb v-if="breadcrumbs && breadcrumbs.length" :crumbs="breadcrumbs" />
<div class="flex items-center justify-between">
<div>
<div class="flex items-center gap-3">
<router-link
v-if="backTo"
:to="backTo"
class="text-gray-400 hover:text-gray-600"
>
<svg class="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" /></svg>
</router-link>
<h1 class="text-2xl font-bold text-gray-900">{{ title }}</h1>
<slot name="meta"></slot>
</div>
<p v-if="subtitle" class="text-sm text-gray-500 mt-1">{{ subtitle }}</p>
</div>
<div class="flex items-center gap-2">
<slot name="actions"></slot>
</div>
</div>
</div>
</template>
<script setup>
import Breadcrumb from '@/components/common/Breadcrumb.vue'
defineProps({
title: {
type: String,
required: true
},
subtitle: {
type: String,
default: ''
},
backTo: {
type: [String, Object],
default: null
},
breadcrumbs: {
type: Array,
default: null
}
})
</script>

View File

@@ -0,0 +1,62 @@
<template>
<div class="flex gap-1 items-center">
<button
v-if="showAll"
@click="$emit('update:modelValue', null)"
class="px-2.5 py-1 rounded text-xs font-medium transition-colors"
:class="[
sizeClasses,
modelValue === null
? 'bg-primary-600 text-white'
: 'bg-gray-100 text-gray-600 hover:bg-gray-200'
]"
>
{{ allLabel }}
</button>
<div v-if="showAll" class="border-l border-gray-300 h-5 mx-1"></div>
<button
v-for="t in [1, 2, 3]"
:key="t"
@click="$emit('update:modelValue', t)"
class="px-2.5 py-1 rounded text-xs font-medium transition-colors"
:class="[
sizeClasses,
modelValue === t
? 'bg-primary-600 text-white'
: 'bg-gray-100 text-gray-600 hover:bg-gray-200'
]"
>
T{{ t }}
</button>
</div>
</template>
<script setup>
import { computed } from 'vue'
const props = defineProps({
modelValue: {
type: Number,
default: null
},
showAll: {
type: Boolean,
default: false
},
allLabel: {
type: String,
default: 'Tous'
},
size: {
type: String,
default: 'sm',
validator: (v) => ['sm', 'md'].includes(v)
}
})
defineEmits(['update:modelValue'])
const sizeClasses = computed(() => {
return props.size === 'md' ? 'px-3 py-1.5 text-sm' : 'px-2.5 py-1 text-xs'
})
</script>

View File

@@ -55,7 +55,7 @@
>
<div
class="w-8 h-8 rounded border-2 border-white shadow-sm flex items-center justify-center text-xs font-bold"
:style="{ backgroundColor: example.color, color: getTextColor(example.color) }"
:style="{ backgroundColor: example.color, color: getTextColorForBg(example.color) }"
>
{{ example.note }}
</div>
@@ -257,9 +257,17 @@
<!-- Actions -->
<div class="flex justify-between items-center">
<button @click="resetScale" class="btn btn-secondary">
<button @click="showResetConfirm = true" class="btn btn-secondary">
Valeurs par defaut
</button>
<ConfirmDialog
v-model="showResetConfirm"
title="Reinitialiser l'echelle"
message="Reinitialiser l'echelle aux valeurs par defaut ?"
confirmLabel="Reinitialiser"
variant="warning"
@confirm="doResetScale"
/>
<button
@click="saveScale"
@@ -335,6 +343,7 @@ import { useConfigStore } from '@/stores/config'
import { useNotificationsStore } from '@/stores/notifications'
import ColorPicker from '@/components/common/ColorPicker.vue'
import Modal from '@/components/common/Modal.vue'
import ConfirmDialog from '@/components/common/ConfirmDialog.vue'
const configStore = useConfigStore()
const notifications = useNotificationsStore()
@@ -387,86 +396,7 @@ const gradientExamples = computed(() => [
{ percent: 0.75, note: 15, color: interpolateColorHSL(gradientForm.value.min_color, gradientForm.value.max_color, 0.75) }
])
// Color functions
function hexToRgb(hex) {
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex)
return result ? {
r: parseInt(result[1], 16),
g: parseInt(result[2], 16),
b: parseInt(result[3], 16)
} : { r: 0, g: 0, b: 0 }
}
function rgbToHex(r, g, b) {
return "#" + ((1 << 24) + (r << 16) + (g << 8) + b).toString(16).slice(1)
}
function rgbToHsl(r, g, b) {
r /= 255; g /= 255; b /= 255
const max = Math.max(r, g, b), min = Math.min(r, g, b)
let h, s, l = (max + min) / 2
if (max === min) {
h = s = 0
} else {
const d = max - min
s = l > 0.5 ? d / (2 - max - min) : d / (max + min)
switch (max) {
case r: h = (g - b) / d + (g < b ? 6 : 0); break
case g: h = (b - r) / d + 2; break
case b: h = (r - g) / d + 4; break
}
h /= 6
}
return { h: h * 360, s: s * 100, l: l * 100 }
}
function hslToRgb(h, s, l) {
h /= 360; s /= 100; l /= 100
const hue2rgb = (p, q, t) => {
if (t < 0) t += 1
if (t > 1) t -= 1
if (t < 1/6) return p + (q - p) * 6 * t
if (t < 1/2) return q
if (t < 2/3) return p + (q - p) * (2/3 - t) * 6
return p
}
let r, g, b
if (s === 0) {
r = g = b = l
} else {
const q = l < 0.5 ? l * (1 + s) : l + s - l * s
const p = 2 * l - q
r = hue2rgb(p, q, h + 1/3)
g = hue2rgb(p, q, h)
b = hue2rgb(p, q, h - 1/3)
}
return { r: Math.round(r * 255), g: Math.round(g * 255), b: Math.round(b * 255) }
}
function interpolateColorHSL(color1, color2, factor) {
const rgb1 = hexToRgb(color1)
const rgb2 = hexToRgb(color2)
const hsl1 = rgbToHsl(rgb1.r, rgb1.g, rgb1.b)
const hsl2 = rgbToHsl(rgb2.r, rgb2.g, rgb2.b)
let deltaH = hsl2.h - hsl1.h
if (deltaH > 180) hsl2.h -= 360
else if (deltaH < -180) hsl2.h += 360
const h = ((hsl1.h + (hsl2.h - hsl1.h) * factor) % 360 + 360) % 360
const s = hsl1.s + (hsl2.s - hsl1.s) * factor
const l = hsl1.l + (hsl2.l - hsl1.l) * factor
const rgb = hslToRgb(h, s, l)
return rgbToHex(rgb.r, rgb.g, rgb.b)
}
function getTextColor(bgColor) {
const rgb = hexToRgb(bgColor)
const brightness = (rgb.r * 299 + rgb.g * 587 + rgb.b * 114) / 1000
return brightness > 128 ? '#000000' : '#ffffff'
}
import { interpolateColorHSL, getTextColorForBg } from '@/utils/colors'
// Actions
function updateGradientPreview() {
@@ -504,9 +434,9 @@ async function saveScale() {
}
}
async function resetScale() {
if (!confirm('Reinitialiser l\'echelle aux valeurs par defaut ?')) return
const showResetConfirm = ref(false)
async function doResetScale() {
try {
await configStore.resetScale()
loadScaleForm()

View File

@@ -119,10 +119,10 @@
<script setup>
import { computed } from 'vue'
import { Bar, Chart as ChartGeneric } from 'vue-chartjs'
import { Chart as ChartJS, CategoryScale, LinearScale, BarElement, LineElement, PointElement, Title, Tooltip, Legend } from 'chart.js'
import { Chart as ChartJS, CategoryScale, LinearScale, BarElement, LineElement, PointElement, ScatterController, Title, Tooltip, Legend } from 'chart.js'
import { BoxPlotController, BoxAndWiskers } from '@sgratzl/chartjs-chart-boxplot'
ChartJS.register(CategoryScale, LinearScale, BarElement, LineElement, PointElement, Title, Tooltip, Legend, BoxPlotController, BoxAndWiskers)
ChartJS.register(CategoryScale, LinearScale, BarElement, LineElement, PointElement, ScatterController, Title, Tooltip, Legend, BoxPlotController, BoxAndWiskers)
const props = defineProps({
student: { type: Object, required: true },

View File

@@ -7,7 +7,7 @@
:value="search"
@input="$emit('update:search', $event.target.value)"
placeholder="Rechercher..."
class="w-full text-xs border border-gray-300 rounded py-1.5 pl-7 pr-2 focus:ring-purple-500 focus:border-purple-500"
class="w-full text-xs border border-gray-300 rounded py-1.5 pl-7 pr-2 focus:ring-primary-500 focus:border-primary-500"
/>
<svg class="absolute left-2 top-2 h-3.5 w-3.5 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />

View File

@@ -0,0 +1,33 @@
export const AcademicCapIcon = { template: '<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M4.26 10.147a60.438 60.438 0 0 0-.491 6.347A48.62 48.62 0 0 1 12 20.904a48.62 48.62 0 0 1 8.232-4.41 60.46 60.46 0 0 0-.491-6.347m-15.482 0a50.636 50.636 0 0 0-2.658-.813A59.906 59.906 0 0 1 12 3.493a59.903 59.903 0 0 1 10.399 5.84c-.896.248-1.783.52-2.658.814m-15.482 0A50.717 50.717 0 0 1 12 13.489a50.702 50.702 0 0 1 7.74-3.342M6.75 15a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5Zm0 0v-3.675A55.378 55.378 0 0 1 12 8.443m-7.007 11.55A5.981 5.981 0 0 0 6.75 15.75v-1.5" /></svg>' }
export const ChartIcon = { template: '<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M3 13.125C3 12.504 3.504 12 4.125 12h2.25c.621 0 1.125.504 1.125 1.125v6.75C7.5 20.496 6.996 21 6.375 21h-2.25A1.125 1.125 0 0 1 3 19.875v-6.75ZM9.75 8.625c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125v11.25c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 0 1-1.125-1.125V8.625ZM16.5 4.125c0-.621.504-1.125 1.125-1.125h2.25C20.496 3 21 3.504 21 4.125v15.75c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 0 1-1.125-1.125V4.125Z" /></svg>' }
export const CheckIcon = { template: '<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M9 12.75 11.25 15 15 9.75M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z" /></svg>' }
export const ChevronRightIcon = { template: '<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="m8.25 4.5 7.5 7.5-7.5 7.5" /></svg>' }
export const ClipboardIcon = { template: '<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M9 12h3.75M9 15h3.75M9 18h3.75m3 .75H18a2.25 2.25 0 0 0 2.25-2.25V6.108c0-1.135-.845-2.098-1.976-2.192a48.424 48.424 0 0 0-1.123-.08m-5.801 0c-.065.21-.1.433-.1.664 0 .414.336.75.75.75h4.5a.75.75 0 0 0 .75-.75 2.25 2.25 0 0 0-.1-.664m-5.8 0A2.251 2.251 0 0 1 13.5 2.25H15c1.012 0 1.867.668 2.15 1.586m-5.8 0c-.376.023-.75.05-1.124.08C9.095 4.01 8.25 4.973 8.25 6.108V8.25m0 0H4.875c-.621 0-1.125.504-1.125 1.125v11.25c0 .621.504 1.125 1.125 1.125h9.75c.621 0 1.125-.504 1.125-1.125V9.375c0-.621-.504-1.125-1.125-1.125H8.25ZM6.75 12h.008v.008H6.75V12Zm0 3h.008v.008H6.75V15Zm0 3h.008v.008H6.75V18Z" /></svg>' }
export const CogIcon = { template: '<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.325.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 0 1 1.37.49l1.296 2.247a1.125 1.125 0 0 1-.26 1.431l-1.003.827c-.293.241-.438.613-.43.992a7.723 7.723 0 0 1 0 .255c-.008.378.137.75.43.991l1.004.827c.424.35.534.955.26 1.43l-1.298 2.247a1.125 1.125 0 0 1-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.47 6.47 0 0 1-.22.128c-.331.183-.581.495-.644.869l-.213 1.281c-.09.543-.56.94-1.11.94h-2.594c-.55 0-1.019-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 0 1-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 0 1-1.369-.49l-1.297-2.247a1.125 1.125 0 0 1 .26-1.431l1.004-.827c.292-.24.437-.613.43-.991a6.932 6.932 0 0 1 0-.255c.007-.38-.138-.751-.43-.992l-1.004-.827a1.125 1.125 0 0 1-.26-1.43l1.297-2.247a1.125 1.125 0 0 1 1.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.086.22-.128.332-.183.582-.495.644-.869l.214-1.28Z" /><path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z" /></svg>' }
export const ExclamationIcon = { template: '<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M12 9v3.75m9-.75a9 9 0 1 1-18 0 9 9 0 0 1 18 0Zm-9 3.75h.008v.008H12v-.008Z" /></svg>' }
export const FolderIcon = { template: '<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M2.25 12.75V12A2.25 2.25 0 0 1 4.5 9.75h15A2.25 2.25 0 0 1 21.75 12v.75m-8.69-6.44-2.12-2.12a1.5 1.5 0 0 0-1.061-.44H4.5A2.25 2.25 0 0 0 2.25 6v12a2.25 2.25 0 0 0 2.25 2.25h15A2.25 2.25 0 0 0 21.75 18V9a2.25 2.25 0 0 0-2.25-2.25h-5.379a1.5 1.5 0 0 1-1.06-.44Z" /></svg>' }
export const HomeIcon = { template: '<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="m2.25 12 8.954-8.955c.44-.439 1.152-.439 1.591 0L21.75 12M4.5 9.75v10.125c0 .621.504 1.125 1.125 1.125H9.75v-4.875c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125V21h4.125c.621 0 1.125-.504 1.125-1.125V9.75M8.25 21h8.25" /></svg>' }
export const InfoIcon = { template: '<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="m11.25 11.25.041-.02a.75.75 0 0 1 1.063.852l-.708 2.836a.75.75 0 0 0 1.063.853l.041-.021M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Zm-9-3.75h.008v.008H12V8.25Z" /></svg>' }
export const MenuIcon = { template: '<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M3.75 6.75h16.5M3.75 12h16.5m-16.5 5.25h16.5" /></svg>' }
export const PencilIcon = { template: '<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="m16.862 4.487 1.687-1.688a1.875 1.875 0 1 1 2.652 2.652L10.582 16.07a4.5 4.5 0 0 1-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 0 1 1.13-1.897l8.932-8.931Zm0 0L19.5 7.125M18 14v4.75A2.25 2.25 0 0 1 15.75 21H5.25A2.25 2.25 0 0 1 3 18.75V8.25A2.25 2.25 0 0 1 5.25 6H10" /></svg>' }
export const PlusIcon = { template: '<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15" /></svg>' }
export const SearchIcon = { template: '<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="m21 21-5.197-5.197m0 0A7.5 7.5 0 1 0 5.196 5.196a7.5 7.5 0 0 0 10.607 10.607Z" /></svg>' }
export const TrashIcon = { template: '<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="m14.74 9-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 0 1-2.244 2.077H8.084a2.25 2.25 0 0 1-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 0 0-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 0 1 3.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 0 0-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 0 0-7.5 0" /></svg>' }
export const UsersIcon = { template: '<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M18 18.72a9.094 9.094 0 0 0 3.741-.479 3 3 0 0 0-4.682-2.72m.94 3.198.001.031c0 .225-.012.447-.037.666A11.944 11.944 0 0 1 12 21c-2.17 0-4.207-.576-5.963-1.584A6.062 6.062 0 0 1 6 18.719m12 0a5.971 5.971 0 0 0-.941-3.197m0 0A5.995 5.995 0 0 0 12 12.75a5.995 5.995 0 0 0-5.058 2.772m0 0a3 3 0 0 0-4.681 2.72 8.986 8.986 0 0 0 3.74.477m.94-3.197a5.971 5.971 0 0 0-.94 3.197M15 6.75a3 3 0 1 1-6 0 3 3 0 0 1 6 0Zm6 3a2.25 2.25 0 1 1-4.5 0 2.25 2.25 0 0 1 4.5 0Zm-13.5 0a2.25 2.25 0 1 1-4.5 0 2.25 2.25 0 0 1 4.5 0Z" /></svg>' }
export const XIcon = { template: '<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M6 18 18 6M6 6l12 12" /></svg>' }

View File

@@ -13,24 +13,12 @@ const routes = [
component: () => import('@/views/ClassListView.vue'),
meta: { title: 'Classes' }
},
{
path: '/classes/new',
name: 'class-create',
component: () => import('@/views/ClassFormView.vue'),
meta: { title: 'Nouvelle classe' }
},
{
path: '/classes/:id',
name: 'class-dashboard',
component: () => import('@/views/ClassDashboardView.vue'),
meta: { title: 'Dashboard classe' }
},
{
path: '/classes/:id/edit',
name: 'class-edit',
component: () => import('@/views/ClassFormView.vue'),
meta: { title: 'Modifier classe' }
},
{
path: '/classes/:id/students',
name: 'class-students',

View File

@@ -0,0 +1,79 @@
export function hexToRgb(hex) {
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex)
return result ? {
r: parseInt(result[1], 16),
g: parseInt(result[2], 16),
b: parseInt(result[3], 16)
} : { r: 0, g: 0, b: 0 }
}
export function rgbToHex(r, g, b) {
return "#" + ((1 << 24) + (r << 16) + (g << 8) + b).toString(16).slice(1)
}
export function rgbToHsl(r, g, b) {
r /= 255; g /= 255; b /= 255
const max = Math.max(r, g, b), min = Math.min(r, g, b)
let h, s, l = (max + min) / 2
if (max === min) {
h = s = 0
} else {
const d = max - min
s = l > 0.5 ? d / (2 - max - min) : d / (max + min)
switch (max) {
case r: h = (g - b) / d + (g < b ? 6 : 0); break
case g: h = (b - r) / d + 2; break
case b: h = (r - g) / d + 4; break
}
h /= 6
}
return { h: h * 360, s: s * 100, l: l * 100 }
}
export function hslToRgb(h, s, l) {
h /= 360; s /= 100; l /= 100
const hue2rgb = (p, q, t) => {
if (t < 0) t += 1
if (t > 1) t -= 1
if (t < 1/6) return p + (q - p) * 6 * t
if (t < 1/2) return q
if (t < 2/3) return p + (q - p) * (2/3 - t) * 6
return p
}
let r, g, b
if (s === 0) {
r = g = b = l
} else {
const q = l < 0.5 ? l * (1 + s) : l + s - l * s
const p = 2 * l - q
r = hue2rgb(p, q, h + 1/3)
g = hue2rgb(p, q, h)
b = hue2rgb(p, q, h - 1/3)
}
return { r: Math.round(r * 255), g: Math.round(g * 255), b: Math.round(b * 255) }
}
export function interpolateColorHSL(color1, color2, factor) {
const rgb1 = hexToRgb(color1)
const rgb2 = hexToRgb(color2)
const hsl1 = rgbToHsl(rgb1.r, rgb1.g, rgb1.b)
const hsl2 = rgbToHsl(rgb2.r, rgb2.g, rgb2.b)
let deltaH = hsl2.h - hsl1.h
if (deltaH > 180) hsl2.h -= 360
else if (deltaH < -180) hsl2.h += 360
const h = ((hsl1.h + (hsl2.h - hsl1.h) * factor) % 360 + 360) % 360
const s = hsl1.s + (hsl2.s - hsl1.s) * factor
const l = hsl1.l + (hsl2.l - hsl1.l) * factor
const rgb = hslToRgb(h, s, l)
return rgbToHex(rgb.r, rgb.g, rgb.b)
}
export function getTextColorForBg(bgColor) {
const rgb = hexToRgb(bgColor)
const brightness = (rgb.r * 299 + rgb.g * 587 + rgb.b * 114) / 1000
return brightness > 128 ? '#000000' : '#ffffff'
}

View File

@@ -4,23 +4,18 @@
<LoadingSpinner v-if="loading" text="Chargement..." fullPage />
<template v-else-if="assessment">
<!-- Hero section -->
<div class="bg-gradient-to-r from-primary-600 to-indigo-700 rounded-2xl p-8 mb-8 text-white">
<div class="flex justify-between items-start">
<div>
<div class="flex items-center gap-3 mb-2">
<h1 class="text-3xl font-bold">{{ assessment.title }}</h1>
<span class="bg-white/20 px-3 py-1 rounded-full text-sm">T{{ assessment.trimester }}</span>
</div>
<div class="flex items-center gap-4 text-primary-100">
<span>{{ assessment.class_name }}</span>
<span>{{ formatDate(assessment.date) }}</span>
<span>{{ assessment.total_points }} points</span>
</div>
</div>
<PageHeader
:title="assessment.title"
:subtitle="`${assessment.class_name} \u00b7 ${formatDate(assessment.date)} \u00b7 ${assessment.total_points} points`"
:breadcrumbs="[{ label: 'Évaluations', to: '/assessments' }, { label: assessment.title }]"
>
<template #meta>
<span class="badge badge-primary">T{{ assessment.trimester }}</span>
</template>
<template #actions>
<ProgressIndicator :progress="assessment.progress" size="lg" />
</div>
</div>
</template>
</PageHeader>
<!-- Actions -->
<div class="grid grid-cols-2 md:grid-cols-4 gap-4 mb-8">
@@ -225,12 +220,8 @@ import { useNotificationsStore } from '@/stores/notifications'
import LoadingSpinner from '@/components/common/LoadingSpinner.vue'
import ProgressIndicator from '@/components/assessment/ProgressIndicator.vue'
import Modal from '@/components/common/Modal.vue'
// Icons
const PencilIcon = { template: '<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="m16.862 4.487 1.687-1.688a1.875 1.875 0 1 1 2.652 2.652L10.582 16.07a4.5 4.5 0 0 1-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 0 1 1.13-1.897l8.932-8.931Zm0 0L19.5 7.125M18 14v4.75A2.25 2.25 0 0 1 15.75 21H5.25A2.25 2.25 0 0 1 3 18.75V8.25A2.25 2.25 0 0 1 5.25 6H10" /></svg>' }
const ChartIcon = { template: '<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M3 13.125C3 12.504 3.504 12 4.125 12h2.25c.621 0 1.125.504 1.125 1.125v6.75C7.5 20.496 6.996 21 6.375 21h-2.25A1.125 1.125 0 0 1 3 19.875v-6.75ZM9.75 8.625c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125v11.25c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 0 1-1.125-1.125V8.625ZM16.5 4.125c0-.621.504-1.125 1.125-1.125h2.25C20.496 3 21 3.504 21 4.125v15.75c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 0 1-1.125-1.125V4.125Z" /></svg>' }
const CogIcon = { template: '<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.325.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 0 1 1.37.49l1.296 2.247a1.125 1.125 0 0 1-.26 1.431l-1.003.827c-.293.241-.438.613-.43.992a7.723 7.723 0 0 1 0 .255c-.008.378.137.75.43.991l1.004.827c.424.35.534.955.26 1.43l-1.298 2.247a1.125 1.125 0 0 1-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.47 6.47 0 0 1-.22.128c-.331.183-.581.495-.644.869l-.213 1.281c-.09.543-.56.94-1.11.94h-2.594c-.55 0-1.019-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 0 1-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 0 1-1.369-.49l-1.297-2.247a1.125 1.125 0 0 1 .26-1.431l1.004-.827c.292-.24.437-.613.43-.991a6.932 6.932 0 0 1 0-.255c.007-.38-.138-.751-.43-.992l-1.004-.827a1.125 1.125 0 0 1-.26-1.43l1.297-2.247a1.125 1.125 0 0 1 1.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.086.22-.128.332-.183.582-.495.644-.869l.214-1.28Z" /><path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z" /></svg>' }
const TrashIcon = { template: '<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="m14.74 9-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 0 1-2.244 2.077H8.084a2.25 2.25 0 0 1-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 0 0-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 0 1 3.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 0 0-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 0 0-7.5 0" /></svg>' }
import PageHeader from '@/components/common/PageHeader.vue'
import { PencilIcon, ChartIcon, CogIcon, TrashIcon } from '@/components/icons'
const route = useRoute()
const router = useRouter()

View File

@@ -1,14 +1,12 @@
<template>
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4">
<!-- Header compact -->
<Breadcrumb :crumbs="isEdit
? [{ label: 'Évaluations', to: '/assessments' }, { label: form.title || 'Évaluation', to: `/assessments/${route.params.id}` }, { label: 'Modifier' }]
: [{ label: 'Évaluations', to: '/assessments' }, { label: 'Nouvelle évaluation' }]
" />
<div class="flex items-center justify-between mb-4">
<div class="flex items-center gap-4">
<router-link
:to="isEdit ? `/assessments/${route.params.id}` : '/assessments'"
class="text-primary-600 hover:text-primary-800 text-sm font-medium"
>
&larr; Retour
</router-link>
<h1 class="text-xl font-semibold text-gray-900">
{{ isEdit ? 'Modifier l\'évaluation' : 'Nouvelle évaluation' }}
</h1>
@@ -273,6 +271,14 @@
+ Ajouter un exercice
</button>
</form>
<ConfirmDialog
v-model="confirmDialog.show"
:title="confirmDialog.title"
:message="confirmDialog.message"
:confirmLabel="confirmDialog.confirmLabel"
:variant="confirmDialog.variant"
@confirm="confirmDialog.onConfirm"
/>
</div>
</template>
@@ -283,6 +289,8 @@ import { useAssessmentsStore } from '@/stores/assessments'
import { useClassesStore } from '@/stores/classes'
import { useNotificationsStore } from '@/stores/notifications'
import DomainAutocomplete from '@/components/assessment/DomainAutocomplete.vue'
import ConfirmDialog from '@/components/common/ConfirmDialog.vue'
import Breadcrumb from '@/components/common/Breadcrumb.vue'
import configService from '@/services/config'
const route = useRoute()
@@ -297,6 +305,19 @@ const classes = computed(() => classesStore.classes)
const competences = ref([])
const formDirty = ref(false)
const confirmDialog = ref({
show: false,
title: '',
message: '',
confirmLabel: 'Confirmer',
variant: 'danger',
onConfirm: () => {}
})
function showConfirm(opts) {
confirmDialog.value = { show: true, ...opts }
}
// Computed pour le récapitulatif
const totalElements = computed(() => {
return form.value.exercises.reduce((sum, ex) => sum + ex.grading_elements.length, 0)
@@ -413,10 +434,19 @@ function addExercise() {
function removeExercise(idx) {
const exercise = form.value.exercises[idx]
if (isEdit.value && exercise.id) {
if (!confirm('Cet exercice contient potentiellement des notes. Supprimer ?')) {
showConfirm({
title: 'Supprimer l\'exercice',
message: 'Cet exercice contient potentiellement des notes. Supprimer ?',
confirmLabel: 'Supprimer',
variant: 'danger',
onConfirm: () => doRemoveExercise(idx)
})
return
}
}
doRemoveExercise(idx)
}
function doRemoveExercise(idx) {
form.value.exercises.splice(idx, 1)
form.value.exercises.forEach((ex, i) => {
ex.order = i + 1
@@ -448,10 +478,15 @@ function addElement(exIdx) {
function removeElement(exIdx, elIdx) {
const element = form.value.exercises[exIdx].grading_elements[elIdx]
if (isEdit.value && element.id) {
if (!confirm('Cet élément contient potentiellement des notes. Supprimer ?')) {
showConfirm({
title: 'Supprimer l\'élément',
message: 'Cet élément contient potentiellement des notes. Supprimer ?',
confirmLabel: 'Supprimer',
variant: 'danger',
onConfirm: () => form.value.exercises[exIdx].grading_elements.splice(elIdx, 1)
})
return
}
}
form.value.exercises[exIdx].grading_elements.splice(elIdx, 1)
}
@@ -464,10 +499,20 @@ async function submit() {
const hasEmptyExercises = form.value.exercises.some(ex => ex.grading_elements.length === 0)
if (hasEmptyExercises) {
if (!confirm('Certains exercices n\'ont pas d\'éléments de notation. Voulez-vous continuer ?')) {
showConfirm({
title: 'Exercices vides',
message: 'Certains exercices n\'ont pas d\'éléments de notation. Voulez-vous continuer ?',
confirmLabel: 'Continuer',
variant: 'warning',
onConfirm: () => doSubmit()
})
return
}
}
doSubmit()
}
async function doSubmit() {
// Prepare data for API
const data = {
@@ -498,8 +543,6 @@ async function submit() {
data.class_group_id = form.value.class_group_id
}
console.log('Sending data:', JSON.stringify(data, null, 2))
submitting.value = true
try {
if (isEdit.value) {
@@ -514,8 +557,6 @@ async function submit() {
router.push(`/assessments/${created.id}`)
}
} catch (e) {
console.error('Error saving assessment:', e)
console.error('Response data:', JSON.stringify(e.response?.data, null, 2))
const detail = e.response?.data?.detail
const errorMsg = Array.isArray(detail)
? detail.map(d => `${d.loc?.join('.')}: ${d.msg}`).join(', ')

View File

@@ -1,84 +1,43 @@
<template>
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<!-- Hero section amélioré -->
<div class="bg-gradient-to-r from-warning-500 to-orange-600 rounded-2xl p-8 mb-8 text-white">
<div class="flex justify-between items-start">
<div>
<h1 class="text-3xl font-bold mb-2">Évaluations</h1>
<p class="text-warning-100 mb-4">{{ assessments.length }} évaluation(s)</p>
<!-- Stats rapides -->
<div class="flex flex-wrap gap-4 text-sm text-warning-100">
<div class="flex items-center gap-1">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<span>{{ completedCount }} terminées</span>
</div>
<div class="flex items-center gap-1">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<span>{{ inProgressCount }} en cours</span>
</div>
<div class="flex items-center gap-1">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
<span>{{ notStartedCount }} non commencées</span>
</div>
</div>
</div>
<router-link to="/assessments/new" class="btn bg-white text-warning-600 hover:bg-warning-50">
<PageHeader title="Évaluations">
<template #meta>
<span class="badge badge-success">{{ completedCount }} terminées</span>
<span class="badge badge-warning">{{ inProgressCount }} en cours</span>
<span class="badge badge-danger">{{ notStartedCount }} non commencées</span>
</template>
<template #actions>
<router-link to="/assessments/new" class="btn btn-primary">
<PlusIcon class="w-5 h-5 mr-2 inline" />
Nouvelle évaluation
</router-link>
</div>
</div>
</template>
</PageHeader>
<!-- Filters -->
<div class="card card-body mb-6">
<div class="grid grid-cols-1 md:grid-cols-4 gap-4">
<div>
<label class="label">Trimestre</label>
<select v-model="filters.trimester" class="input" @change="applyFilters">
<option :value="null">Tous</option>
<option :value="1">Trimestre 1</option>
<option :value="2">Trimestre 2</option>
<option :value="3">Trimestre 3</option>
</select>
</div>
<div>
<label class="label">Classe</label>
<select v-model="filters.class_id" class="input" @change="applyFilters">
<option :value="null">Toutes</option>
<!-- Inline filter toolbar -->
<div class="flex flex-wrap items-center gap-3 mb-4">
<TrimesterSelector v-model="filters.trimester" showAll allLabel="Tous" size="sm" />
<select v-model="filters.class_id" class="input text-sm py-1.5 w-auto" @change="applyFilters">
<option :value="null">Toutes les classes</option>
<option v-for="cls in classes" :key="cls.id" :value="cls.id">
{{ cls.name }}
</option>
</select>
</div>
<div>
<label class="label">Statut</label>
<select v-model="filters.status" class="input" @change="applyFilters">
<option value="all">Tous</option>
<select v-model="filters.status" class="input text-sm py-1.5 w-auto" @change="applyFilters">
<option value="all">Tous statuts</option>
<option value="incomplete">Non terminées</option>
<option value="complete">Terminées</option>
<option value="not_started">Non commencées</option>
</select>
</div>
<div>
<label class="label">Tri</label>
<select v-model="filters.sort" class="input" @change="applyFilters">
<select v-model="filters.sort" class="input text-sm py-1.5 w-auto" @change="applyFilters">
<option value="date_desc">Date (récent)</option>
<option value="date_asc">Date (ancien)</option>
<option value="title">Titre (A-Z)</option>
<option value="class">Classe</option>
</select>
</div>
</div>
<button v-if="hasActiveFilters" @click="resetFilters" class="text-xs text-gray-500 hover:text-gray-700 underline">
Réinitialiser
</button>
</div>
<!-- Loading state -->
@@ -99,61 +58,61 @@
</button>
</div>
<!-- Assessments list -->
<div v-else class="space-y-4">
<router-link
<!-- Compact table -->
<div v-else class="card overflow-hidden">
<table class="w-full text-sm">
<thead>
<tr class="bg-gray-50 border-b border-gray-200">
<th class="px-4 py-2.5 text-left font-medium text-gray-600">Titre</th>
<th class="px-4 py-2.5 text-left font-medium text-gray-600">Classe</th>
<th class="px-4 py-2.5 text-left font-medium text-gray-600">Date</th>
<th class="px-4 py-2.5 text-center font-medium text-gray-600">Points</th>
<th class="px-4 py-2.5 text-center font-medium text-gray-600">Trimestre</th>
<th class="px-4 py-2.5 text-center font-medium text-gray-600">Progression</th>
<th class="px-4 py-2.5 text-right font-medium text-gray-600"></th>
</tr>
</thead>
<tbody>
<tr
v-for="assessment in filteredAssessments"
:key="assessment.id"
:to="`/assessments/${assessment.id}`"
class="card card-body hover:shadow-md transition-shadow block"
class="border-b border-gray-100 hover:bg-gray-50 transition-colors cursor-pointer"
@click="$router.push(`/assessments/${assessment.id}`)"
>
<div class="flex items-center justify-between">
<div class="flex-1">
<div class="flex items-center gap-3 mb-2">
<h3 class="text-lg font-semibold">{{ assessment.title }}</h3>
<span class="badge badge-primary">T{{ assessment.trimester }}</span>
</div>
<div class="flex items-center gap-4 text-sm text-gray-500">
<span>{{ assessment.class_name }}</span>
<span>{{ formatDate(assessment.date) }}</span>
<span>{{ assessment.total_points }} pts</span>
</div>
</div>
<div class="flex items-center gap-4">
<ProgressIndicator
:progress="assessment.progress"
size="md"
/>
<td class="px-4 py-2.5 font-medium text-gray-900">{{ assessment.title }}</td>
<td class="px-4 py-2.5 text-gray-600">{{ assessment.class_name }}</td>
<td class="px-4 py-2.5 text-gray-600">{{ formatDate(assessment.date) }}</td>
<td class="px-4 py-2.5 text-center text-gray-600">{{ assessment.total_points }}</td>
<td class="px-4 py-2.5 text-center"><span class="badge badge-primary">T{{ assessment.trimester }}</span></td>
<td class="px-4 py-2.5 text-center">
<ProgressIndicator :progress="assessment.progress" size="sm" />
</td>
<td class="px-4 py-2.5 text-right">
<router-link
:to="`/assessments/${assessment.id}/grading`"
class="text-xs font-medium text-primary-600 hover:text-primary-800 whitespace-nowrap"
class="text-xs font-medium text-primary-600 hover:text-primary-800"
@click.stop
>
Corriger
</router-link>
<ChevronRightIcon class="w-5 h-5 text-gray-400" />
</div>
</div>
</router-link>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { useAssessmentsStore } from '@/stores/assessments'
import { useClassesStore } from '@/stores/classes'
import LoadingSpinner from '@/components/common/LoadingSpinner.vue'
import ProgressIndicator from '@/components/assessment/ProgressIndicator.vue'
import PageHeader from '@/components/common/PageHeader.vue'
import TrimesterSelector from '@/components/common/TrimesterSelector.vue'
import { PlusIcon, ClipboardIcon } from '@/components/icons'
// Icons
const PlusIcon = { template: '<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15" /></svg>' }
const ClipboardIcon = { template: '<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M9 12h3.75M9 15h3.75M9 18h3.75m3 .75H18a2.25 2.25 0 0 0 2.25-2.25V6.108c0-1.135-.845-2.098-1.976-2.192a48.424 48.424 0 0 0-1.123-.08m-5.801 0c-.065.21-.1.433-.1.664 0 .414.336.75.75.75h4.5a.75.75 0 0 0 .75-.75 2.25 2.25 0 0 0-.1-.664m-5.8 0A2.251 2.251 0 0 1 13.5 2.25H15c1.012 0 1.867.668 2.15 1.586m-5.8 0c-.376.023-.75.05-1.124.08C9.095 4.01 8.25 4.973 8.25 6.108V8.25m0 0H4.875c-.621 0-1.125.504-1.125 1.125v11.25c0 .621.504 1.125 1.125 1.125h9.75c.621 0 1.125-.504 1.125-1.125V9.375c0-.621-.504-1.125-1.125-1.125H8.25ZM6.75 12h.008v.008H6.75V12Zm0 3h.008v.008H6.75V15Zm0 3h.008v.008H6.75V18Z" /></svg>' }
const ChevronRightIcon = { template: '<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="m8.25 4.5 7.5 7.5-7.5 7.5" /></svg>' }
const router = useRouter()
const assessmentsStore = useAssessmentsStore()
const classesStore = useClassesStore()
@@ -223,6 +182,10 @@ function formatDate(dateStr) {
})
}
const hasActiveFilters = computed(() =>
filters.value.trimester !== null || filters.value.class_id !== null || filters.value.status !== 'all'
)
function applyFilters() {
// Filters are reactive, computed will update automatically
}
@@ -236,10 +199,6 @@ function resetFilters() {
}
}
function goToGrading(id) {
router.push(`/assessments/${id}/grading`)
}
onMounted(async () => {
try {
await Promise.all([

View File

@@ -1,59 +1,26 @@
<template>
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<div class="px-4 sm:px-6 lg:px-8 py-8">
<LoadingSpinner v-if="loading" text="Chargement..." fullPage />
<template v-else-if="classData">
<!-- Hero amélioré -->
<div class="bg-gradient-to-br from-blue-50 to-indigo-100 rounded-xl p-6 md:p-8 mb-8">
<div class="flex justify-between items-start">
<div>
<h1 class="text-3xl font-bold text-gray-900 mb-2">{{ classData.name }}</h1>
<p class="text-gray-600">{{ classData.year }} - {{ classData.students_count }} élèves</p>
</div>
<div class="flex gap-2">
<PageHeader
:title="classData.name"
:subtitle="`${classData.year} \u00b7 ${classData.students_count} élèves`"
:breadcrumbs="[{ label: 'Classes', to: '/classes' }, { label: classData.name }]"
>
<template #actions>
<router-link :to="`/classes/${classData.id}/students`" class="btn btn-secondary">
Élèves
</router-link>
<router-link :to="`/classes/${classData.id}/council`" class="btn btn-secondary">
Conseil
</router-link>
</div>
</div>
</div>
</template>
</PageHeader>
<!-- Trimester selector -->
<div class="mb-6">
<div class="flex flex-wrap gap-2 items-center">
<!-- Vision annuelle -->
<button
@click="selectTrimester(null)"
class="btn"
:class="trimester === null ? 'btn-primary' : 'btn-secondary'"
>
📊 Vision annuelle
</button>
<!-- Séparateur visuel -->
<div class="border-l border-gray-300 h-8 mx-1"></div>
<!-- Trimestres individuels -->
<button
v-for="t in [1, 2, 3]"
:key="t"
@click="selectTrimester(t)"
class="btn"
:class="trimester === t ? 'btn-primary' : 'btn-secondary'"
>
Trimestre {{ t }}
</button>
</div>
<!-- Indicateur de période affichée -->
<div class="mt-3 text-center">
<p class="text-sm font-medium text-gray-600">
{{ trimester === null ? '📊 Toutes les évaluations de l\'année' : `📅 Évaluations du trimestre ${trimester}` }}
</p>
</div>
<TrimesterSelector v-model="trimester" showAll allLabel="Annuel" size="md" @update:modelValue="selectTrimester" />
</div>
<!-- Stats principales - Grid 4 colonnes -->
@@ -238,6 +205,8 @@ import { ref, computed, onMounted } from 'vue'
import { useRoute } from 'vue-router'
import { useClassesStore } from '@/stores/classes'
import LoadingSpinner from '@/components/common/LoadingSpinner.vue'
import PageHeader from '@/components/common/PageHeader.vue'
import TrimesterSelector from '@/components/common/TrimesterSelector.vue'
const route = useRoute()
const classesStore = useClassesStore()

View File

@@ -1,79 +0,0 @@
<template>
<div class="max-w-2xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<h1 class="text-2xl font-bold mb-6">{{ isEdit ? 'Modifier' : 'Nouvelle' }} classe</h1>
<form @submit.prevent="submit" class="card card-body space-y-4">
<div>
<label class="label">Nom de la classe *</label>
<input v-model="form.name" type="text" class="input" required placeholder="Ex: 6ème A" />
</div>
<div>
<label class="label">Année scolaire *</label>
<input v-model="form.year" type="text" class="input" required placeholder="Ex: 2024-2025" />
</div>
<div>
<label class="label">Description</label>
<textarea v-model="form.description" class="input" rows="3" placeholder="Description optionnelle"></textarea>
</div>
<div class="flex justify-end gap-3 pt-4">
<router-link to="/classes" class="btn btn-secondary">Annuler</router-link>
<button type="submit" class="btn btn-primary" :disabled="submitting">
{{ submitting ? 'Enregistrement...' : 'Enregistrer' }}
</button>
</div>
</form>
</div>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useClassesStore } from '@/stores/classes'
import { useNotificationsStore } from '@/stores/notifications'
const route = useRoute()
const router = useRouter()
const classesStore = useClassesStore()
const notifications = useNotificationsStore()
const isEdit = computed(() => !!route.params.id)
const submitting = ref(false)
const form = ref({
name: '',
year: new Date().getFullYear() + '-' + (new Date().getFullYear() + 1),
description: ''
})
async function submit() {
submitting.value = true
try {
if (isEdit.value) {
await classesStore.updateClass(route.params.id, form.value)
notifications.success('Classe modifiée')
} else {
await classesStore.createClass(form.value)
notifications.success('Classe créée')
}
router.push('/classes')
} catch (e) {
notifications.error('Erreur lors de l\'enregistrement')
} finally {
submitting.value = false
}
}
onMounted(async () => {
if (isEdit.value) {
const cls = await classesStore.fetchClass(route.params.id)
form.value = {
name: cls.name,
year: cls.year,
description: cls.description || ''
}
}
})
</script>

View File

@@ -1,18 +1,16 @@
<template>
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<!-- Hero section -->
<div class="bg-gradient-to-r from-primary-600 to-primary-800 rounded-2xl p-8 mb-8 text-white">
<div class="flex justify-between items-center">
<div>
<h1 class="text-3xl font-bold mb-2">Classes</h1>
<p class="text-primary-100">{{ classes.length }} classe(s) - {{ totalStudents }} élève(s)</p>
</div>
<router-link to="/classes/new" class="btn bg-white text-primary-600 hover:bg-primary-50">
<PageHeader title="Classes">
<template #meta>
<span class="badge badge-primary">{{ classes.length }}</span>
</template>
<template #actions>
<button @click="openCreateModal" class="btn btn-primary">
<PlusIcon class="w-5 h-5 mr-2 inline" />
Nouvelle classe
</router-link>
</div>
</div>
</button>
</template>
</PageHeader>
<!-- Loading state avec skeleton -->
<div v-if="loading" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
@@ -25,8 +23,11 @@
title="Aucune classe"
description="Créez votre première classe pour commencer à gérer vos élèves et évaluations."
icon="users"
:primaryAction="{ to: '/classes/new', label: 'Créer une classe' }"
/>
>
<template #actions>
<button @click="openCreateModal" class="btn btn-primary">Créer une classe</button>
</template>
</EmptyState>
</div>
<!-- Classes grid v2.0 -->
@@ -81,12 +82,12 @@
<!-- Actions secondaires -->
<div class="pt-3 border-t border-gray-100 flex gap-2">
<router-link
:to="`/classes/${cls.id}/edit`"
<button
@click="openEditModal(cls)"
class="flex-1 bg-gray-50 hover:bg-gray-100 text-gray-600 hover:text-gray-800 px-3 py-2 rounded-lg text-xs font-medium transition-colors text-center"
>
Modifier
</router-link>
</button>
<button
@click.stop="confirmDelete(cls)"
class="flex-1 bg-red-50 hover:bg-red-100 text-red-600 hover:text-red-800 px-3 py-2 rounded-lg text-xs font-medium transition-colors"
@@ -98,6 +99,35 @@
</div>
</div>
<!-- Class form modal (create/edit) -->
<Modal v-model="showFormModal" :title="editingClass ? 'Modifier la classe' : 'Nouvelle classe'" size="sm">
<form @submit.prevent="submitForm" class="space-y-4">
<div>
<label class="label">Nom de la classe *</label>
<input v-model="form.name" type="text" class="input" required placeholder="Ex: 6ème A" />
</div>
<div>
<label class="label">Année scolaire *</label>
<input v-model="form.year" type="text" class="input" required placeholder="Ex: 2024-2025" />
</div>
<div>
<label class="label">Description</label>
<textarea v-model="form.description" class="input" rows="3" placeholder="Description optionnelle"></textarea>
</div>
</form>
<template #footer>
<div class="flex justify-end gap-3">
<button @click="showFormModal = false" class="btn btn-secondary">
Annuler
</button>
<button @click="submitForm" class="btn btn-primary" :disabled="submitting || !form.name || !form.year">
{{ submitting ? 'Enregistrement...' : 'Enregistrer' }}
</button>
</div>
</template>
</Modal>
<!-- Delete confirmation modal -->
<Modal v-model="showDeleteModal" title="Confirmer la suppression" size="sm">
<p class="text-gray-600">
Êtes-vous sûr de vouloir supprimer la classe
@@ -109,10 +139,10 @@
</p>
<template #footer>
<div class="flex justify-end gap-3">
<button @click="showDeleteModal = false" class="px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-md hover:bg-gray-50">
<button @click="showDeleteModal = false" class="btn btn-secondary">
Annuler
</button>
<button @click="executeDelete" class="px-4 py-2 text-sm font-medium text-white bg-red-600 rounded-md hover:bg-red-700">
<button @click="executeDelete" class="btn btn-danger">
Supprimer
</button>
</div>
@@ -124,22 +154,64 @@
<script setup>
import { computed, onMounted, ref } from 'vue'
import { useClassesStore } from '@/stores/classes'
import { useNotificationsStore } from '@/stores/notifications'
import SkeletonLoader from '@/components/common/SkeletonLoader.vue'
import EmptyState from '@/components/common/EmptyState.vue'
import Modal from '@/components/common/Modal.vue'
// Icons
const PlusIcon = { template: '<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15" /></svg>' }
const UsersIcon = { template: '<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M18 18.72a9.094 9.094 0 0 0 3.741-.479 3 3 0 0 0-4.682-2.72m.94 3.198.001.031c0 .225-.012.447-.037.666A11.944 11.944 0 0 1 12 21c-2.17 0-4.207-.576-5.963-1.584A6.062 6.062 0 0 1 6 18.719m12 0a5.971 5.971 0 0 0-.941-3.197m0 0A5.995 5.995 0 0 0 12 12.75a5.995 5.995 0 0 0-5.058 2.772m0 0a3 3 0 0 0-4.681 2.72 8.986 8.986 0 0 0 3.74.477m.94-3.197a5.971 5.971 0 0 0-.94 3.197M15 6.75a3 3 0 1 1-6 0 3 3 0 0 1 6 0Zm6 3a2.25 2.25 0 1 1-4.5 0 2.25 2.25 0 0 1 4.5 0Zm-13.5 0a2.25 2.25 0 1 1-4.5 0 2.25 2.25 0 0 1 4.5 0Z" /></svg>' }
const ClipboardIcon = { template: '<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M9 12h3.75M9 15h3.75M9 18h3.75m3 .75H18a2.25 2.25 0 0 0 2.25-2.25V6.108c0-1.135-.845-2.098-1.976-2.192a48.424 48.424 0 0 0-1.123-.08m-5.801 0c-.065.21-.1.433-.1.664 0 .414.336.75.75.75h4.5a.75.75 0 0 0 .75-.75 2.25 2.25 0 0 0-.1-.664m-5.8 0A2.251 2.251 0 0 1 13.5 2.25H15c1.012 0 1.867.668 2.15 1.586m-5.8 0c-.376.023-.75.05-1.124.08C9.095 4.01 8.25 4.973 8.25 6.108V8.25m0 0H4.875c-.621 0-1.125.504-1.125 1.125v11.25c0 .621.504 1.125 1.125 1.125h9.75c.621 0 1.125-.504 1.125-1.125V9.375c0-.621-.504-1.125-1.125-1.125H8.25ZM6.75 12h.008v.008H6.75V12Zm0 3h.008v.008H6.75V15Zm0 3h.008v.008H6.75V18Z" /></svg>' }
import PageHeader from '@/components/common/PageHeader.vue'
import { PlusIcon, UsersIcon, ClipboardIcon } from '@/components/icons'
const classesStore = useClassesStore()
const notifications = useNotificationsStore()
const loading = ref(true)
// Delete modal
const showDeleteModal = ref(false)
const classToDelete = ref(null)
// Form modal
const showFormModal = ref(false)
const editingClass = ref(null)
const submitting = ref(false)
const form = ref({ name: '', year: '', description: '' })
const classes = computed(() => classesStore.classes)
const totalStudents = computed(() => classesStore.totalStudents)
function defaultYear() {
const y = new Date().getFullYear()
return `${y}-${y + 1}`
}
function openCreateModal() {
editingClass.value = null
form.value = { name: '', year: defaultYear(), description: '' }
showFormModal.value = true
}
function openEditModal(cls) {
editingClass.value = cls
form.value = { name: cls.name, year: cls.year, description: cls.description || '' }
showFormModal.value = true
}
async function submitForm() {
if (!form.value.name || !form.value.year) return
submitting.value = true
try {
if (editingClass.value) {
await classesStore.updateClass(editingClass.value.id, form.value)
notifications.success('Classe modifiée')
} else {
await classesStore.createClass(form.value)
notifications.success('Classe créée')
}
showFormModal.value = false
} catch (e) {
notifications.error('Erreur lors de l\'enregistrement')
} finally {
submitting.value = false
}
}
// Fonction pour obtenir le gradient selon le niveau scolaire
function getGradientClass(className) {

View File

@@ -4,6 +4,11 @@
<template v-else>
<!-- Header -->
<Breadcrumb :crumbs="[
{ label: 'Classes', to: '/classes' },
{ label: classData?.name, to: `/classes/${classData?.id}` },
{ label: 'Élèves' }
]" />
<div class="flex justify-between items-center mb-6">
<div>
<h1 class="text-2xl font-bold">{{ classData?.name }}</h1>
@@ -15,7 +20,7 @@
type="checkbox"
v-model="includeDeparted"
@change="loadStudents"
class="rounded border-gray-300 text-indigo-600 focus:ring-indigo-500"
class="rounded border-gray-300 text-primary-600 focus:ring-primary-500"
/>
<span class="text-sm font-medium text-gray-700">Inclure les élèves partis</span>
</label>
@@ -58,7 +63,7 @@
@keyup.enter="saveEmail(student)"
@keyup.escape="cancelEditEmail"
type="email"
class="flex-1 px-2 py-1 border border-indigo-300 rounded focus:outline-none focus:ring-2 focus:ring-indigo-500"
class="flex-1 px-2 py-1 border border-primary-300 rounded focus:outline-none focus:ring-2 focus:ring-primary-500"
placeholder="email@exemple.com"
/>
<button @click="cancelEditEmail" class="text-gray-400 hover:text-gray-600">
@@ -71,7 +76,7 @@
<span class="flex-1 text-gray-600">{{ student.email || '-' }}</span>
<button
@click="startEditEmail(student)"
class="opacity-0 group-hover:opacity-100 text-gray-400 hover:text-indigo-600 transition-opacity"
class="opacity-0 group-hover:opacity-100 text-gray-400 hover:text-primary-600 transition-opacity"
title="Modifier l'email"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@@ -127,7 +132,7 @@
<button
v-else
@click="openReenrollModal(student)"
class="text-sm text-indigo-600 hover:text-indigo-800 font-medium"
class="text-sm text-primary-600 hover:text-primary-800 font-medium"
>
Réinscrire
</button>
@@ -154,7 +159,7 @@
:class="[
'py-4 px-1 border-b-2 font-medium text-sm',
addMode === 'new'
? 'border-indigo-500 text-indigo-600'
? 'border-primary-500 text-primary-600'
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
]"
>
@@ -165,7 +170,7 @@
:class="[
'py-4 px-1 border-b-2 font-medium text-sm',
addMode === 'existing'
? 'border-indigo-500 text-indigo-600'
? 'border-primary-500 text-primary-600'
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
]"
>
@@ -182,7 +187,7 @@
v-model="newStudent.last_name"
type="text"
required
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-indigo-500"
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-primary-500"
/>
</div>
<div>
@@ -191,7 +196,7 @@
v-model="newStudent.first_name"
type="text"
required
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-indigo-500"
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-primary-500"
/>
</div>
<div>
@@ -199,7 +204,7 @@
<input
v-model="newStudent.email"
type="email"
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-indigo-500"
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-primary-500"
/>
</div>
<div>
@@ -208,7 +213,7 @@
v-model="newStudent.enrollment_date"
type="date"
required
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-indigo-500"
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-primary-500"
/>
</div>
<div>
@@ -216,7 +221,7 @@
<input
v-model="newStudent.enrollment_reason"
type="text"
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-indigo-500"
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-primary-500"
placeholder="Ex: Nouvelle inscription"
/>
</div>
@@ -228,7 +233,7 @@
<label class="block text-sm font-medium text-gray-700 mb-1">Élève *</label>
<select
v-model="existingStudent.student_id"
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-indigo-500"
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-primary-500"
>
<option :value="null">Sélectionner un élève...</option>
<option v-for="student in availableStudents" :key="student.id" :value="student.id">
@@ -242,7 +247,7 @@
v-model="existingStudent.enrollment_date"
type="date"
required
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-indigo-500"
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-primary-500"
/>
</div>
<div>
@@ -250,7 +255,7 @@
<input
v-model="existingStudent.enrollment_reason"
type="text"
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-indigo-500"
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-primary-500"
placeholder="Ex: Transfert depuis..."
/>
</div>
@@ -258,17 +263,10 @@
<!-- Actions -->
<div class="flex justify-end gap-3 pt-4">
<button
@click="showAddModal = false"
class="px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-md hover:bg-gray-50"
>
<button @click="showAddModal = false" class="btn btn-secondary">
Annuler
</button>
<button
@click="enrollStudent"
:disabled="!canEnroll"
class="px-4 py-2 text-sm font-medium text-white bg-indigo-600 rounded-md hover:bg-indigo-700 disabled:opacity-50 disabled:cursor-not-allowed"
>
<button @click="enrollStudent" :disabled="!canEnroll" class="btn btn-primary disabled:opacity-50 disabled:cursor-not-allowed">
Inscrire
</button>
</div>
@@ -287,7 +285,7 @@
v-model="departureDate"
type="date"
required
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-indigo-500"
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-primary-500"
/>
</div>
<div>
@@ -295,23 +293,16 @@
<input
v-model="departureReason"
type="text"
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-indigo-500"
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-primary-500"
placeholder="Ex: Déménagement, transfert..."
/>
</div>
<div class="flex justify-end gap-3 pt-4">
<button
@click="showDepartureModal = false"
class="px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-md hover:bg-gray-50"
>
<button @click="showDepartureModal = false" class="btn btn-secondary">
Annuler
</button>
<button
@click="confirmDeparture"
:disabled="!departureDate"
class="px-4 py-2 text-sm font-medium text-white bg-red-600 rounded-md hover:bg-red-700 disabled:opacity-50 disabled:cursor-not-allowed"
>
<button @click="confirmDeparture" :disabled="!departureDate" class="btn btn-danger disabled:opacity-50 disabled:cursor-not-allowed">
Confirmer le départ
</button>
</div>
@@ -330,7 +321,7 @@
v-model="reenrollDate"
type="date"
required
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-indigo-500"
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-primary-500"
/>
</div>
<div>
@@ -338,23 +329,16 @@
<input
v-model="reenrollReason"
type="text"
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-indigo-500"
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-primary-500"
placeholder="Ex: Retour après absence..."
/>
</div>
<div class="flex justify-end gap-3 pt-4">
<button
@click="showReenrollModal = false"
class="px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-md hover:bg-gray-50"
>
<button @click="showReenrollModal = false" class="btn btn-secondary">
Annuler
</button>
<button
@click="confirmReenroll"
:disabled="!reenrollDate"
class="px-4 py-2 text-sm font-medium text-white bg-indigo-600 rounded-md hover:bg-indigo-700 disabled:opacity-50 disabled:cursor-not-allowed"
>
<button @click="confirmReenroll" :disabled="!reenrollDate" class="btn btn-primary disabled:opacity-50 disabled:cursor-not-allowed">
Réinscrire
</button>
</div>
@@ -372,6 +356,7 @@ import classesService from '@/services/classes'
import studentsService from '@/services/students'
import LoadingSpinner from '@/components/common/LoadingSpinner.vue'
import Modal from '@/components/common/Modal.vue'
import Breadcrumb from '@/components/common/Breadcrumb.vue'
const route = useRoute()
const classesStore = useClassesStore()
@@ -440,7 +425,6 @@ async function loadStudents() {
students.value = await classesService.getStudents(id, null, includeDeparted.value)
} catch (error) {
notifications.error('Erreur lors du chargement des élèves')
console.error(error)
}
}
@@ -451,7 +435,6 @@ async function loadAvailableStudents() {
availableStudents.value = allStudents.filter(s => !s.current_class_id)
} catch (error) {
notifications.error('Erreur lors du chargement des élèves disponibles')
console.error(error)
}
}
@@ -494,7 +477,6 @@ async function saveEmail(student) {
}, 2000)
notifications.error(error.response?.data?.detail || 'Erreur lors de la mise à jour de l\'email')
console.error(error)
}
}
@@ -541,7 +523,6 @@ async function enrollStudent() {
await loadStudents()
} catch (error) {
notifications.error(error.response?.data?.detail || 'Erreur lors de l\'inscription')
console.error(error)
}
}
@@ -566,7 +547,6 @@ async function confirmDeparture() {
await loadStudents()
} catch (error) {
notifications.error(error.response?.data?.detail || 'Erreur lors de l\'enregistrement du départ')
console.error(error)
}
}
@@ -594,7 +574,6 @@ async function confirmReenroll() {
await loadStudents()
} catch (error) {
notifications.error(error.response?.data?.detail || 'Erreur lors de la réinscription')
console.error(error)
}
}

View File

@@ -1,21 +1,6 @@
<template>
<div class="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<!-- Hero Section -->
<div class="bg-gradient-to-r from-purple-600 to-blue-600 text-white rounded-xl p-8 shadow-lg mb-8">
<div class="flex items-center justify-between">
<div>
<h1 class="text-3xl font-bold mb-2">Configuration</h1>
<p class="text-lg opacity-90">Personnalisez votre application Notytex</p>
</div>
<div class="hidden md:block">
<div class="w-20 h-20 bg-white/20 rounded-full flex items-center justify-center">
<svg class="w-10 h-10" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M11.49 3.17c-.38-1.56-2.6-1.56-2.98 0a1.532 1.532 0 01-2.286.948c-1.372-.836-2.942.734-2.106 2.106.54.886.061 2.042-.947 2.287-1.561.379-1.561 2.6 0 2.978a1.532 1.532 0 01.947 2.287c-.836 1.372.734 2.942 2.106 2.106a1.532 1.532 0 012.287.947c.379 1.561 2.6 1.561 2.978 0a1.533 1.533 0 012.287-.947c1.372.836 2.942-.734 2.106-2.106a1.533 1.533 0 01.947-2.287c1.561-.379 1.561-2.6 0-2.978a1.532 1.532 0 01-.947-2.287c.836-1.372-.734-2.942-2.106-2.106a1.532 1.532 0 01-2.287-.947zM10 13a3 3 0 100-6 3 3 0 000 6z" clip-rule="evenodd"/>
</svg>
</div>
</div>
</div>
</div>
<PageHeader title="Configuration" />
<!-- Loading state -->
<div v-if="configStore.loading" class="flex justify-center py-12">
@@ -54,6 +39,7 @@
import { ref, onMounted } from 'vue'
import { useConfigStore } from '@/stores/config'
import LoadingSpinner from '@/components/common/LoadingSpinner.vue'
import PageHeader from '@/components/common/PageHeader.vue'
import ConfigGeneralTab from '@/components/config/ConfigGeneralTab.vue'
import ConfigCompetencesTab from '@/components/config/ConfigCompetencesTab.vue'
import ConfigDomainsTab from '@/components/config/ConfigDomainsTab.vue'

View File

@@ -1,29 +1,20 @@
<template>
<div class="px-4 pt-2 pb-1 flex flex-col overflow-hidden" style="height: calc(100vh - 4rem)">
<div class="px-4 pt-2 pb-1 flex flex-col overflow-hidden h-full">
<LoadingSpinner v-if="loading" text="Chargement..." fullPage />
<template v-else-if="classData">
<!-- Breadcrumb -->
<Breadcrumb :crumbs="[
{ label: 'Classes', to: '/classes' },
{ label: classData.name, to: `/classes/${classData.id}` },
{ label: 'Conseil' }
]" />
<!-- Compact toolbar: class info + trimester + stats on one line -->
<div v-if="currentStats" class="flex items-center gap-3 py-2 border-b border-gray-200 flex-shrink-0 flex-wrap">
<router-link :to="`/classes/${classData.id}`" class="text-gray-400 hover:text-gray-600 flex-shrink-0">
<svg class="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" /></svg>
</router-link>
<span class="font-semibold text-gray-900 truncate">{{ classData.name }}</span>
<span class="text-xs text-gray-400 flex-shrink-0">{{ classData.year }}</span>
<div class="flex gap-1 flex-shrink-0">
<button
v-for="t in [1, 2, 3]"
:key="t"
@click="selectTrimester(t)"
class="px-2.5 py-1 rounded text-xs font-medium transition-colors"
:class="trimester === t
? 'bg-purple-600 text-white'
: 'bg-gray-100 text-gray-600 hover:bg-gray-200'"
>
T{{ t }}
</button>
</div>
<TrimesterSelector v-model="trimester" @update:modelValue="selectTrimester" />
<div class="border-l border-gray-200 h-5 flex-shrink-0"></div>
@@ -87,6 +78,8 @@ import { classesService } from '@/services/classes'
import LoadingSpinner from '@/components/common/LoadingSpinner.vue'
import CouncilStudentList from '@/components/council/CouncilStudentList.vue'
import CouncilStudentDetail from '@/components/council/CouncilStudentDetail.vue'
import TrimesterSelector from '@/components/common/TrimesterSelector.vue'
import Breadcrumb from '@/components/common/Breadcrumb.vue'
const route = useRoute()
const classesStore = useClassesStore()

View File

@@ -1,10 +1,6 @@
<template>
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<!-- Hero section -->
<div class="bg-gradient-to-r from-primary-600 to-primary-800 rounded-2xl p-8 mb-8 text-white">
<h1 class="text-3xl font-bold mb-2">Tableau de bord</h1>
<p class="text-primary-100">Bienvenue sur Notytex - Gestion des évaluations scolaires</p>
</div>
<PageHeader title="Tableau de bord" />
<!-- Loading state -->
<LoadingSpinner v-if="loading" text="Chargement..." fullPage />
@@ -152,16 +148,9 @@ import { onMounted, ref } from 'vue'
import { useClassesStore } from '@/stores/classes'
import { useAssessmentsStore } from '@/stores/assessments'
import LoadingSpinner from '@/components/common/LoadingSpinner.vue'
import PageHeader from '@/components/common/PageHeader.vue'
import ProgressIndicator from '@/components/assessment/ProgressIndicator.vue'
// Icons
const UsersIcon = { template: '<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M18 18.72a9.094 9.094 0 0 0 3.741-.479 3 3 0 0 0-4.682-2.72m.94 3.198.001.031c0 .225-.012.447-.037.666A11.944 11.944 0 0 1 12 21c-2.17 0-4.207-.576-5.963-1.584A6.062 6.062 0 0 1 6 18.719m12 0a5.971 5.971 0 0 0-.941-3.197m0 0A5.995 5.995 0 0 0 12 12.75a5.995 5.995 0 0 0-5.058 2.772m0 0a3 3 0 0 0-4.681 2.72 8.986 8.986 0 0 0 3.74.477m.94-3.197a5.971 5.971 0 0 0-.94 3.197M15 6.75a3 3 0 1 1-6 0 3 3 0 0 1 6 0Zm6 3a2.25 2.25 0 1 1-4.5 0 2.25 2.25 0 0 1 4.5 0Zm-13.5 0a2.25 2.25 0 1 1-4.5 0 2.25 2.25 0 0 1 4.5 0Z" /></svg>' }
const AcademicCapIcon = { template: '<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M4.26 10.147a60.438 60.438 0 0 0-.491 6.347A48.62 48.62 0 0 1 12 20.904a48.62 48.62 0 0 1 8.232-4.41 60.46 60.46 0 0 0-.491-6.347m-15.482 0a50.636 50.636 0 0 0-2.658-.813A59.906 59.906 0 0 1 12 3.493a59.903 59.903 0 0 1 10.399 5.84c-.896.248-1.783.52-2.658.814m-15.482 0A50.717 50.717 0 0 1 12 13.489a50.702 50.702 0 0 1 7.74-3.342M6.75 15a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5Zm0 0v-3.675A55.378 55.378 0 0 1 12 8.443m-7.007 11.55A5.981 5.981 0 0 0 6.75 15.75v-1.5" /></svg>' }
const ClipboardIcon = { template: '<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M9 12h3.75M9 15h3.75M9 18h3.75m3 .75H18a2.25 2.25 0 0 0 2.25-2.25V6.108c0-1.135-.845-2.098-1.976-2.192a48.424 48.424 0 0 0-1.123-.08m-5.801 0c-.065.21-.1.433-.1.664 0 .414.336.75.75.75h4.5a.75.75 0 0 0 .75-.75 2.25 2.25 0 0 0-.1-.664m-5.8 0A2.251 2.251 0 0 1 13.5 2.25H15c1.012 0 1.867.668 2.15 1.586m-5.8 0c-.376.023-.75.05-1.124.08C9.095 4.01 8.25 4.973 8.25 6.108V8.25m0 0H4.875c-.621 0-1.125.504-1.125 1.125v11.25c0 .621.504 1.125 1.125 1.125h9.75c.621 0 1.125-.504 1.125-1.125V9.375c0-.621-.504-1.125-1.125-1.125H8.25ZM6.75 12h.008v.008H6.75V12Zm0 3h.008v.008H6.75V15Zm0 3h.008v.008H6.75V18Z" /></svg>' }
const PencilIcon = { template: '<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="m16.862 4.487 1.687-1.688a1.875 1.875 0 1 1 2.652 2.652L10.582 16.07a4.5 4.5 0 0 1-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 0 1 1.13-1.897l8.932-8.931Zm0 0L19.5 7.125M18 14v4.75A2.25 2.25 0 0 1 15.75 21H5.25A2.25 2.25 0 0 1 3 18.75V8.25A2.25 2.25 0 0 1 5.25 6H10" /></svg>' }
const PlusIcon = { template: '<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15" /></svg>' }
const ChartIcon = { template: '<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M3 13.125C3 12.504 3.504 12 4.125 12h2.25c.621 0 1.125.504 1.125 1.125v6.75C7.5 20.496 6.996 21 6.375 21h-2.25A1.125 1.125 0 0 1 3 19.875v-6.75ZM9.75 8.625c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125v11.25c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 0 1-1.125-1.125V8.625ZM16.5 4.125c0-.621.504-1.125 1.125-1.125h2.25C20.496 3 21 3.504 21 4.125v15.75c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 0 1-1.125-1.125V4.125Z" /></svg>' }
const CogIcon = { template: '<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.325.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 0 1 1.37.49l1.296 2.247a1.125 1.125 0 0 1-.26 1.431l-1.003.827c-.293.241-.438.613-.43.992a7.723 7.723 0 0 1 0 .255c-.008.378.137.75.43.991l1.004.827c.424.35.534.955.26 1.43l-1.298 2.247a1.125 1.125 0 0 1-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.47 6.47 0 0 1-.22.128c-.331.183-.581.495-.644.869l-.213 1.281c-.09.543-.56.94-1.11.94h-2.594c-.55 0-1.019-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 0 1-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 0 1-1.369-.49l-1.297-2.247a1.125 1.125 0 0 1 .26-1.431l1.004-.827c.292-.24.437-.613.43-.991a6.932 6.932 0 0 1 0-.255c.007-.38-.138-.751-.43-.992l-1.004-.827a1.125 1.125 0 0 1-.26-1.43l1.297-2.247a1.125 1.125 0 0 1 1.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.086.22-.128.332-.183.582-.495.644-.869l.214-1.28Z" /><path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z" /></svg>' }
import { UsersIcon, AcademicCapIcon, ClipboardIcon, PencilIcon, PlusIcon, ChartIcon, CogIcon } from '@/components/icons'
const classesStore = useClassesStore()
const assessmentsStore = useAssessmentsStore()

View File

@@ -1,23 +1,19 @@
<template>
<div class="h-screen w-screen overflow-hidden flex flex-col bg-gray-50">
<div class="h-full overflow-hidden flex flex-col bg-gray-50">
<!-- Loading -->
<LoadingSpinner v-if="loading" text="Chargement..." fullPage />
<template v-else-if="assessment">
<!-- Header compact -->
<header class="flex-shrink-0 bg-white border-b border-gray-200 px-4 py-3">
<header class="flex-shrink-0 bg-white border-b border-gray-200 px-4 py-2">
<Breadcrumb :crumbs="[
{ label: 'Évaluations', to: '/assessments' },
{ label: assessment.title, to: `/assessments/${assessment.id}` },
{ label: 'Notation' }
]" />
<div class="flex items-center justify-between">
<!-- Gauche : Navigation + Titre -->
<!-- Gauche : Titre -->
<div class="flex items-center space-x-4">
<router-link
:to="{ name: 'assessment-detail', params: { id: assessment.id }}"
class="text-gray-500 hover:text-gray-700 flex items-center text-sm"
>
<svg class="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" />
</svg>
Retour
</router-link>
<div>
<h1 class="text-lg font-bold text-gray-900">{{ assessment.title }}</h1>
<p class="text-sm text-gray-500">{{ assessment.class_name }} - Saisie des notes</p>
@@ -66,7 +62,7 @@
<button
@click="saveAll"
:disabled="saving"
class="px-4 py-1.5 text-sm bg-blue-600 hover:bg-blue-700 text-white rounded-lg transition-colors flex items-center disabled:opacity-50"
class="px-4 py-1.5 text-sm bg-primary-600 hover:bg-primary-700 text-white rounded-lg transition-colors flex items-center disabled:opacity-50"
>
<svg v-if="saving" class="animate-spin -ml-1 mr-2 h-4 w-4 text-white" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
@@ -79,9 +75,9 @@
</div>
<!-- Guide de saisie -->
<div class="mt-3 bg-gradient-to-r from-blue-50 to-purple-50 border border-blue-200 rounded-lg px-4 py-2">
<div class="mt-3 bg-gradient-to-r from-primary-50 to-accent-50 border border-primary-200 rounded-lg px-4 py-2">
<div class="flex flex-wrap items-center gap-4 text-xs">
<span class="font-semibold text-blue-900">Guide :</span>
<span class="font-semibold text-primary-900">Guide :</span>
<span><strong>Notes</strong> = valeurs décimales (ex: 15.5)</span>
<span><strong>Scores</strong> = 0-3 (0=Non acquis, 3=Expert)</span>
<span>
@@ -98,18 +94,18 @@
<table class="w-full text-sm border-collapse">
<!-- Header exercices -->
<thead class="sticky top-0 z-30">
<tr class="bg-gradient-to-r from-indigo-100 to-purple-100 border-b-2 border-indigo-300">
<th class="px-3 py-2 text-left text-xs font-medium text-gray-700 uppercase sticky left-0 bg-gradient-to-r from-indigo-100 to-purple-100 border-r border-indigo-200 min-w-[200px]">
<tr class="bg-gradient-to-r from-primary-100 to-accent-100 border-b-2 border-primary-300">
<th class="px-3 py-2 text-left text-xs font-medium text-gray-700 uppercase sticky left-0 bg-gradient-to-r from-primary-100 to-accent-100 border-r border-primary-200 min-w-[200px]">
Élève
</th>
<th
v-for="(group, exerciseId) in exerciseGroups"
:key="exerciseId"
:colspan="group.elements.length"
class="px-2 py-2 text-center text-sm font-bold text-indigo-900 border-x border-indigo-300"
class="px-2 py-2 text-center text-sm font-bold text-primary-900 border-x border-primary-300"
>
{{ group.title }}
<div class="text-xs font-normal text-indigo-700">{{ group.elements.length }} élément(s)</div>
<div class="text-xs font-normal text-primary-700">{{ group.elements.length }} élément(s)</div>
</th>
<th class="px-3 py-2 text-center text-xs font-medium text-gray-700 uppercase min-w-[80px]">
Total
@@ -127,7 +123,7 @@
v-model="studentFilter"
type="text"
placeholder="Filtrer les élèves..."
class="w-full px-2 py-1 pl-7 text-xs border border-gray-300 rounded focus:ring-1 focus:ring-blue-500 focus:border-blue-500"
class="w-full px-2 py-1 pl-7 text-xs border border-gray-300 rounded focus:ring-1 focus:ring-primary-500 focus:border-primary-500"
@focus="filterFocused = true"
@blur="filterFocused = false"
/>
@@ -169,13 +165,13 @@
<div class="mt-1 flex justify-center gap-1 flex-wrap">
<span
v-if="element.grading_type === 'score'"
class="inline-flex items-center px-1 py-0.5 rounded text-xs font-medium bg-purple-100 text-purple-800"
class="inline-flex items-center px-1 py-0.5 rounded text-xs font-medium bg-accent-100 text-accent-800"
title="Évaluation par compétences"
>
Score
</span>
<span
class="inline-flex items-center px-1 py-0.5 rounded text-xs font-medium bg-blue-100 text-blue-800"
class="inline-flex items-center px-1 py-0.5 rounded text-xs font-medium bg-primary-100 text-primary-800"
title="Points maximum"
>
{{ element.max_points }}pts
@@ -201,7 +197,7 @@
<span v-if="(element.domain || element.domain_name) && element.skill" class="text-gray-400"> / </span>
<span
v-if="element.skill"
class="text-indigo-600"
class="text-primary-600"
>
{{ element.skill }}
</span>
@@ -237,7 +233,7 @@
</div>
<button
@click="openQuickComplete(student.id)"
class="ml-2 text-xs bg-blue-100 hover:bg-blue-200 text-blue-700 px-2 py-1 rounded transition-colors"
class="ml-2 text-xs bg-primary-100 hover:bg-primary-200 text-primary-700 px-2 py-1 rounded transition-colors"
title="Compléter les champs vides"
>
&#9889;
@@ -261,7 +257,7 @@
@focus="setCurrentPosition(studentIdx, elementIdx)"
:data-row="studentIdx"
:data-col="elementIdx"
class="w-full text-xs border rounded py-1 px-1 focus:ring-2 focus:ring-purple-500 focus:border-purple-500 transition-all"
class="w-full text-xs border rounded py-1 px-1 focus:ring-2 focus:ring-accent-500 focus:border-accent-500 transition-all"
:class="getInputClass(getGrade(student.id, element.id), 'score')"
:style="getInputStyle(getGrade(student.id, element.id), 'score')"
>
@@ -288,7 +284,7 @@
:data-row="studentIdx"
:data-col="elementIdx"
:placeholder="`0-${element.max_points}`"
class="w-full text-xs border rounded py-1 px-2 text-center focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-all"
class="w-full text-xs border rounded py-1 px-2 text-center focus:ring-2 focus:ring-primary-500 focus:border-primary-500 transition-all"
:class="getInputClass(getGrade(student.id, element.id), 'notes', element.max_points)"
:style="getInputStyle(getGrade(student.id, element.id), 'notes', element.max_points)"
/>
@@ -358,7 +354,7 @@
</label>
<select
v-model="quickCompleteValue"
class="w-full border border-gray-300 rounded-md px-3 py-2 focus:ring-blue-500 focus:border-blue-500"
class="w-full border border-gray-300 rounded-md px-3 py-2 focus:ring-primary-500 focus:border-primary-500"
>
<option value=".">. - Pas de réponse</option>
<option value="d">d - Dispensé</option>
@@ -369,7 +365,7 @@
<input
type="checkbox"
v-model="quickCompleteOverwrite"
class="mr-3 h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded"
class="mr-3 h-4 w-4 text-primary-600 focus:ring-primary-500 border-gray-300 rounded"
/>
<span class="text-sm text-gray-700">Écraser les valeurs existantes</span>
</label>
@@ -380,16 +376,10 @@
</div>
<template #footer>
<div class="flex justify-end space-x-3">
<button
@click="showQuickComplete = false"
class="px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-md hover:bg-gray-50"
>
<button @click="showQuickComplete = false" class="btn btn-secondary">
Annuler
</button>
<button
@click="executeQuickComplete"
class="px-4 py-2 text-sm font-medium text-white bg-blue-600 rounded-md hover:bg-blue-700"
>
<button @click="executeQuickComplete" class="btn btn-primary">
Appliquer
</button>
</div>
@@ -403,16 +393,10 @@
</p>
<template #footer>
<div class="flex justify-end space-x-3">
<button
@click="showResetModal = false"
class="px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-md hover:bg-gray-50"
>
<button @click="showResetModal = false" class="btn btn-secondary">
Annuler
</button>
<button
@click="resetForm"
class="px-4 py-2 text-sm font-medium text-white bg-red-600 rounded-md hover:bg-red-700"
>
<button @click="resetForm" class="btn btn-danger">
Réinitialiser
</button>
</div>
@@ -446,10 +430,7 @@
</div>
<template #footer>
<div class="flex justify-end">
<button
@click="showErrorsModal = false"
class="px-4 py-2 text-sm font-medium text-white bg-primary-600 rounded-md hover:bg-primary-700"
>
<button @click="showErrorsModal = false" class="btn btn-primary">
Compris
</button>
</div>
@@ -470,6 +451,7 @@ import classesService from '@/services/classes'
import assessmentsService from '@/services/assessments'
import LoadingSpinner from '@/components/common/LoadingSpinner.vue'
import Modal from '@/components/common/Modal.vue'
import Breadcrumb from '@/components/common/Breadcrumb.vue'
const route = useRoute()
const router = useRouter()
@@ -477,80 +459,7 @@ const assessmentsStore = useAssessmentsStore()
const configStore = useConfigStore()
const notifications = useNotificationsStore()
// Color interpolation functions
function hexToRgb(hex) {
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex)
return result ? {
r: parseInt(result[1], 16),
g: parseInt(result[2], 16),
b: parseInt(result[3], 16)
} : { r: 0, g: 0, b: 0 }
}
function rgbToHex(r, g, b) {
return "#" + ((1 << 24) + (r << 16) + (g << 8) + b).toString(16).slice(1)
}
function rgbToHsl(r, g, b) {
r /= 255; g /= 255; b /= 255
const max = Math.max(r, g, b), min = Math.min(r, g, b)
let h, s, l = (max + min) / 2
if (max === min) {
h = s = 0
} else {
const d = max - min
s = l > 0.5 ? d / (2 - max - min) : d / (max + min)
switch (max) {
case r: h = (g - b) / d + (g < b ? 6 : 0); break
case g: h = (b - r) / d + 2; break
case b: h = (r - g) / d + 4; break
}
h /= 6
}
return { h: h * 360, s: s * 100, l: l * 100 }
}
function hslToRgb(h, s, l) {
h /= 360; s /= 100; l /= 100
const hue2rgb = (p, q, t) => {
if (t < 0) t += 1
if (t > 1) t -= 1
if (t < 1/6) return p + (q - p) * 6 * t
if (t < 1/2) return q
if (t < 2/3) return p + (q - p) * (2/3 - t) * 6
return p
}
let r, g, b
if (s === 0) {
r = g = b = l
} else {
const q = l < 0.5 ? l * (1 + s) : l + s - l * s
const p = 2 * l - q
r = hue2rgb(p, q, h + 1/3)
g = hue2rgb(p, q, h)
b = hue2rgb(p, q, h - 1/3)
}
return { r: Math.round(r * 255), g: Math.round(g * 255), b: Math.round(b * 255) }
}
function interpolateColorHSL(color1, color2, factor) {
const rgb1 = hexToRgb(color1)
const rgb2 = hexToRgb(color2)
const hsl1 = rgbToHsl(rgb1.r, rgb1.g, rgb1.b)
const hsl2 = rgbToHsl(rgb2.r, rgb2.g, rgb2.b)
let deltaH = hsl2.h - hsl1.h
if (deltaH > 180) hsl2.h -= 360
else if (deltaH < -180) hsl2.h += 360
const h = ((hsl1.h + (hsl2.h - hsl1.h) * factor) % 360 + 360) % 360
const s = hsl1.s + (hsl2.s - hsl1.s) * factor
const l = hsl1.l + (hsl2.l - hsl1.l) * factor
const rgb = hslToRgb(h, s, l)
return rgbToHex(rgb.r, rgb.g, rgb.b)
}
import { interpolateColorHSL } from '@/utils/colors'
// State
const loading = ref(true)

View File

@@ -1,74 +1,22 @@
<template>
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<div class="px-4 sm:px-6 lg:px-8 py-8">
<LoadingSpinner v-if="loading" text="Chargement des résultats..." fullPage />
<template v-else-if="results">
<!-- Header amélioré avec métadonnées -->
<div class="bg-gradient-to-r from-success-500 to-emerald-600 rounded-2xl p-8 mb-8 text-white">
<div class="flex justify-between items-start">
<div>
<h1 class="text-3xl font-bold mb-2">{{ results.assessment_title }}</h1>
<p class="text-success-100 mb-4">Résultats de l'évaluation</p>
<PageHeader
:title="results.assessment_title"
:subtitle="`Résultats \u00b7 ${gradedStudents.length}/${results.students_scores?.length || 0} élèves évalués`"
:breadcrumbs="[{ label: 'Évaluations', to: '/assessments' }, { label: results.assessment_title, to: `/assessments/${results.assessment_id}` }, { label: 'Résultats' }]"
/>
<!-- Métadonnées avec icônes -->
<div class="flex flex-wrap gap-4 text-sm text-success-100">
<div class="flex items-center gap-1">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
<span>{{ gradedStudents.length }}/{{ results.students_scores?.length || 0 }} élèves évalués</span>
</div>
<div class="flex items-center gap-1">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
</svg>
<span>Moy: {{ calculatedStats?.mean?.toFixed(1) || '-' }}/20</span>
</div>
<div class="flex items-center gap-1">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 7h8m0 0v8m0-8l-8 8-4-4-6 6" />
</svg>
<span>Max: {{ calculatedStats?.max?.toFixed(1) || '-' }}</span>
</div>
</div>
</div>
<!-- Bouton retour -->
<router-link
:to="`/assessments/${results.assessment_id}`"
class="btn bg-white/20 hover:bg-white/30 text-white"
>
Retour
</router-link>
</div>
</div>
<!-- Statistics -->
<div class="grid grid-cols-2 md:grid-cols-6 gap-4 mb-8">
<div class="card card-body text-center">
<p class="text-sm text-gray-500">Évalués</p>
<p class="text-2xl font-bold">{{ gradedStudents.length }}<span class="text-sm text-gray-400">/{{ results.students_scores?.length }}</span></p>
</div>
<div class="card card-body text-center">
<p class="text-sm text-gray-500">Moyenne</p>
<p class="text-2xl font-bold">{{ calculatedStats?.mean?.toFixed(2) || '-' }}</p>
</div>
<div class="card card-body text-center">
<p class="text-sm text-gray-500">Médiane</p>
<p class="text-2xl font-bold">{{ calculatedStats?.median?.toFixed(2) || '-' }}</p>
</div>
<div class="card card-body text-center">
<p class="text-sm text-gray-500">Écart-type</p>
<p class="text-2xl font-bold">{{ calculatedStats?.std_dev?.toFixed(2) || '-' }}</p>
</div>
<div class="card card-body text-center">
<p class="text-sm text-gray-500">Min</p>
<p class="text-2xl font-bold">{{ calculatedStats?.min?.toFixed(2) || '-' }}</p>
</div>
<div class="card card-body text-center">
<p class="text-sm text-gray-500">Max</p>
<p class="text-2xl font-bold">{{ calculatedStats?.max?.toFixed(2) || '-' }}</p>
</div>
<!-- Statistics bar -->
<div class="flex items-center gap-6 px-4 py-3 bg-white rounded-lg border border-gray-200 mb-6 text-sm">
<div><span class="text-gray-500">Évalués</span> <span class="font-bold">{{ gradedStudents.length }}<span class="text-gray-400">/{{ results.students_scores?.length }}</span></span></div>
<div><span class="text-gray-500">Moy</span> <span class="font-bold">{{ calculatedStats?.mean?.toFixed(2) || '-' }}</span></div>
<div><span class="text-gray-500">Med</span> <span class="font-bold">{{ calculatedStats?.median?.toFixed(2) || '-' }}</span></div>
<div><span class="text-gray-500">E-T</span> <span class="font-bold">{{ calculatedStats?.std_dev?.toFixed(2) || '-' }}</span></div>
<div><span class="text-gray-500">Min</span> <span class="font-bold">{{ calculatedStats?.min?.toFixed(2) || '-' }}</span></div>
<div><span class="text-gray-500">Max</span> <span class="font-bold">{{ calculatedStats?.max?.toFixed(2) || '-' }}</span></div>
</div>
<!-- Avertissement si élèves non évalués -->
@@ -368,6 +316,7 @@ import { Bar } from 'vue-chartjs'
import { Chart as ChartJS, CategoryScale, LinearScale, BarElement, Title, Tooltip, Legend } from 'chart.js'
import LoadingSpinner from '@/components/common/LoadingSpinner.vue'
import SendReportsModal from '@/components/assessment/SendReportsModal.vue'
import PageHeader from '@/components/common/PageHeader.vue'
ChartJS.register(CategoryScale, LinearScale, BarElement, Title, Tooltip, Legend)
@@ -379,86 +328,7 @@ const configStore = useConfigStore()
const selectedStudents = ref([])
const showSendModal = ref(false)
// Color interpolation functions
function hexToRgb(hex) {
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex)
return result ? {
r: parseInt(result[1], 16),
g: parseInt(result[2], 16),
b: parseInt(result[3], 16)
} : { r: 0, g: 0, b: 0 }
}
function rgbToHex(r, g, b) {
return "#" + ((1 << 24) + (r << 16) + (g << 8) + b).toString(16).slice(1)
}
function rgbToHsl(r, g, b) {
r /= 255; g /= 255; b /= 255
const max = Math.max(r, g, b), min = Math.min(r, g, b)
let h, s, l = (max + min) / 2
if (max === min) {
h = s = 0
} else {
const d = max - min
s = l > 0.5 ? d / (2 - max - min) : d / (max + min)
switch (max) {
case r: h = (g - b) / d + (g < b ? 6 : 0); break
case g: h = (b - r) / d + 2; break
case b: h = (r - g) / d + 4; break
}
h /= 6
}
return { h: h * 360, s: s * 100, l: l * 100 }
}
function hslToRgb(h, s, l) {
h /= 360; s /= 100; l /= 100
const hue2rgb = (p, q, t) => {
if (t < 0) t += 1
if (t > 1) t -= 1
if (t < 1/6) return p + (q - p) * 6 * t
if (t < 1/2) return q
if (t < 2/3) return p + (q - p) * (2/3 - t) * 6
return p
}
let r, g, b
if (s === 0) {
r = g = b = l
} else {
const q = l < 0.5 ? l * (1 + s) : l + s - l * s
const p = 2 * l - q
r = hue2rgb(p, q, h + 1/3)
g = hue2rgb(p, q, h)
b = hue2rgb(p, q, h - 1/3)
}
return { r: Math.round(r * 255), g: Math.round(g * 255), b: Math.round(b * 255) }
}
function interpolateColorHSL(color1, color2, factor) {
const rgb1 = hexToRgb(color1)
const rgb2 = hexToRgb(color2)
const hsl1 = rgbToHsl(rgb1.r, rgb1.g, rgb1.b)
const hsl2 = rgbToHsl(rgb2.r, rgb2.g, rgb2.b)
let deltaH = hsl2.h - hsl1.h
if (deltaH > 180) hsl2.h -= 360
else if (deltaH < -180) hsl2.h += 360
const h = ((hsl1.h + (hsl2.h - hsl1.h) * factor) % 360 + 360) % 360
const s = hsl1.s + (hsl2.s - hsl1.s) * factor
const l = hsl1.l + (hsl2.l - hsl1.l) * factor
const rgb = hslToRgb(h, s, l)
return rgbToHex(rgb.r, rgb.g, rgb.b)
}
function getTextColorForBg(bgColor) {
const rgb = hexToRgb(bgColor)
const brightness = (rgb.r * 299 + rgb.g * 587 + rgb.b * 114) / 1000
return brightness > 128 ? '#000000' : '#ffffff'
}
import { interpolateColorHSL, getTextColorForBg } from '@/utils/colors'
// Get gradient color based on percentage
function getGradientColor(percentage) {

View File

@@ -1,10 +1,10 @@
<template>
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<!-- Hero section -->
<div class="bg-gradient-to-r from-success-500 to-emerald-600 rounded-2xl p-8 mb-8 text-white">
<h1 class="text-3xl font-bold mb-2">Élèves</h1>
<p class="text-success-100">{{ students.length }} élève(s) au total</p>
</div>
<PageHeader title="Élèves">
<template #meta>
<span class="badge badge-primary">{{ students.length }}</span>
</template>
</PageHeader>
<!-- Search -->
<div class="card card-body mb-6">
@@ -70,10 +70,8 @@
import { ref, onMounted } from 'vue'
import studentsService from '@/services/students'
import LoadingSpinner from '@/components/common/LoadingSpinner.vue'
// Icons
const SearchIcon = { template: '<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="m21 21-5.197-5.197m0 0A7.5 7.5 0 1 0 5.196 5.196a7.5 7.5 0 0 0 10.607 10.607Z" /></svg>' }
const AcademicCapIcon = { template: '<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M4.26 10.147a60.438 60.438 0 0 0-.491 6.347A48.62 48.62 0 0 1 12 20.904a48.62 48.62 0 0 1 8.232-4.41 60.46 60.46 0 0 0-.491-6.347m-15.482 0a50.636 50.636 0 0 0-2.658-.813A59.906 59.906 0 0 1 12 3.493a59.903 59.903 0 0 1 10.399 5.84c-.896.248-1.783.52-2.658.814m-15.482 0A50.717 50.717 0 0 1 12 13.489a50.702 50.702 0 0 1 7.74-3.342M6.75 15a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5Zm0 0v-3.675A55.378 55.378 0 0 1 12 8.443m-7.007 11.55A5.981 5.981 0 0 0 6.75 15.75v-1.5" /></svg>' }
import PageHeader from '@/components/common/PageHeader.vue'
import { SearchIcon, AcademicCapIcon } from '@/components/icons'
const students = ref([])
const loading = ref(true)

View File

@@ -22,20 +22,46 @@ export default {
success: {
50: '#f0fdf4',
100: '#dcfce7',
200: '#bbf7d0',
300: '#86efac',
400: '#4ade80',
500: '#22c55e',
600: '#16a34a',
700: '#15803d',
800: '#166534',
},
warning: {
50: '#fffbeb',
100: '#fef3c7',
200: '#fde68a',
300: '#fcd34d',
400: '#fbbf24',
500: '#f59e0b',
600: '#d97706',
700: '#b45309',
800: '#92400e',
},
danger: {
50: '#fef2f2',
100: '#fee2e2',
200: '#fecaca',
300: '#fca5a5',
400: '#f87171',
500: '#ef4444',
600: '#dc2626',
700: '#b91c1c',
800: '#991b1b',
},
accent: {
50: '#faf5ff',
100: '#f3e8ff',
200: '#e9d5ff',
300: '#d8b4fe',
400: '#c084fc',
500: '#a855f7',
600: '#9333ea',
700: '#7e22ce',
800: '#6b21a8',
}
}
},