Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e74a264b37 | ||
| 60bb3cf8c4 | |||
|
|
1a485e8780 | ||
| 5c6d42c729 |
@@ -19,6 +19,7 @@ security:
|
|||||||
pattern: ^/login_check
|
pattern: ^/login_check
|
||||||
stateless: true
|
stateless: true
|
||||||
provider: app_user_provider
|
provider: app_user_provider
|
||||||
|
user_checker: App\Security\UserChecker
|
||||||
json_login:
|
json_login:
|
||||||
check_path: /login_check
|
check_path: /login_check
|
||||||
username_path: username
|
username_path: username
|
||||||
@@ -29,6 +30,7 @@ security:
|
|||||||
pattern: ^/api
|
pattern: ^/api
|
||||||
stateless: true
|
stateless: true
|
||||||
provider: app_user_provider
|
provider: app_user_provider
|
||||||
|
user_checker: App\Security\UserChecker
|
||||||
jwt: ~
|
jwt: ~
|
||||||
logout:
|
logout:
|
||||||
path: /api/logout
|
path: /api/logout
|
||||||
|
|||||||
@@ -1,2 +1,2 @@
|
|||||||
parameters:
|
parameters:
|
||||||
app.version: '0.1.63'
|
app.version: '0.1.65'
|
||||||
|
|||||||
@@ -372,7 +372,26 @@ Tous les filtres checkbox sont cochés par défaut à l'ouverture du drawer.
|
|||||||
- Justificatif Montant : upload via `/mileage_allowances/{id}/amount-receipt`, téléchargement via GET même URL
|
- Justificatif Montant : upload via `/mileage_allowances/{id}/amount-receipt`, téléchargement via GET même URL
|
||||||
- La suppression d'un frais supprime les deux fichiers justificatifs du disque
|
- La suppression d'un frais supprime les deux fichiers justificatifs du disque
|
||||||
|
|
||||||
## 13) Notifications
|
## 13) Observations
|
||||||
|
|
||||||
|
- Onglet "Observation" sur la fiche employé (icône `mdi:note-text-outline`)
|
||||||
|
- Entité `Observation` (table `observations`)
|
||||||
|
- Champs:
|
||||||
|
- `month` (mois, obligatoire)
|
||||||
|
- `content` (texte d'observation, obligatoire)
|
||||||
|
- Contrainte: une seule observation par mois par employé (unique sur `employee_id + month`)
|
||||||
|
- Tableau: colonnes Mois | Observation
|
||||||
|
- Drawer avec champs mois (`type="month"`) et textarea "Observation"
|
||||||
|
- CRUD standard: création, modification, suppression avec confirmation
|
||||||
|
|
||||||
|
## 14) Verrouillage utilisateur
|
||||||
|
|
||||||
|
- Champ `isLocked` (boolean, default false) sur l'entité `User`
|
||||||
|
- Un admin peut verrouiller/déverrouiller un utilisateur depuis la page Utilisateurs (checkbox dans le drawer)
|
||||||
|
- Un utilisateur verrouillé ne peut plus se connecter (vérification via `UserChecker` sur les firewalls `login` et `api`)
|
||||||
|
- Colonne "Statut" dans le tableau utilisateurs avec label "Actif" (vert) ou "Verrouillé" (rouge)
|
||||||
|
|
||||||
|
## 15) Notifications
|
||||||
|
|
||||||
- Icône cloche en topbar:
|
- Icône cloche en topbar:
|
||||||
- badge = nombre de notifications non lues
|
- badge = nombre de notifications non lues
|
||||||
|
|||||||
187
frontend/components/employees/ObservationTab.vue
Normal file
187
frontend/components/employees/ObservationTab.vue
Normal file
@@ -0,0 +1,187 @@
|
|||||||
|
<template>
|
||||||
|
<section class="mt-8">
|
||||||
|
<div class="overflow-hidden bg-white">
|
||||||
|
<div
|
||||||
|
class="grid grid-cols-2 border border-black bg-tertiary-500 px-6 py-3 text-[20px] font-semibold text-black rounded-t-md">
|
||||||
|
<p>Mois</p>
|
||||||
|
<p>Observation</p>
|
||||||
|
</div>
|
||||||
|
<div v-if="observations.length === 0" class="px-6 py-4 text-[20px] font-bold text-primary-500 border-x border-b border-primary-500 rounded-b-md">
|
||||||
|
Aucune observation.
|
||||||
|
</div>
|
||||||
|
<div v-else class="border-x border-b border-primary-500 rounded-b-md">
|
||||||
|
<div
|
||||||
|
v-for="item in observations"
|
||||||
|
:key="item.id"
|
||||||
|
class="grid grid-cols-2 border-b border-primary-500 px-6 py-3 text-md font-bold text-primary-500 last:border-b-0 cursor-pointer hover:bg-tertiary-500"
|
||||||
|
@click="onOpenEditDrawer(item)"
|
||||||
|
>
|
||||||
|
<p>{{ formatMonth(item.month) }}</p>
|
||||||
|
<p class="truncate">{{ item.content }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-center mb-4 mt-8">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="flex w-[200px] items-center justify-center gap-2 rounded-md bg-primary-500 px-4 py-2 text-md text-white disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
@click="onOpenCreateDrawer"
|
||||||
|
>
|
||||||
|
+ Ajouter
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<AppDrawer v-model="isDrawerOpen" :title="isEditing ? 'Modification observation' : 'Nouvelle observation'">
|
||||||
|
<form class="space-y-4" @submit.prevent="onSubmit">
|
||||||
|
<div>
|
||||||
|
<label class="text-md font-semibold text-neutral-700" for="observation-month">
|
||||||
|
Mois <span class="text-red-600">*</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="observation-month"
|
||||||
|
v-model="form.month"
|
||||||
|
type="month"
|
||||||
|
class="capitalize mt-2 w-full rounded-md border border-neutral-300 px-3 py-2 text-base text-neutral-900 focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-secondary-500/20"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="text-md font-semibold text-neutral-700" for="observation-content">
|
||||||
|
Observation <span class="text-red-600">*</span>
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
id="observation-content"
|
||||||
|
v-model="form.content"
|
||||||
|
rows="5"
|
||||||
|
class="mt-2 w-full rounded-md border border-neutral-300 px-3 py-2 text-base text-neutral-900 focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-secondary-500/20"
|
||||||
|
placeholder="Observation..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="isEditing" class="grid grid-cols-2 gap-3 pt-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="flex items-center justify-center rounded-md bg-red-500 px-4 py-2 text-md font-semibold text-white hover:bg-red-600"
|
||||||
|
@click="onDelete"
|
||||||
|
>
|
||||||
|
Supprimer
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="flex items-center justify-center rounded-md bg-primary-500 px-4 py-2 text-md font-semibold text-white hover:bg-secondary-500 disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
:disabled="!isFormValid"
|
||||||
|
>
|
||||||
|
Modifier
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div v-else class="flex justify-center pt-2">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="flex w-[200px] items-center justify-center gap-2 rounded-md bg-primary-500 px-4 py-2 text-md font-semibold text-white hover:bg-secondary-500 disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
:disabled="!isFormValid"
|
||||||
|
>
|
||||||
|
+ Ajouter
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</AppDrawer>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type { Observation } from '~/services/dto/observation'
|
||||||
|
import AppDrawer from '~/components/AppDrawer.vue'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
observations: Observation[]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(event: 'create', data: { month: string; content: string }): void
|
||||||
|
(event: 'update', id: number, data: { month: string; content: string }): void
|
||||||
|
(event: 'delete', id: number): void
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const isDrawerOpen = ref(false)
|
||||||
|
const isEditing = ref(false)
|
||||||
|
const editingItem = ref<Observation | null>(null)
|
||||||
|
|
||||||
|
const currentYearMonth = () => {
|
||||||
|
const now = new Date()
|
||||||
|
return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}`
|
||||||
|
}
|
||||||
|
|
||||||
|
const form = reactive({
|
||||||
|
month: currentYearMonth(),
|
||||||
|
content: ''
|
||||||
|
})
|
||||||
|
|
||||||
|
const isFormValid = computed(() => {
|
||||||
|
return form.month && form.content.trim().length > 0
|
||||||
|
})
|
||||||
|
|
||||||
|
const monthLabels: Record<number, string> = {
|
||||||
|
1: 'Janvier',
|
||||||
|
2: 'Février',
|
||||||
|
3: 'Mars',
|
||||||
|
4: 'Avril',
|
||||||
|
5: 'Mai',
|
||||||
|
6: 'Juin',
|
||||||
|
7: 'Juillet',
|
||||||
|
8: 'Août',
|
||||||
|
9: 'Septembre',
|
||||||
|
10: 'Octobre',
|
||||||
|
11: 'Novembre',
|
||||||
|
12: 'Décembre'
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatMonth = (dateStr: string): string => {
|
||||||
|
const date = new Date(dateStr)
|
||||||
|
if (Number.isNaN(date.getTime())) return dateStr
|
||||||
|
const month = date.getMonth() + 1
|
||||||
|
const year = date.getFullYear()
|
||||||
|
return `${monthLabels[month]} ${year}`
|
||||||
|
}
|
||||||
|
|
||||||
|
const resetForm = () => {
|
||||||
|
form.month = currentYearMonth()
|
||||||
|
form.content = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
const onOpenCreateDrawer = () => {
|
||||||
|
isEditing.value = false
|
||||||
|
editingItem.value = null
|
||||||
|
resetForm()
|
||||||
|
isDrawerOpen.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
const onOpenEditDrawer = (item: Observation) => {
|
||||||
|
isEditing.value = true
|
||||||
|
editingItem.value = item
|
||||||
|
form.month = item.month.substring(0, 7)
|
||||||
|
form.content = item.content
|
||||||
|
isDrawerOpen.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
const onSubmit = () => {
|
||||||
|
const data = {
|
||||||
|
month: `${form.month}-01`,
|
||||||
|
content: form.content
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isEditing.value && editingItem.value) {
|
||||||
|
emit('update', editingItem.value.id, data)
|
||||||
|
} else {
|
||||||
|
emit('create', data)
|
||||||
|
}
|
||||||
|
isDrawerOpen.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
const onDelete = () => {
|
||||||
|
if (!editingItem.value) return
|
||||||
|
const ok = window.confirm('Supprimer cette observation ?')
|
||||||
|
if (!ok) return
|
||||||
|
emit('delete', editingItem.value.id)
|
||||||
|
isDrawerOpen.value = false
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -6,7 +6,7 @@ export const useEmployeeDetailPage = () => {
|
|||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const employee = ref<Employee | null>(null)
|
const employee = ref<Employee | null>(null)
|
||||||
const isLoading = ref(false)
|
const isLoading = ref(false)
|
||||||
const activeTab = ref<'contract' | 'leave' | 'rtt' | 'mileage' | 'bonus'>('contract')
|
const activeTab = ref<'contract' | 'leave' | 'rtt' | 'mileage' | 'bonus' | 'observation'>('contract')
|
||||||
|
|
||||||
const showLeaveTab = computed(() => employee.value?.currentContractNature !== 'INTERIM')
|
const showLeaveTab = computed(() => employee.value?.currentContractNature !== 'INTERIM')
|
||||||
const showRttTab = computed(() => employee.value?.contract?.type !== CONTRACT_TYPES.FORFAIT)
|
const showRttTab = computed(() => employee.value?.contract?.type !== CONTRACT_TYPES.FORFAIT)
|
||||||
@@ -40,6 +40,7 @@ export const useEmployeeDetailPage = () => {
|
|||||||
rtt.resetLoaded()
|
rtt.resetLoaded()
|
||||||
mileage.resetLoaded()
|
mileage.resetLoaded()
|
||||||
bonus.resetLoaded()
|
bonus.resetLoaded()
|
||||||
|
observation.resetLoaded()
|
||||||
|
|
||||||
if (activeTab.value === 'leave' && showLeaveTab.value) {
|
if (activeTab.value === 'leave' && showLeaveTab.value) {
|
||||||
await leave.loadLeaveData()
|
await leave.loadLeaveData()
|
||||||
@@ -49,6 +50,8 @@ export const useEmployeeDetailPage = () => {
|
|||||||
await mileage.loadMileageData()
|
await mileage.loadMileageData()
|
||||||
} else if (activeTab.value === 'bonus') {
|
} else if (activeTab.value === 'bonus') {
|
||||||
await bonus.loadBonusData()
|
await bonus.loadBonusData()
|
||||||
|
} else if (activeTab.value === 'observation') {
|
||||||
|
await observation.loadObservationData()
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
isLoading.value = false
|
isLoading.value = false
|
||||||
@@ -60,6 +63,7 @@ export const useEmployeeDetailPage = () => {
|
|||||||
const rtt = useEmployeeRtt(employee, loadEmployee)
|
const rtt = useEmployeeRtt(employee, loadEmployee)
|
||||||
const mileage = useEmployeeMileage(employee, loadEmployee)
|
const mileage = useEmployeeMileage(employee, loadEmployee)
|
||||||
const bonus = useEmployeeBonus(employee, loadEmployee)
|
const bonus = useEmployeeBonus(employee, loadEmployee)
|
||||||
|
const observation = useEmployeeObservation(employee, loadEmployee)
|
||||||
|
|
||||||
watch(activeTab, (tab) => {
|
watch(activeTab, (tab) => {
|
||||||
if (tab === 'leave' && !leave.leaveDataLoaded.value && showLeaveTab.value) {
|
if (tab === 'leave' && !leave.leaveDataLoaded.value && showLeaveTab.value) {
|
||||||
@@ -70,6 +74,8 @@ export const useEmployeeDetailPage = () => {
|
|||||||
mileage.loadMileageData()
|
mileage.loadMileageData()
|
||||||
} else if (tab === 'bonus' && !bonus.bonusDataLoaded.value) {
|
} else if (tab === 'bonus' && !bonus.bonusDataLoaded.value) {
|
||||||
bonus.loadBonusData()
|
bonus.loadBonusData()
|
||||||
|
} else if (tab === 'observation' && !observation.observationDataLoaded.value) {
|
||||||
|
observation.loadObservationData()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -89,6 +95,7 @@ export const useEmployeeDetailPage = () => {
|
|||||||
...leave,
|
...leave,
|
||||||
...rtt,
|
...rtt,
|
||||||
...mileage,
|
...mileage,
|
||||||
...bonus
|
...bonus,
|
||||||
|
...observation
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
61
frontend/composables/useEmployeeObservation.ts
Normal file
61
frontend/composables/useEmployeeObservation.ts
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
import type { Ref } from 'vue'
|
||||||
|
import type { Observation } from '~/services/dto/observation'
|
||||||
|
import type { Employee } from '~/services/dto/employee'
|
||||||
|
import {
|
||||||
|
listObservations,
|
||||||
|
createObservation,
|
||||||
|
updateObservation,
|
||||||
|
deleteObservation
|
||||||
|
} from '~/services/observations'
|
||||||
|
|
||||||
|
export const useEmployeeObservation = (employee: Ref<Employee | null>, reloadEmployee: () => Promise<void>) => {
|
||||||
|
const observations = ref<Observation[]>([])
|
||||||
|
const isObservationLoading = ref(false)
|
||||||
|
const observationDataLoaded = ref(false)
|
||||||
|
|
||||||
|
const loadObservationData = async () => {
|
||||||
|
if (!employee.value || isObservationLoading.value) return
|
||||||
|
isObservationLoading.value = true
|
||||||
|
try {
|
||||||
|
observations.value = await listObservations(employee.value.id)
|
||||||
|
observationDataLoaded.value = true
|
||||||
|
} finally {
|
||||||
|
isObservationLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const resetLoaded = () => {
|
||||||
|
observationDataLoaded.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
const submitCreateObservation = async (data: { month: string; content: string }) => {
|
||||||
|
if (!employee.value) return
|
||||||
|
await createObservation({
|
||||||
|
employeeId: employee.value.id,
|
||||||
|
month: data.month,
|
||||||
|
content: data.content
|
||||||
|
})
|
||||||
|
await reloadEmployee()
|
||||||
|
}
|
||||||
|
|
||||||
|
const submitUpdateObservation = async (id: number, data: { month: string; content: string }) => {
|
||||||
|
await updateObservation(id, data)
|
||||||
|
await reloadEmployee()
|
||||||
|
}
|
||||||
|
|
||||||
|
const submitDeleteObservation = async (id: number) => {
|
||||||
|
await deleteObservation(id)
|
||||||
|
await reloadEmployee()
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
observations,
|
||||||
|
isObservationLoading,
|
||||||
|
observationDataLoaded,
|
||||||
|
loadObservationData,
|
||||||
|
resetLoaded,
|
||||||
|
submitCreateObservation,
|
||||||
|
submitUpdateObservation,
|
||||||
|
submitDeleteObservation
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -46,6 +46,11 @@
|
|||||||
"create": "Impossible de créer la prime.",
|
"create": "Impossible de créer la prime.",
|
||||||
"update": "Impossible de mettre à jour la prime.",
|
"update": "Impossible de mettre à jour la prime.",
|
||||||
"delete": "Impossible de supprimer la prime."
|
"delete": "Impossible de supprimer la prime."
|
||||||
|
},
|
||||||
|
"observation": {
|
||||||
|
"create": "Impossible de créer l'observation.",
|
||||||
|
"update": "Impossible de mettre à jour l'observation.",
|
||||||
|
"delete": "Impossible de supprimer l'observation."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"success": {
|
"success": {
|
||||||
@@ -87,6 +92,11 @@
|
|||||||
"create": "Prime créée.",
|
"create": "Prime créée.",
|
||||||
"update": "Prime mise à jour.",
|
"update": "Prime mise à jour.",
|
||||||
"delete": "Prime supprimée."
|
"delete": "Prime supprimée."
|
||||||
|
},
|
||||||
|
"observation": {
|
||||||
|
"create": "Observation créée.",
|
||||||
|
"update": "Observation mise à jour.",
|
||||||
|
"delete": "Observation supprimée."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -84,6 +84,16 @@
|
|||||||
<Icon name="mdi:money-100" size="24" class="align-self"/>
|
<Icon name="mdi:money-100" size="24" class="align-self"/>
|
||||||
Prime
|
Prime
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
class="pb-2 border-b-2 flex items-center gap-3"
|
||||||
|
:class="activeTab === 'observation'
|
||||||
|
? 'border-primary-500 text-primary-500'
|
||||||
|
: 'border-transparent text-primary-500/50 hover:text-primary-500'"
|
||||||
|
@click="activeTab = 'observation'"
|
||||||
|
>
|
||||||
|
<Icon name="mdi:note-text-outline" size="24" class="align-self"/>
|
||||||
|
Observation
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="min-h-0 flex-1">
|
<div class="min-h-0 flex-1">
|
||||||
@@ -173,6 +183,19 @@
|
|||||||
@delete="submitDeleteBonus"
|
@delete="submitDeleteBonus"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<div v-else-if="activeTab === 'observation'" class="h-full">
|
||||||
|
<div v-if="isObservationLoading" class="mt-6 rounded-lg border border-neutral-200 bg-white p-6 text-md text-neutral-600">
|
||||||
|
Chargement...
|
||||||
|
</div>
|
||||||
|
<EmployeesObservationTab
|
||||||
|
v-else
|
||||||
|
class="h-full"
|
||||||
|
:observations="observations"
|
||||||
|
@create="submitCreateObservation"
|
||||||
|
@update="submitUpdateObservation"
|
||||||
|
@delete="submitDeleteObservation"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -254,7 +277,12 @@ const {
|
|||||||
isBonusLoading,
|
isBonusLoading,
|
||||||
submitCreateBonus,
|
submitCreateBonus,
|
||||||
submitUpdateBonus,
|
submitUpdateBonus,
|
||||||
submitDeleteBonus
|
submitDeleteBonus,
|
||||||
|
observations,
|
||||||
|
isObservationLoading,
|
||||||
|
submitCreateObservation,
|
||||||
|
submitUpdateObservation,
|
||||||
|
submitDeleteObservation
|
||||||
} = useEmployeeDetailPage()
|
} = useEmployeeDetailPage()
|
||||||
|
|
||||||
const handleYearlyHoursPrint = async (year: number) => {
|
const handleYearlyHoursPrint = async (year: number) => {
|
||||||
|
|||||||
@@ -19,11 +19,12 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-else class="min-h-0 overflow-auto rounded-md bg-white">
|
<div v-else class="min-h-0 overflow-auto rounded-md bg-white">
|
||||||
<div class="grid grid-cols-4 gap-4 border border-black bg-tertiary-500 px-6 py-3 text-[20px] font-semibold text-black rounded-t-md sticky top-0 z-10">
|
<div class="grid grid-cols-5 gap-4 border border-black bg-tertiary-500 px-6 py-3 text-[20px] font-semibold text-black rounded-t-md sticky top-0 z-10">
|
||||||
<span class="text-left">Utilisateur</span>
|
<span class="text-left">Utilisateur</span>
|
||||||
<span class="text-left">Employé</span>
|
<span class="text-left">Employé</span>
|
||||||
<span class="text-left">Accès</span>
|
<span class="text-left">Accès</span>
|
||||||
<span class="text-left">Sites</span>
|
<span class="text-left">Sites</span>
|
||||||
|
<span class="text-left">Statut</span>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="isLoading" class="px-6 py-4 text-md text-neutral-500">
|
<div v-if="isLoading" class="px-6 py-4 text-md text-neutral-500">
|
||||||
Chargement...
|
Chargement...
|
||||||
@@ -32,7 +33,7 @@
|
|||||||
<div
|
<div
|
||||||
v-for="user in users"
|
v-for="user in users"
|
||||||
:key="user.id"
|
:key="user.id"
|
||||||
class="grid grid-cols-4 items-center gap-4 border-b border-primary-500 px-6 py-3 text-md font-bold text-primary-500 last:border-b-0 cursor-pointer hover:bg-tertiary-500"
|
class="grid grid-cols-5 items-center gap-4 border-b border-primary-500 px-6 py-3 text-md font-bold text-primary-500 last:border-b-0 cursor-pointer hover:bg-tertiary-500"
|
||||||
@click="openEdit(user)"
|
@click="openEdit(user)"
|
||||||
>
|
>
|
||||||
<span>{{ user.username }}</span>
|
<span>{{ user.username }}</span>
|
||||||
@@ -41,6 +42,16 @@
|
|||||||
</span>
|
</span>
|
||||||
<span>{{ getAccessLabel(user) }}</span>
|
<span>{{ getAccessLabel(user) }}</span>
|
||||||
<span>{{ getSiteLabels(user) }}</span>
|
<span>{{ getSiteLabels(user) }}</span>
|
||||||
|
<span>
|
||||||
|
<span
|
||||||
|
v-if="user.isLocked"
|
||||||
|
class="inline-block rounded-full bg-red-100 px-3 py-1 text-sm font-semibold text-red-700"
|
||||||
|
>Verrouillé</span>
|
||||||
|
<span
|
||||||
|
v-else
|
||||||
|
class="inline-block rounded-full bg-green-100 px-3 py-1 text-sm font-semibold text-green-700"
|
||||||
|
>Actif</span>
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -164,6 +175,20 @@
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="flex items-center gap-2 cursor-pointer">
|
||||||
|
<input
|
||||||
|
v-model="form.isLocked"
|
||||||
|
type="checkbox"
|
||||||
|
class="cursor-pointer"
|
||||||
|
/>
|
||||||
|
<span class="text-md font-semibold text-neutral-700">Verrouiller le compte</span>
|
||||||
|
</label>
|
||||||
|
<p class="mt-1 text-sm text-neutral-500">
|
||||||
|
Un compte verrouillé ne peut plus se connecter.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="flex justify-center pt-2">
|
<div class="flex justify-center pt-2">
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
@@ -207,7 +232,8 @@ const form = reactive({
|
|||||||
password: '',
|
password: '',
|
||||||
accessMode: 'admin' as 'admin' | 'self' | 'sites',
|
accessMode: 'admin' as 'admin' | 'self' | 'sites',
|
||||||
employeeId: '' as number | '',
|
employeeId: '' as number | '',
|
||||||
siteIds: [] as number[]
|
siteIds: [] as number[],
|
||||||
|
isLocked: false
|
||||||
})
|
})
|
||||||
|
|
||||||
const validationTouched = reactive({
|
const validationTouched = reactive({
|
||||||
@@ -318,6 +344,7 @@ const resetForm = () => {
|
|||||||
form.employeeId = ''
|
form.employeeId = ''
|
||||||
form.accessMode = 'admin'
|
form.accessMode = 'admin'
|
||||||
form.siteIds = []
|
form.siteIds = []
|
||||||
|
form.isLocked = false
|
||||||
editingUser.value = null
|
editingUser.value = null
|
||||||
validationTouched.username = false
|
validationTouched.username = false
|
||||||
validationTouched.password = false
|
validationTouched.password = false
|
||||||
@@ -345,6 +372,7 @@ const openEdit = (user: User) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
form.employeeId = user.employee?.id ?? ''
|
form.employeeId = user.employee?.id ?? ''
|
||||||
|
form.isLocked = user.isLocked
|
||||||
|
|
||||||
const siteRoles = userAccessById.value.get(user.id) ?? []
|
const siteRoles = userAccessById.value.get(user.id) ?? []
|
||||||
form.siteIds = siteRoles.map((role) => role.site?.id).filter((id): id is number => typeof id === 'number')
|
form.siteIds = siteRoles.map((role) => role.site?.id).filter((id): id is number => typeof id === 'number')
|
||||||
@@ -398,7 +426,8 @@ const handleSubmit = async () => {
|
|||||||
username: form.username,
|
username: form.username,
|
||||||
plainPassword: form.password.trim() ? form.password : undefined,
|
plainPassword: form.password.trim() ? form.password : undefined,
|
||||||
roles,
|
roles,
|
||||||
employeeId
|
employeeId,
|
||||||
|
isLocked: form.isLocked
|
||||||
})
|
})
|
||||||
|
|
||||||
const existingSiteRoles = userAccessById.value.get(editingUser.value.id) ?? []
|
const existingSiteRoles = userAccessById.value.get(editingUser.value.id) ?? []
|
||||||
@@ -422,7 +451,8 @@ const handleSubmit = async () => {
|
|||||||
username: form.username,
|
username: form.username,
|
||||||
plainPassword: form.password,
|
plainPassword: form.password,
|
||||||
roles,
|
roles,
|
||||||
employeeId
|
employeeId,
|
||||||
|
isLocked: form.isLocked
|
||||||
})
|
})
|
||||||
|
|
||||||
if (form.accessMode === 'sites' && form.siteIds.length > 0) {
|
if (form.accessMode === 'sites' && form.siteIds.length > 0) {
|
||||||
|
|||||||
6
frontend/services/dto/observation.ts
Normal file
6
frontend/services/dto/observation.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
export type Observation = {
|
||||||
|
id: number
|
||||||
|
month: string
|
||||||
|
content: string
|
||||||
|
createdAt: string
|
||||||
|
}
|
||||||
@@ -4,5 +4,6 @@ export type User = {
|
|||||||
id: number
|
id: number
|
||||||
username: string
|
username: string
|
||||||
roles: string[]
|
roles: string[]
|
||||||
|
isLocked: boolean
|
||||||
employee?: Employee | null
|
employee?: Employee | null
|
||||||
}
|
}
|
||||||
|
|||||||
50
frontend/services/observations.ts
Normal file
50
frontend/services/observations.ts
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
import type { Observation } from './dto/observation'
|
||||||
|
import { extractItems } from '~/utils/api'
|
||||||
|
|
||||||
|
export const listObservations = async (employeeId: number) => {
|
||||||
|
const api = useApi()
|
||||||
|
const data = await api.get<Observation[] | { 'hydra:member'?: Observation[] }>(
|
||||||
|
'/observations',
|
||||||
|
{ employee: `/api/employees/${employeeId}` },
|
||||||
|
{ toast: false }
|
||||||
|
)
|
||||||
|
return extractItems<Observation>(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const createObservation = async (data: {
|
||||||
|
employeeId: number
|
||||||
|
month: string
|
||||||
|
content: string
|
||||||
|
}) => {
|
||||||
|
const api = useApi()
|
||||||
|
return api.post<Observation>('/observations', {
|
||||||
|
employee: `/api/employees/${data.employeeId}`,
|
||||||
|
month: data.month,
|
||||||
|
content: data.content
|
||||||
|
}, {
|
||||||
|
toastSuccessKey: 'success.observation.create',
|
||||||
|
toastErrorKey: 'errors.observation.create'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export const updateObservation = async (id: number, data: {
|
||||||
|
month: string
|
||||||
|
content: string
|
||||||
|
}) => {
|
||||||
|
const api = useApi()
|
||||||
|
return api.patch<Observation>(`/observations/${id}`, {
|
||||||
|
month: data.month,
|
||||||
|
content: data.content
|
||||||
|
}, {
|
||||||
|
toastSuccessKey: 'success.observation.update',
|
||||||
|
toastErrorKey: 'errors.observation.update'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export const deleteObservation = async (id: number) => {
|
||||||
|
const api = useApi()
|
||||||
|
return api.delete(`/observations/${id}`, {}, {
|
||||||
|
toastSuccessKey: 'success.observation.delete',
|
||||||
|
toastErrorKey: 'errors.observation.delete'
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -16,6 +16,7 @@ export const createUser = async (payload: {
|
|||||||
plainPassword: string
|
plainPassword: string
|
||||||
roles: string[]
|
roles: string[]
|
||||||
employeeId?: number | null
|
employeeId?: number | null
|
||||||
|
isLocked?: boolean
|
||||||
}) => {
|
}) => {
|
||||||
const api = useApi()
|
const api = useApi()
|
||||||
return api.post<User>(
|
return api.post<User>(
|
||||||
@@ -24,7 +25,8 @@ export const createUser = async (payload: {
|
|||||||
username: payload.username,
|
username: payload.username,
|
||||||
plainPassword: payload.plainPassword,
|
plainPassword: payload.plainPassword,
|
||||||
roles: payload.roles,
|
roles: payload.roles,
|
||||||
employee: payload.employeeId ? `/api/employees/${payload.employeeId}` : null
|
employee: payload.employeeId ? `/api/employees/${payload.employeeId}` : null,
|
||||||
|
isLocked: payload.isLocked ?? false
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
toastSuccessKey: 'success.user.create',
|
toastSuccessKey: 'success.user.create',
|
||||||
@@ -38,12 +40,14 @@ export const updateUser = async (id: number, payload: {
|
|||||||
plainPassword?: string
|
plainPassword?: string
|
||||||
roles: string[]
|
roles: string[]
|
||||||
employeeId?: number | null
|
employeeId?: number | null
|
||||||
|
isLocked?: boolean
|
||||||
}) => {
|
}) => {
|
||||||
const api = useApi()
|
const api = useApi()
|
||||||
const body: Record<string, unknown> = {
|
const body: Record<string, unknown> = {
|
||||||
username: payload.username,
|
username: payload.username,
|
||||||
roles: payload.roles,
|
roles: payload.roles,
|
||||||
employee: payload.employeeId ? `/api/employees/${payload.employeeId}` : null
|
employee: payload.employeeId ? `/api/employees/${payload.employeeId}` : null,
|
||||||
|
isLocked: payload.isLocked ?? false
|
||||||
}
|
}
|
||||||
|
|
||||||
if (payload.plainPassword) {
|
if (payload.plainPassword) {
|
||||||
|
|||||||
32
migrations/Version20260325081258.php
Normal file
32
migrations/Version20260325081258.php
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace DoctrineMigrations;
|
||||||
|
|
||||||
|
use Doctrine\DBAL\Schema\Schema;
|
||||||
|
use Doctrine\Migrations\AbstractMigration;
|
||||||
|
|
||||||
|
final class Version20260325081258 extends AbstractMigration
|
||||||
|
{
|
||||||
|
public function getDescription(): string
|
||||||
|
{
|
||||||
|
return 'Create observations table with unique constraint on (employee_id, month)';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function up(Schema $schema): void
|
||||||
|
{
|
||||||
|
$this->addSql('CREATE TABLE observations (id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL, employee_id INT NOT NULL, month DATE NOT NULL, content TEXT NOT NULL, created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, PRIMARY KEY (id))');
|
||||||
|
$this->addSql('CREATE INDEX IDX_BBC15BA88C03F15C ON observations (employee_id)');
|
||||||
|
$this->addSql('CREATE UNIQUE INDEX uniq_observation_employee_month ON observations (employee_id, month)');
|
||||||
|
$this->addSql('ALTER TABLE observations ADD CONSTRAINT FK_BBC15BA88C03F15C FOREIGN KEY (employee_id) REFERENCES employees (id) NOT DEFERRABLE');
|
||||||
|
$this->addSql("COMMENT ON COLUMN observations.month IS '(DC2Type:date_immutable)'");
|
||||||
|
$this->addSql("COMMENT ON COLUMN observations.created_at IS '(DC2Type:datetime_immutable)'");
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(Schema $schema): void
|
||||||
|
{
|
||||||
|
$this->addSql('ALTER TABLE observations DROP CONSTRAINT FK_BBC15BA88C03F15C');
|
||||||
|
$this->addSql('DROP TABLE observations');
|
||||||
|
}
|
||||||
|
}
|
||||||
26
migrations/Version20260325084215.php
Normal file
26
migrations/Version20260325084215.php
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace DoctrineMigrations;
|
||||||
|
|
||||||
|
use Doctrine\DBAL\Schema\Schema;
|
||||||
|
use Doctrine\Migrations\AbstractMigration;
|
||||||
|
|
||||||
|
final class Version20260325084215 extends AbstractMigration
|
||||||
|
{
|
||||||
|
public function getDescription(): string
|
||||||
|
{
|
||||||
|
return 'Add is_locked column to users table';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function up(Schema $schema): void
|
||||||
|
{
|
||||||
|
$this->addSql('ALTER TABLE users ADD is_locked BOOLEAN DEFAULT false NOT NULL');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(Schema $schema): void
|
||||||
|
{
|
||||||
|
$this->addSql('ALTER TABLE users DROP is_locked');
|
||||||
|
}
|
||||||
|
}
|
||||||
130
src/Entity/Observation.php
Normal file
130
src/Entity/Observation.php
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Entity;
|
||||||
|
|
||||||
|
use ApiPlatform\Doctrine\Orm\Filter\DateFilter;
|
||||||
|
use ApiPlatform\Doctrine\Orm\Filter\SearchFilter;
|
||||||
|
use ApiPlatform\Metadata\ApiFilter;
|
||||||
|
use ApiPlatform\Metadata\ApiResource;
|
||||||
|
use ApiPlatform\Metadata\Delete;
|
||||||
|
use ApiPlatform\Metadata\Get;
|
||||||
|
use ApiPlatform\Metadata\GetCollection;
|
||||||
|
use ApiPlatform\Metadata\Patch;
|
||||||
|
use ApiPlatform\Metadata\Post;
|
||||||
|
use App\Repository\ObservationRepository;
|
||||||
|
use DateTimeImmutable;
|
||||||
|
use Doctrine\ORM\Mapping as ORM;
|
||||||
|
use Symfony\Component\Serializer\Attribute\Groups;
|
||||||
|
|
||||||
|
#[ApiResource(
|
||||||
|
operations: [
|
||||||
|
new Get(
|
||||||
|
security: "is_granted('ROLE_ADMIN')"
|
||||||
|
),
|
||||||
|
new GetCollection(
|
||||||
|
security: "is_granted('ROLE_ADMIN')"
|
||||||
|
),
|
||||||
|
new Post(
|
||||||
|
security: "is_granted('ROLE_ADMIN')"
|
||||||
|
),
|
||||||
|
new Patch(
|
||||||
|
security: "is_granted('ROLE_ADMIN')"
|
||||||
|
),
|
||||||
|
new Delete(
|
||||||
|
security: "is_granted('ROLE_ADMIN')"
|
||||||
|
),
|
||||||
|
],
|
||||||
|
normalizationContext: [
|
||||||
|
'groups' => ['observation:read', 'employee:read'],
|
||||||
|
'datetime_format' => 'Y-m-d',
|
||||||
|
],
|
||||||
|
denormalizationContext: [
|
||||||
|
'groups' => ['observation:write'],
|
||||||
|
'datetime_format' => 'Y-m-d',
|
||||||
|
],
|
||||||
|
order: ['month' => 'DESC'],
|
||||||
|
paginationEnabled: false,
|
||||||
|
)]
|
||||||
|
#[ApiFilter(DateFilter::class, properties: ['month'])]
|
||||||
|
#[ApiFilter(SearchFilter::class, properties: ['employee' => 'exact'])]
|
||||||
|
#[ORM\Entity(repositoryClass: ObservationRepository::class)]
|
||||||
|
#[ORM\Table(name: 'observations')]
|
||||||
|
#[ORM\UniqueConstraint(name: 'uniq_observation_employee_month', columns: ['employee_id', 'month'])]
|
||||||
|
class Observation
|
||||||
|
{
|
||||||
|
#[ORM\Id]
|
||||||
|
#[ORM\GeneratedValue]
|
||||||
|
#[ORM\Column(type: 'integer')]
|
||||||
|
#[Groups(['observation:read'])]
|
||||||
|
private ?int $id = null;
|
||||||
|
|
||||||
|
#[ORM\ManyToOne(targetEntity: Employee::class)]
|
||||||
|
#[ORM\JoinColumn(nullable: false)]
|
||||||
|
#[Groups(['observation:read', 'observation:write'])]
|
||||||
|
private ?Employee $employee = null;
|
||||||
|
|
||||||
|
#[ORM\Column(type: 'date_immutable')]
|
||||||
|
#[Groups(['observation:read', 'observation:write'])]
|
||||||
|
private ?DateTimeImmutable $month = null;
|
||||||
|
|
||||||
|
#[ORM\Column(type: 'text')]
|
||||||
|
#[Groups(['observation:read', 'observation:write'])]
|
||||||
|
private string $content = '';
|
||||||
|
|
||||||
|
#[ORM\Column(type: 'datetime_immutable')]
|
||||||
|
#[Groups(['observation:read'])]
|
||||||
|
private DateTimeImmutable $createdAt;
|
||||||
|
|
||||||
|
public function __construct()
|
||||||
|
{
|
||||||
|
$this->createdAt = new DateTimeImmutable();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getId(): ?int
|
||||||
|
{
|
||||||
|
return $this->id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getEmployee(): ?Employee
|
||||||
|
{
|
||||||
|
return $this->employee;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setEmployee(?Employee $employee): self
|
||||||
|
{
|
||||||
|
$this->employee = $employee;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getMonth(): ?DateTimeImmutable
|
||||||
|
{
|
||||||
|
return $this->month;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setMonth(?DateTimeImmutable $month): self
|
||||||
|
{
|
||||||
|
$this->month = $month;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getContent(): string
|
||||||
|
{
|
||||||
|
return $this->content;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setContent(string $content): self
|
||||||
|
{
|
||||||
|
$this->content = $content;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getCreatedAt(): DateTimeImmutable
|
||||||
|
{
|
||||||
|
return $this->createdAt;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -19,6 +19,7 @@ use Doctrine\ORM\Mapping as ORM;
|
|||||||
use Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface;
|
use Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface;
|
||||||
use Symfony\Component\Security\Core\User\UserInterface;
|
use Symfony\Component\Security\Core\User\UserInterface;
|
||||||
use Symfony\Component\Serializer\Attribute\Groups;
|
use Symfony\Component\Serializer\Attribute\Groups;
|
||||||
|
use Symfony\Component\Serializer\Attribute\SerializedName;
|
||||||
|
|
||||||
#[ApiResource(
|
#[ApiResource(
|
||||||
operations: [
|
operations: [
|
||||||
@@ -84,6 +85,11 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
|
|||||||
#[Groups(['user:read', 'user:write'])]
|
#[Groups(['user:read', 'user:write'])]
|
||||||
private ?Employee $employee = null;
|
private ?Employee $employee = null;
|
||||||
|
|
||||||
|
#[ORM\Column(type: 'boolean', options: ['default' => false])]
|
||||||
|
#[Groups(['user:read', 'user:write'])]
|
||||||
|
#[SerializedName('isLocked')]
|
||||||
|
private bool $isLocked = false;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @var Collection<int, UserSiteRole>
|
* @var Collection<int, UserSiteRole>
|
||||||
*/
|
*/
|
||||||
@@ -204,5 +210,19 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
|
|||||||
return $this;
|
return $this;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[Groups(['user:read'])]
|
||||||
|
#[SerializedName('isLocked')]
|
||||||
|
public function isLocked(): bool
|
||||||
|
{
|
||||||
|
return $this->isLocked;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setIsLocked(bool $isLocked): self
|
||||||
|
{
|
||||||
|
$this->isLocked = $isLocked;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
public function eraseCredentials(): void {}
|
public function eraseCredentials(): void {}
|
||||||
}
|
}
|
||||||
|
|||||||
38
src/Repository/ObservationRepository.php
Normal file
38
src/Repository/ObservationRepository.php
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Repository;
|
||||||
|
|
||||||
|
use App\Entity\Observation;
|
||||||
|
use DateTimeImmutable;
|
||||||
|
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||||
|
use Doctrine\Persistence\ManagerRegistry;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @extends ServiceEntityRepository<Observation>
|
||||||
|
*/
|
||||||
|
final class ObservationRepository extends ServiceEntityRepository
|
||||||
|
{
|
||||||
|
public function __construct(ManagerRegistry $registry)
|
||||||
|
{
|
||||||
|
parent::__construct($registry, Observation::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return Observation[]
|
||||||
|
*/
|
||||||
|
public function findByMonth(DateTimeImmutable $from, DateTimeImmutable $to): array
|
||||||
|
{
|
||||||
|
return $this->createQueryBuilder('o')
|
||||||
|
->andWhere('o.month >= :from')
|
||||||
|
->andWhere('o.month <= :to')
|
||||||
|
->setParameter('from', $from)
|
||||||
|
->setParameter('to', $to)
|
||||||
|
->innerJoin('o.employee', 'e')
|
||||||
|
->addSelect('e')
|
||||||
|
->getQuery()
|
||||||
|
->getResult()
|
||||||
|
;
|
||||||
|
}
|
||||||
|
}
|
||||||
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->isLocked()) {
|
||||||
|
throw new CustomUserMessageAccountStatusException('Ce compte est verrouillé.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function checkPostAuth(UserInterface $user, ?TokenInterface $token = null): void {}
|
||||||
|
}
|
||||||
@@ -45,18 +45,21 @@ final readonly class EmployeeContractPeriodManager implements EmployeeContractPe
|
|||||||
?EmployeeContractPeriod $todayPeriod,
|
?EmployeeContractPeriod $todayPeriod,
|
||||||
DateTimeImmutable $requestedEndDate,
|
DateTimeImmutable $requestedEndDate,
|
||||||
bool $paidLeaveSettled,
|
bool $paidLeaveSettled,
|
||||||
?string $comment = null
|
?string $comment = null,
|
||||||
|
bool $isAlreadyEnded = false
|
||||||
): void {
|
): void {
|
||||||
if (null === $todayPeriod) {
|
if (null === $todayPeriod) {
|
||||||
throw new UnprocessableEntityHttpException('No active contract period to close.');
|
throw new UnprocessableEntityHttpException('No active contract period to close.');
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->periodValidator->assertCloseEndDateCanBeApplied(
|
if (!$isAlreadyEnded) {
|
||||||
$todayPeriod->getStartDate(),
|
$this->periodValidator->assertCloseEndDateCanBeApplied(
|
||||||
$todayPeriod->getEndDate(),
|
$todayPeriod->getStartDate(),
|
||||||
$requestedEndDate,
|
$todayPeriod->getEndDate(),
|
||||||
$todayPeriod->getContractNatureEnum()
|
$requestedEndDate,
|
||||||
);
|
$todayPeriod->getContractNatureEnum()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
$todayPeriod->setEndDate($requestedEndDate);
|
$todayPeriod->setEndDate($requestedEndDate);
|
||||||
$todayPeriod->setPaidLeaveSettled($paidLeaveSettled);
|
$todayPeriod->setPaidLeaveSettled($paidLeaveSettled);
|
||||||
|
|||||||
@@ -25,7 +25,8 @@ interface EmployeeContractPeriodManagerInterface
|
|||||||
?EmployeeContractPeriod $todayPeriod,
|
?EmployeeContractPeriod $todayPeriod,
|
||||||
DateTimeImmutable $requestedEndDate,
|
DateTimeImmutable $requestedEndDate,
|
||||||
bool $paidLeaveSettled,
|
bool $paidLeaveSettled,
|
||||||
?string $comment = null
|
?string $comment = null,
|
||||||
|
bool $isAlreadyEnded = false
|
||||||
): void;
|
): void;
|
||||||
|
|
||||||
public function createNextPeriod(
|
public function createNextPeriod(
|
||||||
|
|||||||
@@ -92,11 +92,13 @@ final readonly class EmployeeWriteProcessor implements ProcessorInterface
|
|||||||
if (null === $requestedEndDate) {
|
if (null === $requestedEndDate) {
|
||||||
throw new UnprocessableEntityHttpException('contractEndDate is required for close-only request.');
|
throw new UnprocessableEntityHttpException('contractEndDate is required for close-only request.');
|
||||||
}
|
}
|
||||||
|
$isAlreadyEnded = null === $todayPeriod;
|
||||||
$this->periodManager->closeCurrentPeriod(
|
$this->periodManager->closeCurrentPeriod(
|
||||||
$effectivePeriod,
|
$effectivePeriod,
|
||||||
$requestedEndDate,
|
$requestedEndDate,
|
||||||
$changeRequest->contractPaidLeaveSettled ?? false,
|
$changeRequest->contractPaidLeaveSettled ?? false,
|
||||||
$changeRequest->contractComment
|
$changeRequest->contractComment,
|
||||||
|
$isAlreadyEnded
|
||||||
);
|
);
|
||||||
|
|
||||||
return $result;
|
return $result;
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ use App\Repository\BonusRepository;
|
|||||||
use App\Repository\EmployeeRepository;
|
use App\Repository\EmployeeRepository;
|
||||||
use App\Repository\EmployeeRttPaymentRepository;
|
use App\Repository\EmployeeRttPaymentRepository;
|
||||||
use App\Repository\MileageAllowanceRepository;
|
use App\Repository\MileageAllowanceRepository;
|
||||||
|
use App\Repository\ObservationRepository;
|
||||||
use App\Repository\WorkHourRepository;
|
use App\Repository\WorkHourRepository;
|
||||||
use App\Service\Contracts\EmployeeContractResolver;
|
use App\Service\Contracts\EmployeeContractResolver;
|
||||||
use DateInterval;
|
use DateInterval;
|
||||||
@@ -36,6 +37,7 @@ class SalaryRecapPrintProvider implements ProviderInterface
|
|||||||
private EmployeeRttPaymentRepository $rttPaymentRepository,
|
private EmployeeRttPaymentRepository $rttPaymentRepository,
|
||||||
private BonusRepository $bonusRepository,
|
private BonusRepository $bonusRepository,
|
||||||
private MileageAllowanceRepository $mileageAllowanceRepository,
|
private MileageAllowanceRepository $mileageAllowanceRepository,
|
||||||
|
private ObservationRepository $observationRepository,
|
||||||
private EmployeeContractResolver $contractResolver,
|
private EmployeeContractResolver $contractResolver,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@@ -62,20 +64,22 @@ class SalaryRecapPrintProvider implements ProviderInterface
|
|||||||
$monthNumber = (int) $from->format('n');
|
$monthNumber = (int) $from->format('n');
|
||||||
$rttPayments = $this->rttPaymentRepository->findByYearAndMonth($year, $monthNumber);
|
$rttPayments = $this->rttPaymentRepository->findByYearAndMonth($year, $monthNumber);
|
||||||
|
|
||||||
$bonuses = $this->bonusRepository->findByMonth($from, $to);
|
$bonuses = $this->bonusRepository->findByMonth($from, $to);
|
||||||
$mileages = $this->mileageAllowanceRepository->findByMonth($from, $to);
|
$mileages = $this->mileageAllowanceRepository->findByMonth($from, $to);
|
||||||
|
$observations = $this->observationRepository->findByMonth($from, $to);
|
||||||
|
|
||||||
$days = $this->buildDays($from, $to);
|
$days = $this->buildDays($from, $to);
|
||||||
$contractMap = $this->contractResolver->resolveForEmployeesAndDays($employees, $days);
|
$contractMap = $this->contractResolver->resolveForEmployeesAndDays($employees, $days);
|
||||||
$driverMap = $this->contractResolver->resolveIsDriverForEmployeesAndDays($employees, $days);
|
$driverMap = $this->contractResolver->resolveIsDriverForEmployeesAndDays($employees, $days);
|
||||||
|
|
||||||
$workHourMap = $this->buildWorkHourMap($workHours);
|
$workHourMap = $this->buildWorkHourMap($workHours);
|
||||||
$absenceMap = $this->buildAbsenceMap($absences);
|
$absenceMap = $this->buildAbsenceMap($absences);
|
||||||
$rttPaymentMap = $this->buildRttPaymentMap($rttPayments);
|
$rttPaymentMap = $this->buildRttPaymentMap($rttPayments);
|
||||||
$bonusMap = $this->buildBonusMap($bonuses);
|
$bonusMap = $this->buildBonusMap($bonuses);
|
||||||
$mileageMap = $this->buildMileageMap($mileages);
|
$mileageMap = $this->buildMileageMap($mileages);
|
||||||
|
$observationMap = $this->buildObservationMap($observations);
|
||||||
|
|
||||||
$siteGroups = $this->aggregateBySite($employees, $days, $contractMap, $driverMap, $workHourMap, $absenceMap, $rttPaymentMap, $bonusMap, $mileageMap);
|
$siteGroups = $this->aggregateBySite($employees, $days, $contractMap, $driverMap, $workHourMap, $absenceMap, $rttPaymentMap, $bonusMap, $mileageMap, $observationMap);
|
||||||
|
|
||||||
$options = new Options();
|
$options = new Options();
|
||||||
$options->set('isRemoteEnabled', true);
|
$options->set('isRemoteEnabled', true);
|
||||||
@@ -204,6 +208,23 @@ class SalaryRecapPrintProvider implements ProviderInterface
|
|||||||
return $map;
|
return $map;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<int, string>
|
||||||
|
*/
|
||||||
|
private function buildObservationMap(array $observations): array
|
||||||
|
{
|
||||||
|
$map = [];
|
||||||
|
foreach ($observations as $observation) {
|
||||||
|
$employeeId = $observation->getEmployee()?->getId();
|
||||||
|
if (!$employeeId) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$map[$employeeId] = $observation->getContent();
|
||||||
|
}
|
||||||
|
|
||||||
|
return $map;
|
||||||
|
}
|
||||||
|
|
||||||
private function aggregateBySite(
|
private function aggregateBySite(
|
||||||
array $employees,
|
array $employees,
|
||||||
array $days,
|
array $days,
|
||||||
@@ -214,6 +235,7 @@ class SalaryRecapPrintProvider implements ProviderInterface
|
|||||||
array $rttPaymentMap,
|
array $rttPaymentMap,
|
||||||
array $bonusMap,
|
array $bonusMap,
|
||||||
array $mileageMap,
|
array $mileageMap,
|
||||||
|
array $observationMap,
|
||||||
): array {
|
): array {
|
||||||
$siteGroups = [];
|
$siteGroups = [];
|
||||||
|
|
||||||
@@ -234,6 +256,7 @@ class SalaryRecapPrintProvider implements ProviderInterface
|
|||||||
$rttPaymentMap[$employeeId] ?? 0,
|
$rttPaymentMap[$employeeId] ?? 0,
|
||||||
$bonusMap[$employeeId] ?? 0.0,
|
$bonusMap[$employeeId] ?? 0.0,
|
||||||
$mileageMap[$employeeId] ?? 0.0,
|
$mileageMap[$employeeId] ?? 0.0,
|
||||||
|
$observationMap[$employeeId] ?? '',
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!isset($siteGroups[$siteId])) {
|
if (!isset($siteGroups[$siteId])) {
|
||||||
@@ -261,6 +284,7 @@ class SalaryRecapPrintProvider implements ProviderInterface
|
|||||||
int $rttPaidMinutes,
|
int $rttPaidMinutes,
|
||||||
float $bonusAmount,
|
float $bonusAmount,
|
||||||
float $mileageKm,
|
float $mileageKm,
|
||||||
|
string $observation,
|
||||||
): array {
|
): array {
|
||||||
$contractName = null;
|
$contractName = null;
|
||||||
$presenceDays = 0.0;
|
$presenceDays = 0.0;
|
||||||
@@ -373,6 +397,7 @@ class SalaryRecapPrintProvider implements ProviderInterface
|
|||||||
'driverMeals' => $driverMeals,
|
'driverMeals' => $driverMeals,
|
||||||
'driverOvernight' => $driverOvernight,
|
'driverOvernight' => $driverOvernight,
|
||||||
'driverSaturdays' => $driverSaturdays,
|
'driverSaturdays' => $driverSaturdays,
|
||||||
|
'observation' => $observation,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -76,7 +76,12 @@
|
|||||||
word-break: break-word;
|
word-break: break-word;
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
}
|
}
|
||||||
td.obs { }
|
td.obs {
|
||||||
|
text-align: left;
|
||||||
|
white-space: normal;
|
||||||
|
word-break: break-word;
|
||||||
|
font-size: 9px;
|
||||||
|
}
|
||||||
|
|
||||||
tbody td { font-size: 10px; }
|
tbody td { font-size: 10px; }
|
||||||
</style>
|
</style>
|
||||||
@@ -139,7 +144,7 @@
|
|||||||
<td class="num">{{ row.nightBasketCount > 0 ? row.nightBasketCount : '' }}</td>
|
<td class="num">{{ row.nightBasketCount > 0 ? row.nightBasketCount : '' }}</td>
|
||||||
<td class="num">{{ row.paidHours > 0 ? row.paidHours : '' }}</td>
|
<td class="num">{{ row.paidHours > 0 ? row.paidHours : '' }}</td>
|
||||||
<td class="num">{{ row.sundayHours > 0 ? row.sundayHours : '' }}</td>
|
<td class="num">{{ row.sundayHours > 0 ? row.sundayHours : '' }}</td>
|
||||||
<td class="num">{{ row.bonusAmount > 0 ? row.bonusAmount : '' }}</td>
|
<td class="num">{{ row.bonusAmount > 0 ? row.bonusAmount ~ ' €' : '' }}</td>
|
||||||
<td class="num">{{ row.congesCount > 0 ? row.congesCount : '' }}</td>
|
<td class="num">{{ row.congesCount > 0 ? row.congesCount : '' }}</td>
|
||||||
<td class="dates">{{ row.congesDates }}</td>
|
<td class="dates">{{ row.congesDates }}</td>
|
||||||
<td class="num">{{ row.maladieCount > 0 ? row.maladieCount : '' }}</td>
|
<td class="num">{{ row.maladieCount > 0 ? row.maladieCount : '' }}</td>
|
||||||
@@ -148,7 +153,7 @@
|
|||||||
<td class="num">{{ row.isDriver and row.driverMeals > 0 ? row.driverMeals : '' }}</td>
|
<td class="num">{{ row.isDriver and row.driverMeals > 0 ? row.driverMeals : '' }}</td>
|
||||||
<td class="num">{{ row.isDriver and row.driverOvernight > 0 ? row.driverOvernight : '' }}</td>
|
<td class="num">{{ row.isDriver and row.driverOvernight > 0 ? row.driverOvernight : '' }}</td>
|
||||||
<td class="num">{{ row.isDriver and row.driverSaturdays > 0 ? row.driverSaturdays : '' }}</td>
|
<td class="num">{{ row.isDriver and row.driverSaturdays > 0 ? row.driverSaturdays : '' }}</td>
|
||||||
<td class="obs"></td>
|
<td class="obs">{{ row.observation }}</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% else %}
|
{% else %}
|
||||||
<tr>
|
<tr>
|
||||||
|
|||||||
Reference in New Issue
Block a user