Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d2e9f9ed65 | ||
|
|
c5898fbf74 | ||
|
|
0180dd3715 | ||
|
|
0f99098291 | ||
|
|
1c6f473dff | ||
|
|
c95fff530c |
@@ -45,6 +45,7 @@ jobs:
|
||||
set -euo pipefail
|
||||
mkdir -p release
|
||||
tar -czf "release/lesstime-${GITHUB_REF_NAME}.tar.gz" \
|
||||
.env \
|
||||
bin \
|
||||
config \
|
||||
migrations \
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
parameters:
|
||||
app.version: '0.1.2'
|
||||
app.version: '0.2.2'
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
server {
|
||||
listen 80;
|
||||
listen [::]:80;
|
||||
server_name project.malio-dev.fr;
|
||||
|
||||
root /var/www/lesstime/frontend/.output/public;
|
||||
@@ -37,7 +38,6 @@ server {
|
||||
fastcgi_param SCRIPT_FILENAME /var/www/lesstime/public/index.php;
|
||||
fastcgi_param DOCUMENT_ROOT /var/www/lesstime/public;
|
||||
fastcgi_pass unix:/run/php/php8.4-fpm.sock;
|
||||
internal;
|
||||
}
|
||||
|
||||
location ~ \.php$ {
|
||||
|
||||
213
docs/deploy.md
Normal file
213
docs/deploy.md
Normal 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 |
|
||||
@@ -65,6 +65,20 @@
|
||||
@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 -->
|
||||
<div class="mt-4 grid grid-cols-1 gap-x-6 gap-y-4 sm:grid-cols-2">
|
||||
<MalioSelect
|
||||
@@ -266,6 +280,8 @@ import type { TaskGroup } from '~/services/dto/task-group'
|
||||
import type { UserData } from '~/services/dto/user-data'
|
||||
import { useTaskService } from '~/services/tasks'
|
||||
|
||||
import type { Project } from '~/services/dto/project'
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: boolean
|
||||
task: Task | null
|
||||
@@ -276,6 +292,7 @@ const props = defineProps<{
|
||||
tags: TaskTag[]
|
||||
groups: TaskGroup[]
|
||||
users: UserData[]
|
||||
projects?: Project[]
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -318,10 +335,12 @@ const form = reactive({
|
||||
groupId: null as number | null,
|
||||
tagIds: [] as number[],
|
||||
clientTicketId: null as number | null,
|
||||
projectId: null as number | null,
|
||||
})
|
||||
|
||||
const touched = reactive({
|
||||
title: false,
|
||||
project: false,
|
||||
})
|
||||
|
||||
const statusOptions = computed(() =>
|
||||
@@ -340,8 +359,22 @@ const userOptions = computed(() =>
|
||||
props.users.map(u => ({ label: u.username, value: u.id }))
|
||||
)
|
||||
|
||||
const groupOptions = computed(() =>
|
||||
props.groups.map(g => ({ label: g.title, value: g.id }))
|
||||
const groupOptions = computed(() => {
|
||||
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(() => {
|
||||
@@ -385,8 +418,10 @@ function populateForm(task: Task | null) {
|
||||
form.groupId = null
|
||||
form.tagIds = []
|
||||
form.clientTicketId = null
|
||||
form.projectId = null
|
||||
}
|
||||
touched.title = false
|
||||
touched.project = false
|
||||
}
|
||||
|
||||
watch(() => props.modelValue, async (open) => {
|
||||
@@ -394,9 +429,14 @@ watch(() => props.modelValue, async (open) => {
|
||||
confirmDeleteDocOpen.value = false
|
||||
documentToDelete.value = null
|
||||
populateForm(props.task)
|
||||
try {
|
||||
clientTickets.value = await clientTicketService.getAll({ project: props.projectId })
|
||||
} catch {
|
||||
const pid = resolvedProjectId.value
|
||||
if (pid) {
|
||||
try {
|
||||
clientTickets.value = await clientTicketService.getAll({ project: pid })
|
||||
} catch {
|
||||
clientTickets.value = []
|
||||
}
|
||||
} else {
|
||||
clientTickets.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 }))
|
||||
)
|
||||
|
||||
// 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 isAdmin = computed(() => authStore.user?.roles?.includes('ROLE_ADMIN') ?? false)
|
||||
|
||||
@@ -541,7 +597,9 @@ async function handleUnarchive() {
|
||||
|
||||
async function handleSubmit() {
|
||||
touched.title = true
|
||||
touched.project = true
|
||||
if (!form.title.trim()) return
|
||||
if (showProjectSelect.value && !form.projectId) return
|
||||
|
||||
isSubmitting.value = true
|
||||
try {
|
||||
@@ -553,7 +611,7 @@ async function handleSubmit() {
|
||||
priority: form.priorityId ? `/api/task_priorities/${form.priorityId}` : null,
|
||||
assignee: form.assigneeId ? `/api/users/${form.assigneeId}` : 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}`),
|
||||
clientTicket: form.clientTicketId ? `/api/client_tickets/${form.clientTicketId}` : null,
|
||||
}
|
||||
|
||||
@@ -112,7 +112,8 @@
|
||||
"allEfforts": "Tous les efforts",
|
||||
"allAssignees": "Tous",
|
||||
"noTasks": "Aucune tâche",
|
||||
"backlog": "Backlog"
|
||||
"backlog": "Backlog",
|
||||
"createTask": "Créer une tâche"
|
||||
},
|
||||
"dashboard": {
|
||||
"title": "Tableau de bord",
|
||||
|
||||
@@ -123,9 +123,9 @@
|
||||
</div>
|
||||
</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" />
|
||||
<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" />
|
||||
<slot/>
|
||||
</main>
|
||||
|
||||
@@ -214,6 +214,11 @@ async function onDropBacklog(event: DragEvent) {
|
||||
}
|
||||
|
||||
// Modal
|
||||
function openTaskCreate() {
|
||||
selectedTask.value = null
|
||||
taskModalOpen.value = true
|
||||
}
|
||||
|
||||
function openTaskEdit(task: Task) {
|
||||
selectedTask.value = task
|
||||
taskModalOpen.value = true
|
||||
@@ -229,28 +234,37 @@ onMounted(() => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<div class="min-w-0">
|
||||
<!-- Header + Filters -->
|
||||
<div class="sticky top-8 z-20 bg-white pb-4 sm:top-12">
|
||||
<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>
|
||||
<div class="flex gap-1">
|
||||
<div class="flex items-center gap-2">
|
||||
<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'"
|
||||
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"
|
||||
@click="openTaskCreate"
|
||||
>
|
||||
<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" />
|
||||
<Icon name="mdi:plus" size="18" />
|
||||
{{ $t('myTasks.createTask') }}
|
||||
</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>
|
||||
|
||||
@@ -314,11 +328,11 @@ onMounted(() => {
|
||||
|
||||
<!-- Kanban View -->
|
||||
<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
|
||||
v-for="status in sortedStatuses"
|
||||
: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'"
|
||||
@dragover.prevent
|
||||
@dragenter.prevent="onDragEnter(status.id)"
|
||||
@@ -446,6 +460,7 @@ onMounted(() => {
|
||||
:tags="tags"
|
||||
:groups="selectedTask?.project?.id ? groups.filter(g => g.project?.id === selectedTask?.project?.id) : groups"
|
||||
:users="users"
|
||||
:projects="projects"
|
||||
@saved="onSaved"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="min-w-0">
|
||||
<div class="sticky top-8 z-20 bg-white pb-4 sm:top-12">
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<h1 class="text-xl font-bold text-primary-500 sm:text-2xl">{{ project?.name ?? '' }}</h1>
|
||||
@@ -62,11 +62,11 @@
|
||||
</div>
|
||||
|
||||
<!-- 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
|
||||
v-for="status in statuses"
|
||||
: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'"
|
||||
@dragover.prevent
|
||||
@dragenter.prevent="onDragEnter(status.id)"
|
||||
|
||||
35
migrations/Version20260316124157.php
Normal file
35
migrations/Version20260316124157.php
Normal 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');
|
||||
}
|
||||
}
|
||||
@@ -85,6 +85,11 @@ class TimeEntry
|
||||
#[Groups(['time_entry:read', 'time_entry:write'])]
|
||||
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> */
|
||||
#[ORM\ManyToMany(targetEntity: TaskTag::class)]
|
||||
#[ORM\JoinTable(
|
||||
@@ -189,6 +194,18 @@ class TimeEntry
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getClientTicket(): ?ClientTicket
|
||||
{
|
||||
return $this->clientTicket;
|
||||
}
|
||||
|
||||
public function setClientTicket(?ClientTicket $clientTicket): static
|
||||
{
|
||||
$this->clientTicket = $clientTicket;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/** @return Collection<int, TaskTag> */
|
||||
public function getTags(): Collection
|
||||
{
|
||||
|
||||
@@ -4,6 +4,7 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Mcp\Tool;
|
||||
|
||||
use App\Entity\ClientTicket;
|
||||
use App\Entity\Project;
|
||||
use App\Entity\Task;
|
||||
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>
|
||||
*/
|
||||
public static function timeEntry(TimeEntry $entry): array
|
||||
{
|
||||
return [
|
||||
'id' => $entry->getId(),
|
||||
'title' => $entry->getTitle(),
|
||||
'description' => $entry->getDescription(),
|
||||
'startedAt' => $entry->getStartedAt()?->format('c'),
|
||||
'stoppedAt' => $entry->getStoppedAt()?->format('c'),
|
||||
'duration' => self::durationMinutes($entry),
|
||||
'user' => self::user($entry->getUser()),
|
||||
'project' => $entry->getProject() ? self::projectRef($entry->getProject()) : null,
|
||||
'task' => self::taskRef($entry->getTask()),
|
||||
'tags' => self::tags($entry->getTags()),
|
||||
'id' => $entry->getId(),
|
||||
'title' => $entry->getTitle(),
|
||||
'description' => $entry->getDescription(),
|
||||
'startedAt' => $entry->getStartedAt()?->format('c'),
|
||||
'stoppedAt' => $entry->getStoppedAt()?->format('c'),
|
||||
'duration' => self::durationMinutes($entry),
|
||||
'user' => self::user($entry->getUser()),
|
||||
'project' => $entry->getProject() ? self::projectRef($entry->getProject()) : null,
|
||||
'task' => self::taskRef($entry->getTask()),
|
||||
'clientTicket' => self::clientTicketRef($entry->getClientTicket()),
|
||||
'tags' => self::tags($entry->getTags()),
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ namespace App\Mcp\Tool\TimeEntry;
|
||||
|
||||
use App\Entity\TimeEntry;
|
||||
use App\Mcp\Tool\Serializer;
|
||||
use App\Repository\ClientTicketRepository;
|
||||
use App\Repository\ProjectRepository;
|
||||
use App\Repository\TaskRepository;
|
||||
use App\Repository\TaskTagRepository;
|
||||
@@ -28,6 +29,7 @@ class CreateTimeEntryTool
|
||||
private readonly TaskRepository $taskRepository,
|
||||
private readonly TaskTagRepository $taskTagRepository,
|
||||
private readonly TimeEntryRepository $timeEntryRepository,
|
||||
private readonly ClientTicketRepository $clientTicketRepository,
|
||||
) {}
|
||||
|
||||
public function __invoke(
|
||||
@@ -39,6 +41,7 @@ class CreateTimeEntryTool
|
||||
?int $taskId = null,
|
||||
?array $tagIds = null,
|
||||
?string $description = null,
|
||||
?int $clientTicketId = null,
|
||||
): string {
|
||||
$user = $this->userRepository->find($userId);
|
||||
if (null === $user) {
|
||||
@@ -80,6 +83,13 @@ class CreateTimeEntryTool
|
||||
}
|
||||
$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) {
|
||||
foreach ($tagIds as $tagId) {
|
||||
$tag = $this->taskTagRepository->find($tagId);
|
||||
|
||||
@@ -20,6 +20,7 @@ class ListTimeEntriesTool
|
||||
?int $userId = null,
|
||||
?int $projectId = null,
|
||||
?int $taskId = null,
|
||||
?int $clientTicketId = null,
|
||||
?string $startDate = null,
|
||||
?string $endDate = null,
|
||||
int $limit = 100,
|
||||
@@ -31,6 +32,7 @@ class ListTimeEntriesTool
|
||||
->leftJoin('te.project', 'p')->addSelect('p')
|
||||
->leftJoin('te.task', 't')->addSelect('t')
|
||||
->leftJoin('te.tags', 'tg')->addSelect('tg')
|
||||
->leftJoin('te.clientTicket', 'ct')->addSelect('ct')
|
||||
->orderBy('te.startedAt', 'DESC')
|
||||
->setMaxResults($limit)
|
||||
;
|
||||
@@ -44,6 +46,9 @@ class ListTimeEntriesTool
|
||||
if (null !== $taskId) {
|
||||
$qb->andWhere('t.id = :taskId')->setParameter('taskId', $taskId);
|
||||
}
|
||||
if (null !== $clientTicketId) {
|
||||
$qb->andWhere('ct.id = :clientTicketId')->setParameter('clientTicketId', $clientTicketId);
|
||||
}
|
||||
if (null !== $startDate) {
|
||||
$qb->andWhere('te.startedAt >= :startDate')
|
||||
->setParameter('startDate', new DateTimeImmutable($startDate.' 00:00:00'))
|
||||
|
||||
@@ -5,6 +5,7 @@ declare(strict_types=1);
|
||||
namespace App\Mcp\Tool\TimeEntry;
|
||||
|
||||
use App\Mcp\Tool\Serializer;
|
||||
use App\Repository\ClientTicketRepository;
|
||||
use App\Repository\ProjectRepository;
|
||||
use App\Repository\TaskRepository;
|
||||
use App\Repository\TaskTagRepository;
|
||||
@@ -24,6 +25,7 @@ class UpdateTimeEntryTool
|
||||
private readonly ProjectRepository $projectRepository,
|
||||
private readonly TaskRepository $taskRepository,
|
||||
private readonly TaskTagRepository $taskTagRepository,
|
||||
private readonly ClientTicketRepository $clientTicketRepository,
|
||||
private readonly EntityManagerInterface $entityManager,
|
||||
) {}
|
||||
|
||||
@@ -36,6 +38,7 @@ class UpdateTimeEntryTool
|
||||
?int $taskId = null,
|
||||
?array $tagIds = null,
|
||||
?string $description = null,
|
||||
?int $clientTicketId = null,
|
||||
): string {
|
||||
$entry = $this->timeEntryRepository->find($id);
|
||||
|
||||
@@ -69,6 +72,13 @@ class UpdateTimeEntryTool
|
||||
}
|
||||
$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) {
|
||||
foreach ($entry->getTags()->toArray() as $existingTag) {
|
||||
$entry->removeTag($existingTag);
|
||||
|
||||
Reference in New Issue
Block a user