Skip to content

Commit 5b6be47

Browse files
committed
add tag untagged items ui
1 parent 45d7f81 commit 5b6be47

5 files changed

Lines changed: 201 additions & 3 deletions

File tree

app/controllers/admin/agenda_items_controller.rb

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,48 @@
11
module Admin
22
class AgendaItemsController < BaseController
3+
def untagged
4+
scope = AgendaItem.left_joins(:agenda_item_tags)
5+
.where(agenda_item_tags: { id: nil })
6+
.includes(:tags, agenda_section: { agenda_version: :meeting })
7+
8+
if params[:type].present? && AgendaItem.item_types.key?(params[:type])
9+
scope = scope.where(item_type: params[:type])
10+
end
11+
12+
if params[:meeting_id].present?
13+
scope = scope.joins(agenda_section: :agenda_version)
14+
.where(agenda_versions: { meeting_id: params[:meeting_id] })
15+
end
16+
17+
@items = scope.order("meetings.date DESC, agenda_items.item_number ASC")
18+
.references(:meetings)
19+
20+
@untagged_count = @items.size
21+
@total_count = AgendaItem.count
22+
23+
@meetings_for_filter = Meeting.joins(agenda_versions: { agenda_sections: :agenda_items })
24+
.joins("LEFT JOIN agenda_item_tags ON agenda_item_tags.agenda_item_id = agenda_items.id")
25+
.where(agenda_item_tags: { id: nil })
26+
.distinct
27+
.chronological
28+
end
29+
30+
def auto_tag_all
31+
items = AgendaItem.left_joins(:agenda_item_tags)
32+
.where(agenda_item_tags: { id: nil })
33+
.to_a
34+
35+
AutoTaggingService.new(items).call
36+
37+
still_untagged = AgendaItem.left_joins(:agenda_item_tags)
38+
.where(agenda_item_tags: { id: nil })
39+
.count
40+
tagged_now = items.size - still_untagged
41+
42+
redirect_to untagged_admin_agenda_items_path,
43+
notice: "Auto-tagged #{tagged_now} items. #{still_untagged} still need manual tagging."
44+
end
45+
346
def update
447
@agenda_item = AgendaItem.find(params[:id])
548
if @agenda_item.update(agenda_item_params)
Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
<%# Header %>
2+
<div class="mb-6">
3+
<div class="flex items-center gap-2 text-sm text-gray-500 mb-3">
4+
<%= link_to "Dashboard", admin_dashboard_path, class: "hover:text-blue-600" %>
5+
<span>/</span>
6+
<span class="text-gray-900">Untagged Items</span>
7+
</div>
8+
9+
<div class="flex flex-col sm:flex-row sm:items-start sm:justify-between gap-4">
10+
<div>
11+
<h1 class="text-xl sm:text-2xl font-bold text-gray-900">Untagged Items</h1>
12+
<p class="mt-1 text-sm text-gray-500">
13+
<span class="font-semibold text-gray-700"><%= @untagged_count %></span> of <%= @total_count %> items need tags.
14+
Add tags inline or run auto-tag to apply keyword rules.
15+
</p>
16+
</div>
17+
<div class="flex items-center gap-2">
18+
<%= button_to "Auto Tag All",
19+
auto_tag_all_admin_agenda_items_path,
20+
method: :post,
21+
class: "px-4 py-2 text-sm font-medium text-blue-700 bg-blue-50 rounded-lg hover:bg-blue-100 ring-1 ring-blue-600/10 transition-colors cursor-pointer",
22+
data: { turbo_confirm: "Run auto-tag rules on all #{@untagged_count} untagged items?" } %>
23+
</div>
24+
</div>
25+
</div>
26+
27+
<%# Filters %>
28+
<div class="flex flex-wrap gap-2 mb-6">
29+
<%= link_to "All",
30+
untagged_admin_agenda_items_path(meeting_id: params[:meeting_id]),
31+
class: "px-3 py-1.5 text-xs font-medium rounded-md transition-colors #{params[:type].blank? ? 'bg-gray-900 text-white' : 'bg-gray-100 text-gray-700 hover:bg-gray-200'}" %>
32+
<% AgendaItem.item_types.each_key do |type| %>
33+
<%= link_to type.titleize,
34+
untagged_admin_agenda_items_path(type: type, meeting_id: params[:meeting_id]),
35+
class: "px-3 py-1.5 text-xs font-medium rounded-md transition-colors #{params[:type] == type ? 'bg-gray-900 text-white' : 'bg-gray-100 text-gray-700 hover:bg-gray-200'}" %>
36+
<% end %>
37+
38+
<% if @meetings_for_filter.any? %>
39+
<span class="text-gray-300 mx-1">|</span>
40+
<form action="<%= untagged_admin_agenda_items_path %>" method="get" class="inline-flex items-center gap-1">
41+
<% if params[:type].present? %>
42+
<input type="hidden" name="type" value="<%= params[:type] %>" />
43+
<% end %>
44+
<select name="meeting_id" onchange="this.form.submit()"
45+
class="text-xs rounded-md border border-gray-300 py-1.5 pl-2 pr-7 text-gray-700 focus:border-blue-500 focus:ring-blue-500">
46+
<option value="">All meetings</option>
47+
<% @meetings_for_filter.each do |m| %>
48+
<option value="<%= m.id %>" <%= "selected" if params[:meeting_id].to_s == m.id.to_s %>><%= m.display_name %></option>
49+
<% end %>
50+
</select>
51+
</form>
52+
<% end %>
53+
</div>
54+
55+
<%# Progress bar %>
56+
<div class="mb-6">
57+
<div class="flex items-center justify-between text-xs text-gray-500 mb-1">
58+
<span>Tag coverage</span>
59+
<span><%= ((@total_count - @untagged_count) * 100.0 / [@total_count, 1].max).round %>%</span>
60+
</div>
61+
<div class="w-full bg-gray-200 rounded-full h-2">
62+
<div class="bg-emerald-500 h-2 rounded-full transition-all" style="width: <%= ((@total_count - @untagged_count) * 100.0 / [@total_count, 1].max).round %>%"></div>
63+
</div>
64+
</div>
65+
66+
<%# Items grouped by meeting %>
67+
<% grouped = @items.group_by { |item| item.agenda_section.agenda_version.meeting } %>
68+
<% if grouped.any? %>
69+
<div class="space-y-6">
70+
<% grouped.each do |meeting, items| %>
71+
<div class="bg-white rounded-lg border border-gray-200 overflow-hidden">
72+
<div class="px-4 sm:px-6 py-3 bg-gray-50 border-b border-gray-200 flex items-center justify-between">
73+
<div>
74+
<%= link_to meeting.display_name, admin_meeting_path(meeting), class: "font-semibold text-gray-900 hover:text-blue-600" %>
75+
<span class="text-xs text-gray-500 ml-2"><%= items.size %> untagged</span>
76+
</div>
77+
<%= button_to "Auto Tag",
78+
auto_tag_admin_meeting_path(meeting),
79+
method: :post,
80+
class: "px-2.5 py-1 text-xs font-medium text-blue-700 bg-blue-50 rounded-md hover:bg-blue-100 ring-1 ring-blue-600/10 transition-colors cursor-pointer",
81+
data: { turbo_confirm: "Auto-tag items in #{meeting.display_name}?" } %>
82+
</div>
83+
84+
<div class="divide-y divide-gray-100">
85+
<% items.each do |item| %>
86+
<div id="untagged_item_<%= item.id %>" class="px-4 sm:px-6 py-3">
87+
<div class="flex items-start gap-3">
88+
<div class="shrink-0 flex flex-col items-center gap-1">
89+
<span class="text-xs font-medium text-gray-500 bg-gray-100 rounded px-1.5 py-0.5"><%= item.item_number %></span>
90+
<span class="text-[10px] text-gray-400"><%= item.item_type.titleize %></span>
91+
</div>
92+
<div class="flex-1 min-w-0">
93+
<p class="text-sm text-gray-800 leading-snug"><%= item.title.truncate(200) %></p>
94+
<div class="flex flex-wrap items-center gap-2 mt-1.5 text-xs text-gray-400">
95+
<% if item.file_number.present? %>
96+
<span class="bg-gray-100 text-gray-600 px-1.5 py-0.5 rounded"><%= item.file_number %></span>
97+
<% end %>
98+
<% if item.result.present? %>
99+
<span class="bg-blue-50 text-blue-600 px-1.5 py-0.5 rounded"><%= item.result.titleize %></span>
100+
<% end %>
101+
<span><%= item.agenda_section.title %></span>
102+
</div>
103+
<div class="mt-2">
104+
<%= render "admin/agenda_item_tags/tags", agenda_item: item %>
105+
</div>
106+
</div>
107+
</div>
108+
</div>
109+
<% end %>
110+
</div>
111+
</div>
112+
<% end %>
113+
</div>
114+
<% else %>
115+
<div class="bg-white rounded-lg border border-gray-200 px-6 py-12 text-center">
116+
<p class="text-gray-500">All items are tagged!</p>
117+
<%= link_to "Back to dashboard", admin_dashboard_path, class: "mt-2 text-sm text-blue-600 hover:text-blue-800 inline-block" %>
118+
</div>
119+
<% end %>

