diff --git a/src/app-routes.tsx b/src/app-routes.tsx index 8e11eca56..3bef39d91 100644 --- a/src/app-routes.tsx +++ b/src/app-routes.tsx @@ -22,6 +22,7 @@ import { RequireBlockRole } from './shared/router/require-block-role' import { RequireActivityApproval } from './shared/router/require-activity-approval' import { LazyActivitiesPage } from './features/binnacle/features/activity/ui/activities-page.lazy' import { useIsMobile } from './shared/hooks/use-is-mobile' +import { LazyAvailabilityPage } from './features/binnacle/features/availability/ui/availability-page.lazy' export const AppRoutes: FC = () => { const isMobile = useIsMobile() @@ -108,6 +109,14 @@ export const AppRoutes: FC = () => { } /> + + + + } + /> diff --git a/src/features/administration/features/project/application/block-project-cmd.test.ts b/src/features/administration/features/project/application/block-project-cmd.test.ts index b3abf6291..d1e157c3f 100644 --- a/src/features/administration/features/project/application/block-project-cmd.test.ts +++ b/src/features/administration/features/project/application/block-project-cmd.test.ts @@ -1,6 +1,6 @@ import { mock } from 'jest-mock-extended' -import { ProjectRepository } from '../domain/project-repository' import { BlockProjectCmd } from './block-project-cmd' +import { ProjectRepository } from '../../../../shared/project/domain/project-repository' describe('BlockProjectCmd', () => { it('should block a project', async () => { diff --git a/src/features/administration/features/project/application/block-project-cmd.ts b/src/features/administration/features/project/application/block-project-cmd.ts index ec7b06e54..6fb1e27da 100644 --- a/src/features/administration/features/project/application/block-project-cmd.ts +++ b/src/features/administration/features/project/application/block-project-cmd.ts @@ -1,15 +1,13 @@ import { Command, UseCaseKey } from '@archimedes/arch' -import { ADMINISTRATION_PROJECT_REPOSITORY } from '../../../../../shared/di/container-tokens' +import { PROJECT_REPOSITORY } from '../../../../../shared/di/container-tokens' import { Id } from '../../../../../shared/types/id' import { inject, singleton } from 'tsyringe' -import type { ProjectRepository } from '../domain/project-repository' +import type { ProjectRepository } from '../../../../shared/project/domain/project-repository' @UseCaseKey('BlockProjectCmd') @singleton() export class BlockProjectCmd extends Command<{ projectId: Id; date: Date }> { - constructor( - @inject(ADMINISTRATION_PROJECT_REPOSITORY) private projectRepository: ProjectRepository - ) { + constructor(@inject(PROJECT_REPOSITORY) private projectRepository: ProjectRepository) { super() } diff --git a/src/features/administration/features/project/application/get-projects-list-qry.ts b/src/features/administration/features/project/application/get-projects-list-qry.ts deleted file mode 100644 index e5ba4c170..000000000 --- a/src/features/administration/features/project/application/get-projects-list-qry.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { InvalidateCache, Query, UseCaseKey } from '@archimedes/arch' -import { GetUsersListQry } from '../../../../shared/user/application/get-users-list-qry' -import { ADMINISTRATION_PROJECT_REPOSITORY } from '../../../../../shared/di/container-tokens' -import { inject, singleton } from 'tsyringe' -import { OrganizationWithStatus } from '../domain/organization-status' -import { Project } from '../domain/project' -import type { ProjectRepository } from '../domain/project-repository' -import { ProjectsWithUserName } from '../domain/services/projects-with-user-name' -import { Id } from '../../../../../shared/types/id' - -@UseCaseKey('GetProjectsListQry') -@InvalidateCache -@singleton() -export class GetProjectsListQry extends Query { - constructor( - @inject(ADMINISTRATION_PROJECT_REPOSITORY) private projectRepository: ProjectRepository, - private getUsersListQry: GetUsersListQry, - private projectsWithUserName: ProjectsWithUserName - ) { - super() - } - - async internalExecute(organizationStatus?: OrganizationWithStatus): Promise { - const projects = await this.projectRepository.getProjects(organizationStatus) - - const blockerUserIds = projects - .map((project) => project.blockedByUser) - .filter((id) => id !== null) as Id[] - - if (blockerUserIds.length > 0) { - const uniqueBlockerUserIds = Array.from(new Set(blockerUserIds)) - const usersList = await this.getUsersListQry.execute({ - ids: uniqueBlockerUserIds - }) - return this.projectsWithUserName.addUserNameToProjects(projects, usersList) - } - return projects - } -} diff --git a/src/features/administration/features/project/application/get-projects-list-qry.test.ts b/src/features/administration/features/project/application/get-projects-with-blocker-user-name.test.ts similarity index 64% rename from src/features/administration/features/project/application/get-projects-list-qry.test.ts rename to src/features/administration/features/project/application/get-projects-with-blocker-user-name.test.ts index 2fcf6bf34..8c29b80df 100644 --- a/src/features/administration/features/project/application/get-projects-list-qry.test.ts +++ b/src/features/administration/features/project/application/get-projects-with-blocker-user-name.test.ts @@ -1,11 +1,11 @@ import { GetUsersListQry } from '../../../../shared/user/application/get-users-list-qry' import { mock } from 'jest-mock-extended' -import { ProjectRepository } from '../domain/project-repository' +import { GetProjectsWithBlockerUserName } from './get-projects-with-blocker-user-name' import { ProjectsWithUserName } from '../domain/services/projects-with-user-name' -import { GetProjectsListQry } from './get-projects-list-qry' -import { ProjectMother } from '../domain/tests/project-mother' +import { GetProjectsQry } from '../../../../shared/project/application/binnacle/get-projects-qry' +import { ProjectMother } from '../../../../../test-utils/mothers/project-mother' -describe('GetProjectsListQry', () => { +describe('GetProjectsWithBlockerUserName', () => { it('should get the project list', async () => { const { getProjectsListQry, projectRepository, getUsersListQry } = setup() const organizationWithStatus = { @@ -13,13 +13,13 @@ describe('GetProjectsListQry', () => { open: true } - projectRepository.getProjects.mockResolvedValue( + projectRepository.execute.mockResolvedValue( ProjectMother.projectsFilteredByOrganizationDateIso() ) await getProjectsListQry.internalExecute(organizationWithStatus) - expect(projectRepository.getProjects).toBeCalledWith(organizationWithStatus) + expect(projectRepository.execute).toBeCalledWith(organizationWithStatus) expect(getUsersListQry.execute).toHaveBeenCalledWith({ ids: [2, 1] }) }) @@ -30,29 +30,29 @@ describe('GetProjectsListQry', () => { open: true } - projectRepository.getProjects.mockResolvedValue([ + projectRepository.execute.mockResolvedValue([ ProjectMother.projectsFilteredByOrganizationDateIso()[2] ]) await getProjectsListQry.internalExecute(organizationWithStatus) - expect(projectRepository.getProjects).toBeCalledWith(organizationWithStatus) + expect(projectRepository.execute).toBeCalledWith(organizationWithStatus) expect(getUsersListQry.execute).not.toHaveBeenCalled() }) }) function setup() { - const projectRepository = mock() + const getProjectQry = mock() const getUsersListQry = mock() const projectsWithUserName = mock() return { - getProjectsListQry: new GetProjectsListQry( - projectRepository, + getProjectsListQry: new GetProjectsWithBlockerUserName( + getProjectQry, getUsersListQry, projectsWithUserName ), - projectRepository, + projectRepository: getProjectQry, getUsersListQry, projectsWithUserName } diff --git a/src/features/administration/features/project/application/get-projects-with-blocker-user-name.ts b/src/features/administration/features/project/application/get-projects-with-blocker-user-name.ts new file mode 100644 index 000000000..f6f48950e --- /dev/null +++ b/src/features/administration/features/project/application/get-projects-with-blocker-user-name.ts @@ -0,0 +1,41 @@ +import { InvalidateCache, Query, UseCaseKey } from '@archimedes/arch' +import { GetUsersListQry } from '../../../../shared/user/application/get-users-list-qry' +import { singleton } from 'tsyringe' +import { Id } from '../../../../../shared/types/id' +import { Project } from '../../../../shared/project/domain/project' +import { ProjectOrganizationFilters } from '../../../../shared/project/domain/project-organization-filters' +import { ProjectsWithUserName } from '../domain/services/projects-with-user-name' +import { GetProjectsQry } from '../../../../shared/project/application/binnacle/get-projects-qry' + +@UseCaseKey('GetProjectsWithBlockerUserName') +@InvalidateCache +@singleton() +export class GetProjectsWithBlockerUserName extends Query { + constructor( + private getProjectsQry: GetProjectsQry, + private getUsersListQry: GetUsersListQry, + private projectsWithUserName: ProjectsWithUserName + ) { + super() + } + + async internalExecute(organizationStatus?: ProjectOrganizationFilters): Promise { + if (organizationStatus) { + const projects = await this.getProjectsQry.execute(organizationStatus) + + const blockerUserIds = projects + .map((project) => project.blockedByUser) + .filter((id) => id !== null) as Id[] + + if (blockerUserIds.length > 0) { + const uniqueBlockerUserIds = Array.from(new Set(blockerUserIds)) + const usersList = await this.getUsersListQry.execute({ + ids: uniqueBlockerUserIds + }) + return this.projectsWithUserName.addProjectBlockerUserName(projects, usersList) + } + return projects + } + return [] + } +} diff --git a/src/features/administration/features/project/application/unblock-project-cmd.test.ts b/src/features/administration/features/project/application/unblock-project-cmd.test.ts index a7e286d05..5cee99b67 100644 --- a/src/features/administration/features/project/application/unblock-project-cmd.test.ts +++ b/src/features/administration/features/project/application/unblock-project-cmd.test.ts @@ -1,6 +1,6 @@ import { mock } from 'jest-mock-extended' -import { ProjectRepository } from '../domain/project-repository' import { UnblockProjectCmd } from './unblock-project-cmd' +import { ProjectRepository } from '../../../../shared/project/domain/project-repository' describe('UnblockProjectCmd', () => { it('should unblock a project', async () => { diff --git a/src/features/administration/features/project/application/unblock-project-cmd.ts b/src/features/administration/features/project/application/unblock-project-cmd.ts index f0d326eef..560f35225 100644 --- a/src/features/administration/features/project/application/unblock-project-cmd.ts +++ b/src/features/administration/features/project/application/unblock-project-cmd.ts @@ -1,14 +1,12 @@ import { Command, Id, UseCaseKey } from '@archimedes/arch' -import { ADMINISTRATION_PROJECT_REPOSITORY } from '../../../../../shared/di/container-tokens' +import { PROJECT_REPOSITORY } from '../../../../../shared/di/container-tokens' import { inject, singleton } from 'tsyringe' -import type { ProjectRepository } from '../domain/project-repository' +import type { ProjectRepository } from '../../../../shared/project/domain/project-repository' @UseCaseKey('UnblockProjectCmd') @singleton() export class UnblockProjectCmd extends Command { - constructor( - @inject(ADMINISTRATION_PROJECT_REPOSITORY) private projectRepository: ProjectRepository - ) { + constructor(@inject(PROJECT_REPOSITORY) private projectRepository: ProjectRepository) { super() } diff --git a/src/features/administration/features/project/domain/organization-status.ts b/src/features/administration/features/project/domain/organization-status.ts deleted file mode 100644 index 43aa73ac9..000000000 --- a/src/features/administration/features/project/domain/organization-status.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { Id } from '@archimedes/arch' - -export interface OrganizationWithStatus { - organizationId: Id - open: boolean -} diff --git a/src/features/administration/features/project/domain/project-repository.ts b/src/features/administration/features/project/domain/project-repository.ts deleted file mode 100644 index 54892f55e..000000000 --- a/src/features/administration/features/project/domain/project-repository.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { Id } from '../../../../../shared/types/id' -import { OrganizationWithStatus } from './organization-status' -import { Project } from './project' - -export interface ProjectRepository { - getProjects(organizationStatus?: OrganizationWithStatus): Promise - blockProject(projectId: Id, date: Date): Promise - setUnblock(projectId: Id): Promise -} diff --git a/src/features/administration/features/project/domain/services/projects-with-user-name.ts b/src/features/administration/features/project/domain/services/projects-with-user-name.ts index 2d5d7cd9a..31addc743 100644 --- a/src/features/administration/features/project/domain/services/projects-with-user-name.ts +++ b/src/features/administration/features/project/domain/services/projects-with-user-name.ts @@ -1,10 +1,10 @@ import { singleton } from 'tsyringe' import { UserInfo } from '../../../../../shared/user/domain/user-info' -import { Project } from '../project' +import { Project } from '../../../../../shared/project/domain/project' @singleton() export class ProjectsWithUserName { - addUserNameToProjects(projectsWithoutUserName: Project[], usersList: UserInfo[]): Project[] { + addProjectBlockerUserName(projectsWithoutUserName: Project[], usersList: UserInfo[]): Project[] { return projectsWithoutUserName.map((projectWithoutUserName) => { const { blockedByUser, ...projectDetails } = projectWithoutUserName diff --git a/src/features/administration/features/project/domain/tests/project-mother.ts b/src/features/administration/features/project/domain/tests/project-mother.ts deleted file mode 100644 index c2aee2e65..000000000 --- a/src/features/administration/features/project/domain/tests/project-mother.ts +++ /dev/null @@ -1,112 +0,0 @@ -import { parseISO } from '../../../../../../shared/utils/chrono' -import { ProjectDto } from '../project-dto' -import { Project } from '../project' - -export class ProjectMother { - static projectsFilteredByOrganization(): ProjectDto[] { - return [ - { - id: 1, - name: 'Proyecto A', - open: true, - billable: true, - startDate: '2023-01-01', - blockDate: '2023-06-01', - blockedByUser: 2, - organizationId: 1 - }, - { - id: 2, - name: 'Proyecto B', - open: true, - billable: true, - startDate: '2023-03-01', - blockDate: null, - blockedByUser: 1, - organizationId: 1 - }, - { - id: 3, - name: 'Proyecto C', - open: false, - billable: false, - startDate: '2023-03-01', - blockDate: null, - blockedByUser: null, - organizationId: 1 - } - ] - } - - static projectsFilteredByOrganizationDateIso(): Project[] { - return [ - { - id: 1, - name: 'Proyecto A', - open: true, - billable: true, - startDate: parseISO('2023-01-01'), - blockDate: parseISO('2023-06-01'), - blockedByUser: 2, - organizationId: 1 - }, - { - id: 2, - name: 'Proyecto B', - open: true, - billable: true, - startDate: parseISO('2023-03-01'), - blockDate: null, - blockedByUser: 1, - organizationId: 1 - }, - { - id: 3, - name: 'Proyecto C', - open: false, - billable: false, - startDate: parseISO('2023-03-01'), - blockDate: null, - blockedByUser: null, - organizationId: 1 - } - ] - } - - static projectsFilteredByOrganizationDateIsoWithName(): Project[] { - return [ - { - id: 1, - name: 'Proyecto A', - open: true, - billable: true, - startDate: parseISO('2023-01-01'), - blockDate: parseISO('2023-06-01'), - blockedByUser: 2, - organizationId: 1, - blockedByUserName: 'John Doe' - }, - { - id: 2, - name: 'Proyecto B', - open: true, - billable: true, - startDate: parseISO('2023-03-01'), - blockDate: null, - blockedByUser: 1, - organizationId: 1, - blockedByUserName: 'Lorem ipsum' - }, - { - id: 3, - name: 'Proyecto C', - open: false, - billable: false, - startDate: parseISO('2023-03-01'), - blockDate: null, - blockedByUser: null, - organizationId: 1 - } - ] - } -} diff --git a/src/features/administration/features/project/infrastructure/fake-project-repository.ts b/src/features/administration/features/project/infrastructure/fake-project-repository.ts deleted file mode 100644 index a02abaa7d..000000000 --- a/src/features/administration/features/project/infrastructure/fake-project-repository.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { singleton } from 'tsyringe' -import { OrganizationWithStatus } from '../domain/organization-status' -import { Project } from '../domain/project' -import { ProjectRepository } from '../domain/project-repository' -import { ProjectMother } from '../domain/tests/project-mother' - -@singleton() -export class FakeProjectRepository implements ProjectRepository { - async getProjects(organizationWithStatus?: OrganizationWithStatus): Promise { - if (organizationWithStatus) { - return ProjectMother.projectsFilteredByOrganizationDateIsoWithName() - } - return [] - } - - async blockProject(): Promise { - return - } - - async setUnblock(): Promise { - return - } -} diff --git a/src/features/administration/features/project/tests/view-projects.int.tsx b/src/features/administration/features/project/tests/view-projects.int.tsx new file mode 100644 index 000000000..c93f4e0e8 --- /dev/null +++ b/src/features/administration/features/project/tests/view-projects.int.tsx @@ -0,0 +1,40 @@ +import ProjectsPage from '../ui/projects-page' + +describe('View projects', () => { + it('should not view any project if there is no organization', () => { + setup() + + cy.findByTestId('organization_field').should('contain.text', '') + + cy.get('[data-testid="empty-desktop-view"]').should('contain.text', '') + + cy.findByText('It is necessary to filter by organization to obtain the projects.').should( + 'exist' + ) + }) + + it('should view project is organization', () => { + setup() + + cy.findByTestId('organization_field').type('Test') + cy.findByText('Test organization').click() + + cy.findByText('Proyecto A').should('exist') + }) + + it('should show toast after blocking project', () => { + setup() + + cy.findByTestId('organization_field').type('Test') + cy.findByText('Test organization').click() + + cy.findByText('Block').click() + cy.get('form').submit() + + cy.findByText('The project has been blocked.').should('exist') + }) +}) + +const setup = () => { + cy.mount() +} diff --git a/src/features/administration/features/project/ui/components/block-project-modal.tsx b/src/features/administration/features/project/ui/components/block-project-modal.tsx index 8de587422..d0f8748a2 100644 --- a/src/features/administration/features/project/ui/components/block-project-modal.tsx +++ b/src/features/administration/features/project/ui/components/block-project-modal.tsx @@ -19,10 +19,10 @@ import { useResolve } from '../../../../../../shared/di/use-resolve' import { DateField } from '../../../../../../shared/components/form-fields/date-field' import { chrono, parseISO } from '../../../../../../shared/utils/chrono' import { BlockProjectCmd } from '../../application/block-project-cmd' -import { Project } from '../../domain/project' -import { ProjectErrorMessage } from '../../domain/services/project-error-message' import { ProjectModalFormSchema, ProjectModalFormValidationSchema } from './project-modal.schema' import { useIsMobile } from '../../../../../../shared/hooks/use-is-mobile' +import { Project } from '../../../../../shared/project/domain/project' +import { ProjectErrorMessage } from '../../../../../shared/project/domain/services/project-error-message' type ProjectModalProps = { onClose(): void diff --git a/src/features/administration/features/project/ui/components/combos/projects-combos.tsx b/src/features/administration/features/project/ui/components/combos/projects-combos.tsx index 364c48b44..cfec4e6f3 100644 --- a/src/features/administration/features/project/ui/components/combos/projects-combos.tsx +++ b/src/features/administration/features/project/ui/components/combos/projects-combos.tsx @@ -52,7 +52,11 @@ export const ProjectsFilterFormCombos: FC = (props) => { marginBottom={5} marginTop={4} > - + ) diff --git a/src/features/administration/features/project/ui/components/projects-table.test.tsx b/src/features/administration/features/project/ui/components/projects-table.test.tsx deleted file mode 100644 index 39205b9db..000000000 --- a/src/features/administration/features/project/ui/components/projects-table.test.tsx +++ /dev/null @@ -1,78 +0,0 @@ -import userEvent from '@testing-library/user-event' -import { OrganizationRepository } from '../../../../../binnacle/features/organization/domain/organization-repository' -import { UserRepository } from '../../../../../shared/user/domain/user-repository' -import { - ADMINISTRATION_PROJECT_REPOSITORY, - ORGANIZATION_REPOSITORY, - USER_REPOSITORY -} from '../../../../../../shared/di/container-tokens' -import { OrganizationMother } from '../../../../../../test-utils/mothers/organization-mother' -import { UserMother } from '../../../../../../test-utils/mothers/user-mother' -import { container } from 'tsyringe' -import { ProjectRepository } from '../../domain/project-repository' -import { ProjectMother } from '../../domain/tests/project-mother' -import { ProjectsTable } from './projects-table' -import { render, screen, waitFor, act } from '../../../../../../test-utils/render' -import { useIsMobile } from '../../../../../../shared/hooks/use-is-mobile' - -jest.mock('../../../../../../shared/hooks/use-is-mobile') - -describe('ProjectsTable', () => { - it('should show all projects when organization filter is changed', async () => { - setup() - const projects = ProjectMother.projectsFilteredByOrganizationDateIsoWithName() - - await act(async () => { - const organizationCombo = screen.getByTestId('organization_field') - await userEvent.type(organizationCombo, OrganizationMother.organization().name) - }) - - await waitFor(() => { - projects.map((p) => { - expect(screen.getByText(p.name)).toBeInTheDocument() - }) - }) - }) - - it('should execute onProjectClicked method when project block action is pressed', async () => { - const { onProjectClicked } = setup() - - await act(async () => { - const organizationCombo = screen.getByTestId('organization_field') - await userEvent.type(organizationCombo, OrganizationMother.organization().name) - }) - - await act(async () => { - const blockButtons = screen.getAllByRole('button') - await userEvent.click(blockButtons[0]) - }) - expect(onProjectClicked).toBeCalledTimes(1) - }) -}) - -function setup() { - const projectRepository = container.resolve>( - ADMINISTRATION_PROJECT_REPOSITORY - ) - const userRepository = container.resolve>(USER_REPOSITORY) - const organizationRepository = - container.resolve>(ORGANIZATION_REPOSITORY) - organizationRepository.getAll.mockResolvedValue(OrganizationMother.organizations()) - - projectRepository.getProjects.mockResolvedValue( - ProjectMother.projectsFilteredByOrganizationDateIsoWithName() - ) - userRepository.getUsers.mockResolvedValue(UserMother.userList()) - - const onProjectClicked = jest.fn() - ;(useIsMobile as jest.Mock).mockReturnValue(false) - - render() - - return { - projectRepository, - userRepository, - organizationRepository, - onProjectClicked - } -} diff --git a/src/features/administration/features/project/ui/components/projects-table.tsx b/src/features/administration/features/project/ui/components/projects-table.tsx index 63c12a5e2..8bcae882c 100644 --- a/src/features/administration/features/project/ui/components/projects-table.tsx +++ b/src/features/administration/features/project/ui/components/projects-table.tsx @@ -7,15 +7,15 @@ import { useSubscribeToUseCase } from '../../../../../../shared/arch/hooks/use-s import { Table } from '../../../../../../shared/components/table/table' import { ColumnsProps } from '../../../../../../shared/components/table/table.types' import { BlockProjectCmd } from '../../application/block-project-cmd' -import { GetProjectsListQry } from '../../application/get-projects-list-qry' +import { GetProjectsWithBlockerUserName } from '../../application/get-projects-with-blocker-user-name' import { UnblockProjectCmd } from '../../application/unblock-project-cmd' -import { OrganizationWithStatus } from '../../domain/organization-status' -import { Project } from '../../domain/project' import { ProjectStatus } from '../../domain/project-status' import { AdaptedProjects, adaptProjectsToTable } from '../projects-page-utils' import { ProjectsFilterFormCombos } from './combos/projects-combos' import { StatusBadge } from './status-badge' import { useIsMobile } from '../../../../../../shared/hooks/use-is-mobile' +import { Project } from '../../../../../shared/project/domain/project' +import { ProjectOrganizationFilters } from '../../../../../shared/project/domain/project-organization-filters' interface Props { onProjectClicked(project: Project): void @@ -26,7 +26,7 @@ export const ProjectsTable: FC = (props) => { const { t } = useTranslation() const [organizationName, setOrganizationName] = useState('') const [lastSelectedOrganizationWithStatus, setLastSelectedOrganizationWithStatus] = - useState() + useState() const [tableProjects, setTableProjects] = useState([]) const isMobile = useIsMobile() @@ -34,7 +34,7 @@ export const ProjectsTable: FC = (props) => { isLoading: isLoadingProjectsList, result: projectList = [], executeUseCase: getProjectsListQry - } = useExecuteUseCaseOnMount(GetProjectsListQry) + } = useExecuteUseCaseOnMount(GetProjectsWithBlockerUserName) useSubscribeToUseCase( BlockProjectCmd, @@ -57,8 +57,8 @@ export const ProjectsTable: FC = (props) => { async (organization: Organization, status: ProjectStatus) => { if (organization?.id) { setOrganizationName(organization.name) - const organizationWithStatus: OrganizationWithStatus = { - organizationId: organization.id, + const organizationWithStatus: ProjectOrganizationFilters = { + organizationIds: [organization.id], open: status.value } await getProjectsListQry(organizationWithStatus) diff --git a/src/features/administration/features/project/ui/components/unblock-project-modal.tsx b/src/features/administration/features/project/ui/components/unblock-project-modal.tsx index ac8f06c7c..4fc18307d 100644 --- a/src/features/administration/features/project/ui/components/unblock-project-modal.tsx +++ b/src/features/administration/features/project/ui/components/unblock-project-modal.tsx @@ -15,14 +15,17 @@ import { useGetUseCase } from '../../../../../../shared/arch/hooks/use-get-use-c import { useResolve } from '../../../../../../shared/di/use-resolve' import { useIsMobile } from '../../../../../../shared/hooks/use-is-mobile' import { UnblockProjectCmd } from '../../application/unblock-project-cmd' -import { Project } from '../../domain/project' -import { ProjectErrorMessage } from '../../domain/services/project-error-message' +import { Project } from '../../../../../shared/project/domain/project' +import { ProjectErrorMessage } from '../../../../../shared/project/domain/services/project-error-message' interface Props { project: Project + onCancel(): void + onClose(): void } + export const UnblockProjectModal: FC = (props) => { const { project, onClose, onCancel } = props const isMobile = useIsMobile() diff --git a/src/features/administration/features/project/ui/projects-page-utils.tsx b/src/features/administration/features/project/ui/projects-page-utils.tsx index 6cb8dc2eb..55b05f198 100644 --- a/src/features/administration/features/project/ui/projects-page-utils.tsx +++ b/src/features/administration/features/project/ui/projects-page-utils.tsx @@ -1,5 +1,6 @@ -import { Project } from '../domain/project' import { chrono } from '../../../../../shared/utils/chrono' +import { Project } from '../../../../shared/project/domain/project' + export interface AdaptedProjects { key: number organization: string diff --git a/src/features/administration/features/project/ui/projects-page.tsx b/src/features/administration/features/project/ui/projects-page.tsx index df47dad57..8cf5ed0eb 100644 --- a/src/features/administration/features/project/ui/projects-page.tsx +++ b/src/features/administration/features/project/ui/projects-page.tsx @@ -1,10 +1,10 @@ import { FC, useState } from 'react' import { useTranslation } from 'react-i18next' import { PageWithTitle } from '../../../../../shared/components/page-with-title/page-with-title' -import { Project } from '../domain/project' import { BlockProjectModal } from './components/block-project-modal' import { ProjectsTable } from './components/projects-table' import { UnblockProjectModal } from './components/unblock-project-modal' +import { Project } from '../../../../shared/project/domain/project' const ProjectsPage: FC = () => { const { t } = useTranslation() diff --git a/src/features/binnacle/features/activity/domain/services/generate-year-balance.test.ts b/src/features/binnacle/features/activity/domain/services/generate-year-balance.test.ts index 39b4c0920..e328df7ee 100644 --- a/src/features/binnacle/features/activity/domain/services/generate-year-balance.test.ts +++ b/src/features/binnacle/features/activity/domain/services/generate-year-balance.test.ts @@ -1,4 +1,4 @@ -import { ProjectMother } from '../../../../../../test-utils/mothers/project-mother' +import { LiteProjectMother } from '../../../../../../test-utils/mothers/lite-project-mother' import { OrganizationMother } from '../../../../../../test-utils/mothers/organization-mother' import { ProjectRoleMother } from '../../../../../../test-utils/mothers/project-role-mother' import { SearchMother } from '../../../../../../test-utils/mothers/search-mother' @@ -177,7 +177,7 @@ describe('GenerateYearBalance', () => { it('should generate the balance role list when', async () => { const { generateYearBalance } = setup() const organization = OrganizationMother.organization() - const project = ProjectMother.billableLiteProjectWithOrganizationId() + const project = LiteProjectMother.billableLiteProjectWithOrganizationId() const projectRole = ProjectRoleMother.liteProjectRoleInMinutes() const timeSummaryWithRoles = ActivityMother.timeSummary() const searchRolesResponseWithRole = SearchMother.customRoles({ diff --git a/src/features/binnacle/features/activity/ui/calendar-desktop/calendar-controls/calendar-control-utils.ts b/src/features/binnacle/features/activity/ui/calendar-desktop/calendar-controls/calendar-control-utils.ts index 8e31335dc..f3852bfb4 100644 --- a/src/features/binnacle/features/activity/ui/calendar-desktop/calendar-controls/calendar-control-utils.ts +++ b/src/features/binnacle/features/activity/ui/calendar-desktop/calendar-controls/calendar-control-utils.ts @@ -1,9 +1,9 @@ -export const handleKeyPressWhenModalIsNotOpened = ( +export const handleKeyPressWhenBodyIsNotFocused = ( pressedKey: string, controlledKey: string, handler: () => void ) => { - const isModalOpened = document.querySelector('[id^=chakra-modal]') !== null - if (isModalOpened) return + const hasFocusInBody = document.activeElement?.tagName === 'BODY' + if (!hasFocusInBody) return if (pressedKey === controlledKey) handler() } diff --git a/src/features/binnacle/features/activity/ui/calendar-desktop/calendar-controls/calendar-controls.tsx b/src/features/binnacle/features/activity/ui/calendar-desktop/calendar-controls/calendar-controls.tsx index 68ab4bc3c..ca95ee589 100644 --- a/src/features/binnacle/features/activity/ui/calendar-desktop/calendar-controls/calendar-controls.tsx +++ b/src/features/binnacle/features/activity/ui/calendar-desktop/calendar-controls/calendar-controls.tsx @@ -3,10 +3,17 @@ import { CalendarPicker } from './calendar-picker/calendar-picker' import { NextMonthArrow } from './next-month-arrow' import { PrevMonthArrow } from './prev-month-arrow' import { TodayButton } from './today-button' +import { useIsMobile } from '../../../../../../../shared/hooks/use-is-mobile' export const CalendarControls = () => { + const isMobile = useIsMobile() return ( - + diff --git a/src/features/binnacle/features/activity/ui/calendar-desktop/calendar-controls/calendar-picker/calendar-picker.tsx b/src/features/binnacle/features/activity/ui/calendar-desktop/calendar-controls/calendar-picker/calendar-picker.tsx index 12ac64d31..d82792210 100644 --- a/src/features/binnacle/features/activity/ui/calendar-desktop/calendar-controls/calendar-picker/calendar-picker.tsx +++ b/src/features/binnacle/features/activity/ui/calendar-desktop/calendar-controls/calendar-picker/calendar-picker.tsx @@ -21,6 +21,7 @@ import { MonthsList } from './months-list' import { useCalendarContext } from '../../../contexts/calendar-context' import { useExecuteUseCaseOnMount } from '../../../../../../../../shared/arch/hooks/use-execute-use-case-on-mount' import { GetUserLoggedQry } from '../../../../../../../shared/user/application/get-user-logged-qry' +import { useIsMobile } from '../../../../../../../../shared/hooks/use-is-mobile' export const CalendarPicker = () => { const { selectedDate } = useCalendarContext() @@ -35,6 +36,8 @@ export const CalendarPicker = () => { const [selectedMonthName, setSelectedMonthName] = useState('') const [selectedYearNumber, setSelectedYearNumber] = useState(null) + const isMobile = useIsMobile() + useEffect(() => { const monthName = chrono(selectedDate).format('MMMM') const yearName = chrono(selectedDate).format('yyyy') @@ -52,12 +55,12 @@ export const CalendarPicker = () => { return ( - diff --git a/src/features/binnacle/features/activity/ui/components/activity-form/activity-form.schema.ts b/src/features/binnacle/features/activity/ui/components/activity-form/activity-form.schema.ts index 709b33b42..85805a43c 100644 --- a/src/features/binnacle/features/activity/ui/components/activity-form/activity-form.schema.ts +++ b/src/features/binnacle/features/activity/ui/components/activity-form/activity-form.schema.ts @@ -1,10 +1,10 @@ import { Organization } from '../../../../organization/domain/organization' import { NonHydratedProjectRole } from '../../../../project-role/domain/non-hydrated-project-role' import { ProjectRole } from '../../../../project-role/domain/project-role' -import { Project } from '../../../../project/domain/project' import { i18n } from '../../../../../../../shared/i18n/i18n' import { chrono, parse } from '../../../../../../../shared/utils/chrono' import * as yup from 'yup' +import { Project } from '../../../../../../shared/project/domain/project' export interface ActivityFormSchema { showRecentRole: boolean @@ -15,7 +15,7 @@ export interface ActivityFormSchema { billable: boolean description: string organization?: Organization - project?: Project & { organizationId: number } + project?: Project projectRole?: NonHydratedProjectRole recentProjectRole?: ProjectRole file?: File @@ -68,7 +68,7 @@ export const ActivityFormValidationSchema: yup.ObjectSchema is: true, then: (schema) => schema.nullable(), otherwise: (schema) => schema.required(i18n.t('form_errors.select_an_option')) - }) as yup.ObjectSchema, + }) as yup.ObjectSchema, projectRole: yup.object().when('showRecentRole', { is: true, then: (schema) => schema.nullable(), diff --git a/src/features/binnacle/features/activity/ui/components/activity-form/activity-form.test.tsx b/src/features/binnacle/features/activity/ui/components/activity-form/activity-form.test.tsx index 13c91c9ec..69d6f32ad 100644 --- a/src/features/binnacle/features/activity/ui/components/activity-form/activity-form.test.tsx +++ b/src/features/binnacle/features/activity/ui/components/activity-form/activity-form.test.tsx @@ -9,7 +9,7 @@ import { waitForElementToBeRemoved } from '../../../../../../../test-utils/render' import { OrganizationMother } from '../../../../../../../test-utils/mothers/organization-mother' -import { ProjectMother } from '../../../../../../../test-utils/mothers/project-mother' +import { LiteProjectMother } from '../../../../../../../test-utils/mothers/lite-project-mother' import { ProjectRoleMother } from '../../../../../../../test-utils/mothers/project-role-mother' import { useExecuteUseCaseOnMount } from '../../../../../../../shared/arch/hooks/use-execute-use-case-on-mount' import { useGetUseCase } from '../../../../../../../shared/arch/hooks/use-get-use-case' @@ -566,7 +566,7 @@ function setup( if (arg.prototype.key === 'GetProjectsQry') { return { isLoading: false, - executeUseCase: jest.fn().mockResolvedValue(ProjectMother.projects()) + executeUseCase: jest.fn().mockResolvedValue(LiteProjectMother.projects()) } } if (arg.prototype.key === 'GetProjectRolesQry') { diff --git a/src/features/binnacle/features/activity/ui/components/activity-form/components/combos/activity-form-combos.tsx b/src/features/binnacle/features/activity/ui/components/activity-form/components/combos/activity-form-combos.tsx index 378d01f14..02b81bc20 100644 --- a/src/features/binnacle/features/activity/ui/components/activity-form/components/combos/activity-form-combos.tsx +++ b/src/features/binnacle/features/activity/ui/components/activity-form/components/combos/activity-form-combos.tsx @@ -59,6 +59,7 @@ export const ActivityFormCombos = forwardRef( if (item?.name !== organization?.name) onOrganizationChange() }} isReadOnly={isReadOnly} + organizationFilters={{ imputable: true }} /> onChange?: (item: Organization) => void isReadOnly?: boolean + organizationFilters: OrganizationFilters } export const OrganizationsCombo = forwardRef((props, ref) => { const { name = 'organization', control, onChange, isReadOnly } = props const { t } = useTranslation() - const { isLoading, result: organizations } = useExecuteUseCaseOnMount(GetOrganizationsQry) + const { isLoading, result: organizations } = useExecuteUseCaseOnMount( + GetOrganizationsQry, + props.organizationFilters + ) return ( ((props, re return } - executeUseCase(organization.id).then((projects) => { + executeUseCase({ organizationIds: [organization.id], open: true }).then((projects) => { setItems(projects) }) }, [executeUseCase, organization]) diff --git a/src/features/binnacle/features/availability/application/get-absences-qry.test.ts b/src/features/binnacle/features/availability/application/get-absences-qry.test.ts new file mode 100644 index 000000000..de7238503 --- /dev/null +++ b/src/features/binnacle/features/availability/application/get-absences-qry.test.ts @@ -0,0 +1,32 @@ +import { mock } from 'jest-mock-extended' +import { AbsenceRepository } from '../domain/absence-repository' +import { GetAbsencesQry } from './get-absences-qry' +import { AbsenceMother } from '../../../../../test-utils/mothers/absence-mother' + +describe('GetAbsencesQry', () => { + it('should get absences', async function () { + const { getAbsencesQry, absenceRepository } = setup() + const absences = AbsenceMother.userAbsences() + absenceRepository.getAbsences.mockResolvedValue(absences) + + const response = await getAbsencesQry.internalExecute({ + startDate: '10-10-2023', + endDate: '10-10-2023' + }) + + expect(absenceRepository.getAbsences).toHaveBeenCalledWith({ + startDate: '10-10-2023', + endDate: '10-10-2023' + }) + expect(response).toEqual(absences) + }) +}) + +const setup = () => { + const absenceRepository = mock() + + return { + absenceRepository, + getAbsencesQry: new GetAbsencesQry(absenceRepository) + } +} diff --git a/src/features/binnacle/features/availability/application/get-absences-qry.ts b/src/features/binnacle/features/availability/application/get-absences-qry.ts new file mode 100644 index 000000000..d1dd19115 --- /dev/null +++ b/src/features/binnacle/features/availability/application/get-absences-qry.ts @@ -0,0 +1,18 @@ +import { Query, UseCaseKey } from '@archimedes/arch' +import { AbsenceFilters } from '../domain/absence-filters' +import type { AbsenceRepository } from '../domain/absence-repository' +import { inject, singleton } from 'tsyringe' +import { ABSENCE_REPOSITORY } from '../../../../../shared/di/container-tokens' +import { UserAbsence } from '../domain/user-absence' + +@UseCaseKey('GetAbsencesQry') +@singleton() +export class GetAbsencesQry extends Query { + constructor(@inject(ABSENCE_REPOSITORY) private absenceRepository: AbsenceRepository) { + super() + } + + internalExecute(absenceFilters: AbsenceFilters): Promise { + return this.absenceRepository.getAbsences(absenceFilters) + } +} diff --git a/src/features/binnacle/features/availability/domain/absence-filters.ts b/src/features/binnacle/features/availability/domain/absence-filters.ts new file mode 100644 index 000000000..676ca59da --- /dev/null +++ b/src/features/binnacle/features/availability/domain/absence-filters.ts @@ -0,0 +1,9 @@ +import { Id } from '../../../../../shared/types/id' + +export interface AbsenceFilters { + userIds?: Id[] + organizationIds?: Id[] + projectIds?: Id[] + startDate: string + endDate: string +} diff --git a/src/features/binnacle/features/availability/domain/absence-overflow.ts b/src/features/binnacle/features/availability/domain/absence-overflow.ts new file mode 100644 index 000000000..af26c5d9e --- /dev/null +++ b/src/features/binnacle/features/availability/domain/absence-overflow.ts @@ -0,0 +1 @@ +export type AbsenceOverflow = 'normal' | 'end' | 'start' | 'both' diff --git a/src/features/binnacle/features/availability/domain/absence-repository.ts b/src/features/binnacle/features/availability/domain/absence-repository.ts new file mode 100644 index 000000000..1d8918811 --- /dev/null +++ b/src/features/binnacle/features/availability/domain/absence-repository.ts @@ -0,0 +1,6 @@ +import { AbsenceFilters } from './absence-filters' +import { UserAbsence } from './user-absence' + +export interface AbsenceRepository { + getAbsences(absenceFilters: AbsenceFilters): Promise +} diff --git a/src/features/binnacle/features/availability/domain/absence-type.ts b/src/features/binnacle/features/availability/domain/absence-type.ts new file mode 100644 index 000000000..374f1ba5e --- /dev/null +++ b/src/features/binnacle/features/availability/domain/absence-type.ts @@ -0,0 +1,6 @@ +const AbsenceTypes = { + VACATION: 'VACATION', + PAID_LEAVE: 'PAID_LEAVE' +} + +export type AbsenceType = keyof typeof AbsenceTypes diff --git a/src/features/binnacle/features/availability/domain/absence-with-overflow-info.tsx b/src/features/binnacle/features/availability/domain/absence-with-overflow-info.tsx new file mode 100644 index 000000000..2d62f78ad --- /dev/null +++ b/src/features/binnacle/features/availability/domain/absence-with-overflow-info.tsx @@ -0,0 +1,4 @@ +import { Absence } from './absence' +import { AbsenceOverflow } from './absence-overflow' + +export type AbsenceWithOverflowInfo = Absence & { overflowType: AbsenceOverflow } diff --git a/src/features/binnacle/features/availability/domain/absence.ts b/src/features/binnacle/features/availability/domain/absence.ts new file mode 100644 index 000000000..4c4ce89af --- /dev/null +++ b/src/features/binnacle/features/availability/domain/absence.ts @@ -0,0 +1,7 @@ +import { AbsenceType } from './absence-type' + +export interface Absence { + type: AbsenceType + startDate: Date + endDate: Date +} diff --git a/src/features/binnacle/features/availability/domain/user-absence.ts b/src/features/binnacle/features/availability/domain/user-absence.ts new file mode 100644 index 000000000..b42cd6918 --- /dev/null +++ b/src/features/binnacle/features/availability/domain/user-absence.ts @@ -0,0 +1,8 @@ +import { Id } from '../../../../../shared/types/id' +import { Absence } from './absence' + +export interface UserAbsence { + userId: Id + userName: string + absences: Absence[] +} diff --git a/src/features/binnacle/features/availability/infrastructure/fake-absence-repository.ts b/src/features/binnacle/features/availability/infrastructure/fake-absence-repository.ts new file mode 100644 index 000000000..9a396115c --- /dev/null +++ b/src/features/binnacle/features/availability/infrastructure/fake-absence-repository.ts @@ -0,0 +1,11 @@ +import { AbsenceRepository } from '../domain/absence-repository' +import { AbsenceMother } from '../../../../../test-utils/mothers/absence-mother' +import { singleton } from 'tsyringe' +import { UserAbsence } from '../domain/user-absence' + +@singleton() +export class FakeAbsenceRepository implements AbsenceRepository { + async getAbsences(): Promise { + return AbsenceMother.userAbsences() + } +} diff --git a/src/features/binnacle/features/availability/infrastructure/http-absence-repository.test.ts b/src/features/binnacle/features/availability/infrastructure/http-absence-repository.test.ts new file mode 100644 index 000000000..f72dde0ab --- /dev/null +++ b/src/features/binnacle/features/availability/infrastructure/http-absence-repository.test.ts @@ -0,0 +1,27 @@ +import { HttpClient } from '../../../../../shared/http/http-client' +import { HttpAbsenceRepository } from './http-absence-repository' +import { mock } from 'jest-mock-extended' + +describe('HttpAbsenceRepository', () => { + it('should get absences', () => { + const { httpClient, httpAbsenceRepository } = setup() + + httpAbsenceRepository.getAbsences({ startDate: '10-10-2023', endDate: '10-10-2023' }) + + expect(httpClient.get).toHaveBeenCalledWith('/api/absence', { + params: { + startDate: '10-10-2023', + endDate: '10-10-2023' + } + }) + }) +}) + +const setup = () => { + const httpClient = mock() + + return { + httpClient, + httpAbsenceRepository: new HttpAbsenceRepository(httpClient) + } +} diff --git a/src/features/binnacle/features/availability/infrastructure/http-absence-repository.ts b/src/features/binnacle/features/availability/infrastructure/http-absence-repository.ts new file mode 100644 index 000000000..b7e1333c1 --- /dev/null +++ b/src/features/binnacle/features/availability/infrastructure/http-absence-repository.ts @@ -0,0 +1,18 @@ +import { singleton } from 'tsyringe' +import { AbsenceRepository } from '../domain/absence-repository' +import { HttpClient } from '../../../../../shared/http/http-client' +import { AbsenceFilters } from '../domain/absence-filters' +import { UserAbsence } from '../domain/user-absence' + +@singleton() +export class HttpAbsenceRepository implements AbsenceRepository { + protected static absencePath = '/api/absence' + + constructor(private httpClient: HttpClient) {} + + async getAbsences(absenceFilters: AbsenceFilters): Promise { + return await this.httpClient.get(HttpAbsenceRepository.absencePath, { + params: absenceFilters + }) + } +} diff --git a/src/features/binnacle/features/availability/tests/see-users-availability.int.tsx b/src/features/binnacle/features/availability/tests/see-users-availability.int.tsx new file mode 100644 index 000000000..5fa924a28 --- /dev/null +++ b/src/features/binnacle/features/availability/tests/see-users-availability.int.tsx @@ -0,0 +1,29 @@ +import AvailabilityPage from '../ui/availability-page' + +describe('See users availability', () => { + it('should not show table until filters are selected', () => { + setup() + + cy.findByLabelText('Organization', { selector: 'input' }).should('have.value', '') + + cy.findByLabelText('User', { selector: 'input' }).should('have.value', '') + + cy.findByText('Paid leave').should('not.exist') + }) + + it('should show table when filters are selected', () => { + setup() + + cy.findByLabelText('Organization', { selector: 'input' }).click() + + cy.findByText('Test organization').click() + + cy.findByText('Paid leave').should('be.visible') + }) +}) + +const setup = () => { + cy.clock().invoke('setSystemTime', new Date(2023, 9, 1, 0, 0, 0, 0).getTime()) + + cy.mount() +} diff --git a/src/features/binnacle/features/availability/ui/availability-page.lazy.ts b/src/features/binnacle/features/availability/ui/availability-page.lazy.ts new file mode 100644 index 000000000..08c91a9bc --- /dev/null +++ b/src/features/binnacle/features/availability/ui/availability-page.lazy.ts @@ -0,0 +1,3 @@ +import { lazy } from 'react' + +export const LazyAvailabilityPage = lazy(() => import('./availability-page')) diff --git a/src/features/binnacle/features/availability/ui/availability-page.tsx b/src/features/binnacle/features/availability/ui/availability-page.tsx new file mode 100644 index 000000000..b81884f4d --- /dev/null +++ b/src/features/binnacle/features/availability/ui/availability-page.tsx @@ -0,0 +1,19 @@ +import { FC } from 'react' +import { PageWithTitle } from '../../../../../shared/components/page-with-title/page-with-title' +import { useTranslation } from 'react-i18next' +import { CalendarProvider } from '../../activity/ui/contexts/calendar-context' +import { AvailabilityTable } from './components/availability-table/availability-table' + +const AvailabilityPage: FC = () => { + const { t } = useTranslation() + + return ( + + + + + + ) +} + +export default AvailabilityPage diff --git a/src/features/binnacle/features/availability/ui/components/availability-table/absence-item/absence-item.tsx b/src/features/binnacle/features/availability/ui/components/availability-table/absence-item/absence-item.tsx new file mode 100644 index 000000000..350f9938b --- /dev/null +++ b/src/features/binnacle/features/availability/ui/components/availability-table/absence-item/absence-item.tsx @@ -0,0 +1,95 @@ +import { Box, Text, useColorModeValue } from '@chakra-ui/react' +import { FC } from 'react' +import { useTranslation } from 'react-i18next' +import { Absence } from '../../../../domain/absence' +import { chrono } from '../../../../../../../../shared/utils/chrono' +import { AbsenceOverflow } from '../../../../domain/absence-overflow' +import { useIsMobile } from '../../../../../../../../shared/hooks/use-is-mobile' + +interface Props { + userName: string + absence: Absence + overflowType: AbsenceOverflow +} + +export const AbsenceItem: FC = ({ absence, userName, overflowType }) => { + const { t } = useTranslation() + const isMobile = useIsMobile() + + const getDurationInDays = () => { + const duration = chrono(absence.endDate).diff(absence.startDate, 'day') + const cellPadding = '12px' + const boxSize = isMobile ? '36px' : '48px' + const durationPlusLast = duration + 1 + + if (overflowType === 'end') { + return `calc(${durationPlusLast * 100}% + ${durationPlusLast - 1}px - ${cellPadding})` + } + + if (overflowType === 'both') { + return `calc(${durationPlusLast * 100}% + ${durationPlusLast - 1}px)` + } + + if (overflowType === 'start') { + return `calc(${durationPlusLast * 100}%)` + } + + return `calc(${duration * 100}% + ${boxSize})` + } + + const getBorderRadius = () => { + if (overflowType === 'end') { + return '14px 0 0 14px ' + } + + if (overflowType === 'start') { + return '0 14px 14px 0' + } + + if (overflowType === 'both') { + return '0 0 0 0' + } + + return '14px' + } + + const getAbsenceTypeName = () => + absence.type === 'VACATION' ? 'absences.vacation' : 'absences.paidLeave' + + const backgroundColor = useColorModeValue('gray.300', 'gray.600') + + return ( + + + {t(`${getAbsenceTypeName()}`)} + + + ) +} diff --git a/src/features/binnacle/features/availability/ui/components/availability-table/availability-table-cell/availability-table-cell-header.css b/src/features/binnacle/features/availability/ui/components/availability-table/availability-table-cell/availability-table-cell-header.css new file mode 100644 index 000000000..b5effd0ae --- /dev/null +++ b/src/features/binnacle/features/availability/ui/components/availability-table/availability-table-cell/availability-table-cell-header.css @@ -0,0 +1,6 @@ +#is-today { + border-radius: 50%; + font-weight: bold; + color: white; + background-color: var(--primary-color); +} diff --git a/src/features/binnacle/features/availability/ui/components/availability-table/availability-table-cell/availability-table-cell-header.tsx b/src/features/binnacle/features/availability/ui/components/availability-table/availability-table-cell/availability-table-cell-header.tsx new file mode 100644 index 000000000..1d3cfd20b --- /dev/null +++ b/src/features/binnacle/features/availability/ui/components/availability-table/availability-table-cell/availability-table-cell-header.tsx @@ -0,0 +1,47 @@ +import { FC } from 'react' +import { Box, Text, Th, useColorModeValue } from '@chakra-ui/react' +import { isToday } from 'date-fns' +import './availability-table-cell-header.css' +import { chrono, isWeekend } from '../../../../../../../../shared/utils/chrono' +import { getWeekdaysName } from '../../../../../activity/utils/get-weekdays-name' +import { useIsMobile } from '../../../../../../../../shared/hooks/use-is-mobile' + +interface Props { + day: Date + isHoliday: boolean +} + +const weekDays = getWeekdaysName() + +const getWeekDay = (date: Date) => { + const weekDay = chrono(date).get('weekday') + return weekDay === 0 ? 7 : weekDay +} + +export const AvailabilityTableCellHeader: FC = ({ day, isHoliday }) => { + const borderColor = useColorModeValue('gray.300', 'gray.700') + + const isMobile = useIsMobile() + + return ( + + + {weekDays[getWeekDay(day) - 1]} + + {day.getDate()} + + + + ) +} diff --git a/src/features/binnacle/features/availability/ui/components/availability-table/availability-table-cell/availability-table-cell.tsx b/src/features/binnacle/features/availability/ui/components/availability-table/availability-table-cell/availability-table-cell.tsx new file mode 100644 index 000000000..8f282d91e --- /dev/null +++ b/src/features/binnacle/features/availability/ui/components/availability-table/availability-table-cell/availability-table-cell.tsx @@ -0,0 +1,41 @@ +import { FC } from 'react' +import { Box, Td } from '@chakra-ui/react' +import { isWeekend } from '../../../../../../../../shared/utils/chrono' +import { AbsenceItem } from '../absence-item/absence-item' +import { AbsenceWithOverflowInfo } from '../../../../domain/absence-with-overflow-info' +import { useIsMobile } from '../../../../../../../../shared/hooks/use-is-mobile' + +interface Props { + day: Date + isHoliday: boolean + userName: string + absences?: AbsenceWithOverflowInfo[] +} + +export const AvailabilityTableCell: FC = ({ day, isHoliday, absences, userName }) => { + const isMobile = useIsMobile() + + return ( + + + {absences + ? absences.map((absence, index) => ( + + )) + : ''} + + + ) +} diff --git a/src/features/binnacle/features/availability/ui/components/availability-table/availability-table-filters/availability-table-filters.schema.ts b/src/features/binnacle/features/availability/ui/components/availability-table/availability-table-filters/availability-table-filters.schema.ts new file mode 100644 index 000000000..b9c6548d7 --- /dev/null +++ b/src/features/binnacle/features/availability/ui/components/availability-table/availability-table-filters/availability-table-filters.schema.ts @@ -0,0 +1,9 @@ +import { UserInfo } from '../../../../../../../shared/user/domain/user-info' +import { Project } from '../../../../../../../shared/project/domain/project' +import { Organization } from '../../../../../organization/domain/organization' + +export interface AvailabilityTableFiltersSchema { + organization: Organization + project: Project + user: UserInfo +} diff --git a/src/features/binnacle/features/availability/ui/components/availability-table/availability-table-filters/availability-table-filters.tsx b/src/features/binnacle/features/availability/ui/components/availability-table/availability-table-filters/availability-table-filters.tsx new file mode 100644 index 000000000..5f20485df --- /dev/null +++ b/src/features/binnacle/features/availability/ui/components/availability-table/availability-table-filters/availability-table-filters.tsx @@ -0,0 +1,70 @@ +import { Flex } from '@chakra-ui/react' +import { FC, useMemo } from 'react' +import { UserInfo } from '../../../../../../../shared/user/domain/user-info' +import { AbsenceFilters } from '../../../../domain/absence-filters' +import { OrganizationsCombo } from '../../../../../activity/ui/components/activity-form/components/combos/organizations-combo' +import { ProjectsCombo } from '../../../../../activity/ui/components/activity-form/components/combos/projects-combo' +import { useController, useForm, useWatch } from 'react-hook-form' +import { UserFilter } from './user-filter/user-filter' +import { AvailabilityTableFiltersSchema } from './availability-table-filters.schema' +import { Project } from '../../../../../../../shared/project/domain/project' +import { Organization } from '../../../../../organization/domain/organization' +import { useIsMobile } from '../../../../../../../../shared/hooks/use-is-mobile' + +interface Props { + onChange: (params: Partial) => void +} + +export const AvailabilityTableFilters: FC = ({ onChange }) => { + const { control } = useForm() + const [organization] = useWatch({ + control, + name: ['organization'] + }) + + const { field: projectField } = useController({ + name: 'project', + control + }) + + const isMobile = useIsMobile() + + const projectDisabled = useMemo(() => organization === undefined, [organization]) + + const handleChange = (params: Partial) => { + onChange(params) + } + + return ( + + { + handleChange({ organizationIds: organization ? [organization?.id] : undefined }) + projectField.onChange() + }} + /> + { + handleChange({ projectIds: project ? [project?.id] : undefined }) + }} + isDisabled={projectDisabled} + /> + + handleChange({ userIds: userInfo ? [userInfo?.id] : undefined }) + } + > + + ) +} diff --git a/src/features/binnacle/features/availability/ui/components/availability-table/availability-table-filters/user-filter/user-filter.tsx b/src/features/binnacle/features/availability/ui/components/availability-table/availability-table-filters/user-filter/user-filter.tsx new file mode 100644 index 000000000..e56376baa --- /dev/null +++ b/src/features/binnacle/features/availability/ui/components/availability-table/availability-table-filters/user-filter/user-filter.tsx @@ -0,0 +1,54 @@ +import { FC } from 'react' +import { useTranslation } from 'react-i18next' +import { useForm } from 'react-hook-form' +import { useExecuteUseCaseOnMount } from '../../../../../../../../../shared/arch/hooks/use-execute-use-case-on-mount' +import { GetUsersListQry } from '../../../../../../../../shared/user/application/get-users-list-qry' +import { UserInfo } from '../../../../../../../../shared/user/domain/user-info' +import { ComboField } from '../../../../../../../../../shared/components/form-fields/combo-field' + +interface Props { + onChange: (user: UserInfo) => void +} + +export const UserFilter: FC = (props) => { + const { t } = useTranslation() + const { control } = useForm() + const { + isLoading, + result: users, + executeUseCase: getUsersListQry + } = useExecuteUseCaseOnMount(GetUsersListQry, { + active: true, + limit: 100 + }) + + const handleChange = (user: UserInfo) => { + props.onChange(user) + } + + const handleInputChange = (event: any) => { + const timeoutId = setTimeout(async () => { + await getUsersListQry({ + active: true, + limit: 100, + nameLike: event.target.value + }) + + return () => clearTimeout(timeoutId) + }, 2000) + } + + return ( + + ) +} diff --git a/src/features/binnacle/features/availability/ui/components/availability-table/availability-table-header/availability-table-header.tsx b/src/features/binnacle/features/availability/ui/components/availability-table/availability-table-header/availability-table-header.tsx new file mode 100644 index 000000000..73954c163 --- /dev/null +++ b/src/features/binnacle/features/availability/ui/components/availability-table/availability-table-header/availability-table-header.tsx @@ -0,0 +1,28 @@ +import { AvailabilityTableFilters } from '../availability-table-filters/availability-table-filters' +import { CalendarControls } from '../../../../../activity/ui/calendar-desktop/calendar-controls/calendar-controls' +import { Flex } from '@chakra-ui/react' +import { useIsMobile } from '../../../../../../../../shared/hooks/use-is-mobile' +import { FC } from 'react' +import { AbsenceFilters } from '../../../../domain/absence-filters' + +interface Props { + onFilterChange: (params: Partial) => void +} + +export const AvailabilityTableHeader: FC = ({ onFilterChange }) => { + const isMobile = useIsMobile() + + return ( + + + + + ) +} diff --git a/src/features/binnacle/features/availability/ui/components/availability-table/availability-table.module.css b/src/features/binnacle/features/availability/ui/components/availability-table/availability-table.module.css new file mode 100644 index 000000000..45ca2bd25 --- /dev/null +++ b/src/features/binnacle/features/availability/ui/components/availability-table/availability-table.module.css @@ -0,0 +1,31 @@ +.data-table { + border-collapse: separate; + border-spacing: 0; +} + +.data-table th { + border-top: 1px solid; + border-bottom: 1px solid; + border-right: 1px solid; + border-color: var(--table-border-color); +} + +.data-table td { + border-bottom: 1px solid; + border-right: 1px solid; + border-color: var(--table-border-color); +} + +.data-table th:first-child, +.data-table td:first-child { + border-left: 1px solid var(--table-border-color); + position: sticky; + background: var(--bg-color); + left: 0; + z-index: 2; +} + +.data-table th:first-child { + border-top: none; + border-left: none; +} diff --git a/src/features/binnacle/features/availability/ui/components/availability-table/availability-table.tsx b/src/features/binnacle/features/availability/ui/components/availability-table/availability-table.tsx new file mode 100644 index 000000000..f1c98bd97 --- /dev/null +++ b/src/features/binnacle/features/availability/ui/components/availability-table/availability-table.tsx @@ -0,0 +1,204 @@ +import { FC, useEffect, useMemo, useState } from 'react' +import { Box, Flex, Spinner, Table, Tbody, Td, Text, Th, Thead, Tr } from '@chakra-ui/react' +import { chrono } from '../../../../../../../shared/utils/chrono' +import { useExecuteUseCaseOnMount } from '../../../../../../../shared/arch/hooks/use-execute-use-case-on-mount' +import { GetAbsencesQry } from '../../../application/get-absences-qry' +import { useCalendarContext } from '../../../../activity/ui/contexts/calendar-context' +import { AbsenceFilters } from '../../../domain/absence-filters' +import { useGetUseCase } from '../../../../../../../shared/arch/hooks/use-get-use-case' +import { GetHolidaysByYearQry } from '../../../../holiday/application/get-holidays-by-year-qry' +import { useTranslation } from 'react-i18next' +import { AvailabilityTableCellHeader } from './availability-table-cell/availability-table-cell-header' +import { AvailabilityTableCell } from './availability-table-cell/availability-table-cell' +import { AbsenceWithOverflowInfo } from '../../../domain/absence-with-overflow-info' +import { UserAbsence } from '../../../domain/user-absence' +import { useIsMobile } from '../../../../../../../shared/hooks/use-is-mobile' +import { AvailabilityTableHeader } from './availability-table-header/availability-table-header' +import styles from './availability-table.module.css' + +export const AvailabilityTable: FC = () => { + const { selectedDate } = useCalendarContext() + const [userAbsences, setUserAbsences] = useState([]) + const [absenceFilters, setAbsenceFilters] = useState({ + startDate: chrono().format(chrono.DATE_FORMAT), + endDate: chrono().format(chrono.DATE_FORMAT) + }) + const [previousSelectedDate, setPreviousSelectedDate] = useState(selectedDate) + + const { t } = useTranslation() + const isMobile = useIsMobile() + + const selectedDateInterval = useMemo(() => { + return { + start: chrono(selectedDate).startOf('month').minus(5, 'day').getDate(), + end: chrono(selectedDate).endOf('month').plus(5, 'day').getDate() + } + }, [selectedDate]) + + const { executeUseCase: getAbsencesQry, isLoading } = useGetUseCase(GetAbsencesQry) + + const daysOfMonth = chrono(selectedDateInterval.start).eachDayUntil(selectedDateInterval.end) + + const { result: holidays = [] } = useExecuteUseCaseOnMount( + GetHolidaysByYearQry, + selectedDateInterval.start.getFullYear() + ) + + const requiredFiltersAreSelected = () => + absenceFilters.organizationIds !== undefined || absenceFilters.userIds !== undefined + + useEffect(() => { + if (requiredFiltersAreSelected()) { + getAbsencesQry({ + ...absenceFilters, + startDate: chrono(selectedDateInterval.start).format(chrono.DATE_FORMAT), + endDate: chrono(selectedDateInterval.end).format(chrono.DATE_FORMAT) + }).then((absences) => { + setUserAbsences(absences) + }) + } else { + setUserAbsences([]) + } + }, [absenceFilters, selectedDateInterval]) + + const checkIfHoliday = (day: Date) => + holidays.some((holiday) => chrono(day).isSameDay(holiday.date)) + + const onFilterChange = (updatedFilter: Partial) => { + setAbsenceFilters({ ...absenceFilters, ...updatedFilter }) + } + + useEffect(() => { + if (chrono().isBetween(selectedDateInterval.start, selectedDateInterval.end)) { + const element = document.getElementById('is-today') + if (element !== null) element.scrollIntoView({ inline: 'center' }) + } else if (previousSelectedDate > selectedDate) { + const lastElement = document.querySelector('thead tr th:last-child') + if (lastElement !== null) lastElement.scrollIntoView({ inline: 'center' }) + } else if (previousSelectedDate < selectedDate) { + const firstElement = document.querySelector('thead tr th:first-child + th') + if (firstElement !== null) firstElement.scrollIntoView({ inline: 'center' }) + } + setPreviousSelectedDate(selectedDate) + }, [userAbsences]) + + const updateAbsencesBasedOnInterval = (userAbsence: UserAbsence) => { + return userAbsence.absences + .map((absence) => { + const checkIfStartDateAndEndDateAreInsideInterval = + chrono(absence.startDate).isDateWithinInterval(selectedDateInterval) && + chrono(absence.endDate).isDateWithinInterval(selectedDateInterval) + + const CheckIfBothDatesAreOutsideOfInterval = + !chrono(absence.startDate).isDateWithinInterval(selectedDateInterval) && + !chrono(absence.endDate).isDateWithinInterval(selectedDateInterval) && + chrono(selectedDateInterval.start).isDateWithinInterval({ + start: chrono(absence.startDate).getDate(), + end: chrono(absence.endDate).getDate() + }) + + const checkIfEndDateIsOutsideOfInterval = + chrono(absence.startDate).isDateWithinInterval(selectedDateInterval) && + !chrono(absence.endDate).isDateWithinInterval(selectedDateInterval) + + const checkIfStartDateIsOutsideOfInterval = + !chrono(absence.startDate).isDateWithinInterval(selectedDateInterval) && + chrono(absence.endDate).isDateWithinInterval(selectedDateInterval) + + if (checkIfStartDateAndEndDateAreInsideInterval) { + return { ...absence, overflowType: 'normal' } + } + + if (checkIfEndDateIsOutsideOfInterval) { + return { ...absence, endDate: selectedDateInterval.end, overflowType: 'end' } + } + + if (checkIfStartDateIsOutsideOfInterval) { + return { + ...absence, + startDate: selectedDateInterval.start, + overflowType: 'start' + } + } + if (CheckIfBothDatesAreOutsideOfInterval) { + return { + ...absence, + startDate: selectedDateInterval.start, + endDate: selectedDateInterval.end, + overflowType: 'both' + } + } + }) + .filter((x) => x !== undefined) as AbsenceWithOverflowInfo[] + } + + const tableHeaders = ( + + + + {daysOfMonth.map((day, index) => ( + + ))} + + + ) + + const tableRows = ( + + {userAbsences?.map((userAbsence, index) => ( + + + + {userAbsence.userName} + + + {daysOfMonth.map((day, index) => ( + + chrono(day).isSameDay(x.startDate) + )} + isHoliday={checkIfHoliday(day)} + > + ))} + + ))} + + ) + + const layoutWithData = ( + + + {tableHeaders} + {tableRows} +
+
+ ) + + return ( + <> + + {isLoading ? ( + + + + ) : !requiredFiltersAreSelected() ? ( + {t('absences.requiredFilters')} + ) : userAbsences.length === 0 ? ( + {t('absences.emptyMessage')} + ) : ( + layoutWithData + )} + + ) +} diff --git a/src/features/binnacle/features/holiday/application/get-holidays-by-year-qry.ts b/src/features/binnacle/features/holiday/application/get-holidays-by-year-qry.ts new file mode 100644 index 000000000..fc8e6735f --- /dev/null +++ b/src/features/binnacle/features/holiday/application/get-holidays-by-year-qry.ts @@ -0,0 +1,17 @@ +import { Query, UseCaseKey } from '@archimedes/arch' +import { inject, singleton } from 'tsyringe' +import { Holiday } from '../domain/holiday' +import { HOLIDAY_REPOSITORY } from '../../../../../shared/di/container-tokens' +import type { HolidayRepository } from '../domain/holiday-repository' + +@UseCaseKey('GetHolidayByYearQry') +@singleton() +export class GetHolidaysByYearQry extends Query { + constructor(@inject(HOLIDAY_REPOSITORY) private holidayRepository: HolidayRepository) { + super() + } + + internalExecute(year: number): Promise { + return this.holidayRepository.getHolidaysByYear(year) + } +} diff --git a/src/features/binnacle/features/holiday/domain/holiday-repository.ts b/src/features/binnacle/features/holiday/domain/holiday-repository.ts index 66eeffd72..5c0bbfbe2 100644 --- a/src/features/binnacle/features/holiday/domain/holiday-repository.ts +++ b/src/features/binnacle/features/holiday/domain/holiday-repository.ts @@ -3,4 +3,6 @@ import { Holiday } from './holiday' export interface HolidayRepository { getAll(interval: DateInterval): Promise + + getHolidaysByYear(year: number): Promise } diff --git a/src/features/binnacle/features/holiday/infrastructure/fake-holiday-repository.ts b/src/features/binnacle/features/holiday/infrastructure/fake-holiday-repository.ts index 61c8ec5f1..952640eec 100644 --- a/src/features/binnacle/features/holiday/infrastructure/fake-holiday-repository.ts +++ b/src/features/binnacle/features/holiday/infrastructure/fake-holiday-repository.ts @@ -8,4 +8,8 @@ export class FakeHolidayRepository implements HolidayRepository { async getAll(): Promise { return HolidayMother.holidays() } + + async getHolidaysByYear(): Promise { + return HolidayMother.holidays() + } } diff --git a/src/features/binnacle/features/holiday/infrastructure/http-holiday-repository.ts b/src/features/binnacle/features/holiday/infrastructure/http-holiday-repository.ts index e717c9114..a0894fd23 100644 --- a/src/features/binnacle/features/holiday/infrastructure/http-holiday-repository.ts +++ b/src/features/binnacle/features/holiday/infrastructure/http-holiday-repository.ts @@ -9,6 +9,7 @@ import { HolidayRepository } from '../domain/holiday-repository' @singleton() export class HttpHolidayRepository implements HolidayRepository { protected static holidayPath = '/api/holidays' + protected static newHolidayPath = '/api/holiday' constructor(private httpClient: HttpClient) {} @@ -27,4 +28,14 @@ export class HttpHolidayRepository implements HolidayRepository { return holidays.map((holiday) => ({ ...holiday, date: new Date(holiday.date) })) } + + async getHolidaysByYear(year: number): Promise { + const data = await this.httpClient.get(HttpHolidayRepository.newHolidayPath, { + params: { + year + } + }) + + return data.map((holiday) => ({ ...holiday, date: new Date(holiday.date) })) + } } diff --git a/src/features/binnacle/features/organization/application/get-organizations-qry.test.ts b/src/features/binnacle/features/organization/application/get-organizations-qry.test.ts index 9e1fa7111..99f7c76a1 100644 --- a/src/features/binnacle/features/organization/application/get-organizations-qry.test.ts +++ b/src/features/binnacle/features/organization/application/get-organizations-qry.test.ts @@ -9,7 +9,7 @@ describe('GetHolidaysQry', () => { const organizations = OrganizationMother.organizations() organizationRepository.getAll.mockResolvedValue(organizations) - const response = await getOrganizationQry.internalExecute() + const response = await getOrganizationQry.internalExecute({ imputable: true }) expect(organizationRepository.getAll).toHaveBeenCalled() expect(response).toEqual(organizations) diff --git a/src/features/binnacle/features/organization/application/get-organizations-qry.ts b/src/features/binnacle/features/organization/application/get-organizations-qry.ts index 7541f7436..f9d029db9 100644 --- a/src/features/binnacle/features/organization/application/get-organizations-qry.ts +++ b/src/features/binnacle/features/organization/application/get-organizations-qry.ts @@ -3,17 +3,18 @@ import { ORGANIZATION_REPOSITORY } from '../../../../../shared/di/container-toke import { inject, singleton } from 'tsyringe' import { Organization } from '../domain/organization' import type { OrganizationRepository } from '../domain/organization-repository' +import { OrganizationFilters } from '../domain/organization-filters' @UseCaseKey('GetOrganizationsQry') @singleton() -export class GetOrganizationsQry extends Query { +export class GetOrganizationsQry extends Query { constructor( @inject(ORGANIZATION_REPOSITORY) private organizationRepository: OrganizationRepository ) { super() } - internalExecute(): Promise { - return this.organizationRepository.getAll() + internalExecute(organizationFilters: OrganizationFilters): Promise { + return this.organizationRepository.getAll(organizationFilters) } } diff --git a/src/features/binnacle/features/organization/domain/organization-filters.ts b/src/features/binnacle/features/organization/domain/organization-filters.ts new file mode 100644 index 000000000..e1d5940eb --- /dev/null +++ b/src/features/binnacle/features/organization/domain/organization-filters.ts @@ -0,0 +1,6 @@ +import { OrganizationType } from './organization-type' + +export interface OrganizationFilters { + imputable?: boolean + types?: OrganizationType[] +} diff --git a/src/features/binnacle/features/organization/domain/organization-repository.ts b/src/features/binnacle/features/organization/domain/organization-repository.ts index efc93239f..09dc8150a 100644 --- a/src/features/binnacle/features/organization/domain/organization-repository.ts +++ b/src/features/binnacle/features/organization/domain/organization-repository.ts @@ -1,5 +1,6 @@ import { Organization } from './organization' +import { OrganizationFilters } from './organization-filters' export interface OrganizationRepository { - getAll(): Promise + getAll(organizationFilters?: OrganizationFilters): Promise } diff --git a/src/features/binnacle/features/organization/domain/organization-type.ts b/src/features/binnacle/features/organization/domain/organization-type.ts new file mode 100644 index 000000000..3d4cf4091 --- /dev/null +++ b/src/features/binnacle/features/organization/domain/organization-type.ts @@ -0,0 +1,7 @@ +export const OrganizationTypes = { + CLIENT: 'CLIENT', + PROVIDER: 'PROVIDER', + PROSPECT: 'PROSPECT' +} + +export type OrganizationType = keyof typeof OrganizationTypes diff --git a/src/features/binnacle/features/organization/infrastructure/http-organization-repository.ts b/src/features/binnacle/features/organization/infrastructure/http-organization-repository.ts index edf12606b..209026f96 100644 --- a/src/features/binnacle/features/organization/infrastructure/http-organization-repository.ts +++ b/src/features/binnacle/features/organization/infrastructure/http-organization-repository.ts @@ -2,6 +2,7 @@ import { HttpClient } from '../../../../../shared/http/http-client' import { singleton } from 'tsyringe' import { Organization } from '../domain/organization' import { OrganizationRepository } from '../domain/organization-repository' +import { OrganizationFilters } from '../domain/organization-filters' @singleton() export class HttpOrganizationRepository implements OrganizationRepository { @@ -9,7 +10,9 @@ export class HttpOrganizationRepository implements OrganizationRepository { constructor(private httpClient: HttpClient) {} - async getAll(): Promise { - return this.httpClient.get(HttpOrganizationRepository.organizationPath) + async getAll(organizationFilters?: OrganizationFilters): Promise { + return this.httpClient.get(HttpOrganizationRepository.organizationPath, { + params: organizationFilters + }) } } diff --git a/src/features/binnacle/features/project-role/domain/non-hydrated-project-role.ts b/src/features/binnacle/features/project-role/domain/non-hydrated-project-role.ts index c2a11822f..615e17373 100644 --- a/src/features/binnacle/features/project-role/domain/non-hydrated-project-role.ts +++ b/src/features/binnacle/features/project-role/domain/non-hydrated-project-role.ts @@ -1,7 +1,7 @@ import { Id } from '../../../../../shared/types/id' import { Organization } from '../../organization/domain/organization' -import { Project } from '../../project/domain/project' import { ProjectRole } from './project-role' +import { Project } from '../../../../shared/project/domain/project' export type NonHydratedProjectRole = Omit & { organizationId: Organization['id'] diff --git a/src/features/binnacle/features/project/application/get-projects-qry.ts b/src/features/binnacle/features/project/application/get-projects-qry.ts deleted file mode 100644 index aa0595dd5..000000000 --- a/src/features/binnacle/features/project/application/get-projects-qry.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { Id, Query, UseCaseKey } from '@archimedes/arch' -import { inject, singleton } from 'tsyringe' -import { Project } from '../domain/project' -import type { ProjectRepository } from '../domain/project-repository' -import { PROJECT_REPOSITORY } from '../../../../../shared/di/container-tokens' - -@UseCaseKey('GetProjectsQry') -@singleton() -export class GetProjectsQry extends Query { - constructor(@inject(PROJECT_REPOSITORY) private projectRepository: ProjectRepository) { - super() - } - - internalExecute(organizationId: Id): Promise { - return this.projectRepository.getAll(organizationId) - } -} diff --git a/src/features/binnacle/features/project/domain/lite-project.ts b/src/features/binnacle/features/project/domain/lite-project.ts index a80a5935e..1730a810f 100644 --- a/src/features/binnacle/features/project/domain/lite-project.ts +++ b/src/features/binnacle/features/project/domain/lite-project.ts @@ -1,3 +1,3 @@ -import { Project } from './project' +import { Project } from '../../../../shared/project/domain/project' export type LiteProject = Pick diff --git a/src/features/binnacle/features/project/domain/project-repository.ts b/src/features/binnacle/features/project/domain/project-repository.ts deleted file mode 100644 index b5ea9e020..000000000 --- a/src/features/binnacle/features/project/domain/project-repository.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { Id } from '../../../../../shared/types/id' -import { Project } from './project' - -export interface ProjectRepository { - getAll(organizationId: Id): Promise -} diff --git a/src/features/binnacle/features/project/domain/project.ts b/src/features/binnacle/features/project/domain/project.ts deleted file mode 100644 index d931be305..000000000 --- a/src/features/binnacle/features/project/domain/project.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { Id } from '../../../../../shared/types/id' - -export interface Project { - id: Id - name: string - billable: boolean - open: boolean -} diff --git a/src/features/binnacle/features/project/infrastructure/fake-project-repository.ts b/src/features/binnacle/features/project/infrastructure/fake-project-repository.ts deleted file mode 100644 index e95cd0d81..000000000 --- a/src/features/binnacle/features/project/infrastructure/fake-project-repository.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { ProjectMother } from '../../../../../test-utils/mothers/project-mother' -import { Project } from '../domain/project' -import { ProjectRepository } from '../domain/project-repository' - -export class FakeProjectRepository implements ProjectRepository { - async getAll(): Promise { - return ProjectMother.projects() - } -} diff --git a/src/features/binnacle/features/project/infrastructure/http-project-repository.test.ts b/src/features/binnacle/features/project/infrastructure/http-project-repository.test.ts deleted file mode 100644 index 7333ad245..000000000 --- a/src/features/binnacle/features/project/infrastructure/http-project-repository.test.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { mock } from 'jest-mock-extended' -import { HttpClient } from '../../../../../shared/http/http-client' -import { HttpProjectRepository } from './http-project-repository' -import { ProjectMother } from '../../../../../test-utils/mothers/project-mother' -import { OrganizationMother } from '../../../../../test-utils/mothers/organization-mother' - -describe('HttpProjectRepository', () => { - it('should call http client for projects', async () => { - const { httpClient, httpProjectRepository } = setup() - const organizations = ProjectMother.projects() - httpClient.get.mockResolvedValue(ProjectMother.projects()) - - const result = await httpProjectRepository.getAll(OrganizationMother.organization().id) - - expect(httpClient.get).toHaveBeenCalled() - expect(result).toEqual(organizations) - }) -}) - -function setup() { - const httpClient = mock() - - return { - httpClient, - httpProjectRepository: new HttpProjectRepository(httpClient) - } -} diff --git a/src/features/binnacle/features/project/infrastructure/http-project-repository.ts b/src/features/binnacle/features/project/infrastructure/http-project-repository.ts deleted file mode 100644 index a95c43b4f..000000000 --- a/src/features/binnacle/features/project/infrastructure/http-project-repository.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { HttpClient } from '../../../../../shared/http/http-client' -import { Id } from '../../../../../shared/types/id' -import { singleton } from 'tsyringe' -import { Project } from '../domain/project' -import { ProjectRepository } from '../domain/project-repository' - -@singleton() -export class HttpProjectRepository implements ProjectRepository { - protected static projectPath = (organizationId: Id) => - `/api/organizations/${organizationId}/projects` - - constructor(private httpClient: HttpClient) {} - - getAll(organizationId: Id): Promise { - return this.httpClient.get(HttpProjectRepository.projectPath(organizationId)) - } -} diff --git a/src/features/binnacle/features/project/application/get-projects-qry.test.ts b/src/features/shared/project/application/binnacle/get-projects-qry.test.ts similarity index 53% rename from src/features/binnacle/features/project/application/get-projects-qry.test.ts rename to src/features/shared/project/application/binnacle/get-projects-qry.test.ts index 99150da10..1cf54042a 100644 --- a/src/features/binnacle/features/project/application/get-projects-qry.test.ts +++ b/src/features/shared/project/application/binnacle/get-projects-qry.test.ts @@ -1,18 +1,21 @@ import { mock } from 'jest-mock-extended' import { GetProjectsQry } from './get-projects-qry' -import { ProjectRepository } from '../domain/project-repository' -import { ProjectMother } from '../../../../../test-utils/mothers/project-mother' +import { LiteProjectMother } from '../../../../../test-utils/mothers/lite-project-mother' import { OrganizationMother } from '../../../../../test-utils/mothers/organization-mother' +import { ProjectRepository } from '../../domain/project-repository' describe('GetProjectsQry', () => { it('should get projects from repository', async () => { const { getProjectQry, projectRepository } = setup() - const projects = ProjectMother.projects() - projectRepository.getAll.mockResolvedValue(projects) + const projects = LiteProjectMother.projects() + projectRepository.getProjects.mockResolvedValue(projects) - const response = await getProjectQry.internalExecute(OrganizationMother.organization().id) + const response = await getProjectQry.internalExecute({ + organizationIds: [OrganizationMother.organization().id], + open: true + }) - expect(projectRepository.getAll).toHaveBeenCalled() + expect(projectRepository.getProjects).toHaveBeenCalled() expect(response).toEqual(projects) }) }) diff --git a/src/features/shared/project/application/binnacle/get-projects-qry.ts b/src/features/shared/project/application/binnacle/get-projects-qry.ts new file mode 100644 index 000000000..c2ee4ad1d --- /dev/null +++ b/src/features/shared/project/application/binnacle/get-projects-qry.ts @@ -0,0 +1,18 @@ +import { Query, UseCaseKey } from '@archimedes/arch' +import { inject, singleton } from 'tsyringe' +import { PROJECT_REPOSITORY } from '../../../../../shared/di/container-tokens' +import { Project } from '../../domain/project' +import type { ProjectRepository } from '../../domain/project-repository' +import { ProjectOrganizationFilters } from '../../domain/project-organization-filters' + +@UseCaseKey('GetProjectsQry') +@singleton() +export class GetProjectsQry extends Query { + constructor(@inject(PROJECT_REPOSITORY) private projectRepository: ProjectRepository) { + super() + } + + internalExecute(organizationStatus?: ProjectOrganizationFilters): Promise { + return this.projectRepository.getProjects(organizationStatus) + } +} diff --git a/src/features/administration/features/project/domain/project-code-error.ts b/src/features/shared/project/domain/project-code-error.ts similarity index 100% rename from src/features/administration/features/project/domain/project-code-error.ts rename to src/features/shared/project/domain/project-code-error.ts diff --git a/src/features/administration/features/project/domain/project-dto.ts b/src/features/shared/project/domain/project-dto.ts similarity index 78% rename from src/features/administration/features/project/domain/project-dto.ts rename to src/features/shared/project/domain/project-dto.ts index 2f409accb..2a013346d 100644 --- a/src/features/administration/features/project/domain/project-dto.ts +++ b/src/features/shared/project/domain/project-dto.ts @@ -1,4 +1,4 @@ -import { Id } from '../../../../../shared/types/id' +import { Id } from '../../../../shared/types/id' export interface ProjectDto { id: Id diff --git a/src/features/shared/project/domain/project-organization-filters.ts b/src/features/shared/project/domain/project-organization-filters.ts new file mode 100644 index 000000000..915daccc2 --- /dev/null +++ b/src/features/shared/project/domain/project-organization-filters.ts @@ -0,0 +1,6 @@ +import { Id } from '@archimedes/arch' + +export interface ProjectOrganizationFilters { + organizationIds?: Id[] + open?: boolean +} diff --git a/src/features/shared/project/domain/project-repository.ts b/src/features/shared/project/domain/project-repository.ts new file mode 100644 index 000000000..a07539861 --- /dev/null +++ b/src/features/shared/project/domain/project-repository.ts @@ -0,0 +1,11 @@ +import { ProjectOrganizationFilters } from './project-organization-filters' +import { Project } from './project' +import { Id } from '../../../../shared/types/id' + +export interface ProjectRepository { + getProjects(organizationStatus?: ProjectOrganizationFilters): Promise + + blockProject(projectId: Id, date: Date): Promise + + setUnblock(projectId: Id): Promise +} diff --git a/src/features/administration/features/project/domain/project.ts b/src/features/shared/project/domain/project.ts similarity index 80% rename from src/features/administration/features/project/domain/project.ts rename to src/features/shared/project/domain/project.ts index 2f41ff04d..29214f6d5 100644 --- a/src/features/administration/features/project/domain/project.ts +++ b/src/features/shared/project/domain/project.ts @@ -1,4 +1,4 @@ -import { Id } from '../../../../../shared/types/id' +import { Id } from '../../../../shared/types/id' export interface Project { id: Id diff --git a/src/features/administration/features/project/domain/services/project-error-message.ts b/src/features/shared/project/domain/services/project-error-message.ts similarity index 83% rename from src/features/administration/features/project/domain/services/project-error-message.ts rename to src/features/shared/project/domain/services/project-error-message.ts index 22ea5e6cb..0ba67ea9d 100644 --- a/src/features/administration/features/project/domain/services/project-error-message.ts +++ b/src/features/shared/project/domain/services/project-error-message.ts @@ -1,7 +1,7 @@ -import { i18n } from '../../../../../../shared/i18n/i18n' -import { NotificationMessage } from '../../../../../../shared/notification/notification-message' import { injectable } from 'tsyringe' import { ProjectCodeError } from '../project-code-error' +import { NotificationMessage } from '../../../../../shared/notification/notification-message' +import { i18n } from '../../../../../shared/i18n/i18n' type ProjectCodeError = keyof typeof ProjectCodeError diff --git a/src/features/shared/project/infrastructure/fake-project-repository.ts b/src/features/shared/project/infrastructure/fake-project-repository.ts new file mode 100644 index 000000000..ac75c09d6 --- /dev/null +++ b/src/features/shared/project/infrastructure/fake-project-repository.ts @@ -0,0 +1,19 @@ +import { singleton } from 'tsyringe' +import { Project } from '../domain/project' +import { ProjectRepository } from '../domain/project-repository' +import { ProjectMother } from '../../../../test-utils/mothers/project-mother' + +@singleton() +export class FakeProjectRepository implements ProjectRepository { + async getProjects(): Promise { + return ProjectMother.projectsFilteredByOrganizationDateIsoWithName() + } + + async blockProject(): Promise { + return + } + + async setUnblock(): Promise { + return + } +} diff --git a/src/features/administration/features/project/infrastructure/http-project-repository.test.ts b/src/features/shared/project/infrastructure/http-project-repository.test.ts similarity index 84% rename from src/features/administration/features/project/infrastructure/http-project-repository.test.ts rename to src/features/shared/project/infrastructure/http-project-repository.test.ts index 49a298d6c..e6615df8a 100644 --- a/src/features/administration/features/project/infrastructure/http-project-repository.test.ts +++ b/src/features/shared/project/infrastructure/http-project-repository.test.ts @@ -1,8 +1,8 @@ import { mock } from 'jest-mock-extended' -import { HttpClient } from '../../../../../shared/http/http-client' -import { chrono } from '../../../../../shared/utils/chrono' -import { ProjectMother } from '../domain/tests/project-mother' import { HttpProjectRepository } from './http-project-repository' +import { chrono } from '../../../../shared/utils/chrono' +import { HttpClient } from '../../../../shared/http/http-client' +import { ProjectMother } from '../../../../test-utils/mothers/project-mother' describe('HttpProjectRepository', () => { test('should get projects by organizationId', async () => { @@ -10,10 +10,10 @@ describe('HttpProjectRepository', () => { httpClient.get.mockResolvedValue(ProjectMother.projectsFilteredByOrganization()) - const result = await projectRepository.getProjects({ organizationId: 1, open: true }) + const result = await projectRepository.getProjects({ organizationIds: [1], open: true }) expect(httpClient.get).toHaveBeenCalledWith('/api/project', { - params: { organizationId: 1, open: true } + params: { organizationIds: [1], open: true } }) expect(result).toEqual(ProjectMother.projectsFilteredByOrganizationDateIso()) diff --git a/src/features/administration/features/project/infrastructure/http-project-repository.ts b/src/features/shared/project/infrastructure/http-project-repository.ts similarity index 58% rename from src/features/administration/features/project/infrastructure/http-project-repository.ts rename to src/features/shared/project/infrastructure/http-project-repository.ts index c89a05fdd..fc1f64191 100644 --- a/src/features/administration/features/project/infrastructure/http-project-repository.ts +++ b/src/features/shared/project/infrastructure/http-project-repository.ts @@ -1,12 +1,12 @@ -import { HttpClient } from '../../../../../shared/http/http-client' import { singleton } from 'tsyringe' -import { Id } from '../../../../../shared/types/id' -import { chrono } from '../../../../../shared/utils/chrono' -import { OrganizationWithStatus } from '../domain/organization-status' +import { ProjectOrganizationFilters } from '../domain/project-organization-filters' import { Project } from '../domain/project' import { ProjectDto } from '../domain/project-dto' import { ProjectRepository } from '../domain/project-repository' import { ProjectMapper } from './project-mapper' +import { Id } from '../../../../shared/types/id' +import { HttpClient } from '../../../../shared/http/http-client' +import { chrono } from '../../../../shared/utils/chrono' @singleton() export class HttpProjectRepository implements ProjectRepository { @@ -16,18 +16,11 @@ export class HttpProjectRepository implements ProjectRepository { constructor(private httpClient: HttpClient) {} - async getProjects(organizationWithStatus?: OrganizationWithStatus): Promise { - if (organizationWithStatus) { - const { organizationId, open } = organizationWithStatus - const data = await this.httpClient.get(HttpProjectRepository.projectPath, { - params: { - organizationId: organizationId, - open: open - } - }) - return ProjectMapper.toDomainList(data) - } - return [] + async getProjects(organizationWithStatus: ProjectOrganizationFilters): Promise { + const data = await this.httpClient.get(HttpProjectRepository.projectPath, { + params: organizationWithStatus + }) + return ProjectMapper.toDomainList(data) } async blockProject(projectId: Id, date: Date): Promise { diff --git a/src/features/administration/features/project/infrastructure/project-mapper.ts b/src/features/shared/project/infrastructure/project-mapper.ts similarity index 89% rename from src/features/administration/features/project/infrastructure/project-mapper.ts rename to src/features/shared/project/infrastructure/project-mapper.ts index aad3629d3..be2c09483 100644 --- a/src/features/administration/features/project/infrastructure/project-mapper.ts +++ b/src/features/shared/project/infrastructure/project-mapper.ts @@ -1,6 +1,6 @@ -import { parseISO } from '../../../../../shared/utils/chrono' import { ProjectDto } from '../domain/project-dto' import { Project } from '../domain/project' +import { parseISO } from '../../../../shared/utils/chrono' export class ProjectMapper { static toDomain(projectDto: ProjectDto): Project { diff --git a/src/features/shared/user/application/get-users-list-qry.ts b/src/features/shared/user/application/get-users-list-qry.ts index e4f93843c..fd5354f5c 100644 --- a/src/features/shared/user/application/get-users-list-qry.ts +++ b/src/features/shared/user/application/get-users-list-qry.ts @@ -3,21 +3,16 @@ import { USER_REPOSITORY } from '../../../../shared/di/container-tokens' import { inject, singleton } from 'tsyringe' import type { UserRepository } from '../domain/user-repository' import { UserInfo } from '../domain/user-info' -import { Id } from '../../../../shared/types/id' - -interface GetUsersParams { - ids?: Id[] - active?: boolean -} +import { UserFilters } from '../domain/user-filters' @UseCaseKey('GetUsersListQry') @singleton() -export class GetUsersListQry extends Query { +export class GetUsersListQry extends Query { constructor(@inject(USER_REPOSITORY) private userRepository: UserRepository) { super() } - internalExecute(params: GetUsersParams): Promise { - return this.userRepository.getUsers(params?.ids, params?.active) + internalExecute(filters: UserFilters): Promise { + return this.userRepository.getUsers(filters) } } diff --git a/src/features/shared/user/domain/user-filters.ts b/src/features/shared/user/domain/user-filters.ts new file mode 100644 index 000000000..6e7092db0 --- /dev/null +++ b/src/features/shared/user/domain/user-filters.ts @@ -0,0 +1,8 @@ +import { Id } from '../../../../shared/types/id' + +export interface UserFilters { + ids?: Id[] + active?: boolean + nameLike?: string + limit?: number +} diff --git a/src/features/shared/user/domain/user-repository.ts b/src/features/shared/user/domain/user-repository.ts index 07ebe79fb..fcd4fb7d6 100644 --- a/src/features/shared/user/domain/user-repository.ts +++ b/src/features/shared/user/domain/user-repository.ts @@ -1,9 +1,9 @@ import { User } from './user' import { UserInfo } from './user-info' -import { Id } from '../../../../shared/types/id' +import { UserFilters } from './user-filters' export interface UserRepository { getUser(): Promise - getUsers(ids?: Id[], active?: boolean): Promise + getUsers(filters?: UserFilters): Promise } diff --git a/src/features/shared/user/infrastructure/http-user-repository.test.ts b/src/features/shared/user/infrastructure/http-user-repository.test.ts index 6cf00f4f2..1a6c9e468 100644 --- a/src/features/shared/user/infrastructure/http-user-repository.test.ts +++ b/src/features/shared/user/infrastructure/http-user-repository.test.ts @@ -44,7 +44,10 @@ describe('UserRepository', () => { httpClient.get.mockResolvedValue(UserMother.userList()) - const result = await userRepository.getUsers([1, 2], true) + const result = await userRepository.getUsers({ + ids: [1, 2], + active: true + }) expect(httpClient.get).toHaveBeenCalledWith('/api/user', { params: { diff --git a/src/features/shared/user/infrastructure/http-user-repository.ts b/src/features/shared/user/infrastructure/http-user-repository.ts index 065dcf4f6..389017ee3 100644 --- a/src/features/shared/user/infrastructure/http-user-repository.ts +++ b/src/features/shared/user/infrastructure/http-user-repository.ts @@ -4,7 +4,7 @@ import { AnonymousUserError } from '../domain/anonymous-user-error' import { UserRepository } from '../domain/user-repository' import { User } from '../domain/user' import { UserInfo } from '../domain/user-info' -import { Id } from '../../../../shared/types/id' +import { UserFilters } from '../domain/user-filters' @singleton() export class HttpUserRepository implements UserRepository { @@ -25,10 +25,10 @@ export class HttpUserRepository implements UserRepository { } } - async getUsers(ids?: Id[], active?: boolean): Promise { + async getUsers(filters?: UserFilters): Promise { try { return await this.httpClient.get(HttpUserRepository.usersPath, { - params: { ids, active } + params: filters }) } catch (error) { if (error.response?.status === 404 || error.response?.status === 401) { diff --git a/src/shared/archimedes/archimedes.ts b/src/shared/archimedes/archimedes.ts index 88d29df90..0917d2c54 100644 --- a/src/shared/archimedes/archimedes.ts +++ b/src/shared/archimedes/archimedes.ts @@ -34,10 +34,11 @@ import { SaveUserSettingsCmd } from '../../features/shared/user/features/setting import { TOAST } from '../di/container-tokens' import { container } from 'tsyringe' import { BlockProjectCmd } from '../../features/administration/features/project/application/block-project-cmd' -import { GetProjectsListQry } from '../../features/administration/features/project/application/get-projects-list-qry' +import { GetProjectsWithBlockerUserName } from '../../features/administration/features/project/application/get-projects-with-blocker-user-name' import { UnblockProjectCmd } from '../../features/administration/features/project/application/unblock-project-cmd' import { ToastNotificationLink } from './links/toast-notification-link' import { ToastType } from '../notification/toast' +import { GetAbsencesQry } from '../../features/binnacle/features/availability/application/get-absences-qry' const toast = container.resolve(TOAST) Archimedes.createChain([ @@ -103,5 +104,14 @@ CacheInvalidations.set(UpdateVacationCmd.prototype.key, [ GetCalendarDataQry.prototype.key, GetActivitySummaryQry.prototype.key ]) -CacheInvalidations.set(BlockProjectCmd.prototype.key, [GetProjectsListQry.prototype.key]) -CacheInvalidations.set(UnblockProjectCmd.prototype.key, [GetProjectsListQry.prototype.key]) + +//Block +CacheInvalidations.set(BlockProjectCmd.prototype.key, [ + GetProjectsWithBlockerUserName.prototype.key +]) +CacheInvalidations.set(UnblockProjectCmd.prototype.key, [ + GetProjectsWithBlockerUserName.prototype.key +]) + +//Absence +CacheInvalidations.set(GetAbsencesQry.prototype.key, [InvalidationPolicy.NO_CACHE]) diff --git a/src/shared/components/form-fields/combo-field.tsx b/src/shared/components/form-fields/combo-field.tsx index 21812e63e..b183388fe 100644 --- a/src/shared/components/form-fields/combo-field.tsx +++ b/src/shared/components/form-fields/combo-field.tsx @@ -12,6 +12,7 @@ interface Props extends InputProps { items: any[] isLoading: boolean onChange?: (value: any) => void + onInputChange?: (value: any) => void isDisabled: boolean } @@ -47,6 +48,7 @@ export const ComboField = forwardRef( { activePath(paths.binnacle) || activePath(paths.calendar) || activePath(paths.activities) || + activePath(paths.availability) || (activePath(paths.pendingActivities) && !isMobile) } > @@ -112,6 +113,17 @@ export const NavMenu: FC = () => { {t('pages.pending_activities')} )} + } + isActive={activePath(paths.availability)} + isChild={true} + px={2} + py={3} + > + {t('pages.availability')} + diff --git a/src/shared/di/container-tokens.ts b/src/shared/di/container-tokens.ts index 63df0cae1..c58bcfe70 100644 --- a/src/shared/di/container-tokens.ts +++ b/src/shared/di/container-tokens.ts @@ -11,4 +11,4 @@ export const VERSION_REPOSITORY = Symbol('VERSION_REPOSITORY') export const ORGANIZATION_REPOSITORY = Symbol('ORGANIZATION_REPOSITORY') export const PROJECT_REPOSITORY = Symbol('PROJECT_REPOSITORY') export const PROJECT_ROLE_REPOSITORY = Symbol('PROJECT_ROLE_REPOSITORY') -export const ADMINISTRATION_PROJECT_REPOSITORY = Symbol('ADMINISTRATION_PROJECT_REPOSITORY') +export const ABSENCE_REPOSITORY = Symbol('ABSENCE_REPOSITORY') diff --git a/src/shared/di/container.ts b/src/shared/di/container.ts index 46fd26f4a..551987b66 100644 --- a/src/shared/di/container.ts +++ b/src/shared/di/container.ts @@ -1,10 +1,8 @@ -import { HttpProjectRepository as HttpAdministrationProjectRepository } from '../../features/administration/features/project/infrastructure/http-project-repository' import { HttpAuthRepository } from '../../features/auth/infrastructure/http-auth-repository' import { HttpActivityRepository } from '../../features/binnacle/features/activity/infrastructure/http-activity-repository' import { HttpHolidayRepository } from '../../features/binnacle/features/holiday/infrastructure/http-holiday-repository' import { HttpOrganizationRepository } from '../../features/binnacle/features/organization/infrastructure/http-organization-repository' import { HttpProjectRoleRepository } from '../../features/binnacle/features/project-role/infrastructure/http-project-role-repository' -import { HttpProjectRepository } from '../../features/binnacle/features/project/infrastructure/http-project-repository' import { HttpSearchRepository } from '../../features/binnacle/features/search/infrastructure/http-search-repository' import { HttpVacationRepository } from '../../features/binnacle/features/vacation/infrastructure/http-vacation-repository' import { LocalStorageUserSettingsRepository } from '../../features/shared/user/features/settings/infrastructure/local-storage-user-settings-repository' @@ -12,8 +10,8 @@ import { HttpUserRepository } from '../../features/shared/user/infrastructure/ht import { HttpVersionRepository } from '../../features/version/infrastructure/http-version-repository' import { container } from 'tsyringe' import { + ABSENCE_REPOSITORY, ACTIVITY_REPOSITORY, - ADMINISTRATION_PROJECT_REPOSITORY, AUTH_REPOSITORY, HOLIDAY_REPOSITORY, ORGANIZATION_REPOSITORY, @@ -28,6 +26,8 @@ import { VERSION_REPOSITORY } from './container-tokens' import { toast, ToastType } from '../notification/toast' +import { HttpProjectRepository } from '../../features/shared/project/infrastructure/http-project-repository' +import { HttpAbsenceRepository } from '../../features/binnacle/features/availability/infrastructure/http-absence-repository' container.register(STORAGE, { useValue: localStorage }) container.register(TOAST, { useValue: toast }) @@ -42,4 +42,4 @@ container.registerSingleton(PROJECT_ROLE_REPOSITORY, HttpProjectRoleRepository) container.registerSingleton(PROJECT_REPOSITORY, HttpProjectRepository) container.registerSingleton(ORGANIZATION_REPOSITORY, HttpOrganizationRepository) container.registerSingleton(ACTIVITY_REPOSITORY, HttpActivityRepository) -container.registerSingleton(ADMINISTRATION_PROJECT_REPOSITORY, HttpAdministrationProjectRepository) +container.registerSingleton(ABSENCE_REPOSITORY, HttpAbsenceRepository) diff --git a/src/shared/i18n/en.json b/src/shared/i18n/en.json index c6f11c7e5..a16e1ee81 100644 --- a/src/shared/i18n/en.json +++ b/src/shared/i18n/en.json @@ -7,7 +7,8 @@ "vacations": "Vacations", "projects": "Projects", "administration": "Administration", - "activities": "Activities" + "activities": "Activities", + "availability": "Availability" }, "navbar": { "logo": "Logo", @@ -81,6 +82,9 @@ "projects_filter": { "status": "Status" }, + "users_filter": { + "user": "User" + }, "activity": { "filter": "Filter", "create": "Create", @@ -161,6 +165,13 @@ "open": "Open", "closed": "Closed" }, + "absences": { + "vacation": "Vacation", + "paidLeave": "Paid leave", + "requiredFilters": "It is necessary to filter by organization or user to obtain availability.", + "emptyMessage": "No results have been obtained within the selected period with the selected filters.", + "absenceItemDescription": "Absence of type {{type}} for user {{name}} from {{startDate}} to {{endDate}}" + }, "actions": { "approve": "Approve", "save": "Save", diff --git a/src/shared/i18n/es.json b/src/shared/i18n/es.json index a8f5950be..d4c6a3122 100644 --- a/src/shared/i18n/es.json +++ b/src/shared/i18n/es.json @@ -7,7 +7,8 @@ "settings": "Preferencias", "vacations": "Vacaciones", "projects": "Proyectos", - "administration": "Administración" + "administration": "Administración", + "availability": "Disponibilidad" }, "navbar": { "logo": "Logo", @@ -81,6 +82,9 @@ "projects_filter": { "status": "Estado" }, + "users_filter": { + "user": "Usuario" + }, "activity": { "filter": "Filtrar", "create": "Crear", @@ -161,6 +165,13 @@ "open": "Abierto", "closed": "Cerrado" }, + "absences": { + "vacation": "Vacaciones", + "paidLeave": "Permiso", + "requiredFilters": "Es necesario filtrar por organización o usuario para obtener la disponibilidad.", + "emptyMessage": "No se han obtenido resultados dentro del periodo seleccionado con los filtros seleccionados.", + "absenceItemDescription": "Ausencia del tipo {{type}} para el usuario {{name}} desde {{startDate}} hasta {{endDate}}" + }, "actions": { "approve": "Aprobar", "save": "Guardar", diff --git a/src/shared/router/paths.ts b/src/shared/router/paths.ts index 5ad00b7ed..ba0fad0a9 100644 --- a/src/shared/router/paths.ts +++ b/src/shared/router/paths.ts @@ -9,7 +9,8 @@ export const rawPaths = { settings: '/settings', pendingActivities: '/binnacle/pending-activities', projects: '/administration/projects', - activities: '/binnacle/activities' + activities: '/binnacle/activities', + availability: '/binnacle/availability' } export const paths = { @@ -21,5 +22,6 @@ export const paths = { settings: `${basename}${rawPaths.settings}`, pendingActivities: `${basename}${rawPaths.pendingActivities}`, projects: `${basename}${rawPaths.projects}`, - activities: `${basename}${rawPaths.activities}` + activities: `${basename}${rawPaths.activities}`, + availability: `${basename}${rawPaths.availability}` } diff --git a/src/shared/utils/chrono.ts b/src/shared/utils/chrono.ts index 3d1378d49..6cf72ecbb 100644 --- a/src/shared/utils/chrono.ts +++ b/src/shared/utils/chrono.ts @@ -1,4 +1,5 @@ import * as fns from 'date-fns' +import { isWithinInterval } from 'date-fns' import { es } from 'date-fns/locale' import { i18n } from '../i18n/i18n' import { TimeUnit, TimeUnits } from '../types/time-unit' @@ -237,6 +238,10 @@ class Chrono { }) } + isDateWithinInterval = (interval: Interval) => { + return isWithinInterval(this.date, interval) + } + diff = (date: Date, unit: UnitType) => { switch (unit) { case 'day': @@ -398,6 +403,10 @@ export const isSunday = (date: Date) => { return fns.isSunday(date) } +export const isWeekend = (date: Date) => { + return fns.isWeekend(date) +} + export const isFirstDayOfMonth = (date: Date) => { return fns.isFirstDayOfMonth(date) } diff --git a/src/styles/misc.css b/src/styles/misc.css index 2ff491e08..ec8cc5f47 100644 --- a/src/styles/misc.css +++ b/src/styles/misc.css @@ -3,6 +3,8 @@ --body-bg-color: var(--chakra-colors-gray-700); --private-holiday-color: var(--chakra-colors-blue-400); --public-holiday-color: var(--chakra-colors-yellow-400); + --table-border-color: var(--chakra-colors-gray-700); + --bg-color: #1a202c; } .chakra-ui-light { @@ -10,6 +12,8 @@ --body-bg-color: white; --private-holiday-color: var(--chakra-colors-blue-400); --public-holiday-color: var(--chakra-colors-yellow-400); + --table-border-color: var(--chakra-colors-gray-300); + --bg-color: white; } /* diff --git a/src/test-utils/di/integration-di.ts b/src/test-utils/di/integration-di.ts index 9b6abe8ca..5ddbb365d 100644 --- a/src/test-utils/di/integration-di.ts +++ b/src/test-utils/di/integration-di.ts @@ -1,8 +1,8 @@ import 'reflect-metadata' import { container } from 'tsyringe' import { + ABSENCE_REPOSITORY, ACTIVITY_REPOSITORY, - ADMINISTRATION_PROJECT_REPOSITORY, AUTH_REPOSITORY, HOLIDAY_REPOSITORY, ORGANIZATION_REPOSITORY, @@ -23,12 +23,12 @@ import { FakeVacationRepository } from '../../features/binnacle/features/vacatio import { FakeHolidayRepository } from '../../features/binnacle/features/holiday/infrastructure/fake-holiday-repository' import { FakeSearchRepository } from '../../features/binnacle/features/search/infrastructure/fake-search-repository' import { FakeProjectRoleRepository } from '../../features/binnacle/features/project-role/infrastructure/fake-project-role-repository' -import { FakeProjectRepository } from '../../features/binnacle/features/project/infrastructure/fake-project-repository' import { FakeOrganizationRepository } from '../../features/binnacle/features/organization/infrastructure/fake-organization-repository' import { FakeActivityRepository } from '../../features/binnacle/features/activity/infrastructure/fake-activity-repository' -import { FakeProjectRepository as FakeProjectRepositoryAdministration } from '../../features/administration/features/project/infrastructure/fake-project-repository' import { toast, ToastType } from '../../shared/notification/toast' import { FakeUserSettingsRepository } from '../../features/shared/user/features/settings/infrastructure/fake-user-settings-repository' +import { FakeProjectRepository } from '../../features/shared/project/infrastructure/fake-project-repository' +import { FakeAbsenceRepository } from '../../features/binnacle/features/availability/infrastructure/fake-absence-repository' container.register(STORAGE, { useValue: localStorage }) container.register(TOAST, { useValue: toast }) @@ -43,4 +43,4 @@ container.registerSingleton(PROJECT_ROLE_REPOSITORY, FakeProjectRoleRepository) container.registerSingleton(PROJECT_REPOSITORY, FakeProjectRepository) container.registerSingleton(ORGANIZATION_REPOSITORY, FakeOrganizationRepository) container.registerSingleton(ACTIVITY_REPOSITORY, FakeActivityRepository) -container.registerSingleton(ADMINISTRATION_PROJECT_REPOSITORY, FakeProjectRepositoryAdministration) +container.registerSingleton(ABSENCE_REPOSITORY, FakeAbsenceRepository) diff --git a/src/test-utils/di/unit-di.ts b/src/test-utils/di/unit-di.ts index 18d4db1e7..9de299be2 100644 --- a/src/test-utils/di/unit-di.ts +++ b/src/test-utils/di/unit-di.ts @@ -1,5 +1,4 @@ import 'reflect-metadata' -import { ProjectRepository } from '../../features/administration/features/project/domain/project-repository' import { AuthRepository } from '../../features/auth/domain/auth-repository' import { ActivityRepository } from '../../features/binnacle/features/activity/domain/activity-repository' import { OrganizationRepository } from '../../features/binnacle/features/organization/domain/organization-repository' @@ -9,19 +8,20 @@ import { mock } from 'jest-mock-extended' import { container } from 'tsyringe' import { ACTIVITY_REPOSITORY, - ADMINISTRATION_PROJECT_REPOSITORY, AUTH_REPOSITORY, ORGANIZATION_REPOSITORY, + PROJECT_REPOSITORY, TOAST, USER_REPOSITORY, USER_SETTINGS_REPOSITORY } from '../../shared/di/container-tokens' import { toast, ToastType } from '../../shared/notification/toast' +import { ProjectRepository } from '../../features/shared/project/domain/project-repository' container.register(TOAST, { useValue: toast }) container.register(USER_SETTINGS_REPOSITORY, { useValue: mock() }) container.register(AUTH_REPOSITORY, { useValue: mock() }) container.register(USER_REPOSITORY, { useValue: mock() }) container.register(ORGANIZATION_REPOSITORY, { useValue: mock() }) -container.register(ADMINISTRATION_PROJECT_REPOSITORY, { useValue: mock() }) +container.register(PROJECT_REPOSITORY, { useValue: mock() }) container.register(ACTIVITY_REPOSITORY, { useValue: mock() }) diff --git a/src/test-utils/mothers/absence-mother.ts b/src/test-utils/mothers/absence-mother.ts new file mode 100644 index 000000000..0e0d13d14 --- /dev/null +++ b/src/test-utils/mothers/absence-mother.ts @@ -0,0 +1,65 @@ +import { Absence } from '../../features/binnacle/features/availability/domain/absence' +import { parseISO } from '../../shared/utils/chrono' +import { UserAbsence } from '../../features/binnacle/features/availability/domain/user-absence' + +export class AbsenceMother { + static userAbsences(): UserAbsence[] { + return [ + this.vacationUser(), + this.vacationUser({ userId: 3, userName: 'Available user', absences: [] }), + this.paidLeaveUser(), + this.paidLeaveUser({ + absences: [ + this.paidLeaveAbsence({ + startDate: parseISO('2023-09-02'), + endDate: parseISO('2023-09-10') + }) + ] + }), + this.paidLeaveUser({ + absences: [ + this.paidLeaveAbsence({ + startDate: parseISO('2023-09-25'), + endDate: parseISO('2023-11-10') + }) + ] + }) + ] + } + + static paidLeaveUser(override?: Partial): UserAbsence { + return { + userId: 1, + userName: 'Paid leave user', + absences: [this.paidLeaveAbsence()], + ...override + } + } + + static vacationUser(override?: Partial): UserAbsence { + return { + userId: 1, + userName: 'Vacation user', + absences: [this.vacationAbsence()], + ...override + } + } + + static paidLeaveAbsence(override?: Partial): Absence { + return { + type: 'PAID_LEAVE', + startDate: parseISO('2023-09-01'), + endDate: parseISO('2023-09-01'), + ...override + } + } + + static vacationAbsence(override?: Partial): Absence { + return { + type: 'VACATION', + startDate: parseISO('2023-09-03'), + endDate: parseISO('2023-09-05'), + ...override + } + } +} diff --git a/src/test-utils/mothers/activity-mother.ts b/src/test-utils/mothers/activity-mother.ts index cabce1bd0..8d3044ba6 100644 --- a/src/test-utils/mothers/activity-mother.ts +++ b/src/test-utils/mothers/activity-mother.ts @@ -13,7 +13,7 @@ import { } from '../../features/binnacle/features/activity/domain/year-balance' import { Serialized } from '../../shared/types/serialized' import { OrganizationMother } from './organization-mother' -import { ProjectMother } from './project-mother' +import { LiteProjectMother } from './lite-project-mother' import { ProjectRoleMother } from './project-role-mother' export class ActivityMother { @@ -91,7 +91,7 @@ export class ActivityMother { billable: true, hasEvidences: false, organization: OrganizationMother.organization(), - project: ProjectMother.billableLiteProjectWithOrganizationId(), + project: LiteProjectMother.billableLiteProjectWithOrganizationId(), projectRole: ProjectRoleMother.liteProjectRoleInMinutes(), approval: { canBeApproved: true, @@ -178,7 +178,7 @@ export class ActivityMother { billable: false, hasEvidences: true, organization: OrganizationMother.organization(), - project: ProjectMother.billableLiteProjectWithOrganizationId(), + project: LiteProjectMother.billableLiteProjectWithOrganizationId(), projectRole: ProjectRoleMother.liteProjectRoleInDaysRequireApproval(), approval: { canBeApproved: true, @@ -204,7 +204,7 @@ export class ActivityMother { billable: false, hasEvidences: false, organization: OrganizationMother.organization(), - project: ProjectMother.billableLiteProjectWithOrganizationId(), + project: LiteProjectMother.billableLiteProjectWithOrganizationId(), projectRole: ProjectRoleMother.liteProjectRoleInDaysRequireApproval(), approval: { canBeApproved: false, diff --git a/src/test-utils/mothers/lite-project-mother.ts b/src/test-utils/mothers/lite-project-mother.ts new file mode 100644 index 000000000..eed873f46 --- /dev/null +++ b/src/test-utils/mothers/lite-project-mother.ts @@ -0,0 +1,60 @@ +import { LiteProject } from '../../features/binnacle/features/project/domain/lite-project' +import { LiteProjectWithOrganizationId } from '../../features/binnacle/features/search/domain/lite-project-with-organization-id' +import { OrganizationMother } from './organization-mother' +import { Project } from '../../features/shared/project/domain/project' +import { ProjectMother } from './project-mother' + +export class LiteProjectMother { + static projects(): Project[] { + return [ProjectMother.notBillableProject(), ProjectMother.billableProject()] + } + + static liteProjectsWithOrganizationId(): LiteProjectWithOrganizationId[] { + return [ + this.notBillableLiteProjectWithOrganizationId(), + this.billableLiteProjectWithOrganizationId() + ] + } + + static notBillableLiteProjectWithOrganizationId(): LiteProjectWithOrganizationId { + const { id, name } = ProjectMother.notBillableProject() + + return { + id, + name, + billable: false, + organizationId: OrganizationMother.organization().id + } + } + + static notBillableLiteProject(): LiteProject { + const { id, name, billable } = ProjectMother.notBillableProject() + + return { + id, + name, + billable + } + } + + static billableLiteProject(): LiteProject { + const { id, name, billable } = ProjectMother.billableProject() + + return { + id, + name, + billable + } + } + + static billableLiteProjectWithOrganizationId(): LiteProjectWithOrganizationId { + const { id, name } = ProjectMother.billableProject() + + return { + billable: false, + id, + name, + organizationId: OrganizationMother.organization().id + } + } +} diff --git a/src/test-utils/mothers/project-mother.ts b/src/test-utils/mothers/project-mother.ts index 599697fd1..e415020b2 100644 --- a/src/test-utils/mothers/project-mother.ts +++ b/src/test-utils/mothers/project-mother.ts @@ -1,18 +1,19 @@ -import { LiteProject } from '../../features/binnacle/features/project/domain/lite-project' -import { Project } from '../../features/binnacle/features/project/domain/project' -import { LiteProjectWithOrganizationId } from '../../features/binnacle/features/search/domain/lite-project-with-organization-id' -import { OrganizationMother } from './organization-mother' +import { Project } from '../../features/shared/project/domain/project' +import { parseISO } from '../../shared/utils/chrono' +import { ProjectDto } from '../../features/shared/project/domain/project-dto' export class ProjectMother { - static projects(): Project[] { - return [this.notBillableProject(), this.billableProject()] - } - - static liteProjectsWithOrganizationId(): LiteProjectWithOrganizationId[] { - return [ - this.notBillableLiteProjectWithOrganizationId(), - this.billableLiteProjectWithOrganizationId() - ] + static billableProject(): Project { + return { + id: 2, + name: 'Billable project', + billable: true, + open: true, + startDate: parseISO('2023-01-01'), + blockDate: parseISO('2023-06-01'), + blockedByUser: null, + organizationId: 1 + } } static notBillableProject(): Project { @@ -20,58 +21,118 @@ export class ProjectMother { id: 1, name: 'No billable project', billable: false, - open: true - } - } - - static notBillableLiteProjectWithOrganizationId(): LiteProjectWithOrganizationId { - const { id, name } = this.notBillableProject() - - return { - id, - name, - billable: false, - organizationId: OrganizationMother.organization().id - } - } - - static notBillableLiteProject(): LiteProject { - const { id, name, billable } = this.notBillableProject() - - return { - id, - name, - billable + open: true, + startDate: parseISO('2023-01-01'), + blockDate: parseISO('2023-06-01'), + blockedByUser: null, + organizationId: 1 } } - static billableProject(): Project { - return { - id: 2, - name: 'Billable project', - billable: true, - open: true - } + static projectsFilteredByOrganization(): ProjectDto[] { + return [ + { + id: 1, + name: 'Proyecto A', + open: true, + billable: true, + startDate: '2023-01-01', + blockDate: '2023-06-01', + blockedByUser: 2, + organizationId: 1 + }, + { + id: 2, + name: 'Proyecto B', + open: true, + billable: true, + startDate: '2023-03-01', + blockDate: null, + blockedByUser: 1, + organizationId: 1 + }, + { + id: 3, + name: 'Proyecto C', + open: false, + billable: false, + startDate: '2023-03-01', + blockDate: null, + blockedByUser: null, + organizationId: 1 + } + ] } - static billableLiteProject(): LiteProject { - const { id, name, billable } = this.billableProject() - - return { - id, - name, - billable - } + static projectsFilteredByOrganizationDateIso(): Project[] { + return [ + { + id: 1, + name: 'Proyecto A', + open: true, + billable: true, + startDate: parseISO('2023-01-01'), + blockDate: parseISO('2023-06-01'), + blockedByUser: 2, + organizationId: 1 + }, + { + id: 2, + name: 'Proyecto B', + open: true, + billable: true, + startDate: parseISO('2023-03-01'), + blockDate: null, + blockedByUser: 1, + organizationId: 1 + }, + { + id: 3, + name: 'Proyecto C', + open: false, + billable: false, + startDate: parseISO('2023-03-01'), + blockDate: null, + blockedByUser: null, + organizationId: 1 + } + ] } - static billableLiteProjectWithOrganizationId(): LiteProjectWithOrganizationId { - const { id, name } = this.billableProject() - - return { - billable: false, - id, - name, - organizationId: OrganizationMother.organization().id - } + static projectsFilteredByOrganizationDateIsoWithName(): Project[] { + return [ + { + id: 1, + name: 'Proyecto A', + open: true, + billable: true, + startDate: parseISO('2023-01-01'), + blockDate: parseISO('2023-06-01'), + blockedByUser: 2, + organizationId: 1, + blockedByUserName: 'John Doe' + }, + { + id: 2, + name: 'Proyecto B', + open: true, + billable: true, + startDate: parseISO('2023-03-01'), + blockDate: null, + blockedByUser: 1, + organizationId: 1, + blockedByUserName: 'Lorem ipsum' + }, + { + id: 3, + name: 'Proyecto C', + open: false, + billable: false, + startDate: parseISO('2023-03-01'), + blockDate: null, + blockedByUser: null, + organizationId: 1 + } + ] } } diff --git a/src/test-utils/mothers/project-role-mother.ts b/src/test-utils/mothers/project-role-mother.ts index abe57169a..f05a5f182 100644 --- a/src/test-utils/mothers/project-role-mother.ts +++ b/src/test-utils/mothers/project-role-mother.ts @@ -3,6 +3,7 @@ import { ProjectRole } from '../../features/binnacle/features/project-role/domai import { LiteProjectRoleWithProjectId } from '../../features/binnacle/features/search/domain/lite-project-role-with-project-id' import { TimeUnits } from '../../shared/types/time-unit' import { OrganizationMother } from './organization-mother' +import { LiteProjectMother } from './lite-project-mother' import { ProjectMother } from './project-mother' export class ProjectRoleMother { @@ -35,7 +36,7 @@ export class ProjectRoleMother { id: 1, name: 'Project in minutes', organization: OrganizationMother.organization(), - project: ProjectMother.billableLiteProject(), + project: LiteProjectMother.billableLiteProject(), userId: 1, requireEvidence: 'NO', requireApproval: false, @@ -55,7 +56,7 @@ export class ProjectRoleMother { id: 2, name: 'Project in days', organization: OrganizationMother.organization(), - project: ProjectMother.notBillableLiteProject(), + project: LiteProjectMother.notBillableLiteProject(), userId: 1, requireEvidence: 'NO', requireApproval: false, @@ -75,7 +76,7 @@ export class ProjectRoleMother { id: 3, name: 'Project in days 2', organization: OrganizationMother.organization(), - project: ProjectMother.notBillableLiteProject(), + project: LiteProjectMother.notBillableLiteProject(), userId: 1, requireEvidence: 'NO', requireApproval: true, diff --git a/src/test-utils/mothers/search-mother.ts b/src/test-utils/mothers/search-mother.ts index 5d47e8dda..14fdc2aea 100644 --- a/src/test-utils/mothers/search-mother.ts +++ b/src/test-utils/mothers/search-mother.ts @@ -1,13 +1,13 @@ import { SearchProjectRolesResult } from '../../features/binnacle/features/search/domain/search-project-roles-result' import { OrganizationMother } from './organization-mother' -import { ProjectMother } from './project-mother' +import { LiteProjectMother } from './lite-project-mother' import { ProjectRoleMother } from './project-role-mother' export class SearchMother { static roles(): SearchProjectRolesResult { return { organizations: OrganizationMother.organizations(), - projects: ProjectMother.liteProjectsWithOrganizationId(), + projects: LiteProjectMother.liteProjectsWithOrganizationId(), projectRoles: ProjectRoleMother.liteProjectRoles() } } @@ -15,7 +15,7 @@ export class SearchMother { static customRoles(override?: Partial): SearchProjectRolesResult { return { organizations: OrganizationMother.organizations(), - projects: ProjectMother.liteProjectsWithOrganizationId(), + projects: LiteProjectMother.liteProjectsWithOrganizationId(), projectRoles: ProjectRoleMother.liteProjectRoles(), ...override }