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_ENV=dev
|
||||||
APP_SECRET=
|
APP_SECRET="a64f5614357bf56aecb1d7470e431535"
|
||||||
APP_SHARE_DIR=var/share
|
APP_DEBUG=1
|
||||||
###< symfony/framework-bundle ###
|
|
||||||
|
|
||||||
###> symfony/routing ###
|
DEFAULT_URI=http://localhost/
|
||||||
# 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 ###
|
|
||||||
|
|
||||||
###> nelmio/cors-bundle ###
|
###> 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 ###
|
###< nelmio/cors-bundle ###
|
||||||
|
|
||||||
###> lexik/jwt-authentication-bundle ###
|
###> lexik/jwt-authentication-bundle ###
|
||||||
JWT_SECRET_KEY=%kernel.project_dir%/config/jwt/private.pem
|
JWT_SECRET_KEY=%kernel.project_dir%/config/jwt/private.pem
|
||||||
JWT_PUBLIC_KEY=%kernel.project_dir%/config/jwt/public.pem
|
JWT_PUBLIC_KEY=%kernel.project_dir%/config/jwt/public.pem
|
||||||
JWT_PASSPHRASE=
|
JWT_PASSPHRASE=c2dbeec8fa8255bdab24e88b9fc1e57927740c429ae3b930d03e51b92e13a85f
|
||||||
JWT_COOKIE_SECURE=0
|
JWT_COOKIE_SECURE=0
|
||||||
JWT_TOKEN_TTL=86400
|
JWT_TOKEN_TTL=86400
|
||||||
JWT_COOKIE_TTL=86400
|
JWT_COOKIE_TTL=86400
|
||||||
###< lexik/jwt-authentication-bundle ###
|
###< 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
|
## 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/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/Repository/ # Repositories Doctrine
|
||||||
src/DataFixtures/ # Fixtures
|
src/DataFixtures/ # Fixtures
|
||||||
config/ # Config Symfony (security, api_platform, lexik_jwt, nelmio_cors, doctrine)
|
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
|
migrations/ # Migrations Doctrine
|
||||||
docs/plans/ # Plans d'implémentation
|
docs/plans/ # Plans d'implémentation
|
||||||
frontend/ # App Nuxt 4
|
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/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/composables/# Composables (useApi, useAppVersion)
|
||||||
frontend/stores/ # Stores Pinia (auth, ui, timer)
|
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/services/dto/ # Types TypeScript
|
||||||
frontend/i18n/locales/ # Fichiers de traduction (langDir résolu depuis i18n/)
|
frontend/i18n/locales/ # Fichiers de traduction (langDir résolu depuis i18n/)
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -22,6 +22,7 @@
|
|||||||
"symfony/expression-language": "8.0.*",
|
"symfony/expression-language": "8.0.*",
|
||||||
"symfony/flex": "^2",
|
"symfony/flex": "^2",
|
||||||
"symfony/framework-bundle": "8.0.*",
|
"symfony/framework-bundle": "8.0.*",
|
||||||
|
"symfony/http-client": "8.0.*",
|
||||||
"symfony/property-access": "8.0.*",
|
"symfony/property-access": "8.0.*",
|
||||||
"symfony/property-info": "8.0.*",
|
"symfony/property-info": "8.0.*",
|
||||||
"symfony/runtime": "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",
|
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
||||||
"This file is @generated automatically"
|
"This file is @generated automatically"
|
||||||
],
|
],
|
||||||
"content-hash": "9482fc27494f618b2bae1b7f250e8326",
|
"content-hash": "4790d8c80c0fb208e5af11fb205c0202",
|
||||||
"packages": [
|
"packages": [
|
||||||
{
|
{
|
||||||
"name": "api-platform/doctrine-common",
|
"name": "api-platform/doctrine-common",
|
||||||
@@ -4618,6 +4618,180 @@
|
|||||||
],
|
],
|
||||||
"time": "2026-03-06T15:40:00+00:00"
|
"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",
|
"name": "symfony/http-foundation",
|
||||||
"version": "v8.0.7",
|
"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é
|
## Résumé
|
||||||
|
|
||||||
@@ -35,6 +35,7 @@ Une migration Doctrine unique pour les 3 champs (`task_status.is_final`, `task.a
|
|||||||
### Archivage de groupe (bulk)
|
### Archivage de groupe (bulk)
|
||||||
|
|
||||||
L'archivage d'un groupe est une opération frontend multi-appels :
|
L'archivage d'un groupe est une opération frontend multi-appels :
|
||||||
|
|
||||||
1. PATCH chaque ticket du groupe avec `{ archived: true }`
|
1. PATCH chaque ticket du groupe avec `{ archived: true }`
|
||||||
2. PATCH le 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
|
### TaskDrawer — archivage et modale suppression
|
||||||
|
|
||||||
**Bouton "Archiver"** :
|
**Bouton "Archiver"** :
|
||||||
|
|
||||||
- Visible uniquement quand le ticket a un statut avec `isFinal: true`
|
- Visible uniquement quand le ticket a un statut avec `isFinal: true`
|
||||||
- PATCH `{ archived: true }` sur le ticket
|
- PATCH `{ archived: true }` sur le ticket
|
||||||
- Si un timer est actif sur ce ticket, l'arrêter avant d'archiver
|
- Si un timer est actif sur ce ticket, l'arrêter avant d'archiver
|
||||||
- Ferme le drawer et rafraîchit la liste des tickets
|
- Ferme le drawer et rafraîchit la liste des tickets
|
||||||
|
|
||||||
**Bouton "Désarchiver"** :
|
**Bouton "Désarchiver"** :
|
||||||
|
|
||||||
- Visible quand on consulte un ticket archivé (depuis la page archives)
|
- Visible quand on consulte un ticket archivé (depuis la page archives)
|
||||||
- PATCH `{ archived: false }`
|
- PATCH `{ archived: false }`
|
||||||
- Ferme le drawer et rafraîchit la page archives
|
- Ferme le drawer et rafraîchit la page archives
|
||||||
|
|
||||||
**Modale de confirmation de suppression** :
|
**Modale de confirmation de suppression** :
|
||||||
|
|
||||||
- Déclenchée au clic sur "Supprimer" dans le TaskDrawer
|
- 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."
|
- Message : "Êtes-vous sûr de vouloir supprimer ce ticket ? Cette action est irréversible."
|
||||||
- Deux boutons : "Annuler" / "Supprimer" (style destructif, rouge)
|
- 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.
|
**Toggle "Voir les groupes archivés"** : bascule pour afficher les groupes archivés.
|
||||||
|
|
||||||
**Bouton "Archiver" sur un groupe** :
|
**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)
|
- 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)
|
- Archive tous les tickets du groupe puis le groupe lui-même (appels PATCH séquentiels)
|
||||||
- Rafraîchit la liste
|
- Rafraîchit la liste
|
||||||
|
|
||||||
**Bouton "Désarchiver" sur un groupe archivé** :
|
**Bouton "Désarchiver" sur un groupe archivé** :
|
||||||
|
|
||||||
- Désarchive le groupe + tous ses tickets (écrase l'état individuel des tickets)
|
- Désarchive le groupe + tous ses tickets (écrase l'état individuel des tickets)
|
||||||
- Rafraîchit la liste
|
- Rafraîchit la liste
|
||||||
|
|
||||||
@@ -123,6 +129,7 @@ Ajout du champ `archived: boolean` dans les types `TaskGroup` et `TaskGroupWrite
|
|||||||
## Traductions (i18n)
|
## Traductions (i18n)
|
||||||
|
|
||||||
Clés à ajouter dans `fr.json` :
|
Clés à ajouter dans `fr.json` :
|
||||||
|
|
||||||
- `task.archive` / `task.unarchive`
|
- `task.archive` / `task.unarchive`
|
||||||
- `task.delete_confirm_title` / `task.delete_confirm_message`
|
- `task.delete_confirm_title` / `task.delete_confirm_message`
|
||||||
- `group.archive` / `group.unarchive`
|
- `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" />
|
<ColorPicker v-model="form.color" />
|
||||||
</div>
|
</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">
|
<div class="mt-6 flex justify-end">
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
@@ -49,7 +59,9 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { Project, ProjectWrite } from '~/services/dto/project'
|
import type { Project, ProjectWrite } from '~/services/dto/project'
|
||||||
import type { Client } from '~/services/dto/client'
|
import type { Client } from '~/services/dto/client'
|
||||||
|
import type { GiteaRepository } from '~/services/dto/gitea'
|
||||||
import { useProjectService } from '~/services/projects'
|
import { useProjectService } from '~/services/projects'
|
||||||
|
import { useGiteaService } from '~/services/gitea'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
modelValue: boolean
|
modelValue: boolean
|
||||||
@@ -70,12 +82,20 @@ const isOpen = computed({
|
|||||||
const isEditing = computed(() => !!props.project)
|
const isEditing = computed(() => !!props.project)
|
||||||
const isSubmitting = ref(false)
|
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({
|
const form = reactive({
|
||||||
code: '',
|
code: '',
|
||||||
name: '',
|
name: '',
|
||||||
description: '',
|
description: '',
|
||||||
color: '#222783',
|
color: '#222783',
|
||||||
clientId: null as number | null,
|
clientId: null as number | null,
|
||||||
|
giteaRepoFullName: null as string | null,
|
||||||
})
|
})
|
||||||
|
|
||||||
const touched = reactive({
|
const touched = reactive({
|
||||||
@@ -95,12 +115,16 @@ watch(() => props.modelValue, (open) => {
|
|||||||
form.description = props.project.description ?? ''
|
form.description = props.project.description ?? ''
|
||||||
form.color = props.project.color ?? '#222783'
|
form.color = props.project.color ?? '#222783'
|
||||||
form.clientId = props.project.client?.id ?? null
|
form.clientId = props.project.client?.id ?? null
|
||||||
|
form.giteaRepoFullName = props.project?.giteaOwner && props.project?.giteaRepo
|
||||||
|
? `${props.project.giteaOwner}/${props.project.giteaRepo}`
|
||||||
|
: null
|
||||||
} else {
|
} else {
|
||||||
form.code = ''
|
form.code = ''
|
||||||
form.name = ''
|
form.name = ''
|
||||||
form.description = ''
|
form.description = ''
|
||||||
form.color = '#222783'
|
form.color = '#222783'
|
||||||
form.clientId = null
|
form.clientId = null
|
||||||
|
form.giteaRepoFullName = null
|
||||||
}
|
}
|
||||||
touched.code = false
|
touched.code = false
|
||||||
touched.name = false
|
touched.name = false
|
||||||
@@ -124,6 +148,15 @@ async function handleSubmit() {
|
|||||||
client: form.clientId ? `/api/clients/${form.clientId}` : null,
|
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) {
|
if (isEditing.value && props.project) {
|
||||||
await update(props.project.id, payload)
|
await update(props.project.id, payload)
|
||||||
} else {
|
} else {
|
||||||
@@ -137,4 +170,12 @@ async function handleSubmit() {
|
|||||||
isSubmitting.value = false
|
isSubmitting.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
try {
|
||||||
|
giteaRepos.value = await listRepositories()
|
||||||
|
} catch {
|
||||||
|
// Gitea not configured, ignore
|
||||||
|
}
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -62,6 +62,7 @@
|
|||||||
v-model="drawerOpen"
|
v-model="drawerOpen"
|
||||||
:group="selectedItem"
|
:group="selectedItem"
|
||||||
:project-id="projectId"
|
:project-id="projectId"
|
||||||
|
:tasks="[...activeTasks, ...archivedTasks]"
|
||||||
@saved="onSaved"
|
@saved="onSaved"
|
||||||
/>
|
/>
|
||||||
</div>
|
</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" />
|
<ColorPicker v-model="form.color" />
|
||||||
</div>
|
</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
|
<button
|
||||||
type="submit"
|
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"
|
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">
|
<script setup lang="ts">
|
||||||
import type { TaskGroup, TaskGroupWrite } from '~/services/dto/task-group'
|
import type { TaskGroup, TaskGroupWrite } from '~/services/dto/task-group'
|
||||||
|
import type { Task } from '~/services/dto/task'
|
||||||
import { useTaskGroupService } from '~/services/task-groups'
|
import { useTaskGroupService } from '~/services/task-groups'
|
||||||
|
import { useTaskService } from '~/services/tasks'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
modelValue: boolean
|
modelValue: boolean
|
||||||
group: TaskGroup | null
|
group: TaskGroup | null
|
||||||
projectId: number
|
projectId: number
|
||||||
|
tasks?: Task[]
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
@@ -79,6 +107,51 @@ watch(() => props.modelValue, (open) => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const { create, update } = useTaskGroupService()
|
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() {
|
async function handleSubmit() {
|
||||||
touched.title = true
|
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>
|
||||||
</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 -->
|
<!-- Drag ghost preview -->
|
||||||
<div
|
<div
|
||||||
v-if="dragState && dragState.targetDayIndex === dayIndex"
|
v-if="dragState && dragState.targetDayIndex === dayIndex"
|
||||||
@@ -154,6 +166,27 @@ const gridBodyEl = ref<HTMLElement | null>(null)
|
|||||||
const dayColumnEls = ref<HTMLElement[]>([])
|
const dayColumnEls = ref<HTMLElement[]>([])
|
||||||
const stickyOffset = computed(() => props.stickyOffset ?? 0)
|
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 {
|
function getScrollParent(): HTMLElement | null {
|
||||||
let el = calendarEl.value?.parentElement
|
let el = calendarEl.value?.parentElement
|
||||||
while (el) {
|
while (el) {
|
||||||
|
|||||||
@@ -83,6 +83,72 @@
|
|||||||
"showArchived": "Voir les groupes archivés",
|
"showArchived": "Voir les groupes archivés",
|
||||||
"hideArchived": "Masquer les groupes archivés",
|
"hideArchived": "Masquer les groupes archivés",
|
||||||
"statusFinal": "Statut final",
|
"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"
|
:collapsed="ui.sidebarCollapsed"
|
||||||
:class="ui.sidebarCollapsed ? 'mt-4' : 'border-t border-secondary-500 pt-6'"
|
: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
|
<SidebarLink
|
||||||
to="/projects"
|
to="/projects"
|
||||||
icon="mdi:folder-outline"
|
icon="mdi:folder-outline"
|
||||||
|
|||||||
@@ -25,6 +25,7 @@
|
|||||||
<AdminPriorityTab v-if="activeTab === 'priorities'" />
|
<AdminPriorityTab v-if="activeTab === 'priorities'" />
|
||||||
<AdminTagTab v-if="activeTab === 'tags'" />
|
<AdminTagTab v-if="activeTab === 'tags'" />
|
||||||
<AdminUserTab v-if="activeTab === 'users'" />
|
<AdminUserTab v-if="activeTab === 'users'" />
|
||||||
|
<AdminGiteaTab v-if="activeTab === 'gitea'" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -39,6 +40,7 @@ const tabs = [
|
|||||||
{ key: 'priorities', label: 'Priorités' },
|
{ key: 'priorities', label: 'Priorités' },
|
||||||
{ key: 'tags', label: 'Tags' },
|
{ key: 'tags', label: 'Tags' },
|
||||||
{ key: 'users', label: 'Utilisateurs' },
|
{ key: 'users', label: 'Utilisateurs' },
|
||||||
|
{ key: 'gitea', label: 'Gitea' },
|
||||||
] as const
|
] as const
|
||||||
|
|
||||||
type TabKey = typeof tabs[number]['key']
|
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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<TaskDrawer
|
<TaskModal
|
||||||
v-model="taskDrawerOpen"
|
v-model="taskDrawerOpen"
|
||||||
:task="selectedTask"
|
:task="selectedTask"
|
||||||
:project-id="projectId"
|
:project-id="projectId"
|
||||||
|
|||||||
@@ -10,13 +10,42 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mt-4">
|
<div class="mt-4 flex flex-wrap gap-3">
|
||||||
<MalioSelect
|
<MalioSelect
|
||||||
v-model="selectedGroupId"
|
v-model="selectedGroupId"
|
||||||
:options="groupFilterOptions"
|
:options="groupFilterOptions"
|
||||||
label="Groupe"
|
label="Groupe"
|
||||||
empty-option-label="Tous les groupes"
|
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>
|
</div>
|
||||||
|
|
||||||
@@ -116,7 +145,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<TaskDrawer
|
<TaskModal
|
||||||
v-model="taskDrawerOpen"
|
v-model="taskDrawerOpen"
|
||||||
:task="selectedTask"
|
:task="selectedTask"
|
||||||
:project-id="projectId"
|
:project-id="projectId"
|
||||||
@@ -175,6 +204,9 @@ const users = ref<UserData[]>([])
|
|||||||
const isLoading = ref(true)
|
const isLoading = ref(true)
|
||||||
|
|
||||||
const selectedGroupId = ref<number | null>(null)
|
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 dragOverStatusId = ref<number | null>(null)
|
||||||
const dragCounter = ref(0)
|
const dragCounter = ref(0)
|
||||||
const taskDrawerOpen = ref(false)
|
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 }))
|
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(() => {
|
const filteredTasks = computed(() => {
|
||||||
let result = tasks.value.filter(t => !t.archived)
|
let result = tasks.value.filter(t => !t.archived)
|
||||||
if (selectedGroupId.value) {
|
if (selectedGroupId.value) {
|
||||||
result = result.filter(t => t.group?.id === 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
|
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
|
description: string | null
|
||||||
color: string
|
color: string
|
||||||
client: Client | null
|
client: Client | null
|
||||||
|
giteaOwner: string | null
|
||||||
|
giteaRepo: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ProjectWrite = {
|
export type ProjectWrite = {
|
||||||
@@ -16,4 +18,6 @@ export type ProjectWrite = {
|
|||||||
description: string | null
|
description: string | null
|
||||||
color: string
|
color: string
|
||||||
client: string | null // IRI : "/api/clients/1" ou null
|
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)
|
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> {
|
async function create(payload: TaskWrite): Promise<Task> {
|
||||||
return api.post<Task>('/tasks', payload as Record<string, unknown>, {
|
return api.post<Task>('/tasks', payload as Record<string, unknown>, {
|
||||||
toastSuccessKey: 'tasks.created',
|
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\Id]
|
||||||
#[ORM\GeneratedValue]
|
#[ORM\GeneratedValue]
|
||||||
#[ORM\Column]
|
#[ORM\Column]
|
||||||
#[Groups(['project:read', 'time_entry:read'])]
|
#[Groups(['project:read', 'time_entry:read', 'task:read'])]
|
||||||
private ?int $id = null;
|
private ?int $id = null;
|
||||||
|
|
||||||
#[ORM\Column(length: 10, unique: true)]
|
#[ORM\Column(length: 10, unique: true)]
|
||||||
@@ -64,6 +64,14 @@ class Project
|
|||||||
#[Groups(['project:read', 'project:write'])]
|
#[Groups(['project:read', 'project:write'])]
|
||||||
private ?Client $client = null;
|
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
|
public function getId(): ?int
|
||||||
{
|
{
|
||||||
return $this->id;
|
return $this->id;
|
||||||
@@ -128,4 +136,33 @@ class Project
|
|||||||
|
|
||||||
return $this;
|
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(
|
#[ApiResource(
|
||||||
operations: [
|
operations: [
|
||||||
new GetCollection(),
|
new GetCollection(paginationEnabled: false),
|
||||||
new Get(),
|
new Get(),
|
||||||
new Post(security: "is_granted('ROLE_ADMIN')", processor: TaskNumberProcessor::class),
|
new Post(security: "is_granted('ROLE_ADMIN')", processor: TaskNumberProcessor::class),
|
||||||
new Patch(security: "is_granted('ROLE_ADMIN')"),
|
new Patch(security: "is_granted('ROLE_ADMIN')"),
|
||||||
@@ -32,7 +32,7 @@ use Symfony\Component\Serializer\Attribute\Groups;
|
|||||||
denormalizationContext: ['groups' => ['task:write']],
|
denormalizationContext: ['groups' => ['task:write']],
|
||||||
order: ['id' => 'DESC'],
|
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'])]
|
#[ApiFilter(BooleanFilter::class, properties: ['archived'])]
|
||||||
#[ORM\Entity(repositoryClass: TaskRepository::class)]
|
#[ORM\Entity(repositoryClass: TaskRepository::class)]
|
||||||
class Task
|
class Task
|
||||||
|
|||||||
@@ -92,7 +92,7 @@ class TaskStatus
|
|||||||
return $this;
|
return $this;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function isFinal(): bool
|
public function getIsFinal(): bool
|
||||||
{
|
{
|
||||||
return $this->isFinal;
|
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