diff --git a/app/assets/stylesheets/application.css b/app/assets/stylesheets/application.css index e161b34..216424f 100644 --- a/app/assets/stylesheets/application.css +++ b/app/assets/stylesheets/application.css @@ -1906,6 +1906,66 @@ hr { letter-spacing: 0.04em; } +/* Volunteer slot capacity states --------------------------------------- */ + +.volunteer-capacity { + font-size: 0.75rem; + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; + margin: 0.375rem 0 0; + text-align: right; + letter-spacing: 0.04em; +} + +.volunteer-capacity--empty { + color: #b91c1c; + font-weight: 600; +} + +.volunteer-capacity--partial { + color: #9A3412; +} + +.volunteer-capacity--full { + color: #7a8189; +} + +.schedule-item.schedule-item--volunteer-empty { + border-left: 4px solid #b91c1c; + background: #fef2f2; + padding-left: 1.5rem; + padding-right: 1.5rem; +} + +/* Admin tabs ----------------------------------------------------------- */ + +.admin-tabs { + display: flex; + gap: 0.5rem; + border-bottom: 1px solid #e5e7eb; + margin-bottom: 1.5rem; +} + +.admin-tab { + padding: 0.5rem 1rem; + color: #6b7280; + text-decoration: none; + font-size: 0.9375rem; + border-bottom: 2px solid transparent; + margin-bottom: -1px; + transition: color 0.15s ease, border-color 0.15s ease; +} + +.admin-tab:hover { + color: #1e3a8a; + opacity: 1; +} + +.admin-tab--active { + color: #1e3a8a; + border-bottom-color: #1e3a8a; + font-weight: 600; +} + .add-btn--disabled { background: #f3f4f6; color: #9ca3af; diff --git a/app/controllers/admin/schedule_items_controller.rb b/app/controllers/admin/schedule_items_controller.rb index 57f6265..cc77ffd 100644 --- a/app/controllers/admin/schedule_items_controller.rb +++ b/app/controllers/admin/schedule_items_controller.rb @@ -49,12 +49,13 @@ def schedule_item_params attrs = params.require(:schedule_item).permit( :day, :time_label, :sort_time, :title, :host, :location, :map_url, :description, :kind, :flexible, :is_public, :audience, - :embassy_mode, :embassy_capacity + :embassy_mode, :embassy_capacity, :volunteer_capacity ) unless attrs[:kind] == "embassy" attrs[:embassy_mode] = nil attrs[:embassy_capacity] = nil end + attrs[:volunteer_capacity] = nil unless attrs[:kind] == "volunteer" attrs end end diff --git a/app/controllers/admin/volunteer_signups_controller.rb b/app/controllers/admin/volunteer_signups_controller.rb new file mode 100644 index 0000000..5fbec51 --- /dev/null +++ b/app/controllers/admin/volunteer_signups_controller.rb @@ -0,0 +1,20 @@ +class Admin::VolunteerSignupsController < AdminController + def create + user = User.where(role: [ :volunteer, :admin ]).find(params[:user_id]) + slot = ScheduleItem.volunteer.find(params[:schedule_item_id]) + PlanItem.create!(user: user, schedule_item: slot) + redirect_back fallback_location: admin_volunteers_path, + notice: "Assigned #{user.full_name} to #{slot.title}." + rescue ActiveRecord::RecordInvalid => e + redirect_back fallback_location: admin_volunteers_path, alert: e.message + end + + def destroy + plan_item = PlanItem.find(params[:id]) + user_name = plan_item.user.full_name + title = plan_item.schedule_item.title + plan_item.destroy + redirect_back fallback_location: admin_volunteers_path, + notice: "Removed #{user_name} from #{title}." + end +end diff --git a/app/controllers/admin/volunteer_slots_controller.rb b/app/controllers/admin/volunteer_slots_controller.rb new file mode 100644 index 0000000..2a20fe3 --- /dev/null +++ b/app/controllers/admin/volunteer_slots_controller.rb @@ -0,0 +1,21 @@ +class Admin::VolunteerSlotsController < AdminController + before_action :set_slot, only: %i[show] + + def index + @slots = ScheduleItem.volunteer.ordered + end + + def show + @signups = @slot.plan_items.includes(:user) + signed_up_ids = @signups.map(&:user_id) + @available_volunteers = User.where(role: [ :volunteer, :admin ]) + .where.not(id: signed_up_ids) + .order(:last_name, :first_name) + end + + private + + def set_slot + @slot = ScheduleItem.volunteer.find(params[:id]) + end +end diff --git a/app/controllers/admin/volunteers_controller.rb b/app/controllers/admin/volunteers_controller.rb new file mode 100644 index 0000000..9d7c476 --- /dev/null +++ b/app/controllers/admin/volunteers_controller.rb @@ -0,0 +1,35 @@ +class Admin::VolunteersController < AdminController + before_action :set_volunteer, only: %i[show] + + def index + @volunteers = User.where(role: [ :volunteer, :admin ]) + .order(:last_name, :first_name) + @slot_counts = PlanItem.joins(:schedule_item) + .merge(ScheduleItem.volunteer) + .group(:user_id) + .count + end + + def show + @slots = @volunteer.plan_items + .joins(:schedule_item) + .merge(ScheduleItem.volunteer) + .includes(:schedule_item) + .sort_by { |pi| + [ + ScheduleItem::DAY_META.keys.index(pi.schedule_item.day) || 99, + pi.schedule_item.sort_time.to_i + ] + } + signed_up_ids = @slots.map(&:schedule_item_id) + @available_slots = ScheduleItem.volunteer + .where.not(id: signed_up_ids) + .ordered + end + + private + + def set_volunteer + @volunteer = User.where(role: [ :volunteer, :admin ]).find(params[:id]) + end +end diff --git a/app/controllers/dashboard_controller.rb b/app/controllers/dashboard_controller.rb index 9c36ed0..02e0cfd 100644 --- a/app/controllers/dashboard_controller.rb +++ b/app/controllers/dashboard_controller.rb @@ -2,5 +2,8 @@ class DashboardController < ApplicationController before_action :authenticate_user! def show + if current_user.volunteer? || current_user.admin? + @empty_volunteer_count = ScheduleItem.volunteer_empty.count + end end end diff --git a/app/javascript/controllers/embassy_settings_controller.js b/app/javascript/controllers/embassy_settings_controller.js index f2a7f62..fb1eea0 100644 --- a/app/javascript/controllers/embassy_settings_controller.js +++ b/app/javascript/controllers/embassy_settings_controller.js @@ -1,17 +1,18 @@ import { Controller } from "@hotwired/stimulus" -// Toggles visibility of the embassy-only settings (capacity, mode) on the -// admin schedule-item form based on the kind dropdown. +// Toggles visibility of kind-specific settings panels (embassy and volunteer) +// on the admin schedule-item form based on the kind dropdown. export default class extends Controller { - static targets = ["kindSelect", "extras"] + static targets = ["kindSelect", "extras", "volunteerExtras"] connect() { this.toggle() } toggle() { - if (!this.hasExtrasTarget || !this.hasKindSelectTarget) return - const isEmbassy = this.kindSelectTarget.value === "embassy" - this.extrasTarget.hidden = !isEmbassy + if (!this.hasKindSelectTarget) return + const kind = this.kindSelectTarget.value + if (this.hasExtrasTarget) this.extrasTarget.hidden = kind !== "embassy" + if (this.hasVolunteerExtrasTarget) this.volunteerExtrasTarget.hidden = kind !== "volunteer" } } diff --git a/app/models/plan_item.rb b/app/models/plan_item.rb index 07d482d..0d99c07 100644 --- a/app/models/plan_item.rb +++ b/app/models/plan_item.rb @@ -7,10 +7,19 @@ class PlanItem < ApplicationRecord scope: :schedule_item_id, message: "already has this item on their plan" } + validate :volunteer_slot_not_full, on: :create scope :for_day, ->(day_key) { joins(:schedule_item) .where(schedule_items: { day: day_key }) .order("schedule_items.sort_time") } + + private + + def volunteer_slot_not_full + return unless schedule_item&.volunteer? + return unless schedule_item.volunteer_full? + errors.add(:base, "This volunteer slot is full") + end end diff --git a/app/models/schedule_item.rb b/app/models/schedule_item.rb index 97a98a2..9e9922c 100644 --- a/app/models/schedule_item.rb +++ b/app/models/schedule_item.rb @@ -18,6 +18,8 @@ class ScheduleItem < ApplicationRecord validates :kind, presence: true validates :embassy_mode, inclusion: { in: EMBASSY_MODES }, allow_nil: true validates :embassy_capacity, numericality: { only_integer: true, greater_than: 0 }, allow_nil: true + validates :volunteer_capacity, numericality: { only_integer: true, greater_than: 0 }, allow_nil: true + validates :volunteer_capacity, presence: true, if: :volunteer? DAY_META = { "wed" => { label: "Wednesday", date: "April 29", subtitle: "Pre-Conference" }, @@ -53,6 +55,7 @@ class ScheduleItem < ApplicationRecord scope :by_kind, ->(kind) { kind.present? && kinds.key?(kind.to_s) ? where(kind: kind) : all } + scope :volunteer_empty, -> { volunteer.where.missing(:plan_items) } # Creators always get auto-added to their own plan — whether the item is # private (only they see it) or public (others can RSVP). The rationale: @@ -76,6 +79,34 @@ def full? embassy_capacity.present? && seats_remaining.zero? end + def volunteer_signup_count + plan_items.count + end + + def volunteer_seats_remaining + return nil unless volunteer_capacity + [ volunteer_capacity - volunteer_signup_count, 0 ].max + end + + def volunteer_empty? + volunteer? && volunteer_signup_count.zero? + end + + def volunteer_full? + volunteer? && volunteer_capacity.present? && volunteer_seats_remaining.zero? + end + + def volunteer_partial? + volunteer? && !volunteer_empty? && !volunteer_full? + end + + def volunteer_state + return nil unless volunteer? + return :empty if volunteer_empty? + return :full if volunteer_full? + :partial + end + def editable_by?(user) return false if user.nil? user.admin? || created_by_id == user.id diff --git a/app/views/admin/dashboard/show.html.erb b/app/views/admin/dashboard/show.html.erb index 34af9f0..ea5c260 100644 --- a/app/views/admin/dashboard/show.html.erb +++ b/app/views/admin/dashboard/show.html.erb @@ -32,11 +32,10 @@
Manage attendees, volunteers, and admins. Sync from Tito.
<% end %> -
+ <%= link_to admin_volunteers_path, class: "action-card" do %>
Volunteers
Coordinate volunteer signups, shifts, and assignments.
- Soon -
+ <% end %> <%= link_to admin_schedule_items_path, class: "action-card" do %>
Schedule
diff --git a/app/views/admin/schedule_items/_form.html.erb b/app/views/admin/schedule_items/_form.html.erb index 5687f62..01fcfd7 100644 --- a/app/views/admin/schedule_items/_form.html.erb +++ b/app/views/admin/schedule_items/_form.html.erb @@ -129,6 +129,24 @@ +
> +

Volunteer-only settings

+

+ These fields only apply when Kind = Volunteer. Set how + many people you need to fill this slot — once it hits capacity, signups + are blocked. +

+ +
+ <%= f.label :volunteer_capacity, "Volunteers needed", class: "label" %> + <%= f.number_field :volunteer_capacity, min: 1, max: 50, + class: "input", style: "max-width: 160px;", + placeholder: "e.g., 3" %> +
+
+
<%= f.submit class: "btn btn-navy" %> <%= link_to "Cancel", admin_schedule_items_path, class: "btn btn-muted" %> diff --git a/app/views/admin/volunteer_slots/index.html.erb b/app/views/admin/volunteer_slots/index.html.erb new file mode 100644 index 0000000..b69edb0 --- /dev/null +++ b/app/views/admin/volunteer_slots/index.html.erb @@ -0,0 +1,53 @@ +<% content_for :container_width, "max-w-6xl" %> + +

Volunteers

+ +<%= render "admin/volunteers/tabs" %> + +<% if @slots.empty? %> +

+ No volunteer slots yet. Create one from + <%= link_to "Schedule", admin_schedule_items_path, class: "text-blue underline" %> + (set Kind to Volunteer). +

+<% else %> +
+ + + + + + + + + + + <% @slots.each do |slot| %> + <% state = slot.volunteer_state %> + + + + + + + <% end %> + +
SlotDay & timeStatus
<%= slot.title %> + <%= ScheduleItem::DAY_META.dig(slot.day, :label) %>
+ <%= slot.time_label %> +
+ + <% case state + when :empty %> + Help wanted! 0 of <%= slot.volunteer_capacity %> + <% when :partial %> + <%= slot.volunteer_signup_count %> of <%= slot.volunteer_capacity %> + <% when :full %> + Filled (<%= slot.volunteer_capacity %> of <%= slot.volunteer_capacity %>) + <% end %> + + + <%= link_to "View", admin_volunteer_slot_path(slot), class: "btn btn-muted" %> +
+
+<% end %> diff --git a/app/views/admin/volunteer_slots/show.html.erb b/app/views/admin/volunteer_slots/show.html.erb new file mode 100644 index 0000000..2d31b2c --- /dev/null +++ b/app/views/admin/volunteer_slots/show.html.erb @@ -0,0 +1,84 @@ +<% content_for :container_width, "max-w-6xl" %> + +
+ <%= link_to "← All slots", admin_volunteer_slots_path, class: "text-blue underline" %> +
+ +<% state = @slot.volunteer_state %> + +
+
+

<%= @slot.title %>

+

+ <%= ScheduleItem::DAY_META.dig(@slot.day, :label) %> · <%= @slot.time_label %> + <% if @slot.location.present? %> · <%= @slot.location %><% end %> +

+
+ + <% case state + when :empty %> + Help wanted! 0 of <%= @slot.volunteer_capacity %> + <% when :partial %> + <%= @slot.volunteer_signup_count %> of <%= @slot.volunteer_capacity %> + <% when :full %> + Filled (<%= @slot.volunteer_capacity %> of <%= @slot.volunteer_capacity %>) + <% end %> + +
+ +<%= render "admin/volunteers/tabs" %> + +
+ <%= link_to "Edit slot", edit_admin_schedule_item_path(@slot), class: "btn btn-muted" %> +
+ +

+ Signed up + (<%= @signups.size %>) +

+ +<% if @signups.any? %> + +<% else %> +

Nobody has signed up yet.

+<% end %> + +

Add a volunteer

+ +<% if @slot.volunteer_full? %> +

This slot is full. Remove someone above to free up capacity.

+<% elsif @available_volunteers.any? %> +
+ <%= form_with url: admin_volunteer_signups_path, method: :post, class: "space-y-4" do |f| %> + <%= hidden_field_tag :schedule_item_id, @slot.id %> +
+ <%= label_tag :user_id, "Volunteer", class: "label" %> + <%= select_tag :user_id, + options_from_collection_for_select(@available_volunteers, :id, :full_name), + include_blank: "— Select a volunteer —", class: "select" %> +
+
+ <%= f.submit "Add to this slot", class: "btn btn-navy" %> +
+ <% end %> +
+<% else %> +

+ Everyone with the volunteer role is already signed up. Promote more attendees from + <%= link_to "Attendees", admin_users_path, class: "text-blue underline" %>. +

+<% end %> diff --git a/app/views/admin/volunteers/_tabs.html.erb b/app/views/admin/volunteers/_tabs.html.erb new file mode 100644 index 0000000..7f50359 --- /dev/null +++ b/app/views/admin/volunteers/_tabs.html.erb @@ -0,0 +1,6 @@ + diff --git a/app/views/admin/volunteers/index.html.erb b/app/views/admin/volunteers/index.html.erb new file mode 100644 index 0000000..f5b95be --- /dev/null +++ b/app/views/admin/volunteers/index.html.erb @@ -0,0 +1,35 @@ +<% content_for :container_width, "max-w-6xl" %> + +

Volunteers

+ +<%= render "admin/volunteers/tabs" %> + +<% if @volunteers.empty? %> +

No volunteers yet. Promote attendees from <%= link_to "Attendees", admin_users_path, class: "text-blue underline" %>.

+<% else %> +
+ + + + + + + + + + + <% @volunteers.each do |volunteer| %> + <% count = @slot_counts[volunteer.id].to_i %> + + + + + + + <% end %> + +
NameRoleSlots signed up for
<%= volunteer.full_name %><%= volunteer.role.humanize %><%= count %> + <%= link_to "View", admin_volunteer_path(volunteer), class: "btn btn-muted" %> +
+
+<% end %> diff --git a/app/views/admin/volunteers/show.html.erb b/app/views/admin/volunteers/show.html.erb new file mode 100644 index 0000000..2200335 --- /dev/null +++ b/app/views/admin/volunteers/show.html.erb @@ -0,0 +1,75 @@ +<% content_for :container_width, "max-w-6xl" %> + +
+ <%= link_to "← All volunteers", admin_volunteers_path, class: "text-blue underline" %> +
+ +
+
+

<%= @volunteer.full_name %>

+

<%= @volunteer.email %>

+
+ <%= @volunteer.role.humanize %> +
+ +<%= render "admin/volunteers/tabs" %> + +

+ Volunteer slots + (<%= @slots.size %>) +

+ +<% if @slots.any? %> + +<% else %> +

Not signed up for any volunteer slots yet.

+<% end %> + +

Assign to a volunteer slot

+ +<% if @available_slots.any? %> +
+ <%= form_with url: admin_volunteer_signups_path, method: :post, class: "space-y-4" do |f| %> + <%= hidden_field_tag :user_id, @volunteer.id %> +
+ <%= label_tag :schedule_item_id, "Volunteer slot", class: "label" %> + <% + slot_options = @available_slots.map do |s| + status = s.volunteer_full? ? "FULL" : "#{s.volunteer_signup_count} of #{s.volunteer_capacity}" + label = "#{s.title} — #{ScheduleItem::DAY_META.dig(s.day, :label)} #{s.time_label} (#{status})" + [ label, s.id ] + end + %> + <%= select_tag :schedule_item_id, + options_for_select(slot_options), + include_blank: "— Select a slot —", class: "select" %> +
+
+ <%= f.submit "Assign", class: "btn btn-navy" %> +
+ <% end %> +
+<% else %> +

No more volunteer slots available — they're already on every one.

+<% end %> diff --git a/app/views/dashboard/show.html.erb b/app/views/dashboard/show.html.erb index d15fd5e..ace9ca1 100644 --- a/app/views/dashboard/show.html.erb +++ b/app/views/dashboard/show.html.erb @@ -13,11 +13,12 @@ What would you like to do?

- <% if current_user.volunteer? || current_user.admin? %> + <% if (current_user.volunteer? || current_user.admin?) && @empty_volunteer_count.to_i > 0 %> <%= link_to schedule_path(kind: :volunteer), class: "action-card action-card--hero mb-6" do %>

Volunteers Needed

- We could use a hand. Pick a slot whenever you've got time. + We need help with <%= pluralize(@empty_volunteer_count, "thing") %>. + Pick a slot whenever you've got time.

<% end %> diff --git a/app/views/layouts/admin.html.erb b/app/views/layouts/admin.html.erb index 566614f..573aab3 100644 --- a/app/views/layouts/admin.html.erb +++ b/app/views/layouts/admin.html.erb @@ -24,10 +24,7 @@ <%= link_to "Ruby Embassy Admin", admin_root_path, class: "font-semibold" %> <%= link_to "Admin Dashboard", admin_root_path %> <%= link_to "Attendees", admin_users_path %> - - Volunteers - Soon - + <%= link_to "Volunteers", admin_volunteers_path %> <%= link_to "Schedule", admin_schedule_items_path %> <%= link_to "Embassy Applications", admin_embassy_applications_path %> @@ -46,10 +43,7 @@