Compare commits

..

6 Commits

Author SHA1 Message Date
Matthieu
d2e9f9ed65 chore : bump version to 0.2.2
All checks were successful
Auto Tag Develop / tag (push) Successful in 4s
Build Release Artefact / build (push) Successful in 1m31s
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-16 16:35:08 +01:00
Matthieu
c5898fbf74 feat(ui) : add create task button on my-tasks and responsive kanban columns
- Add "Créer une tâche" button on my-tasks page with mandatory project selector
- TaskModal now accepts optional projects prop for project selection in create mode
- Replace fixed-width kanban columns (w-72 shrink-0) with flexible layout (min-w-36 flex-1)
- Add min-w-0 and overflow-x-hidden on default layout to properly contain content
- Kanban now adapts to screen size from 1024px to 1920px+

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-16 16:35:02 +01:00
Matthieu
0180dd3715 chore : bump version to 0.2.1
All checks were successful
Auto Tag Develop / tag (push) Successful in 5s
Build Release Artefact / build (push) Successful in 1m32s
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-16 14:41:38 +01:00
Matthieu
0f99098291 chore : bump version to 0.2.0 and update deploy doc
Some checks failed
Auto Tag Develop / tag (push) Successful in 5s
Build Release Artefact / build (push) Failing after 1m25s
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-16 14:29:41 +01:00
Matthieu
1c6f473dff feat(mcp) : add clientTicket relation to time entries
Add ManyToOne relation from TimeEntry to ClientTicket entity.
MCP tools create-time-entry, update-time-entry, and list-time-entries
now support clientTicketId parameter for linking tickets to time entries.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-16 14:28:31 +01:00
Matthieu
c95fff530c docs(deploy) : add deployment guide and MCP connection tutorial
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-16 14:28:31 +01:00
15 changed files with 424 additions and 41 deletions

View File

@@ -45,6 +45,7 @@ jobs:
set -euo pipefail set -euo pipefail
mkdir -p release mkdir -p release
tar -czf "release/lesstime-${GITHUB_REF_NAME}.tar.gz" \ tar -czf "release/lesstime-${GITHUB_REF_NAME}.tar.gz" \
.env \
bin \ bin \
config \ config \
migrations \ migrations \

View File

@@ -1,2 +1,2 @@
parameters: parameters:
app.version: '0.1.2' app.version: '0.2.2'

View File

