feat(project-management) : extract Projects/Tasks front into Nuxt module layer

Tranche 4 of LST-65. Companion to the backend module migration.

- Move pages (my-tasks, projects, projects/[id]/{index,groups,archives}),
  18 components (project + task), 10 services and 10 DTOs into
  frontend/modules/project-management/ (auto-detected layer).
- Rewrite explicit ~/services/* and ~/services/dto/* imports across 38
  consumers (admin tabs, mail modals, dashboard, mail page, layout) including
  the time-tracking module whose DTOs referenced project/task/task-tag.
- clients.ts and shared DTOs (client, user-data) stay at the root.
- Routes /my-tasks, /projects, /projects/:id(/groups|/archives) preserved;
  i18n stays global.

nuxt build passes; routes confirmed.
This commit is contained in:
Matthieu
2026-06-20 17:06:13 +02:00
parent c90d91d6c4
commit 7446b7dca9
60 changed files with 143 additions and 142 deletions
@@ -0,0 +1,159 @@
<template>
<div class="mt-5">
<p class="mb-2 text-sm font-medium text-neutral-700">{{ $t('bookstack.links.title') }}</p>
<!-- Search -->
<div class="relative">
<MalioInputText
v-model="searchQuery"
:placeholder="$t('bookstack.links.searchPlaceholder')"
input-class="w-full"
/>
<!-- Dropdown results -->
<div
v-if="searchResults.length > 0"
class="absolute z-30 mt-1 w-full rounded-md border border-neutral-200 bg-white shadow-lg"
>
<button
v-for="result in searchResults"
:key="`${result.type}-${result.id}`"
type="button"
class="flex w-full items-center gap-2 px-3 py-2 text-left text-sm hover:bg-neutral-50"
@click="handleAdd(result)"
>
<Icon
:name="result.type === 'page' ? 'mdi:file-document-outline' : 'mdi:book-outline'"
size="16"
class="shrink-0 text-neutral-400"
/>
<span class="truncate">{{ result.name }}</span>
<span class="ml-auto shrink-0 text-xs text-neutral-400">{{ result.type }}</span>
</button>
</div>
<p v-if="searchQuery.length >= 2 && !isSearching && searchResults.length === 0 && hasSearched" class="mt-1 text-xs text-neutral-400">
{{ $t('bookstack.links.noResults') }}
</p>
</div>
<!-- Linked documents -->
<div v-if="links.length > 0" class="mt-3 space-y-1">
<div
v-for="link in links"
:key="link.id"
class="flex items-center gap-2 rounded-md px-2 py-1.5 text-sm hover:bg-neutral-50"
>
<Icon
:name="link.bookstackType === 'page' ? 'mdi:file-document-outline' : 'mdi:book-outline'"
size="16"
class="shrink-0 text-neutral-400"
/>
<a
:href="link.url"
target="_blank"
rel="noopener noreferrer"
class="truncate text-primary-500 hover:underline"
>
{{ link.title }}
</a>
<MalioButtonIcon
icon="mdi:close"
aria-label="Supprimer le lien"
variant="ghost"
icon-size="16"
button-class="ml-auto shrink-0 text-neutral-300 hover:text-red-500"
@click="handleRemove(link.id)"
/>
</div>
</div>
<p v-else-if="!isLoading" class="mt-2 text-xs text-neutral-400">
{{ $t('bookstack.links.empty') }}
</p>
</div>
</template>
<script setup lang="ts">
import type { BookStackLink, BookStackSearchResult } from '~/services/dto/bookstack'
import { useBookStackService } from '~/services/bookstack'
const props = defineProps<{
taskId: number
}>()
const { getLinks, addLink, removeLink, search } = useBookStackService()
const links = ref<BookStackLink[]>([])
const searchQuery = ref('')
const searchResults = ref<BookStackSearchResult[]>([])
const isLoading = ref(true)
const isSearching = ref(false)
const hasSearched = ref(false)
let debounceTimer: ReturnType<typeof setTimeout> | null = null
watch(searchQuery, (query) => {
if (debounceTimer) clearTimeout(debounceTimer)
hasSearched.value = false
searchResults.value = []
if (query.trim().length < 2) {
return
}
debounceTimer = setTimeout(async () => {
isSearching.value = true
try {
searchResults.value = await search(props.taskId, query.trim())
} catch {
searchResults.value = []
} finally {
isSearching.value = false
hasSearched.value = true
}
}, 300)
})
async function handleAdd(result: BookStackSearchResult) {
searchQuery.value = ''
searchResults.value = []
hasSearched.value = false
// Check if already linked
if (links.value.some(l => l.bookstackId === result.id && l.bookstackType === result.type)) {
return
}
try {
const created = await addLink(props.taskId, {
bookstackId: result.id,
bookstackType: result.type,
title: result.name,
url: result.url,
})
links.value.unshift(created)
} catch {
// Error handled by useApi toast
}
}
async function handleRemove(linkId: number) {
try {
await removeLink(props.taskId, linkId)
links.value = links.value.filter(l => l.id !== linkId)
} catch {
// Error handled by useApi toast
}
}
onMounted(async () => {
try {
links.value = await getLinks(props.taskId)
} catch {
// Error handled by useApi toast
} finally {
isLoading.value = false
}
})
</script>