From c851e4ac4516308871024e5138d00e4c79cd02e7 Mon Sep 17 00:00:00 2001 From: adrian peralta Date: Thu, 8 Aug 2024 01:47:34 +0000 Subject: [PATCH 01/33] feat: #6 define company service endpoint --- .github/workflows/test.yml | 2 +- .../_company_service.json.jbuilder | 3 +- backend/config/routes.rb | 6 +- backend/spec/factories/company_services.rb | 6 +- .../spec/requests/company_services_spec.rb | 128 +++--------------- .../routing/company_services_routing_spec.rb | 21 --- 6 files changed, 28 insertions(+), 138 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 8a3dc86..799f7e6 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -23,7 +23,7 @@ jobs: - name: Run Rails tests run: | cd backend - bin/rails test + bundle exec rspec frontend: name: 'Vue Frontend Tests' diff --git a/backend/app/views/company_services/_company_service.json.jbuilder b/backend/app/views/company_services/_company_service.json.jbuilder index 2bf158a..8c8cd64 100644 --- a/backend/app/views/company_services/_company_service.json.jbuilder +++ b/backend/app/views/company_services/_company_service.json.jbuilder @@ -1,2 +1 @@ -json.extract! company_service, :id, :name, :contract_start_date, :contract_end_date, :created_at, :updated_at -json.url company_service_url(company_service, format: :json) +json.extract! company_service, :id, :name diff --git a/backend/config/routes.rb b/backend/config/routes.rb index 0d0947a..3e356be 100644 --- a/backend/config/routes.rb +++ b/backend/config/routes.rb @@ -1,11 +1,7 @@ Rails.application.routes.draw do - resources :company_services - # Define your application routes per the DSL in https://guides.rubyonrails.org/routing.html + resources :company_services, only: [:index] - # Reveal health status on /up that returns 200 if the app boots with no exceptions, otherwise 500. - # Can be used by load balancers and uptime monitors to verify that the app is live. get "up" => "rails/health#show", as: :rails_health_check - # Defines the root path route ("/") # root "posts#index" end diff --git a/backend/spec/factories/company_services.rb b/backend/spec/factories/company_services.rb index 235c287..1999de0 100644 --- a/backend/spec/factories/company_services.rb +++ b/backend/spec/factories/company_services.rb @@ -11,8 +11,8 @@ # FactoryBot.define do factory :company_service do - name { "MyString" } - contract_start_date { "2024-08-02 03:19:06" } - contract_end_date { "2024-08-02 03:19:06" } + name { Faker::Company.unique.name } + contract_start_date { Faker::Date.backward(days: 30) } + contract_end_date { Faker::Date.forward(days: 30) } end end diff --git a/backend/spec/requests/company_services_spec.rb b/backend/spec/requests/company_services_spec.rb index e443de0..7f659f6 100644 --- a/backend/spec/requests/company_services_spec.rb +++ b/backend/spec/requests/company_services_spec.rb @@ -1,127 +1,43 @@ require 'rails_helper' -# This spec was generated by rspec-rails when you ran the scaffold generator. -# It demonstrates how one might use RSpec to test the controller code that -# was generated by Rails when you ran the scaffold generator. -# -# It assumes that the implementation code is generated by the rails scaffold -# generator. If you are using any extension libraries to generate different -# controller code, this generated spec may or may not pass. -# -# It only uses APIs available in rails and/or rspec-rails. There are a number -# of tools you can use to make these specs even more expressive, but we're -# sticking to rails and rspec-rails APIs to keep things simple and stable. - RSpec.describe "/company_services", type: :request do - # This should return the minimal set of attributes required to create a valid - # CompanyService. As you add validations to CompanyService, be sure to - # adjust the attributes here as well. - let(:valid_attributes) { - skip("Add a hash of attributes valid for your model") - } + let!(:service_a) { create(:company_service)} + let!(:service_b) { create(:company_service)} + let!(:service_c) { create(:company_service)} - let(:invalid_attributes) { - skip("Add a hash of attributes invalid for your model") - } - - # This should return the minimal set of values that should be in the headers - # in order to pass any filters (e.g. authentication) defined in - # CompanyServicesController, or in your router and rack - # middleware. Be sure to keep this updated too. let(:valid_headers) { - {} + {"Content-Type" => "application/json"} } describe "GET /index" do it "renders a successful response" do - CompanyService.create! valid_attributes get company_services_url, headers: valid_headers, as: :json expect(response).to be_successful end end - describe "GET /show" do - it "renders a successful response" do - company_service = CompanyService.create! valid_attributes - get company_service_url(company_service), as: :json - expect(response).to be_successful - end + it 'returns the correct JSON structure' do + get company_services_url, headers: valid_headers, as: :json + json_response = JSON.parse(response.body) + + expect(json_response).to have_key('data') + expect(json_response['data'].length).to eq(3) + expect(json_response['data']).to match_array([ + { 'id' => service_a.id, 'name' => service_a.name }, + { 'id' => service_b.id, 'name' => service_b.name }, + { 'id' => service_c.id, 'name' => service_c.name } + ]) + expect(json_response).to have_key('status') + expect(json_response).to have_key('statusText') end - describe "POST /create" do - context "with valid parameters" do - it "creates a new CompanyService" do - expect { - post company_services_url, - params: { company_service: valid_attributes }, headers: valid_headers, as: :json - }.to change(CompanyService, :count).by(1) - end - - it "renders a JSON response with the new company_service" do - post company_services_url, - params: { company_service: valid_attributes }, headers: valid_headers, as: :json - expect(response).to have_http_status(:created) - expect(response.content_type).to match(a_string_including("application/json")) - end - end - - context "with invalid parameters" do - it "does not create a new CompanyService" do - expect { - post company_services_url, - params: { company_service: invalid_attributes }, as: :json - }.to change(CompanyService, :count).by(0) - end + it 'returns status 200' do + get company_services_url, headers: valid_headers, as: :json + json_response = JSON.parse(response.body) - it "renders a JSON response with errors for the new company_service" do - post company_services_url, - params: { company_service: invalid_attributes }, headers: valid_headers, as: :json - expect(response).to have_http_status(:unprocessable_entity) - expect(response.content_type).to match(a_string_including("application/json")) - end - end + expect(json_response['status']).to eq(200) + expect(json_response['statusText']).to eq('OK') end - describe "PATCH /update" do - context "with valid parameters" do - let(:new_attributes) { - skip("Add a hash of attributes valid for your model") - } - - it "updates the requested company_service" do - company_service = CompanyService.create! valid_attributes - patch company_service_url(company_service), - params: { company_service: new_attributes }, headers: valid_headers, as: :json - company_service.reload - skip("Add assertions for updated state") - end - it "renders a JSON response with the company_service" do - company_service = CompanyService.create! valid_attributes - patch company_service_url(company_service), - params: { company_service: new_attributes }, headers: valid_headers, as: :json - expect(response).to have_http_status(:ok) - expect(response.content_type).to match(a_string_including("application/json")) - end - end - - context "with invalid parameters" do - it "renders a JSON response with errors for the company_service" do - company_service = CompanyService.create! valid_attributes - patch company_service_url(company_service), - params: { company_service: invalid_attributes }, headers: valid_headers, as: :json - expect(response).to have_http_status(:unprocessable_entity) - expect(response.content_type).to match(a_string_including("application/json")) - end - end - end - - describe "DELETE /destroy" do - it "destroys the requested company_service" do - company_service = CompanyService.create! valid_attributes - expect { - delete company_service_url(company_service), headers: valid_headers, as: :json - }.to change(CompanyService, :count).by(-1) - end - end end diff --git a/backend/spec/routing/company_services_routing_spec.rb b/backend/spec/routing/company_services_routing_spec.rb index 1dd58ff..2638077 100644 --- a/backend/spec/routing/company_services_routing_spec.rb +++ b/backend/spec/routing/company_services_routing_spec.rb @@ -5,26 +5,5 @@ it "routes to #index" do expect(get: "/company_services").to route_to("company_services#index") end - - it "routes to #show" do - expect(get: "/company_services/1").to route_to("company_services#show", id: "1") - end - - - it "routes to #create" do - expect(post: "/company_services").to route_to("company_services#create") - end - - it "routes to #update via PUT" do - expect(put: "/company_services/1").to route_to("company_services#update", id: "1") - end - - it "routes to #update via PATCH" do - expect(patch: "/company_services/1").to route_to("company_services#update", id: "1") - end - - it "routes to #destroy" do - expect(delete: "/company_services/1").to route_to("company_services#destroy", id: "1") - end end end From e52ff35650da6d958cbca55c89c96a1e36d7b77f Mon Sep 17 00:00:00 2001 From: adrian peralta Date: Thu, 8 Aug 2024 01:49:14 +0000 Subject: [PATCH 02/33] config: #6 define endpoints needed by the frontend --- frontend/requests/01_company_services.http | 26 ++++++++++++++++++++++ 1 file changed, 26 insertions(+) create mode 100644 frontend/requests/01_company_services.http diff --git a/frontend/requests/01_company_services.http b/frontend/requests/01_company_services.http new file mode 100644 index 0000000..21a8e1d --- /dev/null +++ b/frontend/requests/01_company_services.http @@ -0,0 +1,26 @@ +## Endpoints - Gestion de Turnos (Shifts) +### 1st Dropdown - Get Company Services +GET http://rails:3000/company_services HTTP/1.1 +Accept: application/json + +### 2nd Dropdown - Get Weeks +GET http://rails:3000/company_services/:id/weeks HTTP/1.1 +Accept: application/json + + +### Engineers Table +GET http://rails:3000/company_services/:id/engineers?week=YYYY-WW HTTP/1.1 +Accept: application/json +### Shifts Table +GET http://rails:3000/company_services/:id/shifts?week=YYYY-WW HTTP/1.1 +Accept: application/json + + +## Endpoints - Gestion de Disponibilidad (Availability) +### Boton Editar Disponibilidad: Consultar Disponibilidad de ingenieros +GET http://rails:3000/company_services/:id/engineers/availability?week=YYYY-WW HTTP/1.1 +Accept: application/json +### Updates Engineer Availability +POST http://rails:3000/company_services/:id/engineers/availability HTTP/1.1 +Accept: application/json + From d36919e7c1d9f50b9cc3bf830c074365b698431f Mon Sep 17 00:00:00 2001 From: adrian peralta Date: Thu, 8 Aug 2024 03:38:51 +0000 Subject: [PATCH 03/33] feat: #6 enforce json request and responses --- .../app/controllers/application_controller.rb | 12 ++++ backend/config/routes.rb | 3 +- .../spec/requests/company_services_spec.rb | 68 +++++++++++-------- 3 files changed, 53 insertions(+), 30 deletions(-) diff --git a/backend/app/controllers/application_controller.rb b/backend/app/controllers/application_controller.rb index 4ac8823..5b2694b 100644 --- a/backend/app/controllers/application_controller.rb +++ b/backend/app/controllers/application_controller.rb @@ -1,2 +1,14 @@ class ApplicationController < ActionController::API + before_action :ensure_json_request + before_action :set_default_format + + private + def set_default_format + request.format = :json + end + + def ensure_json_request + return if request.format.json? + render json: { error: 'Not Acceptable' }, status: :not_acceptable + end end diff --git a/backend/config/routes.rb b/backend/config/routes.rb index 3e356be..2f78b2c 100644 --- a/backend/config/routes.rb +++ b/backend/config/routes.rb @@ -1,7 +1,6 @@ Rails.application.routes.draw do - resources :company_services, only: [:index] + resources :company_services, only: [:index], constraints: { format: 'json' } get "up" => "rails/health#show", as: :rails_health_check - # root "posts#index" end diff --git a/backend/spec/requests/company_services_spec.rb b/backend/spec/requests/company_services_spec.rb index 7f659f6..48bdd03 100644 --- a/backend/spec/requests/company_services_spec.rb +++ b/backend/spec/requests/company_services_spec.rb @@ -9,35 +9,47 @@ {"Content-Type" => "application/json"} } - describe "GET /index" do - it "renders a successful response" do - get company_services_url, headers: valid_headers, as: :json - expect(response).to be_successful + describe "GET /company_services" do + describe 'Response format' do + context "when format is json" do + it "returns a successful response" do + get company_services_url, headers: valid_headers, as: :json + expect(response).to be_successful + expect(response.content_type).to eq("application/json; charset=utf-8") + end + end + + context "when format is not json" do + it "returns a not acceptable response" do + get company_services_url, headers: valid_headers, as: :html + expect(response).to have_http_status(:not_acceptable) + end + end end - end - - it 'returns the correct JSON structure' do - get company_services_url, headers: valid_headers, as: :json - json_response = JSON.parse(response.body) - - expect(json_response).to have_key('data') - expect(json_response['data'].length).to eq(3) - expect(json_response['data']).to match_array([ - { 'id' => service_a.id, 'name' => service_a.name }, - { 'id' => service_b.id, 'name' => service_b.name }, - { 'id' => service_c.id, 'name' => service_c.name } - ]) - expect(json_response).to have_key('status') - expect(json_response).to have_key('statusText') - end - - it 'returns status 200' do - get company_services_url, headers: valid_headers, as: :json - json_response = JSON.parse(response.body) - expect(json_response['status']).to eq(200) - expect(json_response['statusText']).to eq('OK') + describe 'Response structure' do + it 'returns the correct JSON structure' do + get company_services_url, headers: valid_headers, as: :json + json_response = JSON.parse(response.body) + + expect(json_response).to have_key('data') + expect(json_response['data'].length).to eq(3) + expect(json_response['data']).to match_array([ + { 'id' => service_a.id, 'name' => service_a.name }, + { 'id' => service_b.id, 'name' => service_b.name }, + { 'id' => service_c.id, 'name' => service_c.name } + ]) + expect(json_response).to have_key('status') + expect(json_response).to have_key('statusText') + end + + it 'returns status 200' do + get company_services_url, headers: valid_headers, as: :json + json_response = JSON.parse(response.body) + + expect(json_response['status']).to eq(200) + expect(json_response['statusText']).to eq('OK') + end + end end - - end From 5f7ec9235f8e68218f2305eb1ceddcc0910d88b5 Mon Sep 17 00:00:00 2001 From: adrian peralta Date: Thu, 8 Aug 2024 04:29:49 +0000 Subject: [PATCH 04/33] feat: #6 handle errors when request unknown paths --- .../app/controllers/application_controller.rb | 8 +++++++ backend/config/routes.rb | 1 + backend/spec/requests/errors_spec.rb | 21 +++++++++++++++++++ backend/spec/routing/errors_routing_spec.rb | 11 ++++++++++ 4 files changed, 41 insertions(+) create mode 100644 backend/spec/requests/errors_spec.rb create mode 100644 backend/spec/routing/errors_routing_spec.rb diff --git a/backend/app/controllers/application_controller.rb b/backend/app/controllers/application_controller.rb index 5b2694b..7ead391 100644 --- a/backend/app/controllers/application_controller.rb +++ b/backend/app/controllers/application_controller.rb @@ -1,7 +1,14 @@ class ApplicationController < ActionController::API + rescue_from ActiveRecord::RecordNotFound, with: :render_not_found + rescue_from ActionController::RoutingError, with: :render_not_found + before_action :ensure_json_request before_action :set_default_format + def render_not_found + render json: { error: 'Not Found' }, status: :not_found + end + private def set_default_format request.format = :json @@ -11,4 +18,5 @@ def ensure_json_request return if request.format.json? render json: { error: 'Not Acceptable' }, status: :not_acceptable end + end diff --git a/backend/config/routes.rb b/backend/config/routes.rb index 2f78b2c..641a098 100644 --- a/backend/config/routes.rb +++ b/backend/config/routes.rb @@ -3,4 +3,5 @@ get "up" => "rails/health#show", as: :rails_health_check + match '*unmatched', to: 'application#render_not_found', via: :all end diff --git a/backend/spec/requests/errors_spec.rb b/backend/spec/requests/errors_spec.rb new file mode 100644 index 0000000..744da17 --- /dev/null +++ b/backend/spec/requests/errors_spec.rb @@ -0,0 +1,21 @@ +# spec/requests/errors_spec.rb +require 'rails_helper' + +RSpec.describe "Error Handling", type: :request do + describe 'GET /unknown' do + context "when format is json" do + it 'returns a 404 Not Found' do + get '/unknown', as: :json + expect(response).to have_http_status(:not_found) + expect(response.body).to include('Not Found') + end + end + + context "when format is not json" do + it 'returns a not acceptable response' do + get '/unknown', as: :html + expect(response).to have_http_status(:not_acceptable) + end + end + end +end diff --git a/backend/spec/routing/errors_routing_spec.rb b/backend/spec/routing/errors_routing_spec.rb new file mode 100644 index 0000000..6d8c65c --- /dev/null +++ b/backend/spec/routing/errors_routing_spec.rb @@ -0,0 +1,11 @@ +require 'rails_helper' + +RSpec.describe ApplicationController, type: :routing do + it "routes unmatched paths to the render_not_found action" do + expect(get: "/unknown_path").to route_to( + controller: "application", + action: "render_not_found", + unmatched: "unknown_path" + ) + end +end \ No newline at end of file From 381f54f1cd495dc485998e3cabf85089a2f9267d Mon Sep 17 00:00:00 2001 From: adrian peralta Date: Thu, 8 Aug 2024 04:34:03 +0000 Subject: [PATCH 05/33] feat: #6 define weeks route --- backend/app/controllers/weeks_controller.rb | 4 ++++ backend/config/routes.rb | 4 +++- backend/spec/routing/weeks_routing_spec.rb | 13 +++++++++++++ 3 files changed, 20 insertions(+), 1 deletion(-) create mode 100644 backend/app/controllers/weeks_controller.rb create mode 100644 backend/spec/routing/weeks_routing_spec.rb diff --git a/backend/app/controllers/weeks_controller.rb b/backend/app/controllers/weeks_controller.rb new file mode 100644 index 0000000..529d7b5 --- /dev/null +++ b/backend/app/controllers/weeks_controller.rb @@ -0,0 +1,4 @@ +class WeeksController < ApplicationController + def index + end +end diff --git a/backend/config/routes.rb b/backend/config/routes.rb index 641a098..74b8c3d 100644 --- a/backend/config/routes.rb +++ b/backend/config/routes.rb @@ -1,5 +1,7 @@ Rails.application.routes.draw do - resources :company_services, only: [:index], constraints: { format: 'json' } + resources :company_services, only: [:index], constraints: { format: 'json' } do + resources :weeks, only: [:index] + end get "up" => "rails/health#show", as: :rails_health_check diff --git a/backend/spec/routing/weeks_routing_spec.rb b/backend/spec/routing/weeks_routing_spec.rb new file mode 100644 index 0000000..5f22769 --- /dev/null +++ b/backend/spec/routing/weeks_routing_spec.rb @@ -0,0 +1,13 @@ +require 'rails_helper' + +RSpec.describe WeeksController, type: :routing do + describe 'routing' do + it 'routes to the weeks index for a specific company service' do + expect(get: '/company_services/1/weeks').to route_to( + controller: 'weeks', + action: 'index', + company_service_id: '1' + ) + end + end +end From f0f4b7811eede00e871bed7b84314d850e9bf5d9 Mon Sep 17 00:00:00 2001 From: adrian peralta Date: Thu, 8 Aug 2024 05:05:13 +0000 Subject: [PATCH 06/33] refactor: #6 move Json response related logic from application controller to concern --- .../app/controllers/application_controller.rb | 21 +--------------- backend/app/controllers/concerns/.keep | 0 .../app/controllers/concerns/json_response.rb | 25 +++++++++++++++++++ 3 files changed, 26 insertions(+), 20 deletions(-) delete mode 100644 backend/app/controllers/concerns/.keep create mode 100644 backend/app/controllers/concerns/json_response.rb diff --git a/backend/app/controllers/application_controller.rb b/backend/app/controllers/application_controller.rb index 7ead391..36885eb 100644 --- a/backend/app/controllers/application_controller.rb +++ b/backend/app/controllers/application_controller.rb @@ -1,22 +1,3 @@ class ApplicationController < ActionController::API - rescue_from ActiveRecord::RecordNotFound, with: :render_not_found - rescue_from ActionController::RoutingError, with: :render_not_found - - before_action :ensure_json_request - before_action :set_default_format - - def render_not_found - render json: { error: 'Not Found' }, status: :not_found - end - - private - def set_default_format - request.format = :json - end - - def ensure_json_request - return if request.format.json? - render json: { error: 'Not Acceptable' }, status: :not_acceptable - end - + include JsonResponse end diff --git a/backend/app/controllers/concerns/.keep b/backend/app/controllers/concerns/.keep deleted file mode 100644 index e69de29..0000000 diff --git a/backend/app/controllers/concerns/json_response.rb b/backend/app/controllers/concerns/json_response.rb new file mode 100644 index 0000000..42b63b3 --- /dev/null +++ b/backend/app/controllers/concerns/json_response.rb @@ -0,0 +1,25 @@ +module JsonResponse + extend ActiveSupport::Concern + + included do + rescue_from ActiveRecord::RecordNotFound, with: :render_not_found + rescue_from ActionController::RoutingError, with: :render_not_found + + before_action :ensure_json_request + before_action :set_default_format + end + + def render_not_found + render json: { error: 'Not Found' }, status: :not_found + end + + private + def set_default_format + request.format = :json + end + + def ensure_json_request + return if request.format.json? + render json: { error: 'Not Acceptable' }, status: :not_acceptable + end +end From 560263b9eb089054a951f122dd9288bd99d3f0f8 Mon Sep 17 00:00:00 2001 From: adrian peralta Date: Thu, 8 Aug 2024 16:55:08 +0000 Subject: [PATCH 07/33] feat: #6 define weeks basic structure --- backend/app/views/weeks/index.json.jbuilder | 11 +++++++ backend/spec/requests/weeks_spec.rb | 34 +++++++++++++++++++++ 2 files changed, 45 insertions(+) create mode 100644 backend/app/views/weeks/index.json.jbuilder create mode 100644 backend/spec/requests/weeks_spec.rb diff --git a/backend/app/views/weeks/index.json.jbuilder b/backend/app/views/weeks/index.json.jbuilder new file mode 100644 index 0000000..3591cd4 --- /dev/null +++ b/backend/app/views/weeks/index.json.jbuilder @@ -0,0 +1,11 @@ +json.data do + json.past do + json.info "past" + end + json.future do + json.info "future" + end +end + +json.status 200 +json.statusText "OK" \ No newline at end of file diff --git a/backend/spec/requests/weeks_spec.rb b/backend/spec/requests/weeks_spec.rb new file mode 100644 index 0000000..a0619c6 --- /dev/null +++ b/backend/spec/requests/weeks_spec.rb @@ -0,0 +1,34 @@ +require 'rails_helper' + +RSpec.describe "Weeks", type: :request do + let!(:company_service) { create(:company_service) } + + let(:valid_headers) { + {"Content-Type" => "application/json"} + } + + describe "GET /company_services/:id/weeks" do + before { + get company_service_weeks_path(company_service.id), + headers: valid_headers, + as: :json + } + + it 'returns a success response' do + expect(response).to have_http_status(:success) + end + + it 'returns the correct JSON structure' do + json_response = JSON.parse(response.body) + expect(json_response).to have_key('data') + expect(json_response).to have_key('status') + expect(json_response).to have_key('statusText') + + expect(json_response['data']).to include('past', 'future') + expect(json_response['status']).to eq(200) + expect(json_response['statusText']).to eq("OK") + end + + end + +end From 5a652c612c09decef8814a18c6b6750dc963ebe6 Mon Sep 17 00:00:00 2001 From: adrian peralta Date: Thu, 8 Aug 2024 17:17:48 +0000 Subject: [PATCH 08/33] feat: #6 endpoint has correct weeks data --- backend/app/controllers/weeks_controller.rb | 12 ++++++++++++ backend/app/views/weeks/_week.json.jbuilder | 1 + backend/app/views/weeks/index.json.jbuilder | 4 ++-- backend/spec/requests/weeks_spec.rb | 14 ++++++++++++++ 4 files changed, 29 insertions(+), 2 deletions(-) create mode 100644 backend/app/views/weeks/_week.json.jbuilder diff --git a/backend/app/controllers/weeks_controller.rb b/backend/app/controllers/weeks_controller.rb index 529d7b5..ec5280d 100644 --- a/backend/app/controllers/weeks_controller.rb +++ b/backend/app/controllers/weeks_controller.rb @@ -1,4 +1,16 @@ class WeeksController < ApplicationController def index + @weeks_past = [{ + id: "2024-32", + label: "Semana 32 del 2024", + start_date: "05/08/2024", + end_date: "11/08/2024" + }] + @weeks_future = [{ + id: "2024-33", + label: "Semana 33 del 2024", + start_date: "12/08/2024", + end_date: "18/08/2024" + }] end end diff --git a/backend/app/views/weeks/_week.json.jbuilder b/backend/app/views/weeks/_week.json.jbuilder new file mode 100644 index 0000000..ef864ac --- /dev/null +++ b/backend/app/views/weeks/_week.json.jbuilder @@ -0,0 +1 @@ +json.extract! week, :id, :label, :start_date, :end_date \ No newline at end of file diff --git a/backend/app/views/weeks/index.json.jbuilder b/backend/app/views/weeks/index.json.jbuilder index 3591cd4..d421fb7 100644 --- a/backend/app/views/weeks/index.json.jbuilder +++ b/backend/app/views/weeks/index.json.jbuilder @@ -1,9 +1,9 @@ json.data do json.past do - json.info "past" + json.array! @weeks_past, partial: "weeks/week", as: :week end json.future do - json.info "future" + json.array! @weeks_future, partial: "weeks/week", as: :week end end diff --git a/backend/spec/requests/weeks_spec.rb b/backend/spec/requests/weeks_spec.rb index a0619c6..72dc3eb 100644 --- a/backend/spec/requests/weeks_spec.rb +++ b/backend/spec/requests/weeks_spec.rb @@ -29,6 +29,20 @@ expect(json_response['statusText']).to eq("OK") end + it 'returns the correct weeks data' do + json_response = JSON.parse(response.body) + + expect(json_response['data']['future']).to be_an(Array) + + # Check the format of the weeks + expect(json_response['data']['future']).to all(include('id', 'label', 'start_date', 'end_date')) + + week_identifier_format = /^\d{4}-\d{2}$/ #YYYY-WWW + expect(json_response['data']['future']).to all(have_key('id')) + expect(json_response['data']['future']).to all( + include('id' => match(/^\d{4}-\d{2}$/)) + ) + end end end From 001406a35ec70ed91a4e61b3f7d6d04ad565a426 Mon Sep 17 00:00:00 2001 From: adrian peralta Date: Thu, 8 Aug 2024 22:35:20 +0000 Subject: [PATCH 09/33] feat: #6 show past and future weeks based on company service id --- backend/app/controllers/weeks_controller.rb | 13 +- backend/app/services/week_service.rb | 68 +++++++++ backend/spec/services/week_service_spec.rb | 161 ++++++++++++++++++++ 3 files changed, 230 insertions(+), 12 deletions(-) create mode 100644 backend/app/services/week_service.rb create mode 100644 backend/spec/services/week_service_spec.rb diff --git a/backend/app/controllers/weeks_controller.rb b/backend/app/controllers/weeks_controller.rb index ec5280d..7079b96 100644 --- a/backend/app/controllers/weeks_controller.rb +++ b/backend/app/controllers/weeks_controller.rb @@ -1,16 +1,5 @@ class WeeksController < ApplicationController def index - @weeks_past = [{ - id: "2024-32", - label: "Semana 32 del 2024", - start_date: "05/08/2024", - end_date: "11/08/2024" - }] - @weeks_future = [{ - id: "2024-33", - label: "Semana 33 del 2024", - start_date: "12/08/2024", - end_date: "18/08/2024" - }] + @weeks = WeekService.generate_weeks_data(params[:company_service_id]) end end diff --git a/backend/app/services/week_service.rb b/backend/app/services/week_service.rb new file mode 100644 index 0000000..bc6da8f --- /dev/null +++ b/backend/app/services/week_service.rb @@ -0,0 +1,68 @@ +class WeekService + + def self.generate_weeks_data(company_service_id) + company_service = CompanyService.find(company_service_id) + start_date = company_service.contract_start_date.to_date + end_date = company_service.contract_end_date.to_date + + current_date = Date.today + past_weeks = [] + future_weeks = [] + + if date_in_contract?(start_date, end_date, current_date) + past_weeks = fetch_past(current_date, start_date) + future_weeks = fetch_future(current_date, end_date) + else + past_weeks = fetch_past(end_date, start_date) + end + { past: past_weeks, future: future_weeks } + end + + ## REFACTOR: make private, just validate based on 2 contracts + def self.date_in_contract?(start_date, end_date, date) + # service.contract_start_date <= date && date <= service.contract_end_date + (start_date..end_date).cover?(date) + end + + def self.week_identifier(date) + "#{date.year}-#{date.strftime('%W')}" + end + + def self.one_week(week_start, week_end) + { + id: week_identifier(week_start), + label: "Semana #{week_start.strftime('%W')} del #{week_start.year}", + start_date: week_start.strftime('%d/%m/%Y'), + end_date: week_end.strftime('%d/%m/%Y') + } + end + + def self.fetch_past(date, start_date_limit) + first_monday = start_date_limit.beginning_of_week + weeks = [] + mondays = [date.beginning_of_week] + while mondays.last > first_monday + week_start = mondays.last.last_week + week_end = week_start.end_of_week + weeks << one_week(week_start, week_end) + mondays << week_start + end + weeks + end + + def self.fetch_future(date, end_date_limit) + weeks = [] + week_start = date.beginning_of_week + 5.times do + week_end = week_start.end_of_week + + break if week_start > end_date_limit + weeks << one_week(week_start, week_end) + + week_start = week_start.next_week + end + + weeks + end + +end \ No newline at end of file diff --git a/backend/spec/services/week_service_spec.rb b/backend/spec/services/week_service_spec.rb new file mode 100644 index 0000000..ce83e4b --- /dev/null +++ b/backend/spec/services/week_service_spec.rb @@ -0,0 +1,161 @@ +require 'rails_helper' + +RSpec.describe WeekService, type: :service do + describe '.week_identifier' do + it 'returns the correct week identifier' do + date = Date.new(2024, 8, 5) # Lunes 5 de Agosto del 2024 + expected_identifier = "2024-32" + + result = WeekService.week_identifier(date) + + expect(result).to eq(expected_identifier) + end + end + + describe '.one_week' do + it 'returns the correct week structure' do + week_start = Date.new(2024, 8, 5) # Lunes 5 de Agosto del 2024 + week_end = week_start.end_of_week # Domingo 11 de Agosto del 2024 + + expected_week = { + id: "2024-32", + label: "Semana 32 del 2024", + start_date: "05/08/2024", + end_date: "11/08/2024" + } + + result = WeekService.one_week(week_start, week_end) + + expect(result).to eq(expected_week) + end + end + + describe '.fetch_past' do + it 'returns all past weeks until the contract start date' do + today = Date.new(2024, 8, 5) ## Lunes 5 de Agosto del 2024 + start_date_limit = Date.new(2024, 7, 15) # Lunes 15 de Julio del 2024 + + expected_past_weeks = [ + { + id: "2024-31", + label: "Semana 31 del 2024", + start_date: "29/07/2024", + end_date: "04/08/2024" + }, + { + id: "2024-30", + label: "Semana 30 del 2024", + start_date: "22/07/2024", + end_date: "28/07/2024" + }, + { + id: "2024-29", + label: "Semana 29 del 2024", + start_date: "15/07/2024", + end_date: "21/07/2024" + } + ] + + result = WeekService.fetch_past(today, start_date_limit) + + expect(result).to eq(expected_past_weeks) + end + end + describe '.fetch_future' do + context 'when there are enough weeks before the end_date_limit' do + it 'returns 5 weeks' do + date = Date.new(2024, 8, 15) # Jueves 15 de Agosto del 2024 + end_date_limit = Date.new(2024, 10, 31) # 31 de Octubre del 2024 + + expected_future_weeks = [ + { + id: "2024-33", + label: "Semana 33 del 2024", + start_date: "12/08/2024", + end_date: "18/08/2024" + }, + { + id: "2024-34", + label: "Semana 34 del 2024", + start_date: "19/08/2024", + end_date: "25/08/2024" + }, + { + id: "2024-35", + label: "Semana 35 del 2024", + start_date: "26/08/2024", + end_date: "01/09/2024" + }, + { + id: "2024-36", + label: "Semana 36 del 2024", + start_date: "02/09/2024", + end_date: "08/09/2024" + }, + { + id: "2024-37", + label: "Semana 37 del 2024", + start_date: "09/09/2024", + end_date: "15/09/2024" + } + ] + + result = WeekService.fetch_future(date, end_date_limit) + + expect(result).to eq(expected_future_weeks) + end + end + + context 'when the end_date_limit restricts the number of weeks' do + it 'returns only the weeks within the end_date_limit' do + date = Date.new(2024, 8, 15) # Jueves 15 de Agosto del 2024 + end_date_limit = Date.new(2024, 9, 1) # Domingo 1 de Setiembre del 2024 + + expected_future_weeks = [ + { + id: "2024-33", + label: "Semana 33 del 2024", + start_date: "12/08/2024", + end_date: "18/08/2024" + }, + { + id: "2024-34", + label: "Semana 34 del 2024", + start_date: "19/08/2024", + end_date: "25/08/2024" + }, + { + id: "2024-35", + label: "Semana 35 del 2024", + start_date: "26/08/2024", + end_date: "01/09/2024" + } + ] + + result = WeekService.fetch_future(date, end_date_limit) + + expect(result).to eq(expected_future_weeks) + end + end + + context 'when the end_date_limit is very close' do + it 'returns only one week if limit restricts it to 1 week' do + date = Date.new(2024, 8, 15) # Jueves 15 de Agosto del 2024 + end_date_limit = Date.new(2024, 8, 18) # Only allows for 1 week + + expected_future_weeks = [ + { + id: "2024-33", + label: "Semana 33 del 2024", + start_date: "12/08/2024", + end_date: "18/08/2024" + } + ] + + result = WeekService.fetch_future(date, end_date_limit) + + expect(result).to eq(expected_future_weeks) + end + end + end +end From c52a6ebd945711f8a831c37abde3b5ee52adb85b Mon Sep 17 00:00:00 2001 From: adrian peralta Date: Thu, 8 Aug 2024 23:12:00 +0000 Subject: [PATCH 10/33] config: #6 update github test workflow --- .github/workflows/test.yml | 34 ++++++++++++++++++++++++++++++++-- README.md | 5 +++-- 2 files changed, 35 insertions(+), 4 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 799f7e6..897f759 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -6,6 +6,22 @@ jobs: backend: name: 'Rails API Tests' runs-on: ubuntu-latest + + services: + postgres: + image: postgres:latest + env: + POSTGRES_USER: postgres_user + POSTGRES_PASSWORD: testpassword + POSTGRES_DB: monitoring_sys_test + ports: + - 5432:5432 + options: >- + --health-cmd "pg_isready -U postgres" + --health-interval 10s + --health-timeout 5s + --health-retries 5 + steps: - name: Checkout code uses: actions/checkout@v4 @@ -18,12 +34,26 @@ jobs: - name: Install dependencies run: | cd backend + gem install bundler bundle install - - name: Run Rails tests + - name: Set up environment variables + run: | + echo "DATABASE_HOST=localhost" >> $GITHUB_ENV + echo "DATABASE_USER=postgres_user" >> $GITHUB_ENV + echo "DATABASE_PASSWORD=testpassword" >> $GITHUB_ENV + + - name: Set up database + run: | + cd backend + RAILS_ENV=test rails db:create + RAILS_ENV=test rails db:migrate + RAILS_ENV=test rails db:seed + + - name: Run RSpec tests run: | cd backend - bundle exec rspec + RAILS_ENV=test bundle exec rspec frontend: name: 'Vue Frontend Tests' diff --git a/README.md b/README.md index df56d70..5ea8d39 100644 --- a/README.md +++ b/README.md @@ -77,12 +77,13 @@ - abrir command palette: ctrl + shift + p - Seleccionar: Reopen in container - Seleccionar: "Rails API Container" - - Dentro ejecutar: `rails s` + - Dentro ejecutar: `rails s -b 0.0.0.0` - Ejecutar el contenedor Vue Container: - abrir command palette: ctrl + shift + p - Seleccionar: Reopen in container - Seleccionar: "Vue Container" - - Dentro ejecutar: `yarn dev` + - Dentro ejecutar mocked: `yarn dev` + - Dentro ejecutar api: `yarn serve:api` - navegar a 0.0.0.0:8080 para empezar a usar la app - Ejecutar tests e2e: ```bash From 7b54b7bbbac523b04c65542044fbd6c57ec8b38e Mon Sep 17 00:00:00 2001 From: adrian peralta Date: Thu, 8 Aug 2024 23:19:00 +0000 Subject: [PATCH 11/33] feat: #6 add missing files --- backend/app/controllers/concerns/json_response.rb | 7 ++++--- backend/app/views/weeks/index.json.jbuilder | 4 ++-- frontend/requests/01_company_services.http | 6 +++++- 3 files changed, 11 insertions(+), 6 deletions(-) diff --git a/backend/app/controllers/concerns/json_response.rb b/backend/app/controllers/concerns/json_response.rb index 42b63b3..093368a 100644 --- a/backend/app/controllers/concerns/json_response.rb +++ b/backend/app/controllers/concerns/json_response.rb @@ -7,10 +7,11 @@ module JsonResponse before_action :ensure_json_request before_action :set_default_format - end - def render_not_found - render json: { error: 'Not Found' }, status: :not_found + def render_not_found + render json: { error: 'Not Found' }, status: :not_found + end + end private diff --git a/backend/app/views/weeks/index.json.jbuilder b/backend/app/views/weeks/index.json.jbuilder index d421fb7..3b4ed56 100644 --- a/backend/app/views/weeks/index.json.jbuilder +++ b/backend/app/views/weeks/index.json.jbuilder @@ -1,9 +1,9 @@ json.data do json.past do - json.array! @weeks_past, partial: "weeks/week", as: :week + json.array! @weeks[:past], partial: "weeks/week", as: :week end json.future do - json.array! @weeks_future, partial: "weeks/week", as: :week + json.array! @weeks[:future], partial: "weeks/week", as: :week end end diff --git a/frontend/requests/01_company_services.http b/frontend/requests/01_company_services.http index 21a8e1d..0876fc1 100644 --- a/frontend/requests/01_company_services.http +++ b/frontend/requests/01_company_services.http @@ -4,7 +4,7 @@ GET http://rails:3000/company_services HTTP/1.1 Accept: application/json ### 2nd Dropdown - Get Weeks -GET http://rails:3000/company_services/:id/weeks HTTP/1.1 +GET http://rails:3000/company_services/1/weeks HTTP/1.1 Accept: application/json @@ -24,3 +24,7 @@ Accept: application/json POST http://rails:3000/company_services/:id/engineers/availability HTTP/1.1 Accept: application/json + +### error handling +GET http://rails:3000/abc HTTP/1.1 +Accept: application/json \ No newline at end of file From d8bc3632f9668b85f69a59bb7a339957283650a1 Mon Sep 17 00:00:00 2001 From: adrian peralta Date: Fri, 9 Aug 2024 01:57:17 +0000 Subject: [PATCH 12/33] feat: #6 update frontend api requests to ensure json format --- README.md | 2 +- frontend/src/api/AvailabilityApi.ts | 8 +++++++- frontend/src/api/CompanyServiceApi.ts | 27 +++++++++++++++++++++++---- 3 files changed, 31 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 5ea8d39..9d2677a 100644 --- a/README.md +++ b/README.md @@ -83,7 +83,7 @@ - Seleccionar: Reopen in container - Seleccionar: "Vue Container" - Dentro ejecutar mocked: `yarn dev` - - Dentro ejecutar api: `yarn serve:api` + - (alternativa) Dentro ejecutar api: `yarn serve:api` - navegar a 0.0.0.0:8080 para empezar a usar la app - Ejecutar tests e2e: ```bash diff --git a/frontend/src/api/AvailabilityApi.ts b/frontend/src/api/AvailabilityApi.ts index 6fbf548..aaa6e0b 100644 --- a/frontend/src/api/AvailabilityApi.ts +++ b/frontend/src/api/AvailabilityApi.ts @@ -20,7 +20,12 @@ export const requestAvailabilities = async ( } else { try { const response = await fetch( - `/api/company_services/${serviceId}//engineers/availability?week=${weekId}` + `/api/company_services/${serviceId}//engineers/availability?week=${weekId}`, + { + headers: { + Accept: "application/json", + }, + } ); if (!response.ok) { throw new Error(`HTTP error! status: ${response.status}`); @@ -49,6 +54,7 @@ export const storeAvailabilities = async ( method: "POST", headers: { "Content-Type": "application/json", + Accept: "application/json", }, body: JSON.stringify(availabilityPayload), } diff --git a/frontend/src/api/CompanyServiceApi.ts b/frontend/src/api/CompanyServiceApi.ts index d5aaafd..b9c7298 100644 --- a/frontend/src/api/CompanyServiceApi.ts +++ b/frontend/src/api/CompanyServiceApi.ts @@ -18,7 +18,12 @@ export const fetchCompanyServices = async (): Promise => { }); } else { try { - const response = await fetch("/api/company_services"); + // const response = await fetch("/api/company_services"); + const response = await fetch("/api/company_services", { + headers: { + Accept: "application/json", + }, + }); if (!response.ok) { throw new Error(`HTTP error! status: ${response.status}`); } @@ -45,7 +50,11 @@ export const requestWeeks = async (serviceId: number): Promise => { }); } else { try { - const response = await fetch(`/api/company_services/${serviceId}/weeks`); + const response = await fetch(`/api/company_services/${serviceId}/weeks`, { + headers: { + Accept: "application/json", + }, + }); if (!response.ok) { throw new Error(`HTTP error! status: ${response.status}`); } @@ -76,7 +85,12 @@ export const requestShifts = async ( } else { try { const response = await fetch( - `/api/company_services/${serviceId}/shifts?week=${weekId}` + `/api/company_services/${serviceId}/shifts?week=${weekId}`, + { + headers: { + Accept: "application/json", + }, + } ); if (!response.ok) { throw new Error(`HTTP error! status: ${response.status}`); @@ -108,7 +122,12 @@ export const requestEngineers = async ( } else { try { const response = await fetch( - `/api/company_services/${serviceId}/engineers?week=${weekId}` + `/api/company_services/${serviceId}/engineers?week=${weekId}`, + { + headers: { + Accept: "application/json", + }, + } ); if (!response.ok) { throw new Error(`HTTP error! status: ${response.status}`); From d70d5b0626977c8b3b38ad1e200e5e0a83500c34 Mon Sep 17 00:00:00 2001 From: adrian peralta Date: Fri, 9 Aug 2024 02:59:57 +0000 Subject: [PATCH 13/33] feat: #6 define service engineers route --- backend/config/routes.rb | 1 + backend/spec/routing/engineers_routing_spec.rb | 13 +++++++++++++ 2 files changed, 14 insertions(+) create mode 100644 backend/spec/routing/engineers_routing_spec.rb diff --git a/backend/config/routes.rb b/backend/config/routes.rb index 74b8c3d..bfe4b5d 100644 --- a/backend/config/routes.rb +++ b/backend/config/routes.rb @@ -1,6 +1,7 @@ Rails.application.routes.draw do resources :company_services, only: [:index], constraints: { format: 'json' } do resources :weeks, only: [:index] + resources :engineers, only: [:index], module: :company_services end get "up" => "rails/health#show", as: :rails_health_check diff --git a/backend/spec/routing/engineers_routing_spec.rb b/backend/spec/routing/engineers_routing_spec.rb new file mode 100644 index 0000000..d16d1c0 --- /dev/null +++ b/backend/spec/routing/engineers_routing_spec.rb @@ -0,0 +1,13 @@ +require 'rails_helper' + +RSpec.describe CompanyServices::EngineersController, type: :routing do + it 'routes to the engineers assigned to an specific company service' do + expect(get: '/company_services/1/engineers?week=2024-36').to route_to( + controller: 'company_services/engineers', + action: 'index', + company_service_id: '1', + week: '2024-36' + ) + end +end + From d663d23ff8aa129d27a7c7535c72b9d629f45042 Mon Sep 17 00:00:00 2001 From: adrian peralta Date: Fri, 9 Aug 2024 17:19:59 +0000 Subject: [PATCH 14/33] feat: #6 define engineers controller, model, factories and seeds --- .../company_services/engineers_controller.rb | 10 +++++++ backend/app/models/engineer.rb | 2 ++ .../engineers/_engineer.json.jbuilder | 1 + .../engineers/index.json.jbuilder | 1 + .../engineers/show.json.jbuilder | 1 + .../20240809024958_create_engineers.rb | 10 +++++++ backend/db/schema.rb | 9 ++++++- backend/db/seeds.rb | 15 ++++++----- backend/spec/factories/company_services.rb | 4 +-- backend/spec/factories/engineers.rb | 6 +++++ backend/spec/requests/engineers_spec.rb | 26 +++++++++++++++++++ 11 files changed, 75 insertions(+), 10 deletions(-) create mode 100644 backend/app/controllers/company_services/engineers_controller.rb create mode 100644 backend/app/models/engineer.rb create mode 100644 backend/app/views/company_services/engineers/_engineer.json.jbuilder create mode 100644 backend/app/views/company_services/engineers/index.json.jbuilder create mode 100644 backend/app/views/company_services/engineers/show.json.jbuilder create mode 100644 backend/db/migrate/20240809024958_create_engineers.rb create mode 100644 backend/spec/factories/engineers.rb create mode 100644 backend/spec/requests/engineers_spec.rb diff --git a/backend/app/controllers/company_services/engineers_controller.rb b/backend/app/controllers/company_services/engineers_controller.rb new file mode 100644 index 0000000..c6b05f4 --- /dev/null +++ b/backend/app/controllers/company_services/engineers_controller.rb @@ -0,0 +1,10 @@ +module CompanyServices + class EngineersController < ApplicationController + # GET /engineers + # GET /engineers.json + def index + puts "REACH CONTROLLER" + @engineers = Engineer.all + end + end +end \ No newline at end of file diff --git a/backend/app/models/engineer.rb b/backend/app/models/engineer.rb new file mode 100644 index 0000000..e632d5f --- /dev/null +++ b/backend/app/models/engineer.rb @@ -0,0 +1,2 @@ +class Engineer < ApplicationRecord +end diff --git a/backend/app/views/company_services/engineers/_engineer.json.jbuilder b/backend/app/views/company_services/engineers/_engineer.json.jbuilder new file mode 100644 index 0000000..1a545fb --- /dev/null +++ b/backend/app/views/company_services/engineers/_engineer.json.jbuilder @@ -0,0 +1 @@ +json.extract! engineer, :id, :name, :color diff --git a/backend/app/views/company_services/engineers/index.json.jbuilder b/backend/app/views/company_services/engineers/index.json.jbuilder new file mode 100644 index 0000000..caff3bf --- /dev/null +++ b/backend/app/views/company_services/engineers/index.json.jbuilder @@ -0,0 +1 @@ +json.array! @engineers, partial: "company_services/engineers/engineer", as: :engineer diff --git a/backend/app/views/company_services/engineers/show.json.jbuilder b/backend/app/views/company_services/engineers/show.json.jbuilder new file mode 100644 index 0000000..e902769 --- /dev/null +++ b/backend/app/views/company_services/engineers/show.json.jbuilder @@ -0,0 +1 @@ +json.partial! "engineers/engineer", engineer: @engineer diff --git a/backend/db/migrate/20240809024958_create_engineers.rb b/backend/db/migrate/20240809024958_create_engineers.rb new file mode 100644 index 0000000..dc147bb --- /dev/null +++ b/backend/db/migrate/20240809024958_create_engineers.rb @@ -0,0 +1,10 @@ +class CreateEngineers < ActiveRecord::Migration[7.1] + def change + create_table :engineers do |t| + t.string :name + t.string :color + + t.timestamps + end + end +end diff --git a/backend/db/schema.rb b/backend/db/schema.rb index 8ea75aa..e052ca8 100644 --- a/backend/db/schema.rb +++ b/backend/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[7.1].define(version: 2024_08_02_031906) do +ActiveRecord::Schema[7.1].define(version: 2024_08_09_024958) do # These are extensions that must be enabled in order to support this database enable_extension "plpgsql" @@ -22,4 +22,11 @@ t.datetime "updated_at", null: false end + create_table "engineers", force: :cascade do |t| + t.string "name" + t.string "color" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + end + end diff --git a/backend/db/seeds.rb b/backend/db/seeds.rb index a32107f..671c87e 100644 --- a/backend/db/seeds.rb +++ b/backend/db/seeds.rb @@ -1,9 +1,10 @@ require 'faker' -10.times do - company_service = CompanyService.create( - name: Faker::Company.unique.name, - contract_start_date: Faker::Date.backward(days: 30), - contract_end_date: Faker::Date.forward(days: 30) - ) -end \ No newline at end of file +CompanyService.destroy_all +Engineer.destroy_all + +10.times { FactoryBot.create(:company_service) } +puts "10 Company Services Created" + +10.times { FactoryBot.create(:engineer) } +puts "10 Engineers Created" diff --git a/backend/spec/factories/company_services.rb b/backend/spec/factories/company_services.rb index 1999de0..3a16732 100644 --- a/backend/spec/factories/company_services.rb +++ b/backend/spec/factories/company_services.rb @@ -12,7 +12,7 @@ FactoryBot.define do factory :company_service do name { Faker::Company.unique.name } - contract_start_date { Faker::Date.backward(days: 30) } - contract_end_date { Faker::Date.forward(days: 30) } + contract_start_date { Faker::Date.between(from: '2024-01-01', to: '2024-12-31') } + contract_end_date { Faker::Date.between(from: '2024-01-01', to: '2024-12-31') } end end diff --git a/backend/spec/factories/engineers.rb b/backend/spec/factories/engineers.rb new file mode 100644 index 0000000..054c0fe --- /dev/null +++ b/backend/spec/factories/engineers.rb @@ -0,0 +1,6 @@ +FactoryBot.define do + factory :engineer do + name { Faker::Name.name } + color { Faker::Color.hex_color } + end +end diff --git a/backend/spec/requests/engineers_spec.rb b/backend/spec/requests/engineers_spec.rb new file mode 100644 index 0000000..5641b6c --- /dev/null +++ b/backend/spec/requests/engineers_spec.rb @@ -0,0 +1,26 @@ +require 'rails_helper' + +RSpec.describe "CompanyServices::Engineers", type: :request do + let!(:company_service) { create(:company_service) } + let(:week) { "2024-32" } + + let(:valid_headers) { + {"Content-Type" => "application/json"} + } + + before do + @engineer1 = create(:engineer, name: "Engineer 1", color: "#a5b4fc") + @engineer2 = create(:engineer, name: "Engineer 2", color: "#5eead4") + @engineer3 = create(:engineer, name: "Engineer 3", color: "#bef264") + end + + describe "GET /company_services/:company_service_id/engineers?week=YYYY-WW" do + it "renders a successful response" do + get company_service_engineers_url(company_service_id: company_service.id, week: week), + headers: valid_headers, + as: :json + expect(response).to be_successful + end + end + +end From 8f926eeede1eeb662cd2daa25461420f4072c945 Mon Sep 17 00:00:00 2001 From: adrian peralta Date: Fri, 9 Aug 2024 17:35:10 +0000 Subject: [PATCH 15/33] feat: #6 define json response structure --- .../engineers/index.json.jbuilder | 7 ++++++- backend/spec/requests/engineers_spec.rb | 16 ++++++++++++++++ 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/backend/app/views/company_services/engineers/index.json.jbuilder b/backend/app/views/company_services/engineers/index.json.jbuilder index caff3bf..98e1159 100644 --- a/backend/app/views/company_services/engineers/index.json.jbuilder +++ b/backend/app/views/company_services/engineers/index.json.jbuilder @@ -1 +1,6 @@ -json.array! @engineers, partial: "company_services/engineers/engineer", as: :engineer +json.data do + json.array! @engineers, partial: "company_services/engineers/engineer", as: :engineer +end + +json.status 200 +json.statusText "OK" \ No newline at end of file diff --git a/backend/spec/requests/engineers_spec.rb b/backend/spec/requests/engineers_spec.rb index 5641b6c..fd3dae7 100644 --- a/backend/spec/requests/engineers_spec.rb +++ b/backend/spec/requests/engineers_spec.rb @@ -21,6 +21,22 @@ as: :json expect(response).to be_successful end + it 'returns the correct JSON structure' do + get company_service_engineers_url(company_service_id: company_service.id, week: week), + headers: valid_headers, + as: :json + json_response = JSON.parse(response.body) + + expect(json_response).to have_key('data') + expect(json_response['data'].length).to eq(3) + expect(json_response['data']).to match_array([ + { 'id' => @engineer1.id, 'name' => @engineer1.name, 'color' => @engineer1.color }, + { 'id' => @engineer2.id, 'name' => @engineer2.name, 'color' => @engineer2.color }, + { 'id' => @engineer3.id, 'name' => @engineer3.name, 'color' => @engineer3.color } + ]) + expect(json_response).to have_key('status') + expect(json_response).to have_key('statusText') + end end end From 9f58463ebafd5b135d33c766a281d99189f89e9c Mon Sep 17 00:00:00 2001 From: adrian peralta Date: Fri, 9 Aug 2024 19:23:10 +0000 Subject: [PATCH 16/33] feat: #6 create company_service_engineer model --- backend/app/models/company_service_engineer.rb | 4 ++++ ...240809191347_create_company_service_engineers.rb | 10 ++++++++++ backend/db/schema.rb | 13 ++++++++++++- backend/spec/factories/company_service_engineers.rb | 6 ++++++ 4 files changed, 32 insertions(+), 1 deletion(-) create mode 100644 backend/app/models/company_service_engineer.rb create mode 100644 backend/db/migrate/20240809191347_create_company_service_engineers.rb create mode 100644 backend/spec/factories/company_service_engineers.rb diff --git a/backend/app/models/company_service_engineer.rb b/backend/app/models/company_service_engineer.rb new file mode 100644 index 0000000..7860dbb --- /dev/null +++ b/backend/app/models/company_service_engineer.rb @@ -0,0 +1,4 @@ +class CompanyServiceEngineer < ApplicationRecord + belongs_to :company_service + belongs_to :engineer +end diff --git a/backend/db/migrate/20240809191347_create_company_service_engineers.rb b/backend/db/migrate/20240809191347_create_company_service_engineers.rb new file mode 100644 index 0000000..c752796 --- /dev/null +++ b/backend/db/migrate/20240809191347_create_company_service_engineers.rb @@ -0,0 +1,10 @@ +class CreateCompanyServiceEngineers < ActiveRecord::Migration[7.1] + def change + create_table :company_service_engineers do |t| + t.references :company_service, null: false, foreign_key: true + t.references :engineer, null: false, foreign_key: true + + t.timestamps + end + end +end diff --git a/backend/db/schema.rb b/backend/db/schema.rb index e052ca8..563f33c 100644 --- a/backend/db/schema.rb +++ b/backend/db/schema.rb @@ -10,10 +10,19 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[7.1].define(version: 2024_08_09_024958) do +ActiveRecord::Schema[7.1].define(version: 2024_08_09_191347) do # These are extensions that must be enabled in order to support this database enable_extension "plpgsql" + create_table "company_service_engineers", force: :cascade do |t| + t.bigint "company_service_id", null: false + t.bigint "engineer_id", null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["company_service_id"], name: "index_company_service_engineers_on_company_service_id" + t.index ["engineer_id"], name: "index_company_service_engineers_on_engineer_id" + end + create_table "company_services", force: :cascade do |t| t.string "name" t.datetime "contract_start_date" @@ -29,4 +38,6 @@ t.datetime "updated_at", null: false end + add_foreign_key "company_service_engineers", "company_services" + add_foreign_key "company_service_engineers", "engineers" end diff --git a/backend/spec/factories/company_service_engineers.rb b/backend/spec/factories/company_service_engineers.rb new file mode 100644 index 0000000..649aa95 --- /dev/null +++ b/backend/spec/factories/company_service_engineers.rb @@ -0,0 +1,6 @@ +FactoryBot.define do + factory :company_service_engineer do + association :company_service + association :engineer + end +end From cc207d94e89b8f323046c7bf77a7a4412d1fa401 Mon Sep 17 00:00:00 2001 From: adrian peralta Date: Fri, 9 Aug 2024 19:26:42 +0000 Subject: [PATCH 17/33] feat: #6 validate associations between company service and engineers through CompanyServiceEngineer model --- backend/app/models/company_service.rb | 2 ++ backend/app/models/engineer.rb | 2 ++ backend/spec/models/company_service_engineer_spec.rb | 8 ++++++++ backend/spec/models/company_service_spec.rb | 5 ++++- backend/spec/models/engineer_spec.rb | 8 ++++++++ 5 files changed, 24 insertions(+), 1 deletion(-) create mode 100644 backend/spec/models/company_service_engineer_spec.rb create mode 100644 backend/spec/models/engineer_spec.rb diff --git a/backend/app/models/company_service.rb b/backend/app/models/company_service.rb index 3d16c72..3cb243a 100644 --- a/backend/app/models/company_service.rb +++ b/backend/app/models/company_service.rb @@ -10,4 +10,6 @@ # updated_at :datetime not null # class CompanyService < ApplicationRecord + has_many :company_service_engineers, dependent: :destroy + has_many :engineers, through: :company_service_engineers end diff --git a/backend/app/models/engineer.rb b/backend/app/models/engineer.rb index e632d5f..108584f 100644 --- a/backend/app/models/engineer.rb +++ b/backend/app/models/engineer.rb @@ -1,2 +1,4 @@ class Engineer < ApplicationRecord + has_many :company_service_engineers, dependent: :destroy + has_many :company_services, through: :company_service_engineers end diff --git a/backend/spec/models/company_service_engineer_spec.rb b/backend/spec/models/company_service_engineer_spec.rb new file mode 100644 index 0000000..28a8c97 --- /dev/null +++ b/backend/spec/models/company_service_engineer_spec.rb @@ -0,0 +1,8 @@ +require 'rails_helper' + +RSpec.describe CompanyServiceEngineer, type: :model do + describe 'associations' do + it { should belong_to(:company_service) } + it { should belong_to(:engineer) } + end +end diff --git a/backend/spec/models/company_service_spec.rb b/backend/spec/models/company_service_spec.rb index 469fcca..42d990d 100644 --- a/backend/spec/models/company_service_spec.rb +++ b/backend/spec/models/company_service_spec.rb @@ -12,5 +12,8 @@ require 'rails_helper' RSpec.describe CompanyService, type: :model do - pending "add some examples to (or delete) #{__FILE__}" + describe 'associations' do + it { should have_many(:company_service_engineers).dependent(:destroy) } + it { should have_many(:engineers).through(:company_service_engineers) } + end end diff --git a/backend/spec/models/engineer_spec.rb b/backend/spec/models/engineer_spec.rb new file mode 100644 index 0000000..3d77aad --- /dev/null +++ b/backend/spec/models/engineer_spec.rb @@ -0,0 +1,8 @@ +require 'rails_helper' + +RSpec.describe Engineer, type: :model do + describe 'associations' do + it { should have_many(:company_service_engineers).dependent(:destroy) } + it { should have_many(:company_services).through(:company_service_engineers) } + end +end From ecfd34438624d7243eb6e879cec31cd0cc129c18 Mon Sep 17 00:00:00 2001 From: adrian peralta Date: Fri, 9 Aug 2024 20:45:46 +0000 Subject: [PATCH 18/33] feat: #6 limit 3 engineers per company service --- backend/app/models/company_service_engineer.rb | 10 ++++++++++ .../models/company_service_engineer_spec.rb | 17 +++++++++++++++++ 2 files changed, 27 insertions(+) diff --git a/backend/app/models/company_service_engineer.rb b/backend/app/models/company_service_engineer.rb index 7860dbb..fa2ba9c 100644 --- a/backend/app/models/company_service_engineer.rb +++ b/backend/app/models/company_service_engineer.rb @@ -1,4 +1,14 @@ class CompanyServiceEngineer < ApplicationRecord belongs_to :company_service belongs_to :engineer + + validate :limited_engineers + + private + + def limited_engineers + if company_service.present? && company_service.company_service_engineers.count >= 3 + errors.add(:base, "Cannot assign more than 3 engineers to a company service") + end + end end diff --git a/backend/spec/models/company_service_engineer_spec.rb b/backend/spec/models/company_service_engineer_spec.rb index 28a8c97..1de3e61 100644 --- a/backend/spec/models/company_service_engineer_spec.rb +++ b/backend/spec/models/company_service_engineer_spec.rb @@ -5,4 +5,21 @@ it { should belong_to(:company_service) } it { should belong_to(:engineer) } end + + describe 'validations' do + it 'validates that no more than 3 engineers can be assigned to a company service' do + company_service = create(:company_service) + engineers = create_list(:engineer, 3) + + engineers.each do |engineer| + CompanyServiceEngineer.create!(company_service: company_service, engineer: engineer) + end + + fourth_engineer = create(:engineer) + company_service_engineer = CompanyServiceEngineer.new(company_service: company_service, engineer: fourth_engineer) + + expect(company_service_engineer.valid?).to be_falsey + expect(company_service_engineer.errors.full_messages).to include("Cannot assign more than 3 engineers to a company service") + end + end end From 0f6020396db101e89bb20d857adc8bfaf0104dec Mon Sep 17 00:00:00 2001 From: adrian peralta Date: Fri, 9 Aug 2024 20:55:46 +0000 Subject: [PATCH 19/33] feat: #6 update seeds to create CompanyServices with Engineers --- backend/db/seeds.rb | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/backend/db/seeds.rb b/backend/db/seeds.rb index 671c87e..9e281e1 100644 --- a/backend/db/seeds.rb +++ b/backend/db/seeds.rb @@ -1,10 +1,20 @@ require 'faker' +CompanyServiceEngineer.destroy_all CompanyService.destroy_all Engineer.destroy_all -10.times { FactoryBot.create(:company_service) } -puts "10 Company Services Created" +3.times do + company_service = FactoryBot.create(:company_service) -10.times { FactoryBot.create(:engineer) } -puts "10 Engineers Created" + # Create engineers and associate them with the company service + engineers = FactoryBot.create_list(:engineer, 3) # Creates 3 engineers + engineers.each do |engineer| + CompanyServiceEngineer.create!( + company_service: company_service, + engineer: engineer + ) + end +end +puts "3 Company Services Created" +puts "9 Engineers Created" From bc2c283e05bca0306e14f8bbd7151de5a84de84b Mon Sep 17 00:00:00 2001 From: adrian peralta Date: Fri, 9 Aug 2024 21:20:23 +0000 Subject: [PATCH 20/33] feat: #6 fix company services factory --- backend/spec/factories/company_services.rb | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/backend/spec/factories/company_services.rb b/backend/spec/factories/company_services.rb index 3a16732..01ad25d 100644 --- a/backend/spec/factories/company_services.rb +++ b/backend/spec/factories/company_services.rb @@ -12,7 +12,10 @@ FactoryBot.define do factory :company_service do name { Faker::Company.unique.name } - contract_start_date { Faker::Date.between(from: '2024-01-01', to: '2024-12-31') } - contract_end_date { Faker::Date.between(from: '2024-01-01', to: '2024-12-31') } + contract_start_date { Faker::Date.between(from: '2024-08-01', to: '2024-08-31').beginning_of_week } + contract_end_date { Faker::Date.between( + from: contract_start_date, + to: '2024-12-31').end_of_week + } end end From 74a4fc20b1f8e7720be53eb4ca2db411c4f7bd71 Mon Sep 17 00:00:00 2001 From: adrian peralta Date: Fri, 9 Aug 2024 21:55:26 +0000 Subject: [PATCH 21/33] feat: #6 defien service shifts route --- backend/config/routes.rb | 1 + backend/spec/routing/shifts_routing_spec.rb | 13 +++++++++++++ 2 files changed, 14 insertions(+) create mode 100644 backend/spec/routing/shifts_routing_spec.rb diff --git a/backend/config/routes.rb b/backend/config/routes.rb index bfe4b5d..f41770e 100644 --- a/backend/config/routes.rb +++ b/backend/config/routes.rb @@ -2,6 +2,7 @@ resources :company_services, only: [:index], constraints: { format: 'json' } do resources :weeks, only: [:index] resources :engineers, only: [:index], module: :company_services + resources :shifts, only: [:index], module: :company_services end get "up" => "rails/health#show", as: :rails_health_check diff --git a/backend/spec/routing/shifts_routing_spec.rb b/backend/spec/routing/shifts_routing_spec.rb new file mode 100644 index 0000000..f270912 --- /dev/null +++ b/backend/spec/routing/shifts_routing_spec.rb @@ -0,0 +1,13 @@ +require 'rails_helper' + +RSpec.describe CompanyServices::ShiftsController, type: :routing do + it 'routes to the shifts related to an specific company service in an specific week' do + expect(get: '/company_services/1/shifts?week=2024-36').to route_to( + controller: 'company_services/shifts', + action: 'index', + company_service_id: '1', + week: '2024-36' + ) + end +end + From acd7af7a868f70c029dda2f597e7aa183f0787cc Mon Sep 17 00:00:00 2001 From: adrian peralta Date: Sat, 10 Aug 2024 17:14:24 +0000 Subject: [PATCH 22/33] feat: #6 add companyServiceEngineers model and shifts endpoint --- .../company_services/engineers_controller.rb | 1 - .../company_services/shifts_controller.rb | 7 +++ backend/app/models/company_service.rb | 1 + .../app/models/company_service_engineer.rb | 10 ++++ backend/app/models/engineer.rb | 10 ++++ backend/app/models/shift.rb | 16 ++++++ backend/app/services/fetch_shifts_service.rb | 56 +++++++++++++++++++ .../shifts/_shift.json.jbuilder | 1 + .../shifts/index.json.jbuilder | 6 ++ .../shifts/show.json.jbuilder | 1 + .../migrate/20240809212458_create_shifts.rb | 13 +++++ backend/db/schema.rb | 14 ++++- backend/db/seeds.rb | 31 +++++----- .../factories/company_service_engineers.rb | 14 ++++- backend/spec/factories/company_services.rb | 2 +- backend/spec/factories/engineers.rb | 10 ++++ backend/spec/factories/shifts.rb | 44 +++++++++++++++ backend/spec/requests/errors_spec.rb | 1 - backend/spec/requests/shifts_spec.rb | 53 ++++++++++++++++++ 19 files changed, 271 insertions(+), 20 deletions(-) create mode 100644 backend/app/controllers/company_services/shifts_controller.rb create mode 100644 backend/app/models/shift.rb create mode 100644 backend/app/services/fetch_shifts_service.rb create mode 100644 backend/app/views/company_services/shifts/_shift.json.jbuilder create mode 100644 backend/app/views/company_services/shifts/index.json.jbuilder create mode 100644 backend/app/views/company_services/shifts/show.json.jbuilder create mode 100644 backend/db/migrate/20240809212458_create_shifts.rb create mode 100644 backend/spec/factories/shifts.rb create mode 100644 backend/spec/requests/shifts_spec.rb diff --git a/backend/app/controllers/company_services/engineers_controller.rb b/backend/app/controllers/company_services/engineers_controller.rb index c6b05f4..7062316 100644 --- a/backend/app/controllers/company_services/engineers_controller.rb +++ b/backend/app/controllers/company_services/engineers_controller.rb @@ -3,7 +3,6 @@ class EngineersController < ApplicationController # GET /engineers # GET /engineers.json def index - puts "REACH CONTROLLER" @engineers = Engineer.all end end diff --git a/backend/app/controllers/company_services/shifts_controller.rb b/backend/app/controllers/company_services/shifts_controller.rb new file mode 100644 index 0000000..64ce919 --- /dev/null +++ b/backend/app/controllers/company_services/shifts_controller.rb @@ -0,0 +1,7 @@ +module CompanyServices + class ShiftsController < ApplicationController + def index + @shifts = FetchShiftsService.new(params[:company_service_id],params[:week]).call + end + end +end diff --git a/backend/app/models/company_service.rb b/backend/app/models/company_service.rb index 3cb243a..5e1bb44 100644 --- a/backend/app/models/company_service.rb +++ b/backend/app/models/company_service.rb @@ -12,4 +12,5 @@ class CompanyService < ApplicationRecord has_many :company_service_engineers, dependent: :destroy has_many :engineers, through: :company_service_engineers + has_many :shifts, dependent: :destroy end diff --git a/backend/app/models/company_service_engineer.rb b/backend/app/models/company_service_engineer.rb index fa2ba9c..1004d18 100644 --- a/backend/app/models/company_service_engineer.rb +++ b/backend/app/models/company_service_engineer.rb @@ -1,3 +1,13 @@ +# == Schema Information +# +# Table name: company_service_engineers +# +# id :bigint not null, primary key +# company_service_id :bigint not null +# engineer_id :bigint not null +# created_at :datetime not null +# updated_at :datetime not null +# class CompanyServiceEngineer < ApplicationRecord belongs_to :company_service belongs_to :engineer diff --git a/backend/app/models/engineer.rb b/backend/app/models/engineer.rb index 108584f..95a3a56 100644 --- a/backend/app/models/engineer.rb +++ b/backend/app/models/engineer.rb @@ -1,3 +1,13 @@ +# == Schema Information +# +# Table name: engineers +# +# id :bigint not null, primary key +# name :string +# color :string +# created_at :datetime not null +# updated_at :datetime not null +# class Engineer < ApplicationRecord has_many :company_service_engineers, dependent: :destroy has_many :company_services, through: :company_service_engineers diff --git a/backend/app/models/shift.rb b/backend/app/models/shift.rb new file mode 100644 index 0000000..98ae105 --- /dev/null +++ b/backend/app/models/shift.rb @@ -0,0 +1,16 @@ +# == Schema Information +# +# Table name: shifts +# +# id :bigint not null, primary key +# company_service_id :bigint not null +# week :string +# day :string +# start_time :time +# end_time :time +# created_at :datetime not null +# updated_at :datetime not null +# +class Shift < ApplicationRecord + belongs_to :company_service +end diff --git a/backend/app/services/fetch_shifts_service.rb b/backend/app/services/fetch_shifts_service.rb new file mode 100644 index 0000000..128c952 --- /dev/null +++ b/backend/app/services/fetch_shifts_service.rb @@ -0,0 +1,56 @@ +class FetchShiftsService + def initialize(company_service_id, week) + @company_service = CompanyService.find(company_service_id) + @week = week + end + + def call + shifts_by_day.map do |day, shifts| + { + day: day, + dayLabel: formatted_day_label(day), + time_blocks: format_time_blocks(shifts) + } + end + end + + private + + def shifts_by_day + @company_service.shifts + .where(week: @week) + .order(:day, :start_time) + .group_by(&:day) + end + + def formatted_day_label(day) + date = Date.commercial( + Date.today.year, + @week.split('-').last.to_i, + Date::DAYNAMES.index(day.capitalize) + ) + # I18n.l(date, format: "%A %d de %B", locale: I18n.locale) + I18n.l(date, format: :long, locale: :es) + end + + def format_time_blocks(shifts) + shifts.map do |shift| + { + start_time: shift.start_time.strftime("%H:%W"), + end_time: shift.end_time.strftime("%H:%W"), + amount_of_hours: ((shift.end_time - shift.start_time) / 1.hour).to_i, + engineer: nil #format_engineer(shift.engineer) + } + end + end + + def format_engineer(engineer) + return nil unless engineer.present? + + { + id: engineer.id, + name: engineer.name, + color: engineer.color + } + end +end diff --git a/backend/app/views/company_services/shifts/_shift.json.jbuilder b/backend/app/views/company_services/shifts/_shift.json.jbuilder new file mode 100644 index 0000000..805e1bd --- /dev/null +++ b/backend/app/views/company_services/shifts/_shift.json.jbuilder @@ -0,0 +1 @@ +json.extract! shift, :day, :dayLabel, :time_blocks \ No newline at end of file diff --git a/backend/app/views/company_services/shifts/index.json.jbuilder b/backend/app/views/company_services/shifts/index.json.jbuilder new file mode 100644 index 0000000..b41ffa8 --- /dev/null +++ b/backend/app/views/company_services/shifts/index.json.jbuilder @@ -0,0 +1,6 @@ +json.data do + json.array! @shifts, partial: "company_services/shifts/shift", as: :shift +end + +json.status 200 +json.statusText "OK" \ No newline at end of file diff --git a/backend/app/views/company_services/shifts/show.json.jbuilder b/backend/app/views/company_services/shifts/show.json.jbuilder new file mode 100644 index 0000000..74930f6 --- /dev/null +++ b/backend/app/views/company_services/shifts/show.json.jbuilder @@ -0,0 +1 @@ +json.partial! "shifts/shift", shift: @shift diff --git a/backend/db/migrate/20240809212458_create_shifts.rb b/backend/db/migrate/20240809212458_create_shifts.rb new file mode 100644 index 0000000..33706e7 --- /dev/null +++ b/backend/db/migrate/20240809212458_create_shifts.rb @@ -0,0 +1,13 @@ +class CreateShifts < ActiveRecord::Migration[7.1] + def change + create_table :shifts do |t| + t.references :company_service, null: false, foreign_key: true + t.string :week + t.string :day + t.time :start_time + t.time :end_time + + t.timestamps + end + end +end diff --git a/backend/db/schema.rb b/backend/db/schema.rb index 563f33c..0cdf998 100644 --- a/backend/db/schema.rb +++ b/backend/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[7.1].define(version: 2024_08_09_191347) do +ActiveRecord::Schema[7.1].define(version: 2024_08_09_212458) do # These are extensions that must be enabled in order to support this database enable_extension "plpgsql" @@ -38,6 +38,18 @@ t.datetime "updated_at", null: false end + create_table "shifts", force: :cascade do |t| + t.bigint "company_service_id", null: false + t.string "week" + t.string "day" + t.time "start_time" + t.time "end_time" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["company_service_id"], name: "index_shifts_on_company_service_id" + end + add_foreign_key "company_service_engineers", "company_services" add_foreign_key "company_service_engineers", "engineers" + add_foreign_key "shifts", "company_services" end diff --git a/backend/db/seeds.rb b/backend/db/seeds.rb index 9e281e1..7fd2de7 100644 --- a/backend/db/seeds.rb +++ b/backend/db/seeds.rb @@ -1,20 +1,23 @@ require 'faker' -CompanyServiceEngineer.destroy_all -CompanyService.destroy_all -Engineer.destroy_all +10.times { FactoryBot.create(:company_service) } +puts "10 Company Services Created" -3.times do - company_service = FactoryBot.create(:company_service) +10.times { FactoryBot.create(:engineer) } +puts "10 Engineers Created" - # Create engineers and associate them with the company service - engineers = FactoryBot.create_list(:engineer, 3) # Creates 3 engineers - engineers.each do |engineer| - CompanyServiceEngineer.create!( - company_service: company_service, - engineer: engineer - ) +# Assign 3 random engineers to each CompanyService and create shifts +CompanyService.all.each do |company_service| + # Select 3 random engineers + selected_engineers = Engineer.all.sample(3) + + # Assign selected engineers to a CompanyService + selected_engineers.each do |engineer| + CompanyServiceEngineer.create!(company_service: company_service, engineer: engineer) + end + + # Create shifts for the the company + ["2024-32", "2024-33", "2024-34"].each do |week| + create(:shift, :for_week, week: week, company_service: company_service) end end -puts "3 Company Services Created" -puts "9 Engineers Created" diff --git a/backend/spec/factories/company_service_engineers.rb b/backend/spec/factories/company_service_engineers.rb index 649aa95..b212fbc 100644 --- a/backend/spec/factories/company_service_engineers.rb +++ b/backend/spec/factories/company_service_engineers.rb @@ -1,6 +1,16 @@ +# == Schema Information +# +# Table name: company_service_engineers +# +# id :bigint not null, primary key +# company_service_id :bigint not null +# engineer_id :bigint not null +# created_at :datetime not null +# updated_at :datetime not null +# FactoryBot.define do factory :company_service_engineer do - association :company_service - association :engineer + company_service + engineer end end diff --git a/backend/spec/factories/company_services.rb b/backend/spec/factories/company_services.rb index 01ad25d..f3a63f3 100644 --- a/backend/spec/factories/company_services.rb +++ b/backend/spec/factories/company_services.rb @@ -15,7 +15,7 @@ contract_start_date { Faker::Date.between(from: '2024-08-01', to: '2024-08-31').beginning_of_week } contract_end_date { Faker::Date.between( from: contract_start_date, - to: '2024-12-31').end_of_week + to: '2024-10-31').end_of_week } end end diff --git a/backend/spec/factories/engineers.rb b/backend/spec/factories/engineers.rb index 054c0fe..015b343 100644 --- a/backend/spec/factories/engineers.rb +++ b/backend/spec/factories/engineers.rb @@ -1,3 +1,13 @@ +# == Schema Information +# +# Table name: engineers +# +# id :bigint not null, primary key +# name :string +# color :string +# created_at :datetime not null +# updated_at :datetime not null +# FactoryBot.define do factory :engineer do name { Faker::Name.name } diff --git a/backend/spec/factories/shifts.rb b/backend/spec/factories/shifts.rb new file mode 100644 index 0000000..69a2542 --- /dev/null +++ b/backend/spec/factories/shifts.rb @@ -0,0 +1,44 @@ +# == Schema Information +# +# Table name: shifts +# +# id :bigint not null, primary key +# company_service_id :bigint not null +# week :string +# day :string +# start_time :time +# end_time :time +# created_at :datetime not null +# updated_at :datetime not null + +FactoryBot.define do + factory :shift do + company_service + + week { "#{Date.today.year}-#{Date.today.cweek}" } + day { %w[Monday Tuesday Wednesday Thursday Friday Saturday Sunday].sample } + + start_time { Faker::Time.between(from: DateTime.now.beginning_of_day, to: DateTime.now.end_of_day).change(min: 0).strftime("%H:%M") } + end_time do + duration_in_hours = rand(3..10) + (Time.parse(start_time) + duration_in_hours.hours).strftime("%H:%M") + end + + trait :for_week do |week| + after(:create) do |shift, evaluator| + week_days = %w[Monday Tuesday Wednesday Thursday Friday Saturday Sunday] + start_time = Time.parse("09:00") + + week_days.each do |day| + # Create shifts for the entire week + shift_for_day = create(:shift, + company_service: shift.company_service, + week: week, + day: day, + start_time: start_time.strftime("%H:%M"), + end_time: (start_time + rand(3..10).hours).strftime("%H:%M")) + end + end + end + end +end \ No newline at end of file diff --git a/backend/spec/requests/errors_spec.rb b/backend/spec/requests/errors_spec.rb index 744da17..fef786d 100644 --- a/backend/spec/requests/errors_spec.rb +++ b/backend/spec/requests/errors_spec.rb @@ -1,4 +1,3 @@ -# spec/requests/errors_spec.rb require 'rails_helper' RSpec.describe "Error Handling", type: :request do diff --git a/backend/spec/requests/shifts_spec.rb b/backend/spec/requests/shifts_spec.rb new file mode 100644 index 0000000..a032f8b --- /dev/null +++ b/backend/spec/requests/shifts_spec.rb @@ -0,0 +1,53 @@ +require 'rails_helper' + +RSpec.describe "CompanyServices::Shifts", type: :request do + let!(:company_service) { create(:company_service) } + let(:week) { "2024-32" } + + let(:valid_headers) { + {"Content-Type" => "application/json"} + } + before do + create(:shift, company_service: company_service, week: week, day: "Monday", start_time: "09:00", end_time: "10:00") + create(:shift, company_service: company_service, week: week, day: "Monday", start_time: "10:00", end_time: "11:00") + + create(:shift, company_service: company_service, week: week, day: "Tuesday", start_time: "18:00", end_time: "19:00") + create(:shift, company_service: company_service, week: week, day: "Tuesday", start_time: "20:00", end_time: "21:00") + + I18n.locale = :es + get company_service_shifts_url(company_service_id: company_service.id, week: week), + headers: valid_headers, + as: :json + end + + describe "GET /index" do + it "renders a successful response" do + expect(response).to be_successful + end + end + + describe 'JSON response' do + it 'returns the correct JSON structure' do + + json_response = JSON.parse(response.body) + + expect(json_response['status']).to eq(200) + expect(json_response['statusText']).to eq("OK") + expect(json_response['data']).to be_an(Array) + + # Verify structure for Monday + monday_shifts = json_response['data'].find { |d| d['day'] == "Monday" } + expect(monday_shifts['dayLabel']).to eq("Lunes 05 de Agosto") + expect(monday_shifts['time_blocks'].count).to eq(2) + expect(monday_shifts['time_blocks'][0]['engineer']).to be_nil + expect(monday_shifts['time_blocks'][1]['engineer']).to be_nil + + # Verify that an unassigned shift has a nil engineer + tuesday_shifts = json_response['data'].find { |d| d['day'] == "Tuesday" } + expect(tuesday_shifts['time_blocks'][0]['engineer']).to be_nil + expect(tuesday_shifts['time_blocks'][1]['engineer']).to be_nil + end + + end + +end From d69537b277bb7acf83ea5f06eca2f95da01f9a06 Mon Sep 17 00:00:00 2001 From: adrian peralta Date: Sat, 10 Aug 2024 18:27:43 +0000 Subject: [PATCH 23/33] refactor: #6 weeks service to test only pulic methods --- backend/app/controllers/weeks_controller.rb | 2 +- backend/app/services/week_service.rb | 35 +-- backend/spec/services/week_service_spec.rb | 293 ++++++++++---------- 3 files changed, 170 insertions(+), 160 deletions(-) diff --git a/backend/app/controllers/weeks_controller.rb b/backend/app/controllers/weeks_controller.rb index 7079b96..e473e6b 100644 --- a/backend/app/controllers/weeks_controller.rb +++ b/backend/app/controllers/weeks_controller.rb @@ -1,5 +1,5 @@ class WeeksController < ApplicationController def index - @weeks = WeekService.generate_weeks_data(params[:company_service_id]) + @weeks = WeekService.new(params[:company_service_id]).call end end diff --git a/backend/app/services/week_service.rb b/backend/app/services/week_service.rb index bc6da8f..7c0024d 100644 --- a/backend/app/services/week_service.rb +++ b/backend/app/services/week_service.rb @@ -1,43 +1,46 @@ class WeekService - - def self.generate_weeks_data(company_service_id) + def initialize(company_service_id) company_service = CompanyService.find(company_service_id) - start_date = company_service.contract_start_date.to_date - end_date = company_service.contract_end_date.to_date + @start_date = company_service.contract_start_date.to_date + @end_date = company_service.contract_end_date.to_date + end + def call current_date = Date.today past_weeks = [] future_weeks = [] - if date_in_contract?(start_date, end_date, current_date) - past_weeks = fetch_past(current_date, start_date) - future_weeks = fetch_future(current_date, end_date) + # binding.pry + if date_in_contract?(@start_date, @end_date, current_date) + past_weeks = fetch_past(current_date, @start_date) + future_weeks = fetch_future(current_date, @end_date) else - past_weeks = fetch_past(end_date, start_date) + past_weeks = fetch_past(@end_date, @start_date) + future_weeks = fetch_future(@start_date, @end_date) end { past: past_weeks, future: future_weeks } end - ## REFACTOR: make private, just validate based on 2 contracts - def self.date_in_contract?(start_date, end_date, date) + private + def date_in_contract?(start_date, end_date, date) # service.contract_start_date <= date && date <= service.contract_end_date (start_date..end_date).cover?(date) end - def self.week_identifier(date) - "#{date.year}-#{date.strftime('%W')}" + def week_identifier(date) + "#{date.year}-#{date.cweek}" end - def self.one_week(week_start, week_end) + def one_week(week_start, week_end) { id: week_identifier(week_start), - label: "Semana #{week_start.strftime('%W')} del #{week_start.year}", + label: "Semana #{week_start.cweek} del #{week_start.year}", start_date: week_start.strftime('%d/%m/%Y'), end_date: week_end.strftime('%d/%m/%Y') } end - def self.fetch_past(date, start_date_limit) + def fetch_past(date, start_date_limit) first_monday = start_date_limit.beginning_of_week weeks = [] mondays = [date.beginning_of_week] @@ -50,7 +53,7 @@ def self.fetch_past(date, start_date_limit) weeks end - def self.fetch_future(date, end_date_limit) + def fetch_future(date, end_date_limit) weeks = [] week_start = date.beginning_of_week 5.times do diff --git a/backend/spec/services/week_service_spec.rb b/backend/spec/services/week_service_spec.rb index ce83e4b..8f77566 100644 --- a/backend/spec/services/week_service_spec.rb +++ b/backend/spec/services/week_service_spec.rb @@ -1,160 +1,167 @@ require 'rails_helper' RSpec.describe WeekService, type: :service do - describe '.week_identifier' do - it 'returns the correct week identifier' do - date = Date.new(2024, 8, 5) # Lunes 5 de Agosto del 2024 - expected_identifier = "2024-32" - - result = WeekService.week_identifier(date) - - expect(result).to eq(expected_identifier) - end - end - - describe '.one_week' do - it 'returns the correct week structure' do - week_start = Date.new(2024, 8, 5) # Lunes 5 de Agosto del 2024 - week_end = week_start.end_of_week # Domingo 11 de Agosto del 2024 - - expected_week = { - id: "2024-32", - label: "Semana 32 del 2024", - start_date: "05/08/2024", - end_date: "11/08/2024" - } - - result = WeekService.one_week(week_start, week_end) - - expect(result).to eq(expected_week) + let(:company_service) { create(:company_service, + contract_start_date: '2024-07-15', + contract_end_date: '2024-09-15') + } + let(:service) { WeekService.new(company_service.id) } + + describe '#call generates weeks data based on contract' do + before do + # Monday, 5 of August, 2024 - Week: 32 + allow(Date).to receive(:today).and_return(Date.new(2024, 8, 5)) end - end - - describe '.fetch_past' do - it 'returns all past weeks until the contract start date' do - today = Date.new(2024, 8, 5) ## Lunes 5 de Agosto del 2024 - start_date_limit = Date.new(2024, 7, 15) # Lunes 15 de Julio del 2024 - - expected_past_weeks = [ - { - id: "2024-31", - label: "Semana 31 del 2024", - start_date: "29/07/2024", - end_date: "04/08/2024" - }, - { - id: "2024-30", - label: "Semana 30 del 2024", - start_date: "22/07/2024", - end_date: "28/07/2024" - }, - { - id: "2024-29", - label: "Semana 29 del 2024", - start_date: "15/07/2024", - end_date: "21/07/2024" - } - ] - - result = WeekService.fetch_past(today, start_date_limit) - - expect(result).to eq(expected_past_weeks) + describe 'week identifier' do + it 'returns the current week 32 identifier within the future weeks' do + # .week_identifier + result = service.call + future_weeks = result[:future] + + expect(future_weeks).to include( + a_hash_including(id: "2024-32") + ) + end end - end - describe '.fetch_future' do - context 'when there are enough weeks before the end_date_limit' do - it 'returns 5 weeks' do - date = Date.new(2024, 8, 15) # Jueves 15 de Agosto del 2024 - end_date_limit = Date.new(2024, 10, 31) # 31 de Octubre del 2024 - expected_future_weeks = [ - { - id: "2024-33", - label: "Semana 33 del 2024", - start_date: "12/08/2024", - end_date: "18/08/2024" - }, - { - id: "2024-34", - label: "Semana 34 del 2024", - start_date: "19/08/2024", - end_date: "25/08/2024" - }, - { - id: "2024-35", - label: "Semana 35 del 2024", - start_date: "26/08/2024", - end_date: "01/09/2024" - }, - { - id: "2024-36", - label: "Semana 36 del 2024", - start_date: "02/09/2024", - end_date: "08/09/2024" - }, - { - id: "2024-37", - label: "Semana 37 del 2024", - start_date: "09/09/2024", - end_date: "15/09/2024" - } - ] - - result = WeekService.fetch_future(date, end_date_limit) - - expect(result).to eq(expected_future_weeks) + describe 'Single Week' do + it 'returns the correct week structure for the past and future' do + result = service.call + past_weeks = result[:past] + future_weeks = result[:future] + + expect(past_weeks).to include( + a_hash_including( + id: "2024-31", + label: "Semana 31 del 2024", + start_date: "29/07/2024", + end_date: "04/08/2024" + ) + ) + + expect(future_weeks).to include( + a_hash_including( + id: "2024-32", + label: "Semana 32 del 2024", + start_date: "05/08/2024", + end_date: "11/08/2024" + ) + ) end end - context 'when the end_date_limit restricts the number of weeks' do - it 'returns only the weeks within the end_date_limit' do - date = Date.new(2024, 8, 15) # Jueves 15 de Agosto del 2024 - end_date_limit = Date.new(2024, 9, 1) # Domingo 1 de Setiembre del 2024 - - expected_future_weeks = [ - { - id: "2024-33", - label: "Semana 33 del 2024", - start_date: "12/08/2024", - end_date: "18/08/2024" - }, + context 'Contract Range: 15Jul -> 15 Set 2024 - W29-W37' do + describe 'Past Weeks' do + it 'returns all past weeks until the contract start date' do + result = service.call + past_weeks = result[:past] + + expect(past_weeks).to eq([ + { + id: "2024-31", + label: "Semana 31 del 2024", + start_date: "29/07/2024", + end_date: "04/08/2024" + }, { - id: "2024-34", - label: "Semana 34 del 2024", - start_date: "19/08/2024", - end_date: "25/08/2024" + id: "2024-30", + label: "Semana 30 del 2024", + start_date: "22/07/2024", + end_date: "28/07/2024" }, { - id: "2024-35", - label: "Semana 35 del 2024", - start_date: "26/08/2024", - end_date: "01/09/2024" + id: "2024-29", + label: "Semana 29 del 2024", + start_date: "15/07/2024", + end_date: "21/07/2024" } - ] - - result = WeekService.fetch_future(date, end_date_limit) - - expect(result).to eq(expected_future_weeks) + ]) + end end - end - - context 'when the end_date_limit is very close' do - it 'returns only one week if limit restricts it to 1 week' do - date = Date.new(2024, 8, 15) # Jueves 15 de Agosto del 2024 - end_date_limit = Date.new(2024, 8, 18) # Only allows for 1 week - - expected_future_weeks = [ - { - id: "2024-33", - label: "Semana 33 del 2024", - start_date: "12/08/2024", - end_date: "18/08/2024" - } - ] - - result = WeekService.fetch_future(date, end_date_limit) - - expect(result).to eq(expected_future_weeks) + describe 'Future Weeks' do + it 'returns the correct 5 future weeks based on end_date_limit' do + result = service.call + future_weeks = result[:future] + + expect(future_weeks).to eq([ + { + id: "2024-32", + label: "Semana 32 del 2024", + start_date: "05/08/2024", + end_date: "11/08/2024" + }, + { + id: "2024-33", + label: "Semana 33 del 2024", + start_date: "12/08/2024", + end_date: "18/08/2024" + }, + { + id: "2024-34", + label: "Semana 34 del 2024", + start_date: "19/08/2024", + end_date: "25/08/2024" + }, + { + id: "2024-35", + label: "Semana 35 del 2024", + start_date: "26/08/2024", + end_date: "01/09/2024" + }, + { + id: "2024-36", + label: "Semana 36 del 2024", + start_date: "02/09/2024", + end_date: "08/09/2024" + } + ]) + end + + it 'returns fewer future weeks when the end_date_limit is close' do + # go 1 week in the future - get closer to end date + allow(Date).to receive(:today).and_return(Date.new(2024, 8, 26)) + + result = service.call + future_weeks = result[:future] + + expect(future_weeks).to eq([ + { + id: "2024-35", + label: "Semana 35 del 2024", + start_date: "26/08/2024", + end_date: "01/09/2024" + }, + { + id: "2024-36", + label: "Semana 36 del 2024", + start_date: "02/09/2024", + end_date: "08/09/2024" + }, + { + id: "2024-37", + label: "Semana 37 del 2024", + start_date: "09/09/2024", + end_date: "15/09/2024" + } + ]) + end + + it 'returns only one week when its the last week of contract' do + # go to the future - last week of contract + allow(Date).to receive(:today).and_return(Date.new(2024, 9, 9)) + + result = service.call + future_weeks = result[:future] + + expect(future_weeks).to eq([ + { + id: "2024-37", + label: "Semana 37 del 2024", + start_date: "09/09/2024", + end_date: "15/09/2024" + } + ]) + end end end end From d2231a5ebeb7cf1ed441734e2a5cc621d51d6e0b Mon Sep 17 00:00:00 2001 From: adrian peralta Date: Sat, 10 Aug 2024 18:50:50 +0000 Subject: [PATCH 24/33] fix: #6 future weeks far from current day --- backend/app/services/week_service.rb | 24 +++++++++++++--------- backend/spec/services/week_service_spec.rb | 12 ++++++++++- 2 files changed, 25 insertions(+), 11 deletions(-) diff --git a/backend/app/services/week_service.rb b/backend/app/services/week_service.rb index 7c0024d..63d0c50 100644 --- a/backend/app/services/week_service.rb +++ b/backend/app/services/week_service.rb @@ -14,9 +14,10 @@ def call if date_in_contract?(@start_date, @end_date, current_date) past_weeks = fetch_past(current_date, @start_date) future_weeks = fetch_future(current_date, @end_date) - else + elsif current_date < @start_date + future_weeks = fetch_future(@start_date, @end_date, offset: true) + elsif current_date > @end_date past_weeks = fetch_past(@end_date, @start_date) - future_weeks = fetch_future(@start_date, @end_date) end { past: past_weeks, future: future_weeks } end @@ -40,10 +41,10 @@ def one_week(week_start, week_end) } end - def fetch_past(date, start_date_limit) + def fetch_past(end_date, start_date_limit) first_monday = start_date_limit.beginning_of_week weeks = [] - mondays = [date.beginning_of_week] + mondays = [end_date.beginning_of_week] while mondays.last > first_monday week_start = mondays.last.last_week week_end = week_start.end_of_week @@ -53,15 +54,18 @@ def fetch_past(date, start_date_limit) weeks end - def fetch_future(date, end_date_limit) + def fetch_future(start_date, end_date_limit, offset: false) weeks = [] - week_start = date.beginning_of_week - 5.times do + week_start = offset ? Date.today.beginning_of_week : start_date.beginning_of_week + weeks_limit = 5 + while weeks.count < weeks_limit && week_start <= end_date_limit + if offset && week_start < start_date.beginning_of_week + week_start = week_start.next_week + weeks_limit -= 1 + next + end week_end = week_start.end_of_week - - break if week_start > end_date_limit weeks << one_week(week_start, week_end) - week_start = week_start.next_week end diff --git a/backend/spec/services/week_service_spec.rb b/backend/spec/services/week_service_spec.rb index 8f77566..3bbd3c2 100644 --- a/backend/spec/services/week_service_spec.rb +++ b/backend/spec/services/week_service_spec.rb @@ -50,7 +50,7 @@ end end - context 'Contract Range: 15Jul -> 15 Set 2024 - W29-W37' do + context 'Contract Range: 15Jul -> 15 Set 2024 - W29-W37 = 8 weeks' do describe 'Past Weeks' do it 'returns all past weeks until the contract start date' do result = service.call @@ -78,6 +78,7 @@ ]) end end + describe 'Future Weeks' do it 'returns the correct 5 future weeks based on end_date_limit' do result = service.call @@ -162,6 +163,15 @@ } ]) end + it 'returns all weeks that enter on 5 weeks range when the current date a bit far in future' do + # go to the past - contract dates still in future + allow(Date).to receive(:today).and_return(Date.new(2024, 07, 1)) + result = service.call + past_weeks = result[:past] + future_weeks = result[:future] + expect(past_weeks.count).to eq(0) + expect(future_weeks.count).to eq(3) + end end end end From 078101c74046e157b4d8c0db796e00a8f06d5668 Mon Sep 17 00:00:00 2001 From: adrian peralta Date: Sat, 10 Aug 2024 19:01:01 +0000 Subject: [PATCH 25/33] fix: #6 past weeks older than current day --- backend/app/services/week_service.rb | 7 ++++--- backend/spec/services/week_service_spec.rb | 11 ++++++++++- 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/backend/app/services/week_service.rb b/backend/app/services/week_service.rb index 63d0c50..a450d5d 100644 --- a/backend/app/services/week_service.rb +++ b/backend/app/services/week_service.rb @@ -12,12 +12,12 @@ def call # binding.pry if date_in_contract?(@start_date, @end_date, current_date) - past_weeks = fetch_past(current_date, @start_date) + past_weeks = fetch_past(@start_date, current_date) future_weeks = fetch_future(current_date, @end_date) elsif current_date < @start_date future_weeks = fetch_future(@start_date, @end_date, offset: true) elsif current_date > @end_date - past_weeks = fetch_past(@end_date, @start_date) + past_weeks = fetch_past(@start_date, @end_date, offset: true ) end { past: past_weeks, future: future_weeks } end @@ -41,9 +41,10 @@ def one_week(week_start, week_end) } end - def fetch_past(end_date, start_date_limit) + def fetch_past(start_date_limit, end_date, offset: false) first_monday = start_date_limit.beginning_of_week weeks = [] + end_date = end_date.next_week if offset # include current if offset mondays = [end_date.beginning_of_week] while mondays.last > first_monday week_start = mondays.last.last_week diff --git a/backend/spec/services/week_service_spec.rb b/backend/spec/services/week_service_spec.rb index 3bbd3c2..5525df0 100644 --- a/backend/spec/services/week_service_spec.rb +++ b/backend/spec/services/week_service_spec.rb @@ -50,7 +50,7 @@ end end - context 'Contract Range: 15Jul -> 15 Set 2024 - W29-W37 = 8 weeks' do + context 'Contract Range: 15Jul -> 15 Set 2024 - W29-W37 = 9 weeks' do describe 'Past Weeks' do it 'returns all past weeks until the contract start date' do result = service.call @@ -77,6 +77,15 @@ } ]) end + it 'returns all contract weeks when the current date surpass contract dates' do + # go to the future - suprass contract dates + allow(Date).to receive(:today).and_return(Date.new(2024, 12, 1)) + result = service.call + past_weeks = result[:past] + future_weeks = result[:future] + expect(past_weeks.count).to eq(9) + expect(future_weeks.count).to eq(0) + end end describe 'Future Weeks' do From 621cf7e66a40609e27b9af194623c1ed4dbb26fd Mon Sep 17 00:00:00 2001 From: adrian peralta Date: Sat, 10 Aug 2024 20:51:05 +0000 Subject: [PATCH 26/33] fix: #6 seed with FactoryBot to create shifts --- backend/db/seeds.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/db/seeds.rb b/backend/db/seeds.rb index 7fd2de7..328481c 100644 --- a/backend/db/seeds.rb +++ b/backend/db/seeds.rb @@ -18,6 +18,6 @@ # Create shifts for the the company ["2024-32", "2024-33", "2024-34"].each do |week| - create(:shift, :for_week, week: week, company_service: company_service) + FactoryBot.create(:shift, :for_week, week: week, company_service: company_service) end end From a95566f8bfb4bc73f977074681766820cd6ca994 Mon Sep 17 00:00:00 2001 From: adrian peralta Date: Sat, 10 Aug 2024 20:56:48 +0000 Subject: [PATCH 27/33] fix: #6 update shift factory --- backend/spec/factories/shifts.rb | 21 +++++++++++++-------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/backend/spec/factories/shifts.rb b/backend/spec/factories/shifts.rb index 69a2542..80fa8a5 100644 --- a/backend/spec/factories/shifts.rb +++ b/backend/spec/factories/shifts.rb @@ -24,21 +24,26 @@ (Time.parse(start_time) + duration_in_hours.hours).strftime("%H:%M") end - trait :for_week do |week| + trait :for_week do + transient do + week { "#{Date.today.year}-#{Date.today.cweek}" } + end + after(:create) do |shift, evaluator| week_days = %w[Monday Tuesday Wednesday Thursday Friday Saturday Sunday] start_time = Time.parse("09:00") week_days.each do |day| - # Create shifts for the entire week - shift_for_day = create(:shift, - company_service: shift.company_service, - week: week, - day: day, - start_time: start_time.strftime("%H:%M"), - end_time: (start_time + rand(3..10).hours).strftime("%H:%M")) + create(:shift, + company_service: shift.company_service, + week: evaluator.week, + day: day, + start_time: start_time.strftime("%H:%M"), + end_time: (start_time + rand(3..10).hours).strftime("%H:%M")) + start_time += 1.day # Move to the next day for the next iteration end end end + end end \ No newline at end of file From 732bde1e3cf27ef939316b3e5e124d482e9b089d Mon Sep 17 00:00:00 2001 From: adrian peralta Date: Sat, 10 Aug 2024 21:00:07 +0000 Subject: [PATCH 28/33] fix: #6 add spanish weeks and months --- backend/config/locales/es.yml | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) create mode 100644 backend/config/locales/es.yml diff --git a/backend/config/locales/es.yml b/backend/config/locales/es.yml new file mode 100644 index 0000000..bce98ee --- /dev/null +++ b/backend/config/locales/es.yml @@ -0,0 +1,30 @@ +es: + date: + day_names: + - Domingo + - Lunes + - Martes + - Miércoles + - Jueves + - Viernes + - Sábado + month_names: + - Enero + - Enero + - Febrero + - Marzo + - Abril + - Mayo + - Junio + - Julio + - Agosto + - Septiembre + - Octubre + - Noviembre + - Diciembre + formats: + default: "%d/%m/%Y" + long: "%A %d de %B" + time: + am: "AM" + pm: "PM" From 52e9eeccd8a320b0c52aeab6bed9bb25ef9d5a16 Mon Sep 17 00:00:00 2001 From: adrian peralta Date: Sat, 10 Aug 2024 21:07:55 +0000 Subject: [PATCH 29/33] feat: #6 get engineers assigned to a company service --- .../controllers/company_services/engineers_controller.rb | 6 +++--- backend/spec/requests/engineers_spec.rb | 6 ++++++ 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/backend/app/controllers/company_services/engineers_controller.rb b/backend/app/controllers/company_services/engineers_controller.rb index 7062316..fcdf998 100644 --- a/backend/app/controllers/company_services/engineers_controller.rb +++ b/backend/app/controllers/company_services/engineers_controller.rb @@ -1,9 +1,9 @@ module CompanyServices class EngineersController < ApplicationController - # GET /engineers - # GET /engineers.json def index - @engineers = Engineer.all + company_service = CompanyService.find(params[:company_service_id]) + week = params[:week] + @engineers = company_service.engineers end end end \ No newline at end of file diff --git a/backend/spec/requests/engineers_spec.rb b/backend/spec/requests/engineers_spec.rb index fd3dae7..e3be3a0 100644 --- a/backend/spec/requests/engineers_spec.rb +++ b/backend/spec/requests/engineers_spec.rb @@ -12,6 +12,11 @@ @engineer1 = create(:engineer, name: "Engineer 1", color: "#a5b4fc") @engineer2 = create(:engineer, name: "Engineer 2", color: "#5eead4") @engineer3 = create(:engineer, name: "Engineer 3", color: "#bef264") + + CompanyServiceEngineer.create!(company_service: company_service, engineer: @engineer1) + CompanyServiceEngineer.create!(company_service: company_service, engineer: @engineer2) + CompanyServiceEngineer.create!(company_service: company_service, engineer: @engineer3) + end describe "GET /company_services/:company_service_id/engineers?week=YYYY-WW" do @@ -28,6 +33,7 @@ json_response = JSON.parse(response.body) expect(json_response).to have_key('data') + puts json_response expect(json_response['data'].length).to eq(3) expect(json_response['data']).to match_array([ { 'id' => @engineer1.id, 'name' => @engineer1.name, 'color' => @engineer1.color }, From c58fb229514dc6190a32c95fd1ebd3cdeb2eadca Mon Sep 17 00:00:00 2001 From: adrian peralta Date: Sun, 11 Aug 2024 01:39:56 +0000 Subject: [PATCH 30/33] feat: #6 add contract_weeks to company service model --- ...20240810215412_add_contract_weeks_to_company_services.rb | 6 ++++++ backend/db/schema.rb | 4 +++- backend/spec/factories/company_services.rb | 3 +++ 3 files changed, 12 insertions(+), 1 deletion(-) create mode 100644 backend/db/migrate/20240810215412_add_contract_weeks_to_company_services.rb diff --git a/backend/db/migrate/20240810215412_add_contract_weeks_to_company_services.rb b/backend/db/migrate/20240810215412_add_contract_weeks_to_company_services.rb new file mode 100644 index 0000000..f2b0c3a --- /dev/null +++ b/backend/db/migrate/20240810215412_add_contract_weeks_to_company_services.rb @@ -0,0 +1,6 @@ +class AddContractWeeksToCompanyServices < ActiveRecord::Migration[7.1] + def change + add_column :company_services, :contract_start_week, :string + add_column :company_services, :contract_end_week, :string + end +end diff --git a/backend/db/schema.rb b/backend/db/schema.rb index 0cdf998..4ea7bae 100644 --- a/backend/db/schema.rb +++ b/backend/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[7.1].define(version: 2024_08_09_212458) do +ActiveRecord::Schema[7.1].define(version: 2024_08_10_215412) do # These are extensions that must be enabled in order to support this database enable_extension "plpgsql" @@ -29,6 +29,8 @@ t.datetime "contract_end_date" t.datetime "created_at", null: false t.datetime "updated_at", null: false + t.string "contract_start_week" + t.string "contract_end_week" end create_table "engineers", force: :cascade do |t| diff --git a/backend/spec/factories/company_services.rb b/backend/spec/factories/company_services.rb index f3a63f3..5c07b7e 100644 --- a/backend/spec/factories/company_services.rb +++ b/backend/spec/factories/company_services.rb @@ -17,5 +17,8 @@ from: contract_start_date, to: '2024-10-31').end_of_week } + contract_start_week { "#{contract_start_date.year}-#{contract_start_date.cweek}" } + contract_end_week { "#{contract_end_date.year}-#{contract_end_date.cweek}" } end end +# improve: company_service defien contract_start_week and end_week to avoid doing calculations on date level. From bcb5113e802390fe217ee54bc7e3593e1f23d6bc Mon Sep 17 00:00:00 2001 From: adrian peralta Date: Sun, 11 Aug 2024 02:14:56 +0000 Subject: [PATCH 31/33] feat: #6 list company service shifts --- backend/app/services/fetch_shifts_service.rb | 14 +++-- backend/db/seeds.rb | 18 +++++- backend/spec/factories/shifts.rb | 40 ++++++------- backend/spec/requests/engineers_spec.rb | 1 - backend/spec/requests/shifts_spec.rb | 6 +- .../services/fetch_shifts_service_spec.rb | 57 +++++++++++++++++++ backend/spec/services/week_service_spec.rb | 4 +- 7 files changed, 103 insertions(+), 37 deletions(-) create mode 100644 backend/spec/services/fetch_shifts_service_spec.rb diff --git a/backend/app/services/fetch_shifts_service.rb b/backend/app/services/fetch_shifts_service.rb index 128c952..a6237d8 100644 --- a/backend/app/services/fetch_shifts_service.rb +++ b/backend/app/services/fetch_shifts_service.rb @@ -17,17 +17,21 @@ def call private def shifts_by_day + # "Monday" => [shifts] @company_service.shifts - .where(week: @week) - .order(:day, :start_time) + .where(week: @company_service.contract_start_week) .group_by(&:day) end def formatted_day_label(day) + current_year = Date.today.year + week_number = @week.split('-').last.to_i + day_index = Date::DAYNAMES.index(day.capitalize) + day_index = day_index == 0 ? 7 : day_index date = Date.commercial( - Date.today.year, - @week.split('-').last.to_i, - Date::DAYNAMES.index(day.capitalize) + current_year, + week_number, + day_index ) # I18n.l(date, format: "%A %d de %B", locale: I18n.locale) I18n.l(date, format: :long, locale: :es) diff --git a/backend/db/seeds.rb b/backend/db/seeds.rb index 328481c..248d7c7 100644 --- a/backend/db/seeds.rb +++ b/backend/db/seeds.rb @@ -15,9 +15,21 @@ selected_engineers.each do |engineer| CompanyServiceEngineer.create!(company_service: company_service, engineer: engineer) end + puts "Assigned 3 Engineers to #{company_service.name}" - # Create shifts for the the company - ["2024-32", "2024-33", "2024-34"].each do |week| - FactoryBot.create(:shift, :for_week, week: week, company_service: company_service) + puts "Get 1st week of #{company_service.name}" + # Get the start and end weeks from the company_service + start_week = company_service.contract_start_week + # end_week = company_service.contract_end_week + puts "Create Shift Week for #{company_service.name}" + week_days = %w[Monday Tuesday Wednesday Thursday Friday Saturday Sunday] + start_time = Time.parse("09:00") + + week_days.each do |day| + FactoryBot.create(:shift, + company_service: company_service, + week: start_week, + day: day) + start_time += 1.day end end diff --git a/backend/spec/factories/shifts.rb b/backend/spec/factories/shifts.rb index 80fa8a5..c92fdd5 100644 --- a/backend/spec/factories/shifts.rb +++ b/backend/spec/factories/shifts.rb @@ -18,32 +18,24 @@ week { "#{Date.today.year}-#{Date.today.cweek}" } day { %w[Monday Tuesday Wednesday Thursday Friday Saturday Sunday].sample } - start_time { Faker::Time.between(from: DateTime.now.beginning_of_day, to: DateTime.now.end_of_day).change(min: 0).strftime("%H:%M") } + start_time { + Faker::Time.between( + from: DateTime.now.change(hour: 04, min: 00), + to: DateTime.now.change(hour: 20, min: 00) + ).change(min: 0) + .strftime("%H:%M") + } end_time do - duration_in_hours = rand(3..10) - (Time.parse(start_time) + duration_in_hours.hours).strftime("%H:%M") - end - - trait :for_week do - transient do - week { "#{Date.today.year}-#{Date.today.cweek}" } - end + start_time_time = Time.parse(start_time) + plus_one_hour = start_time_time + 1.hour + midnight = start_time_time.end_of_day + end_time_range = (plus_one_hour)..(midnight) - after(:create) do |shift, evaluator| - week_days = %w[Monday Tuesday Wednesday Thursday Friday Saturday Sunday] - start_time = Time.parse("09:00") - - week_days.each do |day| - create(:shift, - company_service: shift.company_service, - week: evaluator.week, - day: day, - start_time: start_time.strftime("%H:%M"), - end_time: (start_time + rand(3..10).hours).strftime("%H:%M")) - start_time += 1.day # Move to the next day for the next iteration - end - end + Faker::Time.between( + from: end_time_range.first, + to: end_time_range.last + ).change(min: 0) + .strftime("%H:%M") end - end end \ No newline at end of file diff --git a/backend/spec/requests/engineers_spec.rb b/backend/spec/requests/engineers_spec.rb index e3be3a0..553ee64 100644 --- a/backend/spec/requests/engineers_spec.rb +++ b/backend/spec/requests/engineers_spec.rb @@ -33,7 +33,6 @@ json_response = JSON.parse(response.body) expect(json_response).to have_key('data') - puts json_response expect(json_response['data'].length).to eq(3) expect(json_response['data']).to match_array([ { 'id' => @engineer1.id, 'name' => @engineer1.name, 'color' => @engineer1.color }, diff --git a/backend/spec/requests/shifts_spec.rb b/backend/spec/requests/shifts_spec.rb index a032f8b..b12d269 100644 --- a/backend/spec/requests/shifts_spec.rb +++ b/backend/spec/requests/shifts_spec.rb @@ -1,8 +1,10 @@ require 'rails_helper' RSpec.describe "CompanyServices::Shifts", type: :request do - let!(:company_service) { create(:company_service) } - let(:week) { "2024-32" } + let!(:company_service) { create(:company_service, + contract_start_date: Date.parse('2024-08-05'), + contract_end_date: Date.parse('2024-08-11')) } + let(:week) { company_service.contract_start_week } let(:valid_headers) { {"Content-Type" => "application/json"} diff --git a/backend/spec/services/fetch_shifts_service_spec.rb b/backend/spec/services/fetch_shifts_service_spec.rb new file mode 100644 index 0000000..a09de6a --- /dev/null +++ b/backend/spec/services/fetch_shifts_service_spec.rb @@ -0,0 +1,57 @@ +require 'rails_helper' + +RSpec.describe FetchShiftsService, type: :service do + let(:company_service) { create(:company_service, + contract_start_date: Date.parse('2024-08-05'), + contract_end_date: Date.parse('2024-08-11')) } + let(:week) { company_service.contract_start_week } + let(:service) { FetchShiftsService.new(company_service.id, week) } + + describe '#call' do + context 'when there are shifts for the week' do + let!(:shift1) { create(:shift, company_service: company_service, week: week, day: 'Monday', start_time: '09:00', end_time: '12:00') } + let!(:shift2) { create(:shift, company_service: company_service, week: week, day: 'Monday', start_time: '14:00', end_time: '18:00') } + let!(:shift3) { create(:shift, company_service: company_service, week: week, day: 'Tuesday', start_time: '09:00', end_time: '11:00') } + + it 'returns formatted shift data grouped by day' do + result = service.call + + expect(result).to be_an(Array) + expect(result.size).to eq(2) # Monday and Tuesday + + monday = result.find { |day_data| day_data[:day] == 'Monday' } + tuesday = result.find { |day_data| day_data[:day] == 'Tuesday' } + + expect(monday[:dayLabel]).to eq('Lunes 05 de Agosto') # Adjust this label depending on the exact date of the week 32 + expect(monday[:time_blocks].size).to eq(2) + expect(monday[:time_blocks].first[:start_time]).to eq('09:00') + expect(monday[:time_blocks].first[:end_time]).to eq('12:00') + expect(monday[:time_blocks].first[:amount_of_hours]).to eq(3) + + expect(tuesday[:dayLabel]).to eq('Martes 06 de Agosto') + expect(tuesday[:time_blocks].size).to eq(1) + expect(tuesday[:time_blocks].first[:start_time]).to eq('09:00') + expect(tuesday[:time_blocks].first[:end_time]).to eq('11:00') + expect(tuesday[:time_blocks].first[:amount_of_hours]).to eq(2) + end + end + + context 'when there are no shifts for the week' do + it 'returns an empty array' do + result = service.call + expect(result).to eq([]) + end + end + + context 'when formatting the day label' do + let!(:shift) { create(:shift, company_service: company_service, week: week, day: 'Wednesday', start_time: '09:00', end_time: '17:00') } + + it 'returns the correct day label in Spanish' do + result = service.call + wednesday = result.find { |day_data| day_data[:day] == 'Wednesday' } + + expect(wednesday[:dayLabel]).to eq('Miércoles 07 de Agosto') # Adjust based on actual week date + end + end + end +end diff --git a/backend/spec/services/week_service_spec.rb b/backend/spec/services/week_service_spec.rb index 5525df0..28cd54d 100644 --- a/backend/spec/services/week_service_spec.rb +++ b/backend/spec/services/week_service_spec.rb @@ -2,8 +2,8 @@ RSpec.describe WeekService, type: :service do let(:company_service) { create(:company_service, - contract_start_date: '2024-07-15', - contract_end_date: '2024-09-15') + contract_start_date: Date.parse('2024-07-15'), + contract_end_date: Date.parse('2024-09-15')) } let(:service) { WeekService.new(company_service.id) } From 3fa0395748bbc6619fd4829fc0789c013ea4cbd6 Mon Sep 17 00:00:00 2001 From: adrian peralta Date: Sun, 11 Aug 2024 02:58:31 +0000 Subject: [PATCH 32/33] refactor: #6 expand company services factory start date --- README.md | 80 +++++++++++++++++++--- backend/spec/factories/company_services.rb | 2 +- backend/spec/factories/engineers.rb | 9 ++- 3 files changed, 81 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index 9d2677a..6d4c4e3 100644 --- a/README.md +++ b/README.md @@ -26,17 +26,22 @@ ### Componentes #### Endpoints - Gestion de Turnos (Shifts) - 1st Dropdown (Services) - - GET /api/company_services + - GET /api/company_services + - [example response](frontend/src/mock/company_services.json) - 2nd Dropdown (Weeks) - GET /api/company_services/:id/weeks + - [example response](frontend/src/mock/weeks_service_a.json) - Engineers Table - GET /api/company_services/:id/engineers?week=YYYY-WW + - [example response](frontend/src/mock/engineers_a_w1.json) - Shifts Table - GET /api/company_services/:id/shifts?week=YYYY-WW + - [example response](frontend/src/mock/shifts_a_w1.json) #### Endpoints - Gestion de Disponibilidad (Availability) - Dropdowns anteriores (gestion de turnos) para el filtrado y llenado de semana - Boton Editar Disponibilidad: Consultar Disponibilidad de ingenieros - GET /api/company_services/:id/engineers/availability?week=YYYY-WW + - [example response](frontend/src/mock/eng_availability_a_w1.json.json) - Updates Engineer Availability - POST /api/company_services/:id/engineers/availability - week @@ -51,12 +56,66 @@ #### Modelos 1. Servicios monitoreados - - Bloques de 1h - - Horario establecido (grupo de bloques) -2. Semana -3. Engineer -4. Turno (bloques de 1 hora) -5. Asignacion (Relacion Ingeniero - Hora) + - Contrato: Fechas Establecidas +2. Engineer +3. CompanyServiceEngineer + - Asignar 3 ingenieros encargados del servicio durante el contrato. +4. Shift (Turno) - bloques de 1 hora + - Contrato: Horas por dia de semana establecidas (grupo de bloques) +5. EngineerShift + - Bloque asignado a ingeniero +6. Availability (Disponibilidad) - Must: engineer + +#### Modelos - Instancias Ejemplo +1. CompanyService + id: 1 + name: "Service A" + contract_start_date: "2024-08-01" + contract_end_date: "2024-08-31" + +2. Engineer + id: 1 + name:"Alice Smith" + color:"Bob Johnson" + +3. Shift + company_service:1 + engineer:(sin asignar) + week:"2024-32" + day:"Monday" + start_time:"2024-08-07 09:00:00" + end_time:"2024-08-07 10:00:00" + +4. Availability + engineer:1 + week:"2024-32" + day:"Monday" + start_time:"2024-08-07 09:00:00" + end_time:"2024-08-07 10:00:00" + available:true + +#### Modelos - Instancias Factory Bot +```ruby +# 1. CompanyService +FactoryBot.attributes_for :company_service +=> {:name=>"Farrell, Mohr and Haley", :contract_start_date=>Thu, 18 Jul 2024, :contract_end_date=>Tue, 20 Aug 2024} + +# 2. Engineer +FactoryBot.attributes_for :engineer +=> {:name=>"Russell Hermann", :color=>"#0c0d0d"} + +# 3. CompanyServiceEngineer +#FactoryBot.attributes_for :company_service_engineer + +# 4. Shift +FactoryBot.attributes_for :shift +=> {:week=>"2024-32", :day=>"Tuesday", :start_time=>"13:00", :end_time=>"18:00"} +# 5. EngineerShift + +# 6. Availability +FactoryBot.attributes_for :availability + +``` #### Arquitectura Frontend (Grafica Figma) - View @@ -65,6 +124,7 @@ - CompanyServiceApi.ts #### Arquitectura Backend (Grafica Figma) + ### Ejecución #### Ambiente de desarrollo - Se ha usado Devcontainer y docker-compose para facilitar el desarrollo usando contenedores y vscode @@ -77,7 +137,11 @@ - abrir command palette: ctrl + shift + p - Seleccionar: Reopen in container - Seleccionar: "Rails API Container" - - Dentro ejecutar: `rails s -b 0.0.0.0` + - Dentro ejecutar: + ```bash + rails db:setup + rails s -b 0.0.0.0 + ``` - Ejecutar el contenedor Vue Container: - abrir command palette: ctrl + shift + p - Seleccionar: Reopen in container diff --git a/backend/spec/factories/company_services.rb b/backend/spec/factories/company_services.rb index 5c07b7e..82c293d 100644 --- a/backend/spec/factories/company_services.rb +++ b/backend/spec/factories/company_services.rb @@ -12,7 +12,7 @@ FactoryBot.define do factory :company_service do name { Faker::Company.unique.name } - contract_start_date { Faker::Date.between(from: '2024-08-01', to: '2024-08-31').beginning_of_week } + contract_start_date { Faker::Date.between(from: '2024-07-01', to: '2024-08-31').beginning_of_week } contract_end_date { Faker::Date.between( from: contract_start_date, to: '2024-10-31').end_of_week diff --git a/backend/spec/factories/engineers.rb b/backend/spec/factories/engineers.rb index 015b343..2168ad6 100644 --- a/backend/spec/factories/engineers.rb +++ b/backend/spec/factories/engineers.rb @@ -11,6 +11,13 @@ FactoryBot.define do factory :engineer do name { Faker::Name.name } - color { Faker::Color.hex_color } + # color { Faker::Color.hex_color } + color do + # Generate a pastel color by keeping RGB values high and saturation low + r = rand(200..255) + g = rand(200..255) + b = rand(200..255) + "#%02x%02x%02x" % [r, g, b] + end end end From e7a8bee33fb5d7f6fbf53e3b90df151c72b51d6b Mon Sep 17 00:00:00 2001 From: adrian peralta Date: Sun, 11 Aug 2024 05:47:45 +0000 Subject: [PATCH 33/33] config: #6 update readme --- 2-availability_management.png | Bin 0 -> 42448 bytes README.md | 2 ++ 2 files changed, 2 insertions(+) create mode 100644 2-availability_management.png diff --git a/2-availability_management.png b/2-availability_management.png new file mode 100644 index 0000000000000000000000000000000000000000..25b6b19292923d16a05c90c7c8415ad56c5cb42d GIT binary patch literal 42448 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 literal 0 HcmV?d00001 diff --git a/README.md b/README.md index 6d4c4e3..41a894f 100644 --- a/README.md +++ b/README.md @@ -171,6 +171,8 @@ docker-compose -f .devcontainer/docker-compose.yml up ### Screenshots #### Ejecución ![figma-1](./1-shift_management.png) +![figma-2](./2-availability_management.png) + #### Figma ![figma-1](./shift-availability-management-figma.jpg)