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>
317 lines
8.5 KiB
Vue
317 lines
8.5 KiB
Vue
<template>
|
|
<div class="relative">
|
|
<input
|
|
ref="inputRef"
|
|
v-model="searchQuery"
|
|
type="text"
|
|
:placeholder="placeholder"
|
|
class="input w-full text-xs py-1"
|
|
autocomplete="off"
|
|
@input="onInput"
|
|
@focus="onFocus"
|
|
@blur="onBlur"
|
|
@keydown="onKeydown"
|
|
/>
|
|
|
|
<!-- Suggestions dropdown -->
|
|
<div
|
|
v-if="showSuggestions && (suggestions.length > 0 || canCreate)"
|
|
class="absolute z-50 w-full mt-1 bg-white border border-gray-300 rounded-md shadow-lg max-h-48 overflow-y-auto"
|
|
>
|
|
<!-- Existing domains -->
|
|
<div
|
|
v-for="(domain, idx) in suggestions"
|
|
:key="domain.id"
|
|
class="px-3 py-2 text-sm cursor-pointer hover:bg-gray-100 flex items-center"
|
|
:class="{ 'bg-primary-100': idx === selectedIndex }"
|
|
@mousedown.prevent="selectDomain(domain)"
|
|
>
|
|
<div
|
|
class="w-3 h-3 rounded-full mr-2 flex-shrink-0"
|
|
:style="{ backgroundColor: domain.color }"
|
|
></div>
|
|
<span v-html="highlightMatch(domain.name)"></span>
|
|
</div>
|
|
|
|
<!-- Create new option -->
|
|
<div
|
|
v-if="canCreate"
|
|
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-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-accent-600 font-medium">Créer "{{ searchQuery }}"</span>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Create Domain Modal -->
|
|
<Modal v-model="showCreateModal" title="Créer un nouveau domaine">
|
|
<div class="space-y-4">
|
|
<div>
|
|
<label class="label">Nom du domaine *</label>
|
|
<input
|
|
v-model="newDomain.name"
|
|
type="text"
|
|
class="input w-full"
|
|
placeholder="Ex: Calcul mental"
|
|
/>
|
|
</div>
|
|
|
|
<div>
|
|
<label class="label">Couleur associée</label>
|
|
<div class="flex flex-wrap gap-2 mb-3">
|
|
<button
|
|
v-for="color in colorOptions"
|
|
:key="color"
|
|
type="button"
|
|
class="w-8 h-8 rounded-full border-2 transition-all"
|
|
: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>
|
|
</div>
|
|
<input
|
|
v-model="newDomain.color"
|
|
type="color"
|
|
class="w-full h-10 border border-gray-300 rounded cursor-pointer"
|
|
/>
|
|
</div>
|
|
|
|
<div>
|
|
<label class="label">Description (optionnel)</label>
|
|
<textarea
|
|
v-model="newDomain.description"
|
|
class="input w-full"
|
|
rows="2"
|
|
placeholder="Description du domaine..."
|
|
></textarea>
|
|
</div>
|
|
</div>
|
|
|
|
<template #footer>
|
|
<div class="flex justify-end gap-3">
|
|
<button type="button" class="btn btn-secondary" @click="showCreateModal = false">
|
|
Annuler
|
|
</button>
|
|
<button
|
|
type="button"
|
|
class="btn btn-primary"
|
|
:disabled="!newDomain.name || creatingDomain"
|
|
@click="createDomain"
|
|
>
|
|
{{ creatingDomain ? 'Création...' : 'Créer' }}
|
|
</button>
|
|
</div>
|
|
</template>
|
|
</Modal>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup>
|
|
import { ref, computed, watch, nextTick } from 'vue'
|
|
import Modal from '@/components/common/Modal.vue'
|
|
import configService from '@/services/config'
|
|
import { useNotificationsStore } from '@/stores/notifications'
|
|
|
|
const props = defineProps({
|
|
modelValue: {
|
|
type: Object,
|
|
default: null
|
|
},
|
|
placeholder: {
|
|
type: String,
|
|
default: 'Saisissez un domaine...'
|
|
}
|
|
})
|
|
|
|
const emit = defineEmits(['update:modelValue'])
|
|
|
|
const notifications = useNotificationsStore()
|
|
|
|
const inputRef = ref(null)
|
|
const searchQuery = ref('')
|
|
const suggestions = ref([])
|
|
const showSuggestions = ref(false)
|
|
const selectedIndex = ref(-1)
|
|
const showCreateModal = ref(false)
|
|
const creatingDomain = ref(false)
|
|
let debounceTimer = null
|
|
|
|
const colorOptions = [
|
|
'#3b82f6', '#10b981', '#f59e0b', '#8b5cf6',
|
|
'#ef4444', '#06b6d4', '#84cc16', '#f97316'
|
|
]
|
|
|
|
const newDomain = ref({
|
|
name: '',
|
|
color: '#3b82f6',
|
|
description: ''
|
|
})
|
|
|
|
// Check if we can show "create new" option
|
|
const canCreate = computed(() => {
|
|
if (!searchQuery.value.trim()) return false
|
|
const exactMatch = suggestions.value.find(
|
|
d => d.name.toLowerCase() === searchQuery.value.toLowerCase()
|
|
)
|
|
return !exactMatch
|
|
})
|
|
|
|
// Initialize from modelValue
|
|
watch(() => props.modelValue, (val) => {
|
|
if (val && val.name) {
|
|
searchQuery.value = val.name
|
|
} else {
|
|
searchQuery.value = ''
|
|
}
|
|
}, { immediate: true })
|
|
|
|
async function searchDomains(query) {
|
|
if (!query.trim()) {
|
|
suggestions.value = []
|
|
return
|
|
}
|
|
|
|
try {
|
|
const result = await configService.searchDomains(query)
|
|
suggestions.value = result.domains || []
|
|
} catch (error) {
|
|
console.error('Error searching domains:', error)
|
|
suggestions.value = []
|
|
}
|
|
}
|
|
|
|
function onInput() {
|
|
clearTimeout(debounceTimer)
|
|
selectedIndex.value = -1
|
|
|
|
if (!searchQuery.value.trim()) {
|
|
suggestions.value = []
|
|
emit('update:modelValue', null)
|
|
return
|
|
}
|
|
|
|
debounceTimer = setTimeout(() => {
|
|
searchDomains(searchQuery.value)
|
|
}, 300)
|
|
}
|
|
|
|
function onFocus() {
|
|
if (searchQuery.value.trim()) {
|
|
searchDomains(searchQuery.value)
|
|
}
|
|
showSuggestions.value = true
|
|
}
|
|
|
|
function onBlur() {
|
|
// Delay to allow click on suggestions
|
|
setTimeout(() => {
|
|
showSuggestions.value = false
|
|
selectedIndex.value = -1
|
|
}, 200)
|
|
}
|
|
|
|
function onKeydown(e) {
|
|
const totalItems = suggestions.value.length + (canCreate.value ? 1 : 0)
|
|
|
|
switch (e.key) {
|
|
case 'ArrowDown':
|
|
e.preventDefault()
|
|
if (totalItems > 0) {
|
|
selectedIndex.value = Math.min(selectedIndex.value + 1, totalItems - 1)
|
|
}
|
|
break
|
|
|
|
case 'ArrowUp':
|
|
e.preventDefault()
|
|
if (totalItems > 0) {
|
|
selectedIndex.value = Math.max(selectedIndex.value - 1, -1)
|
|
}
|
|
break
|
|
|
|
case 'Enter':
|
|
e.preventDefault()
|
|
if (selectedIndex.value >= 0) {
|
|
if (selectedIndex.value < suggestions.value.length) {
|
|
selectDomain(suggestions.value[selectedIndex.value])
|
|
} else if (canCreate.value) {
|
|
openCreateModal()
|
|
}
|
|
} else if (suggestions.value.length > 0) {
|
|
selectDomain(suggestions.value[0])
|
|
}
|
|
break
|
|
|
|
case 'Escape':
|
|
showSuggestions.value = false
|
|
selectedIndex.value = -1
|
|
inputRef.value?.blur()
|
|
break
|
|
|
|
case 'Tab':
|
|
// Ne pas bloquer le Tab - laisser passer au champ suivant
|
|
// Si des suggestions sont affichées, les fermer
|
|
if (showSuggestions.value) {
|
|
showSuggestions.value = false
|
|
selectedIndex.value = -1
|
|
}
|
|
// Ne pas appeler e.preventDefault() pour permettre la navigation naturelle
|
|
break
|
|
}
|
|
}
|
|
|
|
function selectDomain(domain) {
|
|
searchQuery.value = domain.name
|
|
showSuggestions.value = false
|
|
selectedIndex.value = -1
|
|
emit('update:modelValue', domain)
|
|
nextTick(() => inputRef.value?.focus())
|
|
}
|
|
|
|
function highlightMatch(text) {
|
|
const query = searchQuery.value.toLowerCase()
|
|
const lowerText = text.toLowerCase()
|
|
|
|
if (lowerText.includes(query)) {
|
|
const index = lowerText.indexOf(query)
|
|
return (
|
|
text.substring(0, index) +
|
|
'<strong>' + text.substring(index, index + query.length) + '</strong>' +
|
|
text.substring(index + query.length)
|
|
)
|
|
}
|
|
|
|
return text
|
|
}
|
|
|
|
function openCreateModal() {
|
|
newDomain.value = {
|
|
name: searchQuery.value,
|
|
color: '#3b82f6',
|
|
description: ''
|
|
}
|
|
showSuggestions.value = false
|
|
showCreateModal.value = true
|
|
}
|
|
|
|
async function createDomain() {
|
|
if (!newDomain.value.name) return
|
|
|
|
creatingDomain.value = true
|
|
try {
|
|
const created = await configService.createDomain(newDomain.value)
|
|
showCreateModal.value = false
|
|
selectDomain(created)
|
|
notifications.success('Domaine créé avec succès')
|
|
} catch (error) {
|
|
console.error('Error creating domain:', error)
|
|
notifications.error(error.response?.data?.detail || 'Erreur lors de la création du domaine')
|
|
} finally {
|
|
creatingDomain.value = false
|
|
}
|
|
}
|
|
</script>
|