Compare commits

..

3 Commits

Author SHA1 Message Date
gitea-actions
4074457499 chore: bump version to v0.2.10
All checks were successful
Auto Tag Develop / tag (push) Successful in 4s
Build Release Artefact / build (push) Successful in 1m27s
2026-03-18 10:08:03 +00:00
Matthieu
b29b4d304d fix(user) : clear allowedProjects when removing ROLE_CLIENT
All checks were successful
Auto Tag Develop / tag (push) Successful in 6s
Prevents sending /api/projects/undefined when saving a user after
removing client role. Also auto-clears client and projects when
ROLE_CLIENT checkbox is unchecked.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 11:07:51 +01:00
Matthieu
dd9db93751 feat(project) : add delete button for empty projects with confirmation modal
Adds taskCount virtual field on Project entity, delete button in ProjectDrawer
(visible only when taskCount === 0), and a reusable ConfirmDeleteProjectModal.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 11:07:41 +01:00
8 changed files with 122 additions and 6 deletions

View File

@@ -1,2 +1,2 @@
parameters:
app.version: '0.2.9'
app.version: '0.2.10'

View File

@@ -64,7 +64,7 @@
</div>
</form>
<div v-if="isEditing && project" class="mt-6 border-t border-neutral-200 pt-4">
<div v-if="isEditing && project" class="mt-6 border-t border-neutral-200 pt-4 flex items-center justify-between">
<button
class="flex items-center gap-2 text-sm text-neutral-500 hover:text-amber-600"
:disabled="isSubmitting"
@@ -73,7 +73,21 @@
<Icon :name="project.archived ? 'mdi:archive-arrow-up-outline' : 'mdi:archive-arrow-down-outline'" size="18" />
{{ project.archived ? 'Désarchiver' : 'Archiver' }}
</button>
<button
v-if="project.taskCount === 0"
class="flex items-center gap-2 text-sm text-neutral-500 hover:text-red-600"
:disabled="isSubmitting"
@click="confirmDeleteOpen = true"
>
<Icon name="mdi:delete-outline" size="18" />
{{ $t('common.delete') }}
</button>
</div>
<ConfirmDeleteProjectModal
v-model="confirmDeleteOpen"
@confirm="handleDelete"
/>
</AppDrawer>
</template>
@@ -104,6 +118,7 @@ const isOpen = computed({
const isEditing = computed(() => !!props.project)
const isSubmitting = ref(false)
const confirmDeleteOpen = ref(false)
const { listRepositories } = useGiteaService()
const giteaRepos = ref<GiteaRepository[]>([])
@@ -164,7 +179,7 @@ watch(() => props.modelValue, (open) => {
}
})
const { create, update } = useProjectService()
const { create, update, remove } = useProjectService()
async function handleSubmit() {
touched.name = true
@@ -213,6 +228,19 @@ async function handleSubmit() {
}
}
async function handleDelete() {
if (!props.project) return
isSubmitting.value = true
try {
await remove(props.project.id)
emit('saved')
isOpen.value = false
} finally {
confirmDeleteOpen.value = false
isSubmitting.value = false
}
}
async function handleArchiveToggle() {
if (!props.project) return
isSubmitting.value = true

View File

@@ -0,0 +1,58 @@
<template>
<Teleport v-if="modelValue" to="body">
<Transition name="modal" appear>
<div class="fixed inset-0 z-50 flex items-center justify-center">
<div class="absolute inset-0 bg-black/30" @click="cancel" />
<div class="relative z-10 w-full max-w-md rounded-lg bg-white p-6 shadow-xl">
<h3 class="text-lg font-bold text-neutral-900">{{ $t('projects.deleteConfirmTitle') }}</h3>
<p class="mt-3 text-sm text-neutral-600">
{{ $t('projects.deleteConfirmMessage') }}
</p>
<div class="mt-6 flex justify-end gap-3">
<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"
@click="cancel"
>
{{ $t('common.cancel') }}
</button>
<button
type="button"
class="rounded-md bg-red-600 px-4 py-2 text-sm font-semibold text-white hover:bg-red-700"
@click="$emit('confirm')"
>
{{ $t('common.delete') }}
</button>
</div>
</div>
</div>
</Transition>
</Teleport>
</template>
<script setup lang="ts">
defineProps<{
modelValue: boolean
}>()
const emit = defineEmits<{
(e: 'update:modelValue', value: boolean): void
(e: 'confirm'): void
}>()
function cancel() {
emit('update:modelValue', false)
}
</script>
<style scoped>
.modal-enter-active,
.modal-leave-active {
transition: opacity 0.2s ease;
}
.modal-enter-from,
.modal-leave-to {
opacity: 0;
}
</style>

View File

@@ -148,6 +148,13 @@ function onClientChange(value: number | null) {
}
}
watch(() => form.roles, (roles) => {
if (!roles.includes('ROLE_CLIENT')) {
form.clientId = null
form.allowedProjectIds = []
}
})
watch(() => props.modelValue, async (open) => {
if (open) {
if (props.item) {
@@ -189,7 +196,9 @@ async function handleSubmit() {
username: form.username.trim(),
roles: form.roles,
client: form.clientId !== null ? `/api/clients/${form.clientId}` : null,
allowedProjects: form.allowedProjectIds.map((id) => `/api/projects/${id}`),
allowedProjects: form.clientId !== null
? form.allowedProjectIds.map((id) => `/api/projects/${id}`)
: [],
}
if (form.password) {
payload.plainPassword = form.password

View File

@@ -39,7 +39,10 @@
"noArchivedProjects": "Aucun projet archivé.",
"addProject": "Ajouter un projet",
"addProjectShort": "Projet",
"editProject": "Modifier un projet"
"editProject": "Modifier un projet",
"deleteConfirmTitle": "Supprimer le projet",
"deleteConfirmMessage": "Êtes-vous sûr de vouloir supprimer ce projet ? Cette action est irréversible.",
"cannotDelete": "Impossible de supprimer un projet contenant des tickets."
},
"taskStatuses": {
"created": "Statut créé avec succès.",

View File

@@ -13,6 +13,7 @@ export type Project = {
bookstackShelfId: number | null
bookstackShelfName: string | null
archived: boolean
taskCount: number
}
export type ProjectWrite = {

View File

@@ -13,6 +13,8 @@ use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Patch;
use ApiPlatform\Metadata\Post;
use App\Repository\ProjectRepository;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity;
use Symfony\Component\Serializer\Attribute\Groups;
@@ -87,6 +89,15 @@ class Project
#[Groups(['project:read', 'project:write'])]
private bool $archived = false;
/** @var Collection<int, Task> */
#[ORM\OneToMany(targetEntity: Task::class, mappedBy: 'project')]
private Collection $tasks;
public function __construct()
{
$this->tasks = new ArrayCollection();
}
public function getId(): ?int
{
return $this->id;
@@ -216,4 +227,10 @@ class Project
return $this;
}
#[Groups(['project:read'])]
public function getTaskCount(): int
{
return $this->tasks->count();
}
}

View File

@@ -82,7 +82,7 @@ class Task
#[Groups(['task:read', 'task:write'])]
private ?TaskGroup $group = null;
#[ORM\ManyToOne(targetEntity: Project::class)]
#[ORM\ManyToOne(targetEntity: Project::class, inversedBy: 'tasks')]
#[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')]
#[Groups(['task:read', 'task:write'])]
private ?Project $project = null;