diff --git a/app/assets/stylesheets/application.scss b/app/assets/stylesheets/application.scss index 7775f1b78..eb29ffb78 100644 --- a/app/assets/stylesheets/application.scss +++ b/app/assets/stylesheets/application.scss @@ -82,6 +82,7 @@ @use "pages/certification/ships/index" as certification_ships_index; @use "pages/certification/ships/show" as certification_ships_show; @use "pages/certification/ships/monitor" as certification_ships_monitor; +@use "pages/certification/ships/spot_checks" as certification_ships_spot_checks; @use "pages/certification/mystats" as certification_mystats; @use "pages/certification/payouts" as certification_payouts; @use "pages/raffle"; diff --git a/app/assets/stylesheets/pages/certification/ships/_spot_checks.scss b/app/assets/stylesheets/pages/certification/ships/_spot_checks.scss new file mode 100644 index 000000000..345a14526 --- /dev/null +++ b/app/assets/stylesheets/pages/certification/ships/_spot_checks.scss @@ -0,0 +1,245 @@ + +.spot-checks { + &__stats { + display: flex; + flex-wrap: wrap; + gap: var(--space-m); + padding: var(--space-m) var(--space-l); + margin-bottom: var(--space-l); + background: var(--card-bg, rgba(255, 255, 255, 0.04)); + border: 1px solid var(--card-border, rgba(255, 255, 255, 0.1)); + border-radius: 12px; + } + + &__stat { + display: flex; + flex-direction: column; + gap: 2px; + padding-right: var(--space-l); + border-right: 1px solid rgba(255, 255, 255, 0.1); + + &:last-child { + border-right: none; + padding-right: 0; + } + } + + &__stat-value { + font-size: 1.5rem; + font-weight: 700; + line-height: 1; + font-variant-numeric: tabular-nums; + + small { + font-size: 0.8rem; + font-weight: 400; + color: var(--muted, rgba(255, 255, 255, 0.7)); + } + + &--good { + color: #4ade80; + } + + &--bad { + color: #f87171; + } + + &--muted { + color: var(--muted, rgba(255, 255, 255, 0.7)); + } + } + + &__stat-label { + font-size: 0.72rem; + text-transform: uppercase; + letter-spacing: 0.06em; + color: var(--muted, rgba(255, 255, 255, 0.7)); + } + + &__cell-spotcheck { + min-width: 260px; + vertical-align: top !important; + padding-top: 12px !important; + padding-bottom: 12px !important; + } + + &__form { + display: flex; + flex-direction: column; + gap: var(--space-xs); + } + + &__form-radios { + display: flex; + gap: var(--space-s); + align-items: center; + } + + &__radio-label { + display: inline-flex; + align-items: center; + gap: var(--space-xxs); + padding: 4px 10px; + border-radius: 999px; + border: 1px solid rgba(255, 255, 255, 0.15); + cursor: pointer; + font-size: 0.85rem; + font-weight: 600; + transition: + border-color 150ms ease, + background 150ms ease; + + &--good { + &:has(input:checked), + &:hover { + border-color: #4ade80; + background: rgba(74, 222, 128, 0.12); + color: #4ade80; + } + } + + &--bad { + &:has(input:checked), + &:hover { + border-color: #f87171; + background: rgba(248, 113, 113, 0.12); + color: #f87171; + } + } + } + + &__radio { + position: absolute; + opacity: 0; + width: 0; + height: 0; + pointer-events: none; + } + + &__radio-text { + user-select: none; + } + + &__justification-wrap { + display: flex; + flex-direction: column; + gap: var(--space-xxs); + } + + &__field-label { + font-size: 0.72rem; + text-transform: uppercase; + letter-spacing: 0.06em; + color: var(--muted, rgba(255, 255, 255, 0.7)); + } + + &__textarea { + width: 100%; + padding: var(--space-xs) var(--space-s); + border-radius: 8px; + border: 1px solid rgba(255, 255, 255, 0.15); + background: rgba(0, 0, 0, 0.3); + color: var(--color-space-text); + font-family: inherit; + font-size: 0.85rem; + resize: vertical; + color-scheme: dark; + + &:focus { + outline: none; + border-color: var(--color-brand-mint); + } + } + + &__form-actions { + display: flex; + align-items: center; + gap: var(--space-s); + } + + &__result { + display: flex; + flex-direction: column; + gap: 4px; + padding: var(--space-xs) var(--space-s); + border-radius: 8px; + margin-bottom: var(--space-xs); + + &--good { + background: rgba(74, 222, 128, 0.08); + border: 1px solid rgba(74, 222, 128, 0.25); + } + + &--bad { + background: rgba(248, 113, 113, 0.08); + border: 1px solid rgba(248, 113, 113, 0.25); + } + } + + &__result-badge { + font-size: 0.8rem; + font-weight: 700; + + .spot-checks__result--good & { + color: #4ade80; + } + + .spot-checks__result--bad & { + color: #f87171; + } + } + + &__result-justification { + margin: 0; + font-size: 0.8rem; + color: var(--muted, rgba(255, 255, 255, 0.7)); + font-style: italic; + } + + &__result-meta { + font-size: 0.72rem; + color: var(--muted, rgba(255, 255, 255, 0.5)); + } + + // --- Edit override disclosure -------------------------------------------- + &__override { + margin-top: var(--space-xxs); + } + + &__override-toggle { + font-size: 0.75rem; + color: var(--muted, rgba(255, 255, 255, 0.6)); + cursor: pointer; + user-select: none; + + &:hover { + color: var(--color-brand-mint); + } + + &::marker { + content: ""; + } + + &::-webkit-details-marker { + display: none; + } + } + + &__feedback-hint { + color: var(--color-brand-peach); + font-size: 0.78rem; + } + + &__mobile-card { + display: flex; + flex-direction: column; + gap: var(--space-xs); + padding: var(--space-m); + } + + &__mobile-form { + margin-top: var(--space-xs); + border-top: 1px solid rgba(255, 255, 255, 0.08); + padding-top: var(--space-xs); + } +} diff --git a/app/controllers/admin/certification/ship_spot_checks_controller.rb b/app/controllers/admin/certification/ship_spot_checks_controller.rb new file mode 100644 index 000000000..449d6cc37 --- /dev/null +++ b/app/controllers/admin/certification/ship_spot_checks_controller.rb @@ -0,0 +1,101 @@ +class Admin::Certification::ShipSpotChecksController < Admin::Certification::ApplicationController + before_action :set_body_class + + def index + authorize ::Certification::ShipSpotCheck, policy_class: Admin::Certification::ShipSpotCheckPolicy + + @reviewer_id = params[:reviewer_id].presence + @status = params[:status].presence_in(%w[approved returned all]) || "all" + @sort = params[:sort] == "oldest" ? "oldest" : "newest" + @rating = params[:rating].presence_in(%w[good bad unchecked]) || "unchecked" + + @reviewers = User.joins( + "INNER JOIN certification_ship_reviews ON certification_ship_reviews.reviewer_id = users.id" + ).where.not("certification_ship_reviews.status" => 0) + .distinct + .order(:display_name) + + scope = ::Certification::Ship + .joins(:project, :reviewer) + .where.not(status: :pending) + .where(projects: { deleted_at: nil }) + .includes(:reviewer, :spot_check, project: { memberships: :user }) + + scope = scope.where(reviewer_id: @reviewer_id) if @reviewer_id.present? + scope = scope.where(status: @status) unless @status == "all" + + scope = case @rating + when "unchecked" + scope.where.missing(:spot_check) + when "good" + scope.joins(:spot_check).where(certification_ship_spot_checks: { rating: :good }) + when "bad" + scope.joins(:spot_check).where(certification_ship_spot_checks: { rating: :bad }) + else + scope + end + + scope = scope.order(decided_at: @sort == "oldest" ? :asc : :desc) + + @pagy, @ships = pagy(:offset, scope, limit: 25) + + @spot_checks_by_ship = ::Certification::ShipSpotCheck + .where(ship_id: @ships.map(&:id)) + .index_by(&:ship_id) + + @stats = spot_check_stats + + @lb_period = params[:lb].presence_in(%w[daily weekly alltime]) || "daily" + @leaderboards = { + "daily" => ::Certification::ShipSpotCheck.leaderboard(:daily), + "weekly" => ::Certification::ShipSpotCheck.leaderboard(:weekly), + "alltime" => ::Certification::ShipSpotCheck.leaderboard(:alltime) + } + end + + def create + authorize ::Certification::ShipSpotCheck, policy_class: Admin::Certification::ShipSpotCheckPolicy + + @ship = ::Certification::Ship.find(params[:ship_id]) + @spot_check = ::Certification::ShipSpotCheck.find_or_initialize_by(ship_id: @ship.id) + @spot_check.assign_attributes(spot_check_params.merge(checker: current_user)) + + if @spot_check.save + redirect_back_or_to spot_checks_admin_certification_ships_path(return_params), + notice: "Spot check recorded for \"#{@ship.project.title}\"." + else + redirect_back_or_to spot_checks_admin_certification_ships_path(return_params), + alert: "Couldn't save spot check: #{@spot_check.errors.full_messages.to_sentence}" + end + end + + private + + def set_body_class + @body_class = "app-layout-page" + end + + def spot_check_params + params.require(:certification_ship_spot_check).permit(:rating, :justification) + end + + def return_params + params.permit(:reviewer_id, :status, :sort, :rating, :page) + end + + def spot_check_stats + all_decided = ::Certification::Ship.where.not(status: :pending).count + checked = ::Certification::ShipSpotCheck.count + good_count = ::Certification::ShipSpotCheck.good.count + bad_count = ::Certification::ShipSpotCheck.bad.count + + { + total_decided: all_decided, + checked: checked, + unchecked: all_decided - checked, + good: good_count, + bad: bad_count, + coverage_pct: all_decided.zero? ? nil : (checked * 100.0 / all_decided).round(1) + } + end +end diff --git a/app/controllers/admin/certification/ships_controller.rb b/app/controllers/admin/certification/ships_controller.rb index 2e9b148f5..b0acc248d 100644 --- a/app/controllers/admin/certification/ships_controller.rb +++ b/app/controllers/admin/certification/ships_controller.rb @@ -71,6 +71,7 @@ def logs def show authorize @ship @reviewed_today = ::Certification::Ship.reviewed_today(current_user) + @spot_check = ::Certification::ShipSpotCheck.find_by(ship_id: @ship.id) if current_user.admin? || current_user.super_admin? end def set_project_type diff --git a/app/javascript/controllers/index.js b/app/javascript/controllers/index.js index 40742bf1a..194a8f8cd 100644 --- a/app/javascript/controllers/index.js +++ b/app/javascript/controllers/index.js @@ -301,6 +301,9 @@ application.register("slack-doodle", SlackDoodleController); import SortableController from "./sortable_controller"; application.register("sortable", SortableController); +import SpotCheckFormController from "./spot_check_form_controller"; +application.register("spot-check-form", SpotCheckFormController); + import StarImageInputController from "./star_image_input_controller"; application.register("star-image-input", StarImageInputController); diff --git a/app/javascript/controllers/spot_check_form_controller.js b/app/javascript/controllers/spot_check_form_controller.js new file mode 100644 index 000000000..6f1e46631 --- /dev/null +++ b/app/javascript/controllers/spot_check_form_controller.js @@ -0,0 +1,12 @@ +import { Controller } from "@hotwired/stimulus" + +export default class extends Controller { + static targets = ["justification"] + + toggleJustification(event) { + const isBad = event.target.value === "bad" + this.justificationTargets.forEach(el => { + el.style.display = isBad ? "" : "none" + }) + } +} diff --git a/app/models/certification/ship.rb b/app/models/certification/ship.rb index f1531b9fa..d1c07ad61 100644 --- a/app/models/certification/ship.rb +++ b/app/models/certification/ship.rb @@ -44,6 +44,8 @@ class Ship < ApplicationRecord belongs_to :reviewer, class_name: "User", optional: true belongs_to :returned_by, class_name: "User", optional: true + has_one :spot_check, class_name: "Certification::ShipSpotCheck", foreign_key: :ship_id, dependent: :destroy + has_paper_trail # The reviewer records a walkthrough and passes it along with the verdict. diff --git a/app/models/certification/ship_spot_check.rb b/app/models/certification/ship_spot_check.rb new file mode 100644 index 000000000..b25b94a9e --- /dev/null +++ b/app/models/certification/ship_spot_check.rb @@ -0,0 +1,56 @@ +# == Schema Information +# +# Table name: certification_ship_spot_checks +# +# id :bigint not null, primary key +# justification :text +# rating :integer default("good"), not null +# created_at :datetime not null +# updated_at :datetime not null +# checker_id :bigint not null +# ship_id :bigint not null +# +# Indexes +# +# index_certification_ship_spot_checks_on_checker_id (checker_id) +# index_certification_ship_spot_checks_on_ship_id (ship_id) +# index_ship_spot_checks_unique_per_ship (ship_id) UNIQUE +# +# Foreign Keys +# +# fk_rails_... (checker_id => users.id) +# fk_rails_... (ship_id => certification_ship_reviews.id) +# +module Certification + class ShipSpotCheck < ApplicationRecord + self.table_name = "certification_ship_spot_checks" + + belongs_to :ship, class_name: "Certification::Ship", foreign_key: :ship_id + belongs_to :checker, class_name: "User" + + has_paper_trail + + enum :rating, { good: 0, bad: 1 }, default: :good + + validates :rating, presence: true + validates :justification, presence: true, if: :bad? + validates :justification, length: { maximum: 5_000 }, allow_blank: true + validates :ship_id, uniqueness: { message: "has already been spot-checked" } + + def self.leaderboard(period) + scope = joins(:checker) + scope = case period + when :daily then scope.where("certification_ship_spot_checks.created_at >= ?", Time.current.beginning_of_day) + when :weekly then scope.where("certification_ship_spot_checks.created_at >= ?", Time.current.beginning_of_week) + else scope + end + + scope + .group("users.id", "users.display_name") + .order(Arel.sql("count_all DESC")) + .limit(10) + .count + .map { |(id, name), count| { id: id, name: name, count: count } } + end + end +end diff --git a/app/models/reviewer_payout_request.rb b/app/models/reviewer_payout_request.rb index 52ebc7e87..bc7874410 100644 --- a/app/models/reviewer_payout_request.rb +++ b/app/models/reviewer_payout_request.rb @@ -12,9 +12,6 @@ # paid_amount :integer # paid_at :datetime # created_at :datetime not null -# updated_at :datetime not null -# admin_id :bigint -# user_id :bigint not null # # Indexes # diff --git a/app/policies/admin/certification/ship_spot_check_policy.rb b/app/policies/admin/certification/ship_spot_check_policy.rb new file mode 100644 index 000000000..2c9f2b8be --- /dev/null +++ b/app/policies/admin/certification/ship_spot_check_policy.rb @@ -0,0 +1,6 @@ +# frozen_string_literal: true + +class Admin::Certification::ShipSpotCheckPolicy < ApplicationPolicy + def index? = user&.admin? + def create? = user&.admin? +end diff --git a/app/views/admin/certification/ship_spot_checks/_form.html.erb b/app/views/admin/certification/ship_spot_checks/_form.html.erb new file mode 100644 index 000000000..51c2f92c8 --- /dev/null +++ b/app/views/admin/certification/ship_spot_checks/_form.html.erb @@ -0,0 +1,40 @@ +<% filter_params = request.query_parameters.slice(:reviewer_id, :status, :sort, :rating, :page) %> +<%= form_with model: existing || Certification::ShipSpotCheck.new, + url: admin_certification_ship_spot_checks_path(ship_id: ship.id, **filter_params), + class: "spot-checks__form", + data: { controller: "spot-check-form" } do |f| %> + +
No reviews match these filters.
+| Project | +Feedback | +Status | +Spot check | +
|---|---|---|---|
|
+
+ <%= link_to ship.project.title, admin_certification_ship_path(ship), class: "ship-queue__project-title", target: "_blank" %>
+ #<%= ship.id %>
+
+
+ |
+
+ <% if ship.feedback.present? %>
+ <%= truncate(ship.feedback, length: 120) %> + <% else %> + — + <% end %> + <% if ship.verdict_video.attached? %> + <%= link_to "▶ Watch video", url_for(ship.verdict_video), class: "action-btn action-btn--small action-btn--secondary", target: "_blank", rel: "noopener", style: "display: inline-flex; align-items: center; padding: 4px 8px; font-size: 0.75rem;" %> + <% end %> + |
+ + <%= ship.status.capitalize %> + | +
+ <% if existing %>
+
+
+
+
+ <% else %>
+ <%= render "form", ship: ship, existing: nil %>
+ <% end %>
+ Edit+ <%= render "form", ship: ship, existing: existing %> + |
+