Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e74a264b37 | ||
| 60bb3cf8c4 | |||
|
|
1a485e8780 | ||
| 5c6d42c729 |
@@ -19,6 +19,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
|
||||
@@ -29,6 +30,7 @@ security:
|
||||
pattern: ^/api
|
||||
stateless: true
|
||||
provider: app_user_provider
|
||||
user_checker: App\Security\UserChecker
|
||||
jwt: ~
|
||||
logout:
|
||||
path: /api/logout
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
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
|
||||
- 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:
|
||||
- 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 employee = ref<Employee | null>(null)
|
||||
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 showRttTab = computed(() => employee.value?.contract?.type !== CONTRACT_TYPES.FORFAIT)
|
||||
@@ -40,6 +40,7 @@ export const useEmployeeDetailPage = () => {
|
||||
rtt.resetLoaded()
|
||||
mileage.resetLoaded()
|
||||
bonus.resetLoaded()
|
||||
observation.resetLoaded()
|
||||
|
||||
if (activeTab.value === 'leave' && showLeaveTab.value) {
|
||||
await leave.loadLeaveData()
|
||||
@@ -49,6 +50,8 @@ export const useEmployeeDetailPage = () => {
|
||||
await mileage.loadMileageData()
|
||||
} else if (activeTab.value === 'bonus') {
|
||||
await bonus.loadBonusData()
|
||||
} else if (activeTab.value === 'observation') {
|
||||
await observation.loadObservationData()
|
||||
}
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
@@ -60,6 +63,7 @@ export const useEmployeeDetailPage = () => {
|
||||
const rtt = useEmployeeRtt(employee, loadEmployee)
|
||||
const mileage = useEmployeeMileage(employee, loadEmployee)
|
||||
const bonus = useEmployeeBonus(employee, loadEmployee)
|
||||
const observation = useEmployeeObservation(employee, loadEmployee)
|
||||
|
||||
watch(activeTab, (tab) => {
|
||||
if (tab === 'leave' && !leave.leaveDataLoaded.value && showLeaveTab.value) {
|
||||
@@ -70,6 +74,8 @@ export const useEmployeeDetailPage = () => {
|
||||
mileage.loadMileageData()
|
||||
} else if (tab === 'bonus' && !bonus.bonusDataLoaded.value) {
|
||||
bonus.loadBonusData()
|
||||
} else if (tab === 'observation' && !observation.observationDataLoaded.value) {
|
||||
observation.loadObservationData()
|
||||
}
|
||||
})
|
||||
|
||||
@@ -89,6 +95,7 @@ export const useEmployeeDetailPage = () => {
|
||||
...leave,
|
||||
...rtt,
|
||||
...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.",
|
||||
"update": "Impossible de mettre à jour 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": {
|
||||
@@ -87,6 +92,11 @@
|
||||
"create": "Prime créée.",
|
||||
"update": "Prime mise à jour.",
|
||||
"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"/>
|
||||
Prime
|
||||
</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 class="min-h-0 flex-1">
|
||||
@@ -173,6 +183,19 @@
|
||||
@delete="submitDeleteBonus"
|
||||
/>
|
||||
</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>
|
||||
|
||||
@@ -254,7 +277,12 @@ const {
|
||||
isBonusLoading,
|
||||
submitCreateBonus,
|
||||
submitUpdateBonus,
|
||||
submitDeleteBonus
|
||||
submitDeleteBonus,
|
||||
observations,
|
||||
isObservationLoading,
|
||||
submitCreateObservation,
|
||||
submitUpdateObservation,
|
||||
submitDeleteObservation
|
||||
} = useEmployeeDetailPage()
|
||||
|
||||
const handleYearlyHoursPrint = async (year: number) => {
|
||||
|
||||
@@ -19,11 +19,12 @@
|
||||
</div>
|
||||
|
||||
<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">Employé</span>
|
||||
<span class="text-left">Accès</span>
|
||||
<span class="text-left">Sites</span>
|
||||
<span class="text-left">Statut</span>
|
||||
</div>
|
||||
<div v-if="isLoading" class="px-6 py-4 text-md text-neutral-500">
|
||||
Chargement...
|
||||
@@ -32,7 +33,7 @@
|
||||
<div
|
||||
v-for="user in users"
|
||||
: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)"
|
||||
>
|
||||
<span>{{ user.username }}</span>
|
||||
@@ -41,6 +42,16 @@
|
||||
</span>
|
||||
<span>{{ getAccessLabel(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>
|
||||
@@ -164,6 +175,20 @@
|
||||
</p>
|
||||
</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">
|
||||
<button
|
||||
type="submit"
|
||||
@@ -207,7 +232,8 @@ const form = reactive({
|
||||
password: '',
|
||||
accessMode: 'admin' as 'admin' | 'self' | 'sites',
|
||||
employeeId: '' as number | '',
|
||||
siteIds: [] as number[]
|
||||
siteIds: [] as number[],
|
||||
isLocked: false
|
||||
})
|
||||
|
||||
const validationTouched = reactive({
|
||||
@@ -318,6 +344,7 @@ const resetForm = () => {
|
||||
form.employeeId = ''
|
||||
form.accessMode = 'admin'
|
||||
form.siteIds = []
|
||||
form.isLocked = false
|
||||
editingUser.value = null
|
||||
validationTouched.username = false
|
||||
validationTouched.password = false
|
||||
@@ -345,6 +372,7 @@ const openEdit = (user: User) => {
|
||||
}
|
||||
|
||||
form.employeeId = user.employee?.id ?? ''
|
||||
form.isLocked = user.isLocked
|
||||
|
||||
const siteRoles = userAccessById.value.get(user.id) ?? []
|
||||
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,
|
||||
plainPassword: form.password.trim() ? form.password : undefined,
|
||||
roles,
|
||||
employeeId
|
||||
employeeId,
|
||||
isLocked: form.isLocked
|
||||
})
|
||||
|
||||
const existingSiteRoles = userAccessById.value.get(editingUser.value.id) ?? []
|
||||
@@ -422,7 +451,8 @@ const handleSubmit = async () => {
|
||||
username: form.username,
|
||||
plainPassword: form.password,
|
||||
roles,
|
||||
employeeId
|
||||
employeeId,
|
||||
isLocked: form.isLocked
|
||||
})
|
||||
|
||||
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
|
||||
username: string
|
||||
roles: string[]
|
||||
isLocked: boolean
|
||||
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
|
||||
roles: string[]
|
||||
employeeId?: number | null
|
||||
isLocked?: boolean
|
||||
}) => {
|
||||
const api = useApi()
|
||||
return api.post<User>(
|
||||
@@ -24,7 +25,8 @@ export const createUser = async (payload: {
|
||||
username: payload.username,
|
||||
plainPassword: payload.plainPassword,
|
||||
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',
|
||||
@@ -38,12 +40,14 @@ export const updateUser = async (id: number, payload: {
|
||||
plainPassword?: string
|
||||
roles: string[]
|
||||
employeeId?: number | null
|
||||
isLocked?: boolean
|
||||
}) => {
|
||||
const api = useApi()
|
||||
const body: Record<string, unknown> = {
|
||||
username: payload.username,
|
||||
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) {
|
||||
|
||||
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\UserInterface;
|
||||
use Symfony\Component\Serializer\Attribute\Groups;
|
||||
use Symfony\Component\Serializer\Attribute\SerializedName;
|
||||
|
||||
#[ApiResource(
|
||||
operations: [
|
||||
@@ -84,6 +85,11 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
|
||||
#[Groups(['user:read', 'user:write'])]
|
||||
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>
|
||||
*/
|
||||
@@ -204,5 +210,19 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
|
||||
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 {}
|
||||
}
|
||||
|
||||
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,
|
||||
DateTimeImmutable $requestedEndDate,
|
||||
bool $paidLeaveSettled,
|
||||
?string $comment = null
|
||||
?string $comment = null,
|
||||
bool $isAlreadyEnded = false
|
||||
): void {
|
||||
if (null === $todayPeriod) {
|
||||
throw new UnprocessableEntityHttpException('No active contract period to close.');
|
||||
}
|
||||
|
||||
$this->periodValidator->assertCloseEndDateCanBeApplied(
|
||||
$todayPeriod->getStartDate(),
|
||||
$todayPeriod->getEndDate(),
|
||||
$requestedEndDate,
|
||||
$todayPeriod->getContractNatureEnum()
|
||||
);
|
||||
if (!$isAlreadyEnded) {
|
||||
$this->periodValidator->assertCloseEndDateCanBeApplied(
|
||||
$todayPeriod->getStartDate(),
|
||||
$todayPeriod->getEndDate(),
|
||||
$requestedEndDate,
|
||||
$todayPeriod->getContractNatureEnum()
|
||||
);
|
||||
}
|
||||
|
||||
$todayPeriod->setEndDate($requestedEndDate);
|
||||
$todayPeriod->setPaidLeaveSettled($paidLeaveSettled);
|
||||
|
||||
@@ -25,7 +25,8 @@ interface EmployeeContractPeriodManagerInterface
|
||||
?EmployeeContractPeriod $todayPeriod,
|
||||
DateTimeImmutable $requestedEndDate,
|
||||
bool $paidLeaveSettled,
|
||||
?string $comment = null
|
||||
?string $comment = null,
|
||||
bool $isAlreadyEnded = false
|
||||
): void;
|
||||
|
||||
public function createNextPeriod(
|
||||
|
||||
@@ -92,11 +92,13 @@ final readonly class EmployeeWriteProcessor implements ProcessorInterface
|
||||
if (null === $requestedEndDate) {
|
||||
throw new UnprocessableEntityHttpException('contractEndDate is required for close-only request.');
|
||||
}
|
||||
$isAlreadyEnded = null === $todayPeriod;
|
||||
$this->periodManager->closeCurrentPeriod(
|
||||
$effectivePeriod,
|
||||
$requestedEndDate,
|
||||
$changeRequest->contractPaidLeaveSettled ?? false,
|
||||
$changeRequest->contractComment
|
||||
$changeRequest->contractComment,
|
||||
$isAlreadyEnded
|
||||
);
|
||||
|
||||
return $result;
|
||||
|
||||
@@ -15,6 +15,7 @@ use App\Repository\BonusRepository;
|
||||
use App\Repository\EmployeeRepository;
|
||||
use App\Repository\EmployeeRttPaymentRepository;
|
||||
use App\Repository\MileageAllowanceRepository;
|
||||
use App\Repository\ObservationRepository;
|
||||
use App\Repository\WorkHourRepository;
|
||||
use App\Service\Contracts\EmployeeContractResolver;
|
||||
use DateInterval;
|
||||
@@ -36,6 +37,7 @@ class SalaryRecapPrintProvider implements ProviderInterface
|
||||
private EmployeeRttPaymentRepository $rttPaymentRepository,
|
||||
private BonusRepository $bonusRepository,
|
||||
private MileageAllowanceRepository $mileageAllowanceRepository,
|
||||
private ObservationRepository $observationRepository,
|
||||
private EmployeeContractResolver $contractResolver,
|
||||
) {}
|
||||
|
||||
@@ -62,20 +64,22 @@ class SalaryRecapPrintProvider implements ProviderInterface
|
||||
$monthNumber = (int) $from->format('n');
|
||||
$rttPayments = $this->rttPaymentRepository->findByYearAndMonth($year, $monthNumber);
|
||||
|
||||
$bonuses = $this->bonusRepository->findByMonth($from, $to);
|
||||
$mileages = $this->mileageAllowanceRepository->findByMonth($from, $to);
|
||||
$bonuses = $this->bonusRepository->findByMonth($from, $to);
|
||||
$mileages = $this->mileageAllowanceRepository->findByMonth($from, $to);
|
||||
$observations = $this->observationRepository->findByMonth($from, $to);
|
||||
|
||||
$days = $this->buildDays($from, $to);
|
||||
$contractMap = $this->contractResolver->resolveForEmployeesAndDays($employees, $days);
|
||||
$driverMap = $this->contractResolver->resolveIsDriverForEmployeesAndDays($employees, $days);
|
||||
|
||||
$workHourMap = $this->buildWorkHourMap($workHours);
|
||||
$absenceMap = $this->buildAbsenceMap($absences);
|
||||
$rttPaymentMap = $this->buildRttPaymentMap($rttPayments);
|
||||
$bonusMap = $this->buildBonusMap($bonuses);
|
||||
$mileageMap = $this->buildMileageMap($mileages);
|
||||
$workHourMap = $this->buildWorkHourMap($workHours);
|
||||
$absenceMap = $this->buildAbsenceMap($absences);
|
||||
$rttPaymentMap = $this->buildRttPaymentMap($rttPayments);
|
||||
$bonusMap = $this->buildBonusMap($bonuses);
|
||||
$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->set('isRemoteEnabled', true);
|
||||
@@ -204,6 +208,23 @@ class SalaryRecapPrintProvider implements ProviderInterface
|
||||
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(
|
||||
array $employees,
|
||||
array $days,
|
||||
@@ -214,6 +235,7 @@ class SalaryRecapPrintProvider implements ProviderInterface
|
||||
array $rttPaymentMap,
|
||||
array $bonusMap,
|
||||
array $mileageMap,
|
||||
array $observationMap,
|
||||
): array {
|
||||
$siteGroups = [];
|
||||
|
||||
@@ -234,6 +256,7 @@ class SalaryRecapPrintProvider implements ProviderInterface
|
||||
$rttPaymentMap[$employeeId] ?? 0,
|
||||
$bonusMap[$employeeId] ?? 0.0,
|
||||
$mileageMap[$employeeId] ?? 0.0,
|
||||
$observationMap[$employeeId] ?? '',
|
||||
);
|
||||
|
||||
if (!isset($siteGroups[$siteId])) {
|
||||
@@ -261,6 +284,7 @@ class SalaryRecapPrintProvider implements ProviderInterface
|
||||
int $rttPaidMinutes,
|
||||
float $bonusAmount,
|
||||
float $mileageKm,
|
||||
string $observation,
|
||||
): array {
|
||||
$contractName = null;
|
||||
$presenceDays = 0.0;
|
||||
@@ -373,6 +397,7 @@ class SalaryRecapPrintProvider implements ProviderInterface
|
||||
'driverMeals' => $driverMeals,
|
||||
'driverOvernight' => $driverOvernight,
|
||||
'driverSaturdays' => $driverSaturdays,
|
||||
'observation' => $observation,
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -76,7 +76,12 @@
|
||||
word-break: break-word;
|
||||
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; }
|
||||
</style>
|
||||
@@ -139,7 +144,7 @@
|
||||
<td class="num">{{ row.nightBasketCount > 0 ? row.nightBasketCount : '' }}</td>
|
||||
<td class="num">{{ row.paidHours > 0 ? row.paidHours : '' }}</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="dates">{{ row.congesDates }}</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.driverOvernight > 0 ? row.driverOvernight : '' }}</td>
|
||||
<td class="num">{{ row.isDriver and row.driverSaturdays > 0 ? row.driverSaturdays : '' }}</td>
|
||||
<td class="obs"></td>
|
||||
<td class="obs">{{ row.observation }}</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr>
|
||||
|
||||
Reference in New Issue
Block a user