[#326] Admin modification creation client #25

Merged
tristan merged 2 commits from feat/326-creation-modification-customer into develop 2026-02-13 07:36:59 +00:00
8 changed files with 321 additions and 20 deletions

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

@@ -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),
},
Review

Pas besoin de from customer au même titre que pas besoin de fromSupplier sur l'autre page

Pas besoin de from customer au même titre que pas besoin de fromSupplier sur l'autre page
})
}
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),
},
})
Review

Même chose ici

Même chose ici
}
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;
} }
} }