feat(mail) : extract Mail front into Nuxt module layer
LST-67 (2.5) front. Completes the Mail module. - New frontend/modules/mail/ layer (auto-detected): /mail page (3 columns), 7 components, mail service + DTO, mail store (folders/messages/unread polling). - sanitizeMailHtml util and useSystemFolderLabel composable stay global; AdminMailTab stays in /admin (service import repointed). - Consumers repointed: AdminMailTab and PM TaskModal -> ~/modules/mail/...; the store is auto-imported (Pinia storesDirs) so the layout badge/polling is unchanged. - /mail gated by the mail module: sidebar.php item with module=mail (so SidebarFilter disables /mail when the module is off); the layout filters /mail from the API sections to avoid a visual duplicate. ROLE_CLIENT exclusion kept. - i18n key sidebar.general.mail added. nuxt build passes; /mail and all other routes preserved.
This commit is contained in:
@@ -140,7 +140,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useMailService } from '~/services/mail'
|
||||
import { useMailService } from '~/modules/mail/services/mail'
|
||||
|
||||
const { getConfiguration, updateConfiguration, testConfiguration } = useMailService()
|
||||
|
||||
|
||||
@@ -1,121 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
const props = defineProps<{
|
||||
/** Ouverture de la visionneuse. */
|
||||
modelValue: boolean
|
||||
/** Nom du fichier affiché dans la barre. */
|
||||
filename: string
|
||||
/** Type MIME — détermine le rendu (image vs PDF). */
|
||||
mimeType: string
|
||||
/** Object URL du Blob de la pièce jointe. null tant que le contenu charge. */
|
||||
url: string | null
|
||||
/** Téléchargement en cours du contenu. */
|
||||
loading: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: boolean]
|
||||
download: []
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const isImage = computed(() => props.mimeType.startsWith('image/'))
|
||||
const isPdf = computed(() => props.mimeType === 'application/pdf')
|
||||
|
||||
function close(): void {
|
||||
emit('update:modelValue', false)
|
||||
}
|
||||
|
||||
function onKeydown(e: KeyboardEvent): void {
|
||||
if (e.key === 'Escape') close()
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(open) => {
|
||||
if (open) {
|
||||
window.addEventListener('keydown', onKeydown)
|
||||
} else {
|
||||
window.removeEventListener('keydown', onKeydown)
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
onBeforeUnmount(() => window.removeEventListener('keydown', onKeydown))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Teleport v-if="modelValue" to="body">
|
||||
<Transition name="mail-preview" appear>
|
||||
<div class="fixed inset-0 z-50 flex flex-col bg-slate-900/80 backdrop-blur-sm">
|
||||
<!-- Barre supérieure -->
|
||||
<div class="flex flex-shrink-0 items-center justify-between gap-4 px-4 py-3 text-white">
|
||||
<div class="flex min-w-0 items-center gap-2">
|
||||
<Icon
|
||||
:name="isImage ? 'material-symbols:image-outline' : 'material-symbols:picture-as-pdf-outline'"
|
||||
size="18"
|
||||
class="flex-shrink-0 text-white/70"
|
||||
/>
|
||||
<span class="truncate text-sm font-medium">{{ filename }}</span>
|
||||
</div>
|
||||
<div class="flex flex-shrink-0 items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
class="flex items-center gap-1.5 rounded-md px-3 py-1.5 text-sm text-white/90 transition-colors hover:bg-white/10"
|
||||
@click="emit('download')"
|
||||
>
|
||||
<Icon name="material-symbols:download" size="18" />
|
||||
<span class="hidden sm:inline">{{ t('mail.actions.download') }}</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-md p-1.5 text-white/90 transition-colors hover:bg-white/10"
|
||||
:aria-label="t('mail.preview.close')"
|
||||
@click="close"
|
||||
>
|
||||
<Icon name="mdi:close" size="20" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Contenu -->
|
||||
<div class="flex min-h-0 flex-1 items-center justify-center overflow-auto p-4" @click.self="close">
|
||||
<div v-if="loading" class="flex flex-col items-center gap-3 text-white/70">
|
||||
<Icon name="material-symbols:progress-activity" size="32" class="animate-spin" />
|
||||
<span class="text-sm">{{ t('mail.preview.loading') }}</span>
|
||||
</div>
|
||||
|
||||
<img
|
||||
v-else-if="isImage && url"
|
||||
:src="url"
|
||||
:alt="filename"
|
||||
class="max-h-full max-w-full rounded-lg object-contain shadow-2xl"
|
||||
>
|
||||
|
||||
<iframe
|
||||
v-else-if="isPdf && url"
|
||||
:src="url"
|
||||
:title="filename"
|
||||
class="h-full w-full max-w-5xl rounded-lg bg-white shadow-2xl"
|
||||
/>
|
||||
|
||||
<div v-else class="flex flex-col items-center gap-3 text-white/70">
|
||||
<Icon name="material-symbols:visibility-off-outline" size="32" />
|
||||
<span class="text-sm">{{ t('mail.preview.unavailable') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.mail-preview-enter-active,
|
||||
.mail-preview-leave-active {
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
.mail-preview-enter-from,
|
||||
.mail-preview-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -1,144 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import type { MailMessageDetailDto } from '~/services/dto/mail'
|
||||
import type { Task } from '~/modules/project-management/services/dto/task'
|
||||
import type { Project } from '~/modules/project-management/services/dto/project'
|
||||
import type { TaskGroup } from '~/modules/project-management/services/dto/task-group'
|
||||
import type { UserData } from '~/services/dto/user-data'
|
||||
import { useMailService } from '~/services/mail'
|
||||
import { useProjectService } from '~/modules/project-management/services/projects'
|
||||
import { useTaskGroupService } from '~/modules/project-management/services/task-groups'
|
||||
import { useUserService } from '~/services/users'
|
||||
import { useAuthStore } from '~/shared/stores/auth'
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: boolean
|
||||
messageId: number
|
||||
messageDetail: MailMessageDetailDto | null
|
||||
}>()
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: boolean]
|
||||
created: [task: Task]
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const auth = useAuthStore()
|
||||
const mailService = useMailService()
|
||||
const projectService = useProjectService()
|
||||
const taskGroupService = useTaskGroupService()
|
||||
const userService = useUserService()
|
||||
|
||||
const projectId = ref<number | null>(null)
|
||||
const taskGroupId = ref<number | null>(null)
|
||||
const assigneeId = ref<number | null>(null)
|
||||
const statusId = ref<number | null>(null)
|
||||
const isSubmitting = ref(false)
|
||||
const touchedProject = ref(false)
|
||||
|
||||
const projects = ref<Project[]>([])
|
||||
const groups = ref<TaskGroup[]>([])
|
||||
const users = ref<UserData[]>([])
|
||||
const loadingGroups = ref(false)
|
||||
|
||||
const projectOptions = computed(() => projects.value.map(p => ({ label: p.name, value: p.id })))
|
||||
const groupOptions = computed(() => groups.value.filter(g => !g.archived).map(g => ({ label: g.title, value: g.id })))
|
||||
const userOptions = computed(() => users.value.map(u => ({ label: u.username, value: u.id })))
|
||||
|
||||
const selectedProject = computed(() => projects.value.find(p => p.id === projectId.value) ?? null)
|
||||
const statusOptions = computed(() =>
|
||||
(selectedProject.value?.workflow?.statuses ?? []).map(s => ({ label: s.label, value: s.id })),
|
||||
)
|
||||
|
||||
onMounted(async () => {
|
||||
const [projs, us] = await Promise.all([
|
||||
projectService.getAll({ archived: false }),
|
||||
userService.getAll(),
|
||||
])
|
||||
projects.value = projs
|
||||
users.value = us
|
||||
})
|
||||
|
||||
watch(projectId, async (pid) => {
|
||||
taskGroupId.value = null
|
||||
statusId.value = selectedProject.value?.workflow?.statuses?.[0]?.id ?? null
|
||||
groups.value = []
|
||||
if (!pid) return
|
||||
loadingGroups.value = true
|
||||
try {
|
||||
groups.value = await taskGroupService.getByProject(pid)
|
||||
} finally {
|
||||
loadingGroups.value = false
|
||||
}
|
||||
})
|
||||
|
||||
watch(() => props.modelValue, (open) => {
|
||||
if (open) {
|
||||
projectId.value = null
|
||||
taskGroupId.value = null
|
||||
statusId.value = null
|
||||
assigneeId.value = auth.user?.id ?? null
|
||||
touchedProject.value = false
|
||||
}
|
||||
})
|
||||
|
||||
function close(): void {
|
||||
emit('update:modelValue', false)
|
||||
}
|
||||
|
||||
async function handleSubmit(): Promise<void> {
|
||||
touchedProject.value = true
|
||||
if (!projectId.value) return
|
||||
isSubmitting.value = true
|
||||
try {
|
||||
const task = await mailService.createTaskFromMail(props.messageId, {
|
||||
projectId: projectId.value,
|
||||
taskGroupId: taskGroupId.value ?? undefined,
|
||||
assigneeId: assigneeId.value ?? undefined,
|
||||
statusId: statusId.value ?? undefined,
|
||||
})
|
||||
emit('created', task)
|
||||
close()
|
||||
} finally {
|
||||
isSubmitting.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppModal
|
||||
:model-value="modelValue"
|
||||
width="lg"
|
||||
:title="t('mail.createTaskModal.title')"
|
||||
@update:model-value="emit('update:modelValue', $event)"
|
||||
>
|
||||
<div class="space-y-5">
|
||||
<div v-if="messageDetail" class="rounded-lg border border-neutral-200 bg-neutral-50 px-4 py-3 text-sm">
|
||||
<p class="truncate font-medium text-neutral-800">{{ messageDetail.header.subject ?? t('mail.noSubject') }}</p>
|
||||
<p class="mt-0.5 truncate text-xs text-neutral-500">{{ messageDetail.header.fromName ?? messageDetail.header.fromEmail }}</p>
|
||||
<p class="mt-2 text-xs italic text-neutral-400">{{ t('mail.createTaskModal.titleHint') }}</p>
|
||||
<p class="text-xs italic text-neutral-400">{{ t('mail.createTaskModal.descriptionHint') }}</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<MalioSelect v-model="projectId" :options="projectOptions" :label="t('mail.createTaskModal.projectLabel')" :empty-option-label="t('mail.createTaskModal.projectPlaceholder')" group-class="w-full" />
|
||||
<p v-if="touchedProject && !projectId" class="mt-1 text-xs text-red-500">{{ t('mail.createTaskModal.projectLabel').replace(' *', '') }} requis</p>
|
||||
</div>
|
||||
|
||||
<div v-if="projectId">
|
||||
<MalioSelect v-model="taskGroupId" :options="groupOptions" :label="t('mail.createTaskModal.groupLabel')" :empty-option-label="t('mail.createTaskModal.groupPlaceholder')" group-class="w-full" :disabled="loadingGroups" />
|
||||
</div>
|
||||
|
||||
<div v-if="projectId">
|
||||
<MalioSelect v-model="statusId" :options="statusOptions" :label="t('mail.createTaskModal.statusLabel')" group-class="w-full" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<MalioSelect v-model="assigneeId" :options="userOptions" :label="t('mail.createTaskModal.assigneeLabel')" :empty-option-label="t('mail.createTaskModal.assigneePlaceholder')" group-class="w-full" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<MalioButton variant="tertiary" label="Annuler" button-class="w-auto px-4" @click="close" />
|
||||
<MalioButton :label="t('mail.createTaskModal.submit')" button-class="w-auto px-6" :disabled="isSubmitting" @click="handleSubmit" />
|
||||
</template>
|
||||
</AppModal>
|
||||
</template>
|
||||
@@ -1,123 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import type { MailFolderDto } from '~/services/dto/mail'
|
||||
|
||||
const props = defineProps<{
|
||||
/** Arbre de dossiers (getter folderTree du store) */
|
||||
folders: readonly MailFolderDto[]
|
||||
/** Chemin du dossier actuellement sélectionné */
|
||||
selectedPath: string | null
|
||||
/** Niveau de profondeur pour l'indentation (usage récursif interne) */
|
||||
depth?: number
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
select: [path: string]
|
||||
}>()
|
||||
|
||||
const { getFolderLabel, getFolderIcon } = useSystemFolderLabel()
|
||||
const { t } = useI18n()
|
||||
|
||||
const currentDepth = computed(() => props.depth ?? 0)
|
||||
|
||||
// Dossiers dépliés (repliés par défaut → seuls les dossiers racine sont visibles).
|
||||
const expanded = ref<Set<string>>(new Set())
|
||||
|
||||
function isExpanded(path: string): boolean {
|
||||
return expanded.value.has(path)
|
||||
}
|
||||
|
||||
function toggleExpanded(path: string): void {
|
||||
const next = new Set(expanded.value)
|
||||
if (next.has(path)) {
|
||||
next.delete(path)
|
||||
} else {
|
||||
next.add(path)
|
||||
}
|
||||
expanded.value = next
|
||||
}
|
||||
|
||||
function hasChildren(folder: MailFolderDto): boolean {
|
||||
return !!folder.children && folder.children.length > 0
|
||||
}
|
||||
|
||||
function handleSelect(path: string): void {
|
||||
emit('select', path)
|
||||
}
|
||||
|
||||
function paddingStyle(): Record<string, string> {
|
||||
const depth = currentDepth.value
|
||||
return { paddingLeft: `${0.5 + depth * 0.75}rem` }
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<div
|
||||
v-if="folders.length === 0 && currentDepth === 0"
|
||||
class="px-3 py-4 text-sm text-neutral-400 italic"
|
||||
>
|
||||
{{ t('mail.empty.folder') }}
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<div v-for="folder in folders" :key="folder.path">
|
||||
<div
|
||||
class="flex items-center gap-1 rounded-md pr-2 py-1.5 text-sm transition-colors"
|
||||
:class="
|
||||
selectedPath === folder.path
|
||||
? 'bg-primary-100 text-primary-700 font-medium'
|
||||
: 'text-neutral-700 hover:bg-neutral-100'
|
||||
"
|
||||
:style="paddingStyle()"
|
||||
>
|
||||
<button
|
||||
v-if="hasChildren(folder)"
|
||||
type="button"
|
||||
class="flex-shrink-0 rounded p-0.5 hover:bg-neutral-200"
|
||||
:aria-label="isExpanded(folder.path) ? t('mail.folderTree.collapse') : t('mail.folderTree.expand')"
|
||||
@click.stop="toggleExpanded(folder.path)"
|
||||
>
|
||||
<Icon
|
||||
:name="isExpanded(folder.path) ? 'material-symbols:keyboard-arrow-down' : 'material-symbols:chevron-right'"
|
||||
size="16"
|
||||
class="text-neutral-400"
|
||||
/>
|
||||
</button>
|
||||
<span v-else class="inline-block w-[22px] flex-shrink-0" />
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="flex flex-1 items-center gap-2 text-left min-w-0"
|
||||
@click="handleSelect(folder.path)"
|
||||
>
|
||||
<Icon
|
||||
:name="getFolderIcon(folder.path)"
|
||||
size="16"
|
||||
class="flex-shrink-0"
|
||||
:class="selectedPath === folder.path ? 'text-primary-600' : 'text-neutral-400'"
|
||||
/>
|
||||
|
||||
<span class="flex-1 truncate">
|
||||
{{ getFolderLabel(folder.path, folder.displayName) }}
|
||||
</span>
|
||||
|
||||
<span
|
||||
v-if="folder.unreadCount > 0"
|
||||
class="ml-auto flex-shrink-0 rounded-full bg-primary-500 px-1.5 py-0.5 text-xs font-bold text-white"
|
||||
>
|
||||
{{ folder.unreadCount > 99 ? '99+' : folder.unreadCount }}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<MailFolderTree
|
||||
v-if="hasChildren(folder) && isExpanded(folder.path)"
|
||||
:folders="folder.children"
|
||||
:selected-path="selectedPath"
|
||||
:depth="currentDepth + 1"
|
||||
@select="handleSelect"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,266 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import type { Task } from '~/modules/project-management/services/dto/task'
|
||||
import type { Project } from '~/modules/project-management/services/dto/project'
|
||||
import { useMailService } from '~/services/mail'
|
||||
import { useTaskService } from '~/modules/project-management/services/tasks'
|
||||
import { useProjectService } from '~/modules/project-management/services/projects'
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: boolean
|
||||
/** ID BDD du message à lier */
|
||||
messageId: number
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: boolean]
|
||||
/** Émis après liaison réussie — payload = id de la tâche liée */
|
||||
linked: [taskId: number]
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const mailService = useMailService()
|
||||
const taskService = useTaskService()
|
||||
const projectService = useProjectService()
|
||||
|
||||
// ─── État recherche ───────────────────────────────────────────────────────
|
||||
|
||||
const searchQuery = ref('')
|
||||
const filterProjectId = ref<number | null>(null)
|
||||
const results = ref<Task[]>([])
|
||||
const selectedTask = ref<Task | null>(null)
|
||||
const isLoading = ref(false)
|
||||
const isSubmitting = ref(false)
|
||||
|
||||
// ─── Projets pour le filtre ───────────────────────────────────────────────
|
||||
|
||||
const projects = ref<Project[]>([])
|
||||
|
||||
const projectFilterOptions = computed(() =>
|
||||
projects.value.map(p => ({ label: p.name, value: p.id })),
|
||||
)
|
||||
|
||||
onMounted(async () => {
|
||||
projects.value = await projectService.getAll({ archived: false })
|
||||
})
|
||||
|
||||
// ─── Debounce recherche ───────────────────────────────────────────────────
|
||||
|
||||
let debounceTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
watch([searchQuery, filterProjectId], () => {
|
||||
selectedTask.value = null
|
||||
if (debounceTimer) clearTimeout(debounceTimer)
|
||||
debounceTimer = setTimeout(() => {
|
||||
void runSearch()
|
||||
}, 300)
|
||||
})
|
||||
|
||||
async function runSearch(): Promise<void> {
|
||||
const q = searchQuery.value.trim()
|
||||
if (!q && !filterProjectId.value) {
|
||||
results.value = []
|
||||
return
|
||||
}
|
||||
isLoading.value = true
|
||||
try {
|
||||
const params: Record<string, string | number | boolean | string[]> = {
|
||||
archived: false,
|
||||
}
|
||||
if (q) params['title'] = q
|
||||
if (filterProjectId.value) params['project'] = `/api/projects/${filterProjectId.value}`
|
||||
results.value = await taskService.getFiltered(params)
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Reset à l'ouverture ──────────────────────────────────────────────────
|
||||
|
||||
watch(() => props.modelValue, (open) => {
|
||||
if (open) {
|
||||
searchQuery.value = ''
|
||||
filterProjectId.value = null
|
||||
results.value = []
|
||||
selectedTask.value = null
|
||||
}
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (debounceTimer) clearTimeout(debounceTimer)
|
||||
})
|
||||
|
||||
// ─── Actions ──────────────────────────────────────────────────────────────
|
||||
|
||||
function close(): void {
|
||||
emit('update:modelValue', false)
|
||||
}
|
||||
|
||||
function selectTask(task: Task): void {
|
||||
selectedTask.value = task
|
||||
}
|
||||
|
||||
async function handleSubmit(): Promise<void> {
|
||||
if (!selectedTask.value) return
|
||||
isSubmitting.value = true
|
||||
try {
|
||||
await mailService.linkTask(props.messageId, selectedTask.value.id)
|
||||
emit('linked', selectedTask.value.id)
|
||||
close()
|
||||
} finally {
|
||||
isSubmitting.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Teleport v-if="modelValue" to="body">
|
||||
<Transition name="mail-modal" appear>
|
||||
<div class="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||
<div
|
||||
class="absolute inset-0 bg-slate-900/40 backdrop-blur-sm"
|
||||
@click="close"
|
||||
/>
|
||||
|
||||
<div
|
||||
class="relative z-10 w-full max-w-lg rounded-2xl bg-white shadow-2xl ring-1 ring-black/5 overflow-hidden"
|
||||
style="max-height: min(90vh, 640px)"
|
||||
>
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between border-b border-neutral-100 bg-neutral-50/80 px-6 py-4">
|
||||
<h2 class="text-base font-bold text-neutral-900">
|
||||
{{ t('mail.linkTaskModal.title') }}
|
||||
</h2>
|
||||
<MalioButtonIcon
|
||||
icon="mdi:close"
|
||||
aria-label="Fermer"
|
||||
variant="ghost"
|
||||
icon-size="20"
|
||||
@click="close"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Corps -->
|
||||
<div class="overflow-y-auto px-6 py-5 space-y-4">
|
||||
<!-- Filtre projet -->
|
||||
<MalioSelect
|
||||
v-model="filterProjectId"
|
||||
:options="projectFilterOptions"
|
||||
:label="t('mail.linkTaskModal.projectFilter')"
|
||||
:empty-option-label="t('mail.linkTaskModal.projectAll')"
|
||||
group-class="w-full"
|
||||
/>
|
||||
|
||||
<!-- Recherche tâche -->
|
||||
<div>
|
||||
<label class="mb-1 block text-sm font-medium text-neutral-700">
|
||||
{{ t('mail.linkTaskModal.title') }}
|
||||
</label>
|
||||
<input
|
||||
v-model="searchQuery"
|
||||
type="text"
|
||||
:placeholder="t('mail.linkTaskModal.searchPlaceholder')"
|
||||
class="w-full rounded-md border border-neutral-300 px-3 py-2 text-sm focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Résultats -->
|
||||
<div class="max-h-64 overflow-y-auto rounded-md border border-neutral-200">
|
||||
<!-- Chargement -->
|
||||
<div
|
||||
v-if="isLoading"
|
||||
class="flex items-center justify-center py-6 text-sm text-neutral-400"
|
||||
>
|
||||
<Icon name="material-symbols:progress-activity" size="18" class="mr-2 animate-spin" />
|
||||
{{ t('mail.linkTaskModal.loading') }}
|
||||
</div>
|
||||
|
||||
<!-- Vide -->
|
||||
<div
|
||||
v-else-if="!isLoading && results.length === 0 && (searchQuery.trim() || filterProjectId)"
|
||||
class="py-6 text-center text-sm text-neutral-400 italic"
|
||||
>
|
||||
{{ t('mail.linkTaskModal.empty') }}
|
||||
</div>
|
||||
|
||||
<!-- Liste résultats -->
|
||||
<button
|
||||
v-for="task in results"
|
||||
:key="task.id"
|
||||
type="button"
|
||||
class="flex w-full items-start gap-3 px-4 py-3 text-left text-sm transition-colors hover:bg-neutral-50"
|
||||
:class="selectedTask?.id === task.id
|
||||
? 'bg-primary-50 border-l-2 border-primary-500'
|
||||
: 'border-l-2 border-transparent'"
|
||||
@click="selectTask(task)"
|
||||
>
|
||||
<Icon
|
||||
name="material-symbols:task-outline"
|
||||
size="16"
|
||||
class="mt-0.5 flex-shrink-0 text-neutral-400"
|
||||
/>
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="truncate font-medium text-neutral-800">
|
||||
{{ task.title }}
|
||||
</p>
|
||||
<p
|
||||
v-if="task.project"
|
||||
class="truncate text-xs text-neutral-500"
|
||||
>
|
||||
{{ task.project.name }}
|
||||
<span v-if="task.project.code && task.number">
|
||||
— {{ task.project.code }}-{{ task.number }}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
<Icon
|
||||
v-if="selectedTask?.id === task.id"
|
||||
name="material-symbols:check-circle"
|
||||
size="16"
|
||||
class="flex-shrink-0 text-primary-500"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<div class="flex justify-end gap-3 border-t border-neutral-100 px-6 py-4">
|
||||
<MalioButton
|
||||
variant="tertiary"
|
||||
label="Annuler"
|
||||
button-class="w-auto px-4"
|
||||
@click="close"
|
||||
/>
|
||||
<MalioButton
|
||||
:label="t('mail.linkTaskModal.submit')"
|
||||
button-class="w-auto px-6"
|
||||
:disabled="!selectedTask || isSubmitting"
|
||||
@click="handleSubmit"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.mail-modal-enter-active,
|
||||
.mail-modal-leave-active {
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.mail-modal-enter-active > div:last-child,
|
||||
.mail-modal-leave-active > div:last-child {
|
||||
transition: transform 0.2s cubic-bezier(0.16, 1, 0.3, 1), opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.mail-modal-enter-from,
|
||||
.mail-modal-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.mail-modal-enter-from > div:last-child {
|
||||
transform: scale(0.95) translateY(8px);
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -1,151 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import type { MailMessageHeaderDto } from '~/services/dto/mail'
|
||||
|
||||
const props = defineProps<{
|
||||
messages: readonly MailMessageHeaderDto[]
|
||||
selectedId: number | null
|
||||
loading: boolean
|
||||
hasMore: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
select: [id: number]
|
||||
loadMore: []
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const sentinelRef = ref<HTMLDivElement | null>(null)
|
||||
let observer: IntersectionObserver | null = null
|
||||
|
||||
onMounted(() => {
|
||||
if (!sentinelRef.value) return
|
||||
observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
const entry = entries[0]
|
||||
if (entry?.isIntersecting && props.hasMore && !props.loading) {
|
||||
emit('loadMore')
|
||||
}
|
||||
},
|
||||
{ threshold: 0.1 },
|
||||
)
|
||||
observer.observe(sentinelRef.value)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
observer?.disconnect()
|
||||
observer = null
|
||||
})
|
||||
|
||||
/**
|
||||
* Formate une date ISO en date relative (il y a X minutes/heures/jours).
|
||||
* Utilise Intl.RelativeTimeFormat avec la locale fr.
|
||||
*/
|
||||
function formatRelative(isoDate: string | null): string {
|
||||
if (!isoDate) return ''
|
||||
const date = new Date(isoDate)
|
||||
const now = new Date()
|
||||
const diffMs = date.getTime() - now.getTime()
|
||||
const diffSeconds = Math.round(diffMs / 1000)
|
||||
const diffMinutes = Math.round(diffSeconds / 60)
|
||||
const diffHours = Math.round(diffMinutes / 60)
|
||||
const diffDays = Math.round(diffHours / 24)
|
||||
|
||||
const rtf = new Intl.RelativeTimeFormat('fr', { numeric: 'auto' })
|
||||
|
||||
if (Math.abs(diffMinutes) < 1) return rtf.format(diffSeconds, 'second')
|
||||
if (Math.abs(diffHours) < 1) return rtf.format(diffMinutes, 'minute')
|
||||
if (Math.abs(diffDays) < 1) return rtf.format(diffHours, 'hour')
|
||||
if (Math.abs(diffDays) < 30) return rtf.format(diffDays, 'day')
|
||||
|
||||
return date.toLocaleDateString('fr', { day: '2-digit', month: 'short', year: 'numeric' })
|
||||
}
|
||||
|
||||
function getSenderLabel(msg: MailMessageHeaderDto): string {
|
||||
return msg.fromName ?? msg.fromEmail ?? ''
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex h-full flex-col overflow-hidden">
|
||||
<div
|
||||
v-if="!loading && messages.length === 0"
|
||||
class="flex flex-1 items-center justify-center text-sm text-neutral-400 italic px-4 text-center"
|
||||
>
|
||||
{{ t('mail.empty.list') }}
|
||||
</div>
|
||||
|
||||
<div v-else class="flex-1 overflow-y-auto divide-y divide-neutral-100">
|
||||
<button
|
||||
v-for="msg in messages"
|
||||
:key="msg.id"
|
||||
type="button"
|
||||
class="flex w-full gap-3 px-3 py-3 text-left transition-colors hover:bg-neutral-50 focus:outline-none"
|
||||
:class="[
|
||||
selectedId === msg.id ? 'bg-primary-50 border-l-2 border-primary-500' : '',
|
||||
!msg.isRead ? 'bg-white' : 'bg-neutral-50/50',
|
||||
]"
|
||||
@click="emit('select', msg.id)"
|
||||
>
|
||||
<div class="mt-1.5 flex-shrink-0">
|
||||
<span
|
||||
class="block h-2 w-2 rounded-full"
|
||||
:class="msg.isRead ? 'bg-transparent' : 'bg-primary-500'"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<span
|
||||
class="truncate text-sm"
|
||||
:class="msg.isRead ? 'text-neutral-600 font-normal' : 'text-neutral-900 font-semibold'"
|
||||
>
|
||||
{{ getSenderLabel(msg) }}
|
||||
</span>
|
||||
<span class="flex-shrink-0 text-xs text-neutral-400">
|
||||
{{ formatRelative(msg.sentAt ?? msg.receivedAt) }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<p
|
||||
class="truncate text-sm"
|
||||
:class="msg.isRead ? 'text-neutral-500' : 'text-neutral-800 font-medium'"
|
||||
>
|
||||
{{ msg.subject ?? t('mail.noSubject') }}
|
||||
</p>
|
||||
|
||||
<div class="mt-0.5 flex items-center gap-1.5">
|
||||
<Icon
|
||||
v-if="msg.isFlagged"
|
||||
name="material-symbols:star"
|
||||
size="14"
|
||||
class="text-amber-400 flex-shrink-0"
|
||||
/>
|
||||
<Icon
|
||||
v-if="msg.hasAttachments"
|
||||
name="material-symbols:attach-file"
|
||||
size="14"
|
||||
class="text-neutral-400 flex-shrink-0"
|
||||
/>
|
||||
<Icon
|
||||
v-if="msg.linkedTaskIds.length > 0"
|
||||
name="material-symbols:task-outline"
|
||||
size="14"
|
||||
class="text-primary-400 flex-shrink-0"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<div ref="sentinelRef" class="h-px" />
|
||||
|
||||
<div v-if="loading && messages.length > 0" class="flex items-center justify-center py-4">
|
||||
<Icon name="material-symbols:progress-activity" size="20" class="animate-spin text-neutral-400" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="loading && messages.length === 0" class="flex flex-1 items-center justify-center">
|
||||
<Icon name="material-symbols:progress-activity" size="24" class="animate-spin text-neutral-400" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,278 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import type { MailMessageDetailDto, MailAddressDto, MailAttachmentDto } from '~/services/dto/mail'
|
||||
import { sanitizeMailHtml } from '~/utils/sanitizeMailHtml'
|
||||
import { useMailService } from '~/services/mail'
|
||||
|
||||
const props = defineProps<{
|
||||
/** Détail complet du message. null = aucun message sélectionné. */
|
||||
detail: MailMessageDetailDto | null
|
||||
loading: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
createTask: [mailId: number]
|
||||
linkTask: [mailId: number]
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const mailService = useMailService()
|
||||
|
||||
const showImages = ref(false)
|
||||
|
||||
const sanitizedBody = computed((): string => {
|
||||
if (!props.detail?.bodyHtml) return ''
|
||||
return sanitizeMailHtml(props.detail.bodyHtml, { allowImages: showImages.value })
|
||||
})
|
||||
|
||||
// ─── Pièces jointes : aperçu / téléchargement ──────────────────────────────
|
||||
|
||||
function isImage(mime: string): boolean {
|
||||
return mime.startsWith('image/')
|
||||
}
|
||||
|
||||
function isPdf(mime: string): boolean {
|
||||
return mime === 'application/pdf'
|
||||
}
|
||||
|
||||
function isPreviewable(mime: string): boolean {
|
||||
return isImage(mime) || isPdf(mime)
|
||||
}
|
||||
|
||||
function attachmentIcon(mime: string): string {
|
||||
if (isImage(mime)) return 'material-symbols:image-outline'
|
||||
if (isPdf(mime)) return 'material-symbols:picture-as-pdf-outline'
|
||||
return 'material-symbols:attach-file'
|
||||
}
|
||||
|
||||
const previewOpen = ref(false)
|
||||
const previewLoading = ref(false)
|
||||
const previewAtt = ref<MailAttachmentDto | null>(null)
|
||||
const previewUrl = ref<string | null>(null)
|
||||
let previewBlob: Blob | null = null
|
||||
|
||||
function revokePreview(): void {
|
||||
if (previewUrl.value) {
|
||||
URL.revokeObjectURL(previewUrl.value)
|
||||
previewUrl.value = null
|
||||
}
|
||||
previewBlob = null
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.detail?.header.id,
|
||||
() => {
|
||||
showImages.value = false
|
||||
previewOpen.value = false
|
||||
revokePreview()
|
||||
},
|
||||
)
|
||||
|
||||
watch(previewOpen, (open) => {
|
||||
if (!open) revokePreview()
|
||||
})
|
||||
|
||||
onBeforeUnmount(revokePreview)
|
||||
|
||||
async function handleAttachmentClick(att: MailAttachmentDto): Promise<void> {
|
||||
if (!isPreviewable(att.mimeType)) {
|
||||
await handleDownload(att.downloadId, att.filename)
|
||||
return
|
||||
}
|
||||
|
||||
previewAtt.value = att
|
||||
previewUrl.value = null
|
||||
previewLoading.value = true
|
||||
previewOpen.value = true
|
||||
|
||||
try {
|
||||
const { data } = await mailService.downloadAttachment(att.downloadId)
|
||||
previewBlob = data
|
||||
previewUrl.value = URL.createObjectURL(data)
|
||||
} catch {
|
||||
// useApi affiche déjà le toast — on referme la visionneuse.
|
||||
previewOpen.value = false
|
||||
} finally {
|
||||
previewLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function downloadFromPreview(): void {
|
||||
const att = previewAtt.value
|
||||
if (!att) return
|
||||
if (previewBlob) {
|
||||
triggerBlobDownload(previewBlob, att.filename)
|
||||
} else {
|
||||
void handleDownload(att.downloadId, att.filename)
|
||||
}
|
||||
}
|
||||
|
||||
function triggerBlobDownload(blob: Blob, filename: string): void {
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = filename
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
|
||||
async function handleDownload(downloadId: string, filename: string): Promise<void> {
|
||||
try {
|
||||
const { data } = await mailService.downloadAttachment(downloadId)
|
||||
triggerBlobDownload(data, filename)
|
||||
} catch {
|
||||
// L'erreur est gérée par useApi (toast automatique)
|
||||
}
|
||||
}
|
||||
|
||||
function formatDate(iso: string | null): string {
|
||||
if (!iso) return ''
|
||||
return new Date(iso).toLocaleString('fr', {
|
||||
dateStyle: 'long',
|
||||
timeStyle: 'short',
|
||||
})
|
||||
}
|
||||
|
||||
function joinAddresses(addresses: MailAddressDto[]): string {
|
||||
return addresses
|
||||
.map((a) => (a.name ? `${a.name} <${a.email}>` : a.email))
|
||||
.join(', ')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex h-full flex-col overflow-hidden">
|
||||
<div
|
||||
v-if="!detail && !loading"
|
||||
class="flex flex-1 items-center justify-center text-sm text-neutral-400 italic px-8 text-center"
|
||||
>
|
||||
{{ t('mail.empty.viewer') }}
|
||||
</div>
|
||||
|
||||
<div v-else-if="loading" class="flex flex-1 items-center justify-center">
|
||||
<Icon name="material-symbols:progress-activity" size="28" class="animate-spin text-neutral-400" />
|
||||
</div>
|
||||
|
||||
<template v-else-if="detail">
|
||||
<div class="flex-shrink-0 border-b border-neutral-200 px-4 py-3 space-y-1.5">
|
||||
<h2 class="text-base font-semibold text-neutral-900 break-words">
|
||||
{{ detail.header.subject ?? t('mail.noSubject') }}
|
||||
</h2>
|
||||
|
||||
<dl class="text-xs text-neutral-500 space-y-0.5">
|
||||
<div class="flex gap-1.5">
|
||||
<dt class="font-medium text-neutral-600 w-5 flex-shrink-0">{{ t('mail.from') }}</dt>
|
||||
<dd class="break-all">
|
||||
{{
|
||||
detail.header.fromName
|
||||
? `${detail.header.fromName} <${detail.header.fromEmail}>`
|
||||
: (detail.header.fromEmail ?? '')
|
||||
}}
|
||||
</dd>
|
||||
</div>
|
||||
<div v-if="detail.header.toRecipients.length > 0" class="flex gap-1.5">
|
||||
<dt class="font-medium text-neutral-600 w-5 flex-shrink-0">{{ t('mail.to') }}</dt>
|
||||
<dd class="break-all">{{ joinAddresses(detail.header.toRecipients) }}</dd>
|
||||
</div>
|
||||
<div v-if="detail.header.ccRecipients.length > 0" class="flex gap-1.5">
|
||||
<dt class="font-medium text-neutral-600 w-5 flex-shrink-0">{{ t('mail.cc') }}</dt>
|
||||
<dd class="break-all">{{ joinAddresses(detail.header.ccRecipients) }}</dd>
|
||||
</div>
|
||||
<div class="flex gap-1.5">
|
||||
<dt class="font-medium text-neutral-600 w-5 flex-shrink-0">{{ t('mail.date') }}</dt>
|
||||
<dd>{{ formatDate(detail.header.sentAt ?? detail.header.receivedAt) }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
<div class="flex flex-wrap items-center gap-2 pt-1">
|
||||
<MalioButton
|
||||
:label="t('mail.actions.createTask')"
|
||||
variant="primary"
|
||||
icon-name="material-symbols:add-task-outline"
|
||||
icon-position="left"
|
||||
:icon-size="13"
|
||||
button-class="text-xs px-2.5 py-1"
|
||||
@click="emit('createTask', detail.header.id)"
|
||||
/>
|
||||
<MalioButton
|
||||
:label="t('mail.actions.linkTask')"
|
||||
variant="secondary"
|
||||
icon-name="material-symbols:link"
|
||||
icon-position="left"
|
||||
:icon-size="13"
|
||||
button-class="text-xs px-2.5 py-1"
|
||||
@click="emit('linkTask', detail.header.id)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 overflow-y-auto px-4 py-3">
|
||||
<div
|
||||
v-if="!showImages && detail.bodyHtml"
|
||||
class="mb-3 flex items-center gap-3 rounded-md border border-amber-200 bg-amber-50 px-3 py-2 text-sm"
|
||||
>
|
||||
<Icon name="material-symbols:image-outline" size="16" class="text-amber-500 flex-shrink-0" />
|
||||
<span class="flex-1 text-amber-700">
|
||||
{{ t('mail.remoteImagesBlocked') }}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
class="text-xs font-medium text-amber-700 underline hover:text-amber-900 transition-colors"
|
||||
@click="showImages = true"
|
||||
>
|
||||
{{ t('mail.actions.showImages') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="detail.bodyHtml"
|
||||
class="prose prose-sm max-w-none text-neutral-800"
|
||||
v-html="sanitizedBody"
|
||||
/>
|
||||
|
||||
<pre
|
||||
v-else-if="detail.bodyText"
|
||||
class="whitespace-pre-wrap font-sans text-sm text-neutral-700"
|
||||
>{{ detail.bodyText }}</pre>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="detail.attachments.length > 0"
|
||||
class="flex-shrink-0 border-t border-neutral-200 px-4 py-3"
|
||||
>
|
||||
<p class="mb-2 text-xs font-semibold uppercase tracking-wide text-neutral-500">
|
||||
{{ t('mail.attachments') }} ({{ detail.attachments.length }})
|
||||
</p>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<button
|
||||
v-for="att in detail.attachments"
|
||||
:key="att.downloadId"
|
||||
type="button"
|
||||
class="flex items-center gap-1.5 rounded border border-neutral-200 bg-neutral-50 px-2.5 py-1.5 text-xs text-neutral-700 transition-colors hover:bg-neutral-100 hover:border-neutral-300"
|
||||
:title="isPreviewable(att.mimeType) ? t('mail.preview.open') : t('mail.actions.download')"
|
||||
@click="handleAttachmentClick(att)"
|
||||
>
|
||||
<Icon :name="attachmentIcon(att.mimeType)" size="14" class="flex-shrink-0 text-neutral-400" />
|
||||
<span class="max-w-[180px] truncate">{{ att.filename }}</span>
|
||||
<span class="text-neutral-400">({{ Math.round(att.size / 1024) }} Ko)</span>
|
||||
<Icon
|
||||
v-if="isPreviewable(att.mimeType)"
|
||||
name="material-symbols:visibility-outline"
|
||||
size="13"
|
||||
class="flex-shrink-0 text-neutral-400"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<MailAttachmentPreview
|
||||
v-if="previewAtt"
|
||||
v-model="previewOpen"
|
||||
:filename="previewAtt.filename"
|
||||
:mime-type="previewAtt.mimeType"
|
||||
:url="previewUrl"
|
||||
:loading="previewLoading"
|
||||
@download="downloadFromPreview"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,24 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { useMailStore } from '~/stores/mail'
|
||||
|
||||
const store = useMailStore()
|
||||
const { syncing } = storeToRefs(store)
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
async function handleRefresh(): Promise<void> {
|
||||
await store.triggerSync()
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<MalioButton
|
||||
:label="t('mail.actions.refresh')"
|
||||
variant="secondary"
|
||||
icon-name="material-symbols:refresh"
|
||||
icon-position="left"
|
||||
:icon-size="16"
|
||||
:disabled="syncing"
|
||||
@click="handleRefresh"
|
||||
/>
|
||||
</template>
|
||||
Reference in New Issue
Block a user