tudla_hubstaff 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/MIT-LICENSE +20 -0
- data/README.md +270 -0
- data/Rakefile +6 -0
- data/app/assets/stylesheets/tudla_hubstaff/application.css +15 -0
- data/app/assets/tailwind/tudla_hubstaff/engine.css +32 -0
- data/app/components/tudla_hubstaff/activity_row_component.html.erb +46 -0
- data/app/components/tudla_hubstaff/activity_row_component.rb +67 -0
- data/app/components/tudla_hubstaff/base_component.rb +10 -0
- data/app/components/tudla_hubstaff/project_row_component.html.erb +22 -0
- data/app/components/tudla_hubstaff/project_row_component.rb +21 -0
- data/app/components/tudla_hubstaff/task_activities_component.html.erb +82 -0
- data/app/components/tudla_hubstaff/task_activities_component.rb +93 -0
- data/app/components/tudla_hubstaff/task_row_component.html.erb +22 -0
- data/app/components/tudla_hubstaff/task_row_component.rb +17 -0
- data/app/components/tudla_hubstaff/ui/map_button_component.html.erb +8 -0
- data/app/components/tudla_hubstaff/ui/map_button_component.rb +21 -0
- data/app/components/tudla_hubstaff/ui/mapping_modal_component.html.erb +51 -0
- data/app/components/tudla_hubstaff/ui/mapping_modal_component.rb +34 -0
- data/app/components/tudla_hubstaff/ui/modal_component.html.erb +36 -0
- data/app/components/tudla_hubstaff/ui/modal_component.rb +17 -0
- data/app/components/tudla_hubstaff/ui/pagination_component.html.erb +19 -0
- data/app/components/tudla_hubstaff/ui/pagination_component.rb +47 -0
- data/app/components/tudla_hubstaff/ui/status_badge_component.html.erb +3 -0
- data/app/components/tudla_hubstaff/ui/status_badge_component.rb +26 -0
- data/app/components/tudla_hubstaff/ui/table_component.html.erb +20 -0
- data/app/components/tudla_hubstaff/ui/table_component.rb +15 -0
- data/app/components/tudla_hubstaff/ui.rb +4 -0
- data/app/components/tudla_hubstaff/user_activities_list_component.html.erb +70 -0
- data/app/components/tudla_hubstaff/user_activities_list_component.rb +92 -0
- data/app/components/tudla_hubstaff/user_row_component.html.erb +22 -0
- data/app/components/tudla_hubstaff/user_row_component.rb +13 -0
- data/app/controllers/tudla_hubstaff/activities_controller.rb +88 -0
- data/app/controllers/tudla_hubstaff/application_controller.rb +14 -0
- data/app/controllers/tudla_hubstaff/projects_controller.rb +75 -0
- data/app/controllers/tudla_hubstaff/tasks_controller.rb +75 -0
- data/app/controllers/tudla_hubstaff/users_controller.rb +75 -0
- data/app/helpers/tudla_hubstaff/application_helper.rb +4 -0
- data/app/javascript/tudla_hubstaff/application.js +21 -0
- data/app/javascript/tudla_hubstaff/controllers/activity_task_mapping_modal_controller.js +183 -0
- data/app/javascript/tudla_hubstaff/controllers/index.js +15 -0
- data/app/javascript/tudla_hubstaff/controllers/project_mapping_modal_controller.js +180 -0
- data/app/javascript/tudla_hubstaff/controllers/task_mapping_modal_controller.js +181 -0
- data/app/javascript/tudla_hubstaff/controllers/user_mapping_modal_controller.js +181 -0
- data/app/jobs/tudla_hubstaff/application_job.rb +4 -0
- data/app/jobs/tudla_hubstaff/fetch_updated_activities_job.rb +40 -0
- data/app/mailers/tudla_hubstaff/application_mailer.rb +6 -0
- data/app/models/tudla_hubstaff/activity.rb +6 -0
- data/app/models/tudla_hubstaff/application_record.rb +5 -0
- data/app/models/tudla_hubstaff/config.rb +8 -0
- data/app/models/tudla_hubstaff/organization_update.rb +5 -0
- data/app/models/tudla_hubstaff/project.rb +14 -0
- data/app/models/tudla_hubstaff/task.rb +9 -0
- data/app/models/tudla_hubstaff/user.rb +16 -0
- data/app/services/tudla_hubstaff/api_client.rb +64 -0
- data/app/services/tudla_hubstaff/api_connection.rb +54 -0
- data/app/services/tudla_hubstaff/concerns/activity_syncing.rb +46 -0
- data/app/services/tudla_hubstaff/concerns/task_syncing.rb +32 -0
- data/app/services/tudla_hubstaff/fetch_updated_activities_service.rb +28 -0
- data/app/services/tudla_hubstaff/get_task_service.rb +24 -0
- data/app/services/tudla_hubstaff/sync_activities_service.rb +28 -0
- data/app/services/tudla_hubstaff/sync_projects_service.rb +51 -0
- data/app/services/tudla_hubstaff/sync_tasks_service.rb +31 -0
- data/app/services/tudla_hubstaff/sync_users_service.rb +53 -0
- data/app/views/layouts/tudla_hubstaff/application.html.erb +19 -0
- data/app/views/tudla_hubstaff/projects/unmapped.html.erb +75 -0
- data/app/views/tudla_hubstaff/tasks/unmapped.html.erb +75 -0
- data/app/views/tudla_hubstaff/users/map_tudla_user.turbo_stream.erb +1 -0
- data/app/views/tudla_hubstaff/users/unmapped.html.erb +75 -0
- data/config/importmap.rb +7 -0
- data/config/routes.rb +41 -0
- data/db/migrate/20251230220607_create_tudla_hubstaff_users.rb +16 -0
- data/db/migrate/20251230222524_create_tudla_hubstaff_tasks.rb +21 -0
- data/db/migrate/20251230223731_create_tudla_hubstaff_projects.rb +15 -0
- data/db/migrate/20251230225408_add_hubstaff_ids_to_models.rb +14 -0
- data/db/migrate/20251231041753_add_last_updated_at_to_models.rb +7 -0
- data/db/migrate/20251231043338_add_tudla_ids_to_models.rb +14 -0
- data/db/migrate/20251231045209_create_tudla_hubstaff_activities.rb +30 -0
- data/db/migrate/20251231074018_create_tudla_hubstaff_configs.rb +15 -0
- data/db/migrate/20251231074556_create_tudla_hubstaff_organization_updates.rb +12 -0
- data/lib/generators/tudla_hubstaff/components_generator.rb +68 -0
- data/lib/generators/tudla_hubstaff/views_generator.rb +54 -0
- data/lib/tasks/tudla_hubstaff_tasks.rake +18 -0
- data/lib/tudla_hubstaff/engine.rb +88 -0
- data/lib/tudla_hubstaff/provider.rb +12 -0
- data/lib/tudla_hubstaff/version.rb +3 -0
- data/lib/tudla_hubstaff.rb +49 -0
- metadata +442 -0
|
@@ -0,0 +1,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,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,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,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
|