escalated 0.4.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 (90) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +21 -0
  3. data/README.md +302 -0
  4. data/app/controllers/escalated/admin/bulk_actions_controller.rb +42 -0
  5. data/app/controllers/escalated/admin/canned_responses_controller.rb +73 -0
  6. data/app/controllers/escalated/admin/departments_controller.rb +135 -0
  7. data/app/controllers/escalated/admin/escalation_rules_controller.rb +121 -0
  8. data/app/controllers/escalated/admin/macros_controller.rb +73 -0
  9. data/app/controllers/escalated/admin/reports_controller.rb +152 -0
  10. data/app/controllers/escalated/admin/settings_controller.rb +111 -0
  11. data/app/controllers/escalated/admin/sla_policies_controller.rb +109 -0
  12. data/app/controllers/escalated/admin/tags_controller.rb +67 -0
  13. data/app/controllers/escalated/admin/tickets_controller.rb +299 -0
  14. data/app/controllers/escalated/agent/bulk_actions_controller.rb +42 -0
  15. data/app/controllers/escalated/agent/dashboard_controller.rb +94 -0
  16. data/app/controllers/escalated/agent/tickets_controller.rb +330 -0
  17. data/app/controllers/escalated/application_controller.rb +110 -0
  18. data/app/controllers/escalated/customer/satisfaction_ratings_controller.rb +44 -0
  19. data/app/controllers/escalated/customer/tickets_controller.rb +169 -0
  20. data/app/controllers/escalated/guest/tickets_controller.rb +231 -0
  21. data/app/controllers/escalated/inbound_controller.rb +79 -0
  22. data/app/jobs/escalated/check_sla_job.rb +36 -0
  23. data/app/jobs/escalated/close_resolved_job.rb +51 -0
  24. data/app/jobs/escalated/evaluate_escalations_job.rb +24 -0
  25. data/app/jobs/escalated/poll_imap_job.rb +74 -0
  26. data/app/jobs/escalated/purge_activities_job.rb +24 -0
  27. data/app/mailers/escalated/application_mailer.rb +6 -0
  28. data/app/mailers/escalated/ticket_mailer.rb +93 -0
  29. data/app/models/escalated/application_record.rb +5 -0
  30. data/app/models/escalated/attachment.rb +46 -0
  31. data/app/models/escalated/canned_response.rb +45 -0
  32. data/app/models/escalated/department.rb +43 -0
  33. data/app/models/escalated/escalated_setting.rb +43 -0
  34. data/app/models/escalated/escalation_rule.rb +96 -0
  35. data/app/models/escalated/inbound_email.rb +60 -0
  36. data/app/models/escalated/macro.rb +18 -0
  37. data/app/models/escalated/reply.rb +42 -0
  38. data/app/models/escalated/satisfaction_rating.rb +21 -0
  39. data/app/models/escalated/sla_policy.rb +54 -0
  40. data/app/models/escalated/tag.rb +28 -0
  41. data/app/models/escalated/ticket.rb +166 -0
  42. data/app/models/escalated/ticket_activity.rb +60 -0
  43. data/app/policies/escalated/canned_response_policy.rb +40 -0
  44. data/app/policies/escalated/department_policy.rb +36 -0
  45. data/app/policies/escalated/escalation_rule_policy.rb +36 -0
  46. data/app/policies/escalated/sla_policy_policy.rb +36 -0
  47. data/app/policies/escalated/tag_policy.rb +36 -0
  48. data/app/policies/escalated/ticket_policy.rb +111 -0
  49. data/config/routes.rb +81 -0
  50. data/db/migrate/001_create_escalated_departments.rb +18 -0
  51. data/db/migrate/002_create_escalated_sla_policies.rb +23 -0
  52. data/db/migrate/003_create_escalated_tags.rb +15 -0
  53. data/db/migrate/004_create_escalated_tickets.rb +48 -0
  54. data/db/migrate/005_create_escalated_replies.rb +21 -0
  55. data/db/migrate/006_create_escalated_attachments.rb +17 -0
  56. data/db/migrate/007_create_escalated_ticket_tags.rb +13 -0
  57. data/db/migrate/008_create_escalated_support_tables.rb +49 -0
  58. data/db/migrate/009_create_escalated_ticket_activities.rb +20 -0
  59. data/db/migrate/010_create_escalated_settings.rb +29 -0
  60. data/db/migrate/011_add_guest_fields_to_escalated_tickets.rb +28 -0
  61. data/db/migrate/012_create_escalated_inbound_emails.rb +30 -0
  62. data/db/migrate/013_create_escalated_macros.rb +18 -0
  63. data/db/migrate/014_create_escalated_ticket_followers.rb +18 -0
  64. data/db/migrate/015_create_escalated_satisfaction_ratings.rb +21 -0
  65. data/db/migrate/016_add_is_pinned_to_escalated_replies.rb +6 -0
  66. data/lib/escalated/configuration.rb +111 -0
  67. data/lib/escalated/drivers/cloud_driver.rb +134 -0
  68. data/lib/escalated/drivers/hosted_api_client.rb +166 -0
  69. data/lib/escalated/drivers/local_driver.rb +341 -0
  70. data/lib/escalated/drivers/synced_driver.rb +124 -0
  71. data/lib/escalated/engine.rb +45 -0
  72. data/lib/escalated/mail/adapters/base_adapter.rb +60 -0
  73. data/lib/escalated/mail/adapters/imap_adapter.rb +209 -0
  74. data/lib/escalated/mail/adapters/mailgun_adapter.rb +93 -0
  75. data/lib/escalated/mail/adapters/postmark_adapter.rb +94 -0
  76. data/lib/escalated/mail/adapters/ses_adapter.rb +179 -0
  77. data/lib/escalated/mail/inbound_message.rb +78 -0
  78. data/lib/escalated/manager.rb +33 -0
  79. data/lib/escalated/services/assignment_service.rb +85 -0
  80. data/lib/escalated/services/attachment_service.rb +110 -0
  81. data/lib/escalated/services/escalation_service.rb +159 -0
  82. data/lib/escalated/services/inbound_email_service.rb +255 -0
  83. data/lib/escalated/services/macro_service.rb +49 -0
  84. data/lib/escalated/services/notification_service.rb +157 -0
  85. data/lib/escalated/services/sla_service.rb +203 -0
  86. data/lib/escalated/services/ticket_service.rb +113 -0
  87. data/lib/escalated.rb +25 -0
  88. data/lib/generators/escalated/install_generator.rb +75 -0
  89. data/lib/generators/escalated/templates/initializer.rb +89 -0
  90. metadata +227 -0
