55 KiB
Time Tracking Implementation Plan
For agentic workers: REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Add a Toggl-style time tracking system with timer, calendar view (week/day), drag/resize/copy-paste of time blocks.
Architecture: New TimeEntry entity with API Platform, custom provider for active timer. Frontend: Pinia timer store, calendar page with interactive grid, sidebar timer widget.
Tech Stack: PHP 8.4 / Symfony 8 / API Platform 4 / Doctrine ORM, Nuxt 4 / Vue 3 / Pinia / Tailwind CSS
Spec: docs/superpowers/specs/2026-03-10-time-tracking-design.md
Chunk 1: Backend — Entity, Migration, API
Task 1: Create TimeEntry Entity
Files:
-
Create:
src/Entity/TimeEntry.php -
Create:
src/Repository/TimeEntryRepository.php -
Step 1: Create the Repository
<?php
declare(strict_types=1);
namespace App\Repository;
use App\Entity\TimeEntry;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<TimeEntry>
*/
class TimeEntryRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, TimeEntry::class);
}
}
- Step 2: Create the TimeEntry Entity
<?php
declare(strict_types=1);
namespace App\Entity;
use ApiPlatform\Doctrine\Orm\Filter\DateFilter;
use ApiPlatform\Doctrine\Orm\Filter\SearchFilter;
use ApiPlatform\Metadata\ApiFilter;
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\TimeEntryRepository;
use App\State\ActiveTimeEntryProvider;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Attribute\Groups;
#[ApiResource(
operations: [
new GetCollection(),
new Get(),
new Get(
uriTemplate: '/time_entries/active',
provider: ActiveTimeEntryProvider::class,
openapiContext: ['summary' => 'Get the active timer for the current user'],
),
new Post(security: "is_granted('ROLE_USER')"),
new Patch(security: "is_granted('ROLE_ADMIN') or object.getUser() == user"),
new Delete(security: "is_granted('ROLE_ADMIN') or object.getUser() == user"),
],
normalizationContext: ['groups' => ['time_entry:read']],
denormalizationContext: ['groups' => ['time_entry:write']],
order: ['startedAt' => 'DESC'],
)]
#[ApiFilter(SearchFilter::class, properties: ['user' => 'exact', 'project' => 'exact', 'types' => '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)'])]
class TimeEntry
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
#[Groups(['time_entry:read'])]
private ?int $id = null;
#[ORM\Column(length: 255, nullable: true)]
#[Groups(['time_entry:read', 'time_entry:write'])]
private ?string $title = null;
#[ORM\Column(type: Types::TEXT, nullable: true)]
#[Groups(['time_entry:read', 'time_entry:write'])]
private ?string $description = null;
#[ORM\Column(type: Types::DATETIMETZ_IMMUTABLE)]
#[Groups(['time_entry:read', 'time_entry:write'])]
private ?\DateTimeImmutable $startedAt = null;
#[ORM\Column(type: Types::DATETIMETZ_IMMUTABLE, nullable: true)]
#[Groups(['time_entry:read', 'time_entry:write'])]
private ?\DateTimeImmutable $stoppedAt = null;
#[ORM\ManyToOne(targetEntity: User::class)]
#[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')]
#[Groups(['time_entry:read', 'time_entry:write'])]
private ?User $user = null;
#[ORM\ManyToOne(targetEntity: Project::class)]
#[ORM\JoinColumn(nullable: true, onDelete: 'SET NULL')]
#[Groups(['time_entry:read', 'time_entry:write'])]
private ?Project $project = null;
#[ORM\ManyToOne(targetEntity: Task::class)]
#[ORM\JoinColumn(nullable: true, onDelete: 'SET NULL')]
#[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')]
#[Groups(['time_entry:read', 'time_entry:write'])]
private Collection $types;
public function __construct()
{
$this->types = new ArrayCollection();
}
public function getId(): ?int
{
return $this->id;
}
public function getTitle(): ?string
{
return $this->title;
}
public function setTitle(?string $title): static
{
$this->title = $title;
return $this;
}
public function getDescription(): ?string
{
return $this->description;
}
public function setDescription(?string $description): static
{
$this->description = $description;
return $this;
}
public function getStartedAt(): ?\DateTimeImmutable
{
return $this->startedAt;
}
public function setStartedAt(\DateTimeImmutable $startedAt): static
{
$this->startedAt = $startedAt;
return $this;
}
public function getStoppedAt(): ?\DateTimeImmutable
{
return $this->stoppedAt;
}
public function setStoppedAt(?\DateTimeImmutable $stoppedAt): static
{
$this->stoppedAt = $stoppedAt;
return $this;
}
public function getUser(): ?User
{
return $this->user;
}
public function setUser(?User $user): static
{
$this->user = $user;
return $this;
}
public function getProject(): ?Project
{
return $this->project;
}
public function setProject(?Project $project): static
{
$this->project = $project;
return $this;
}
public function getTask(): ?Task
{
return $this->task;
}
public function setTask(?Task $task): static
{
$this->task = $task;
return $this;
}
/** @return Collection<int, TaskType> */
public function getTypes(): Collection
{
return $this->types;
}
public function addType(TaskType $type): static
{
if (!$this->types->contains($type)) {
$this->types->add($type);
}
return $this;
}
public function removeType(TaskType $type): static
{
$this->types->removeElement($type);
return $this;
}
}
- Step 3: Generate and run migration
make shell
# Inside container:
php bin/console doctrine:migrations:diff
exit
make migration-migrate
Verify: the migration creates time_entry table, time_entry_task_type join table, and all FK constraints.
- Step 4: Commit
git add src/Entity/TimeEntry.php src/Repository/TimeEntryRepository.php migrations/
git commit -m "feat(time-tracking) : add TimeEntry entity and migration"
Task 2: Create ActiveTimeEntryProvider
Files:
-
Create:
src/State/ActiveTimeEntryProvider.php -
Step 1: Create the provider
Follow the same pattern as MeProvider. This provider returns the single active timer (stoppedAt IS NULL) for the authenticated user.
<?php
declare(strict_types=1);
namespace App\State;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProviderInterface;
use App\Entity\TimeEntry;
use App\Repository\TimeEntryRepository;
use Symfony\Bundle\SecurityBundle\Security;
/**
* @implements ProviderInterface<TimeEntry>
*/
final readonly class ActiveTimeEntryProvider implements ProviderInterface
{
public function __construct(
private Security $security,
private TimeEntryRepository $timeEntryRepository,
) {}
public function provide(Operation $operation, array $uriVariables = [], array $context = []): ?TimeEntry
{
$user = $this->security->getUser();
if (!$user) {
return null;
}
return $this->timeEntryRepository->findOneBy([
'user' => $user,
'stoppedAt' => null,
]);
}
}
- Step 2: Add findActiveByUser to Repository
Add a dedicated method to TimeEntryRepository for clarity:
public function findActiveByUser(User $user): ?TimeEntry
{
return $this->findOneBy([
'user' => $user,
'stoppedAt' => null,
]);
}
Then update ActiveTimeEntryProvider to use $this->timeEntryRepository->findActiveByUser($user).
- Step 3: Test the API manually
# Start containers if not running
make start
# Login as admin
curl -c cookies.txt -X POST http://localhost:8082/api/login_check \
-H 'Content-Type: application/json' \
-d '{"username":"admin","password":"admin"}'
# Get active timer (should return empty/null)
curl -b cookies.txt http://localhost:8082/api/time_entries/active
# Create a time entry (timer start)
curl -b cookies.txt -X POST http://localhost:8082/api/time_entries \
-H 'Content-Type: application/json' \
-d '{"startedAt":"2026-03-10T10:00:00+00:00","user":"/api/users/1"}'
# Get active timer (should return the entry)
curl -b cookies.txt http://localhost:8082/api/time_entries/active
# Stop the timer
curl -b cookies.txt -X PATCH http://localhost:8082/api/time_entries/1 \
-H 'Content-Type: application/merge-patch+json' \
-d '{"stoppedAt":"2026-03-10T11:30:00+00:00"}'
# Get active timer (should return null again)
curl -b cookies.txt http://localhost:8082/api/time_entries/active
- Step 4: Commit
git add src/State/ActiveTimeEntryProvider.php src/Repository/TimeEntryRepository.php
git commit -m "feat(time-tracking) : add ActiveTimeEntryProvider for current user timer"
Task 3: Add TimeEntry fixtures
Files:
-
Modify:
src/DataFixtures/AppFixtures.php -
Step 1: Add sample time entries to fixtures
At the end of the load() method in AppFixtures.php, after the existing task fixtures, add time entries for the SIRH project. Use the existing $projectSirh, $admin user, and task type references.
First, add the import at the top of AppFixtures.php alongside the other entity imports:
use App\Entity\TimeEntry;
Then add at the end of the load() method, before the final $manager->flush():
// --- Time Entries (SIRH project, admin user) ---
$timeEntryData = [
['title' => 'Réunion', 'project' => $projectSirh, 'type' => $typeAuth, 'start' => '09:00', 'stop' => '09:45', 'day' => 1],
['title' => 'Page accueil', 'project' => $projectSirh, 'type' => $typePassword, 'start' => '10:00', 'stop' => '12:00', 'day' => 0],
['title' => 'Design admin', 'project' => $projectSirh, 'type' => $typeAuth, 'start' => '09:30', 'stop' => '11:00', 'day' => 2],
['title' => 'Page accueil', 'project' => $projectSirh, 'type' => $typePassword, 'start' => '10:30', 'stop' => '12:15', 'day' => 1],
['title' => 'System os', 'project' => $projectSirh, 'type' => $typeCalendar, 'start' => '13:00', 'stop' => '15:30', 'day' => 0],
['title' => 'Login', 'project' => $projectSirh, 'type' => $typePassword, 'start' => '13:00', 'stop' => '15:00', 'day' => 1],
['title' => 'Script vault', 'project' => $projectSirh, 'type' => $typeCalendar, 'start' => '10:00', 'stop' => '12:00', 'day' => 3],
['title' => 'Script backup BDD', 'project' => $projectSirh, 'type' => $typeAuth, 'start' => '13:30', 'stop' => '15:00', 'day' => 3],
['title' => 'Maquette', 'project' => $projectSirh, 'type' => null, 'start' => '09:00', 'stop' => '11:00', 'day' => 4],
['title' => 'PC compta', 'project' => $projectSirh, 'type' => null, 'start' => '13:30', 'stop' => '15:30', 'day' => 4],
];
$monday = new \DateTimeImmutable('monday this week', new \DateTimeZone('UTC'));
foreach ($timeEntryData as $data) {
$entry = new TimeEntry();
$entry->setTitle($data['title']);
$entry->setUser($admin);
$entry->setProject($data['project']);
$entry->setStartedAt($monday->modify("+{$data['day']} days")->modify($data['start']));
$entry->setStoppedAt($monday->modify("+{$data['day']} days")->modify($data['stop']));
if ($data['type']) {
$entry->addType($data['type']);
}
$manager->persist($entry);
}
Note: The existing $manager->flush() at the end of load() will persist these entries. Do not add a second flush().
- Step 2: Reload fixtures
make db-reset
- Step 3: Verify via API
curl -b cookies.txt "http://localhost:8082/api/time_entries"
Expected: JSON-LD collection with 10 time entries.
- Step 4: Commit
git add src/DataFixtures/AppFixtures.php
git commit -m "feat(time-tracking) : add time entry fixtures"
Chunk 2: Frontend — DTO, Service, Timer Store
Task 4: Create TimeEntry DTO
Files:
-
Create:
frontend/services/dto/time-entry.ts -
Modify:
frontend/services/dto/task.ts -
Step 1: Add
projectfield to Task DTO
In frontend/services/dto/task.ts, add the project field to the Task type. Import Project from ./project:
import type { Project } from './project'
Add to Task type after group:
project: Project
- Step 2: Create TimeEntry DTO
import type { UserData } from './user-data'
import type { Project } from './project'
import type { Task } from './task'
import type { TaskType } from './task-type'
export type TimeEntry = {
id: number
'@id'?: string
title: string | null
description: string | null
startedAt: string
stoppedAt: string | null
user: UserData
project: Project | null
task: Task | null
types: TaskType[]
}
export type TimeEntryWrite = {
title?: string | null
description?: string | null
startedAt: string
stoppedAt?: string | null
user: string
project?: string | null
task?: string | null
types?: string[]
}
- Step 3: Commit
git add frontend/services/dto/time-entry.ts frontend/services/dto/task.ts
git commit -m "feat(time-tracking) : add TimeEntry DTO and project field to Task DTO"
Task 5: Create TimeEntry service
Files:
-
Create:
frontend/services/time-entries.ts -
Step 1: Create the service
Follow the exact same pattern as frontend/services/tasks.ts:
import type { TimeEntry, TimeEntryWrite } from './dto/time-entry'
import type { HydraCollection } from '~/utils/api'
import { extractHydraMembers } from '~/utils/api'
export function useTimeEntryService() {
const api = useApi()
async function getByDateRange(params: {
after: string
before: string
user?: number
types?: number[]
}): Promise<TimeEntry[]> {
const query: Record<string, unknown> = {
'startedAt[after]': params.after,
'startedAt[before]': params.before,
}
if (params.user) {
query.user = `/api/users/${params.user}`
}
const data = await api.get<HydraCollection<TimeEntry>>('/time_entries', query)
return extractHydraMembers(data)
}
async function getActive(): Promise<TimeEntry | null> {
const result = await api.get<TimeEntry | null>('/time_entries/active', {}, { toast: false })
return result ?? null
}
async function create(payload: TimeEntryWrite): Promise<TimeEntry> {
return api.post<TimeEntry>('/time_entries', payload as Record<string, unknown>, {
toastSuccessKey: 'timeEntries.created',
})
}
async function update(id: number, payload: Partial<TimeEntryWrite>): Promise<TimeEntry> {
return api.patch<TimeEntry>(`/time_entries/${id}`, payload as Record<string, unknown>, {
toastSuccessKey: 'timeEntries.updated',
})
}
async function remove(id: number): Promise<void> {
await api.delete(`/time_entries/${id}`, {}, {
toastSuccessKey: 'timeEntries.deleted',
})
}
return { getByDateRange, getActive, create, update, remove }
}
- Step 2: Add i18n keys
In frontend/i18n/locales/fr.json, add:
"timeEntries": {
"created": "Temps enregistré",
"updated": "Temps modifié",
"deleted": "Temps supprimé"
}
- Step 3: Commit
git add frontend/services/time-entries.ts frontend/i18n/locales/fr.json
git commit -m "feat(time-tracking) : add time entry service and i18n keys"
Task 6: Create Timer Store
Files:
-
Create:
frontend/stores/timer.ts -
Step 1: Create the timer store
import { defineStore } from 'pinia'
import type { TimeEntry } from '~/services/dto/time-entry'
import type { Task } from '~/services/dto/task'
export const useTimerStore = defineStore('timer', () => {
const activeEntry = ref<TimeEntry | null>(null)
const now = ref(Date.now())
let intervalId: ReturnType<typeof setInterval> | null = null
const isRunning = computed(() => activeEntry.value !== null)
const elapsed = computed(() => {
if (!activeEntry.value) return 0
const start = new Date(activeEntry.value.startedAt).getTime()
return Math.floor((now.value - start) / 1000)
})
const elapsedFormatted = computed(() => {
const total = elapsed.value
const h = Math.floor(total / 3600)
const m = Math.floor((total % 3600) / 60)
const s = total % 60
return [h, m, s].map((v) => String(v).padStart(2, '0')).join(':')
})
function startTicking() {
stopTicking()
now.value = Date.now()
intervalId = setInterval(() => {
now.value = Date.now()
}, 1000)
}
function stopTicking() {
if (intervalId) {
clearInterval(intervalId)
intervalId = null
}
}
async function fetchActive() {
const { getActive } = useTimeEntryService()
activeEntry.value = await getActive()
if (activeEntry.value) {
startTicking()
} else {
stopTicking()
}
}
async function start() {
const authStore = useAuthStore()
if (!authStore.user) return
if (isRunning.value) {
await stop()
}
const { create } = useTimeEntryService()
activeEntry.value = await create({
startedAt: new Date().toISOString(),
user: `/api/users/${authStore.user.id}`,
})
startTicking()
}
async function startFromTask(task: Task) {
const authStore = useAuthStore()
if (!authStore.user) return
if (isRunning.value) {
await stop()
}
const { create } = useTimeEntryService()
activeEntry.value = await create({
startedAt: new Date().toISOString(),
user: `/api/users/${authStore.user.id}`,
title: task.title,
project: task.project?.['@id'] ?? `/api/projects/${task.project?.id}`,
task: task['@id'] ?? `/api/tasks/${task.id}`,
types: task.types.map((t) => t['@id'] ?? `/api/task_types/${t.id}`),
})
startTicking()
}
async function stop() {
if (!activeEntry.value) return
const { update } = useTimeEntryService()
await update(activeEntry.value.id, {
stoppedAt: new Date().toISOString(),
})
activeEntry.value = null
stopTicking()
}
return {
activeEntry,
isRunning,
elapsed,
elapsedFormatted,
fetchActive,
start,
startFromTask,
stop,
}
})
- Step 2: Commit
git add frontend/stores/timer.ts
git commit -m "feat(time-tracking) : add timer Pinia store"
Chunk 3: Frontend — Sidebar Timer + TaskCard Integration
Task 7: Create SidebarTimer component
Files:
-
Create:
frontend/components/SidebarTimer.vue -
Step 1: Create the component
<template>
<div class="flex items-center gap-2">
<button
class="flex items-center justify-center rounded-full transition-colors"
:class="timerStore.isRunning
? 'bg-red-500 hover:bg-red-600 text-white'
: 'bg-green-500 hover:bg-green-600 text-white'"
style="width: 32px; height: 32px;"
: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="18"
/>
</button>
<span
v-if="!collapsed"
class="font-mono text-sm font-bold"
:class="timerStore.isRunning ? 'text-white' : 'text-neutral-400'"
>
{{ timerStore.elapsedFormatted }}
</span>
</div>
</template>
<script setup lang="ts">
defineProps<{
collapsed: boolean
}>()
const timerStore = useTimerStore()
</script>
- Step 2: Integrate into default.vue layout
In frontend/layouts/default.vue, add the SidebarTimer between the nav and the bottom version/collapse section. Find the closing </nav> tag and add before the <div class="flex flex-col gap-2 items-center p-4">:
<div class="px-4 py-3 border-t border-secondary-500">
<SidebarTimer :collapsed="ui.sidebarCollapsed" />
</div>
Also add fetchActive call in the <script setup>. After const currentProjectId = computed(...):
const timerStore = useTimerStore()
onMounted(() => {
timerStore.fetchActive()
})
onMounted is auto-imported by Nuxt — no manual import needed.
- Step 3: Verify visually
make dev-nuxt
Open http://localhost:3002, login as admin/admin. The timer should appear in the sidebar bottom area with 00:00:00 and a green play button. Click play → timer starts counting. Click stop → resets.
- Step 4: Commit
git add frontend/components/SidebarTimer.vue frontend/layouts/default.vue
git commit -m "feat(time-tracking) : add sidebar timer widget"
Task 8: Connect TaskCard play button to timer
Files:
-
Modify:
frontend/components/TaskCard.vue -
Step 1: Wire the play button
In TaskCard.vue, replace the play button @click.stop with an actual handler:
Replace:
<button
class="shrink-0 text-neutral-400 hover:text-primary-500"
@click.stop
>
<Icon name="mdi:play-circle-outline" size="20" />
</button>
With:
<button
class="shrink-0 text-neutral-400 hover:text-primary-500"
@click.stop="onPlay"
>
<Icon name="mdi:play-circle-outline" size="20" />
</button>
Add in the <script setup>:
const timerStore = useTimerStore()
function onPlay() {
timerStore.startFromTask(props.task)
}
- Step 2: Verify
Go to a project kanban page. Click the play button on a task card. The sidebar timer should start. The API should create a time entry with the task's title, project, and types.
- Step 3: Commit
git add frontend/components/TaskCard.vue
git commit -m "feat(time-tracking) : connect TaskCard play button to timer store"
Chunk 4: Frontend — Time Tracking Page (Calendar Grid)
Task 9: Create TimeEntryDrawer component
Files:
-
Create:
frontend/components/TimeEntryDrawer.vue -
Step 1: Create the drawer
Uses AppDrawer pattern from existing drawers (e.g., TaskDrawer.vue).
<template>
<AppDrawer v-model="isOpen" :title="isEditing ? 'Modifier un temps' : 'Ajouter une Activité'">
<form class="space-y-4" @submit.prevent="onSubmit">
<div>
<label class="mb-1 block text-sm font-semibold text-neutral-700">Titre</label>
<input
v-model="form.title"
type="text"
class="w-full rounded-md border border-neutral-300 px-3 py-2 text-sm focus:border-primary-500 focus:outline-none"
placeholder="Que fais-tu ?"
/>
</div>
<div>
<label class="mb-1 block text-sm font-semibold text-neutral-700">Description</label>
<textarea
v-model="form.description"
rows="3"
class="w-full rounded-md border border-neutral-300 px-3 py-2 text-sm focus:border-primary-500 focus:outline-none"
/>
</div>
<div class="grid grid-cols-2 gap-3">
<div>
<label class="mb-1 block text-sm font-semibold text-neutral-700">Heure début</label>
<input
v-model="form.startedAt"
type="datetime-local"
class="w-full rounded-md border border-neutral-300 px-3 py-2 text-sm focus:border-primary-500 focus:outline-none"
/>
</div>
<div>
<label class="mb-1 block text-sm font-semibold text-neutral-700">Heure fin</label>
<input
v-model="form.stoppedAt"
type="datetime-local"
class="w-full rounded-md border border-neutral-300 px-3 py-2 text-sm focus:border-primary-500 focus:outline-none"
/>
</div>
</div>
<div>
<label class="mb-1 block text-sm font-semibold text-neutral-700">Utilisateur</label>
<select
v-model="form.userId"
class="w-full rounded-md border border-neutral-300 px-3 py-2 text-sm focus:border-primary-500 focus:outline-none"
>
<option v-for="u in users" :key="u.id" :value="u.id">{{ u.username }}</option>
</select>
</div>
<div>
<label class="mb-1 block text-sm font-semibold text-neutral-700">Projet</label>
<select
v-model="form.projectId"
class="w-full rounded-md border border-neutral-300 px-3 py-2 text-sm focus:border-primary-500 focus:outline-none"
>
<option :value="null">— Aucun —</option>
<option v-for="p in projects" :key="p.id" :value="p.id">{{ p.name }}</option>
</select>
</div>
<div>
<label class="mb-1 block text-sm font-semibold text-neutral-700">Type</label>
<select
v-model="form.typeId"
class="w-full rounded-md border border-neutral-300 px-3 py-2 text-sm focus:border-primary-500 focus:outline-none"
>
<option :value="null">— Aucun —</option>
<option v-for="t in types" :key="t.id" :value="t.id">{{ t.label }}</option>
</select>
</div>
<button
type="submit"
class="w-full rounded-md bg-primary-500 px-4 py-2 text-sm font-semibold text-white hover:bg-primary-600 transition"
>
Enregistrer
</button>
</form>
</AppDrawer>
</template>
<script setup lang="ts">
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'
const props = defineProps<{
modelValue: boolean
entry?: TimeEntry | null
prefillStartedAt?: string | null
users: UserData[]
projects: Project[]
types: TaskType[]
}>()
const emit = defineEmits<{
(e: 'update:modelValue', value: boolean): void
(e: 'saved'): void
}>()
const isOpen = computed({
get: () => props.modelValue,
set: (v) => emit('update:modelValue', v),
})
const isEditing = computed(() => !!props.entry)
const authStore = useAuthStore()
const form = reactive({
title: '',
description: '',
startedAt: '',
stoppedAt: '',
userId: authStore.user?.id ?? null as number | null,
projectId: null as number | null,
typeId: null as number | null,
})
watch(() => props.entry, (entry) => {
if (entry) {
form.title = entry.title ?? ''
form.description = entry.description ?? ''
form.startedAt = toLocalDatetimeInput(entry.startedAt)
form.stoppedAt = entry.stoppedAt ? toLocalDatetimeInput(entry.stoppedAt) : ''
form.userId = entry.user?.id ?? authStore.user?.id ?? null
form.projectId = entry.project?.id ?? null
form.typeId = entry.types?.[0]?.id ?? null
} else {
form.title = ''
form.description = ''
form.startedAt = props.prefillStartedAt ? toLocalDatetimeInput(props.prefillStartedAt) : ''
form.stoppedAt = ''
form.userId = authStore.user?.id ?? null
form.projectId = null
form.typeId = null
}
}, { immediate: true })
function toLocalDatetimeInput(iso: string): string {
const d = new Date(iso)
const offset = d.getTimezoneOffset()
const local = new Date(d.getTime() - offset * 60000)
return local.toISOString().slice(0, 16)
}
function toISOFromLocal(localStr: string): string {
return new Date(localStr).toISOString()
}
async function onSubmit() {
const { create, update } = useTimeEntryService()
const payload: Record<string, unknown> = {
title: form.title || null,
description: form.description || null,
startedAt: toISOFromLocal(form.startedAt),
stoppedAt: form.stoppedAt ? toISOFromLocal(form.stoppedAt) : null,
user: `/api/users/${form.userId}`,
project: form.projectId ? `/api/projects/${form.projectId}` : null,
types: form.typeId ? [`/api/task_types/${form.typeId}`] : [],
}
if (isEditing.value && props.entry) {
await update(props.entry.id, payload)
} else {
await create(payload as any)
}
emit('saved')
isOpen.value = false
}
</script>
- Step 2: Commit
git add frontend/components/TimeEntryDrawer.vue
git commit -m "feat(time-tracking) : add TimeEntryDrawer component"
Task 10: Create TimeEntryBlock component
Files:
-
Create:
frontend/components/TimeEntryBlock.vue -
Step 1: Create the block component
This component renders a single time entry block in the calendar grid. It handles drag-to-move and resize (drag bottom edge).
<template>
<div
ref="blockEl"
class="absolute left-1 right-1 cursor-pointer overflow-hidden rounded-md px-2 py-1 text-xs text-white shadow-sm select-none"
:style="blockStyle"
draggable="true"
@click.stop="emit('click', entry)"
@contextmenu.prevent="emit('contextmenu', $event, entry)"
@dragstart="onDragStart"
@dragend="onDragEnd"
>
<div class="font-semibold truncate">{{ entry.title || 'Sans titre' }}</div>
<div v-if="entry.project" class="truncate text-[10px] opacity-80">{{ entry.project.name }}</div>
<div class="mt-0.5 flex items-center gap-1">
<span
v-for="type in entry.types"
:key="type.id"
class="rounded-full px-1.5 py-0.5 text-[9px] font-semibold"
:style="{ backgroundColor: type.color }"
>
{{ type.label }}
</span>
<span class="ml-auto text-[10px] opacity-80">{{ duration }}</span>
</div>
<!-- Resize handle -->
<div
class="absolute bottom-0 left-0 right-0 h-2 cursor-s-resize"
@mousedown.stop.prevent="onResizeStart"
/>
</div>
</template>
<script setup lang="ts">
import type { TimeEntry } from '~/services/dto/time-entry'
const props = defineProps<{
entry: TimeEntry
hourHeight: number
dayStartHour: number
}>()
const emit = defineEmits<{
(e: 'click', entry: TimeEntry): void
(e: 'contextmenu', event: MouseEvent, entry: TimeEntry): void
(e: 'resize', entry: TimeEntry, newStoppedAt: string): void
(e: 'move', entry: TimeEntry, newStartedAt: string, newStoppedAt: string): void
}>()
const blockEl = ref<HTMLElement | null>(null)
const startDate = computed(() => new Date(props.entry.startedAt))
const endDate = computed(() => props.entry.stoppedAt ? new Date(props.entry.stoppedAt) : new Date())
const duration = computed(() => {
const diff = endDate.value.getTime() - startDate.value.getTime()
const h = Math.floor(diff / 3600000)
const m = Math.floor((diff % 3600000) / 60000)
const s = Math.floor((diff % 60000) / 1000)
return [h, m, s].map((v) => String(v).padStart(2, '0')).join(' : ')
})
const blockStyle = computed(() => {
const startMinutes = startDate.value.getHours() * 60 + startDate.value.getMinutes()
const endMinutes = endDate.value.getHours() * 60 + endDate.value.getMinutes()
const topPx = ((startMinutes - props.dayStartHour * 60) / 60) * props.hourHeight
const heightPx = Math.max(((endMinutes - startMinutes) / 60) * props.hourHeight, 20)
const bgColor = props.entry.project?.color ?? '#94a3b8'
return {
top: `${topPx}px`,
height: `${heightPx}px`,
backgroundColor: bgColor,
}
})
function onDragStart(event: DragEvent) {
event.dataTransfer!.effectAllowed = 'move'
event.dataTransfer!.setData('application/time-entry-id', String(props.entry.id))
event.dataTransfer!.setData('application/time-entry-start', props.entry.startedAt)
event.dataTransfer!.setData('application/time-entry-stop', props.entry.stoppedAt ?? '')
;(event.target as HTMLElement).classList.add('opacity-50')
}
function onDragEnd(event: DragEvent) {
;(event.target as HTMLElement).classList.remove('opacity-50')
}
function onResizeStart(event: MouseEvent) {
const startY = event.clientY
const originalHeight = blockEl.value?.offsetHeight ?? 0
function onMouseMove(e: MouseEvent) {
const delta = e.clientY - startY
const newHeight = Math.max(originalHeight + delta, 20)
if (blockEl.value) {
blockEl.value.style.height = `${newHeight}px`
}
}
function onMouseUp(e: MouseEvent) {
document.removeEventListener('mousemove', onMouseMove)
document.removeEventListener('mouseup', onMouseUp)
const delta = e.clientY - startY
const deltaMinutes = Math.round((delta / props.hourHeight) * 60 / 15) * 15
const originalEnd = endDate.value
const newEnd = new Date(originalEnd.getTime() + deltaMinutes * 60000)
emit('resize', props.entry, newEnd.toISOString())
}
document.addEventListener('mousemove', onMouseMove)
document.addEventListener('mouseup', onMouseUp)
}
</script>
- Step 2: Commit
git add frontend/components/TimeEntryBlock.vue
git commit -m "feat(time-tracking) : add TimeEntryBlock component with drag and resize"
Task 11: Create TimeEntryContextMenu component
Files:
-
Create:
frontend/components/TimeEntryContextMenu.vue -
Step 1: Create the context menu
<template>
<Teleport to="body">
<div
v-if="visible"
ref="menuEl"
class="fixed z-50 min-w-36 rounded-md border border-neutral-200 bg-white py-1 shadow-lg"
:style="{ top: `${y}px`, left: `${x}px` }"
>
<button
v-if="entry"
class="flex w-full items-center gap-2 px-4 py-2 text-sm text-neutral-700 hover:bg-neutral-100"
@click="onCopy"
>
<Icon name="mdi:content-copy" size="16" />
Copier
</button>
<button
v-if="canPaste"
class="flex w-full items-center gap-2 px-4 py-2 text-sm text-neutral-700 hover:bg-neutral-100"
@click="onPaste"
>
<Icon name="mdi:content-paste" size="16" />
Coller
</button>
<button
v-if="entry"
class="flex w-full items-center gap-2 px-4 py-2 text-sm text-red-600 hover:bg-red-50"
@click="onDelete"
>
<Icon name="mdi:delete-outline" size="16" />
Supprimer
</button>
</div>
</Teleport>
</template>
<script setup lang="ts">
import type { TimeEntry } from '~/services/dto/time-entry'
const props = defineProps<{
visible: boolean
x: number
y: number
entry?: TimeEntry | null
canPaste: boolean
}>()
const emit = defineEmits<{
(e: 'close'): void
(e: 'copy', entry: TimeEntry): void
(e: 'paste'): void
(e: 'delete', entry: TimeEntry): void
}>()
const menuEl = ref<HTMLElement | null>(null)
function onCopy() {
if (props.entry) emit('copy', props.entry)
emit('close')
}
function onPaste() {
emit('paste')
emit('close')
}
function onDelete() {
if (props.entry) emit('delete', props.entry)
emit('close')
}
function onClickOutside(event: MouseEvent) {
if (menuEl.value && !menuEl.value.contains(event.target as Node)) {
emit('close')
}
}
watch(() => props.visible, (v) => {
if (v) {
setTimeout(() => document.addEventListener('click', onClickOutside), 0)
} else {
document.removeEventListener('click', onClickOutside)
}
})
onUnmounted(() => {
document.removeEventListener('click', onClickOutside)
})
</script>
- Step 2: Commit
git add frontend/components/TimeEntryContextMenu.vue
git commit -m "feat(time-tracking) : add context menu for copy/paste/delete"
Task 12: Create TimeTrackingCalendar component
Files:
-
Create:
frontend/components/TimeTrackingCalendar.vue -
Step 1: Create the calendar grid
This is the largest component. It renders the hour rows and day columns, positions TimeEntryBlock components, and handles drop events for moving entries.
<template>
<div class="relative overflow-auto rounded-lg border border-neutral-200 bg-white" style="max-height: calc(100vh - 220px);">
<!-- Day headers -->
<div class="sticky top-0 z-20 flex border-b border-neutral-200 bg-white">
<div class="w-16 shrink-0 border-r border-neutral-200" />
<div
v-for="day in days"
:key="day.dateStr"
class="flex-1 border-r border-neutral-100 py-2 text-center"
>
<div class="text-lg font-bold" :class="isToday(day.date) ? 'text-orange-500' : 'text-neutral-900'">
{{ day.dayNum }}
</div>
<div class="text-xs" :class="isToday(day.date) ? 'text-orange-500' : 'text-neutral-500'">
{{ day.label }}
</div>
<div class="text-[10px] text-neutral-400">{{ day.totalFormatted }}</div>
</div>
</div>
<!-- Grid body -->
<div class="relative flex">
<!-- Hour labels -->
<div class="w-16 shrink-0">
<div
v-for="hour in hours"
:key="hour"
class="flex items-start justify-end border-r border-neutral-200 pr-2 text-xs text-neutral-400"
:style="{ height: `${hourHeight}px` }"
>
{{ String(hour).padStart(2, '0') }} : 00
</div>
</div>
<!-- Day columns -->
<div
v-for="day in days"
:key="day.dateStr"
class="relative flex-1 border-r border-neutral-100"
@dragover.prevent
@drop="onDropOnDay($event, day)"
@click="onClickGrid($event, day)"
@contextmenu.prevent="onContextMenuGrid($event, day)"
>
<!-- Hour row lines -->
<div
v-for="hour in hours"
:key="hour"
class="border-b border-neutral-100"
:style="{ height: `${hourHeight}px` }"
/>
<!-- Time entry blocks -->
<TimeEntryBlock
v-for="entry in entriesForDay(day.dateStr)"
:key="entry.id"
:entry="entry"
:hour-height="hourHeight"
:day-start-hour="0"
@click="emit('editEntry', $event)"
@contextmenu="(ev, ent) => emit('contextmenu', ev, ent)"
@resize="(ent, newStop) => emit('resizeEntry', ent, newStop)"
/>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import type { TimeEntry } from '~/services/dto/time-entry'
const props = defineProps<{
entries: TimeEntry[]
startDate: Date
viewMode: 'week' | 'day'
}>()
const emit = defineEmits<{
(e: 'editEntry', entry: TimeEntry): void
(e: 'createEntry', startedAt: string): void
(e: 'moveEntry', entry: TimeEntry, newStartedAt: string, newStoppedAt: string): void
(e: 'resizeEntry', entry: TimeEntry, newStoppedAt: string): void
(e: 'contextmenu', event: MouseEvent, entry: TimeEntry | null): void
}>()
const hourHeight = 60
const hours = Array.from({ length: 24 }, (_, i) => i)
const dayLabels = ['Dim', 'Lun', 'Mar', 'Mer', 'Jeu', 'Ven', 'Sam']
const days = computed(() => {
const count = props.viewMode === 'week' ? 7 : 1
const result = []
for (let i = 0; i < count; i++) {
const d = new Date(props.startDate)
d.setDate(d.getDate() + i)
const dateStr = toDateStr(d)
const dayEntries = props.entries.filter((e) => toDateStr(new Date(e.startedAt)) === dateStr)
const totalMs = dayEntries.reduce((sum, e) => {
if (!e.stoppedAt) return sum
return sum + (new Date(e.stoppedAt).getTime() - new Date(e.startedAt).getTime())
}, 0)
const totalH = Math.floor(totalMs / 3600000)
const totalM = Math.floor((totalMs % 3600000) / 60000)
const totalS = Math.floor((totalMs % 60000) / 1000)
result.push({
date: new Date(d),
dateStr,
dayNum: d.getDate(),
label: dayLabels[d.getDay()],
totalFormatted: `${String(totalH).padStart(2, '0')}:${String(totalM).padStart(2, '0')}:${String(totalS).padStart(2, '0')}`,
})
}
return result
})
function toDateStr(d: Date): string {
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
}
function isToday(d: Date): boolean {
return toDateStr(d) === toDateStr(new Date())
}
function entriesForDay(dateStr: string): TimeEntry[] {
return props.entries.filter((e) => toDateStr(new Date(e.startedAt)) === dateStr)
}
function getHourFromY(event: MouseEvent, dayEl: HTMLElement): number {
const rect = dayEl.getBoundingClientRect()
const y = event.clientY - rect.top
const minutes = Math.round((y / hourHeight) * 60 / 15) * 15
return minutes / 60
}
function onClickGrid(event: MouseEvent, day: { date: Date; dateStr: string }) {
const target = event.currentTarget as HTMLElement
const hourDecimal = getHourFromY(event, target)
const h = Math.floor(hourDecimal)
const m = Math.round((hourDecimal - h) * 60)
const d = new Date(day.date)
d.setHours(h, m, 0, 0)
emit('createEntry', d.toISOString())
}
function onContextMenuGrid(event: MouseEvent, day: { date: Date; dateStr: string }) {
emit('contextmenu', event, null)
}
function onDropOnDay(event: DragEvent, day: { date: Date; dateStr: string }) {
const entryId = event.dataTransfer?.getData('application/time-entry-id')
const startStr = event.dataTransfer?.getData('application/time-entry-start')
const stopStr = event.dataTransfer?.getData('application/time-entry-stop')
if (!entryId || !startStr) return
const entry = props.entries.find((e) => e.id === Number(entryId))
if (!entry) return
const target = event.currentTarget as HTMLElement
const hourDecimal = getHourFromY(event as unknown as MouseEvent, target)
const h = Math.floor(hourDecimal)
const m = Math.round((hourDecimal - h) * 60)
const originalStart = new Date(startStr)
const originalStop = stopStr ? new Date(stopStr) : null
const durationMs = originalStop ? originalStop.getTime() - originalStart.getTime() : 3600000
const newStart = new Date(day.date)
newStart.setHours(h, m, 0, 0)
const newStop = new Date(newStart.getTime() + durationMs)
emit('moveEntry', entry, newStart.toISOString(), newStop.toISOString())
}
</script>
- Step 2: Commit
git add frontend/components/TimeTrackingCalendar.vue
git commit -m "feat(time-tracking) : add TimeTrackingCalendar grid component"
Chunk 5: Frontend — Time Tracking Page (Assembly)
Task 13: Create the Time Tracking page
Files:
-
Create:
frontend/pages/time-tracking.vue -
Step 1: Create the page
<template>
<div>
<div class="flex items-center justify-between">
<h1 class="text-2xl font-bold text-neutral-900">Suivi des temps</h1>
<button
class="rounded-md bg-primary-500 px-4 py-2 text-sm font-semibold text-white hover:bg-primary-600 transition"
@click="openCreateDrawer()"
>
+ Ajouter une Activité
</button>
</div>
<div class="mt-4 flex items-center gap-4">
<h2 class="text-lg font-bold text-orange-500">
{{ currentMonthLabel }}
</h2>
<div class="flex items-center gap-1 rounded-md border border-neutral-200">
<button class="px-2 py-1 text-neutral-500 hover:text-neutral-700" @click="navigatePrev">
<Icon name="mdi:chevron-left" size="20" />
</button>
<button
v-for="mode in (['week', 'day'] as const)"
:key="mode"
class="px-3 py-1 text-sm font-semibold transition"
:class="viewMode === mode ? 'bg-primary-500 text-white rounded' : 'text-neutral-500 hover:text-neutral-700'"
@click="viewMode = mode"
>
{{ mode === 'week' ? 'Semaine' : 'Jour' }}
</button>
<button class="px-2 py-1 text-neutral-500 hover:text-neutral-700" @click="navigateNext">
<Icon name="mdi:chevron-right" size="20" />
</button>
</div>
<select
v-model="selectedUserId"
class="rounded-md border border-neutral-200 px-3 py-1.5 text-sm"
@change="loadEntries"
>
<option v-for="u in users" :key="u.id" :value="u.id">{{ u.username }}</option>
</select>
<select
v-model="selectedTypeId"
class="rounded-md border border-neutral-200 px-3 py-1.5 text-sm"
@change="loadEntries"
>
<option :value="null">Type</option>
<option v-for="t in types" :key="t.id" :value="t.id">{{ t.label }}</option>
</select>
</div>
<div class="mt-4">
<TimeTrackingCalendar
:entries="filteredEntries"
:start-date="startDate"
:view-mode="viewMode"
@edit-entry="openEditDrawer"
@create-entry="openCreateDrawer"
@move-entry="onMoveEntry"
@resize-entry="onResizeEntry"
@contextmenu="onContextMenu"
/>
</div>
<TimeEntryDrawer
v-model="drawerOpen"
:entry="editingEntry"
:prefill-started-at="prefillStartedAt"
:users="users"
:projects="projects"
:types="types"
@saved="loadEntries"
/>
<TimeEntryContextMenu
:visible="contextMenu.visible"
:x="contextMenu.x"
:y="contextMenu.y"
:entry="contextMenu.entry"
:can-paste="!!clipboard"
@close="contextMenu.visible = false"
@copy="onCopy"
@paste="onPaste"
@delete="onDelete"
/>
</div>
</template>
<script setup lang="ts">
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 { extractHydraMembers } from '~/utils/api'
useHead({ title: 'Suivi des temps' })
const authStore = useAuthStore()
const timeEntryService = useTimeEntryService()
const viewMode = ref<'week' | 'day'>('week')
const startDate = ref(getMonday(new Date()))
const selectedUserId = ref<number | null>(authStore.user?.id ?? null)
const selectedTypeId = ref<number | null>(null)
const entries = ref<TimeEntry[]>([])
const users = ref<UserData[]>([])
const projects = ref<Project[]>([])
const types = ref<TaskType[]>([])
const drawerOpen = ref(false)
const editingEntry = ref<TimeEntry | null>(null)
const prefillStartedAt = ref<string | null>(null)
const clipboard = ref<TimeEntry | null>(null)
const contextMenu = reactive({
visible: false,
x: 0,
y: 0,
entry: null as TimeEntry | null,
targetDate: null as string | null,
})
const currentMonthLabel = computed(() => {
const d = startDate.value
const months = ['Janvier', 'Février', 'Mars', 'Avril', 'Mai', 'Juin', 'Juillet', 'Août', 'Septembre', 'Octobre', 'Novembre', 'Décembre']
return `${months[d.getMonth()]} ${d.getFullYear()}`
})
const filteredEntries = computed(() => {
if (!selectedTypeId.value) return entries.value
return entries.value.filter((e) =>
e.types.some((t) => t.id === selectedTypeId.value)
)
})
function getMonday(d: Date): Date {
const date = new Date(d)
const day = date.getDay()
const diff = date.getDate() - day + (day === 0 ? -6 : 1)
date.setDate(diff)
date.setHours(0, 0, 0, 0)
return date
}
function navigatePrev() {
const d = new Date(startDate.value)
d.setDate(d.getDate() - (viewMode.value === 'week' ? 7 : 1))
startDate.value = viewMode.value === 'week' ? getMonday(d) : d
loadEntries()
}
function navigateNext() {
const d = new Date(startDate.value)
d.setDate(d.getDate() + (viewMode.value === 'week' ? 7 : 1))
startDate.value = viewMode.value === 'week' ? getMonday(d) : d
loadEntries()
}
function openCreateDrawer(startedAt?: string) {
editingEntry.value = null
prefillStartedAt.value = startedAt ?? null
drawerOpen.value = true
}
function openEditDrawer(entry: TimeEntry) {
editingEntry.value = entry
prefillStartedAt.value = null
drawerOpen.value = true
}
async function onMoveEntry(entry: TimeEntry, newStartedAt: string, newStoppedAt: string) {
await timeEntryService.update(entry.id, { startedAt: newStartedAt, stoppedAt: newStoppedAt })
await loadEntries()
}
async function onResizeEntry(entry: TimeEntry, newStoppedAt: string) {
await timeEntryService.update(entry.id, { stoppedAt: newStoppedAt })
await loadEntries()
}
function onContextMenu(event: MouseEvent, entry: TimeEntry | null) {
contextMenu.visible = true
contextMenu.x = event.clientX
contextMenu.y = event.clientY
contextMenu.entry = entry
}
function onCopy(entry: TimeEntry) {
clipboard.value = entry
}
async function onPaste() {
if (!clipboard.value) return
const { create } = useTimeEntryService()
await create({
title: clipboard.value.title ?? undefined,
description: clipboard.value.description ?? undefined,
startedAt: clipboard.value.startedAt,
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}`),
})
await loadEntries()
}
async function onDelete(entry: TimeEntry) {
await timeEntryService.remove(entry.id)
await loadEntries()
}
async function loadEntries() {
const end = new Date(startDate.value)
end.setDate(end.getDate() + (viewMode.value === 'week' ? 7 : 1))
entries.value = await timeEntryService.getByDateRange({
after: startDate.value.toISOString(),
before: end.toISOString(),
user: selectedUserId.value ?? undefined,
})
}
async function loadReferenceData() {
const api = useApi()
const [usersData, projectsData, typesData] = await Promise.all([
api.get<any>('/users'),
api.get<any>('/projects'),
api.get<any>('/task_types'),
])
users.value = extractHydraMembers(usersData)
projects.value = extractHydraMembers(projectsData)
types.value = extractHydraMembers(typesData)
}
onMounted(async () => {
await loadReferenceData()
await loadEntries()
})
watch(viewMode, () => {
startDate.value = viewMode.value === 'week' ? getMonday(startDate.value) : startDate.value
loadEntries()
})
</script>
- Step 2: Commit
git add frontend/pages/time-tracking.vue
git commit -m "feat(time-tracking) : add time tracking page with calendar, drawer, and context menu"
Task 14: Add sidebar navigation link
Files:
-
Modify:
frontend/layouts/default.vue -
Step 1: Add "Suivi de temps" link
In default.vue, after the "Clients" SidebarLink and before the "Administration" link, add:
<SidebarLink
to="/time-tracking"
icon="mdi:clock-outline"
label="Suivi de temps"
:collapsed="ui.sidebarCollapsed"
/>
- Step 2: Verify the full flow
make dev-nuxt
- Login as
admin/admin - Click "Suivi de temps" in sidebar → page loads with calendar
- Fixtures should show time entries as colored blocks
- Click a block → drawer opens in edit mode
- Click "+ Ajouter une Activité" → drawer opens in create mode
- Click on empty grid slot → drawer opens with pre-filled start time
- Drag a block to another slot → entry moves
- Drag bottom edge of a block → entry resizes
- Right-click on block → context menu with Copy/Delete
- Copy a block, right-click empty slot → Paste
- Start timer from sidebar → timer counts
- Go to kanban, click play on a card → timer starts with task data
- Step 3: Commit
git add frontend/layouts/default.vue
git commit -m "feat(time-tracking) : add time tracking link in sidebar navigation"
Summary
| Chunk | Tasks | Description |
|---|---|---|
| 1 | 1-3 | Backend: Entity, Migration, Provider, Fixtures |
| 2 | 4-6 | Frontend: DTO, Service, Timer Store |
| 3 | 7-8 | Frontend: Sidebar Timer, TaskCard integration |
| 4 | 9-12 | Frontend: Drawer, Block, ContextMenu, Calendar components |
| 5 | 13-14 | Frontend: Page assembly, Sidebar link, Full integration |