Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bbd05cea3e | ||
| 7f78454553 | |||
|
|
696100a622 | ||
| 97f21ab35c |
107
.idea/workspace.xml
generated
107
.idea/workspace.xml
generated
@@ -4,12 +4,7 @@
|
||||
<option name="autoReloadType" value="SELECTIVE" />
|
||||
</component>
|
||||
<component name="ChangeListManager">
|
||||
<list default="true" id="7c107abe-5995-4428-8429-b146aaca8386" name="Changes" comment="fix : bouton de mise en attente">
|
||||
<change beforePath="$PROJECT_DIR$/.idea/workspace.xml" beforeDir="false" afterPath="$PROJECT_DIR$/.idea/workspace.xml" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/CHANGELOG.md" beforeDir="false" afterPath="$PROJECT_DIR$/CHANGELOG.md" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/config/reference.php" beforeDir="false" afterPath="$PROJECT_DIR$/config/reference.php" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/frontend/services/auth.ts" beforeDir="false" afterPath="$PROJECT_DIR$/frontend/services/auth.ts" afterDir="false" />
|
||||
</list>
|
||||
<list default="true" id="7c107abe-5995-4428-8429-b146aaca8386" name="Changes" comment="feat : update CHANGELOG.md" />
|
||||
<option name="SHOW_DIALOG" value="false" />
|
||||
<option name="HIGHLIGHT_CONFLICTS" value="true" />
|
||||
<option name="HIGHLIGHT_NON_ACTIVE_CHANGELIST" value="false" />
|
||||
@@ -40,7 +35,7 @@
|
||||
<component name="Git.Settings">
|
||||
<option name="RECENT_BRANCH_BY_REPOSITORY">
|
||||
<map>
|
||||
<entry key="$PROJECT_DIR$" value="develop" />
|
||||
<entry key="$PROJECT_DIR$" value="feature/FER-12-ajouter-un-blocage-des-utilisateurs" />
|
||||
</map>
|
||||
</option>
|
||||
<option name="RECENT_GIT_ROOT_PATH" value="$PROJECT_DIR$" />
|
||||
@@ -231,7 +226,7 @@
|
||||
"RunOnceActivity.TerminalTabsStorage.copyFrom.TerminalArrangementManager.252": "true",
|
||||
"RunOnceActivity.git.unshallow": "true",
|
||||
"RunOnceActivity.typescript.service.memoryLimit.init": "true",
|
||||
"git-widget-placeholder": "fix/FER-11-corriger-le-probleme-de-bearer-token",
|
||||
"git-widget-placeholder": "feature/FER-13-faire-des-recherches-sur-le-scanner-des-betes",
|
||||
"last_opened_file_path": "//wsl.localhost/Ubuntu-24.04/home/m-tristan/workspace/Ferme",
|
||||
"node.js.detected.package.eslint": "true",
|
||||
"node.js.detected.package.tslint": "true",
|
||||
@@ -325,47 +320,7 @@
|
||||
<workItem from="1773766075191" duration="6202000" />
|
||||
<workItem from="1773824491213" duration="24805000" />
|
||||
<workItem from="1774275549972" duration="51000" />
|
||||
<workItem from="1774276665015" duration="13844000" />
|
||||
</task>
|
||||
<task id="LOCAL-00031" summary="feat : ajout plus d'information sur la liste des réceptions côté front sur la page d'accueil">
|
||||
<option name="closed" value="true" />
|
||||
<created>1769098861988</created>
|
||||
<option name="number" value="00031" />
|
||||
<option name="presentableId" value="LOCAL-00031" />
|
||||
<option name="project" value="LOCAL" />
|
||||
<updated>1769098861988</updated>
|
||||
</task>
|
||||
<task id="LOCAL-00032" summary="fix : redirige sur le login sur une 401 et reset du auth state + doc + timeout du toaster">
|
||||
<option name="closed" value="true" />
|
||||
<created>1769100048933</created>
|
||||
<option name="number" value="00032" />
|
||||
<option name="presentableId" value="LOCAL-00032" />
|
||||
<option name="project" value="LOCAL" />
|
||||
<updated>1769100048933</updated>
|
||||
</task>
|
||||
<task id="LOCAL-00033" summary="feat : ajout de la debug bar en mod dev">
|
||||
<option name="closed" value="true" />
|
||||
<created>1769177611987</created>
|
||||
<option name="number" value="00033" />
|
||||
<option name="presentableId" value="LOCAL-00033" />
|
||||
<option name="project" value="LOCAL" />
|
||||
<updated>1769177611987</updated>
|
||||
</task>
|
||||
<task id="LOCAL-00034" summary="feat : ajout du bundle Malio ednotif pour l'utilisation des WS">
|
||||
<option name="closed" value="true" />
|
||||
<created>1769184861047</created>
|
||||
<option name="number" value="00034" />
|
||||
<option name="presentableId" value="LOCAL-00034" />
|
||||
<option name="project" value="LOCAL" />
|
||||
<updated>1769184861047</updated>
|
||||
</task>
|
||||
<task id="LOCAL-00035" summary="fix : modification de la conf du bundle ednotif">
|
||||
<option name="closed" value="true" />
|
||||
<created>1769434793487</created>
|
||||
<option name="number" value="00035" />
|
||||
<option name="presentableId" value="LOCAL-00035" />
|
||||
<option name="project" value="LOCAL" />
|
||||
<updated>1769434793487</updated>
|
||||
<workItem from="1774276665015" duration="28381000" />
|
||||
</task>
|
||||
<task id="LOCAL-00036" summary="feat : update du CHANGELOG.md">
|
||||
<option name="closed" value="true" />
|
||||
@@ -719,7 +674,47 @@
|
||||
<option name="project" value="LOCAL" />
|
||||
<updated>1774337609427</updated>
|
||||
</task>
|
||||
<option name="localTasksCounter" value="80" />
|
||||
<task id="LOCAL-00080" summary="fix : problème de bearer token">
|
||||
<option name="closed" value="true" />
|
||||
<created>1774448105945</created>
|
||||
<option name="number" value="00080" />
|
||||
<option name="presentableId" value="LOCAL-00080" />
|
||||
<option name="project" value="LOCAL" />
|
||||
<updated>1774448105945</updated>
|
||||
</task>
|
||||
<task id="LOCAL-00081" summary="feat : système de blocage utilisateur">
|
||||
<option name="closed" value="true" />
|
||||
<created>1774450388149</created>
|
||||
<option name="number" value="00081" />
|
||||
<option name="presentableId" value="LOCAL-00081" />
|
||||
<option name="project" value="LOCAL" />
|
||||
<updated>1774450388149</updated>
|
||||
</task>
|
||||
<task id="LOCAL-00082" summary="feat : ajout d'un système de scanner bovin">
|
||||
<option name="closed" value="true" />
|
||||
<created>1774543296474</created>
|
||||
<option name="number" value="00082" />
|
||||
<option name="presentableId" value="LOCAL-00082" />
|
||||
<option name="project" value="LOCAL" />
|
||||
<updated>1774543296474</updated>
|
||||
</task>
|
||||
<task id="LOCAL-00083" summary="feat : mise à jour du CLAUDE.md">
|
||||
<option name="closed" value="true" />
|
||||
<created>1774543626516</created>
|
||||
<option name="number" value="00083" />
|
||||
<option name="presentableId" value="LOCAL-00083" />
|
||||
<option name="project" value="LOCAL" />
|
||||
<updated>1774543626516</updated>
|
||||
</task>
|
||||
<task id="LOCAL-00084" summary="feat : update CHANGELOG.md">
|
||||
<option name="closed" value="true" />
|
||||
<created>1774543766582</created>
|
||||
<option name="number" value="00084" />
|
||||
<option name="presentableId" value="LOCAL-00084" />
|
||||
<option name="project" value="LOCAL" />
|
||||
<updated>1774543766582</updated>
|
||||
</task>
|
||||
<option name="localTasksCounter" value="85" />
|
||||
<servers />
|
||||
</component>
|
||||
<component name="TypeScriptGeneratedFilesManager">
|
||||
@@ -769,11 +764,6 @@
|
||||
</option>
|
||||
</component>
|
||||
<component name="VcsManagerConfiguration">
|
||||
<MESSAGE value="feat : changelog" />
|
||||
<MESSAGE value="feat : lister les expeditions terminees" />
|
||||
<MESSAGE value="fix: corrections diverses" />
|
||||
<MESSAGE value="fix : corrections diverses" />
|
||||
<MESSAGE value="fix : corrections frontend" />
|
||||
<MESSAGE value="feat : affichage et modification expédition et modification bouton valider" />
|
||||
<MESSAGE value="fix : erreur customer adress et bouton valider oublie" />
|
||||
<MESSAGE value="feat : changelog update" />
|
||||
@@ -794,7 +784,12 @@
|
||||
<MESSAGE value="fix : order récéption/expédition + correction style bouton récéption" />
|
||||
<MESSAGE value="fix : style bon de récéption" />
|
||||
<MESSAGE value="fix : bouton de mise en attente" />
|
||||
<option name="LAST_COMMIT_MESSAGE" value="fix : bouton de mise en attente" />
|
||||
<MESSAGE value="fix : problème de bearer token" />
|
||||
<MESSAGE value="feat : système de blocage utilisateur" />
|
||||
<MESSAGE value="feat : ajout d'un système de scanner bovin" />
|
||||
<MESSAGE value="feat : mise à jour du CLAUDE.md" />
|
||||
<MESSAGE value="feat : update CHANGELOG.md" />
|
||||
<option name="LAST_COMMIT_MESSAGE" value="feat : update CHANGELOG.md" />
|
||||
</component>
|
||||
<component name="XDebuggerManager">
|
||||
<breakpoint-manager>
|
||||
|
||||
@@ -60,6 +60,8 @@ Ajouter dans le fichier .env du frontend
|
||||
* [#353] modification front admin client
|
||||
* [#353] modification front admin utilisateur
|
||||
* [#FER-11] Corriger le problème de bearer token
|
||||
* [#FER-12] Ajouter un blocage des utilisateurs
|
||||
* [#FER-13] Faire des recherches sur le scanner des bêtes
|
||||
|
||||
### Changed
|
||||
|
||||
|
||||
@@ -142,6 +142,15 @@ frontend/
|
||||
- `BuildingCase` a `bovines` (OneToMany).
|
||||
- Rapport PDF cases : `GET /building_cases/{id}/weights-report` → template Twig, projection depuis `arrivalDate`, gain journalier fixe `1.3 kg/jour`.
|
||||
|
||||
### Scanner boucles auriculaires
|
||||
- Page dédiée `/scan` : scan de codes-barres Code 39/128 (boucles auriculaires bovines) depuis un téléphone Android via Chrome.
|
||||
- Utilise l'API native `BarcodeDetector` (Shape Detection API, Chrome Android 83+) — pas de lib JS, décodage hardware quasi-instantané.
|
||||
- **Non supporté sur iOS** (tous les navigateurs iOS utilisent WebKit, qui n'implémente pas `BarcodeDetector`).
|
||||
- Les 4 premiers caractères du code-barres sont retirés avant enregistrement (`rawValue.slice(4)`).
|
||||
- Composable `useBarcodeScanner` : caméra arrière, anti-doublon 2s, vibration au scan.
|
||||
- Le bovin est créé via `POST /bovines` avec `Content-Type: application/ld+json` (nécessaire pour la résolution d'IRI de `buildingCase`).
|
||||
- Sélection bâtiment → case (filtrées dynamiquement) avant de scanner.
|
||||
|
||||
### Données de référence
|
||||
- `ReceptionType`, `MerchandiseType`, `PelletType`, `Building`, `Supplier` (avec `Address` via join table, `createdBy` → User), `Customer` (avec `Address` via join table, `createdBy` → User), `Truck`, `Carrier`, `Driver`, `Vehicle`.
|
||||
- `Address` : champ `label` nullable (déprécié, retiré du front et du `address:write`), expose `fullAddress` via getter. `countryCode` par défaut `FR` côté front.
|
||||
|
||||
@@ -20,6 +20,7 @@ security:
|
||||
pattern: ^/login_check
|
||||
stateless: true
|
||||
provider: app_user_provider
|
||||
user_checker: App\Security\UserChecker
|
||||
json_login:
|
||||
check_path: /login_check
|
||||
username_path: username
|
||||
@@ -30,6 +31,7 @@ security:
|
||||
pattern: ^/
|
||||
stateless: true
|
||||
provider: app_user_provider
|
||||
user_checker: App\Security\UserChecker
|
||||
jwt: ~
|
||||
logout:
|
||||
path: /api/logout
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
parameters:
|
||||
app.version: '0.0.78'
|
||||
app.version: '0.0.80'
|
||||
|
||||
113
frontend/composables/useBarcodeScanner.ts
Normal file
113
frontend/composables/useBarcodeScanner.ts
Normal file
@@ -0,0 +1,113 @@
|
||||
import { ref, onUnmounted } from 'vue'
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
BarcodeDetector: new (options?: { formats: string[] }) => {
|
||||
detect(source: HTMLVideoElement | ImageBitmapSource): Promise<{ rawValue: string }[]>
|
||||
}
|
||||
}
|
||||
const BarcodeDetector: Window['BarcodeDetector'] | undefined
|
||||
}
|
||||
|
||||
export function useBarcodeScanner(onDetected: (code: string) => void) {
|
||||
const isSupported = ref('BarcodeDetector' in globalThis)
|
||||
const isScanning = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
|
||||
let detector: InstanceType<Window['BarcodeDetector']> | null = null
|
||||
let stream: MediaStream | null = null
|
||||
let animationFrameId: number | null = null
|
||||
let lastDetectedCode = ''
|
||||
let lastDetectedTime = 0
|
||||
|
||||
const COOLDOWN_MS = 2000
|
||||
|
||||
async function start(videoElement: HTMLVideoElement) {
|
||||
if (!isSupported.value) {
|
||||
error.value = 'BarcodeDetector non supporté. Utilisez Chrome sur Android.'
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
detector = new BarcodeDetector({ formats: ['code_39', 'code_128'] })
|
||||
|
||||
stream = await navigator.mediaDevices.getUserMedia({
|
||||
video: {
|
||||
facingMode: 'environment',
|
||||
width: { ideal: 1280 },
|
||||
height: { ideal: 720 }
|
||||
}
|
||||
})
|
||||
|
||||
videoElement.srcObject = stream
|
||||
await videoElement.play()
|
||||
isScanning.value = true
|
||||
error.value = null
|
||||
|
||||
scanLoop(videoElement)
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : 'Erreur lors du démarrage de la caméra'
|
||||
isScanning.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function scanLoop(videoElement: HTMLVideoElement) {
|
||||
if (!isScanning.value || !detector) return
|
||||
|
||||
animationFrameId = requestAnimationFrame(async () => {
|
||||
try {
|
||||
if (videoElement.readyState >= HTMLMediaElement.HAVE_ENOUGH_DATA) {
|
||||
const barcodes = await detector!.detect(videoElement)
|
||||
|
||||
if (barcodes.length > 0) {
|
||||
const code = barcodes[0].rawValue.slice(4)
|
||||
const now = Date.now()
|
||||
|
||||
if (code !== lastDetectedCode || now - lastDetectedTime > COOLDOWN_MS) {
|
||||
lastDetectedCode = code
|
||||
lastDetectedTime = now
|
||||
|
||||
if (navigator.vibrate) {
|
||||
navigator.vibrate(100)
|
||||
}
|
||||
|
||||
onDetected(code)
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Detection error on single frame, continue
|
||||
}
|
||||
|
||||
scanLoop(videoElement)
|
||||
})
|
||||
}
|
||||
|
||||
function stop() {
|
||||
isScanning.value = false
|
||||
|
||||
if (animationFrameId !== null) {
|
||||
cancelAnimationFrame(animationFrameId)
|
||||
animationFrameId = null
|
||||
}
|
||||
|
||||
if (stream) {
|
||||
stream.getTracks().forEach(track => track.stop())
|
||||
stream = null
|
||||
}
|
||||
|
||||
detector = null
|
||||
}
|
||||
|
||||
onUnmounted(() => {
|
||||
stop()
|
||||
})
|
||||
|
||||
return {
|
||||
isSupported,
|
||||
isScanning,
|
||||
error,
|
||||
start,
|
||||
stop
|
||||
}
|
||||
}
|
||||
@@ -89,6 +89,9 @@
|
||||
"create": "Impossible de créer le type bovin.",
|
||||
"update": "Impossible de mettre à jour le type bovin."
|
||||
},
|
||||
"bovine": {
|
||||
"create": "Impossible d'enregistrer le bovin."
|
||||
},
|
||||
"carrier": {
|
||||
"list": "Impossible de récupérer la liste des transporteurs.",
|
||||
"fetch": "Impossible de récupérer les données du transporteur",
|
||||
@@ -146,6 +149,9 @@
|
||||
"update": "Type bovin mis à jour avec succès.",
|
||||
"create": "Type bovin créé avec succès."
|
||||
},
|
||||
"bovine": {
|
||||
"create": "Bovin enregistré avec succès."
|
||||
},
|
||||
"weight": {
|
||||
"update": "Pesée mis à jour"
|
||||
}
|
||||
|
||||
@@ -122,6 +122,23 @@
|
||||
Bovins
|
||||
</a>
|
||||
</NuxtLink>
|
||||
|
||||
<NuxtLink
|
||||
v-if="auth.isAdmin"
|
||||
to="/scan"
|
||||
custom
|
||||
v-slot="{ href, navigate }"
|
||||
>
|
||||
<a
|
||||
:href="href"
|
||||
@click="navigate"
|
||||
:class="route.path.startsWith('/scan')
|
||||
? 'opacity-100'
|
||||
: 'opacity-65 hover:opacity-100 transition'"
|
||||
>
|
||||
Scanner
|
||||
</a>
|
||||
</NuxtLink>
|
||||
</nav>
|
||||
|
||||
<!-- Spacer mobile (pour centrer visuellement le header si besoin) -->
|
||||
@@ -218,6 +235,9 @@
|
||||
<NuxtLink v-if="auth.isAdmin" to="/admin/bovin/bovin-list" @click="closeMenu">
|
||||
Bovins
|
||||
</NuxtLink>
|
||||
<NuxtLink to="/scan" @click="closeMenu">
|
||||
Scanner
|
||||
</NuxtLink>
|
||||
</nav>
|
||||
|
||||
<button
|
||||
@@ -231,7 +251,7 @@
|
||||
</aside>
|
||||
</transition>
|
||||
</header>
|
||||
<main class="mx-auto w-full max-w-[1280px] mt-16">
|
||||
<main class="md:mx-auto w-full md:max-w-[1280px] mt-4 md:mt-16">
|
||||
<slot/>
|
||||
</main>
|
||||
<footer class="w-full mt-auto bg-primary-500 px-6 py-3">
|
||||
|
||||
@@ -41,10 +41,24 @@
|
||||
type="password"
|
||||
:disabled="!auth.isAdmin"
|
||||
wrapper-class="w-[280px]"
|
||||
required
|
||||
:required="!userId"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center mb-11">
|
||||
<label class="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
id="user-locked"
|
||||
v-model="form.isLocked"
|
||||
type="checkbox"
|
||||
:disabled="!auth.isAdmin"
|
||||
class="w-5 h-5 accent-primary-500"
|
||||
/>
|
||||
<span class="text-sm text-primary-700">Verrouiller le compte</span>
|
||||
</label>
|
||||
<p class="ml-4 text-xs text-slate-400">Un compte verrouillé ne peut plus se connecter.</p>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-center">
|
||||
<UiButton
|
||||
class="inline-flex mb-28 items-center justify-center text-xl min-w-[194px] text-white uppercase bg-primary-500 h-[50px] rounded hover:opacity-80 justify-self-end"
|
||||
@@ -86,7 +100,8 @@ const resolveUserId = (param: unknown) => {
|
||||
const form = reactive<UserFormData>({
|
||||
username: '',
|
||||
password: '',
|
||||
role: ''
|
||||
role: '',
|
||||
isLocked: false
|
||||
})
|
||||
|
||||
const hydrateFromUser = (user: UserData | null) => {
|
||||
@@ -99,6 +114,7 @@ const hydrateFromUser = (user: UserData | null) => {
|
||||
const hasAdmin = roles.includes('ROLE_ADMIN')
|
||||
form.role = hasAdmin ? 'ROLE_ADMIN' : 'ROLE_USER'
|
||||
form.password = ''
|
||||
form.isLocked = user.isLocked ?? false
|
||||
isHydrating.value = false
|
||||
}
|
||||
|
||||
@@ -129,6 +145,7 @@ async function validate() {
|
||||
const basePayload: UserPayload = {
|
||||
username: normalizedUsername,
|
||||
roles: normalizedRole ? [normalizedRole] : undefined,
|
||||
isLocked: form.isLocked,
|
||||
}
|
||||
if (normalizedPassword) {
|
||||
basePayload.password = normalizedPassword
|
||||
|
||||
@@ -4,9 +4,10 @@
|
||||
</div>
|
||||
|
||||
<div v-if="auth.isAdmin" class="mt-7 border border-slate-200 mb-11">
|
||||
<div class="grid grid-cols-2 text-primary-700 gap-4 bg-slate-100 px-4 py-3 text-sm font-semibold uppercase tracking-wide">
|
||||
<div class="grid grid-cols-3 text-primary-700 gap-4 bg-slate-100 px-4 py-3 text-sm font-semibold uppercase tracking-wide">
|
||||
<div>Utilisateur</div>
|
||||
<div>Role</div>
|
||||
<div>Statut</div>
|
||||
</div>
|
||||
<div v-if="userList.length === 0" class="px-4 py-6 text-slate-400">
|
||||
Aucun utilisateur.
|
||||
@@ -15,7 +16,7 @@
|
||||
<div
|
||||
v-for="user in userList"
|
||||
:key="user.id"
|
||||
class="grid grid-cols-2 text-primary-700 gap-4 px-4 py-3 text-sm hover:bg-slate-50 cursor-pointer border-t border-slate-200 items-center"
|
||||
class="grid grid-cols-3 text-primary-700 gap-4 px-4 py-3 text-sm hover:bg-slate-50 cursor-pointer border-t border-slate-200 items-center"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
@click="goToUser(user.id)"
|
||||
@@ -23,6 +24,16 @@
|
||||
>
|
||||
<div>{{ user.username }}</div>
|
||||
<div>{{ getRoleLabels(user.roles) }}</div>
|
||||
<div>
|
||||
<span
|
||||
v-if="user.isLocked"
|
||||
class="inline-block px-2 py-0.5 text-xs font-semibold rounded bg-red-100 text-red-700"
|
||||
>Verrouillé</span>
|
||||
<span
|
||||
v-else
|
||||
class="inline-block px-2 py-0.5 text-xs font-semibold rounded bg-green-100 text-green-700"
|
||||
>Actif</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
229
frontend/pages/scan.vue
Normal file
229
frontend/pages/scan.vue
Normal file
@@ -0,0 +1,229 @@
|
||||
<template>
|
||||
<div class="flex flex-col gap-4 px-4">
|
||||
<h1 class="text-2xl text-primary-500 font-bold uppercase">Scanner des bovins</h1>
|
||||
|
||||
<!-- Message si non supporté -->
|
||||
<div v-if="!scanner.isSupported.value" class="bg-red-50 border border-red-200 rounded p-4 text-red-700 flex flex-col w-full">
|
||||
<p class="font-bold">Scanner non disponible</p>
|
||||
<p class="text-sm mt-1">BarcodeDetector n'est pas supportée par ce navigateur. Utilisez Chrome sur Android.</p>
|
||||
</div>
|
||||
|
||||
<!-- Erreur caméra -->
|
||||
<div v-if="scanner.error.value" class="bg-red-50 border border-red-200 rounded p-4 text-red-700">
|
||||
<p>{{ scanner.error.value }}</p>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap gap-4">
|
||||
<UiSelect
|
||||
id="scan-building"
|
||||
v-model="selectedBuildingId"
|
||||
label="Bâtiment"
|
||||
:options="buildingOptions"
|
||||
wrapper-class="w-full max-w-[280px]"
|
||||
required
|
||||
/>
|
||||
|
||||
<UiSelect
|
||||
id="scan-case"
|
||||
v-model="selectedCaseId"
|
||||
label="Case"
|
||||
:options="caseOptions"
|
||||
:disabled="!selectedBuildingId"
|
||||
wrapper-class="w-full max-w-[280px]"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Zone caméra pleine hauteur -->
|
||||
<div v-if="showScanner" class="fixed inset-0 z-50 flex flex-col bg-black overflow-hidden">
|
||||
<!-- Header scanner -->
|
||||
<div class="flex items-center justify-between px-4 py-3 bg-black/90">
|
||||
<div class="text-white text-sm font-semibold">
|
||||
{{ scannedCount }} bovin{{ scannedCount > 1 ? 's' : '' }} scanné{{ scannedCount > 1 ? 's' : '' }}
|
||||
</div>
|
||||
<UiButton
|
||||
type="button"
|
||||
class="text-md font-bold uppercase bg-red-500 text-white h-[40px] px-4"
|
||||
@click="stopScanning"
|
||||
>
|
||||
Arrêter
|
||||
</UiButton>
|
||||
</div>
|
||||
<div class="flex-1 relative">
|
||||
<video
|
||||
ref="videoRef"
|
||||
class="w-full h-full object-cover"
|
||||
playsinline
|
||||
muted
|
||||
/>
|
||||
<!-- Overlay zone de scan -->
|
||||
<div class="absolute inset-0 flex items-center justify-center pointer-events-none">
|
||||
<div class="w-[90%] h-48 border-2 border-white/70 rounded-lg" />
|
||||
</div>
|
||||
<!-- Dernier scan -->
|
||||
<div v-if="lastScanned" class="absolute bottom-4 left-1/2 -translate-x-1/2 bg-green-600/80 text-white px-4 py-2 rounded-full text-sm font-semibold">
|
||||
{{ lastScanned }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Bouton démarrer -->
|
||||
<div v-if="!showScanner" class="flex gap-3">
|
||||
<UiButton
|
||||
type="button"
|
||||
class="text-md font-bold uppercase bg-primary-500 text-white h-[50px] w-full max-w-[272px]"
|
||||
:disabled="!scanner.isSupported.value"
|
||||
@click="startScanning"
|
||||
>
|
||||
Démarrer le scanner
|
||||
</UiButton>
|
||||
</div>
|
||||
|
||||
<!-- Liste des numéros scannés -->
|
||||
<div class="flex flex-col gap-3">
|
||||
<p class="text-sm text-slate-500 font-semibold">
|
||||
{{ scannedCount }} bovin{{ scannedCount > 1 ? 's' : '' }} scanné{{ scannedCount > 1 ? 's' : '' }}
|
||||
</p>
|
||||
|
||||
<div
|
||||
v-for="(entry, index) in entries"
|
||||
:key="index"
|
||||
class="flex items-center gap-2"
|
||||
>
|
||||
<UiTextInput
|
||||
:id="`scan-entry-${index}`"
|
||||
:ref="(el: any) => setInputRef(el?.$el?.querySelector('input') ?? el, index)"
|
||||
:model-value="entries[index]"
|
||||
placeholder="Numéro national"
|
||||
wrapper-class="flex-1 max-w-md"
|
||||
@update:model-value="(val: string) => entries[index] = val ?? ''"
|
||||
/>
|
||||
<button
|
||||
v-if="entries.length > 1"
|
||||
type="button"
|
||||
class="text-red-400 hover:text-red-600"
|
||||
@click="removeEntry(index)"
|
||||
>
|
||||
<Icon name="mdi:close-circle" size="24" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="flex gap-3 mt-4">
|
||||
<UiButton
|
||||
type="button"
|
||||
class="text-md font-bold uppercase bg-primary-500 text-white h-[50px] w-full"
|
||||
:disabled="scannedCount === 0 || isSubmitting || !selectedCaseId"
|
||||
:loading="isSubmitting"
|
||||
@click="submit"
|
||||
>
|
||||
Valider ({{ scannedCount }})
|
||||
</UiButton>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, nextTick, onMounted, watch } from 'vue'
|
||||
import { useBarcodeScanner } from '~/composables/useBarcodeScanner'
|
||||
import { createBovine } from '~/services/bovine'
|
||||
import { getBuildingList } from '~/services/building'
|
||||
import type { BuildingData } from '~/services/dto/building-data'
|
||||
|
||||
const videoRef = ref<HTMLVideoElement>()
|
||||
const entries = ref<string[]>([''])
|
||||
const inputRefs = ref<(HTMLInputElement | null)[]>([])
|
||||
const isSubmitting = ref(false)
|
||||
const lastScanned = ref('')
|
||||
const showScanner = ref(false)
|
||||
|
||||
const buildings = ref<BuildingData[]>([])
|
||||
const selectedBuildingId = ref<string | number | null>(null)
|
||||
const selectedCaseId = ref<string | number | null>(null)
|
||||
|
||||
const buildingOptions = computed(() =>
|
||||
buildings.value.map(b => ({ value: b.id, label: b.label }))
|
||||
)
|
||||
|
||||
const caseOptions = computed(() => {
|
||||
const building = buildings.value.find(b => b.id === Number(selectedBuildingId.value))
|
||||
if (!building?.buildingCases) return []
|
||||
return [...building.buildingCases]
|
||||
.sort((a, b) => (a.caseNumber ?? 0) - (b.caseNumber ?? 0))
|
||||
.map(c => ({
|
||||
value: c.id,
|
||||
label: `Case ${c.caseNumber ?? c.code ?? c.id}`
|
||||
}))
|
||||
})
|
||||
|
||||
watch(selectedBuildingId, () => {
|
||||
selectedCaseId.value = null
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
buildings.value = await getBuildingList()
|
||||
})
|
||||
|
||||
const scannedCount = computed(() => entries.value.filter(e => e.trim() !== '').length)
|
||||
|
||||
function setInputRef(el: HTMLInputElement | null, index: number) {
|
||||
inputRefs.value[index] = el
|
||||
}
|
||||
|
||||
const scanner = useBarcodeScanner((code: string) => {
|
||||
if (entries.value.some(e => e.trim() === code)) return
|
||||
|
||||
const emptyIndex = entries.value.findIndex(e => e.trim() === '')
|
||||
if (emptyIndex !== -1) {
|
||||
entries.value[emptyIndex] = code
|
||||
} else {
|
||||
entries.value.push(code)
|
||||
}
|
||||
|
||||
lastScanned.value = code
|
||||
entries.value.push('')
|
||||
})
|
||||
|
||||
function startScanning() {
|
||||
showScanner.value = true
|
||||
nextTick(() => {
|
||||
if (videoRef.value) {
|
||||
scanner.start(videoRef.value)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function stopScanning() {
|
||||
scanner.stop()
|
||||
showScanner.value = false
|
||||
lastScanned.value = ''
|
||||
}
|
||||
|
||||
function removeEntry(index: number) {
|
||||
entries.value.splice(index, 1)
|
||||
inputRefs.value.splice(index, 1)
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
const numbers = entries.value.filter(e => e.trim() !== '').map(e => e.trim())
|
||||
if (numbers.length === 0 || !selectedCaseId.value) return
|
||||
|
||||
const caseIri = `/api/building_cases/${selectedCaseId.value}`
|
||||
isSubmitting.value = true
|
||||
|
||||
try {
|
||||
let successCount = 0
|
||||
for (const nationalNumber of numbers) {
|
||||
const result = await createBovine({ nationalNumber, buildingCase: caseIri })
|
||||
if (result) successCount++
|
||||
}
|
||||
|
||||
if (successCount > 0) {
|
||||
clearAll()
|
||||
}
|
||||
} finally {
|
||||
isSubmitting.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
29
frontend/services/bovine.ts
Normal file
29
frontend/services/bovine.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { useApi } from '~/composables/useApi'
|
||||
import type { BovineData, BovinePayload } from '~/services/dto/bovine-data'
|
||||
|
||||
export async function createBovine(payload: BovinePayload) {
|
||||
const api = useApi()
|
||||
return api.post<BovineData>('bovines', payload, {
|
||||
headers: { 'Content-Type': 'application/ld+json' },
|
||||
toastErrorKey: 'errors.bovine.create',
|
||||
toastSuccessKey: 'success.bovine.create'
|
||||
})
|
||||
}
|
||||
|
||||
export async function createBovines(nationalNumbers: string[]): Promise<{ created: BovineData[]; errors: string[] }> {
|
||||
const created: BovineData[] = []
|
||||
const errors: string[] = []
|
||||
|
||||
for (const nationalNumber of nationalNumbers) {
|
||||
try {
|
||||
const bovine = await createBovine({ nationalNumber })
|
||||
if (bovine) {
|
||||
created.push(bovine)
|
||||
}
|
||||
} catch {
|
||||
errors.push(nationalNumber)
|
||||
}
|
||||
}
|
||||
|
||||
return { created, errors }
|
||||
}
|
||||
14
frontend/services/dto/bovine-data.ts
Normal file
14
frontend/services/dto/bovine-data.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
export interface BovineData {
|
||||
id: number
|
||||
nationalNumber: string
|
||||
receivedWeight: number | null
|
||||
arrivalDate: string | null
|
||||
buildingCase: string | null
|
||||
}
|
||||
|
||||
export type BovinePayload = {
|
||||
nationalNumber?: string
|
||||
receivedWeight?: number | null
|
||||
arrivalDate?: string | null
|
||||
buildingCase?: string | null
|
||||
}
|
||||
@@ -1,8 +1,10 @@
|
||||
import type { BuildingLayoutData } from '~/services/dto/building-layout-data'
|
||||
import type { BuildingCaseData } from '~/services/dto/building-case-data'
|
||||
|
||||
export interface BuildingData {
|
||||
id: number
|
||||
label: string
|
||||
code: string
|
||||
layouts?: BuildingLayoutData[] | null
|
||||
buildingCases?: BuildingCaseData[] | null
|
||||
}
|
||||
|
||||
@@ -2,16 +2,19 @@ export interface UserData {
|
||||
id: number
|
||||
username: string
|
||||
roles: string[]
|
||||
isLocked: boolean
|
||||
}
|
||||
|
||||
export type UserPayload = {
|
||||
username?: string
|
||||
password?: string
|
||||
roles?: string[]
|
||||
isLocked?: boolean
|
||||
}
|
||||
|
||||
export type UserFormData = {
|
||||
username: string
|
||||
password: string
|
||||
role: string
|
||||
isLocked: boolean
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import {defineStore} from 'pinia'
|
||||
import type {UserData} from '~/services/dto/user-data'
|
||||
import {getCurrentUser, createUser, login, logout} from '~/services/auth'
|
||||
import {getCurrentUser, createUser, updateUser, login, logout} from '~/services/auth'
|
||||
import type {UserPayload} from "~/services/dto/user-data";
|
||||
import {ROLE} from '~/utils/constants'
|
||||
|
||||
@@ -58,7 +58,7 @@ export const useAuthStore = defineStore('auth', {
|
||||
},
|
||||
async updateUser(id: number, payload: UserPayload) {
|
||||
this.isLoading = true
|
||||
const result = await createUser(payload).finally(() => {
|
||||
const result = await updateUser(id, payload).finally(() => {
|
||||
this.isLoading = false
|
||||
})
|
||||
return result
|
||||
|
||||
31
migrations/Version20260325142815.php
Normal file
31
migrations/Version20260325142815.php
Normal file
@@ -0,0 +1,31 @@
|
||||
<?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 Version20260325142815 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('ALTER TABLE "user" ADD is_locked BOOLEAN DEFAULT false NOT NULL');
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
// this down() migration is auto-generated, please modify it to your needs
|
||||
$this->addSql('ALTER TABLE public."user" DROP is_locked');
|
||||
}
|
||||
}
|
||||
@@ -53,6 +53,8 @@ class Building
|
||||
* @var Collection<int, BuildingCase>
|
||||
*/
|
||||
#[ORM\OneToMany(targetEntity: BuildingCase::class, mappedBy: 'id_building')]
|
||||
#[Groups(['building:read'])]
|
||||
#[SerializedName('buildingCases')]
|
||||
private Collection $buildingCases;
|
||||
|
||||
/**
|
||||
|
||||
@@ -17,6 +17,10 @@ use Symfony\Component\Serializer\Attribute\SerializedName;
|
||||
#[ORM\Entity]
|
||||
#[ApiResource(
|
||||
operations: [
|
||||
new Get(
|
||||
requirements: ['id' => '\d+'],
|
||||
normalizationContext: ['groups' => ['building:read']],
|
||||
),
|
||||
new Get(
|
||||
uriTemplate: '/building_cases/{id}/weights-report',
|
||||
requirements: ['id' => '\d+'],
|
||||
|
||||
@@ -9,12 +9,14 @@ use ApiPlatform\Metadata\Get;
|
||||
use ApiPlatform\Metadata\GetCollection;
|
||||
use ApiPlatform\Metadata\Patch;
|
||||
use ApiPlatform\Metadata\Post;
|
||||
use App\State\ActiveUsersProvider;
|
||||
use App\State\MeProvider;
|
||||
use App\State\UserPasswordProcessor;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface;
|
||||
use Symfony\Component\Security\Core\User\UserInterface;
|
||||
use Symfony\Component\Serializer\Attribute\Groups;
|
||||
use Symfony\Component\Serializer\Attribute\SerializedName;
|
||||
|
||||
#[ORM\Entity]
|
||||
#[ORM\Table(name: 'user', schema: 'public')]
|
||||
@@ -45,7 +47,8 @@ use Symfony\Component\Serializer\Attribute\Groups;
|
||||
),
|
||||
new GetCollection(
|
||||
normalizationContext: ['groups' => ['user-login:read']],
|
||||
security: "is_granted('PUBLIC_ACCESS')"
|
||||
security: "is_granted('PUBLIC_ACCESS')",
|
||||
provider: ActiveUsersProvider::class
|
||||
),
|
||||
new GetCollection(
|
||||
uriTemplate: '/admin/users',
|
||||
@@ -76,6 +79,11 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
|
||||
#[Groups(['user:write'])]
|
||||
private string $password = '';
|
||||
|
||||
#[ORM\Column(type: 'boolean', options: ['default' => false])]
|
||||
#[Groups(['user:read', 'user:write'])]
|
||||
#[SerializedName('isLocked')]
|
||||
private bool $isLocked = false;
|
||||
|
||||
public function getId(): ?int
|
||||
{
|
||||
return $this->id;
|
||||
@@ -125,6 +133,18 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getIsLocked(): bool
|
||||
{
|
||||
return $this->isLocked;
|
||||
}
|
||||
|
||||
public function setIsLocked(bool $isLocked): self
|
||||
{
|
||||
$this->isLocked = $isLocked;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function eraseCredentials(): void
|
||||
{
|
||||
// No-op: we don't store temporary sensitive data on the entity.
|
||||
|
||||
27
src/Security/UserChecker.php
Normal file
27
src/Security/UserChecker.php
Normal file
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Security;
|
||||
|
||||
use App\Entity\User;
|
||||
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
|
||||
use Symfony\Component\Security\Core\Exception\CustomUserMessageAccountStatusException;
|
||||
use Symfony\Component\Security\Core\User\UserCheckerInterface;
|
||||
use Symfony\Component\Security\Core\User\UserInterface;
|
||||
|
||||
final class UserChecker implements UserCheckerInterface
|
||||
{
|
||||
public function checkPreAuth(UserInterface $user): void
|
||||
{
|
||||
if (!$user instanceof User) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ($user->getIsLocked()) {
|
||||
throw new CustomUserMessageAccountStatusException('Ce compte est verrouillé.');
|
||||
}
|
||||
}
|
||||
|
||||
public function checkPostAuth(UserInterface $user, ?TokenInterface $token = null): void {}
|
||||
}
|
||||
20
src/State/ActiveUsersProvider.php
Normal file
20
src/State/ActiveUsersProvider.php
Normal file
@@ -0,0 +1,20 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\State;
|
||||
|
||||
use ApiPlatform\Metadata\Operation;
|
||||
use ApiPlatform\State\ProviderInterface;
|
||||
use App\Entity\User;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
|
||||
final readonly class ActiveUsersProvider implements ProviderInterface
|
||||
{
|
||||
public function __construct(private EntityManagerInterface $em) {}
|
||||
|
||||
public function provide(Operation $operation, array $uriVariables = [], array $context = []): array
|
||||
{
|
||||
return $this->em->getRepository(User::class)->findBy(['isLocked' => false]);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user