refactor : rename TaskType to TaskTag across the stack

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Matthieu
2026-03-13 08:20:21 +01:00
parent dbae1f7536
commit 56275a9ebe
16 changed files with 216 additions and 117 deletions

View File

@@ -1,12 +1,12 @@
<template>
<div>
<div class="flex items-center justify-between">
<h2 class="text-lg font-bold text-neutral-900">Types</h2>
<h2 class="text-lg font-bold text-neutral-900">Tags</h2>
<button
class="rounded-md bg-primary-500 px-4 py-2 text-sm font-semibold text-white hover:bg-secondary-500"
@click="openCreate"
>
+ Ajouter un type
+ Ajouter un tag
</button>
</div>
@@ -14,7 +14,7 @@
:columns="columns"
:items="items"
:loading="isLoading"
empty-message="Aucun type trouvé."
empty-message="Aucun tag trouvé."
deletable
@row-click="openEdit"
@delete="(item) => handleDelete(item.id)"
@@ -27,7 +27,7 @@
</template>
</DataTable>
<TaskTypeDrawer
<TaskTagDrawer
v-model="drawerOpen"
:item="selectedItem"
@saved="onSaved"
@@ -36,8 +36,8 @@
</template>
<script setup lang="ts">
import type { TaskType } from '~/services/dto/task-type'
import { useTaskTypeService } from '~/services/task-types'
import type { TaskTag } from '~/services/dto/task-tag'
import { useTaskTagService } from '~/services/task-tags'
import type { DataTableColumn } from '~/components/ui/DataTable.vue'
@@ -46,11 +46,11 @@ const columns: DataTableColumn[] = [
{ key: 'color', label: 'Couleur' },
]
const { getAll, remove } = useTaskTypeService()
const items = ref<TaskType[]>([])
const { getAll, remove } = useTaskTagService()
const items = ref<TaskTag[]>([])
const isLoading = ref(true)
const drawerOpen = ref(false)
const selectedItem = ref<TaskType | null>(null)
const selectedItem = ref<TaskTag | null>(null)
async function loadItems() {
isLoading.value = true
@@ -66,7 +66,7 @@ function openCreate() {
drawerOpen.value = true
}
function openEdit(item: TaskType) {
function openEdit(item: TaskTag) {
selectedItem.value = item
drawerOpen.value = true
}

View File

@@ -7,7 +7,10 @@
@click="emit('click')"
>
<div class="flex items-start justify-between gap-2">
<h4 class="text-sm font-semibold text-neutral-900">{{ task.title }}</h4>
<div class="min-w-0">
<span v-if="task.project && task.number" class="text-xs font-medium text-neutral-400">{{ task.project.code }}{{ task.number }}</span>
<h4 class="text-sm font-semibold text-neutral-900">{{ task.title }}</h4>
</div>
<button
class="shrink-0 transition-colors"
:class="isTimerOnTask ? 'text-[#F18619] hover:text-[#d97314]' : 'text-neutral-400 hover:text-primary-500'"
@@ -26,12 +29,12 @@
{{ task.priority.label }}
</span>
<span
v-for="type in task.types"
:key="type.id"
v-for="tag in task.tags"
:key="tag.id"
class="rounded-full px-2 py-0.5 text-xs font-semibold text-white"
:style="{ backgroundColor: type.color }"
:style="{ backgroundColor: tag.color }"
>
{{ type.label }}
{{ tag.label }}
</span>
<span
v-if="task.assignee"

View File

@@ -1,5 +1,5 @@
<template>
<AppDrawer v-model="isOpen" :title="isEditing ? 'Modifier un type' : 'Ajouter un type'">
<AppDrawer v-model="isOpen" :title="isEditing ? 'Modifier un tag' : 'Ajouter un tag'">
<form @submit.prevent="handleSubmit" class="flex flex-col gap-2">
<MalioInputText
v-model="form.label"
@@ -26,12 +26,12 @@
</template>
<script setup lang="ts">
import type { TaskType, TaskTypeWrite } from '~/services/dto/task-type'
import { useTaskTypeService } from '~/services/task-types'
import type { TaskTag, TaskTagWrite } from '~/services/dto/task-tag'
import { useTaskTagService } from '~/services/task-tags'
const props = defineProps<{
modelValue: boolean
item: TaskType | null
item: TaskTag | null
}>()
const emit = defineEmits<{
@@ -69,7 +69,7 @@ watch(() => props.modelValue, (open) => {
}
})
const { create, update } = useTaskTypeService()
const { create, update } = useTaskTagService()
async function handleSubmit() {
touched.label = true
@@ -77,7 +77,7 @@ async function handleSubmit() {
isSubmitting.value = true
try {
const payload: TaskTypeWrite = {
const payload: TaskTagWrite = {
label: form.label.trim(),
color: form.color,
}

View File

@@ -25,14 +25,14 @@
<span class="ml-auto shrink-0 text-[10px] tabular-nums opacity-80">{{ duration }}</span>
</div>
<div v-if="entry.project" class="truncate text-[10px] opacity-80">{{ entry.project.name }}</div>
<div v-if="entry.types.length" class="mt-0.5 flex items-center gap-1 overflow-hidden">
<div v-if="entry.tags.length" class="mt-0.5 flex items-center gap-1 overflow-hidden">
<span
v-for="type in entry.types"
:key="type.id"
v-for="tag in entry.tags"
:key="tag.id"
class="inline-flex items-center gap-0.5 truncate text-[9px] opacity-90"
>
<span class="inline-block h-1.5 w-1.5 shrink-0 rounded-full" :style="{ backgroundColor: type.color }" />
{{ type.label }}
<span class="inline-block h-1.5 w-1.5 shrink-0 rounded-full" :style="{ backgroundColor: tag.color }" />
{{ tag.label }}
</span>
</div>
</template>

View File

@@ -73,25 +73,25 @@
/>
<div>
<p class="mb-2 text-sm font-semibold text-neutral-700">Types</p>
<p class="mb-2 text-sm font-semibold text-neutral-700">Tags</p>
<div class="flex flex-wrap gap-2">
<label
v-for="type in types"
:key="type.id"
v-for="tag in tags"
:key="tag.id"
class="cursor-pointer rounded-full px-3 py-1 text-xs font-semibold transition"
:class="form.typeIds.includes(type.id)
:class="form.tagIds.includes(tag.id)
? 'text-white'
: 'bg-neutral-100 text-neutral-600 hover:bg-neutral-200'"
:style="form.typeIds.includes(type.id) ? { backgroundColor: type.color } : {}"
:style="form.tagIds.includes(tag.id) ? { backgroundColor: tag.color } : {}"
>
<input
type="checkbox"
class="hidden"
:value="type.id"
:checked="form.typeIds.includes(type.id)"
@change="toggleType(type.id)"
:value="tag.id"
:checked="form.tagIds.includes(tag.id)"
@change="toggleTag(tag.id)"
/>
{{ type.label }}
{{ tag.label }}
</label>
</div>
</div>
@@ -120,7 +120,7 @@
import type { TimeEntry } from '~/services/dto/time-entry'
import type { UserData } from '~/services/dto/user-data'
import type { Project } from '~/services/dto/project'
import type { TaskType } from '~/services/dto/task-type'
import type { TaskTag } from '~/services/dto/task-tag'
import { useTimeEntryService } from '~/services/time-entries'
const props = defineProps<{
@@ -129,7 +129,7 @@ const props = defineProps<{
prefillStartedAt?: string | null
users: UserData[]
projects: Project[]
types: TaskType[]
tags: TaskTag[]
}>()
const emit = defineEmits<{
@@ -154,7 +154,7 @@ const form = reactive({
endTime: '',
userId: authStore.user?.id ?? null as number | null,
projectId: null as number | null,
typeIds: [] as number[],
tagIds: [] as number[],
})
const userOptions = computed(() =>
@@ -176,12 +176,12 @@ const durationLabel = computed(() => {
return m > 0 ? `${h}h${String(m).padStart(2, '0')}` : `${h}h`
})
function toggleType(id: number) {
const idx = form.typeIds.indexOf(id)
function toggleTag(id: number) {
const idx = form.tagIds.indexOf(id)
if (idx >= 0) {
form.typeIds.splice(idx, 1)
form.tagIds.splice(idx, 1)
} else {
form.typeIds.push(id)
form.tagIds.push(id)
}
}
@@ -212,7 +212,7 @@ function populateForm(entry: TimeEntry | null | undefined) {
form.endTime = entry.stoppedAt ? toLocalTime(entry.stoppedAt) : ''
form.userId = entry.user?.id ?? authStore.user?.id ?? null
form.projectId = entry.project?.id ?? null
form.typeIds = entry.types?.map(t => t.id) ?? []
form.tagIds = entry.tags?.map(t => t.id) ?? []
} else {
form.title = ''
form.description = ''
@@ -221,7 +221,7 @@ function populateForm(entry: TimeEntry | null | undefined) {
form.endTime = ''
form.userId = authStore.user?.id ?? null
form.projectId = null
form.typeIds = []
form.tagIds = []
}
}
@@ -251,7 +251,7 @@ async function onSubmit() {
stoppedAt: form.endTime ? toISO(form.date, form.endTime) : null,
user: `/api/users/${form.userId}`,
project: form.projectId ? `/api/projects/${form.projectId}` : null,
types: form.typeIds.map(id => `/api/task_types/${id}`),
tags: form.tagIds.map(id => `/api/task_tags/${id}`),
}
if (isEditing.value && props.entry) {

View File

@@ -23,12 +23,12 @@
{{ entry.title || 'Sans titre' }}
</span>
<span
v-for="type in entry.types"
:key="type.id"
v-for="tag in entry.tags"
:key="tag.id"
class="shrink-0 rounded-full px-2 py-0.5 text-[10px] font-semibold text-white"
:style="{ backgroundColor: type.color }"
:style="{ backgroundColor: tag.color }"
>
{{ type.label }}
{{ tag.label }}
</span>
</div>
<div class="mt-0.5 flex items-center gap-2 text-xs text-neutral-500">

View File

@@ -23,7 +23,7 @@
<AdminStatusTab v-if="activeTab === 'statuses'" />
<AdminEffortTab v-if="activeTab === 'efforts'" />
<AdminPriorityTab v-if="activeTab === 'priorities'" />
<AdminTypeTab v-if="activeTab === 'types'" />
<AdminTagTab v-if="activeTab === 'tags'" />
<AdminUserTab v-if="activeTab === 'users'" />
</div>
</div>
@@ -37,7 +37,7 @@ const tabs = [
{ key: 'statuses', label: 'Statuts' },
{ key: 'efforts', label: 'Efforts' },
{ key: 'priorities', label: 'Priorités' },
{ key: 'types', label: 'Types' },
{ key: 'tags', label: 'Tags' },
{ key: 'users', label: 'Utilisateurs' },
] as const

View File

@@ -55,10 +55,10 @@
/>
<MalioSelect
v-model="selectedTypeId"
:options="typeOptions"
v-model="selectedTagId"
:options="tagOptions"
empty-option-label="Tous"
label="Type"
label="Tag"
min-width="!w-40"
text-field="text-sm"
text-value="text-sm"
@@ -93,7 +93,7 @@
:prefill-started-at="prefillStartedAt"
:users="users"
:projects="projects"
:types="types"
:tags="tags"
@saved="loadEntries"
/>
@@ -115,7 +115,7 @@
import type { TimeEntry } from '~/services/dto/time-entry'
import type { UserData } from '~/services/dto/user-data'
import type { Project } from '~/services/dto/project'
import type { TaskType } from '~/services/dto/task-type'
import type { TaskTag } from '~/services/dto/task-tag'
import { useTimeEntryService } from '~/services/time-entries'
import { extractHydraMembers } from '~/utils/api'
@@ -127,13 +127,13 @@ const timeEntryService = useTimeEntryService()
const viewMode = ref<'week' | 'day' | 'list'>('week')
const startDate = ref(getMonday(new Date()))
const selectedUserId = ref<number | null>(authStore.user?.id ?? null)
const selectedTypeId = ref<number | null>(null)
const selectedTagId = ref<number | null>(null)
const selectedProjectId = ref<number | null>(null)
const entries = ref<TimeEntry[]>([])
const users = ref<UserData[]>([])
const projects = ref<Project[]>([])
const types = ref<TaskType[]>([])
const tags = ref<TaskTag[]>([])
const drawerOpen = ref(false)
const editingEntry = ref<TimeEntry | null>(null)
@@ -164,8 +164,8 @@ const projectOptions = computed(() =>
projects.value.map(p => ({ label: p.name, value: p.id }))
)
const typeOptions = computed(() =>
types.value.map(t => ({ label: t.label, value: t.id }))
const tagOptions = computed(() =>
tags.value.map(t => ({ label: t.label, value: t.id }))
)
let pageHeaderResizeObserver: ResizeObserver | null = null
@@ -179,8 +179,8 @@ const filteredEntries = computed(() => {
if (selectedProjectId.value) {
result = result.filter((e) => e.project?.id === selectedProjectId.value)
}
if (selectedTypeId.value) {
result = result.filter((e) => e.types.some((t) => t.id === selectedTypeId.value))
if (selectedTagId.value) {
result = result.filter((e) => e.tags.some((t) => t.id === selectedTagId.value))
}
return result
})
@@ -269,7 +269,7 @@ async function onPaste() {
stoppedAt: clipboard.value.stoppedAt ?? undefined,
user: `/api/users/${selectedUserId.value}`,
project: clipboard.value.project ? `/api/projects/${clipboard.value.project.id}` : null,
types: clipboard.value.types.map((t) => `/api/task_types/${t.id}`),
tags: clipboard.value.tags.map((t) => `/api/task_tags/${t.id}`),
})
await loadEntries()
}
@@ -314,12 +314,12 @@ async function loadReferenceData() {
const [usersData, projectsData, typesData] = await Promise.all([
api.get<any>('/users'),
api.get<any>('/projects'),
api.get<any>('/task_types'),
api.get<any>('/task_tags'),
])
users.value = extractHydraMembers(usersData)
projects.value = extractHydraMembers(projectsData)
types.value = extractHydraMembers(typesData)
tags.value = extractHydraMembers(typesData)
}
onMounted(async () => {

View File

@@ -1,11 +1,11 @@
export type TaskType = {
export type TaskTag = {
id: number
'@id'?: string
label: string
color: string
}
export type TaskTypeWrite = {
export type TaskTagWrite = {
label: string
color: string
}

View File

@@ -1,7 +1,7 @@
import type { UserData } from './user-data'
import type { Project } from './project'
import type { Task } from './task'
import type { TaskType } from './task-type'
import type { TaskTag } from './task-tag'
export type TimeEntry = {
id: number
@@ -13,7 +13,7 @@ export type TimeEntry = {
user: UserData
project: Project | null
task: Task | null
types: TaskType[]
tags: TaskTag[]
}
export type TimeEntryWrite = {
@@ -24,5 +24,5 @@ export type TimeEntryWrite = {
user: string
project?: string | null
task?: string | null
types?: string[]
tags?: string[]
}

View File

@@ -0,0 +1,32 @@
import type { TaskTag, TaskTagWrite } from './dto/task-tag'
import type { HydraCollection } from '~/utils/api'
import { extractHydraMembers } from '~/utils/api'
export function useTaskTagService() {
const api = useApi()
async function getAll(): Promise<TaskTag[]> {
const data = await api.get<HydraCollection<TaskTag>>('/task_tags')
return extractHydraMembers(data)
}
async function create(payload: TaskTagWrite): Promise<TaskTag> {
return api.post<TaskTag>('/task_tags', payload as Record<string, unknown>, {
toastSuccessKey: 'taskTags.created',
})
}
async function update(id: number, payload: Partial<TaskTagWrite>): Promise<TaskTag> {
return api.patch<TaskTag>(`/task_tags/${id}`, payload as Record<string, unknown>, {
toastSuccessKey: 'taskTags.updated',
})
}
async function remove(id: number): Promise<void> {
await api.delete(`/task_tags/${id}`, {}, {
toastSuccessKey: 'taskTags.deleted',
})
}
return { getAll, create, update, remove }
}

View File

@@ -1,32 +0,0 @@
import type { TaskType, TaskTypeWrite } from './dto/task-type'
import type { HydraCollection } from '~/utils/api'
import { extractHydraMembers } from '~/utils/api'
export function useTaskTypeService() {
const api = useApi()
async function getAll(): Promise<TaskType[]> {
const data = await api.get<HydraCollection<TaskType>>('/task_types')
return extractHydraMembers(data)
}
async function create(payload: TaskTypeWrite): Promise<TaskType> {
return api.post<TaskType>('/task_types', payload as Record<string, unknown>, {
toastSuccessKey: 'taskTypes.created',
})
}
async function update(id: number, payload: Partial<TaskTypeWrite>): Promise<TaskType> {
return api.patch<TaskType>(`/task_types/${id}`, payload as Record<string, unknown>, {
toastSuccessKey: 'taskTypes.updated',
})
}
async function remove(id: number): Promise<void> {
await api.delete(`/task_types/${id}`, {}, {
toastSuccessKey: 'taskTypes.deleted',
})
}
return { getAll, create, update, remove }
}

View File

@@ -83,7 +83,7 @@ export const useTimerStore = defineStore('timer', () => {
? (typeof task.project === 'string' ? task.project : (task.project['@id'] ?? (task.project.id ? `/api/projects/${task.project.id}` : null)))
: null,
task: typeof task === 'string' ? task : (task['@id'] ?? `/api/tasks/${task.id}`),
types: task.types?.map((t) => typeof t === 'string' ? t : (t['@id'] ?? `/api/task_types/${t.id}`)) ?? [],
tags: task.tags?.map((t) => typeof t === 'string' ? t : (t['@id'] ?? `/api/task_tags/${t.id}`)) ?? [],
})
startTicking()
}

75
src/Entity/TaskTag.php Normal file
View File

@@ -0,0 +1,75 @@
<?php
declare(strict_types=1);
namespace App\Entity;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Delete;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Patch;
use ApiPlatform\Metadata\Post;
use App\Repository\TaskTagRepository;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Attribute\Groups;
#[ApiResource(
operations: [
new GetCollection(),
new Get(),
new Post(security: "is_granted('ROLE_ADMIN')"),
new Patch(security: "is_granted('ROLE_ADMIN')"),
new Delete(security: "is_granted('ROLE_ADMIN')"),
],
normalizationContext: ['groups' => ['task_tag:read']],
denormalizationContext: ['groups' => ['task_tag:write']],
order: ['label' => 'ASC'],
)]
#[ORM\Entity(repositoryClass: TaskTagRepository::class)]
#[ORM\Table(name: 'task_type')]
class TaskTag
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
#[Groups(['task_tag:read', 'task:read', 'time_entry:read'])]
private ?int $id = null;
#[ORM\Column(length: 255)]
#[Groups(['task_tag:read', 'task_tag:write', 'task:read', 'time_entry:read'])]
private ?string $label = null;
#[ORM\Column(length: 7)]
#[Groups(['task_tag:read', 'task_tag:write', 'task:read', 'time_entry:read'])]
private ?string $color = '#222783';
public function getId(): ?int
{
return $this->id;
}
public function getLabel(): ?string
{
return $this->label;
}
public function setLabel(string $label): static
{
$this->label = $label;
return $this;
}
public function getColor(): ?string
{
return $this->color;
}
public function setColor(string $color): static
{
$this->color = $color;
return $this;
}
}

View File

@@ -41,7 +41,7 @@ use Symfony\Component\Serializer\Attribute\Groups;
denormalizationContext: ['groups' => ['time_entry:write']],
order: ['startedAt' => 'DESC'],
)]
#[ApiFilter(SearchFilter::class, properties: ['user' => 'exact', 'project' => 'exact', 'types' => 'exact'])]
#[ApiFilter(SearchFilter::class, properties: ['user' => 'exact', 'project' => 'exact', 'tags' => 'exact'])]
#[ApiFilter(DateFilter::class, properties: ['startedAt'])]
#[ORM\Entity(repositoryClass: TimeEntryRepository::class)]
#[ORM\UniqueConstraint(name: 'uniq_active_timer', columns: ['user_id'], options: ['where' => '(stopped_at IS NULL)'])]
@@ -84,15 +84,19 @@ class TimeEntry
#[Groups(['time_entry:read', 'time_entry:write'])]
private ?Task $task = null;
/** @var Collection<int, TaskType> */
#[ORM\ManyToMany(targetEntity: TaskType::class)]
#[ORM\JoinTable(name: 'time_entry_task_type')]
/** @var Collection<int, TaskTag> */
#[ORM\ManyToMany(targetEntity: TaskTag::class)]
#[ORM\JoinTable(
name: 'time_entry_task_type',
joinColumns: [new ORM\JoinColumn(name: 'time_entry_id', referencedColumnName: 'id')],
inverseJoinColumns: [new ORM\JoinColumn(name: 'task_type_id', referencedColumnName: 'id')],
)]
#[Groups(['time_entry:read', 'time_entry:write'])]
private Collection $types;
private Collection $tags;
public function __construct()
{
$this->types = new ArrayCollection();
$this->tags = new ArrayCollection();
}
public function getId(): ?int
@@ -184,24 +188,24 @@ class TimeEntry
return $this;
}
/** @return Collection<int, TaskType> */
public function getTypes(): Collection
/** @return Collection<int, TaskTag> */
public function getTags(): Collection
{
return $this->types;
return $this->tags;
}
public function addType(TaskType $type): static
public function addTag(TaskTag $tag): static
{
if (!$this->types->contains($type)) {
$this->types->add($type);
if (!$this->tags->contains($tag)) {
$this->tags->add($tag);
}
return $this;
}
public function removeType(TaskType $type): static
public function removeTag(TaskTag $tag): static
{
$this->types->removeElement($type);
$this->tags->removeElement($tag);
return $this;
}

View File

@@ -0,0 +1,17 @@
<?php
declare(strict_types=1);
namespace App\Repository;
use App\Entity\TaskTag;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
class TaskTagRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, TaskTag::class);
}
}