@@ -1,5 +1,6 @@
server { server {
listen 80; listen 80;
listen [::]:80;
server_name project.malio-dev.fr; server_name project.malio-dev.fr;
root /var/www/lesstime/frontend/.output/public; root /var/www/lesstime/frontend/.output/public;
@@ -37,7 +38,6 @@ server {
fastcgi_param SCRIPT_FILENAME /var/www/lesstime/public/index.php; fastcgi_param SCRIPT_FILENAME /var/www/lesstime/public/index.php;
fastcgi_param DOCUMENT_ROOT /var/www/lesstime/public; fastcgi_param DOCUMENT_ROOT /var/www/lesstime/public;
fastcgi_pass unix:/run/php/php8.4-fpm.sock; fastcgi_pass unix:/run/php/php8.4-fpm.sock;
internal;
} }
location ~ \.php$ { location ~ \.php$ {

213
docs/deploy.md Normal file
View File

@@ -0,0 +1,213 @@
# Deploiement sur serveur Ubuntu (sans Docker)
## Prerequis
- Ubuntu 22.04+ avec PHP 8.4, Node 24, PostgreSQL 16, Nginx
- Acces root ou sudo sur le serveur
## 1. Preparer la BDD
```bash
sudo -u postgres createuser lesstime
sudo -u postgres createdb -O lesstime lesstime
sudo -u postgres psql -c "ALTER USER lesstime WITH PASSWORD 'ton-mdp';"
```
## 2. Creer les dossiers
```bash
sudo mkdir -p /var/www/lesstime/var/log /var/www/lesstime/var/cache /var/www/lesstime/config/jwt
sudo chown -R www-data:www-data /var/www/lesstime
```
## 3. Configurer l'environnement
```bash
sudo nano /var/www/lesstime/.env
```
Contenu minimal :
```ini
APP_ENV=prod
```
```bash
sudo nano /var/www/lesstime/.env.local
```
Contenu :
```ini
APP_ENV=prod
APP_SECRET=<random-hex-32>
APP_DEBUG=0
DEFAULT_URI=http://project.malio-dev.fr/
CORS_ALLOW_ORIGIN='^https?://project\.malio-dev\.fr$'
DATABASE_URL="postgresql://lesstime:<mdp>@localhost:5432/lesstime?serverVersion=16&charset=utf8"
JWT_SECRET_KEY=%kernel.project_dir%/config/jwt/private.pem
JWT_PUBLIC_KEY=%kernel.project_dir%/config/jwt/public.pem
JWT_PASSPHRASE=<passphrase>
JWT_COOKIE_SECURE=0
JWT_TOKEN_TTL=86400
JWT_COOKIE_TTL=86400
ENCRYPTION_KEY=<random-hex-32>
```
> `JWT_COOKIE_SECURE=0` car HTTP. Passer a `1` si HTTPS.
## 4. Installer le script de deploy
```bash
sudo cp script/deploy-release.sh /usr/local/bin/deploy-lesstime
sudo chmod +x /usr/local/bin/deploy-lesstime
```
Si le repo Gitea est prive, configurer un token :
```bash
echo "ton-token-gitea" | sudo tee /etc/lesstime-release-token
sudo chmod 600 /etc/lesstime-release-token
```
## 5. Deployer une release
```bash
sudo /usr/local/bin/deploy-lesstime v0.2.1
```
Le script telecharge l'artefact, extrait les fichiers, clear le cache et lance les migrations.
## 6. Generer les cles JWT
```bash
cd /var/www/lesstime
sudo -u www-data php bin/console lexik:jwt:generate-keypair --skip-if-exists --env=prod
```
## 7. Configurer Nginx
```bash
sudo cp deploy/nginx/lesstime.conf /etc/nginx/sites-available/lesstime
sudo ln -sf /etc/nginx/sites-available/lesstime /etc/nginx/sites-enabled/
sudo nginx -t && sudo systemctl reload nginx
```
## 8. Creer le premier user admin
Hasher un mot de passe :
```bash
php /var/www/lesstime/bin/console security:hash-password --env=prod
```
Choisir `App\Entity\User`, taper le mdp, copier le hash. Puis :
```bash
sudo -u postgres psql lesstime -c "INSERT INTO \"user\" (username, roles, password, created_at) VALUES ('admin', '[\"ROLE_ADMIN\"]', '<le-hash>', NOW());"
```
## 9. Tester
```bash
curl http://project.malio-dev.fr/api/version
curl http://project.malio-dev.fr/
```
---
# Connecter le serveur MCP a Claude Code
Le serveur MCP expose 22 tools (projets, taches, time tracking avec liaison tickets client, metadonnees) via le endpoint HTTP `/_mcp`.
## 1. Generer un token API
Sur le serveur (ou en local via Docker) :
```bash
# Production (serveur)
php /var/www/lesstime/bin/console app:generate-api-token admin --env=prod
# Dev (Docker)
docker exec -it php-lesstime-fpm php bin/console app:generate-api-token admin
```
La commande affiche un token de 64 caracteres. Ce token est lie a l'utilisateur et stocke en base (champ `apiToken` de l'entite `User`).
## 2. Configurer Claude Code
### Transport HTTP (recommande pour la prod)
Creer ou modifier `.mcp.json` a la racine du projet :
```json
{
"mcpServers": {
"lesstime": {
"type": "http",
"url": "http://project.malio-dev.fr/_mcp",
"headers": {
"Authorization": "Bearer <ton-token>"
}
}
}
}
```
### Transport STDIO (dev local via Docker)
```json
{
"mcpServers": {
"lesstime-local": {
"command": "docker",
"args": [
"exec",
"-i",
"php-lesstime-fpm",
"php",
"bin/console",
"mcp:server"
]
}
}
}
```
### Transport STDIO via SSH (prod sans endpoint HTTP)
```json
{
"mcpServers": {
"lesstime": {
"command": "ssh",
"args": [
"user@serveur",
"php",
"/var/www/lesstime/bin/console",
"mcp:server",
"--env=prod"
]
}
}
}
```
## 3. Redemarrer Claude Code
Apres modification de `.mcp.json`, relancer Claude Code pour qu'il detecte le serveur.
## 4. Verifier
Demander a Claude d'utiliser un outil MCP, par exemple :
- "Liste les projets sur Lesstime"
- "Cree une tache dans le projet LT"
## Tools disponibles
| Domaine | Tools |
|---------|-------|
| Projets | list-projects, get-project, create-project, update-project |
| Taches | list-tasks, get-task, create-task, update-task, delete-task |
| Metadonnees | list-statuses, list-priorities, list-efforts, list-tags, list-groups, create-group, update-group |
| Time tracking | list-time-entries, create-time-entry, update-time-entry, delete-time-entry (supporte clientTicketId) |
| Reference | list-users, list-clients |

View File

@@ -65,6 +65,20 @@
@blur="touched.title = true" @blur="touched.title = true"
/> />
<!-- Project select (create mode with project list) -->
<div v-if="showProjectSelect" class="mt-4">
<MalioSelect
v-model="form.projectId"
:options="projectOptions"
label="Projet *"
empty-option-label="Sélectionner un projet"
min-width="w-full"
/>
<p v-if="touched.project && !form.projectId" class="mt-1 text-xs text-red-500">
Le projet est requis
</p>
</div>
<!-- Two-column selects --> <!-- Two-column selects -->
<div class="mt-4 grid grid-cols-1 gap-x-6 gap-y-4 sm:grid-cols-2"> <div class="mt-4 grid grid-cols-1 gap-x-6 gap-y-4 sm:grid-cols-2">
<MalioSelect <MalioSelect
@@ -266,6 +280,8 @@ import type { TaskGroup } from '~/services/dto/task-group'
import type { UserData } from '~/services/dto/user-data' import type { UserData } from '~/services/dto/user-data'
import { useTaskService } from '~/services/tasks' import { useTaskService } from '~/services/tasks'
import type { Project } from '~/services/dto/project'
const props = defineProps<{ const props = defineProps<{
modelValue: boolean modelValue: boolean
task: Task | null task: Task | null
@@ -276,6 +292,7 @@ const props = defineProps<{
tags: TaskTag[] tags: TaskTag[]
groups: TaskGroup[] groups: TaskGroup[]
users: UserData[] users: UserData[]
projects?: Project[]
}>() }>()
const emit = defineEmits<{ const emit = defineEmits<{
@@ -318,10 +335,12 @@ const form = reactive({
groupId: null as number | null, groupId: null as number | null,
tagIds: [] as number[], tagIds: [] as number[],
clientTicketId: null as number | null, clientTicketId: null as number | null,
projectId: null as number | null,
}) })
const touched = reactive({ const touched = reactive({
title: false, title: false,
project: false,
}) })
const statusOptions = computed(() => const statusOptions = computed(() =>
@@ -340,8 +359,22 @@ const userOptions = computed(() =>
props.users.map(u => ({ label: u.username, value: u.id })) props.users.map(u => ({ label: u.username, value: u.id }))
) )
const groupOptions = computed(() => const groupOptions = computed(() => {
props.groups.map(g => ({ label: g.title, value: g.id })) let filtered = props.groups
if (showProjectSelect.value && form.projectId) {
filtered = filtered.filter(g => g.project?.id === form.projectId)
}
return filtered.map(g => ({ label: g.title, value: g.id }))
})
const showProjectSelect = computed(() => !!props.projects?.length && !isEditing.value)
const projectOptions = computed(() =>
(props.projects ?? []).map(p => ({ label: p.name, value: p.id }))
)
const resolvedProjectId = computed(() =>
showProjectSelect.value ? form.projectId : props.projectId
) )
const canArchive = computed(() => { const canArchive = computed(() => {
@@ -385,8 +418,10 @@ function populateForm(task: Task | null) {
form.groupId = null form.groupId = null
form.tagIds = [] form.tagIds = []
form.clientTicketId = null form.clientTicketId = null
form.projectId = null
} }
touched.title = false touched.title = false
touched.project = false
} }
watch(() => props.modelValue, async (open) => { watch(() => props.modelValue, async (open) => {
@@ -394,9 +429,14 @@ watch(() => props.modelValue, async (open) => {
confirmDeleteDocOpen.value = false confirmDeleteDocOpen.value = false
documentToDelete.value = null documentToDelete.value = null
populateForm(props.task) populateForm(props.task)
try { const pid = resolvedProjectId.value
clientTickets.value = await clientTicketService.getAll({ project: props.projectId }) if (pid) {
} catch { try {
clientTickets.value = await clientTicketService.getAll({ project: pid })
} catch {
clientTickets.value = []
}
} else {
clientTickets.value = [] clientTickets.value = []
} }
if (props.task?.project?.giteaOwner && props.task?.project?.giteaRepo && !giteaUrl.value) { if (props.task?.project?.giteaOwner && props.task?.project?.giteaRepo && !giteaUrl.value) {
@@ -426,6 +466,22 @@ const clientTicketOptions = computed(() =>
clientTickets.value.map(ct => ({ label: `CT-${String(ct.number).padStart(3, '0')}${ct.title}`, value: ct.id })) clientTickets.value.map(ct => ({ label: `CT-${String(ct.number).padStart(3, '0')}${ct.title}`, value: ct.id }))
) )
// Reset group and reload client tickets when project changes in create mode
watch(() => form.projectId, async (pid) => {
if (!showProjectSelect.value) return
form.groupId = null
form.clientTicketId = null
if (pid) {
try {
clientTickets.value = await clientTicketService.getAll({ project: pid })
} catch {
clientTickets.value = []
}
} else {
clientTickets.value = []
}
})
const authStore = useAuthStore() const authStore = useAuthStore()
const isAdmin = computed(() => authStore.user?.roles?.includes('ROLE_ADMIN') ?? false) const isAdmin = computed(() => authStore.user?.roles?.includes('ROLE_ADMIN') ?? false)
@@ -541,7 +597,9 @@ async function handleUnarchive() {
async function handleSubmit() { async function handleSubmit() {
touched.title = true touched.title = true
touched.project = true
if (!form.title.trim()) return if (!form.title.trim()) return
if (showProjectSelect.value && !form.projectId) return
isSubmitting.value = true isSubmitting.value = true
try { try {
@@ -553,7 +611,7 @@ async function handleSubmit() {
priority: form.priorityId ? `/api/task_priorities/${form.priorityId}` : null, priority: form.priorityId ? `/api/task_priorities/${form.priorityId}` : null,
assignee: form.assigneeId ? `/api/users/${form.assigneeId}` : null, assignee: form.assigneeId ? `/api/users/${form.assigneeId}` : null,
group: form.groupId ? `/api/task_groups/${form.groupId}` : null, group: form.groupId ? `/api/task_groups/${form.groupId}` : null,
project: `/api/projects/${props.projectId}`, project: `/api/projects/${resolvedProjectId.value}`,
tags: form.tagIds.map(id => `/api/task_tags/${id}`), tags: form.tagIds.map(id => `/api/task_tags/${id}`),
clientTicket: form.clientTicketId ? `/api/client_tickets/${form.clientTicketId}` : null, clientTicket: form.clientTicketId ? `/api/client_tickets/${form.clientTicketId}` : null,
} }

View File

@@ -112,7 +112,8 @@
"allEfforts": "Tous les efforts", "allEfforts": "Tous les efforts",
"allAssignees": "Tous", "allAssignees": "Tous",
"noTasks": "Aucune tâche", "noTasks": "Aucune tâche",
"backlog": "Backlog" "backlog": "Backlog",
"createTask": "Créer une tâche"
}, },
"dashboard": { "dashboard": {
"title": "Tableau de bord", "title": "Tableau de bord",

View File

@@ -123,9 +123,9 @@
</div> </div>
</aside> </aside>
<div class="h-full flex-1 flex flex-col min-h-0"> <div class="h-full flex-1 flex flex-col min-h-0 min-w-0">
<AppTopNav :user="auth.user" /> <AppTopNav :user="auth.user" />
<main class="flex flex-1 flex-col overflow-y-auto bg-white px-4 pb-24 sm:px-8 lg:px-16"> <main class="flex flex-1 flex-col overflow-y-auto overflow-x-hidden bg-white px-4 pb-24 sm:px-8 lg:px-16">
<div aria-hidden="true" class="pointer-events-none sticky top-0 z-30 h-8 flex-shrink-0 bg-white sm:h-12" /> <div aria-hidden="true" class="pointer-events-none sticky top-0 z-30 h-8 flex-shrink-0 bg-white sm:h-12" />
<slot/> <slot/>
</main> </main>

View File

@@ -214,6 +214,11 @@ async function onDropBacklog(event: DragEvent) {
} }
// Modal // Modal
function openTaskCreate() {
selectedTask.value = null
taskModalOpen.value = true
}
function openTaskEdit(task: Task) { function openTaskEdit(task: Task) {
selectedTask.value = task selectedTask.value = task
taskModalOpen.value = true taskModalOpen.value = true
@@ -229,28 +234,37 @@ onMounted(() => {
</script> </script>
<template> <template>
<div> <div class="min-w-0">
<!-- Header + Filters --> <!-- Header + Filters -->
<div class="sticky top-8 z-20 bg-white pb-4 sm:top-12"> <div class="sticky top-8 z-20 bg-white pb-4 sm:top-12">
<div class="flex items-center justify-between gap-3"> <div class="flex items-center justify-between gap-3">
<h1 class="text-xl font-bold text-primary-500 sm:text-2xl">{{ $t('myTasks.title') }}</h1> <h1 class="text-xl font-bold text-primary-500 sm:text-2xl">{{ $t('myTasks.title') }}</h1>
<div class="flex gap-1"> <div class="flex items-center gap-2">
<button <button
class="flex items-center justify-center rounded-md p-1.5 transition-colors" class="flex items-center gap-1.5 rounded-lg bg-primary-500 px-3 py-1.5 text-sm font-semibold text-white transition-colors hover:bg-secondary-500"
:class="viewMode === 'kanban' ? 'bg-primary-500 text-white' : 'text-neutral-400 hover:text-primary-500'" @click="openTaskCreate"
:title="$t('myTasks.viewKanban')"
@click="viewMode = 'kanban'"
> >
<Icon name="mdi:view-column-outline" size="18" /> <Icon name="mdi:plus" size="18" />
</button> {{ $t('myTasks.createTask') }}
<button
class="flex items-center justify-center rounded-md p-1.5 transition-colors"
:class="viewMode === 'list' ? 'bg-primary-500 text-white' : 'text-neutral-400 hover:text-primary-500'"
:title="$t('myTasks.viewList')"
@click="viewMode = 'list'"
>
<Icon name="mdi:view-list-outline" size="18" />
</button> </button>
<div class="flex gap-1">
<button
class="flex items-center justify-center rounded-md p-1.5 transition-colors"
:class="viewMode === 'kanban' ? 'bg-primary-500 text-white' : 'text-neutral-400 hover:text-primary-500'"
:title="$t('myTasks.viewKanban')"
@click="viewMode = 'kanban'"
>
<Icon name="mdi:view-column-outline" size="18" />
</button>
<button
class="flex items-center justify-center rounded-md p-1.5 transition-colors"
:class="viewMode === 'list' ? 'bg-primary-500 text-white' : 'text-neutral-400 hover:text-primary-500'"
:title="$t('myTasks.viewList')"
@click="viewMode = 'list'"
>
<Icon name="mdi:view-list-outline" size="18" />
</button>
</div>
</div> </div>
</div> </div>
@@ -314,11 +328,11 @@ onMounted(() => {
<!-- Kanban View --> <!-- Kanban View -->
<div v-if="viewMode === 'kanban'"> <div v-if="viewMode === 'kanban'">
<div class="mt-6 flex gap-4 overflow-x-auto pb-4"> <div class="mt-6 flex gap-3 overflow-x-auto pb-4">
<div <div
v-for="status in sortedStatuses" v-for="status in sortedStatuses"
:key="status.id" :key="status.id"
class="flex w-72 shrink-0 flex-col rounded-lg transition-colors" class="flex min-w-36 flex-1 flex-col rounded-lg transition-colors"
:class="dragOverStatusId === status.id ? 'bg-neutral-200' : 'bg-neutral-50'" :class="dragOverStatusId === status.id ? 'bg-neutral-200' : 'bg-neutral-50'"
@dragover.prevent @dragover.prevent
@dragenter.prevent="onDragEnter(status.id)" @dragenter.prevent="onDragEnter(status.id)"
@@ -446,6 +460,7 @@ onMounted(() => {
:tags="tags" :tags="tags"
:groups="selectedTask?.project?.id ? groups.filter(g => g.project?.id === selectedTask?.project?.id) : groups" :groups="selectedTask?.project?.id ? groups.filter(g => g.project?.id === selectedTask?.project?.id) : groups"
:users="users" :users="users"
:projects="projects"
@saved="onSaved" @saved="onSaved"
/> />
</div> </div>

View File

@@ -1,5 +1,5 @@
<template> <template>
<div> <div class="min-w-0">
<div class="sticky top-8 z-20 bg-white pb-4 sm:top-12"> <div class="sticky top-8 z-20 bg-white pb-4 sm:top-12">
<div class="flex items-center justify-between gap-3"> <div class="flex items-center justify-between gap-3">
<h1 class="text-xl font-bold text-primary-500 sm:text-2xl">{{ project?.name ?? '' }}</h1> <h1 class="text-xl font-bold text-primary-500 sm:text-2xl">{{ project?.name ?? '' }}</h1>
@@ -62,11 +62,11 @@
</div> </div>
<!-- Kanban --> <!-- Kanban -->
<div class="mt-6 flex gap-4 overflow-x-auto pb-4"> <div class="mt-6 flex gap-3 overflow-x-auto pb-4">
<div <div
v-for="status in statuses" v-for="status in statuses"
:key="status.id" :key="status.id"
class="flex w-72 shrink-0 flex-col rounded-lg transition-colors" class="flex min-w-36 flex-1 flex-col rounded-lg transition-colors"
:class="dragOverStatusId === status.id ? 'bg-neutral-200' : 'bg-neutral-50'" :class="dragOverStatusId === status.id ? 'bg-neutral-200' : 'bg-neutral-50'"
@dragover.prevent @dragover.prevent
@dragenter.prevent="onDragEnter(status.id)" @dragenter.prevent="onDragEnter(status.id)"

View File

@@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20260316124157 extends AbstractMigration
{
public function getDescription(): string
{
return '';
}
public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
$this->addSql('ALTER TABLE time_entry ADD client_ticket_id INT DEFAULT NULL');
$this->addSql('ALTER TABLE time_entry ADD CONSTRAINT FK_6E537C0C9B2097DD FOREIGN KEY (client_ticket_id) REFERENCES client_ticket (id) ON DELETE SET NULL NOT DEFERRABLE');
$this->addSql('CREATE INDEX IDX_6E537C0C9B2097DD ON time_entry (client_ticket_id)');
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql('ALTER TABLE time_entry DROP CONSTRAINT FK_6E537C0C9B2097DD');
$this->addSql('DROP INDEX IDX_6E537C0C9B2097DD');
$this->addSql('ALTER TABLE time_entry DROP client_ticket_id');
}
}

View File

@@ -85,6 +85,11 @@ class TimeEntry
#[Groups(['time_entry:read', 'time_entry:write'])] #[Groups(['time_entry:read', 'time_entry:write'])]
private ?Task $task = null; private ?Task $task = null;
#[ORM\ManyToOne(targetEntity: ClientTicket::class)]
#[ORM\JoinColumn(nullable: true, onDelete: 'SET NULL')]
#[Groups(['time_entry:read', 'time_entry:write'])]
private ?ClientTicket $clientTicket = null;
/** @var Collection<int, TaskTag> */ /** @var Collection<int, TaskTag> */
#[ORM\ManyToMany(targetEntity: TaskTag::class)] #[ORM\ManyToMany(targetEntity: TaskTag::class)]
#[ORM\JoinTable( #[ORM\JoinTable(
@@ -189,6 +194,18 @@ class TimeEntry
return $this; return $this;
} }
public function getClientTicket(): ?ClientTicket
{
return $this->clientTicket;
}
public function setClientTicket(?ClientTicket $clientTicket): static
{
$this->clientTicket = $clientTicket;
return $this;
}
/** @return Collection<int, TaskTag> */ /** @return Collection<int, TaskTag> */
public function getTags(): Collection public function getTags(): Collection
{ {

View File

@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace App\Mcp\Tool; namespace App\Mcp\Tool;
use App\Entity\ClientTicket;
use App\Entity\Project; use App\Entity\Project;
use App\Entity\Task; use App\Entity\Task;
use App\Entity\TaskDocument; use App\Entity\TaskDocument;
@@ -239,22 +240,39 @@ final class Serializer
]; ];
} }
/**
* @return null|array{id: ?int, number: ?int, title: ?string}
*/
public static function clientTicketRef(?ClientTicket $ticket): ?array
{
if (null === $ticket) {
return null;
}
return [
'id' => $ticket->getId(),
'number' => $ticket->getNumber(),
'title' => $ticket->getTitle(),
];
}
/** /**
* @return array<string, mixed> * @return array<string, mixed>
*/ */
public static function timeEntry(TimeEntry $entry): array public static function timeEntry(TimeEntry $entry): array
{ {
return [ return [
'id' => $entry->getId(), 'id' => $entry->getId(),
'title' => $entry->getTitle(), 'title' => $entry->getTitle(),
'description' => $entry->getDescription(), 'description' => $entry->getDescription(),
'startedAt' => $entry->getStartedAt()?->format('c'), 'startedAt' => $entry->getStartedAt()?->format('c'),
'stoppedAt' => $entry->getStoppedAt()?->format('c'), 'stoppedAt' => $entry->getStoppedAt()?->format('c'),
'duration' => self::durationMinutes($entry), 'duration' => self::durationMinutes($entry),
'user' => self::user($entry->getUser()), 'user' => self::user($entry->getUser()),
'project' => $entry->getProject() ? self::projectRef($entry->getProject()) : null, 'project' => $entry->getProject() ? self::projectRef($entry->getProject()) : null,
'task' => self::taskRef($entry->getTask()), 'task' => self::taskRef($entry->getTask()),
'tags' => self::tags($entry->getTags()), 'clientTicket' => self::clientTicketRef($entry->getClientTicket()),
'tags' => self::tags($entry->getTags()),
]; ];
} }

View File

@@ -6,6 +6,7 @@ namespace App\Mcp\Tool\TimeEntry;
use App\Entity\TimeEntry; use App\Entity\TimeEntry;
use App\Mcp\Tool\Serializer; use App\Mcp\Tool\Serializer;
use App\Repository\ClientTicketRepository;
use App\Repository\ProjectRepository; use App\Repository\ProjectRepository;
use App\Repository\TaskRepository; use App\Repository\TaskRepository;
use App\Repository\TaskTagRepository; use App\Repository\TaskTagRepository;
@@ -28,6 +29,7 @@ class CreateTimeEntryTool
private readonly TaskRepository $taskRepository, private readonly TaskRepository $taskRepository,
private readonly TaskTagRepository $taskTagRepository, private readonly TaskTagRepository $taskTagRepository,
private readonly TimeEntryRepository $timeEntryRepository, private readonly TimeEntryRepository $timeEntryRepository,
private readonly ClientTicketRepository $clientTicketRepository,
) {} ) {}
public function __invoke( public function __invoke(
@@ -39,6 +41,7 @@ class CreateTimeEntryTool
?int $taskId = null, ?int $taskId = null,
?array $tagIds = null, ?array $tagIds = null,
?string $description = null, ?string $description = null,
?int $clientTicketId = null,
): string { ): string {
$user = $this->userRepository->find($userId); $user = $this->userRepository->find($userId);
if (null === $user) { if (null === $user) {
@@ -80,6 +83,13 @@ class CreateTimeEntryTool
} }
$entry->setTask($task); $entry->setTask($task);
} }
if (null !== $clientTicketId) {
$clientTicket = $this->clientTicketRepository->find($clientTicketId);
if (null === $clientTicket) {
throw new InvalidArgumentException(sprintf('ClientTicket with ID %d not found.', $clientTicketId));
}
$entry->setClientTicket($clientTicket);
}
if (null !== $tagIds) { if (null !== $tagIds) {
foreach ($tagIds as $tagId) { foreach ($tagIds as $tagId) {
$tag = $this->taskTagRepository->find($tagId); $tag = $this->taskTagRepository->find($tagId);

View File

@@ -20,6 +20,7 @@ class ListTimeEntriesTool
?int $userId = null, ?int $userId = null,
?int $projectId = null, ?int $projectId = null,
?int $taskId = null, ?int $taskId = null,
?int $clientTicketId = null,
?string $startDate = null, ?string $startDate = null,
?string $endDate = null, ?string $endDate = null,
int $limit = 100, int $limit = 100,
@@ -31,6 +32,7 @@ class ListTimeEntriesTool
->leftJoin('te.project', 'p')->addSelect('p') ->leftJoin('te.project', 'p')->addSelect('p')
->leftJoin('te.task', 't')->addSelect('t') ->leftJoin('te.task', 't')->addSelect('t')
->leftJoin('te.tags', 'tg')->addSelect('tg') ->leftJoin('te.tags', 'tg')->addSelect('tg')
->leftJoin('te.clientTicket', 'ct')->addSelect('ct')
->orderBy('te.startedAt', 'DESC') ->orderBy('te.startedAt', 'DESC')
->setMaxResults($limit) ->setMaxResults($limit)
; ;
@@ -44,6 +46,9 @@ class ListTimeEntriesTool
if (null !== $taskId) { if (null !== $taskId) {
$qb->andWhere('t.id = :taskId')->setParameter('taskId', $taskId); $qb->andWhere('t.id = :taskId')->setParameter('taskId', $taskId);
} }
if (null !== $clientTicketId) {
$qb->andWhere('ct.id = :clientTicketId')->setParameter('clientTicketId', $clientTicketId);
}
if (null !== $startDate) { if (null !== $startDate) {
$qb->andWhere('te.startedAt >= :startDate') $qb->andWhere('te.startedAt >= :startDate')
->setParameter('startDate', new DateTimeImmutable($startDate.' 00:00:00')) ->setParameter('startDate', new DateTimeImmutable($startDate.' 00:00:00'))

View File

@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace App\Mcp\Tool\TimeEntry; namespace App\Mcp\Tool\TimeEntry;
use App\Mcp\Tool\Serializer; use App\Mcp\Tool\Serializer;
use App\Repository\ClientTicketRepository;
use App\Repository\ProjectRepository; use App\Repository\ProjectRepository;
use App\Repository\TaskRepository; use App\Repository\TaskRepository;
use App\Repository\TaskTagRepository; use App\Repository\TaskTagRepository;
@@ -24,6 +25,7 @@ class UpdateTimeEntryTool
private readonly ProjectRepository $projectRepository, private readonly ProjectRepository $projectRepository,
private readonly TaskRepository $taskRepository, private readonly TaskRepository $taskRepository,
private readonly TaskTagRepository $taskTagRepository, private readonly TaskTagRepository $taskTagRepository,
private readonly ClientTicketRepository $clientTicketRepository,
private readonly EntityManagerInterface $entityManager, private readonly EntityManagerInterface $entityManager,
) {} ) {}
@@ -36,6 +38,7 @@ class UpdateTimeEntryTool
?int $taskId = null, ?int $taskId = null,
?array $tagIds = null, ?array $tagIds = null,
?string $description = null, ?string $description = null,
?int $clientTicketId = null,
): string { ): string {
$entry = $this->timeEntryRepository->find($id); $entry = $this->timeEntryRepository->find($id);
@@ -69,6 +72,13 @@ class UpdateTimeEntryTool
} }
$entry->setTask($task); $entry->setTask($task);
} }
if (null !== $clientTicketId) {
$clientTicket = $this->clientTicketRepository->find($clientTicketId);
if (null === $clientTicket) {
throw new InvalidArgumentException(sprintf('ClientTicket with ID %d not found.', $clientTicketId));
}
$entry->setClientTicket($clientTicket);
}
if (null !== $tagIds) { if (null !== $tagIds) {
foreach ($entry->getTags()->toArray() as $existingTag) { foreach ($entry->getTags()->toArray() as $existingTag) {
$entry->removeTag($existingTag); $entry->removeTag($existingTag);