From 93cb6fd739836b9ad6bd59acf7d7df2d62452802 Mon Sep 17 00:00:00 2001 From: Florimond Manca Date: Fri, 14 Jun 2024 11:28:14 +0200 Subject: [PATCH] =?UTF-8?q?Am=C3=A9liore=20l'interface=20de=20demandes=20d?= =?UTF-8?q?e=20cong=C3=A9s=20(#437)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- e2e/nav.spec.js | 2 +- .../Leave/Query/GetLeaveRequestByIdQuery.ts | 3 +- .../GetLeaveRequestByIdQueryHandler.spec.ts | 36 +++- .../Query/GetLeaveRequestByIdQueryHandler.ts | 9 +- .../GetLeaveRequestsQueryHandler.spec.ts | 166 +++++++----------- .../Query/GetLeaveRequestsQueryHandler.ts | 25 +-- .../Leave/View/LeaveRequestDetailView.ts | 8 +- .../Leave/LeaveRequest.entity.ts | 9 + .../CanLeaveRequestBeCancelled.spec.ts | 66 +++++++ .../CanLeaveRequestBeCancelled.ts | 28 +++ .../DoesLeaveRequestBelongToUser.spec.ts | 11 +- .../DoesLeaveRequestBelongToUser.ts | 9 +- .../DeleteLeaveRequestController.ts | 2 +- .../Controller/EditLeaveRequestController.ts | 18 +- .../Controller/GetLeaveRequestController.ts | 10 +- .../Leave/Controller/ListLeavesController.ts | 56 ------ .../ModerateLeaveRequestController.ts | 2 +- .../Repository/LeaveRequestRepository.ts | 5 +- .../Leave/Table/LeaveRequestTableFactory.ts | 51 +++++- .../Leave/Table/LeaveTableFactory.ts | 55 ------ .../HumanResource/humanResource.module.ts | 6 +- .../Project/Table/ProjectTableFactory.ts | 3 +- src/Infrastructure/Tables/RowFactory.ts | 11 +- .../NunjucksTemplates/NunjucksTemplates.ts | 4 + src/Infrastructure/Ui/Picto.ts | 5 + src/assets/customElements/dialogTrigger.js | 43 +++++ src/assets/customElements/formSubmit.js | 22 +++ src/assets/customElements/index.js | 2 + src/assets/styles/components/icon.css | 11 -- src/assets/styles/components/index.css | 1 + src/assets/styles/components/picto.css | 19 ++ src/assets/styles/defaults.css | 4 + src/templates/components/nav_links.njk | 2 +- src/templates/macros/dialog.njk | 28 +++ src/templates/pages/leave_requests/_form.njk | 20 +++ src/templates/pages/leave_requests/add.njk | 2 +- src/templates/pages/leave_requests/detail.njk | 20 ++- src/templates/pages/leave_requests/edit.njk | 12 +- src/templates/pages/leave_requests/list.njk | 4 +- src/templates/pages/leaves/list.njk | 39 ---- src/templates/tables/cells/actions.njk | 22 ++- src/templates/tables/cells/picto.njk | 6 +- src/translations/fr-FR.ftl | 17 +- 43 files changed, 530 insertions(+), 344 deletions(-) create mode 100644 src/Domain/HumanResource/Leave/Specification/CanLeaveRequestBeCancelled.spec.ts create mode 100644 src/Domain/HumanResource/Leave/Specification/CanLeaveRequestBeCancelled.ts delete mode 100644 src/Infrastructure/HumanResource/Leave/Controller/ListLeavesController.ts delete mode 100644 src/Infrastructure/HumanResource/Leave/Table/LeaveTableFactory.ts create mode 100644 src/Infrastructure/Ui/Picto.ts create mode 100644 src/assets/customElements/dialogTrigger.js create mode 100644 src/assets/customElements/formSubmit.js create mode 100644 src/assets/styles/components/picto.css create mode 100644 src/templates/macros/dialog.njk delete mode 100644 src/templates/pages/leaves/list.njk diff --git a/e2e/nav.spec.js b/e2e/nav.spec.js index 5888a160..4d630f3c 100644 --- a/e2e/nav.spec.js +++ b/e2e/nav.spec.js @@ -19,7 +19,7 @@ test('nav links', async ({ page }) => { ['Clients', '/app/customers'], ['Projets', '/app/projects'], ['Missions', '/app/tasks'], - ['Congés', '/app/people/leaves'], + ['Congés', '/app/people/leave_requests'], ['Éléments de paie', '/app/people/payroll_elements'], ['Tickets resto', '/app/people/meal_tickets'], ['Coopérateur·ices et salarié·es', '/app/people/users'] diff --git a/src/Application/HumanResource/Leave/Query/GetLeaveRequestByIdQuery.ts b/src/Application/HumanResource/Leave/Query/GetLeaveRequestByIdQuery.ts index 264f1b78..7577dcda 100644 --- a/src/Application/HumanResource/Leave/Query/GetLeaveRequestByIdQuery.ts +++ b/src/Application/HumanResource/Leave/Query/GetLeaveRequestByIdQuery.ts @@ -1,5 +1,6 @@ import { IQuery } from 'src/Application/IQuery'; +import { User } from 'src/Domain/HumanResource/User/User.entity'; export class GetLeaveRequestByIdQuery implements IQuery { - constructor(public readonly id: string) {} + constructor(public readonly id: string, public readonly currentUser: User) {} } diff --git a/src/Application/HumanResource/Leave/Query/GetLeaveRequestByIdQueryHandler.spec.ts b/src/Application/HumanResource/Leave/Query/GetLeaveRequestByIdQueryHandler.spec.ts index 1cd74e00..dc0cef6c 100644 --- a/src/Application/HumanResource/Leave/Query/GetLeaveRequestByIdQueryHandler.spec.ts +++ b/src/Application/HumanResource/Leave/Query/GetLeaveRequestByIdQueryHandler.spec.ts @@ -12,18 +12,22 @@ import { UserSummaryView } from '../../User/View/UserSummaryView'; import { LeaveRequestDetailView } from '../View/LeaveRequestDetailView'; import { GetLeaveRequestByIdQuery } from './GetLeaveRequestByIdQuery'; import { GetLeaveRequestByIdQueryHandler } from './GetLeaveRequestByIdQueryHandler'; +import { CanLeaveRequestBeCancelled } from 'src/Domain/HumanResource/Leave/Specification/CanLeaveRequestBeCancelled'; describe('GetLeaveRequestByIdQueryHandler', () => { let leaveRequestRepository: LeaveRequestRepository; let dateUtils: DateUtilsAdapter; + let canLeaveRequestBeCancelled: CanLeaveRequestBeCancelled; let queryHandler: GetLeaveRequestByIdQueryHandler; beforeEach(() => { leaveRequestRepository = mock(LeaveRequestRepository); dateUtils = mock(DateUtilsAdapter); + canLeaveRequestBeCancelled = mock(CanLeaveRequestBeCancelled); queryHandler = new GetLeaveRequestByIdQueryHandler( instance(leaveRequestRepository), - instance(dateUtils) + instance(dateUtils), + instance(canLeaveRequestBeCancelled) ); }); @@ -37,6 +41,7 @@ describe('GetLeaveRequestByIdQueryHandler', () => { '2020-05-15', true, 7.5, + false, 'Country vacation', new UserSummaryView( '54bb15ad-56da-45f8-b594-3ca43f13d4c0', @@ -48,6 +53,7 @@ describe('GetLeaveRequestByIdQueryHandler', () => { 'Ada', 'LOVELACE' ), + '2020-05-01', 'Go go go' ); @@ -72,8 +78,13 @@ describe('GetLeaveRequestByIdQueryHandler', () => { when(leave.getComment()).thenReturn('Country vacation'); when(leave.getUser()).thenReturn(instance(user)); when(leave.getModerator()).thenReturn(instance(moderator)); + when(leave.getModerateAt()).thenReturn('2020-05-01'); when(leave.getModerationComment()).thenReturn('Go go go'); + when( + canLeaveRequestBeCancelled.isSatisfiedBy(instance(user), instance(leave)) + ).thenReturn(false); + when( leaveRequestRepository.findOneById('204522d3-f077-4d21-b3ee-6e0d742fca44') ).thenResolve(instance(leave)); @@ -84,7 +95,10 @@ describe('GetLeaveRequestByIdQueryHandler', () => { expect( await queryHandler.execute( - new GetLeaveRequestByIdQuery('204522d3-f077-4d21-b3ee-6e0d742fca44') + new GetLeaveRequestByIdQuery( + '204522d3-f077-4d21-b3ee-6e0d742fca44', + instance(user) + ) ) ).toMatchObject(expectedResult); @@ -101,9 +115,14 @@ describe('GetLeaveRequestByIdQueryHandler', () => { leaveRequestRepository.findOneById('204522d3-f077-4d21-b3ee-6e0d742fca44') ).thenResolve(null); + const user = mock(User); + try { await queryHandler.execute( - new GetLeaveRequestByIdQuery('204522d3-f077-4d21-b3ee-6e0d742fca44') + new GetLeaveRequestByIdQuery( + '204522d3-f077-4d21-b3ee-6e0d742fca44', + user + ) ); } catch (e) { expect(e).toBeInstanceOf(LeaveRequestNotFoundException); @@ -126,6 +145,7 @@ describe('GetLeaveRequestByIdQueryHandler', () => { '2020-05-15', true, 7.5, + false, 'Country vacation', new UserSummaryView( '2402455a-4dc1-47c9-89a4-f9b859f02f5c', @@ -133,6 +153,7 @@ describe('GetLeaveRequestByIdQueryHandler', () => { 'DOE' ), null, + null, null ); @@ -154,6 +175,10 @@ describe('GetLeaveRequestByIdQueryHandler', () => { when(leave.getModerator()).thenReturn(null); when(leave.getModerationComment()).thenReturn(null); + when( + canLeaveRequestBeCancelled.isSatisfiedBy(instance(user), instance(leave)) + ).thenReturn(false); + when( leaveRequestRepository.findOneById('a3753b9c-b711-4e0e-a535-e473161bd612') ).thenResolve(instance(leave)); @@ -164,7 +189,10 @@ describe('GetLeaveRequestByIdQueryHandler', () => { expect( await queryHandler.execute( - new GetLeaveRequestByIdQuery('a3753b9c-b711-4e0e-a535-e473161bd612') + new GetLeaveRequestByIdQuery( + 'a3753b9c-b711-4e0e-a535-e473161bd612', + instance(user) + ) ) ).toMatchObject(expectedResult); diff --git a/src/Application/HumanResource/Leave/Query/GetLeaveRequestByIdQueryHandler.ts b/src/Application/HumanResource/Leave/Query/GetLeaveRequestByIdQueryHandler.ts index 1c9184cc..eb91cdc7 100644 --- a/src/Application/HumanResource/Leave/Query/GetLeaveRequestByIdQueryHandler.ts +++ b/src/Application/HumanResource/Leave/Query/GetLeaveRequestByIdQueryHandler.ts @@ -6,6 +6,7 @@ import { LeaveRequestRepository } from 'src/Infrastructure/HumanResource/Leave/R import { UserSummaryView } from '../../User/View/UserSummaryView'; import { LeaveRequestDetailView } from '../View/LeaveRequestDetailView'; import { GetLeaveRequestByIdQuery } from './GetLeaveRequestByIdQuery'; +import { CanLeaveRequestBeCancelled } from 'src/Domain/HumanResource/Leave/Specification/CanLeaveRequestBeCancelled'; @QueryHandler(GetLeaveRequestByIdQuery) export class GetLeaveRequestByIdQueryHandler { @@ -13,7 +14,8 @@ export class GetLeaveRequestByIdQueryHandler { @Inject('ILeaveRequestRepository') private readonly leaveRequestRepository: LeaveRequestRepository, @Inject('IDateUtils') - private readonly dateUtils: IDateUtils + private readonly dateUtils: IDateUtils, + private readonly canLeaveRequestBeCancelled: CanLeaveRequestBeCancelled ) {} public async execute( @@ -53,6 +55,10 @@ export class GetLeaveRequestByIdQueryHandler { leaveRequest.getEndDate(), leaveRequest.isEndsAllDay() ), + this.canLeaveRequestBeCancelled.isSatisfiedBy( + query.currentUser, + leaveRequest + ), leaveRequest.getComment(), new UserSummaryView( user.getId(), @@ -60,6 +66,7 @@ export class GetLeaveRequestByIdQueryHandler { user.getLastName() ), moderatorView, + leaveRequest.getModerateAt(), leaveRequest.getModerationComment() ); } diff --git a/src/Application/HumanResource/Leave/Query/GetLeaveRequestsQueryHandler.spec.ts b/src/Application/HumanResource/Leave/Query/GetLeaveRequestsQueryHandler.spec.ts index 04f2fa0c..50ee4fb3 100644 --- a/src/Application/HumanResource/Leave/Query/GetLeaveRequestsQueryHandler.spec.ts +++ b/src/Application/HumanResource/Leave/Query/GetLeaveRequestsQueryHandler.spec.ts @@ -13,22 +13,25 @@ import { Pagination } from 'src/Application/Common/Pagination'; import { LeaveRequestRepository } from 'src/Infrastructure/HumanResource/Leave/Repository/LeaveRequestRepository'; import { GetLeaveRequestsQuery } from './GetLeaveRequestsQuery'; import { UserRepository } from 'src/Infrastructure/HumanResource/User/Repository/UserRepository'; -import { LoggedUser } from 'src/Infrastructure/HumanResource/User/Decorator/LoggedUser'; +import { CanLeaveRequestBeCancelled } from 'src/Domain/HumanResource/Leave/Specification/CanLeaveRequestBeCancelled'; describe('GetLeaveRequestsQueryHandler', () => { let leaveRequestRepository: LeaveRequestRepository; let userRepository: UserRepository; let dateUtils: DateUtilsAdapter; + let canLeaveRequestBeCancelled: CanLeaveRequestBeCancelled; let queryHandler: GetLeaveRequestsQueryHandler; beforeEach(() => { leaveRequestRepository = mock(LeaveRequestRepository); userRepository = mock(UserRepository); dateUtils = mock(DateUtilsAdapter); + canLeaveRequestBeCancelled = mock(CanLeaveRequestBeCancelled); queryHandler = new GetLeaveRequestsQueryHandler( instance(leaveRequestRepository), instance(userRepository), - instance(dateUtils) + instance(dateUtils), + instance(canLeaveRequestBeCancelled) ); }); @@ -64,9 +67,24 @@ describe('GetLeaveRequestsQueryHandler', () => { 'Mathieu', 'MARCHOIS' ) + ), + new LeaveRequestView( + 'd54f15d6-1a1d-47e8-8672-9f46018f9960', + Type.PAID, + Status.ACCEPTED, + '2020-05-08', + '2020-05-15', + 4, + true, + null, + new UserSummaryView( + 'abcffaa9-cdc5-40fa-a71c-0e93eadd61fd', + 'John', + 'DOE' + ) ) ], - 2 + 3 ); const user = mock(User); @@ -76,6 +94,8 @@ describe('GetLeaveRequestsQueryHandler', () => { const loggedUser = mock(User); when(loggedUser.getId()).thenReturn('abcffaa9-cdc5-40fa-a71c-0e93eadd61fd'); + when(loggedUser.getFirstName()).thenReturn('John'); + when(loggedUser.getLastName()).thenReturn('DOE'); const leave1 = mock(LeaveRequest); when(leave1.getId()).thenReturn('d54f15d6-1a1d-47e8-8672-9f46018f9960'); @@ -88,6 +108,13 @@ describe('GetLeaveRequestsQueryHandler', () => { when(leave1.getUser()).thenReturn(instance(user)); when(user.getLastName()).thenReturn('MARCHOIS'); + when( + canLeaveRequestBeCancelled.isSatisfiedBy( + instance(loggedUser), + instance(leave1) + ) + ).thenReturn(false); + const leave2 = mock(LeaveRequest); when(leave2.getId()).thenReturn('252dacfc-1db9-4112-b1bc-37d28d50ced0'); when(leave2.getType()).thenReturn(Type.SPECIAL); @@ -98,9 +125,33 @@ describe('GetLeaveRequestsQueryHandler', () => { when(leave2.isEndsAllDay()).thenReturn(false); when(leave2.getUser()).thenReturn(instance(user)); + when( + canLeaveRequestBeCancelled.isSatisfiedBy( + instance(loggedUser), + instance(leave2) + ) + ).thenReturn(false); + + const leave3 = mock(LeaveRequest); + when(leave3.getId()).thenReturn('d54f15d6-1a1d-47e8-8672-9f46018f9960'); + when(leave3.getType()).thenReturn(Type.PAID); + when(leave3.getStatus()).thenReturn(Status.ACCEPTED); + when(leave3.getStartDate()).thenReturn('2020-05-08'); + when(leave3.isStartsAllDay()).thenReturn(true); + when(leave3.getEndDate()).thenReturn('2020-05-15'); + when(leave3.isEndsAllDay()).thenReturn(true); + when(leave3.getUser()).thenReturn(instance(loggedUser)); + + when( + canLeaveRequestBeCancelled.isSatisfiedBy( + instance(loggedUser), + instance(leave3) + ) + ).thenReturn(true); + when(leaveRequestRepository.findLeaveRequests(1, null)).thenResolve([ - [instance(leave1), instance(leave2)], - 2 + [instance(leave1), instance(leave2), instance(leave3)], + 3 ]); when( @@ -115,6 +166,10 @@ describe('GetLeaveRequestsQueryHandler', () => { dateUtils.getLeaveDuration('2020-05-01', false, '2020-05-15', false) ).thenReturn(8); + when( + dateUtils.getLeaveDuration('2020-05-08', true, '2020-05-15', true) + ).thenReturn(4); + expect( await queryHandler.execute( new GetLeaveRequestsQuery( @@ -131,104 +186,9 @@ describe('GetLeaveRequestsQueryHandler', () => { verify( dateUtils.getLeaveDuration('2020-05-01', false, '2020-05-15', false) ).once(); + verify( + dateUtils.getLeaveDuration('2020-05-08', true, '2020-05-15', true) + ).once(); verify(leaveRequestRepository.findLeaveRequests(1, null)).once(); }); - - it('testGetLeaves', async () => { - const expectedResult = new Pagination( - [ - new LeaveRequestView( - 'd54f15d6-1a1d-47e8-8672-9f46018f9960', - Type.PAID, - Status.ACCEPTED, - '2020-05-05', - '2020-05-15', - 6.5, - true, - null, - new UserSummaryView( - 'eb9e1d9b-dce2-48a9-b64f-f0872f3157d2', - 'Mathieu', - 'MARCHOIS' - ) - ), - new LeaveRequestView( - '252dacfc-1db9-4112-b1bc-37d28d50ced0', - Type.SPECIAL, - Status.ACCEPTED, - '2020-05-01', - '2020-05-15', - 8, - false, // can't be cancelled because 2020-05-01 is in the past - null, - new UserSummaryView( - 'eb9e1d9b-dce2-48a9-b64f-f0872f3157d2', - 'Mathieu', - 'MARCHOIS' - ) - ) - ], - 2 - ); - - const user = mock(User); - when(user.getId()).thenReturn('eb9e1d9b-dce2-48a9-b64f-f0872f3157d2'); - when(user.getFirstName()).thenReturn('Mathieu'); - when(user.getLastName()).thenReturn('MARCHOIS'); - - // same user is logged in - const loggedUser = mock(User); - when(loggedUser.getId()).thenReturn('eb9e1d9b-dce2-48a9-b64f-f0872f3157d2'); - - const leave1 = mock(LeaveRequest); - when(leave1.getId()).thenReturn('d54f15d6-1a1d-47e8-8672-9f46018f9960'); - when(leave1.getType()).thenReturn(Type.PAID); - when(leave1.getStatus()).thenReturn(Status.ACCEPTED); - when(leave1.getStartDate()).thenReturn('2020-05-05'); - when(leave1.isStartsAllDay()).thenReturn(false); - when(leave1.getEndDate()).thenReturn('2020-05-15'); - when(leave1.isEndsAllDay()).thenReturn(true); - when(leave1.getUser()).thenReturn(instance(user)); - when(user.getLastName()).thenReturn('MARCHOIS'); - - const leave2 = mock(LeaveRequest); - when(leave2.getId()).thenReturn('252dacfc-1db9-4112-b1bc-37d28d50ced0'); - when(leave2.getType()).thenReturn(Type.SPECIAL); - when(leave2.getStatus()).thenReturn(Status.ACCEPTED); - when(leave2.getStartDate()).thenReturn('2020-05-01'); - when(leave2.isStartsAllDay()).thenReturn(false); - when(leave2.getEndDate()).thenReturn('2020-05-15'); - when(leave2.isEndsAllDay()).thenReturn(false); - when(leave2.getUser()).thenReturn(instance(user)); - - when( - leaveRequestRepository.findLeaveRequests(1, Status.ACCEPTED) - ).thenResolve([[instance(leave1), instance(leave2)], 2]); - - when( - userRepository.findOneById('eb9e1d9b-dce2-48a9-b64f-f0872f3157d2') - ).thenResolve(instance(loggedUser)); - - when( - dateUtils.getLeaveDuration('2020-05-05', false, '2020-05-15', true) - ).thenReturn(6.5); - - when( - dateUtils.getLeaveDuration('2020-05-01', false, '2020-05-15', false) - ).thenReturn(8); - - when(dateUtils.getCurrentDate()).thenReturn(new Date('2020-05-02')); - - expect( - await queryHandler.execute( - new GetLeaveRequestsQuery( - 'eb9e1d9b-dce2-48a9-b64f-f0872f3157d2', - 1, - Status.ACCEPTED - ) - ) - ).toMatchObject(expectedResult); - - verify(leaveRequestRepository.findLeaveRequests(1, Status.ACCEPTED)).once(); - }); }); diff --git a/src/Application/HumanResource/Leave/Query/GetLeaveRequestsQueryHandler.ts b/src/Application/HumanResource/Leave/Query/GetLeaveRequestsQueryHandler.ts index 9782a6a5..67300462 100644 --- a/src/Application/HumanResource/Leave/Query/GetLeaveRequestsQueryHandler.ts +++ b/src/Application/HumanResource/Leave/Query/GetLeaveRequestsQueryHandler.ts @@ -8,8 +8,7 @@ import { Pagination } from 'src/Application/Common/Pagination'; import { ILeaveRequestRepository } from 'src/Domain/HumanResource/Leave/Repository/ILeaveRequestRepository'; import { IUserRepository } from 'src/Domain/HumanResource/User/Repository/IUserRepository'; import { UserNotFoundException } from 'src/Domain/HumanResource/User/Exception/UserNotFoundException'; -import { User } from 'src/Domain/HumanResource/User/User.entity'; -import { LeaveRequest } from 'src/Domain/HumanResource/Leave/LeaveRequest.entity'; +import { CanLeaveRequestBeCancelled } from 'src/Domain/HumanResource/Leave/Specification/CanLeaveRequestBeCancelled'; @QueryHandler(GetLeaveRequestsQuery) export class GetLeaveRequestsQueryHandler { @@ -19,7 +18,8 @@ export class GetLeaveRequestsQueryHandler { @Inject('IUserRepository') private readonly userRepository: IUserRepository, @Inject('IDateUtils') - private readonly dateUtils: IDateUtils + private readonly dateUtils: IDateUtils, + private readonly canLeaveRequestBeCancelled: CanLeaveRequestBeCancelled ) {} public async execute({ @@ -54,7 +54,10 @@ export class GetLeaveRequestsQueryHandler { leaveRequest.getEndDate(), leaveRequest.isEndsAllDay() ), - this.canCancelLeave(currentUser, leaveRequest), + this.canLeaveRequestBeCancelled.isSatisfiedBy( + currentUser, + leaveRequest + ), null, new UserSummaryView( leaveUser.getId(), @@ -67,18 +70,4 @@ export class GetLeaveRequestsQueryHandler { return new Pagination(leaveRequestViews, total); } - - private canCancelLeave(byUser: User, leaveRequest: LeaveRequest): boolean { - if (byUser.getId() !== leaveRequest.getUser().getId()) { - return false; - } - - if ( - new Date(leaveRequest.getStartDate()) < this.dateUtils.getCurrentDate() - ) { - return false; - } - - return true; - } } diff --git a/src/Application/HumanResource/Leave/View/LeaveRequestDetailView.ts b/src/Application/HumanResource/Leave/View/LeaveRequestDetailView.ts index d4b32960..f2a1fc6f 100644 --- a/src/Application/HumanResource/Leave/View/LeaveRequestDetailView.ts +++ b/src/Application/HumanResource/Leave/View/LeaveRequestDetailView.ts @@ -2,10 +2,12 @@ import { UserSummaryView } from '../../User/View/UserSummaryView'; import { Type, Status, - ILeaveRequestModeration + ILeaveRequestModeration, + ILeaveRequestOwnership } from 'src/Domain/HumanResource/Leave/LeaveRequest.entity'; -export class LeaveRequestDetailView implements ILeaveRequestModeration { +export class LeaveRequestDetailView + implements ILeaveRequestModeration, ILeaveRequestOwnership { constructor( public readonly id: string, public readonly type: Type, @@ -15,9 +17,11 @@ export class LeaveRequestDetailView implements ILeaveRequestModeration { public readonly endDate: string, public readonly endsAllDay: boolean, public readonly duration: number, + public readonly canCancel: boolean = false, public readonly comment: string, public readonly user: UserSummaryView, public readonly moderator: UserSummaryView = null, + public readonly moderateAt: string = null, public readonly moderationComment: string = null ) {} diff --git a/src/Domain/HumanResource/Leave/LeaveRequest.entity.ts b/src/Domain/HumanResource/Leave/LeaveRequest.entity.ts index 133685a5..2037aa6b 100644 --- a/src/Domain/HumanResource/Leave/LeaveRequest.entity.ts +++ b/src/Domain/HumanResource/Leave/LeaveRequest.entity.ts @@ -21,6 +21,15 @@ export interface ILeaveRequestModeration { getUserId(): string; } +export interface ILeaveRequestOwnership { + getUserId(): string; +} + +export interface ILeaveRequestCancellation { + getUserId(): string; + getStartDate(): string; +} + @Entity() export class LeaveRequest implements ILeaveRequestModeration { @PrimaryGeneratedColumn('uuid') diff --git a/src/Domain/HumanResource/Leave/Specification/CanLeaveRequestBeCancelled.spec.ts b/src/Domain/HumanResource/Leave/Specification/CanLeaveRequestBeCancelled.spec.ts new file mode 100644 index 00000000..6a648690 --- /dev/null +++ b/src/Domain/HumanResource/Leave/Specification/CanLeaveRequestBeCancelled.spec.ts @@ -0,0 +1,66 @@ +import { mock, instance, when } from 'ts-mockito'; +import { User } from 'src/Domain/HumanResource/User/User.entity'; +import { LeaveRequest, Status } from '../LeaveRequest.entity'; +import { CanLeaveRequestBeCancelled } from './CanLeaveRequestBeCancelled'; +import { DateUtilsAdapter } from 'src/Infrastructure/Adapter/DateUtilsAdapter'; + +describe('CanLeaveRequestBeCancelled', () => { + let canLeaveRequestBeCancelled: CanLeaveRequestBeCancelled; + const user = mock(User); + const leaveRequest = mock(LeaveRequest); + const dateUtils = mock(DateUtilsAdapter); + + beforeEach(() => { + canLeaveRequestBeCancelled = new CanLeaveRequestBeCancelled( + instance(dateUtils) + ); + }); + + it('testCanBeCancelled', async () => { + when(user.getId()).thenReturn('cfdd06eb-cd71-44b9-82c6-46110b30ce05'); + when(leaveRequest.getUserId()).thenReturn( + 'cfdd06eb-cd71-44b9-82c6-46110b30ce05' + ); + when(leaveRequest.getStartDate()).thenReturn('2020-05-05'); + when(dateUtils.getCurrentDate()).thenReturn(new Date('2020-04-05')); + + expect( + await canLeaveRequestBeCancelled.isSatisfiedBy( + instance(user), + instance(leaveRequest) + ) + ).toBe(true); + }); + + it('testCannotBeCancelledDifferentUser', async () => { + when(user.getId()).thenReturn('cfdd06eb-cd71-44b9-82c6-46110b30ce05'); + when(leaveRequest.getUserId()).thenReturn( + 'abb9697a-dc04-4b8c-a757-ad3da0995676' + ); + when(leaveRequest.getStartDate()).thenReturn('2020-05-05'); + when(dateUtils.getCurrentDate()).thenReturn(new Date('2020-04-05')); + + expect( + await canLeaveRequestBeCancelled.isSatisfiedBy( + instance(user), + instance(leaveRequest) + ) + ).toBe(false); + }); + + it('testCannotBeCancelledIsPast', async () => { + when(user.getId()).thenReturn('cfdd06eb-cd71-44b9-82c6-46110b30ce05'); + when(leaveRequest.getUserId()).thenReturn( + 'cfdd06eb-cd71-44b9-82c6-46110b30ce05' + ); + when(leaveRequest.getStartDate()).thenReturn('2020-05-05'); + when(dateUtils.getCurrentDate()).thenReturn(new Date('2020-06-05')); + + expect( + await canLeaveRequestBeCancelled.isSatisfiedBy( + instance(user), + instance(leaveRequest) + ) + ).toBe(false); + }); +}); diff --git a/src/Domain/HumanResource/Leave/Specification/CanLeaveRequestBeCancelled.ts b/src/Domain/HumanResource/Leave/Specification/CanLeaveRequestBeCancelled.ts new file mode 100644 index 00000000..0256da91 --- /dev/null +++ b/src/Domain/HumanResource/Leave/Specification/CanLeaveRequestBeCancelled.ts @@ -0,0 +1,28 @@ +import { ILeaveRequestCancellation } from '../LeaveRequest.entity'; +import { User } from '../../User/User.entity'; +import { Inject } from '@nestjs/common'; +import { IDateUtils } from 'src/Application/IDateUtils'; + +export class CanLeaveRequestBeCancelled { + constructor( + @Inject('IDateUtils') + private dateUtils: IDateUtils + ) {} + + public isSatisfiedBy( + byUser: User, + leaveRequest: ILeaveRequestCancellation + ): boolean { + if (byUser.getId() !== leaveRequest.getUserId()) { + return false; + } + + if ( + new Date(leaveRequest.getStartDate()) < this.dateUtils.getCurrentDate() + ) { + return false; + } + + return true; + } +} diff --git a/src/Domain/HumanResource/Leave/Specification/DoesLeaveRequestBelongToUser.spec.ts b/src/Domain/HumanResource/Leave/Specification/DoesLeaveRequestBelongToUser.spec.ts index 839f4112..d77f6a67 100644 --- a/src/Domain/HumanResource/Leave/Specification/DoesLeaveRequestBelongToUser.spec.ts +++ b/src/Domain/HumanResource/Leave/Specification/DoesLeaveRequestBelongToUser.spec.ts @@ -5,7 +5,6 @@ import { DoesLeaveRequestBelongToUser } from './DoesLeaveRequestBelongToUser'; describe('DoesLeaveRequestBelongToUser', () => { let doesLeaveRequestBelongToUser: DoesLeaveRequestBelongToUser; - const user = mock(User); const leaveRequest = mock(LeaveRequest); const owner = mock(User); @@ -14,9 +13,10 @@ describe('DoesLeaveRequestBelongToUser', () => { }); it('testLeaveRequestCantBeRemoved', async () => { - when(user.getId()).thenReturn('cfdd06eb-cd71-44b9-82c6-46110b30ce05'); when(owner.getId()).thenReturn('50e624ef-3609-4053-a437-f74844a2d2de'); - when(leaveRequest.getUser()).thenReturn(instance(user)); + when(leaveRequest.getUserId()).thenReturn( + 'cfdd06eb-cd71-44b9-82c6-46110b30ce05' + ); expect( await doesLeaveRequestBelongToUser.isSatisfiedBy( @@ -27,9 +27,10 @@ describe('DoesLeaveRequestBelongToUser', () => { }); it('testLeaveRequestCanBeRemoved', async () => { - when(user.getId()).thenReturn('cfdd06eb-cd71-44b9-82c6-46110b30ce05'); when(owner.getId()).thenReturn('cfdd06eb-cd71-44b9-82c6-46110b30ce05'); - when(leaveRequest.getUser()).thenReturn(instance(user)); + when(leaveRequest.getUserId()).thenReturn( + 'cfdd06eb-cd71-44b9-82c6-46110b30ce05' + ); expect( await doesLeaveRequestBelongToUser.isSatisfiedBy( diff --git a/src/Domain/HumanResource/Leave/Specification/DoesLeaveRequestBelongToUser.ts b/src/Domain/HumanResource/Leave/Specification/DoesLeaveRequestBelongToUser.ts index 08d01ad0..29bbee4d 100644 --- a/src/Domain/HumanResource/Leave/Specification/DoesLeaveRequestBelongToUser.ts +++ b/src/Domain/HumanResource/Leave/Specification/DoesLeaveRequestBelongToUser.ts @@ -1,8 +1,11 @@ import { User } from '../../User/User.entity'; -import { LeaveRequest } from '../LeaveRequest.entity'; +import { ILeaveRequestOwnership } from '../LeaveRequest.entity'; export class DoesLeaveRequestBelongToUser { - public isSatisfiedBy(leaveRequest: LeaveRequest, user: User): boolean { - return leaveRequest.getUser().getId() === user.getId(); + public isSatisfiedBy( + leaveRequest: ILeaveRequestOwnership, + user: User + ): boolean { + return leaveRequest.getUserId() === user.getId(); } } diff --git a/src/Infrastructure/HumanResource/Leave/Controller/DeleteLeaveRequestController.ts b/src/Infrastructure/HumanResource/Leave/Controller/DeleteLeaveRequestController.ts index 38103f69..e583d10d 100644 --- a/src/Infrastructure/HumanResource/Leave/Controller/DeleteLeaveRequestController.ts +++ b/src/Infrastructure/HumanResource/Leave/Controller/DeleteLeaveRequestController.ts @@ -36,7 +36,7 @@ export class DeleteLeaveRequestController { try { await this.commandBus.execute(new DeleteLeaveRequestCommand(id, user)); - res.redirect(303, this.resolver.resolve('people_leaves_list')); + res.redirect(303, this.resolver.resolve('people_leave_requests_list')); } catch (e) { throw new BadRequestException(e.message); } diff --git a/src/Infrastructure/HumanResource/Leave/Controller/EditLeaveRequestController.ts b/src/Infrastructure/HumanResource/Leave/Controller/EditLeaveRequestController.ts index b50d2cd4..e7565c0a 100644 --- a/src/Infrastructure/HumanResource/Leave/Controller/EditLeaveRequestController.ts +++ b/src/Infrastructure/HumanResource/Leave/Controller/EditLeaveRequestController.ts @@ -17,12 +17,16 @@ import { LoggedUser } from '../../User/Decorator/LoggedUser'; import { IsAuthenticatedGuard } from '../../User/Security/IsAuthenticatedGuard'; import { WithName } from 'src/Infrastructure/Common/ExtendedRouting/WithName'; import { User } from 'src/Domain/HumanResource/User/User.entity'; -import { Type } from 'src/Domain/HumanResource/Leave/LeaveRequest.entity'; +import { + Status, + Type +} from 'src/Domain/HumanResource/Leave/LeaveRequest.entity'; import { UpdateLeaveRequestCommand } from 'src/Application/HumanResource/Leave/Command/UpdateLeaveRequestCommand'; import { IdDTO } from 'src/Infrastructure/Common/DTO/IdDTO'; import { GetLeaveRequestByIdQuery } from 'src/Application/HumanResource/Leave/Query/GetLeaveRequestByIdQuery'; import { IQueryBus } from 'src/Application/IQueryBus'; import { RouteNameResolver } from 'src/Infrastructure/Common/ExtendedRouting/RouteNameResolver'; +import { DoesLeaveRequestBelongToUser } from 'src/Domain/HumanResource/Leave/Specification/DoesLeaveRequestBelongToUser'; @Controller('app/people/leave-requests/edit') @UseGuards(IsAuthenticatedGuard) @@ -32,22 +36,26 @@ export class EditLeaveRequestController { private readonly commandBus: ICommandBus, @Inject('IQueryBus') private readonly queryBus: IQueryBus, - private readonly resolver: RouteNameResolver + private readonly resolver: RouteNameResolver, + private readonly doesLeaveRequestBelongToUser: DoesLeaveRequestBelongToUser ) {} @Get(':id') @WithName('people_leave_requests_edit') @Render('pages/leave_requests/edit.njk') - public async get(@Param() { id }: IdDTO) { + public async get(@Param() { id }: IdDTO, @LoggedUser() user: User) { const leaveRequest = await this.queryBus.execute( - new GetLeaveRequestByIdQuery(id) + new GetLeaveRequestByIdQuery(id, user) ); const types = Object.values(Type); return { leaveRequest, - types + types, + canDelete: + leaveRequest.status === Status.PENDING && + this.doesLeaveRequestBelongToUser.isSatisfiedBy(leaveRequest, user) }; } diff --git a/src/Infrastructure/HumanResource/Leave/Controller/GetLeaveRequestController.ts b/src/Infrastructure/HumanResource/Leave/Controller/GetLeaveRequestController.ts index eeea09ed..56dc65c0 100644 --- a/src/Infrastructure/HumanResource/Leave/Controller/GetLeaveRequestController.ts +++ b/src/Infrastructure/HumanResource/Leave/Controller/GetLeaveRequestController.ts @@ -16,6 +16,8 @@ import { GetLeaveRequestByIdQuery } from 'src/Application/HumanResource/Leave/Qu import { IdDTO } from 'src/Infrastructure/Common/DTO/IdDTO'; import { CanLeaveRequestBeModerated } from 'src/Domain/HumanResource/Leave/Specification/CanLeaveRequestBeModerated'; import { LeaveRequestDetailView } from 'src/Application/HumanResource/Leave/View/LeaveRequestDetailView'; +import { DoesLeaveRequestBelongToUser } from 'src/Domain/HumanResource/Leave/Specification/DoesLeaveRequestBelongToUser'; +import { Status } from 'src/Domain/HumanResource/Leave/LeaveRequest.entity'; @Controller('app/people/leaves') @UseGuards(IsAuthenticatedGuard) @@ -24,7 +26,8 @@ export class GetLeaveRequestController { @Inject('IQueryBus') private readonly queryBus: IQueryBus, private readonly tableFactory: LeaveRequestTableFactory, - private readonly canLeaveRequestBeModerated: CanLeaveRequestBeModerated + private readonly canLeaveRequestBeModerated: CanLeaveRequestBeModerated, + private readonly doesLeaveRequestBelongToUser: DoesLeaveRequestBelongToUser ) {} @Get(':id') @@ -32,7 +35,7 @@ export class GetLeaveRequestController { @Render('pages/leave_requests/detail.njk') public async get(@Param() { id }: IdDTO, @LoggedUser() user: User) { const leaveRequest: LeaveRequestDetailView = await this.queryBus.execute( - new GetLeaveRequestByIdQuery(id) + new GetLeaveRequestByIdQuery(id, user) ); const inline = this.tableFactory.createInline(leaveRequest); @@ -40,6 +43,9 @@ export class GetLeaveRequestController { return { leaveRequest, inline, + canEdit: + leaveRequest.status === Status.PENDING && + this.doesLeaveRequestBelongToUser.isSatisfiedBy(leaveRequest, user), canModerate: this.canLeaveRequestBeModerated.isSatisfiedBy( leaveRequest, user diff --git a/src/Infrastructure/HumanResource/Leave/Controller/ListLeavesController.ts b/src/Infrastructure/HumanResource/Leave/Controller/ListLeavesController.ts deleted file mode 100644 index 90b2af36..00000000 --- a/src/Infrastructure/HumanResource/Leave/Controller/ListLeavesController.ts +++ /dev/null @@ -1,56 +0,0 @@ -import { - Controller, - Inject, - UseGuards, - Get, - Render, - Query -} from '@nestjs/common'; -import { IQueryBus } from 'src/Application/IQueryBus'; -import { User } from 'src/Domain/HumanResource/User/User.entity'; -import { Status } from 'src/Domain/HumanResource/Leave/LeaveRequest.entity'; -import { GetLeaveRequestsQuery } from 'src/Application/HumanResource/Leave/Query/GetLeaveRequestsQuery'; -import { IsAuthenticatedGuard } from 'src/Infrastructure/HumanResource/User/Security/IsAuthenticatedGuard'; -import { WithName } from 'src/Infrastructure/Common/ExtendedRouting/WithName'; -import { PaginationDTO } from 'src/Infrastructure/Common/DTO/PaginationDTO'; -import { LoggedUser } from '../../User/Decorator/LoggedUser'; -import { LeaveTableFactory } from '../Table/LeaveTableFactory'; -import { Pagination } from 'src/Application/Common/Pagination'; -import { LeaveRequestView } from 'src/Application/HumanResource/Leave/View/LeaveRequestView'; - -@Controller('app/people/leaves') -@UseGuards(IsAuthenticatedGuard) -export class ListLeavesController { - constructor( - @Inject('IQueryBus') - private readonly queryBus: IQueryBus, - private readonly tableFactory: LeaveTableFactory - ) {} - - @Get() - @WithName('people_leaves_list') - @Render('pages/leaves/list.njk') - public async get( - @Query() paginationDto: PaginationDTO, - @LoggedUser() user: User - ) { - const pagination: Pagination = await this.queryBus.execute( - new GetLeaveRequestsQuery( - user.getId(), - paginationDto.page, - Status.ACCEPTED - ) - ); - - const table = this.tableFactory.create(pagination.items); - - const calendarToken = process.env.CALENDAR_TOKEN; - - return { - table, - calendarToken, - pagination, - currentPage: paginationDto.page - }; - } -} diff --git a/src/Infrastructure/HumanResource/Leave/Controller/ModerateLeaveRequestController.ts b/src/Infrastructure/HumanResource/Leave/Controller/ModerateLeaveRequestController.ts index e77a51e7..d47387ba 100644 --- a/src/Infrastructure/HumanResource/Leave/Controller/ModerateLeaveRequestController.ts +++ b/src/Infrastructure/HumanResource/Leave/Controller/ModerateLeaveRequestController.ts @@ -45,7 +45,7 @@ export class ModerateLeaveRequestController { try { await this.commandBus.execute(command); - res.redirect(303, this.resolver.resolve('people_leaves_list')); + res.redirect(303, this.resolver.resolve('people_leave_requests_list')); } catch (e) { throw new BadRequestException(e.message); } diff --git a/src/Infrastructure/HumanResource/Leave/Repository/LeaveRequestRepository.ts b/src/Infrastructure/HumanResource/Leave/Repository/LeaveRequestRepository.ts index ec762768..bf0056c2 100644 --- a/src/Infrastructure/HumanResource/Leave/Repository/LeaveRequestRepository.ts +++ b/src/Infrastructure/HumanResource/Leave/Repository/LeaveRequestRepository.ts @@ -39,6 +39,7 @@ export class LeaveRequestRepository implements ILeaveRequestRepository { 'leaveRequest.endDate', 'leaveRequest.endsAllDay', 'leaveRequest.comment', + 'leaveRequest.moderateAt', 'leaveRequest.moderationComment', 'user.id', 'user.firstName', @@ -105,10 +106,6 @@ export class LeaveRequestRepository implements ILeaveRequestRepository { if (status) { query.where('leaveRequest.status = :status', { status }); - } else { - query.where('leaveRequest.status != :status', { - status: Status.ACCEPTED - }); } return query.getManyAndCount(); diff --git a/src/Infrastructure/HumanResource/Leave/Table/LeaveRequestTableFactory.ts b/src/Infrastructure/HumanResource/Leave/Table/LeaveRequestTableFactory.ts index 9af7bbd2..9355279a 100644 --- a/src/Infrastructure/HumanResource/Leave/Table/LeaveRequestTableFactory.ts +++ b/src/Infrastructure/HumanResource/Leave/Table/LeaveRequestTableFactory.ts @@ -1,4 +1,4 @@ -import { Injectable } from '@nestjs/common'; +import { Inject, Injectable } from '@nestjs/common'; import { LeaveRequestDetailView } from 'src/Application/HumanResource/Leave/View/LeaveRequestDetailView'; import { LeaveRequestView } from 'src/Application/HumanResource/Leave/View/LeaveRequestView'; import { RouteNameResolver } from 'src/Infrastructure/Common/ExtendedRouting/RouteNameResolver'; @@ -7,20 +7,27 @@ import { formatDate } from 'src/Infrastructure/Common/Utils/dateUtils'; import { formatFullName } from 'src/Infrastructure/Common/Utils/formatUtils'; import { ActionsOptions, + RowBuilder, RowFactory } from 'src/Infrastructure/Tables/RowFactory'; +import { Status } from 'src/Domain/HumanResource/Leave/LeaveRequest.entity'; +import { Picto } from 'src/Infrastructure/Ui/Picto'; +import { ITranslator } from 'src/Infrastructure/Translations/ITranslator'; @Injectable() export class LeaveRequestTableFactory { constructor( private readonly resolver: RouteNameResolver, - private rowFactory: RowFactory + private rowFactory: RowFactory, + @Inject('ITranslator') + private readonly translator: ITranslator ) {} public create(leaveRequests: LeaveRequestView[], userId: string): Table { const columns = [ 'leaves-user', 'leaves-period', + 'leaves-duration', 'leaves-type', 'leaves-status', 'common-actions' @@ -36,7 +43,10 @@ export class LeaveRequestTableFactory { } }; - if (leaveRequest.user.id === userId) { + if ( + leaveRequest.user.id === userId && + leaveRequest.status === Status.PENDING + ) { actions.edit = { url: this.resolver.resolve('people_leave_requests_edit', { id: leaveRequest.id @@ -44,6 +54,14 @@ export class LeaveRequestTableFactory { }; } + if (leaveRequest.canCancel) { + actions.delete = { + url: this.resolver.resolve('people_leave_requests_delete', { + id: leaveRequest.id + }) + }; + } + return this.rowFactory .createBuilder() .value(formatFullName(leaveRequest.user)) @@ -51,8 +69,9 @@ export class LeaveRequestTableFactory { startDate: leaveRequest.startDate, endDate: leaveRequest.endDate }) + .trans('leaves-duration-value', { days: leaveRequest.duration }) .trans('leaves-type-value', { type: leaveRequest.type }) - .trans('leaves-status-value', { status: leaveRequest.status }) + .apply(builder => this.addStatusRow(builder, leaveRequest.status)) .actions(actions) .build(); } @@ -68,19 +87,41 @@ export class LeaveRequestTableFactory { 'leaves-startDate', 'leaves-endDate', 'leaves-duration', + 'leaves-moderator', + 'leaves-moderateAt', 'leaves-comment' ]; const row = this.rowFactory .createBuilder() - .trans('leaves-status-value', { status: leaveRequest.status }) + .apply(builder => this.addStatusRow(builder, leaveRequest.status)) .trans('leaves-type-value', { type: leaveRequest.type }) .value(formatDate(new Date(leaveRequest.startDate))) .value(formatDate(new Date(leaveRequest.endDate))) .trans('leaves-duration-value', { days: leaveRequest.duration }) + .value( + leaveRequest.moderator ? formatFullName(leaveRequest.moderator) : '' + ) + .value( + leaveRequest.moderateAt + ? formatDate(new Date(leaveRequest.moderateAt)) + : '' + ) .value(leaveRequest.comment) .build(); return new Inline(columns, row); } + + private addStatusRow(builder: RowBuilder, status: Status): void { + builder.picto( + { + [Status.PENDING]: Picto.HOURGLASS, + [Status.ACCEPTED]: Picto.ACTIVE, + [Status.REFUSED]: Picto.DISABLED + }[status], + this.translator.translate('leaves-status-value', { status }), + { class: status === Status.PENDING ? 'pc-bold' : '' } + ); + } } diff --git a/src/Infrastructure/HumanResource/Leave/Table/LeaveTableFactory.ts b/src/Infrastructure/HumanResource/Leave/Table/LeaveTableFactory.ts deleted file mode 100644 index e8da86ec..00000000 --- a/src/Infrastructure/HumanResource/Leave/Table/LeaveTableFactory.ts +++ /dev/null @@ -1,55 +0,0 @@ -import { Injectable } from '@nestjs/common'; -import { LeaveRequestView } from 'src/Application/HumanResource/Leave/View/LeaveRequestView'; -import { RouteNameResolver } from 'src/Infrastructure/Common/ExtendedRouting/RouteNameResolver'; -import { Row, Table } from 'src/Infrastructure/Tables'; -import { formatFullName } from 'src/Infrastructure/Common/Utils/formatUtils'; -import { - ActionsOptions, - RowFactory -} from 'src/Infrastructure/Tables/RowFactory'; - -@Injectable() -export class LeaveTableFactory { - constructor( - private readonly resolver: RouteNameResolver, - private readonly rowFactory: RowFactory - ) {} - - public create(leaveRequests: LeaveRequestView[]): Table { - const columns = [ - 'leaves-user', - 'leaves-period', - 'leaves-duration', - 'leaves-type', - 'common-actions' - ]; - - const rows = leaveRequests.map( - (leaveRequest): Row => { - const actions: ActionsOptions = {}; - - if (leaveRequest.canCancel) { - actions.delete = { - url: this.resolver.resolve('people_leave_requests_delete', { - id: leaveRequest.id - }) - }; - } - - return this.rowFactory - .createBuilder() - .value(formatFullName(leaveRequest.user)) - .trans('leaves-period-value', { - startDate: leaveRequest.startDate, - endDate: leaveRequest.endDate - }) - .trans('leaves-duration-value', { days: leaveRequest.duration }) - .trans('leaves-type-value', { type: leaveRequest.type }) - .actions(actions) - .build(); - } - ); - - return new Table(columns, rows); - } -} diff --git a/src/Infrastructure/HumanResource/humanResource.module.ts b/src/Infrastructure/HumanResource/humanResource.module.ts index fbfba6a6..db669756 100644 --- a/src/Infrastructure/HumanResource/humanResource.module.ts +++ b/src/Infrastructure/HumanResource/humanResource.module.ts @@ -24,6 +24,7 @@ import { CreateLeaveRequestCommandHandler } from 'src/Application/HumanResource/ import { DoesLeaveRequestExistForPeriod } from 'src/Domain/HumanResource/Leave/Specification/DoesLeaveRequestExistForPeriod'; import { RefuseLeaveRequestCommandHandler } from 'src/Application/HumanResource/Leave/Command/RefuseLeaveRequestCommandHandler'; import { CanLeaveRequestBeModerated } from 'src/Domain/HumanResource/Leave/Specification/CanLeaveRequestBeModerated'; +import { CanLeaveRequestBeCancelled } from 'src/Domain/HumanResource/Leave/Specification/CanLeaveRequestBeCancelled'; import { AcceptedLeaveRequestEventListener } from 'src/Application/HumanResource/Leave/Event/AcceptedLeaveRequestEventListener'; import { EventRepository } from '../FairCalendar/Repository/EventRepository'; import { Event } from 'src/Domain/FairCalendar/Event.entity'; @@ -63,9 +64,7 @@ import { ListUsersController } from './User/Controller/ListUsersController'; import { UserTableFactory } from './User/Table/UserTableFactory'; import { AddUserController } from './User/Controller/AddUserController'; import { EditUserController } from './User/Controller/EditUserController'; -import { ListLeavesController } from './Leave/Controller/ListLeavesController'; import { ListLeaveRequestsController } from './Leave/Controller/ListLeaveRequestsController'; -import { LeaveTableFactory } from './Leave/Table/LeaveTableFactory'; import { AddLeaveRequestController } from './Leave/Controller/AddLeaveRequestController'; import { EditLeaveRequestController } from './Leave/Controller/EditLeaveRequestController'; import { LeaveRequestTableFactory } from './Leave/Table/LeaveRequestTableFactory'; @@ -109,7 +108,6 @@ import { ExportLeavesCalendarController } from './Leave/Controller/ExportLeavesC AddUserController, EditUserController, EditProfileController, - ListLeavesController, ListLeaveRequestsController, AddLeaveRequestController, EditLeaveRequestController, @@ -165,6 +163,7 @@ import { ExportLeavesCalendarController } from './Leave/Controller/ExportLeavesC DoesLeaveRequestExistForPeriod, RefuseLeaveRequestCommandHandler, CanLeaveRequestBeModerated, + CanLeaveRequestBeCancelled, AcceptLeaveRequestCommandHandler, AcceptedLeaveRequestEventListener, LeaveRequestToLeavesConverter, @@ -181,7 +180,6 @@ import { ExportLeavesCalendarController } from './Leave/Controller/ExportLeavesC UpdateLeaveRequestCommandHandler, IncreaseUserSavingsRecordCommandHandler, UserTableFactory, - LeaveTableFactory, LeaveRequestTableFactory, PayrollElementsTableFactory, MealTicketTableFactory diff --git a/src/Infrastructure/Project/Table/ProjectTableFactory.ts b/src/Infrastructure/Project/Table/ProjectTableFactory.ts index e9e14ca5..ae844037 100644 --- a/src/Infrastructure/Project/Table/ProjectTableFactory.ts +++ b/src/Infrastructure/Project/Table/ProjectTableFactory.ts @@ -3,6 +3,7 @@ import { ProjectView } from 'src/Application/Project/View/ProjectView'; import { RouteNameResolver } from 'src/Infrastructure/Common/ExtendedRouting/RouteNameResolver'; import { RowFactory } from 'src/Infrastructure/Tables/RowFactory'; import { Table } from 'src/Infrastructure/Tables'; +import { Picto } from 'src/Infrastructure/Ui/Picto'; @Injectable() export class ProjectTableFactory { @@ -23,7 +24,7 @@ export class ProjectTableFactory { this.rowFactory .createBuilder() .picto( - project.active ? 'active' : 'disabled', + project.active ? Picto.ACTIVE : Picto.DISABLED, project.active ? 'common-yes' : 'common-no' ) .value(project.name) diff --git a/src/Infrastructure/Tables/RowFactory.ts b/src/Infrastructure/Tables/RowFactory.ts index 13781da7..4a9165bd 100644 --- a/src/Infrastructure/Tables/RowFactory.ts +++ b/src/Infrastructure/Tables/RowFactory.ts @@ -99,7 +99,7 @@ export class RowFactory { } } -class RowBuilder { +export class RowBuilder { private cells: ICell[]; constructor( @@ -109,6 +109,11 @@ class RowBuilder { this.cells = []; } + public apply(cb: (b: RowBuilder) => void): RowBuilder { + cb(this); + return this; + } + public value(value: string | number): RowBuilder { this.cells.push(new ValueCell(value)); return this; @@ -124,11 +129,11 @@ class RowBuilder { return this; } - public picto(picto: string, message: string): RowBuilder { + public picto(picto: string, message: string, attr: any = {}): RowBuilder { this.cells.push( new TemplateCell( 'tables/cells/picto.njk', - { message, picto }, + { message, picto, attr }, this.templates ) ); diff --git a/src/Infrastructure/Templates/NunjucksTemplates/NunjucksTemplates.ts b/src/Infrastructure/Templates/NunjucksTemplates/NunjucksTemplates.ts index 73f7386d..67d855f3 100644 --- a/src/Infrastructure/Templates/NunjucksTemplates/NunjucksTemplates.ts +++ b/src/Infrastructure/Templates/NunjucksTemplates/NunjucksTemplates.ts @@ -1,3 +1,4 @@ +import * as crypto from 'crypto'; import { Inject, Injectable } from '@nestjs/common'; import { NextFunction, Request, Response } from 'express'; import { NestExpressApplication } from '@nestjs/platform-express'; @@ -65,6 +66,9 @@ export class NunjucksTemplates implements ITemplates { } return result; }); + env.addGlobal('randomHex', (size: number) => { + return crypto.randomBytes(size).toString('hex'); + }); env.addExtension('tables', new TablesExtension(this)); diff --git a/src/Infrastructure/Ui/Picto.ts b/src/Infrastructure/Ui/Picto.ts new file mode 100644 index 00000000..2dfecad6 --- /dev/null +++ b/src/Infrastructure/Ui/Picto.ts @@ -0,0 +1,5 @@ +export enum Picto { + HOURGLASS = 'hourglass', + ACTIVE = 'active', + DISABLED = 'disabled' +} diff --git a/src/assets/customElements/dialogTrigger.js b/src/assets/customElements/dialogTrigger.js new file mode 100644 index 00000000..2d915f05 --- /dev/null +++ b/src/assets/customElements/dialogTrigger.js @@ -0,0 +1,43 @@ +// @ts-check + +customElements.define( + 'pc-dialog-trigger', + class extends HTMLElement { + connectedCallback() { + const dialogId = this.getAttribute('dialog'); + + if (!dialogId) { + throw new Error('"dialog" attribute is required'); + } + + const dialog = /** @type {HTMLDialogElement|null} */ (document.getElementById( + dialogId + )); + + if (!dialog) { + throw new Error(`element "${dialogId}" does not exist`); + } + + const button = this.querySelector('button'); + + if (!button) { + return; + } + + const submitValue = this.getAttribute('submitValue') || 'submit'; + + button.addEventListener('click', event => { + event.preventDefault(); + dialog.showModal(); + }); + + dialog.addEventListener('close', () => { + if (dialog.returnValue === submitValue) { + this.dispatchEvent( + new CustomEvent('dialog-trigger:submit', { bubbles: true }) + ); + } + }); + } + } +); diff --git a/src/assets/customElements/formSubmit.js b/src/assets/customElements/formSubmit.js new file mode 100644 index 00000000..b6ea7bc1 --- /dev/null +++ b/src/assets/customElements/formSubmit.js @@ -0,0 +1,22 @@ +// @ts-check + +customElements.define( + 'pc-form-submit', + class extends HTMLElement { + connectedCallback() { + const form = this.querySelector('form'); + + if (!form) { + console.warn('
element not found'); + return; + } + + const eventName = this.getAttribute('on') || 'form:request-submit'; + + this.addEventListener(eventName, () => { + console.log('submit'); + form.requestSubmit(); + }); + } + } +); diff --git a/src/assets/customElements/index.js b/src/assets/customElements/index.js index da8d1d8a..c16e4c85 100644 --- a/src/assets/customElements/index.js +++ b/src/assets/customElements/index.js @@ -8,6 +8,8 @@ import frameForm from './frameForm'; import monthNavigator from './monthNavigator'; import navMenuButton from './navMenuButton'; import themeToggler from './themeToggler'; +import './dialogTrigger'; +import './formSubmit'; customElements.define('pc-auto-form', autoForm); customElements.define('pc-clipboard-button', clipboardButton); diff --git a/src/assets/styles/components/icon.css b/src/assets/styles/components/icon.css index 1dbfd667..e75ecce8 100644 --- a/src/assets/styles/components/icon.css +++ b/src/assets/styles/components/icon.css @@ -39,14 +39,3 @@ html[data-theme='dark'] .pc-icon--action-violet { .pc-icon--right > .pc-icon { margin-inline-start: calc(2 * var(--v)); } - -.pc-picto::before { - margin-inline-end: var(--v); -} - -.pc-picto--active::before { - content: '✅'; -} -.pc-picto--disabled::before { - content: '🚫'; -} diff --git a/src/assets/styles/components/index.css b/src/assets/styles/components/index.css index d796bc20..0a75d4c2 100644 --- a/src/assets/styles/components/index.css +++ b/src/assets/styles/components/index.css @@ -13,6 +13,7 @@ @import './label.css'; @import './link.css'; @import './nav.css'; +@import './picto.css'; @import './select-group.css'; @import './shell.css'; @import './table.css'; diff --git a/src/assets/styles/components/picto.css b/src/assets/styles/components/picto.css new file mode 100644 index 00000000..00b7a769 --- /dev/null +++ b/src/assets/styles/components/picto.css @@ -0,0 +1,19 @@ +.pc-picto::before { + margin-inline-end: var(--v); +} + +.pc-picto--hourglass::before { + content: '⌛'; +} + +.pc-picto--active::before { + content: '✅'; +} + +.pc-picto--disabled::before { + content: '🚫'; +} + +.pc-picto--close::before { + content: '❌'; +} diff --git a/src/assets/styles/defaults.css b/src/assets/styles/defaults.css index 47e62f12..90e478c6 100644 --- a/src/assets/styles/defaults.css +++ b/src/assets/styles/defaults.css @@ -32,3 +32,7 @@ p { fieldset { border: none; } + +dialog { + margin: auto; +} diff --git a/src/templates/components/nav_links.njk b/src/templates/components/nav_links.njk index b051ae15..72f47e6f 100644 --- a/src/templates/components/nav_links.njk +++ b/src/templates/components/nav_links.njk @@ -52,7 +52,7 @@
  • - + {{ 'leaves-title'|trans }}
  • diff --git a/src/templates/macros/dialog.njk b/src/templates/macros/dialog.njk new file mode 100644 index 00000000..84690bfe --- /dev/null +++ b/src/templates/macros/dialog.njk @@ -0,0 +1,28 @@ +{% from 'macros/attr.njk' import render_attr %} + +{% macro confirm_dialog_form(title, actions) %} + +
    +

    + {{ title }} +

    + + +
    + +
    + {% for action in actions %} + + {% endfor %} +
    + +{% endmacro %} diff --git a/src/templates/pages/leave_requests/_form.njk b/src/templates/pages/leave_requests/_form.njk index 0f321eba..fd910bff 100644 --- a/src/templates/pages/leave_requests/_form.njk +++ b/src/templates/pages/leave_requests/_form.njk @@ -1,5 +1,6 @@ {% import 'macros/buttons.njk' as buttons %} {% from 'macros/attr.njk' import render_attr %} +{% from 'macros/dialog.njk' import confirm_dialog_form %} {% macro leave_request_form(leaveRequest, types, attr=null) %}
    @@ -68,3 +69,22 @@
    {% endmacro %} + +{% macro leave_request_delete_form(leaveRequest) %} + {% set action = 'common-form-cancel'|trans %} + +
    + + + +
    +
    + + + {% set yesAction = { label: "leave-requests-delete-yes"|trans, value: "confirm", attr: {class: "pc-btn", type: 'submit'} } %} + {% set noAction = { label: "leave-requests-delete-no"|trans, value: "close", attr: {class: "pc-btn pc-btn--secondary"} }%} + {{ confirm_dialog_form(title="leave-requests-delete-title"|trans, actions=[yesAction, noAction]) }} + +{% endmacro %} diff --git a/src/templates/pages/leave_requests/add.njk b/src/templates/pages/leave_requests/add.njk index bc2d1eca..87a8d137 100644 --- a/src/templates/pages/leave_requests/add.njk +++ b/src/templates/pages/leave_requests/add.njk @@ -6,7 +6,7 @@ {% block main %}
    - {{ breadcrumb([{ title: 'people-title'|trans }, { title: 'leaves-title'|trans, href: path('people_leaves_list') }, { title: 'leave-requests-title'|trans, href: path('people_leave_requests_list') }, { title: title }]) }} + {{ breadcrumb([{ title: 'people-title'|trans }, { title: 'leaves-title'|trans, href: path('people_leave_requests_list') }, { title: title }]) }}

    {{ title }}

    diff --git a/src/templates/pages/leave_requests/detail.njk b/src/templates/pages/leave_requests/detail.njk index 0b17bf05..9172a685 100644 --- a/src/templates/pages/leave_requests/detail.njk +++ b/src/templates/pages/leave_requests/detail.njk @@ -1,14 +1,26 @@ {% extends 'layouts/app.njk' %} {% from 'macros/breadcrumb.njk' import breadcrumb %} -{% from './_form.njk' import leave_request_moderation_form with context %} +{% from './_form.njk' import leave_request_moderation_form, leave_request_delete_form with context %} -{% set title = 'leave-requests-edit-title'|trans({ user: leaveRequest.user|fullName }) %} +{% set title = 'leave-requests-detail-title'|trans({ user: leaveRequest.user|fullName }) %} {% block main %}
    - {{ breadcrumb([{ title: 'people-title'|trans }, { title: 'leaves-title'|trans, href: path('people_leaves_list') }, { title: 'leave-requests-title'|trans, href: path('people_leave_requests_list') }, { title: title }]) }} + {{ breadcrumb([{ title: 'people-title'|trans }, { title: 'leaves-title'|trans, href: path('people_leave_requests_list') }, { title: title }]) }} -

    {{ title }}

    +
    +

    {{ title }}

    + + {% if canEdit %} + + {{ 'common-edit'|trans }} + + {% endif %} + + {% if leaveRequest.canCancel %} + {{ leave_request_delete_form(leaveRequest) }} + {% endif %} +
    {% inlinetable inline, { attr: { class: 'pc-card' } } %} diff --git a/src/templates/pages/leave_requests/edit.njk b/src/templates/pages/leave_requests/edit.njk index ee8e14e4..6d8a475d 100644 --- a/src/templates/pages/leave_requests/edit.njk +++ b/src/templates/pages/leave_requests/edit.njk @@ -1,14 +1,20 @@ {% extends 'layouts/app.njk' %} {% from 'macros/breadcrumb.njk' import breadcrumb %} -{% from './_form.njk' import leave_request_form %} +{% from './_form.njk' import leave_request_form, leave_request_delete_form with context %} {% set title = 'leave-requests-edit-title'|trans({ user: leaveRequest.user|fullName }) %} {% block main %}
    - {{ breadcrumb([{ title: 'people-title'|trans }, { title: 'leaves-title'|trans, href: path('people_leaves_list') }, { title: 'leave-requests-title'|trans, href: path('people_leave_requests_list') }, { title: title }]) }} + {{ breadcrumb([{ title: 'people-title'|trans }, { title: 'leaves-title'|trans, href: path('people_leave_requests_list') }, { title: 'leave-requests-detail-title'|trans({ user: leaveRequest.user|fullName }), href: path('people_leave_requests_detail', {id: leaveRequest.id}) }, { title: title }]) }} -

    {{ title }}

    +
    +

    {{ title }}

    + + {% if canDelete %} + {{ leave_request_delete_form(leaveRequest) }} + {% endif %} +
    {{ leave_request_form(leaveRequest, types, attr={class: 'pc-card pc-box'}) }}
    diff --git a/src/templates/pages/leave_requests/list.njk b/src/templates/pages/leave_requests/list.njk index 508a501e..9cf80fcc 100644 --- a/src/templates/pages/leave_requests/list.njk +++ b/src/templates/pages/leave_requests/list.njk @@ -2,11 +2,11 @@ {% import 'macros/links.njk' as links %} {% from 'macros/breadcrumb.njk' import breadcrumb %} -{% set title = 'leave-requests-title'|trans %} +{% set title = 'leaves-title'|trans %} {% block main %}
    - {{ breadcrumb([{ title: 'people-title'|trans }, { title: 'leaves-title'|trans, href: path('people_leaves_list') }, { title: title }]) }} + {{ breadcrumb([{ title: 'people-title'|trans }, { title: title }]) }}

    diff --git a/src/templates/pages/leaves/list.njk b/src/templates/pages/leaves/list.njk deleted file mode 100644 index 6657df94..00000000 --- a/src/templates/pages/leaves/list.njk +++ /dev/null @@ -1,39 +0,0 @@ -{% extends 'layouts/app.njk' %} -{% import 'macros/icons.njk' as icons %} -{% import 'macros/links.njk' as links %} -{% from 'macros/breadcrumb.njk' import breadcrumb %} - -{% set title = 'leaves-title'|trans %} - -{% block main %} -
    - {{ breadcrumb([{ title: 'people-title'|trans }, { title: title }]) }} - - - -
    - - - - - -
    - - {% table table %} - - {% set paginationUrl = path('people_leaves_list') %} - {% include 'includes/pagination.njk' %} -
    - -

    -{% endblock main %} diff --git a/src/templates/tables/cells/actions.njk b/src/templates/tables/cells/actions.njk index 2b37f7d6..4efac5cf 100644 --- a/src/templates/tables/cells/actions.njk +++ b/src/templates/tables/cells/actions.njk @@ -1,4 +1,5 @@ {% import 'macros/icons.njk' as icons %} +{% from 'macros/dialog.njk' import confirm_dialog_form %} {% if actions.view %} @@ -14,10 +15,21 @@ {% endif %} {% if actions.delete %} -
    - -
    + {% set id = randomHex(4) %} + + +
    + + + +
    +
    + + + {% set actions = [{ label: "dialog-delete-yes"|trans, value: "confirm", attr: {class: "pc-btn", type: 'submit'} }, { label: "dialog-delete-no"|trans, value: "close", attr: {class: "pc-btn pc-btn--secondary"} }] %} + {{ confirm_dialog_form(title="dialog-delete-title"|trans, actions=actions) }} + {% endif %}
    diff --git a/src/templates/tables/cells/picto.njk b/src/templates/tables/cells/picto.njk index c0f993e7..02d11a5d 100644 --- a/src/templates/tables/cells/picto.njk +++ b/src/templates/tables/cells/picto.njk @@ -1 +1,5 @@ -{{ message|trans }} \ No newline at end of file +{% from 'macros/attr.njk' import render_attr %} + +{% set attr = attr|merge({class: attr.class|default('') ~ (' pc-picto pc-picto--' ~ picto) }) %} + +{{ message|trans }} diff --git a/src/translations/fr-FR.ftl b/src/translations/fr-FR.ftl index 939395ba..e2b49628 100644 --- a/src/translations/fr-FR.ftl +++ b/src/translations/fr-FR.ftl @@ -1,10 +1,12 @@ common-form-save = Enregistrer common-form-update = Mettre à jour common-form-delete = Supprimer +common-form-cancel = Annuler common-view = Voir common-add = Ajouter common-edit = Modifier common-actions = Actions +common-close = Fermer common-yes = Oui common-no = Non common-table-empty = Aucun élément @@ -15,6 +17,10 @@ common-month-previous = Mois précédent common-month-today = Aujourd'hui common-month-next = Mois suivant +dialog-delete-title = Supprimer cet élément ? +dialog-delete-yes = Oui, supprimer +dialog-delete-no = Non, ne pas supprimer + coop-name = Fairness site-title = Permacoop breadcrumb-title = Fil d'Ariane @@ -153,14 +159,21 @@ leaves-duration-value = {$days -> [1] 1 jour *[other] {$days} jours } +leaves-moderator = Modérateur⋅ice +leaves-moderateAt = Modéré le leaves-see-requests = Voir les demandes de congés -leave-requests-title = Demandes de congés leave-requests-add-title = Faire une demande de congés -leave-requests-edit-title = Demande de {$user} +leave-requests-detail-title = Demande de {$user} +leave-requests-edit-title = Édition de la demande de {$user} leave-requests-error-cannot-moderate = Vous ne pouvez pas modérer cette demande de congés. leave-requests-moderation = Modération leave-requests-moderation-accept = Accepter la demande de congés leave-requests-moderation-deny = Refuser la demande de congés +leave-requests-delete = Annuler ce congé +leave-requests-delete-title = Annuler ce congé ? +leave-requests-delete-yes = Oui, annuler +leave-requests-delete-no = Non, ne pas annuler + leaves-calendar-url-title = Lien d'abonnement au calendrier payroll-elements-title = Éléments de paie