From eaf8a11e2bb96bfc4e90111ada39df77dc50ff84 Mon Sep 17 00:00:00 2001
From: tristan
Date: Wed, 29 Apr 2026 15:45:02 +0000
Subject: [PATCH] =?UTF-8?q?feat:=20ajout=20des=20commentaires=20=C3=A0=20l?=
=?UTF-8?q?a=20semaine=20(#15)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
| Numéro du ticket | Titre du ticket |
|------------------|-----------------|
| | |
## Description de la PR
## Modification du .env
## Check list
- [x] Pas de régression
- [ ] TU/TI/TF rédigée
- [x] TU/TI/TF OK
- [ ] CHANGELOG modifié
Reviewed-on: https://gitea.malio.fr/MALIO-DEV/SIRH/pulls/15
Co-authored-by: tristan
Co-committed-by: tristan
---
CLAUDE.md | 6 +
.../driver-hours/DriverHoursWeekView.vue | 10 +-
frontend/components/hours/HoursWeekView.vue | 10 +-
.../components/hours/WeekCommentDrawer.vue | 81 +++++++++++
frontend/composables/useDriverHoursPage.ts | 15 +-
frontend/composables/useHoursPage.ts | 15 +-
frontend/data/documentation-content.ts | 10 ++
frontend/i18n/locales/fr.json | 8 ++
frontend/pages/driver-hours.vue | 19 ++-
frontend/pages/hours.vue | 19 ++-
frontend/services/dto/work-hour.ts | 2 +
frontend/services/employee-week-comments.ts | 24 ++++
migrations/Version20260417100000.php | 29 ++++
src/Dto/WorkHours/WeeklySummaryRow.php | 2 +
src/Entity/EmployeeWeekComment.php | 136 ++++++++++++++++++
.../EmployeeWeekCommentRepository.php | 58 ++++++++
.../EmployeeWeekCommentWriteProcessor.php | 80 +++++++++++
src/State/WorkHourWeeklySummaryProvider.php | 20 ++-
.../EmployeeWeekCommentWriteProcessorTest.php | 76 ++++++++++
.../WorkHourWeeklySummaryProviderTest.php | 11 ++
20 files changed, 617 insertions(+), 14 deletions(-)
create mode 100644 frontend/components/hours/WeekCommentDrawer.vue
create mode 100644 frontend/services/employee-week-comments.ts
create mode 100644 migrations/Version20260417100000.php
create mode 100644 src/Entity/EmployeeWeekComment.php
create mode 100644 src/Repository/EmployeeWeekCommentRepository.php
create mode 100644 src/State/EmployeeWeekCommentWriteProcessor.php
create mode 100644 tests/State/EmployeeWeekCommentWriteProcessorTest.php
diff --git a/CLAUDE.md b/CLAUDE.md
index cd33261..635947a 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -48,6 +48,12 @@
- Saisie d'heures (ou de jours de présence) autorisée sur un férié
- **Crédit automatique des heures contractuelles** sur un férié Lun-Ven pour tout contrat hors Forfait, **uniquement en l'absence d'absence déclarée** : le total journalier = `max(saisie + credited_absence, référence_contractuelle)`. Référence : 35h→7h, 39h→8h Lun-Jeu/7h Ven, CUSTOM→weeklyHours/5, INTERIM→idem 35h/39h/custom selon weeklyHours. Aucune ligne BDD créée (crédit virtuel). Drivers : crédité en `dayHoursMinutes`. Impacte directement le total hebdo RTT (tranches 25%/50%). Dès qu'une absence est posée sur le férié, le crédit virtuel saute — c'est le `countAsWorkedHours` du type d'absence qui pilote. Services : `App\Service\WorkHours\HolidayVirtualHoursResolver` + `DailyReferenceMinutesResolver`. Doc complète : `doc/holiday-virtual-hours.md`.
+## Commentaires de semaine
+- Entité `EmployeeWeekComment` : commentaire libre par employé et semaine ISO (unique `(employee_id, week_start_date)`). `week_start_date` = lundi.
+- CRUD `/employee_week_comments` `ROLE_ADMIN`. Write processor audite via `AuditLogger`.
+- Picto bulle vue semaine (HoursWeekView + DriverHoursWeekView) : fond bleu/rouge. Intégré dans `WeeklySummaryRow.comment/commentId`.
+- Doc : `doc/week-comments.md`.
+
## Validation Rules
- `isValid` (RH): locks line for everyone (admin can only untoggle validation)
- `isSiteValid` (site manager): locks for non-admin, admin can still edit
diff --git a/frontend/components/driver-hours/DriverHoursWeekView.vue b/frontend/components/driver-hours/DriverHoursWeekView.vue
index a750128..0c4999b 100644
--- a/frontend/components/driver-hours/DriverHoursWeekView.vue
+++ b/frontend/components/driver-hours/DriverHoursWeekView.vue
@@ -33,8 +33,11 @@
{{ row.firstName }} {{ row.lastName }}
({{ row.contractName ?? '-' }})
-
- {{ row.siteName ?? 'Sans site' }} — {{ contractNatureLabel(row.contractNature) }}
+
+ {{ row.siteName ?? 'Sans site' }} — {{ contractNatureLabel(row.contractNature) }}
+
@@ -118,9 +121,12 @@ const cellTitle = (daily: {
defineProps<{
isWeekLoading: boolean
+ isAdmin: boolean
weekGridCols: string
weeklySummary: WeeklyWorkHourSummary | null
weekDayHeaders: Array<{ date: string; weekday: string; dayDate: string }>
formatMinutes: (minutes: number) => string
}>()
+
+defineEmits<{ (e: 'open-comment', row: WeeklyWorkHourSummary['rows'][number]): void }>()
diff --git a/frontend/components/hours/HoursWeekView.vue b/frontend/components/hours/HoursWeekView.vue
index d9033dc..32ffdc3 100644
--- a/frontend/components/hours/HoursWeekView.vue
+++ b/frontend/components/hours/HoursWeekView.vue
@@ -94,8 +94,11 @@
{{ row.firstName }} {{ row.lastName }}
({{ row.contractName ?? '-' }})
-
- {{ row.siteName ?? 'Sans site' }} — {{ contractNatureLabel(row.contractNature) }}
+
+ {{ row.siteName ?? 'Sans site' }} — {{ contractNatureLabel(row.contractNature) }}
+
@@ -179,9 +182,12 @@ const cellTitle = (daily: {
defineProps<{
isWeekLoading: boolean
+ isAdmin: boolean
weekGridCols: string
weeklySummary: WeeklyWorkHourSummary | null
weekDayHeaders: Array<{ date: string; label: string }>
formatMinutes: (minutes: number) => string
}>()
+
+defineEmits<{ (e: 'open-comment', row: WeeklyWorkHourSummary['rows'][number]): void }>()
diff --git a/frontend/components/hours/WeekCommentDrawer.vue b/frontend/components/hours/WeekCommentDrawer.vue
new file mode 100644
index 0000000..c9adedf
--- /dev/null
+++ b/frontend/components/hours/WeekCommentDrawer.vue
@@ -0,0 +1,81 @@
+
+
+
+
+
+
+
diff --git a/frontend/composables/useDriverHoursPage.ts b/frontend/composables/useDriverHoursPage.ts
index 74fd5fb..92510d7 100644
--- a/frontend/composables/useDriverHoursPage.ts
+++ b/frontend/composables/useDriverHoursPage.ts
@@ -926,6 +926,15 @@ export const useDriverHoursPage = () => {
}
}
+ const isWeekCommentDrawerOpen = ref(false)
+ const weekCommentContext = ref<{ employeeId: number; employeeLabel: string; weekStart: string; weekEnd: string; content: string; commentId: number | null } | null>(null)
+ const openWeekCommentDrawer = (row: { employeeId: number; firstName: string; lastName: string; comment?: string | null; commentId?: number | null }) => {
+ if (!weeklySummary.value) return
+ weekCommentContext.value = { employeeId: row.employeeId, employeeLabel: `${row.firstName} ${row.lastName}`.trim(), weekStart: weeklySummary.value.weekStart, weekEnd: weeklySummary.value.weekEnd, content: row.comment ?? '', commentId: row.commentId ?? null }
+ isWeekCommentDrawerOpen.value = true
+ }
+ const reloadWeeklySummary = async () => { await loadWeeklySummary() }
+
return {
isAdmin,
isSelfUser,
@@ -993,6 +1002,10 @@ export const useDriverHoursPage = () => {
deleteAbsenceFromDrawer,
closeAbsenceDrawer,
formatMinutes,
- handleSave
+ handleSave,
+ isWeekCommentDrawerOpen,
+ weekCommentContext,
+ openWeekCommentDrawer,
+ reloadWeeklySummary
}
}
diff --git a/frontend/composables/useHoursPage.ts b/frontend/composables/useHoursPage.ts
index 76860ed..c59f541 100644
--- a/frontend/composables/useHoursPage.ts
+++ b/frontend/composables/useHoursPage.ts
@@ -1112,6 +1112,15 @@ export const useHoursPage = () => {
}
}
+ const isWeekCommentDrawerOpen = ref(false)
+ const weekCommentContext = ref<{ employeeId: number; employeeLabel: string; weekStart: string; weekEnd: string; content: string; commentId: number | null } | null>(null)
+ const openWeekCommentDrawer = (row: { employeeId: number; firstName: string; lastName: string; comment?: string | null; commentId?: number | null }) => {
+ if (!weeklySummary.value) return
+ weekCommentContext.value = { employeeId: row.employeeId, employeeLabel: `${row.firstName} ${row.lastName}`.trim(), weekStart: weeklySummary.value.weekStart, weekEnd: weeklySummary.value.weekEnd, content: row.comment ?? '', commentId: row.commentId ?? null }
+ isWeekCommentDrawerOpen.value = true
+ }
+ const reloadWeeklySummary = async () => { await loadWeeklySummary() }
+
return {
isAdmin,
isSelfUser,
@@ -1186,6 +1195,10 @@ export const useHoursPage = () => {
deleteAbsenceFromDrawer,
closeAbsenceDrawer,
formatMinutes,
- handleSave
+ handleSave,
+ isWeekCommentDrawerOpen,
+ weekCommentContext,
+ openWeekCommentDrawer,
+ reloadWeeklySummary
}
}
diff --git a/frontend/data/documentation-content.ts b/frontend/data/documentation-content.ts
index 42d405a..a1c1e24 100644
--- a/frontend/data/documentation-content.ts
+++ b/frontend/data/documentation-content.ts
@@ -80,6 +80,16 @@ export const documentationSections: DocSection[] = [
{ type: 'list', content: 'Jour : total des heures dans la plage 06:00–21:00\nNuit : total des heures dans les plages 00:00–06:00 et 21:00–24:00\nTotal : somme des heures de jour et de nuit' },
],
},
+ {
+ id: 'commentaire-semaine',
+ title: 'Commentaires de semaine (admin)',
+ requiredLevel: 'admin',
+ blocks: [
+ { type: 'paragraph', content: 'Sur la vue semaine, un picto bulle permet d\'attacher un commentaire libre sur la semaine d\'un employé.' },
+ { type: 'list', content: 'Bulle bleue : pas de commentaire\nBulle rouge : un commentaire existe\nClic : ouvre le drawer avec textarea' },
+ { type: 'note', content: 'Les commentaires n\'affectent aucun calcul. Pour supprimer, videz la textarea puis Enregistrer, ou bouton Supprimer.' },
+ ],
+ },
],
},
{
diff --git a/frontend/i18n/locales/fr.json b/frontend/i18n/locales/fr.json
index 75abe0e..4d2fb3e 100644
--- a/frontend/i18n/locales/fr.json
+++ b/frontend/i18n/locales/fr.json
@@ -59,6 +59,10 @@
},
"leaveRecap": {
"load": "Impossible de charger le récap des congés."
+ },
+ "weekComment": {
+ "save": "Impossible d'enregistrer le commentaire de semaine.",
+ "delete": "Impossible de supprimer le commentaire de semaine."
}
},
"success": {
@@ -110,6 +114,10 @@
"create": "Observation créée.",
"update": "Observation mise à jour.",
"delete": "Observation supprimée."
+ },
+ "weekComment": {
+ "save": "Commentaire enregistré.",
+ "delete": "Commentaire supprimé."
}
}
}
diff --git a/frontend/pages/driver-hours.vue b/frontend/pages/driver-hours.vue
index 5b74cf8..dae82bc 100644
--- a/frontend/pages/driver-hours.vue
+++ b/frontend/pages/driver-hours.vue
@@ -74,11 +74,13 @@
@@ -110,6 +112,17 @@
@cancel="closeAbsenceDrawer"
/>
+
@@ -179,7 +192,11 @@ const {
formatMinutes,
isSelectedDateHoliday,
selectedHolidayLabel,
- handleSave
+ handleSave,
+ isWeekCommentDrawerOpen,
+ weekCommentContext,
+ openWeekCommentDrawer,
+ reloadWeeklySummary
} = useDriverHoursPage()
useHead({
diff --git a/frontend/pages/hours.vue b/frontend/pages/hours.vue
index debc600..ed5456b 100644
--- a/frontend/pages/hours.vue
+++ b/frontend/pages/hours.vue
@@ -81,11 +81,13 @@
@@ -117,6 +119,17 @@
@cancel="closeAbsenceDrawer"
/>
+
@@ -193,7 +206,11 @@ const {
deleteAbsenceFromDrawer,
closeAbsenceDrawer,
formatMinutes,
- handleSave
+ handleSave,
+ isWeekCommentDrawerOpen,
+ weekCommentContext,
+ openWeekCommentDrawer,
+ reloadWeeklySummary
} = useHoursPage()
useHead({
diff --git a/frontend/services/dto/work-hour.ts b/frontend/services/dto/work-hour.ts
index 64d0b5b..03e6a41 100644
--- a/frontend/services/dto/work-hour.ts
+++ b/frontend/services/dto/work-hour.ts
@@ -89,6 +89,8 @@ export type WeeklyWorkHourRowSummary = {
weeklyOvernightCount?: number
hasContractForWeek?: boolean
contractNature?: 'CDI' | 'CDD' | 'INTERIM' | null
+ comment?: string | null
+ commentId?: number | null
}
export type WeeklyWorkHourSummary = {
diff --git a/frontend/services/employee-week-comments.ts b/frontend/services/employee-week-comments.ts
new file mode 100644
index 0000000..dd1e745
--- /dev/null
+++ b/frontend/services/employee-week-comments.ts
@@ -0,0 +1,24 @@
+export type EmployeeWeekComment = {
+ id: number
+ weekStartDate: string
+ content: string
+}
+
+export const createWeekComment = async (payload: { employeeId: number; weekStartDate: string; content: string }) => {
+ const api = useApi()
+ return api.post('/employee_week_comments', {
+ employee: `/api/employees/${payload.employeeId}`,
+ weekStartDate: payload.weekStartDate,
+ content: payload.content
+ }, { toastSuccessKey: 'success.weekComment.save', toastErrorKey: 'errors.weekComment.save' })
+}
+
+export const updateWeekComment = async (id: number, content: string) => {
+ const api = useApi()
+ return api.patch(`/employee_week_comments/${id}`, { content }, { toastSuccessKey: 'success.weekComment.save', toastErrorKey: 'errors.weekComment.save' })
+}
+
+export const deleteWeekComment = async (id: number) => {
+ const api = useApi()
+ await api.delete(`/employee_week_comments/${id}`, {}, { toastSuccessKey: 'success.weekComment.delete', toastErrorKey: 'errors.weekComment.delete' })
+}
diff --git a/migrations/Version20260417100000.php b/migrations/Version20260417100000.php
new file mode 100644
index 0000000..3d958b1
--- /dev/null
+++ b/migrations/Version20260417100000.php
@@ -0,0 +1,29 @@
+addSql('CREATE TABLE employee_week_comments (id SERIAL NOT NULL, employee_id INT NOT NULL, week_start_date DATE NOT NULL, content TEXT NOT NULL, created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, updated_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, PRIMARY KEY(id))');
+ $this->addSql('CREATE UNIQUE INDEX uniq_employee_week_comment ON employee_week_comments (employee_id, week_start_date)');
+ $this->addSql('CREATE INDEX idx_ewc_week_start ON employee_week_comments (week_start_date)');
+ $this->addSql('ALTER TABLE employee_week_comments ADD CONSTRAINT fk_ewc_employee FOREIGN KEY (employee_id) REFERENCES employees(id) ON DELETE CASCADE');
+ }
+
+ public function down(Schema $schema): void
+ {
+ $this->addSql('DROP TABLE employee_week_comments');
+ }
+}
diff --git a/src/Dto/WorkHours/WeeklySummaryRow.php b/src/Dto/WorkHours/WeeklySummaryRow.php
index e4aacc4..812f5bf 100644
--- a/src/Dto/WorkHours/WeeklySummaryRow.php
+++ b/src/Dto/WorkHours/WeeklySummaryRow.php
@@ -35,5 +35,7 @@ final class WeeklySummaryRow
public int $weeklyOvernightCount = 0,
public bool $hasContractForWeek = true,
public ?string $contractNature = null,
+ public ?string $comment = null,
+ public ?int $commentId = null,
) {}
}
diff --git a/src/Entity/EmployeeWeekComment.php b/src/Entity/EmployeeWeekComment.php
new file mode 100644
index 0000000..762e4f4
--- /dev/null
+++ b/src/Entity/EmployeeWeekComment.php
@@ -0,0 +1,136 @@
+ ['week_comment:read'], 'datetime_format' => 'Y-m-d'],
+ denormalizationContext: ['groups' => ['week_comment:write'], 'datetime_format' => 'Y-m-d'],
+ order: ['weekStartDate' => 'DESC'],
+ paginationEnabled: false,
+)]
+#[ApiFilter(DateFilter::class, properties: ['weekStartDate'])]
+#[ApiFilter(SearchFilter::class, properties: ['employee' => 'exact'])]
+#[ORM\Entity(repositoryClass: EmployeeWeekCommentRepository::class)]
+#[ORM\Table(name: 'employee_week_comments')]
+#[ORM\UniqueConstraint(name: 'uniq_employee_week_comment', columns: ['employee_id', 'week_start_date'])]
+class EmployeeWeekComment
+{
+ #[ORM\Id]
+ #[ORM\GeneratedValue]
+ #[ORM\Column(type: 'integer')]
+ #[Groups(['week_comment:read'])]
+ private ?int $id = null;
+
+ #[ORM\ManyToOne(targetEntity: Employee::class)]
+ #[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')]
+ #[Groups(['week_comment:read', 'week_comment:write'])]
+ #[Assert\NotNull]
+ private ?Employee $employee = null;
+
+ #[ORM\Column(type: 'date_immutable')]
+ #[Groups(['week_comment:read', 'week_comment:write'])]
+ #[Assert\NotNull]
+ private ?DateTimeImmutable $weekStartDate = null;
+
+ #[ORM\Column(type: 'text')]
+ #[Groups(['week_comment:read', 'week_comment:write'])]
+ #[Assert\NotBlank]
+ #[Assert\Length(max: 5000)]
+ private string $content = '';
+
+ #[ORM\Column(type: 'datetime_immutable')]
+ #[Groups(['week_comment:read'])]
+ private DateTimeImmutable $createdAt;
+
+ #[ORM\Column(type: 'datetime_immutable')]
+ #[Groups(['week_comment:read'])]
+ private DateTimeImmutable $updatedAt;
+
+ public function __construct()
+ {
+ $now = new DateTimeImmutable();
+ $this->createdAt = $now;
+ $this->updatedAt = $now;
+ }
+
+ 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 getWeekStartDate(): ?DateTimeImmutable
+ {
+ return $this->weekStartDate;
+ }
+
+ public function setWeekStartDate(?DateTimeImmutable $weekStartDate): self
+ {
+ $this->weekStartDate = $weekStartDate;
+
+ 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;
+ }
+
+ public function getUpdatedAt(): DateTimeImmutable
+ {
+ return $this->updatedAt;
+ }
+
+ public function touchUpdatedAt(): void
+ {
+ $this->updatedAt = new DateTimeImmutable();
+ }
+}
diff --git a/src/Repository/EmployeeWeekCommentRepository.php b/src/Repository/EmployeeWeekCommentRepository.php
new file mode 100644
index 0000000..9984a8d
--- /dev/null
+++ b/src/Repository/EmployeeWeekCommentRepository.php
@@ -0,0 +1,58 @@
+
+ */
+class EmployeeWeekCommentRepository extends ServiceEntityRepository
+{
+ public function __construct(ManagerRegistry $registry)
+ {
+ parent::__construct($registry, EmployeeWeekComment::class);
+ }
+
+ public function findOneByEmployeeAndWeek(Employee $employee, DateTimeImmutable $weekStart): ?EmployeeWeekComment
+ {
+ return $this->findOneBy(['employee' => $employee, 'weekStartDate' => $weekStart]);
+ }
+
+ /**
+ * @param list $employees
+ *
+ * @return array employee_id → comment
+ */
+ public function findByWeekAndEmployees(DateTimeImmutable $weekStart, array $employees): array
+ {
+ if ([] === $employees) {
+ return [];
+ }
+
+ $rows = $this->createQueryBuilder('c')
+ ->andWhere('c.weekStartDate = :weekStart')
+ ->andWhere('c.employee IN (:employees)')
+ ->setParameter('weekStart', $weekStart)
+ ->setParameter('employees', $employees)
+ ->innerJoin('c.employee', 'e')->addSelect('e')
+ ->getQuery()->getResult()
+ ;
+
+ $map = [];
+ foreach ($rows as $row) {
+ $eid = $row->getEmployee()?->getId();
+ if (null !== $eid) {
+ $map[$eid] = $row;
+ }
+ }
+
+ return $map;
+ }
+}
diff --git a/src/State/EmployeeWeekCommentWriteProcessor.php b/src/State/EmployeeWeekCommentWriteProcessor.php
new file mode 100644
index 0000000..766d8a9
--- /dev/null
+++ b/src/State/EmployeeWeekCommentWriteProcessor.php
@@ -0,0 +1,80 @@
+getEmployee();
+
+ if ($operation instanceof DeleteOperationInterface) {
+ $this->auditLogger->log(
+ $employee,
+ 'delete',
+ 'week_comment',
+ $data->getId(),
+ sprintf('Commentaire semaine supprimé pour %s (semaine du %s)', $this->label($employee), $data->getWeekStartDate()?->format('d/m/Y') ?? '?'),
+ ['old' => ['content' => $data->getContent()]],
+ $data->getWeekStartDate(),
+ );
+ $result = $this->removeProcessor->process($data, $operation, $uriVariables, $context);
+ $this->entityManager->flush();
+
+ return $result;
+ }
+
+ $weekStart = $data->getWeekStartDate();
+ if (null === $weekStart || '1' !== $weekStart->format('N')) {
+ throw new UnprocessableEntityHttpException('weekStartDate must be a Monday (ISO weekday 1).');
+ }
+
+ $prev = null;
+ if (null !== $data->getId()) {
+ $prev = $this->entityManager->getUnitOfWork()->getOriginalEntityData($data)['content'] ?? null;
+ $data->touchUpdatedAt();
+ }
+
+ $result = $this->persistProcessor->process($data, $operation, $uriVariables, $context);
+
+ if (null === $prev) {
+ $this->auditLogger->log($employee, 'create', 'week_comment', $data->getId(), sprintf('Commentaire semaine créé pour %s (semaine du %s)', $this->label($employee), $weekStart->format('d/m/Y')), ['new' => ['content' => $data->getContent()]], $weekStart);
+ } elseif ($prev !== $data->getContent()) {
+ $this->auditLogger->log($employee, 'update', 'week_comment', $data->getId(), sprintf('Commentaire semaine modifié pour %s (semaine du %s)', $this->label($employee), $weekStart->format('d/m/Y')), ['old' => ['content' => $prev], 'new' => ['content' => $data->getContent()]], $weekStart);
+ }
+
+ $this->entityManager->flush();
+
+ return $result;
+ }
+
+ private function label(mixed $e): string
+ {
+ return $e instanceof Employee ? trim(($e->getLastName() ?? '').' '.($e->getFirstName() ?? '')) : '?';
+ }
+}
diff --git a/src/State/WorkHourWeeklySummaryProvider.php b/src/State/WorkHourWeeklySummaryProvider.php
index 41a196f..00ae3f1 100644
--- a/src/State/WorkHourWeeklySummaryProvider.php
+++ b/src/State/WorkHourWeeklySummaryProvider.php
@@ -13,6 +13,7 @@ use App\Dto\WorkHours\WorkMetrics;
use App\Entity\Absence;
use App\Entity\Contract;
use App\Entity\Employee;
+use App\Entity\EmployeeWeekComment;
use App\Entity\User;
use App\Entity\WorkHour;
use App\Enum\ContractNature;
@@ -21,6 +22,7 @@ use App\Enum\TrackingMode;
use App\Repository\Contract\AbsenceReadRepositoryInterface;
use App\Repository\Contract\EmployeeScopedRepositoryInterface;
use App\Repository\Contract\WorkHourReadRepositoryInterface;
+use App\Repository\EmployeeWeekCommentRepository;
use App\Service\Contracts\EmployeeContractResolver;
use App\Service\PublicHolidayServiceInterface;
use App\Service\WorkHours\AbsenceSegmentsResolver;
@@ -48,6 +50,7 @@ final readonly class WorkHourWeeklySummaryProvider implements ProviderInterface
private DailyReferenceMinutesResolver $dailyReferenceResolver,
private HolidayVirtualHoursResolver $holidayVirtualHoursResolver,
private PublicHolidayServiceInterface $publicHolidayService,
+ private EmployeeWeekCommentRepository $weekCommentRepository,
) {}
public function provide(Operation $operation, array $uriVariables = [], array $context = []): WorkHourWeeklySummary
@@ -65,11 +68,13 @@ final readonly class WorkHourWeeklySummaryProvider implements ProviderInterface
$workHours = $this->workHourRepository->findByDateRangeAndEmployees($weekStart, $weekEnd, $employees);
$absences = $this->absenceRepository->findForPrint($weekStart, $weekEnd, $employees);
+ $weekComments = $this->weekCommentRepository->findByWeekAndEmployees($weekStart, $employees);
+
$summary = new WorkHourWeeklySummary();
$summary->weekStart = $weekStart->format('Y-m-d');
$summary->weekEnd = $weekEnd->format('Y-m-d');
$summary->days = $days;
- $summary->rows = $this->buildRows($employees, $workHours, $absences, $days, $anchorDate->format('Y-m-d'));
+ $summary->rows = $this->buildRows($employees, $workHours, $absences, $days, $anchorDate->format('Y-m-d'), $weekComments);
return $summary;
}
@@ -112,14 +117,15 @@ final readonly class WorkHourWeeklySummaryProvider implements ProviderInterface
}
/**
- * @param list $employees
- * @param list $workHours
- * @param list $absences
- * @param list $days
+ * @param list $employees
+ * @param list $workHours
+ * @param list $absences
+ * @param list $days
+ * @param array $weekComments
*
* @return list
*/
- private function buildRows(array $employees, array $workHours, array $absences, array $days, string $anchorDateYmd): array
+ private function buildRows(array $employees, array $workHours, array $absences, array $days, string $anchorDateYmd, array $weekComments = []): array
{
$contractsByEmployeeDate = $this->contractResolver->resolveForEmployeesAndDays($employees, $days);
$contractNaturesByEmployeeDate = $this->contractResolver->resolveNaturesForEmployeesAndDays($employees, $days);
@@ -375,6 +381,8 @@ final readonly class WorkHourWeeklySummaryProvider implements ProviderInterface
weeklyOvernightCount: $weeklyOvernightCount,
hasContractForWeek: $hasContractForWeek,
contractNature: $weekAnchorContractNature->value,
+ comment: ($weekComments[$employeeId] ?? null)?->getContent(),
+ commentId: ($weekComments[$employeeId] ?? null)?->getId(),
);
}
diff --git a/tests/State/EmployeeWeekCommentWriteProcessorTest.php b/tests/State/EmployeeWeekCommentWriteProcessorTest.php
new file mode 100644
index 0000000..0e31796
--- /dev/null
+++ b/tests/State/EmployeeWeekCommentWriteProcessorTest.php
@@ -0,0 +1,76 @@
+createStub(ProcessorInterface::class),
+ $this->createStub(ProcessorInterface::class),
+ $this->createStub(EntityManagerInterface::class),
+ $this->createStub(AuditLogger::class),
+ );
+
+ $comment = new EmployeeWeekComment()
+ ->setEmployee(new Employee()->setFirstName('A')->setLastName('B'))
+ ->setWeekStartDate(new DateTimeImmutable('2026-04-14'))
+ ->setContent('test')
+ ;
+
+ $this->expectException(UnprocessableEntityHttpException::class);
+ $processor->process($comment, new Post());
+ }
+
+ public function testAcceptsMondayAndAuditsCreate(): void
+ {
+ $persist = $this->createMock(ProcessorInterface::class);
+ $persist->expects(self::once())->method('process');
+ $em = $this->createMock(EntityManagerInterface::class);
+ $em->method('getUnitOfWork')->willReturn($this->createStub(UnitOfWork::class));
+ $em->expects(self::once())->method('flush');
+ $auditor = $this->createMock(AuditLogger::class);
+ $auditor->expects(self::once())->method('log')->with(self::anything(), 'create', 'week_comment');
+
+ $processor = new EmployeeWeekCommentWriteProcessor($persist, $this->createStub(ProcessorInterface::class), $em, $auditor);
+ $processor->process(
+ new EmployeeWeekComment()->setEmployee(new Employee()->setFirstName('A')->setLastName('B'))->setWeekStartDate(new DateTimeImmutable('2026-04-13'))->setContent('x'),
+ new Post()
+ );
+ }
+
+ public function testDeleteAudits(): void
+ {
+ $remove = $this->createMock(ProcessorInterface::class);
+ $remove->expects(self::once())->method('process');
+ $em = $this->createMock(EntityManagerInterface::class);
+ $em->expects(self::once())->method('flush');
+ $auditor = $this->createMock(AuditLogger::class);
+ $auditor->expects(self::once())->method('log')->with(self::anything(), 'delete', 'week_comment');
+
+ $processor = new EmployeeWeekCommentWriteProcessor($this->createStub(ProcessorInterface::class), $remove, $em, $auditor);
+ $processor->process(
+ new EmployeeWeekComment()->setEmployee(new Employee()->setFirstName('A')->setLastName('B'))->setWeekStartDate(new DateTimeImmutable('2026-04-13'))->setContent('x'),
+ new Delete()
+ );
+ }
+}
diff --git a/tests/State/WorkHourWeeklySummaryProviderTest.php b/tests/State/WorkHourWeeklySummaryProviderTest.php
index 8f53d81..63ba1de 100644
--- a/tests/State/WorkHourWeeklySummaryProviderTest.php
+++ b/tests/State/WorkHourWeeklySummaryProviderTest.php
@@ -15,6 +15,7 @@ use App\Enum\HalfDay;
use App\Repository\Contract\AbsenceReadRepositoryInterface;
use App\Repository\Contract\EmployeeScopedRepositoryInterface;
use App\Repository\Contract\WorkHourReadRepositoryInterface;
+use App\Repository\EmployeeWeekCommentRepository;
use App\Service\Contracts\EmployeeContractResolver;
use App\Service\PublicHolidayServiceInterface;
use App\Service\WorkHours\AbsenceSegmentsResolver;
@@ -67,6 +68,7 @@ final class WorkHourWeeklySummaryProviderTest extends TestCase
new DailyReferenceMinutesResolver(),
$this->buildHolidayResolver(),
$this->buildHolidayService(),
+ $this->buildWeekCommentRepoStub(),
);
$this->expectException(AccessDeniedHttpException::class);
@@ -130,6 +132,7 @@ final class WorkHourWeeklySummaryProviderTest extends TestCase
new DailyReferenceMinutesResolver(),
$this->buildHolidayResolver(),
$this->buildHolidayService(),
+ $this->buildWeekCommentRepoStub(),
);
$result = $provider->provide(new Get());
@@ -180,6 +183,14 @@ final class WorkHourWeeklySummaryProviderTest extends TestCase
$property->setValue($entity, $id);
}
+ private function buildWeekCommentRepoStub(): EmployeeWeekCommentRepository
+ {
+ $r = $this->createStub(EmployeeWeekCommentRepository::class);
+ $r->method('findByWeekAndEmployees')->willReturn([]);
+
+ return $r;
+ }
+
private function buildHolidayResolver(array $holidayMap = []): HolidayVirtualHoursResolver
{
return new HolidayVirtualHoursResolver(