@tekkare/romulus 0.1.231 → 0.1.233

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,117 @@
1
+ <!--
2
+ RmProjectCreateDialog - creer un projet : un nom, une description facultative.
3
+
4
+ Fermer apres avoir commence a remplir (croix, clic a cote, Echap) ne doit
5
+ pas perdre ce qui a ete tape : une seconde question « Quitter sans
6
+ enregistrer ? » se pose par-dessus. Le formulaire se vide quand l'app le
7
+ ferme apres une creation reussie, ou quand on confirme l'abandon.
8
+ -->
9
+ <script setup lang="ts">
10
+ import { computed, ref, watch } from 'vue'
11
+
12
+ const props = withDefaults(
13
+ defineProps<{
14
+ modelValue: boolean
15
+ loading?: boolean
16
+ /** Refus de l'API, affiche sous le formulaire. */
17
+ error?: string
18
+ lang?: 'fr' | 'en'
19
+ }>(),
20
+ { loading: false, error: '', lang: 'fr' },
21
+ )
22
+
23
+ const emit = defineEmits<{
24
+ 'update:modelValue': [value: boolean]
25
+ 'create': [value: { title: string, description: string }]
26
+ }>()
27
+
28
+ const WORDS = {
29
+ fr: {
30
+ heading: 'Nouveau projet', name: 'Nom', placeholder: 'Veille oncologie', description: 'Description (facultative)',
31
+ required: 'Un nom est requis', cancel: 'Annuler', create: 'Créer',
32
+ leave: 'Quitter sans enregistrer ?', lost: 'Ce que vous avez saisi sera perdu.', keep: 'Continuer la saisie', quit: 'Quitter',
33
+ },
34
+ en: {
35
+ heading: 'New project', name: 'Name', placeholder: 'Oncology watch', description: 'Description (optional)',
36
+ required: 'A name is required', cancel: 'Cancel', create: 'Create',
37
+ leave: 'Leave without saving?', lost: 'What you entered will be lost.', keep: 'Keep editing', quit: 'Leave',
38
+ },
39
+ }
40
+ const T = computed(() => WORDS[props.lang] ?? WORDS.fr)
41
+
42
+ const title = ref('')
43
+ const description = ref('')
44
+ const missing = ref(false)
45
+ const confirming = ref(false)
46
+
47
+ function reset() {
48
+ title.value = ''
49
+ description.value = ''
50
+ missing.value = false
51
+ }
52
+
53
+ // Ferme par l'app (creation faite) : le prochain projet repart de zero.
54
+ watch(() => props.modelValue, (open, was) => {
55
+ if (was && !open && !confirming.value) reset()
56
+ })
57
+
58
+ function requestClose() {
59
+ if (title.value.trim() || description.value.trim()) confirming.value = true
60
+ else emit('update:modelValue', false)
61
+ }
62
+
63
+ function discard() {
64
+ confirming.value = false
65
+ reset()
66
+ emit('update:modelValue', false)
67
+ }
68
+
69
+ function submit() {
70
+ const name = title.value.trim()
71
+ if (!name) {
72
+ missing.value = true
73
+ return
74
+ }
75
+ emit('create', { title: name, description: description.value.trim() })
76
+ }
77
+ </script>
78
+
79
+ <template>
80
+ <RmModal
81
+ :model-value="modelValue"
82
+ size="sm"
83
+ mode="free"
84
+ :label="T.heading"
85
+ @update:model-value="$event ? emit('update:modelValue', true) : requestClose()"
86
+ >
87
+ <template #header>
88
+ <p class="rm-project-dialog__title">{{ T.heading }}</p>
89
+ </template>
90
+ <form class="rm-project-dialog__form" @submit.prevent="submit">
91
+ <RmInput v-model="title" :label="T.name" :placeholder="T.placeholder" :error="missing ? T.required : ''" />
92
+ <RmInput v-model="description" :label="T.description" />
93
+ <p v-if="error" class="rm-project-dialog__error">{{ error }}</p>
94
+ <!-- Entree valide le formulaire. -->
95
+ <button type="submit" hidden />
96
+ </form>
97
+ <template #footer>
98
+ <RmButton variant="tertiary" @click="requestClose">{{ T.cancel }}</RmButton>
99
+ <RmButton variant="primary" :loading="loading" @click="submit">{{ T.create }}</RmButton>
100
+ </template>
101
+ </RmModal>
102
+
103
+ <RmModal v-model="confirming" size="sm" mode="free" :label="T.leave">
104
+ <template #header>
105
+ <p class="rm-project-dialog__title">{{ T.leave }}</p>
106
+ </template>
107
+ <p class="rm-project-dialog__text">{{ T.lost }}</p>
108
+ <template #footer>
109
+ <RmButton variant="tertiary" @click="confirming = false">{{ T.keep }}</RmButton>
110
+ <RmButton variant="danger" @click="discard">{{ T.quit }}</RmButton>
111
+ </template>
112
+ </RmModal>
113
+ </template>
114
+
115
+ <style scoped>
116
+ .rm-project-dialog__title{font-size:15px;font-weight:600;margin:0}.rm-project-dialog__form{display:flex;flex-direction:column;gap:16px}.rm-project-dialog__text{font-size:var(--font-14);line-height:1.5;margin:0}.rm-project-dialog__error{color:var(--color-danger);font-size:var(--font-13);margin:0}
117
+ </style>
@@ -0,0 +1,60 @@
1
+ <!--
2
+ RmProjectDeleteDialog - supprimer un projet, pour de bon.
3
+
4
+ La suppression emporte tout ce que le projet rassemble, pour tous ses
5
+ membres : la modale le dit, nomme le projet, et attend un second clic.
6
+ -->
7
+ <script setup lang="ts">
8
+ import { computed } from 'vue'
9
+
10
+ const props = withDefaults(
11
+ defineProps<{
12
+ modelValue: boolean
13
+ title: string
14
+ loading?: boolean
15
+ error?: string
16
+ lang?: 'fr' | 'en'
17
+ }>(),
18
+ { loading: false, error: '', lang: 'fr' },
19
+ )
20
+
21
+ const emit = defineEmits<{
22
+ 'update:modelValue': [value: boolean]
23
+ 'confirm': []
24
+ }>()
25
+
26
+ const WORDS = {
27
+ fr: {
28
+ heading: 'Supprimer le projet', cancel: 'Annuler', confirm: 'Supprimer définitivement',
29
+ text: (t: string) => `« ${t} » sera supprimé définitivement, avec ses ressources, ses commentaires et ses documents déposés, pour tous ses membres. Cette action est irréversible.`,
30
+ },
31
+ en: {
32
+ heading: 'Delete the project', cancel: 'Cancel', confirm: 'Delete for good',
33
+ text: (t: string) => `« ${t} » will be deleted for good, with its resources, comments and uploaded documents, for every member. This cannot be undone.`,
34
+ },
35
+ }
36
+ const T = computed(() => WORDS[props.lang] ?? WORDS.fr)
37
+
38
+ const open = computed({
39
+ get: () => props.modelValue,
40
+ set: (value: boolean) => emit('update:modelValue', value),
41
+ })
42
+ </script>
43
+
44
+ <template>
45
+ <RmModal v-model="open" size="sm" :label="T.heading">
46
+ <template #header>
47
+ <h2 class="rm-project-dialog__title">{{ T.heading }}</h2>
48
+ </template>
49
+ <p class="rm-project-dialog__text">{{ T.text(title) }}</p>
50
+ <p v-if="error" class="rm-project-dialog__error">{{ error }}</p>
51
+ <template #footer>
52
+ <RmButton variant="tertiary" @click="open = false">{{ T.cancel }}</RmButton>
53
+ <RmButton variant="danger" prefix-icon="Trash" :loading="loading" @click="emit('confirm')">{{ T.confirm }}</RmButton>
54
+ </template>
55
+ </RmModal>
56
+ </template>
57
+
58
+ <style scoped>
59
+ .rm-project-dialog__title{font-size:var(--font-18);font-weight:var(--weight-bold);margin:0}.rm-project-dialog__text{font-size:var(--font-14);line-height:1.5;margin:0}.rm-project-dialog__error{color:var(--color-danger);font-size:var(--font-13);margin:8px 0 0}
60
+ </style>
@@ -0,0 +1,115 @@
1
+ <!--
2
+ RmProjectList - la page « Mes projets » : les projets en cartes, groupes.
3
+
4
+ Les epingles d'abord, puis les miens, puis ceux qu'on m'a partages (cf.
5
+ rmProjectGroups). Une carte mene au projet (`to`) ; l'epingle est posee sur
6
+ la carte, hors du lien, pour qu'epingler n'ouvre rien. Les epingles sont un
7
+ choix de lecture : l'app les garde ou elle veut et les rend dans `pinned`.
8
+
9
+ Chargement, echec et liste vide se disent ici. Le bouton de creation de
10
+ l'etat vide vient de l'app (slot `empty-action`) : elle seule sait si la
11
+ creation est libre ou assistee.
12
+ -->
13
+ <script setup lang="ts">
14
+ import { computed } from 'vue'
15
+ import { rmProjectGroups, rmProjectSummary, type RmProject } from '../utils/projects'
16
+
17
+ const props = withDefaults(
18
+ defineProps<{
19
+ projects: RmProject[]
20
+ /** Projet qui recoit les ajouts. */
21
+ activeId?: string | null
22
+ loading?: boolean
23
+ failed?: boolean
24
+ /** Texte de l'etat vide : ce qu'un projet rassemble dans cette app. */
25
+ emptyText?: string
26
+ lang?: 'fr' | 'en'
27
+ }>(),
28
+ { activeId: null, loading: false, failed: false, emptyText: '', lang: 'fr' },
29
+ )
30
+
31
+ const emit = defineEmits<{ pin: [projectId: string] }>()
32
+
33
+ const WORDS = {
34
+ fr: {
35
+ active: 'Actif', pin: 'Épingler en haut', unpin: 'Désépingler',
36
+ failedTitle: 'Projets indisponibles', failedText: 'Le service des projets n\'a pas répondu. Réessayez dans un instant.',
37
+ emptyTitle: 'Aucun projet', emptyText: 'Un projet rassemble ce que vous mettez de côté au fil des pages.',
38
+ },
39
+ en: {
40
+ active: 'Active', pin: 'Pin to the top', unpin: 'Unpin',
41
+ failedTitle: 'Projects unavailable', failedText: 'The project service did not answer. Try again in a moment.',
42
+ emptyTitle: 'No project yet', emptyText: 'A project gathers what you set aside across the pages.',
43
+ },
44
+ }
45
+ const T = computed(() => WORDS[props.lang] ?? WORDS.fr)
46
+
47
+ const groups = computed(() => rmProjectGroups(props.projects, props.lang))
48
+ </script>
49
+
50
+ <template>
51
+ <div class="rm-project-list">
52
+ <RmLoadingState v-if="loading && !projects.length" />
53
+
54
+ <RmErrorState v-else-if="failed" :title="T.failedTitle" :text="T.failedText" action-label="" />
55
+
56
+ <div v-else-if="!projects.length" class="rm-project-list__empty">
57
+ <RmEmptyState icon="FolderSimplePlus" tone="primary" :title="T.emptyTitle" :text="emptyText || T.emptyText" />
58
+ <slot name="empty-action" />
59
+ </div>
60
+
61
+ <section v-for="group in groups" v-else :key="group.key" class="rm-project-list__group">
62
+ <p class="rm-project-list__group-title">
63
+ <RmIcon v-if="group.key === 'pinned'" name="PushPin" weight="fill" :size="12" />
64
+ {{ group.label }}
65
+ </p>
66
+ <div class="rm-project-list__grid">
67
+ <RmSectionCard v-for="project in group.projects" :key="project.id" position="none" action class="rm-project-list__card">
68
+ <!-- Deux branches en dur : `<component :is="'NuxtLink'">` ne resout rien. -->
69
+ <NuxtLink v-if="project.to" :to="project.to" class="rm-project-list__link">
70
+ <div class="rm-project-list__head">
71
+ <RmIcon name="FolderSimple" :size="18" />
72
+ <span class="rm-project-list__title">{{ project.title }}</span>
73
+ <RmTag v-if="project.id === activeId" variant="teal">{{ T.active }}</RmTag>
74
+ </div>
75
+ <p v-if="project.description" class="rm-project-list__text">{{ project.description }}</p>
76
+ <p class="rm-project-list__meta">{{ rmProjectSummary(project, lang) }}</p>
77
+ <p v-if="project.createdAt" class="rm-project-list__meta">
78
+ <RmDate :date="project.createdAt" :lang="lang" />
79
+ </p>
80
+ </NuxtLink>
81
+ <div v-else class="rm-project-list__link">
82
+ <div class="rm-project-list__head">
83
+ <RmIcon name="FolderSimple" :size="18" />
84
+ <span class="rm-project-list__title">{{ project.title }}</span>
85
+ <RmTag v-if="project.id === activeId" variant="teal">{{ T.active }}</RmTag>
86
+ </div>
87
+ <p v-if="project.description" class="rm-project-list__text">{{ project.description }}</p>
88
+ <p class="rm-project-list__meta">{{ rmProjectSummary(project, lang) }}</p>
89
+ <p v-if="project.createdAt" class="rm-project-list__meta">
90
+ <RmDate :date="project.createdAt" :lang="lang" />
91
+ </p>
92
+ </div>
93
+ <RmButton
94
+ variant="tertiary"
95
+ size="sm"
96
+ icon-only
97
+ ghost
98
+ class="rm-project-list__pin"
99
+ :class="{ 'is-on': project.pinned }"
100
+ :aria-pressed="!!project.pinned"
101
+ :aria-label="project.pinned ? T.unpin : T.pin"
102
+ :title="project.pinned ? T.unpin : T.pin"
103
+ @click="emit('pin', project.id)"
104
+ >
105
+ <RmIcon name="PushPin" :weight="project.pinned ? 'fill' : 'regular'" :size="14" />
106
+ </RmButton>
107
+ </RmSectionCard>
108
+ </div>
109
+ </section>
110
+ </div>
111
+ </template>
112
+
113
+ <style scoped>
114
+ .rm-project-list{display:flex;flex-direction:column;gap:24px}.rm-project-list__group{display:flex;flex-direction:column;gap:12px}.rm-project-list__group-title{align-items:center;color:var(--rm-shell-text-muted);display:flex;font-size:11px;font-weight:700;gap:6px;letter-spacing:.06em;margin:0;text-transform:uppercase}.rm-project-list__grid{display:grid;gap:16px;grid-template-columns:repeat(auto-fill,minmax(320px,1fr))}.rm-project-list__card{display:flex;flex-direction:column;position:relative}.rm-project-list__link{color:inherit;display:flex;flex-direction:column;gap:6px;text-decoration:none}.rm-project-list__head{align-items:center;display:flex;gap:8px;padding-right:32px}.rm-project-list__title{font-size:15px;font-weight:600}.rm-project-list__text{color:var(--rm-shell-text-muted);font-size:13px;margin:0}.rm-project-list__meta{color:var(--rm-shell-text-muted);display:flex;font-size:12px;gap:6px;margin:0}.rm-project-list__pin{position:absolute;right:10px;top:10px}.rm-project-list__pin.is-on{color:var(--color-primary)}.rm-project-list__empty{align-items:center;display:flex;flex-direction:column;gap:16px}
115
+ </style>
@@ -0,0 +1,294 @@
1
+ <!--
2
+ RmProjectOverview - la vue d'ensemble d'un projet, d'un coup d'oeil.
3
+
4
+ Tout se calcule sur ce que la page a deja relu (lignes, commentaires). En
5
+ tete, les chiffres ; puis le cadrage (la description, que les membres
6
+ ecrivent, et les rangees que l'app deduit de ses ressources : marques,
7
+ maladies, pays...) avec les informations du projet en pied ; a droite les
8
+ facons d'avancer (`steps`, un lien par onglet) et la repartition par
9
+ famille ; en bas, les derniers ajouts.
10
+
11
+ La description s'edite sur place : `describe` part avec le texte, l'app
12
+ l'enregistre et repasse `saving` a faux. Sans `saveError`, l'editeur se
13
+ referme.
14
+ -->
15
+ <script setup lang="ts">
16
+ import { computed, ref, watch } from 'vue'
17
+ import { rmProjectFamilies, type RmProjectResource, type RmProjectScopeRow, type RmProjectStep } from '../utils/projects'
18
+
19
+ const props = withDefaults(
20
+ defineProps<{
21
+ project: {
22
+ title: string
23
+ description?: string
24
+ createdAt?: string
25
+ ownerName?: string
26
+ /** Ouvert a tout l'espace de travail. */
27
+ shared?: boolean
28
+ } | null
29
+ rows: RmProjectResource[]
30
+ /** Personnes a qui le projet est ouvert nommement, proprietaire exclu. */
31
+ sharedCount?: number
32
+ /** Documents deposes. `null` : inconnu, la tuile n'est pas montree. */
33
+ documentsCount?: number | null
34
+ scopeRows?: RmProjectScopeRow[]
35
+ steps?: RmProjectStep[]
36
+ /** Le lecteur peut modifier la description. */
37
+ editable?: boolean
38
+ loading?: boolean
39
+ saving?: boolean
40
+ saveError?: string
41
+ /** Texte des derniers ajouts quand le projet est vide. */
42
+ emptyText?: string
43
+ lang?: 'fr' | 'en'
44
+ }>(),
45
+ {
46
+ sharedCount: 0,
47
+ documentsCount: null,
48
+ scopeRows: () => [],
49
+ steps: () => [],
50
+ editable: true,
51
+ loading: false,
52
+ saving: false,
53
+ saveError: '',
54
+ emptyText: '',
55
+ lang: 'fr',
56
+ },
57
+ )
58
+
59
+ const emit = defineEmits<{
60
+ tab: [key: string]
61
+ describe: [description: string]
62
+ open: [row: RmProjectResource]
63
+ }>()
64
+
65
+ const WORDS = {
66
+ fr: {
67
+ resources: 'Ressources', families: 'Familles', documents: 'Documents déposés', comments: 'Commentaires',
68
+ scope: 'Cadrage du projet', scopeText: 'Déduit des ressources rangées dans le projet. La description reste modifiable.',
69
+ edit: 'Modifier', placeholder: 'De quoi parle ce projet, à quoi il sert, ce qu\'il reste à faire',
70
+ cancel: 'Annuler', save: 'Enregistrer', fill: 'À renseigner',
71
+ created: 'Créé le', owner: 'Propriétaire', visibility: 'Visibilité', last: 'Dernier ajout',
72
+ workspace: 'Tout l\'espace de travail', private: 'Privé', people: (n: number) => `${n} ${n > 1 ? 'personnes' : 'personne'}`,
73
+ start: 'Démarrer', startText: 'Pour faire avancer ce projet.', startEmpty: 'Le projet est vide pour l\'instant. Pour le remplir :',
74
+ breakdown: 'Répartition par famille', nothing: 'Rien n\'est encore rangé.',
75
+ latest: 'Derniers ajouts', all: 'Toutes les ressources', unknown: 'Élément inconnu',
76
+ empty: 'Parcourez les pages et utilisez le bouton Projet d\'une carte pour la ranger ici.',
77
+ },
78
+ en: {
79
+ resources: 'Resources', families: 'Families', documents: 'Documents', comments: 'Comments',
80
+ scope: 'Project scope', scopeText: 'Drawn from the resources filed in the project. The description can be edited.',
81
+ edit: 'Edit', placeholder: 'What is this project about, what is it for, what is left to do',
82
+ cancel: 'Cancel', save: 'Save', fill: 'To fill in',
83
+ created: 'Created', owner: 'Owner', visibility: 'Visibility', last: 'Last addition',
84
+ workspace: 'Whole workspace', private: 'Private', people: (n: number) => `${n} ${n > 1 ? 'people' : 'person'}`,
85
+ start: 'Get started', startText: 'Ways to move this project forward.', startEmpty: 'The project is empty for now. To fill it:',
86
+ breakdown: 'Breakdown by family', nothing: 'Nothing filed yet.',
87
+ latest: 'Latest additions', all: 'All resources', unknown: 'Unknown item',
88
+ empty: 'Browse the pages and use the Project button on a card to file it here.',
89
+ },
90
+ }
91
+ const T = computed(() => WORDS[props.lang] ?? WORDS.fr)
92
+
93
+ const families = computed(() => rmProjectFamilies(props.rows))
94
+ const commentTotal = computed(() => props.rows.reduce((sum, row) => sum + (row.comments ?? 0), 0))
95
+
96
+ const kpis = computed(() => [
97
+ { icon: 'Stack', value: props.rows.length, legend: T.value.resources },
98
+ { icon: 'SquaresFour', value: families.value.length, legend: T.value.families },
99
+ ...(props.documentsCount == null ? [] : [{ icon: 'FileText', value: props.documentsCount, legend: T.value.documents }]),
100
+ { icon: 'ChatCircle', value: commentTotal.value, legend: T.value.comments },
101
+ ])
102
+
103
+ const breakdown = computed(() =>
104
+ [...families.value].sort((a, b) => b.count - a.count).map(f => ({ name: f.label, value: f.count })))
105
+
106
+ const visibility = computed(() => {
107
+ if (props.project?.shared) return T.value.workspace
108
+ return props.sharedCount ? T.value.people(props.sharedCount) : T.value.private
109
+ })
110
+
111
+ const lastAdded = computed(() =>
112
+ props.rows.map(row => row.createdAt ?? '').filter(Boolean).sort().at(-1) ?? '')
113
+
114
+ const latest = computed(() =>
115
+ [...props.rows].sort((a, b) => String(b.createdAt ?? '').localeCompare(String(a.createdAt ?? ''))).slice(0, 5))
116
+
117
+ function formatDate(value?: string) {
118
+ const date = new Date(value ?? '')
119
+ if (Number.isNaN(date.getTime())) return ''
120
+ return date.toLocaleDateString(props.lang === 'en' ? 'en-GB' : 'fr-FR', { day: 'numeric', month: 'long', year: 'numeric' })
121
+ }
122
+
123
+ const editing = ref(false)
124
+ const draft = ref('')
125
+
126
+ function startEdit() {
127
+ draft.value = props.project?.description ?? ''
128
+ editing.value = true
129
+ }
130
+
131
+ watch(() => props.saving, (now, was) => {
132
+ if (was && !now && !props.saveError) editing.value = false
133
+ })
134
+
135
+ /** Le reste d'une rangee du cadrage, au survol de sa pastille « +X ». */
136
+ const SCOPE_MAX = 8
137
+ const moreOpen = ref(false)
138
+ const moreAnchor = ref<HTMLElement | null>(null)
139
+ const moreRow = ref<RmProjectScopeRow | null>(null)
140
+
141
+ function showMore(row: RmProjectScopeRow, event: Event) {
142
+ moreAnchor.value = event.currentTarget as HTMLElement
143
+ moreRow.value = row
144
+ moreOpen.value = true
145
+ }
146
+
147
+ function onLatest(event: MouseEvent, row: RmProjectResource) {
148
+ if (event.metaKey || event.ctrlKey || event.shiftKey || event.button !== 0) return
149
+ event.preventDefault()
150
+ emit('open', row)
151
+ }
152
+ </script>
153
+
154
+ <template>
155
+ <div class="rm-project-ov">
156
+ <RmKpiBar v-if="loading" loading :tiles="kpis.length" />
157
+ <RmKpiBar v-else :kpis="kpis" />
158
+
159
+ <div class="rm-project-ov__top">
160
+ <RmCard padding="lg" class="rm-project-ov__card is-scope">
161
+ <div class="rm-project-ov__card-head">
162
+ <div class="rm-project-ov__titles">
163
+ <h2 class="rm-project-ov__title">{{ T.scope }}</h2>
164
+ <p class="rm-project-ov__context">{{ T.scopeText }}</p>
165
+ </div>
166
+ <RmButton v-if="editable && !editing" variant="secondary" size="sm" prefix-icon="PencilSimple" @click="startEdit">
167
+ {{ T.edit }}
168
+ </RmButton>
169
+ </div>
170
+
171
+ <ul class="rm-project-ov__scope">
172
+ <li class="rm-project-ov__scope-row">
173
+ <RmIcon name="TextAlignLeft" :size="16" class="rm-project-ov__scope-icon" />
174
+ <div class="rm-project-ov__scope-body">
175
+ <p class="rm-project-ov__scope-key">Description</p>
176
+ <template v-if="editing">
177
+ <RmTextarea v-model="draft" :rows="4" :placeholder="T.placeholder" :error="saveError" />
178
+ <div class="rm-project-ov__edit-actions">
179
+ <RmButton variant="tertiary" size="sm" @click="editing = false">{{ T.cancel }}</RmButton>
180
+ <RmButton variant="primary" size="sm" :loading="saving" @click="emit('describe', draft.trim())">{{ T.save }}</RmButton>
181
+ </div>
182
+ </template>
183
+ <!-- Coupee a trois lignes : le cadrage se lit d'un coup d'oeil. -->
184
+ <RmClampText v-else-if="project?.description" class="rm-project-ov__scope-value" :text="project.description" :lines="3" :lang="lang" />
185
+ <button v-else-if="editable" type="button" class="rm-project-ov__placeholder" @click="startEdit">{{ T.fill }}</button>
186
+ </div>
187
+ </li>
188
+
189
+ <li v-for="row in scopeRows" :key="row.key" class="rm-project-ov__scope-row">
190
+ <RmIcon :name="row.icon" :size="16" class="rm-project-ov__scope-icon" />
191
+ <div class="rm-project-ov__scope-body">
192
+ <p class="rm-project-ov__scope-key">{{ row.label }}</p>
193
+ <div v-if="row.values.length" class="rm-project-ov__tags">
194
+ <RmTag v-for="value in row.values.slice(0, SCOPE_MAX)" :key="value" variant="gray" class="rm-project-ov__tag" :title="value">
195
+ {{ value }}
196
+ </RmTag>
197
+ <RmTag
198
+ v-if="row.values.length > SCOPE_MAX"
199
+ variant="gray"
200
+ tabindex="0"
201
+ :aria-label="`${row.label} : ${row.values.slice(SCOPE_MAX).join(', ')}`"
202
+ @mouseenter="showMore(row, $event)"
203
+ @focus="showMore(row, $event)"
204
+ @mouseleave="moreOpen = false"
205
+ @blur="moreOpen = false"
206
+ >
207
+ +{{ row.values.length - SCOPE_MAX }}
208
+ </RmTag>
209
+ </div>
210
+ <p v-else class="rm-project-ov__scope-none">{{ row.none ?? '' }}</p>
211
+ </div>
212
+ </li>
213
+ </ul>
214
+
215
+ <RmPopover v-model="moreOpen" :anchor="moreAnchor" :label="moreRow?.label" align="start">
216
+ <ul class="rm-project-ov__more">
217
+ <li v-for="value in moreRow?.values.slice(SCOPE_MAX) ?? []" :key="value">{{ value }}</li>
218
+ </ul>
219
+ </RmPopover>
220
+
221
+ <dl class="rm-project-ov__meta">
222
+ <div v-if="project?.createdAt">
223
+ <dt>{{ T.created }}</dt>
224
+ <dd>{{ formatDate(project.createdAt) }}</dd>
225
+ </div>
226
+ <div v-if="project?.ownerName">
227
+ <dt>{{ T.owner }}</dt>
228
+ <dd>{{ project.ownerName }}</dd>
229
+ </div>
230
+ <div>
231
+ <dt>{{ T.visibility }}</dt>
232
+ <dd>{{ visibility }}</dd>
233
+ </div>
234
+ <div v-if="lastAdded">
235
+ <dt>{{ T.last }}</dt>
236
+ <dd>{{ formatDate(lastAdded) }}</dd>
237
+ </div>
238
+ </dl>
239
+ </RmCard>
240
+
241
+ <div class="rm-project-ov__side">
242
+ <RmCard v-if="steps.length" padding="lg" class="rm-project-ov__card">
243
+ <div class="rm-project-ov__titles">
244
+ <h2 class="rm-project-ov__title">{{ T.start }}</h2>
245
+ <p class="rm-project-ov__context">{{ rows.length ? T.startText : T.startEmpty }}</p>
246
+ </div>
247
+ <ol class="rm-project-ov__start">
248
+ <li v-for="step in steps" :key="step.tab">
249
+ <button type="button" class="rm-project-ov__start-item" @click="emit('tab', step.tab)">
250
+ <span class="rm-project-ov__start-icon"><RmIcon :name="step.icon" :size="17" /></span>
251
+ <span class="rm-project-ov__start-text">
252
+ <span class="rm-project-ov__start-title">{{ step.title }}</span>
253
+ <span class="rm-project-ov__start-desc">{{ step.text }}</span>
254
+ </span>
255
+ <RmIcon name="ArrowRight" :size="14" class="rm-project-ov__start-arrow" />
256
+ </button>
257
+ </li>
258
+ </ol>
259
+ </RmCard>
260
+
261
+ <RmSectionCard class="rm-project-ov__card">
262
+ <template #header>{{ T.breakdown }}</template>
263
+ <RmDonutChart v-if="breakdown.length" :data="breakdown" :height="240" hide-legend />
264
+ <p v-else class="rm-project-ov__empty">{{ T.nothing }}</p>
265
+ </RmSectionCard>
266
+ </div>
267
+ </div>
268
+
269
+ <RmSectionCard class="rm-project-ov__card">
270
+ <template #header>
271
+ <div class="rm-project-ov__head">
272
+ <span>{{ T.latest }}</span>
273
+ <RmButton v-if="rows.length" variant="text" size="sm" suffix-icon="ArrowRight" @click="emit('tab', 'resources')">
274
+ {{ T.all }}
275
+ </RmButton>
276
+ </div>
277
+ </template>
278
+ <ul v-if="latest.length" class="rm-project-ov__list">
279
+ <li v-for="row in latest" :key="`${row.type}-${row.id}`" class="rm-project-ov__row">
280
+ <RmIcon :name="row.icon" :size="16" class="rm-project-ov__row-icon" />
281
+ <a v-if="row.path" :href="row.path" class="rm-project-ov__row-title" @click="onLatest($event, row)">{{ row.title || T.unknown }}</a>
282
+ <span v-else class="rm-project-ov__row-title">{{ row.title || T.unknown }}</span>
283
+ <span class="rm-project-ov__row-family">{{ row.family }}</span>
284
+ <span v-if="row.createdAt" class="rm-project-ov__row-date">{{ formatDate(row.createdAt) }}</span>
285
+ </li>
286
+ </ul>
287
+ <p v-else class="rm-project-ov__empty">{{ emptyText || T.empty }}</p>
288
+ </RmSectionCard>
289
+ </div>
290
+ </template>
291
+
292
+ <style scoped>
293
+ .rm-project-ov{display:flex;flex-direction:column;gap:16px}.rm-project-ov__top{align-items:stretch;display:grid;gap:16px;grid-template-columns:minmax(0,3fr) minmax(0,2fr)}@media (max-width:1100px){.rm-project-ov__top{grid-template-columns:1fr}}.rm-project-ov__card{min-width:0}.rm-project-ov__card.is-scope,.rm-project-ov__card.is-scope :deep(.r-card__body){display:flex;flex-direction:column}.rm-project-ov__card.is-scope{height:100%}.rm-project-ov__card.is-scope :deep(.r-card__body){flex:1}.rm-project-ov__card-head{align-items:flex-start;display:flex;gap:12px;justify-content:space-between}.rm-project-ov__titles{display:flex;flex-direction:column;gap:4px;margin-bottom:12px}.rm-project-ov__title{color:var(--text-primary);font-size:15px;font-weight:var(--weight-bold);margin:0}.rm-project-ov__context{color:var(--text-muted);font-size:12px;margin:0}.rm-project-ov__scope{display:flex;flex-direction:column;list-style:none;margin:0 0 16px;padding:0}.rm-project-ov__scope-row{align-items:flex-start;border-bottom:1px solid var(--border-soft,var(--border-default));display:flex;gap:12px;padding:8px 0}.rm-project-ov__scope-icon{color:var(--color-secondary);flex:none;margin-top:1px}.rm-project-ov__scope-body{flex:1;min-width:0}.rm-project-ov__scope-key{color:var(--text-muted);font-size:10.5px;font-weight:700;letter-spacing:.08em;margin:0 0 4px;text-transform:uppercase}.rm-project-ov__scope-value{color:var(--text-primary);font-size:13px;font-weight:600;margin:0;white-space:pre-line}.rm-project-ov__tags{display:flex;flex-wrap:wrap;gap:6px}.rm-project-ov__tag{display:block;max-width:100%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.rm-project-ov__scope-none{color:var(--text-muted);font-size:13px;font-style:italic;margin:0}.rm-project-ov__more{font-size:13px;margin:0;padding-left:16px}.rm-project-ov__placeholder{background:none;border:none;color:var(--text-muted);cursor:pointer;font:inherit;font-size:13px;font-style:italic;margin:0;padding:0}.rm-project-ov__placeholder:hover{text-decoration:underline}.rm-project-ov__edit-actions{display:flex;gap:8px;justify-content:flex-end;margin:8px 0 12px}.rm-project-ov__meta{border-top:1px solid var(--border-default);display:grid;gap:12px;grid-template-columns:repeat(auto-fit,minmax(120px,1fr));margin:auto 0 0;padding-top:16px}.rm-project-ov__meta dt{color:var(--text-secondary);font-size:12px}.rm-project-ov__meta dd{font-size:14px;font-weight:500;margin:2px 0 0}.rm-project-ov__side{display:flex;flex-direction:column;gap:16px;min-width:0}.rm-project-ov__side>:last-child{flex:1}.rm-project-ov__start{display:flex;flex-direction:column;gap:8px;list-style:none;margin:0;padding:0}.rm-project-ov__start-item{align-items:center;background:var(--bg-surface);border:1px solid var(--border-default);border-radius:var(--radius-xl,12px);color:inherit;cursor:pointer;display:flex;font:inherit;gap:12px;padding:10px 12px;text-align:left;transition:border-color .15s,background .15s,box-shadow .15s;width:100%}.rm-project-ov__start-item:hover{background:var(--bg-hover,var(--bg-surface-2));border-color:var(--color-secondary);box-shadow:var(--shadow-sm)}.rm-project-ov__start-item:focus-visible{outline:2px solid var(--color-primary);outline-offset:2px}.rm-project-ov__start-icon{align-items:center;background:var(--bg-hover,var(--bg-surface-2));border-radius:var(--radius-lg,8px);color:var(--color-secondary);display:flex;flex:none;height:34px;justify-content:center;width:34px}.rm-project-ov__start-text{display:flex;flex:1;flex-direction:column;gap:2px;min-width:0}.rm-project-ov__start-title{color:var(--text-primary);font-size:13px;font-weight:700}.rm-project-ov__start-desc{color:var(--text-muted);font-size:11px;line-height:1.45}.rm-project-ov__start-arrow{color:var(--text-muted);flex:none}.rm-project-ov__head{align-items:center;display:flex;justify-content:space-between;width:100%}.rm-project-ov__empty{color:var(--text-secondary);font-size:14px;margin:0}.rm-project-ov__list{display:flex;flex-direction:column;list-style:none;margin:0;padding:0}.rm-project-ov__row{align-items:center;border-bottom:1px solid var(--border-default);display:flex;font-size:14px;gap:10px;padding:8px 0}.rm-project-ov__row:last-child{border-bottom:none}.rm-project-ov__row-icon{color:var(--text-secondary);flex:none}.rm-project-ov__row-title{color:var(--text-primary);flex:1;min-width:0;overflow:hidden;text-decoration:none;text-overflow:ellipsis;white-space:nowrap}a.rm-project-ov__row-title{color:var(--color-primary)}a.rm-project-ov__row-title:hover{text-decoration:underline}.rm-project-ov__row-date,.rm-project-ov__row-family{color:var(--text-muted);flex:none;font-size:12px}
294
+ </style>