tudla_hubstaff 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/MIT-LICENSE +20 -0
- data/README.md +270 -0
- data/Rakefile +6 -0
- data/app/assets/stylesheets/tudla_hubstaff/application.css +15 -0
- data/app/assets/tailwind/tudla_hubstaff/engine.css +32 -0
- data/app/components/tudla_hubstaff/activity_row_component.html.erb +46 -0
- data/app/components/tudla_hubstaff/activity_row_component.rb +67 -0
- data/app/components/tudla_hubstaff/base_component.rb +10 -0
- data/app/components/tudla_hubstaff/project_row_component.html.erb +22 -0
- data/app/components/tudla_hubstaff/project_row_component.rb +21 -0
- data/app/components/tudla_hubstaff/task_activities_component.html.erb +82 -0
- data/app/components/tudla_hubstaff/task_activities_component.rb +93 -0
- data/app/components/tudla_hubstaff/task_row_component.html.erb +22 -0
- data/app/components/tudla_hubstaff/task_row_component.rb +17 -0
- data/app/components/tudla_hubstaff/ui/map_button_component.html.erb +8 -0
- data/app/components/tudla_hubstaff/ui/map_button_component.rb +21 -0
- data/app/components/tudla_hubstaff/ui/mapping_modal_component.html.erb +51 -0
- data/app/components/tudla_hubstaff/ui/mapping_modal_component.rb +34 -0
- data/app/components/tudla_hubstaff/ui/modal_component.html.erb +36 -0
- data/app/components/tudla_hubstaff/ui/modal_component.rb +17 -0
- data/app/components/tudla_hubstaff/ui/pagination_component.html.erb +19 -0
- data/app/components/tudla_hubstaff/ui/pagination_component.rb +47 -0
- data/app/components/tudla_hubstaff/ui/status_badge_component.html.erb +3 -0
- data/app/components/tudla_hubstaff/ui/status_badge_component.rb +26 -0
- data/app/components/tudla_hubstaff/ui/table_component.html.erb +20 -0
- data/app/components/tudla_hubstaff/ui/table_component.rb +15 -0
- data/app/components/tudla_hubstaff/ui.rb +4 -0
- data/app/components/tudla_hubstaff/user_activities_list_component.html.erb +70 -0
- data/app/components/tudla_hubstaff/user_activities_list_component.rb +92 -0
- data/app/components/tudla_hubstaff/user_row_component.html.erb +22 -0
- data/app/components/tudla_hubstaff/user_row_component.rb +13 -0
- data/app/controllers/tudla_hubstaff/activities_controller.rb +88 -0
- data/app/controllers/tudla_hubstaff/application_controller.rb +14 -0
- data/app/controllers/tudla_hubstaff/projects_controller.rb +75 -0
- data/app/controllers/tudla_hubstaff/tasks_controller.rb +75 -0
- data/app/controllers/tudla_hubstaff/users_controller.rb +75 -0
- data/app/helpers/tudla_hubstaff/application_helper.rb +4 -0
- data/app/javascript/tudla_hubstaff/application.js +21 -0
- data/app/javascript/tudla_hubstaff/controllers/activity_task_mapping_modal_controller.js +183 -0
- data/app/javascript/tudla_hubstaff/controllers/index.js +15 -0
- data/app/javascript/tudla_hubstaff/controllers/project_mapping_modal_controller.js +180 -0
- data/app/javascript/tudla_hubstaff/controllers/task_mapping_modal_controller.js +181 -0
- data/app/javascript/tudla_hubstaff/controllers/user_mapping_modal_controller.js +181 -0
- data/app/jobs/tudla_hubstaff/application_job.rb +4 -0
- data/app/jobs/tudla_hubstaff/fetch_updated_activities_job.rb +40 -0
- data/app/mailers/tudla_hubstaff/application_mailer.rb +6 -0
- data/app/models/tudla_hubstaff/activity.rb +6 -0
- data/app/models/tudla_hubstaff/application_record.rb +5 -0
- data/app/models/tudla_hubstaff/config.rb +8 -0
- data/app/models/tudla_hubstaff/organization_update.rb +5 -0
- data/app/models/tudla_hubstaff/project.rb +14 -0
- data/app/models/tudla_hubstaff/task.rb +9 -0
- data/app/models/tudla_hubstaff/user.rb +16 -0
- data/app/services/tudla_hubstaff/api_client.rb +64 -0
- data/app/services/tudla_hubstaff/api_connection.rb +54 -0
- data/app/services/tudla_hubstaff/concerns/activity_syncing.rb +46 -0
- data/app/services/tudla_hubstaff/concerns/task_syncing.rb +32 -0
- data/app/services/tudla_hubstaff/fetch_updated_activities_service.rb +28 -0
- data/app/services/tudla_hubstaff/get_task_service.rb +24 -0
- data/app/services/tudla_hubstaff/sync_activities_service.rb +28 -0
- data/app/services/tudla_hubstaff/sync_projects_service.rb +51 -0
- data/app/services/tudla_hubstaff/sync_tasks_service.rb +31 -0
- data/app/services/tudla_hubstaff/sync_users_service.rb +53 -0
- data/app/views/layouts/tudla_hubstaff/application.html.erb +19 -0
- data/app/views/tudla_hubstaff/projects/unmapped.html.erb +75 -0
- data/app/views/tudla_hubstaff/tasks/unmapped.html.erb +75 -0
- data/app/views/tudla_hubstaff/users/map_tudla_user.turbo_stream.erb +1 -0
- data/app/views/tudla_hubstaff/users/unmapped.html.erb +75 -0
- data/config/importmap.rb +7 -0
- data/config/routes.rb +41 -0
- data/db/migrate/20251230220607_create_tudla_hubstaff_users.rb +16 -0
- data/db/migrate/20251230222524_create_tudla_hubstaff_tasks.rb +21 -0
- data/db/migrate/20251230223731_create_tudla_hubstaff_projects.rb +15 -0
- data/db/migrate/20251230225408_add_hubstaff_ids_to_models.rb +14 -0
- data/db/migrate/20251231041753_add_last_updated_at_to_models.rb +7 -0
- data/db/migrate/20251231043338_add_tudla_ids_to_models.rb +14 -0
- data/db/migrate/20251231045209_create_tudla_hubstaff_activities.rb +30 -0
- data/db/migrate/20251231074018_create_tudla_hubstaff_configs.rb +15 -0
- data/db/migrate/20251231074556_create_tudla_hubstaff_organization_updates.rb +12 -0
- data/lib/generators/tudla_hubstaff/components_generator.rb +68 -0
- data/lib/generators/tudla_hubstaff/views_generator.rb +54 -0
- data/lib/tasks/tudla_hubstaff_tasks.rake +18 -0
- data/lib/tudla_hubstaff/engine.rb +88 -0
- data/lib/tudla_hubstaff/provider.rb +12 -0
- data/lib/tudla_hubstaff/version.rb +3 -0
- data/lib/tudla_hubstaff.rb +49 -0
- metadata +442 -0
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
module TudlaHubstaff
|
|
2
|
+
class ApiClient
|
|
3
|
+
attr_reader :organization_id, :connection
|
|
4
|
+
|
|
5
|
+
def initialize(organization_id, connection)
|
|
6
|
+
@organization_id = organization_id
|
|
7
|
+
@connection = connection
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def daily_activities_by_date(start_time, stop_time, options = {})
|
|
11
|
+
activities = connection.get("v2/organizations/#{organization_id}/activities/daily") do |req|
|
|
12
|
+
req.params["date[start]"] = start_time
|
|
13
|
+
req.params["date[stop]"] = stop_time
|
|
14
|
+
req.params["page_start_id"] = options[:page_start_id] unless options[:page_start_id].nil?
|
|
15
|
+
req.params["page_limit"] = options[:page_limit] unless options[:page_limit].nil?
|
|
16
|
+
end
|
|
17
|
+
activities.body
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def daily_activities_by_updated(start_time, stop_time, options = {})
|
|
21
|
+
activities = connection.get("v2/organizations/#{organization_id}/activities/daily/updates") do |req|
|
|
22
|
+
req.params["updated[start]"] = start_time
|
|
23
|
+
req.params["updated[stop]"] = stop_time
|
|
24
|
+
req.params["page_start_id"] = options[:page_start_id] unless options[:page_start_id].nil?
|
|
25
|
+
req.params["page_limit"] = options[:page_limit] unless options[:page_limit].nil?
|
|
26
|
+
end
|
|
27
|
+
activities.body
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def members(options = {})
|
|
31
|
+
members = connection.get("v2/organizations/#{organization_id}/members") do |req|
|
|
32
|
+
req.params["include"] = [ "users" ]
|
|
33
|
+
req.params["page_start_id"] = options[:page_start_id] unless options[:page_start_id].nil?
|
|
34
|
+
req.params["page_limit"] = options[:page_limit] unless options[:page_limit].nil?
|
|
35
|
+
end
|
|
36
|
+
members.body
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def projects(options = {})
|
|
40
|
+
projects = connection.get("v2/organizations/#{@organization_id}/projects") do |req|
|
|
41
|
+
req.params["page_start_id"] = options[:page_start_id] unless options[:page_start_id].nil?
|
|
42
|
+
req.params["page_limit"] = options[:page_limit] unless options[:page_limit].nil?
|
|
43
|
+
req.params["status"] = options[:status] unless options[:status].nil?
|
|
44
|
+
end
|
|
45
|
+
projects.body
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def tasks(options = {})
|
|
49
|
+
tasks = connection.get("v2/organizations/#{organization_id}/tasks") do |req|
|
|
50
|
+
req.params["page_start_id"] = options[:page_start_id] unless options[:page_start_id].nil?
|
|
51
|
+
req.params["page_limit"] = options[:page_limit] unless options[:page_limit].nil?
|
|
52
|
+
status = options.key?(:status) ? options[:status] : "active"
|
|
53
|
+
req.params["status"] = status unless status.nil?
|
|
54
|
+
req.params["project_ids"] = options[:project_ids].join(",") unless options[:project_ids].nil?
|
|
55
|
+
end
|
|
56
|
+
tasks.body
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def task(task_id)
|
|
60
|
+
task = connection.get("v2/tasks/#{task_id}")
|
|
61
|
+
task.body
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
module TudlaHubstaff
|
|
2
|
+
class ApiConnection
|
|
3
|
+
AUTH_URL = "https://account.hubstaff.com"
|
|
4
|
+
BASE_URL = "https://api.hubstaff.com/"
|
|
5
|
+
|
|
6
|
+
def initialize(personal_access_token)
|
|
7
|
+
@personal_access_token = personal_access_token
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def connection
|
|
11
|
+
@connection ||= Faraday.new do |req|
|
|
12
|
+
req.url_prefix = BASE_URL
|
|
13
|
+
req.adapter :httpx
|
|
14
|
+
req.request :authorization, "Bearer", access_token
|
|
15
|
+
req.request :json
|
|
16
|
+
req.response :json
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
delegate :get, :post, :put, :delete, to: :connection
|
|
21
|
+
|
|
22
|
+
private
|
|
23
|
+
|
|
24
|
+
def token_endpoint
|
|
25
|
+
@token_endpoint ||= Rails.cache.fetch("hubstaff_token_endpoint", expires_in: 1.weeks) do
|
|
26
|
+
OpenIDConnect::Discovery::Provider::Config.discover!(AUTH_URL).token_endpoint
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def refresh_token
|
|
31
|
+
@refresh_token = Rails.cache.fetch("hubstaff_refresh_token") do
|
|
32
|
+
@personal_access_token
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def access_token
|
|
37
|
+
access_token = Rails.cache.read("hubstaff_access_token")
|
|
38
|
+
access_token ||= begin
|
|
39
|
+
now = Time.current
|
|
40
|
+
auth_conn = Faraday.new(url: token_endpoint) do |req|
|
|
41
|
+
req.request :url_encoded
|
|
42
|
+
req.adapter :httpx
|
|
43
|
+
end
|
|
44
|
+
response = auth_conn.post do |req|
|
|
45
|
+
req.body = { grant_type: "refresh_token", refresh_token: refresh_token }
|
|
46
|
+
end
|
|
47
|
+
body = JSON.parse(response.body)
|
|
48
|
+
Rails.cache.write("hubstaff_refresh_token", body["refresh_token"])
|
|
49
|
+
Rails.cache.write("hubstaff_access_token", body["access_token"], expires_at: now + body["expires_in"])
|
|
50
|
+
body["access_token"]
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
module TudlaHubstaff
|
|
2
|
+
module Concerns
|
|
3
|
+
module ActivitySyncing
|
|
4
|
+
private
|
|
5
|
+
|
|
6
|
+
def sync_activity(activity_data)
|
|
7
|
+
api_updated_at = Time.parse(activity_data["updated_at"]) if activity_data["updated_at"]
|
|
8
|
+
|
|
9
|
+
activity = Activity.find_or_initialize_by(activity_id: activity_data["id"])
|
|
10
|
+
|
|
11
|
+
return if activity.last_updated_at && api_updated_at && api_updated_at <= activity.last_updated_at
|
|
12
|
+
|
|
13
|
+
activity_attributes = {
|
|
14
|
+
activity_id: activity_data["id"],
|
|
15
|
+
date: activity_data["date"],
|
|
16
|
+
user_id: activity_data["user_id"],
|
|
17
|
+
project_id: activity_data["project_id"],
|
|
18
|
+
task_id: activity_data["task_id"],
|
|
19
|
+
keyboard: activity_data["keyboard"] || 0,
|
|
20
|
+
mouse: activity_data["mouse"] || 0,
|
|
21
|
+
overall: activity_data["overall"] || 0,
|
|
22
|
+
tracked: activity_data["tracked"] || 0,
|
|
23
|
+
input_tracked: activity_data["input_tracked"] || 0,
|
|
24
|
+
manual: activity_data["manual"] || 0,
|
|
25
|
+
idle: activity_data["idle"] || 0,
|
|
26
|
+
resumed: activity_data["resumed"] || 0,
|
|
27
|
+
billable: activity_data["billable"] || 0,
|
|
28
|
+
work_break: activity_data["work_break"] || 0,
|
|
29
|
+
last_updated_at: api_updated_at
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
activity.update!(activity_attributes)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def process_activities_response(response)
|
|
36
|
+
activities_data = response["daily_activities"] || []
|
|
37
|
+
|
|
38
|
+
activities_data.each do |activity_data|
|
|
39
|
+
sync_activity(activity_data)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
activities_data.size
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
module TudlaHubstaff
|
|
2
|
+
module Concerns
|
|
3
|
+
module TaskSyncing
|
|
4
|
+
private
|
|
5
|
+
|
|
6
|
+
def sync_task(task_data)
|
|
7
|
+
api_updated_at = Time.parse(task_data["updated_at"]) if task_data["updated_at"]
|
|
8
|
+
|
|
9
|
+
task = Task.find_or_initialize_by(task_id: task_data["id"])
|
|
10
|
+
|
|
11
|
+
return task if task.last_updated_at && api_updated_at && api_updated_at <= task.last_updated_at
|
|
12
|
+
|
|
13
|
+
task_attributes = {
|
|
14
|
+
task_id: task_data["id"],
|
|
15
|
+
integration_id: task_data["integration_id"],
|
|
16
|
+
status: task_data["status"],
|
|
17
|
+
project_id: task_data["project_id"],
|
|
18
|
+
project_type: task_data["project_type"],
|
|
19
|
+
summary: task_data["summary"],
|
|
20
|
+
details: task_data["details"],
|
|
21
|
+
remote_id: task_data["remote_id"],
|
|
22
|
+
remote_alternate_id: task_data["remote_alternate_id"],
|
|
23
|
+
metadata: task_data["metadata"] || {},
|
|
24
|
+
last_updated_at: api_updated_at
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
task.update!(task_attributes)
|
|
28
|
+
task
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
module TudlaHubstaff
|
|
2
|
+
class FetchUpdatedActivitiesService
|
|
3
|
+
include Concerns::ActivitySyncing
|
|
4
|
+
|
|
5
|
+
attr_reader :api_client, :start_time, :stop_time
|
|
6
|
+
|
|
7
|
+
def initialize(api_client, start_time, stop_time)
|
|
8
|
+
@api_client = api_client
|
|
9
|
+
@start_time = start_time
|
|
10
|
+
@stop_time = stop_time
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def call
|
|
14
|
+
page_start_id = nil
|
|
15
|
+
total_synced = 0
|
|
16
|
+
|
|
17
|
+
loop do
|
|
18
|
+
response = api_client.daily_activities_by_updated(start_time, stop_time, page_start_id: page_start_id)
|
|
19
|
+
total_synced += process_activities_response(response)
|
|
20
|
+
|
|
21
|
+
page_start_id = response.dig("pagination", "next_page_start_id")
|
|
22
|
+
break if page_start_id.nil?
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
{ success: true, total_synced: total_synced }
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
module TudlaHubstaff
|
|
2
|
+
class GetTaskService
|
|
3
|
+
include Concerns::TaskSyncing
|
|
4
|
+
|
|
5
|
+
attr_reader :api_client, :task_id
|
|
6
|
+
|
|
7
|
+
def initialize(api_client, task_id)
|
|
8
|
+
@api_client = api_client
|
|
9
|
+
@task_id = task_id
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def call
|
|
13
|
+
response = api_client.task(task_id)
|
|
14
|
+
task_data = response["task"]
|
|
15
|
+
|
|
16
|
+
return { success: false, error: "Task not found" } unless task_data
|
|
17
|
+
|
|
18
|
+
task = sync_task(task_data)
|
|
19
|
+
{ success: true, task: task }
|
|
20
|
+
rescue Faraday::Error => e
|
|
21
|
+
{ success: false, error: e.message }
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
module TudlaHubstaff
|
|
2
|
+
class SyncActivitiesService
|
|
3
|
+
include Concerns::ActivitySyncing
|
|
4
|
+
|
|
5
|
+
attr_reader :api_client, :start_time, :stop_time
|
|
6
|
+
|
|
7
|
+
def initialize(api_client, start_time, stop_time)
|
|
8
|
+
@api_client = api_client
|
|
9
|
+
@start_time = start_time
|
|
10
|
+
@stop_time = stop_time
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def call
|
|
14
|
+
page_start_id = nil
|
|
15
|
+
total_synced = 0
|
|
16
|
+
|
|
17
|
+
loop do
|
|
18
|
+
response = api_client.daily_activities_by_date(start_time, stop_time, page_start_id: page_start_id)
|
|
19
|
+
total_synced += process_activities_response(response)
|
|
20
|
+
|
|
21
|
+
page_start_id = response.dig("pagination", "next_page_start_id")
|
|
22
|
+
break if page_start_id.nil?
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
{ success: true, total_synced: total_synced }
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
module TudlaHubstaff
|
|
2
|
+
class SyncProjectsService
|
|
3
|
+
attr_reader :api_client
|
|
4
|
+
|
|
5
|
+
def initialize(api_client)
|
|
6
|
+
@api_client = api_client
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
def call
|
|
10
|
+
page_start_id = nil
|
|
11
|
+
total_synced = 0
|
|
12
|
+
|
|
13
|
+
loop do
|
|
14
|
+
response = api_client.projects(page_start_id: page_start_id)
|
|
15
|
+
projects_data = response["projects"] || []
|
|
16
|
+
|
|
17
|
+
projects_data.each do |project_data|
|
|
18
|
+
sync_project(project_data)
|
|
19
|
+
total_synced += 1
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
page_start_id = response.dig("pagination", "next_page_start_id")
|
|
23
|
+
break if page_start_id.nil?
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
{ success: true, total_synced: total_synced }
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
private
|
|
30
|
+
|
|
31
|
+
def sync_project(project_data)
|
|
32
|
+
api_updated_at = Time.parse(project_data["updated_at"]) if project_data["updated_at"]
|
|
33
|
+
|
|
34
|
+
project = Project.find_or_initialize_by(project_id: project_data["id"])
|
|
35
|
+
|
|
36
|
+
return if project.last_updated_at && api_updated_at && api_updated_at <= project.last_updated_at
|
|
37
|
+
|
|
38
|
+
project_attributes = {
|
|
39
|
+
project_id: project_data["id"],
|
|
40
|
+
name: project_data["name"],
|
|
41
|
+
status: project_data["status"],
|
|
42
|
+
project_type: project_data["type"],
|
|
43
|
+
client_id: project_data["client_id"],
|
|
44
|
+
metadata: project_data["metadata"] || {},
|
|
45
|
+
last_updated_at: api_updated_at
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
project.update!(project_attributes)
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
module TudlaHubstaff
|
|
2
|
+
class SyncTasksService
|
|
3
|
+
include Concerns::TaskSyncing
|
|
4
|
+
|
|
5
|
+
attr_reader :api_client
|
|
6
|
+
|
|
7
|
+
def initialize(api_client)
|
|
8
|
+
@api_client = api_client
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def call
|
|
12
|
+
page_start_id = nil
|
|
13
|
+
total_synced = 0
|
|
14
|
+
|
|
15
|
+
loop do
|
|
16
|
+
response = api_client.tasks(page_start_id: page_start_id)
|
|
17
|
+
tasks_data = response["tasks"] || []
|
|
18
|
+
|
|
19
|
+
tasks_data.each do |task_data|
|
|
20
|
+
sync_task(task_data)
|
|
21
|
+
total_synced += 1
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
page_start_id = response.dig("pagination", "next_page_start_id")
|
|
25
|
+
break if page_start_id.nil?
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
{ success: true, total_synced: total_synced }
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
module TudlaHubstaff
|
|
2
|
+
class SyncUsersService
|
|
3
|
+
attr_reader :api_client
|
|
4
|
+
|
|
5
|
+
def initialize(api_client)
|
|
6
|
+
@api_client = api_client
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
def call
|
|
10
|
+
page_start_id = nil
|
|
11
|
+
total_synced = 0
|
|
12
|
+
|
|
13
|
+
loop do
|
|
14
|
+
response = api_client.members(page_start_id: page_start_id)
|
|
15
|
+
users_data = response["users"] || []
|
|
16
|
+
|
|
17
|
+
users_data.each do |user_data|
|
|
18
|
+
sync_user(user_data)
|
|
19
|
+
total_synced += 1
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
page_start_id = response.dig("pagination", "next_page_start_id")
|
|
23
|
+
break if page_start_id.nil?
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
{ success: true, total_synced: total_synced }
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
private
|
|
30
|
+
|
|
31
|
+
def sync_user(user_data)
|
|
32
|
+
api_updated_at = Time.parse(user_data["updated_at"]) if user_data["updated_at"]
|
|
33
|
+
|
|
34
|
+
user = User.find_or_initialize_by(user_id: user_data["id"])
|
|
35
|
+
|
|
36
|
+
return if user.last_updated_at && api_updated_at && api_updated_at <= user.last_updated_at
|
|
37
|
+
|
|
38
|
+
user_attributes = {
|
|
39
|
+
user_id: user_data["id"],
|
|
40
|
+
name: user_data["name"],
|
|
41
|
+
first_name: user_data["first_name"],
|
|
42
|
+
last_name: user_data["last_name"],
|
|
43
|
+
email: user_data["email"],
|
|
44
|
+
time_zone: user_data["time_zone"],
|
|
45
|
+
ip_address: user_data["ip_address"],
|
|
46
|
+
status: user_data["status"],
|
|
47
|
+
last_updated_at: api_updated_at
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
user.update!(user_attributes)
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html>
|
|
3
|
+
<head>
|
|
4
|
+
<title>TudlaHubstaff</title>
|
|
5
|
+
<%= csrf_meta_tags %>
|
|
6
|
+
<%= csp_meta_tag %>
|
|
7
|
+
|
|
8
|
+
<%= yield :head %>
|
|
9
|
+
|
|
10
|
+
<%= stylesheet_link_tag "tailwind", media: "all" %>
|
|
11
|
+
<%= javascript_importmap_tags %>
|
|
12
|
+
</head>
|
|
13
|
+
<body>
|
|
14
|
+
|
|
15
|
+
<%= yield %>
|
|
16
|
+
|
|
17
|
+
<%= javascript_import_module_tag "tudla_hubstaff/controllers/index" %>
|
|
18
|
+
</body>
|
|
19
|
+
</html>
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
<div data-controller="project-mapping-modal" data-available-projects-url="<%= available_tudla_projects_projects_path %>">
|
|
2
|
+
<div class="container mx-auto px-4 py-8">
|
|
3
|
+
<h1 class="text-2xl font-bold mb-6">Unmapped Projects</h1>
|
|
4
|
+
|
|
5
|
+
<% if @projects.any? %>
|
|
6
|
+
<div class="bg-white shadow rounded-lg overflow-hidden">
|
|
7
|
+
<table class="min-w-full divide-y divide-gray-200">
|
|
8
|
+
<thead class="bg-gray-50">
|
|
9
|
+
<tr>
|
|
10
|
+
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Name</th>
|
|
11
|
+
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Type</th>
|
|
12
|
+
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Status</th>
|
|
13
|
+
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Actions</th>
|
|
14
|
+
</tr>
|
|
15
|
+
</thead>
|
|
16
|
+
<tbody id="unmapped-projects-list" class="bg-white divide-y divide-gray-200">
|
|
17
|
+
<% @projects.each do |project| %>
|
|
18
|
+
<%= render TudlaHubstaff::ProjectRowComponent.new(project: project) %>
|
|
19
|
+
<% end %>
|
|
20
|
+
</tbody>
|
|
21
|
+
</table>
|
|
22
|
+
</div>
|
|
23
|
+
|
|
24
|
+
<%= render TudlaHubstaff::UI::PaginationComponent.new(
|
|
25
|
+
pagy: @pagy,
|
|
26
|
+
path_helper: :unmapped_projects_path
|
|
27
|
+
) %>
|
|
28
|
+
<% else %>
|
|
29
|
+
<div class="text-center py-12">
|
|
30
|
+
<p class="text-gray-500">No unmapped projects found.</p>
|
|
31
|
+
</div>
|
|
32
|
+
<% end %>
|
|
33
|
+
</div>
|
|
34
|
+
|
|
35
|
+
<%= render TudlaHubstaff::UI::ModalComponent.new(id: "project-mapping-modal", stimulus_controller: "project-mapping-modal") do |modal| %>
|
|
36
|
+
<% modal.with_header do %>
|
|
37
|
+
<h3 class="text-lg font-medium leading-6 text-gray-900 mb-4">
|
|
38
|
+
Map Project: <span data-project-mapping-modal-target="projectName"></span>
|
|
39
|
+
</h3>
|
|
40
|
+
<% end %>
|
|
41
|
+
|
|
42
|
+
<% modal.with_body do %>
|
|
43
|
+
<!-- Search Filter -->
|
|
44
|
+
<div class="mb-4">
|
|
45
|
+
<input type="text"
|
|
46
|
+
placeholder="Filter by name..."
|
|
47
|
+
class="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500"
|
|
48
|
+
data-project-mapping-modal-target="searchInput"
|
|
49
|
+
data-action="input->project-mapping-modal#filterProjects">
|
|
50
|
+
</div>
|
|
51
|
+
|
|
52
|
+
<!-- Projects List -->
|
|
53
|
+
<div class="max-h-80 overflow-y-auto">
|
|
54
|
+
<div data-project-mapping-modal-target="projectsList" class="space-y-2">
|
|
55
|
+
<!-- Projects will be loaded here -->
|
|
56
|
+
</div>
|
|
57
|
+
<div data-project-mapping-modal-target="loading" class="hidden text-center py-4">
|
|
58
|
+
<span class="text-gray-500">Loading...</span>
|
|
59
|
+
</div>
|
|
60
|
+
</div>
|
|
61
|
+
|
|
62
|
+
<!-- Modal Pagination -->
|
|
63
|
+
<div data-project-mapping-modal-target="pagination" class="flex justify-between items-center mt-4 pt-4 border-t border-gray-200">
|
|
64
|
+
</div>
|
|
65
|
+
<% end %>
|
|
66
|
+
|
|
67
|
+
<% modal.with_footer do %>
|
|
68
|
+
<button type="button"
|
|
69
|
+
class="mt-3 inline-flex w-full justify-center rounded-md bg-white px-3 py-2 text-sm font-semibold text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 hover:bg-gray-50 sm:mt-0 sm:w-auto"
|
|
70
|
+
data-action="click->project-mapping-modal#close">
|
|
71
|
+
Cancel
|
|
72
|
+
</button>
|
|
73
|
+
<% end %>
|
|
74
|
+
<% end %>
|
|
75
|
+
</div>
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
<div data-controller="task-mapping-modal" data-available-tasks-url="<%= available_tudla_tasks_tasks_path %>">
|
|
2
|
+
<div class="container mx-auto px-4 py-8">
|
|
3
|
+
<h1 class="text-2xl font-bold mb-6">Unmapped Tasks</h1>
|
|
4
|
+
|
|
5
|
+
<% if @tasks.any? %>
|
|
6
|
+
<div class="bg-white shadow rounded-lg overflow-hidden">
|
|
7
|
+
<table class="min-w-full divide-y divide-gray-200">
|
|
8
|
+
<thead class="bg-gray-50">
|
|
9
|
+
<tr>
|
|
10
|
+
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Summary</th>
|
|
11
|
+
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Project</th>
|
|
12
|
+
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Status</th>
|
|
13
|
+
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Actions</th>
|
|
14
|
+
</tr>
|
|
15
|
+
</thead>
|
|
16
|
+
<tbody id="unmapped-tasks-list" class="bg-white divide-y divide-gray-200">
|
|
17
|
+
<% @tasks.each do |task| %>
|
|
18
|
+
<%= render TudlaHubstaff::TaskRowComponent.new(task: task) %>
|
|
19
|
+
<% end %>
|
|
20
|
+
</tbody>
|
|
21
|
+
</table>
|
|
22
|
+
</div>
|
|
23
|
+
|
|
24
|
+
<%= render TudlaHubstaff::UI::PaginationComponent.new(
|
|
25
|
+
pagy: @pagy,
|
|
26
|
+
path_helper: :unmapped_tasks_path
|
|
27
|
+
) %>
|
|
28
|
+
<% else %>
|
|
29
|
+
<div class="text-center py-12">
|
|
30
|
+
<p class="text-gray-500">No unmapped tasks found.</p>
|
|
31
|
+
</div>
|
|
32
|
+
<% end %>
|
|
33
|
+
</div>
|
|
34
|
+
|
|
35
|
+
<%= render TudlaHubstaff::UI::ModalComponent.new(id: "task-mapping-modal", stimulus_controller: "task-mapping-modal") do |modal| %>
|
|
36
|
+
<% modal.with_header do %>
|
|
37
|
+
<h3 class="text-lg font-medium leading-6 text-gray-900 mb-4">
|
|
38
|
+
Map Task: <span data-task-mapping-modal-target="taskName"></span>
|
|
39
|
+
</h3>
|
|
40
|
+
<% end %>
|
|
41
|
+
|
|
42
|
+
<% modal.with_body do %>
|
|
43
|
+
<!-- Search Filter -->
|
|
44
|
+
<div class="mb-4">
|
|
45
|
+
<input type="text"
|
|
46
|
+
placeholder="Filter by name..."
|
|
47
|
+
class="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500"
|
|
48
|
+
data-task-mapping-modal-target="searchInput"
|
|
49
|
+
data-action="input->task-mapping-modal#filterTasks">
|
|
50
|
+
</div>
|
|
51
|
+
|
|
52
|
+
<!-- Tasks List -->
|
|
53
|
+
<div class="max-h-80 overflow-y-auto">
|
|
54
|
+
<div data-task-mapping-modal-target="tasksList" class="space-y-2">
|
|
55
|
+
<!-- Tasks will be loaded here -->
|
|
56
|
+
</div>
|
|
57
|
+
<div data-task-mapping-modal-target="loading" class="hidden text-center py-4">
|
|
58
|
+
<span class="text-gray-500">Loading...</span>
|
|
59
|
+
</div>
|
|
60
|
+
</div>
|
|
61
|
+
|
|
62
|
+
<!-- Modal Pagination -->
|
|
63
|
+
<div data-task-mapping-modal-target="pagination" class="flex justify-between items-center mt-4 pt-4 border-t border-gray-200">
|
|
64
|
+
</div>
|
|
65
|
+
<% end %>
|
|
66
|
+
|
|
67
|
+
<% modal.with_footer do %>
|
|
68
|
+
<button type="button"
|
|
69
|
+
class="mt-3 inline-flex w-full justify-center rounded-md bg-white px-3 py-2 text-sm font-semibold text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 hover:bg-gray-50 sm:mt-0 sm:w-auto"
|
|
70
|
+
data-action="click->task-mapping-modal#close">
|
|
71
|
+
Cancel
|
|
72
|
+
</button>
|
|
73
|
+
<% end %>
|
|
74
|
+
<% end %>
|
|
75
|
+
</div>
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
<%= turbo_stream.remove dom_id(@user) %>
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
<div data-controller="user-mapping-modal" data-available-users-url="<%= available_tudla_users_users_path %>">
|
|
2
|
+
<div class="container mx-auto px-4 py-8">
|
|
3
|
+
<h1 class="text-2xl font-bold mb-6">Unmapped Users</h1>
|
|
4
|
+
|
|
5
|
+
<% if @users.any? %>
|
|
6
|
+
<div class="bg-white shadow rounded-lg overflow-hidden">
|
|
7
|
+
<table class="min-w-full divide-y divide-gray-200">
|
|
8
|
+
<thead class="bg-gray-50">
|
|
9
|
+
<tr>
|
|
10
|
+
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Name</th>
|
|
11
|
+
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Email</th>
|
|
12
|
+
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Status</th>
|
|
13
|
+
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Actions</th>
|
|
14
|
+
</tr>
|
|
15
|
+
</thead>
|
|
16
|
+
<tbody id="unmapped-users-list" class="bg-white divide-y divide-gray-200">
|
|
17
|
+
<% @users.each do |user| %>
|
|
18
|
+
<%= render TudlaHubstaff::UserRowComponent.new(user: user) %>
|
|
19
|
+
<% end %>
|
|
20
|
+
</tbody>
|
|
21
|
+
</table>
|
|
22
|
+
</div>
|
|
23
|
+
|
|
24
|
+
<%= render TudlaHubstaff::UI::PaginationComponent.new(
|
|
25
|
+
pagy: @pagy,
|
|
26
|
+
path_helper: :unmapped_users_path
|
|
27
|
+
) %>
|
|
28
|
+
<% else %>
|
|
29
|
+
<div class="text-center py-12">
|
|
30
|
+
<p class="text-gray-500">No unmapped users found.</p>
|
|
31
|
+
</div>
|
|
32
|
+
<% end %>
|
|
33
|
+
</div>
|
|
34
|
+
|
|
35
|
+
<%= render TudlaHubstaff::UI::ModalComponent.new(id: "mapping-modal", stimulus_controller: "user-mapping-modal") do |modal| %>
|
|
36
|
+
<% modal.with_header do %>
|
|
37
|
+
<h3 class="text-lg font-medium leading-6 text-gray-900 mb-4">
|
|
38
|
+
Map User: <span data-user-mapping-modal-target="userName"></span>
|
|
39
|
+
</h3>
|
|
40
|
+
<% end %>
|
|
41
|
+
|
|
42
|
+
<% modal.with_body do %>
|
|
43
|
+
<!-- Search Filter -->
|
|
44
|
+
<div class="mb-4">
|
|
45
|
+
<input type="text"
|
|
46
|
+
placeholder="Filter by name..."
|
|
47
|
+
class="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500"
|
|
48
|
+
data-user-mapping-modal-target="searchInput"
|
|
49
|
+
data-action="input->user-mapping-modal#filterUsers">
|
|
50
|
+
</div>
|
|
51
|
+
|
|
52
|
+
<!-- Users List -->
|
|
53
|
+
<div class="max-h-80 overflow-y-auto">
|
|
54
|
+
<div data-user-mapping-modal-target="usersList" class="space-y-2">
|
|
55
|
+
<!-- Users will be loaded here -->
|
|
56
|
+
</div>
|
|
57
|
+
<div data-user-mapping-modal-target="loading" class="hidden text-center py-4">
|
|
58
|
+
<span class="text-gray-500">Loading...</span>
|
|
59
|
+
</div>
|
|
60
|
+
</div>
|
|
61
|
+
|
|
62
|
+
<!-- Modal Pagination -->
|
|
63
|
+
<div data-user-mapping-modal-target="pagination" class="flex justify-between items-center mt-4 pt-4 border-t border-gray-200">
|
|
64
|
+
</div>
|
|
65
|
+
<% end %>
|
|
66
|
+
|
|
67
|
+
<% modal.with_footer do %>
|
|
68
|
+
<button type="button"
|
|
69
|
+
class="mt-3 inline-flex w-full justify-center rounded-md bg-white px-3 py-2 text-sm font-semibold text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 hover:bg-gray-50 sm:mt-0 sm:w-auto"
|
|
70
|
+
data-action="click->user-mapping-modal#close">
|
|
71
|
+
Cancel
|
|
72
|
+
</button>
|
|
73
|
+
<% end %>
|
|
74
|
+
<% end %>
|
|
75
|
+
</div>
|