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
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: d4618d21ce8eea610d7b0c2b25ca7f4188b4044ddac344ae30a5518e154db21c
4
+ data.tar.gz: 44c08c47579cd47c0ac320f02c95b3bb58e36b2c5a0d6ebe051941bf3f962c0c
5
+ SHA512:
6
+ metadata.gz: 91f7745c87bfd703b0384ab62f898c7d5d3f313ec4e65848241bcb90947f9a43702988e5338c954052a22cd709e25fe5d253a17722cecad18357171816b3ddad
7
+ data.tar.gz: 750b594080e755fc669bf6f9469b0a9ee689eedc1ca7d1162b85a88f388b46cad2988fdc27b8e9aeac18c1097cad86e9acb0a4c5119db84104eb38b823b62e71
data/MIT-LICENSE ADDED
@@ -0,0 +1,20 @@
1
+ Copyright Tibo Mogul
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining
4
+ a copy of this software and associated documentation files (the
5
+ "Software"), to deal in the Software without restriction, including
6
+ without limitation the rights to use, copy, modify, merge, publish,
7
+ distribute, sublicense, and/or sell copies of the Software, and to
8
+ permit persons to whom the Software is furnished to do so, subject to
9
+ the following conditions:
10
+
11
+ The above copyright notice and this permission notice shall be
12
+ included in all copies or substantial portions of the Software.
13
+
14
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
15
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
16
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
17
+ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
18
+ LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
19
+ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
20
+ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,270 @@
1
+ # TudlaHubstaff
2
+
3
+ `TudlaHubstaff` is a Rails 8.1 mountable engine that provides a seamless integration with the **Hubstaff API v2**. It is built with the "Solid trifecta" (Solid Queue, Solid Cache, and Solid Cable) and integrates into the `TudlaContracts` ecosystem.
4
+
5
+ ## Features
6
+
7
+ - **Hubstaff API v2 Integration**: Fetch daily activities, members, and tasks.
8
+ - **Automated OAuth2**: Handles token refreshing and discovery automatically via `OpenIDConnect`.
9
+ - **Solid Stack Integration**: SQL-backed background jobs, caching, and pub/sub.
10
+ - **TudlaContracts Compatible**: Registers as a `time_sheet` provider.
11
+ - **ViewComponent Architecture**: Modular, testable UI components with slot-based composition.
12
+ - **Unmapped Entity Management**: Built-in views for mapping Users, Tasks, and Projects to Tudla entities.
13
+ - **Customizable Views**: Generator-based ejection pattern for host app customization.
14
+
15
+ ## Installation
16
+
17
+ Add the gem to your application's Gemfile:
18
+
19
+ ```ruby
20
+ gem "tudla_contracts"
21
+ gem "tudla_hubstaff"
22
+ ```
23
+
24
+ And then execute:
25
+ ```bash
26
+ $ bundle install
27
+ ```
28
+
29
+ ### Mounting the Engine
30
+
31
+ Mount the engine in your application's `config/routes.rb`:
32
+
33
+ ```ruby
34
+ Rails.application.routes.draw do
35
+ mount TudlaHubstaff::Engine => "/tudla_hubstaff"
36
+ end
37
+ ```
38
+
39
+ ### Installing Migrations
40
+
41
+ Copy the engine's migrations to your application:
42
+
43
+ ```bash
44
+ $ bin/rails tudla_hubstaff:install:migrations
45
+ ```
46
+
47
+ Then run the migrations:
48
+
49
+ ```bash
50
+ $ bin/rails db:migrate
51
+ ```
52
+
53
+ ### Generating the engine's Tailwind import file
54
+
55
+ We are using`tailwindcss-rails` experiment support https://github.com/rails/tailwindcss-rails?tab=readme-ov-file#rails-engines-support-experimental
56
+
57
+ Run the following command to generate the engine's Tailwind import file `app/assets/builds/tailwind/tudla_hubstaff.css`:
58
+ ```bash
59
+ $ bin/rails tailwindcss:engines
60
+ ```
61
+
62
+ Edit your `app/assets/tailwind/application.css` to import the engine's Tailwind import file:
63
+ ```css
64
+ @import "../builds/tailwind/tudla_hubstaff";
65
+ ```
66
+
67
+ ### Importing the engine's JavaScript
68
+
69
+ Edit your `app/javascript/application.js` to import the engine's JavaScript:
70
+ ```javascript
71
+ import "tudla_hubstaff/controllers/index"
72
+ ```
73
+
74
+ ### Database Setup
75
+
76
+ This engine uses the Solid trifecta. Ensure your host application is configured with the necessary databases (primary, queue, cache, cable) as described in the [Solid Rails documentation](https://github.com/rails/solid_queue).
77
+
78
+ ## Configuration
79
+
80
+ The engine registers itself automatically with `TudlaContracts`. You typically interact with it via the `TudlaHubstaff::Provider`.
81
+
82
+ To initialize a connection, you need a Hubstaff Personal Access Token (used as the initial refresh token):
83
+
84
+ ```ruby
85
+ config = TudlaHubstaff::Config.new
86
+ config.tudla_organization = Organization.first
87
+ config.personal_access_token = ENV["HUBSTAFF_PAT"]
88
+ config.organization_id = ENV["HUBSTAFF_VALUEPRO_ID"].to_i
89
+ config.save
90
+
91
+ # provider = TudlaHubstaff::Provider.new(config)
92
+ ```
93
+
94
+ ### Host Interface Configuration
95
+
96
+ The engine requires a host interface class to provide available Tudla entities for mapping. Configure this in an initializer:
97
+
98
+ ```ruby
99
+ # config/initializers/tudla_hubstaff.rb
100
+ TudlaHubstaff.host_interface_class = "::HubstaffHostInterface"
101
+ ```
102
+
103
+ Your class must inherit from `TudlaContracts::Integrations::HostInterface` and implement:
104
+
105
+ ```ruby
106
+ class YourApp::HubstaffHostInterface < TudlaContracts::Integrations::HostInterface
107
+ def available_users_for_user(current_user)
108
+ # Return array of objects with :id, :name, :email
109
+ end
110
+
111
+ def available_tasks_for_user(current_user)
112
+ # Return array of objects with :id, :name, :project_name
113
+ end
114
+
115
+ def available_projects_for_user(current_user)
116
+ # Return array of objects with :id, :name
117
+ end
118
+ end
119
+ ```
120
+
121
+ ### Layout Configuration
122
+
123
+ By default, the engine uses its own layout. To use your application's layout:
124
+
125
+ ```ruby
126
+ # config/initializers/tudla_hubstaff.rb
127
+ TudlaHubstaff::Engine.config.tudla_hubstaff.layout = "application"
128
+ ```
129
+
130
+ ## Usage
131
+
132
+ ### Fetching Activities
133
+
134
+ ```ruby
135
+ activities = provider.daily_activities
136
+ ```
137
+
138
+ ### Advanced API Access
139
+
140
+ For more granular control, use the `ApiClient`:
141
+
142
+ ```ruby
143
+ connection = TudlaHubstaff::ApiConnection.new("your_pat")
144
+ client = TudlaHubstaff::ApiClient.new("your_org_id", connection)
145
+
146
+ # Fetch members
147
+ members = client.members
148
+
149
+ # Fetch tasks
150
+ tasks = client.tasks(status: "active")
151
+ ```
152
+
153
+ ## Scheduling Background Jobs
154
+
155
+ The engine includes a `FetchUpdatedActivitiesJob` that syncs activity updates from Hubstaff. **The host application must schedule this job to run periodically.**
156
+
157
+ ### Using Solid Queue (Recommended)
158
+
159
+ Add a recurring job in your host application's `config/recurring.yml`:
160
+
161
+ ```yaml
162
+ production:
163
+ fetch_hubstaff_activities:
164
+ class: TudlaHubstaff::FetchUpdatedActivitiesJob
165
+ schedule: every 15 minutes
166
+ ```
167
+
168
+ ### Using Cron or Other Schedulers
169
+
170
+ If you prefer cron or another scheduler, enqueue the job periodically:
171
+
172
+ ```ruby
173
+ TudlaHubstaff::FetchUpdatedActivitiesJob.perform_later
174
+ ```
175
+
176
+ **Note:** Before the job can process an organization, you must create an `OrganizationUpdate` record with a `last_updated_at` timestamp. Records with `nil` timestamps are skipped.
177
+
178
+ ## Architecture
179
+
180
+ - **`TudlaHubstaff::Provider`**: The main entry point for the `TudlaContracts` interface.
181
+ - **`TudlaHubstaff::ApiClient`**: Handles Hubstaff v2 API endpoints.
182
+ - **`TudlaHubstaff::ApiConnection`**: Manages Faraday connections and OAuth2 token lifecycle.
183
+ - **Caching**: Access tokens and refresh tokens are stored in `Rails.cache` (backed by `SolidCache`).
184
+
185
+ ### ViewComponent Architecture
186
+
187
+ The engine uses [ViewComponent](https://viewcomponent.org/) for modular, testable UI components:
188
+
189
+ - **`TudlaHubstaff::BaseComponent`**: Base class with engine route helpers
190
+ - **`TudlaHubstaff::UI::ModalComponent`**: Reusable modal with slots (header, body, footer)
191
+ - **`TudlaHubstaff::UI::TableComponent`**: Table with header and rows slots
192
+ - **`TudlaHubstaff::UI::PaginationComponent`**: Pagination with configurable path helper
193
+ - **`TudlaHubstaff::UI::StatusBadgeComponent`**: Status badges with color coding
194
+ - **`TudlaHubstaff::UI::MappingModalComponent`**: Specialized modal for entity mapping
195
+ - **`TudlaHubstaff::UI::MapButtonComponent`**: Button to trigger mapping modal
196
+
197
+ ## Customizing Views
198
+
199
+ The engine provides generators to copy views and components to your application for customization.
200
+
201
+ ### Copy All Views
202
+
203
+ ```bash
204
+ rails g tudla_hubstaff:views
205
+ ```
206
+
207
+ ### Copy Scoped Views
208
+
209
+ ```bash
210
+ # Copy only user views
211
+ rails g tudla_hubstaff:views users
212
+
213
+ # Copy only task views
214
+ rails g tudla_hubstaff:views tasks
215
+
216
+ # Copy only project views
217
+ rails g tudla_hubstaff:views projects
218
+ ```
219
+
220
+ ### Copy Components
221
+
222
+ ```bash
223
+ # Copy all components (Ruby classes and templates)
224
+ rails g tudla_hubstaff:components
225
+
226
+ # Copy only component templates (recommended for customization)
227
+ rails g tudla_hubstaff:components --templates_only
228
+
229
+ # Copy specific component scope
230
+ rails g tudla_hubstaff:components ui
231
+ ```
232
+
233
+ **Note:** When copying only templates, the engine retains control over the component logic while you customize the presentation.
234
+
235
+ ## Testing
236
+
237
+ The engine uses RSpec for testing. A dummy Rails application is located in `spec/dummy`.
238
+
239
+ ### Database Setup for Tests
240
+
241
+ **Important:** Rails 8 with the Solid trifecta uses multiple databases (primary, queue, cache, cable). When resetting the test database, you must load each schema separately.
242
+
243
+ ```bash
244
+ # One-time setup or when you need a complete refresh
245
+ RAILS_ENV=test bundle exec rails db:reset
246
+
247
+ # Load Solid gem schemas (required after db:reset)
248
+ RAILS_ENV=test bundle exec rails app:db:schema:load:cache
249
+ RAILS_ENV=test bundle exec rails app:db:schema:load:queue
250
+ RAILS_ENV=test bundle exec rails app:db:schema:load:cable
251
+
252
+ # Run the specs
253
+ bundle exec rspec
254
+ ```
255
+
256
+ **Quick workflow for ongoing development:**
257
+
258
+ ```bash
259
+ # When adding new migrations
260
+ RAILS_ENV=test bundle exec rails db:migrate
261
+
262
+ # Run tests
263
+ bundle exec rspec
264
+ ```
265
+
266
+ **Note:** `db:reset` only loads the primary database schema. The Solid gem databases (cache, queue, cable) have separate schema files that must be loaded explicitly.
267
+
268
+ ## License
269
+
270
+ The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
data/Rakefile ADDED
@@ -0,0 +1,6 @@
1
+ require "bundler/setup"
2
+
3
+ APP_RAKEFILE = File.expand_path("spec/dummy/Rakefile", __dir__)
4
+ load "rails/tasks/engine.rake"
5
+
6
+ require "bundler/gem_tasks"
@@ -0,0 +1,15 @@
1
+ /*
2
+ * This is a manifest file that'll be compiled into application.css, which will include all the files
3
+ * listed below.
4
+ *
5
+ * Any CSS and SCSS file within this directory, lib/assets/stylesheets, vendor/assets/stylesheets,
6
+ * or any plugin's vendor/assets/stylesheets directory can be referenced here using a relative path.
7
+ *
8
+ * You're free to add application-wide styles to this file and they'll appear at the bottom of the
9
+ * compiled file so the styles you add here take precedence over styles defined in any other CSS/SCSS
10
+ * files in this directory. Styles in this file should be added after the last require_* statement.
11
+ * It is generally better to create a new file per style scope.
12
+ *
13
+ *= require_tree .
14
+ *= require_self
15
+ */
@@ -0,0 +1,32 @@
1
+ /* Engine CSS */
2
+ @import "tailwindcss";
3
+
4
+ /*
5
+ @source Directive:
6
+ Explicitly tells the CLI where to look for class usage within the engine.
7
+ Relative path resolution is key here.
8
+ Assuming the file is in app/assets/tailwind/my_engine/engine.css:
9
+ .. -> my_engine
10
+ .. -> tailwind
11
+ .. -> assets
12
+ .. -> app
13
+ ../views -> Target Directory
14
+ */
15
+ @source "../../../views";
16
+ @source "../../../components";
17
+
18
+ /*
19
+ @plugin Directive:
20
+ If the engine relies on specific plugins (e.g., typography, forms),
21
+ they must be declared here if not inheriting from the host.
22
+ */
23
+ /*
24
+ @plugin "@tailwindcss/typography";
25
+ */
26
+
27
+ /* Custom Theme Variables specific to the engine */
28
+ /*
29
+ @theme {
30
+ --color-engine-brand: oklch(0.65 0.2 30);
31
+ }
32
+ */
@@ -0,0 +1,46 @@
1
+ <tr id="<%= dom_id %>" class="tudla_hubstaff-activity-row">
2
+ <td class="px-6 py-4 whitespace-nowrap">
3
+ <div class="text-sm text-gray-900"><%= format_date(activity.date) %></div>
4
+ </td>
5
+ <td class="px-6 py-4 whitespace-nowrap">
6
+ <div class="text-sm font-medium text-gray-900"><%= task_name %></div>
7
+ <% if mapped? %>
8
+ <div class="text-xs text-green-600">Mapped</div>
9
+ <% elsif hubstaff_task.present? %>
10
+ <div class="text-xs text-gray-500">Hubstaff Task</div>
11
+ <% end %>
12
+ </td>
13
+ <td class="px-6 py-4 whitespace-nowrap">
14
+ <div class="text-sm text-gray-900"><%= format_duration(activity.tracked) %></div>
15
+ <% if activity.billable.to_i > 0 %>
16
+ <div class="text-xs text-green-600">Billable: <%= format_duration(activity.billable) %></div>
17
+ <% end %>
18
+ </td>
19
+ <td class="px-6 py-4 whitespace-nowrap">
20
+ <div class="flex items-center space-x-2">
21
+ <div class="text-sm text-gray-500">
22
+ <span title="Overall activity"><%= activity.overall %>%</span>
23
+ </div>
24
+ <div class="w-16 bg-gray-200 rounded-full h-2">
25
+ <div class="bg-indigo-600 h-2 rounded-full" style="width: <%= [activity.overall.to_i, 100].min %>%"></div>
26
+ </div>
27
+ </div>
28
+ </td>
29
+ <td class="px-6 py-4 whitespace-nowrap text-sm font-medium">
30
+ <% if show_map_button? %>
31
+ <button type="button"
32
+ class="text-indigo-600 hover:text-indigo-900"
33
+ data-action="click->activity-task-mapping-modal#open"
34
+ data-activity-id="<%= activity.id %>"
35
+ data-hubstaff-task-id="<%= hubstaff_task.id %>"
36
+ data-task-name="<%= hubstaff_task.summary %>"
37
+ data-map-url="<%= map_url %>">
38
+ Map
39
+ </button>
40
+ <% elsif mapped? %>
41
+ <span class="text-green-600">✓</span>
42
+ <% else %>
43
+ <span class="text-gray-400">-</span>
44
+ <% end %>
45
+ </td>
46
+ </tr>
@@ -0,0 +1,67 @@
1
+ module TudlaHubstaff
2
+ class ActivityRowComponent < TudlaHubstaff::BaseComponent
3
+ def initialize(activity:)
4
+ @activity = activity
5
+ end
6
+
7
+ attr_reader :activity
8
+
9
+ def dom_id
10
+ "activity_#{activity.id}"
11
+ end
12
+
13
+ def hubstaff_task
14
+ @hubstaff_task ||= TudlaHubstaff::Task.find_by(task_id: activity.task_id) if activity.task_id.present?
15
+ end
16
+
17
+ def tudla_task_id
18
+ hubstaff_task&.tudla_task_id
19
+ end
20
+
21
+ def mapped?
22
+ tudla_task_id.present?
23
+ end
24
+
25
+ def task_name
26
+ if mapped?
27
+ tudla_task&.name || "Task ##{tudla_task_id}"
28
+ elsif hubstaff_task.present?
29
+ hubstaff_task.summary
30
+ else
31
+ "No task"
32
+ end
33
+ end
34
+
35
+ def tudla_task
36
+ @tudla_task ||= TudlaHubstaff.host_interface.find_task(tudla_task_id) if tudla_task_id.present?
37
+ end
38
+
39
+ def show_map_button?
40
+ hubstaff_task.present? && !mapped?
41
+ end
42
+
43
+ def map_url
44
+ map_activity_task_activity_path(activity) if hubstaff_task.present?
45
+ end
46
+
47
+ def format_duration(seconds)
48
+ return "0m" if seconds.nil? || seconds.zero?
49
+
50
+ hours = seconds / 3600
51
+ minutes = (seconds % 3600) / 60
52
+
53
+ if hours > 0
54
+ "#{hours}h #{minutes}m"
55
+ else
56
+ "#{minutes}m"
57
+ end
58
+ end
59
+
60
+ def format_date(date_string)
61
+ return "" if date_string.blank?
62
+ Date.parse(date_string).strftime("%b %d, %Y")
63
+ rescue ArgumentError
64
+ date_string
65
+ end
66
+ end
67
+ end
@@ -0,0 +1,10 @@
1
+ module TudlaHubstaff
2
+ class BaseComponent < ViewComponent::Base
3
+ include TudlaHubstaff::Engine.routes.url_helpers
4
+ include Turbo::FramesHelper
5
+
6
+ def default_url_options
7
+ { only_path: true }
8
+ end
9
+ end
10
+ end
@@ -0,0 +1,22 @@
1
+ <tr id="<%= dom_id %>" class="tudla_hubstaff-project-row">
2
+ <td class="px-6 py-4 whitespace-nowrap">
3
+ <div class="text-sm font-medium text-gray-900"><%= project.name %></div>
4
+ <div class="text-sm text-gray-500"><%= truncated_description %></div>
5
+ </td>
6
+ <td class="px-6 py-4 whitespace-nowrap">
7
+ <div class="text-sm text-gray-900"><%= formatted_project_type %></div>
8
+ </td>
9
+ <td class="px-6 py-4 whitespace-nowrap">
10
+ <%= render TudlaHubstaff::UI::StatusBadgeComponent.new(status: project.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->project-mapping-modal#open"
16
+ data-project-id="<%= project.id %>"
17
+ data-project-name="<%= project.name %>"
18
+ data-map-url="<%= map_tudla_project_project_path(project) %>">
19
+ Map to Tudla Project
20
+ </button>
21
+ </td>
22
+ </tr>
@@ -0,0 +1,21 @@
1
+ module TudlaHubstaff
2
+ class ProjectRowComponent < TudlaHubstaff::BaseComponent
3
+ def initialize(project:)
4
+ @project = project
5
+ end
6
+
7
+ attr_reader :project
8
+
9
+ def dom_id
10
+ "tudla_hubstaff_project_#{project.id}"
11
+ end
12
+
13
+ def truncated_description
14
+ project.description.to_s.truncate(50)
15
+ end
16
+
17
+ def formatted_project_type
18
+ project.project_type.titleize
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,82 @@
1
+ <div class="tudla_hubstaff-task-activities">
2
+ <% if activities.any? %>
3
+ <div class="bg-white shadow rounded-lg overflow-hidden">
4
+ <table class="min-w-full divide-y divide-gray-200">
5
+ <thead class="bg-gray-50">
6
+ <tr>
7
+ <th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Date</th>
8
+ <th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">User</th>
9
+ <th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Tracked</th>
10
+ <th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Activity</th>
11
+ <th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Last Updated</th>
12
+ </tr>
13
+ </thead>
14
+ <tbody class="bg-white divide-y divide-gray-200">
15
+ <% activities.each do |activity| %>
16
+ <tr class="tudla_hubstaff-activity-row">
17
+ <td class="px-6 py-4 whitespace-nowrap">
18
+ <div class="text-sm text-gray-900"><%= format_date(activity.date) %></div>
19
+ </td>
20
+ <td class="px-6 py-4 whitespace-nowrap">
21
+ <div class="text-sm text-gray-900"><%= activity.user_id %></div>
22
+ </td>
23
+ <td class="px-6 py-4 whitespace-nowrap">
24
+ <div class="text-sm text-gray-900"><%= format_duration(activity.tracked) %></div>
25
+ <% if activity.billable.to_i > 0 %>
26
+ <div class="text-xs text-green-600">Billable: <%= format_duration(activity.billable) %></div>
27
+ <% end %>
28
+ </td>
29
+ <td class="px-6 py-4 whitespace-nowrap">
30
+ <div class="flex items-center space-x-2">
31
+ <div class="text-sm text-gray-500">
32
+ <span title="Overall activity"><%= activity.overall %>%</span>
33
+ </div>
34
+ <div class="w-16 bg-gray-200 rounded-full h-2">
35
+ <div class="bg-indigo-600 h-2 rounded-full" style="width: <%= [activity.overall.to_i, 100].min %>%"></div>
36
+ </div>
37
+ </div>
38
+ </td>
39
+ <td class="px-6 py-4 whitespace-nowrap">
40
+ <div class="text-sm text-gray-500">
41
+ <% if activity.last_updated_at.present? %>
42
+ <%= activity.last_updated_at.strftime("%b %d, %Y %H:%M") %>
43
+ <% else %>
44
+ -
45
+ <% end %>
46
+ </div>
47
+ </td>
48
+ </tr>
49
+ <% end %>
50
+ </tbody>
51
+ </table>
52
+ </div>
53
+
54
+ <% if total_pages > 1 %>
55
+ <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">
56
+ <div class="text-sm text-gray-700">
57
+ 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
58
+ </div>
59
+ <div class="flex space-x-2">
60
+ <% if show_previous? %>
61
+ <span class="text-indigo-600 hover:text-indigo-900 cursor-pointer" data-page="<%= previous_page %>">
62
+ Previous
63
+ </span>
64
+ <% else %>
65
+ <span class="text-gray-400 cursor-not-allowed">Previous</span>
66
+ <% end %>
67
+ <% if show_next? %>
68
+ <span class="text-indigo-600 hover:text-indigo-900 cursor-pointer" data-page="<%= next_page %>">
69
+ Next
70
+ </span>
71
+ <% else %>
72
+ <span class="text-gray-400 cursor-not-allowed">Next</span>
73
+ <% end %>
74
+ </div>
75
+ </div>
76
+ <% end %>
77
+ <% else %>
78
+ <div class="text-center py-12 bg-white rounded-lg border border-gray-200">
79
+ <p class="text-gray-500">No activities found for this task.</p>
80
+ </div>
81
+ <% end %>
82
+ </div>