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,93 @@
1
+ module TudlaHubstaff
2
+ class TaskActivitiesComponent < TudlaHubstaff::BaseComponent
3
+ PER_PAGE = 20
4
+
5
+ def initialize(tudla_task_id:, current_page: 1, per_page: PER_PAGE)
6
+ @tudla_task_id = tudla_task_id
7
+ @current_page = current_page.to_i
8
+ @per_page = per_page.to_i
9
+ end
10
+
11
+ attr_reader :tudla_task_id, :current_page, :per_page
12
+
13
+ def activities
14
+ @activities ||= fetch_activities
15
+ end
16
+
17
+ def total_count
18
+ @total_count ||= activities_scope.count
19
+ end
20
+
21
+ def total_pages
22
+ (total_count.to_f / per_page).ceil
23
+ end
24
+
25
+ def render?
26
+ tudla_task_id.present?
27
+ end
28
+
29
+ def start_item
30
+ return 0 if total_count.zero?
31
+ ((current_page - 1) * per_page) + 1
32
+ end
33
+
34
+ def end_item
35
+ [ current_page * per_page, total_count ].min
36
+ end
37
+
38
+ def show_previous?
39
+ current_page > 1
40
+ end
41
+
42
+ def show_next?
43
+ current_page < total_pages
44
+ end
45
+
46
+ def previous_page
47
+ current_page - 1
48
+ end
49
+
50
+ def next_page
51
+ current_page + 1
52
+ end
53
+
54
+ def format_duration(seconds)
55
+ return "0m" if seconds.nil? || seconds.zero?
56
+
57
+ hours = seconds / 3600
58
+ minutes = (seconds % 3600) / 60
59
+
60
+ if hours > 0
61
+ "#{hours}h #{minutes}m"
62
+ else
63
+ "#{minutes}m"
64
+ end
65
+ end
66
+
67
+ def format_date(date_string)
68
+ return "" if date_string.blank?
69
+ Date.parse(date_string).strftime("%b %d, %Y")
70
+ rescue ArgumentError
71
+ date_string
72
+ end
73
+
74
+ private
75
+
76
+ def fetch_activities
77
+ activities_scope
78
+ .order(last_updated_at: :desc)
79
+ .offset((current_page - 1) * per_page)
80
+ .limit(per_page)
81
+ end
82
+
83
+ def activities_scope
84
+ @activities_scope ||= begin
85
+ hubstaff_task_ids = TudlaHubstaff::Task
86
+ .where(tudla_task_id: tudla_task_id)
87
+ .pluck(:task_id)
88
+
89
+ TudlaHubstaff::Activity.where(task_id: hubstaff_task_ids)
90
+ end
91
+ end
92
+ end
93
+ end
@@ -0,0 +1,22 @@
1
+ <tr id="<%= dom_id %>" class="tudla_hubstaff-task-row">
2
+ <td class="px-6 py-4 whitespace-nowrap">
3
+ <div class="text-sm font-medium text-gray-900"><%= task.summary %></div>
4
+ <div class="text-sm text-gray-500"><%= truncated_details %></div>
5
+ </td>
6
+ <td class="px-6 py-4 whitespace-nowrap">
7
+ <div class="text-sm text-gray-900"><%= task.project_type %></div>
8
+ </td>
9
+ <td class="px-6 py-4 whitespace-nowrap">
10
+ <%= render TudlaHubstaff::UI::StatusBadgeComponent.new(status: task.status) %>
11
+ </td>
12
+ <td class="px-6 py-4 whitespace-nowrap text-sm font-medium">
13
+ <button type="button"
14
+ class="text-indigo-600 hover:text-indigo-900"
15
+ data-action="click->task-mapping-modal#open"
16
+ data-task-id="<%= task.id %>"
17
+ data-task-name="<%= task.summary %>"
18
+ data-map-url="<%= map_tudla_task_task_path(task) %>">
19
+ Map to Tudla Task
20
+ </button>
21
+ </td>
22
+ </tr>
@@ -0,0 +1,17 @@
1
+ module TudlaHubstaff
2
+ class TaskRowComponent < TudlaHubstaff::BaseComponent
3
+ def initialize(task:)
4
+ @task = task
5
+ end
6
+
7
+ attr_reader :task
8
+
9
+ def dom_id
10
+ "tudla_hubstaff_task_#{task.id}"
11
+ end
12
+
13
+ def truncated_details
14
+ task.details.to_s.truncate(50)
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,8 @@
1
+ <button type="button"
2
+ class="tudla_hubstaff-map-button text-indigo-600 hover:text-indigo-900"
3
+ data-action="click-><%= stimulus_controller %>#open"
4
+ data-item-id="<%= item_id %>"
5
+ data-item-name="<%= item_name %>"
6
+ data-map-url="<%= map_url %>">
7
+ <%= button_text %>
8
+ </button>
@@ -0,0 +1,21 @@
1
+ module TudlaHubstaff
2
+ module UI
3
+ class MapButtonComponent < TudlaHubstaff::BaseComponent
4
+ def initialize(
5
+ item_id:,
6
+ item_name:,
7
+ map_url:,
8
+ stimulus_controller:,
9
+ button_text: "Map"
10
+ )
11
+ @item_id = item_id
12
+ @item_name = item_name
13
+ @map_url = map_url
14
+ @stimulus_controller = stimulus_controller
15
+ @button_text = button_text
16
+ end
17
+
18
+ attr_reader :item_id, :item_name, :map_url, :stimulus_controller, :button_text
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,51 @@
1
+ <div id="<%= id %>"
2
+ class="tudla_hubstaff-mapping-modal fixed inset-0 z-50 overflow-y-auto hidden"
3
+ data-<%= stimulus_controller %>-target="modal">
4
+ <!-- Backdrop -->
5
+ <div class="fixed inset-0 bg-black/50 transition-opacity"
6
+ data-action="click-><%= stimulus_controller %>#close"></div>
7
+
8
+ <!-- Modal Content -->
9
+ <div class="fixed inset-0 z-10 overflow-y-auto">
10
+ <div class="flex min-h-full items-center justify-center p-4">
11
+ <div class="relative transform overflow-hidden rounded-lg bg-white text-left shadow-xl transition-all w-full max-w-lg">
12
+ <div class="bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4">
13
+ <h3 class="text-lg font-medium leading-6 text-gray-900 mb-4">
14
+ <%= title %>: <span data-<%= stimulus_controller %>-target="itemName"></span>
15
+ </h3>
16
+
17
+ <!-- Search Filter -->
18
+ <div class="mb-4">
19
+ <input type="text"
20
+ placeholder="<%= search_placeholder %>"
21
+ 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"
22
+ data-<%= stimulus_controller %>-target="searchInput"
23
+ data-action="input-><%= stimulus_controller %>#filterItems">
24
+ </div>
25
+
26
+ <!-- Items List -->
27
+ <div class="max-h-80 overflow-y-auto">
28
+ <div data-<%= stimulus_controller %>-target="itemsList" class="space-y-2">
29
+ <!-- Items will be loaded here -->
30
+ </div>
31
+ <div data-<%= stimulus_controller %>-target="loading" class="hidden text-center py-4">
32
+ <span class="text-gray-500"><%= loading_text %></span>
33
+ </div>
34
+ </div>
35
+
36
+ <!-- Modal Pagination -->
37
+ <div data-<%= stimulus_controller %>-target="pagination" class="flex justify-between items-center mt-4 pt-4 border-t border-gray-200">
38
+ </div>
39
+ </div>
40
+
41
+ <div class="bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6">
42
+ <button type="button"
43
+ 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"
44
+ data-action="click-><%= stimulus_controller %>#close">
45
+ <%= cancel_text %>
46
+ </button>
47
+ </div>
48
+ </div>
49
+ </div>
50
+ </div>
51
+ </div>
@@ -0,0 +1,34 @@
1
+ module TudlaHubstaff
2
+ module UI
3
+ class MappingModalComponent < TudlaHubstaff::BaseComponent
4
+ renders_one :search_input
5
+ renders_one :items_list
6
+ renders_one :pagination
7
+
8
+ def initialize(
9
+ id:,
10
+ title:,
11
+ stimulus_controller:,
12
+ available_items_url:,
13
+ search_placeholder: "Filter by name...",
14
+ loading_text: "Loading...",
15
+ cancel_text: "Cancel"
16
+ )
17
+ @id = id
18
+ @title = title
19
+ @stimulus_controller = stimulus_controller
20
+ @available_items_url = available_items_url
21
+ @search_placeholder = search_placeholder
22
+ @loading_text = loading_text
23
+ @cancel_text = cancel_text
24
+ end
25
+
26
+ attr_reader :id, :title, :stimulus_controller, :available_items_url,
27
+ :search_placeholder, :loading_text, :cancel_text
28
+
29
+ def target_prefix
30
+ stimulus_controller.gsub("-", "_").camelize(:lower)
31
+ end
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,36 @@
1
+ <div id="<%= id %>"
2
+ class="tudla_hubstaff-modal fixed inset-0 z-50 overflow-y-auto hidden"
3
+ <% if stimulus_controller %>data-<%= stimulus_controller %>-target="modal"<% end %>>
4
+ <!-- Backdrop -->
5
+ <div class="fixed inset-0 bg-black/50 transition-opacity"
6
+ <% if stimulus_controller %>data-action="click-><%= stimulus_controller %>#close"<% end %>></div>
7
+
8
+ <!-- Modal Content -->
9
+ <div class="fixed inset-0 z-10 overflow-y-auto">
10
+ <div class="flex min-h-full items-center justify-center p-4">
11
+ <div class="relative transform overflow-hidden rounded-lg bg-white text-left shadow-xl transition-all w-full max-w-lg">
12
+ <div class="bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4">
13
+ <% if header? %>
14
+ <%= header %>
15
+ <% elsif title %>
16
+ <h3 class="text-lg font-medium leading-6 text-gray-900 mb-4">
17
+ <%= title %>
18
+ </h3>
19
+ <% end %>
20
+
21
+ <% if body? %>
22
+ <%= body %>
23
+ <% else %>
24
+ <%= content %>
25
+ <% end %>
26
+ </div>
27
+
28
+ <% if footer? %>
29
+ <div class="bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6">
30
+ <%= footer %>
31
+ </div>
32
+ <% end %>
33
+ </div>
34
+ </div>
35
+ </div>
36
+ </div>
@@ -0,0 +1,17 @@
1
+ module TudlaHubstaff
2
+ module UI
3
+ class ModalComponent < TudlaHubstaff::BaseComponent
4
+ renders_one :header
5
+ renders_one :body
6
+ renders_one :footer
7
+
8
+ def initialize(id:, title: nil, stimulus_controller: nil)
9
+ @id = id
10
+ @title = title
11
+ @stimulus_controller = stimulus_controller
12
+ end
13
+
14
+ attr_reader :id, :title, :stimulus_controller
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,19 @@
1
+ <nav class="tudla_hubstaff-pagination flex items-center justify-between border-t border-gray-200 px-4 py-3 sm:px-6 mt-4" aria-label="Pagination">
2
+ <div class="hidden sm:block">
3
+ <p class="text-sm text-gray-700">
4
+ Showing <span class="font-medium"><%= start_item %></span> to
5
+ <span class="font-medium"><%= end_item %></span> of
6
+ <span class="font-medium"><%= total_count %></span> results
7
+ </p>
8
+ </div>
9
+ <div class="flex flex-1 justify-between sm:justify-end gap-2">
10
+ <% if show_previous? %>
11
+ <%= link_to "Previous", previous_page_path,
12
+ class: "relative inline-flex items-center rounded-md bg-white px-3 py-2 text-sm font-semibold text-gray-900 ring-1 ring-inset ring-gray-300 hover:bg-gray-50" %>
13
+ <% end %>
14
+ <% if show_next? %>
15
+ <%= link_to "Next", next_page_path,
16
+ class: "relative inline-flex items-center rounded-md bg-white px-3 py-2 text-sm font-semibold text-gray-900 ring-1 ring-inset ring-gray-300 hover:bg-gray-50" %>
17
+ <% end %>
18
+ </div>
19
+ </nav>
@@ -0,0 +1,47 @@
1
+ module TudlaHubstaff
2
+ module UI
3
+ class PaginationComponent < TudlaHubstaff::BaseComponent
4
+ def initialize(pagy:, path_helper:, path_params: {})
5
+ @pagy = pagy
6
+ @path_helper = path_helper
7
+ @path_params = path_params
8
+ end
9
+
10
+ attr_reader :pagy, :path_helper, :path_params
11
+
12
+ delegate :last, :count, :from, :to, :previous, :next, to: :pagy
13
+
14
+ def render?
15
+ last > 1
16
+ end
17
+
18
+ def start_item
19
+ from
20
+ end
21
+
22
+ def end_item
23
+ to
24
+ end
25
+
26
+ def total_count
27
+ count
28
+ end
29
+
30
+ def previous_page_path
31
+ send(path_helper, path_params.merge(page: previous))
32
+ end
33
+
34
+ def next_page_path
35
+ send(path_helper, path_params.merge(page: self.next))
36
+ end
37
+
38
+ def show_previous?
39
+ previous.present?
40
+ end
41
+
42
+ def show_next?
43
+ self.next.present?
44
+ end
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,3 @@
1
+ <span class="tudla_hubstaff-status-badge px-2 inline-flex text-xs leading-5 font-semibold rounded-full <%= style_classes %>">
2
+ <%= display_text %>
3
+ </span>
@@ -0,0 +1,26 @@
1
+ module TudlaHubstaff
2
+ module UI
3
+ class StatusBadgeComponent < TudlaHubstaff::BaseComponent
4
+ STYLES = {
5
+ active: "bg-green-100 text-green-800",
6
+ pending: "bg-gray-100 text-gray-800",
7
+ inactive: "bg-gray-100 text-gray-800",
8
+ default: "bg-gray-100 text-gray-800"
9
+ }.freeze
10
+
11
+ def initialize(status:)
12
+ @status = status.to_s.downcase
13
+ end
14
+
15
+ attr_reader :status
16
+
17
+ def style_classes
18
+ STYLES[status.to_sym] || STYLES[:default]
19
+ end
20
+
21
+ def display_text
22
+ status.presence || "pending"
23
+ end
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,20 @@
1
+ <% if rows? %>
2
+ <div class="tudla_hubstaff-table bg-white shadow rounded-lg overflow-hidden">
3
+ <table class="min-w-full divide-y divide-gray-200" <%= "id=\"#{id}\"" if id %>>
4
+ <% if header? %>
5
+ <thead class="bg-gray-50">
6
+ <%= header %>
7
+ </thead>
8
+ <% end %>
9
+ <tbody class="bg-white divide-y divide-gray-200">
10
+ <% rows.each do |row| %>
11
+ <%= row %>
12
+ <% end %>
13
+ </tbody>
14
+ </table>
15
+ </div>
16
+ <% else %>
17
+ <div class="text-center py-12">
18
+ <p class="text-gray-500"><%= empty_message %></p>
19
+ </div>
20
+ <% end %>
@@ -0,0 +1,15 @@
1
+ module TudlaHubstaff
2
+ module UI
3
+ class TableComponent < TudlaHubstaff::BaseComponent
4
+ renders_one :header
5
+ renders_many :rows
6
+
7
+ def initialize(id: nil, empty_message: "No items found.")
8
+ @id = id
9
+ @empty_message = empty_message
10
+ end
11
+
12
+ attr_reader :id, :empty_message
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,4 @@
1
+ module TudlaHubstaff
2
+ module UI
3
+ end
4
+ end
@@ -0,0 +1,70 @@
1
+ <%= turbo_frame_tag turbo_frame_id do %>
2
+ <div class="tudla_hubstaff-user-activities-list" data-controller="activity-task-mapping-modal" data-available-tasks-url="<%= available_tudla_tasks_activities_path %>">
3
+ <div class="flex items-center justify-between mb-4">
4
+ <div class="flex items-center space-x-4">
5
+ <%= link_to "All", filter_path(unmapped: false),
6
+ class: "px-3 py-1 text-sm rounded-md #{unmapped_only ? 'text-gray-600 hover:bg-gray-100' : 'bg-indigo-600 text-white'}",
7
+ data: { turbo_frame: turbo_frame_id } %>
8
+ <%= link_to "Unmapped Only", filter_path(unmapped: true),
9
+ class: "px-3 py-1 text-sm rounded-md #{unmapped_only ? 'bg-indigo-600 text-white' : 'text-gray-600 hover:bg-gray-100'}",
10
+ data: { turbo_frame: turbo_frame_id } %>
11
+ </div>
12
+ </div>
13
+
14
+ <% if activities.any? %>
15
+ <div class="bg-white shadow rounded-lg overflow-hidden">
16
+ <table class="min-w-full divide-y divide-gray-200">
17
+ <thead class="bg-gray-50">
18
+ <tr>
19
+ <th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Date</th>
20
+ <th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Task</th>
21
+ <th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Tracked</th>
22
+ <th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Activity</th>
23
+ <th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Actions</th>
24
+ </tr>
25
+ </thead>
26
+ <tbody class="bg-white divide-y divide-gray-200">
27
+ <% activities.each do |activity| %>
28
+ <%= render TudlaHubstaff::ActivityRowComponent.new(activity: activity) %>
29
+ <% end %>
30
+ </tbody>
31
+ </table>
32
+ </div>
33
+
34
+ <% if total_pages > 1 %>
35
+ <div class="tudla_hubstaff-pagination flex items-center justify-between mt-4 px-4 py-3 bg-white border border-gray-200 rounded-lg sm:px-6">
36
+ <div class="text-sm text-gray-700">
37
+ Showing <span class="font-medium"><%= start_item %></span> to <span class="font-medium"><%= end_item %></span> of <span class="font-medium"><%= total_count %></span> activities
38
+ </div>
39
+ <div class="flex space-x-2">
40
+ <% if show_previous? %>
41
+ <%= link_to "Previous", pagination_path(page: previous_page),
42
+ class: "text-indigo-600 hover:text-indigo-900",
43
+ data: { turbo_frame: turbo_frame_id } %>
44
+ <% else %>
45
+ <span class="text-gray-400 cursor-not-allowed">Previous</span>
46
+ <% end %>
47
+ <% if show_next? %>
48
+ <%= link_to "Next", pagination_path(page: next_page),
49
+ class: "text-indigo-600 hover:text-indigo-900",
50
+ data: { turbo_frame: turbo_frame_id } %>
51
+ <% else %>
52
+ <span class="text-gray-400 cursor-not-allowed">Next</span>
53
+ <% end %>
54
+ </div>
55
+ </div>
56
+ <% end %>
57
+
58
+ <%= render TudlaHubstaff::UI::MappingModalComponent.new(
59
+ id: "activity-task-mapping-modal",
60
+ title: "Map Activity Task",
61
+ stimulus_controller: "activity-task-mapping-modal",
62
+ available_items_url: available_tudla_tasks_activities_path
63
+ ) %>
64
+ <% else %>
65
+ <div class="text-center py-12 bg-white rounded-lg border border-gray-200">
66
+ <p class="text-gray-500">No activities found for this user.</p>
67
+ </div>
68
+ <% end %>
69
+ </div>
70
+ <% end %>
@@ -0,0 +1,92 @@
1
+ module TudlaHubstaff
2
+ class UserActivitiesListComponent < TudlaHubstaff::BaseComponent
3
+ PER_PAGE = 10
4
+
5
+ def initialize(user_id:, current_page: 1, per_page: PER_PAGE, unmapped_only: false)
6
+ @user_id = user_id
7
+ @current_page = current_page.to_i
8
+ @per_page = per_page.to_i
9
+ @unmapped_only = unmapped_only
10
+ end
11
+
12
+ attr_reader :user_id, :current_page, :per_page, :unmapped_only
13
+
14
+ def activities
15
+ @activities ||= fetch_activities
16
+ end
17
+
18
+ def total_count
19
+ @total_count ||= activities_scope.count
20
+ end
21
+
22
+ def total_pages
23
+ (total_count.to_f / per_page).ceil
24
+ end
25
+
26
+ def render?
27
+ user_id.present?
28
+ end
29
+
30
+ def start_item
31
+ return 0 if total_count.zero?
32
+ ((current_page - 1) * per_page) + 1
33
+ end
34
+
35
+ def end_item
36
+ [ current_page * per_page, total_count ].min
37
+ end
38
+
39
+ def show_previous?
40
+ current_page > 1
41
+ end
42
+
43
+ def show_next?
44
+ current_page < total_pages
45
+ end
46
+
47
+ def previous_page
48
+ current_page - 1
49
+ end
50
+
51
+ def next_page
52
+ current_page + 1
53
+ end
54
+
55
+ def turbo_frame_id
56
+ "user_#{user_id}_activities"
57
+ end
58
+
59
+ def pagination_path(page:)
60
+ user_activities_activities_path(user_id: user_id, page: page, unmapped_only: unmapped_only)
61
+ end
62
+
63
+ def filter_path(unmapped:)
64
+ user_activities_activities_path(user_id: user_id, page: 1, unmapped_only: unmapped)
65
+ end
66
+
67
+ private
68
+
69
+ def tudla_hubstaff_user
70
+ @tudla_hubstaff_user ||= TudlaHubstaff::User.find_by(tudla_user_id: user_id)
71
+ end
72
+
73
+ def fetch_activities
74
+ activities_scope
75
+ .order(updated_at: :desc)
76
+ .offset((current_page - 1) * per_page)
77
+ .limit(per_page)
78
+ end
79
+
80
+ def activities_scope
81
+ return TudlaHubstaff::Activity.none if tudla_hubstaff_user.blank?
82
+ @activities_scope ||= begin
83
+ scope = TudlaHubstaff::Activity.where(user_id: tudla_hubstaff_user.user_id)
84
+ if unmapped_only
85
+ unmapped_task_ids = TudlaHubstaff::Task.where(tudla_task_id: nil).pluck(:task_id)
86
+ scope = scope.where(task_id: unmapped_task_ids)
87
+ end
88
+ scope
89
+ end
90
+ end
91
+ end
92
+ end
@@ -0,0 +1,22 @@
1
+ <tr id="<%= dom_id %>" class="tudla_hubstaff-user-row">
2
+ <td class="px-6 py-4 whitespace-nowrap">
3
+ <div class="text-sm font-medium text-gray-900"><%= user.name %></div>
4
+ <div class="text-sm text-gray-500"><%= user.first_name %> <%= user.last_name %></div>
5
+ </td>
6
+ <td class="px-6 py-4 whitespace-nowrap">
7
+ <div class="text-sm text-gray-900"><%= user.email %></div>
8
+ </td>
9
+ <td class="px-6 py-4 whitespace-nowrap">
10
+ <%= render TudlaHubstaff::UI::StatusBadgeComponent.new(status: user.status) %>
11
+ </td>
12
+ <td class="px-6 py-4 whitespace-nowrap text-sm font-medium">
13
+ <button type="button"
14
+ class="text-indigo-600 hover:text-indigo-900"
15
+ data-action="click->user-mapping-modal#open"
16
+ data-user-id="<%= user.id %>"
17
+ data-user-name="<%= user.name %>"
18
+ data-map-url="<%= map_tudla_user_user_path(user) %>">
19
+ Map to Tudla User
20
+ </button>
21
+ </td>
22
+ </tr>
@@ -0,0 +1,13 @@
1
+ module TudlaHubstaff
2
+ class UserRowComponent < TudlaHubstaff::BaseComponent
3
+ def initialize(user:)
4
+ @user = user
5
+ end
6
+
7
+ attr_reader :user
8
+
9
+ def dom_id
10
+ "tudla_hubstaff_user_#{user.id}"
11
+ end
12
+ end
13
+ end