Skip to content

Commit 9613583

Browse files
authored
add flagged queue (#423)
1 parent c465298 commit 9613583

5 files changed

Lines changed: 124 additions & 12 deletions

File tree

app/controllers/admin/application_controller.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ def admin_stats_payload
3030
pending_reviews_tier_3: tier_counts["tier_3"].to_i,
3131
pending_reviews_tier_4: tier_counts["tier_4"].to_i,
3232
pending_requirements_checks: Project.kept.where(status: :pending).requirements_unchecked.count,
33+
flagged_projects: Project.kept.flagged_for_review.count,
3334
projects: Project.kept.count,
3435
users: User.kept.count,
3536
pending_orders: Order.pending.count,

app/controllers/admin/reviews_controller.rb

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ class Admin::ReviewsController < Admin::ApplicationController
44
before_action :set_project, only: [ :show, :skip, :track, :claim ]
55

66
REQUIREMENTS_QUEUE = "requirements".freeze
7+
FLAGGED_QUEUE = "flagged".freeze
78

89
TRACKABLE_BUTTONS = %w[
910
end_session skip next_project view_project
@@ -35,6 +36,13 @@ def requirements
3536
render_queue(policy_scope(Project).kept.where(status: :pending).requirements_unchecked, REQUIREMENTS_QUEUE)
3637
end
3738

39+
def flagged
40+
base = policy_scope(Project).kept.flagged_for_review
41+
base = base.where(status: params[:status]) if Project.statuses.key?(params[:status].to_s)
42+
43+
render_queue(base, FLAGGED_QUEUE, already_flagged: true)
44+
end
45+
3846
LEADERBOARD_DECISION_ACTIONS = %w[project.approved project.returned project.rejected project.pitch_approved].freeze
3947
LEADERBOARD_WEEKLY_TARGET = 5
4048

@@ -230,12 +238,14 @@ def serialize_review_queue_row(project, sessions)
230238
name: project.name,
231239
user_id: project.user_id,
232240
user_display_name: project.user.display_name,
241+
status: project.status,
233242
tier: project.tier,
234243
is_build_review: project.build_review,
235244
waiting_since_iso: (project.submitted_at || project.created_at).iso8601,
236245
claimed_by: holder ? { name: holder.reviewer.display_name, avatar: holder.reviewer.avatar } : nil,
237246
flagged: project.flagged_for_review?,
238247
flag_reason: project.flag_reason,
248+
flagged_by_name: project.flagged_by&.display_name,
239249
requirements_checked_by: project.requirements_checked_by&.display_name
240250
}
241251
end
@@ -251,17 +261,17 @@ def require_review_access!
251261
raise ActionController::RoutingError, "Not Found"
252262
end
253263

254-
def render_queue(base, queue_key)
255-
queue = base.not_flagged_for_review
264+
def render_queue(base, queue_key, already_flagged: false)
265+
queue = already_flagged ? base : base.not_flagged_for_review
256266

257267
scope =
258268
case params[:filter]
259-
when "flagged" then base.flagged_for_review
269+
when "flagged" then already_flagged ? base : base.flagged_for_review
260270
when "design" then queue.where(build_review: false)
261271
when "build" then queue.where(build_review: true)
262272
else queue
263273
end
264-
scope = scope.includes(:user, :requirements_checked_by)
274+
scope = scope.includes(:user, :requirements_checked_by, :flagged_by)
265275
scope = scope.search(params[:query]) if params[:query].present?
266276

267277
@pagy, @projects = pagy(scope.order(Arel.sql("COALESCE(submitted_at, created_at) ASC")))
@@ -276,6 +286,7 @@ def render_queue(base, queue_key)
276286
pagy: pagy_props(@pagy),
277287
query: params[:query].to_s,
278288
filter: params[:filter].to_s,
289+
status: params[:status].to_s,
279290
tier: queue_key,
280291
allowed_tiers: available_queues,
281292
metrics: ReviewQueueMetrics.new(queue).as_json,

app/javascript/components/admin/AdminSidebar.tsx

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import {
1313
Sparkles,
1414
Bot,
1515
Flag,
16+
FlagTriangleRight,
1617
LifeBuoy,
1718
Database,
1819
Activity,
@@ -42,6 +43,7 @@ interface AdminStats {
4243
pending_reviews_tier_3: number
4344
pending_reviews_tier_4: number
4445
pending_requirements_checks: number
46+
flagged_projects: number
4547
projects: number
4648
users: number
4749
pending_orders: number
@@ -129,6 +131,13 @@ function buildSections(): { items: NavItem[] }[] {
129131
statKey: 'pending_pitches',
130132
permission: 'pending_reviews',
131133
},
134+
{
135+
label: 'Flagged Projects',
136+
href: '/admin/reviews/flagged',
137+
icon: FlagTriangleRight,
138+
statKey: 'flagged_projects',
139+
permission: 'pending_reviews',
140+
},
132141
{ label: 'Leaderboard', href: '/admin/reviews/leaderboard', icon: Trophy, permission: 'pending_reviews' },
133142
],
134143
},

app/javascript/pages/Admin/Reviews/Queue.tsx

Lines changed: 98 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -15,17 +15,22 @@ interface ReviewQueueRow {
1515
name: string
1616
user_id: number
1717
user_display_name: string
18+
status: string
1819
tier: ProjectTier
1920
is_build_review: boolean
2021
waiting_since_iso: string
2122
claimed_by: { name: string; avatar: string } | null
23+
flagged_by_name: string | null
2224
requirements_checked_by: string | null
2325
}
2426

2527
const REQUIREMENTS_QUEUE = 'requirements'
28+
const FLAGGED_QUEUE = 'flagged'
2629

2730
function queueLabel(key: string) {
28-
return key === REQUIREMENTS_QUEUE ? 'REQS' : key.toUpperCase()
31+
if (key === REQUIREMENTS_QUEUE) return 'REQS'
32+
if (key === FLAGGED_QUEUE) return 'Flagged'
33+
return key.toUpperCase()
2934
}
3035

3136
const FILTERS = [
@@ -34,6 +39,34 @@ const FILTERS = [
3439
{ key: 'build', label: 'Build' },
3540
]
3641

42+
const STATUS_FILTERS = [
43+
{ key: '', label: 'All statuses' },
44+
{ key: 'draft', label: 'Draft' },
45+
{ key: 'pending', label: 'Pending' },
46+
{ key: 'approved', label: 'Approved' },
47+
{ key: 'returned', label: 'Returned' },
48+
{ key: 'rejected', label: 'Rejected' },
49+
{ key: 'pitch_pending', label: 'Pitch Pending' },
50+
{ key: 'pitch_approved', label: 'Pitch Approved' },
51+
]
52+
53+
const STATUS_BADGE_VARIANT: Record<string, 'outline' | 'success' | 'warning' | 'destructive' | 'secondary'> = {
54+
draft: 'secondary',
55+
pending: 'outline',
56+
approved: 'success',
57+
returned: 'warning',
58+
rejected: 'destructive',
59+
pitch_pending: 'outline',
60+
pitch_approved: 'success',
61+
}
62+
63+
function statusLabel(status: string) {
64+
return status
65+
.split('_')
66+
.map((w) => w[0].toUpperCase() + w.slice(1))
67+
.join(' ')
68+
}
69+
3770
function waitBadgeVariant(seconds: number, slaHours: number): 'outline' | 'warning' | 'destructive' {
3871
const hours = seconds / 3600
3972
if (hours >= slaHours) return 'destructive'
@@ -46,6 +79,7 @@ export default function AdminReviewsQueue({
4679
pagy,
4780
query,
4881
filter,
82+
status,
4983
tier,
5084
allowed_tiers,
5185
metrics,
@@ -55,13 +89,15 @@ export default function AdminReviewsQueue({
5589
pagy: PagyProps
5690
query: string
5791
filter: string
92+
status: string
5893
tier: string
5994
allowed_tiers: string[]
6095
metrics: QueueMetrics
6196
first_pending_id: number | null
6297
}) {
6398
const [searchQuery, setSearchQuery] = useState(query)
6499
const [now, setNow] = useState(() => Date.now())
100+
const isFlaggedQueue = tier === FLAGGED_QUEUE
65101

66102
useEffect(() => {
67103
const t = setInterval(() => setNow(Date.now()), 30000)
@@ -70,13 +106,25 @@ export default function AdminReviewsQueue({
70106

71107
function submitSearch(e: React.FormEvent) {
72108
e.preventDefault()
73-
router.get(`/admin/reviews/${tier}`, { query: searchQuery, filter: filter || undefined }, { preserveState: true })
109+
router.get(
110+
`/admin/reviews/${tier}`,
111+
{ query: searchQuery, filter: filter || undefined, status: status || undefined },
112+
{ preserveState: true },
113+
)
74114
}
75115

76116
function applyFilter(key: string) {
77117
router.get(
78118
`/admin/reviews/${tier}`,
79-
{ query: searchQuery || undefined, filter: key || undefined },
119+
{ query: searchQuery || undefined, filter: key || undefined, status: status || undefined },
120+
{ preserveState: true },
121+
)
122+
}
123+
124+
function applyStatus(key: string) {
125+
router.get(
126+
`/admin/reviews/${tier}`,
127+
{ query: searchQuery || undefined, filter: filter || undefined, status: key || undefined },
80128
{ preserveState: true },
81129
)
82130
}
@@ -86,12 +134,18 @@ export default function AdminReviewsQueue({
86134
<div className="flex flex-col md:flex-row md:items-center md:justify-between gap-4">
87135
<div>
88136
<h1 className="text-2xl font-semibold tracking-tight">
89-
{tier === REQUIREMENTS_QUEUE ? 'Requirements Check Queue' : `Tier ${tier.slice(1)} Review Queue`}
137+
{tier === REQUIREMENTS_QUEUE
138+
? 'Requirements Check Queue'
139+
: tier === FLAGGED_QUEUE
140+
? 'Flagged Projects'
141+
: `Tier ${tier.slice(1)} Review Queue`}
90142
</h1>
91143
<p className="text-sm text-muted-foreground mt-1">
92144
{tier === REQUIREMENTS_QUEUE
93145
? 'Projects waiting on a requirements check. Work the checklist, then pass it on or return it to the builder.'
94-
: 'Oldest submissions first. Open one to start a timed review session — keyboard shortcuts make it fast.'}
146+
: tier === FLAGGED_QUEUE
147+
? 'Projects a reviewer has pulled aside for a second look. Resolve the flag from inside the review.'
148+
: 'Oldest submissions first. Open one to start a timed review session — keyboard shortcuts make it fast.'}
95149
</p>
96150
{allowed_tiers.length > 1 && (
97151
<div className="flex gap-2 mt-3">
@@ -144,6 +198,28 @@ export default function AdminReviewsQueue({
144198
</button>
145199
)
146200
})}
201+
{isFlaggedQueue && (
202+
<>
203+
<span className="w-px bg-border mx-1" />
204+
{STATUS_FILTERS.map((f) => {
205+
const isActive = (status || '') === f.key
206+
return (
207+
<button
208+
key={f.key}
209+
onClick={() => applyStatus(f.key)}
210+
className={cn(
211+
'inline-flex items-center px-3 py-1.5 text-xs font-medium rounded-md border transition-colors cursor-pointer',
212+
isActive
213+
? 'bg-primary text-primary-foreground border-primary'
214+
: 'border-border bg-background text-muted-foreground hover:bg-accent hover:text-foreground',
215+
)}
216+
>
217+
{f.label}
218+
</button>
219+
)
220+
})}
221+
</>
222+
)}
147223
</div>
148224
<form onSubmit={submitSearch} className="flex gap-2 items-center">
149225
<div className="relative">
@@ -164,16 +240,19 @@ export default function AdminReviewsQueue({
164240

165241
<div className="rounded-md border border-border bg-card overflow-hidden">
166242
{projects.length === 0 ? (
167-
<p className="text-sm text-muted-foreground py-12 text-center">Nothing pending — queue is clear. 🎉</p>
243+
<p className="text-sm text-muted-foreground py-12 text-center">
244+
{isFlaggedQueue ? 'No flagged projects — queue is clear. 🎉' : 'Nothing pending — queue is clear. 🎉'}
245+
</p>
168246
) : (
169247
<Table>
170248
<TableHeader>
171249
<TableRow>
172250
<TableHead>Project</TableHead>
173251
<TableHead>Author</TableHead>
174252
<TableHead>Type</TableHead>
253+
{isFlaggedQueue && <TableHead>Status</TableHead>}
175254
<TableHead>Waiting</TableHead>
176-
<TableHead>Claimed</TableHead>
255+
<TableHead>{isFlaggedQueue ? 'Flagged by' : 'Claimed'}</TableHead>
177256
<TableHead className="w-24"></TableHead>
178257
</TableRow>
179258
</TableHeader>
@@ -206,14 +285,25 @@ export default function AdminReviewsQueue({
206285
<span className="text-xs text-muted-foreground capitalize">{p.tier.replace('_', ' ')}</span>
207286
)}
208287
</TableCell>
288+
{isFlaggedQueue && (
289+
<TableCell>
290+
<Badge variant={STATUS_BADGE_VARIANT[p.status] ?? 'outline'}>{statusLabel(p.status)}</Badge>
291+
</TableCell>
292+
)}
209293
<TableCell>
210294
<Badge variant={waitBadgeVariant(waitSeconds, metrics.sla_hours)}>
211295
{formatDuration(waitSeconds)}
212296
{waitSeconds / 3600 >= metrics.sla_hours && ' overdue'}
213297
</Badge>
214298
</TableCell>
215299
<TableCell>
216-
{p.claimed_by ? (
300+
{isFlaggedQueue ? (
301+
p.flagged_by_name ? (
302+
<span className="text-xs text-muted-foreground">{p.flagged_by_name}</span>
303+
) : (
304+
<span className="text-xs text-muted-foreground/50"></span>
305+
)
306+
) : p.claimed_by ? (
217307
<span className="flex items-center gap-1.5 text-xs text-muted-foreground">
218308
<img src={p.claimed_by.avatar} alt="" className="size-4 rounded-full" />
219309
{p.claimed_by.name}

config/routes.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -300,6 +300,7 @@
300300
get "reviews/:tier" => "reviews#index", as: :tier_reviews, constraints: { tier: /t[1-4]/ }
301301
get "reviews/leaderboard" => "reviews#leaderboard", as: :reviews_leaderboard
302302
get "reviews/requirements" => "reviews#requirements", as: :requirements_reviews
303+
get "reviews/flagged" => "reviews#flagged", as: :flagged_reviews
303304
get "reviews/:id" => "reviews#show", as: :review
304305
post "reviews/:id/skip" => "reviews#skip", as: :skip_review
305306
post "reviews/:id/track" => "reviews#track", as: :track_review

0 commit comments

Comments
 (0)