Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 60 additions & 0 deletions app/assets/stylesheets/application.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
3 changes: 2 additions & 1 deletion app/controllers/admin/schedule_items_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
20 changes: 20 additions & 0 deletions app/controllers/admin/volunteer_signups_controller.rb
Original file line number Diff line number Diff line change
@@ -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
21 changes: 21 additions & 0 deletions app/controllers/admin/volunteer_slots_controller.rb
Original file line number Diff line number Diff line change
@@ -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
35 changes: 35 additions & 0 deletions app/controllers/admin/volunteers_controller.rb
Original file line number Diff line number Diff line change
@@ -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
3 changes: 3 additions & 0 deletions app/controllers/dashboard_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
13 changes: 7 additions & 6 deletions app/javascript/controllers/embassy_settings_controller.js
Original file line number Diff line number Diff line change
@@ -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"
}
}
9 changes: 9 additions & 0 deletions app/models/plan_item.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
31 changes: 31 additions & 0 deletions app/models/schedule_item.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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" },
Expand Down Expand Up @@ -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:
Expand All @@ -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
Expand Down
5 changes: 2 additions & 3 deletions app/views/admin/dashboard/show.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -32,11 +32,10 @@
<div class="action-card__description">Manage attendees, volunteers, and admins. Sync from Tito.</div>
<% end %>

<div class="action-card action-card--soon">
<%= link_to admin_volunteers_path, class: "action-card" do %>
<div class="action-card__title">Volunteers</div>
<div class="action-card__description">Coordinate volunteer signups, shifts, and assignments.</div>
<span class="action-card__badge">Soon</span>
</div>
<% end %>

<%= link_to admin_schedule_items_path, class: "action-card" do %>
<div class="action-card__title">Schedule</div>
Expand Down
18 changes: 18 additions & 0 deletions app/views/admin/schedule_items/_form.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,24 @@
</div>
</div>

<div class="volunteer-form-extras"
data-embassy-settings-target="volunteerExtras"
<%= "hidden".html_safe unless schedule_item.kind == "volunteer" %>>
<h3 class="font-bold mb-2">Volunteer-only settings</h3>
<p class="text-muted text-sm mb-3">
These fields only apply when <strong>Kind = Volunteer</strong>. Set how
many people you need to fill this slot — once it hits capacity, signups
are blocked.
</p>

<div>
<%= 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" %>
</div>
</div>

<div class="flex gap-3 pt-4">
<%= f.submit class: "btn btn-navy" %>
<%= link_to "Cancel", admin_schedule_items_path, class: "btn btn-muted" %>
Expand Down
53 changes: 53 additions & 0 deletions app/views/admin/volunteer_slots/index.html.erb
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
<% content_for :container_width, "max-w-6xl" %>

<h1 class="text-2xl font-bold text-navy mb-6">Volunteers</h1>

<%= render "admin/volunteers/tabs" %>

<% if @slots.empty? %>
<p class="text-gray">
No volunteer slots yet. Create one from
<%= link_to "Schedule", admin_schedule_items_path, class: "text-blue underline" %>
(set Kind to Volunteer).
</p>
<% else %>
<div class="table-scroll">
<table class="table">
<thead>
<tr>
<th>Slot</th>
<th>Day &amp; time</th>
<th>Status</th>
<th></th>
</tr>
</thead>
<tbody>
<% @slots.each do |slot| %>
<% state = slot.volunteer_state %>
<tr>
<td class="font-medium text-navy"><%= slot.title %></td>
<td>
<%= ScheduleItem::DAY_META.dig(slot.day, :label) %><br>
<span class="text-gray text-sm"><%= slot.time_label %></span>
</td>
<td>
<span class="volunteer-capacity volunteer-capacity--<%= state %>" style="text-align: left;">
<% 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 %>
</span>
</td>
<td class="text-right">
<%= link_to "View", admin_volunteer_slot_path(slot), class: "btn btn-muted" %>
</td>
</tr>
<% end %>
</tbody>
</table>
</div>
<% end %>
Loading
Loading