1830 lines
55 KiB
Markdown
1830 lines
55 KiB
Markdown
# 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
|
|
<?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
|
|
<?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**
|
|
|
|
```bash
|
|
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**
|
|
|
|
```bash
|
|
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
|
|
<?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:
|
|
|
|
```php
|
|
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**
|
|
|
|
```bash
|
|
# 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**
|
|
|
|
```bash
|
|
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:
|
|
|
|
```php
|
|
use App\Entity\TimeEntry;
|
|
```
|
|
|
|
Then add at the end of the `load()` method, before the final `$manager->flush()`:
|
|
|
|
```php
|
|
// --- 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**
|
|
|
|
```bash
|
|
make db-reset
|
|
```
|
|
|
|
- [ ] **Step 3: Verify via API**
|
|
|
|
```bash
|
|
curl -b cookies.txt "http://localhost:8082/api/time_entries"
|
|
```
|
|
|
|
Expected: JSON-LD collection with 10 time entries.
|
|
|
|
- [ ] **Step 4: Commit**
|
|
|
|
```bash
|
|
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 `project` field to Task DTO**
|
|
|
|
In `frontend/services/dto/task.ts`, add the `project` field to the `Task` type. Import `Project` from `./project`:
|
|
|
|
```typescript
|
|
import type { Project } from './project'
|
|
```
|
|
|
|
Add to `Task` type after `group`:
|
|
|
|
```typescript
|
|
project: Project
|
|
```
|
|
|
|
- [ ] **Step 2: Create TimeEntry DTO**
|
|
|
|
```typescript
|
|
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**
|
|
|
|
```bash
|
|
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`:
|
|
|
|
```typescript
|
|
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:
|
|
|
|
```json
|
|
"timeEntries": {
|
|
"created": "Temps enregistré",
|
|
"updated": "Temps modifié",
|
|
"deleted": "Temps supprimé"
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 3: Commit**
|
|
|
|
```bash
|
|
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**
|
|
|
|
```typescript
|
|
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**
|
|
|
|
```bash
|
|
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**
|
|
|
|
```vue
|
|
<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">`:
|
|
|
|
```vue
|
|
<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(...)`:
|
|
|
|
```typescript
|
|
const timerStore = useTimerStore()
|
|
|
|
onMounted(() => {
|
|
timerStore.fetchActive()
|
|
})
|
|
```
|
|
|
|
`onMounted` is auto-imported by Nuxt — no manual import needed.
|
|
|
|
- [ ] **Step 3: Verify visually**
|
|
|
|
```bash
|
|
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**
|
|
|
|
```bash
|
|
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:
|
|
```vue
|
|
<button
|
|
class="shrink-0 text-neutral-400 hover:text-primary-500"
|
|
@click.stop
|
|
>
|
|
<Icon name="mdi:play-circle-outline" size="20" />
|
|
</button>
|
|
```
|
|
|
|
With:
|
|
```vue
|
|
<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>`:
|
|
|
|
```typescript
|
|
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**
|
|
|
|
```bash
|
|
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`).
|
|
|
|
```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**
|
|
|
|
```bash
|
|
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).
|
|
|
|
```vue
|
|
<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**
|
|
|
|
```bash
|
|
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**
|
|
|
|
```vue
|
|
<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**
|
|
|
|
```bash
|
|
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.
|
|
|
|
```vue
|
|
<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**
|
|
|
|
```bash
|
|
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**
|
|
|
|
```vue
|
|
<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**
|
|
|
|
```bash
|
|
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:
|
|
|
|
```vue
|
|
<SidebarLink
|
|
to="/time-tracking"
|
|
icon="mdi:clock-outline"
|
|
label="Suivi de temps"
|
|
:collapsed="ui.sidebarCollapsed"
|
|
/>
|
|
```
|
|
|
|
- [ ] **Step 2: Verify the full flow**
|
|
|
|
```bash
|
|
make dev-nuxt
|
|
```
|
|
|
|
1. Login as `admin/admin`
|
|
2. Click "Suivi de temps" in sidebar → page loads with calendar
|
|
3. Fixtures should show time entries as colored blocks
|
|
4. Click a block → drawer opens in edit mode
|
|
5. Click "+ Ajouter une Activité" → drawer opens in create mode
|
|
6. Click on empty grid slot → drawer opens with pre-filled start time
|
|
7. Drag a block to another slot → entry moves
|
|
8. Drag bottom edge of a block → entry resizes
|
|
9. Right-click on block → context menu with Copy/Delete
|
|
10. Copy a block, right-click empty slot → Paste
|
|
11. Start timer from sidebar → timer counts
|
|
12. Go to kanban, click play on a card → timer starts with task data
|
|
|
|
- [ ] **Step 3: Commit**
|
|
|
|
```bash
|
|
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 |
|