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.
Files changed (88) hide show
  1. checksums.yaml +7 -0
  2. data/MIT-LICENSE +20 -0
  3. data/README.md +270 -0
  4. data/Rakefile +6 -0
  5. data/app/assets/stylesheets/tudla_hubstaff/application.css +15 -0
  6. data/app/assets/tailwind/tudla_hubstaff/engine.css +32 -0
  7. data/app/components/tudla_hubstaff/activity_row_component.html.erb +46 -0
  8. data/app/components/tudla_hubstaff/activity_row_component.rb +67 -0
  9. data/app/components/tudla_hubstaff/base_component.rb +10 -0
  10. data/app/components/tudla_hubstaff/project_row_component.html.erb +22 -0
  11. data/app/components/tudla_hubstaff/project_row_component.rb +21 -0
  12. data/app/components/tudla_hubstaff/task_activities_component.html.erb +82 -0
  13. data/app/components/tudla_hubstaff/task_activities_component.rb +93 -0
  14. data/app/components/tudla_hubstaff/task_row_component.html.erb +22 -0
  15. data/app/components/tudla_hubstaff/task_row_component.rb +17 -0
  16. data/app/components/tudla_hubstaff/ui/map_button_component.html.erb +8 -0
  17. data/app/components/tudla_hubstaff/ui/map_button_component.rb +21 -0
  18. data/app/components/tudla_hubstaff/ui/mapping_modal_component.html.erb +51 -0
  19. data/app/components/tudla_hubstaff/ui/mapping_modal_component.rb +34 -0
  20. data/app/components/tudla_hubstaff/ui/modal_component.html.erb +36 -0
  21. data/app/components/tudla_hubstaff/ui/modal_component.rb +17 -0
  22. data/app/components/tudla_hubstaff/ui/pagination_component.html.erb +19 -0
  23. data/app/components/tudla_hubstaff/ui/pagination_component.rb +47 -0
  24. data/app/components/tudla_hubstaff/ui/status_badge_component.html.erb +3 -0
  25. data/app/components/tudla_hubstaff/ui/status_badge_component.rb +26 -0
  26. data/app/components/tudla_hubstaff/ui/table_component.html.erb +20 -0
  27. data/app/components/tudla_hubstaff/ui/table_component.rb +15 -0
  28. data/app/components/tudla_hubstaff/ui.rb +4 -0
  29. data/app/components/tudla_hubstaff/user_activities_list_component.html.erb +70 -0
  30. data/app/components/tudla_hubstaff/user_activities_list_component.rb +92 -0
  31. data/app/components/tudla_hubstaff/user_row_component.html.erb +22 -0
  32. data/app/components/tudla_hubstaff/user_row_component.rb +13 -0
  33. data/app/controllers/tudla_hubstaff/activities_controller.rb +88 -0
  34. data/app/controllers/tudla_hubstaff/application_controller.rb +14 -0
  35. data/app/controllers/tudla_hubstaff/projects_controller.rb +75 -0
  36. data/app/controllers/tudla_hubstaff/tasks_controller.rb +75 -0
  37. data/app/controllers/tudla_hubstaff/users_controller.rb +75 -0
  38. data/app/helpers/tudla_hubstaff/application_helper.rb +4 -0
  39. data/app/javascript/tudla_hubstaff/application.js +21 -0
  40. data/app/javascript/tudla_hubstaff/controllers/activity_task_mapping_modal_controller.js +183 -0
  41. data/app/javascript/tudla_hubstaff/controllers/index.js +15 -0
  42. data/app/javascript/tudla_hubstaff/controllers/project_mapping_modal_controller.js +180 -0
  43. data/app/javascript/tudla_hubstaff/controllers/task_mapping_modal_controller.js +181 -0
  44. data/app/javascript/tudla_hubstaff/controllers/user_mapping_modal_controller.js +181 -0
  45. data/app/jobs/tudla_hubstaff/application_job.rb +4 -0
  46. data/app/jobs/tudla_hubstaff/fetch_updated_activities_job.rb +40 -0
  47. data/app/mailers/tudla_hubstaff/application_mailer.rb +6 -0
  48. data/app/models/tudla_hubstaff/activity.rb +6 -0
  49. data/app/models/tudla_hubstaff/application_record.rb +5 -0
  50. data/app/models/tudla_hubstaff/config.rb +8 -0
  51. data/app/models/tudla_hubstaff/organization_update.rb +5 -0
  52. data/app/models/tudla_hubstaff/project.rb +14 -0
  53. data/app/models/tudla_hubstaff/task.rb +9 -0
  54. data/app/models/tudla_hubstaff/user.rb +16 -0
  55. data/app/services/tudla_hubstaff/api_client.rb +64 -0
  56. data/app/services/tudla_hubstaff/api_connection.rb +54 -0
  57. data/app/services/tudla_hubstaff/concerns/activity_syncing.rb +46 -0
  58. data/app/services/tudla_hubstaff/concerns/task_syncing.rb +32 -0
  59. data/app/services/tudla_hubstaff/fetch_updated_activities_service.rb +28 -0
  60. data/app/services/tudla_hubstaff/get_task_service.rb +24 -0
  61. data/app/services/tudla_hubstaff/sync_activities_service.rb +28 -0
  62. data/app/services/tudla_hubstaff/sync_projects_service.rb +51 -0
  63. data/app/services/tudla_hubstaff/sync_tasks_service.rb +31 -0
  64. data/app/services/tudla_hubstaff/sync_users_service.rb +53 -0
  65. data/app/views/layouts/tudla_hubstaff/application.html.erb +19 -0
  66. data/app/views/tudla_hubstaff/projects/unmapped.html.erb +75 -0
  67. data/app/views/tudla_hubstaff/tasks/unmapped.html.erb +75 -0
  68. data/app/views/tudla_hubstaff/users/map_tudla_user.turbo_stream.erb +1 -0
  69. data/app/views/tudla_hubstaff/users/unmapped.html.erb +75 -0
  70. data/config/importmap.rb +7 -0
  71. data/config/routes.rb +41 -0
  72. data/db/migrate/20251230220607_create_tudla_hubstaff_users.rb +16 -0
  73. data/db/migrate/20251230222524_create_tudla_hubstaff_tasks.rb +21 -0
  74. data/db/migrate/20251230223731_create_tudla_hubstaff_projects.rb +15 -0
  75. data/db/migrate/20251230225408_add_hubstaff_ids_to_models.rb +14 -0
  76. data/db/migrate/20251231041753_add_last_updated_at_to_models.rb +7 -0
  77. data/db/migrate/20251231043338_add_tudla_ids_to_models.rb +14 -0
  78. data/db/migrate/20251231045209_create_tudla_hubstaff_activities.rb +30 -0
  79. data/db/migrate/20251231074018_create_tudla_hubstaff_configs.rb +15 -0
  80. data/db/migrate/20251231074556_create_tudla_hubstaff_organization_updates.rb +12 -0
  81. data/lib/generators/tudla_hubstaff/components_generator.rb +68 -0
  82. data/lib/generators/tudla_hubstaff/views_generator.rb +54 -0
  83. data/lib/tasks/tudla_hubstaff_tasks.rake +18 -0
  84. data/lib/tudla_hubstaff/engine.rb +88 -0
  85. data/lib/tudla_hubstaff/provider.rb +12 -0
  86. data/lib/tudla_hubstaff/version.rb +3 -0
  87. data/lib/tudla_hubstaff.rb +49 -0
  88. 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>