Compare commits

...

6 Commits

Author SHA1 Message Date
gitea-actions 7f79bdf236 chore: bump version to v0.4.20
Auto Tag Develop / tag (push) Successful in 12s
Build & Push Docker Image / build (push) Successful in 1m6s
2026-06-01 20:33:07 +00:00
Matthieu e87c474672 feat(mcp) : ajout du tool add-task-document pour attacher des documents Markdown à un ticket
Auto Tag Develop / tag (push) Successful in 10s
Nouveau tool MCP recevant le contenu texte brut (pas de base64), optimisé pour le Markdown. MIME inféré depuis l'extension du fileName (text/markdown par défaut). Persiste un TaskDocument avec uploadedBy = utilisateur du token MCP.
2026-06-01 22:32:44 +02:00
gitea-actions 8cfa048e5a chore: bump version to v0.4.19
Auto Tag Develop / tag (push) Successful in 6s
Build & Push Docker Image / build (push) Successful in 51s
2026-05-29 14:46:18 +00:00
Matthieu c692e4cf43 fix(time-tracking) : afficher toutes les time entries sans filtre projet
Auto Tag Develop / tag (push) Successful in 13s
La vue suivi de temps tapait la GetCollection paginée de /time_entries
(30 items/page) et ne lisait que la première page : sur une semaine
chargée, les entrées les plus anciennes (triées startedAt DESC) étaient
tronquées tant qu'aucun filtre projet ne réduisait le total sous 30.