@@ -0,0 +1,121 @@
1
+ module Escalated
2
+ module Admin
3
+ class EscalationRulesController < Escalated::ApplicationController
4
+ before_action :require_admin!
5
+ before_action :set_rule, only: [:show, :edit, :update, :destroy]
6
+
7
+ def index
8
+ rules = Escalated::EscalationRule.ordered
9
+
10
+ render inertia: "Escalated/Admin/EscalationRules/Index", props: {
11
+ escalation_rules: rules.map { |r| rule_json(r) }
12
+ }
13
+ end
14
+
15
+ def new
16
+ render inertia: "Escalated/Admin/EscalationRules/Form", props: {
17
+ escalation_rule: nil,
18
+ departments: Escalated::Department.active.ordered.map { |d| { id: d.id, name: d.name } },
19
+ agents: agent_list,
20
+ statuses: Escalated::Ticket.statuses.keys,
21
+ priorities: Escalated::Ticket.priorities.keys
22
+ }
23
+ end
24
+
25
+ def create
26
+ rule = Escalated::EscalationRule.new(rule_params)
27
+
28
+ if rule.save
29
+ redirect_to admin_escalation_rule_path(rule), notice: "Escalation rule created."
30
+ else
31
+ redirect_back fallback_location: new_admin_escalation_rule_path,
32
+ alert: rule.errors.full_messages.join(", ")
33
+ end
34
+ end
35
+
36
+ def show
37
+ render inertia: "Escalated/Admin/EscalationRules/Show", props: {
38
+ escalation_rule: rule_json(@rule)
39
+ }
40
+ end
41
+
42
+ def edit
43
+ render inertia: "Escalated/Admin/EscalationRules/Form", props: {
44
+ escalation_rule: rule_json(@rule),
45
+ departments: Escalated::Department.active.ordered.map { |d| { id: d.id, name: d.name } },
46
+ agents: agent_list,
47
+ statuses: Escalated::Ticket.statuses.keys,
48
+ priorities: Escalated::Ticket.priorities.keys
49
+ }
50
+ end
51
+
52
+ def update
53
+ if @rule.update(rule_params)
54
+ redirect_to admin_escalation_rule_path(@rule), notice: "Escalation rule updated."
55
+ else
56
+ redirect_back fallback_location: edit_admin_escalation_rule_path(@rule),
57
+ alert: @rule.errors.full_messages.join(", ")
58
+ end
59
+ end
60
+
61
+ def destroy
62
+ @rule.destroy!
63
+ redirect_to admin_escalation_rules_path, notice: "Escalation rule deleted."
64
+ end
65
+
66
+ private
67
+
68
+ def set_rule
69
+ @rule = Escalated::EscalationRule.find(params[:id])
70
+ end
71
+
72
+ def rule_params
73
+ params.require(:escalation_rule).permit(
74
+ :name, :description, :is_active, :priority,
75
+ conditions: {},
76
+ actions: {}
77
+ )
78
+ end
79
+
80
+ def rule_json(rule)
81
+ {
82
+ id: rule.id,
83
+ name: rule.name,
84
+ description: rule.description,
85
+ is_active: rule.is_active,
86
+ priority: rule.priority,
87
+ conditions: rule.conditions,
88
+ actions: rule.actions,
89
+ created_at: rule.created_at&.iso8601,
90
+ updated_at: rule.updated_at&.iso8601
91
+ }
92
+ end
93
+
94
+ def agent_list
95
+ if Escalated.configuration.user_model.respond_to?(:escalated_agents)
96
+ Escalated.configuration.user_model.escalated_agents.map { |a|
97
+ { id: a.id, name: a.respond_to?(:name) ? a.name : a.email, email: a.email }
98
+ }
99
+ else
100
+ []
101
+ end
102
+ end
103
+
104
+ def admin_escalation_rule_path(rule)
105
+ escalated.admin_escalation_rule_path(rule)
106
+ end
107
+
108
+ def new_admin_escalation_rule_path
109
+ escalated.new_admin_escalation_rule_path
110
+ end
111
+
112
+ def edit_admin_escalation_rule_path(rule)
113
+ escalated.edit_admin_escalation_rule_path(rule)
114
+ end
115
+
116
+ def admin_escalation_rules_path
117
+ escalated.admin_escalation_rules_path
118
+ end
119
+ end
120
+ end
121
+ end
@@ -0,0 +1,73 @@
1
+ module Escalated
2
+ module Admin
3
+ class MacrosController < Escalated::ApplicationController
4
+ before_action :require_admin!
5
+ before_action :set_macro, only: [:update, :destroy]
6
+
7
+ def index
8
+ macros = Escalated::Macro.ordered
9
+
10
+ render inertia: "Escalated/Admin/Macros/Index", props: {
11
+ macros: macros.map { |m| macro_json(m) }
12
+ }
13
+ end
14
+
15
+ def create
16
+ macro = Escalated::Macro.new(macro_params)
17
+ macro.created_by = escalated_current_user.id
18
+
19
+ if macro.save
20
+ redirect_to admin_macros_path, notice: "Macro created."
21
+ else
22
+ redirect_back fallback_location: admin_macros_path,
23
+ alert: macro.errors.full_messages.join(", ")
24
+ end
25
+ end
26
+
27
+ def update
28
+ if @macro.update(macro_params)
29
+ redirect_to admin_macros_path, notice: "Macro updated."
30
+ else
31
+ redirect_back fallback_location: admin_macros_path,
32
+ alert: @macro.errors.full_messages.join(", ")
33
+ end
34
+ end
35
+
36
+ def destroy
37
+ @macro.destroy!
38
+ redirect_to admin_macros_path, notice: "Macro deleted."
39
+ end
40
+
41
+ private
42
+
43
+ def set_macro
44
+ @macro = Escalated::Macro.find(params[:id])
45
+ end
46
+
47
+ def macro_params
48
+ params.require(:macro).permit(:name, :description, :is_shared, :order, actions: [:type, :value])
49
+ end
50
+
51
+ def macro_json(macro)
52
+ {
53
+ id: macro.id,
54
+ name: macro.name,
55
+ description: macro.description,
56
+ actions: macro.actions,
57
+ is_shared: macro.is_shared,
58
+ order: macro.order,
59
+ creator: macro.creator ? {
60
+ id: macro.creator.id,
61
+ name: macro.creator.respond_to?(:name) ? macro.creator.name : macro.creator.email
62
+ } : nil,
63
+ created_at: macro.created_at&.iso8601,
64
+ updated_at: macro.updated_at&.iso8601
65
+ }
66
+ end
67
+
68
+ def admin_macros_path
69
+ escalated.admin_macros_path
70
+ end
71
+ end
72
+ end
73
+ end
@@ -0,0 +1,152 @@
1
+ module Escalated
2
+ module Admin
3
+ class ReportsController < Escalated::ApplicationController
4
+ before_action :require_admin!
5
+
6
+ def index
7
+ period_start = parse_date(params[:from]) || 30.days.ago.beginning_of_day
8
+ period_end = parse_date(params[:to]) || Time.current.end_of_day
9
+
10
+ tickets_in_period = Escalated::Ticket.where(created_at: period_start..period_end)
11
+
12
+ stats = {
13
+ overview: {
14
+ total_created: tickets_in_period.count,
15
+ total_resolved: tickets_in_period.where.not(resolved_at: nil).count,
16
+ total_closed: tickets_in_period.where(status: :closed).count,
17
+ currently_open: Escalated::Ticket.by_open.count,
18
+ currently_unassigned: Escalated::Ticket.by_open.unassigned.count
19
+ },
20
+ by_status: Escalated::Ticket.statuses.keys.each_with_object({}) { |status, hash|
21
+ hash[status] = Escalated::Ticket.where(status: status).count
22
+ },
23
+ by_priority: Escalated::Ticket.priorities.keys.each_with_object({}) { |priority, hash|
24
+ hash[priority] = tickets_in_period.where(priority: priority).count
25
+ },
26
+ by_department: Escalated::Department.ordered.map { |dept|
27
+ {
28
+ id: dept.id,
29
+ name: dept.name,
30
+ total: tickets_in_period.where(department_id: dept.id).count,
31
+ open: Escalated::Ticket.by_open.where(department_id: dept.id).count
32
+ }
33
+ },
34
+ sla: Services::SlaService.stats,
35
+ performance: {
36
+ avg_first_response_hours: calculate_avg(:first_response, period_start, period_end),
37
+ avg_resolution_hours: calculate_avg(:resolution, period_start, period_end),
38
+ tickets_per_day: tickets_in_period.count.to_f / [(period_end - period_start) / 1.day, 1].max
39
+ },
40
+ trends: calculate_daily_trends(period_start, period_end),
41
+ top_agents: calculate_top_agents(period_start, period_end),
42
+ csat: calculate_csat_stats(period_start, period_end)
43
+ }
44
+
45
+ render inertia: "Escalated/Admin/Reports/Index", props: {
46
+ stats: stats,
47
+ filters: {
48
+ from: period_start.iso8601,
49
+ to: period_end.iso8601
50
+ }
51
+ }
52
+ end
53
+
54
+ private
55
+
56
+ def parse_date(value)
57
+ return nil unless value.present?
58
+
59
+ Time.zone.parse(value)
60
+ rescue ArgumentError
61
+ nil
62
+ end
63
+
64
+ def calculate_avg(type, from, to)
65
+ scope = Escalated::Ticket.where(created_at: from..to)
66
+
67
+ case type
68
+ when :first_response
69
+ tickets = scope.where.not(first_response_at: nil)
70
+ return 0 if tickets.empty?
71
+
72
+ total = tickets.sum { |t| (t.first_response_at - t.created_at).to_f }
73
+ (total / tickets.count / 3600.0).round(1)
74
+ when :resolution
75
+ tickets = scope.where.not(resolved_at: nil)
76
+ return 0 if tickets.empty?
77
+
78
+ total = tickets.sum { |t| (t.resolved_at - t.created_at).to_f }
79
+ (total / tickets.count / 3600.0).round(1)
80
+ end
81
+ end
82
+
83
+ def calculate_daily_trends(from, to)
84
+ days = ((to - from) / 1.day).ceil
85
+ days = [days, 90].min # Cap at 90 days
86
+
87
+ (0...days).map do |i|
88
+ day_start = from + i.days
89
+ day_end = day_start + 1.day
90
+
91
+ {
92
+ date: day_start.strftime("%Y-%m-%d"),
93
+ created: Escalated::Ticket.where(created_at: day_start..day_end).count,
94
+ resolved: Escalated::Ticket.where(resolved_at: day_start..day_end).count,
95
+ closed: Escalated::Ticket.where(closed_at: day_start..day_end).count
96
+ }
97
+ end
98
+ end
99
+
100
+ def calculate_top_agents(from, to)
101
+ resolved_counts = Escalated::Ticket
102
+ .where(resolved_at: from..to)
103
+ .where.not(assigned_to: nil)
104
+ .group(:assigned_to)
105
+ .count
106
+ .sort_by { |_, count| -count }
107
+ .first(10)
108
+
109
+ resolved_counts.map do |agent_id, count|
110
+ agent = Escalated.configuration.user_model.find_by(id: agent_id)
111
+ next unless agent
112
+
113
+ {
114
+ id: agent.id,
115
+ name: agent.respond_to?(:name) ? agent.name : agent.email,
116
+ resolved_count: count,
117
+ avg_resolution_hours: calculate_agent_avg_resolution(agent_id, from, to)
118
+ }
119
+ end.compact
120
+ end
121
+
122
+ def calculate_csat_stats(from, to)
123
+ ratings = Escalated::SatisfactionRating.where(created_at: from..to)
124
+
125
+ total = ratings.count
126
+ return { average: 0, total: 0, breakdown: {} } if total.zero?
127
+
128
+ average = (ratings.average(:rating).to_f).round(2)
129
+ breakdown = (1..5).each_with_object({}) do |star, hash|
130
+ hash[star] = ratings.where(rating: star).count
131
+ end
132
+
133
+ {
134
+ average: average,
135
+ total: total,
136
+ breakdown: breakdown
137
+ }
138
+ end
139
+
140
+ def calculate_agent_avg_resolution(agent_id, from, to)
141
+ tickets = Escalated::Ticket
142
+ .where(assigned_to: agent_id, resolved_at: from..to)
143
+ .where.not(resolved_at: nil)
144
+
145
+ return 0 if tickets.empty?
146
+
147
+ total = tickets.sum { |t| (t.resolved_at - t.created_at).to_f }
148
+ (total / tickets.count / 3600.0).round(1)
149
+ end
150
+ end
151
+ end
152
+ end
@@ -0,0 +1,111 @@
1
+ module Escalated
2
+ module Admin
3
+ class SettingsController < Escalated::ApplicationController
4
+ before_action :require_admin!
5
+
6
+ SENSITIVE_KEYS = %w[mailgun_signing_key postmark_inbound_token imap_password].freeze
7
+
8
+ def index
9
+ settings = Escalated::EscalatedSetting.all_as_hash
10
+ SENSITIVE_KEYS.each do |key|
11
+ settings[key] = mask_secret(settings[key]) if settings.key?(key)
12
+ end
13
+
14
+ render inertia: "Escalated/Admin/Settings", props: {
15
+ settings: settings
16
+ }
17
+ end
18
+
19
+ def update
20
+ # Boolean settings
21
+ %w[guest_tickets_enabled allow_customer_close inbound_email_enabled].each do |key|
22
+ value = params[key].in?(%w[1 true on]) ? "1" : "0"
23
+ Escalated::EscalatedSetting.set(key, value)
24
+ end
25
+
26
+ # Integer settings
27
+ %w[auto_close_resolved_after_days max_attachments_per_reply max_attachment_size_kb].each do |key|
28
+ raw = params[key]
29
+ next unless raw.present?
30
+
31
+ int_val = raw.to_i
32
+ Escalated::EscalatedSetting.set(key, [0, int_val].max.to_s) if int_val >= 0
33
+ end
34
+
35
+ # String settings
36
+ prefix = params[:ticket_reference_prefix].to_s.strip
37
+ if prefix.present? && prefix.match?(/\A[a-zA-Z0-9]+\z/) && prefix.length <= 10
38
+ Escalated::EscalatedSetting.set("ticket_reference_prefix", prefix)
39
+ end
40
+
41
+ # Inbound email settings
42
+ update_inbound_email_settings
43
+
44
+ redirect_to escalated.admin_settings_path, notice: "Settings updated successfully."
45
+ end
46
+
47
+ private
48
+
49
+ def update_inbound_email_settings
50
+ # Adapter selection (mailgun, postmark, ses, imap)
51
+ adapter = params[:inbound_email_adapter].to_s.strip.downcase
52
+ if adapter.present? && %w[mailgun postmark ses imap].include?(adapter)
53
+ Escalated::EscalatedSetting.set("inbound_email_adapter", adapter)
54
+ end
55
+
56
+ # Inbound email address
57
+ address = params[:inbound_email_address].to_s.strip
58
+ if address.present? && address.match?(/\A[^@\s]+@[^@\s]+\z/)
59
+ Escalated::EscalatedSetting.set("inbound_email_address", address)
60
+ elsif params.key?(:inbound_email_address) && address.blank?
61
+ Escalated::EscalatedSetting.set("inbound_email_address", "")
62
+ end
63
+
64
+ # Adapter-specific string settings (only save if present, skip masked values)
65
+ %w[
66
+ mailgun_signing_key postmark_inbound_token
67
+ ses_region ses_topic_arn
68
+ imap_host imap_username imap_password imap_mailbox
69
+ ].each do |key|
70
+ next unless params.key?(key)
71
+
72
+ raw = params[key].to_s.strip
73
+ next if SENSITIVE_KEYS.include?(key) && masked_value?(raw)
74
+
75
+ Escalated::EscalatedSetting.set(key, raw)
76
+ end
77
+
78
+ # IMAP port (integer)
79
+ if params[:imap_port].present?
80
+ port = params[:imap_port].to_i
81
+ Escalated::EscalatedSetting.set("imap_port", port.to_s) if port > 0 && port <= 65535
82
+ end
83
+
84
+ # IMAP encryption (ssl, tls, starttls, none)
85
+ encryption = params[:imap_encryption].to_s.strip.downcase
86
+ if encryption.present? && %w[ssl tls starttls none].include?(encryption)
87
+ Escalated::EscalatedSetting.set("imap_encryption", encryption)
88
+ end
89
+ end
90
+
91
+ def mask_secret(value)
92
+ return '' if value.blank?
93
+
94
+ len = value.length
95
+ return '*' * len if len <= 6
96
+
97
+ value[0, 3] + '*' * [len - 3, 12].min
98
+ end
99
+
100
+ def masked_value?(value)
101
+ return false if value.blank?
102
+
103
+ value.match?(/\A.{0,3}\*{3,}\z/)
104
+ end
105
+
106
+ def admin_settings_path
107
+ "/#{Escalated.configuration.route_prefix}/admin/settings"
108
+ end
109
+ end
110
+ end
111
+ end
@@ -0,0 +1,109 @@
1
+ module Escalated
2
+ module Admin
3
+ class SlaPoliciesController < Escalated::ApplicationController
4
+ before_action :require_admin!
5
+ before_action :set_sla_policy, only: [:show, :edit, :update, :destroy]
6
+
7
+ def index
8
+ policies = Escalated::SlaPolicy.ordered
9
+
10
+ render inertia: "Escalated/Admin/SlaPolicies/Index", props: {
11
+ sla_policies: policies.map { |p| sla_policy_json(p) }
12
+ }
13
+ end
14
+
15
+ def new
16
+ render inertia: "Escalated/Admin/SlaPolicies/Form", props: {
17
+ sla_policy: nil,
18
+ priorities: Escalated::Ticket.priorities.keys
19
+ }
20
+ end
21
+
22
+ def create
23
+ policy = Escalated::SlaPolicy.new(sla_policy_params)
24
+
25
+ if policy.save
26
+ redirect_to admin_sla_policy_path(policy), notice: "SLA Policy created."
27
+ else
28
+ redirect_back fallback_location: new_admin_sla_policy_path,
29
+ alert: policy.errors.full_messages.join(", ")
30
+ end
31
+ end
32
+
33
+ def show
34
+ render inertia: "Escalated/Admin/SlaPolicies/Show", props: {
35
+ sla_policy: sla_policy_json(@sla_policy),
36
+ targets: @sla_policy.priority_targets,
37
+ department_count: @sla_policy.departments.count,
38
+ ticket_count: @sla_policy.tickets.count
39
+ }
40
+ end
41
+
42
+ def edit
43
+ render inertia: "Escalated/Admin/SlaPolicies/Form", props: {
44
+ sla_policy: sla_policy_json(@sla_policy),
45
+ priorities: Escalated::Ticket.priorities.keys
46
+ }
47
+ end
48
+
49
+ def update
50
+ if @sla_policy.update(sla_policy_params)
51
+ redirect_to admin_sla_policy_path(@sla_policy), notice: "SLA Policy updated."
52
+ else
53
+ redirect_back fallback_location: edit_admin_sla_policy_path(@sla_policy),
54
+ alert: @sla_policy.errors.full_messages.join(", ")
55
+ end
56
+ end
57
+
58
+ def destroy
59
+ @sla_policy.destroy!
60
+ redirect_to admin_sla_policies_path, notice: "SLA Policy deleted."
61
+ end
62
+
63
+ private
64
+
65
+ def set_sla_policy
66
+ @sla_policy = Escalated::SlaPolicy.find(params[:id])
67
+ end
68
+
69
+ def sla_policy_params
70
+ params.require(:sla_policy).permit(
71
+ :name, :description, :is_active, :is_default,
72
+ first_response_hours: {},
73
+ resolution_hours: {}
74
+ )
75
+ end
76
+
77
+ def sla_policy_json(policy)
78
+ {
79
+ id: policy.id,
80
+ name: policy.name,
81
+ description: policy.description,
82
+ is_active: policy.is_active,
83
+ is_default: policy.is_default,
84
+ first_response_hours: policy.first_response_hours,
85
+ resolution_hours: policy.resolution_hours,
86
+ targets: policy.priority_targets,
87
+ created_at: policy.created_at&.iso8601,
88
+ updated_at: policy.updated_at&.iso8601
89
+ }
90
+ end
91
+
92
+ def admin_sla_policy_path(policy)
93
+ escalated.admin_sla_policy_path(policy)
94
+ end
95
+
96
+ def new_admin_sla_policy_path
97
+ escalated.new_admin_sla_policy_path
98
+ end
99
+
100
+ def edit_admin_sla_policy_path(policy)
101
+ escalated.edit_admin_sla_policy_path(policy)
102
+ end
103
+
104
+ def admin_sla_policies_path
105
+ escalated.admin_sla_policies_path
106
+ end
107
+ end
108
+ end
109
+ end
@@ -0,0 +1,67 @@
1
+ module Escalated
2
+ module Admin
3
+ class TagsController < Escalated::ApplicationController
4
+ before_action :require_admin!
5
+ before_action :set_tag, only: [:update, :destroy]
6
+
7
+ def index
8
+ tags = Escalated::Tag.ordered
9
+
10
+ render inertia: "Escalated/Admin/Tags/Index", props: {
11
+ tags: tags.map { |t| tag_json(t) }
12
+ }
13
+ end
14
+
15
+ def create
16
+ tag = Escalated::Tag.new(tag_params)
17
+
18
+ if tag.save
19
+ redirect_to admin_tags_path, notice: "Tag created."
20
+ else
21
+ redirect_back fallback_location: admin_tags_path,
22
+ alert: tag.errors.full_messages.join(", ")
23
+ end
24
+ end
25
+
26
+ def update
27
+ if @tag.update(tag_params)
28
+ redirect_to admin_tags_path, notice: "Tag updated."
29
+ else
30
+ redirect_back fallback_location: admin_tags_path,
31
+ alert: @tag.errors.full_messages.join(", ")
32
+ end
33
+ end
34
+
35
+ def destroy
36
+ @tag.destroy!
37
+ redirect_to admin_tags_path, notice: "Tag deleted."
38
+ end
39
+
40
+ private
41
+
42
+ def set_tag
43
+ @tag = Escalated::Tag.find(params[:id])
44
+ end
45
+
46
+ def tag_params
47
+ params.require(:tag).permit(:name, :color, :description)
48
+ end
49
+
50
+ def tag_json(tag)
51
+ {
52
+ id: tag.id,
53
+ name: tag.name,
54
+ slug: tag.slug,
55
+ color: tag.color,
56
+ description: tag.description,
57
+ ticket_count: tag.ticket_count,
58
+ created_at: tag.created_at&.iso8601
59
+ }
60
+ end
61
+
62
+ def admin_tags_path
63
+ escalated.admin_tags_path
64
+ end
65
+ end
66
+ end
67
+ end