From 6223babeee44862dbb4a46708ab9a04c3687de6c Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 4 Jan 2025 02:39:43 +0000 Subject: [PATCH 01/15] chore(deps): bump next from 14.2.17 to 14.2.21 in /apps/web Bumps [next](https://github.com/vercel/next.js) from 14.2.17 to 14.2.21. - [Release notes](https://github.com/vercel/next.js/releases) - [Changelog](https://github.com/vercel/next.js/blob/canary/release.js) - [Commits](https://github.com/vercel/next.js/compare/v14.2.17...v14.2.21) --- updated-dependencies: - dependency-name: next dependency-type: direct:production ... Signed-off-by: dependabot[bot] --- apps/web/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/package.json b/apps/web/package.json index 41d0cd574..fc4168a85 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -102,7 +102,7 @@ "moment": "^2.29.4", "moment-timezone": "^0.5.42", "nanoid": "5.0.9", - "next": "14.2.17", + "next": "14.2.21", "next-auth": "^5.0.0-beta.18", "next-intl": "^3.3.2", "next-themes": "^0.2.1", From 2adfad51f2a3d85dd7f3145ec7c337a95471cb8d Mon Sep 17 00:00:00 2001 From: Ruslan Konviser Date: Sat, 4 Jan 2025 16:07:22 +0100 Subject: [PATCH 02/15] docs: adding Docker & Docker Compose (#3496) * docs: adding Docker & Docker Compose * chore: add docker-compose --- .env.compose | 6 + .env.demo.compose | 6 + .env.docker | 6 + README.md | 50 +++++++-- docker-compose.build.yml | 28 +++++ docker-compose.demo.yml | 20 ++++ docker-compose.infra.yml | 233 +++++++++++++++++++++++++++++++++++++++ docker-compose.yml | 117 ++++++++++++++++++++ 8 files changed, 458 insertions(+), 8 deletions(-) create mode 100644 .env.compose create mode 100644 .env.demo.compose create mode 100644 .env.docker create mode 100644 docker-compose.build.yml create mode 100644 docker-compose.demo.yml create mode 100644 docker-compose.infra.yml create mode 100644 docker-compose.yml diff --git a/.env.compose b/.env.compose new file mode 100644 index 000000000..0f9f93b75 --- /dev/null +++ b/.env.compose @@ -0,0 +1,6 @@ +# Docker Compose sample .env file for Production + +NODE_ENV=production + +# set true if running inside Docker container +IS_DOCKER=true diff --git a/.env.demo.compose b/.env.demo.compose new file mode 100644 index 000000000..0f9f93b75 --- /dev/null +++ b/.env.demo.compose @@ -0,0 +1,6 @@ +# Docker Compose sample .env file for Production + +NODE_ENV=production + +# set true if running inside Docker container +IS_DOCKER=true diff --git a/.env.docker b/.env.docker new file mode 100644 index 000000000..0f9f93b75 --- /dev/null +++ b/.env.docker @@ -0,0 +1,6 @@ +# Docker Compose sample .env file for Production + +NODE_ENV=production + +# set true if running inside Docker container +IS_DOCKER=true diff --git a/README.md b/README.md index 5fdf6a7fc..4dcd098b9 100644 --- a/README.md +++ b/README.md @@ -92,16 +92,54 @@ Please refer to our official [Platform Documentation](https://docs.ever.team) (W -### Quick Start with our public live APIs +### Run with Docker Compose + +- Clone repo. +- Make sure you have the latest Docker Compose [installed locally](https://docs.docker.com/compose/install). Important: you need a minimum [v2.20](https://docs.docker.com/compose/release-notes/#2200). +- Run `docker-compose -f docker-compose.demo.yml up`, if you want to run the platform in basic configuration (e.g. for Demo / explore functionality / quick run) using our prebuilt Docker images. Check `.env.demo.compose` file for different settings (optionally). _(Note: Docker Compose will use latest images pre-build automatically from head of `master` branch using GitHub CI/CD.)_ +- Run `docker-compose up`, if you want to run the platform in production configuration using our prebuilt Docker images. Check `.env.compose` file for different settings (optionally). _(Note: Docker Compose will use latest images pre-build automatically from head of `master` branch using GitHub CI/CD.)_ +- Run `docker-compose -f docker-compose.build.yml up`, if you want to build everything (code and Docker images) locally. Check `.env.compose` file for different settings (optionally). _(Note: this can be long process because it builds whole platform locally. Other options above are much faster!)_ +- :coffee: time... It might take some time for the first Docker Compose run, even if you used prebuilt Docker images. +- Open in your browser, register a new account, and start using Ever Teams! +- Enjoy! + +_Notes:_ +- _You can execute `docker-compose` command with `-d` option to run it in the "detached" mode (allows containers to run in the background, separate from the terminal)._ +- _By default, Ever Teams web frontend will be connected to our production [Ever Gauzy API](https://github.com/ever-co/ever-gauzy) API endpoint . You can change it in environment variables `GAUZY_API_SERVER_URL` and `NEXT_PUBLIC_GAUZY_API_SERVER_URL`, see more in the section about how to run with a Self-hosted Backend._ + +### Run with Docker + +#### Build & Run + +Run with Public Images: +- You can pull our public docker image with `docker pull everco/ever-teams-webapp .` command. +- You can run docker image with the following command: `docker run -p 127.0.0.1:3030:3030/tcp everco/ever-teams-webapp`. +- Open in your browser, register a new account, and start using Ever Teams! + +_Note: To build such images on each release (push to our master branch), we are using relevant [Github Action](https://github.com/ever-co/ever-teams/blob/develop/.github/workflows/docker-build-publish-prod.yml)._ + +Build and Run Locally: +- If you want to build an image locally from our source code (after clone repo locally), please run the following command (from the root of mono-repo): `docker build . -t ever-teams-webapp -f Dockerfile`. +- To run the locally built image, please run the following command: `docker run -p 127.0.0.1:3030:3030/tcp ever-teams-webapp`. +- Open in your browser, register a new account, and start using Ever Teams! + +_Note: By default, Ever Teams web frontend will be connected to our production [Ever Gauzy API](https://github.com/ever-co/ever-gauzy) API endpoint . You can change it in environment variables `GAUZY_API_SERVER_URL` and `NEXT_PUBLIC_GAUZY_API_SERVER_URL`, see more in the section about how to run with a Self-hosted Backend._ + +#### Images + +We have Ever Teams Docker images published into: +- https://hub.docker.com/u/everco?page=1&search=ever-teams +- https://github.com/orgs/ever-co/packages?tab=packages&q=ever-teams + +### Quick Start to manually build & run locally 1. Clone this repo 2. Run `yarn install` 3. Run `yarn build:web && yarn start:web` OR `yarn start:web:dev` 4. Open in in your Browser -Notes: - -- by default, Ever Teams web frontend will be connected to our production [Ever Gauzy API](https://github.com/ever-co/ever-gauzy) API endpoint . You can change it in environment variables `GAUZY_API_SERVER_URL` and `NEXT_PUBLIC_GAUZY_API_SERVER_URL`, see below how to run with a Self-hosted Backend. +_Notes:_ +- _by default, Ever Teams web frontend will be connected to our production [Ever Gauzy API](https://github.com/ever-co/ever-gauzy) API endpoint . You can change it in environment variables `GAUZY_API_SERVER_URL` and `NEXT_PUBLIC_GAUZY_API_SERVER_URL`, see below how to run with a Self-hosted Backend._ ### Run with a Self-hosted Backend @@ -128,10 +166,6 @@ DevContainers for VSCode are supported (WIP). [Click here to get started.](https://vscode.dev/redirect?url=vscode://ms-vscode-remote.remote-containers/cloneInVolume?url=https://github.com/ever-co/ever-teams) -### Run in Docker & Docker Compose - -WIP - ## 🚗 Self Hosting ### DigitalOcean diff --git a/docker-compose.build.yml b/docker-compose.build.yml new file mode 100644 index 000000000..2fc03b31b --- /dev/null +++ b/docker-compose.build.yml @@ -0,0 +1,28 @@ +services: + webapp: + container_name: webapp + image: ever-teams-webapp:latest + build: + context: . + dockerfile: Dockerfile + args: + GAUZY_API_SERVER_URL: ${GAUZY_API_SERVER_URL:-https://api.ever.team} + NEXT_PUBLIC_GAUZY_API_SERVER_URL: ${NEXT_PUBLIC_GAUZY_API_SERVER_URL:-https://api.ever.team} + NODE_ENV: ${NODE_ENV:-development} + DEMO: 'true' + environment: + GAUZY_API_SERVER_URL: ${GAUZY_API_SERVER_URL:-https://api.ever.team} + NEXT_PUBLIC_GAUZY_API_SERVER_URL: ${NEXT_PUBLIC_GAUZY_API_SERVER_URL:-https://api.ever.team} + NODE_ENV: ${NODE_ENV:-development} + DEMO: 'true' + env_file: + - .env.compose + restart: on-failure + ports: + - '3030:${UI_PORT:-3030}' + networks: + - overlay + +networks: + overlay: + driver: bridge diff --git a/docker-compose.demo.yml b/docker-compose.demo.yml new file mode 100644 index 000000000..3832a11b5 --- /dev/null +++ b/docker-compose.demo.yml @@ -0,0 +1,20 @@ +services: + webapp: + container_name: webapp + image: ghcr.io/ever-co/ever-teams-webapp:latest + environment: + GAUZY_API_SERVER_URL: ${GAUZY_API_SERVER_URL:-https://api.ever.team} + NEXT_PUBLIC_GAUZY_API_SERVER_URL: ${NEXT_PUBLIC_GAUZY_API_SERVER_URL:-https://api.ever.team} + NODE_ENV: ${NODE_ENV:-development} + DEMO: 'true' + env_file: + - .env.demo.compose + restart: on-failure + ports: + - '3030:${UI_PORT:-3030}' + networks: + - overlay + +networks: + overlay: + driver: bridge diff --git a/docker-compose.infra.yml b/docker-compose.infra.yml new file mode 100644 index 000000000..0aae0b4ad --- /dev/null +++ b/docker-compose.infra.yml @@ -0,0 +1,233 @@ +services: + db: + image: postgres:15-alpine + container_name: db + restart: always + environment: + POSTGRES_DB: ${DB_NAME:-gauzy} + POSTGRES_USER: ${DB_USER:-postgres} + POSTGRES_PASSWORD: ${DB_PASS:-gauzy_password} + healthcheck: + test: + [ + 'CMD-SHELL', + 'psql postgres://$${POSTGRES_USER}:$${POSTGRES_PASSWORD}@localhost:5432/$${POSTGRES_DB} || exit 1' + ] + volumes: + - postgres_data:/var/lib/postgresql/data/ + - ./.deploy/db/init-user-db.sh:/docker-entrypoint-initdb.d/init-user-db.sh + ports: + - '5432:5432' + networks: + - overlay + + zipkin: + image: ghcr.io/openzipkin/zipkin-slim:latest + container_name: zipkin + # Environment settings are defined here https://github.com/openzipkin/zipkin/blob/master/zipkin-server/README.md#environment-variables + environment: + - STORAGE_TYPE=mem + # Uncomment to enable self-tracing + # - SELF_TRACING_ENABLED=true + # Uncomment to increase heap size + # - JAVA_OPTS=-Xms128m -Xmx128m -XX:+ExitOnOutOfMemoryError + ports: + # Port used for the Zipkin UI and HTTP Api + - 9411:9411 + networks: + - overlay + + cube: + image: cubejs/cube:latest + container_name: cube + ports: + - '4000:4000' # Cube Playground + - '5430:5430' # Port for Cube SQL + environment: + CUBEJS_DEV_MODE: 'true' + CUBEJS_DB_TYPE: postgres + CUBEJS_DB_HOST: db + CUBEJS_DB_PORT: 5432 + CUBEJS_DB_NAME: ${DB_NAME:-gauzy} + CUBEJS_DB_USER: ${DB_USER:-postgres} + CUBEJS_DB_PASS: ${DB_PASS:-gauzy_password} + # Credentials to connect to Cube SQL APIs + CUBEJS_PG_SQL_PORT: 5430 + CUBEJS_SQL_USER: ${CUBE_USER:-cube_user} + CUBEJS_SQL_PASSWORD: ${CUBE_PASS:-cube_pass} + volumes: + - 'cube_data:/cube/conf' + links: + - db + networks: + - overlay + + jitsu: + container_name: jitsu + image: jitsucom/jitsu:latest + extra_hosts: + - 'host.docker.internal:host-gateway' + environment: + - REDIS_URL=redis://redis:6379 + # Retroactive users recognition can affect RAM significant. + # Read more about the solution https://jitsu.com/docs/other-features/retroactive-user-recognition + - USER_RECOGNITION_ENABLED=true + - USER_RECOGNITION_REDIS_URL=redis://jitsu_redis_users_recognition:6380 + - TERM=xterm-256color + depends_on: + redis: + condition: service_healthy + jitsu_redis_users_recognition: + condition: service_healthy + volumes: + - ./.deploy/jitsu/configurator/data/logs:/home/configurator/data/logs + - ./.deploy/jitsu/server/data/logs:/home/eventnative/data/logs + - ./.deploy/jitsu/server/data/logs/events:/home/eventnative/data/logs/events + - /var/run/docker.sock:/var/run/docker.sock + - jitsu_workspace:/home/eventnative/data/airbyte + restart: always + ports: + - '8000:8000' + networks: + - overlay + + elasticsearch: + image: 'elasticsearch:7.17.7' + container_name: elasticsearch + volumes: + - elasticsearch_data:/usr/share/elasticsearch/data + environment: + ES_JAVA_OPTS: -Xms512m -Xmx1024m + discovery.type: single-node + http.port: 9200 + http.cors.enabled: 'true' + http.cors.allow-origin: http://localhost:3000,http://127.0.0.1:3000,http://localhost:1358,http://127.0.0.1:1358 + http.cors.allow-headers: X-Requested-With,X-Auth-Token,Content-Type,Content-Length,Authorization + http.cors.allow-credentials: 'true' + bootstrap.memory_lock: 'true' + xpack.security.enabled: 'false' + ports: + - '9200' + - '9300' + ulimits: + memlock: + soft: -1 + hard: -1 + healthcheck: + test: ['CMD', 'curl', '-f', 'http://localhost:9200/_cat/health'] + interval: 5s + timeout: 5s + retries: 10 + start_period: 20s + networks: + - overlay + + # Elasticsearch Management UI + dejavu: + image: appbaseio/dejavu:3.6.0 + container_name: dejavu + ports: + - '1358:1358' + links: + - elasticsearch + networks: + - overlay + + # TODO: For now used in Jitsu, but we will need to create another one dedicated for Jitsu later + redis: + image: 'redis:7.0.2-alpine' + container_name: redis + restart: unless-stopped + healthcheck: + test: ['CMD-SHELL', 'redis-cli -h localhost -p 6379 PING'] + interval: 1s + timeout: 30s + ports: + - '6379' + volumes: + - ./.deploy/redis/data:/data + networks: + - overlay + + jitsu_redis_users_recognition: + image: 'redis:7.0.2-alpine' + container_name: jitsu_redis_users_recognition + command: redis-server /usr/local/etc/redis/redis.conf + restart: unless-stopped + healthcheck: + test: ['CMD-SHELL', 'redis-cli -h localhost -p 6380 PING'] + interval: 1s + timeout: 30s + ports: + - '6380' + volumes: + - ./.deploy/redis/jitsu_users_recognition/data:/data + - ./.deploy/redis/jitsu_users_recognition/redis.conf:/usr/local/etc/redis/redis.conf + networks: + - overlay + + minio: + restart: unless-stopped + image: quay.io/minio/minio:latest + container_name: minio + volumes: + - minio_data:/data + environment: + MINIO_ROOT_USER: ever-gauzy-access-key + MINIO_ROOT_PASSWORD: ever-gauzy-secret-key + command: server /data --address :9000 --console-address ":9001" + ports: + - 9000:9000 + - 9001:9001 + networks: + - overlay + + minio_create_buckets: + image: minio/mc + environment: + MINIO_ROOT_USER: ever-gauzy-access-key + MINIO_ROOT_PASSWORD: ever-gauzy-secret-key + entrypoint: + - '/bin/sh' + - '-c' + command: + - "until (/usr/bin/mc alias set minio http://minio:9000 $$MINIO_ROOT_USER $$MINIO_ROOT_PASSWORD) do + echo 'Waiting to start minio...' && sleep 1; + done; + /usr/bin/mc mb minio/ever-gauzy --region=eu-north-1; + exit 0;" + depends_on: + - minio + networks: + - overlay + + pgweb: + image: sosedoff/pgweb + container_name: pgweb + restart: always + depends_on: + - db + links: + - db:${DB_HOST:-db} + environment: + POSTGRES_DB: ${DB_NAME:-gauzy} + POSTGRES_USER: ${DB_USER:-postgres} + POSTGRES_PASSWORD: ${DB_PASS:-gauzy_password} + PGWEB_DATABASE_URL: postgres://${DB_USER:-postgres}:${DB_PASS:-gauzy_password}@${DB_HOST:-db}:${DB_PORT:-5432}/${DB_NAME:-gauzy}?sslmode=disable + ports: + - '8081:8081' + networks: + - overlay + +volumes: + postgres_data: {} + redis_data: {} + elasticsearch_data: {} + minio_data: {} + cube_data: {} + certificates: {} + jitsu_workspace: {} + +networks: + overlay: + driver: bridge diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 000000000..3ce242d32 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,117 @@ +include: + - ./docker-compose.infra.yml + +services: + api: + container_name: api + image: ghcr.io/ever-co/gauzy-api:latest + environment: + API_HOST: ${API_HOST:-api} + API_PORT: ${API_PORT:-3000} + NODE_ENV: ${NODE_ENV:-development} + DB_HOST: db + API_BASE_URL: ${API_BASE_URL:-http://localhost:3000} + CLIENT_BASE_URL: ${CLIENT_BASE_URL:-http://localhost:4200} + CLOUD_PROVIDER: ${CLOUD_PROVIDER:-} + SENTRY_DSN: ${SENTRY_DSN:-} + SENTRY_HTTP_TRACING_ENABLED: ${SENTRY_HTTP_TRACING_ENABLED:-} + SENTRY_POSTGRES_TRACKING_ENABLED: ${SENTRY_POSTGRES_TRACKING_ENABLED:-} + SENTRY_PROFILING_ENABLED: ${SENTRY_PROFILING_ENABLED:-} + JITSU_SERVER_URL: ${JITSU_SERVER_URL:-} + OTEL_EXPORTER_OTLP_TRACES_ENDPOINT: ${OTEL_EXPORTER_OTLP_TRACES_ENDPOINT:-} + OTEL_EXPORTER_OTLP_HEADERS: ${OTEL_EXPORTER_OTLP_HEADERS:-} + OTEL_ENABLED: ${OTEL_ENABLED:-} + OTEL_PROVIDER: ${OTEL_PROVIDER:-} + JITSU_SERVER_WRITE_KEY: ${JITSU_SERVER_WRITE_KEY:-} + GAUZY_GITHUB_CLIENT_ID: ${GAUZY_GITHUB_CLIENT_ID:-} + GAUZY_GITHUB_CLIENT_SECRET: ${GAUZY_GITHUB_CLIENT_SECRET:-} + GAUZY_GITHUB_WEBHOOK_URL: ${GAUZY_GITHUB_WEBHOOK_URL:-} + GAUZY_GITHUB_WEBHOOK_SECRET: ${GAUZY_GITHUB_WEBHOOK_SECRET:-} + GAUZY_GITHUB_APP_PRIVATE_KEY: ${GAUZY_GITHUB_APP_PRIVATE_KEY:-} + GAUZY_GITHUB_APP_ID: ${GAUZY_GITHUB_APP_ID:-} + GAUZY_GITHUB_APP_NAME: ${GAUZY_GITHUB_APP_NAME:-} + GAUZY_GITHUB_POST_INSTALL_URL: ${GAUZY_GITHUB_POST_INSTALL_URL:-} + GAUZY_GITHUB_OAUTH_CLIENT_ID: ${GAUZY_GITHUB_OAUTH_CLIENT_ID:-} + GAUZY_GITHUB_OAUTH_CLIENT_SECRET: ${GAUZY_GITHUB_OAUTH_CLIENT_SECRET:-} + GAUZY_GITHUB_OAUTH_CALLBACK_URL: ${GAUZY_GITHUB_OAUTH_CALLBACK_URL:-} + MAGIC_CODE_EXPIRATION_TIME: ${MAGIC_CODE_EXPIRATION_TIME:-} + APP_NAME: ${APP_NAME:-} + APP_LOGO: ${APP_LOGO:-} + APP_SIGNATURE: ${APP_SIGNATURE:-} + APP_LINK: ${APP_LINK:-} + APP_EMAIL_CONFIRMATION_URL: ${APP_EMAIL_CONFIRMATION_URL:-} + APP_MAGIC_SIGN_URL: ${APP_MAGIC_SIGN_URL:-} + COMPANY_LINK: ${COMPANY_LINK:-} + COMPANY_NAME: ${COMPANY_NAME:-} + + env_file: + - .env.compose + entrypoint: './entrypoint.compose.sh' + command: ['node', 'main.js'] + restart: on-failure + depends_on: + db: + condition: service_healthy + zipkin: + condition: service_started + redis: + condition: service_started + minio: + condition: service_started + minio_create_buckets: + condition: service_started + elasticsearch: + condition: service_healthy + cube: + condition: service_started + links: + - db:${DB_HOST:-db} + - cube:${CUBE_HOST:-cube} + - redis:${REDIS_HOST:-redis} + - minio:${MINIO_HOST:-minio} + - elasticsearch:${ES_HOST:-elasticsearch} + # volumes: + # - webapp_node_modules:/srv/gauzy/node_modules + # - api_node_modules:/srv/gauzy/apps/api/node_modules + ports: + - '3000:${API_PORT:-3000}' + networks: + - overlay + + webapp: + container_name: webapp + image: ghcr.io/ever-co/ever-teams-webapp:latest + environment: + GAUZY_API_SERVER_URL: ${GAUZY_API_SERVER_URL:-https://api.ever.team} + NEXT_PUBLIC_GAUZY_API_SERVER_URL: ${NEXT_PUBLIC_GAUZY_API_SERVER_URL:-https://api.ever.team} + NODE_ENV: ${NODE_ENV:-development} + DEMO: 'true' + env_file: + - .env.compose + restart: on-failure + links: + - db:${DB_HOST:-db} + - api:${API_HOST:-api} + - cube:${CUBE_HOST:-cube} + - redis:${REDIS_HOST:-redis} + - minio:${MINIO_HOST:-minio} + - elasticsearch:${ES_HOST:-elasticsearch} + depends_on: + db: + condition: service_healthy + redis: + condition: service_started + minio: + condition: service_started + minio_create_buckets: + condition: service_started + elasticsearch: + condition: service_healthy + api: + condition: service_started + # volumes: + # - webapp_node_modules:/srv/gauzy/node_modules + ports: + - '3030:${UI_PORT:-3030}' + networks: + - overlay From bfb5c2533d783a02338c8015c8105374f35d9784 Mon Sep 17 00:00:00 2001 From: AKILIMAILI CIZUNGU Innocent <51681130+Innocent-Akim@users.noreply.github.com> Date: Mon, 6 Jan 2025 22:11:46 +0200 Subject: [PATCH 03/15] Fix(timesheet): Correct handling of timesheet updates in state (#3497) * fix(timesheet): correct handling of timesheet updates in state * cspall --- .../[memberId]/components/AddTaskModal.tsx | 9 +-- .../[memberId]/components/EditTaskModal.tsx | 4 +- .../components/RejectSelectedModal.tsx | 10 ++- .../[locale]/timesheet/[memberId]/page.tsx | 2 +- apps/web/app/hooks/features/useTimesheet.ts | 69 ++++++++++++------- 5 files changed, 59 insertions(+), 35 deletions(-) diff --git a/apps/web/app/[locale]/timesheet/[memberId]/components/AddTaskModal.tsx b/apps/web/app/[locale]/timesheet/[memberId]/components/AddTaskModal.tsx index cfdc306ad..7aa24577b 100644 --- a/apps/web/app/[locale]/timesheet/[memberId]/components/AddTaskModal.tsx +++ b/apps/web/app/[locale]/timesheet/[memberId]/components/AddTaskModal.tsx @@ -105,7 +105,6 @@ export function AddTaskModal({ closeModal, isOpen }: IAddTaskModalProps) { source: TimerSource.BROWSER as any, taskId: formState.taskId, employeeId: formState.employeeId, - organizationContactId: null || "", organizationTeamId: null, }; try { @@ -117,7 +116,6 @@ export function AddTaskModal({ closeModal, isOpen }: IAddTaskModalProps) { if (!shift.dateFrom || !shift.startTime || !shift.endTime) { throw new Error('Incomplete shift data.'); } - const baseDate = shift.dateFrom instanceof Date ? shift.dateFrom : new Date(shift.dateFrom); const start = createUtcDate(baseDate, shift.startTime); const end = createUtcDate(baseDate, shift.endTime); @@ -130,10 +128,11 @@ export function AddTaskModal({ closeModal, isOpen }: IAddTaskModalProps) { ...payload, startedAt: start, stoppedAt: end, + taskId: payload.taskId }); }) ); - console.log('Timesheets successfully created.'); + closeModal(); } catch (error) { console.error('Failed to create timesheet:', error); } @@ -154,7 +153,9 @@ export function AddTaskModal({ closeModal, isOpen }: IAddTaskModalProps) { * updateFormState('taskId', value.id)} + onChange={(value) => { + updateFormState('taskId', value) + }} classNameGroup='h-[40vh]' ariaLabel='Task issues' className='w-full font-medium' diff --git a/apps/web/app/[locale]/timesheet/[memberId]/components/EditTaskModal.tsx b/apps/web/app/[locale]/timesheet/[memberId]/components/EditTaskModal.tsx index 66a19e4de..14ad3f596 100644 --- a/apps/web/app/[locale]/timesheet/[memberId]/components/EditTaskModal.tsx +++ b/apps/web/app/[locale]/timesheet/[memberId]/components/EditTaskModal.tsx @@ -193,7 +193,9 @@ export function EditTaskModal({ isOpen, closeModal, dataTimesheet }: IEditTaskMo className="border border-transparent hover:border-transparent dark:hover:border-transparent" options={activeTeam?.members || []} value={timesheetData.employeeId} - onChange={(value) => setTimesheetData({ ...timesheetData, employeeId: value.employeeId })} + onChange={(value) => { + setTimesheetData({ ...timesheetData, employeeId: value }) + }} renderOption={(option) => (
{option.employee.fullName} diff --git a/apps/web/app/[locale]/timesheet/[memberId]/components/RejectSelectedModal.tsx b/apps/web/app/[locale]/timesheet/[memberId]/components/RejectSelectedModal.tsx index 6e82b567b..80ff2f1f2 100644 --- a/apps/web/app/[locale]/timesheet/[memberId]/components/RejectSelectedModal.tsx +++ b/apps/web/app/[locale]/timesheet/[memberId]/components/RejectSelectedModal.tsx @@ -1,6 +1,7 @@ import { useTimesheet } from '@/app/hooks/features/useTimesheet'; import { clsxm } from '@/app/utils'; import { Modal } from '@/lib/components'; +import { ReloadIcon } from '@radix-ui/react-icons'; import { useTranslations } from 'next-intl'; import { useState } from 'react'; export interface IRejectSelectedModalProps { @@ -33,7 +34,7 @@ export function RejectSelectedModal({ }: IRejectSelectedModalProps) { const [isSubmitting, setIsSubmitting] = useState(false); const [reason, setReason] = useState(''); - const { updateTimesheetStatus, setSelectTimesheetId } = useTimesheet({}); + const { updateTimesheetStatus, loadingUpdateTimesheetStatus, setSelectTimesheetId } = useTimesheet({}); const t = useTranslations(); const handleSubmit = async (e: React.FormEvent) => { @@ -44,8 +45,8 @@ export function RejectSelectedModal({ status: 'DENIED', ids: selectTimesheetId || [], }).then(() => { - closeModal(); setSelectTimesheetId([]) + closeModal(); }).catch((error) => console.error(error)); } finally { setIsSubmitting(false); @@ -97,7 +98,7 @@ export function RejectSelectedModal({
diff --git a/apps/web/app/[locale]/timesheet/[memberId]/page.tsx b/apps/web/app/[locale]/timesheet/[memberId]/page.tsx index d79febb07..6b7da3d78 100644 --- a/apps/web/app/[locale]/timesheet/[memberId]/page.tsx +++ b/apps/web/app/[locale]/timesheet/[memberId]/page.tsx @@ -171,7 +171,7 @@ const TimeSheet = React.memo(function TimeSheetPage({ params }: { params: { memb }} /> } diff --git a/apps/web/app/hooks/features/useTimesheet.ts b/apps/web/app/hooks/features/useTimesheet.ts index d06e4db2b..af068a0dd 100644 --- a/apps/web/app/hooks/features/useTimesheet.ts +++ b/apps/web/app/hooks/features/useTimesheet.ts @@ -7,6 +7,7 @@ import { deleteTaskTimesheetLogsApi, getTaskTimesheetLogsApi, updateStatusTimesh import moment from 'moment'; import { ID, TimesheetLog, TimesheetStatus, UpdateTimesheet } from '@/app/interfaces'; import { useTimelogFilterOptions } from './useTimelogFilterOptions'; +import axios from 'axios'; interface TimesheetParams { startDate?: Date | string; @@ -25,18 +26,18 @@ const groupByDate = (items: TimesheetLog[]): GroupedTimesheet[] => { if (!items?.length) return []; type GroupedMap = Record; const groupedByDate = items.reduce((acc, item) => { - if (!item?.timesheet?.createdAt) { + if (!item?.createdAt) { console.warn('Skipping item with missing timesheet or createdAt:', item); return acc; } try { - const date = new Date(item.timesheet.createdAt).toISOString().split('T')[0]; + const date = new Date(item.createdAt).toISOString().split('T')[0]; if (!acc[date]) acc[date] = []; acc[date].push(item); } catch (error) { console.error( `Failed to process date for timesheet ${item.timesheet.id}:`, - { createdAt: item.timesheet.createdAt, error } + { createdAt: item.createdAt, error } ); } return acc; @@ -61,19 +62,19 @@ const createGroupingFunction = (getKey: GroupingKeyFunction) => (items: Timeshee type GroupedMap = Record; const grouped = items.reduce((acc, item) => { - if (!item?.timesheet?.createdAt) { + if (!item?.createdAt) { console.warn('Skipping item with missing timesheet or createdAt:', item); return acc; } try { - const date = new Date(item.timesheet.createdAt); + const date = new Date(item.createdAt); const key = getKey(date); if (!acc[key]) acc[key] = []; acc[key].push(item); } catch (error) { console.error( `Failed to process date for timesheet ${item.timesheet.id}:`, - { createdAt: item.timesheet.createdAt, error } + { createdAt: item.createdAt, error } ); } return acc; @@ -177,40 +178,56 @@ export function useTimesheet({ throw new Error("User not authenticated"); } try { - const response = await queryCreateTimesheet(timesheetParams); - setTimesheet((prevTimesheet) => [ - response.data, - ...(prevTimesheet || []) - ]); + const response = queryCreateTimesheet(timesheetParams).then((res) => { + return res.data + }); + return response } catch (error) { - console.error('Error:', error); + if (axios.isAxiosError(error)) { + console.error('Axios Error:', { + status: error.response?.status, + statusText: error.response?.statusText, + data: error.response?.data + }); + throw new Error(`Request failed: ${error.message}`); + } + console.error('Error:', error instanceof Error ? error.message : error); + throw error; } }, [queryCreateTimesheet, setTimesheet, user] ); - - - const updateTimesheet = useCallback<(params: UpdateTimesheet) => Promise>( - async ({ ...timesheet }: UpdateTimesheet) => { + const updateTimesheet = useCallback( + async (timesheet: UpdateTimesheet) => { if (!user) { - throw new Error("User not authenticated"); + console.warn("User not authenticated!"); + return; } try { const response = await queryUpdateTimesheet(timesheet); - setTimesheet((prevTimesheet) => { - const updatedTimesheets = prevTimesheet.map((item) => - item.id === response.data.id - ? { ...item, ...response.data } - : item + if (response?.data?.id) { + setTimesheet((prevTimesheet) => + prevTimesheet.map((item) => + item.id === response.data.id + ? { ...item, ...response.data } + : item + ) ); - return updatedTimesheets; - }); + } else { + console.warn( + "Unexpected structure of the response. No update performed.", + response + ); + } } catch (error) { - console.error('Error updating timesheet:', error); + console.error("Error updating the timesheet:", error); throw error; } - }, [queryUpdateTimesheet, setTimesheet, user]) + }, + [queryUpdateTimesheet, setTimesheet, user] + ); + const updateTimesheetStatus = useCallback( From 04ecaf80d135240e9fd2db64337afe5e9593179d Mon Sep 17 00:00:00 2001 From: AKILIMAILI CIZUNGU Innocent <51681130+Innocent-Akim@users.noreply.github.com> Date: Tue, 7 Jan 2025 00:01:00 +0200 Subject: [PATCH 04/15] [Feat]:Calendar timesheet selection (#3498) * feat: add SelectedTimesheet component for managing task selection in calendar view timesheet * feat: add SelectedTimesheet component for managing task selection in calendar view timesheet --- .../[memberId]/components/CalendarView.tsx | 80 ++++++++++++------- .../[memberId]/components/SelectionBar.tsx | 76 ++++++++++++++++++ .../[locale]/timesheet/[memberId]/page.tsx | 23 +++++- .../hooks/features/useTimelogFilterOptions.ts | 6 +- apps/web/app/hooks/features/useTimesheet.ts | 7 +- apps/web/app/stores/time-logs.ts | 1 + 6 files changed, 158 insertions(+), 35 deletions(-) diff --git a/apps/web/app/[locale]/timesheet/[memberId]/components/CalendarView.tsx b/apps/web/app/[locale]/timesheet/[memberId]/components/CalendarView.tsx index 57a3f0bea..a3c003e5a 100644 --- a/apps/web/app/[locale]/timesheet/[memberId]/components/CalendarView.tsx +++ b/apps/web/app/[locale]/timesheet/[memberId]/components/CalendarView.tsx @@ -11,8 +11,9 @@ import { cn } from "@/lib/utils"; import MonthlyTimesheetCalendar from "./MonthlyTimesheetCalendar"; import { useTimelogFilterOptions } from "@/app/hooks"; import WeeklyTimesheetCalendar from "./WeeklyTimesheetCalendar"; -import { IUser } from "@/app/interfaces"; +import { IUser, TimesheetLog } from "@/app/interfaces"; import TimesheetSkeleton from "@components/shared/skeleton/TimesheetSkeleton"; +import { Checkbox } from "@components/ui/checkbox"; interface BaseCalendarDataViewProps { t: TranslationHooks data: GroupedTimesheet[]; @@ -51,7 +52,6 @@ export function CalendarView({ data, loading, user }: { data?: GroupedTimesheet[ ); } - return (
{(() => { @@ -70,7 +70,7 @@ export function CalendarView({ data, loading, user }: { data?: GroupedTimesheet[ } const CalendarDataView = ({ data, t }: { data?: GroupedTimesheet[], t: TranslationHooks }) => { - const { getStatusTimesheet } = useTimesheet({}); + const { getStatusTimesheet, handleSelectRowTimesheet, selectTimesheetId } = useTimesheet({}); return (
@@ -134,9 +134,9 @@ const CalendarDataView = ({ data, t }: { data?: GroupedTimesheet[], t: Translati }} className={cn( - 'border-l-4 rounded-l flex flex-col p-2 gap-2 items-start space-x-4 ', + 'group/item border-l-4 rounded-l flex flex-col p-2 gap-2 items-start space-x-4', )}> -
+
-
- {task.project?.imageUrl && ( - - )} - - {task.project?.name ?? 'No Project'} - +
+
+ {task.project?.imageUrl && ( + + )} + + {task.project?.name ?? 'No Project'} + +
+
))} @@ -188,7 +194,7 @@ const CalendarDataView = ({ data, t }: { data?: GroupedTimesheet[], t: Translati } const BaseCalendarDataView = ({ data, daysLabels, t, CalendarComponent }: BaseCalendarDataViewProps) => { - const { getStatusTimesheet } = useTimesheet({}); + const { getStatusTimesheet, handleSelectRowTimesheet, selectTimesheetId } = useTimesheet({}); return (
@@ -263,16 +269,23 @@ const BaseCalendarDataView = ({ data, daysLabels, t, CalendarComponent }: BaseCa dash taskNumberClassName="text-sm" /> -
- {task.project?.imageUrl && ( - - )} - - {task.project?.name ?? 'No Project'} - +
+
+ {task.project?.imageUrl && ( + + )} + + {task.project?.name ?? 'No Project'} + +
+
))} @@ -299,3 +312,14 @@ const MonthlyCalendarDataView = (props: { data: GroupedTimesheet[], t: Translati const WeeklyCalendarDataView = (props: { data: GroupedTimesheet[], t: TranslationHooks, daysLabels?: string[] }) => ( ); + + +export const CheckBoxTimesheet = ({ selectTimesheetId, timesheet, handleSelectRowTimesheet }: { selectTimesheetId: TimesheetLog[], timesheet: TimesheetLog, handleSelectRowTimesheet: (items: TimesheetLog) => void }) => { + return handleSelectRowTimesheet(timesheet)} + checked={selectTimesheetId.includes(timesheet)} + /> +} diff --git a/apps/web/app/[locale]/timesheet/[memberId]/components/SelectionBar.tsx b/apps/web/app/[locale]/timesheet/[memberId]/components/SelectionBar.tsx index 5cdf3bfa1..bac09e440 100644 --- a/apps/web/app/[locale]/timesheet/[memberId]/components/SelectionBar.tsx +++ b/apps/web/app/[locale]/timesheet/[memberId]/components/SelectionBar.tsx @@ -1,5 +1,7 @@ +import { ID, TimesheetLog, TimesheetStatus } from "@/app/interfaces"; import { cn } from "@/lib/utils"; import { useTranslations } from "next-intl"; +import { useCallback } from "react"; type ActionButtonProps = { label: string; @@ -70,3 +72,77 @@ export const SelectionBar = ({
) } + + +interface SelectedTimesheetProps { + selectTimesheetId: TimesheetLog[]; + updateTimesheetStatus: ({ status, ids }: { status: TimesheetStatus, ids: ID[] | ID }) => Promise; + deleteTaskTimesheet: ({ logIds }: { logIds: string[] }) => Promise; + setSelectTimesheetId: React.Dispatch>; + fullWidth: boolean; +} + + +/** + * SelectedTimesheet + * + * A component that renders a selection bar to handle tasks in the timesheet. + * It provides buttons to approve, reject, delete and clear the selected tasks. + * + * @param selectTimesheetId - The selected timesheet logs. + * @param updateTimesheetStatus - A function to update the status of the selected timesheet logs. + * @param deleteTaskTimesheet - A function to delete the selected timesheet logs. + * @param setSelectTimesheetId - A function to set the selected timesheet logs. + * @param fullWidth - A boolean to indicate if the component should be rendered in full width. + * @returns {React.ReactElement} - The rendered timesheet component. + */ +export const SelectedTimesheet: React.FC = ({ selectTimesheetId, updateTimesheetStatus, deleteTaskTimesheet, setSelectTimesheetId, fullWidth }) => { + const handleApprove = useCallback(async () => { + try { + updateTimesheetStatus({ + status: 'APPROVED', + ids: selectTimesheetId.map((select) => select.timesheet.id).filter((id) => id !== undefined) + }).then(() => { + setSelectTimesheetId([]); + }); + } catch (error) { + console.error(error); + } + }, [selectTimesheetId, updateTimesheetStatus]); + + const handleReject = useCallback(async () => { + try { + updateTimesheetStatus({ + status: 'DENIED', + ids: selectTimesheetId.map((select) => select.timesheet.id).filter((id) => id !== undefined) + }).then(() => { + setSelectTimesheetId([]); + }); + } catch (error) { + console.error(error); + } + }, [selectTimesheetId, updateTimesheetStatus]); + + const handleDelete = useCallback(async () => { + try { + deleteTaskTimesheet({ + logIds: selectTimesheetId?.map((select) => select.timesheet.id).filter((id) => id !== undefined) + }).then(() => { + setSelectTimesheetId([]); + }); + } catch (error) { + console.error(error); + } + }, [selectTimesheetId, deleteTaskTimesheet, setSelectTimesheetId]); + + return ( + setSelectTimesheetId([])} + fullWidth={fullWidth} + /> + ) +} diff --git a/apps/web/app/[locale]/timesheet/[memberId]/page.tsx b/apps/web/app/[locale]/timesheet/[memberId]/page.tsx index 6b7da3d78..c64fc3766 100644 --- a/apps/web/app/[locale]/timesheet/[memberId]/page.tsx +++ b/apps/web/app/[locale]/timesheet/[memberId]/page.tsx @@ -14,7 +14,7 @@ import { fullWidthState } from '@app/stores/fullWidth'; import { useAtomValue } from 'jotai'; import { ArrowLeftIcon } from 'assets/svg'; -import { CalendarView, CalendarViewIcon, FilterStatus, ListViewIcon, MemberWorkIcon, MenHoursIcon, PendingTaskIcon, TimesheetCard, TimesheetFilter, TimesheetView } from './components'; +import { CalendarView, CalendarViewIcon, FilterStatus, ListViewIcon, MemberWorkIcon, MenHoursIcon, PendingTaskIcon, SelectedTimesheet, TimesheetCard, TimesheetFilter, TimesheetView } from './components'; import { GoSearch } from 'react-icons/go'; import { differenceBetweenHours, getGreeting, secondsToTime } from '@/app/helpers'; @@ -55,7 +55,16 @@ const TimeSheet = React.memo(function TimeSheetPage({ params }: { params: { memb to: endOfMonth(new Date()), }); - const { timesheet: filterDataTimesheet, statusTimesheet, loadingTimesheet, isManage, timesheetGroupByDays } = useTimesheet({ + const { + timesheet: filterDataTimesheet, + statusTimesheet, loadingTimesheet, + isManage, + timesheetGroupByDays, + selectTimesheetId, + setSelectTimesheetId, + updateTimesheetStatus, + deleteTaskTimesheet + } = useTimesheet({ startDate: dateRange.from!, endDate: dateRange.to!, timesheetViewMode: timesheetNavigator, @@ -281,8 +290,16 @@ const TimeSheet = React.memo(function TimeSheetPage({ params }: { params: { memb totalGroups={totalGroups} /> )} -
+
+ {selectTimesheetId.length > 0 && + }
diff --git a/apps/web/app/hooks/features/useTimelogFilterOptions.ts b/apps/web/app/hooks/features/useTimelogFilterOptions.ts index 242352156..d10519559 100644 --- a/apps/web/app/hooks/features/useTimelogFilterOptions.ts +++ b/apps/web/app/hooks/features/useTimelogFilterOptions.ts @@ -1,5 +1,5 @@ import { IUser, RoleNameEnum, TimesheetLog } from '@/app/interfaces'; -import { timesheetDeleteState, timesheetGroupByDayState, timesheetFilterEmployeeState, timesheetFilterProjectState, timesheetFilterStatusState, timesheetFilterTaskState, timesheetUpdateStatus } from '@/app/stores'; +import { timesheetDeleteState, timesheetGroupByDayState, timesheetFilterEmployeeState, timesheetFilterProjectState, timesheetFilterStatusState, timesheetFilterTaskState, timesheetUpdateStatus, selectTimesheetIdState } from '@/app/stores'; import { useAtom } from 'jotai'; import React from 'react'; @@ -13,7 +13,9 @@ export function useTimelogFilterOptions() { const [timesheetGroupByDays, setTimesheetGroupByDays] = useAtom(timesheetGroupByDayState); const [puTimesheetStatus, setPuTimesheetStatus] = useAtom(timesheetUpdateStatus) const [selectedItems, setSelectedItems] = React.useState<{ status: string; date: string }[]>([]); - const [selectTimesheetId, setSelectTimesheetId] = React.useState([]) + // const [selectTimesheetId, setSelectTimesheetId] = React.useState([]) + const [selectTimesheetId, setSelectTimesheetId] = useAtom(selectTimesheetIdState) + const employee = employeeState; const project = projectState; diff --git a/apps/web/app/hooks/features/useTimesheet.ts b/apps/web/app/hooks/features/useTimesheet.ts index af068a0dd..894587bc2 100644 --- a/apps/web/app/hooks/features/useTimesheet.ts +++ b/apps/web/app/hooks/features/useTimesheet.ts @@ -129,7 +129,7 @@ export function useTimesheet({ }: TimesheetParams) { const { user } = useAuthenticateUser(); const [timesheet, setTimesheet] = useAtom(timesheetRapportState); - const { employee, project, task, statusState, timesheetGroupByDays, puTimesheetStatus, isUserAllowedToAccess, normalizeText, setSelectTimesheetId } = useTimelogFilterOptions(); + const { employee, project, task, statusState, timesheetGroupByDays, puTimesheetStatus, isUserAllowedToAccess, normalizeText, setSelectTimesheetId, selectTimesheetId, handleSelectRowByStatusAndDate, handleSelectRowTimesheet } = useTimelogFilterOptions(); const { loading: loadingTimesheet, queryCall: queryTimesheet } = useQuery(getTaskTimesheetLogsApi); const { loading: loadingDeleteTimesheet, queryCall: queryDeleteTimesheet } = useQuery(deleteTaskTimesheetLogsApi); const { loading: loadingUpdateTimesheetStatus, queryCall: queryUpdateTimesheetStatus } = useQuery(updateStatusTimesheetFromApi) @@ -384,6 +384,9 @@ export function useTimesheet({ groupByDate, isManage, normalizeText, - setSelectTimesheetId + setSelectTimesheetId, + selectTimesheetId, + handleSelectRowByStatusAndDate, + handleSelectRowTimesheet }; } diff --git a/apps/web/app/stores/time-logs.ts b/apps/web/app/stores/time-logs.ts index 19ad5da66..514fc7e89 100644 --- a/apps/web/app/stores/time-logs.ts +++ b/apps/web/app/stores/time-logs.ts @@ -21,3 +21,4 @@ export const timesheetDeleteState = atom([]); export const timesheetGroupByDayState = atom('Daily') export const timesheetUpdateStatus = atom([]) export const timesheetUpdateState = atom(null) +export const selectTimesheetIdState = atom([]) From 75eaa3d49c164a7f6cd81b2ac3f8ba4267ae8ed6 Mon Sep 17 00:00:00 2001 From: Innocent-akim Date: Wed, 8 Jan 2025 10:30:47 +0200 Subject: [PATCH 05/15] feat: implement automatic offline page display --- apps/web/app/[locale]/layout.tsx | 19 ++++++---- apps/web/app/[locale]/page-component.tsx | 18 +++------ apps/web/components/offline-wrapper/index.tsx | 37 +++++++++++++++++++ apps/web/components/pages/offline/index.tsx | 3 ++ 4 files changed, 57 insertions(+), 20 deletions(-) create mode 100644 apps/web/components/offline-wrapper/index.tsx diff --git a/apps/web/app/[locale]/layout.tsx b/apps/web/app/[locale]/layout.tsx index 7353f53b4..85dc858c7 100644 --- a/apps/web/app/[locale]/layout.tsx +++ b/apps/web/app/[locale]/layout.tsx @@ -17,6 +17,7 @@ import { PropsWithChildren, useEffect } from 'react'; import { useCheckAPI } from '@app/hooks/useCheckAPI'; import GlobalSkeleton from '@components/ui/global-skeleton'; +import OfflineWrapper from '@components/offline-wrapper'; import { JitsuOptions } from '@jitsu/jitsu-react/dist/useJitsu'; import { PHProvider } from './integration/posthog/provider'; @@ -145,14 +146,16 @@ const LocaleLayout = ({ children, params: { locale }, pageProps }: PropsWithChil enableSystem disableTransitionOnChange > - {loading && !pathname?.startsWith('/auth') ? ( - - ) : ( - <> - - {children} - - )} + + {loading && !pathname?.startsWith('/auth') ? ( + + ) : ( + <> + + {children} + + )} + diff --git a/apps/web/app/[locale]/page-component.tsx b/apps/web/app/[locale]/page-component.tsx index 2a1ab3151..f8c5f696d 100644 --- a/apps/web/app/[locale]/page-component.tsx +++ b/apps/web/app/[locale]/page-component.tsx @@ -2,7 +2,7 @@ 'use client'; import React, { useEffect, useState } from 'react'; -import { useOrganizationTeams, useTimerView } from '@app/hooks'; +import { useOrganizationTeams } from '@app/hooks'; import { clsxm } from '@app/utils'; import NoTeam from '@components/pages/main/no-team'; import { withAuthentication } from 'lib/app/authenticator'; @@ -10,8 +10,6 @@ import { Breadcrumb, Card, Container } from 'lib/components'; import { AuthUserTaskInput, TeamInvitations, TeamMembers, Timer, UnverifiedEmail } from 'lib/features'; import { MainLayout } from 'lib/layout'; import { IssuesView } from '@app/constants'; -import { useNetworkState } from '@uidotdev/usehooks'; -import Offline from '@components/pages/offline'; import { useTranslations } from 'next-intl'; import { Analytics } from '@vercel/analytics/react'; @@ -34,7 +32,6 @@ function MainPage() { const t = useTranslations(); const [headerSize] = useState(10); const { isTeamMember, isTrackingEnabled, activeTeam } = useOrganizationTeams(); - const { timerStatus } = useTimerView(); const [fullWidth, setFullWidth] = useAtom(fullWidthState); const [view, setView] = useAtom(headerTabs); @@ -44,7 +41,7 @@ function MainPage() { { title: activeTeam?.name || '', href: '/' }, { title: t(`common.${view}`), href: `/` } ]; - const { online } = useNetworkState(); + useEffect(() => { if (view == IssuesView.KANBAN && path == '/') { setView(IssuesView.CARDS); @@ -57,13 +54,10 @@ function MainPage() { setFullWidth(JSON.parse(window?.localStorage.getItem('conf-fullWidth-mode') || 'true')); }, [setFullWidth]); - if (!online) { - return ; - } return ( <>
- {/*
*/} + {/*
*/}
-
+
-
+
@@ -100,7 +94,7 @@ function MainPage() { footerClassName={clsxm('')} > -
{isTeamMember ? +
{isTeamMember ? diff --git a/apps/web/components/offline-wrapper/index.tsx b/apps/web/components/offline-wrapper/index.tsx new file mode 100644 index 000000000..d56797850 --- /dev/null +++ b/apps/web/components/offline-wrapper/index.tsx @@ -0,0 +1,37 @@ +'use client'; + +import { useNetworkState } from '@uidotdev/usehooks'; +import Offline from '@components/pages/offline'; +import { useTimerView } from '@app/hooks'; +import { usePathname } from 'next/navigation'; + +interface OfflineWrapperProps { + children: React.ReactNode; +} + +/** + * A wrapper component that conditionally renders the Offline component if the user is not online. + * The Offline component is not shown on authentication pages (paths starting with /auth). + * When the user is offline, the Offline component is rendered with the showTimer prop set to + * whether the timer is running or not. + * + * @example + * + * + * + * @param {React.ReactNode} children - The children components to render when the user is online + * @returns {React.ReactElement} - The Offline component if the user is offline (except on auth pages), or the children components if the user is online + */ +export default function OfflineWrapper({ children }: OfflineWrapperProps) { + const { online } = useNetworkState(); + const { timerStatus } = useTimerView(); + const pathname = usePathname(); + + const isAuthPage = pathname?.startsWith('/auth'); + + if (!online && !isAuthPage) { + return ; + } + + return <>{children}; +} diff --git a/apps/web/components/pages/offline/index.tsx b/apps/web/components/pages/offline/index.tsx index 198e689df..dec2f2754 100644 --- a/apps/web/components/pages/offline/index.tsx +++ b/apps/web/components/pages/offline/index.tsx @@ -3,11 +3,14 @@ import { cn } from '@/lib/utils'; import SadCry from '@components/ui/svgs/sad-cry'; import { Text } from 'lib/components'; import { useTranslations } from 'next-intl'; + interface IPropsOffline { showTimer?: boolean } + function Offline({ showTimer }: IPropsOffline) { const t = useTranslations(); + return (
From 2da11925ccc99fe3d93ff2b27f718fccc532b2e5 Mon Sep 17 00:00:00 2001 From: AKILIMAILI CIZUNGU Innocent <51681130+Innocent-Akim@users.noreply.github.com> Date: Wed, 8 Jan 2025 21:17:36 +0200 Subject: [PATCH 06/15] [Feat]: Visual Distinction of Days with Time Entries in Date Range Filter (#3499) * feat: Visual Distinction of Days with Time Entries in Date Range Filter * fix: Refactor suggestion --- .../[memberId]/components/TimesheetFilter.tsx | 2 +- .../components/TimesheetFilterDate.tsx | 152 +++++++++++++++--- 2 files changed, 130 insertions(+), 24 deletions(-) diff --git a/apps/web/app/[locale]/timesheet/[memberId]/components/TimesheetFilter.tsx b/apps/web/app/[locale]/timesheet/[memberId]/components/TimesheetFilter.tsx index d4cd7aba5..61c353a79 100644 --- a/apps/web/app/[locale]/timesheet/[memberId]/components/TimesheetFilter.tsx +++ b/apps/web/app/[locale]/timesheet/[memberId]/components/TimesheetFilter.tsx @@ -41,7 +41,7 @@ export function TimesheetFilter({ closeModal, isOpen, openModal, t, initDate, fi
- + {isManage && ( <> diff --git a/apps/web/app/[locale]/timesheet/[memberId]/components/TimesheetFilterDate.tsx b/apps/web/app/[locale]/timesheet/[memberId]/components/TimesheetFilterDate.tsx index 83e1f8a91..40c226e5f 100644 --- a/apps/web/app/[locale]/timesheet/[memberId]/components/TimesheetFilterDate.tsx +++ b/apps/web/app/[locale]/timesheet/[memberId]/components/TimesheetFilterDate.tsx @@ -5,13 +5,14 @@ import { DatePicker } from '@components/ui/DatePicker'; import { Button } from '@components/ui/button'; import { Popover, PopoverContent, PopoverTrigger } from '@components/ui/popover'; import { CalendarIcon } from '@radix-ui/react-icons'; -import { format } from 'date-fns'; +import { format, isAfter, isToday, startOfToday } from 'date-fns'; import { TranslationHooks } from 'next-intl'; import { MdKeyboardArrowRight } from 'react-icons/md'; import { PiCalendarDotsThin } from 'react-icons/pi'; import React, { Dispatch, useEffect, useState, SetStateAction, useCallback, useMemo, memo } from 'react'; import moment from 'moment'; import { ChevronDown } from 'lucide-react'; +import { TimesheetLog } from '@/app/interfaces'; interface DatePickerInputProps { @@ -25,6 +26,7 @@ export interface TimesheetFilterDateProps { minDate?: Date; maxDate?: Date; t: TranslationHooks; + data?: TimesheetLog[] } export function TimesheetFilterDate({ @@ -32,12 +34,25 @@ export function TimesheetFilterDate({ initialRange, minDate, maxDate, + data, t }: Readonly) { - const [dateRange, setDateRange] = React.useState<{ from: Date | null; to: Date | null }>({ - from: initialRange?.from ?? new Date(), - to: initialRange?.to ?? new Date() - }); + const today = startOfToday() + + const adjustedInitialRange = React.useMemo(() => { + if (!initialRange) { + return { + from: today, + to: today, + } + } + return { + from: initialRange.from, + to: initialRange.to && isAfter(initialRange.to, today) ? today : initialRange.to, + } + }, [initialRange, today]) + + const [dateRange, setDateRange] = React.useState<{ from: Date | null; to: Date | null }>(adjustedInitialRange); const [isVisible, setIsVisible] = useState(false); @@ -49,6 +64,7 @@ export function TimesheetFilterDate({ onChange?.({ ...dateRange, from: fromDate }); }; + const handleToChange = (toDate: Date | null) => { if (dateRange.from && toDate && toDate < dateRange.from) { return; @@ -129,12 +145,13 @@ export function TimesheetFilterDate({ {isVisible && (
- +
@@ -215,14 +232,17 @@ export function DatePickerFilter({ date, setDate, minDate, - maxDate + maxDate, + timesheet, }: { label: string; date: Date | null; setDate: (date: Date | null) => void; minDate?: Date | null; maxDate?: Date | null; + timesheet?: TimesheetLog[], }) { + const isDateDisabled = React.useCallback( (date: Date) => { if (minDate && date < minDate) return true; @@ -232,14 +252,54 @@ export function DatePickerFilter({ [minDate, maxDate] ); + const datesWithEntries = React.useMemo(() => { + return new Set(timesheet?.map((entry) => format(new Date(entry.createdAt), "yyyy-MM-dd"))) + }, [timesheet]) + + const entriesByDate = React.useMemo(() => { + const map = new Map(); + timesheet?.forEach(entry => { + const dateKey = format(new Date(entry.createdAt), "yyyy-MM-dd"); + if (!map.has(dateKey)) { + map.set(dateKey, []); + } + map.get(dateKey)?.push(entry); + }); + return map; + }, [timesheet]); + + const getEntriesForDate = (date: Date) => { + const dateKey = format(date, "yyyy-MM-dd"); + return entriesByDate.get(dateKey) || []; + }; + const hasTimeEntry = (date: Date) => { + return datesWithEntries.has(format(date, "yyyy-MM-dd")) + } + + + const handleSelect = (day: Date) => { + if (day && !isDateDisabled(day)) { + setDate(day); + } + }; + return (
} mode="single" @@ -247,26 +307,72 @@ export function DatePickerFilter({ initialFocus defaultMonth={date ?? new Date()} selected={date ?? new Date()} - onSelect={(selectedDate) => { - if (selectedDate && !isDateDisabled(selectedDate)) { - setDate(selectedDate); - } + onSelect={(date) => date && handleSelect(date)} + modifiers={{ + hasEntry: (date) => hasTimeEntry(date), + today: (day) => isToday(day), }} modifiersClassNames={{ - booked: clsxm( - 'relative after:absolute after:bottom-0 after:left-1/2 after:-translate-x-1/2 after:w-1.5 after:h-1.5 after:bg-primary after:rounded-full' - ), - selected: clsxm('bg-primary after:hidden text-white !rounded-full'), - pastDay: clsxm( - 'relative after:absolute after:bottom-0 after:left-1/2 after:-translate-x-1/2 after:w-1.5 after:h-1.5 after:bg-yellow-600 after:rounded-full' - ), - today: clsxm('border-2 !border-yellow-700 rounded') + selected: clsxm("bg-primary after:hidden text-white !rounded-full"), + today: clsxm("border-2 !border-yellow-700 rounded"), + }} + disabled={[ + ...(minDate ? [{ before: minDate }] : []), + ...(maxDate ? [{ after: maxDate }] : []), + { + before: new Date(new Date().getFullYear(), new Date().getMonth(), 1), + }, + ]} + components={{ + Day: ({ date: dayDate, ...props }) => { + const isSelected = date?.getTime() === dayDate.getTime(); + + const isDayDisabled = isDateDisabled(dayDate); + return ( + + ); + }, }} - disabled={[...(minDate ? [{ before: minDate }] : []), ...(maxDate ? [{ after: maxDate }] : [])]} />
); } +const DayIndicators = ({ entries }: { entries: TimesheetLog[] }) => { + if (entries.length === 1) { + return ; + } + return ( +
+ {[...Array(3)].map((_, index) => ( + + ))} +
+ ); +}; + interface ICalendarProps { setSelectedPlan: Dispatch>; From eb05f7030a151372640180f8860e036dee3a9826 Mon Sep 17 00:00:00 2001 From: AKILIMAILI CIZUNGU Innocent <51681130+Innocent-Akim@users.noreply.github.com> Date: Fri, 10 Jan 2025 09:23:26 +0200 Subject: [PATCH 07/15] [Feat]: Set Working Hours (#3501) * feat: add TimePicker component with time selection and input editing * feat(WorkingHours): add configurable working hours component * fix: add timezone component * feat: create reusable ToggleSwitch component with dynamic styles and icons --- .../app/[locale]/settings/personal/page.tsx | 7 + .../[locale]/timesheet/[memberId]/page.tsx | 35 ++-- apps/web/app/hooks/useLeftSettingData.ts | 5 + apps/web/components/ui/time-picker.tsx | 125 +++++++++++++ apps/web/lib/components/toggler.tsx | 17 +- apps/web/lib/settings/working-hours.tsx | 165 ++++++++++++++++++ yarn.lock | 155 +++++++--------- 7 files changed, 394 insertions(+), 115 deletions(-) create mode 100644 apps/web/components/ui/time-picker.tsx create mode 100644 apps/web/lib/settings/working-hours.tsx diff --git a/apps/web/app/[locale]/settings/personal/page.tsx b/apps/web/app/[locale]/settings/personal/page.tsx index 63bb4889d..cc38d86e4 100644 --- a/apps/web/app/[locale]/settings/personal/page.tsx +++ b/apps/web/app/[locale]/settings/personal/page.tsx @@ -6,6 +6,7 @@ import { Accordian } from 'lib/components/accordian'; import Link from 'next/link'; import { useTranslations } from 'next-intl'; import { SyncZone } from 'lib/settings/sync.zone'; +import { WorkingHours } from '@/lib/settings/working-hours'; const Personal = () => { const t = useTranslations(); @@ -28,6 +29,12 @@ const Personal = () => { + + + ) : ( - + + {selectTimesheetId.length > 0 && } - loading={loadingTimesheet} - /> + )} {shouldRenderPagination && ( - {selectTimesheetId.length > 0 && - } +
diff --git a/apps/web/app/hooks/useLeftSettingData.ts b/apps/web/app/hooks/useLeftSettingData.ts index 60172af88..940e8e408 100644 --- a/apps/web/app/hooks/useLeftSettingData.ts +++ b/apps/web/app/hooks/useLeftSettingData.ts @@ -8,6 +8,11 @@ export const useLeftSettingData = () => { color: '#7E7991', href: '#general' }, + { + title: 'Working hours', + color: '#7E7991', + href: '#working-hours', + }, // { // title: t('pages.settingsPersonal.WORK_SCHEDULE'), // color: '#7E7991', diff --git a/apps/web/components/ui/time-picker.tsx b/apps/web/components/ui/time-picker.tsx new file mode 100644 index 000000000..472cfa3fd --- /dev/null +++ b/apps/web/components/ui/time-picker.tsx @@ -0,0 +1,125 @@ +import React, { useState } from 'react'; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from './select'; +import { Input } from './input'; + +interface TimePickerProps { + value: string; + onChange: (value: string) => void; + disabled?: boolean; +} + +const generateTimeOptions = () => { + const options: string[] = []; + for (let hour = 0; hour < 24; hour++) { + for (let minute = 0; minute < 60; minute += 10) { + const formattedHour = hour.toString().padStart(2, '0'); + const formattedMinute = minute.toString().padStart(2, '0'); + options.push(`${formattedHour}:${formattedMinute}`); + } + } + return options; +}; + +const isValidTimeFormat = (time: string): boolean => { + const timeRegex = /^([0-1]?[0-9]|2[0-3]):[0-5][0-9]$/; + return timeRegex.test(time); +}; + +const formatTime = (time: string): string => { + if (!time) return ''; + const [hours, minutes] = time.split(':'); + const formattedHours = hours.padStart(2, '0'); + const formattedMinutes = minutes ? minutes.padStart(2, '0') : '00'; + return `${formattedHours}:${formattedMinutes}`; +}; + +export const TimePicker: React.FC = ({ value, onChange, disabled }) => { + const [inputValue, setInputValue] = useState(value); + const [isEditing, setIsEditing] = useState(false); + const timeOptions = generateTimeOptions(); + + const handleInputChange = (e: React.ChangeEvent) => { + const newValue = e.target.value; + setInputValue(newValue); + + // Auto-format as user types + if (newValue.length === 2 && !newValue.includes(':')) { + setInputValue(newValue + ':'); + } + }; + + const handleInputBlur = () => { + setIsEditing(false); + if (isValidTimeFormat(inputValue)) { + const formattedTime = formatTime(inputValue); + setInputValue(formattedTime); + onChange(formattedTime); + } else { + setInputValue(value); + } + }; + + const handleSelectChange = (newValue: string) => { + setInputValue(newValue); + onChange(newValue); + }; + + return ( +
+ {isEditing ? ( + + ) : ( + +
+
+ {timeOptions.map((time) => ( + + {time} + + ))} +
+ + + )} +
+ ); +}; diff --git a/apps/web/lib/components/toggler.tsx b/apps/web/lib/components/toggler.tsx index af90396ee..a829be297 100644 --- a/apps/web/lib/components/toggler.tsx +++ b/apps/web/lib/components/toggler.tsx @@ -138,7 +138,7 @@ export function DataSyncToggler({ className }: IClassName) { className={clsxm( 'flex flex-row justify-center items-center p-2 w-8 h-8 rounded-[60px] ml-[-2px]', dataSync && - 'bg-white text-primary shadow-md dark:bg-transparent dark:bg-[#3B4454]' + 'bg-white text-primary shadow-md dark:bg-transparent dark:bg-[#3B4454]' )} > @@ -149,7 +149,7 @@ export function DataSyncToggler({ className }: IClassName) { className={clsxm( 'flex flex-row justify-center items-center p-2 w-8 h-8 rounded-[60px] mr-[-2px]', !dataSync && - 'bg-red-400 shadow-md dark:bg-transparent dark:bg-red-400' + 'bg-red-400 shadow-md dark:bg-transparent dark:bg-red-400' )} > @@ -186,7 +186,7 @@ export function DataSyncModeToggler({ className }: IClassName) { className={clsxm( 'flex flex-row justify-center items-center p-2 w-8 h-8 rounded-[60px] ml-[-2px]', dataSyncMode == 'REAL_TIME' && - 'bg-white text-primary shadow-md dark:bg-transparent dark:bg-[#3B4454]' + 'bg-white text-primary shadow-md dark:bg-transparent dark:bg-[#3B4454]' )} > @@ -197,7 +197,7 @@ export function DataSyncModeToggler({ className }: IClassName) { className={clsxm( 'flex flex-row justify-center items-center p-2 w-8 h-8 rounded-[60px] mr-[-2px]', dataSyncMode == 'PULL' && - 'bg-white shadow-md dark:bg-transparent dark:bg-[#3B4454]' + 'bg-white shadow-md dark:bg-transparent dark:bg-[#3B4454]' )} > @@ -234,9 +234,8 @@ export function CommonToggle({ >