Ajout d'une GetCollection dédiée /time_entries/range non paginée, bornée
par date, vers laquelle pointe désormais getByDateRange.
2026-05-29 16:46:04 +02:00
gitea-actions 81d905257a chore: bump version to v0.4.18
Auto Tag Develop / tag (push) Successful in 7s
Build & Push Docker Image / build (push) Successful in 1m0s
2026-05-28 08:51:21 +00:00
Matthieu a3c0696023 feat(projects) : archivage en masse des tickets sur statut final
Auto Tag Develop / tag (push) Successful in 9s
- TaskBulkActions : prop canArchive + bouton archive conditionnel
- pages/projects/[id] : computed canArchiveSelection (true quand le filtre statut courant pointe vers un statut isFinal)
- purge la sélection des ids hors filtre courant pour garder le compteur cohérent en vue liste
2026-05-28 10:50:48 +02:00
7 changed files with 151 additions and 2 deletions
+4
View File
@@ -45,6 +45,10 @@ services:
arguments: arguments:
$uploadDir: '%task_document_upload_dir%' $uploadDir: '%task_document_upload_dir%'
App\Mcp\Tool\Task\AddTaskDocumentTool:
arguments:
$uploadDir: '%task_document_upload_dir%'
App\Controller\UserAvatarController: App\Controller\UserAvatarController:
arguments: arguments:
$avatarUploadDir: '%avatar_upload_dir%' $avatarUploadDir: '%avatar_upload_dir%'
+1 -1
View File
@@ -1,2 +1,2 @@
parameters: parameters:
app.version: '0.4.17' app.version: '0.4.20'
@@ -79,6 +79,17 @@
@update:model-value="(v: number | null) => v && emit('bulk-update', 'group', v)" @update:model-value="(v: number | null) => v && emit('bulk-update', 'group', v)"
/> />
<!-- Archive (only when current filter targets a final status) -->
<MalioButtonIcon
v-if="canArchive"
icon="mdi:archive-outline"
aria-label="Archiver"
variant="ghost"
icon-size="22"
button-class="self-end text-neutral-500 hover:bg-primary-50 hover:text-primary-500"
@click="emit('bulk-archive')"
/>
<!-- Delete --> <!-- Delete -->
<MalioButtonIcon <MalioButtonIcon
icon="mdi:delete-outline" icon="mdi:delete-outline"
@@ -113,9 +124,11 @@ const props = withDefaults(defineProps<{
groups: TaskGroup[] groups: TaskGroup[]
selectedTasks?: Task[] selectedTasks?: Task[]
projects?: Project[] projects?: Project[]
canArchive?: boolean
}>(), { }>(), {
selectedTasks: () => [], selectedTasks: () => [],
projects: () => [], projects: () => [],
canArchive: false,
}) })
const emit = defineEmits<{ const emit = defineEmits<{
+15
View File
@@ -161,6 +161,7 @@
:priorities="priorities" :priorities="priorities"
:efforts="efforts" :efforts="efforts"
:groups="groups" :groups="groups"
:can-archive="canArchiveSelection"
@toggle-all="toggleSelectAll(filteredTasks)" @toggle-all="toggleSelectAll(filteredTasks)"
@bulk-update="onBulkUpdate" @bulk-update="onBulkUpdate"
@bulk-archive="onBulkArchive" @bulk-archive="onBulkArchive"
@@ -297,6 +298,12 @@ const effortFilterOptions = computed(() =>
efforts.value.map(e => ({ label: e.label, value: e.id })) efforts.value.map(e => ({ label: e.label, value: e.id }))
) )
const canArchiveSelection = computed(() => {
if (selectedStatusId.value === null) return false
const status = statuses.value.find(s => s.id === selectedStatusId.value)
return status?.isFinal === true
})
const filteredTasks = computed(() => { const filteredTasks = computed(() => {
let result = tasks.value.filter(t => !t.archived) let result = tasks.value.filter(t => !t.archived)
if (selectedGroupId.value) { if (selectedGroupId.value) {
@@ -323,6 +330,14 @@ const filteredTasks = computed(() => {
return result return result
}) })
watch(filteredTasks, (list) => {
if (selectedTaskIds.size === 0) return
const visibleIds = new Set(list.map(t => t.id))
for (const id of selectedTaskIds) {
if (!visibleIds.has(id)) selectedTaskIds.delete(id)
}
})
function tasksByStatus(statusId: number): Task[] { function tasksByStatus(statusId: number): Task[] {
return filteredTasks.value.filter(t => t.status?.id === statusId) return filteredTasks.value.filter(t => t.status?.id === statusId)
} }
+1 -1
View File
@@ -25,7 +25,7 @@ export function useTimeEntryService() {
if (params.tag) { if (params.tag) {
query['tags[]'] = `/api/task_tags/${params.tag}` query['tags[]'] = `/api/task_tags/${params.tag}`
} }
const data = await api.get<HydraCollection<TimeEntry>>('/time_entries', query) const data = await api.get<HydraCollection<TimeEntry>>('/time_entries/range', query)
return extractHydraMembers(data) return extractHydraMembers(data)
} }
+7
View File
@@ -25,6 +25,13 @@ use Symfony\Component\Serializer\Attribute\Groups;
#[ApiResource( #[ApiResource(
operations: [ operations: [
new GetCollection(security: "is_granted('ROLE_USER')"), new GetCollection(security: "is_granted('ROLE_USER')"),
new GetCollection(
name: 'time_entries_range',
uriTemplate: '/time_entries/range',
description: 'List time entries for a bounded date range without pagination (used by the time-tracking calendar)',
paginationEnabled: false,
security: "is_granted('ROLE_USER')",
),
new GetCollection( new GetCollection(
name: 'active_time_entry', name: 'active_time_entry',
uriTemplate: '/time_entries/active', uriTemplate: '/time_entries/active',
+110
View File
@@ -0,0 +1,110 @@
<?php
declare(strict_types=1);
namespace App\Mcp\Tool\Task;
use App\Entity\TaskDocument;
use App\Repository\TaskRepository;
use DateTimeImmutable;
use Doctrine\ORM\EntityManagerInterface;
use InvalidArgumentException;
use Mcp\Capability\Attribute\McpTool;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
use Symfony\Component\Uid\Uuid;
use function sprintf;
use function strlen;
#[McpTool(name: 'add-task-document', description: 'Attach a text document (Markdown by default) to a task by passing its raw content. Optimized for Markdown reports/notes: the content is written verbatim as a UTF-8 file, no base64 needed. The MIME type is inferred from the fileName extension (.md, .txt, .csv, .json, .xml), defaulting to text/markdown.')]
class AddTaskDocumentTool
{
private const MAX_CONTENT_SIZE = 5 * 1024 * 1024; // 5 MB of text
private const EXTENSION_TO_MIME = [
'md' => 'text/markdown',
'markdown' => 'text/markdown',
'txt' => 'text/plain',
'csv' => 'text/csv',
'json' => 'application/json',
'xml' => 'text/xml',
];
public function __construct(
private readonly EntityManagerInterface $entityManager,
private readonly TaskRepository $taskRepository,
private readonly Security $security,
private readonly string $uploadDir,
) {}
/**
* @param int $taskId ID of the task to attach the document to
* @param string $content Raw text content of the document (e.g. Markdown)
* @param string $fileName Display name of the document, including extension (defaults to "document.md")
*/
public function __invoke(
int $taskId,
string $content,
string $fileName = 'document.md',
): string {
if (!$this->security->isGranted('ROLE_USER')) {
throw new AccessDeniedException('Access denied: ROLE_USER required.');
}
$task = $this->taskRepository->find($taskId);
if (null === $task) {
throw new InvalidArgumentException(sprintf('Task with ID %d not found.', $taskId));
}
if ('' === $content) {
throw new InvalidArgumentException('Document content cannot be empty.');
}
$size = strlen($content);
if ($size > self::MAX_CONTENT_SIZE) {
throw new InvalidArgumentException('Content size exceeds 5 MB limit.');
}
$originalName = '' !== trim($fileName) ? trim($fileName) : 'document.md';
$extension = strtolower(pathinfo($originalName, PATHINFO_EXTENSION));
$mimeType = self::EXTENSION_TO_MIME[$extension] ?? 'text/markdown';
if ('' === $extension) {
$originalName .= '.md';
$extension = 'md';
}
$storedName = Uuid::v4()->toRfc4122().'.'.$extension;
if (!is_dir($this->uploadDir) && !mkdir($this->uploadDir, 0o775, true) && !is_dir($this->uploadDir)) {
throw new InvalidArgumentException(sprintf('Upload directory "%s" could not be created.', $this->uploadDir));
}
if (false === file_put_contents($this->uploadDir.'/'.$storedName, $content)) {
throw new InvalidArgumentException('Failed to write document to disk.');
}
$document = new TaskDocument();
$document->setTask($task);
$document->setOriginalName($originalName);
$document->setFileName($storedName);
$document->setMimeType($mimeType);
$document->setSize($size);
$document->setCreatedAt(new DateTimeImmutable());
$document->setUploadedBy($this->security->getUser());
$this->entityManager->persist($document);
$this->entityManager->flush();
return json_encode([
'id' => $document->getId(),
'taskId' => $task->getId(),
'originalName' => $document->getOriginalName(),
'mimeType' => $document->getMimeType(),
'size' => $document->getSize(),
'createdAt' => $document->getCreatedAt()?->format('c'),
'uploadedBy' => $document->getUploadedBy()?->getUsername(),
]);
}
}