diff --git a/src/Controller/Contracts/MaxHoursController.php b/src/Controller/Contracts/MaxHoursController.php
new file mode 100644
index 00000000..80860760
--- /dev/null
+++ b/src/Controller/Contracts/MaxHoursController.php
@@ -0,0 +1,72 @@
+getOrganization();
+
+ $this->denyAccessUnlessGranted('orga:manage:contracts', $organization);
+
+ return $this->render('contracts/max_hours/edit.html.twig', [
+ 'organization' => $organization,
+ 'contract' => $contract,
+ 'additionalHours' => 0,
+ ]);
+ }
+
+ #[Route('/contracts/{uid}/max-hours/edit', name: 'update contract max hours', methods: ['POST'])]
+ public function update(
+ Contract $contract,
+ Request $request,
+ ContractRepository $contractRepository,
+ TranslatorInterface $translator,
+ ): Response {
+ $organization = $contract->getOrganization();
+
+ $this->denyAccessUnlessGranted('orga:manage:contracts', $organization);
+
+ $additionalHours = $request->request->getInt('additionalHours');
+ $csrfToken = $request->request->getString('_csrf_token');
+
+ if ($additionalHours < 0) {
+ $additionalHours = 0;
+ }
+
+ if (!$this->isCsrfTokenValid('update contract max hours', $csrfToken)) {
+ return $this->renderBadRequest('contracts/max_hours/edit.html.twig', [
+ 'organization' => $organization,
+ 'contract' => $contract,
+ 'additionalHours' => $additionalHours,
+ 'error' => $translator->trans('csrf.invalid', [], 'errors'),
+ ]);
+ }
+
+ $maxHours = $contract->getMaxHours() + $additionalHours;
+ $contract->setMaxHours($maxHours);
+
+ $contractRepository->save($contract, true);
+
+ return $this->redirectToRoute('organization contract', [
+ 'uid' => $organization->getUid(),
+ 'contract_uid' => $contract->getUid(),
+ ]);
+ }
+}
diff --git a/templates/contracts/max_hours/edit.html.twig b/templates/contracts/max_hours/edit.html.twig
new file mode 100644
index 00000000..18dc1255
--- /dev/null
+++ b/templates/contracts/max_hours/edit.html.twig
@@ -0,0 +1,46 @@
+{#
+ # This file is part of Bileto.
+ # Copyright 2022-2023 Probesys
+ # SPDX-License-Identifier: AGPL-3.0-or-later
+ #}
+
+{% extends 'modal.html.twig' %}
+
+{% block title %}{{ 'contracts.max_hours.edit.title' | trans }}{% endblock %}
+
+{% block body %}
+
+{% endblock %}
diff --git a/templates/organizations/contracts/show.html.twig b/templates/organizations/contracts/show.html.twig
index 5b95349b..441893b6 100644
--- a/templates/organizations/contracts/show.html.twig
+++ b/templates/organizations/contracts/show.html.twig
@@ -49,9 +49,20 @@
{% endif %}
-
- {{ 'contracts.hours_consumed' | trans({ 'hours': contract.consumedMinutes | formatMinutes, 'maxHours': contract.maxHours }) | raw }}
-
+
+
+ {{ 'contracts.hours_consumed' | trans({ 'hours': contract.consumedMinutes | formatMinutes, 'maxHours': contract.maxHours }) | raw }}
+
+
+
+
diff --git a/tests/Controller/Contracts/MaxHoursControllerTest.php b/tests/Controller/Contracts/MaxHoursControllerTest.php
new file mode 100644
index 00000000..ca8c4a6e
--- /dev/null
+++ b/tests/Controller/Contracts/MaxHoursControllerTest.php
@@ -0,0 +1,133 @@
+loginUser($user->object());
+ $this->grantOrga($user->object(), ['orga:manage:contracts']);
+ $contract = ContractFactory::createOne();
+
+ $client->request('GET', "/contracts/{$contract->getUid()}/max-hours/edit");
+
+ $this->assertResponseIsSuccessful();
+ $this->assertSelectorTextContains('h1', 'Extend the number of hours of the contract');
+ }
+
+ public function testGetEditFailsIfAccessIsForbidden(): void
+ {
+ $this->expectException(AccessDeniedException::class);
+
+ $client = static::createClient();
+ $user = UserFactory::createOne();
+ $client->loginUser($user->object());
+ $contract = ContractFactory::createOne();
+
+ $client->catchExceptions(false);
+ $client->request('GET', "/contracts/{$contract->getUid()}/max-hours/edit");
+ }
+
+ public function testPostUpdateSavesTheMaxHours(): void
+ {
+ $client = static::createClient();
+ $user = UserFactory::createOne();
+ $client->loginUser($user->object());
+ $this->grantOrga($user->object(), ['orga:manage:contracts']);
+ $contract = ContractFactory::createOne([
+ 'maxHours' => 10,
+ ]);
+
+ $client->request('POST', "/contracts/{$contract->getUid()}/max-hours/edit", [
+ '_csrf_token' => $this->generateCsrfToken($client, 'update contract max hours'),
+ 'additionalHours' => 5,
+ ]);
+
+ $organization = $contract->getOrganization();
+ $this->assertResponseRedirects("/organizations/{$organization->getUid()}/contracts/{$contract->getUid()}", 302);
+ $contract->refresh();
+ $this->assertSame(15, $contract->getMaxHours());
+ }
+
+ public function testPostUpdateDoesNotAcceptNegativeAdditionalHours(): void
+ {
+ $client = static::createClient();
+ $user = UserFactory::createOne();
+ $client->loginUser($user->object());
+ $this->grantOrga($user->object(), ['orga:manage:contracts']);
+ $contract = ContractFactory::createOne([
+ 'maxHours' => 10,
+ ]);
+
+ $client->request('POST', "/contracts/{$contract->getUid()}/max-hours/edit", [
+ '_csrf_token' => $this->generateCsrfToken($client, 'update contract max hours'),
+ 'additionalHours' => -5,
+ ]);
+
+ $organization = $contract->getOrganization();
+ $this->assertResponseRedirects("/organizations/{$organization->getUid()}/contracts/{$contract->getUid()}", 302);
+ $contract->refresh();
+ $this->assertSame(10, $contract->getMaxHours());
+ }
+
+ public function testPostUpdateFailsIfCsrfTokenIsInvalid(): void
+ {
+ $client = static::createClient();
+ $user = UserFactory::createOne();
+ $client->loginUser($user->object());
+ $this->grantOrga($user->object(), ['orga:manage:contracts']);
+ $contract = ContractFactory::createOne([
+ 'maxHours' => 10,
+ ]);
+
+ $client->request('POST', "/contracts/{$contract->getUid()}/max-hours/edit", [
+ '_csrf_token' => 'not the token',
+ 'additionalHours' => 5,
+ ]);
+
+ $organization = $contract->getOrganization();
+ $this->assertSelectorTextContains('[data-test="alert-error"]', 'The security token is invalid');
+ $contract->refresh();
+ $this->assertSame(10, $contract->getMaxHours());
+ }
+
+ public function testPostUpdateFailsIfAccessIsForbidden(): void
+ {
+ $this->expectException(AccessDeniedException::class);
+
+ $client = static::createClient();
+ $user = UserFactory::createOne();
+ $client->loginUser($user->object());
+ $contract = ContractFactory::createOne([
+ 'maxHours' => 10,
+ ]);
+
+ $client->catchExceptions(false);
+ $client->request('POST', "/contracts/{$contract->getUid()}/max-hours/edit", [
+ '_csrf_token' => $this->generateCsrfToken($client, 'update contract max hours'),
+ 'additionalHours' => 5,
+ ]);
+ }
+}
diff --git a/translations/messages+intl-icu.en_GB.yaml b/translations/messages+intl-icu.en_GB.yaml
index c3d2de5d..fe78ba70 100644
--- a/translations/messages+intl-icu.en_GB.yaml
+++ b/translations/messages+intl-icu.en_GB.yaml
@@ -56,6 +56,8 @@ contracts.index.number: '{count, plural, =0 {No contracts} one {1 contract} othe
contracts.index.tickets: '{count, plural, =0 {No tickets} one {1 ticket} other {# tickets}}'
contracts.index.title: Contracts
contracts.max_hours: 'Max number of hours'
+contracts.max_hours.edit.additional_hours: 'Number of additional hours'
+contracts.max_hours.edit.title: 'Extend the number of hours of the contract'
contracts.name: Name
contracts.new.billing_interval: 'Billing interval in minutes'
contracts.new.billing_interval.caption: 'The time spent on tickets will be rounded up for billing purposes (e.g. for 15 minutes of time spent, 30 minutes will be deducted for a corresponding billing interval).'
@@ -63,6 +65,7 @@ contracts.new.submit: 'Create the contract'
contracts.new.title: 'New contract'
contracts.notes: 'Private notes'
contracts.show.billing_interval: 'Billing interval: {count, plural, one {1 minute} other {# minutes}}'
+contracts.show.extend: Extend
contracts.show.notes_confidential: confidential
contracts.show.renew: Renew
contracts.start_at: 'Start on'
diff --git a/translations/messages+intl-icu.fr_FR.yaml b/translations/messages+intl-icu.fr_FR.yaml
index 4458901f..435954c7 100644
--- a/translations/messages+intl-icu.fr_FR.yaml
+++ b/translations/messages+intl-icu.fr_FR.yaml
@@ -56,6 +56,8 @@ contracts.index.number: '{count, plural, =0 {Aucun contrat} one {1 contrat} othe
contracts.index.tickets: '{count, plural, =0 {Aucun ticket} one {1 ticket} other {# tickets}}'
contracts.index.title: Contrats
contracts.max_hours: 'Nombre max. d’heures'
+contracts.max_hours.edit.additional_hours: 'Nombre d’heures supplémentaires'
+contracts.max_hours.edit.title: 'Rallonger le nombre d’heures du contrat'
contracts.name: Nom
contracts.new.billing_interval: 'Intervalle de facturation en minutes'
contracts.new.billing_interval.caption: 'Le temps passé sur les tickets sera arrondi pour le décompte de la facturation (ex. pour 15 minutes de temps passés, 30 minutes seront décomptés pour un intervalle de facturation correspondant).'
@@ -63,6 +65,7 @@ contracts.new.submit: 'Créer le contrat'
contracts.new.title: 'Nouveau contrat'
contracts.notes: 'Notes privées'
contracts.show.billing_interval: "Intervalle de facturation\_: {count, plural, one {1 minute} other {# minutes}}"
+contracts.show.extend: Rallonger
contracts.show.notes_confidential: confidentielles
contracts.show.renew: Renouveler
contracts.start_at: 'Démarre le'