@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.
- package/dist/module.json +1 -1
- package/dist/runtime/components/CopyCode.vue +79 -0
- package/dist/runtime/components/ProjectAddButton.vue +226 -0
- package/dist/runtime/components/ProjectBanner.vue +134 -0
- package/dist/runtime/components/ProjectComments.vue +110 -0
- package/dist/runtime/components/ProjectCreateDialog.vue +117 -0
- package/dist/runtime/components/ProjectDeleteDialog.vue +60 -0
- package/dist/runtime/components/ProjectList.vue +115 -0
- package/dist/runtime/components/ProjectOverview.vue +294 -0
- package/dist/runtime/components/ProjectPanel.vue +193 -0
- package/dist/runtime/components/ProjectRenameDialog.vue +67 -0
- package/dist/runtime/components/ProjectResources.vue +258 -0
- package/dist/runtime/components/ResultCard.vue +4 -1
- package/dist/runtime/components/SourceCard.vue +2 -30
- package/dist/runtime/utils/clipboard.d.ts +8 -0
- package/dist/runtime/utils/clipboard.js +21 -0
- package/dist/runtime/utils/projects.d.ts +200 -0
- package/dist/runtime/utils/projects.js +123 -0
- package/package.json +1 -1
|
@@ -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>
|