Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
733d236
Replace Embassy mock data with real backend
Kitkatnik Apr 26, 2026
ebb74ef
Show "Submit Application" label on application submit button
Kitkatnik Apr 26, 2026
7df0495
Make "Save & Return Later" actually save the draft
Kitkatnik Apr 26, 2026
2b57b20
Fix PDF layout: 2-column short fields, max_length, page numbers
Kitkatnik Apr 26, 2026
ace0dcb
Compact PDF checkboxes, fill page 2 with ordinances, fix copy
Kitkatnik Apr 26, 2026
e63589e
Restore 2-column grid for Section 4 checkbox group on PDF
Kitkatnik Apr 26, 2026
974441d
Drop 'will be printed on official PDF' from max-length hint
Kitkatnik Apr 26, 2026
ea2968e
Force Section 5 onto its own page in the PDF
Kitkatnik Apr 26, 2026
5b64838
PDF copy fixes: signature, §2, §5, instruction #2
Kitkatnik Apr 26, 2026
ea9739c
Align checkbox options in fixed-column grid
Kitkatnik Apr 26, 2026
01e70e5
Restyle admin dashboard: quick links + stats grid, navbar rework
Kitkatnik Apr 26, 2026
83e044f
Merge Kitkatnik/embassy-backend (real Embassy models + adjudication b…
Kitkatnik Apr 26, 2026
7d87838
Real Embassy stats + sortable, searchable applications queue
Kitkatnik Apr 26, 2026
7c191bb
Compact dashboard layout: stats first, denser tile grid, wider container
Kitkatnik Apr 26, 2026
e81add1
Reorder dashboard tiles, add Volunteers placeholder, clarify schedule…
Kitkatnik Apr 26, 2026
bbf524b
Restore descriptions on dashboard Manage tiles
Kitkatnik Apr 26, 2026
510e34e
Drop sub-label from Schedule Items stat card
Kitkatnik Apr 26, 2026
0c04e0b
Get CI green: rubocop autofix + empty placeholder fixtures
Kitkatnik Apr 26, 2026
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
4 changes: 4 additions & 0 deletions Gemfile
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,10 @@ gem "tito_ruby"
# Postmark for transactional email in production
gem "postmark-rails"

# PDF generation for the passport application form
gem "prawn"
gem "prawn-table"

group :development, :test do
# See https://guides.rubyonrails.org/debugging_rails_applications.html#debugging-with-the-debug-gem
gem "debug", platforms: %i[ mri windows ], require: "debug/prelude"
Expand Down
9 changes: 9 additions & 0 deletions Gemfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -243,6 +243,7 @@ GEM
parser (3.3.11.1)
ast (~> 2.4.1)
racc
pdf-core (0.9.0)
pg (1.6.3)
pg (1.6.3-aarch64-linux)
pg (1.6.3-aarch64-linux-musl)
Expand All @@ -257,6 +258,11 @@ GEM
postmark (>= 1.21.3, < 2.0)
pp (0.6.3)
prettyprint
prawn (2.4.0)
pdf-core (~> 0.9.0)
ttfunk (~> 1.7)
prawn-table (0.2.2)
prawn (>= 1.3.0, < 3.0.0)
prettyprint (0.2.0)
prism (1.9.0)
propshaft (1.3.1)
Expand Down Expand Up @@ -398,6 +404,7 @@ GEM
activemodel (>= 7.1, < 9.0)
faraday (~> 2.0)
tsort (0.2.0)
ttfunk (1.7.0)
turbo-rails (2.0.23)
actionpack (>= 7.1.0)
railties (>= 7.1.0)
Expand Down Expand Up @@ -449,6 +456,8 @@ DEPENDENCIES
mission_control-jobs
pg (~> 1.1)
postmark-rails
prawn
prawn-table
propshaft
puma (>= 5.0)
rack-mini-profiler
Expand Down
83 changes: 83 additions & 0 deletions app/assets/stylesheets/application.css
Original file line number Diff line number Diff line change
Expand Up @@ -276,6 +276,7 @@ hr {
.mb-1 { margin-bottom: 0.25rem; }
.mb-2 { margin-bottom: 0.5rem; }
.mb-6 { margin-bottom: 1.5rem; }
.mb-10 { margin-bottom: 2.5rem; }
.mb-8 { margin-bottom: 2rem; }
.mb-16 { margin-bottom: 4rem; }

Expand All @@ -302,6 +303,18 @@ hr {
.space-y-12 > * + * { margin-top: 3rem; }

.grid { display: grid; }
.grid-cols-1 { grid-template-columns: repeat(1, minmax(0, 1fr)); }
.grid-cols-2 { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.grid-cols-3 { grid-template-columns: repeat(3, minmax(0, 1fr)); }
.grid-cols-4 { grid-template-columns: repeat(4, minmax(0, 1fr)); }

@media (min-width: 768px) {
.md\:grid-cols-2 { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.md\:grid-cols-3 { grid-template-columns: repeat(3, minmax(0, 1fr)); }
.md\:grid-cols-4 { grid-template-columns: repeat(4, minmax(0, 1fr)); }
.md\:grid-cols-6 { grid-template-columns: repeat(6, minmax(0, 1fr)); }
}

.inline-block { display: inline-block; }

.text-sm { font-size: 0.875rem; line-height: 1.25rem; }
Expand Down Expand Up @@ -1201,6 +1214,16 @@ hr {
transform: translateX(2px);
}

/* Tighter variant for dense dashboards — title only, no description */
.action-card--compact {
padding: 0.875rem 1.125rem;
}
.action-card--compact .action-card__title {
font-size: 1rem;
margin-bottom: 0;
padding-right: 0;
}

.action-card--soon {
background-color: #fafbfc;
border-style: dashed;
Expand Down Expand Up @@ -1234,6 +1257,59 @@ hr {
border-radius: 999px;
}

/* ============================================================
ADMIN DASHBOARD STAT CARDS
============================================================ */

.stat-card {
background: #ffffff;
border: 1px solid #e5e7eb;
border-radius: 12px;
padding: 1.25rem 1.5rem;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04);
}

.stat-card__label {
font-size: 0.8125rem;
text-transform: uppercase;
letter-spacing: 0.06em;
color: #6b7280;
font-weight: 600;
}

.stat-card__value {
font-family: "Playfair Display", serif;
font-size: 2.25rem;
line-height: 1.1;
color: #0C2866;
margin-top: 0.25rem;
}

.stat-card__sub {
font-size: 0.8125rem;
color: #6b7280;
margin-top: 0.5rem;
}

/* Disabled "coming soon" item inside the admin navbar */
.admin-nav .nav-disabled {
color: rgba(255, 255, 255, 0.55);
cursor: not-allowed;
display: inline-flex;
align-items: center;
gap: 0.5rem;
}

.admin-nav .nav-soon {
font-size: 0.625rem;
text-transform: uppercase;
letter-spacing: 0.06em;
background: rgba(255, 255, 255, 0.18);
color: #ffffff;
padding: 0.125rem 0.5rem;
border-radius: 999px;
}

/* ============================================================
EMBASSY (mockup)
============================================================ */
Expand All @@ -1243,6 +1319,13 @@ hr {
}

.flex-1 { flex: 1 1 auto; }
.whitespace-nowrap { white-space: nowrap; }

/* Sortable column header — used by app/helpers/application_helper.rb#sort_link */
.table th a { color: inherit; text-decoration: none; }
.table th a:hover { text-decoration: underline; }
.table th .sort-active,
.table th a.sort-active { color: #0C2866; font-weight: 700; }

/* Embassy page wrappers ------------------------------------------------ */

Expand Down
9 changes: 4 additions & 5 deletions app/controllers/admin/dashboard_controller.rb
Original file line number Diff line number Diff line change
@@ -1,11 +1,10 @@
module Admin
class DashboardController < AdminController
def show
@user_counts = User.group(:role).count
@total_users = User.count
@total_schedule_items = ScheduleItem.count
@public_items = ScheduleItem.where(is_public: true).count
@total_rsvps = PlanItem.count
@attendees_count = User.attendee.count
@embassy_applications_count = EmbassyApplication.submitted.where(passport_received_at: nil).count
@rsvps_count = PlanItem.count
@schedule_items_count = ScheduleItem.count
end
end
end
87 changes: 84 additions & 3 deletions app/controllers/admin/embassy_applications_controller.rb
Original file line number Diff line number Diff line change
@@ -1,10 +1,91 @@
class Admin::EmbassyApplicationsController < AdminController
ACTIVE_SORTS = {
"appointment" => "schedule_items.day ASC, schedule_items.sort_time ASC",
"name" => "users.last_name ASC, users.first_name ASC",
"serial" => "embassy_applications.serial ASC"
}.freeze

DELIVERED_SORTS = ACTIVE_SORTS.merge(
"delivered" => "embassy_applications.passport_received_at DESC"
).freeze

def index
@applications = FakeEmbassy.submitted_applications
@sort = ACTIVE_SORTS.key?(params[:sort]) ? params[:sort] : "appointment"
@query = params[:q].to_s.strip
@applications = filtered_scope
.where(passport_received_at: nil)
.reorder(Arel.sql(ACTIVE_SORTS[@sort]))
end

def delivered
@sort = DELIVERED_SORTS.key?(params[:sort]) ? params[:sort] : "delivered"
@query = params[:q].to_s.strip
@applications = filtered_scope
.where.not(passport_received_at: nil)
.reorder(Arel.sql(DELIVERED_SORTS[@sort]))
end

def show
@application = FakeEmbassy.find_submitted_application(params[:id])
@sections = FakeEmbassy.sample_questions
@application = EmbassyApplication.includes(
:notary_profile,
embassy_application_answers: :question,
embassy_booking: [ :user, :schedule_item ]
).find_by!(serial: params[:id])

@booking = @application.embassy_booking
@schedule_item = @application.schedule_item
@notary = @application.notary_profile
@sections = sections_for(@application)

respond_to do |format|
format.html
format.pdf do
send_data PassportApplicationPdf.new(application: @application).render,
filename: "#{@application.serial}.pdf",
type: "application/pdf",
disposition: "attachment"
end
end
end

def mark_received
application = EmbassyApplication.find_by!(serial: params[:id])
application.update!(passport_received_at: Time.current)
redirect_back fallback_location: admin_embassy_applications_path,
notice: "Marked #{application.serial} as delivered."
end

def unmark_received
application = EmbassyApplication.find_by!(serial: params[:id])
application.update!(passport_received_at: nil)
redirect_back fallback_location: delivered_admin_embassy_applications_path,
notice: "Moved #{application.serial} back to the active queue."
end

private

def filtered_scope
scope = EmbassyApplication
.submitted
.joins(embassy_booking: [ :user, :schedule_item ])
.includes(:notary_profile, embassy_booking: [ :user, :schedule_item ])

return scope if params[:q].blank?

term = "%#{params[:q].strip}%"
scope.where(
"users.first_name ILIKE :t OR users.last_name ILIKE :t OR users.email ILIKE :t OR embassy_applications.serial ILIKE :t",
t: term
)
end

def sections_for(application)
{
1 => Question.for_section(1).to_a,
2 => Question.for_section(2).to_a,
3 => application.drawn_questions.to_a,
4 => Question.for_section(4).to_a,
5 => Question.for_section(5).to_a
}
end
end
13 changes: 7 additions & 6 deletions app/controllers/admin/embassy_blank_pdfs_controller.rb
Original file line number Diff line number Diff line change
@@ -1,13 +1,14 @@
class Admin::EmbassyBlankPdfsController < AdminController
def new
@default_count = 12
@preview_sections = FakeEmbassy.sample_questions
@preview_serial = "RE-0427-A"
@preview_notary = EmbassyQuestionsSeed::NOTARY_POOL.sample(random: Random.new(42))
@default_count = 12
end

def create
@count = (params[:count].presence || 12).to_i.clamp(1, 100)
@serials = (0...@count).map { |i| FakeEmbassy.serial_for(i) }
count = (params[:count].presence || 12).to_i.clamp(1, 100)

send_data PassportApplicationPdf.new(application: nil, count: count).render,
filename: "ruby-embassy-blank-applications-#{count}.pdf",
type: "application/pdf",
disposition: "attachment"
end
end
54 changes: 45 additions & 9 deletions app/controllers/admin/embassy_questions_controller.rb
Original file line number Diff line number Diff line change
@@ -1,30 +1,66 @@
class Admin::EmbassyQuestionsController < AdminController
before_action :set_question, only: %i[edit update destroy]

def index
@questions = FakeEmbassy.question_bank
@notary_pool = FakeEmbassy.notary_pool
@questions = Question
.left_joins(:embassy_application_answers)
.group("questions.id")
.select("questions.*, COUNT(DISTINCT embassy_application_answers.embassy_application_id) AS computed_usage_count")
.order(:section, :position)

@questions_by_section = @questions.group_by(&:section)
@notary_pool = NotaryProfile.order(:external_id)
end

def new
@question = { id: "", section: 1, label: "", type: :short,
required: false, help: "", status: "active" }
@question = Question.new(section: 1, field_type: "short", scope: "common", required: false, status: "active", options: [])
end

def create
redirect_to admin_embassy_questions_path,
notice: "Question added to the bank."
@question = Question.new(question_params)
if @question.save
redirect_to admin_embassy_questions_path,
notice: "Question added to the bank."
else
render :new, status: :unprocessable_content
end
end

def edit
@question = FakeEmbassy.find_question(params[:id])
end

def update
redirect_to admin_embassy_questions_path,
notice: "Question updated."
if @question.update(question_params)
redirect_to admin_embassy_questions_path,
notice: "Question updated."
else
render :edit, status: :unprocessable_content
end
end

def destroy
@question.update!(status: "archived")
redirect_to admin_embassy_questions_path,
notice: "Question archived."
end

private

def set_question
@question = Question.find(params[:id])
end

def question_params
permitted = params.require(:question).permit(
:external_id, :section, :position, :label, :help, :placeholder, :max_length,
:field_type, :required, :scope, :status, :options_text
)
parse_options(permitted)
end

def parse_options(permitted)
text = permitted.delete(:options_text)
permitted[:options] = text.to_s.split(/\r?\n/).map(&:strip).reject(&:blank?) if text
permitted
end
end
10 changes: 8 additions & 2 deletions app/controllers/admin/schedule_items_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -45,10 +45,16 @@ def set_schedule_item
# :slug is intentionally omitted — it's a seed idempotency key for
# config/schedule.yml items and should never be set by hand.
def schedule_item_params
params.require(:schedule_item).permit(
attrs = params.require(:schedule_item).permit(
:day, :time_label, :sort_time, :title, :host,
:location, :description, :kind, :flexible, :is_public
:location, :description, :kind, :flexible, :is_public,
:embassy_mode, :embassy_capacity
)
unless attrs[:kind] == "embassy"
attrs[:embassy_mode] = nil
attrs[:embassy_capacity] = nil
end
attrs
end
end
end
Loading
Loading