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.
Files changed (109) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +12 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +160 -0
  5. data/SECURITY.md +15 -0
  6. data/app/assets/munawaba/INTER-LICENSE.txt +92 -0
  7. data/app/assets/munawaba/JETBRAINS-MONO-LICENSE.txt +93 -0
  8. data/app/assets/munawaba/dashboard.css +272 -0
  9. data/app/assets/munawaba/dashboard.js +60 -0
  10. data/app/assets/munawaba/inter.woff2 +0 -0
  11. data/app/assets/munawaba/jetbrains-mono.woff2 +0 -0
  12. data/app/assets/munawaba/logo.png +0 -0
  13. data/app/controllers/munawaba/application_controller.rb +149 -0
  14. data/app/controllers/munawaba/audit_events_controller.rb +31 -0
  15. data/app/controllers/munawaba/calendars_controller.rb +47 -0
  16. data/app/controllers/munawaba/frontends_controller.rb +28 -0
  17. data/app/controllers/munawaba/notification_deliveries_controller.rb +41 -0
  18. data/app/controllers/munawaba/overrides_controller.rb +52 -0
  19. data/app/controllers/munawaba/overviews_controller.rb +22 -0
  20. data/app/controllers/munawaba/people_controller.rb +73 -0
  21. data/app/controllers/munawaba/rotations_controller.rb +61 -0
  22. data/app/controllers/munawaba/schedules_controller.rb +115 -0
  23. data/app/controllers/munawaba/shifts_controller.rb +16 -0
  24. data/app/controllers/munawaba/slack_integrations_controller.rb +46 -0
  25. data/app/controllers/munawaba/themes_controller.rb +19 -0
  26. data/app/helpers/munawaba/application_helper.rb +208 -0
  27. data/app/jobs/munawaba/application_job.rb +7 -0
  28. data/app/jobs/munawaba/deliver_notification_job.rb +13 -0
  29. data/app/jobs/munawaba/dispatch_due_notifications_job.rb +7 -0
  30. data/app/jobs/munawaba/maintain_projection_job.rb +25 -0
  31. data/app/jobs/munawaba/maintenance_job.rb +28 -0
  32. data/app/models/munawaba/application_record.rb +7 -0
  33. data/app/models/munawaba/audit_event.rb +16 -0
  34. data/app/models/munawaba/notification_delivery.rb +75 -0
  35. data/app/models/munawaba/person.rb +23 -0
  36. data/app/models/munawaba/schedule.rb +85 -0
  37. data/app/models/munawaba/schedule_membership.rb +13 -0
  38. data/app/models/munawaba/shift.rb +51 -0
  39. data/app/models/munawaba/shift_override.rb +31 -0
  40. data/app/services/munawaba/audit/recorder.rb +17 -0
  41. data/app/services/munawaba/canonical.rb +43 -0
  42. data/app/services/munawaba/commands.rb +741 -0
  43. data/app/services/munawaba/conflicts/finder.rb +59 -0
  44. data/app/services/munawaba/integrations/planner_effects.rb +27 -0
  45. data/app/services/munawaba/integrations.rb +93 -0
  46. data/app/services/munawaba/notifications/context/v1.rb +65 -0
  47. data/app/services/munawaba/notifications/deliver.rb +94 -0
  48. data/app/services/munawaba/notifications/dispatcher.rb +50 -0
  49. data/app/services/munawaba/notifications/planner.rb +154 -0
  50. data/app/services/munawaba/notifications/reclaim_expired_leases.rb +27 -0
  51. data/app/services/munawaba/notifications/retry_failed.rb +51 -0
  52. data/app/services/munawaba/notifications/validity.rb +90 -0
  53. data/app/services/munawaba/notifications/wake_dispatcher.rb +17 -0
  54. data/app/services/munawaba/preview.rb +5 -0
  55. data/app/services/munawaba/result.rb +7 -0
  56. data/app/services/munawaba/shifts/normalize_future_timing.rb +172 -0
  57. data/app/services/munawaba/shifts/project.rb +50 -0
  58. data/app/services/munawaba/shifts/projection_plan.rb +89 -0
  59. data/app/services/munawaba/slack/client.rb +105 -0
  60. data/app/services/munawaba/slack/renderer.rb +48 -0
  61. data/app/services/munawaba/slack/webhook_validator.rb +35 -0
  62. data/app/services/munawaba/timing/boundary_calculator.rb +92 -0
  63. data/app/services/munawaba/timing/persisted_interval_verifier.rb +26 -0
  64. data/app/views/layouts/munawaba/application.html.erb +61 -0
  65. data/app/views/munawaba/audit_events/index.html.erb +85 -0
  66. data/app/views/munawaba/calendars/_shift.html.erb +17 -0
  67. data/app/views/munawaba/calendars/show.html.erb +81 -0
  68. data/app/views/munawaba/notification_deliveries/index.html.erb +65 -0
  69. data/app/views/munawaba/overrides/new.html.erb +19 -0
  70. data/app/views/munawaba/overviews/show.html.erb +76 -0
  71. data/app/views/munawaba/people/_form.html.erb +25 -0
  72. data/app/views/munawaba/people/edit.html.erb +2 -0
  73. data/app/views/munawaba/people/index.html.erb +49 -0
  74. data/app/views/munawaba/people/new.html.erb +2 -0
  75. data/app/views/munawaba/people/show.html.erb +34 -0
  76. data/app/views/munawaba/rotations/edit.html.erb +39 -0
  77. data/app/views/munawaba/schedules/_form.html.erb +49 -0
  78. data/app/views/munawaba/schedules/_resume_form.html.erb +24 -0
  79. data/app/views/munawaba/schedules/edit.html.erb +2 -0
  80. data/app/views/munawaba/schedules/index.html.erb +46 -0
  81. data/app/views/munawaba/schedules/new.html.erb +2 -0
  82. data/app/views/munawaba/schedules/resume.html.erb +4 -0
  83. data/app/views/munawaba/schedules/show.html.erb +86 -0
  84. data/app/views/munawaba/shared/_empty.html.erb +6 -0
  85. data/app/views/munawaba/shared/_errors.html.erb +23 -0
  86. data/app/views/munawaba/shared/_navigation.html.erb +8 -0
  87. data/app/views/munawaba/shared/_projection.html.erb +20 -0
  88. data/app/views/munawaba/shared/_proposal_details.html.erb +108 -0
  89. data/app/views/munawaba/shared/_shifts.html.erb +50 -0
  90. data/app/views/munawaba/shared/proposal.html.erb +5 -0
  91. data/app/views/munawaba/shifts/show.html.erb +65 -0
  92. data/app/views/munawaba/slack_integrations/edit.html.erb +48 -0
  93. data/config/routes.rb +45 -0
  94. data/db/migrate/20260905000001_create_munawaba_people.rb +33 -0
  95. data/db/migrate/20260905000002_create_munawaba_schedules.rb +73 -0
  96. data/db/migrate/20260905000003_create_munawaba_schedule_memberships.rb +25 -0
  97. data/db/migrate/20260905000004_create_munawaba_shifts.rb +51 -0
  98. data/db/migrate/20260905000005_create_munawaba_shift_overrides.rb +33 -0
  99. data/db/migrate/20260905000006_create_munawaba_notification_deliveries.rb +83 -0
  100. data/db/migrate/20260905000007_create_munawaba_audit_events.rb +36 -0
  101. data/db/migrate/20260905000008_simplify_munawaba_activity.rb +16 -0
  102. data/lib/generators/munawaba/install_generator.rb +34 -0
  103. data/lib/generators/munawaba/templates/munawaba.rb +23 -0
  104. data/lib/munawaba/configuration.rb +91 -0
  105. data/lib/munawaba/defaults.rb +20 -0
  106. data/lib/munawaba/engine.rb +16 -0
  107. data/lib/munawaba/version.rb +5 -0
  108. data/lib/munawaba.rb +33 -0
  109. metadata +287 -0
