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,180 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ export default class extends Controller {
4
+ static targets = ["modal", "projectName", "projectsList", "loading", "pagination", "searchInput"]
5
+
6
+ connect() {
7
+ this.currentPage = 1
8
+ this.currentProjectId = null
9
+ this.searchTimeout = null
10
+ }
11
+
12
+ open(event) {
13
+ this.currentProjectId = event.currentTarget.dataset.projectId
14
+ this._mapUrl = event.currentTarget.dataset.mapUrl
15
+ const projectName = event.currentTarget.dataset.projectName
16
+
17
+ this.projectNameTarget.textContent = projectName
18
+ this.searchInputTarget.value = ""
19
+ this.currentPage = 1
20
+
21
+ this.modalTarget.classList.remove("hidden")
22
+ this.loadProjects()
23
+ }
24
+
25
+ close() {
26
+ this.modalTarget.classList.add("hidden")
27
+ this.projectsListTarget.innerHTML = ""
28
+ this.paginationTarget.innerHTML = ""
29
+ }
30
+
31
+ filterProjects() {
32
+ clearTimeout(this.searchTimeout)
33
+ this.searchTimeout = setTimeout(() => {
34
+ this.currentPage = 1
35
+ this.loadProjects()
36
+ }, 300)
37
+ }
38
+
39
+ async loadProjects() {
40
+ this.loadingTarget.classList.remove("hidden")
41
+ this.projectsListTarget.innerHTML = ""
42
+
43
+ const nameFilter = this.searchInputTarget.value
44
+ const url = new URL(this.availableProjectsUrl, window.location.origin)
45
+ url.searchParams.set("page", this.currentPage)
46
+ if (nameFilter) {
47
+ url.searchParams.set("name", nameFilter)
48
+ }
49
+
50
+ try {
51
+ const response = await fetch(url, {
52
+ headers: {
53
+ "Accept": "application/json",
54
+ "X-CSRF-Token": this.csrfToken
55
+ }
56
+ })
57
+
58
+ if (!response.ok) throw new Error("Failed to load projects")
59
+
60
+ const data = await response.json()
61
+ this.renderProjects(data.projects)
62
+ this.renderPagination(data.current_page, data.total_pages)
63
+ } catch (error) {
64
+ this.projectsListTarget.innerHTML = `<p class="text-red-500 text-center py-4">Failed to load projects</p>`
65
+ } finally {
66
+ this.loadingTarget.classList.add("hidden")
67
+ }
68
+ }
69
+
70
+ renderProjects(projects) {
71
+ if (projects.length === 0) {
72
+ this.projectsListTarget.innerHTML = `<p class="text-gray-500 text-center py-4">No projects found</p>`
73
+ return
74
+ }
75
+
76
+ this.projectsListTarget.innerHTML = projects.map(project => `
77
+ <button type="button"
78
+ class="w-full text-left px-4 py-3 rounded-md border border-gray-200 hover:bg-indigo-50 hover:border-indigo-300 transition-colors"
79
+ data-action="click->project-mapping-modal#selectProject"
80
+ data-tudla-project-id="${project.id}">
81
+ <div class="font-medium text-gray-900">${this.escapeHtml(project.name)}</div>
82
+ </button>
83
+ `).join("")
84
+ }
85
+
86
+ renderPagination(currentPage, totalPages) {
87
+ if (totalPages <= 1) {
88
+ this.paginationTarget.innerHTML = ""
89
+ return
90
+ }
91
+
92
+ let html = `<span class="text-sm text-gray-500">Page ${currentPage} of ${totalPages}</span><div class="flex gap-2">`
93
+
94
+ if (currentPage > 1) {
95
+ html += `<button type="button"
96
+ class="px-3 py-1 text-sm rounded-md bg-white border border-gray-300 hover:bg-gray-50"
97
+ data-action="click->project-mapping-modal#prevPage">
98
+ Previous
99
+ </button>`
100
+ }
101
+
102
+ if (currentPage < totalPages) {
103
+ html += `<button type="button"
104
+ class="px-3 py-1 text-sm rounded-md bg-white border border-gray-300 hover:bg-gray-50"
105
+ data-action="click->project-mapping-modal#nextPage">
106
+ Next
107
+ </button>`
108
+ }
109
+
110
+ html += "</div>"
111
+ this.paginationTarget.innerHTML = html
112
+ }
113
+
114
+ prevPage() {
115
+ if (this.currentPage > 1) {
116
+ this.currentPage--
117
+ this.loadProjects()
118
+ }
119
+ }
120
+
121
+ nextPage() {
122
+ this.currentPage++
123
+ this.loadProjects()
124
+ }
125
+
126
+ selectProject(event) {
127
+ event.preventDefault()
128
+ event.stopPropagation()
129
+
130
+ const tudlaProjectId = event.currentTarget.dataset.tudlaProjectId
131
+ const mapUrl = this._mapUrl
132
+
133
+ if (!mapUrl) {
134
+ alert("Error: Map URL not set. Please try again.")
135
+ return
136
+ }
137
+
138
+ const xhr = new XMLHttpRequest()
139
+ const fullUrl = window.location.origin + mapUrl
140
+ const csrfToken = this.csrfToken
141
+
142
+ xhr.open("PATCH", fullUrl, true)
143
+ xhr.setRequestHeader("Content-Type", "application/x-www-form-urlencoded")
144
+ xhr.setRequestHeader("Accept", "text/html, application/xhtml+xml")
145
+ xhr.setRequestHeader("X-Requested-With", "XMLHttpRequest")
146
+ if (csrfToken) {
147
+ xhr.setRequestHeader("X-CSRF-Token", csrfToken)
148
+ }
149
+
150
+ xhr.onload = () => {
151
+ if (xhr.status >= 200 && xhr.status < 300) {
152
+ alert("Success! Project mapped.")
153
+ window.location.href = window.location.pathname
154
+ } else {
155
+ alert("Failed to map project. Status: " + xhr.status)
156
+ }
157
+ }
158
+
159
+ xhr.onerror = () => {
160
+ alert("Network error mapping project.")
161
+ }
162
+
163
+ xhr.send(`tudla_project_id=${encodeURIComponent(tudlaProjectId)}`)
164
+ this.close()
165
+ }
166
+
167
+ escapeHtml(text) {
168
+ const div = document.createElement("div")
169
+ div.textContent = text
170
+ return div.innerHTML
171
+ }
172
+
173
+ get availableProjectsUrl() {
174
+ return this.element.dataset.availableProjectsUrl || "/tudla_hubstaff/projects/available_tudla_projects"
175
+ }
176
+
177
+ get csrfToken() {
178
+ return document.querySelector('meta[name="csrf-token"]')?.content || ""
179
+ }
180
+ }
@@ -0,0 +1,181 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ export default class extends Controller {
4
+ static targets = ["modal", "taskName", "tasksList", "loading", "pagination", "searchInput"]
5
+
6
+ connect() {
7
+ this.currentPage = 1
8
+ this.currentTaskId = null
9
+ this.searchTimeout = null
10
+ }
11
+
12
+ open(event) {
13
+ this.currentTaskId = event.currentTarget.dataset.taskId
14
+ this._mapUrl = event.currentTarget.dataset.mapUrl
15
+ const taskName = event.currentTarget.dataset.taskName
16
+
17
+ this.taskNameTarget.textContent = taskName
18
+ this.searchInputTarget.value = ""
19
+ this.currentPage = 1
20
+
21
+ this.modalTarget.classList.remove("hidden")
22
+ this.loadTasks()
23
+ }
24
+
25
+ close() {
26
+ this.modalTarget.classList.add("hidden")
27
+ this.tasksListTarget.innerHTML = ""
28
+ this.paginationTarget.innerHTML = ""
29
+ }
30
+
31
+ filterTasks() {
32
+ clearTimeout(this.searchTimeout)
33
+ this.searchTimeout = setTimeout(() => {
34
+ this.currentPage = 1
35
+ this.loadTasks()
36
+ }, 300)
37
+ }
38
+
39
+ async loadTasks() {
40
+ this.loadingTarget.classList.remove("hidden")
41
+ this.tasksListTarget.innerHTML = ""
42
+
43
+ const nameFilter = this.searchInputTarget.value
44
+ const url = new URL(this.availableTasksUrl, window.location.origin)
45
+ url.searchParams.set("page", this.currentPage)
46
+ if (nameFilter) {
47
+ url.searchParams.set("name", nameFilter)
48
+ }
49
+
50
+ try {
51
+ const response = await fetch(url, {
52
+ headers: {
53
+ "Accept": "application/json",
54
+ "X-CSRF-Token": this.csrfToken
55
+ }
56
+ })
57
+
58
+ if (!response.ok) throw new Error("Failed to load tasks")
59
+
60
+ const data = await response.json()
61
+ this.renderTasks(data.tasks)
62
+ this.renderPagination(data.current_page, data.total_pages)
63
+ } catch (error) {
64
+ this.tasksListTarget.innerHTML = `<p class="text-red-500 text-center py-4">Failed to load tasks</p>`
65
+ } finally {
66
+ this.loadingTarget.classList.add("hidden")
67
+ }
68
+ }
69
+
70
+ renderTasks(tasks) {
71
+ if (tasks.length === 0) {
72
+ this.tasksListTarget.innerHTML = `<p class="text-gray-500 text-center py-4">No tasks found</p>`
73
+ return
74
+ }
75
+
76
+ this.tasksListTarget.innerHTML = tasks.map(task => `
77
+ <button type="button"
78
+ class="w-full text-left px-4 py-3 rounded-md border border-gray-200 hover:bg-indigo-50 hover:border-indigo-300 transition-colors"
79
+ data-action="click->task-mapping-modal#selectTask"
80
+ data-tudla-task-id="${task.id}">
81
+ <div class="font-medium text-gray-900">${this.escapeHtml(task.name)}</div>
82
+ <div class="text-sm text-gray-500">${this.escapeHtml(task.project_name || "")}</div>
83
+ </button>
84
+ `).join("")
85
+ }
86
+
87
+ renderPagination(currentPage, totalPages) {
88
+ if (totalPages <= 1) {
89
+ this.paginationTarget.innerHTML = ""
90
+ return
91
+ }
92
+
93
+ let html = `<span class="text-sm text-gray-500">Page ${currentPage} of ${totalPages}</span><div class="flex gap-2">`
94
+
95
+ if (currentPage > 1) {
96
+ html += `<button type="button"
97
+ class="px-3 py-1 text-sm rounded-md bg-white border border-gray-300 hover:bg-gray-50"
98
+ data-action="click->task-mapping-modal#prevPage">
99
+ Previous
100
+ </button>`
101
+ }
102
+
103
+ if (currentPage < totalPages) {
104
+ html += `<button type="button"
105
+ class="px-3 py-1 text-sm rounded-md bg-white border border-gray-300 hover:bg-gray-50"
106
+ data-action="click->task-mapping-modal#nextPage">
107
+ Next
108
+ </button>`
109
+ }
110
+
111
+ html += "</div>"
112
+ this.paginationTarget.innerHTML = html
113
+ }
114
+
115
+ prevPage() {
116
+ if (this.currentPage > 1) {
117
+ this.currentPage--
118
+ this.loadTasks()
119
+ }
120
+ }
121
+
122
+ nextPage() {
123
+ this.currentPage++
124
+ this.loadTasks()
125
+ }
126
+
127
+ selectTask(event) {
128
+ event.preventDefault()
129
+ event.stopPropagation()
130
+
131
+ const tudlaTaskId = event.currentTarget.dataset.tudlaTaskId
132
+ const mapUrl = this._mapUrl
133
+
134
+ if (!mapUrl) {
135
+ alert("Error: Map URL not set. Please try again.")
136
+ return
137
+ }
138
+
139
+ const xhr = new XMLHttpRequest()
140
+ const fullUrl = window.location.origin + mapUrl
141
+ const csrfToken = this.csrfToken
142
+
143
+ xhr.open("PATCH", fullUrl, true)
144
+ xhr.setRequestHeader("Content-Type", "application/x-www-form-urlencoded")
145
+ xhr.setRequestHeader("Accept", "text/html, application/xhtml+xml")
146
+ xhr.setRequestHeader("X-Requested-With", "XMLHttpRequest")
147
+ if (csrfToken) {
148
+ xhr.setRequestHeader("X-CSRF-Token", csrfToken)
149
+ }
150
+
151
+ xhr.onload = () => {
152
+ if (xhr.status >= 200 && xhr.status < 300) {
153
+ alert("Success! Task mapped.")
154
+ window.location.href = window.location.pathname
155
+ } else {
156
+ alert("Failed to map task. Status: " + xhr.status)
157
+ }
158
+ }
159
+
160
+ xhr.onerror = () => {
161
+ alert("Network error mapping task.")
162
+ }
163
+
164
+ xhr.send(`tudla_task_id=${encodeURIComponent(tudlaTaskId)}`)
165
+ this.close()
166
+ }
167
+
168
+ escapeHtml(text) {
169
+ const div = document.createElement("div")
170
+ div.textContent = text
171
+ return div.innerHTML
172
+ }
173
+
174
+ get availableTasksUrl() {
175
+ return this.element.dataset.availableTasksUrl || "/tudla_hubstaff/tasks/available_tudla_tasks"
176
+ }
177
+
178
+ get csrfToken() {
179
+ return document.querySelector('meta[name="csrf-token"]')?.content || ""
180
+ }
181
+ }
@@ -0,0 +1,181 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ export default class extends Controller {
4
+ static targets = ["modal", "userName", "usersList", "loading", "pagination", "searchInput"]
5
+
6
+ connect() {
7
+ this.currentPage = 1
8
+ this.currentUserId = null
9
+ this.searchTimeout = null
10
+ }
11
+
12
+ open(event) {
13
+ this.currentUserId = event.currentTarget.dataset.userId
14
+ this._mapUrl = event.currentTarget.dataset.mapUrl
15
+ const userName = event.currentTarget.dataset.userName
16
+
17
+ this.userNameTarget.textContent = userName
18
+ this.searchInputTarget.value = ""
19
+ this.currentPage = 1
20
+
21
+ this.modalTarget.classList.remove("hidden")
22
+ this.loadUsers()
23
+ }
24
+
25
+ close() {
26
+ this.modalTarget.classList.add("hidden")
27
+ this.usersListTarget.innerHTML = ""
28
+ this.paginationTarget.innerHTML = ""
29
+ }
30
+
31
+ filterUsers() {
32
+ clearTimeout(this.searchTimeout)
33
+ this.searchTimeout = setTimeout(() => {
34
+ this.currentPage = 1
35
+ this.loadUsers()
36
+ }, 300)
37
+ }
38
+
39
+ async loadUsers() {
40
+ this.loadingTarget.classList.remove("hidden")
41
+ this.usersListTarget.innerHTML = ""
42
+
43
+ const nameFilter = this.searchInputTarget.value
44
+ const url = new URL(this.availableUsersUrl, window.location.origin)
45
+ url.searchParams.set("page", this.currentPage)
46
+ if (nameFilter) {
47
+ url.searchParams.set("name", nameFilter)
48
+ }
49
+
50
+ try {
51
+ const response = await fetch(url, {
52
+ headers: {
53
+ "Accept": "application/json",
54
+ "X-CSRF-Token": this.csrfToken
55
+ }
56
+ })
57
+
58
+ if (!response.ok) throw new Error("Failed to load users")
59
+
60
+ const data = await response.json()
61
+ this.renderUsers(data.users)
62
+ this.renderPagination(data.current_page, data.total_pages)
63
+ } catch (error) {
64
+ this.usersListTarget.innerHTML = `<p class="text-red-500 text-center py-4">Failed to load users</p>`
65
+ } finally {
66
+ this.loadingTarget.classList.add("hidden")
67
+ }
68
+ }
69
+
70
+ renderUsers(users) {
71
+ if (users.length === 0) {
72
+ this.usersListTarget.innerHTML = `<p class="text-gray-500 text-center py-4">No users found</p>`
73
+ return
74
+ }
75
+
76
+ this.usersListTarget.innerHTML = users.map(user => `
77
+ <button type="button"
78
+ class="w-full text-left px-4 py-3 rounded-md border border-gray-200 hover:bg-indigo-50 hover:border-indigo-300 transition-colors"
79
+ data-action="click->user-mapping-modal#selectUser"
80
+ data-tudla-user-id="${user.id}">
81
+ <div class="font-medium text-gray-900">${this.escapeHtml(user.name)}</div>
82
+ <div class="text-sm text-gray-500">${this.escapeHtml(user.email || "")}</div>
83
+ </button>
84
+ `).join("")
85
+ }
86
+
87
+ renderPagination(currentPage, totalPages) {
88
+ if (totalPages <= 1) {
89
+ this.paginationTarget.innerHTML = ""
90
+ return
91
+ }
92
+
93
+ let html = `<span class="text-sm text-gray-500">Page ${currentPage} of ${totalPages}</span><div class="flex gap-2">`
94
+
95
+ if (currentPage > 1) {
96
+ html += `<button type="button"
97
+ class="px-3 py-1 text-sm rounded-md bg-white border border-gray-300 hover:bg-gray-50"
98
+ data-action="click->user-mapping-modal#prevPage">
99
+ Previous
100
+ </button>`
101
+ }
102
+
103
+ if (currentPage < totalPages) {
104
+ html += `<button type="button"
105
+ class="px-3 py-1 text-sm rounded-md bg-white border border-gray-300 hover:bg-gray-50"
106
+ data-action="click->user-mapping-modal#nextPage">
107
+ Next
108
+ </button>`
109
+ }
110
+
111
+ html += "</div>"
112
+ this.paginationTarget.innerHTML = html
113
+ }
114
+
115
+ prevPage() {
116
+ if (this.currentPage > 1) {
117
+ this.currentPage--
118
+ this.loadUsers()
119
+ }
120
+ }
121
+
122
+ nextPage() {
123
+ this.currentPage++
124
+ this.loadUsers()
125
+ }
126
+
127
+ selectUser(event) {
128
+ event.preventDefault()
129
+ event.stopPropagation()
130
+
131
+ const tudlaUserId = event.currentTarget.dataset.tudlaUserId
132
+ const mapUrl = this._mapUrl
133
+
134
+ if (!mapUrl) {
135
+ alert("Error: Map URL not set. Please try again.")
136
+ return
137
+ }
138
+
139
+ const xhr = new XMLHttpRequest()
140
+ const fullUrl = window.location.origin + mapUrl
141
+ const csrfToken = this.csrfToken
142
+
143
+ xhr.open("PATCH", fullUrl, true)
144
+ xhr.setRequestHeader("Content-Type", "application/x-www-form-urlencoded")
145
+ xhr.setRequestHeader("Accept", "text/html, application/xhtml+xml")
146
+ xhr.setRequestHeader("X-Requested-With", "XMLHttpRequest")
147
+ if (csrfToken) {
148
+ xhr.setRequestHeader("X-CSRF-Token", csrfToken)
149
+ }
150
+
151
+ xhr.onload = () => {
152
+ if (xhr.status >= 200 && xhr.status < 300) {
153
+ alert("Success! User mapped.")
154
+ window.location.href = window.location.pathname
155
+ } else {
156
+ alert("Failed to map user. Status: " + xhr.status)
157
+ }
158
+ }
159
+
160
+ xhr.onerror = () => {
161
+ alert("Network error mapping user.")
162
+ }
163
+
164
+ xhr.send(`tudla_user_id=${encodeURIComponent(tudlaUserId)}`)
165
+ this.close()
166
+ }
167
+
168
+ escapeHtml(text) {
169
+ const div = document.createElement("div")
170
+ div.textContent = text
171
+ return div.innerHTML
172
+ }
173
+
174
+ get availableUsersUrl() {
175
+ return this.element.dataset.availableUsersUrl || "/tudla_hubstaff/users/available_tudla_users"
176
+ }
177
+
178
+ get csrfToken() {
179
+ return document.querySelector('meta[name="csrf-token"]')?.content || ""
180
+ }
181
+ }
@@ -0,0 +1,4 @@
1
+ module TudlaHubstaff
2
+ class ApplicationJob < ActiveJob::Base
3
+ end
4
+ end
@@ -0,0 +1,40 @@
1
+ module TudlaHubstaff
2
+ class FetchUpdatedActivitiesJob < ApplicationJob
3
+ queue_as :default
4
+
5
+ def perform
6
+ current_time = Time.current
7
+
8
+ OrganizationUpdate.find_each do |organization_update|
9
+ process_organization_update(organization_update, current_time)
10
+ end
11
+ end
12
+
13
+ private
14
+
15
+ def process_organization_update(organization_update, current_time)
16
+ return if organization_update.last_updated_at.nil?
17
+
18
+ config = Config.find_by(organization_id: organization_update.organization_id)
19
+ return unless config
20
+
21
+ ApplicationRecord.transaction do
22
+ api_client = build_api_client(config)
23
+ start_time = organization_update.last_updated_at.iso8601
24
+ stop_time = current_time.iso8601
25
+
26
+ FetchUpdatedActivitiesService.new(api_client, start_time, stop_time).call
27
+
28
+ organization_update.update!(last_updated_at: current_time)
29
+ end
30
+ rescue StandardError => e
31
+ Rails.logger.error("Failed to fetch updated activities for organization #{organization_update.organization_id}: #{e.message}")
32
+ Rails.logger.error(e.backtrace.join("\n"))
33
+ end
34
+
35
+ def build_api_client(config)
36
+ connection = ApiConnection.new(config.personal_access_token).connection
37
+ ApiClient.new(config.organization_id, connection)
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,6 @@
1
+ module TudlaHubstaff
2
+ class ApplicationMailer < ActionMailer::Base
3
+ default from: "from@example.com"
4
+ layout "mailer"
5
+ end
6
+ end
@@ -0,0 +1,6 @@
1
+ module TudlaHubstaff
2
+ class Activity < ApplicationRecord
3
+ validates :activity_id, presence: true, uniqueness: true
4
+ validates :date, :user_id, presence: true
5
+ end
6
+ end
@@ -0,0 +1,5 @@
1
+ module TudlaHubstaff
2
+ class ApplicationRecord < ActiveRecord::Base
3
+ self.abstract_class = true
4
+ end
5
+ end
@@ -0,0 +1,8 @@
1
+ module TudlaHubstaff
2
+ class Config < ApplicationRecord
3
+ belongs_to :tudla_organization, polymorphic: true
4
+
5
+ validates :tudla_organization_type, :tudla_organization_id, :organization_id, presence: true
6
+ validates :personal_access_token, presence: true
7
+ end
8
+ end
@@ -0,0 +1,5 @@
1
+ module TudlaHubstaff
2
+ class OrganizationUpdate < ApplicationRecord
3
+ validates :organization_id, presence: true
4
+ end
5
+ end
@@ -0,0 +1,14 @@
1
+ module TudlaHubstaff
2
+ class Project < ApplicationRecord
3
+ validates :name, :project_type, presence: true
4
+ validates :project_id, presence: true, uniqueness: true
5
+
6
+ enum :project_type, {
7
+ project: "project",
8
+ work_order: "work_order",
9
+ work_break: "work_break"
10
+ }
11
+
12
+ # Rails 8 automatic identification of JSON columns
13
+ end
14
+ end
@@ -0,0 +1,9 @@
1
+ module TudlaHubstaff
2
+ class Task < ApplicationRecord
3
+ validates :project_id, :project_type, :summary, presence: true
4
+ validates :task_id, presence: true, uniqueness: true
5
+
6
+ # Rails 8 automatic identification of JSON columns
7
+ # No explicit serialize needed if using json/jsonb type
8
+ end
9
+ end
@@ -0,0 +1,16 @@
1
+ module TudlaHubstaff
2
+ class User < ApplicationRecord
3
+ normalizes :email, with: ->(email) { email.strip.downcase }
4
+
5
+ validates :name, :first_name, :last_name, presence: true
6
+ validates :time_zone, presence: true
7
+ validates :status, presence: true
8
+ validates :user_id, presence: true, uniqueness: true
9
+
10
+ enum :status, {
11
+ active: "active",
12
+ inactive: "inactive",
13
+ pending: "pending"
14
+ }, default: :active
15
+ end
16
+ end