Skip to content
Draft
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
1 change: 1 addition & 0 deletions app/assets/stylesheets/application.scss
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
245 changes: 245 additions & 0 deletions app/assets/stylesheets/pages/certification/ships/_spot_checks.scss
Original file line number Diff line number Diff line change
@@ -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);
}
}
101 changes: 101 additions & 0 deletions app/controllers/admin/certification/ship_spot_checks_controller.rb
Original file line number Diff line number Diff line change
@@ -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
1 change: 1 addition & 0 deletions app/controllers/admin/certification/ships_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions app/javascript/controllers/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down
12 changes: 12 additions & 0 deletions app/javascript/controllers/spot_check_form_controller.js
Original file line number Diff line number Diff line change
@@ -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"
})
}
}
2 changes: 2 additions & 0 deletions app/models/certification/ship.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Loading