Bovins Admin #29

Merged
tristan merged 5 commits from feat/bovin-admin into develop 2026-02-17 13:22:30 +00:00
7 changed files with 267 additions and 17 deletions

View File

@@ -47,7 +47,8 @@ Ajouter dans le fichier .env du frontend
* [#326] Admin modification creation client * [#326] Admin modification creation client
* [#325] Correction diverses * [#325] Correction diverses
* fix layout admin * fix layout admin
* Creation page admin listing bovins
* Creation page admin ajout/modification bovins
### Changed ### Changed
### Fixed ### Fixed

View File

@@ -31,8 +31,8 @@
:href="href" :href="href"
@click="navigate" @click="navigate"
:class="route.path === '/' :class="route.path === '/'
? 'opacity-100' ? 'opacity-100'
: 'opacity-65 hover:opacity-100 transition'" : 'opacity-65 hover:opacity-100 transition'"
> >
Accueil Accueil
</a> </a>
@@ -48,8 +48,8 @@
:href="href" :href="href"
@click="navigate" @click="navigate"
:class="route.path.startsWith('/admin/supplier') :class="route.path.startsWith('/admin/supplier')
? 'opacity-100' ? 'opacity-100'
: 'opacity-65 hover:opacity-100 transition'" : 'opacity-65 hover:opacity-100 transition'"
> >
Fournisseurs Fournisseurs
</a> </a>
@@ -65,8 +65,8 @@
:href="href" :href="href"
@click="navigate" @click="navigate"
:class="route.path.startsWith('/admin/carrier') :class="route.path.startsWith('/admin/carrier')
? 'opacity-100' ? 'opacity-100'
: 'opacity-65 hover:opacity-100 transition'" : 'opacity-65 hover:opacity-100 transition'"
> >
Transporteurs Transporteurs
</a> </a>
@@ -82,8 +82,8 @@
:href="href" :href="href"
@click="navigate" @click="navigate"
:class="route.path.startsWith('/admin/user') :class="route.path.startsWith('/admin/user')
? 'opacity-100' ? 'opacity-100'
: 'opacity-65 hover:opacity-100 transition'" : 'opacity-65 hover:opacity-100 transition'"
> >
Utilisateurs Utilisateurs
</a> </a>
@@ -99,12 +99,29 @@
:href="href" :href="href"
@click="navigate" @click="navigate"
:class="route.path.startsWith('/admin/customer') :class="route.path.startsWith('/admin/customer')
? 'opacity-100' ? 'opacity-100'
: 'opacity-65 hover:opacity-100 transition'" : 'opacity-65 hover:opacity-100 transition'"
> >
Clients Clients
</a> </a>
</NuxtLink> </NuxtLink>
<NuxtLink
v-if="auth.isAdmin"
to="/admin/bovin/list"
custom
v-slot="{ href, navigate }"
>
<a
:href="href"
@click="navigate"
:class="route.path.startsWith('/admin/bovin')
? 'opacity-100'
: 'opacity-65 hover:opacity-100 transition'"
>
Bovins
</a>
</NuxtLink>
</nav> </nav>
<!-- Spacer mobile (pour centrer visuellement le header si besoin) --> <!-- Spacer mobile (pour centrer visuellement le header si besoin) -->
@@ -118,7 +135,8 @@
aria-haspopup="true" aria-haspopup="true"
> >
<span class="capitalize font-bold">{{ userDisplayName }}</span> <span class="capitalize font-bold">{{ userDisplayName }}</span>
<span class="ml-[6px] inline-flex items-center font-bold transition-transform group-hover:rotate-180 group-focus-within:rotate-180"> <span
class="ml-[6px] inline-flex items-center font-bold transition-transform group-hover:rotate-180 group-focus-within:rotate-180">
<Icon name="mdi:chevron-down" size="20"/> <Icon name="mdi:chevron-down" size="20"/>
</span> </span>
</button> </button>
@@ -196,6 +214,9 @@
<NuxtLink v-if="auth.isAdmin" to="/admin/customer/customer-list" @click="closeMenu"> <NuxtLink v-if="auth.isAdmin" to="/admin/customer/customer-list" @click="closeMenu">
Clients Clients
</NuxtLink> </NuxtLink>
<NuxtLink v-if="auth.isAdmin" to="/admin/bovin/list" @click="closeMenu">
Bovins
</NuxtLink>
</nav> </nav>
<button <button

View File

@@ -0,0 +1,104 @@
<template>
<form @submit.prevent="validate">
<div class="text-primary-500 flex items-center justify-between">
<h1 class="text-3xl font-bold uppercase">
{{ route.params.id ? 'Modifier bovin' : 'Ajout bovin' }}
</h1>
<UiButton
type="submit"
:disabled="isLoading || isHydrating"
class="text-xl uppercase bg-primary-500 text-white h-[50px] w-[272px] hover:opacity-80"
>
<Icon name="mdi:check" size="28" />
{{ isEdit ? 'Modifier' : 'Ajouter' }}
</UiButton>
</div>
<div class="grid grid-cols-2 items-start gap-y-8 gap-x-40 py-12">
<UiTextInput label="Nom du bovin" id="bovin-label" v-model="form.label" />
<UiTextInput label="Code bovin" id="code-id" v-model="form.code" />
</div>
</form>
</template>
<script setup lang="ts">
import {createBovin, getBovin, updateBovin} from "~/services/bovine-type";
import type {BovineTypeData, BovinFormData} from "~/services/dto/bovine-type-data";
const router = useRouter()
const route = useRoute()
const isLoading = ref(false)
const isHydrating = ref(false)
function resolveId(param: unknown) {
const idStr = Array.isArray(param) ? param[0] : param
if (!idStr) return null
const id = Number(idStr)
return Number.isFinite(id) ? id : null
}
const idBovin = computed(() => resolveId(route.params.id))
const isEdit = computed(() => idBovin.value !== null)
const form = reactive<BovinFormData>({
label: '',
code: ''
})
const hydrateFromBovin = (bovin: BovineTypeData | null) => {
if (!bovin) {
return
}
isHydrating.value = true
form.label = bovin.label ?? ''
form.code = bovin.code ?? ''
isHydrating.value = false
}
watch(
() => idBovin.value,
async (id) => {
if (id === null) {
return
}
isLoading.value = true
try {
const bovin = await getBovin(id)
hydrateFromBovin(bovin)
} finally {
isLoading.value = false
}
},
{immediate: true}
)
async function validate() {
if (isLoading.value || isHydrating.value) return
const normalizedBovinCode = form.code.trim()
const normalizedBovinLabel = form.label.trim()
const basePayload = {
label: normalizedBovinLabel,
code: normalizedBovinCode
}
isLoading.value = true
try {
if (isEdit.value && idBovin.value !== null) {
await updateBovin(idBovin.value, basePayload)
} else {
await createBovin(basePayload)
}
await navigate()
} finally {
isLoading.value = false
}
}
async function navigate(){
return router.push("/admin/bovin/list")
}
</script>

View File

@@ -0,0 +1,72 @@
<template>
<div class="flex items-center justify-between">
<h1 class="text-3xl font-bold text-primary-500 uppercase">Liste des types bovins</h1>
<NuxtLink
to="/admin/bovin"
class="inline-flex items-center justify-center
text-xl text-white uppercase
bg-primary-500 h-[50px] px-8 rounded
hover:opacity-80 gap-2"
@click="handleAddClick"
>
<Icon name="mdi:plus" size="28" />
Ajouter
</NuxtLink>
</div>
<div v-if="auth.isAdmin" class="mt-6 border border-slate-200 mb-16">
<div class="max-h-96 overflow-y-auto">
<div
class="sticky
grid grid-cols-2 gap-4
bg-slate-100 px-4 py-3
font-semibold uppercase
tracking-wide"
>
<div class="col-span-1">Nom</div>
<div class="col-span-1">Code</div>
</div>
<div v-if="bovinList.length === 0" class="px-4 py-6 text-slate-400">

Aucun type de bovin

Aucun type de bovin
Aucun type de bovin.
</div>
<div v-else>
<div
v-for="bovin in bovinList"
:key="bovin.id"
class="grid grid-cols-2 border-t gap-4 px-4 py-2 hover:bg-slate-50 cursor-pointer"
@click="goToBovin(bovin.id)"
>
<div class="col-span-1">{{ bovin.label }}</div>
<div class="col-span-1">{{ bovin.code }}</div>
</div>
</div>
</div>
</div>
<div v-else class="mt-6 border border-slate-200 mb-16 px-4 py-6 text-slate-400">
Accès réservé aux administrateurs.
</div>
</template>
<script setup lang="ts">
import { getBovineTypeList } from "~/services/bovine-type"
import type { BovineTypeData } from "~/services/dto/bovine-type-data"
import { useAuthStore } from "~/stores/auth"
const bovinList = ref<BovineTypeData[]>([])
const router = useRouter()
const auth = useAuthStore()
const goToBovin = (id: number) => {
if (!auth.isAdmin) return
router.push(`/admin/bovin/${id}`)
}
const handleAddClick = (event: Event) => {
if (auth.isAdmin) return
event.preventDefault()
}
onMounted(async () => {
if (!auth.isAdmin) return
bovinList.value = await getBovineTypeList()
})
</script>

View File

@@ -1,5 +1,5 @@
import { useApi } from '~/composables/useApi' import { useApi } from '~/composables/useApi'
import type {BovineTypeData} from "~/services/dto/bovine-type-data"; import type { BovineTypeData, BovinPayload } from "~/services/dto/bovine-type-data";
export type BovineTypeListResponse = export type BovineTypeListResponse =
| BovineTypeData[] | BovineTypeData[]
@@ -12,12 +12,41 @@ export async function getBovineTypeList(): Promise<BovineTypeData[]> {
}) })
if (Array.isArray(response)) { if (Array.isArray(response)) {
return response return response.map(mapToBovineTypeData)
} }
if (response && typeof response === 'object' && Array.isArray(response['hydra:member'])) { if (response && typeof response === 'object' && Array.isArray(response['hydra:member'])) {
return response['hydra:member'] return response['hydra:member'].map(mapToBovineTypeData)
} }
return [] return []
} }
export async function getBovin(id: number): Promise<BovineTypeData> {

Même chose que la fonction getBovineTypeList() donc pas nécessaire. A supprimer

Même chose que la fonction getBovineTypeList() donc pas nécessaire. A supprimer
const api = useApi()
const response = await api.get<BovineTypeData>(`bovine_types/${id}`)
return mapToBovineTypeData(response)
}
export async function createBovin(payload: BovinPayload = {}): Promise<BovineTypeData> {
const api = useApi()
const response = await api.post<BovineTypeData>('bovine_types', toBovineTypePayload(payload))
return mapToBovineTypeData(response)
}
export async function updateBovin(id: number, payload: BovinPayload = {}): Promise<BovineTypeData> {
const api = useApi()
const response = await api.patch<BovineTypeData>(`bovine_types/${id}`, toBovineTypePayload(payload))
return mapToBovineTypeData(response)
}
const mapToBovineTypeData = (item: BovineTypeData): BovineTypeData => ({
id: item.id,
label: item.label,
code: item.code
})
const toBovineTypePayload = (payload: BovinPayload): Partial<BovineTypeData> => ({
label: payload.label ?? undefined,
code: payload.code ?? undefined
})

