refactor(frontend) : reorganize components into subdirectories and fix imports
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
68
frontend/components/ui/AppDrawer.vue
Normal file
68
frontend/components/ui/AppDrawer.vue
Normal file
@@ -0,0 +1,68 @@
|
||||
<template>
|
||||
<Teleport v-if="modelValue" to="body">
|
||||
<Transition name="drawer" appear>
|
||||
<div
|
||||
class="fixed inset-0 z-40 flex justify-end"
|
||||
>
|
||||
<div
|
||||
class="absolute inset-0 bg-black/30"
|
||||
@click="close"
|
||||
/>
|
||||
<div
|
||||
class="relative z-50 flex h-full w-full max-w-md flex-col bg-white shadow-xl"
|
||||
>
|
||||
<div class="flex items-center justify-between border-b border-neutral-200 px-6 py-4">
|
||||
<h2 class="text-lg font-bold text-neutral-900">{{ title }}</h2>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded p-1 text-neutral-400 hover:text-neutral-600"
|
||||
@click="close"
|
||||
>
|
||||
<Icon name="mdi:close" size="24" />
|
||||
</button>
|
||||
</div>
|
||||
<div class="flex-1 overflow-y-auto px-6 py-4">
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const props = defineProps<{
|
||||
modelValue: boolean
|
||||
title: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:modelValue', value: boolean): void
|
||||
}>()
|
||||
|
||||
function close() {
|
||||
emit('update:modelValue', false)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.drawer-enter-active,
|
||||
.drawer-leave-active {
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.drawer-enter-active > div:last-child,
|
||||
.drawer-leave-active > div:last-child {
|
||||
transition: transform 0.3s ease;
|
||||
}
|
||||
|
||||
.drawer-enter-from,
|
||||
.drawer-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.drawer-enter-from > div:last-child,
|
||||
.drawer-leave-to > div:last-child {
|
||||
transform: translateX(100%);
|
||||
}
|
||||
</style>
|
||||
42
frontend/components/ui/AppTopNav.vue
Normal file
42
frontend/components/ui/AppTopNav.vue
Normal file
@@ -0,0 +1,42 @@
|
||||
<template>
|
||||
<header class="border-b border-neutral-200 bg-primary-500 p-5 text-white">
|
||||
<div class="flex h-full items-center justify-end">
|
||||
<div class="flex gap-12 text-xl text-white">
|
||||
<div class="group relative flex gap-4">
|
||||
<Icon name="mdi:account-circle-outline" class="self-center cursor-pointer" size="36" />
|
||||
<p class="self-center cursor-pointer">{{ user?.username }}</p>
|
||||
<div class="invisible absolute right-0 top-full z-20 mt-2 w-44 rounded-md border border-neutral-200 bg-white py-1 text-sm text-neutral-800 opacity-0 shadow-lg transition-all group-hover:visible group-hover:opacity-100">
|
||||
<button
|
||||
type="button"
|
||||
class="block w-full px-3 py-2 text-left hover:bg-neutral-100"
|
||||
>
|
||||
Mon profil
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="block w-full px-3 py-2 text-left hover:bg-neutral-100"
|
||||
@click="handleLogout"
|
||||
>
|
||||
Déconnexion
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { UserData } from '~/services/dto/user-data'
|
||||
|
||||
defineProps<{
|
||||
user?: UserData
|
||||
}>()
|
||||
|
||||
const auth = useAuthStore()
|
||||
|
||||
const handleLogout = async () => {
|
||||
await auth.logout()
|
||||
await navigateTo('/login')
|
||||
}
|
||||
</script>
|
||||
31
frontend/components/ui/ColorPicker.vue
Normal file
31
frontend/components/ui/ColorPicker.vue
Normal file
@@ -0,0 +1,31 @@
|
||||
<template>
|
||||
<div>
|
||||
<p class="mb-2 text-sm font-medium text-neutral-700">Couleur</p>
|
||||
<div class="flex flex-wrap gap-3">
|
||||
<button
|
||||
v-for="color in colors"
|
||||
:key="color"
|
||||
type="button"
|
||||
class="h-10 w-10 rounded-full border-2 transition-transform hover:scale-110"
|
||||
:class="modelValue === color ? 'border-neutral-900 scale-110' : 'border-transparent'"
|
||||
:style="{ backgroundColor: color }"
|
||||
@click="emit('update:modelValue', color)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
modelValue: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:modelValue', value: string): void
|
||||
}>()
|
||||
|
||||
const colors = [
|
||||
'#222783', '#26A69A', '#E91E63', '#4A90D9',
|
||||
'#7E57C2', '#8BC34A', '#FDD835', '#80DEEA', '#FF7043',
|
||||
]
|
||||
</script>
|
||||
96
frontend/components/ui/ConfirmDeleteStatusModal.vue
Normal file
96
frontend/components/ui/ConfirmDeleteStatusModal.vue
Normal file
@@ -0,0 +1,96 @@
|
||||
<template>
|
||||
<Teleport v-if="modelValue" to="body">
|
||||
<Transition name="modal" appear>
|
||||
<div class="fixed inset-0 z-50 flex items-center justify-center">
|
||||
<div class="absolute inset-0 bg-black/30" @click="cancel" />
|
||||
<div class="relative z-10 w-full max-w-md rounded-lg bg-white p-6 shadow-xl">
|
||||
<h3 class="text-lg font-bold text-neutral-900">Supprimer le statut « {{ statusLabel }} »</h3>
|
||||
|
||||
<p class="mt-3 text-sm text-neutral-600">
|
||||
{{ taskCount }} tâche{{ taskCount > 1 ? 's sont liées' : ' est liée' }} à ce statut.
|
||||
Choisissez où les déplacer :
|
||||
</p>
|
||||
|
||||
<div class="mt-4">
|
||||
<MalioSelect
|
||||
v-model="targetStatusId"
|
||||
:options="targetOptions"
|
||||
label="Déplacer vers"
|
||||
empty-option-label="Backlog (sans statut)"
|
||||
min-width="w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="mt-6 flex justify-end gap-3">
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-md border border-neutral-300 px-4 py-2 text-sm font-semibold text-neutral-700 hover:bg-neutral-50"
|
||||
@click="cancel"
|
||||
>
|
||||
Annuler
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-md bg-red-600 px-4 py-2 text-sm font-semibold text-white hover:bg-red-700 disabled:opacity-50"
|
||||
:disabled="isProcessing"
|
||||
@click="confirm"
|
||||
>
|
||||
Supprimer
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { TaskStatus } from '~/services/dto/task-status'
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: boolean
|
||||
statusLabel: string
|
||||
taskCount: number
|
||||
availableStatuses: TaskStatus[]
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:modelValue', value: boolean): void
|
||||
(e: 'confirm', targetStatusId: number | null): void
|
||||
}>()
|
||||
|
||||
const targetStatusId = ref<number | null>(null)
|
||||
const isProcessing = ref(false)
|
||||
|
||||
const targetOptions = computed(() =>
|
||||
props.availableStatuses.map(s => ({ label: s.label, value: s.id }))
|
||||
)
|
||||
|
||||
watch(() => props.modelValue, (open) => {
|
||||
if (open) {
|
||||
targetStatusId.value = null
|
||||
isProcessing.value = false
|
||||
}
|
||||
})
|
||||
|
||||
function cancel() {
|
||||
emit('update:modelValue', false)
|
||||
}
|
||||
|
||||
function confirm() {
|
||||
isProcessing.value = true
|
||||
emit('confirm', targetStatusId.value)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.modal-enter-active,
|
||||
.modal-leave-active {
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.modal-enter-from,
|
||||
.modal-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
||||
77
frontend/components/ui/DataTable.vue
Normal file
77
frontend/components/ui/DataTable.vue
Normal file
@@ -0,0 +1,77 @@
|
||||
<template>
|
||||
<div class="mt-6 overflow-x-auto rounded-lg border border-neutral-200">
|
||||
<table class="w-full text-left text-sm">
|
||||
<thead class="border-b border-neutral-200 bg-neutral-50">
|
||||
<tr>
|
||||
<th
|
||||
v-for="col in columns"
|
||||
:key="col.key"
|
||||
class="px-4 py-3 font-semibold text-neutral-700"
|
||||
>
|
||||
{{ col.label }}
|
||||
</th>
|
||||
<th v-if="deletable" class="px-4 py-3 font-semibold text-neutral-700">
|
||||
Actions
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr
|
||||
v-for="item in items"
|
||||
:key="item.id"
|
||||
class="cursor-pointer border-b border-neutral-100 hover:bg-neutral-50"
|
||||
@click="$emit('row-click', item)"
|
||||
>
|
||||
<td
|
||||
v-for="col in columns"
|
||||
:key="col.key"
|
||||
class="px-4 py-3"
|
||||
:class="[col.class, { 'font-semibold text-primary-500': col.primary }]"
|
||||
>
|
||||
<slot :name="`cell-${col.key}`" :item="item" :value="item[col.key]">
|
||||
{{ item[col.key] }}
|
||||
</slot>
|
||||
</td>
|
||||
<td v-if="deletable" class="px-4 py-3">
|
||||
<button
|
||||
class="text-red-500 hover:text-red-700"
|
||||
@click.stop="$emit('delete', item)"
|
||||
>
|
||||
<Icon name="mdi:delete-outline" size="20" />
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="items.length === 0 && !loading">
|
||||
<td
|
||||
:colspan="columns.length + (deletable ? 1 : 0)"
|
||||
class="px-4 py-8 text-center text-neutral-400"
|
||||
>
|
||||
{{ emptyMessage }}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
export interface DataTableColumn {
|
||||
key: string
|
||||
label: string
|
||||
primary?: boolean
|
||||
class?: string
|
||||
}
|
||||
|
||||
defineProps<{
|
||||
columns: DataTableColumn[]
|
||||
items: Record<string, any>[]
|
||||
loading?: boolean
|
||||
emptyMessage?: string
|
||||
deletable?: boolean
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
(e: 'row-click', item: any): void
|
||||
(e: 'delete', item: any): void
|
||||
}>()
|
||||
</script>
|
||||
52
frontend/components/ui/SidebarLink.vue
Normal file
52
frontend/components/ui/SidebarLink.vue
Normal file
@@ -0,0 +1,52 @@
|
||||
<template>
|
||||
<NuxtLink
|
||||
:to="to"
|
||||
class="group/link relative flex items-center transition-colors hover:text-primary-500"
|
||||
:class="linkClasses"
|
||||
:active-class="exact ? '' : activeClass"
|
||||
:exact-active-class="exact ? activeClass : ''"
|
||||
>
|
||||
<Icon :name="icon" :size="sub ? '20' : '24'" class="flex-shrink-0" />
|
||||
<span
|
||||
v-if="!collapsed"
|
||||
class="self-baseline whitespace-nowrap overflow-hidden transition-opacity duration-300"
|
||||
:class="sub ? 'text-sm' : 'text-md'"
|
||||
>
|
||||
{{ label }}
|
||||
</span>
|
||||
<div
|
||||
v-if="collapsed"
|
||||
class="pointer-events-none absolute left-full z-50 ml-2 rounded-md bg-neutral-800 px-2 py-1 text-xs text-white opacity-0 shadow-lg transition-opacity group-hover/link:pointer-events-auto group-hover/link:opacity-100 whitespace-nowrap"
|
||||
>
|
||||
{{ label }}
|
||||
</div>
|
||||
</NuxtLink>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const props = defineProps<{
|
||||
to: string
|
||||
icon: string
|
||||
label: string
|
||||
collapsed: boolean
|
||||
sub?: boolean
|
||||
exact?: boolean
|
||||
}>()
|
||||
|
||||
const activeClass = computed(() => {
|
||||
if (props.collapsed) {
|
||||
return '!text-primary-500 bg-primary-500/10'
|
||||
}
|
||||
return '!text-primary-500 bg-tertiary-500'
|
||||
})
|
||||
|
||||
const linkClasses = computed(() => {
|
||||
if (props.collapsed) {
|
||||
return 'justify-center w-10 h-10 mx-auto my-1 p-2 rounded-lg text-neutral-600 hover:text-primary-500 hover:bg-primary-500/10'
|
||||
}
|
||||
if (props.sub) {
|
||||
return 'gap-3 px-4 py-2 pl-12 text-sm font-semibold text-neutral-700'
|
||||
}
|
||||
return 'gap-3 px-4 py-3 text-md font-semibold text-neutral-700'
|
||||
})
|
||||
</script>
|
||||
26
frontend/components/ui/SidebarTimer.vue
Normal file
26
frontend/components/ui/SidebarTimer.vue
Normal file
@@ -0,0 +1,26 @@
|
||||
<template>
|
||||
<button
|
||||
class="flex w-full items-center justify-center gap-2 rounded-md px-4 py-2 text-sm font-semibold text-white transition"
|
||||
:class="timerStore.isRunning
|
||||
? 'bg-red-500 hover:bg-red-600'
|
||||
: 'bg-primary-500 hover:bg-primary-600'"
|
||||
:title="timerStore.isRunning ? 'Arrêter le timer' : 'Démarrer un timer'"
|
||||
@click="timerStore.isRunning ? timerStore.stop() : timerStore.start()"
|
||||
>
|
||||
<Icon
|
||||
:name="timerStore.isRunning ? 'mdi:stop' : 'mdi:play'"
|
||||
size="16"
|
||||
/>
|
||||
<span v-if="!collapsed" class="font-mono tracking-wide">
|
||||
{{ timerStore.elapsedFormatted }}
|
||||
</span>
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
collapsed: boolean
|
||||
}>()
|
||||
|
||||
const timerStore = useTimerStore()
|
||||
</script>
|
||||
Reference in New Issue
Block a user