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,39 @@
|
|
|
1
|
+
<div class="mn-page-heading">
|
|
2
|
+
<%= page_title "Edit rotation", @schedule.name %>
|
|
3
|
+
<%= link_to "Back to schedule", schedule_path(@schedule), class: "mn-button" %>
|
|
4
|
+
</div>
|
|
5
|
+
<p class="mn-hint">The first person is Next. Move, remove, and Make next refresh a preview. Changes are saved only after confirmation.</p>
|
|
6
|
+
|
|
7
|
+
<%= form_with url: preview_schedule_rotation_path(@schedule), local: true, class: "mn-card mn-form", id: "mn-form", data: { mn_rotation: true } do %>
|
|
8
|
+
<%= hidden_field_tag :lock_version, params[:lock_version] || @schedule.lock_version %>
|
|
9
|
+
<ol class="mn-roster mn-roster-editor" data-mn-order>
|
|
10
|
+
<% @person_ids.each_with_index do |id, index| %>
|
|
11
|
+
<li draggable="true" data-mn-person-id="<%= id %>">
|
|
12
|
+
<%= hidden_field_tag "person_ids[]", id, id: nil %>
|
|
13
|
+
<span class="mn-position"><%= index + 1 %></span>
|
|
14
|
+
<strong><%= @people_by_id[id]&.name || "Person ##{id}" %></strong>
|
|
15
|
+
<% if index.zero? %>
|
|
16
|
+
<%= state_badge("next") %>
|
|
17
|
+
<% end %>
|
|
18
|
+
<div class="mn-roster-controls">
|
|
19
|
+
<%= button_tag "↑", name: "move", value: "up:#{id}", class: "mn-button mn-icon-button", disabled: index.zero?, aria: { label: "Move #{@people_by_id[id]&.name} up" } %>
|
|
20
|
+
<%= button_tag "↓", name: "move", value: "down:#{id}", class: "mn-button mn-icon-button", disabled: index == @person_ids.length - 1, aria: { label: "Move #{@people_by_id[id]&.name} down" } %>
|
|
21
|
+
<%= button_tag "Make next", name: "move", value: "next:#{id}", class: "mn-button", disabled: index.zero? %>
|
|
22
|
+
<%= button_tag "Remove", name: "move", value: "remove:#{id}", class: "mn-button mn-danger-link", aria: { label: "Remove #{@people_by_id[id]&.name}" } %>
|
|
23
|
+
</div>
|
|
24
|
+
</li>
|
|
25
|
+
<% end %>
|
|
26
|
+
</ol>
|
|
27
|
+
<div class="mn-filter-bar">
|
|
28
|
+
<div>
|
|
29
|
+
<%= label_tag :add_person_id, "Add an active person" %>
|
|
30
|
+
<%= select_tag :add_person_id, options_from_collection_for_select(@available_people, :id, :name), include_blank: "Choose a person" %>
|
|
31
|
+
</div>
|
|
32
|
+
<%= submit_tag "Update preview", class: "mn-button mn-button-primary" %>
|
|
33
|
+
</div>
|
|
34
|
+
<p class="mn-hint" data-mn-order-announcement role="status">You can also drag rows, then update the preview.</p>
|
|
35
|
+
<% end %>
|
|
36
|
+
|
|
37
|
+
<% if @preview %>
|
|
38
|
+
<%= render "munawaba/shared/proposal_details" %>
|
|
39
|
+
<% end %>
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
<%= form_with model: @schedule, url: @schedule.persisted? ? schedule_path(@schedule) : schedules_path, local: true, class: "mn-form mn-card", id: "mn-form" do |form| %>
|
|
2
|
+
<% if @schedule.persisted? %>
|
|
3
|
+
<%= form.hidden_field :lock_version %>
|
|
4
|
+
<% end %>
|
|
5
|
+
<div class="mn-field">
|
|
6
|
+
<%= form.label :name, "Schedule name (required)" %>
|
|
7
|
+
<%= form.text_field :name, required: true, maxlength: 120, placeholder: "Platform on call" %>
|
|
8
|
+
<%= model_errors(@schedule, :name) %>
|
|
9
|
+
</div>
|
|
10
|
+
|
|
11
|
+
<% timing_locked = @schedule.first_activated_at.present? || @schedule.state == "scheduled" %>
|
|
12
|
+
<fieldset <%= "disabled" if timing_locked %>>
|
|
13
|
+
<legend>Handoff rule</legend>
|
|
14
|
+
<% if timing_locked %>
|
|
15
|
+
<p class="mn-hint">Timing can only change on a draft that has never started.</p>
|
|
16
|
+
<% end %>
|
|
17
|
+
<div class="mn-field">
|
|
18
|
+
<%= form.label :cadence, "Cadence (required)" %>
|
|
19
|
+
<%= form.select :cadence, Munawaba::ApplicationHelper::CADENCES.map { |value, label| [label, value] } %>
|
|
20
|
+
</div>
|
|
21
|
+
<div class="mn-field">
|
|
22
|
+
<%= form.label :time_zone, "IANA timezone (required)" %>
|
|
23
|
+
<%= form.text_field :time_zone, required: true, list: "mn-timezones", placeholder: "Europe/London" %>
|
|
24
|
+
<datalist id="mn-timezones">
|
|
25
|
+
<% TZInfo::Timezone.all_identifiers.each do |zone| %>
|
|
26
|
+
<option value="<%= zone %>"></option>
|
|
27
|
+
<% end %>
|
|
28
|
+
</datalist>
|
|
29
|
+
<%= model_errors(@schedule, :time_zone) %>
|
|
30
|
+
</div>
|
|
31
|
+
<div class="mn-field-row">
|
|
32
|
+
<div class="mn-field">
|
|
33
|
+
<%= form.label :anchor_local_date, "First handoff date" %>
|
|
34
|
+
<%= form.date_field :anchor_local_date, required: true %>
|
|
35
|
+
<%= model_errors(@schedule, :anchor_local_date) %>
|
|
36
|
+
</div>
|
|
37
|
+
<div class="mn-field">
|
|
38
|
+
<%= label_tag "schedule_anchor_local_time", "Local handoff time" %>
|
|
39
|
+
<%= time_field_tag "schedule[anchor_local_time]", handoff_time(@schedule), required: true, step: 60 %>
|
|
40
|
+
</div>
|
|
41
|
+
</div>
|
|
42
|
+
<p class="mn-hint">Each boundary follows this local rule, including daylight saving and calendar month changes.</p>
|
|
43
|
+
</fieldset>
|
|
44
|
+
|
|
45
|
+
<div class="mn-form-actions">
|
|
46
|
+
<%= form.submit @schedule.persisted? ? "Save schedule" : "Create schedule", class: "mn-button mn-button-primary" %>
|
|
47
|
+
<%= link_to "Cancel", @schedule.persisted? ? schedule_path(@schedule) : schedules_path, class: "mn-button" %>
|
|
48
|
+
</div>
|
|
49
|
+
<% end %>
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
<%= form_with url: preview_resume_schedule_path(@schedule), local: true, id: "mn-form", class: "mn-form" do %>
|
|
2
|
+
<%= hidden_field_tag :lock_version, @schedule.lock_version %>
|
|
3
|
+
<div class="mn-field">
|
|
4
|
+
<%= label_tag :boundary_index, "Resume at a handoff" %>
|
|
5
|
+
<%= select_tag :boundary_index, options_for_select(future_boundaries(@schedule), params[:boundary_index]), required: true %>
|
|
6
|
+
</div>
|
|
7
|
+
<fieldset>
|
|
8
|
+
<legend>Starting order · first person is Next</legend>
|
|
9
|
+
<p class="mn-hint">Choose the complete order for this new coverage run. Leave positions unused to remove people.</p>
|
|
10
|
+
<% @resume_ids.each_with_index do |id, index| %>
|
|
11
|
+
<div class="mn-field">
|
|
12
|
+
<%= label_tag "resume_person_#{index}", "Position #{index + 1}#{' · Next' if index.zero?}" %>
|
|
13
|
+
<%= select_tag "person_ids[]", options_from_collection_for_select(@resume_people, :id, :name, id), id: "resume_person_#{index}", include_blank: "Leave unused", required: index.zero? %>
|
|
14
|
+
</div>
|
|
15
|
+
<% end %>
|
|
16
|
+
<% if @resume_ids.length < [@resume_people.length, Munawaba::Defaults::MAXIMUM_SCHEDULE_MEMBERS].min %>
|
|
17
|
+
<%= button_tag "Add another position", name: "edit_order", value: "add", class: "mn-button", formnovalidate: true %>
|
|
18
|
+
<% end %>
|
|
19
|
+
</fieldset>
|
|
20
|
+
<%= submit_tag "Preview resume", class: "mn-button mn-button-primary", disabled: @resume_people.empty? %>
|
|
21
|
+
<% if @resume_people.empty? %>
|
|
22
|
+
<p class="mn-conflict">Register an active person before resuming.</p>
|
|
23
|
+
<% end %>
|
|
24
|
+
<% end %>
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
<div class="mn-page-heading">
|
|
2
|
+
<%= page_title "Schedules", "Independent rotations with their own people and handoff times." %>
|
|
3
|
+
<%= link_to "New schedule", new_schedule_path, class: "mn-button mn-button-primary" %>
|
|
4
|
+
</div>
|
|
5
|
+
|
|
6
|
+
<% if @schedules.empty? %>
|
|
7
|
+
<%= render "munawaba/shared/empty", title: "No schedules yet", description: "Create a schedule, add a roster, then preview its first coverage run." %>
|
|
8
|
+
<% else %>
|
|
9
|
+
<div class="mn-table-scroll" role="region" aria-label="Schedules" tabindex="0">
|
|
10
|
+
<table class="mn-table">
|
|
11
|
+
<thead>
|
|
12
|
+
<tr>
|
|
13
|
+
<th scope="col">Schedule</th>
|
|
14
|
+
<th scope="col">Cadence</th>
|
|
15
|
+
<th scope="col">Handoff</th>
|
|
16
|
+
<th scope="col">People</th>
|
|
17
|
+
<th scope="col">State</th>
|
|
18
|
+
</tr>
|
|
19
|
+
</thead>
|
|
20
|
+
<tbody>
|
|
21
|
+
<% @schedules.each do |schedule| %>
|
|
22
|
+
<tr>
|
|
23
|
+
<td><%= link_to schedule.name, schedule_path(schedule), class: "mn-primary-text" %></td>
|
|
24
|
+
<td><%= cadence_name(schedule) %></td>
|
|
25
|
+
<td class="mn-mono">
|
|
26
|
+
<%= handoff_time(schedule) %>
|
|
27
|
+
<span class="mn-cell-sub"><%= schedule.time_zone %></span>
|
|
28
|
+
</td>
|
|
29
|
+
<td class="mn-mono"><%= @membership_counts[schedule.id].to_i %></td>
|
|
30
|
+
<td>
|
|
31
|
+
<%= state_badge(effective_schedule_state(schedule)) %>
|
|
32
|
+
<% if (warning = lifecycle_maintenance_warning(schedule)) %>
|
|
33
|
+
<span class="mn-cell-sub mn-maintenance-warning"><%= warning %></span>
|
|
34
|
+
<% end %>
|
|
35
|
+
</td>
|
|
36
|
+
</tr>
|
|
37
|
+
<% end %>
|
|
38
|
+
</tbody>
|
|
39
|
+
</table>
|
|
40
|
+
</div>
|
|
41
|
+
<% if @more %>
|
|
42
|
+
<div class="mn-pagination">
|
|
43
|
+
<%= link_to "More schedules →", schedules_path(after: name_cursor_for(@schedules.last)), class: "mn-button" %>
|
|
44
|
+
</div>
|
|
45
|
+
<% end %>
|
|
46
|
+
<% end %>
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
<% display_state = effective_schedule_state(@schedule) %>
|
|
2
|
+
<div class="mn-page-heading">
|
|
3
|
+
<%= page_title @schedule.name, "#{cadence_name(@schedule)} · #{handoff_time(@schedule)} · #{@schedule.time_zone}" %>
|
|
4
|
+
<div class="mn-actions">
|
|
5
|
+
<%= state_badge(display_state) %>
|
|
6
|
+
<%= link_to "Edit schedule", edit_schedule_path(@schedule), class: "mn-button" %>
|
|
7
|
+
</div>
|
|
8
|
+
</div>
|
|
9
|
+
|
|
10
|
+
<% if (warning = lifecycle_maintenance_warning(@schedule)) %>
|
|
11
|
+
<p class="mn-notice mn-maintenance-warning"><%= warning %></p>
|
|
12
|
+
<% end %>
|
|
13
|
+
|
|
14
|
+
<div class="mn-tabs">
|
|
15
|
+
<a href="#mn-coverage" aria-current="page">Coverage</a>
|
|
16
|
+
<%= link_to "Calendar", calendar_path(schedule_id: @schedule.id) %>
|
|
17
|
+
<%= link_to "Activity", activity_path(schedule_id: @schedule.id) %>
|
|
18
|
+
<%= link_to "Slack settings", edit_schedule_slack_integration_path(@schedule) %>
|
|
19
|
+
</div>
|
|
20
|
+
|
|
21
|
+
<div class="mn-two-columns">
|
|
22
|
+
<section class="mn-card">
|
|
23
|
+
<div class="mn-card-heading">
|
|
24
|
+
<h2>Current coverage</h2>
|
|
25
|
+
<span class="mn-eyebrow"><%= @schedule.time_zone %></span>
|
|
26
|
+
</div>
|
|
27
|
+
<div class="mn-current-person">
|
|
28
|
+
<strong><%= @current_shift&.effective_person&.name || "No current coverage" %></strong>
|
|
29
|
+
<span class="mn-muted"><%= @current_shift ? "Until #{local_time(@current_shift.ends_at, @schedule.time_zone)}" : "#{display_state.humanize} schedule" %></span>
|
|
30
|
+
</div>
|
|
31
|
+
<% case display_state %>
|
|
32
|
+
<% when "draft" %>
|
|
33
|
+
<%= form_with url: preview_activation_schedule_path(@schedule), local: true, id: "mn-form", class: "mn-form" do %>
|
|
34
|
+
<%= hidden_field_tag :lock_version, @schedule.lock_version %>
|
|
35
|
+
<p class="mn-hint">If the first handoff is in the future, coverage starts then. Otherwise, coverage starts when you confirm. Edit the schedule to choose a later start.</p>
|
|
36
|
+
<%= submit_tag "Preview activation", class: "mn-button mn-button-primary", disabled: @memberships.empty? %>
|
|
37
|
+
<% end %>
|
|
38
|
+
<% if @memberships.empty? %>
|
|
39
|
+
<p class="mn-conflict">Add at least one active person to the rotation before activation.</p>
|
|
40
|
+
<% end %>
|
|
41
|
+
<% when "active" %>
|
|
42
|
+
<%= form_with url: pause_schedule_path(@schedule), local: true do %>
|
|
43
|
+
<%= hidden_field_tag :lock_version, @schedule.lock_version %>
|
|
44
|
+
<%= hidden_field_tag :lifecycle_revision, @schedule.lifecycle_revision %>
|
|
45
|
+
<p class="mn-hint">If you pause, the current shift will finish and upcoming shifts will be canceled.</p>
|
|
46
|
+
<%= submit_tag "Pause after current shift", class: "mn-button mn-button-danger" %>
|
|
47
|
+
<% end %>
|
|
48
|
+
<% when "scheduled" %>
|
|
49
|
+
<p>Coverage begins <strong><%= local_time(@schedule.coverage_starts_at, @schedule.time_zone) %></strong>.</p>
|
|
50
|
+
<%= button_to "Cancel scheduled start", cancel_scheduled_schedule_path(@schedule), params: { lock_version: @schedule.lock_version, lifecycle_revision: @schedule.lifecycle_revision }, class: "mn-button mn-button-danger" %>
|
|
51
|
+
<% when "pausing" %>
|
|
52
|
+
<p>Pausing at <strong><%= local_time(@schedule.pause_effective_at, @schedule.time_zone) %></strong>. No next shift will start.</p>
|
|
53
|
+
<% when "paused" %>
|
|
54
|
+
<% if @schedule.state == "paused" %>
|
|
55
|
+
<%= render "resume_form" %>
|
|
56
|
+
<% end %>
|
|
57
|
+
<% end %>
|
|
58
|
+
</section>
|
|
59
|
+
|
|
60
|
+
<section class="mn-card">
|
|
61
|
+
<div class="mn-card-heading">
|
|
62
|
+
<h2>Rotation order</h2>
|
|
63
|
+
<%= link_to "Edit rotation", edit_schedule_rotation_path(@schedule) %>
|
|
64
|
+
</div>
|
|
65
|
+
<% if @memberships.empty? %>
|
|
66
|
+
<p class="mn-muted">No people in this rotation.</p>
|
|
67
|
+
<% else %>
|
|
68
|
+
<ol class="mn-roster">
|
|
69
|
+
<% @memberships.each_with_index do |membership, index| %>
|
|
70
|
+
<li>
|
|
71
|
+
<span class="mn-position"><%= index + 1 %></span>
|
|
72
|
+
<%= link_to membership.person.name, person_path(membership.person) %>
|
|
73
|
+
<%= state_badge("next") if index.zero? && !%w[paused pausing].include?(display_state) %>
|
|
74
|
+
</li>
|
|
75
|
+
<% end %>
|
|
76
|
+
</ol>
|
|
77
|
+
<% end %>
|
|
78
|
+
<p class="mn-hint">Next is the person due to take over, unless an override assigns someone else. Reordering an active schedule takes effect after the current shift.</p>
|
|
79
|
+
</section>
|
|
80
|
+
</div>
|
|
81
|
+
|
|
82
|
+
<div class="mn-section-heading" id="mn-coverage">
|
|
83
|
+
<h2>Upcoming shifts</h2>
|
|
84
|
+
<span class="mn-muted"><%= @schedule.time_zone %> · Scheduled through <%= local_time(@shifts.last&.ends_at, @schedule.time_zone) %></span>
|
|
85
|
+
</div>
|
|
86
|
+
<%= render "munawaba/shared/shifts", shifts: @shifts, zone: @schedule.time_zone %>
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
<% messages = Array(@errors).presence || (record.respond_to?(:errors) ? record.errors.full_messages : []) %>
|
|
2
|
+
<% if messages.present? || @stale %>
|
|
3
|
+
<div class="mn-error-summary" role="alert" tabindex="-1" data-mn-errors>
|
|
4
|
+
<h2><%= @stale ? "This preview has changed" : "Check the following" %></h2>
|
|
5
|
+
<% if @stale %>
|
|
6
|
+
<p>Projected assignments or handoff times changed. Review the refreshed preview and confirm again.</p>
|
|
7
|
+
<% end %>
|
|
8
|
+
<ul>
|
|
9
|
+
<% if record.respond_to?(:errors) && record.errors.any? %>
|
|
10
|
+
<% record.errors.each do |error| %>
|
|
11
|
+
<li><a href="#<%= error.attribute == :base ? 'mn-form' : "#{record.model_name.param_key}_#{error.attribute}" %>"><%= error.full_message %></a></li>
|
|
12
|
+
<% end %>
|
|
13
|
+
<% (messages - record.errors.full_messages).each do |message| %>
|
|
14
|
+
<li><a href="#mn-form"><%= message %></a></li>
|
|
15
|
+
<% end %>
|
|
16
|
+
<% else %>
|
|
17
|
+
<% messages.each do |message| %>
|
|
18
|
+
<li><a href="#mn-form"><%= message %></a></li>
|
|
19
|
+
<% end %>
|
|
20
|
+
<% end %>
|
|
21
|
+
</ul>
|
|
22
|
+
</div>
|
|
23
|
+
<% end %>
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
<nav aria-label="Main navigation">
|
|
2
|
+
<p class="mn-nav-label">WORKSPACE</p>
|
|
3
|
+
<%= nav_link "Overview", root_path, "overviews" %>
|
|
4
|
+
<%= nav_link "Schedules", schedules_path, "schedules" %>
|
|
5
|
+
<%= nav_link "People", people_path, "people" %>
|
|
6
|
+
<%= nav_link "Calendar", calendar_path, "calendars" %>
|
|
7
|
+
<%= nav_link "Activity", activity_path, "audit_events" %>
|
|
8
|
+
</nav>
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
<div class="mn-table-scroll" role="region" aria-label="Assignment preview" tabindex="0">
|
|
2
|
+
<table class="mn-table">
|
|
3
|
+
<thead>
|
|
4
|
+
<tr>
|
|
5
|
+
<th scope="col">Person</th>
|
|
6
|
+
<th scope="col">Starts</th>
|
|
7
|
+
<th scope="col">Ends</th>
|
|
8
|
+
</tr>
|
|
9
|
+
</thead>
|
|
10
|
+
<tbody>
|
|
11
|
+
<% slots.first(6).each do |slot| %>
|
|
12
|
+
<tr>
|
|
13
|
+
<td><%= person_label(slot[:effective_person_id] || slot[:base_person_id]) %></td>
|
|
14
|
+
<td class="mn-mono"><%= local_time(slot[:starts_at], zone) %></td>
|
|
15
|
+
<td class="mn-mono"><%= local_time(slot[:ends_at], zone) %></td>
|
|
16
|
+
</tr>
|
|
17
|
+
<% end %>
|
|
18
|
+
</tbody>
|
|
19
|
+
</table>
|
|
20
|
+
</div>
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
<%
|
|
2
|
+
slots = projection_slots
|
|
3
|
+
zone = if @subject.is_a?(Munawaba::Person)
|
|
4
|
+
Munawaba.config.organization_time_zone
|
|
5
|
+
elsif @subject.is_a?(Munawaba::Shift)
|
|
6
|
+
@subject.schedule.time_zone
|
|
7
|
+
else
|
|
8
|
+
@subject.time_zone
|
|
9
|
+
end
|
|
10
|
+
conflicts = Array(preview_value(:conflicts, []))
|
|
11
|
+
blockers = Array(detail_value(:blockers))
|
|
12
|
+
%>
|
|
13
|
+
|
|
14
|
+
<% if @operation.to_sym == :deactivate %>
|
|
15
|
+
<div class="mn-notice">Deactivation removes this person from their rotations. If you reactivate them, add them back to each rotation.</div>
|
|
16
|
+
<% Array(detail_value(:schedule_plans)).each do |plan| %>
|
|
17
|
+
<section class="mn-card">
|
|
18
|
+
<h2><%= plan[:name] %></h2>
|
|
19
|
+
<dl class="mn-detail-list">
|
|
20
|
+
<dt>Current order</dt>
|
|
21
|
+
<dd><%= Array(plan[:before_person_ids]).map { |id| person_label(id) }.join(" → ") %></dd>
|
|
22
|
+
<dt>Proposed order</dt>
|
|
23
|
+
<dd><%= Array(plan[:person_ids]).map { |id| person_label(id) }.join(" → ").presence || "Empty roster" %></dd>
|
|
24
|
+
<dt>Next</dt>
|
|
25
|
+
<dd><%= person_label(plan[:successor_person_id]) if plan[:successor_person_id] %></dd>
|
|
26
|
+
<dt>Effective</dt>
|
|
27
|
+
<dd><%= local_time(plan[:effective_at], zone) %></dd>
|
|
28
|
+
</dl>
|
|
29
|
+
<% Array(plan[:blockers]).each do |blocker| %>
|
|
30
|
+
<p class="mn-conflict"><%= blocker %></p>
|
|
31
|
+
<% end %>
|
|
32
|
+
<% planned_slots = Array(plan[:projection]) %>
|
|
33
|
+
<% if planned_slots.present? %>
|
|
34
|
+
<%= render "munawaba/shared/projection", slots: planned_slots, zone: zone %>
|
|
35
|
+
<% end %>
|
|
36
|
+
</section>
|
|
37
|
+
<% end %>
|
|
38
|
+
<% end %>
|
|
39
|
+
|
|
40
|
+
<% if %i[activate resume].include?(@operation.to_sym) %>
|
|
41
|
+
<div class="mn-card">
|
|
42
|
+
<dl class="mn-detail-list">
|
|
43
|
+
<dt>Initial coverage</dt>
|
|
44
|
+
<dd><strong><%= slots.length %> shifts</strong></dd>
|
|
45
|
+
<dt>Scheduled through</dt>
|
|
46
|
+
<dd class="mn-mono"><%= local_time(slots.last[:ends_at], zone) if slots.last %> · <%= zone %></dd>
|
|
47
|
+
</dl>
|
|
48
|
+
<% if @operation.to_sym == :activate && detail_value(:state).to_s == "active" %>
|
|
49
|
+
<p class="mn-notice">Coverage begins when you confirm.</p>
|
|
50
|
+
<% end %>
|
|
51
|
+
</div>
|
|
52
|
+
<% end %>
|
|
53
|
+
|
|
54
|
+
<% if slots.present? %>
|
|
55
|
+
<section class="mn-card">
|
|
56
|
+
<div class="mn-section-heading">
|
|
57
|
+
<h2>Next <%= [slots.length, 6].min %> assignments</h2>
|
|
58
|
+
<span class="mn-muted"><%= zone %></span>
|
|
59
|
+
</div>
|
|
60
|
+
<%= render "munawaba/shared/projection", slots: slots, zone: zone %>
|
|
61
|
+
</section>
|
|
62
|
+
<% end %>
|
|
63
|
+
|
|
64
|
+
<% Array(detail_value(:resolved_boundaries)).each do |boundary| %>
|
|
65
|
+
<% resolution = boundary.resolution %>
|
|
66
|
+
<% if resolution.present? && !%w[exact normal unambiguous].include?(resolution.to_s) %>
|
|
67
|
+
<div class="mn-notice">
|
|
68
|
+
<strong>Daylight saving adjustment</strong>
|
|
69
|
+
<p class="mn-dst-cue"><%= boundary_adjustment_text(boundary, edge: "Handoff", zone: zone) %></p>
|
|
70
|
+
</div>
|
|
71
|
+
<% end %>
|
|
72
|
+
<% end %>
|
|
73
|
+
|
|
74
|
+
<% if conflicts.present? %>
|
|
75
|
+
<section class="mn-warning-panel">
|
|
76
|
+
<h2><%= pluralize(conflicts.size, "conflict") %> across schedules</h2>
|
|
77
|
+
<p>The same person is assigned to overlapping coverage. Review every conflict before confirming.</p>
|
|
78
|
+
<ol class="mn-conflict-list">
|
|
79
|
+
<% conflicts.each do |conflict| %>
|
|
80
|
+
<li><%= readable_conflict(conflict, zone) %></li>
|
|
81
|
+
<% end %>
|
|
82
|
+
</ol>
|
|
83
|
+
</section>
|
|
84
|
+
<% end %>
|
|
85
|
+
|
|
86
|
+
<% if blockers.present? %>
|
|
87
|
+
<div class="mn-warning-panel">
|
|
88
|
+
<h2>This change cannot be confirmed yet</h2>
|
|
89
|
+
<ul>
|
|
90
|
+
<% blockers.each do |blocker| %>
|
|
91
|
+
<li><%= blocker %></li>
|
|
92
|
+
<% end %>
|
|
93
|
+
</ul>
|
|
94
|
+
<p>Resolve these issues and refresh the preview.</p>
|
|
95
|
+
</div>
|
|
96
|
+
<% end %>
|
|
97
|
+
|
|
98
|
+
<% if preview_value(:token).present? && blockers.empty? %>
|
|
99
|
+
<% target, method = confirmation_target %>
|
|
100
|
+
<%= form_with url: target, method: method, local: true, class: "mn-confirmation", id: (@operation.to_sym == :rotation ? "mn-confirmation-form" : "mn-form") do %>
|
|
101
|
+
<%= hidden_field_tag :proposal_token, preview_value(:token) %>
|
|
102
|
+
<%= submitted_hidden_fields(@attributes || {}) %>
|
|
103
|
+
<% if conflicts.present? %>
|
|
104
|
+
<label class="mn-checkbox"><%= check_box_tag :acknowledge_conflicts, "1", params[:acknowledge_conflicts] == "1", required: true %><span>I reviewed all <%= conflicts.size %> conflicts and accept the overlapping assignments.</span></label>
|
|
105
|
+
<% end %>
|
|
106
|
+
<%= submit_tag "Confirm #{(@operation.to_sym == :override ? 'override' : @operation.to_s.humanize.downcase)}", class: "mn-button #{@operation.to_sym == :deactivate ? 'mn-button-danger' : 'mn-button-primary'}" %>
|
|
107
|
+
<% end %>
|
|
108
|
+
<% end %>
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
<%
|
|
2
|
+
rows = shifts.to_a
|
|
3
|
+
conflicts = local_assigns[:conflicts] || conflicts_for(rows)
|
|
4
|
+
%>
|
|
5
|
+
<% if rows.empty? %>
|
|
6
|
+
<%= render "munawaba/shared/empty", title: "No shifts in this window", description: "Check the date range and the schedule's status." %>
|
|
7
|
+
<% else %>
|
|
8
|
+
<div class="mn-table-scroll" role="region" aria-label="Shifts" tabindex="0">
|
|
9
|
+
<table class="mn-table">
|
|
10
|
+
<thead>
|
|
11
|
+
<tr>
|
|
12
|
+
<th scope="col">Schedule / person</th>
|
|
13
|
+
<th scope="col">Starts</th>
|
|
14
|
+
<th scope="col">Ends</th>
|
|
15
|
+
<th scope="col">Coverage</th>
|
|
16
|
+
<th scope="col"><span class="mn-sr-only">Details</span></th>
|
|
17
|
+
</tr>
|
|
18
|
+
</thead>
|
|
19
|
+
<tbody>
|
|
20
|
+
<% rows.each do |shift| %>
|
|
21
|
+
<tr>
|
|
22
|
+
<td>
|
|
23
|
+
<%= link_to shift.schedule.name, schedule_path(shift.schedule), class: "mn-primary-text" %>
|
|
24
|
+
<span class="mn-cell-sub"><%= shift.effective_person.name %></span>
|
|
25
|
+
</td>
|
|
26
|
+
<td class="mn-mono"><%= local_time(shift.starts_at, zone) %></td>
|
|
27
|
+
<td class="mn-mono"><%= local_time(shift.ends_at, zone) %></td>
|
|
28
|
+
<td>
|
|
29
|
+
<% if shift.starts_at <= Time.current && shift.ends_at > Time.current %>
|
|
30
|
+
<%= state_badge("current") %>
|
|
31
|
+
<% else %>
|
|
32
|
+
<%= state_badge("planned") %>
|
|
33
|
+
<% end %>
|
|
34
|
+
<% if shift.effective_person_id != shift.base_person_id %>
|
|
35
|
+
<span class="mn-cell-sub">Override</span>
|
|
36
|
+
<% end %>
|
|
37
|
+
<% shift_dst_adjustments(shift).each do |adjustment| %>
|
|
38
|
+
<span class="mn-cell-sub mn-dst-cue"><%= adjustment %></span>
|
|
39
|
+
<% end %>
|
|
40
|
+
<% if conflicts[shift.id].present? %>
|
|
41
|
+
<span class="mn-conflict">Conflict: <%= conflicts[shift.id].map { |conflict| conflict["name"] }.uniq.join(", ") %></span>
|
|
42
|
+
<% end %>
|
|
43
|
+
</td>
|
|
44
|
+
<td><%= link_to "View shift →", shift_path(shift) %></td>
|
|
45
|
+
</tr>
|
|
46
|
+
<% end %>
|
|
47
|
+
</tbody>
|
|
48
|
+
</table>
|
|
49
|
+
</div>
|
|
50
|
+
<% end %>
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
<div class="mn-page-heading">
|
|
2
|
+
<%= page_title @shift.schedule.name, "Shift ##{@shift.id} · #{@shift.schedule.time_zone}" %>
|
|
3
|
+
<%= link_to "Back to schedule", schedule_path(@shift.schedule), class: "mn-button" %>
|
|
4
|
+
</div>
|
|
5
|
+
|
|
6
|
+
<section class="mn-card mn-compact-card">
|
|
7
|
+
<div class="mn-current-person">
|
|
8
|
+
<span class="mn-eyebrow">EFFECTIVE ASSIGNEE</span>
|
|
9
|
+
<strong><%= @shift.effective_person.name %></strong>
|
|
10
|
+
</div>
|
|
11
|
+
<dl class="mn-detail-list">
|
|
12
|
+
<dt>Base assignee</dt>
|
|
13
|
+
<dd><%= @shift.base_person.name %></dd>
|
|
14
|
+
<dt>Starts</dt>
|
|
15
|
+
<dd class="mn-mono"><%= local_time(@shift.starts_at, @shift.schedule.time_zone) %></dd>
|
|
16
|
+
<dt>Ends</dt>
|
|
17
|
+
<dd class="mn-mono"><%= local_time(@shift.ends_at, @shift.schedule.time_zone) %></dd>
|
|
18
|
+
<dt>Coverage run</dt>
|
|
19
|
+
<dd class="mn-mono"><%= @shift.coverage_revision %></dd>
|
|
20
|
+
</dl>
|
|
21
|
+
<% if @shift.schedule.time_zone != Munawaba.config.organization_time_zone %>
|
|
22
|
+
<p class="mn-hint"><%= Munawaba.config.organization_time_zone %>: <%= local_time(@shift.starts_at) %> – <%= local_time(@shift.ends_at) %></p>
|
|
23
|
+
<% end %>
|
|
24
|
+
<% if @shift.canceled_at %>
|
|
25
|
+
<%= state_badge("canceled") %>
|
|
26
|
+
<% elsif @shift.ends_at > Time.current %>
|
|
27
|
+
<%= link_to @shift.effective_person_id == @shift.base_person_id ? "Override shift" : "Replace override", new_shift_override_path(@shift), class: "mn-button mn-button-primary" %>
|
|
28
|
+
<% end %>
|
|
29
|
+
<%= link_to "View activity", activity_path(shift_id: @shift.id) %>
|
|
30
|
+
</section>
|
|
31
|
+
|
|
32
|
+
<% shift_dst_adjustments(@shift).each do |adjustment| %>
|
|
33
|
+
<p class="mn-notice mn-dst-cue"><%= adjustment %></p>
|
|
34
|
+
<% end %>
|
|
35
|
+
|
|
36
|
+
<% active_override = @overrides.find { |override| override.ended_at.nil? } %>
|
|
37
|
+
<% if active_override && @shift.ends_at > Time.current && @shift.canceled_at.nil? %>
|
|
38
|
+
<section class="mn-card mn-compact-card">
|
|
39
|
+
<h2>Current override</h2>
|
|
40
|
+
<p><%= active_override.reason.presence || "No reason provided." %></p>
|
|
41
|
+
<% if @shift.base_person.active? %>
|
|
42
|
+
<%= form_with url: preview_shift_override_path(@shift), local: true do %>
|
|
43
|
+
<%= hidden_field_tag :operation, @shift.starts_at > Time.current ? "revoke" : "restore_to_base" %>
|
|
44
|
+
<%= submit_tag @shift.starts_at > Time.current ? "Preview revoke override" : "Preview restore to base", class: "mn-button" %>
|
|
45
|
+
<% end %>
|
|
46
|
+
<% else %>
|
|
47
|
+
<p class="mn-conflict">The base person is inactive. Select an active replacement.</p>
|
|
48
|
+
<% end %>
|
|
49
|
+
</section>
|
|
50
|
+
<% end %>
|
|
51
|
+
|
|
52
|
+
<% if @overrides.present? %>
|
|
53
|
+
<section class="mn-card">
|
|
54
|
+
<h2>Override history</h2>
|
|
55
|
+
<ul class="mn-history">
|
|
56
|
+
<% @overrides.each do |override| %>
|
|
57
|
+
<li>
|
|
58
|
+
<strong><%= override.end_reason&.humanize || "Active override" %></strong>
|
|
59
|
+
<span class="mn-mono"><%= local_time(override.created_at, @shift.schedule.time_zone) %></span>
|
|
60
|
+
<p><%= override.reason.presence || "No reason provided." %></p>
|
|
61
|
+
</li>
|
|
62
|
+
<% end %>
|
|
63
|
+
</ul>
|
|
64
|
+
</section>
|
|
65
|
+
<% end %>
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
<div class="mn-page-heading">
|
|
2
|
+
<%= page_title "Slack settings", @schedule.name %>
|
|
3
|
+
<%= link_to "Back to schedule", schedule_path(@schedule), class: "mn-button" %>
|
|
4
|
+
</div>
|
|
5
|
+
|
|
6
|
+
<div class="mn-two-columns">
|
|
7
|
+
<section>
|
|
8
|
+
<%= form_with url: schedule_slack_integration_path(@schedule), method: :patch, scope: :integration, local: true, class: "mn-card mn-form", id: "mn-form" do |form| %>
|
|
9
|
+
<%= form.hidden_field :lock_version, value: @schedule.lock_version %>
|
|
10
|
+
<h2>Incoming webhook</h2>
|
|
11
|
+
<p><%= @schedule.slack_webhook_configured_at ? "Configured #{local_time(@schedule.slack_webhook_configured_at)}" : "Not configured" %></p>
|
|
12
|
+
<div class="mn-field">
|
|
13
|
+
<%= form.label :slack_webhook_url, @schedule.slack_webhook_configured_at ? "Replace webhook URL" : "Webhook URL" %>
|
|
14
|
+
<%= form.password_field :slack_webhook_url, value: "", autocomplete: "new-password", placeholder: "https://hooks.slack.com/services/…", aria: { describedby: "mn-webhook-hint" } %>
|
|
15
|
+
<p class="mn-hint" id="mn-webhook-hint">Leave blank to keep the current webhook. All people and webhooks must belong to one Slack workspace.</p>
|
|
16
|
+
</div>
|
|
17
|
+
<label class="mn-checkbox"><%= form.check_box :slack_enabled, checked: @schedule.slack_enabled %><span>Enable Slack notifications</span></label>
|
|
18
|
+
<fieldset>
|
|
19
|
+
<legend>Message kinds</legend>
|
|
20
|
+
<% { notify_advance: "Advance reminder", notify_shift_start: "Shift starts", notify_assignment_change: "Override assignment changes", notify_next_assignment_change: "Next assignment changes" }.each do |field, label| %>
|
|
21
|
+
<label class="mn-checkbox"><%= form.check_box field, checked: @schedule.public_send(field) %><span><%= label %></span></label>
|
|
22
|
+
<% end %>
|
|
23
|
+
<div class="mn-field">
|
|
24
|
+
<%= form.label :advance_notice_seconds, "Advance reminder lead (seconds)" %>
|
|
25
|
+
<%= form.number_field :advance_notice_seconds, value: @schedule.advance_notice_seconds, min: 60, max: 2592000, required: true %>
|
|
26
|
+
<p class="mn-hint">86,400 seconds = 24 hours. Between 1 minute and 30 days.</p>
|
|
27
|
+
</div>
|
|
28
|
+
</fieldset>
|
|
29
|
+
<%= form.submit "Save Slack settings", class: "mn-button mn-button-primary" %>
|
|
30
|
+
<% end %>
|
|
31
|
+
</section>
|
|
32
|
+
|
|
33
|
+
<section class="mn-card mn-compact-card">
|
|
34
|
+
<h2>Delivery operations</h2>
|
|
35
|
+
<p class="mn-muted"><%= @schedule.slack_enabled ? "This integration is enabled." : "This integration is disabled." %></p>
|
|
36
|
+
<%= link_to "View delivery history →", notification_deliveries_path(schedule_id: @schedule.id) %>
|
|
37
|
+
<% if @schedule.slack_webhook_configured_at %>
|
|
38
|
+
<div class="mn-form-actions">
|
|
39
|
+
<%= button_to "Send test message", test_schedule_slack_integration_path(@schedule), params: { lock_version: @schedule.lock_version }, class: "mn-button", disabled: !@schedule.slack_enabled %>
|
|
40
|
+
</div>
|
|
41
|
+
<details>
|
|
42
|
+
<summary>Remove webhook</summary>
|
|
43
|
+
<p>This also turns off Slack notifications for this schedule.</p>
|
|
44
|
+
<%= button_to "Remove webhook", remove_schedule_slack_integration_path(@schedule), method: :delete, params: { lock_version: @schedule.lock_version }, class: "mn-button mn-button-danger" %>
|
|
45
|
+
</details>
|
|
46
|
+
<% end %>
|
|
47
|
+
</section>
|
|
48
|
+
</div>
|