[#325] Corrections diverses #26

Merged
tristan merged 4 commits from fix/325-corrections-diverses into develop 2026-02-13 08:10:34 +00:00
9 changed files with 322 additions and 21 deletions
Showing only changes of commit 548e5f3c17 - Show all commits

View File

@@ -44,6 +44,7 @@ Ajouter dans le fichier .env du frontend
* [#275] Lister les expéditions en attente * [#275] Lister les expéditions en attente
* [#276] Lister les expéditions terminées * [#276] Lister les expéditions terminées
* [#324] Creation page admin listing clients * [#324] Creation page admin listing clients
* [#326] Admin modification creation client
### Changed ### Changed

View File

@@ -1,2 +1,2 @@
parameters: parameters:
app.version: '0.0.42' app.version: '0.0.43'

View File

@@ -115,6 +115,10 @@
"create": "Fournisseur créé avec succès.", "create": "Fournisseur créé avec succès.",
"update": "Fournisseur mis à jour avec succès." "update": "Fournisseur mis à jour avec succès."
}, },
"customer": {
"create": "Client créé avec succès.",
"update": "Client mis à jour avec succès."
},
"address": { "address": {
"create": "Adresse créée avec succès.", "create": "Adresse créée avec succès.",
"update": "Adresse mise à jour avec succès." "update": "Adresse mise à jour avec succès."

View File

@@ -1,12 +1,192 @@
<template> <template>
<form @submit.prevent="validate">
<div class="flex items-center justify-between">
<h1 class="text-3xl font-bold uppercase">
{{ customerId ? "Modifications du client" : "Ajout d'un client" }}
</h1>
<button
class="text-xl uppercase bg-primary-500 text-white h-[50px] w-[272px]"
type="submit"
:disabled="isLoading || !auth.isAdmin"
>
{{ customerId ? "Sauvegarder" : "Ajouter" }}
</button>
</div>
<div class="grid grid-cols-2 gap-y-8 gap-x-80 mb-10 py-12">
<UiTextInput id="customer-label" v-model="form.label" label="Nom du client" :disabled="!auth.isAdmin"/>
<UiTextInput id="customer-code" v-model="form.code" label="Code" :disabled="!auth.isAdmin"/>
</div>
<div class="mx-24 mb-4 py-6 border-t border-black"></div>
<div class="flex items-center justify-between mb-4">
<h2 class="text-3xl font-bold uppercase">Adresses client</h2>
<button
type="button"
class="text-xl uppercase bg-primary-500 text-white h-[50px] w-[272px]"
:disabled="customerId === null || !auth.isAdmin"
@click="goToAddAddress"
>
Ajouter
</button>
</div>
<div class="overflow-x-auto mb-10">
<table class="w-full border-collapse">
<thead>
<tr class="text-left border-b border-gray-200">
<th class="py-3 pr-4 text-sm uppercase">Libellé</th>
<th class="py-3 pr-4 text-sm uppercase">Rue</th>
<th class="py-3 pr-4 text-sm uppercase">Complément</th>
<th class="py-3 pr-4 text-sm uppercase">Code postal</th>
<th class="py-3 pr-4 text-sm uppercase">Ville</th>
<th class="py-3 pr-4 text-sm uppercase">Pays</th>
</tr>
</thead>
<tbody>
<template v-if="form.addresses.length === 0">
<tr>
<td colspan="6" class="py-4 text-slate-400">
Aucune adresse.
</td>
</tr>
</template>
<template v-else>
<tr
v-for="(address, index) in form.addresses"
:key="address.id ?? index"
class="border-b border-gray-100 hover:bg-slate-50"
:class="auth.isAdmin ? 'cursor-pointer' : 'cursor-not-allowed opacity-60'"
@click="goToEditAddress(address.id ?? null)"
>
<td class="py-3 pr-4">{{ address.label || "—" }}</td>
<td class="py-3 pr-4">{{ address.street || "—" }}</td>
<td class="py-3 pr-4">{{ address.street2 || "—" }}</td>
<td class="py-3 pr-4">{{ address.postalCode || "—" }}</td>
<td class="py-3 pr-4">{{ address.city || "—" }}</td>
<td class="py-3 pr-4">{{ address.countryCode || "—" }}</td>
</tr>
</template>
</tbody>
</table>
</div>
</form>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import {computed, reactive, ref, watch} from "vue"
import {createCustomer, getCustomer, updateCustomer} from "~/services/customer"
import type {CustomerData, CustomerFormData, CustomerPayload} from "~/services/dto/customer-data"
import {useAuthStore} from "~/stores/auth"
definePageMeta({layout: "admin"}) definePageMeta({layout: "admin"})
const route = useRoute() const route = useRoute()
const router = useRouter() const router = useRouter()
const auth = useAuthStore() const auth = useAuthStore()
const 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 customerId = computed(() => resolveId(route.params.id))
const isLoading = ref(false)
const form = reactive<CustomerFormData>({
label: "",
code: "",
addresses: [],
})
const goToAddAddress = () => {
if (customerId.value === null || !auth.isAdmin) return
router.push({
path: "/admin/customer/address",
query: {
customerId: String(customerId.value),
},
})
}
const goToEditAddress = (addressId: number | null) => {
if (customerId.value === null || addressId === null || !auth.isAdmin) return
router.push({
path: "/admin/customer/address",
query: {
customerId: String(customerId.value),
addressId: String(addressId),
},
})
}
const hydrateFromCustomer = (customer: CustomerData | null) => {
if (!customer) return
form.label = customer.label ?? ""
form.code = customer.code ?? ""
if (!Array.isArray(customer.addresses) || customer.addresses.length === 0) {
form.addresses = []
return
}
if (typeof customer.addresses[0] === "string") {
form.addresses = []
return
}
form.addresses = customer.addresses.map((address) => ({
id: address.id ?? null,
label: address.label ?? "",
street: address.street ?? "",
street2: address.street2 ?? null,
postalCode: address.postalCode ?? "",
city: address.city ?? "",
countryCode: address.countryCode ?? "",
}))
}
watch(
() => customerId.value,
async (id) => {
if (id === null) return
isLoading.value = true
try {
const customer = await getCustomer(id)
hydrateFromCustomer(customer)
} finally {
isLoading.value = false
}
},
{immediate: true}
)
async function validate() {
if (isLoading.value) return
if (!auth.isAdmin) return
isLoading.value = true
try {
const label = form.label.trim()
const code = form.code.trim()
const customerPayload: CustomerPayload = {
label,
code,
}
let targetId: number | null = null
if (customerId.value !== null) {
await updateCustomer(customerId.value, customerPayload)
targetId = customerId.value
} else {
const created = await createCustomer(customerPayload)
targetId = created.id
}
if (targetId !== null) {
await router.push(`/admin/customer/${targetId}`)
}
} finally {
isLoading.value = false
}
}
</script> </script>

View File

@@ -0,0 +1,49 @@
<template>
<Address type="customer" :address="address" @validate="validate"/>
</template>
<script setup lang="ts">
import type { AddressData, AddressPayload } from "~/services/address"
import { createAddress, getAddress, updateAddress } from "~/services/address"
import { getCustomer, updateCustomer } from "~/services/customer"
import type { CustomerData } from "~/services/dto/customer-data"
definePageMeta({ layout: "admin" })
const route = useRoute()
const router = useRouter()
const customerId = computed(() => Number(route.query.customerId))
const customer = ref<CustomerData | null>(null)
const addressId = computed(() => (route.query.addressId !== undefined ? Number(route.query.addressId) : null))
const address = ref<AddressData | null>(null)
const validate = async (payload: AddressPayload) => {
try {
if (addressId.value !== null) {
await updateAddress(addressId.value, payload)
} else {
await addAddress(payload)
}
} finally {
await router.push("/admin/customer/" + customerId.value)
}
}
const addAddress = async (payload: AddressPayload) => {
const response: AddressData = await createAddress(payload)
const addressIRI = `/api/addresses/${response.id}`
const existingIris = (customer.value?.addresses ?? [])
.map((item: any) => (typeof item === "string" ? item : `/api/addresses/${item.id}`))
.filter((iri: string | null) => Boolean(iri)) as string[]
const next = [...new Set([...existingIris, addressIRI])]
return await updateCustomer(customerId.value, { addresses: next })
}
onMounted(async () => {
customer.value = await getCustomer(customerId.value)
if (addressId.value !== null) {
address.value = await getAddress(addressId.value)
}
})
</script>

View File

@@ -1,6 +1,6 @@
<template> <template>
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<h1 class="text-3xl font-bold uppercase">Client</h1> <h1 class="text-3xl font-bold uppercase">Liste des Clients</h1>
<NuxtLink <NuxtLink
to="/admin/customer" to="/admin/customer"
class="flex items-center justify-center text-xl uppercase bg-primary-500 text-white h-[50px] w-[272px]" class="flex items-center justify-center text-xl uppercase bg-primary-500 text-white h-[50px] w-[272px]"

View File

@@ -1,23 +1,43 @@
import { useApi } from '~/composables/useApi' import { useApi } from "~/composables/useApi"
import type { CustomerData } from '~/services/dto/customer-data' import type { CustomerData, CustomerPayload } from "~/services/dto/customer-data"
export type CustomerListResponse = export type CustomerListResponse =
| CustomerData[] | CustomerData[]
| { 'hydra:member'?: CustomerData[] } | { "hydra:member"?: CustomerData[] }
export async function getCustomerList(): Promise<CustomerData[]> { export async function getCustomerList(): Promise<CustomerData[]> {
const api = useApi() const api = useApi()
const response = await api.get<CustomerListResponse>('customers', {}, { const response = await api.get<CustomerListResponse>("customers", {}, {
toastErrorKey: 'errors.customer.list' toastErrorKey: "errors.customer.list",
}) })
if (Array.isArray(response)) { if (Array.isArray(response)) return response
return response if (response && typeof response === "object" && Array.isArray(response["hydra:member"])) {
return response["hydra:member"]
} }
if (response && typeof response === 'object' && Array.isArray(response['hydra:member'])) {
return response['hydra:member']
}
return [] return []
} }
export async function getCustomer(id: number): Promise<CustomerData> {
const api = useApi()
return api.get<CustomerData>(`customers/${id}`, {}, {
toastErrorKey: "errors.customer.fetch",
})
}
export async function updateCustomer(id: number, payload: Partial<CustomerPayload>): Promise<CustomerData> {
const api = useApi()
return api.patch<CustomerData>(`customers/${id}`, payload, {
toastErrorKey: "errors.customer.update",
toastSuccessKey: "success.customer.update",
})
}
export async function createCustomer(payload: CustomerPayload): Promise<CustomerData> {
const api = useApi()
return api.post<CustomerData>("customers", payload, {
toastErrorKey: "errors.customer.create",
toastSuccessKey: "success.customer.create",
})
}

View File

@@ -1,8 +1,22 @@
import type { AddressData } from "~/services/dto/address-data" import type { AddressFormData } from "~/services/dto/address-data"
export type CustomerAddresses = AddressFormData[] | string[]
export interface CustomerData { export interface CustomerData {
id: number id: number
label: string label: string
code?: string | null code?: string | null
addresses?: AddressData[] | null addresses: CustomerAddresses
}
export interface CustomerFormData {
label: string
code?: string
addresses: AddressFormData[]
}
export type CustomerPayload = {
label: string
code?: string | null
addresses?: string[]
} }

View File

@@ -8,6 +8,8 @@ use ApiPlatform\Metadata\ApiProperty;
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\Common\Collections\ArrayCollection; use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection; use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM; use Doctrine\ORM\Mapping as ORM;
@@ -24,6 +26,16 @@ use Symfony\Component\Serializer\Attribute\Groups;
new GetCollection( new GetCollection(
normalizationContext: ['groups' => ['customer:read']], normalizationContext: ['groups' => ['customer:read']],
), ),
new Post(
normalizationContext: ['groups' => ['customer:read']],
denormalizationContext: ['groups' => ['customer:write']],
security: "is_granted('ROLE_ADMIN')",
),
new Patch(
normalizationContext: ['groups' => ['customer:read']],
denormalizationContext: ['groups' => ['customer:write']],
security: "is_granted('ROLE_ADMIN')",
),
], ],
security: "is_granted('ROLE_USER')", security: "is_granted('ROLE_USER')",
)] )]
@@ -36,11 +48,11 @@ class Customer
private ?int $id = null; private ?int $id = null;
#[ORM\Column(length: 255)] #[ORM\Column(length: 255)]
#[Groups(['customer:read', 'shipment:read'])] #[Groups(['customer:read', 'customer:write', 'shipment:read'])]
private ?string $label = null; private ?string $label = null;
#[ORM\Column(length: 255)] #[ORM\Column(length: 255)]
#[Groups(['customer:read', 'shipment:read'])] #[Groups(['customer:read', 'customer:write', 'shipment:read'])]
private ?string $code = null; private ?string $code = null;
/** /**
@@ -48,7 +60,7 @@ class Customer
*/ */
#[ORM\ManyToMany(targetEntity: Address::class, inversedBy: 'customers')] #[ORM\ManyToMany(targetEntity: Address::class, inversedBy: 'customers')]
#[ORM\JoinTable(name: 'customer_address')] #[ORM\JoinTable(name: 'customer_address')]
#[Groups(['customer:read'])] #[Groups(['customer:read', 'customer:write'])]
#[ApiProperty(readableLink: true)] #[ApiProperty(readableLink: true)]
private Collection $addresses; private Collection $addresses;
@@ -87,8 +99,29 @@ class Customer
return $this->addresses; return $this->addresses;
} }
public function setAddresses(Collection $addresses): void public function setAddresses(iterable $addresses): self
{ {
$this->addresses = $addresses; $this->addresses->clear();
foreach ($addresses as $address) {
$this->addAddress($address);
}
return $this;
}
public function addAddress(Address $address): self
{
if (!$this->addresses->contains($address)) {
$this->addresses->add($address);
}
return $this;
}
public function removeAddress(Address $address): self
{
$this->addresses->removeElement($address);
return $this;
} }
} }