@@ -0,0 +1,272 @@
1
+ @font-face{font-family:Inter;src:url("inter.woff2") format("woff2");font-style:normal;font-weight:100 900;font-display:swap}
2
+ @font-face{font-family:"JetBrains Mono";src:url("jetbrains-mono.woff2") format("woff2");font-style:normal;font-weight:400;font-display:swap}
3
+
4
+ .mn-app {
5
+ --mn-bg:#0c0d10; --mn-bg-1:#111317; --mn-bg-2:#15181d; --mn-bg-3:#1a1e24;
6
+ --mn-row-hover:#16191f; --mn-border:#20242c; --mn-border-strong:#2a2f38;
7
+ --mn-fg:#e6e9ef; --mn-fg-1:#c2c8d2; --mn-fg-2:#8a92a0; --mn-fg-3:#88919f;
8
+ --mn-decoration:#5d6573; --mn-accent:#d6deea; --mn-selected:#1d2530;
9
+ --mn-success:#4ea36b; --mn-info:#6694e8; --mn-danger:#d96565; --mn-warning:#c98a3f; --mn-focus:#91b9ff;
10
+ color-scheme:dark; color:var(--mn-fg); background:var(--mn-bg);
11
+ font:12.5px/1.4 Inter,system-ui,-apple-system,"Segoe UI",sans-serif;
12
+ font-feature-settings:"cv11","ss01"; -webkit-font-smoothing:antialiased;
13
+ position:absolute; inset:0; width:100%; height:100vh;
14
+ display:grid; grid-template-columns:264px minmax(0,1fr); overflow:hidden;
15
+ }
16
+ .mn-app[data-mn-theme="light"] {
17
+ --mn-bg:#f5f6f8; --mn-bg-1:#fff; --mn-bg-2:#fafbfc; --mn-bg-3:#eef0f4;
18
+ --mn-row-hover:#f3f5f8; --mn-border:#e3e6eb; --mn-border-strong:#cdd2da;
19
+ --mn-fg:#0f1216; --mn-fg-1:#2a2f38; --mn-fg-2:#5b6471; --mn-fg-3:#66707f;
20
+ --mn-decoration:#8a92a0; --mn-accent:#1a1d22; --mn-selected:#e7eef9;
21
+ --mn-success:#267442; --mn-info:#2563d4; --mn-danger:#b52d2d; --mn-warning:#915617; --mn-focus:#225fc6;
22
+ color-scheme:light;
23
+ }
24
+ .mn-app,.mn-app *,.mn-app *::before,.mn-app *::after { box-sizing:border-box; }
25
+ .mn-app :where(a) { color:var(--mn-fg-1); text-decoration:none; text-underline-offset:3px; }
26
+ .mn-app :where(a:hover) { color:var(--mn-fg); }
27
+ .mn-app :where(h1,h2,h3,p) { margin:0; }
28
+ .mn-app :where(h1) { font-size:14px; line-height:1.4; font-weight:600; letter-spacing:-.01em; }
29
+ .mn-app :where(h2,h3) { font-size:12px; font-weight:500; }
30
+ .mn-app p+p { margin-top:8px; }
31
+ .mn-app :where(button,input,select,textarea) { font:inherit; }
32
+ .mn-app button { cursor:pointer; }
33
+ .mn-app button:disabled,.mn-app input:disabled,.mn-app fieldset:disabled { opacity:.5; cursor:not-allowed; }
34
+ .mn-app :focus-visible { outline:2px solid var(--mn-focus); outline-offset:2px; border-radius:3px; }
35
+ .mn-app ::-webkit-scrollbar { width:8px; height:8px; }
36
+ .mn-app ::-webkit-scrollbar-thumb { background:var(--mn-border-strong); border-radius:4px; }
37
+ .mn-app ::-webkit-scrollbar-track { background:transparent; }
38
+ .mn-app .mn-mono,.mn-app code,.mn-app pre { font-family:"JetBrains Mono",ui-monospace,"SFMono-Regular",Menlo,monospace; font-size:11px; font-variant-numeric:tabular-nums; }
39
+ .mn-app .mn-muted,.mn-app .mn-hint { color:var(--mn-fg-2); }
40
+ .mn-app .mn-hint { font-size:11px; line-height:1.5; margin:8px 0; }
41
+ .mn-app .mn-main p a,.mn-app .mn-main dd a { text-decoration:underline; }
42
+
43
+ .mn-app .mn-sidebar { display:flex; flex-direction:column; min-width:0; min-height:0; background:var(--mn-bg-1); border-right:1px solid var(--mn-border); }
44
+ .mn-app .mn-brand { display:flex; align-items:center; gap:6px; min-height:40px; padding:11px 12px; border-bottom:1px solid var(--mn-border); color:var(--mn-fg); font-size:12.5px; font-weight:600; letter-spacing:-.01em; }
45
+ .mn-app .mn-brand:hover { text-decoration:none; }
46
+ .mn-app .mn-brand-logo { display:block; width:18px; height:18px; flex:none; border-radius:4px; object-fit:contain; }
47
+ .mn-app .mn-brand-name { line-height:1; }
48
+ .mn-app .mn-version { color:var(--mn-fg-3); font:10px/1 "JetBrains Mono",monospace; font-weight:400; letter-spacing:normal; }
49
+ .mn-app .mn-desktop-navigation { flex:1; min-height:0; overflow:auto; padding-top:10px; }
50
+ .mn-app .mn-menu,.mn-app .mn-menu>summary { display:none; }
51
+ .mn-app .mn-sidebar nav { padding:0 0 8px; }
52
+ .mn-app .mn-nav-label { padding:4px 14px 6px; font-size:10px; letter-spacing:.06em; color:var(--mn-fg-3); text-transform:uppercase; }
53
+ .mn-app .mn-nav-link { display:flex; position:relative; align-items:center; min-height:27px; margin:0 6px; padding:5px 8px; border-radius:3px; color:var(--mn-fg-1); font-size:12px; line-height:1.4; }
54
+ .mn-app .mn-nav-link:hover { background:var(--mn-row-hover); text-decoration:none; }
55
+ .mn-app .mn-nav-link.mn-selected { background:var(--mn-selected); color:var(--mn-fg); }
56
+ .mn-app .mn-nav-link.mn-selected::before { content:""; position:absolute; left:0; top:4px; bottom:4px; width:2px; border-radius:1px; background:var(--mn-accent); }
57
+ .mn-app .mn-sidebar-footer { display:flex; align-items:center; justify-content:space-between; gap:8px; padding:10px 12px; border-top:1px solid var(--mn-border); color:var(--mn-fg-2); font-size:10px; }
58
+ .mn-app .mn-workspace { display:flex; flex-direction:column; min-width:0; min-height:0; overflow:hidden; }
59
+ .mn-app .mn-topbar { display:flex; align-items:center; justify-content:space-between; flex:none; height:38px; gap:12px; padding:0 14px; border-bottom:1px solid var(--mn-border); background:var(--mn-bg-1); font-size:12px; color:var(--mn-fg-2); }
60
+ .mn-app .mn-breadcrumbs { display:flex; align-items:center; min-width:0; overflow:hidden; white-space:nowrap; }
61
+ .mn-app .mn-breadcrumbs [aria-current] { overflow:hidden; text-overflow:ellipsis; color:var(--mn-fg); font-weight:500; }
62
+ .mn-app .mn-theme-form { flex:none; line-height:1; }
63
+ .mn-app .mn-topbar span[aria-hidden] { padding:0 8px; color:var(--mn-decoration); }
64
+ .mn-app .mn-topbar a { color:var(--mn-fg-2); }
65
+ .mn-app .mn-topbar a:hover { color:var(--mn-fg); text-decoration:underline; }
66
+ .mn-app .mn-topbar-right { display:flex; align-items:center; gap:8px; flex:none; }
67
+ .mn-app .mn-topbar-note { font-size:10.5px; color:var(--mn-fg-3); }
68
+ .mn-app .mn-theme-button { display:inline-flex; align-items:center; justify-content:center; width:22px; height:22px; padding:0; border:1px solid var(--mn-border); border-radius:3px; background:var(--mn-bg-2); color:var(--mn-fg-1); font-size:13px; line-height:1; }
69
+ .mn-app .mn-theme-button:hover { color:var(--mn-fg); background:var(--mn-bg-3); }
70
+ .mn-app .mn-theme-button span[aria-hidden] { padding:0; color:inherit; }
71
+ .mn-app .mn-main { flex:1; width:100%; min-width:0; min-height:0; padding:0; overflow:auto; }
72
+ .mn-app .mn-main:focus { outline:none; }
73
+ .mn-app .mn-overview { display:contents; }
74
+
75
+ .mn-app .mn-heading { padding:10px 14px; border-bottom:1px solid var(--mn-border); background:var(--mn-bg-1); }
76
+ .mn-app .mn-heading p { margin-top:3px; font-size:11px; color:var(--mn-fg-2); }
77
+ .mn-app .mn-page-heading { display:flex; align-items:center; justify-content:space-between; flex-wrap:wrap; gap:10px; padding:10px 14px; border-bottom:1px solid var(--mn-border); background:var(--mn-bg-1); }
78
+ .mn-app .mn-page-heading .mn-heading { min-width:0; padding:0; border:0; }
79
+ .mn-app .mn-actions { display:flex; align-items:center; flex-wrap:wrap; gap:8px; }
80
+ .mn-app .mn-stats { display:grid; grid-template-columns:repeat(4,minmax(0,1fr)); border-bottom:1px solid var(--mn-border); background:var(--mn-bg-1); }
81
+ .mn-app .mn-stats>div { display:flex; flex-direction:column; gap:3px; padding:10px 14px; border-right:1px solid var(--mn-border); min-width:0; }
82
+ .mn-app .mn-stats>div:last-child { border-right:0; }
83
+ .mn-app .mn-stats span { color:var(--mn-fg-3); font-size:9.5px; letter-spacing:.07em; text-transform:uppercase; }
84
+ .mn-app .mn-stats strong { color:var(--mn-fg); font:500 18px/1.4 "JetBrains Mono",monospace; font-variant-numeric:tabular-nums; }
85
+ .mn-app .mn-section-heading { display:flex; align-items:center; justify-content:space-between; flex-wrap:wrap; gap:8px; padding:8px 14px; border-bottom:1px solid var(--mn-border); background:var(--mn-bg-1); }
86
+ .mn-app .mn-section-heading h2 { font-size:10px; font-weight:500; letter-spacing:.07em; text-transform:uppercase; color:var(--mn-fg-2); }
87
+ .mn-app .mn-section-heading>.mn-muted { font-size:10.5px; }
88
+ .mn-app .mn-tabs { display:flex; gap:20px; padding:0 14px; border-bottom:1px solid var(--mn-border); overflow:auto; background:var(--mn-bg-1); }
89
+ .mn-app .mn-tabs a { padding:8px 0; color:var(--mn-fg-2); font-size:11.5px; white-space:nowrap; }
90
+ .mn-app .mn-tabs [aria-current] { color:var(--mn-fg); border-bottom:2px solid var(--mn-accent); }
91
+ .mn-app .mn-filter-bar { display:flex; flex-wrap:wrap; gap:8px 10px; align-items:flex-end; padding:8px 14px; border-bottom:1px solid var(--mn-border); background:var(--mn-bg-1); }
92
+ .mn-app .mn-filter-bar>div { flex:1; min-width:125px; max-width:220px; }
93
+ .mn-app .mn-filter-bar>div>label { display:block; margin-bottom:4px; font-size:9.5px; letter-spacing:.06em; text-transform:uppercase; color:var(--mn-fg-2); }
94
+ .mn-app .mn-filter-bar>a { display:inline-flex; align-items:center; min-height:26px; padding:0 9px; border:1px solid var(--mn-border); border-radius:3px; font-size:11px; }
95
+ .mn-app .mn-filter-bar input:not([type="hidden"]),.mn-app .mn-filter-bar :is(input,select):not([type="hidden"]):not([type="checkbox"]):not([type="submit"]) { min-height:26px; height:26px; padding:3px 7px; font-size:11px; }
96
+
97
+ .mn-app .mn-button { display:inline-flex; align-items:center; justify-content:center; gap:6px; min-height:26px; padding:4px 9px; border:1px solid var(--mn-border-strong); border-radius:3px; background:var(--mn-bg-2); color:var(--mn-fg-1); font:500 11px/1.4 Inter,system-ui,sans-serif; white-space:nowrap; cursor:pointer; }
98
+ .mn-app .mn-button:hover { color:var(--mn-fg); background:var(--mn-bg-3); text-decoration:none; }
99
+ .mn-app .mn-button-primary { border-color:color-mix(in srgb,var(--mn-accent) 40%,var(--mn-border)); background:var(--mn-selected); color:var(--mn-fg); }
100
+ .mn-app .mn-button-primary:hover { border-color:var(--mn-fg-2); background:var(--mn-bg-3); }
101
+ .mn-app .mn-button-danger { color:var(--mn-danger); border-color:color-mix(in srgb,var(--mn-danger) 40%,var(--mn-border)); background:color-mix(in srgb,var(--mn-danger) 6%,var(--mn-bg-1)); }
102
+ .mn-app .mn-danger-link { color:var(--mn-danger); }
103
+ .mn-app .mn-icon-button { min-width:26px; padding:3px 6px; }
104
+ .mn-app .mn-badge { display:inline-flex; align-items:center; gap:4px; height:16px; padding:0 5px; border:1px solid color-mix(in srgb,var(--mn-status,var(--mn-fg-2)) 35%,var(--mn-border)); border-radius:2px; background:color-mix(in srgb,var(--mn-status,var(--mn-fg-2)) 8%,var(--mn-bg-2)); color:var(--mn-status,var(--mn-fg-2)); font:10px/1 "JetBrains Mono",monospace; letter-spacing:.02em; white-space:nowrap; text-transform:none; }
105
+ .mn-app .mn-badge-active,.mn-app .mn-badge-current,.mn-app .mn-badge-delivered { --mn-status:var(--mn-success); }
106
+ .mn-app .mn-badge-active::before,.mn-app .mn-badge-current::before { content:""; width:5px; height:5px; border-radius:50%; background:currentColor; }
107
+ .mn-app .mn-badge-scheduled,.mn-app .mn-badge-next,.mn-app .mn-badge-planned { --mn-status:var(--mn-info); }
108
+ .mn-app .mn-badge-pausing,.mn-app .mn-badge-pending { --mn-status:var(--mn-warning); }
109
+ .mn-app .mn-badge-failed { --mn-status:var(--mn-danger); }
110
+
111
+ .mn-app .mn-table-scroll { position:relative; max-width:100%; overflow:auto; border-bottom:1px solid var(--mn-border); }
112
+ .mn-app .mn-table { width:100%; border-collapse:collapse; text-align:left; font-size:12px; }
113
+ .mn-app .mn-table th { padding:7px 10px; border-bottom:1px solid var(--mn-border); background:var(--mn-bg-1); color:var(--mn-fg-2); font-size:10px; font-weight:500; letter-spacing:.07em; text-transform:uppercase; white-space:nowrap; }
114
+ .mn-app .mn-table td { padding:6px 10px; border-bottom:1px solid var(--mn-border); vertical-align:middle; }
115
+ .mn-app .mn-table tr:last-child td { border-bottom:0; }
116
+ .mn-app .mn-table tbody tr:hover { background:var(--mn-row-hover); }
117
+ .mn-app .mn-table .mn-mono { white-space:nowrap; }
118
+ .mn-app .mn-table a:hover { text-decoration:underline; }
119
+ .mn-app .mn-primary-text { font-weight:500; color:var(--mn-fg); }
120
+ .mn-app .mn-cell-sub { display:block; margin-top:2px; color:var(--mn-fg-2); font-size:10.5px; }
121
+ .mn-app .mn-conflict { display:block; color:var(--mn-warning); font-size:11px; line-height:1.4; white-space:normal; margin-top:5px; }
122
+ .mn-app .mn-overview-table { min-width:1000px; }
123
+ .mn-app .mn-overview-table td { padding-top:9px; padding-bottom:9px; }
124
+ .mn-app .mn-overview-schedule { min-width:200px; }
125
+ .mn-app .mn-overview-schedule>a { font-weight:500; color:var(--mn-fg); }
126
+ .mn-app .mn-overview-person { min-width:135px; }
127
+ .mn-app .mn-overview-table .mn-maintenance-warning { max-width:280px; }
128
+ .mn-app .mn-pagination { display:flex; justify-content:flex-end; padding:8px 14px; border-top:1px solid var(--mn-border); background:var(--mn-bg-1); }
129
+
130
+ .mn-app .mn-card { min-width:0; padding:10px 12px; border:1px solid var(--mn-border); border-radius:4px; background:var(--mn-bg-1); }
131
+ .mn-app .mn-main>.mn-card,.mn-app .mn-main>.mn-form { margin:14px; }
132
+ .mn-app .mn-compact-card { align-self:start; }
133
+ .mn-app .mn-main>.mn-compact-card { max-width:720px; }
134
+ .mn-app .mn-card-heading { display:flex; align-items:center; justify-content:space-between; flex-wrap:wrap; gap:8px; padding-bottom:6px; margin-bottom:8px; border-bottom:1px solid var(--mn-border); }
135
+ .mn-app .mn-card-heading h2,.mn-app .mn-card>h2 { color:var(--mn-fg-2); font-size:10px; font-weight:500; letter-spacing:.07em; text-transform:uppercase; }
136
+ .mn-app .mn-card>h2 { padding-bottom:6px; margin-bottom:8px; border-bottom:1px solid var(--mn-border); }
137
+ .mn-app .mn-card-heading>a { font-size:11px; }
138
+ .mn-app .mn-card>.mn-muted { font-size:11px; }
139
+ .mn-app .mn-card .mn-section-heading { padding:0 0 6px; margin-bottom:6px; }
140
+ .mn-app .mn-card .mn-table-scroll { border-bottom:0; }
141
+ .mn-app .mn-current-person { display:flex; flex-direction:column; gap:4px; margin:10px 0 12px; }
142
+ .mn-app .mn-current-person strong { color:var(--mn-fg); font-size:16px; font-weight:500; letter-spacing:-.01em; }
143
+ .mn-app .mn-current-person .mn-muted { font:11px "JetBrains Mono",monospace; }
144
+ .mn-app .mn-eyebrow { font-size:9.5px; letter-spacing:.06em; text-transform:uppercase; color:var(--mn-fg-2); }
145
+ .mn-app .mn-detail-list { display:grid; grid-template-columns:minmax(100px,.7fr) minmax(0,1.3fr); gap:5px 16px; margin:8px 0 12px; font-size:11.5px; }
146
+ .mn-app .mn-detail-list dt { color:var(--mn-fg-2); font:11px "JetBrains Mono",monospace; }
147
+ .mn-app .mn-detail-list dd { margin:0; overflow-wrap:anywhere; white-space:pre-wrap; }
148
+ .mn-app .mn-two-columns { display:grid; grid-template-columns:minmax(0,1fr) minmax(0,1fr); align-items:start; gap:14px; margin:14px; }
149
+ .mn-app .mn-main>.mn-hint { margin:0; padding:10px 14px; }
150
+ .mn-app input:not([type="hidden"]):not([type="checkbox"]):not([type="submit"]),.mn-app select,.mn-app textarea { width:100%; min-height:30px; padding:5px 8px; border:1px solid var(--mn-border-strong); border-radius:3px; background:var(--mn-bg-2); color:var(--mn-fg); font:11.5px/1.4 "JetBrains Mono",ui-monospace,monospace; }
151
+ .mn-app textarea { resize:vertical; }
152
+ .mn-app input::placeholder,.mn-app textarea::placeholder { color:var(--mn-fg-3); }
153
+ .mn-app .mn-form { max-width:680px; }
154
+ .mn-app .mn-two-columns .mn-form { max-width:none; }
155
+ .mn-app .mn-field { margin-bottom:14px; }
156
+ .mn-app .mn-field>label { display:block; font-size:11px; font-weight:500; margin-bottom:5px; }
157
+ .mn-app .mn-field-row { display:flex; gap:12px; }
158
+ .mn-app .mn-field-row>.mn-field { flex:1; min-width:0; }
159
+ .mn-app fieldset { min-width:0; border:1px solid var(--mn-border); border-radius:3px; padding:10px 12px; margin:0 0 14px; }
160
+ .mn-app legend { padding:0 5px; font-size:11px; font-weight:500; }
161
+ .mn-app .mn-form-actions { display:flex; align-items:center; flex-wrap:wrap; gap:8px; margin-top:16px; }
162
+ .mn-app .mn-checkbox { display:flex; align-items:flex-start; gap:8px; margin:10px 0; font-size:11.5px; cursor:pointer; }
163
+ .mn-app .mn-checkbox input { width:14px; height:14px; flex:none; margin:1px 0 0; accent-color:var(--mn-info); }
164
+ .mn-app .mn-error-summary { padding:10px 12px; margin:14px; border:1px solid var(--mn-danger); border-radius:3px; background:var(--mn-bg-1); }
165
+ .mn-app .mn-error-summary h2,.mn-app .mn-field-error { color:var(--mn-danger); }
166
+ .mn-app .mn-error-summary p { margin-top:6px; }
167
+ .mn-app .mn-error-summary ul { padding-left:20px; margin-bottom:0; }
168
+ .mn-app .mn-error-summary a { text-decoration:underline; }
169
+ .mn-app .mn-field-error { margin-top:4px; font-size:11px; }
170
+ .mn-app .field_with_errors { display:contents; }
171
+ .mn-app .field_with_errors input { border-color:var(--mn-danger); }
172
+ .mn-app .mn-notice,.mn-app .mn-warning-panel { padding:10px 12px; margin:14px; border:1px solid var(--mn-border-strong); border-radius:3px; background:var(--mn-bg-2); font-size:11.5px; line-height:1.5; }
173
+ .mn-app .mn-card .mn-notice { margin:10px 0 0; }
174
+ .mn-app .mn-warning-panel { border-color:color-mix(in srgb,var(--mn-warning) 50%,var(--mn-border)); }
175
+ .mn-app .mn-warning-panel h2 { color:var(--mn-warning); margin-bottom:6px; }
176
+ .mn-app .mn-conflict-list { padding-left:20px; }
177
+ .mn-app .mn-conflict-list li+li { margin-top:6px; }
178
+ .mn-app .mn-confirmation { padding:12px 14px; margin:14px 0 0; border-top:1px solid var(--mn-border); }
179
+ .mn-app .mn-roster { list-style:none; padding:0; margin:8px 0; }
180
+ .mn-app .mn-roster li { display:flex; align-items:center; flex-wrap:wrap; gap:8px; padding:6px 0; border-bottom:1px solid var(--mn-border); }
181
+ .mn-app .mn-roster li:last-child { border-bottom:0; }
182
+ .mn-app .mn-position { min-width:18px; color:var(--mn-fg-3); font:10px "JetBrains Mono",monospace; }
183
+ .mn-app .mn-roster-editor { margin-top:0; }
184
+ .mn-app .mn-roster-editor li { padding:7px 0; }
185
+ .mn-app .mn-roster-editor li[draggable] { cursor:grab; }
186
+ .mn-app .mn-roster-editor li[data-mn-dragging] { opacity:.5; }
187
+ .mn-app .mn-roster-controls { display:flex; flex-wrap:wrap; gap:4px; margin-left:auto; }
188
+ .mn-app .mn-roster-editor strong { font-size:12px; font-weight:500; }
189
+ .mn-app .mn-roster-editor+.mn-filter-bar { margin:10px 0 0; padding:10px 0 0; border:0; border-top:1px solid var(--mn-border); }
190
+
191
+ .mn-app .mn-segment { display:inline-flex; border:1px solid var(--mn-border); border-radius:3px; overflow:hidden; background:var(--mn-bg-2); }
192
+ .mn-app .mn-segment a { padding:4px 9px; border-right:1px solid var(--mn-border); color:var(--mn-fg-2); font-size:11.5px; }
193
+ .mn-app .mn-segment a:last-child { border-right:0; }
194
+ .mn-app .mn-segment [aria-current] { background:var(--mn-selected); color:var(--mn-fg); }
195
+ .mn-app .mn-calendar-scroll { overflow:auto; border-bottom:1px solid var(--mn-border); }
196
+ .mn-app .mn-month-grid { display:grid; grid-template-columns:repeat(7,minmax(130px,1fr)); min-width:910px; background:var(--mn-bg); }
197
+ .mn-app .mn-weekday { padding:7px 8px; border-bottom:1px solid var(--mn-border); background:var(--mn-bg-1); color:var(--mn-fg-2); font-size:10px; letter-spacing:.04em; text-transform:uppercase; }
198
+ .mn-app .mn-day { min-height:110px; padding:6px; border-right:1px solid var(--mn-border); border-bottom:1px solid var(--mn-border); }
199
+ .mn-app .mn-day:nth-child(7n) { border-right:0; }
200
+ .mn-app .mn-day h3 { margin-bottom:6px; color:var(--mn-fg-2); font:10px "JetBrains Mono",monospace; }
201
+ .mn-app .mn-day-outside { background:var(--mn-bg-1); }
202
+ .mn-app .mn-calendar-shift { display:block; padding:5px 6px; margin-bottom:4px; border:1px solid var(--mn-border); border-left:2px solid var(--mn-border-strong); border-radius:2px; background:var(--mn-bg-2); overflow-wrap:anywhere; }
203
+ .mn-app .mn-calendar-shift:hover { background:var(--mn-row-hover); border-left-color:var(--mn-info); text-decoration:none; }
204
+ .mn-app .mn-calendar-shift strong,.mn-app .mn-calendar-shift span { display:block; font-size:11px; }
205
+ .mn-app .mn-calendar-shift strong { color:var(--mn-fg); font-weight:500; }
206
+ .mn-app .mn-calendar-shift .mn-mono { color:var(--mn-fg-2); font-size:10px; line-height:1.4; margin-top:3px; }
207
+ .mn-app .mn-day-more>summary { font-size:10.5px; }
208
+ .mn-app details>summary { cursor:pointer; padding:4px 0; color:var(--mn-fg-1); }
209
+ .mn-app .mn-event-details { max-width:560px; min-width:170px; }
210
+ .mn-app .mn-event-details dd { font:10.5px/1.5 "JetBrains Mono",monospace; }
211
+ .mn-app .mn-history { list-style:none; margin:8px 0 0; padding:0; }
212
+ .mn-app .mn-history li { padding:8px 0; border-bottom:1px solid var(--mn-border); }
213
+ .mn-app .mn-history li:last-child { border-bottom:0; }
214
+ .mn-app .mn-history strong { font-size:12px; font-weight:500; margin-right:12px; }
215
+ .mn-app .mn-history p { color:var(--mn-fg-2); font-size:11px; margin-top:4px; }
216
+ .mn-app .mn-empty { display:flex; flex-direction:column; align-items:center; justify-content:center; padding:64px 20px; gap:6px; text-align:center; }
217
+ .mn-app .mn-empty-symbol { color:var(--mn-decoration); font:28px "JetBrains Mono",monospace; }
218
+ .mn-app .mn-empty h2 { font-size:12.5px; color:var(--mn-fg-1); font-weight:500; }
219
+ .mn-app .mn-empty p { max-width:420px; color:var(--mn-fg-2); font-size:11px; margin:0 0 8px; }
220
+ .mn-app .mn-sr-only { position:absolute; width:1px; height:1px; padding:0; margin:-1px; overflow:hidden; clip:rect(0,0,0,0); white-space:nowrap; border:0; }
221
+ .mn-app .mn-skip-link { position:absolute; top:5px; left:8px; z-index:100; padding:8px 12px; background:var(--mn-fg); color:var(--mn-bg); transform:translateY(-150%); }
222
+ .mn-app .mn-skip-link:focus { transform:translateY(0); }
223
+
224
+ @media(max-width:1000px) {
225
+ .mn-app { grid-template-columns:220px minmax(0,1fr); }
226
+ .mn-app .mn-two-columns { grid-template-columns:1fr; }
227
+ .mn-app .mn-roster-controls { margin-left:auto; }
228
+ }
229
+ @media(max-width:720px) {
230
+ .mn-app { display:block; overflow:auto; }
231
+ .mn-app .mn-sidebar { position:relative; border-right:0; border-bottom:1px solid var(--mn-border); }
232
+ .mn-app .mn-brand { min-height:44px; padding:10px 12px; border-bottom:0; }
233
+ .mn-app .mn-desktop-navigation,.mn-app .mn-sidebar-footer { display:none; }
234
+ .mn-app .mn-menu { display:block; }
235
+ .mn-app .mn-menu>summary { display:flex; align-items:center; justify-content:space-between; min-height:44px; padding:8px 12px; border-top:1px solid var(--mn-border); font-size:12px; }
236
+ .mn-app .mn-menu nav { padding:4px 0 8px; }
237
+ .mn-app .mn-nav-label { display:none; }
238
+ .mn-app .mn-nav-link { min-height:44px; }
239
+ .mn-app .mn-workspace { overflow:visible; }
240
+ .mn-app .mn-topbar { min-height:44px; height:auto; padding:0 12px; }
241
+ .mn-app .mn-topbar-note { display:none; }
242
+ .mn-app .mn-theme-button { width:44px; height:44px; border:0; background:transparent; }
243
+ .mn-app .mn-main { overflow:visible; }
244
+ .mn-app .mn-page-heading,.mn-app .mn-heading { padding:10px 12px; }
245
+ .mn-app .mn-page-heading { align-items:flex-start; }
246
+ .mn-app .mn-page-heading>.mn-heading { width:100%; }
247
+ .mn-app .mn-stats { grid-template-columns:repeat(2,minmax(0,1fr)); }
248
+ .mn-app .mn-stats>div { border-bottom:1px solid var(--mn-border); padding:10px 12px; }
249
+ .mn-app .mn-stats>div:nth-child(2) { border-right:0; }
250
+ .mn-app .mn-stats>div:nth-last-child(-n+2) { border-bottom:0; }
251
+ .mn-app .mn-main>.mn-card,.mn-app .mn-main>.mn-form,.mn-app .mn-two-columns { margin:10px; }
252
+ .mn-app .mn-button,.mn-app input:not([type="hidden"]):not([type="checkbox"]):not([type="submit"]),.mn-app select,.mn-app .mn-filter-bar input:not([type="hidden"]),.mn-app .mn-filter-bar :is(input,select):not([type="hidden"]):not([type="checkbox"]):not([type="submit"]) { min-height:44px; height:auto; }
253
+ .mn-app .mn-checkbox { min-height:44px; align-items:center; }
254
+ .mn-app .mn-checkbox input { width:20px; height:20px; margin-top:0; }
255
+ .mn-app .mn-roster-controls { width:100%; margin-left:0; }
256
+ .mn-app .mn-roster-controls .mn-icon-button { min-width:44px; }
257
+ .mn-app .mn-filter-bar { padding:10px 12px; }
258
+ .mn-app .mn-filter-bar>div { max-width:none; }
259
+ .mn-app .mn-filter-bar>a { min-height:44px; }
260
+ .mn-app .mn-table { min-width:680px; }
261
+ .mn-app .mn-overview-table { min-width:1000px; }
262
+ .mn-app .mn-field-row { display:block; }
263
+ .mn-app .mn-tabs { gap:18px; padding:0 12px; }
264
+ .mn-app .mn-tabs a { display:inline-flex; align-items:center; min-height:44px; padding:8px 0; }
265
+ .mn-app details>summary { min-height:44px; display:flex; align-items:center; }
266
+ .mn-app .mn-segment a { display:inline-flex; align-items:center; min-height:44px; }
267
+ .mn-app .mn-form-actions { align-items:stretch; }
268
+ .mn-app .mn-roster-editor strong { max-width:65%; overflow-wrap:anywhere; }
269
+ }
270
+ @media(prefers-reduced-motion:reduce) {
271
+ .mn-app *,.mn-app *::before,.mn-app *::after { scroll-behavior:auto!important; transition:none!important; animation:none!important; }
272
+ }
@@ -0,0 +1,60 @@
1
+ (function () {
2
+ "use strict";
3
+ var app = document.querySelector(".mn-app");
4
+ if (!app) return;
5
+ var errors = app.querySelector("[data-mn-errors]");
6
+ if (errors) errors.focus();
7
+ var menu = app.querySelector("[data-mn-menu]");
8
+ if (menu && window.matchMedia("(max-width: 720px)").matches) menu.open = false;
9
+ if (menu) menu.addEventListener("keydown", function (event) {
10
+ if (event.key === "Escape" && window.matchMedia("(max-width: 720px)").matches) {
11
+ menu.open = false;
12
+ menu.querySelector("summary").focus();
13
+ }
14
+ });
15
+ var order = app.querySelector("[data-mn-order]");
16
+ if (!order) return;
17
+ var dragged = null;
18
+ order.addEventListener("dragstart", function (event) {
19
+ dragged = event.target.closest("[data-mn-person-id]");
20
+ if (!dragged) return;
21
+ dragged.setAttribute("data-mn-dragging", "true");
22
+ event.dataTransfer.effectAllowed = "move";
23
+ event.dataTransfer.setData("text/plain", dragged.getAttribute("data-mn-person-id"));
24
+ });
25
+ order.addEventListener("dragover", function (event) {
26
+ if (!dragged) return;
27
+ event.preventDefault();
28
+ var target = event.target.closest("[data-mn-person-id]");
29
+ if (target && target !== dragged) {
30
+ var bounds = target.getBoundingClientRect();
31
+ order.insertBefore(dragged, event.clientY < bounds.top + bounds.height / 2 ? target : target.nextSibling);
32
+ }
33
+ });
34
+ order.addEventListener("drop", function (event) {
35
+ if (!dragged) return;
36
+ event.preventDefault();
37
+ var rows = order.querySelectorAll("[data-mn-person-id]");
38
+ rows.forEach(function (row, index) {
39
+ row.querySelector(".mn-position").textContent = index + 1;
40
+ var badge = row.querySelector(".mn-badge-next");
41
+ if (badge) badge.remove();
42
+ if (index === 0) {
43
+ badge = document.createElement("span");
44
+ badge.className = "mn-badge mn-badge-next";
45
+ badge.textContent = "Next";
46
+ row.querySelector("strong").after(badge);
47
+ }
48
+ row.querySelector('[value^="up:"]').disabled = index === 0;
49
+ row.querySelector('[value^="down:"]').disabled = index === rows.length - 1;
50
+ row.querySelector('[value^="next:"]').disabled = index === 0;
51
+ });
52
+ var confirmation = app.querySelector("#mn-confirmation-form");
53
+ if (confirmation) confirmation.remove();
54
+ app.querySelector("[data-mn-order-announcement]").textContent = "Order changed. Update the preview to review and save this order.";
55
+ });
56
+ order.addEventListener("dragend", function () {
57
+ if (dragged) dragged.removeAttribute("data-mn-dragging");
58
+ dragged = null;
59
+ });
60
+ }());
Binary file
Binary file
@@ -0,0 +1,149 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Munawaba
4
+ class ApplicationController < Munawaba.config.parent_controller.constantize
5
+ layout "munawaba/application"
6
+ protect_from_forgery with: :exception
7
+ before_action :authenticate_munawaba!
8
+ before_action :authorize_munawaba!
9
+ before_action :html_only!
10
+ helper Munawaba::ApplicationHelper
11
+ helper_method :munawaba_theme
12
+
13
+ private
14
+
15
+ def authenticate_munawaba!
16
+ callback = Munawaba.config.authenticate
17
+ allowed = callback&.call(self)
18
+ head :unauthorized unless performed? || allowed == true
19
+ end
20
+
21
+ def html_only!
22
+ head :not_acceptable unless request.format.html?
23
+ end
24
+
25
+ def authorize_munawaba!
26
+ capability, record = authorization_target
27
+ allowed = Munawaba.config.authorize&.call(self, capability, record)
28
+ head :forbidden unless performed? || allowed == true
29
+ end
30
+
31
+ def actor
32
+ Munawaba.config.actor&.call(self)
33
+ end
34
+
35
+ def record_parameters(name)
36
+ value = params.require(name)
37
+ raise ActionController::ParameterMissing, name unless value.is_a?(ActionController::Parameters)
38
+
39
+ value
40
+ end
41
+
42
+ def array_parameter(name)
43
+ value = params[name]
44
+ return [] if value.nil?
45
+ unless value.is_a?(Array) && value.all? { |entry| entry.is_a?(String) || entry.is_a?(Integer) }
46
+ raise ActionController::ParameterMissing, name
47
+ end
48
+
49
+ value
50
+ end
51
+
52
+ def scalar_parameter(name)
53
+ value = params[name]
54
+ raise ActionController::ParameterMissing, name unless value.nil? || value.is_a?(String)
55
+
56
+ value
57
+ end
58
+
59
+ def munawaba_theme
60
+ cookies[:munawaba_theme] == "light" ? "light" : "dark"
61
+ end
62
+
63
+ def command(operation, subject, attributes = {}, success_path:, template:)
64
+ result = Commands.call(operation: operation, subject: subject, attributes: attributes,
65
+ actor: actor, token: params[:proposal_token], acknowledge_conflicts: params[:acknowledge_conflicts] == "1")
66
+ if result.success?
67
+ redirect_to success_path.respond_to?(:call) ? success_path.call(result.record) : success_path,
68
+ status: :see_other, notice: "Changes saved."
69
+ else
70
+ @errors = Array(result.errors)
71
+ @preview = result.preview
72
+ @record = result.record if result.record
73
+ @stale = result.status == 409
74
+ yield(result) if block_given?
75
+ render template, status: result.status
76
+ end
77
+ end
78
+
79
+ def proposal(operation, subject, attributes = {})
80
+ @operation, @subject, @attributes = operation, subject, attributes
81
+ result = Commands.preview(operation: operation, subject: subject, attributes: attributes, actor: actor)
82
+ @preview = result.preview
83
+ @errors = Array(result.errors)
84
+ if @preview.nil? && operation.to_sym == :override
85
+ @people = Person.where(active: true).order(:name)
86
+ render "munawaba/overrides/new", status: result.status
87
+ return
88
+ end
89
+ render "munawaba/shared/proposal", status: result.status
90
+ end
91
+
92
+ def refresh_proposal(operation, subject, attributes)
93
+ @operation, @subject, @attributes = operation, subject, attributes
94
+ unless @preview
95
+ refreshed = Commands.preview(operation: operation, subject: subject, attributes: attributes, actor: actor)
96
+ @preview = refreshed.preview
97
+ end
98
+ if @preview&.details
99
+ details = @preview.details
100
+ @operation = details[:operation].to_sym if details[:operation]
101
+ @attributes = @attributes.merge(boundary_index: details[:boundary_index]) if @operation == :resume && details.key?(:boundary_index)
102
+ end
103
+ end
104
+
105
+ def page_size
106
+ 50
107
+ end
108
+
109
+ def before_cursor(scope, column)
110
+ return scope if params[:before].blank?
111
+
112
+ stamp, id = params[:before].to_s.split("|", 2)
113
+ time = Time.iso8601(stamp)
114
+ raise ArgumentError unless id&.match?(/\A\d+\z/)
115
+ raise ArgumentError unless %w[created_at occurred_at].include?(column)
116
+
117
+ table = scope.klass.arel_table
118
+ left = Arel::Nodes::Grouping.new([table[column], table[:id]])
119
+ right = Arel::Nodes::Grouping.new([Arel::Nodes.build_quoted(time), Arel::Nodes.build_quoted(id.to_i)])
120
+ scope.where(left.lt(right))
121
+ rescue ArgumentError
122
+ scope.none
123
+ end
124
+
125
+ def after_name_cursor(scope)
126
+ cursor = scalar_parameter(:after)
127
+ return scope if cursor.blank?
128
+ raise ArgumentError if cursor.bytesize > 1024
129
+
130
+ name, id = JSON.parse(Base64.urlsafe_decode64(cursor))
131
+ raise ArgumentError unless name.is_a?(String) && name.length <= 120 && id.is_a?(Integer) && id.positive?
132
+
133
+ scope.where("(#{scope.klass.table_name}.name, #{scope.klass.table_name}.id) > (?, ?)", name, id)
134
+ rescue ArgumentError, JSON::ParserError
135
+ scope.none
136
+ end
137
+
138
+ def computed_roster(schedule, memberships)
139
+ rows = memberships.to_a
140
+ return rows unless %w[active scheduled].include?(schedule.state)
141
+
142
+ next_shift = Shift.where(schedule_id: schedule.id, canceled_at: nil).where("starts_at > ?", Time.current).order(
143
+ :starts_at, :id
144
+ ).first
145
+ index = rows.index { |membership| membership.person_id == next_shift&.base_person_id }
146
+ index ? rows.rotate(index) : rows
147
+ end
148
+ end
149
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Munawaba
4
+ class AuditEventsController < ApplicationController
5
+ def index
6
+ %i[schedule_id person_id shift_id actor_type actor_id event_type from until].each { |key| scalar_parameter(key) }
7
+ scope = AuditEvent.all
8
+ %i[schedule_id person_id shift_id actor_type actor_id event_type].each do |key|
9
+ scope = scope.where(key => params[key]) if params[key].present?
10
+ end
11
+ scope = scope.where("occurred_at >= ?", Date.iso8601(params[:from]).beginning_of_day) if params[:from].present?
12
+ scope = scope.where("occurred_at < ?",
13
+ Date.iso8601(params[:until]).next_day.beginning_of_day) if params[:until].present?
14
+ @events = before_cursor(scope, "occurred_at").order(occurred_at: :desc, id: :desc).limit(page_size + 1).to_a
15
+ @more = @events.size > page_size
16
+ @events = @events.first(page_size)
17
+ @schedules = Schedule.order(:name)
18
+ @people = Person.order(:name)
19
+ rescue ArgumentError
20
+ @errors = ["Enter valid filter dates."]
21
+ @events, @schedules, @people = [], Schedule.order(:name), Person.order(:name)
22
+ render :index, status: :unprocessable_entity
23
+ end
24
+
25
+ private
26
+
27
+ def authorization_target
28
+ [:view_audit, :activity]
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Munawaba
4
+ class CalendarsController < ApplicationController
5
+ def show
6
+ %i[date until schedule_id person_id view].each { |key| scalar_parameter(key) }
7
+ @schedules = Schedule.order(:name)
8
+ @people = Person.order(:name)
9
+ @schedule = Schedule.find_by(id: params[:schedule_id]) if params[:schedule_id].present?
10
+ @time_zone = @schedule&.time_zone || Munawaba.config.organization_time_zone
11
+ zone = ActiveSupport::TimeZone[@time_zone]
12
+ today = Time.current.in_time_zone(zone).to_date
13
+ @view = params[:view] == "month" ? "month" : "agenda"
14
+ @date = params[:date].present? ? Date.iso8601(params[:date]) : today
15
+ @from = @view == "month" ? @date.beginning_of_month : @date
16
+ @until = params[:until].present? ? Date.iso8601(params[:until]) : (@view == "month" ? @from.next_month : @from + 28)
17
+ floor = (Time.current - Munawaba.config.calendar_past_limit).in_time_zone(zone).to_date
18
+ ceiling = (Time.current + Munawaba.config.calendar_future_limit).in_time_zone(zone).to_date
19
+ raise ArgumentError,
20
+ "Choose dates within the calendar window." if @from < floor || @from > ceiling || @until <= @from || @until > ceiling + 1
21
+ raise ArgumentError, "The calendar range is too long." if @until > (@from + Munawaba.config.calendar_max_span)
22
+
23
+ start_at = zone.local(@from.year, @from.month, @from.day)
24
+ end_at = zone.local(@until.year, @until.month, @until.day)
25
+ @shifts = Shift.live.overlapping(start_at, end_at)
26
+ @shifts = @shifts.where(schedule_id: params[:schedule_id]) if params[:schedule_id].present?
27
+ @shifts = @shifts.where(effective_person_id: params[:person_id]) if params[:person_id].present?
28
+ @shifts = @shifts.includes(:schedule, :base_person, :effective_person).order(:starts_at, :id).to_a
29
+ @previous_date = @view == "month" ? @from.prev_month : @from - 28
30
+ @next_date = @view == "month" ? @from.next_month : @from + 28
31
+ @previous_date = nil if @previous_date < floor
32
+ @next_date = nil if (@view == "month" ? @next_date.next_month : @next_date + 28) > ceiling + 1
33
+ rescue ArgumentError => error
34
+ @errors = [error.message == "invalid date" ? "Enter a valid calendar date." : error.message]
35
+ @shifts = []
36
+ @from ||= today
37
+ @until ||= @from + 28
38
+ render :show, status: :unprocessable_entity
39
+ end
40
+
41
+ private
42
+
43
+ def authorization_target
44
+ [:read, :calendar]
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Munawaba
4
+ # Packaged assets are public and do not run the host's access callbacks.
5
+ class FrontendsController < ActionController::Base
6
+ # Assets do not read sessions or mutate state. Allow Rails to serve the script
7
+ # to a normal script element without requiring an XHR request.
8
+ protect_from_forgery with: :exception
9
+ skip_after_action :verify_same_origin_request, only: :show
10
+ MANIFEST = [
11
+ ["dashboard.css", "text/css; charset=utf-8"],
12
+ ["dashboard.js", "text/javascript; charset=utf-8"],
13
+ ["logo.png", "image/png"],
14
+ ["inter.woff2", "font/woff2"],
15
+ ["jetbrains-mono.woff2", "font/woff2"]
16
+ ].freeze
17
+
18
+ def show
19
+ entry = MANIFEST.find { |filename, _type| filename == params[:id] } if params[:version] == Munawaba::VERSION
20
+ return head :not_found unless entry
21
+
22
+ response.headers["X-Content-Type-Options"] = "nosniff"
23
+ response.headers["Cache-Control"] = Rails.env.development? ? "no-cache" : "public, max-age=31536000, immutable"
24
+ path = Munawaba::Engine.root.join("app/assets/munawaba", entry[0])
25
+ send_file path, type: entry[1], disposition: "inline"
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Munawaba
4
+ class NotificationDeliveriesController < ApplicationController
5
+ def index
6
+ %i[schedule_id status].each { |key| scalar_parameter(key) }
7
+ scope = NotificationDelivery.all
8
+ scope = scope.where(schedule_id: params[:schedule_id]) if params[:schedule_id].present?
9
+ scope = scope.where(status: params[:status]) if params[:status].present?
10
+ @deliveries = before_cursor(scope, "created_at").order(created_at: :desc,
11
+ id: :desc).includes(:schedule).limit(page_size + 1).to_a
12
+ @more = @deliveries.size > page_size
13
+ @deliveries = @deliveries.first(page_size)
14
+ @schedules = Schedule.order(:name)
15
+ end
16
+
17
+ def retry
18
+ result = Notifications::RetryFailed.call(delivery: @delivery, actor: actor,
19
+ acknowledge_duplicate: params[:acknowledge_duplicate] == "1")
20
+ if result.success?
21
+ redirect_to notification_deliveries_path(schedule_id: @delivery.schedule_id), status: :see_other,
22
+ notice: "Retry queued."
23
+ else
24
+ @errors = Array(result.errors)
25
+ # Authorization covers this delivery only. Keep a failed retry's error
26
+ # page limited to that record instead of loading global delivery history.
27
+ @deliveries = [@delivery.reload]
28
+ @schedules = [@delivery.schedule]
29
+ @more = false
30
+ render :index, status: result.status
31
+ end
32
+ end
33
+
34
+ private
35
+
36
+ def authorization_target
37
+ @delivery = NotificationDelivery.find(params[:id]) if action_name == "retry"
38
+ [:manage_integrations, @delivery || :notification_deliveries]
39
+ end
40
+ end
41
+ end