Compare commits
42 Commits
7e7e373231
...
c0b16ef6dc
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c0b16ef6dc | ||
|
|
c89f9c5596 | ||
|
|
94d7794c31 | ||
|
|
3c0baee661 | ||
|
|
c7a0dafae8 | ||
|
|
6eeacd2cb0 | ||
|
|
027e31e139 | ||
|
|
f8c94cb177 | ||
|
|
5b204a3464 | ||
|
|
92baf8ac0e | ||
|
|
2073339d4f | ||
|
|
e278286146 | ||
|
|
a6c5e54619 | ||
|
|
5135e28e3a | ||
|
|
3d0fad3735 | ||
|
|
dcbf5db308 | ||
|
|
7b1aa22c15 | ||
|
|
5577884c13 | ||
|
|
be2e7c60a3 | ||
|
|
136d0eaaa4 | ||
|
|
0b8e2bfc63 | ||
|
|
28e943b519 | ||
|
|
50690e6680 | ||
|
|
c82b6d1b32 | ||
|
|
6ae014fe8a | ||
|
|
3ec9424bb2 | ||
|
|
aa5f6cc7c1 | ||
|
|
14358fdddc | ||
|
|
3ffd18138b | ||
|
|
e5e722c019 | ||
|
|
bc9471e4ba | ||
|
|
cb5aa4584c | ||
|
|
1d0f9a28c3 | ||
|
|
d3ea09319c | ||
|
|
e85ea42d7c | ||
|
|
7540c99501 | ||
|
|
c60f531607 | ||
|
|
638bb2b686 | ||
|
|
7b8c754987 | ||
|
|
bf9faee5f4 | ||
|
|
7d1d81688e | ||
|
|
9a9e5093f5 |
21
.env
21
.env
@@ -1,24 +1,23 @@
|
||||
###> symfony/framework-bundle ###
|
||||
APP_ENV=dev
|
||||
APP_SECRET=
|
||||
APP_SHARE_DIR=var/share
|
||||
###< symfony/framework-bundle ###
|
||||
APP_SECRET="a64f5614357bf56aecb1d7470e431535"
|
||||
APP_DEBUG=1
|
||||
|
||||
###> symfony/routing ###
|
||||
# Configure how to generate URLs in non-HTTP contexts, such as CLI commands.
|
||||
# See https://symfony.com/doc/current/routing.html#generating-urls-in-commands
|
||||
DEFAULT_URI=http://localhost
|
||||
###< symfony/routing ###
|
||||
DEFAULT_URI=http://localhost/
|
||||
|
||||
###> nelmio/cors-bundle ###
|
||||
CORS_ALLOW_ORIGIN='^https?://(localhost|127\.0\.0\.1)(:[0-9]+)?$'
|
||||
CORS_ALLOW_ORIGIN='^https?://(localhost|127.0.0.1)(:[0-9]+)?$'
|
||||
###< nelmio/cors-bundle ###
|
||||
|
||||
###> lexik/jwt-authentication-bundle ###
|
||||
JWT_SECRET_KEY=%kernel.project_dir%/config/jwt/private.pem
|
||||
JWT_PUBLIC_KEY=%kernel.project_dir%/config/jwt/public.pem
|
||||
JWT_PASSPHRASE=
|
||||
JWT_PASSPHRASE=c2dbeec8fa8255bdab24e88b9fc1e57927740c429ae3b930d03e51b92e13a85f
|
||||
JWT_COOKIE_SECURE=0
|
||||
JWT_TOKEN_TTL=86400
|
||||
JWT_COOKIE_TTL=86400
|
||||
###< lexik/jwt-authentication-bundle ###
|
||||
|
||||
|
||||
DATABASE_URL="postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:${POSTGRES_PORT}/${POSTGRES_DB}?serverVersion=16&charset=utf8"
|
||||
|
||||
GITEA_ENCRYPTION_KEY=
|
||||
10
CLAUDE.md
10
CLAUDE.md
@@ -12,9 +12,9 @@ Application de gestion de projet. Monorepo Symfony 8 (API Platform 4) + Nuxt 4.
|
||||
## Structure
|
||||
|
||||
```
|
||||
src/Entity/ # Entités Doctrine (User, Client, Project, Task, TaskStatus, TaskEffort, TaskPriority, TaskType, TaskGroup, TimeEntry)
|
||||
src/Entity/ # Entités Doctrine (User, Client, Project, Task, TaskStatus, TaskEffort, TaskPriority, TaskTag, TaskGroup, TimeEntry)
|
||||
src/ApiResource/ # Ressources API Platform (si découplées des entités)
|
||||
src/State/ # Providers et Processors API Platform (MeProvider, AppVersionProvider, ActiveTimeEntryProvider, UserPasswordHasherProcessor)
|
||||
src/State/ # Providers et Processors API Platform (MeProvider, AppVersionProvider, ActiveTimeEntryProvider, UserPasswordHasherProcessor, TaskNumberProcessor)
|
||||
src/Repository/ # Repositories Doctrine
|
||||
src/DataFixtures/ # Fixtures
|
||||
config/ # Config Symfony (security, api_platform, lexik_jwt, nelmio_cors, doctrine)
|
||||
@@ -22,12 +22,12 @@ config/jwt/ # Clés JWT (private.pem, public.pem)
|
||||
migrations/ # Migrations Doctrine
|
||||
docs/plans/ # Plans d'implémentation
|
||||
frontend/ # App Nuxt 4
|
||||
frontend/pages/ # Pages (index, login, clients, projects, projects/[id], projects/[id]/groups, projects/[id]/statuses, time-tracking, admin)
|
||||
frontend/pages/ # Pages (index, login, projects, projects/[id], projects/[id]/groups, projects/[id]/archives, time-tracking, admin)
|
||||
frontend/layouts/ # Layouts (pas "layout")
|
||||
frontend/components/ # Composants Vue (AppTopNav, AppDrawer, ColorPicker, DataTable, *Drawer, TaskCard, Admin*Tab, ProjectStatusTab, ProjectGroupTab, SidebarLink, SidebarTimer, TimeEntry*, TimeTrackingCalendar, ConfirmDeleteStatusModal)
|
||||
frontend/components/ # Composants Vue (AppTopNav, AppDrawer, ColorPicker, DataTable, ClientDrawer, ProjectDrawer, ProjectGroupTab, TaskCard, TaskDrawer, TaskModal, TaskEffortDrawer, TaskGroupDrawer, TaskPriorityDrawer, TaskStatusDrawer, TaskTagDrawer, Admin*Tab, SidebarLink, SidebarTimer, TimeEntryBlock, TimeEntryContextMenu, TimeEntryDrawer, TimeEntryList, TimeTrackingCalendar, UserDrawer, ConfirmDeleteStatusModal, ConfirmDeleteTaskModal)
|
||||
frontend/composables/# Composables (useApi, useAppVersion)
|
||||
frontend/stores/ # Stores Pinia (auth, ui, timer)
|
||||
frontend/services/ # Services API (auth, clients, projects, tasks, task-statuses, task-efforts, task-groups, task-priorities, task-types, users, time-entries)
|
||||
frontend/services/ # Services API (auth, clients, projects, tasks, task-statuses, task-efforts, task-groups, task-priorities, task-tags, users, time-entries)
|
||||
frontend/services/dto/ # Types TypeScript
|
||||
frontend/i18n/locales/ # Fichiers de traduction (langDir résolu depuis i18n/)
|
||||
```
|
||||
|
||||
@@ -22,6 +22,7 @@
|
||||
"symfony/expression-language": "8.0.*",
|
||||
"symfony/flex": "^2",
|
||||
"symfony/framework-bundle": "8.0.*",
|
||||
"symfony/http-client": "8.0.*",
|
||||
"symfony/property-access": "8.0.*",
|
||||
"symfony/property-info": "8.0.*",
|
||||
"symfony/runtime": "8.0.*",
|
||||
|
||||
176
composer.lock
generated
176
composer.lock
generated
@@ -4,7 +4,7 @@
|
||||
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
||||
"This file is @generated automatically"
|
||||
],
|
||||
"content-hash": "9482fc27494f618b2bae1b7f250e8326",
|
||||
"content-hash": "4790d8c80c0fb208e5af11fb205c0202",
|
||||
"packages": [
|
||||
{
|
||||
"name": "api-platform/doctrine-common",
|
||||
@@ -4618,6 +4618,180 @@
|
||||
],
|
||||
"time": "2026-03-06T15:40:00+00:00"
|
||||
},
|
||||
{
|
||||
"name": "symfony/http-client",
|
||||
"version": "v8.0.7",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/symfony/http-client.git",
|
||||
"reference": "ade9bd433450382f0af154661fc8e72758b4de36"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/symfony/http-client/zipball/ade9bd433450382f0af154661fc8e72758b4de36",
|
||||
"reference": "ade9bd433450382f0af154661fc8e72758b4de36",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"php": ">=8.4",
|
||||
"psr/log": "^1|^2|^3",
|
||||
"symfony/http-client-contracts": "~3.4.4|^3.5.2",
|
||||
"symfony/service-contracts": "^2.5|^3"
|
||||
},
|
||||
"conflict": {
|
||||
"amphp/amp": "<3",
|
||||
"php-http/discovery": "<1.15"
|
||||
},
|
||||
"provide": {
|
||||
"php-http/async-client-implementation": "*",
|
||||
"php-http/client-implementation": "*",
|
||||
"psr/http-client-implementation": "1.0",
|
||||
"symfony/http-client-implementation": "3.0"
|
||||
},
|
||||
"require-dev": {
|
||||
"amphp/http-client": "^5.3.2",
|
||||
"amphp/http-tunnel": "^2.0",
|
||||
"guzzlehttp/promises": "^1.4|^2.0",
|
||||
"nyholm/psr7": "^1.0",
|
||||
"php-http/httplug": "^1.0|^2.0",
|
||||
"psr/http-client": "^1.0",
|
||||
"symfony/cache": "^7.4|^8.0",
|
||||
"symfony/dependency-injection": "^7.4|^8.0",
|
||||
"symfony/http-kernel": "^7.4|^8.0",
|
||||
"symfony/messenger": "^7.4|^8.0",
|
||||
"symfony/process": "^7.4|^8.0",
|
||||
"symfony/rate-limiter": "^7.4|^8.0",
|
||||
"symfony/stopwatch": "^7.4|^8.0"
|
||||
},
|
||||
"type": "library",
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Symfony\\Component\\HttpClient\\": ""
|
||||
},
|
||||
"exclude-from-classmap": [
|
||||
"/Tests/"
|
||||
]
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Nicolas Grekas",
|
||||
"email": "p@tchwork.com"
|
||||
},
|
||||
{
|
||||
"name": "Symfony Community",
|
||||
"homepage": "https://symfony.com/contributors"
|
||||
}
|
||||
],
|
||||
"description": "Provides powerful methods to fetch HTTP resources synchronously or asynchronously",
|
||||
"homepage": "https://symfony.com",
|
||||
"keywords": [
|
||||
"http"
|
||||
],
|
||||
"support": {
|
||||
"source": "https://github.com/symfony/http-client/tree/v8.0.7"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
"url": "https://symfony.com/sponsor",
|
||||
"type": "custom"
|
||||
},
|
||||
{
|
||||
"url": "https://github.com/fabpot",
|
||||
"type": "github"
|
||||
},
|
||||
{
|
||||
"url": "https://github.com/nicolas-grekas",
|
||||
"type": "github"
|
||||
},
|
||||
{
|
||||
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
|
||||
"type": "tidelift"
|
||||
}
|
||||
],
|
||||
"time": "2026-03-06T13:17:40+00:00"
|
||||
},
|
||||
{
|
||||
"name": "symfony/http-client-contracts",
|
||||
"version": "v3.6.0",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/symfony/http-client-contracts.git",
|
||||
"reference": "75d7043853a42837e68111812f4d964b01e5101c"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/symfony/http-client-contracts/zipball/75d7043853a42837e68111812f4d964b01e5101c",
|
||||
"reference": "75d7043853a42837e68111812f4d964b01e5101c",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"php": ">=8.1"
|
||||
},
|
||||
"type": "library",
|
||||
"extra": {
|
||||
"thanks": {
|
||||
"url": "https://github.com/symfony/contracts",
|
||||
"name": "symfony/contracts"
|
||||
},
|
||||
"branch-alias": {
|
||||
"dev-main": "3.6-dev"
|
||||
}
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Symfony\\Contracts\\HttpClient\\": ""
|
||||
},
|
||||
"exclude-from-classmap": [
|
||||
"/Test/"
|
||||
]
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Nicolas Grekas",
|
||||
"email": "p@tchwork.com"
|
||||
},
|
||||
{
|
||||
"name": "Symfony Community",
|
||||
"homepage": "https://symfony.com/contributors"
|
||||
}
|
||||
],
|
||||
"description": "Generic abstractions related to HTTP clients",
|
||||
"homepage": "https://symfony.com",
|
||||
"keywords": [
|
||||
"abstractions",
|
||||
"contracts",
|
||||
"decoupling",
|
||||
"interfaces",
|
||||
"interoperability",
|
||||
"standards"
|
||||
],
|
||||
"support": {
|
||||
"source": "https://github.com/symfony/http-client-contracts/tree/v3.6.0"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
"url": "https://symfony.com/sponsor",
|
||||
"type": "custom"
|
||||
},
|
||||
{
|
||||
"url": "https://github.com/fabpot",
|
||||
"type": "github"
|
||||
},
|
||||
{
|
||||
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
|
||||
"type": "tidelift"
|
||||
}
|
||||
],
|
||||
"time": "2025-04-29T11:18:49+00:00"
|
||||
},
|
||||
{
|
||||
"name": "symfony/http-foundation",
|
||||
"version": "v8.0.7",
|
||||
|
||||
816
docs/plans/2026-03-12-admin-clients-global-statuses.md
Normal file
816
docs/plans/2026-03-12-admin-clients-global-statuses.md
Normal file
@@ -0,0 +1,816 @@
|
||||
# Admin Clients + Global Statuses 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:** Move clients management into the admin page as a tab, and make task statuses global (shared across all projects) instead of per-project.
|
||||
|
||||
**Architecture:** Two independent changes: (1) Extract client CRUD from its dedicated page into an `AdminClientTab` component inside the admin page, remove the standalone `/clients` page and sidebar link. (2) Remove the `project` relationship from `TaskStatus` entity, update the frontend to use `getAll()` everywhere instead of `getByProject()`, remove per-project status management pages/links, and update `AdminStatusTab` + `TaskStatusDrawer` to work without `projectId`.
|
||||
|
||||
**Tech Stack:** PHP 8.4 / Symfony 8 / Doctrine ORM (backend), Nuxt 4 / Vue 3 / TypeScript (frontend)
|
||||
|
||||
---
|
||||
|
||||
## Chunk 1: Move Clients into Admin
|
||||
|
||||
### Task 1: Create AdminClientTab component
|
||||
|
||||
**Files:**
|
||||
- Create: `frontend/components/admin/AdminClientTab.vue`
|
||||
|
||||
- [ ] **Step 1: Create AdminClientTab.vue**
|
||||
|
||||
Extract the logic from `frontend/pages/clients.vue` into a new admin tab component, following the same pattern as `AdminPriorityTab.vue` (h2 title instead of h1, no `useHead`).
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<div>
|
||||
<div class="flex items-center justify-between">
|
||||
<h2 class="text-lg font-bold text-neutral-900">Clients</h2>
|
||||
<button
|
||||
class="rounded-md bg-primary-500 px-4 py-2 text-sm font-semibold text-white hover:bg-secondary-500"
|
||||
@click="openCreate"
|
||||
>
|
||||
+ Ajouter un client
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<DataTable
|
||||
:columns="columns"
|
||||
:items="clients"
|
||||
:loading="isLoading"
|
||||
empty-message="Aucun client trouvé."
|
||||
deletable
|
||||
@row-click="openEdit"
|
||||
@delete="(item) => handleDelete(item.id)"
|
||||
>
|
||||
<template #cell-email="{ item }">
|
||||
{{ item.email ?? '-' }}
|
||||
</template>
|
||||
<template #cell-address="{ item }">
|
||||
{{ formatAddress(item) }}
|
||||
</template>
|
||||
<template #cell-phone="{ item }">
|
||||
{{ item.phone ?? '-' }}
|
||||
</template>
|
||||
</DataTable>
|
||||
|
||||
<ClientDrawer
|
||||
v-model="drawerOpen"
|
||||
:client="selectedClient"
|
||||
@saved="onSaved"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Client } from '~/services/dto/client'
|
||||
import { useClientService } from '~/services/clients'
|
||||
|
||||
import type { DataTableColumn } from '~/components/ui/DataTable.vue'
|
||||
|
||||
const columns: DataTableColumn[] = [
|
||||
{ key: 'name', label: 'Nom', primary: true },
|
||||
{ key: 'email', label: 'Email', class: 'text-primary-500' },
|
||||
{ key: 'address', label: 'Adresse', class: 'text-neutral-700' },
|
||||
{ key: 'phone', label: 'Téléphone', class: 'text-primary-500' },
|
||||
]
|
||||
|
||||
const { getAll, remove } = useClientService()
|
||||
const clients = ref<Client[]>([])
|
||||
const isLoading = ref(true)
|
||||
const drawerOpen = ref(false)
|
||||
const selectedClient = ref<Client | null>(null)
|
||||
|
||||
async function loadClients() {
|
||||
isLoading.value = true
|
||||
try {
|
||||
clients.value = await getAll()
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function openCreate() {
|
||||
selectedClient.value = null
|
||||
drawerOpen.value = true
|
||||
}
|
||||
|
||||
function openEdit(client: Client) {
|
||||
selectedClient.value = client
|
||||
drawerOpen.value = true
|
||||
}
|
||||
|
||||
function formatAddress(client: Client): string {
|
||||
return [client.street, client.postalCode, client.city]
|
||||
.filter(Boolean)
|
||||
.join(', ') || '-'
|
||||
}
|
||||
|
||||
async function handleDelete(id: number) {
|
||||
await remove(id)
|
||||
await loadClients()
|
||||
}
|
||||
|
||||
async function onSaved() {
|
||||
await loadClients()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadClients()
|
||||
})
|
||||
</script>
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Commit**
|
||||
|
||||
```bash
|
||||
git add frontend/components/admin/AdminClientTab.vue
|
||||
git commit -m "feat(admin) : add AdminClientTab component"
|
||||
```
|
||||
|
||||
### Task 2: Add Clients tab to admin page and remove standalone page
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/pages/admin.vue`
|
||||
- Delete: `frontend/pages/clients.vue`
|
||||
|
||||
- [ ] **Step 3: Update admin.vue to include Clients tab**
|
||||
|
||||
Add `AdminClientTab` to the admin page. Add the tab entry to the `tabs` array and the corresponding `v-if` block:
|
||||
|
||||
In `frontend/pages/admin.vue`, update the `tabs` array:
|
||||
|
||||
```typescript
|
||||
const tabs = [
|
||||
{ key: 'clients', label: 'Clients' },
|
||||
{ key: 'efforts', label: 'Efforts' },
|
||||
{ key: 'priorities', label: 'Priorités' },
|
||||
{ key: 'types', label: 'Types' },
|
||||
{ key: 'users', label: 'Utilisateurs' },
|
||||
] as const
|
||||
```
|
||||
|
||||
Change the default active tab:
|
||||
|
||||
```typescript
|
||||
const activeTab = ref<TabKey>('clients')
|
||||
```
|
||||
|
||||
Add the component in the template `<div class="mt-6">` block:
|
||||
|
||||
```html
|
||||
<AdminClientTab v-if="activeTab === 'clients'" />
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Delete standalone clients page**
|
||||
|
||||
Delete `frontend/pages/clients.vue`.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add frontend/pages/admin.vue
|
||||
git rm frontend/pages/clients.vue
|
||||
git commit -m "feat(admin) : move clients into admin page, remove standalone page"
|
||||
```
|
||||
|
||||
### Task 3: Remove clients sidebar link
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/layouts/default.vue`
|
||||
|
||||
- [ ] **Step 6: Remove the Clients SidebarLink from default.vue**
|
||||
|
||||
Remove the following block from `frontend/layouts/default.vue` (lines 60-65):
|
||||
|
||||
```html
|
||||
<SidebarLink
|
||||
to="/clients"
|
||||
icon="mdi:account-group-outline"
|
||||
label="Clients"
|
||||
:collapsed="ui.sidebarCollapsed"
|
||||
/>
|
||||
```
|
||||
|
||||
- [ ] **Step 7: Commit**
|
||||
|
||||
```bash
|
||||
git add frontend/layouts/default.vue
|
||||
git commit -m "refactor(frontend) : remove clients sidebar link"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Chunk 2: Make Task Statuses Global
|
||||
|
||||
### Task 4: Remove project relationship from TaskStatus entity
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/Entity/TaskStatus.php`
|
||||
|
||||
- [ ] **Step 8: Update TaskStatus entity to remove project relationship**
|
||||
|
||||
In `src/Entity/TaskStatus.php`:
|
||||
|
||||
1. Remove the `SearchFilter` import and `#[ApiFilter]` attribute
|
||||
2. Remove the `$project` property, its `#[ORM]` annotations, and the `getProject()`/`setProject()` methods
|
||||
|
||||
The entity should become:
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Entity;
|
||||
|
||||
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\TaskStatusRepository;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use Symfony\Component\Serializer\Attribute\Groups;
|
||||
|
||||
#[ApiResource(
|
||||
operations: [
|
||||
new GetCollection(),
|
||||
new Get(),
|
||||
new Post(security: "is_granted('ROLE_ADMIN')"),
|
||||
new Patch(security: "is_granted('ROLE_ADMIN')"),
|
||||
new Delete(security: "is_granted('ROLE_ADMIN')"),
|
||||
],
|
||||
normalizationContext: ['groups' => ['task_status:read']],
|
||||
denormalizationContext: ['groups' => ['task_status:write']],
|
||||
order: ['position' => 'ASC'],
|
||||
)]
|
||||
#[ORM\Entity(repositoryClass: TaskStatusRepository::class)]
|
||||
class TaskStatus
|
||||
{
|
||||
#[ORM\Id]
|
||||
#[ORM\GeneratedValue]
|
||||
#[ORM\Column]
|
||||
#[Groups(['task_status:read', 'task:read'])]
|
||||
private ?int $id = null;
|
||||
|
||||
#[ORM\Column(length: 255)]
|
||||
#[Groups(['task_status:read', 'task_status:write', 'task:read'])]
|
||||
private ?string $label = null;
|
||||
|
||||
#[ORM\Column(length: 7)]
|
||||
#[Groups(['task_status:read', 'task_status:write', 'task:read'])]
|
||||
private ?string $color = '#222783';
|
||||
|
||||
#[ORM\Column]
|
||||
#[Groups(['task_status:read', 'task_status:write', 'task:read'])]
|
||||
private ?int $position = 0;
|
||||
|
||||
public function getId(): ?int
|
||||
{
|
||||
return $this->id;
|
||||
}
|
||||
|
||||
public function getLabel(): ?string
|
||||
{
|
||||
return $this->label;
|
||||
}
|
||||
|
||||
public function setLabel(string $label): static
|
||||
{
|
||||
$this->label = $label;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getColor(): ?string
|
||||
{
|
||||
return $this->color;
|
||||
}
|
||||
|
||||
public function setColor(string $color): static
|
||||
{
|
||||
$this->color = $color;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getPosition(): ?int
|
||||
{
|
||||
return $this->position;
|
||||
}
|
||||
|
||||
public function setPosition(int $position): static
|
||||
{
|
||||
$this->position = $position;
|
||||
|
||||
return $this;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 9: Commit**
|
||||
|
||||
```bash
|
||||
git add src/Entity/TaskStatus.php
|
||||
git commit -m "refactor(backend) : remove project relationship from TaskStatus entity"
|
||||
```
|
||||
|
||||
### Task 5: Generate and run Doctrine migration
|
||||
|
||||
- [ ] **Step 10: Generate the migration**
|
||||
|
||||
```bash
|
||||
make shell
|
||||
# Inside container:
|
||||
php bin/console doctrine:migrations:diff
|
||||
exit
|
||||
```
|
||||
|
||||
This should generate a migration that:
|
||||
- Drops the `project_id` foreign key from `task_status` table
|
||||
- Drops the `project_id` column from `task_status` table
|
||||
|
||||
- [ ] **Step 11: Review the migration**
|
||||
|
||||
Read the generated migration file in `migrations/` to verify it only drops the FK and column.
|
||||
|
||||
- [ ] **Step 12: Reset database (since structure changed significantly)**
|
||||
|
||||
```bash
|
||||
make db-reset
|
||||
```
|
||||
|
||||
- [ ] **Step 13: Commit**
|
||||
|
||||
```bash
|
||||
git add migrations/
|
||||
git commit -m "feat(backend) : add migration to remove project_id from task_status"
|
||||
```
|
||||
|
||||
### Task 6: Update fixtures for global statuses
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/DataFixtures/AppFixtures.php`
|
||||
|
||||
- [ ] **Step 14: Update fixtures to create global statuses instead of per-project**
|
||||
|
||||
In `src/DataFixtures/AppFixtures.php`, replace the entire per-project status block (lines 95-124, from `// Task Statuses (per project)` through `$statusDone = $sirhStatuses['Terminé'];`) with global creation:
|
||||
|
||||
```php
|
||||
// Task Statuses (global)
|
||||
$defaultStatuses = [
|
||||
['A faire', '#222783', 0],
|
||||
['En cours', '#4A90D9', 1],
|
||||
['Bloqué', '#C62828', 2],
|
||||
['En attente de validation', '#FF8F00', 3],
|
||||
['Terminé', '#26A69A', 4],
|
||||
];
|
||||
|
||||
$statusObjects = [];
|
||||
foreach ($defaultStatuses as [$label, $color, $position]) {
|
||||
$status = new TaskStatus();
|
||||
$status->setLabel($label);
|
||||
$status->setColor($color);
|
||||
$status->setPosition($position);
|
||||
$manager->persist($status);
|
||||
$statusObjects[$label] = $status;
|
||||
}
|
||||
|
||||
$statusTodo = $statusObjects['A faire'];
|
||||
$statusInProgress = $statusObjects['En cours'];
|
||||
$statusBlocked = $statusObjects['Bloqué'];
|
||||
$statusReview = $statusObjects['En attente de validation'];
|
||||
$statusDone = $statusObjects['Terminé'];
|
||||
```
|
||||
|
||||
This replaces the loop that created statuses per-project AND the `$statusesByProject` / `$sirhStatuses` extraction lines (95-124). The task variable references (`$statusTodo`, etc.) remain identical so downstream task creation is unchanged.
|
||||
|
||||
- [ ] **Step 15: Reload fixtures to verify**
|
||||
|
||||
```bash
|
||||
make db-reset
|
||||
```
|
||||
|
||||
- [ ] **Step 16: Commit**
|
||||
|
||||
```bash
|
||||
git add src/DataFixtures/AppFixtures.php
|
||||
git commit -m "fix(fixtures) : create global statuses instead of per-project"
|
||||
```
|
||||
|
||||
### Task 7: Update frontend DTO and service for global statuses
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/services/dto/task-status.ts`
|
||||
- Modify: `frontend/services/task-statuses.ts`
|
||||
|
||||
- [ ] **Step 17: Update TaskStatus DTO to remove project field**
|
||||
|
||||
In `frontend/services/dto/task-status.ts`, remove the `project` import and field from both types:
|
||||
|
||||
```typescript
|
||||
export type TaskStatus = {
|
||||
id: number
|
||||
'@id'?: string
|
||||
label: string
|
||||
color: string
|
||||
position: number
|
||||
}
|
||||
|
||||
export type TaskStatusWrite = {
|
||||
label: string
|
||||
color: string
|
||||
position: number
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 18: Remove getByProject from task-statuses service**
|
||||
|
||||
In `frontend/services/task-statuses.ts`, remove the `getByProject` function and its return:
|
||||
|
||||
```typescript
|
||||
import type { TaskStatus, TaskStatusWrite } from './dto/task-status'
|
||||
import type { HydraCollection } from '~/utils/api'
|
||||
import { extractHydraMembers } from '~/utils/api'
|
||||
|
||||
export function useTaskStatusService() {
|
||||
const api = useApi()
|
||||
|
||||
async function getAll(): Promise<TaskStatus[]> {
|
||||
const data = await api.get<HydraCollection<TaskStatus>>('/task_statuses')
|
||||
return extractHydraMembers(data)
|
||||
}
|
||||
|
||||
async function create(payload: TaskStatusWrite): Promise<TaskStatus> {
|
||||
return api.post<TaskStatus>('/task_statuses', payload as Record<string, unknown>, {
|
||||
toastSuccessKey: 'taskStatuses.created',
|
||||
})
|
||||
}
|
||||
|
||||
async function update(id: number, payload: Partial<TaskStatusWrite>): Promise<TaskStatus> {
|
||||
return api.patch<TaskStatus>(`/task_statuses/${id}`, payload as Record<string, unknown>, {
|
||||
toastSuccessKey: 'taskStatuses.updated',
|
||||
})
|
||||
}
|
||||
|
||||
async function remove(id: number): Promise<void> {
|
||||
await api.delete(`/task_statuses/${id}`, {}, {
|
||||
toastSuccessKey: 'taskStatuses.deleted',
|
||||
})
|
||||
}
|
||||
|
||||
return { getAll, create, update, remove }
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 19: Commit**
|
||||
|
||||
```bash
|
||||
git add frontend/services/dto/task-status.ts frontend/services/task-statuses.ts
|
||||
git commit -m "refactor(frontend) : remove project from TaskStatus DTO and service"
|
||||
```
|
||||
|
||||
### Task 8: Update TaskStatusDrawer to remove projectId
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/components/task/TaskStatusDrawer.vue`
|
||||
|
||||
- [ ] **Step 20: Remove projectId prop from TaskStatusDrawer**
|
||||
|
||||
In `frontend/components/task/TaskStatusDrawer.vue`:
|
||||
|
||||
1. Remove `projectId` from props:
|
||||
|
||||
```typescript
|
||||
const props = defineProps<{
|
||||
modelValue: boolean
|
||||
item: TaskStatus | null
|
||||
}>()
|
||||
```
|
||||
|
||||
2. Remove the `project` field from the payload in `handleSubmit`:
|
||||
|
||||
```typescript
|
||||
const payload: TaskStatusWrite = {
|
||||
label: form.label.trim(),
|
||||
position: Number(form.position),
|
||||
color: form.color,
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 21: Commit**
|
||||
|
||||
```bash
|
||||
git add frontend/components/task/TaskStatusDrawer.vue
|
||||
git commit -m "refactor(frontend) : remove projectId from TaskStatusDrawer"
|
||||
```
|
||||
|
||||
### Task 9: Add getAll to task service and update AdminStatusTab
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/services/tasks.ts`
|
||||
- Modify: `frontend/components/admin/AdminStatusTab.vue`
|
||||
|
||||
- [ ] **Step 22: Add getAll() method to task service**
|
||||
|
||||
`frontend/services/tasks.ts` currently only has `getByProject()`. Add a `getAll` function (needed by AdminStatusTab to check all tasks across projects when deleting a status).
|
||||
|
||||
Add this function inside `useTaskService()`, before `getByProject`:
|
||||
|
||||
```typescript
|
||||
async function getAll(): Promise<Task[]> {
|
||||
const data = await api.get<HydraCollection<Task>>('/tasks')
|
||||
return extractHydraMembers(data)
|
||||
}
|
||||
```
|
||||
|
||||
Update the return statement to include it:
|
||||
|
||||
```typescript
|
||||
return { getAll, getByProject, create, update, remove }
|
||||
```
|
||||
|
||||
- [ ] **Step 23: Update AdminStatusTab to handle task reassignment on delete**
|
||||
|
||||
The existing `AdminStatusTab` does a simple `remove(id)` which would leave tasks orphaned. Port the reassignment logic from `ProjectStatusTab` (which is being deleted). Since statuses are now global, we need to load ALL tasks (not per-project) to check for affected tasks.
|
||||
|
||||
Replace the full content of `frontend/components/admin/AdminStatusTab.vue` with:
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<div>
|
||||
<div class="flex items-center justify-between">
|
||||
<h2 class="text-lg font-bold text-neutral-900">Statuts</h2>
|
||||
<button
|
||||
class="rounded-md bg-primary-500 px-4 py-2 text-sm font-semibold text-white hover:bg-secondary-500"
|
||||
@click="openCreate"
|
||||
>
|
||||
+ Ajouter un statut
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<DataTable
|
||||
:columns="columns"
|
||||
:items="items"
|
||||
:loading="isLoading"
|
||||
empty-message="Aucun statut trouvé."
|
||||
deletable
|
||||
@row-click="openEdit"
|
||||
@delete="requestDelete"
|
||||
>
|
||||
<template #cell-color="{ item }">
|
||||
<span
|
||||
class="inline-block h-6 w-6 rounded-full"
|
||||
:style="{ backgroundColor: item.color }"
|
||||
/>
|
||||
</template>
|
||||
</DataTable>
|
||||
|
||||
<TaskStatusDrawer
|
||||
v-model="drawerOpen"
|
||||
:item="selectedItem"
|
||||
@saved="onSaved"
|
||||
/>
|
||||
|
||||
<ConfirmDeleteStatusModal
|
||||
v-model="confirmModalOpen"
|
||||
:status-label="statusToDelete?.label ?? ''"
|
||||
:task-count="affectedTaskCount"
|
||||
:available-statuses="reassignTargets"
|
||||
@confirm="onConfirmDelete"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { TaskStatus } from '~/services/dto/task-status'
|
||||
import type { Task } from '~/services/dto/task'
|
||||
import { useTaskStatusService } from '~/services/task-statuses'
|
||||
import { useTaskService } from '~/services/tasks'
|
||||
|
||||
import type { DataTableColumn } from '~/components/ui/DataTable.vue'
|
||||
|
||||
const columns: DataTableColumn[] = [
|
||||
{ key: 'label', label: 'Libellé', primary: true },
|
||||
{ key: 'color', label: 'Couleur' },
|
||||
{ key: 'position', label: 'Position', class: 'text-neutral-700' },
|
||||
]
|
||||
|
||||
const statusService = useTaskStatusService()
|
||||
const taskService = useTaskService()
|
||||
|
||||
const items = ref<TaskStatus[]>([])
|
||||
const tasks = ref<Task[]>([])
|
||||
const isLoading = ref(true)
|
||||
const drawerOpen = ref(false)
|
||||
const selectedItem = ref<TaskStatus | null>(null)
|
||||
const confirmModalOpen = ref(false)
|
||||
const statusToDelete = ref<TaskStatus | null>(null)
|
||||
|
||||
const affectedTaskCount = computed(() => {
|
||||
if (!statusToDelete.value) return 0
|
||||
return tasks.value.filter(t => t.status?.id === statusToDelete.value!.id).length
|
||||
})
|
||||
|
||||
const reassignTargets = computed(() => {
|
||||
if (!statusToDelete.value) return items.value
|
||||
return items.value.filter(s => s.id !== statusToDelete.value!.id)
|
||||
})
|
||||
|
||||
async function loadItems() {
|
||||
isLoading.value = true
|
||||
try {
|
||||
const [statuses, allTasks] = await Promise.all([
|
||||
statusService.getAll(),
|
||||
taskService.getAll(),
|
||||
])
|
||||
items.value = statuses
|
||||
tasks.value = allTasks
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function openCreate() {
|
||||
selectedItem.value = null
|
||||
drawerOpen.value = true
|
||||
}
|
||||
|
||||
function openEdit(item: TaskStatus) {
|
||||
selectedItem.value = item
|
||||
drawerOpen.value = true
|
||||
}
|
||||
|
||||
async function requestDelete(item: TaskStatus) {
|
||||
statusToDelete.value = item
|
||||
const count = tasks.value.filter(t => t.status?.id === item.id).length
|
||||
if (count === 0) {
|
||||
await statusService.remove(item.id)
|
||||
await loadItems()
|
||||
} else {
|
||||
confirmModalOpen.value = true
|
||||
}
|
||||
}
|
||||
|
||||
async function onConfirmDelete(targetStatusId: number | null) {
|
||||
if (!statusToDelete.value) return
|
||||
|
||||
const affectedTasks = tasks.value.filter(t => t.status?.id === statusToDelete.value!.id)
|
||||
const statusIri = targetStatusId ? `/api/task_statuses/${targetStatusId}` : null
|
||||
|
||||
await Promise.all(
|
||||
affectedTasks.map(t => taskService.update(t.id, { status: statusIri }))
|
||||
)
|
||||
|
||||
await statusService.remove(statusToDelete.value.id)
|
||||
confirmModalOpen.value = false
|
||||
statusToDelete.value = null
|
||||
await loadItems()
|
||||
}
|
||||
|
||||
async function onSaved() {
|
||||
await loadItems()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadItems()
|
||||
})
|
||||
</script>
|
||||
```
|
||||
|
||||
- [ ] **Step 24: Commit**
|
||||
|
||||
```bash
|
||||
git add frontend/services/tasks.ts frontend/components/admin/AdminStatusTab.vue
|
||||
git commit -m "feat(admin) : add task reassignment logic to AdminStatusTab"
|
||||
```
|
||||
|
||||
### Task 10: Add Statuts tab to admin page
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/pages/admin.vue`
|
||||
|
||||
- [ ] **Step 24: Add Statuts tab to admin.vue**
|
||||
|
||||
In `frontend/pages/admin.vue`, update the `tabs` array to include statuses:
|
||||
|
||||
```typescript
|
||||
const tabs = [
|
||||
{ key: 'clients', label: 'Clients' },
|
||||
{ key: 'statuses', label: 'Statuts' },
|
||||
{ key: 'efforts', label: 'Efforts' },
|
||||
{ key: 'priorities', label: 'Priorités' },
|
||||
{ key: 'types', label: 'Types' },
|
||||
{ key: 'users', label: 'Utilisateurs' },
|
||||
] as const
|
||||
```
|
||||
|
||||
Add the component in the template:
|
||||
|
||||
```html
|
||||
<AdminStatusTab v-if="activeTab === 'statuses'" />
|
||||
```
|
||||
|
||||
- [ ] **Step 25: Commit**
|
||||
|
||||
```bash
|
||||
git add frontend/pages/admin.vue
|
||||
git commit -m "feat(admin) : add statuts tab to admin page"
|
||||
```
|
||||
|
||||
### Task 11: Update kanban page to use global statuses
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/pages/projects/[id]/index.vue`
|
||||
|
||||
- [ ] **Step 26: Change kanban to load global statuses**
|
||||
|
||||
In `frontend/pages/projects/[id]/index.vue`, in the `loadData` function, change:
|
||||
|
||||
```typescript
|
||||
statusService.getByProject(projectId.value),
|
||||
```
|
||||
|
||||
to:
|
||||
|
||||
```typescript
|
||||
statusService.getAll(),
|
||||
```
|
||||
|
||||
- [ ] **Step 27: Commit**
|
||||
|
||||
```bash
|
||||
git add frontend/pages/projects/[id]/index.vue
|
||||
git commit -m "refactor(frontend) : load global statuses in kanban page"
|
||||
```
|
||||
|
||||
### Task 12: Remove per-project status pages, sidebar link, and orphaned components
|
||||
|
||||
**Files:**
|
||||
- Delete: `frontend/pages/projects/[id]/statuses.vue`
|
||||
- Delete: `frontend/components/project/ProjectStatusTab.vue`
|
||||
- Modify: `frontend/layouts/default.vue`
|
||||
|
||||
- [ ] **Step 28: Delete per-project statuses page and ProjectStatusTab**
|
||||
|
||||
```bash
|
||||
git rm frontend/pages/projects/[id]/statuses.vue
|
||||
git rm frontend/components/project/ProjectStatusTab.vue
|
||||
```
|
||||
|
||||
- [ ] **Step 29: Remove statuses SidebarLink from default.vue**
|
||||
|
||||
In `frontend/layouts/default.vue`, remove the statuses sidebar link block (inside the `v-if="currentProjectId"` template, lines 52-58):
|
||||
|
||||
```html
|
||||
<SidebarLink
|
||||
:to="`/projects/${currentProjectId}/statuses`"
|
||||
icon="mdi:list-status"
|
||||
label="Statuts"
|
||||
:collapsed="ui.sidebarCollapsed"
|
||||
sub
|
||||
/>
|
||||
```
|
||||
|
||||
- [ ] **Step 30: Commit**
|
||||
|
||||
```bash
|
||||
git add frontend/layouts/default.vue
|
||||
git commit -m "refactor(frontend) : remove per-project statuses page and sidebar link"
|
||||
```
|
||||
|
||||
### Task 13: Verify and clean up
|
||||
|
||||
- [ ] **Step 31: Check for remaining references to getByProject in task-statuses**
|
||||
|
||||
Search for any remaining `getByProject` calls on the status service and `projectId` references in status-related components:
|
||||
|
||||
```bash
|
||||
cd /home/matthieu/dev_malio/Lesstime
|
||||
grep -rn "getByProject\|projectId" frontend/ --include="*.vue" --include="*.ts" | grep -i status
|
||||
grep -rn "ConfirmDeleteStatusModal\|ProjectStatusTab" frontend/ --include="*.vue" --include="*.ts"
|
||||
```
|
||||
|
||||
Fix any remaining references found.
|
||||
|
||||
- [ ] **Step 32: Run the dev server and verify**
|
||||
|
||||
```bash
|
||||
make db-reset && make dev-nuxt
|
||||
```
|
||||
|
||||
Verify:
|
||||
1. Admin page shows Clients tab with full CRUD (create, edit, delete)
|
||||
2. Admin page shows Statuts tab with global statuses CRUD
|
||||
3. Sidebar no longer shows "Clients" or per-project "Statuts" links
|
||||
4. Kanban board displays all global statuses as columns
|
||||
5. No errors in browser console
|
||||
|
||||
- [ ] **Step 33: Final commit if any cleanup was needed**
|
||||
|
||||
```bash
|
||||
git add -A
|
||||
git commit -m "chore : clean up remaining references after global statuses refactor"
|
||||
```
|
||||
@@ -0,0 +1,158 @@
|
||||
# Time Entry Multi-Type Selection 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:** Allow selecting multiple task types in the TimeEntryDrawer, matching the existing multi-select pattern used in TaskDrawer.
|
||||
|
||||
**Architecture:** Replace the single-select `MalioSelect` dropdown for types with the checkbox-based colored badge multi-select already used in TaskDrawer. The backend (ManyToMany relation) and DTO (`types: string[]`) already support multiple types — only the frontend form state and template need updating.
|
||||
|
||||
**Tech Stack:** Vue 3, TypeScript
|
||||
|
||||
---
|
||||
|
||||
## Chunk 1: Multi-Type Select in TimeEntryDrawer
|
||||
|
||||
### Task 1: Update TimeEntryDrawer to support multiple type selection
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/components/time-tracking/TimeEntryDrawer.vue`
|
||||
|
||||
- [ ] **Step 1: Change form state from single typeId to typeIds array**
|
||||
|
||||
In the `form` reactive object (line 133-142), replace:
|
||||
|
||||
```typescript
|
||||
typeId: null as number | null,
|
||||
```
|
||||
|
||||
with:
|
||||
|
||||
```typescript
|
||||
typeIds: [] as number[],
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Add toggleType function**
|
||||
|
||||
Add this function after the `durationLabel` computed (after line 165):
|
||||
|
||||
```typescript
|
||||
function toggleType(id: number) {
|
||||
const idx = form.typeIds.indexOf(id)
|
||||
if (idx >= 0) {
|
||||
form.typeIds.splice(idx, 1)
|
||||
} else {
|
||||
form.typeIds.push(id)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Remove the typeOptions computed**
|
||||
|
||||
Delete the `typeOptions` computed (lines 152-154):
|
||||
|
||||
```typescript
|
||||
const typeOptions = computed(() =>
|
||||
props.types.map(t => ({ label: t.label, value: t.id }))
|
||||
)
|
||||
```
|
||||
|
||||
This is no longer needed since we won't use `MalioSelect`.
|
||||
|
||||
- [ ] **Step 4: Replace MalioSelect template with multi-select badges**
|
||||
|
||||
Replace the `MalioSelect` for type (lines 75-81):
|
||||
|
||||
```vue
|
||||
<MalioSelect
|
||||
v-model="form.typeId"
|
||||
:options="typeOptions"
|
||||
label="Type"
|
||||
empty-option-label="— Aucun —"
|
||||
min-width="w-full"
|
||||
/>
|
||||
```
|
||||
|
||||
with:
|
||||
|
||||
```vue
|
||||
<div>
|
||||
<p class="mb-2 text-sm font-semibold text-neutral-700">Types</p>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<label
|
||||
v-for="type in types"
|
||||
:key="type.id"
|
||||
class="cursor-pointer rounded-full px-3 py-1 text-xs font-semibold transition"
|
||||
:class="form.typeIds.includes(type.id)
|
||||
? 'text-white'
|
||||
: 'bg-neutral-100 text-neutral-600 hover:bg-neutral-200'"
|
||||
:style="form.typeIds.includes(type.id) ? { backgroundColor: type.color } : {}"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
class="hidden"
|
||||
:value="type.id"
|
||||
:checked="form.typeIds.includes(type.id)"
|
||||
@change="toggleType(type.id)"
|
||||
/>
|
||||
{{ type.label }}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Update populateForm to use typeIds**
|
||||
|
||||
In the `populateForm` function, replace (line 194):
|
||||
|
||||
```typescript
|
||||
form.typeId = entry.types?.[0]?.id ?? null
|
||||
```
|
||||
|
||||
with:
|
||||
|
||||
```typescript
|
||||
form.typeIds = entry.types?.map(t => t.id) ?? []
|
||||
```
|
||||
|
||||
And in the else branch (line 203), replace:
|
||||
|
||||
```typescript
|
||||
form.typeId = null
|
||||
```
|
||||
|
||||
with:
|
||||
|
||||
```typescript
|
||||
form.typeIds = []
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Update onSubmit payload to use typeIds**
|
||||
|
||||
In the `onSubmit` function, replace (line 233):
|
||||
|
||||
```typescript
|
||||
types: form.typeId ? [`/api/task_types/${form.typeId}`] : [],
|
||||
```
|
||||
|
||||
with:
|
||||
|
||||
```typescript
|
||||
types: form.typeIds.map(id => `/api/task_types/${id}`),
|
||||
```
|
||||
|
||||
- [ ] **Step 7: Verify in browser**
|
||||
|
||||
Run: `make dev-nuxt`
|
||||
|
||||
1. Open time tracking page
|
||||
2. Open an existing time entry → verify existing types are pre-selected as colored badges
|
||||
3. Toggle types on/off → verify visual feedback (colored background when selected)
|
||||
4. Save → verify types are persisted correctly
|
||||
5. Create a new time entry with multiple types → verify they save correctly
|
||||
|
||||
- [ ] **Step 8: Commit**
|
||||
|
||||
```bash
|
||||
git add frontend/components/time-tracking/TimeEntryDrawer.vue
|
||||
git commit -m "feat(frontend) : allow multiple type selection in time entry drawer"
|
||||
```
|
||||
2248
docs/superpowers/plans/2026-03-13-gitea-integration.md
Normal file
2248
docs/superpowers/plans/2026-03-13-gitea-integration.md
Normal file
File diff suppressed because it is too large
Load Diff
584
docs/superpowers/plans/2026-03-13-my-tasks-page.md
Normal file
584
docs/superpowers/plans/2026-03-13-my-tasks-page.md
Normal file
@@ -0,0 +1,584 @@
|
||||
# My Tasks Page 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 "/my-tasks" page that displays all non-archived tasks across projects with Kanban and List views, filtered by current user by default.
|
||||
|
||||
**Architecture:** Backend: add SearchFilter annotations on Task entity for server-side filtering. Frontend: new page with filter bar + two view modes (Kanban/List), reusing existing TaskCard and TaskModal components.
|
||||
|
||||
**Tech Stack:** PHP 8.4 / API Platform 4 (SearchFilter), Nuxt 4 / Vue 3, Tailwind CSS, MalioSelect, Pinia
|
||||
|
||||
**Spec:** `docs/superpowers/specs/2026-03-13-my-tasks-page-design.md`
|
||||
|
||||
---
|
||||
|
||||
## Chunk 1: Backend — API Filters
|
||||
|
||||
### Task 1: Add SearchFilter annotations on Task entity
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/Entity/Task.php:35` (ApiFilter line)
|
||||
|
||||
- [ ] **Step 1: Add new SearchFilter properties**
|
||||
|
||||
In `src/Entity/Task.php`, replace the existing `#[ApiFilter(SearchFilter::class, ...)]` line (line 35) with an expanded version that includes `assignee`, `priority`, `effort`, `tags`, and `status`:
|
||||
|
||||
```php
|
||||
#[ApiFilter(SearchFilter::class, properties: ['project' => 'exact', 'group' => 'exact', 'assignee' => 'exact', 'priority' => 'exact', 'effort' => 'exact', 'tags' => 'exact', 'status' => 'exact'])]
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Disable pagination on GetCollection**
|
||||
|
||||
In `src/Entity/Task.php`, modify the `GetCollection` operation (line 25) to disable pagination:
|
||||
|
||||
```php
|
||||
new GetCollection(paginationEnabled: false),
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Verify filters work**
|
||||
|
||||
Run in the container:
|
||||
```bash
|
||||
docker exec -t php-lesstime-fpm php bin/console debug:router | grep tasks
|
||||
```
|
||||
Then test the API call:
|
||||
```bash
|
||||
curl -s 'http://localhost:8082/api/tasks?archived=false&assignee=/api/users/1' -H 'Cookie: BEARER=...' | head -c 500
|
||||
```
|
||||
Expected: JSON response with filtered tasks.
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add src/Entity/Task.php
|
||||
git commit -m "feat(backend) : add SearchFilter for assignee, priority, effort, tags, status on Task"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Chunk 2: Frontend — Service, i18n, Sidebar
|
||||
|
||||
### Task 2: Add `getFiltered` method to task service
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/services/tasks.ts`
|
||||
|
||||
- [ ] **Step 1: Add the `getFiltered` method**
|
||||
|
||||
Add after the `getByProjectArchived` method (after line 27) in `frontend/services/tasks.ts`:
|
||||
|
||||
```typescript
|
||||
async function getFiltered(params: Record<string, string | number | boolean | string[]>): Promise<Task[]> {
|
||||
const data = await api.get<HydraCollection<Task>>('/tasks', params as Record<string, unknown>)
|
||||
return extractHydraMembers(data)
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Export the new method**
|
||||
|
||||
Update the return statement (line 47) to include `getFiltered`:
|
||||
|
||||
```typescript
|
||||
return { getAll, getByProject, getByProjectArchived, getFiltered, create, update, remove }
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add frontend/services/tasks.ts
|
||||
git commit -m "feat(frontend) : add getFiltered method to task service"
|
||||
```
|
||||
|
||||
### Task 3: Add i18n translations
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/i18n/locales/fr.json`
|
||||
|
||||
- [ ] **Step 1: Add myTasks and sidebar keys**
|
||||
|
||||
Add these entries to `frontend/i18n/locales/fr.json` (before the closing `}`):
|
||||
|
||||
```json
|
||||
"myTasks": {
|
||||
"title": "Mes tâches",
|
||||
"viewKanban": "Vue Kanban",
|
||||
"viewList": "Vue Liste",
|
||||
"allProjects": "Tous les projets",
|
||||
"allGroups": "Tous les groupes",
|
||||
"allTypes": "Tous les types",
|
||||
"allPriorities": "Toutes les priorités",
|
||||
"allEfforts": "Tous les efforts",
|
||||
"allAssignees": "Tous",
|
||||
"noTasks": "Aucune tâche",
|
||||
"backlog": "Backlog"
|
||||
},
|
||||
"sidebar": {
|
||||
"myTasks": "Mes tâches"
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Commit**
|
||||
|
||||
```bash
|
||||
git add frontend/i18n/locales/fr.json
|
||||
git commit -m "feat(frontend) : add i18n translations for my-tasks page"
|
||||
```
|
||||
|
||||
### Task 4: Add sidebar navigation link
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/layouts/default.vue:23-35` (nav section)
|
||||
|
||||
- [ ] **Step 1: Add SidebarLink for "Mes tâches"**
|
||||
|
||||
In `frontend/layouts/default.vue`, add a new `SidebarLink` between the "Tableau de bord" link (line 29) and the "Projets" link (line 30):
|
||||
|
||||
```vue
|
||||
<SidebarLink
|
||||
to="/my-tasks"
|
||||
icon="mdi:clipboard-check-outline"
|
||||
label="Mes tâches"
|
||||
:collapsed="ui.sidebarCollapsed"
|
||||
/>
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Commit**
|
||||
|
||||
```bash
|
||||
git add frontend/layouts/default.vue
|
||||
git commit -m "feat(frontend) : add Mes tâches link to sidebar navigation"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Chunk 3: Frontend — My Tasks Page (Kanban + List views)
|
||||
|
||||
### Task 5: Create the my-tasks page
|
||||
|
||||
**Files:**
|
||||
- Create: `frontend/pages/my-tasks.vue`
|
||||
|
||||
- [ ] **Step 1: Create the page file with imports and data loading**
|
||||
|
||||
Create `frontend/pages/my-tasks.vue` with the full page implementation. The page structure:
|
||||
|
||||
**Script section** — data loading pattern (same as `projects/[id]/index.vue`):
|
||||
|
||||
```typescript
|
||||
<script setup lang="ts">
|
||||
import type { Task } from '~/services/dto/task'
|
||||
import type { TaskStatus } from '~/services/dto/task-status'
|
||||
import type { TaskEffort } from '~/services/dto/task-effort'
|
||||
import type { TaskPriority } from '~/services/dto/task-priority'
|
||||
import type { TaskTag } from '~/services/dto/task-tag'
|
||||
import type { TaskGroup } from '~/services/dto/task-group'
|
||||
import type { UserData } from '~/services/dto/user-data'
|
||||
import type { Project } from '~/services/dto/project'
|
||||
import { useTaskService } from '~/services/tasks'
|
||||
import { useTaskStatusService } from '~/services/task-statuses'
|
||||
import { useTaskEffortService } from '~/services/task-efforts'
|
||||
import { useTaskPriorityService } from '~/services/task-priorities'
|
||||
import { useTaskTagService } from '~/services/task-tags'
|
||||
import { useTaskGroupService } from '~/services/task-groups'
|
||||
import { useUserService } from '~/services/users'
|
||||
import { useProjectService } from '~/services/projects'
|
||||
|
||||
const { t } = useI18n()
|
||||
const auth = useAuthStore()
|
||||
|
||||
useHead({ title: t('myTasks.title') })
|
||||
|
||||
const taskService = useTaskService()
|
||||
const statusService = useTaskStatusService()
|
||||
const effortService = useTaskEffortService()
|
||||
const priorityService = useTaskPriorityService()
|
||||
const tagService = useTaskTagService()
|
||||
const groupService = useTaskGroupService()
|
||||
const userService = useUserService()
|
||||
const projectService = useProjectService()
|
||||
|
||||
const tasks = ref<Task[]>([])
|
||||
const statuses = ref<TaskStatus[]>([])
|
||||
const efforts = ref<TaskEffort[]>([])
|
||||
const priorities = ref<TaskPriority[]>([])
|
||||
const tags = ref<TaskTag[]>([])
|
||||
const groups = ref<TaskGroup[]>([])
|
||||
const users = ref<UserData[]>([])
|
||||
const projects = ref<Project[]>([])
|
||||
const isLoading = ref(true)
|
||||
|
||||
// Filters
|
||||
const selectedProjectId = ref<number | null>(null)
|
||||
const selectedGroupId = ref<number | null>(null)
|
||||
const selectedTagId = ref<number | null>(null)
|
||||
const selectedPriorityId = ref<number | null>(null)
|
||||
const selectedEffortId = ref<number | null>(null)
|
||||
const selectedAssigneeId = ref<number | null>(auth.user?.id ?? null)
|
||||
|
||||
// View toggle
|
||||
const viewMode = ref<'kanban' | 'list'>('kanban')
|
||||
|
||||
// Modal
|
||||
const taskModalOpen = ref(false)
|
||||
const selectedTask = ref<Task | null>(null)
|
||||
|
||||
// Filter options
|
||||
const projectOptions = computed(() =>
|
||||
projects.value.map(p => ({ label: p.name, value: p.id }))
|
||||
)
|
||||
|
||||
const groupOptions = computed(() => {
|
||||
let g = groups.value.filter(g => !g.archived)
|
||||
if (selectedProjectId.value) {
|
||||
g = g.filter(g => g.project?.id === selectedProjectId.value)
|
||||
}
|
||||
return g.map(g => ({ label: g.title, value: g.id }))
|
||||
})
|
||||
|
||||
const tagOptions = computed(() =>
|
||||
tags.value.map(t => ({ label: t.label, value: t.id }))
|
||||
)
|
||||
|
||||
const priorityOptions = computed(() =>
|
||||
priorities.value.map(p => ({ label: p.label, value: p.id }))
|
||||
)
|
||||
|
||||
const effortOptions = computed(() =>
|
||||
efforts.value.map(e => ({ label: e.label, value: e.id }))
|
||||
)
|
||||
|
||||
const assigneeOptions = computed(() =>
|
||||
users.value.map(u => ({ label: u.username, value: u.id }))
|
||||
)
|
||||
|
||||
// Kanban helpers
|
||||
const sortedStatuses = computed(() =>
|
||||
[...statuses.value].sort((a, b) => a.position - b.position)
|
||||
)
|
||||
|
||||
function tasksByStatus(statusId: number): Task[] {
|
||||
return tasks.value.filter(t => t.status?.id === statusId)
|
||||
}
|
||||
|
||||
const backlogTasks = computed(() =>
|
||||
tasks.value.filter(t => !t.status)
|
||||
)
|
||||
|
||||
// Data loading
|
||||
async function loadReferenceData() {
|
||||
const [s, e, pr, tg, g, u, p] = await Promise.all([
|
||||
statusService.getAll(),
|
||||
effortService.getAll(),
|
||||
priorityService.getAll(),
|
||||
tagService.getAll(),
|
||||
groupService.getAll(),
|
||||
userService.getAll(),
|
||||
projectService.getAll(),
|
||||
])
|
||||
statuses.value = s
|
||||
efforts.value = e
|
||||
priorities.value = pr
|
||||
tags.value = tg
|
||||
groups.value = g
|
||||
users.value = u
|
||||
projects.value = p
|
||||
}
|
||||
|
||||
async function loadTasks() {
|
||||
const params: Record<string, string | number | boolean | string[]> = {
|
||||
archived: false,
|
||||
}
|
||||
if (selectedAssigneeId.value) {
|
||||
params.assignee = `/api/users/${selectedAssigneeId.value}`
|
||||
}
|
||||
if (selectedProjectId.value) {
|
||||
params.project = `/api/projects/${selectedProjectId.value}`
|
||||
}
|
||||
if (selectedGroupId.value) {
|
||||
params.group = `/api/task_groups/${selectedGroupId.value}`
|
||||
}
|
||||
if (selectedPriorityId.value) {
|
||||
params.priority = `/api/task_priorities/${selectedPriorityId.value}`
|
||||
}
|
||||
if (selectedEffortId.value) {
|
||||
params.effort = `/api/task_efforts/${selectedEffortId.value}`
|
||||
}
|
||||
if (selectedTagId.value) {
|
||||
params['tags[]'] = `/api/task_tags/${selectedTagId.value}`
|
||||
}
|
||||
tasks.value = await taskService.getFiltered(params)
|
||||
}
|
||||
|
||||
async function loadAll() {
|
||||
isLoading.value = true
|
||||
try {
|
||||
await Promise.all([loadReferenceData(), loadTasks()])
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// Watch filters to reload tasks
|
||||
watch(
|
||||
[selectedProjectId, selectedGroupId, selectedTagId, selectedPriorityId, selectedEffortId, selectedAssigneeId],
|
||||
() => { loadTasks() },
|
||||
)
|
||||
|
||||
// Reset group when project changes (no extra loadTasks — the above watcher handles it)
|
||||
watch(selectedProjectId, () => {
|
||||
selectedGroupId.value = null
|
||||
}, { flush: 'sync' })
|
||||
|
||||
// Modal
|
||||
function openTaskEdit(task: Task) {
|
||||
selectedTask.value = task
|
||||
taskModalOpen.value = true
|
||||
}
|
||||
|
||||
async function onSaved() {
|
||||
await loadTasks()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadAll()
|
||||
})
|
||||
</script>
|
||||
```
|
||||
|
||||
**Template section:**
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<div>
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between">
|
||||
<h1 class="text-2xl font-bold text-primary-500">{{ $t('myTasks.title') }}</h1>
|
||||
<div class="flex gap-1">
|
||||
<button
|
||||
class="rounded-lg p-2 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="20" />
|
||||
</button>
|
||||
<button
|
||||
class="rounded-lg p-2 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="20" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Filters -->
|
||||
<div class="mt-4 flex flex-wrap gap-3">
|
||||
<MalioSelect
|
||||
v-model="selectedProjectId"
|
||||
:options="projectOptions"
|
||||
label="Projet"
|
||||
:empty-option-label="$t('myTasks.allProjects')"
|
||||
min-width="w-48"
|
||||
/>
|
||||
<MalioSelect
|
||||
v-model="selectedGroupId"
|
||||
:options="groupOptions"
|
||||
label="Groupe"
|
||||
:empty-option-label="$t('myTasks.allGroups')"
|
||||
min-width="w-48"
|
||||
/>
|
||||
<MalioSelect
|
||||
v-model="selectedTagId"
|
||||
:options="tagOptions"
|
||||
label="Type"
|
||||
:empty-option-label="$t('myTasks.allTypes')"
|
||||
min-width="w-48"
|
||||
/>
|
||||
<MalioSelect
|
||||
v-model="selectedPriorityId"
|
||||
:options="priorityOptions"
|
||||
label="Priorité"
|
||||
:empty-option-label="$t('myTasks.allPriorities')"
|
||||
min-width="w-48"
|
||||
/>
|
||||
<MalioSelect
|
||||
v-model="selectedEffortId"
|
||||
:options="effortOptions"
|
||||
label="Effort"
|
||||
:empty-option-label="$t('myTasks.allEfforts')"
|
||||
min-width="w-48"
|
||||
/>
|
||||
<MalioSelect
|
||||
v-model="selectedAssigneeId"
|
||||
:options="assigneeOptions"
|
||||
label="Assigné"
|
||||
:empty-option-label="$t('myTasks.allAssignees')"
|
||||
min-width="w-48"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Kanban View -->
|
||||
<div v-if="viewMode === 'kanban'" class="mt-6 flex gap-4 overflow-x-auto pb-4">
|
||||
<!-- Backlog column (tasks without status) -->
|
||||
<div
|
||||
v-if="backlogTasks.length > 0"
|
||||
class="flex w-72 shrink-0 flex-col rounded-lg bg-neutral-50"
|
||||
>
|
||||
<div class="rounded-t-lg bg-neutral-500 px-4 py-3 text-sm font-bold text-white">
|
||||
{{ $t('myTasks.backlog') }} ({{ backlogTasks.length }})
|
||||
</div>
|
||||
<div class="flex flex-col gap-3 p-3">
|
||||
<TaskCard
|
||||
v-for="task in backlogTasks"
|
||||
:key="task.id"
|
||||
:task="task"
|
||||
@click="openTaskEdit(task)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Status columns -->
|
||||
<div
|
||||
v-for="status in sortedStatuses"
|
||||
:key="status.id"
|
||||
class="flex w-72 shrink-0 flex-col rounded-lg bg-neutral-50"
|
||||
>
|
||||
<div
|
||||
class="rounded-t-lg px-4 py-3 text-sm font-bold text-white"
|
||||
:style="{ backgroundColor: status.color }"
|
||||
>
|
||||
{{ status.label }} ({{ tasksByStatus(status.id).length }})
|
||||
</div>
|
||||
<div class="flex flex-col gap-3 p-3">
|
||||
<TaskCard
|
||||
v-for="task in tasksByStatus(status.id)"
|
||||
:key="task.id"
|
||||
:task="task"
|
||||
@click="openTaskEdit(task)"
|
||||
/>
|
||||
<p
|
||||
v-if="tasksByStatus(status.id).length === 0"
|
||||
class="py-4 text-center text-xs text-neutral-400"
|
||||
>
|
||||
{{ $t('myTasks.noTasks') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- List View -->
|
||||
<div v-if="viewMode === 'list'" class="mt-6">
|
||||
<div
|
||||
v-for="task in tasks"
|
||||
:key="task.id"
|
||||
class="flex cursor-pointer items-center justify-between border-b border-neutral-100 px-4 py-3 transition-colors hover:bg-neutral-50"
|
||||
@click="openTaskEdit(task)"
|
||||
>
|
||||
<div class="min-w-0 flex-1">
|
||||
<h4 class="text-sm font-semibold text-neutral-900">{{ task.title }}</h4>
|
||||
<div class="mt-1 flex items-center gap-1.5">
|
||||
<span
|
||||
v-if="task.priority"
|
||||
class="rounded-full px-2 py-0.5 text-xs font-semibold text-white"
|
||||
:style="{ backgroundColor: task.priority.color }"
|
||||
>
|
||||
{{ task.priority.label }}
|
||||
</span>
|
||||
<span
|
||||
v-for="tag in task.tags"
|
||||
:key="tag.id"
|
||||
class="rounded-full px-2 py-0.5 text-xs font-semibold text-white"
|
||||
:style="{ backgroundColor: tag.color }"
|
||||
>
|
||||
{{ tag.label }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<button
|
||||
class="shrink-0 transition-colors"
|
||||
:class="isTimerOnTask(task) ? 'text-[#F18619] hover:text-[#d97314]' : 'text-neutral-400 hover:text-primary-500'"
|
||||
@click.stop="isTimerOnTask(task) ? timerStore.stop() : timerStore.startFromTask(task)"
|
||||
>
|
||||
<Icon :name="isTimerOnTask(task) ? 'mdi:stop-circle-outline' : 'mdi:play-circle-outline'" size="20" />
|
||||
</button>
|
||||
<span
|
||||
v-if="task.project && task.number"
|
||||
class="text-sm font-medium text-primary-500"
|
||||
>
|
||||
{{ task.project.code }}-{{ task.number }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<p
|
||||
v-if="tasks.length === 0 && !isLoading"
|
||||
class="py-8 text-center text-sm text-neutral-400"
|
||||
>
|
||||
{{ $t('myTasks.noTasks') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- TaskModal -->
|
||||
<TaskModal
|
||||
v-model="taskModalOpen"
|
||||
:task="selectedTask"
|
||||
:project-id="selectedTask?.project?.id ?? 0"
|
||||
:statuses="statuses"
|
||||
:efforts="efforts"
|
||||
:priorities="priorities"
|
||||
:tags="tags"
|
||||
:groups="selectedTask?.project?.id ? groups.filter(g => g.project?.id === selectedTask?.project?.id) : groups"
|
||||
:users="users"
|
||||
@saved="onSaved"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
```
|
||||
|
||||
Note: add the `timerStore` and `isTimerOnTask` helper in the script section:
|
||||
|
||||
```typescript
|
||||
const timerStore = useTimerStore()
|
||||
|
||||
function isTimerOnTask(task: Task): boolean {
|
||||
const entry = timerStore.activeEntry
|
||||
if (!entry?.task) return false
|
||||
const entryTaskId = typeof entry.task === 'string'
|
||||
? entry.task
|
||||
: (entry.task['@id'] ?? entry.task.id)
|
||||
const taskId = task['@id'] ?? task.id
|
||||
return entryTaskId === taskId || entryTaskId === `/api/tasks/${task.id}`
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Verify the page loads**
|
||||
|
||||
Run: `make dev-nuxt`
|
||||
|
||||
Navigate to `http://localhost:3002/my-tasks`.
|
||||
Expected: page loads with filters and shows tasks assigned to current user in Kanban view.
|
||||
|
||||
- [ ] **Step 3: Test view toggle**
|
||||
|
||||
Click the list icon. Expected: tasks display in list format with title, badges, project code.
|
||||
Click the kanban icon. Expected: tasks display in columns by status.
|
||||
|
||||
- [ ] **Step 4: Test filters**
|
||||
|
||||
Change the assignee filter to "Tous". Expected: all tasks from all users appear.
|
||||
Select a specific project. Expected: only tasks from that project appear.
|
||||
Reset all filters. Expected: all non-archived tasks appear.
|
||||
|
||||
- [ ] **Step 5: Test TaskModal integration**
|
||||
|
||||
Click on a task card/row. Expected: TaskModal opens with task details pre-filled.
|
||||
Edit and save. Expected: modal closes, tasks reload with updated data.
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add frontend/pages/my-tasks.vue
|
||||
git commit -m "feat(frontend) : add my-tasks page with Kanban and List views"
|
||||
```
|
||||
@@ -1,4 +1,4 @@
|
||||
# Feature: Archivage de tickets et de groupes
|
||||
o# Feature: Archivage de tickets et de groupes
|
||||
|
||||
## Résumé
|
||||
|
||||
@@ -35,6 +35,7 @@ Une migration Doctrine unique pour les 3 champs (`task_status.is_final`, `task.a
|
||||
### Archivage de groupe (bulk)
|
||||
|
||||
L'archivage d'un groupe est une opération frontend multi-appels :
|
||||
|
||||
1. PATCH chaque ticket du groupe avec `{ archived: true }`
|
||||
2. PATCH le groupe avec `{ archived: true }`
|
||||
|
||||
@@ -53,17 +54,20 @@ La règle "archiver seulement si statut final" est appliquée côté frontend (v
|
||||
### TaskDrawer — archivage et modale suppression
|
||||
|
||||
**Bouton "Archiver"** :
|
||||
|
||||
- Visible uniquement quand le ticket a un statut avec `isFinal: true`
|
||||
- PATCH `{ archived: true }` sur le ticket
|
||||
- Si un timer est actif sur ce ticket, l'arrêter avant d'archiver
|
||||
- Ferme le drawer et rafraîchit la liste des tickets
|
||||
|
||||
**Bouton "Désarchiver"** :
|
||||
|
||||
- Visible quand on consulte un ticket archivé (depuis la page archives)
|
||||
- PATCH `{ archived: false }`
|
||||
- Ferme le drawer et rafraîchit la page archives
|
||||
|
||||
**Modale de confirmation de suppression** :
|
||||
|
||||
- Déclenchée au clic sur "Supprimer" dans le TaskDrawer
|
||||
- Message : "Êtes-vous sûr de vouloir supprimer ce ticket ? Cette action est irréversible."
|
||||
- Deux boutons : "Annuler" / "Supprimer" (style destructif, rouge)
|
||||
@@ -84,11 +88,13 @@ La règle "archiver seulement si statut final" est appliquée côté frontend (v
|
||||
**Toggle "Voir les groupes archivés"** : bascule pour afficher les groupes archivés.
|
||||
|
||||
**Bouton "Archiver" sur un groupe** :
|
||||
|
||||
- Visible uniquement si le groupe a au moins un ticket ET que **tous** ses tickets ont un statut `isFinal: true` (un ticket sans statut bloque l'archivage)
|
||||
- Archive tous les tickets du groupe puis le groupe lui-même (appels PATCH séquentiels)
|
||||
- Rafraîchit la liste
|
||||
|
||||
**Bouton "Désarchiver" sur un groupe archivé** :
|
||||
|
||||
- Désarchive le groupe + tous ses tickets (écrase l'état individuel des tickets)
|
||||
- Rafraîchit la liste
|
||||
|
||||
@@ -123,6 +129,7 @@ Ajout du champ `archived: boolean` dans les types `TaskGroup` et `TaskGroupWrite
|
||||
## Traductions (i18n)
|
||||
|
||||
Clés à ajouter dans `fr.json` :
|
||||
|
||||
- `task.archive` / `task.unarchive`
|
||||
- `task.delete_confirm_title` / `task.delete_confirm_message`
|
||||
- `group.archive` / `group.unarchive`
|
||||
|
||||
151
docs/superpowers/specs/2026-03-13-gitea-integration-design.md
Normal file
151
docs/superpowers/specs/2026-03-13-gitea-integration-design.md
Normal file
@@ -0,0 +1,151 @@
|
||||
# Intégration Gitea — Design Spec
|
||||
|
||||
## Objectif
|
||||
|
||||
Lier les tickets Lesstime à Gitea pour :
|
||||
- Créer une branche depuis un ticket (avec choix du type : feature, fix, refactor, etc.)
|
||||
- Voir les branches, commits, PRs et statut CI liés à un ticket
|
||||
- Le tout à la demande (pas de webhook, pas de cron, pas de stockage git en base)
|
||||
|
||||
## Décisions
|
||||
|
||||
| Question | Décision |
|
||||
|----------|----------|
|
||||
| Interaction | Depuis Lesstime uniquement (bouton + affichage) |
|
||||
| Repos | Un projet = un repo Gitea |
|
||||
| Nommage branches | `<type>/PROJ-42-titre-en-slug` (type choisi par l'utilisateur) |
|
||||
| Détection commits | Par la branche (tous les commits d'une branche liée au ticket) |
|
||||
| Données affichées | Branches + commits + PRs + statut CI/CD |
|
||||
| Config serveur | Globale (admin) : URL + token API |
|
||||
| Config repo | Par projet : owner + repo name |
|
||||
| Synchronisation | À la demande (appel API Gitea en temps réel) |
|
||||
|
||||
## Architecture
|
||||
|
||||
### Backend
|
||||
|
||||
#### Configuration globale — entité `GiteaConfiguration`
|
||||
|
||||
Entité dédiée (singleton en base, un seul enregistrement) :
|
||||
|
||||
```
|
||||
id: int
|
||||
url: string|null (ex: "https://git.malio.fr")
|
||||
token: string|null (token API personnel Gitea, chiffré via SodiumEncryptor)
|
||||
```
|
||||
|
||||
Le token est chiffré au repos avec une clé symétrique définie en variable d'environnement (`GITEA_ENCRYPTION_KEY`). L'endpoint GET ne retourne jamais le token en clair — seulement un booléen `hasToken`.
|
||||
|
||||
#### Entité `Project` — nouveaux champs
|
||||
|
||||
```
|
||||
gitea_owner: string|null (ex: "malio")
|
||||
gitea_repo: string|null (ex: "lesstime")
|
||||
```
|
||||
|
||||
Nullable car tous les projets ne sont pas forcément liés à un repo.
|
||||
|
||||
#### Service `GiteaApiService`
|
||||
|
||||
Service Symfony qui encapsule les appels HTTP vers l'API Gitea REST v1.
|
||||
|
||||
**Configuration HTTP :**
|
||||
- Timeout : 10s par requête
|
||||
- En cas d'erreur Gitea (timeout, 5xx, réseau), le service lève une `GiteaApiException` avec un message clair. Le frontend affiche un état dégradé (message d'erreur dans la section Git, le reste de la TaskModal fonctionne normalement).
|
||||
|
||||
**Méthodes :**
|
||||
- `testConnection(): bool` — appelle `GET /api/v1/version` pour vérifier la connexion
|
||||
- `listRepositories(): array` — liste les repos accessibles (pour sélection dans le projet)
|
||||
- `getDefaultBranch(project): string` — récupère la branche par défaut du repo via `GET /api/v1/repos/{owner}/{repo}`
|
||||
- `createBranch(project, task, type, baseBranch): string` — crée une branche `<type>/CODE-NUM-slug` à partir d'une branche de base
|
||||
- `listBranches(project, taskCode): array` — liste les branches matchant le pattern `*/CODE-NUM-*` ou `*/CODE-NUM` (délimité pour éviter de matcher PROJ-420 quand on cherche PROJ-42)
|
||||
- `listCommits(project, branch): array` — liste les commits d'une branche (paginé, max 30)
|
||||
- `listPullRequests(project, taskCode): array` — liste les PRs en filtrant par `head` branch (paramètre `?head=` supporté par Gitea)
|
||||
- `getPullRequestChecks(project, prNumber): array` — statut CI/CD d'une PR
|
||||
- `copyBranchName(task, type): string` — génère le nom de branche sans appeler Gitea (pour le bouton "copier")
|
||||
|
||||
**Slug :** généré côté backend avec `Symfony\Component\String\Slugger\AsciiSlugger` — gère les accents français, tronqué à 50 caractères max pour le slug (le nom complet de branche reste sous 80 chars).
|
||||
|
||||
#### Endpoints API Platform
|
||||
|
||||
Nouveaux endpoints :
|
||||
|
||||
- `GET /api/settings/gitea` — récupérer la config Gitea (admin only, retourne url + hasToken)
|
||||
- `PUT /api/settings/gitea` — sauvegarder la config Gitea (admin only)
|
||||
- `POST /api/settings/gitea/test` — tester la connexion Gitea (admin only)
|
||||
- `GET /api/gitea/repositories` — lister les repos disponibles (pour config projet)
|
||||
- `POST /api/tasks/{id}/gitea/branches` — créer une branche pour un ticket
|
||||
- `GET /api/tasks/{id}/gitea/branches` — lister branches liées au ticket
|
||||
- `GET /api/tasks/{id}/gitea/pull-requests` — lister PRs liées au ticket (avec statut CI inclus)
|
||||
|
||||
Les endpoints branches et PRs sont séparés pour permettre un chargement progressif côté frontend et éviter de fan-out trop de requêtes Gitea en un seul appel.
|
||||
|
||||
### Frontend
|
||||
|
||||
#### Service `frontend/services/gitea.ts`
|
||||
|
||||
Nouveau service API encapsulant les appels, cohérent avec le pattern existant (`frontend/services/`).
|
||||
|
||||
#### Admin — Config Gitea
|
||||
|
||||
Nouveau tab `GiteaAdminTab.vue` dans l'admin pour configurer :
|
||||
- URL du serveur Gitea
|
||||
- Token API (champ password, affiche seulement si un token est configuré)
|
||||
- Bouton "Tester la connexion"
|
||||
|
||||
#### ProjectDrawer — Config repo
|
||||
|
||||
Ajout de champs dans le drawer de projet :
|
||||
- Sélecteur de repo Gitea (dropdown alimenté par `GET /api/gitea/repositories`)
|
||||
- Affiche `owner/repo` une fois sélectionné
|
||||
|
||||
#### TaskModal — Section Git
|
||||
|
||||
Nouveau composant `TaskGitSection.vue` intégré dans la TaskModal (visible et chargé uniquement si le projet a un repo configuré).
|
||||
|
||||
**Bouton "Créer une branche"** :
|
||||
- Sélecteur de type : `feature`, `fix`, `refactor`, `hotfix`, `chore`
|
||||
- Sélecteur de branche de base (default: branche par défaut du repo)
|
||||
- Preview du nom : `feature/PROJ-42-titre-de-la-tache`
|
||||
- Bouton de confirmation
|
||||
- Bouton "Copier le nom" (génère le nom sans appeler Gitea, pour création locale)
|
||||
|
||||
**Affichage des infos Git** (chargement progressif) :
|
||||
- Liste des branches liées (avec statut : active / mergée / supprimée)
|
||||
- Pour chaque branche : derniers commits (hash court, message, auteur, date)
|
||||
- PRs associées (titre, statut : open/merged/closed, reviewers)
|
||||
- Statut CI/CD par PR (checks : success/failure/pending)
|
||||
- Liens directs vers Gitea pour chaque élément
|
||||
- En cas d'erreur Gitea : message d'erreur dans la section, le reste de la modal reste fonctionnel
|
||||
|
||||
### Sécurité
|
||||
|
||||
- Le token Gitea est chiffré en base via `SodiumEncryptor` avec clé `GITEA_ENCRYPTION_KEY`
|
||||
- L'endpoint `GET /api/settings/gitea` retourne `url` + `hasToken: bool`, jamais le token en clair
|
||||
- Seuls les `ROLE_ADMIN` peuvent configurer le serveur Gitea et les repos
|
||||
- Les utilisateurs authentifiés peuvent créer des branches et voir les infos git pour les tâches qu'ils ont le droit de voir
|
||||
|
||||
### i18n
|
||||
|
||||
Toutes les chaînes UI (labels, messages d'erreur, types de branche) passent par le système i18n existant (`frontend/i18n/locales/fr.json` et `en.json`).
|
||||
|
||||
## API Gitea — Endpoints utilisés
|
||||
|
||||
| Action | Méthode Gitea API |
|
||||
|--------|-------------------|
|
||||
| Tester connexion | `GET /api/v1/version` |
|
||||
| Info repo (branche défaut) | `GET /api/v1/repos/{owner}/{repo}` |
|
||||
| Lister repos | `GET /api/v1/repos/search` |
|
||||
| Lister branches | `GET /api/v1/repos/{owner}/{repo}/branches` |
|
||||
| Créer branche | `POST /api/v1/repos/{owner}/{repo}/branches` |
|
||||
| Lister commits | `GET /api/v1/repos/{owner}/{repo}/commits?sha={branch}&limit=30` |
|
||||
| Lister PRs par branche | `GET /api/v1/repos/{owner}/{repo}/pulls?state=all&head={branch}` |
|
||||
| Statut CI | `GET /api/v1/repos/{owner}/{repo}/commits/{sha}/statuses` |
|
||||
|
||||
## Hors scope
|
||||
|
||||
- Webhooks Gitea → Lesstime
|
||||
- Stockage des commits/PRs en base
|
||||
- Création de PR depuis Lesstime
|
||||
- Lien multi-repo par projet
|
||||
- Synchronisation périodique (cron/polling)
|
||||
135
docs/superpowers/specs/2026-03-13-my-tasks-page-design.md
Normal file
135
docs/superpowers/specs/2026-03-13-my-tasks-page-design.md
Normal file
@@ -0,0 +1,135 @@
|
||||
# Feature: Page "Mes tâches"
|
||||
|
||||
## Résumé
|
||||
|
||||
Page dédiée `/my-tasks` affichant toutes les tâches non-archivées de tous les projets, avec filtrage côté serveur. Deux vues : Kanban (colonnes par statut) et Liste. Par défaut filtrée sur l'utilisateur courant, avec possibilité de changer l'assigné, voir tous les utilisateurs, et filtrer par projet/groupe/type/priorité/effort.
|
||||
|
||||
## Backend
|
||||
|
||||
### Filtres API Platform sur Task
|
||||
|
||||
Ajouter des `SearchFilter` sur l'entité `Task` pour les champs suivants (en plus des filtres existants `project`, `group`, `archived`) :
|
||||
|
||||
- `assignee` — filtre exact (par id)
|
||||
- `priority` — filtre exact
|
||||
- `effort` — filtre exact
|
||||
- `tags` — filtre exact (ManyToMany, query param format : `tags[]=/api/task_tags/1`)
|
||||
- `status` — filtre exact
|
||||
|
||||
Désactiver la pagination sur l'opération `GetCollection` de Task (`paginationEnabled: false`) pour charger toutes les tâches filtrées en un seul appel.
|
||||
|
||||
Aucune migration nécessaire — les filtres sont purement déclaratifs sur des relations existantes.
|
||||
|
||||
Exemple d'appel :
|
||||
```
|
||||
GET /api/tasks?assignee=/api/users/1&archived=false&project=/api/projects/2&tags[]=/api/task_tags/3
|
||||
```
|
||||
|
||||
## Frontend
|
||||
|
||||
### Nouvelle page : `frontend/pages/my-tasks.vue`
|
||||
|
||||
#### Barre de filtres
|
||||
|
||||
Une ligne horizontale de `MalioSelect` :
|
||||
|
||||
| Filtre | Options | Défaut |
|
||||
|--------|---------|--------|
|
||||
| Projet | Tous les projets de l'utilisateur | Tous |
|
||||
| Groupe | Groupes (filtrés par projet sélectionné si applicable) | Tous |
|
||||
| Type (tags) | Tous les tags | Tous |
|
||||
| Priorité | Toutes les priorités | Tous |
|
||||
| Effort | Tous les efforts | Tous |
|
||||
| Assigné | Tous les utilisateurs + option "Tous" | Utilisateur courant |
|
||||
|
||||
#### Toggle de vue
|
||||
|
||||
Deux boutons icônes en haut à droite (à côté des filtres) :
|
||||
- Icône grille/kanban → vue Kanban
|
||||
- Icône liste → vue Liste
|
||||
|
||||
État persisté en localStorage ou simple ref (pas critique).
|
||||
|
||||
#### Vue Kanban
|
||||
|
||||
- Colonnes par statut (ordonnées par `position`), même layout que `projects/[id]/index.vue`
|
||||
- Chaque colonne contient les `TaskCard` filtrés ayant ce statut
|
||||
- Les tâches sans statut vont dans une section "Backlog" en première colonne
|
||||
- Les colonnes de statut sans tâches sont affichées (cohérent avec la vue projet)
|
||||
- Pas de drag-and-drop (pas pertinent en contexte cross-projet)
|
||||
|
||||
#### Vue Liste
|
||||
|
||||
- Lignes de tâches avec :
|
||||
- Titre (bold)
|
||||
- Badges : priorité (couleur) + tags (couleur)
|
||||
- Code projet + numéro de tâche (ex: `SIRH-1`) — aligné à droite
|
||||
- Icône timer (comme sur les TaskCard)
|
||||
- Pas d'affichage des temps (exclu du périmètre)
|
||||
- Séparation visuelle légère entre les lignes (border-bottom ou alternance de fond)
|
||||
|
||||
#### Chargement des données
|
||||
|
||||
Au montage, chargement parallèle :
|
||||
```typescript
|
||||
const [tasks, statuses, projects, efforts, priorities, tags, groups, users] = await Promise.all([
|
||||
taskService.getFiltered({ assignee: currentUserId, archived: false }),
|
||||
statusService.getAll(),
|
||||
projectService.getAll(),
|
||||
effortService.getAll(),
|
||||
priorityService.getAll(),
|
||||
tagService.getAll(),
|
||||
groupService.getAll(),
|
||||
userService.getAll()
|
||||
])
|
||||
```
|
||||
|
||||
À chaque changement de filtre → nouvel appel API pour les tâches uniquement. Les données de référence (statuts, projets, etc.) ne sont chargées qu'une fois.
|
||||
|
||||
#### Interactions
|
||||
|
||||
- Clic sur une TaskCard ou ligne de liste → ouvre le `TaskModal` en mode édition
|
||||
- Après save/delete dans le modal → rechargement des tâches
|
||||
- Timer sur les cartes/lignes fonctionne via le `useTimerStore` existant
|
||||
|
||||
### Service tasks — nouvelle méthode
|
||||
|
||||
Ajout dans `frontend/services/tasks.ts` :
|
||||
|
||||
```typescript
|
||||
getFiltered(params: Record<string, string | number | boolean | string[]>): Promise<Task[]>
|
||||
```
|
||||
|
||||
Construit les query params à partir de l'objet et appelle `GET /api/tasks?...`. Convertit les ids en IRIs si nécessaire. Gère les paramètres tableau pour les relations ManyToMany (`tags[]`).
|
||||
|
||||
### Navigation
|
||||
|
||||
Nouveau `SidebarLink` dans `frontend/layouts/default.vue` :
|
||||
- Label : "Mes tâches"
|
||||
- Icône : `mdi:clipboard-check-outline` (ou similaire)
|
||||
- Position : entre "Tableau de bord" et "Projets"
|
||||
- Route : `/my-tasks`
|
||||
|
||||
### Traductions (i18n)
|
||||
|
||||
Clés à ajouter dans `fr.json` :
|
||||
- `myTasks.title` : "Mes tâches"
|
||||
- `myTasks.viewKanban` : "Vue Kanban"
|
||||
- `myTasks.viewList` : "Vue Liste"
|
||||
- `myTasks.allProjects` : "Tous les projets"
|
||||
- `myTasks.allGroups` : "Tous les groupes"
|
||||
- `myTasks.allTypes` : "Tous les types"
|
||||
- `myTasks.allPriorities` : "Toutes les priorités"
|
||||
- `myTasks.allEfforts` : "Tous les efforts"
|
||||
- `myTasks.allAssignees` : "Tous"
|
||||
- `myTasks.noTasks` : "Aucune tâche"
|
||||
- `myTasks.backlog` : "Backlog"
|
||||
- Sidebar : `sidebar.myTasks` : "Mes tâches"
|
||||
|
||||
## Hors périmètre
|
||||
|
||||
- Drag-and-drop entre colonnes (pas pertinent cross-projet)
|
||||
- Affichage des temps/durées sur les tâches
|
||||
- Pagination (à ajouter plus tard si nécessaire)
|
||||
- Création de tâche depuis cette page (utiliser la vue projet pour ça)
|
||||
- Recherche texte (pourra être ajoutée plus tard)
|
||||
103
frontend/components/admin/AdminGiteaTab.vue
Normal file
103
frontend/components/admin/AdminGiteaTab.vue
Normal file
@@ -0,0 +1,103 @@
|
||||
<template>
|
||||
<div>
|
||||
<h2 class="text-lg font-bold text-neutral-900">{{ $t('gitea.settings.title') }}</h2>
|
||||
|
||||
<form class="mt-6 max-w-lg space-y-4" @submit.prevent="handleSave">
|
||||
<MalioInputText
|
||||
v-model="form.url"
|
||||
:label="$t('gitea.settings.url')"
|
||||
:placeholder="$t('gitea.settings.urlPlaceholder')"
|
||||
input-class="w-full"
|
||||
/>
|
||||
|
||||
<div>
|
||||
<MalioInputText
|
||||
v-model="form.token"
|
||||
:label="$t('gitea.settings.token')"
|
||||
:placeholder="$t('gitea.settings.tokenPlaceholder')"
|
||||
input-class="w-full"
|
||||
type="password"
|
||||
/>
|
||||
<p v-if="hasToken && !form.token" class="mt-1 text-xs text-green-600">
|
||||
{{ $t('gitea.settings.tokenConfigured') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-3">
|
||||
<button
|
||||
type="submit"
|
||||
class="rounded-md bg-primary-500 px-4 py-2 text-sm font-semibold text-white hover:bg-secondary-500 disabled:opacity-50"
|
||||
:disabled="isSaving"
|
||||
>
|
||||
{{ $t('gitea.settings.save') }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-md border border-neutral-300 px-4 py-2 text-sm font-semibold text-neutral-700 hover:bg-neutral-50 disabled:opacity-50"
|
||||
:disabled="isTesting"
|
||||
@click="handleTest"
|
||||
>
|
||||
{{ $t('gitea.settings.testConnection') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p v-if="testResult !== null" class="text-sm font-medium" :class="testResult ? 'text-green-600' : 'text-red-600'">
|
||||
{{ testResult ? $t('gitea.settings.testSuccess') : $t('gitea.settings.testFailed') }}
|
||||
</p>
|
||||
</form>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useGiteaService } from '~/services/gitea'
|
||||
|
||||
const { getSettings, saveSettings, testConnection } = useGiteaService()
|
||||
|
||||
const form = reactive({
|
||||
url: '',
|
||||
token: '',
|
||||
})
|
||||
|
||||
const hasToken = ref(false)
|
||||
const isSaving = ref(false)
|
||||
const isTesting = ref(false)
|
||||
const testResult = ref<boolean | null>(null)
|
||||
|
||||
async function loadSettings() {
|
||||
const settings = await getSettings()
|
||||
form.url = settings.url ?? ''
|
||||
hasToken.value = settings.hasToken
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
isSaving.value = true
|
||||
try {
|
||||
const result = await saveSettings({
|
||||
url: form.url.trim() || null,
|
||||
token: form.token || null,
|
||||
})
|
||||
hasToken.value = result.hasToken
|
||||
form.token = ''
|
||||
testResult.value = null
|
||||
} finally {
|
||||
isSaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleTest() {
|
||||
isTesting.value = true
|
||||
testResult.value = null
|
||||
try {
|
||||
const result = await testConnection()
|
||||
testResult.value = result.success
|
||||
} catch {
|
||||
testResult.value = false
|
||||
} finally {
|
||||
isTesting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadSettings()
|
||||
})
|
||||
</script>
|
||||
@@ -33,6 +33,16 @@
|
||||
<ColorPicker v-model="form.color" />
|
||||
</div>
|
||||
|
||||
<div v-if="giteaRepos.length" class="mt-4">
|
||||
<MalioSelect
|
||||
v-model="form.giteaRepoFullName"
|
||||
:options="giteaRepoOptions"
|
||||
label="Dépôt Gitea"
|
||||
empty-option-label="Aucun dépôt"
|
||||
min-width="w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="mt-6 flex justify-end">
|
||||
<button
|
||||
type="submit"
|
||||
@@ -49,7 +59,9 @@
|
||||
<script setup lang="ts">
|
||||
import type { Project, ProjectWrite } from '~/services/dto/project'
|
||||
import type { Client } from '~/services/dto/client'
|
||||
import type { GiteaRepository } from '~/services/dto/gitea'
|
||||
import { useProjectService } from '~/services/projects'
|
||||
import { useGiteaService } from '~/services/gitea'
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: boolean
|
||||
@@ -70,12 +82,20 @@ const isOpen = computed({
|
||||
const isEditing = computed(() => !!props.project)
|
||||
const isSubmitting = ref(false)
|
||||
|
||||
const { listRepositories } = useGiteaService()
|
||||
const giteaRepos = ref<GiteaRepository[]>([])
|
||||
|
||||
const giteaRepoOptions = computed(() =>
|
||||
giteaRepos.value.map(r => ({ label: r.fullName, value: r.fullName }))
|
||||
)
|
||||
|
||||
const form = reactive({
|
||||
code: '',
|
||||
name: '',
|
||||
description: '',
|
||||
color: '#222783',
|
||||
clientId: null as number | null,
|
||||
giteaRepoFullName: null as string | null,
|
||||
})
|
||||
|
||||
const touched = reactive({
|
||||
@@ -95,12 +115,16 @@ watch(() => props.modelValue, (open) => {
|
||||
form.description = props.project.description ?? ''
|
||||
form.color = props.project.color ?? '#222783'
|
||||
form.clientId = props.project.client?.id ?? null
|
||||
form.giteaRepoFullName = props.project?.giteaOwner && props.project?.giteaRepo
|
||||
? `${props.project.giteaOwner}/${props.project.giteaRepo}`
|
||||
: null
|
||||
} else {
|
||||
form.code = ''
|
||||
form.name = ''
|
||||
form.description = ''
|
||||
form.color = '#222783'
|
||||
form.clientId = null
|
||||
form.giteaRepoFullName = null
|
||||
}
|
||||
touched.code = false
|
||||
touched.name = false
|
||||
@@ -124,6 +148,15 @@ async function handleSubmit() {
|
||||
client: form.clientId ? `/api/clients/${form.clientId}` : null,
|
||||
}
|
||||
|
||||
if (form.giteaRepoFullName) {
|
||||
const [owner, repo] = form.giteaRepoFullName.split('/')
|
||||
payload.giteaOwner = owner
|
||||
payload.giteaRepo = repo
|
||||
} else {
|
||||
payload.giteaOwner = null
|
||||
payload.giteaRepo = null
|
||||
}
|
||||
|
||||
if (isEditing.value && props.project) {
|
||||
await update(props.project.id, payload)
|
||||
} else {
|
||||
@@ -137,4 +170,12 @@ async function handleSubmit() {
|
||||
isSubmitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
giteaRepos.value = await listRepositories()
|
||||
} catch {
|
||||
// Gitea not configured, ignore
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -62,6 +62,7 @@
|
||||
v-model="drawerOpen"
|
||||
:group="selectedItem"
|
||||
:project-id="projectId"
|
||||
:tasks="[...activeTasks, ...archivedTasks]"
|
||||
@saved="onSaved"
|
||||
/>
|
||||
</div>
|
||||
|
||||
425
frontend/components/task/TaskGitSection.vue
Normal file
425
frontend/components/task/TaskGitSection.vue
Normal file
@@ -0,0 +1,425 @@
|
||||
<template>
|
||||
<div class="mt-5 rounded-lg border border-neutral-200 bg-neutral-50">
|
||||
<!-- Header with tabs -->
|
||||
<div class="flex items-center justify-between border-b border-neutral-200 bg-neutral-100/60 px-4 py-2">
|
||||
<div class="flex gap-1">
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-md px-3 py-1.5 text-xs font-semibold transition-colors"
|
||||
:class="activeTab === 'branches'
|
||||
? 'bg-white text-neutral-900 shadow-sm ring-1 ring-neutral-200'
|
||||
: 'text-neutral-500 hover:text-neutral-700'"
|
||||
@click="activeTab = 'branches'"
|
||||
>
|
||||
<Icon name="mdi:source-branch" size="14" class="mr-1 inline-block align-[-2px]" />
|
||||
{{ $t('gitea.branch.title') }}
|
||||
<span
|
||||
v-if="branches.length"
|
||||
class="ml-1 inline-flex h-4 min-w-4 items-center justify-center rounded-full bg-neutral-200 px-1 text-[10px] font-bold text-neutral-600"
|
||||
>{{ branches.length }}</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-md px-3 py-1.5 text-xs font-semibold transition-colors"
|
||||
:class="activeTab === 'prs'
|
||||
? 'bg-white text-neutral-900 shadow-sm ring-1 ring-neutral-200'
|
||||
: 'text-neutral-500 hover:text-neutral-700'"
|
||||
@click="activeTab = 'prs'"
|
||||
>
|
||||
<Icon name="mdi:source-pull" size="14" class="mr-1 inline-block align-[-2px]" />
|
||||
{{ $t('gitea.pr.title') }}
|
||||
<span
|
||||
v-if="pullRequests.length"
|
||||
class="ml-1 inline-flex h-4 min-w-4 items-center justify-center rounded-full px-1 text-[10px] font-bold"
|
||||
:class="hasOpenPr ? 'bg-green-100 text-green-700' : 'bg-neutral-200 text-neutral-600'"
|
||||
>{{ pullRequests.length }}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="flex gap-1">
|
||||
<button
|
||||
v-if="activeTab === 'branches'"
|
||||
type="button"
|
||||
class="rounded-md px-2.5 py-1.5 text-xs font-medium text-neutral-500 transition-colors hover:bg-neutral-200/60 hover:text-neutral-700"
|
||||
:title="$t('gitea.branch.copy')"
|
||||
@click="handleCopy"
|
||||
>
|
||||
<Icon name="mdi:content-copy" size="14" />
|
||||
</button>
|
||||
<button
|
||||
v-if="activeTab === 'branches'"
|
||||
type="button"
|
||||
class="rounded-md bg-primary-500 px-2.5 py-1.5 text-xs font-semibold text-white transition-colors hover:bg-secondary-500"
|
||||
@click="showCreateForm = !showCreateForm"
|
||||
>
|
||||
<Icon name="mdi:plus" size="14" class="mr-0.5 inline-block align-[-2px]" />
|
||||
{{ $t('gitea.branch.create') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Error state -->
|
||||
<div v-if="error" class="px-4 py-3">
|
||||
<p class="text-xs text-red-500">{{ $t('gitea.error') }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Create branch form (inline) -->
|
||||
<Transition name="slide-down">
|
||||
<div v-if="showCreateForm && activeTab === 'branches'" class="relative z-20 border-b border-neutral-200 bg-white px-4 py-3">
|
||||
<div class="grid grid-cols-[1fr_1fr_auto] items-end gap-3">
|
||||
<MalioSelect
|
||||
v-model="branchForm.type"
|
||||
:options="typeOptions"
|
||||
:label="$t('gitea.branch.type')"
|
||||
min-width="w-full"
|
||||
/>
|
||||
<MalioInputText
|
||||
v-model="branchForm.baseBranch"
|
||||
:label="$t('gitea.branch.baseBranch')"
|
||||
input-class="w-full"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="mb-[2px] rounded-md bg-primary-500 px-4 py-2 text-xs font-semibold text-white transition-colors hover:bg-secondary-500 disabled:opacity-50"
|
||||
:disabled="isCreating"
|
||||
@click="handleCreate"
|
||||
>
|
||||
{{ isCreating ? '...' : $t('gitea.branch.create') }}
|
||||
</button>
|
||||
</div>
|
||||
<code class="mt-2 block rounded bg-neutral-50 px-2 py-1 text-[11px] text-neutral-500">
|
||||
{{ branchPreview }}
|
||||
</code>
|
||||
</div>
|
||||
</Transition>
|
||||
|
||||
<!-- Content area with scroll -->
|
||||
<div class="max-h-64 overflow-y-auto overscroll-contain">
|
||||
<!-- Loading -->
|
||||
<div v-if="(activeTab === 'branches' && isLoading) || (activeTab === 'prs' && isLoadingPrs)" class="flex items-center justify-center py-8">
|
||||
<Icon name="mdi:loading" size="20" class="animate-spin text-neutral-300" />
|
||||
</div>
|
||||
|
||||
<!-- BRANCHES TAB -->
|
||||
<template v-if="activeTab === 'branches' && !isLoading">
|
||||
<div v-if="branches.length" class="divide-y divide-neutral-100">
|
||||
<div
|
||||
v-for="branch in branches"
|
||||
:key="branch.name"
|
||||
class="group"
|
||||
>
|
||||
<!-- Branch header (clickable to expand) -->
|
||||
<button
|
||||
type="button"
|
||||
class="flex w-full items-center gap-2 px-4 py-2.5 text-left transition-colors hover:bg-white"
|
||||
@click="toggleBranch(branch.name)"
|
||||
>
|
||||
<Icon
|
||||
name="mdi:chevron-right"
|
||||
size="14"
|
||||
class="shrink-0 text-neutral-400 transition-transform"
|
||||
:class="{ 'rotate-90': expandedBranches.has(branch.name) }"
|
||||
/>
|
||||
<Icon name="mdi:source-branch" size="14" class="shrink-0 text-primary-500" />
|
||||
<span class="min-w-0 truncate text-xs font-medium text-primary-600">
|
||||
{{ branch.name }}
|
||||
</span>
|
||||
<span
|
||||
v-if="branch.commits.length"
|
||||
class="ml-auto shrink-0 rounded bg-neutral-200/60 px-1.5 py-0.5 text-[10px] font-medium text-neutral-500"
|
||||
>
|
||||
{{ branch.commits.length }} commit{{ branch.commits.length > 1 ? 's' : '' }}
|
||||
</span>
|
||||
<a
|
||||
:href="branchUrl(branch.name)"
|
||||
target="_blank"
|
||||
class="shrink-0 text-neutral-400 opacity-0 transition-opacity hover:text-primary-500 group-hover:opacity-100"
|
||||
@click.stop
|
||||
>
|
||||
<Icon name="mdi:open-in-new" size="12" />
|
||||
</a>
|
||||
</button>
|
||||
|
||||
<!-- Commits (collapsible) -->
|
||||
<Transition name="expand">
|
||||
<div v-if="expandedBranches.has(branch.name) && branch.commits.length" class="border-t border-neutral-100 bg-white">
|
||||
<div
|
||||
v-for="(commit, idx) in branch.commits.slice(0, 10)"
|
||||
:key="commit.sha"
|
||||
class="flex items-center gap-2 px-4 py-1.5"
|
||||
:class="idx !== Math.min(branch.commits.length, 10) - 1 ? 'border-b border-neutral-50' : ''"
|
||||
>
|
||||
<span class="shrink-0 pl-5 font-mono text-[10px] text-primary-400">{{ commit.sha.slice(0, 7) }}</span>
|
||||
<span class="min-w-0 truncate text-[11px] text-neutral-700">{{ commitFirstLine(commit.message) }}</span>
|
||||
<span class="ml-auto shrink-0 text-[10px] text-neutral-400">{{ commit.author }}</span>
|
||||
<span class="shrink-0 text-[10px] text-neutral-300">{{ formatDate(commit.date) }}</span>
|
||||
</div>
|
||||
<div
|
||||
v-if="branch.commits.length > 10"
|
||||
class="border-t border-neutral-50 px-4 py-1.5 text-center text-[10px] text-neutral-400"
|
||||
>
|
||||
+{{ branch.commits.length - 10 }} commits
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p v-else-if="!error" class="py-6 text-center text-xs text-neutral-400">
|
||||
{{ $t('gitea.branch.noBranches') }}
|
||||
</p>
|
||||
</template>
|
||||
|
||||
<!-- PULL REQUESTS TAB -->
|
||||
<template v-if="activeTab === 'prs' && !isLoadingPrs">
|
||||
<div v-if="pullRequests.length" class="divide-y divide-neutral-100">
|
||||
<div
|
||||
v-for="pr in pullRequests"
|
||||
:key="pr.number"
|
||||
class="group flex items-start gap-3 px-4 py-3 transition-colors hover:bg-white"
|
||||
>
|
||||
<!-- Status pill -->
|
||||
<span
|
||||
class="mt-0.5 shrink-0 rounded-full px-2 py-0.5 text-[10px] font-bold uppercase tracking-wider text-white"
|
||||
:class="prStatusClass(pr)"
|
||||
>
|
||||
{{ prStatusLabel(pr) }}
|
||||
</span>
|
||||
|
||||
<!-- PR content -->
|
||||
<div class="min-w-0 flex-1">
|
||||
<a
|
||||
:href="pr.url"
|
||||
target="_blank"
|
||||
class="text-xs font-medium text-neutral-800 hover:text-primary-500 hover:underline"
|
||||
>
|
||||
<span class="text-neutral-400">#{{ pr.number }}</span>
|
||||
{{ pr.title }}
|
||||
</a>
|
||||
<div class="mt-1 flex items-center gap-2">
|
||||
<span class="text-[10px] text-neutral-400">{{ pr.author }}</span>
|
||||
<span v-if="pr.headBranch" class="rounded bg-neutral-100 px-1.5 py-0.5 font-mono text-[10px] text-neutral-500">
|
||||
{{ pr.headBranch }}
|
||||
</span>
|
||||
<!-- CI statuses -->
|
||||
<template v-if="pr.ciStatuses.length">
|
||||
<a
|
||||
v-for="ci in pr.ciStatuses"
|
||||
:key="ci.context"
|
||||
:href="ci.target_url"
|
||||
target="_blank"
|
||||
class="inline-flex items-center gap-0.5 rounded-full px-1.5 py-0.5 text-[10px] font-medium transition-opacity hover:opacity-80"
|
||||
:class="ciStatusClass(ci.status)"
|
||||
>
|
||||
<Icon :name="ciStatusIcon(ci.status)" size="10" />
|
||||
{{ ci.context }}
|
||||
</a>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p v-else-if="branches.length && !error" class="py-6 text-center text-xs text-neutral-400">
|
||||
{{ $t('gitea.pr.noPrs') }}
|
||||
</p>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Task } from '~/services/dto/task'
|
||||
import type { GiteaBranch, GiteaPullRequest } from '~/services/dto/gitea'
|
||||
import { useGiteaService } from '~/services/gitea'
|
||||
|
||||
const { t } = useI18n()
|
||||
const props = defineProps<{
|
||||
task: Task
|
||||
giteaUrl: string
|
||||
}>()
|
||||
|
||||
const { listBranches, createBranch, listPullRequests, getBranchName } = useGiteaService()
|
||||
|
||||
const activeTab = ref<'branches' | 'prs'>('branches')
|
||||
const branches = ref<GiteaBranch[]>([])
|
||||
const pullRequests = ref<GiteaPullRequest[]>([])
|
||||
const isLoading = ref(true)
|
||||
const isLoadingPrs = ref(true)
|
||||
const isCreating = ref(false)
|
||||
const error = ref(false)
|
||||
const showCreateForm = ref(false)
|
||||
const expandedBranches = ref(new Set<string>())
|
||||
|
||||
const branchForm = reactive({
|
||||
type: 'feature',
|
||||
baseBranch: 'develop',
|
||||
})
|
||||
|
||||
const typeOptions = [
|
||||
{ label: t('gitea.branch.types.feature'), value: 'feature' },
|
||||
{ label: t('gitea.branch.types.fix'), value: 'fix' },
|
||||
{ label: t('gitea.branch.types.refactor'), value: 'refactor' },
|
||||
{ label: t('gitea.branch.types.hotfix'), value: 'hotfix' },
|
||||
{ label: t('gitea.branch.types.chore'), value: 'chore' },
|
||||
]
|
||||
|
||||
const hasOpenPr = computed(() => pullRequests.value.some(pr => pr.state === 'open' && !pr.merged))
|
||||
|
||||
const branchPreview = computed(() => {
|
||||
if (!props.task.project?.code || !props.task.number) return ''
|
||||
const slug = props.task.title
|
||||
.toLowerCase()
|
||||
.normalize('NFD')
|
||||
.replace(/[\u0300-\u036f]/g, '')
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-|-$/g, '')
|
||||
.slice(0, 50)
|
||||
return `${branchForm.type}/${props.task.project.code}-${props.task.number}-${slug}`
|
||||
})
|
||||
|
||||
function toggleBranch(name: string) {
|
||||
if (expandedBranches.value.has(name)) {
|
||||
expandedBranches.value.delete(name)
|
||||
} else {
|
||||
expandedBranches.value.add(name)
|
||||
}
|
||||
}
|
||||
|
||||
function branchUrl(name: string): string {
|
||||
const project = props.task.project
|
||||
if (!project?.giteaOwner || !project?.giteaRepo) return '#'
|
||||
return `${props.giteaUrl}/${project.giteaOwner}/${project.giteaRepo}/src/branch/${encodeURIComponent(name)}`
|
||||
}
|
||||
|
||||
function commitFirstLine(message: string): string {
|
||||
return message.split('\n')[0]
|
||||
}
|
||||
|
||||
function formatDate(dateStr: string): string {
|
||||
const d = new Date(dateStr)
|
||||
const now = new Date()
|
||||
const diffMs = now.getTime() - d.getTime()
|
||||
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24))
|
||||
|
||||
if (diffDays === 0) return "aujourd'hui"
|
||||
if (diffDays === 1) return 'hier'
|
||||
if (diffDays < 7) return `il y a ${diffDays}j`
|
||||
return d.toLocaleDateString('fr-FR', { day: 'numeric', month: 'short' })
|
||||
}
|
||||
|
||||
function prStatusClass(pr: GiteaPullRequest): string {
|
||||
if (pr.merged) return 'bg-purple-500'
|
||||
if (pr.state === 'open') return 'bg-green-500'
|
||||
return 'bg-red-500'
|
||||
}
|
||||
|
||||
function prStatusLabel(pr: GiteaPullRequest): string {
|
||||
if (pr.merged) return t('gitea.pr.merged')
|
||||
if (pr.state === 'open') return t('gitea.pr.open')
|
||||
return t('gitea.pr.closed')
|
||||
}
|
||||
|
||||
function ciStatusClass(status: string): string {
|
||||
if (status === 'success') return 'bg-green-100 text-green-700'
|
||||
if (status === 'failure' || status === 'error') return 'bg-red-100 text-red-700'
|
||||
return 'bg-yellow-100 text-yellow-700'
|
||||
}
|
||||
|
||||
function ciStatusIcon(status: string): string {
|
||||
if (status === 'success') return 'mdi:check-circle'
|
||||
if (status === 'failure' || status === 'error') return 'mdi:close-circle'
|
||||
return 'mdi:clock-outline'
|
||||
}
|
||||
|
||||
async function loadData() {
|
||||
if (!props.task.id) return
|
||||
|
||||
isLoading.value = true
|
||||
isLoadingPrs.value = true
|
||||
error.value = false
|
||||
|
||||
try {
|
||||
branches.value = await listBranches(props.task.id)
|
||||
// Auto-expand first branch
|
||||
if (branches.value.length === 1) {
|
||||
expandedBranches.value.add(branches.value[0].name)
|
||||
}
|
||||
} catch {
|
||||
error.value = true
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
|
||||
try {
|
||||
pullRequests.value = await listPullRequests(props.task.id)
|
||||
} catch {
|
||||
// PR errors don't block branch display
|
||||
} finally {
|
||||
isLoadingPrs.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCreate() {
|
||||
isCreating.value = true
|
||||
try {
|
||||
await createBranch(props.task.id, {
|
||||
type: branchForm.type,
|
||||
baseBranch: branchForm.baseBranch,
|
||||
})
|
||||
showCreateForm.value = false
|
||||
await loadData()
|
||||
} finally {
|
||||
isCreating.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCopy() {
|
||||
try {
|
||||
const result = await getBranchName(props.task.id, branchForm.type)
|
||||
await navigator.clipboard.writeText(result.name)
|
||||
const { success } = useToast()
|
||||
success(t('gitea.branch.copied'))
|
||||
} catch {
|
||||
// Silently fail
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadData()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.slide-down-enter-active {
|
||||
transition: opacity 0.15s ease;
|
||||
}
|
||||
|
||||
.slide-down-leave-active {
|
||||
transition: opacity 0.1s ease;
|
||||
}
|
||||
|
||||
.slide-down-enter-from,
|
||||
.slide-down-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.expand-enter-active,
|
||||
.expand-leave-active {
|
||||
transition: all 0.15s ease;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.expand-enter-from,
|
||||
.expand-leave-to {
|
||||
max-height: 0;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.expand-enter-to,
|
||||
.expand-leave-from {
|
||||
max-height: 500px;
|
||||
opacity: 1;
|
||||
}
|
||||
</style>
|
||||
@@ -17,7 +17,32 @@
|
||||
<ColorPicker v-model="form.color" />
|
||||
</div>
|
||||
|
||||
<div class="mt-6 flex justify-end">
|
||||
<div
|
||||
v-if="isEditing && !canArchive && !canUnarchive && nonFinalTasksCount > 0"
|
||||
class="mt-4 rounded-md bg-amber-50 px-4 py-3 text-sm text-amber-700"
|
||||
>
|
||||
{{ $t('archive.groupNonFinalTasks', { count: nonFinalTasksCount }) }}
|
||||
</div>
|
||||
|
||||
<div class="mt-6 flex items-center" :class="isEditing ? 'justify-between' : 'justify-end'">
|
||||
<button
|
||||
v-if="canArchive"
|
||||
type="button"
|
||||
class="rounded-md bg-neutral-500 px-4 py-2 text-sm font-semibold text-white hover:bg-neutral-600 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
:disabled="isSubmitting"
|
||||
@click="handleArchive"
|
||||
>
|
||||
{{ $t('archive.archiveButton') }}
|
||||
</button>
|
||||
<button
|
||||
v-if="canUnarchive"
|
||||
type="button"
|
||||
class="rounded-md bg-neutral-500 px-4 py-2 text-sm font-semibold text-white hover:bg-neutral-600 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
:disabled="isSubmitting"
|
||||
@click="handleUnarchive"
|
||||
>
|
||||
{{ $t('archive.unarchiveButton') }}
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
class="rounded-md bg-primary-500 px-6 py-2 text-sm font-semibold text-white hover:bg-secondary-500 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
@@ -32,12 +57,15 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { TaskGroup, TaskGroupWrite } from '~/services/dto/task-group'
|
||||
import type { Task } from '~/services/dto/task'
|
||||
import { useTaskGroupService } from '~/services/task-groups'
|
||||
import { useTaskService } from '~/services/tasks'
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: boolean
|
||||
group: TaskGroup | null
|
||||
projectId: number
|
||||
tasks?: Task[]
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -79,6 +107,51 @@ watch(() => props.modelValue, (open) => {
|
||||
})
|
||||
|
||||
const { create, update } = useTaskGroupService()
|
||||
const taskService = useTaskService()
|
||||
|
||||
const groupTasks = computed(() =>
|
||||
(props.tasks ?? []).filter(t => t.group?.id === props.group?.id)
|
||||
)
|
||||
|
||||
const nonFinalTasksCount = computed(() =>
|
||||
groupTasks.value.filter(t => t.status?.isFinal !== true).length
|
||||
)
|
||||
|
||||
const canArchive = computed(() => {
|
||||
if (!isEditing.value || !props.group || props.group.archived) return false
|
||||
if (groupTasks.value.length === 0) return false
|
||||
return nonFinalTasksCount.value === 0
|
||||
})
|
||||
|
||||
const canUnarchive = computed(() => {
|
||||
return isEditing.value && !!props.group?.archived
|
||||
})
|
||||
|
||||
async function handleArchive() {
|
||||
if (!props.group) return
|
||||
isSubmitting.value = true
|
||||
try {
|
||||
await Promise.all(groupTasks.value.map(t => taskService.update(t.id, { archived: true })))
|
||||
await update(props.group.id, { archived: true })
|
||||
emit('saved')
|
||||
isOpen.value = false
|
||||
} finally {
|
||||
isSubmitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleUnarchive() {
|
||||
if (!props.group) return
|
||||
isSubmitting.value = true
|
||||
try {
|
||||
await Promise.all(groupTasks.value.map(t => taskService.update(t.id, { archived: false })))
|
||||
await update(props.group.id, { archived: false })
|
||||
emit('saved')
|
||||
isOpen.value = false
|
||||
} finally {
|
||||
isSubmitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
touched.title = true
|
||||
|
||||
446
frontend/components/task/TaskModal.vue
Normal file
446
frontend/components/task/TaskModal.vue
Normal file
@@ -0,0 +1,446 @@
|
||||
<template>
|
||||
<Teleport v-if="isOpen" to="body">
|
||||
<Transition name="task-modal" appear>
|
||||
<div class="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||
<!-- Backdrop -->
|
||||
<div
|
||||
class="absolute inset-0 bg-slate-900/40 backdrop-blur-sm"
|
||||
@click="close"
|
||||
/>
|
||||
|
||||
<!-- Modal -->
|
||||
<div
|
||||
class="relative z-10 flex w-full max-w-2xl flex-col overflow-hidden rounded-2xl bg-white shadow-2xl ring-1 ring-black/5"
|
||||
style="max-height: min(90vh, 900px)"
|
||||
>
|
||||
<!-- Header -->
|
||||
<div class="border-b border-neutral-100 bg-neutral-50/80 px-8 py-5">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-3">
|
||||
<span
|
||||
v-if="isEditing && task?.project?.code && task?.number"
|
||||
class="rounded-md bg-primary-500 px-2.5 py-1 text-xs font-bold tracking-wide text-white"
|
||||
>
|
||||
{{ task.project.code }}-{{ task.number }}
|
||||
</span>
|
||||
<h2 class="text-lg font-bold tracking-tight text-neutral-900">
|
||||
{{ isEditing ? 'Modifier un ticket' : 'Ajouter un ticket' }}
|
||||
</h2>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="flex h-8 w-8 items-center justify-center rounded-lg text-neutral-400 transition-colors hover:bg-neutral-200/60 hover:text-neutral-600"
|
||||
@click="close"
|
||||
>
|
||||
<Icon name="mdi:close" size="20" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Body -->
|
||||
<form @submit.prevent="handleSubmit" class="overflow-y-auto px-8 py-6">
|
||||
<!-- Title -->
|
||||
<MalioInputText
|
||||
v-model="form.title"
|
||||
label="Titre"
|
||||
input-class="w-full"
|
||||
:error="touched.title && !form.title.trim() ? 'Le titre est requis' : ''"
|
||||
@blur="touched.title = true"
|
||||
/>
|
||||
|
||||
<!-- Two-column selects -->
|
||||
<div class="mt-4 grid grid-cols-2 gap-x-6 gap-y-4">
|
||||
<MalioSelect
|
||||
v-model="form.statusId"
|
||||
:options="statusOptions"
|
||||
label="Statut"
|
||||
empty-option-label="Aucun statut"
|
||||
min-width="w-full"
|
||||
/>
|
||||
<MalioSelect
|
||||
v-model="form.assigneeId"
|
||||
:options="userOptions"
|
||||
label="User"
|
||||
empty-option-label="Aucun utilisateur"
|
||||
min-width="w-full"
|
||||
/>
|
||||
<MalioSelect
|
||||
v-model="form.effortId"
|
||||
:options="effortOptions"
|
||||
label="Effort"
|
||||
empty-option-label="Aucun effort"
|
||||
min-width="w-full"
|
||||
/>
|
||||
<MalioSelect
|
||||
v-model="form.priorityId"
|
||||
:options="priorityOptions"
|
||||
label="Priorité"
|
||||
empty-option-label="Aucune priorité"
|
||||
min-width="w-full"
|
||||
/>
|
||||
<MalioSelect
|
||||
v-model="form.groupId"
|
||||
:options="groupOptions"
|
||||
label="Groupe"
|
||||
empty-option-label="Aucun groupe"
|
||||
min-width="w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Tags -->
|
||||
<div v-if="tags.length" class="mt-5">
|
||||
<p class="mb-2 text-sm font-medium text-neutral-700">Tags</p>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<label
|
||||
v-for="tag in tags"
|
||||
:key="tag.id"
|
||||
class="cursor-pointer rounded-full px-3 py-1 text-xs font-semibold transition-all"
|
||||
:class="form.tagIds.includes(tag.id)
|
||||
? 'text-white shadow-sm'
|
||||
: 'bg-neutral-100 text-neutral-600 hover:bg-neutral-200'"
|
||||
:style="form.tagIds.includes(tag.id) ? { backgroundColor: tag.color } : {}"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
class="hidden"
|
||||
:value="tag.id"
|
||||
:checked="form.tagIds.includes(tag.id)"
|
||||
@change="toggleTag(tag.id)"
|
||||
/>
|
||||
{{ tag.label }}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Description -->
|
||||
<div class="mt-5">
|
||||
<MalioInputTextArea
|
||||
v-model="form.description"
|
||||
label="Description"
|
||||
:size="3"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Git section -->
|
||||
<TaskGitSection
|
||||
v-if="hasGitea && isEditing && task"
|
||||
:task="task"
|
||||
:gitea-url="giteaUrl"
|
||||
/>
|
||||
|
||||
<!-- Footer -->
|
||||
<div
|
||||
class="mt-6 flex items-center border-t border-neutral-100 pt-5"
|
||||
:class="isEditing ? 'justify-between' : 'justify-end'"
|
||||
>
|
||||
<button
|
||||
v-if="isEditing"
|
||||
type="button"
|
||||
class="rounded-lg bg-red-50 px-4 py-2 text-sm font-semibold text-red-600 transition-colors hover:bg-red-100 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
:disabled="isSubmitting"
|
||||
@click="confirmDeleteOpen = true"
|
||||
>
|
||||
Supprimer
|
||||
</button>
|
||||
<div class="flex gap-3">
|
||||
<button
|
||||
v-if="canArchive"
|
||||
type="button"
|
||||
class="rounded-lg border border-neutral-300 px-4 py-2 text-sm font-semibold text-neutral-700 transition-colors hover:bg-neutral-50 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
:disabled="isSubmitting"
|
||||
@click="handleArchive"
|
||||
>
|
||||
{{ $t('archive.archiveButton') }}
|
||||
</button>
|
||||
<button
|
||||
v-if="canUnarchive"
|
||||
type="button"
|
||||
class="rounded-lg border border-neutral-300 px-4 py-2 text-sm font-semibold text-neutral-700 transition-colors hover:bg-neutral-50 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
:disabled="isSubmitting"
|
||||
@click="handleUnarchive"
|
||||
>
|
||||
{{ $t('archive.unarchiveButton') }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-lg border border-neutral-300 px-4 py-2 text-sm font-semibold text-neutral-700 transition-colors hover:bg-neutral-50"
|
||||
@click="close"
|
||||
>
|
||||
Annuler
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
class="rounded-lg bg-primary-500 px-6 py-2 text-sm font-semibold text-white transition-colors hover:bg-secondary-500 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
:disabled="isSubmitting"
|
||||
>
|
||||
Enregistrer
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<ConfirmDeleteTaskModal
|
||||
v-model="confirmDeleteOpen"
|
||||
@confirm="handleDelete"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Task, TaskWrite } from '~/services/dto/task'
|
||||
import { useGiteaService } from '~/services/gitea'
|
||||
import type { TaskStatus } from '~/services/dto/task-status'
|
||||
import type { TaskEffort } from '~/services/dto/task-effort'
|
||||
import type { TaskPriority } from '~/services/dto/task-priority'
|
||||
import type { TaskTag } from '~/services/dto/task-tag'
|
||||
import type { TaskGroup } from '~/services/dto/task-group'
|
||||
import type { UserData } from '~/services/dto/user-data'
|
||||
import { useTaskService } from '~/services/tasks'
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: boolean
|
||||
task: Task | null
|
||||
projectId: number
|
||||
statuses: TaskStatus[]
|
||||
efforts: TaskEffort[]
|
||||
priorities: TaskPriority[]
|
||||
tags: TaskTag[]
|
||||
groups: TaskGroup[]
|
||||
users: UserData[]
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:modelValue', value: boolean): void
|
||||
(e: 'saved'): void
|
||||
}>()
|
||||
|
||||
const isOpen = computed({
|
||||
get: () => props.modelValue,
|
||||
set: (v) => emit('update:modelValue', v),
|
||||
})
|
||||
|
||||
function close() {
|
||||
isOpen.value = false
|
||||
}
|
||||
|
||||
const isEditing = computed(() => !!props.task)
|
||||
const isSubmitting = ref(false)
|
||||
const confirmDeleteOpen = ref(false)
|
||||
|
||||
const giteaUrl = ref('')
|
||||
const { getSettings: getGiteaSettings } = useGiteaService()
|
||||
|
||||
const hasGitea = computed(() => {
|
||||
return !!props.task?.project?.giteaOwner && !!props.task?.project?.giteaRepo && !!giteaUrl.value
|
||||
})
|
||||
|
||||
const form = reactive({
|
||||
title: '',
|
||||
description: '',
|
||||
statusId: null as number | null,
|
||||
effortId: null as number | null,
|
||||
priorityId: null as number | null,
|
||||
assigneeId: null as number | null,
|
||||
groupId: null as number | null,
|
||||
tagIds: [] as number[],
|
||||
})
|
||||
|
||||
const touched = reactive({
|
||||
title: false,
|
||||
})
|
||||
|
||||
const statusOptions = computed(() =>
|
||||
props.statuses.map(s => ({ label: s.label, value: s.id }))
|
||||
)
|
||||
|
||||
const effortOptions = computed(() =>
|
||||
props.efforts.map(e => ({ label: e.label, value: e.id }))
|
||||
)
|
||||
|
||||
const priorityOptions = computed(() =>
|
||||
props.priorities.map(p => ({ label: p.label, value: p.id }))
|
||||
)
|
||||
|
||||
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 canArchive = computed(() => {
|
||||
if (!isEditing.value || !props.task) return false
|
||||
if (props.task.archived) return false
|
||||
const status = props.statuses.find(s => s.id === props.task?.status?.id)
|
||||
return !!status?.isFinal
|
||||
})
|
||||
|
||||
const canUnarchive = computed(() => {
|
||||
return isEditing.value && !!props.task?.archived
|
||||
})
|
||||
|
||||
function toggleTag(id: number) {
|
||||
const idx = form.tagIds.indexOf(id)
|
||||
if (idx >= 0) {
|
||||
form.tagIds.splice(idx, 1)
|
||||
} else {
|
||||
form.tagIds.push(id)
|
||||
}
|
||||
}
|
||||
|
||||
function populateForm(task: Task | null) {
|
||||
if (task) {
|
||||
form.title = task.title ?? ''
|
||||
form.description = task.description ?? ''
|
||||
form.statusId = task.status?.id ?? null
|
||||
form.effortId = task.effort?.id ?? null
|
||||
form.priorityId = task.priority?.id ?? null
|
||||
form.assigneeId = task.assignee?.id ?? null
|
||||
form.groupId = task.group?.id ?? null
|
||||
form.tagIds = task.tags.map(t => t.id)
|
||||
} else {
|
||||
form.title = ''
|
||||
form.description = ''
|
||||
form.statusId = null
|
||||
form.effortId = null
|
||||
form.priorityId = null
|
||||
form.assigneeId = null
|
||||
form.groupId = null
|
||||
form.tagIds = []
|
||||
}
|
||||
touched.title = false
|
||||
}
|
||||
|
||||
watch(() => props.modelValue, (open) => {
|
||||
if (open) {
|
||||
populateForm(props.task)
|
||||
}
|
||||
})
|
||||
|
||||
watch(() => props.task, (task) => {
|
||||
if (props.modelValue) {
|
||||
populateForm(task)
|
||||
}
|
||||
})
|
||||
|
||||
watch(() => props.modelValue, async (open) => {
|
||||
if (open && props.task?.project?.giteaOwner && props.task?.project?.giteaRepo && !giteaUrl.value) {
|
||||
try {
|
||||
const settings = await getGiteaSettings()
|
||||
giteaUrl.value = settings.url ?? ''
|
||||
} catch {
|
||||
// Gitea not available
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const { create, update, remove } = useTaskService()
|
||||
|
||||
async function handleDelete() {
|
||||
if (!props.task) return
|
||||
isSubmitting.value = true
|
||||
try {
|
||||
await remove(props.task.id)
|
||||
confirmDeleteOpen.value = false
|
||||
emit('saved')
|
||||
isOpen.value = false
|
||||
} finally {
|
||||
isSubmitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleArchive() {
|
||||
if (!props.task) return
|
||||
const timerStore = useTimerStore()
|
||||
if (timerStore.activeEntry?.task) {
|
||||
const taskIri = typeof timerStore.activeEntry.task === 'string'
|
||||
? timerStore.activeEntry.task
|
||||
: (timerStore.activeEntry.task as any)?.['@id'] ?? `/api/tasks/${(timerStore.activeEntry.task as any)?.id}`
|
||||
if (taskIri === `/api/tasks/${props.task.id}`) {
|
||||
await timerStore.stop()
|
||||
}
|
||||
}
|
||||
isSubmitting.value = true
|
||||
try {
|
||||
await update(props.task.id, { archived: true })
|
||||
emit('saved')
|
||||
isOpen.value = false
|
||||
} finally {
|
||||
isSubmitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleUnarchive() {
|
||||
if (!props.task) return
|
||||
isSubmitting.value = true
|
||||
try {
|
||||
await update(props.task.id, { archived: false })
|
||||
emit('saved')
|
||||
isOpen.value = false
|
||||
} finally {
|
||||
isSubmitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
touched.title = true
|
||||
if (!form.title.trim()) return
|
||||
|
||||
isSubmitting.value = true
|
||||
try {
|
||||
const payload: TaskWrite = {
|
||||
title: form.title.trim(),
|
||||
description: form.description.trim() || null,
|
||||
status: form.statusId ? `/api/task_statuses/${form.statusId}` : null,
|
||||
effort: form.effortId ? `/api/task_efforts/${form.effortId}` : null,
|
||||
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}`,
|
||||
tags: form.tagIds.map(id => `/api/task_tags/${id}`),
|
||||
}
|
||||
|
||||
if (isEditing.value && props.task) {
|
||||
await update(props.task.id, payload)
|
||||
} else {
|
||||
await create(payload)
|
||||
}
|
||||
|
||||
emit('saved')
|
||||
isOpen.value = false
|
||||
} finally {
|
||||
isSubmitting.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.task-modal-enter-active,
|
||||
.task-modal-leave-active {
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.task-modal-enter-active > div:last-child,
|
||||
.task-modal-leave-active > div:last-child {
|
||||
transition: transform 0.2s cubic-bezier(0.16, 1, 0.3, 1), opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.task-modal-enter-from,
|
||||
.task-modal-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.task-modal-enter-from > div:last-child {
|
||||
transform: scale(0.95) translateY(8px);
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.task-modal-leave-to > div:last-child {
|
||||
transform: scale(0.97);
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -108,6 +108,18 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Current time indicator -->
|
||||
<div
|
||||
v-if="isToday(day.date)"
|
||||
class="absolute left-0 right-0 z-10 pointer-events-none"
|
||||
:style="{ top: `${currentTimeTopPx}px` }"
|
||||
>
|
||||
<div class="relative flex items-center">
|
||||
<div class="absolute -left-[5px] h-[10px] w-[10px] rounded-full bg-orange-500" />
|
||||
<div class="h-[2px] w-full bg-orange-500" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Drag ghost preview -->
|
||||
<div
|
||||
v-if="dragState && dragState.targetDayIndex === dayIndex"
|
||||
@@ -154,6 +166,27 @@ const gridBodyEl = ref<HTMLElement | null>(null)
|
||||
const dayColumnEls = ref<HTMLElement[]>([])
|
||||
const stickyOffset = computed(() => props.stickyOffset ?? 0)
|
||||
|
||||
// --- Current time indicator ---
|
||||
const nowMinutes = ref(0)
|
||||
let nowTimer: ReturnType<typeof setInterval> | undefined
|
||||
|
||||
function updateNowMinutes() {
|
||||
const now = new Date()
|
||||
nowMinutes.value = now.getHours() * 60 + now.getMinutes()
|
||||
}
|
||||
|
||||
const currentTimeTopPx = computed(() => (nowMinutes.value / 60) * hourHeight)
|
||||
|
||||
updateNowMinutes()
|
||||
|
||||
onMounted(() => {
|
||||
nowTimer = setInterval(updateNowMinutes, 60_000)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
clearInterval(nowTimer)
|
||||
})
|
||||
|
||||
function getScrollParent(): HTMLElement | null {
|
||||
let el = calendarEl.value?.parentElement
|
||||
while (el) {
|
||||
|
||||
@@ -83,6 +83,72 @@
|
||||
"showArchived": "Voir les groupes archivés",
|
||||
"hideArchived": "Masquer les groupes archivés",
|
||||
"statusFinal": "Statut final",
|
||||
"groupArchiveDisabled": "Tous les tickets doivent être en statut final pour archiver le groupe."
|
||||
"groupArchiveDisabled": "Tous les tickets doivent être en statut final pour archiver le groupe.",
|
||||
"groupNonFinalTasks": "Il reste {count} ticket(s) sans statut final dans ce groupe."
|
||||
},
|
||||
"myTasks": {
|
||||
"title": "Mes tâches",
|
||||
"viewKanban": "Vue Kanban",
|
||||
"viewList": "Vue Liste",
|
||||
"allProjects": "Tous les projets",
|
||||
"allGroups": "Tous les groupes",
|
||||
"allTypes": "Tous les types",
|
||||
"allPriorities": "Toutes les priorités",
|
||||
"allEfforts": "Tous les efforts",
|
||||
"allAssignees": "Tous",
|
||||
"noTasks": "Aucune tâche",
|
||||
"backlog": "Backlog"
|
||||
},
|
||||
"sidebar": {
|
||||
"myTasks": "Mes tâches"
|
||||
},
|
||||
"common": {
|
||||
"cancel": "Annuler",
|
||||
"loading": "Chargement..."
|
||||
},
|
||||
"gitea": {
|
||||
"settings": {
|
||||
"title": "Configuration Gitea",
|
||||
"url": "URL du serveur",
|
||||
"urlPlaceholder": "https://git.example.com",
|
||||
"token": "Token API",
|
||||
"tokenPlaceholder": "Entrez un nouveau token",
|
||||
"tokenConfigured": "Token configuré",
|
||||
"save": "Enregistrer",
|
||||
"saved": "Configuration Gitea sauvegardée.",
|
||||
"testConnection": "Tester la connexion",
|
||||
"testSuccess": "Connexion réussie.",
|
||||
"testFailed": "Connexion échouée."
|
||||
},
|
||||
"branch": {
|
||||
"title": "Git",
|
||||
"create": "Créer une branche",
|
||||
"created": "Branche créée avec succès.",
|
||||
"copy": "Copier le nom",
|
||||
"copied": "Nom de branche copié.",
|
||||
"type": "Type",
|
||||
"baseBranch": "Branche de base",
|
||||
"preview": "Aperçu",
|
||||
"types": {
|
||||
"feature": "feature",
|
||||
"fix": "fix",
|
||||
"refactor": "refactor",
|
||||
"hotfix": "hotfix",
|
||||
"chore": "chore"
|
||||
},
|
||||
"noBranches": "Aucune branche liée.",
|
||||
"commits": "Commits",
|
||||
"noCommits": "Aucun commit."
|
||||
},
|
||||
"pr": {
|
||||
"title": "Pull Requests",
|
||||
"noPrs": "Aucune pull request.",
|
||||
"open": "Ouverte",
|
||||
"merged": "Mergée",
|
||||
"closed": "Fermée",
|
||||
"ci": "CI/CD"
|
||||
},
|
||||
"error": "Erreur de connexion à Gitea.",
|
||||
"notConfigured": "Gitea non configuré pour ce projet."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,6 +27,12 @@
|
||||
:collapsed="ui.sidebarCollapsed"
|
||||
:class="ui.sidebarCollapsed ? 'mt-4' : 'border-t border-secondary-500 pt-6'"
|
||||
/>
|
||||
<SidebarLink
|
||||
to="/my-tasks"
|
||||
icon="mdi:clipboard-check-outline"
|
||||
label="Mes tâches"
|
||||
:collapsed="ui.sidebarCollapsed"
|
||||
/>
|
||||
<SidebarLink
|
||||
to="/projects"
|
||||
icon="mdi:folder-outline"
|
||||
|
||||
@@ -25,6 +25,7 @@
|
||||
<AdminPriorityTab v-if="activeTab === 'priorities'" />
|
||||
<AdminTagTab v-if="activeTab === 'tags'" />
|
||||
<AdminUserTab v-if="activeTab === 'users'" />
|
||||
<AdminGiteaTab v-if="activeTab === 'gitea'" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -39,6 +40,7 @@ const tabs = [
|
||||
{ key: 'priorities', label: 'Priorités' },
|
||||
{ key: 'tags', label: 'Tags' },
|
||||
{ key: 'users', label: 'Utilisateurs' },
|
||||
{ key: 'gitea', label: 'Gitea' },
|
||||
] as const
|
||||
|
||||
type TabKey = typeof tabs[number]['key']
|
||||
|
||||
439
frontend/pages/my-tasks.vue
Normal file
439
frontend/pages/my-tasks.vue
Normal file
@@ -0,0 +1,439 @@
|
||||
<script setup lang="ts">
|
||||
import type { Task } from '~/services/dto/task'
|
||||
import type { TaskStatus } from '~/services/dto/task-status'
|
||||
import type { TaskEffort } from '~/services/dto/task-effort'
|
||||
import type { TaskPriority } from '~/services/dto/task-priority'
|
||||
import type { TaskTag } from '~/services/dto/task-tag'
|
||||
import type { TaskGroup } from '~/services/dto/task-group'
|
||||
import type { UserData } from '~/services/dto/user-data'
|
||||
import type { Project } from '~/services/dto/project'
|
||||
import { useTaskService } from '~/services/tasks'
|
||||
import { useTaskStatusService } from '~/services/task-statuses'
|
||||
import { useTaskEffortService } from '~/services/task-efforts'
|
||||
import { useTaskPriorityService } from '~/services/task-priorities'
|
||||
import { useTaskTagService } from '~/services/task-tags'
|
||||
import { useTaskGroupService } from '~/services/task-groups'
|
||||
import { useUserService } from '~/services/users'
|
||||
import { useProjectService } from '~/services/projects'
|
||||
|
||||
const { t } = useI18n()
|
||||
const auth = useAuthStore()
|
||||
|
||||
useHead({ title: t('myTasks.title') })
|
||||
|
||||
const taskService = useTaskService()
|
||||
const statusService = useTaskStatusService()
|
||||
const effortService = useTaskEffortService()
|
||||
const priorityService = useTaskPriorityService()
|
||||
const tagService = useTaskTagService()
|
||||
const groupService = useTaskGroupService()
|
||||
const userService = useUserService()
|
||||
const projectService = useProjectService()
|
||||
|
||||
const tasks = ref<Task[]>([])
|
||||
const statuses = ref<TaskStatus[]>([])
|
||||
const efforts = ref<TaskEffort[]>([])
|
||||
const priorities = ref<TaskPriority[]>([])
|
||||
const tags = ref<TaskTag[]>([])
|
||||
const groups = ref<TaskGroup[]>([])
|
||||
const users = ref<UserData[]>([])
|
||||
const projects = ref<Project[]>([])
|
||||
const isLoading = ref(true)
|
||||
|
||||
// Filters
|
||||
const selectedProjectId = ref<number | null>(null)
|
||||
const selectedGroupId = ref<number | null>(null)
|
||||
const selectedTagId = ref<number | null>(null)
|
||||
const selectedPriorityId = ref<number | null>(null)
|
||||
const selectedEffortId = ref<number | null>(null)
|
||||
const selectedAssigneeId = ref<number | null>(auth.user?.id ?? null)
|
||||
|
||||
// View toggle
|
||||
const viewMode = ref<'kanban' | 'list'>('kanban')
|
||||
|
||||
// Modal
|
||||
const taskModalOpen = ref(false)
|
||||
const selectedTask = ref<Task | null>(null)
|
||||
|
||||
// Timer
|
||||
const timerStore = useTimerStore()
|
||||
|
||||
function isTimerOnTask(task: Task): boolean {
|
||||
const entry = timerStore.activeEntry
|
||||
if (!entry?.task) return false
|
||||
const entryTaskId = typeof entry.task === 'string'
|
||||
? entry.task
|
||||
: (entry.task['@id'] ?? entry.task.id)
|
||||
const taskId = task['@id'] ?? task.id
|
||||
return entryTaskId === taskId || entryTaskId === `/api/tasks/${task.id}`
|
||||
}
|
||||
|
||||
// Filter options
|
||||
const projectOptions = computed(() =>
|
||||
projects.value.map(p => ({ label: p.name, value: p.id }))
|
||||
)
|
||||
|
||||
const groupOptions = computed(() => {
|
||||
let g = groups.value.filter(g => !g.archived)
|
||||
if (selectedProjectId.value) {
|
||||
g = g.filter(g => g.project?.id === selectedProjectId.value)
|
||||
}
|
||||
return g.map(g => ({ label: g.title, value: g.id }))
|
||||
})
|
||||
|
||||
const tagOptions = computed(() =>
|
||||
tags.value.map(t => ({ label: t.label, value: t.id }))
|
||||
)
|
||||
|
||||
const priorityOptions = computed(() =>
|
||||
priorities.value.map(p => ({ label: p.label, value: p.id }))
|
||||
)
|
||||
|
||||
const effortOptions = computed(() =>
|
||||
efforts.value.map(e => ({ label: e.label, value: e.id }))
|
||||
)
|
||||
|
||||
const assigneeOptions = computed(() =>
|
||||
users.value.map(u => ({ label: u.username, value: u.id }))
|
||||
)
|
||||
|
||||
// Kanban helpers
|
||||
const sortedStatuses = computed(() =>
|
||||
[...statuses.value].sort((a, b) => a.position - b.position)
|
||||
)
|
||||
|
||||
function tasksByStatus(statusId: number): Task[] {
|
||||
return tasks.value.filter(t => t.status?.id === statusId)
|
||||
}
|
||||
|
||||
const backlogTasks = computed(() =>
|
||||
tasks.value.filter(t => !t.status)
|
||||
)
|
||||
|
||||
// Data loading
|
||||
async function loadReferenceData() {
|
||||
const [s, e, pr, tg, g, u, p] = await Promise.all([
|
||||
statusService.getAll(),
|
||||
effortService.getAll(),
|
||||
priorityService.getAll(),
|
||||
tagService.getAll(),
|
||||
groupService.getAll(),
|
||||
userService.getAll(),
|
||||
projectService.getAll(),
|
||||
])
|
||||
statuses.value = s
|
||||
efforts.value = e
|
||||
priorities.value = pr
|
||||
tags.value = tg
|
||||
groups.value = g
|
||||
users.value = u
|
||||
projects.value = p
|
||||
}
|
||||
|
||||
async function loadTasks() {
|
||||
const params: Record<string, string | number | boolean | string[]> = {
|
||||
archived: false,
|
||||
}
|
||||
if (selectedAssigneeId.value) {
|
||||
params.assignee = `/api/users/${selectedAssigneeId.value}`
|
||||
}
|
||||
if (selectedProjectId.value) {
|
||||
params.project = `/api/projects/${selectedProjectId.value}`
|
||||
}
|
||||
if (selectedGroupId.value) {
|
||||
params.group = `/api/task_groups/${selectedGroupId.value}`
|
||||
}
|
||||
if (selectedPriorityId.value) {
|
||||
params.priority = `/api/task_priorities/${selectedPriorityId.value}`
|
||||
}
|
||||
if (selectedEffortId.value) {
|
||||
params.effort = `/api/task_efforts/${selectedEffortId.value}`
|
||||
}
|
||||
if (selectedTagId.value) {
|
||||
params['tags[]'] = `/api/task_tags/${selectedTagId.value}`
|
||||
}
|
||||
tasks.value = await taskService.getFiltered(params)
|
||||
}
|
||||
|
||||
async function loadAll() {
|
||||
isLoading.value = true
|
||||
try {
|
||||
await Promise.all([loadReferenceData(), loadTasks()])
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// Watch filters to reload tasks
|
||||
watch(
|
||||
[selectedProjectId, selectedGroupId, selectedTagId, selectedPriorityId, selectedEffortId, selectedAssigneeId],
|
||||
() => { loadTasks() },
|
||||
)
|
||||
|
||||
// Reset group when project changes
|
||||
watch(selectedProjectId, () => {
|
||||
selectedGroupId.value = null
|
||||
}, { flush: 'sync' })
|
||||
|
||||
// Drag & drop
|
||||
const dragOverStatusId = ref<number | null>(null)
|
||||
const dragCounter = ref(0)
|
||||
|
||||
function onDragEnter(id: number) {
|
||||
dragCounter.value++
|
||||
dragOverStatusId.value = id
|
||||
}
|
||||
|
||||
function onDragLeave() {
|
||||
dragCounter.value--
|
||||
if (dragCounter.value === 0) {
|
||||
dragOverStatusId.value = null
|
||||
}
|
||||
}
|
||||
|
||||
function onDrop(event: DragEvent) {
|
||||
dragCounter.value = 0
|
||||
dragOverStatusId.value = null
|
||||
return Number(event.dataTransfer!.getData('text/plain'))
|
||||
}
|
||||
|
||||
async function onDropStatus(event: DragEvent, status: TaskStatus) {
|
||||
const taskId = onDrop(event)
|
||||
const task = tasks.value.find(t => t.id === taskId)
|
||||
if (!task || task.status?.id === status.id) return
|
||||
task.status = status
|
||||
await taskService.update(taskId, { status: `/api/task_statuses/${status.id}` })
|
||||
}
|
||||
|
||||
async function onDropBacklog(event: DragEvent) {
|
||||
const taskId = onDrop(event)
|
||||
const task = tasks.value.find(t => t.id === taskId)
|
||||
if (!task || !task.status) return
|
||||
task.status = null
|
||||
await taskService.update(taskId, { status: null })
|
||||
}
|
||||
|
||||
// Modal
|
||||
function openTaskEdit(task: Task) {
|
||||
selectedTask.value = task
|
||||
taskModalOpen.value = true
|
||||
}
|
||||
|
||||
async function onSaved() {
|
||||
await loadTasks()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadAll()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between">
|
||||
<h1 class="text-2xl font-bold text-primary-500">{{ $t('myTasks.title') }}</h1>
|
||||
<div class="flex gap-1">
|
||||
<button
|
||||
class="rounded-lg p-2 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="20" />
|
||||
</button>
|
||||
<button
|
||||
class="rounded-lg p-2 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="20" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Filters -->
|
||||
<div class="mt-4 flex flex-wrap gap-3">
|
||||
<MalioSelect
|
||||
v-model="selectedProjectId"
|
||||
:options="projectOptions"
|
||||
label="Projet"
|
||||
:empty-option-label="$t('myTasks.allProjects')"
|
||||
min-width="!w-40"
|
||||
text-field="text-sm"
|
||||
text-value="text-sm"
|
||||
/>
|
||||
<MalioSelect
|
||||
v-model="selectedGroupId"
|
||||
:options="groupOptions"
|
||||
label="Groupe"
|
||||
:empty-option-label="$t('myTasks.allGroups')"
|
||||
min-width="!w-40"
|
||||
text-field="text-sm"
|
||||
text-value="text-sm"
|
||||
/>
|
||||
<MalioSelect
|
||||
v-model="selectedTagId"
|
||||
:options="tagOptions"
|
||||
label="Type"
|
||||
:empty-option-label="$t('myTasks.allTypes')"
|
||||
min-width="!w-40"
|
||||
text-field="text-sm"
|
||||
text-value="text-sm"
|
||||
/>
|
||||
<MalioSelect
|
||||
v-model="selectedPriorityId"
|
||||
:options="priorityOptions"
|
||||
label="Priorité"
|
||||
:empty-option-label="$t('myTasks.allPriorities')"
|
||||
min-width="!w-40"
|
||||
text-field="text-sm"
|
||||
text-value="text-sm"
|
||||
/>
|
||||
<MalioSelect
|
||||
v-model="selectedEffortId"
|
||||
:options="effortOptions"
|
||||
label="Effort"
|
||||
:empty-option-label="$t('myTasks.allEfforts')"
|
||||
min-width="!w-40"
|
||||
text-field="text-sm"
|
||||
text-value="text-sm"
|
||||
/>
|
||||
<MalioSelect
|
||||
v-model="selectedAssigneeId"
|
||||
:options="assigneeOptions"
|
||||
label="Assigné"
|
||||
:empty-option-label="$t('myTasks.allAssignees')"
|
||||
min-width="!w-40"
|
||||
text-field="text-sm"
|
||||
text-value="text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Kanban View -->
|
||||
<div v-if="viewMode === 'kanban'" class="mt-6 flex gap-4 overflow-x-auto pb-4">
|
||||
<!-- Backlog column (tasks without status) -->
|
||||
<div
|
||||
v-if="backlogTasks.length > 0"
|
||||
class="flex w-72 shrink-0 flex-col rounded-lg transition-colors"
|
||||
:class="dragOverStatusId === 0 ? 'bg-neutral-200' : 'bg-neutral-50'"
|
||||
@dragover.prevent
|
||||
@dragenter.prevent="onDragEnter(0)"
|
||||
@dragleave="onDragLeave"
|
||||
@drop.prevent="onDropBacklog($event)"
|
||||
>
|
||||
<div class="rounded-t-lg bg-neutral-500 px-4 py-3 text-sm font-bold text-white">
|
||||
{{ $t('myTasks.backlog') }} ({{ backlogTasks.length }})
|
||||
</div>
|
||||
<div class="flex flex-col gap-3 p-3">
|
||||
<TaskCard
|
||||
v-for="task in backlogTasks"
|
||||
:key="task.id"
|
||||
:task="task"
|
||||
@click="openTaskEdit(task)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Status columns -->
|
||||
<div
|
||||
v-for="status in sortedStatuses"
|
||||
:key="status.id"
|
||||
class="flex w-72 shrink-0 flex-col rounded-lg transition-colors"
|
||||
:class="dragOverStatusId === status.id ? 'bg-neutral-200' : 'bg-neutral-50'"
|
||||
@dragover.prevent
|
||||
@dragenter.prevent="onDragEnter(status.id)"
|
||||
@dragleave="onDragLeave"
|
||||
@drop.prevent="onDropStatus($event, status)"
|
||||
>
|
||||
<div
|
||||
class="rounded-t-lg px-4 py-3 text-sm font-bold text-white"
|
||||
:style="{ backgroundColor: status.color }"
|
||||
>
|
||||
{{ status.label }} ({{ tasksByStatus(status.id).length }})
|
||||
</div>
|
||||
<div class="flex flex-col gap-3 p-3">
|
||||
<TaskCard
|
||||
v-for="task in tasksByStatus(status.id)"
|
||||
:key="task.id"
|
||||
:task="task"
|
||||
@click="openTaskEdit(task)"
|
||||
/>
|
||||
<p
|
||||
v-if="tasksByStatus(status.id).length === 0"
|
||||
class="py-4 text-center text-xs text-neutral-400"
|
||||
>
|
||||
{{ $t('myTasks.noTasks') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- List View -->
|
||||
<div v-if="viewMode === 'list'" class="mt-6">
|
||||
<div
|
||||
v-for="task in tasks"
|
||||
:key="task.id"
|
||||
class="flex cursor-pointer items-center justify-between border-b border-neutral-100 px-4 py-3 transition-colors hover:bg-neutral-50"
|
||||
@click="openTaskEdit(task)"
|
||||
>
|
||||
<div class="min-w-0 flex-1">
|
||||
<h4 class="text-sm font-semibold text-neutral-900">{{ task.title }}</h4>
|
||||
<div class="mt-1 flex items-center gap-1.5">
|
||||
<span
|
||||
v-if="task.priority"
|
||||
class="rounded-full px-2 py-0.5 text-xs font-semibold text-white"
|
||||
:style="{ backgroundColor: task.priority.color }"
|
||||
>
|
||||
{{ task.priority.label }}
|
||||
</span>
|
||||
<span
|
||||
v-for="tag in task.tags"
|
||||
:key="tag.id"
|
||||
class="rounded-full px-2 py-0.5 text-xs font-semibold text-white"
|
||||
:style="{ backgroundColor: tag.color }"
|
||||
>
|
||||
{{ tag.label }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<button
|
||||
class="shrink-0 transition-colors"
|
||||
:class="isTimerOnTask(task) ? 'text-[#F18619] hover:text-[#d97314]' : 'text-neutral-400 hover:text-primary-500'"
|
||||
@click.stop="isTimerOnTask(task) ? timerStore.stop() : timerStore.startFromTask(task)"
|
||||
>
|
||||
<Icon :name="isTimerOnTask(task) ? 'mdi:stop-circle-outline' : 'mdi:play-circle-outline'" size="20" />
|
||||
</button>
|
||||
<span
|
||||
v-if="task.project && task.number"
|
||||
class="text-sm font-medium text-primary-500"
|
||||
>
|
||||
{{ task.project.code }}-{{ task.number }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<p
|
||||
v-if="tasks.length === 0 && !isLoading"
|
||||
class="py-8 text-center text-sm text-neutral-400"
|
||||
>
|
||||
{{ $t('myTasks.noTasks') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- TaskModal -->
|
||||
<TaskModal
|
||||
v-model="taskModalOpen"
|
||||
:task="selectedTask"
|
||||
:project-id="selectedTask?.project?.id ?? 0"
|
||||
:statuses="statuses"
|
||||
:efforts="efforts"
|
||||
:priorities="priorities"
|
||||
:tags="tags"
|
||||
:groups="selectedTask?.project?.id ? groups.filter(g => g.project?.id === selectedTask?.project?.id) : groups"
|
||||
:users="users"
|
||||
@saved="onSaved"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -56,7 +56,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<TaskDrawer
|
||||
<TaskModal
|
||||
v-model="taskDrawerOpen"
|
||||
:task="selectedTask"
|
||||
:project-id="projectId"
|
||||
|
||||
@@ -10,13 +10,42 @@
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="mt-4">
|
||||
<div class="mt-4 flex flex-wrap gap-3">
|
||||
<MalioSelect
|
||||
v-model="selectedGroupId"
|
||||
:options="groupFilterOptions"
|
||||
label="Groupe"
|
||||
empty-option-label="Tous les groupes"
|
||||
min-width="w-64"
|
||||
min-width="!w-40"
|
||||
text-field="text-sm"
|
||||
text-value="text-sm"
|
||||
/>
|
||||
<MalioSelect
|
||||
v-model="selectedTagId"
|
||||
:options="tagFilterOptions"
|
||||
label="Tags"
|
||||
empty-option-label="Tous les tags"
|
||||
min-width="!w-40"
|
||||
text-field="text-sm"
|
||||
text-value="text-sm"
|
||||
/>
|
||||
<MalioSelect
|
||||
v-model="selectedAssigneeId"
|
||||
:options="userFilterOptions"
|
||||
label="User"
|
||||
empty-option-label="Tous les users"
|
||||
min-width="!w-40"
|
||||
text-field="text-sm"
|
||||
text-value="text-sm"
|
||||
/>
|
||||
<MalioSelect
|
||||
v-model="selectedStatusId"
|
||||
:options="statusFilterOptions"
|
||||
label="Status"
|
||||
empty-option-label="Tous les status"
|
||||
min-width="!w-40"
|
||||
text-field="text-sm"
|
||||
text-value="text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -116,7 +145,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<TaskDrawer
|
||||
<TaskModal
|
||||
v-model="taskDrawerOpen"
|
||||
:task="selectedTask"
|
||||
:project-id="projectId"
|
||||
@@ -175,6 +204,9 @@ const users = ref<UserData[]>([])
|
||||
const isLoading = ref(true)
|
||||
|
||||
const selectedGroupId = ref<number | null>(null)
|
||||
const selectedTagId = ref<number | null>(null)
|
||||
const selectedAssigneeId = ref<number | null>(null)
|
||||
const selectedStatusId = ref<number | null>(null)
|
||||
const dragOverStatusId = ref<number | null>(null)
|
||||
const dragCounter = ref(0)
|
||||
const taskDrawerOpen = ref(false)
|
||||
@@ -184,11 +216,32 @@ const groupFilterOptions = computed(() =>
|
||||
groups.value.filter(g => !g.archived).map(g => ({ label: g.title, value: g.id }))
|
||||
)
|
||||
|
||||
const tagFilterOptions = computed(() =>
|
||||
tags.value.map(t => ({ label: t.label, value: t.id }))
|
||||
)
|
||||
|
||||
const userFilterOptions = computed(() =>
|
||||
users.value.map(u => ({ label: u.username, value: u.id }))
|
||||
)
|
||||
|
||||
const statusFilterOptions = computed(() =>
|
||||
statuses.value.map(s => ({ label: s.label, value: s.id }))
|
||||
)
|
||||
|
||||
const filteredTasks = computed(() => {
|
||||
let result = tasks.value.filter(t => !t.archived)
|
||||
if (selectedGroupId.value) {
|
||||
result = result.filter(t => t.group?.id === selectedGroupId.value)
|
||||
}
|
||||
if (selectedTagId.value) {
|
||||
result = result.filter(t => t.tags?.some(tag => tag.id === selectedTagId.value))
|
||||
}
|
||||
if (selectedAssigneeId.value) {
|
||||
result = result.filter(t => t.assignee?.id === selectedAssigneeId.value)
|
||||
}
|
||||
if (selectedStatusId.value) {
|
||||
result = result.filter(t => t.status?.id === selectedStatusId.value)
|
||||
}
|
||||
return result
|
||||
})
|
||||
|
||||
|
||||
57
frontend/services/dto/gitea.ts
Normal file
57
frontend/services/dto/gitea.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
export type GiteaSettings = {
|
||||
url: string | null
|
||||
hasToken: boolean
|
||||
}
|
||||
|
||||
export type GiteaSettingsWrite = {
|
||||
url: string | null
|
||||
token: string | null
|
||||
}
|
||||
|
||||
export type GiteaRepository = {
|
||||
fullName: string
|
||||
name: string
|
||||
owner: string
|
||||
}
|
||||
|
||||
export type GiteaBranch = {
|
||||
name: string
|
||||
commits: GiteaCommit[]
|
||||
}
|
||||
|
||||
export type GiteaCommit = {
|
||||
sha: string
|
||||
message: string
|
||||
author: string
|
||||
date: string
|
||||
}
|
||||
|
||||
export type GiteaBranchCreate = {
|
||||
type: string
|
||||
baseBranch: string
|
||||
}
|
||||
|
||||
export type GiteaPullRequest = {
|
||||
number: number
|
||||
title: string
|
||||
state: string
|
||||
merged: boolean
|
||||
headBranch: string
|
||||
author: string
|
||||
url: string
|
||||
ciStatuses: GiteaCiStatus[]
|
||||
}
|
||||
|
||||
export type GiteaCiStatus = {
|
||||
context: string
|
||||
status: string
|
||||
target_url: string
|
||||
}
|
||||
|
||||
export type GiteaBranchName = {
|
||||
name: string
|
||||
}
|
||||
|
||||
export type GiteaTestResult = {
|
||||
success: boolean
|
||||
}
|
||||
@@ -8,6 +8,8 @@ export type Project = {
|
||||
description: string | null
|
||||
color: string
|
||||
client: Client | null
|
||||
giteaOwner: string | null
|
||||
giteaRepo: string | null
|
||||
}
|
||||
|
||||
export type ProjectWrite = {
|
||||
@@ -16,4 +18,6 @@ export type ProjectWrite = {
|
||||
description: string | null
|
||||
color: string
|
||||
client: string | null // IRI : "/api/clients/1" ou null
|
||||
giteaOwner?: string | null
|
||||
giteaRepo?: string | null
|
||||
}
|
||||
|
||||
66
frontend/services/gitea.ts
Normal file
66
frontend/services/gitea.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import type {
|
||||
GiteaSettings,
|
||||
GiteaSettingsWrite,
|
||||
GiteaRepository,
|
||||
GiteaBranch,
|
||||
GiteaBranchCreate,
|
||||
GiteaPullRequest,
|
||||
GiteaBranchName,
|
||||
GiteaTestResult,
|
||||
} from './dto/gitea'
|
||||
import type { HydraCollection } from '~/utils/api'
|
||||
import { extractHydraMembers } from '~/utils/api'
|
||||
|
||||
export function useGiteaService() {
|
||||
const api = useApi()
|
||||
|
||||
async function getSettings(): Promise<GiteaSettings> {
|
||||
return api.get<GiteaSettings>('/settings/gitea')
|
||||
}
|
||||
|
||||
async function saveSettings(payload: GiteaSettingsWrite): Promise<GiteaSettings> {
|
||||
return api.put<GiteaSettings>('/settings/gitea', payload as Record<string, unknown>, {
|
||||
toastSuccessKey: 'gitea.settings.saved',
|
||||
})
|
||||
}
|
||||
|
||||
async function testConnection(): Promise<GiteaTestResult> {
|
||||
return api.post<GiteaTestResult>('/settings/gitea/test')
|
||||
}
|
||||
|
||||
async function listRepositories(): Promise<GiteaRepository[]> {
|
||||
const data = await api.get<HydraCollection<GiteaRepository>>('/gitea/repositories')
|
||||
return extractHydraMembers(data)
|
||||
}
|
||||
|
||||
async function listBranches(taskId: number): Promise<GiteaBranch[]> {
|
||||
const data = await api.get<HydraCollection<GiteaBranch>>(`/tasks/${taskId}/gitea/branches`)
|
||||
return extractHydraMembers(data)
|
||||
}
|
||||
|
||||
async function createBranch(taskId: number, payload: GiteaBranchCreate): Promise<GiteaBranch> {
|
||||
return api.post<GiteaBranch>(`/tasks/${taskId}/gitea/branches`, payload as Record<string, unknown>, {
|
||||
toastSuccessKey: 'gitea.branch.created',
|
||||
})
|
||||
}
|
||||
|
||||
async function listPullRequests(taskId: number): Promise<GiteaPullRequest[]> {
|
||||
const data = await api.get<HydraCollection<GiteaPullRequest>>(`/tasks/${taskId}/gitea/pull-requests`)
|
||||
return extractHydraMembers(data)
|
||||
}
|
||||
|
||||
async function getBranchName(taskId: number, type: string): Promise<GiteaBranchName> {
|
||||
return api.get<GiteaBranchName>(`/tasks/${taskId}/gitea/branch-name/${type}`)
|
||||
}
|
||||
|
||||
return {
|
||||
getSettings,
|
||||
saveSettings,
|
||||
testConnection,
|
||||
listRepositories,
|
||||
listBranches,
|
||||
createBranch,
|
||||
listPullRequests,
|
||||
getBranchName,
|
||||
}
|
||||
}
|
||||
@@ -26,6 +26,11 @@ export function useTaskService() {
|
||||
return extractHydraMembers(data)
|
||||
}
|
||||
|
||||
async function getFiltered(params: Record<string, string | number | boolean | string[]>): Promise<Task[]> {
|
||||
const data = await api.get<HydraCollection<Task>>('/tasks', params as Record<string, unknown>)
|
||||
return extractHydraMembers(data)
|
||||
}
|
||||
|
||||
async function create(payload: TaskWrite): Promise<Task> {
|
||||
return api.post<Task>('/tasks', payload as Record<string, unknown>, {
|
||||
toastSuccessKey: 'tasks.created',
|
||||
@@ -44,5 +49,5 @@ export function useTaskService() {
|
||||
})
|
||||
}
|
||||
|
||||
return { getAll, getByProject, getByProjectArchived, create, update, remove }
|
||||
return { getAll, getByProject, getByProjectArchived, getFiltered, create, update, remove }
|
||||
}
|
||||
|
||||
41
migrations/Version20260313125531.php
Normal file
41
migrations/Version20260313125531.php
Normal file
@@ -0,0 +1,41 @@
|
||||
<?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 Version20260313125531 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('CREATE TABLE gitea_configuration (id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL, url VARCHAR(255) DEFAULT NULL, encrypted_token TEXT DEFAULT NULL, PRIMARY KEY (id))');
|
||||
$this->addSql('ALTER TABLE project ADD gitea_owner VARCHAR(255) DEFAULT NULL');
|
||||
$this->addSql('ALTER TABLE project ADD gitea_repo VARCHAR(255) DEFAULT NULL');
|
||||
$this->addSql('ALTER TABLE task ALTER archived DROP DEFAULT');
|
||||
$this->addSql('ALTER TABLE task_group ALTER archived DROP DEFAULT');
|
||||
$this->addSql('ALTER TABLE task_status ALTER is_final DROP DEFAULT');
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
// this down() migration is auto-generated, please modify it to your needs
|
||||
$this->addSql('DROP TABLE gitea_configuration');
|
||||
$this->addSql('ALTER TABLE project DROP gitea_owner');
|
||||
$this->addSql('ALTER TABLE project DROP gitea_repo');
|
||||
$this->addSql('ALTER TABLE task ALTER archived SET DEFAULT false');
|
||||
$this->addSql('ALTER TABLE task_group ALTER archived SET DEFAULT false');
|
||||
$this->addSql('ALTER TABLE task_status ALTER is_final SET DEFAULT false');
|
||||
}
|
||||
}
|
||||
46
src/ApiResource/GiteaBranch.php
Normal file
46
src/ApiResource/GiteaBranch.php
Normal file
@@ -0,0 +1,46 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\ApiResource;
|
||||
|
||||
use ApiPlatform\Metadata\ApiResource;
|
||||
use ApiPlatform\Metadata\GetCollection;
|
||||
use ApiPlatform\Metadata\Post;
|
||||
use App\State\GiteaBranchProcessor;
|
||||
use App\State\GiteaBranchProvider;
|
||||
use Symfony\Component\Serializer\Attribute\Groups;
|
||||
|
||||
#[ApiResource(
|
||||
operations: [
|
||||
new GetCollection(
|
||||
uriTemplate: '/tasks/{taskId}/gitea/branches',
|
||||
normalizationContext: ['groups' => ['gitea_branch:read']],
|
||||
provider: GiteaBranchProvider::class,
|
||||
),
|
||||
new Post(
|
||||
uriTemplate: '/tasks/{taskId}/gitea/branches',
|
||||
denormalizationContext: ['groups' => ['gitea_branch:write']],
|
||||
normalizationContext: ['groups' => ['gitea_branch:read']],
|
||||
provider: GiteaBranchProvider::class,
|
||||
processor: GiteaBranchProcessor::class,
|
||||
),
|
||||
],
|
||||
)]
|
||||
final class GiteaBranch
|
||||
{
|
||||
#[Groups(['gitea_branch:read'])]
|
||||
public string $name = '';
|
||||
|
||||
#[Groups(['gitea_branch:write'])]
|
||||
public string $type = 'feature';
|
||||
|
||||
#[Groups(['gitea_branch:write'])]
|
||||
public string $baseBranch = 'main';
|
||||
|
||||
/**
|
||||
* @var array<array{sha: string, message: string, author: string, date: string}>
|
||||
*/
|
||||
#[Groups(['gitea_branch:read'])]
|
||||
public array $commits = [];
|
||||
}
|
||||
25
src/ApiResource/GiteaBranchName.php
Normal file
25
src/ApiResource/GiteaBranchName.php
Normal file
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\ApiResource;
|
||||
|
||||
use ApiPlatform\Metadata\ApiResource;
|
||||
use ApiPlatform\Metadata\Get;
|
||||
use App\State\GiteaBranchNameProvider;
|
||||
use Symfony\Component\Serializer\Attribute\Groups;
|
||||
|
||||
#[ApiResource(
|
||||
operations: [
|
||||
new Get(
|
||||
uriTemplate: '/tasks/{taskId}/gitea/branch-name/{type}',
|
||||
normalizationContext: ['groups' => ['gitea_branch_name:read']],
|
||||
provider: GiteaBranchNameProvider::class,
|
||||
),
|
||||
],
|
||||
)]
|
||||
final class GiteaBranchName
|
||||
{
|
||||
#[Groups(['gitea_branch_name:read'])]
|
||||
public string $name = '';
|
||||
}
|
||||
49
src/ApiResource/GiteaPullRequest.php
Normal file
49
src/ApiResource/GiteaPullRequest.php
Normal file
@@ -0,0 +1,49 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\ApiResource;
|
||||
|
||||
use ApiPlatform\Metadata\ApiResource;
|
||||
use ApiPlatform\Metadata\GetCollection;
|
||||
use App\State\GiteaPullRequestProvider;
|
||||
use Symfony\Component\Serializer\Attribute\Groups;
|
||||
|
||||
#[ApiResource(
|
||||
operations: [
|
||||
new GetCollection(
|
||||
uriTemplate: '/tasks/{taskId}/gitea/pull-requests',
|
||||
normalizationContext: ['groups' => ['gitea_pr:read']],
|
||||
provider: GiteaPullRequestProvider::class,
|
||||
),
|
||||
],
|
||||
)]
|
||||
final class GiteaPullRequest
|
||||
{
|
||||
#[Groups(['gitea_pr:read'])]
|
||||
public int $number = 0;
|
||||
|
||||
#[Groups(['gitea_pr:read'])]
|
||||
public string $title = '';
|
||||
|
||||
#[Groups(['gitea_pr:read'])]
|
||||
public string $state = '';
|
||||
|
||||
#[Groups(['gitea_pr:read'])]
|
||||
public bool $merged = false;
|
||||
|
||||
#[Groups(['gitea_pr:read'])]
|
||||
public string $headBranch = '';
|
||||
|
||||
#[Groups(['gitea_pr:read'])]
|
||||
public string $author = '';
|
||||
|
||||
#[Groups(['gitea_pr:read'])]
|
||||
public string $url = '';
|
||||
|
||||
/**
|
||||
* @var array<array{context: string, status: string, target_url: string}>
|
||||
*/
|
||||
#[Groups(['gitea_pr:read'])]
|
||||
public array $ciStatuses = [];
|
||||
}
|
||||
32
src/ApiResource/GiteaRepository.php
Normal file
32
src/ApiResource/GiteaRepository.php
Normal file
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\ApiResource;
|
||||
|
||||
use ApiPlatform\Metadata\ApiResource;
|
||||
use ApiPlatform\Metadata\GetCollection;
|
||||
use App\State\GiteaRepositoryProvider;
|
||||
use Symfony\Component\Serializer\Attribute\Groups;
|
||||
|
||||
#[ApiResource(
|
||||
operations: [
|
||||
new GetCollection(
|
||||
uriTemplate: '/gitea/repositories',
|
||||
normalizationContext: ['groups' => ['gitea_repo:read']],
|
||||
provider: GiteaRepositoryProvider::class,
|
||||
security: "is_granted('ROLE_ADMIN')",
|
||||
),
|
||||
],
|
||||
)]
|
||||
final class GiteaRepository
|
||||
{
|
||||
#[Groups(['gitea_repo:read'])]
|
||||
public string $fullName = '';
|
||||
|
||||
#[Groups(['gitea_repo:read'])]
|
||||
public string $name = '';
|
||||
|
||||
#[Groups(['gitea_repo:read'])]
|
||||
public string $owner = '';
|
||||
}
|
||||
42
src/ApiResource/GiteaSettings.php
Normal file
42
src/ApiResource/GiteaSettings.php
Normal file
@@ -0,0 +1,42 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\ApiResource;
|
||||
|
||||
use ApiPlatform\Metadata\ApiResource;
|
||||
use ApiPlatform\Metadata\Get;
|
||||
use ApiPlatform\Metadata\Put;
|
||||
use App\State\GiteaSettingsProcessor;
|
||||
use App\State\GiteaSettingsProvider;
|
||||
use Symfony\Component\Serializer\Attribute\Groups;
|
||||
|
||||
#[ApiResource(
|
||||
operations: [
|
||||
new Get(
|
||||
uriTemplate: '/settings/gitea',
|
||||
normalizationContext: ['groups' => ['gitea_settings:read']],
|
||||
provider: GiteaSettingsProvider::class,
|
||||
security: "is_granted('ROLE_ADMIN')",
|
||||
),
|
||||
new Put(
|
||||
uriTemplate: '/settings/gitea',
|
||||
denormalizationContext: ['groups' => ['gitea_settings:write']],
|
||||
normalizationContext: ['groups' => ['gitea_settings:read']],
|
||||
provider: GiteaSettingsProvider::class,
|
||||
processor: GiteaSettingsProcessor::class,
|
||||
security: "is_granted('ROLE_ADMIN')",
|
||||
),
|
||||
],
|
||||
)]
|
||||
final class GiteaSettings
|
||||
{
|
||||
#[Groups(['gitea_settings:read', 'gitea_settings:write'])]
|
||||
public ?string $url = null;
|
||||
|
||||
#[Groups(['gitea_settings:write'])]
|
||||
public ?string $token = null;
|
||||
|
||||
#[Groups(['gitea_settings:read'])]
|
||||
public bool $hasToken = false;
|
||||
}
|
||||
28
src/ApiResource/GiteaTestConnection.php
Normal file
28
src/ApiResource/GiteaTestConnection.php
Normal file
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\ApiResource;
|
||||
|
||||
use ApiPlatform\Metadata\ApiResource;
|
||||
use ApiPlatform\Metadata\Post;
|
||||
use App\State\GiteaTestConnectionProvider;
|
||||
use Symfony\Component\Serializer\Attribute\Groups;
|
||||
|
||||
#[ApiResource(
|
||||
operations: [
|
||||
new Post(
|
||||
uriTemplate: '/settings/gitea/test',
|
||||
input: false,
|
||||
normalizationContext: ['groups' => ['gitea_test:read']],
|
||||
provider: GiteaTestConnectionProvider::class,
|
||||
processor: GiteaTestConnectionProvider::class,
|
||||
security: "is_granted('ROLE_ADMIN')",
|
||||
),
|
||||
],
|
||||
)]
|
||||
final class GiteaTestConnection
|
||||
{
|
||||
#[Groups(['gitea_test:read'])]
|
||||
public bool $success = false;
|
||||
}
|
||||
57
src/Entity/GiteaConfiguration.php
Normal file
57
src/Entity/GiteaConfiguration.php
Normal file
@@ -0,0 +1,57 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Entity;
|
||||
|
||||
use App\Repository\GiteaConfigurationRepository;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
|
||||
#[ORM\Entity(repositoryClass: GiteaConfigurationRepository::class)]
|
||||
class GiteaConfiguration
|
||||
{
|
||||
#[ORM\Id]
|
||||
#[ORM\GeneratedValue]
|
||||
#[ORM\Column]
|
||||
private ?int $id = null;
|
||||
|
||||
#[ORM\Column(length: 255, nullable: true)]
|
||||
private ?string $url = null;
|
||||
|
||||
#[ORM\Column(type: 'text', nullable: true)]
|
||||
private ?string $encryptedToken = null;
|
||||
|
||||
public function getId(): ?int
|
||||
{
|
||||
return $this->id;
|
||||
}
|
||||
|
||||
public function getUrl(): ?string
|
||||
{
|
||||
return $this->url;
|
||||
}
|
||||
|
||||
public function setUrl(?string $url): static
|
||||
{
|
||||
$this->url = $url;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getEncryptedToken(): ?string
|
||||
{
|
||||
return $this->encryptedToken;
|
||||
}
|
||||
|
||||
public function setEncryptedToken(?string $encryptedToken): static
|
||||
{
|
||||
$this->encryptedToken = $encryptedToken;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function hasToken(): bool
|
||||
{
|
||||
return null !== $this->encryptedToken;
|
||||
}
|
||||
}
|
||||
@@ -38,7 +38,7 @@ class Project
|
||||
#[ORM\Id]
|
||||
#[ORM\GeneratedValue]
|
||||
#[ORM\Column]
|
||||
#[Groups(['project:read', 'time_entry:read'])]
|
||||
#[Groups(['project:read', 'time_entry:read', 'task:read'])]
|
||||
private ?int $id = null;
|
||||
|
||||
#[ORM\Column(length: 10, unique: true)]
|
||||
@@ -64,6 +64,14 @@ class Project
|
||||
#[Groups(['project:read', 'project:write'])]
|
||||
private ?Client $client = null;
|
||||
|
||||
#[ORM\Column(length: 255, nullable: true)]
|
||||
#[Groups(['project:read', 'project:write', 'task:read'])]
|
||||
private ?string $giteaOwner = null;
|
||||
|
||||
#[ORM\Column(length: 255, nullable: true)]
|
||||
#[Groups(['project:read', 'project:write', 'task:read'])]
|
||||
private ?string $giteaRepo = null;
|
||||
|
||||
public function getId(): ?int
|
||||
{
|
||||
return $this->id;
|
||||
@@ -128,4 +136,33 @@ class Project
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getGiteaOwner(): ?string
|
||||
{
|
||||
return $this->giteaOwner;
|
||||
}
|
||||
|
||||
public function setGiteaOwner(?string $giteaOwner): static
|
||||
{
|
||||
$this->giteaOwner = $giteaOwner;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getGiteaRepo(): ?string
|
||||
{
|
||||
return $this->giteaRepo;
|
||||
}
|
||||
|
||||
public function setGiteaRepo(?string $giteaRepo): static
|
||||
{
|
||||
$this->giteaRepo = $giteaRepo;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function hasGiteaRepo(): bool
|
||||
{
|
||||
return null !== $this->giteaOwner && null !== $this->giteaRepo;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,7 +22,7 @@ use Symfony\Component\Serializer\Attribute\Groups;
|
||||
|
||||
#[ApiResource(
|
||||
operations: [
|
||||
new GetCollection(),
|
||||
new GetCollection(paginationEnabled: false),
|
||||
new Get(),
|
||||
new Post(security: "is_granted('ROLE_ADMIN')", processor: TaskNumberProcessor::class),
|
||||
new Patch(security: "is_granted('ROLE_ADMIN')"),
|
||||
@@ -32,7 +32,7 @@ use Symfony\Component\Serializer\Attribute\Groups;
|
||||
denormalizationContext: ['groups' => ['task:write']],
|
||||
order: ['id' => 'DESC'],
|
||||
)]
|
||||
#[ApiFilter(SearchFilter::class, properties: ['project' => 'exact', 'group' => 'exact'])]
|
||||
#[ApiFilter(SearchFilter::class, properties: ['project' => 'exact', 'group' => 'exact', 'assignee' => 'exact', 'priority' => 'exact', 'effort' => 'exact', 'tags' => 'exact', 'status' => 'exact'])]
|
||||
#[ApiFilter(BooleanFilter::class, properties: ['archived'])]
|
||||
#[ORM\Entity(repositoryClass: TaskRepository::class)]
|
||||
class Task
|
||||
|
||||
@@ -92,7 +92,7 @@ class TaskStatus
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function isFinal(): bool
|
||||
public function getIsFinal(): bool
|
||||
{
|
||||
return $this->isFinal;
|
||||
}
|
||||
|
||||
@@ -1,74 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Entity;
|
||||
|
||||
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\TaskTypeRepository;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use Symfony\Component\Serializer\Attribute\Groups;
|
||||
|
||||
#[ApiResource(
|
||||
operations: [
|
||||
new GetCollection(),
|
||||
new Get(),
|
||||
new Post(security: "is_granted('ROLE_ADMIN')"),
|
||||
new Patch(security: "is_granted('ROLE_ADMIN')"),
|
||||
new Delete(security: "is_granted('ROLE_ADMIN')"),
|
||||
],
|
||||
normalizationContext: ['groups' => ['task_type:read']],
|
||||
denormalizationContext: ['groups' => ['task_type:write']],
|
||||
order: ['label' => 'ASC'],
|
||||
)]
|
||||
#[ORM\Entity(repositoryClass: TaskTypeRepository::class)]
|
||||
class TaskType
|
||||
{
|
||||
#[ORM\Id]
|
||||
#[ORM\GeneratedValue]
|
||||
#[ORM\Column]
|
||||
#[Groups(['task_type:read', 'task:read', 'time_entry:read'])]
|
||||
private ?int $id = null;
|
||||
|
||||
#[ORM\Column(length: 255)]
|
||||
#[Groups(['task_type:read', 'task_type:write', 'task:read', 'time_entry:read'])]
|
||||
private ?string $label = null;
|
||||
|
||||
#[ORM\Column(length: 7)]
|
||||
#[Groups(['task_type:read', 'task_type:write', 'task:read', 'time_entry:read'])]
|
||||
private ?string $color = '#222783';
|
||||
|
||||
public function getId(): ?int
|
||||
{
|
||||
return $this->id;
|
||||
}
|
||||
|
||||
public function getLabel(): ?string
|
||||
{
|
||||
return $this->label;
|
||||
}
|
||||
|
||||
public function setLabel(string $label): static
|
||||
{
|
||||
$this->label = $label;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getColor(): ?string
|
||||
{
|
||||
return $this->color;
|
||||
}
|
||||
|
||||
public function setColor(string $color): static
|
||||
{
|
||||
$this->color = $color;
|
||||
|
||||
return $this;
|
||||
}
|
||||
}
|
||||
16
src/Exception/GiteaApiException.php
Normal file
16
src/Exception/GiteaApiException.php
Normal file
@@ -0,0 +1,16 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Exception;
|
||||
|
||||
use RuntimeException;
|
||||
use Throwable;
|
||||
|
||||
final class GiteaApiException extends RuntimeException
|
||||
{
|
||||
public function __construct(string $message, int $code = 0, ?Throwable $previous = null)
|
||||
{
|
||||
parent::__construct($message, $code, $previous);
|
||||
}
|
||||
}
|
||||
22
src/Repository/GiteaConfigurationRepository.php
Normal file
22
src/Repository/GiteaConfigurationRepository.php
Normal file
@@ -0,0 +1,22 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Repository;
|
||||
|
||||
use App\Entity\GiteaConfiguration;
|
||||
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||
use Doctrine\Persistence\ManagerRegistry;
|
||||
|
||||
class GiteaConfigurationRepository extends ServiceEntityRepository
|
||||
{
|
||||
public function __construct(ManagerRegistry $registry)
|
||||
{
|
||||
parent::__construct($registry, GiteaConfiguration::class);
|
||||
}
|
||||
|
||||
public function findSingleton(): ?GiteaConfiguration
|
||||
{
|
||||
return $this->findOneBy([]);
|
||||
}
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Repository;
|
||||
|
||||
use App\Entity\TaskType;
|
||||
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||
use Doctrine\Persistence\ManagerRegistry;
|
||||
|
||||
class TaskTypeRepository extends ServiceEntityRepository
|
||||
{
|
||||
public function __construct(ManagerRegistry $registry)
|
||||
{
|
||||
parent::__construct($registry, TaskType::class);
|
||||
}
|
||||
}
|
||||
238
src/Service/GiteaApiService.php
Normal file
238
src/Service/GiteaApiService.php
Normal file
@@ -0,0 +1,238 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Service;
|
||||
|
||||
use App\Entity\GiteaConfiguration;
|
||||
use App\Entity\Project;
|
||||
use App\Entity\Task;
|
||||
use App\Exception\GiteaApiException;
|
||||
use App\Repository\GiteaConfigurationRepository;
|
||||
use Symfony\Component\String\Slugger\AsciiSlugger;
|
||||
use Symfony\Component\String\Slugger\SluggerInterface;
|
||||
use Symfony\Contracts\HttpClient\Exception\ExceptionInterface;
|
||||
use Symfony\Contracts\HttpClient\HttpClientInterface;
|
||||
|
||||
final readonly class GiteaApiService
|
||||
{
|
||||
private SluggerInterface $slugger;
|
||||
|
||||
public function __construct(
|
||||
private HttpClientInterface $httpClient,
|
||||
private GiteaConfigurationRepository $configRepository,
|
||||
private TokenEncryptor $tokenEncryptor,
|
||||
) {
|
||||
$this->slugger = new AsciiSlugger('fr');
|
||||
}
|
||||
|
||||
public function testConnection(): bool
|
||||
{
|
||||
try {
|
||||
$this->request('GET', '/api/v1/version');
|
||||
|
||||
return true;
|
||||
} catch (GiteaApiException) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<array{full_name: string, name: string, owner: array{login: string}}>
|
||||
*/
|
||||
public function listRepositories(): array
|
||||
{
|
||||
$result = [];
|
||||
$page = 1;
|
||||
|
||||
do {
|
||||
$data = $this->request('GET', '/api/v1/repos/search', [
|
||||
'query' => ['page' => $page, 'limit' => 50],
|
||||
]);
|
||||
$result = array_merge($result, $data['data'] ?? []);
|
||||
++$page;
|
||||
} while (!empty($data['data']) && 50 === count($data['data']));
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
public function getDefaultBranch(Project $project): string
|
||||
{
|
||||
$this->assertProjectHasRepo($project);
|
||||
$data = $this->request('GET', sprintf(
|
||||
'/api/v1/repos/%s/%s',
|
||||
$project->getGiteaOwner(),
|
||||
$project->getGiteaRepo(),
|
||||
));
|
||||
|
||||
return $data['default_branch'] ?? 'main';
|
||||
}
|
||||
|
||||
public function createBranch(Project $project, Task $task, string $type, string $baseBranch): string
|
||||
{
|
||||
$this->assertProjectHasRepo($project);
|
||||
$branchName = $this->generateBranchName($task, $type);
|
||||
|
||||
$this->request('POST', sprintf(
|
||||
'/api/v1/repos/%s/%s/branches',
|
||||
$project->getGiteaOwner(),
|
||||
$project->getGiteaRepo(),
|
||||
), [
|
||||
'json' => [
|
||||
'new_branch_name' => $branchName,
|
||||
'old_branch_name' => $baseBranch,
|
||||
],
|
||||
]);
|
||||
|
||||
return $branchName;
|
||||
}
|
||||
|
||||
public function generateBranchName(Task $task, string $type): string
|
||||
{
|
||||
$project = $task->getProject();
|
||||
if (null === $project) {
|
||||
throw new GiteaApiException('Task has no project.');
|
||||
}
|
||||
|
||||
$slug = $this->slugger->slug($task->getTitle())->lower()->truncate(50)->toString();
|
||||
$slug = rtrim($slug, '-');
|
||||
|
||||
return sprintf('%s/%s-%d-%s', $type, $project->getCode(), $task->getNumber(), $slug);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<array{name: string, commit: array}>
|
||||
*/
|
||||
public function listBranches(Project $project, string $taskCode): array
|
||||
{
|
||||
$this->assertProjectHasRepo($project);
|
||||
|
||||
$allBranches = [];
|
||||
$page = 1;
|
||||
|
||||
do {
|
||||
$pageBranches = $this->request('GET', sprintf(
|
||||
'/api/v1/repos/%s/%s/branches',
|
||||
$project->getGiteaOwner(),
|
||||
$project->getGiteaRepo(),
|
||||
), [
|
||||
'query' => ['page' => $page, 'limit' => 50],
|
||||
]);
|
||||
$allBranches = array_merge($allBranches, $pageBranches);
|
||||
++$page;
|
||||
} while (!empty($pageBranches) && 50 === count($pageBranches));
|
||||
|
||||
$regex = sprintf('#^[^/]+/%s($|-.+)#', preg_quote($taskCode, '#'));
|
||||
|
||||
return array_values(array_filter($allBranches, static function (array $branch) use ($regex): bool {
|
||||
return 1 === preg_match($regex, $branch['name']);
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<array{sha: string, commit: array{message: string, author: array}, created: string}>
|
||||
*/
|
||||
public function listCommits(Project $project, string $branch): array
|
||||
{
|
||||
$this->assertProjectHasRepo($project);
|
||||
|
||||
return $this->request('GET', sprintf(
|
||||
'/api/v1/repos/%s/%s/commits',
|
||||
$project->getGiteaOwner(),
|
||||
$project->getGiteaRepo(),
|
||||
), [
|
||||
'query' => ['sha' => $branch, 'limit' => 30],
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<array{number: int, title: string, state: string, head: array, user: array, merged: bool}>
|
||||
*/
|
||||
public function listPullRequests(Project $project, string $taskCode): array
|
||||
{
|
||||
$this->assertProjectHasRepo($project);
|
||||
|
||||
$branches = $this->listBranches($project, $taskCode);
|
||||
$prs = [];
|
||||
|
||||
foreach ($branches as $branch) {
|
||||
$branchPrs = $this->request('GET', sprintf(
|
||||
'/api/v1/repos/%s/%s/pulls',
|
||||
$project->getGiteaOwner(),
|
||||
$project->getGiteaRepo(),
|
||||
), [
|
||||
'query' => ['state' => 'all', 'head' => $branch['name']],
|
||||
]);
|
||||
$prs = array_merge($prs, $branchPrs);
|
||||
}
|
||||
|
||||
// Fetch CI status for each PR
|
||||
foreach ($prs as &$pr) {
|
||||
$sha = $pr['head']['sha'] ?? null;
|
||||
if (null !== $sha) {
|
||||
try {
|
||||
$pr['ci_statuses'] = $this->request('GET', sprintf(
|
||||
'/api/v1/repos/%s/%s/commits/%s/statuses',
|
||||
$project->getGiteaOwner(),
|
||||
$project->getGiteaRepo(),
|
||||
$sha,
|
||||
));
|
||||
} catch (GiteaApiException) {
|
||||
$pr['ci_statuses'] = [];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $prs;
|
||||
}
|
||||
|
||||
private function getConfiguration(): GiteaConfiguration
|
||||
{
|
||||
$config = $this->configRepository->findSingleton();
|
||||
if (null === $config) {
|
||||
throw new GiteaApiException('Gitea is not configured.');
|
||||
}
|
||||
|
||||
return $config;
|
||||
}
|
||||
|
||||
private function getDecryptedToken(GiteaConfiguration $config): string
|
||||
{
|
||||
$encrypted = $config->getEncryptedToken();
|
||||
if (null === $encrypted) {
|
||||
throw new GiteaApiException('Gitea token is not set.');
|
||||
}
|
||||
|
||||
return $this->tokenEncryptor->decrypt($encrypted);
|
||||
}
|
||||
|
||||
private function assertProjectHasRepo(Project $project): void
|
||||
{
|
||||
if (!$project->hasGiteaRepo()) {
|
||||
throw new GiteaApiException('Project has no Gitea repository configured.');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $options
|
||||
*/
|
||||
private function request(string $method, string $path, array $options = []): array
|
||||
{
|
||||
$config = $this->getConfiguration();
|
||||
$token = $this->getDecryptedToken($config);
|
||||
|
||||
$options['headers'] = array_merge($options['headers'] ?? [], [
|
||||
'Authorization' => 'token '.$token,
|
||||
'Accept' => 'application/json',
|
||||
]);
|
||||
$options['timeout'] = 10;
|
||||
|
||||
try {
|
||||
$response = $this->httpClient->request($method, rtrim($config->getUrl(), '/').$path, $options);
|
||||
|
||||
return $response->toArray();
|
||||
} catch (ExceptionInterface $e) {
|
||||
throw new GiteaApiException('Gitea API error: '.$e->getMessage(), 0, $e);
|
||||
}
|
||||
}
|
||||
}
|
||||
52
src/Service/TokenEncryptor.php
Normal file
52
src/Service/TokenEncryptor.php
Normal file
@@ -0,0 +1,52 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Service;
|
||||
|
||||
use InvalidArgumentException;
|
||||
use RuntimeException;
|
||||
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||
|
||||
final readonly class TokenEncryptor
|
||||
{
|
||||
private string $key;
|
||||
|
||||
public function __construct(
|
||||
#[Autowire('%env(GITEA_ENCRYPTION_KEY)%')]
|
||||
string $encryptionKey,
|
||||
) {
|
||||
if ('' === $encryptionKey) {
|
||||
throw new InvalidArgumentException('GITEA_ENCRYPTION_KEY environment variable must be set.');
|
||||
}
|
||||
|
||||
$this->key = sodium_hex2bin($encryptionKey);
|
||||
|
||||
if (SODIUM_CRYPTO_SECRETBOX_KEYBYTES !== mb_strlen($this->key, '8bit')) {
|
||||
throw new InvalidArgumentException('GITEA_ENCRYPTION_KEY must be a valid sodium secret box key.');
|
||||
}
|
||||
}
|
||||
|
||||
public function encrypt(string $plaintext): string
|
||||
{
|
||||
$nonce = random_bytes(SODIUM_CRYPTO_SECRETBOX_NONCEBYTES);
|
||||
$ciphertext = sodium_crypto_secretbox($plaintext, $nonce, $this->key);
|
||||
|
||||
return sodium_bin2hex($nonce.$ciphertext);
|
||||
}
|
||||
|
||||
public function decrypt(string $encrypted): string
|
||||
{
|
||||
$decoded = sodium_hex2bin($encrypted);
|
||||
$nonce = mb_substr($decoded, 0, SODIUM_CRYPTO_SECRETBOX_NONCEBYTES, '8bit');
|
||||
$ciphertext = mb_substr($decoded, SODIUM_CRYPTO_SECRETBOX_NONCEBYTES, null, '8bit');
|
||||
|
||||
$plaintext = sodium_crypto_secretbox_open($ciphertext, $nonce, $this->key);
|
||||
|
||||
if (false === $plaintext) {
|
||||
throw new RuntimeException('Failed to decrypt token.');
|
||||
}
|
||||
|
||||
return $plaintext;
|
||||
}
|
||||
}
|
||||
42
src/State/GiteaBranchNameProvider.php
Normal file
42
src/State/GiteaBranchNameProvider.php
Normal file
@@ -0,0 +1,42 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\State;
|
||||
|
||||
use ApiPlatform\Metadata\Operation;
|
||||
use ApiPlatform\State\ProviderInterface;
|
||||
use App\ApiResource\GiteaBranchName;
|
||||
use App\Entity\Task;
|
||||
use App\Service\GiteaApiService;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
|
||||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||
|
||||
final readonly class GiteaBranchNameProvider implements ProviderInterface
|
||||
{
|
||||
private const array ALLOWED_TYPES = ['feature', 'fix', 'refactor', 'hotfix', 'chore'];
|
||||
|
||||
public function __construct(
|
||||
private GiteaApiService $giteaApiService,
|
||||
private EntityManagerInterface $em,
|
||||
) {}
|
||||
|
||||
public function provide(Operation $operation, array $uriVariables = [], array $context = []): GiteaBranchName
|
||||
{
|
||||
$task = $this->em->getRepository(Task::class)->find($uriVariables['taskId'] ?? 0);
|
||||
if (null === $task) {
|
||||
throw new NotFoundHttpException('Task not found.');
|
||||
}
|
||||
|
||||
$type = $uriVariables['type'] ?? 'feature';
|
||||
if (!in_array($type, self::ALLOWED_TYPES, true)) {
|
||||
throw new BadRequestHttpException('Invalid branch type.');
|
||||
}
|
||||
|
||||
$dto = new GiteaBranchName();
|
||||
$dto->name = $this->giteaApiService->generateBranchName($task, $type);
|
||||
|
||||
return $dto;
|
||||
}
|
||||
}
|
||||
55
src/State/GiteaBranchProcessor.php
Normal file
55
src/State/GiteaBranchProcessor.php
Normal file
@@ -0,0 +1,55 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\State;
|
||||
|
||||
use ApiPlatform\Metadata\Operation;
|
||||
use ApiPlatform\State\ProcessorInterface;
|
||||
use App\ApiResource\GiteaBranch;
|
||||
use App\Entity\Task;
|
||||
use App\Exception\GiteaApiException;
|
||||
use App\Service\GiteaApiService;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
|
||||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||
|
||||
final readonly class GiteaBranchProcessor implements ProcessorInterface
|
||||
{
|
||||
private const array ALLOWED_TYPES = ['feature', 'fix', 'refactor', 'hotfix', 'chore'];
|
||||
|
||||
public function __construct(
|
||||
private GiteaApiService $giteaApiService,
|
||||
private EntityManagerInterface $em,
|
||||
) {}
|
||||
|
||||
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): GiteaBranch
|
||||
{
|
||||
assert($data instanceof GiteaBranch);
|
||||
|
||||
$task = $this->em->getRepository(Task::class)->find($uriVariables['taskId'] ?? 0);
|
||||
if (null === $task || null === $task->getProject()) {
|
||||
throw new NotFoundHttpException('Task not found.');
|
||||
}
|
||||
|
||||
$project = $task->getProject();
|
||||
if (!$project->hasGiteaRepo()) {
|
||||
throw new BadRequestHttpException('Project has no Gitea repository.');
|
||||
}
|
||||
|
||||
if (!in_array($data->type, self::ALLOWED_TYPES, true)) {
|
||||
throw new BadRequestHttpException('Invalid branch type.');
|
||||
}
|
||||
|
||||
try {
|
||||
$branchName = $this->giteaApiService->createBranch($project, $task, $data->type, $data->baseBranch);
|
||||
} catch (GiteaApiException $e) {
|
||||
throw new BadRequestHttpException($e->getMessage());
|
||||
}
|
||||
|
||||
$result = new GiteaBranch();
|
||||
$result->name = $branchName;
|
||||
|
||||
return $result;
|
||||
}
|
||||
}
|
||||
69
src/State/GiteaBranchProvider.php
Normal file
69
src/State/GiteaBranchProvider.php
Normal file
@@ -0,0 +1,69 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\State;
|
||||
|
||||
use ApiPlatform\Metadata\Operation;
|
||||
use ApiPlatform\Metadata\Post;
|
||||
use ApiPlatform\State\ProviderInterface;
|
||||
use App\ApiResource\GiteaBranch;
|
||||
use App\Entity\Task;
|
||||
use App\Exception\GiteaApiException;
|
||||
use App\Service\GiteaApiService;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
|
||||
final readonly class GiteaBranchProvider implements ProviderInterface
|
||||
{
|
||||
public function __construct(
|
||||
private GiteaApiService $giteaApiService,
|
||||
private EntityManagerInterface $em,
|
||||
) {}
|
||||
|
||||
public function provide(Operation $operation, array $uriVariables = [], array $context = []): array|GiteaBranch
|
||||
{
|
||||
if ($operation instanceof Post) {
|
||||
return new GiteaBranch();
|
||||
}
|
||||
|
||||
$task = $this->em->getRepository(Task::class)->find($uriVariables['taskId'] ?? 0);
|
||||
if (null === $task || null === $task->getProject()) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$project = $task->getProject();
|
||||
if (!$project->hasGiteaRepo()) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$taskCode = $project->getCode().'-'.$task->getNumber();
|
||||
|
||||
try {
|
||||
$branches = $this->giteaApiService->listBranches($project, $taskCode);
|
||||
} catch (GiteaApiException) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$result = [];
|
||||
foreach ($branches as $branch) {
|
||||
$dto = new GiteaBranch();
|
||||
$dto->name = $branch['name'];
|
||||
|
||||
try {
|
||||
$commits = $this->giteaApiService->listCommits($project, $branch['name']);
|
||||
$dto->commits = array_map(static fn (array $c): array => [
|
||||
'sha' => substr($c['sha'] ?? '', 0, 7),
|
||||
'message' => $c['commit']['message'] ?? '',
|
||||
'author' => $c['commit']['author']['name'] ?? '',
|
||||
'date' => $c['commit']['author']['date'] ?? $c['created'] ?? '',
|
||||
], $commits);
|
||||
} catch (GiteaApiException) {
|
||||
$dto->commits = [];
|
||||
}
|
||||
|
||||
$result[] = $dto;
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
}
|
||||
60
src/State/GiteaPullRequestProvider.php
Normal file
60
src/State/GiteaPullRequestProvider.php
Normal file
@@ -0,0 +1,60 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\State;
|
||||
|
||||
use ApiPlatform\Metadata\Operation;
|
||||
use ApiPlatform\State\ProviderInterface;
|
||||
use App\ApiResource\GiteaPullRequest;
|
||||
use App\Entity\Task;
|
||||
use App\Exception\GiteaApiException;
|
||||
use App\Service\GiteaApiService;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
|
||||
final readonly class GiteaPullRequestProvider implements ProviderInterface
|
||||
{
|
||||
public function __construct(
|
||||
private GiteaApiService $giteaApiService,
|
||||
private EntityManagerInterface $em,
|
||||
) {}
|
||||
|
||||
public function provide(Operation $operation, array $uriVariables = [], array $context = []): array
|
||||
{
|
||||
$task = $this->em->getRepository(Task::class)->find($uriVariables['taskId'] ?? 0);
|
||||
if (null === $task || null === $task->getProject()) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$project = $task->getProject();
|
||||
if (!$project->hasGiteaRepo()) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$taskCode = $project->getCode().'-'.$task->getNumber();
|
||||
|
||||
try {
|
||||
$prs = $this->giteaApiService->listPullRequests($project, $taskCode);
|
||||
} catch (GiteaApiException) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return array_map(static function (array $pr): GiteaPullRequest {
|
||||
$dto = new GiteaPullRequest();
|
||||
$dto->number = $pr['number'] ?? 0;
|
||||
$dto->title = $pr['title'] ?? '';
|
||||
$dto->state = $pr['state'] ?? '';
|
||||
$dto->merged = $pr['merged'] ?? false;
|
||||
$dto->headBranch = $pr['head']['ref'] ?? '';
|
||||
$dto->author = $pr['user']['login'] ?? '';
|
||||
$dto->url = $pr['html_url'] ?? '';
|
||||
$dto->ciStatuses = array_map(static fn (array $s): array => [
|
||||
'context' => $s['context'] ?? '',
|
||||
'status' => $s['status'] ?? '',
|
||||
'target_url' => $s['target_url'] ?? '',
|
||||
], $pr['ci_statuses'] ?? []);
|
||||
|
||||
return $dto;
|
||||
}, $prs);
|
||||
}
|
||||
}
|
||||
36
src/State/GiteaRepositoryProvider.php
Normal file
36
src/State/GiteaRepositoryProvider.php
Normal file
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\State;
|
||||
|
||||
use ApiPlatform\Metadata\Operation;
|
||||
use ApiPlatform\State\ProviderInterface;
|
||||
use App\ApiResource\GiteaRepository;
|
||||
use App\Exception\GiteaApiException;
|
||||
use App\Service\GiteaApiService;
|
||||
|
||||
final readonly class GiteaRepositoryProvider implements ProviderInterface
|
||||
{
|
||||
public function __construct(
|
||||
private GiteaApiService $giteaApiService,
|
||||
) {}
|
||||
|
||||
public function provide(Operation $operation, array $uriVariables = [], array $context = []): array
|
||||
{
|
||||
try {
|
||||
$repos = $this->giteaApiService->listRepositories();
|
||||
} catch (GiteaApiException) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return array_map(static function (array $repo): GiteaRepository {
|
||||
$dto = new GiteaRepository();
|
||||
$dto->fullName = $repo['full_name'] ?? '';
|
||||
$dto->name = $repo['name'] ?? '';
|
||||
$dto->owner = $repo['owner']['login'] ?? '';
|
||||
|
||||
return $dto;
|
||||
}, $repos);
|
||||
}
|
||||
}
|
||||
47
src/State/GiteaSettingsProcessor.php
Normal file
47
src/State/GiteaSettingsProcessor.php
Normal file
@@ -0,0 +1,47 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\State;
|
||||
|
||||
use ApiPlatform\Metadata\Operation;
|
||||
use ApiPlatform\State\ProcessorInterface;
|
||||
use App\ApiResource\GiteaSettings;
|
||||
use App\Entity\GiteaConfiguration;
|
||||
use App\Repository\GiteaConfigurationRepository;
|
||||
use App\Service\TokenEncryptor;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
|
||||
final readonly class GiteaSettingsProcessor implements ProcessorInterface
|
||||
{
|
||||
public function __construct(
|
||||
private EntityManagerInterface $em,
|
||||
private GiteaConfigurationRepository $configRepository,
|
||||
private TokenEncryptor $tokenEncryptor,
|
||||
) {}
|
||||
|
||||
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): GiteaSettings
|
||||
{
|
||||
assert($data instanceof GiteaSettings);
|
||||
|
||||
$config = $this->configRepository->findSingleton();
|
||||
if (null === $config) {
|
||||
$config = new GiteaConfiguration();
|
||||
}
|
||||
|
||||
$config->setUrl($data->url);
|
||||
|
||||
if (null !== $data->token && '' !== $data->token) {
|
||||
$config->setEncryptedToken($this->tokenEncryptor->encrypt($data->token));
|
||||
}
|
||||
|
||||
$this->em->persist($config);
|
||||
$this->em->flush();
|
||||
|
||||
$result = new GiteaSettings();
|
||||
$result->url = $config->getUrl();
|
||||
$result->hasToken = $config->hasToken();
|
||||
|
||||
return $result;
|
||||
}
|
||||
}
|
||||
30
src/State/GiteaSettingsProvider.php
Normal file
30
src/State/GiteaSettingsProvider.php
Normal file
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\State;
|
||||
|
||||
use ApiPlatform\Metadata\Operation;
|
||||
use ApiPlatform\State\ProviderInterface;
|
||||
use App\ApiResource\GiteaSettings;
|
||||
use App\Repository\GiteaConfigurationRepository;
|
||||
|
||||
final readonly class GiteaSettingsProvider implements ProviderInterface
|
||||
{
|
||||
public function __construct(
|
||||
private GiteaConfigurationRepository $configRepository,
|
||||
) {}
|
||||
|
||||
public function provide(Operation $operation, array $uriVariables = [], array $context = []): GiteaSettings
|
||||
{
|
||||
$config = $this->configRepository->findSingleton();
|
||||
$dto = new GiteaSettings();
|
||||
|
||||
if (null !== $config) {
|
||||
$dto->url = $config->getUrl();
|
||||
$dto->hasToken = $config->hasToken();
|
||||
}
|
||||
|
||||
return $dto;
|
||||
}
|
||||
}
|
||||
31
src/State/GiteaTestConnectionProvider.php
Normal file
31
src/State/GiteaTestConnectionProvider.php
Normal file
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\State;
|
||||
|
||||
use ApiPlatform\Metadata\Operation;
|
||||
use ApiPlatform\State\ProcessorInterface;
|
||||
use ApiPlatform\State\ProviderInterface;
|
||||
use App\ApiResource\GiteaTestConnection;
|
||||
use App\Service\GiteaApiService;
|
||||
|
||||
final readonly class GiteaTestConnectionProvider implements ProviderInterface, ProcessorInterface
|
||||
{
|
||||
public function __construct(
|
||||
private GiteaApiService $giteaApiService,
|
||||
) {}
|
||||
|
||||
public function provide(Operation $operation, array $uriVariables = [], array $context = []): GiteaTestConnection
|
||||
{
|
||||
return new GiteaTestConnection();
|
||||
}
|
||||
|
||||
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): GiteaTestConnection
|
||||
{
|
||||
$result = new GiteaTestConnection();
|
||||
$result->success = $this->giteaApiService->testConnection();
|
||||
|
||||
return $result;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user