app/views/admin/dashboard/show.html.erb

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@
4545
<% end %>
4646

4747
<% if @untagged_count > 0 %>
48-
<div class="bg-violet-50 rounded-lg border border-violet-200 p-4">
48+
<%= link_to untagged_admin_agenda_items_path, class: "block bg-violet-50 rounded-lg border border-violet-200 p-4 hover:bg-violet-100 transition-colors" do %>
4949
<div class="flex items-center gap-2 mb-2">
5050
<span class="w-2 h-2 rounded-full bg-violet-400"></span>
5151
<span class="text-sm font-semibold text-violet-800">Untagged Items</span>
@@ -58,7 +58,7 @@
5858
<div class="bg-violet-500 h-1.5 rounded-full" style="width: <%= @total_items > 0 ? (@tagged_count * 100.0 / @total_items).round : 0 %>%"></div>
5959
</div>
6060
<p class="text-xs text-violet-500 mt-1"><%= (@tagged_count * 100.0 / [@total_items, 1].max).round %>% tagged</p>
61-
</div>
61+
<% end %>
6262
<% end %>
6363

6464
<% if @draft_posts.any? %>

config/routes.rb

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,10 @@
2424
post :seed_rules, on: :collection
2525
end
2626
resources :tag_rules, only: [ :create, :destroy ]
27-
resources :agenda_items, only: [ :update, :destroy ]
27+
resources :agenda_items, only: [ :update, :destroy ] do
28+
get :untagged, on: :collection
29+
post :auto_tag_all, on: :collection
30+
end
2831
resources :agenda_item_tags, only: [ :create, :destroy ] do
2932
post :copy, on: :collection
3033
end
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
require "test_helper"
2+
3+
class Admin::AgendaItemsControllerTest < ActionDispatch::IntegrationTest
4+
test "untagged page as content_admin" do
5+
sign_in_as(users(:content_admin))
6+
get untagged_admin_agenda_items_path
7+
assert_response :success
8+
end
9+
10+
test "untagged page with type filter" do
11+
sign_in_as(users(:content_admin))
12+
get untagged_admin_agenda_items_path(type: "ordinance")
13+
assert_response :success
14+
end
15+
16+
test "untagged page with meeting filter" do
17+
sign_in_as(users(:content_admin))
18+
get untagged_admin_agenda_items_path(meeting_id: meetings(:regular_meeting).id)
19+
assert_response :success
20+
end
21+
22+
test "untagged page requires authentication" do
23+
get untagged_admin_agenda_items_path
24+
assert_redirected_to new_session_path
25+
end
26+
27+
test "auto_tag_all as content_admin" do
28+
sign_in_as(users(:content_admin))
29+
post auto_tag_all_admin_agenda_items_path
30+
assert_redirected_to untagged_admin_agenda_items_path
31+
assert_match(/Auto-tagged/, flash[:notice])
32+
end
33+
end

0 commit comments

Comments
 (0)