([])
+ 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
+ }
+}
diff --git a/frontend/i18n/locales/fr.json b/frontend/i18n/locales/fr.json
index 1d7a261..f24784f 100644
--- a/frontend/i18n/locales/fr.json
+++ b/frontend/i18n/locales/fr.json
@@ -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."
}
}
}
diff --git a/frontend/pages/employees/[id].vue b/frontend/pages/employees/[id].vue
index e5cb294..39d03b0 100644
--- a/frontend/pages/employees/[id].vue
+++ b/frontend/pages/employees/[id].vue
@@ -84,6 +84,16 @@
Prime
+
+
+ Observation
+
@@ -173,6 +183,19 @@
@delete="submitDeleteBonus"
/>
+
@@ -254,7 +277,12 @@ const {
isBonusLoading,
submitCreateBonus,
submitUpdateBonus,
- submitDeleteBonus
+ submitDeleteBonus,
+ observations,
+ isObservationLoading,
+ submitCreateObservation,
+ submitUpdateObservation,
+ submitDeleteObservation
} = useEmployeeDetailPage()
const handleYearlyHoursPrint = async (year: number) => {
diff --git a/frontend/pages/users.vue b/frontend/pages/users.vue
index b3c2fed..e459c72 100644
--- a/frontend/pages/users.vue
+++ b/frontend/pages/users.vue
@@ -19,11 +19,12 @@
-
+
Utilisateur
Employé
Accès
Sites
+ Statut
Chargement...
@@ -32,7 +33,7 @@
{{ user.username }}
@@ -41,6 +42,16 @@
{{ getAccessLabel(user) }}
{{ getSiteLabels(user) }}
+
+ Verrouillé
+ Actif
+
@@ -164,6 +175,20 @@
+
+
+
+ Verrouiller le compte
+
+
+ Un compte verrouillé ne peut plus se connecter.
+
+
+
{
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) {
diff --git a/frontend/services/dto/observation.ts b/frontend/services/dto/observation.ts
new file mode 100644
index 0000000..584df2e
--- /dev/null
+++ b/frontend/services/dto/observation.ts
@@ -0,0 +1,6 @@
+export type Observation = {
+ id: number
+ month: string
+ content: string
+ createdAt: string
+}
diff --git a/frontend/services/dto/user.ts b/frontend/services/dto/user.ts
index 6c36012..3d2187d 100644
--- a/frontend/services/dto/user.ts
+++ b/frontend/services/dto/user.ts
@@ -4,5 +4,6 @@ export type User = {
id: number
username: string
roles: string[]
+ isLocked: boolean
employee?: Employee | null
}
diff --git a/frontend/services/observations.ts b/frontend/services/observations.ts
new file mode 100644
index 0000000..faa5ed9
--- /dev/null
+++ b/frontend/services/observations.ts
@@ -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(
+ '/observations',
+ { employee: `/api/employees/${employeeId}` },
+ { toast: false }
+ )
+ return extractItems(data)
+}
+
+export const createObservation = async (data: {
+ employeeId: number
+ month: string
+ content: string
+}) => {
+ const api = useApi()
+ return api.post('/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(`/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'
+ })
+}
diff --git a/frontend/services/users.ts b/frontend/services/users.ts
index d223da3..a1ab267 100644
--- a/frontend/services/users.ts
+++ b/frontend/services/users.ts
@@ -16,6 +16,7 @@ export const createUser = async (payload: {
plainPassword: string
roles: string[]
employeeId?: number | null
+ isLocked?: boolean
}) => {
const api = useApi()
return api.post(
@@ -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 = {
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) {
diff --git a/migrations/Version20260325081258.php b/migrations/Version20260325081258.php
new file mode 100644
index 0000000..036026d
--- /dev/null
+++ b/migrations/Version20260325081258.php
@@ -0,0 +1,32 @@
+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');
+ }
+}
diff --git a/migrations/Version20260325084215.php b/migrations/Version20260325084215.php
new file mode 100644
index 0000000..6736b0e
--- /dev/null
+++ b/migrations/Version20260325084215.php
@@ -0,0 +1,26 @@
+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');
+ }
+}
diff --git a/src/Entity/Observation.php b/src/Entity/Observation.php
new file mode 100644
index 0000000..0a37e97
--- /dev/null
+++ b/src/Entity/Observation.php
@@ -0,0 +1,130 @@
+ ['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;
+ }
+}
diff --git a/src/Entity/User.php b/src/Entity/User.php
index 2164fd4..d878bfb 100644
--- a/src/Entity/User.php
+++ b/src/Entity/User.php
@@ -84,6 +84,10 @@ 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'])]
+ private bool $isLocked = false;
+
/**
* @var Collection
*/
@@ -204,5 +208,17 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
return $this;
}
+ public function isLocked(): bool
+ {
+ return $this->isLocked;
+ }
+
+ public function setIsLocked(bool $isLocked): self
+ {
+ $this->isLocked = $isLocked;
+
+ return $this;
+ }
+
public function eraseCredentials(): void {}
}
diff --git a/src/Repository/ObservationRepository.php b/src/Repository/ObservationRepository.php
new file mode 100644
index 0000000..77721b8
--- /dev/null
+++ b/src/Repository/ObservationRepository.php
@@ -0,0 +1,38 @@
+
+ */
+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()
+ ;
+ }
+}
diff --git a/src/Security/UserChecker.php b/src/Security/UserChecker.php
new file mode 100644
index 0000000..2e12f6e
--- /dev/null
+++ b/src/Security/UserChecker.php
@@ -0,0 +1,27 @@
+isLocked()) {
+ throw new CustomUserMessageAccountStatusException('Ce compte est verrouillé.');
+ }
+ }
+
+ public function checkPostAuth(UserInterface $user, ?TokenInterface $token = null): void {}
+}
diff --git a/src/State/SalaryRecapPrintProvider.php b/src/State/SalaryRecapPrintProvider.php
index 814618f..6dfc525 100644
--- a/src/State/SalaryRecapPrintProvider.php
+++ b/src/State/SalaryRecapPrintProvider.php
@@ -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
+ */
+ 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,
];
}
diff --git a/templates/salary-recap/print.html.twig b/templates/salary-recap/print.html.twig
index ec77de8..6cd87da 100644
--- a/templates/salary-recap/print.html.twig
+++ b/templates/salary-recap/print.html.twig
@@ -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; }
@@ -139,7 +144,7 @@
{{ row.nightBasketCount > 0 ? row.nightBasketCount : '' }}
{{ row.paidHours > 0 ? row.paidHours : '' }}
{{ row.sundayHours > 0 ? row.sundayHours : '' }}
- {{ row.bonusAmount > 0 ? row.bonusAmount : '' }}
+ {{ row.bonusAmount > 0 ? row.bonusAmount ~ ' €' : '' }}
{{ row.congesCount > 0 ? row.congesCount : '' }}
{{ row.congesDates }}
{{ row.maladieCount > 0 ? row.maladieCount : '' }}
@@ -148,7 +153,7 @@
{{ row.isDriver and row.driverMeals > 0 ? row.driverMeals : '' }}
{{ row.isDriver and row.driverOvernight > 0 ? row.driverOvernight : '' }}
{{ row.isDriver and row.driverSaturdays > 0 ? row.driverSaturdays : '' }}
-
+ {{ row.observation }}
{% else %}