Files
decision/frontend/app/pages/documents/index.vue
Yvv 8dc0dfd452 Palettes variees, logo stamp croque, search/date modernises
- Palettes : Chagrine bleu lavande + ambre + rose ancien (moins aquatique),
  Grave ocre rouge + ardoise (plus lumineux), Zen vert franc + sienne + canard
  (pas de pastels glauques), Peps secondary indigo
- Logo : icone dans stamp carre rotate(-10deg), separe du texte, hover -16deg
- Login : logo stamp rotate -10deg, subtitle epure
- Search : UInput/USelect remplaces par champs custom accent-soft + icone
- Dates : icone clock + opacity 0.7
- Badges : UBadge remplace par pills custom uppercase accent

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 03:27:11 +01:00

562 lines
14 KiB
Vue

<script setup lang="ts">
/**
* Documents de reference — page index.
*
* Utilise SectionLayout avec status filters, recherche, tri,
* et sidebar "Boite a outils" affichant les protocoles de vote.
*/
import type { DocumentCreate } from '~/stores/documents'
const documents = useDocumentsStore()
const protocols = useProtocolsStore()
const auth = useAuthStore()
const activeStatus = ref<string | null>(null)
const searchQuery = ref('')
const sortBy = ref<'date' | 'title' | 'status'>('date')
// New document modal state
const showNewDocModal = ref(false)
const newDoc = ref<DocumentCreate>({
slug: '',
title: '',
doc_type: 'licence',
description: null,
version: '1.0.0',
})
const creating = ref(false)
const newDocTypeOptions = [
{ label: 'Licence', value: 'licence' },
{ label: 'Engagement', value: 'engagement' },
{ label: 'Reglement', value: 'reglement' },
{ label: 'Constitution', value: 'constitution' },
]
const sortOptions = [
{ label: 'Date', value: 'date' },
{ label: 'Titre', value: 'title' },
{ label: 'Statut', value: 'status' },
]
onMounted(async () => {
await Promise.all([
documents.fetchAll(),
protocols.fetchProtocols(),
])
})
/** Status filter pills with counts. */
const statuses = computed(() => [
{ id: 'draft', label: 'En prepa', count: documents.list.filter(d => d.status === 'draft').length },
{ id: 'voting', label: 'En vote', count: documents.list.filter(d => d.status === 'voting').length },
{ id: 'active', label: 'En vigueur', count: documents.list.filter(d => d.status === 'active').length },
{ id: 'archived', label: 'Clos', count: documents.list.filter(d => d.status === 'archived').length },
])
/** Filtered and sorted documents. */
const filteredDocuments = computed(() => {
let list = [...documents.list]
// Filter by status
if (activeStatus.value) {
list = list.filter(d => d.status === activeStatus.value)
}
// Filter by search query (client-side)
if (searchQuery.value.trim()) {
const q = searchQuery.value.toLowerCase()
list = list.filter(d => d.title.toLowerCase().includes(q))
}
// Sort
switch (sortBy.value) {
case 'title':
list.sort((a, b) => a.title.localeCompare(b.title, 'fr'))
break
case 'status':
list.sort((a, b) => a.status.localeCompare(b.status))
break
case 'date':
default:
list.sort((a, b) => new Date(b.updated_at).getTime() - new Date(a.updated_at).getTime())
break
}
return list
})
/** Toolbox vignettes from protocols. */
const toolboxTitle = 'Modalites de vote'
const typeLabel = (docType: string): string => {
switch (docType) {
case 'licence': return 'Licence'
case 'engagement': return 'Engagement'
case 'reglement': return 'Reglement'
case 'constitution': return 'Constitution'
default: return docType
}
}
function formatDate(dateStr: string): string {
return new Date(dateStr).toLocaleDateString('fr-FR', {
day: 'numeric',
month: 'short',
year: 'numeric',
})
}
function generateSlug(title: string): string {
return title
.toLowerCase()
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.replace(/[^a-z0-9\s-]/g, '')
.replace(/\s+/g, '-')
.replace(/-+/g, '-')
.slice(0, 64)
}
watch(() => newDoc.value.title, (title) => {
if (title) {
newDoc.value.slug = generateSlug(title)
}
})
function openNewDocModal() {
newDoc.value = {
slug: '',
title: '',
doc_type: 'licence',
description: null,
version: '1.0.0',
}
showNewDocModal.value = true
}
async function createDocument() {
creating.value = true
try {
const doc = await documents.createDocument(newDoc.value)
showNewDocModal.value = false
if (doc) {
navigateTo(`/documents/${doc.slug}`)
}
}
catch {
// Error handled in store
}
finally {
creating.value = false
}
}
</script>
<template>
<SectionLayout
title="Documents de reference"
subtitle="Textes fondateurs sous vote permanent de la communaute"
:statuses="statuses"
:active-status="activeStatus"
@update:active-status="activeStatus = $event"
>
<!-- Search / sort bar -->
<template #search>
<div class="search-field">
<UIcon name="i-lucide-search" class="search-field__icon" />
<input
v-model="searchQuery"
type="text"
class="search-field__input"
placeholder="Rechercher un document..."
/>
</div>
<select v-model="sortBy" class="sort-select">
<option v-for="opt in sortOptions" :key="opt.value" :value="opt.value">
{{ opt.label }}
</option>
</select>
<button
v-if="auth.isAuthenticated"
class="action-btn"
@click="openNewDocModal"
>
<UIcon name="i-lucide-plus" class="text-xs" />
<span>Nouveau</span>
</button>
</template>
<!-- Main content: document list -->
<template #default>
<!-- Error state -->
<div v-if="documents.error" class="flex items-center gap-3 p-4 rounded-lg" style="background: var(--mood-surface); border: 1px solid var(--mood-border);">
<UIcon name="i-lucide-alert-circle" class="text-xl" style="color: var(--mood-error);" />
<p style="color: var(--mood-text);">{{ documents.error }}</p>
</div>
<!-- Loading state -->
<div v-else-if="documents.loading" class="space-y-3">
<LoadingSkeleton v-for="i in 4" :key="i" :lines="2" card />
</div>
<!-- Empty state -->
<div
v-else-if="filteredDocuments.length === 0"
class="text-center py-12"
style="color: var(--mood-text-muted);"
>
<UIcon name="i-lucide-book-open" class="text-4xl mb-3 block mx-auto" />
<p>Aucun document trouve</p>
<p v-if="searchQuery || activeStatus" class="text-sm mt-1">
Essayez de modifier vos filtres
</p>
</div>
<!-- Document cards -->
<div v-else class="space-y-3">
<div
v-for="doc in filteredDocuments"
:key="doc.id"
class="doc-card"
@click="navigateTo(`/documents/${doc.slug}`)"
>
<div class="doc-card__header">
<h3 class="doc-card__title">
{{ doc.title }}
</h3>
<StatusBadge :status="doc.status" type="document" />
</div>
<div class="doc-card__meta">
<span class="doc-card__type-badge">
{{ typeLabel(doc.doc_type) }}
</span>
<span class="doc-card__version">v{{ doc.version }}</span>
<span class="doc-card__items">
<UIcon name="i-lucide-list" class="text-xs" />
{{ doc.items_count }} item{{ doc.items_count !== 1 ? 's' : '' }}
</span>
<span class="doc-card__date">
<UIcon name="i-lucide-clock" class="text-xs" />
{{ formatDate(doc.updated_at) }}
</span>
</div>
<p v-if="doc.description" class="doc-card__description">
{{ doc.description }}
</p>
</div>
</div>
</template>
<!-- Toolbox sidebar -->
<template #toolbox>
<div class="toolbox-section-title">
{{ toolboxTitle }}
</div>
<template v-if="protocols.protocols.length > 0">
<ToolboxVignette
v-for="protocol in protocols.protocols"
:key="protocol.id"
:title="protocol.name"
:bullets="['Applicable aux documents', protocol.mode_params || 'Configuration standard']"
:actions="[
{ label: 'Voir', icon: 'i-lucide-eye', to: `/protocols/${protocol.id}` },
]"
/>
</template>
<p v-else class="toolbox-empty-text">
Aucun protocole configure
</p>
</template>
</SectionLayout>
<!-- New document modal -->
<UModal v-model:open="showNewDocModal">
<template #content>
<div class="p-4 sm:p-6 space-y-4">
<h3 class="text-base sm:text-lg font-semibold" style="color: var(--mood-text);">
Nouveau document de reference
</h3>
<div class="space-y-4">
<div class="space-y-2">
<label class="text-sm font-medium" style="color: var(--mood-text-muted);">
Titre
</label>
<UInput
v-model="newDoc.title"
placeholder="Ex: Licence G1"
class="w-full"
/>
</div>
<div class="space-y-2">
<label class="text-sm font-medium" style="color: var(--mood-text-muted);">
Slug (identifiant URL)
</label>
<UInput
v-model="newDoc.slug"
placeholder="Ex: licence-g1"
class="w-full font-mono text-sm"
/>
</div>
<div class="space-y-2">
<label class="text-sm font-medium" style="color: var(--mood-text-muted);">
Type de document
</label>
<USelect
v-model="newDoc.doc_type"
:items="newDocTypeOptions"
class="w-full"
/>
</div>
<div class="space-y-2">
<label class="text-sm font-medium" style="color: var(--mood-text-muted);">
Version
</label>
<UInput
v-model="newDoc.version"
placeholder="1.0.0"
class="w-full font-mono text-sm"
/>
</div>
<div class="space-y-2">
<label class="text-sm font-medium" style="color: var(--mood-text-muted);">
Description (optionnelle)
</label>
<UTextarea
v-model="newDoc.description"
:rows="3"
placeholder="Decrivez brievement ce document..."
class="w-full"
/>
</div>
</div>
<div class="flex items-center justify-end gap-3 pt-2">
<UButton
label="Annuler"
variant="ghost"
color="neutral"
@click="showNewDocModal = false"
/>
<UButton
label="Creer le document"
icon="i-lucide-plus"
color="primary"
:loading="creating"
:disabled="!newDoc.title.trim() || !newDoc.slug.trim()"
@click="createDocument"
/>
</div>
</div>
</template>
</UModal>
</template>
<style scoped>
.doc-card {
display: flex;
flex-direction: column;
gap: 0.5rem;
padding: 1rem;
background: var(--mood-surface);
border-radius: 16px;
cursor: pointer;
transition: transform 0.12s ease, box-shadow 0.12s ease;
}
@media (min-width: 640px) {
.doc-card {
gap: 0.625rem;
padding: 1.25rem;
}
}
.doc-card:hover {
transform: translateY(-3px);
box-shadow: 0 8px 24px var(--mood-shadow);
}
.doc-card:active {
transform: translateY(0);
}
.doc-card__header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 0.5rem;
}
.doc-card__title {
font-size: 0.9375rem;
font-weight: 700;
color: var(--mood-text);
line-height: 1.3;
}
@media (min-width: 640px) {
.doc-card__title {
font-size: 1.0625rem;
}
}
.doc-card__meta {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.375rem;
}
@media (min-width: 640px) {
.doc-card__meta {
gap: 0.5rem;
}
}
.doc-card__version {
font-size: 0.8125rem;
font-family: ui-monospace, SFMono-Regular, monospace;
color: var(--mood-text-muted);
}
.doc-card__items {
display: inline-flex;
align-items: center;
gap: 0.25rem;
font-size: 0.8125rem;
color: var(--mood-text-muted);
}
.doc-card__date {
display: inline-flex;
align-items: center;
gap: 0.25rem;
font-size: 0.75rem;
color: var(--mood-text-muted);
margin-left: auto;
opacity: 0.7;
}
@media (min-width: 640px) {
.doc-card__date {
font-size: 0.8125rem;
}
}
.doc-card__description {
font-size: 0.8125rem;
color: var(--mood-text-muted);
line-height: 1.5;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
@media (min-width: 640px) {
.doc-card__description {
font-size: 0.875rem;
}
}
.toolbox-section-title {
font-size: 0.8125rem;
font-weight: 700;
color: var(--mood-text-muted);
text-transform: uppercase;
letter-spacing: 0.04em;
margin-bottom: 0.25rem;
}
.toolbox-empty-text {
font-size: 0.8125rem;
color: var(--mood-text-muted);
}
/* --- Modern search / sort / action --- */
.search-field {
flex: 1;
min-width: 10rem;
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.625rem 1rem;
background: var(--mood-accent-soft);
border-radius: 12px;
transition: box-shadow 0.15s ease;
}
.search-field:focus-within {
box-shadow: 0 0 0 2.5px var(--mood-accent-soft);
}
.search-field__icon {
color: var(--mood-text-muted);
opacity: 0.5;
font-size: 0.875rem;
flex-shrink: 0;
}
.search-field__input {
flex: 1;
background: none;
font-size: 0.9375rem;
color: var(--mood-text);
min-width: 0;
}
.search-field__input::placeholder {
color: var(--mood-text-muted);
opacity: 0.4;
}
.sort-select {
padding: 0.625rem 1rem;
font-size: 0.875rem;
font-weight: 600;
color: var(--mood-text);
background: var(--mood-accent-soft);
border-radius: 12px;
cursor: pointer;
appearance: none;
-webkit-appearance: none;
min-width: 5.5rem;
}
.action-btn {
display: inline-flex;
align-items: center;
gap: 0.375rem;
padding: 0.625rem 1.25rem;
font-size: 0.875rem;
font-weight: 700;
color: var(--mood-accent-text);
background: var(--mood-accent);
border-radius: 20px;
cursor: pointer;
transition: transform 0.12s ease, box-shadow 0.12s ease;
white-space: nowrap;
}
.action-btn:hover {
transform: translateY(-1px);
box-shadow: 0 4px 12px var(--mood-shadow);
}
.action-btn:active {
transform: translateY(0);
}
.doc-card__type-badge {
font-size: 0.6875rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.04em;
padding: 3px 10px;
border-radius: 20px;
background: var(--mood-accent-soft);
color: var(--mood-accent);
}
</style>