View File

@@ -3,3 +3,13 @@ export interface BovineTypeData{
label: string label: string
code: string code: string
} }
export interface BovinFormData {
label: string
code: string
}
export type BovinPayload = {
label?: string | null
code?: string | null
}

View File

@@ -7,6 +7,8 @@ namespace App\Entity;
use ApiPlatform\Metadata\ApiResource; use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Get; use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection; use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Patch;
use ApiPlatform\Metadata\Post;
use Doctrine\ORM\Mapping as ORM; use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Attribute\Groups; use Symfony\Component\Serializer\Attribute\Groups;
@@ -20,6 +22,17 @@ use Symfony\Component\Serializer\Attribute\Groups;
new GetCollection( new GetCollection(
normalizationContext: ['groups' => ['bovine-type:read']], normalizationContext: ['groups' => ['bovine-type:read']],
), ),
new Post(
normalizationContext: ['groups' => ['bovine-type:read']],
denormalizationContext: ['groups' => ['bovine-type:write']],
security: "is_granted('ROLE_ADMIN')",
),
new Patch(
requirements: ['id' => '\d+'],
normalizationContext: ['groups' => ['bovine-type:read']],
denormalizationContext: ['groups' => ['bovine-type:write']],
security: "is_granted('ROLE_ADMIN')",
),
], ],
security: "is_granted('ROLE_USER')", security: "is_granted('ROLE_USER')",
)] )]
@@ -32,11 +45,11 @@ class BovineType
private ?int $id = null; private ?int $id = null;
#[ORM\Column(length: 120)] #[ORM\Column(length: 120)]
#[Groups(['bovine-type:read', 'reception:read', 'reception-bovine:read'])] #[Groups(['bovine-type:read', 'bovine-type:write', 'reception:read', 'reception-bovine:read'])]
private ?string $label = null; private ?string $label = null;
#[ORM\Column(length: 50)] #[ORM\Column(length: 50)]
#[Groups(['bovine-type:read', 'reception:read', 'reception-bovine:read'])] #[Groups(['bovine-type:read', 'bovine-type:write', 'reception:read', 'reception-bovine:read'])]
private ?string $code = null; private ?string $code = null;
public function getId(): ?int public function getId(): ?int