munawaba 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/CHANGELOG.md +12 -0
- data/LICENSE.txt +21 -0
- data/README.md +160 -0
- data/SECURITY.md +15 -0
- data/app/assets/munawaba/INTER-LICENSE.txt +92 -0
- data/app/assets/munawaba/JETBRAINS-MONO-LICENSE.txt +93 -0
- data/app/assets/munawaba/dashboard.css +272 -0
- data/app/assets/munawaba/dashboard.js +60 -0
- data/app/assets/munawaba/inter.woff2 +0 -0
- data/app/assets/munawaba/jetbrains-mono.woff2 +0 -0
- data/app/assets/munawaba/logo.png +0 -0
- data/app/controllers/munawaba/application_controller.rb +149 -0
- data/app/controllers/munawaba/audit_events_controller.rb +31 -0
- data/app/controllers/munawaba/calendars_controller.rb +47 -0
- data/app/controllers/munawaba/frontends_controller.rb +28 -0
- data/app/controllers/munawaba/notification_deliveries_controller.rb +41 -0
- data/app/controllers/munawaba/overrides_controller.rb +52 -0
- data/app/controllers/munawaba/overviews_controller.rb +22 -0
- data/app/controllers/munawaba/people_controller.rb +73 -0
- data/app/controllers/munawaba/rotations_controller.rb +61 -0
- data/app/controllers/munawaba/schedules_controller.rb +115 -0
- data/app/controllers/munawaba/shifts_controller.rb +16 -0
- data/app/controllers/munawaba/slack_integrations_controller.rb +46 -0
- data/app/controllers/munawaba/themes_controller.rb +19 -0
- data/app/helpers/munawaba/application_helper.rb +208 -0
- data/app/jobs/munawaba/application_job.rb +7 -0
- data/app/jobs/munawaba/deliver_notification_job.rb +13 -0
- data/app/jobs/munawaba/dispatch_due_notifications_job.rb +7 -0
- data/app/jobs/munawaba/maintain_projection_job.rb +25 -0
- data/app/jobs/munawaba/maintenance_job.rb +28 -0
- data/app/models/munawaba/application_record.rb +7 -0
- data/app/models/munawaba/audit_event.rb +16 -0
- data/app/models/munawaba/notification_delivery.rb +75 -0
- data/app/models/munawaba/person.rb +23 -0
- data/app/models/munawaba/schedule.rb +85 -0
- data/app/models/munawaba/schedule_membership.rb +13 -0
- data/app/models/munawaba/shift.rb +51 -0
- data/app/models/munawaba/shift_override.rb +31 -0
- data/app/services/munawaba/audit/recorder.rb +17 -0
- data/app/services/munawaba/canonical.rb +43 -0
- data/app/services/munawaba/commands.rb +741 -0
- data/app/services/munawaba/conflicts/finder.rb +59 -0
- data/app/services/munawaba/integrations/planner_effects.rb +27 -0
- data/app/services/munawaba/integrations.rb +93 -0
- data/app/services/munawaba/notifications/context/v1.rb +65 -0
- data/app/services/munawaba/notifications/deliver.rb +94 -0
- data/app/services/munawaba/notifications/dispatcher.rb +50 -0
- data/app/services/munawaba/notifications/planner.rb +154 -0
- data/app/services/munawaba/notifications/reclaim_expired_leases.rb +27 -0
- data/app/services/munawaba/notifications/retry_failed.rb +51 -0
- data/app/services/munawaba/notifications/validity.rb +90 -0
- data/app/services/munawaba/notifications/wake_dispatcher.rb +17 -0
- data/app/services/munawaba/preview.rb +5 -0
- data/app/services/munawaba/result.rb +7 -0
- data/app/services/munawaba/shifts/normalize_future_timing.rb +172 -0
- data/app/services/munawaba/shifts/project.rb +50 -0
- data/app/services/munawaba/shifts/projection_plan.rb +89 -0
- data/app/services/munawaba/slack/client.rb +105 -0
- data/app/services/munawaba/slack/renderer.rb +48 -0
- data/app/services/munawaba/slack/webhook_validator.rb +35 -0
- data/app/services/munawaba/timing/boundary_calculator.rb +92 -0
- data/app/services/munawaba/timing/persisted_interval_verifier.rb +26 -0
- data/app/views/layouts/munawaba/application.html.erb +61 -0
- data/app/views/munawaba/audit_events/index.html.erb +85 -0
- data/app/views/munawaba/calendars/_shift.html.erb +17 -0
- data/app/views/munawaba/calendars/show.html.erb +81 -0
- data/app/views/munawaba/notification_deliveries/index.html.erb +65 -0
- data/app/views/munawaba/overrides/new.html.erb +19 -0
- data/app/views/munawaba/overviews/show.html.erb +76 -0
- data/app/views/munawaba/people/_form.html.erb +25 -0
- data/app/views/munawaba/people/edit.html.erb +2 -0
- data/app/views/munawaba/people/index.html.erb +49 -0
- data/app/views/munawaba/people/new.html.erb +2 -0
- data/app/views/munawaba/people/show.html.erb +34 -0
- data/app/views/munawaba/rotations/edit.html.erb +39 -0
- data/app/views/munawaba/schedules/_form.html.erb +49 -0
- data/app/views/munawaba/schedules/_resume_form.html.erb +24 -0
- data/app/views/munawaba/schedules/edit.html.erb +2 -0
- data/app/views/munawaba/schedules/index.html.erb +46 -0
- data/app/views/munawaba/schedules/new.html.erb +2 -0
- data/app/views/munawaba/schedules/resume.html.erb +4 -0
- data/app/views/munawaba/schedules/show.html.erb +86 -0
- data/app/views/munawaba/shared/_empty.html.erb +6 -0
- data/app/views/munawaba/shared/_errors.html.erb +23 -0
- data/app/views/munawaba/shared/_navigation.html.erb +8 -0
- data/app/views/munawaba/shared/_projection.html.erb +20 -0
- data/app/views/munawaba/shared/_proposal_details.html.erb +108 -0
- data/app/views/munawaba/shared/_shifts.html.erb +50 -0
- data/app/views/munawaba/shared/proposal.html.erb +5 -0
- data/app/views/munawaba/shifts/show.html.erb +65 -0
- data/app/views/munawaba/slack_integrations/edit.html.erb +48 -0
- data/config/routes.rb +45 -0
- data/db/migrate/20260905000001_create_munawaba_people.rb +33 -0
- data/db/migrate/20260905000002_create_munawaba_schedules.rb +73 -0
- data/db/migrate/20260905000003_create_munawaba_schedule_memberships.rb +25 -0
- data/db/migrate/20260905000004_create_munawaba_shifts.rb +51 -0
- data/db/migrate/20260905000005_create_munawaba_shift_overrides.rb +33 -0
- data/db/migrate/20260905000006_create_munawaba_notification_deliveries.rb +83 -0
- data/db/migrate/20260905000007_create_munawaba_audit_events.rb +36 -0
- data/db/migrate/20260905000008_simplify_munawaba_activity.rb +16 -0
- data/lib/generators/munawaba/install_generator.rb +34 -0
- data/lib/generators/munawaba/templates/munawaba.rb +23 -0
- data/lib/munawaba/configuration.rb +91 -0
- data/lib/munawaba/defaults.rb +20 -0
- data/lib/munawaba/engine.rb +16 -0
- data/lib/munawaba/version.rb +5 -0
- data/lib/munawaba.rb +33 -0
- metadata +287 -0
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "tzinfo"
|
|
4
|
+
|
|
5
|
+
module Munawaba
|
|
6
|
+
module Timing
|
|
7
|
+
# Resolve each handoff from the original local rule so short months and DST
|
|
8
|
+
# adjustments do not shift later handoffs.
|
|
9
|
+
class BoundaryCalculator
|
|
10
|
+
Boundary = Struct.new(:index, :nominal_local_date, :nominal_local_seconds,
|
|
11
|
+
:resolved_at, :resolution, :adjustment_seconds, :utc_offset_seconds, keyword_init: true)
|
|
12
|
+
|
|
13
|
+
def self.timezone(iana) = TZInfo::Timezone.get(iana)
|
|
14
|
+
|
|
15
|
+
def initialize(schedule, provider: Timing::BoundaryCalculator.method(:timezone))
|
|
16
|
+
@schedule = schedule
|
|
17
|
+
@zone = provider.call(schedule.time_zone)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def boundary(index)
|
|
21
|
+
raise ArgumentError, "Boundary index must be nonnegative" unless index.is_a?(Integer) && index >= 0
|
|
22
|
+
|
|
23
|
+
anchor = @schedule.anchor_local_date
|
|
24
|
+
date = case @schedule.cadence
|
|
25
|
+
when "one_week" then anchor + (7 * index)
|
|
26
|
+
when "two_weeks" then anchor + (14 * index)
|
|
27
|
+
when "calendar_month" then anchor >> index
|
|
28
|
+
else raise ArgumentError, "Unknown cadence"
|
|
29
|
+
end
|
|
30
|
+
seconds = @schedule.anchor_local_seconds
|
|
31
|
+
local = Time.utc(date.year, date.month, date.day) + seconds
|
|
32
|
+
periods = @zone.periods_for_local(local)
|
|
33
|
+
adjustment = 0
|
|
34
|
+
if periods.any?
|
|
35
|
+
candidates = periods.map { |period| [local - period.utc_total_offset, period.utc_total_offset] }
|
|
36
|
+
resolved, offset = candidates.min_by(&:first)
|
|
37
|
+
resolution = periods.length > 1 ? "ambiguous_earlier" : "exact"
|
|
38
|
+
else
|
|
39
|
+
# A transition's local gap is [UTC + old offset, UTC + new offset).
|
|
40
|
+
transition = @zone.transitions_up_to(local + (3 * 86_400), local - (3 * 86_400)).find do |item|
|
|
41
|
+
old_offset = item.previous_offset.utc_total_offset
|
|
42
|
+
new_offset = item.offset.utc_total_offset
|
|
43
|
+
instant = item.at.to_time.utc
|
|
44
|
+
new_offset > old_offset && local >= instant + old_offset && local < instant + new_offset
|
|
45
|
+
end
|
|
46
|
+
raise ArgumentError, "Timezone provider did not resolve the local gap" unless transition
|
|
47
|
+
|
|
48
|
+
offset = transition.offset.utc_total_offset
|
|
49
|
+
adjustment = offset - transition.previous_offset.utc_total_offset
|
|
50
|
+
resolved = local + adjustment - offset
|
|
51
|
+
resolution = "gap_forward"
|
|
52
|
+
end
|
|
53
|
+
Boundary.new(index: index, nominal_local_date: date, nominal_local_seconds: seconds,
|
|
54
|
+
resolved_at: resolved.utc.freeze, resolution: resolution.freeze,
|
|
55
|
+
adjustment_seconds: adjustment, utc_offset_seconds: offset).freeze
|
|
56
|
+
end
|
|
57
|
+
alias call boundary
|
|
58
|
+
|
|
59
|
+
def slot_at(instant)
|
|
60
|
+
instant = instant.utc
|
|
61
|
+
raise ArgumentError, "Instant precedes the nominal anchor" if instant < boundary(0).resolved_at
|
|
62
|
+
|
|
63
|
+
date = instant.to_date
|
|
64
|
+
anchor = @schedule.anchor_local_date
|
|
65
|
+
index = case @schedule.cadence
|
|
66
|
+
when "one_week" then ((date - anchor).to_i / 7)
|
|
67
|
+
when "two_weeks" then ((date - anchor).to_i / 14)
|
|
68
|
+
when "calendar_month" then ((date.year - anchor.year) * 12) + date.month - anchor.month
|
|
69
|
+
end
|
|
70
|
+
index = [index, 0].max
|
|
71
|
+
index -= 1 while index.positive? && boundary(index).resolved_at > instant
|
|
72
|
+
index += 1 while boundary(index + 1).resolved_at <= instant
|
|
73
|
+
raise ArgumentError,
|
|
74
|
+
"Non-monotonic canonical boundaries" unless boundary(index).resolved_at < boundary(index + 1).resolved_at
|
|
75
|
+
|
|
76
|
+
index
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def selectable_boundaries(now:, limit: Munawaba.config.calendar_future_limit)
|
|
80
|
+
first = now < boundary(0).resolved_at ? 0 : slot_at(now)
|
|
81
|
+
first += 1 if boundary(first).resolved_at < now
|
|
82
|
+
last_at = now + limit
|
|
83
|
+
values = []
|
|
84
|
+
while (item = boundary(first)).resolved_at <= last_at
|
|
85
|
+
values << item
|
|
86
|
+
first += 1
|
|
87
|
+
end
|
|
88
|
+
values.freeze
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Munawaba
|
|
4
|
+
module Timing
|
|
5
|
+
class PersistedIntervalVerifier
|
|
6
|
+
def self.valid?(shift:, now:, provider: Timing::BoundaryCalculator.method(:timezone))
|
|
7
|
+
return false if shift.canceled_at || shift.coverage_revision != shift.schedule.coverage_revision
|
|
8
|
+
|
|
9
|
+
# Started shifts keep their saved times. Check future shifts with the same
|
|
10
|
+
# boundary and start rules used when preparing a scheduling change.
|
|
11
|
+
if shift.starts_at <= now
|
|
12
|
+
calculator = BoundaryCalculator.new(shift.schedule, provider: provider)
|
|
13
|
+
changed = calculator.boundary(shift.boundary_index + 1).resolved_at != shift.ends_at
|
|
14
|
+
ActiveSupport::Notifications.instrument("timing.frozen_interval_rule_change.munawaba",
|
|
15
|
+
schedule_id: shift.schedule_id, shift_id: shift.id) if changed
|
|
16
|
+
return !changed
|
|
17
|
+
end
|
|
18
|
+
prepared = Shifts::NormalizeFutureTiming.prepare(schedule: shift.schedule, now: now, provider: provider)
|
|
19
|
+
virtual = prepared.slots.find { |slot| slot[:id] == shift.id }
|
|
20
|
+
virtual && virtual[:starts_at] == shift.starts_at && virtual[:ends_at] == shift.ends_at && !prepared.clipping
|
|
21
|
+
rescue ArgumentError, TZInfo::InvalidTimezoneIdentifier
|
|
22
|
+
false
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
6
|
+
<meta name="color-scheme" content="dark light">
|
|
7
|
+
<meta name="turbo-visit-control" content="reload">
|
|
8
|
+
<title><%= content_for?(:title) ? "#{yield(:title)} · Munawaba" : "Munawaba" %></title>
|
|
9
|
+
<%= csrf_meta_tags %>
|
|
10
|
+
<%= csp_meta_tag %>
|
|
11
|
+
<link rel="icon" type="image/png" href="<%= frontend_asset_path(version: Munawaba::VERSION, id: "logo.png") %>">
|
|
12
|
+
<%= stylesheet_link_tag frontend_asset_path(version: Munawaba::VERSION, id: "dashboard.css"), nonce: content_security_policy_nonce %>
|
|
13
|
+
<%= javascript_include_tag frontend_asset_path(version: Munawaba::VERSION, id: "dashboard.js"), defer: true, nonce: content_security_policy_nonce %>
|
|
14
|
+
</head>
|
|
15
|
+
<body>
|
|
16
|
+
<div class="mn-app" data-mn-theme="<%= munawaba_theme %>" data-turbo="false">
|
|
17
|
+
<a href="#mn-main" class="mn-skip-link">Skip to content</a>
|
|
18
|
+
<aside class="mn-sidebar" aria-label="Munawaba navigation">
|
|
19
|
+
<%= link_to root_path, class: "mn-brand" do %>
|
|
20
|
+
<%= image_tag frontend_asset_path(version: Munawaba::VERSION, id: "logo.png"), alt: "", class: "mn-brand-logo", width: 18, height: 18 %>
|
|
21
|
+
<span class="mn-brand-name">munawaba</span>
|
|
22
|
+
<span class="mn-version">v<%= Munawaba::VERSION %></span>
|
|
23
|
+
<% end %>
|
|
24
|
+
<div class="mn-desktop-navigation">
|
|
25
|
+
<%= render "munawaba/shared/navigation" %>
|
|
26
|
+
</div>
|
|
27
|
+
<details class="mn-menu" data-mn-menu>
|
|
28
|
+
<summary>Menu <span aria-hidden="true">☰</span></summary>
|
|
29
|
+
<%= render "munawaba/shared/navigation" %>
|
|
30
|
+
</details>
|
|
31
|
+
<div class="mn-sidebar-footer">
|
|
32
|
+
<span>On-call rotations</span>
|
|
33
|
+
</div>
|
|
34
|
+
</aside>
|
|
35
|
+
<div class="mn-workspace">
|
|
36
|
+
<header class="mn-topbar">
|
|
37
|
+
<nav class="mn-breadcrumbs" aria-label="Breadcrumb">
|
|
38
|
+
<%= link_to "Munawaba", root_path %>
|
|
39
|
+
<span class="mn-breadcrumb-separator" aria-hidden="true">/</span>
|
|
40
|
+
<span aria-current="page"><%= content_for?(:title) ? yield(:title) : "Overview" %></span>
|
|
41
|
+
</nav>
|
|
42
|
+
<% theme_action = munawaba_theme == "dark" ? "Light theme" : "Dark theme" %>
|
|
43
|
+
<%= form_with url: theme_path, method: :patch, local: true, class: "mn-theme-form" do %>
|
|
44
|
+
<%= hidden_field_tag :theme, munawaba_theme == "dark" ? "light" : "dark" %>
|
|
45
|
+
<%= button_tag type: "submit", class: "mn-theme-button", title: theme_action, aria: { label: theme_action } do %>
|
|
46
|
+
<span aria-hidden="true"><%= munawaba_theme == "dark" ? "◐" : "◑" %></span>
|
|
47
|
+
<span class="mn-sr-only"><%= theme_action %></span>
|
|
48
|
+
<% end %>
|
|
49
|
+
<% end %>
|
|
50
|
+
</header>
|
|
51
|
+
<main id="mn-main" class="mn-main" tabindex="-1">
|
|
52
|
+
<% if flash[:notice].present? %>
|
|
53
|
+
<div class="mn-notice" role="status"><%= flash[:notice] %></div>
|
|
54
|
+
<% end %>
|
|
55
|
+
<%= render "munawaba/shared/errors", record: (@person || @schedule || @record) %>
|
|
56
|
+
<%= yield %>
|
|
57
|
+
</main>
|
|
58
|
+
</div>
|
|
59
|
+
</div>
|
|
60
|
+
</body>
|
|
61
|
+
</html>
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
<%= page_title "Activity", "Recent changes to people, schedules, and Slack settings. Times: #{Munawaba.config.organization_time_zone}." %>
|
|
2
|
+
|
|
3
|
+
<%= form_with url: activity_path, method: :get, local: true, class: "mn-filter-bar", id: "mn-form" do %>
|
|
4
|
+
<div>
|
|
5
|
+
<%= label_tag :schedule_id, "Schedule" %>
|
|
6
|
+
<%= select_tag :schedule_id, options_from_collection_for_select(@schedules, :id, :name, params[:schedule_id]), include_blank: "All schedules" %>
|
|
7
|
+
</div>
|
|
8
|
+
<div>
|
|
9
|
+
<%= label_tag :person_id, "Person" %>
|
|
10
|
+
<%= select_tag :person_id, options_from_collection_for_select(@people, :id, :name, params[:person_id]), include_blank: "All people" %>
|
|
11
|
+
</div>
|
|
12
|
+
<div>
|
|
13
|
+
<%= label_tag :event_type, "Event" %>
|
|
14
|
+
<%= text_field_tag :event_type, params[:event_type], placeholder: "rotation.changed" %>
|
|
15
|
+
</div>
|
|
16
|
+
<div>
|
|
17
|
+
<%= label_tag :shift_id, "Shift ID" %>
|
|
18
|
+
<%= number_field_tag :shift_id, params[:shift_id], min: 1 %>
|
|
19
|
+
</div>
|
|
20
|
+
<div>
|
|
21
|
+
<%= label_tag :actor_type, "Actor type" %>
|
|
22
|
+
<%= text_field_tag :actor_type, params[:actor_type] %>
|
|
23
|
+
</div>
|
|
24
|
+
<div>
|
|
25
|
+
<%= label_tag :actor_id, "Actor ID" %>
|
|
26
|
+
<%= text_field_tag :actor_id, params[:actor_id] %>
|
|
27
|
+
</div>
|
|
28
|
+
<div>
|
|
29
|
+
<%= label_tag :from, "From date" %>
|
|
30
|
+
<%= date_field_tag :from, params[:from] %>
|
|
31
|
+
</div>
|
|
32
|
+
<div>
|
|
33
|
+
<%= label_tag :until, "Through date" %>
|
|
34
|
+
<%= date_field_tag :until, params[:until] %>
|
|
35
|
+
</div>
|
|
36
|
+
<%= submit_tag "Filter", class: "mn-button" %>
|
|
37
|
+
<%= link_to "Clear", activity_path %>
|
|
38
|
+
<% end %>
|
|
39
|
+
|
|
40
|
+
<% if @events.empty? %>
|
|
41
|
+
<%= render "munawaba/shared/empty", title: "No activity found", description: "Changes matching these filters will appear here." %>
|
|
42
|
+
<% else %>
|
|
43
|
+
<div class="mn-table-scroll" role="region" aria-label="Activity" tabindex="0">
|
|
44
|
+
<table class="mn-table">
|
|
45
|
+
<thead>
|
|
46
|
+
<tr>
|
|
47
|
+
<th scope="col">When</th>
|
|
48
|
+
<th scope="col">Event</th>
|
|
49
|
+
<th scope="col">Actor</th>
|
|
50
|
+
<th scope="col">Details</th>
|
|
51
|
+
</tr>
|
|
52
|
+
</thead>
|
|
53
|
+
<tbody>
|
|
54
|
+
<% @events.each do |event| %>
|
|
55
|
+
<tr>
|
|
56
|
+
<td class="mn-mono"><%= local_time(event.occurred_at) %></td>
|
|
57
|
+
<td>
|
|
58
|
+
<strong><%= event.event_type.tr("._", " ").humanize %></strong>
|
|
59
|
+
<span class="mn-cell-sub mn-mono"><%= event.event_type %></span>
|
|
60
|
+
</td>
|
|
61
|
+
<td><%= event.actor_name.presence || event.actor_id.presence || "—" %></td>
|
|
62
|
+
<td>
|
|
63
|
+
<details class="mn-event-details">
|
|
64
|
+
<summary>View details</summary>
|
|
65
|
+
<dl class="mn-detail-list">
|
|
66
|
+
<dt>Operation</dt>
|
|
67
|
+
<dd class="mn-mono"><%= event.operation_id %></dd>
|
|
68
|
+
<% event.metadata.each do |key, value| %>
|
|
69
|
+
<dt><%= key.humanize %></dt>
|
|
70
|
+
<dd><%= value.is_a?(Hash) || value.is_a?(Array) ? JSON.pretty_generate(value) : value %></dd>
|
|
71
|
+
<% end %>
|
|
72
|
+
</dl>
|
|
73
|
+
</details>
|
|
74
|
+
</td>
|
|
75
|
+
</tr>
|
|
76
|
+
<% end %>
|
|
77
|
+
</tbody>
|
|
78
|
+
</table>
|
|
79
|
+
</div>
|
|
80
|
+
<% if @more %>
|
|
81
|
+
<div class="mn-pagination">
|
|
82
|
+
<%= link_to "Older activity →", activity_path(request.query_parameters.merge(before: cursor_for(@events.last, :occurred_at))), class: "mn-button" %>
|
|
83
|
+
</div>
|
|
84
|
+
<% end %>
|
|
85
|
+
<% end %>
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
<%= link_to shift_path(shift), class: "mn-calendar-shift" do %>
|
|
2
|
+
<strong><%= shift.schedule.name %></strong>
|
|
3
|
+
<span><%= shift.effective_person.name %></span>
|
|
4
|
+
<span class="mn-mono"><%= local_time(shift.starts_at, @time_zone, format: "%b %-d %H:%M") %> → <%= local_time(shift.ends_at, @time_zone, format: "%b %-d %H:%M") %></span>
|
|
5
|
+
<% if shift.starts_at <= Time.current && shift.ends_at > Time.current %>
|
|
6
|
+
<span>● Current shift</span>
|
|
7
|
+
<% end %>
|
|
8
|
+
<% if shift.effective_person_id != shift.base_person_id %>
|
|
9
|
+
<span>Override</span>
|
|
10
|
+
<% end %>
|
|
11
|
+
<% shift_dst_adjustments(shift).each do |adjustment| %>
|
|
12
|
+
<span class="mn-dst-cue"><%= adjustment %></span>
|
|
13
|
+
<% end %>
|
|
14
|
+
<% if conflicts[shift.id].present? %>
|
|
15
|
+
<span class="mn-conflict">Conflict: <%= conflicts[shift.id].map { |row| row["name"] }.uniq.join(", ") %></span>
|
|
16
|
+
<% end %>
|
|
17
|
+
<% end %>
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
<div class="mn-page-heading">
|
|
2
|
+
<%= page_title "Calendar", "Coverage · #{@time_zone}" %>
|
|
3
|
+
<div class="mn-segment">
|
|
4
|
+
<%= link_to "Agenda", calendar_link(@from, "agenda"), aria: { current: ("page" if @view == "agenda") } %>
|
|
5
|
+
<%= link_to "Month", calendar_link(@from, "month"), aria: { current: ("page" if @view == "month") } %>
|
|
6
|
+
</div>
|
|
7
|
+
</div>
|
|
8
|
+
|
|
9
|
+
<%= form_with url: calendar_path, method: :get, local: true, class: "mn-filter-bar", id: "mn-form" do %>
|
|
10
|
+
<%= hidden_field_tag :view, @view %>
|
|
11
|
+
<div>
|
|
12
|
+
<%= label_tag :schedule_id, "Schedule" %>
|
|
13
|
+
<%= select_tag :schedule_id, options_from_collection_for_select(@schedules, :id, :name, params[:schedule_id]), include_blank: "All schedules" %>
|
|
14
|
+
</div>
|
|
15
|
+
<div>
|
|
16
|
+
<%= label_tag :person_id, "Person" %>
|
|
17
|
+
<%= select_tag :person_id, options_from_collection_for_select(@people, :id, :name, params[:person_id]), include_blank: "All people" %>
|
|
18
|
+
</div>
|
|
19
|
+
<div>
|
|
20
|
+
<%= label_tag :date, "From date" %>
|
|
21
|
+
<%= date_field_tag :date, @from %>
|
|
22
|
+
</div>
|
|
23
|
+
<%= submit_tag "Apply", class: "mn-button" %>
|
|
24
|
+
<%= link_to "Clear", calendar_path %>
|
|
25
|
+
<% end %>
|
|
26
|
+
|
|
27
|
+
<div class="mn-section-heading">
|
|
28
|
+
<h2><%= @view == "month" ? @from.strftime("%B %Y") : "#{@from.strftime('%b %-d')} – #{(@until - 1).strftime('%b %-d, %Y')}" %></h2>
|
|
29
|
+
<div class="mn-actions">
|
|
30
|
+
<%= link_to "← Previous", calendar_link(@previous_date), class: "mn-button" if @previous_date %>
|
|
31
|
+
<%= link_to "Today", calendar_path(request.query_parameters.except("date", "until").merge(date: Time.current.in_time_zone(@time_zone).to_date)), class: "mn-button" %>
|
|
32
|
+
<%= link_to "Next →", calendar_link(@next_date), class: "mn-button" if @next_date %>
|
|
33
|
+
</div>
|
|
34
|
+
</div>
|
|
35
|
+
|
|
36
|
+
<% if @view == "month" && @errors.blank? %>
|
|
37
|
+
<%
|
|
38
|
+
conflicts = conflicts_for(@shifts)
|
|
39
|
+
shift_cards = {}
|
|
40
|
+
%>
|
|
41
|
+
<div class="mn-calendar-scroll" role="region" aria-label="Month calendar, <%= @time_zone %>" tabindex="0">
|
|
42
|
+
<div class="mn-month-grid">
|
|
43
|
+
<% calendar_days.first(7).each do |day| %>
|
|
44
|
+
<div class="mn-weekday"><%= day.strftime("%A") %></div>
|
|
45
|
+
<% end %>
|
|
46
|
+
<% calendar_days.each do |day| %>
|
|
47
|
+
<section class="mn-day <%= 'mn-day-outside' if day.month != @from.month %>">
|
|
48
|
+
<h3><time datetime="<%= day.iso8601 %>"><%= day.day %></time><%= " · Today" if day == Time.current.in_time_zone(@time_zone).to_date %></h3>
|
|
49
|
+
<% day_shifts = shifts_on(day) %>
|
|
50
|
+
<% day_shifts.first(3).each do |shift| %>
|
|
51
|
+
<%= shift_cards[shift.id] ||= render("munawaba/calendars/shift", shift: shift, conflicts: conflicts) %>
|
|
52
|
+
<% end %>
|
|
53
|
+
<% if day_shifts.size > 3 %>
|
|
54
|
+
<details class="mn-day-more">
|
|
55
|
+
<summary><%= day_shifts.size - 3 %> more shifts</summary>
|
|
56
|
+
<% day_shifts.drop(3).each do |shift| %>
|
|
57
|
+
<%= shift_cards[shift.id] ||= render("munawaba/calendars/shift", shift: shift, conflicts: conflicts) %>
|
|
58
|
+
<% end %>
|
|
59
|
+
</details>
|
|
60
|
+
<% end %>
|
|
61
|
+
</section>
|
|
62
|
+
<% end %>
|
|
63
|
+
</div>
|
|
64
|
+
</div>
|
|
65
|
+
<% elsif @errors.blank? %>
|
|
66
|
+
<%
|
|
67
|
+
conflicts = conflicts_for(@shifts)
|
|
68
|
+
weeks = @shifts.group_by do |shift|
|
|
69
|
+
[shift.starts_at.in_time_zone(@time_zone).to_date, @from].max.beginning_of_week(Munawaba.config.week_starts_on)
|
|
70
|
+
end
|
|
71
|
+
%>
|
|
72
|
+
<% if weeks.empty? %>
|
|
73
|
+
<%= render "munawaba/shared/shifts", shifts: [], zone: @time_zone %>
|
|
74
|
+
<% end %>
|
|
75
|
+
<% weeks.each do |week, shifts| %>
|
|
76
|
+
<div class="mn-section-heading">
|
|
77
|
+
<h2>Week of <%= week.strftime("%b %-d, %Y") %></h2>
|
|
78
|
+
</div>
|
|
79
|
+
<%= render "munawaba/shared/shifts", shifts: shifts, zone: @time_zone, conflicts: conflicts %>
|
|
80
|
+
<% end %>
|
|
81
|
+
<% end %>
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
<%= page_title "Slack deliveries", "Times: #{Munawaba.config.organization_time_zone}" %>
|
|
2
|
+
<div class="mn-notice">An unknown outcome means Slack may have received the message. Retrying can send it twice.</div>
|
|
3
|
+
|
|
4
|
+
<%= form_with url: notification_deliveries_path, method: :get, local: true, class: "mn-filter-bar", id: "mn-form" do %>
|
|
5
|
+
<div>
|
|
6
|
+
<%= label_tag :schedule_id, "Schedule" %>
|
|
7
|
+
<%= select_tag :schedule_id, options_from_collection_for_select(@schedules, :id, :name, params[:schedule_id]), include_blank: "All schedules" %>
|
|
8
|
+
</div>
|
|
9
|
+
<div>
|
|
10
|
+
<%= label_tag :status, "Status" %>
|
|
11
|
+
<%= select_tag :status, options_for_select(%w[pending enqueued processing delivered failed canceled stale].map { |status| [status.humanize, status] }, params[:status]), include_blank: "All statuses" %>
|
|
12
|
+
</div>
|
|
13
|
+
<%= submit_tag "Filter", class: "mn-button" %>
|
|
14
|
+
<%= link_to "Clear", notification_deliveries_path %>
|
|
15
|
+
<% end %>
|
|
16
|
+
|
|
17
|
+
<% if @deliveries.empty? %>
|
|
18
|
+
<%= render "munawaba/shared/empty", title: "No deliveries found", description: "Try another filter, or check the schedule's Slack settings." %>
|
|
19
|
+
<% else %>
|
|
20
|
+
<div class="mn-table-scroll" role="region" aria-label="Notification deliveries" tabindex="0">
|
|
21
|
+
<table class="mn-table">
|
|
22
|
+
<thead>
|
|
23
|
+
<tr>
|
|
24
|
+
<th scope="col">Created / schedule</th>
|
|
25
|
+
<th scope="col">Kind</th>
|
|
26
|
+
<th scope="col">Status</th>
|
|
27
|
+
<th scope="col">Attempts</th>
|
|
28
|
+
<th scope="col">Outcome / action</th>
|
|
29
|
+
</tr>
|
|
30
|
+
</thead>
|
|
31
|
+
<tbody>
|
|
32
|
+
<% @deliveries.each do |delivery| %>
|
|
33
|
+
<tr>
|
|
34
|
+
<td class="mn-mono">
|
|
35
|
+
<%= local_time(delivery.created_at) %>
|
|
36
|
+
<span class="mn-cell-sub"><%= link_to delivery.schedule.name, edit_schedule_slack_integration_path(delivery.schedule) %></span>
|
|
37
|
+
</td>
|
|
38
|
+
<td><%= delivery.kind.humanize %></td>
|
|
39
|
+
<td><%= state_badge(delivery.status) %></td>
|
|
40
|
+
<td class="mn-mono"><%= delivery.attempt_count %></td>
|
|
41
|
+
<td>
|
|
42
|
+
<% if delivery.last_error_code == "delivery_outcome_unknown" %>
|
|
43
|
+
<p class="mn-conflict">Unknown outcome · may have reached Slack</p>
|
|
44
|
+
<% end %>
|
|
45
|
+
<span class="mn-mono"><%= delivery.last_error_code.presence || (delivery.delivered_at ? "Delivered #{local_time(delivery.delivered_at)}" : "—") %></span>
|
|
46
|
+
<% if delivery.status == "failed" %>
|
|
47
|
+
<%= form_with url: retry_notification_delivery_path(delivery), local: true do %>
|
|
48
|
+
<% if delivery.last_error_code == "delivery_outcome_unknown" %>
|
|
49
|
+
<label class="mn-checkbox"><%= check_box_tag :acknowledge_duplicate, "1", false, required: true %><span>I accept the risk of a duplicate message.</span></label>
|
|
50
|
+
<% end %>
|
|
51
|
+
<%= submit_tag "Retry failed delivery", class: "mn-button" %>
|
|
52
|
+
<% end %>
|
|
53
|
+
<% end %>
|
|
54
|
+
</td>
|
|
55
|
+
</tr>
|
|
56
|
+
<% end %>
|
|
57
|
+
</tbody>
|
|
58
|
+
</table>
|
|
59
|
+
</div>
|
|
60
|
+
<% if @more %>
|
|
61
|
+
<div class="mn-pagination">
|
|
62
|
+
<%= link_to "Older deliveries →", notification_deliveries_path(request.query_parameters.merge(before: cursor_for(@deliveries.last, :created_at))), class: "mn-button" %>
|
|
63
|
+
</div>
|
|
64
|
+
<% end %>
|
|
65
|
+
<% end %>
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
<%= page_title "Override whole shift", "#{@shift.schedule.name} · #{local_time(@shift.starts_at, @shift.schedule.time_zone)} – #{local_time(@shift.ends_at, @shift.schedule.time_zone)} · #{@shift.schedule.time_zone}" %>
|
|
2
|
+
|
|
3
|
+
<%= form_with url: preview_shift_override_path(@shift), local: true, class: "mn-card mn-form", id: "mn-form" do %>
|
|
4
|
+
<%= hidden_field_tag :operation, "apply" %>
|
|
5
|
+
<%= hidden_field_tag :lock_version, @shift.lock_version %>
|
|
6
|
+
<div class="mn-field">
|
|
7
|
+
<%= label_tag :person_id, "Replacement person (required)" %>
|
|
8
|
+
<%= select_tag :person_id, options_from_collection_for_select(@people, :id, :name, params[:person_id]), required: true, include_blank: "Choose an active person" %>
|
|
9
|
+
<p class="mn-hint">Any active person can cover this shift, including someone outside the roster.</p>
|
|
10
|
+
</div>
|
|
11
|
+
<div class="mn-field">
|
|
12
|
+
<%= label_tag :reason, "Reason (optional)" %>
|
|
13
|
+
<%= text_area_tag :reason, params[:reason], maxlength: 1000, rows: 4, placeholder: "Add context for this change" %>
|
|
14
|
+
</div>
|
|
15
|
+
<div class="mn-form-actions">
|
|
16
|
+
<%= submit_tag "Preview override", class: "mn-button mn-button-primary" %>
|
|
17
|
+
<%= link_to "Cancel", shift_path(@shift), class: "mn-button" %>
|
|
18
|
+
</div>
|
|
19
|
+
<% end %>
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
<div class="mn-overview">
|
|
2
|
+
<div class="mn-page-heading">
|
|
3
|
+
<%= page_title "Overview" %>
|
|
4
|
+
<%= link_to "New schedule", new_schedule_path, class: "mn-button mn-button-primary" %>
|
|
5
|
+
</div>
|
|
6
|
+
|
|
7
|
+
<% if @schedules.empty? %>
|
|
8
|
+
<%= render "munawaba/shared/empty", title: "Your first handoff starts here", description: "Register your people, create a schedule, and set the rotation order.", action: link_to("Add a person", new_person_path, class: "mn-button mn-button-primary") %>
|
|
9
|
+
<% else %>
|
|
10
|
+
<div class="mn-stats">
|
|
11
|
+
<div>
|
|
12
|
+
<span>Schedules</span>
|
|
13
|
+
<strong><%= @schedules.size %></strong>
|
|
14
|
+
</div>
|
|
15
|
+
<div>
|
|
16
|
+
<span>Active</span>
|
|
17
|
+
<strong><%= @schedules.count { |schedule| effective_schedule_state(schedule) == "active" } %></strong>
|
|
18
|
+
</div>
|
|
19
|
+
<div>
|
|
20
|
+
<span>Scheduled</span>
|
|
21
|
+
<strong><%= @schedules.count { |schedule| effective_schedule_state(schedule) == "scheduled" } %></strong>
|
|
22
|
+
</div>
|
|
23
|
+
<div>
|
|
24
|
+
<span>Without a roster</span>
|
|
25
|
+
<strong><%= @schedules.count { |schedule| @roster_counts[schedule.id].to_i.zero? } %></strong>
|
|
26
|
+
</div>
|
|
27
|
+
</div>
|
|
28
|
+
<div class="mn-section-heading">
|
|
29
|
+
<h2 id="mn-overview-schedules">All schedules</h2>
|
|
30
|
+
<span class="mn-muted">Times use each schedule’s timezone</span>
|
|
31
|
+
</div>
|
|
32
|
+
<div class="mn-table-scroll" role="region" aria-labelledby="mn-overview-schedules" tabindex="0">
|
|
33
|
+
<table class="mn-table mn-overview-table">
|
|
34
|
+
<thead>
|
|
35
|
+
<tr>
|
|
36
|
+
<th scope="col">Schedule</th>
|
|
37
|
+
<th scope="col">Cadence</th>
|
|
38
|
+
<th scope="col">State</th>
|
|
39
|
+
<th scope="col">On call now</th>
|
|
40
|
+
<th scope="col">Next</th>
|
|
41
|
+
<th scope="col">Next handoff</th>
|
|
42
|
+
<th scope="col">Scheduled through</th>
|
|
43
|
+
</tr>
|
|
44
|
+
</thead>
|
|
45
|
+
<tbody>
|
|
46
|
+
<% @schedules.each do |schedule| %>
|
|
47
|
+
<%
|
|
48
|
+
display_state = effective_schedule_state(schedule)
|
|
49
|
+
shifts = display_state == "paused" ? [] : @shifts.fetch(schedule.id, [])
|
|
50
|
+
current = shifts.find { |shift| shift.starts_at <= @now }
|
|
51
|
+
upcoming = shifts.find { |shift| shift.starts_at > @now }
|
|
52
|
+
%>
|
|
53
|
+
<tr>
|
|
54
|
+
<td class="mn-overview-schedule">
|
|
55
|
+
<%= link_to schedule.name, schedule_path(schedule), class: "mn-primary-text" %>
|
|
56
|
+
<span class="mn-cell-sub mn-mono"><%= schedule.time_zone %></span>
|
|
57
|
+
<% if (warning = lifecycle_maintenance_warning(schedule)) %>
|
|
58
|
+
<span class="mn-conflict mn-maintenance-warning"><%= warning %></span>
|
|
59
|
+
<% end %>
|
|
60
|
+
<% if @roster_counts[schedule.id].to_i.zero? %>
|
|
61
|
+
<span class="mn-conflict">Add people to complete this roster.</span>
|
|
62
|
+
<% end %>
|
|
63
|
+
</td>
|
|
64
|
+
<td><%= cadence_name(schedule) %></td>
|
|
65
|
+
<td><%= state_badge(display_state) %></td>
|
|
66
|
+
<td class="mn-overview-person"><span class="<%= current ? "mn-primary-text" : "mn-muted" %>"><%= current&.effective_person&.name || "No current coverage" %></span></td>
|
|
67
|
+
<td class="mn-overview-person"><%= upcoming&.effective_person&.name || "—" %></td>
|
|
68
|
+
<td class="mn-mono"><%= local_time(current&.ends_at || upcoming&.starts_at, schedule.time_zone) %></td>
|
|
69
|
+
<td class="mn-mono"><%= local_time(display_state == "paused" ? nil : @projected_through[schedule.id], schedule.time_zone) %></td>
|
|
70
|
+
</tr>
|
|
71
|
+
<% end %>
|
|
72
|
+
</tbody>
|
|
73
|
+
</table>
|
|
74
|
+
</div>
|
|
75
|
+
<% end %>
|
|
76
|
+
</div>
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
<%= form_with model: @person, url: @person.persisted? ? person_path(@person) : people_path, local: true, class: "mn-form mn-card", id: "mn-form" do |form| %>
|
|
2
|
+
<% if @person.persisted? %>
|
|
3
|
+
<%= form.hidden_field :lock_version %>
|
|
4
|
+
<% end %>
|
|
5
|
+
<div class="mn-field">
|
|
6
|
+
<%= form.label :name, "Display name (required)" %>
|
|
7
|
+
<%= form.text_field :name, required: true, maxlength: 120, autocomplete: "name", autofocus: @errors.blank? %>
|
|
8
|
+
<%= model_errors(@person, :name) %>
|
|
9
|
+
</div>
|
|
10
|
+
<div class="mn-field">
|
|
11
|
+
<%= form.label :email, "Organization email (required)" %>
|
|
12
|
+
<%= form.email_field :email, required: true, maxlength: 320, autocomplete: "email" %>
|
|
13
|
+
<%= model_errors(@person, :email) %>
|
|
14
|
+
</div>
|
|
15
|
+
<div class="mn-field">
|
|
16
|
+
<%= form.label :slack_member_id, "Slack member ID (optional)" %>
|
|
17
|
+
<%= form.text_field :slack_member_id, maxlength: 32, placeholder: "U012ABC34", spellcheck: false, autocomplete: "off", aria: { describedby: "mn-slack-id-hint" } %>
|
|
18
|
+
<p id="mn-slack-id-hint" class="mn-hint">For Slack mentions. Without an ID, messages use the person’s display name.</p>
|
|
19
|
+
<%= model_errors(@person, :slack_member_id) %>
|
|
20
|
+
</div>
|
|
21
|
+
<div class="mn-form-actions">
|
|
22
|
+
<%= form.submit @person.persisted? ? "Save person" : "Add person", class: "mn-button mn-button-primary" %>
|
|
23
|
+
<%= link_to "Cancel", @person.persisted? ? person_path(@person) : people_path, class: "mn-button" %>
|
|
24
|
+
</div>
|
|
25
|
+
<% end %>
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
<div class="mn-page-heading">
|
|
2
|
+
<%= page_title "People", "Register people once. Include them in any number of schedules." %>
|
|
3
|
+
<%= link_to "Add person", new_person_path, class: "mn-button mn-button-primary" %>
|
|
4
|
+
</div>
|
|
5
|
+
|
|
6
|
+
<%= form_with url: people_path, method: :get, local: true, class: "mn-filter-bar" do %>
|
|
7
|
+
<div>
|
|
8
|
+
<%= label_tag :q, "Search people" %>
|
|
9
|
+
<%= search_field_tag :q, params[:q], placeholder: "Name or email" %>
|
|
10
|
+
</div>
|
|
11
|
+
<div>
|
|
12
|
+
<%= label_tag :state, "Status" %>
|
|
13
|
+
<%= select_tag :state, options_for_select([["All people", ""], ["Active", "active"], ["Inactive", "inactive"]], params[:state]) %>
|
|
14
|
+
</div>
|
|
15
|
+
<%= submit_tag "Filter", class: "mn-button" %>
|
|
16
|
+
<%= link_to "Clear", people_path %>
|
|
17
|
+
<% end %>
|
|
18
|
+
|
|
19
|
+
<% if @people.empty? %>
|
|
20
|
+
<%= render "munawaba/shared/empty", title: "No people found", description: "Add a person to start building your rotations." %>
|
|
21
|
+
<% else %>
|
|
22
|
+
<div class="mn-table-scroll" role="region" aria-label="People" tabindex="0">
|
|
23
|
+
<table class="mn-table">
|
|
24
|
+
<thead>
|
|
25
|
+
<tr>
|
|
26
|
+
<th scope="col">Person</th>
|
|
27
|
+
<th scope="col">Email</th>
|
|
28
|
+
<th scope="col">Schedules</th>
|
|
29
|
+
<th scope="col">Status</th>
|
|
30
|
+
</tr>
|
|
31
|
+
</thead>
|
|
32
|
+
<tbody>
|
|
33
|
+
<% @people.each do |person| %>
|
|
34
|
+
<tr>
|
|
35
|
+
<td><%= link_to person.name, person_path(person), class: "mn-primary-text" %></td>
|
|
36
|
+
<td><%= person.email %></td>
|
|
37
|
+
<td class="mn-mono"><%= @membership_counts[person.id].to_i %></td>
|
|
38
|
+
<td><%= state_badge(person.active? ? "active" : "inactive") %></td>
|
|
39
|
+
</tr>
|
|
40
|
+
<% end %>
|
|
41
|
+
</tbody>
|
|
42
|
+
</table>
|
|
43
|
+
</div>
|
|
44
|
+
<% if @more %>
|
|
45
|
+
<div class="mn-pagination">
|
|
46
|
+
<%= link_to "More people →", people_path(request.query_parameters.merge(after: name_cursor_for(@people.last))), class: "mn-button" %>
|
|
47
|
+
</div>
|
|
48
|
+
<% end %>
|
|
49
|
+
<% end %>
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
<div class="mn-page-heading">
|
|
2
|
+
<%= page_title @person.name, @person.email %>
|
|
3
|
+
<div class="mn-actions">
|
|
4
|
+
<%= state_badge(@person.active? ? "active" : "inactive") %>
|
|
5
|
+
<%= link_to "Edit person", edit_person_path(@person), class: "mn-button" %>
|
|
6
|
+
</div>
|
|
7
|
+
</div>
|
|
8
|
+
|
|
9
|
+
<div class="mn-card mn-compact-card">
|
|
10
|
+
<dl class="mn-detail-list">
|
|
11
|
+
<dt>Slack member ID</dt>
|
|
12
|
+
<dd class="mn-mono"><%= @person.slack_member_id.presence || "Not provided" %></dd>
|
|
13
|
+
<dt>Schedules</dt>
|
|
14
|
+
<dd><%= safe_join(@memberships.map { |membership| link_to membership.schedule.name, schedule_path(membership.schedule) }, ", ").presence || "No memberships" %></dd>
|
|
15
|
+
</dl>
|
|
16
|
+
<div class="mn-actions">
|
|
17
|
+
<%= link_to "View activity", activity_path(person_id: @person.id) %>
|
|
18
|
+
<%= link_to "View calendar", calendar_path(person_id: @person.id) %>
|
|
19
|
+
<% if @person.active? %>
|
|
20
|
+
<%= link_to "Deactivate person", deactivation_person_path(@person), class: "mn-danger-link" %>
|
|
21
|
+
<% else %>
|
|
22
|
+
<%= button_to "Reactivate person", reactivate_person_path(@person), method: :patch, params: { lock_version: @person.lock_version }, class: "mn-button" %>
|
|
23
|
+
<% end %>
|
|
24
|
+
</div>
|
|
25
|
+
<% unless @person.active? %>
|
|
26
|
+
<p class="mn-hint">Reactivation makes this person selectable again. Former memberships are not restored.</p>
|
|
27
|
+
<% end %>
|
|
28
|
+
</div>
|
|
29
|
+
|
|
30
|
+
<div class="mn-section-heading">
|
|
31
|
+
<h2>Current and upcoming assignments</h2>
|
|
32
|
+
<span class="mn-muted"><%= Munawaba.config.organization_time_zone %></span>
|
|
33
|
+
</div>
|
|
34
|
+
<%= render "munawaba/shared/shifts", shifts: @shifts, zone: Munawaba.config.organization_time_zone %>
|